inside (handled by block converter)
+ if (elem.name === "code" && $(elem).parent("pre").length > 0) return;
+
+ const text = $(elem).text().trim();
+ if (!text) return;
+ $(elem).replaceWith(`\`${text}\``);
+ });
+ }
+}
+```
+
+### `convertBlockCodeToMarkdown` — fenced code blocks
+
+```typescript
+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(
+ $(`\`\`\`${lang}\n${text}\n\`\`\``),
+ );
+ });
+
+ // Handle standalone emu-eqn (block-level equations)
+ $("emu-eqn:not([class*='inline'])").each((_, eqn) => {
+ const text = $(eqn).text().trim();
+ $(eqn).replaceWith(
+ $(`\`\`\`\n${text}\n\`\`\``),
+ );
+ });
+}
+```
+
+### `convertListsToMarkdown` — recursive list processing
+
+```typescript
+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($(`${nestedMarkdown}`));
+ });
+
+ // Now get full text (nested lists are replaced with 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($(`${markdown}`));
+ });
+}
+```
+
+Key insight: recursion goes depth-first. When processing an `li`, nested `ol`/`ul` children are first replaced with `` 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.
+
+```typescript
+export function convertGrammarToMarkdown(
+ $: cheerio.CheerioAPI,
+ selector: string = "emu-grammar",
+): void {
+ $(selector).each((_, grammar) => {
+ const text = $(grammar).text().trim();
+ $(grammar).replaceWith(
+ $(`\`\`\`bnf\n${text}\n\`\`\``),
+ );
+ });
+}
+```
+
+Add `"emu-production"` and `"emu-rhs"` to `BLOCK_ELEMENTS` array.
+
+### `formatForIngestion` — orchestrator
+
+```typescript
+export function formatForIngestion(
+ $: cheerio.CheerioAPI,
+ config: Partial = {},
+): 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 `` elements.