Files
ask262/setup/utils/formatHTMLForIngestion.ts
T
bendtherules a2c8bd31c6 feat(lists): alternate markdown list markers by nesting depth
Ordered lists switch between `1.` (even depth) and `A.` (odd depth).
Unordered lists switch between `-` (even depth) and `*` (odd depth).
Makes nested list structure visually distinct in extracted text.
2026-04-06 13:39:43 +05:30

331 lines
8.6 KiB
TypeScript

import type * as cheerio from "cheerio";
/**
* List of block-level HTML elements that should have newlines added after them
* to preserve document structure during text extraction.
*/
export const BLOCK_ELEMENTS = [
"p",
"div",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"li",
"td",
"th",
"pre",
"blockquote",
"section",
"emu-clause",
"emu-note",
"emu-example",
"emu-table",
"figure",
"figcaption",
"ul",
"ol",
"dl",
"dt",
"dd",
"emu-production",
"emu-rhs",
];
export interface FormatConfig {
codeBlocks: {
block: string[];
inline: string[];
grammar: string[];
};
links: string[];
lists: {
ordered: string[];
unordered: string[];
};
tables: string[];
}
export const DEFAULT_CONFIG: FormatConfig = {
codeBlocks: {
block: ["pre>code", "emu-eqn:not([class*='inline'])"],
inline: ["var", "emu-val", "emu-const", "emu-eqn.inline", "code"],
grammar: ["emu-grammar"],
},
links: ["emu-xref a"],
lists: {
ordered: ["ol"],
unordered: ["ul"],
},
tables: ["table", "emu-table"],
};
export function convertLinksToMarkdown(
$: cheerio.CheerioAPI,
selector: string = "emu-xref a[href]",
): void {
$(selector).each((_, elem) => {
const $a = $(elem);
const href = $a.attr("href") ?? "";
// Skip external links
if (href.startsWith("http")) return;
// Strip filename prefix: "abstract-operations.html#sec-tonumber" → "#sec-tonumber"
const hash = href.includes("#") ? `#${href.split("#")[1]}` : href;
const text = $a.text().trim();
if (!text || !hash) return;
$a.replaceWith($(`<span class="link-markdown">[${text}](${hash})</span>`));
});
}
export function convertInlineCodeToMarkdown(
$: cheerio.CheerioAPI,
tags: string[],
): void {
for (const tag of tags) {
$(tag).each((_, elem) => {
// Skip <code> inside <pre> (handled by block converter)
if (
"name" in elem &&
elem.name === "code" &&
$(elem).parent("pre").length > 0
)
return;
const text = $(elem).text().trim();
if (!text) return;
$(elem).replaceWith($(`<span class="inline-code">\`${text}\`</span>`));
});
}
}
function extractLanguage(codeClass: string | undefined): string {
// "javascript hljs" → "javascript", "python hljs" → "python"
if (!codeClass) return "";
const match = codeClass.match(/^(\w+)/);
return match?.[1] ?? "";
}
export function convertBlockCodeToMarkdown(
$: cheerio.CheerioAPI,
tags: string[],
): void {
const selectors = tags.join(", ");
if (!selectors) return;
$(selectors).each((_, elem) => {
const $elem = $(elem);
const lang = extractLanguage($elem.attr("class"));
const text = $elem.text().trim();
$elem.replaceWith(
$(`<pre class="code-markdown">\`\`\`${lang}\n${text}\n\`\`\`</pre>`),
);
});
}
/**
* Returns the markdown list item prefix based on list type and nesting depth.
* Alternates markers to visually distinguish nesting levels:
* - Ordered lists: `1.` at even depth, `A.` at odd depth
* - Unordered lists: `-` at even depth, `*` at odd depth
*
* @param isOrdered - Whether the list is ordered (ol) or unordered (ul)
* @param depth - Nesting depth (0 = top-level)
* @param index - Zero-based item index within its list
* @returns Prefix string like "1. ", "A. ", "- ", or "* "
*/
function getListItemPrefix(
isOrdered: boolean,
depth: number,
index: number,
): string {
if (isOrdered) {
return depth % 2 === 0
? `${index + 1}. `
: `${String.fromCharCode(65 + index)}. `;
}
return depth % 2 === 0 ? "- " : "* ";
}
function listToMarkdown(
$: cheerio.CheerioAPI,
elem: any,
depth: number = 0,
): string {
const $elem = $(elem);
const isOrdered = elem.name === "ol";
const indent = " ".repeat(depth);
const lines: string[] = [];
$elem.children("li").each((i, li) => {
const prefix = getListItemPrefix(isOrdered, depth, i);
const $li = $(li);
const nestedLists: string[] = [];
// Recurse into nested lists first (depth-first)
$li.children("ol, ul").each((_, nested) => {
const nestedMarkdown = listToMarkdown($, nested, depth + 1);
nestedLists.push(nestedMarkdown);
$(nested).remove();
});
// Now get full text
const itemText = $li.text().trim().replace(/\s+/g, " ");
lines.push(`${indent}${prefix}${itemText}`);
for (const nested of nestedLists) {
lines.push(nested);
}
});
return lines.join("\n");
}
export function convertListsToMarkdown(
$: cheerio.CheerioAPI,
ordered: string[] = ["ol"],
unordered: string[] = ["ul"],
): void {
const selectors = [...ordered, ...unordered].join(", ");
if (!selectors) return;
// Process from outermost — recursion handles depth-first nesting
$(selectors).each((_, elem) => {
// Skip if already processed (parent already handled this)
if ($(elem).hasClass("list-markdown") || $(elem).hasClass("list-processed"))
return;
const markdown = listToMarkdown($, elem, 0);
$(elem).replaceWith($(`<pre class="list-markdown">\n${markdown}\n</pre>`));
});
}
export function convertGrammarToMarkdown(
$: cheerio.CheerioAPI,
selector: string = "emu-grammar",
): void {
if (!selector) return;
$(selector).each((_, grammar) => {
const text = $(grammar).text().trim();
$(grammar).replaceWith(
$(`<pre class="code-markdown">\`\`\`bnf\n${text}\n\`\`\`</pre>`),
);
});
}
/**
* Converts HTML tables to markdown table format.
* This preserves table structure when extracting text from HTML.
* Replaces the original table with a <pre> element containing the markdown.
*
* @param $ - Cheerio API instance
*/
export function convertTablesToMarkdown($: cheerio.CheerioAPI): void {
$("table, emu-table").each((_, tableElem) => {
const $table = $(tableElem);
const rows: string[][] = [];
// Extract header rows
$table.find("thead tr").each((_, rowElem) => {
const row: string[] = [];
$(rowElem)
.find("th, td")
.each((_, cellElem) => {
row.push($(cellElem).text().trim().replace(/\|/g, "\\|"));
});
if (row.length > 0) rows.push(row);
});
// Extract body rows
$table.find("tbody tr, tr").each((_, rowElem) => {
// Skip if already processed as header
if ($(rowElem).parent("thead").length > 0) return;
const row: string[] = [];
$(rowElem)
.find("td, th")
.each((_, cellElem) => {
row.push($(cellElem).text().trim().replace(/\|/g, "\\|"));
});
if (row.length > 0) rows.push(row);
});
if (rows.length === 0) return;
// Determine max columns
const maxCols = Math.max(...rows.map((r) => r.length));
// Build markdown table
const mdLines: string[] = [];
// Header row
if (rows.length > 0) {
const header = rows[0].concat(Array(maxCols - rows[0].length).fill(""));
mdLines.push("| " + header.join(" | ") + " |");
}
// Separator
mdLines.push("|" + Array(maxCols).fill(" --- ").join("|") + "|");
// Data rows (skip header if we have more rows)
const dataRows = rows.length > 1 ? rows.slice(1) : [];
for (const row of dataRows) {
const padded = row.concat(Array(maxCols - row.length).fill(""));
mdLines.push("| " + padded.join(" | ") + " |");
}
// Replace table with markdown
const markdown = mdLines.join("\n");
$table.replaceWith($(`<pre class="table-markdown">\n${markdown}\n</pre>`));
});
}
/**
* Adds newlines after specified block elements to preserve document structure.
* This helps text splitters maintain paragraph/section boundaries.
*
* @param $ - Cheerio API instance
* @param elements - Array of element tag names to add newlines after
*/
export function addNewlinesAfterBlocks(
$: cheerio.CheerioAPI,
elements: string[] = BLOCK_ELEMENTS,
): void {
for (const tag of elements) {
$.root()
.find(tag)
.each((_, el) => {
$(el).append("\n");
});
}
}
export function formatForIngestion(
$: cheerio.CheerioAPI,
config: Partial<FormatConfig> = {},
): void {
const cfg = { ...DEFAULT_CONFIG, ...config };
// 1. Inject newlines globally (affects li, p, pre, emu-production, emu-rhs, etc.)
addNewlinesAfterBlocks($);
// 2. Inline leaves (links first — must be before lists/code destroy DOM)
convertLinksToMarkdown($, cfg.links.join(", "));
// 3. Inline code
convertInlineCodeToMarkdown($, cfg.codeBlocks.inline);
// 4. Block leaves (fenced code)
convertBlockCodeToMarkdown($, cfg.codeBlocks.block);
// 5. Structural parents (grammar, lists, tables)
convertGrammarToMarkdown($, cfg.codeBlocks.grammar.join(", "));
convertListsToMarkdown($, cfg.lists.ordered, cfg.lists.unordered);
convertTablesToMarkdown($);
}