/** * Markdown heading-id slug SSOT (server-safe: no React, no DOM). * * This algorithm previously existed in THREE byte-identical copies: * - `components/ui/simple-markdown-renderer.tsx` (generateHeadingId) * - `components/ui/rich-markdown-renderer.tsx` (generateHeadingId) * - `utils/markdown-section-extractor.ts` (extractSections) * * The extractor is the PRODUCER of `sectionIds` and the renderers are the * CONSUMERS — if the two ever drift, deep-link anchors and scroll-spy * targets silently diverge. All three now call these helpers; the parity * test `components/ui/__tests__/markdown-parity.test.tsx` asserts * extractor-vs-renderer ID agreement over the fixture corpus. */ /** Emoji ranges stripped from heading text before slugification. */ export const HEADING_EMOJI_RE = /[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/gu /** Remove emoji characters and trim. */ export function stripHeadingEmojis(text: string): string { return text.replace(HEADING_EMOJI_RE, '').trim() } /** * Core slug chain WITHOUT the emoji strip (the extractor exposes emoji * stripping as an option, so the two steps are kept separable): * lowercase → drop non-word/space/hyphen chars → spaces to hyphens → * trim leading/trailing hyphens. May return `''` for symbol-only input — * callers apply their own fallback (`section-N`). */ export function slugifyHeadingBase(text: string): string { return text .trim() .toLowerCase() .replace(/[^\w\s-]/g, '') .replace(/\s+/g, '-') .replace(/^-+|-+$/g, '') } /** The full default chain used by the renderers: emoji strip + slugify. */ export function slugifyHeadingText(text: string): string { return slugifyHeadingBase(stripHeadingEmojis(text)) } // --------------------------------------------------------------------------- // Heading SCANNER + DEDUPER (the other half of the producer/consumer contract) // --------------------------------------------------------------------------- /** * Sharing the slug chain alone was not enough. The PRODUCER * (`utils/markdown-section-extractor`) and the CONSUMER * (`components/ui/markdown/heading-ids`) each carried their own copy of * "which lines are headings" and "how do duplicates get suffixed", and the * copies drifted: * - the extractor toggled its code-block state on a bare * `line.startsWith('```')`, so a `~~~`-fenced (or wider-backtick, or * indented) block containing `## Setup` produced a SECTION from the * extractor and NO id from the renderer — `sectionIdMap` then missed * silently and the deep-link anchor pointed nowhere; * - the extractor anchored ATX at column 0 while the renderer allowed the * CommonMark 0..3-space indent; * - the dedupe counter was hand-copied in both files, each with a comment * saying "the two must agree". * Both now call `scanHeadings` + `createHeadingIdDeduper`. Server-safe: no * React, no DOM. */ // The CommonMark fence machine used to live in THIS file, which meant a // streaming renderer imported its fence state from a module named after // heading slugs. It now lives in `./markdown-fences`; re-exported here so the // public `utils` surface (and every existing import) is unchanged. export { createFenceTracker, isBlankLine, type FenceTracker, type FenceLineRole, } from './markdown-fences' import { createFenceTracker, isBlankLine } from './markdown-fences' /** A heading the renderer will emit, located in the source. */ export interface ScannedHeading { /** 1-based line of the heading's FIRST line (setext: the title line). */ line: number level: number /** Raw title text, before `stripInlineMarkdown` / slugification. */ text: string } export interface ScanHeadingsOptions { /** * Skip a leading YAML frontmatter block (`---` on line 1 through the next * `---`). Only a DOCUMENT-LEADING block counts: a bare `---` mid-document * is a thematic break or a setext underline, and treating it as a * frontmatter toggle (what the extractor used to do) silently swallowed * every heading until the next one. */ skipFrontmatter?: boolean /** Include raw-HTML headings (`

Title

`). Default true. */ includeRawHtml?: boolean /** * Skip fenced-code blocks. Default true — a `##` inside a code fence is * code, not a heading, and the renderer emits no id for it. */ skipFences?: boolean } /** * EVERY line-anchored regex in this module ends `\r?$`, not `$`. * * Lines here come from `content.split('\n')`, so on a CRLF document each one * carries a trailing `\r`. `$` (no `m` flag) anchors at end of string and `\r` * is not in `[ \t]`, so a `$`-anchored pattern matches NOTHING on such a * document. That was a live REGRESSION: `main`'s looser `^(#{1,6})\s+(.+)` * had no end anchor at all and tolerated the `\r` (the title was `.trim()`ed * afterwards), so `'# Real\r\n\r\nbody\r\n\r\n## Second\r\n'` yielded * `["Real","Second"]` on `main` and `[]` here — every CRLF-stored doc / blog / * release body silently lost its TOC, its in-page anchors and its doc-SEO * heading links. * * The `\r` is ABSORBED, not normalized away: `scanHeadings` reports 1-based * LINE numbers that the renderer's id map is keyed by, and the sanitizer's mask * (which shares `markdown-fences`) is length-preserving — neither may be fed a * rewritten source. */ /** CommonMark ATX heading (closing `#` run is not part of the title). */ const ATX_HEADING_RE = /^ {0,3}(#{1,6})[ \t]+(.*?)(?:[ \t]+#+)?[ \t]*\r?$/ /** ATX with no title at all (`###`, `## ###`) — still a heading, empty text. */ const ATX_EMPTY_RE = /^ {0,3}(#{1,6})[ \t]*#*[ \t]*\r?$/ /** * Raw-HTML heading (`

Title

`) — rehype-raw renders these too. * GLOBAL: two `

`s on one line are two headings, and a non-global scan * silently dropped the second (it then fell through to the renderer's * suffix-free fallback and emitted a DUPLICATE DOM id). */ const RAW_HEADING_RE = /]*>([\s\S]*?)(?:<\/h\1>|$)/gi /** * Blockquote markers and list-item markers preceding an ATX heading * (`> ## Setup`, `- ## Setup`, `1. ## Setup`). mdast emits a REAL `

` for * these; scanning only column-0..3 missed them entirely, so two identical * `> ## Setup` headings both hit the suffix-free fallback and emitted the * duplicate DOM ids this whole module exists to prevent. */ const CONTAINER_PREFIX_RE = /^ {0,3}(?:(?:>[ \t]?)+|(?:[-+*]|\d{1,9}[.)])[ \t]+)+/ /** Setext underline (`===` → h1, `---` → h2), AFTER container-prefix strip. */ const SETEXT_UNDERLINE_RE = /^ {0,3}(=+|-+)[ \t]*\r?$/ /** Thematic break — ends a paragraph run, never underlines it. */ const THEMATIC_BREAK_RE = /^ {0,3}(\*|_){3,}[ \t]*\r?$/ /** * CommonMark HTML-block type 6 tag names — these open a raw HTML block no * matter what follows them on the line. */ const HTML_BLOCK_TAGS = 'address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h1|h2|h3|h4|h5|h6|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul' /** * A line that OPENS a raw HTML block. mdast hands the whole block to the HTML * parser, so a `---` inside it is not a setext underline and the lines above * it are not a paragraph: `
\nText\n---\n
` emits NO heading. The * scanner used to publish a phantom `Text` h2 there. * * The naive `^ {0,3}<` this replaced disqualified any run whose first line * merely STARTED with a tag, so `x more text` + `---` (a plain * paragraph in mdast, yielding an h2) was missed. The alternatives are spelled * out per CommonMark: * - types 1-5: `]*` rather than * a full attribute grammar; the error bias is unchanged (a missed run costs a * TOC entry, never a wrong id — the same direction as the documented * list-item-indent gap in `SetextRun.prefix`). */ const HTML_BLOCK_OPENER_RE = new RegExp( '^ {0,3}(?:' + // types 1-5 '<(?:script|pre|style|textarea)(?:[\\s>]|$)|