import type { FootnoteDefinition, Heading, PhrasingContent, RootContent, } from "mdast" import remarkGfm from "remark-gfm" import remarkParse from "remark-parse" import { unified } from "unified" import type { Anchor, Block, DocumentModel, Emphasis, Footnote, HeadingNode, Lang, LocusSchemeId, } from "@/lib/schemas/model" import { anchorLocus } from "@/lib/model/locus" import { tokenize } from "./tokenize" const ANCHOR_RE = //g const COMMENT_RE = /^$/ const SECTION_RE = /§\s*(\d+[a-z]?)/i interface FlatHeading { node: HeadingNode level: number } interface Ctx { text: string scheme: LocusSchemeId anchors: Anchor[] blocks: Block[] flatHeadings: FlatHeading[] emphasis: Emphasis[] footnoteMarkers: Map footnotes: Footnote[] provenance: string | undefined seq: number } const processor = unified().use(remarkParse).use(remarkGfm) function append(ctx: Ctx, s: string): void { ctx.text += s } function separate(ctx: Ctx): void { if (ctx.text.length > 0) ctx.text += "\n\n" } function recordAnchors(ctx: Ctx, htmlValue: string): void { ANCHOR_RE.lastIndex = 0 let m: RegExpExecArray | null while ((m = ANCHOR_RE.exec(htmlValue)) !== null) { const id = m[1] if (id === undefined) continue ctx.anchors.push({ id, offset: ctx.text.length, locus: anchorLocus(ctx.scheme, id) }) } } function slugify(label: string, seq: number): string { const slug = label .toLowerCase() .replace(/[^\p{L}\p{N}]+/gu, "-") .replace(/^-+|-+$/g, "") .slice(0, 48) return `${slug || "h"}-${seq}` } function serializeInline(nodes: readonly PhrasingContent[], ctx: Ctx): void { for (const n of nodes) { switch (n.type) { case "text": append(ctx, n.value) break case "emphasis": case "strong": case "delete": { const start = ctx.text.length serializeInline(n.children, ctx) ctx.emphasis.push({ start, end: ctx.text.length }) break } case "link": serializeInline(n.children, ctx) break case "inlineCode": append(ctx, n.value) break case "break": append(ctx, "\n") break case "html": // Anchor openers ``; closers ``, comments → no glyph. if (/ 0 && stack[stack.length - 1]!.level >= fh.level) stack.pop() const parent = stack[stack.length - 1] if (parent) parent.node.children.push(fh.node) else roots.push(fh.node) stack.push(fh) } return roots } export interface ParsedDoc { model: DocumentModel provenance: string | undefined /** Footnote ids actually referenced by a `[^id]` marker (orphan check, §8). */ referencedFootnotes: string[] } export function buildModel( md: string, opts: { id: string; lang: Lang; scheme: LocusSchemeId; ingestVersion: number }, ): ParsedDoc { const tree = processor.parse(md) const ctx: Ctx = { text: "", scheme: opts.scheme, anchors: [], blocks: [], flatHeadings: [], emphasis: [], footnoteMarkers: new Map(), footnotes: [], provenance: undefined, seq: 0, } for (const node of tree.children) processBlock(node, ctx) const model: DocumentModel = { id: opts.id, lang: opts.lang, locusScheme: opts.scheme, text: ctx.text, blocks: ctx.blocks, anchors: ctx.anchors, headings: nestHeadings(ctx.flatHeadings), footnotes: ctx.footnotes, tokens: tokenize(ctx.text, opts.lang), emphasis: ctx.emphasis, ingestVersion: opts.ingestVersion, } return { model, provenance: ctx.provenance, referencedFootnotes: Array.from(ctx.footnoteMarkers.keys()), } }