/** * 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 (`