/** * Shared markdown section extractor * Extracts heading sections from markdown content for navigation. */ import { createHeadingIdDeduper, scanHeadings, slugifyHeadingBase, stripHeadingEmojis } from './markdown-heading-id'; import { stripInlineMarkdown } from './markdown-to-plain'; export interface MarkdownSection { id: string; title: string; level: number; } export interface ExtractSectionsOptions { /** Maximum heading level to include (default: 2 = H1-H2) */ maxLevel?: number; /** Remove emoji characters from IDs (default: true) */ removeEmojis?: boolean; /** Handle duplicate IDs by appending a counter suffix (default: true) */ handleDuplicateIds?: boolean; /** Strip bold, italic, and inline code markers from titles (default: true) */ stripFormattingMarkers?: boolean; /** Skip code blocks and YAML frontmatter (default: true) */ skipCodeAndYamlBlocks?: boolean; } const DEFAULT_OPTIONS: Required = { maxLevel: 2, removeEmojis: true, handleDuplicateIds: true, stripFormattingMarkers: true, skipCodeAndYamlBlocks: true, }; export function extractSections(markdown: string, options: ExtractSectionsOptions = {}): MarkdownSection[] { const opts = { ...DEFAULT_OPTIONS, ...options }; const sections: MarkdownSection[] = []; const dedupe = createHeadingIdDeduper(); // ONE scanner, shared with the renderer's heading-id map // (utils/markdown-heading-id.ts). This file used to carry its own // line loop — `line.startsWith('```')` for fences (blind to `~~~`, to // longer backtick runs and to the CommonMark 0..3-space indent) and a // bare `line === '---'` YAML toggle (which a mid-document thematic break // or setext underline tripped, swallowing every heading after it). Both // produced sections the renderer emitted no id for. const scanned = scanHeadings(markdown, { skipFrontmatter: opts.skipCodeAndYamlBlocks, skipFences: opts.skipCodeAndYamlBlocks, }); for (const heading of scanned) { if (heading.level > opts.maxLevel) continue; let title = heading.text; if (opts.stripFormattingMarkers) { title = stripInlineMarkdown(title).trim(); } // Slug chain SSOT — shared with the renderers' heading-id generator // (see utils/markdown-heading-id.ts). Extractor and renderers MUST // agree or deep-link anchors silently diverge. const baseId = slugifyHeadingBase(opts.removeEmojis ? stripHeadingEmojis(title) : title); // Symbol-only heading fallback: the 1-based ordinal among the headings // this extractor EMITS (levels ≤ maxLevel). The renderer's heading-id // generator mirrors this exact counter for levels ≤ 2 // (components/ui/markdown/heading-ids.ts) — the two must agree or // deep-link anchors diverge for symbol-only headings. const cleanId = baseId || `section-${sections.length + 1}`; sections.push({ id: opts.handleDuplicateIds ? dedupe(cleanId) : cleanId, title, level: heading.level, }); } return sections; }