Something went wrong. Try again.
Original-language reader for Kant, Aristotle & more: select any passage to gloss, translate, or hear it read aloud, with exact citations.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240import 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 = /<a\s+id="([^"]+)"\s*>/gconst COMMENT_RE = /^<!--[\s\S]*-->$/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<string, number> 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 `<a id="…">`; closers `</a>`, comments → no glyph. if (/<a\s+id=/i.test(n.value)) recordAnchors(ctx, n.value) break case "footnoteReference": if (!ctx.footnoteMarkers.has(n.identifier)) { ctx.footnoteMarkers.set(n.identifier, ctx.text.length) } break default: break } }}
function processHeading(node: Heading, ctx: Ctx): void { separate(ctx) const start = ctx.text.length serializeInline(node.children, ctx) const end = ctx.text.length const label = ctx.text.slice(start, end).trim() const headingId = slugify(label, ctx.seq++) const section = SECTION_RE.exec(label) const heading: HeadingNode = { headingId, level: node.depth, label, start, end, children: [], ...(section ? { sectionMark: `§ ${section[1]}` } : {}), } ctx.flatHeadings.push({ node: heading, level: node.depth }) ctx.blocks.push({ kind: "heading", start, end, level: node.depth, headingId })}
function processFootnoteDef(node: FootnoteDefinition, ctx: Ctx): void { separate(ctx) const start = ctx.text.length for (const child of node.children) { if (child.type === "paragraph") serializeInline(child.children, ctx) } const end = ctx.text.length const body = ctx.text.slice(start, end).trim() ctx.blocks.push({ kind: "footnoteDef", ref: node.identifier, start, end }) ctx.footnotes.push({ ref: node.identifier, markerOffset: ctx.footnoteMarkers.get(node.identifier) ?? start, body, })}
function processBlock(node: RootContent, ctx: Ctx): void { switch (node.type) { case "heading": processHeading(node, ctx) break case "paragraph": { separate(ctx) const start = ctx.text.length serializeInline(node.children, ctx) ctx.blocks.push({ kind: "para", start, end: ctx.text.length }) break } case "footnoteDefinition": processFootnoteDef(node, ctx) break case "blockquote": for (const child of node.children) processBlock(child, ctx) break case "html": if (COMMENT_RE.test(node.value.trim())) { ctx.provenance = node.value.trim() } else if (/<a\s+id=/i.test(node.value)) { recordAnchors(ctx, node.value) } break case "thematicBreak": break default: break }}
/** Build a heading tree from the flat list using a level stack (tolerant of irregular depths). */function nestHeadings(flat: readonly FlatHeading[]): HeadingNode[] { const roots: HeadingNode[] = [] const stack: FlatHeading[] = [] for (const fh of flat) { while (stack.length > 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()), }}