11 KiB
model
| model |
|---|
| mimo-v2-pro |
Plan: HTML-to-Markdown Transformations for Ingestion
Summary
Add configurable HTML-to-markdown transformations to formatHTMLForIngestion.ts to improve the quality of text stored in the vector index. Transformations convert ecmarkup/HTML elements (code blocks, cross-reference links, lists, inline code) to markdown syntax before the HTMLTextSplitter extracts plain text.
Config Interface
interface FormatConfig {
codeBlocks: {
block: string[]; // fenced code (pre>code, emu-eqn block)
inline: string[]; // inline backticks (var, emu-val, emu-const, emu-eqn inline, code)
grammar: string[]; // fenced bnf (emu-grammar)
};
links: string[]; // [text](#hash) (emu-xref a)
lists: {
ordered: string[]; // 1. 2. 3. (ol)
unordered: string[];// - item (ul)
};
tables: string[]; // markdown tables (table, emu-table)
}
Default config:
const DEFAULT_CONFIG: FormatConfig = {
codeBlocks: {
block: ["pre>code", "emu-eqn:not([class*='inline'])"],
inline: ["var", "emu-val", "emu-const", "emu-eqn.inline"],
// Note: standalone <code> (outside <pre>) is rare; handle with :not(pre > *) check in converter
grammar: ["emu-grammar"],
},
links: ["emu-xref a"],
lists: {
ordered: ["ol"],
unordered: ["ul"],
},
tables: ["table", "emu-table"],
};
Transformation Order
addNewlinesAfterBlocks($)— global newline injection (existing function, runs first)- Inline leaves:
emu-xref a[href]→ links,var/emu-val/emu-const→ inline code - Block leaves:
pre>code→ fenced code,emu-eqn→ fenced code - Structural parents:
emu-grammar→ fenced bnf (uses.text()with pre-injected newlines),ol/ul→ markdown lists,table→ markdown tables
Implementation Details
Approach: Cheerio only
All conversions use cheerio DOM manipulation (already in codebase). No external HTML-to-markdown library. Each conversion replaces DOM elements with text/markdown nodes. The existing HTMLTextSplitter processes the modified DOM unchanged.
New functions in setup/utils/formatHTMLForIngestion.ts
Each function receives cheerio.CheerioAPI and modifies the DOM in-place.
-
convertLinksToMarkdown($, selector: string)— selector default:emu-xref a[href]$(selector).each(...): get href, strip filename, get text, replaceWith<span>containing[text](#hash)- Safety: skip if href starts with
http
-
convertInlineCodeToMarkdown($, tags: string[])$(tag).each(...): get.text(), replaceWith<span>containing`text`- For
codetags: skip if parent ispre
-
convertBlockCodeToMarkdown($, tags: string[])- For
pre>code: get language fromclass, get.text()from code, replaceWith<pre>containing```lang\ntext\n``` - For
emu-eqnblock: get.text(), replaceWith<pre>containing```\ntext\n```
- For
-
convertGrammarToMarkdown($, selector: string)- Walk inner tags (emu-nt, emu-t, emu-rhs, emu-geq), build text representation
- ReplaceWith
<pre>containing```bnf\ntext\n```
-
convertListsToMarkdown($, ordered: string[], unordered: string[])- For each
ol/ul: recursive functionlistToMarkdown(elem, depth, isOrdered) - Process
lichildren, add1./-prefix with indentation - ReplaceWith
<pre>containing markdown text
- For each
-
formatForIngestion($, config?)— orchestrator, calls all in order
Critical Implementation Code
Config interface
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"],
grammar: ["emu-grammar"],
},
links: ["emu-xref a"],
lists: {
ordered: ["ol"],
unordered: ["ul"],
},
tables: ["table", "emu-table"],
};
convertLinksToMarkdown — filename stripping
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>`);
});
}
convertInlineCodeToMarkdown — backtick wrapping
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 (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>`);
});
}
}
convertBlockCodeToMarkdown — fenced code blocks
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 {
// Handle pre>code (JS code examples)
$("pre > code").each((_, code) => {
const lang = extractLanguage($(code).attr("class"));
const text = $(code).text().trim();
const pre = $(code).parent("pre");
pre.replaceWith(
$(`<pre class="code-markdown">\`\`\`${lang}\n${text}\n\`\`\`</pre>`),
);
});
// Handle standalone emu-eqn (block-level equations)
$("emu-eqn:not([class*='inline'])").each((_, eqn) => {
const text = $(eqn).text().trim();
$(eqn).replaceWith(
$(`<pre class="code-markdown">\`\`\`\n${text}\n\`\`\`</pre>`),
);
});
}
convertListsToMarkdown — recursive list processing
function listToMarkdown(
$: cheerio.CheerioAPI,
elem: cheerio.Element,
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 = isOrdered ? `${i + 1}. ` : "- ";
const $li = $(li);
// Recurse into nested lists first (depth-first)
$li.children("ol, ul").each((_, nested) => {
const nestedMarkdown = listToMarkdown($, nested, depth + 1);
$(nested).replaceWith($(`<pre class="list-processed">${nestedMarkdown}</pre>`));
});
// Now get full text (nested lists are replaced with <pre> containing markdown)
const itemText = $li.text().trim().replace(/\s+/g, " ");
lines.push(`${indent}${prefix}${itemText}`);
});
return lines.join("\n");
}
export function convertListsToMarkdown(
$: cheerio.CheerioAPI,
ordered: string[] = ["ol"],
unordered: string[] = ["ul"],
): void {
const selectors = [...ordered, ...unordered].join(", ");
// Process from outermost — recursion handles depth-first nesting
$(selectors).each((_, elem) => {
// Skip if already processed (parent already handled this)
if ($(elem).hasClass("list-markdown")) return;
const markdown = listToMarkdown($, elem, 0);
$(elem).replaceWith($(`<pre class="list-markdown">${markdown}</pre>`));
});
}
Key insight: recursion goes depth-first. When processing an li, nested ol/ul children are first replaced with <pre class="list-processed"> containing their markdown. Then $li.text() includes that markdown. Outer $(selectors).each(...) skips already-processed elements.
convertGrammarToMarkdown — grammar production text
addNewlinesAfterBlocks runs first globally, injecting \n after emu-production and emu-rhs (added to BLOCK_ELEMENTS). Then .text() on emu-grammar naturally preserves production/alternative boundaries.
export function convertGrammarToMarkdown(
$: cheerio.CheerioAPI,
selector: string = "emu-grammar",
): void {
$(selector).each((_, grammar) => {
const text = $(grammar).text().trim();
$(grammar).replaceWith(
$(`<pre class="code-markdown">\`\`\`bnf\n${text}\n\`\`\`</pre>`),
);
});
}
Add "emu-production" and "emu-rhs" to BLOCK_ELEMENTS array.
formatForIngestion — orchestrator
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($);
}
Modify existing functions
convertTablesToMarkdown($): No changes needed..text()already extracts markdown text from pre-processed<span>elements.
Modify setup/ingest.ts
- Replace individual calls to
convertTablesToMarkdown($)andaddNewlinesAfterBlocks($)with single call toformatForIngestion($). - The child
emu-clausereplacement and deep nesting cleanup remain as-is (beforeformatForIngestion).
Updated call order in buildSpecDocuments():
// 1. Child clause replacement (existing)
$section.children("emu-clause").each(...);
$section.find("emu-clause").remove();
// 2. All formatting transformations (single call)
formatForIngestion($);
Files to Modify
setup/utils/formatHTMLForIngestion.ts— add config interface, 5 new functions, orchestrating functionsetup/ingest.ts— replace individual calls withformatForIngestion($)call- New test file:
setup/utils/formatHTMLForIngestion.test.ts— unit tests for each transformation
No new dependencies. Cheerio only.
Verification
- Unit tests: Write tests for each transformation with sample HTML snippets from the spec:
emu-xref a→ markdown linkvar/emu-val/emu-const→ inline codepre>code→ fenced codeemu-grammar→ fenced bnfol/ulwith nesting → markdown liststable→ markdown table (with links inside cells preserved)- Full pipeline via
formatForIngestion
- Manual verification: Run
bun run setup/ingest.tsand inspect output chunks for correct markdown syntax - Run lint/typecheck:
bun run lintandbun run typecheck(if available)