import remarkDirective from "remark-directive"; import remarkGfm from "remark-gfm"; import remarkParse from "remark-parse"; import { unified } from "unified"; import type { components } from "../../contracts/studio/generated.ts"; import { createHeadingId } from "./heading-id.ts"; import { inlinePlainText } from "./inline-plain-text.ts"; type Inline = components["schemas"]["Inline"]; type CaseRenderBlock = components["schemas"]["CaseRenderBlock"]; type EvidenceFigureBlock = components["schemas"]["EvidenceFigureBlock"]; /** * The parser has no asset catalog, so it cannot resolve `EvidenceFigureBlock.asset` * (the canonical, renderer-facing descriptor). It authors everything else the * canonical `CaseRenderBlock` union declares; only the evidence figure narrows to * this authoring shape until a render-model builder attaches the resolved asset. */ export type CaseAuthoringEvidenceFigureBlock = Omit; export type CaseAuthoringBlock = | Exclude | CaseAuthoringEvidenceFigureBlock; type Positioned = { position?: { start: { line: number; column: number }; } | null; }; type TextNode = Positioned & { type: "text"; value: string }; type ParentInlineNode = Positioned & { type: "emphasis" | "strong"; children: PhrasingContent[]; }; type LinkNode = Positioned & { type: "link"; title?: string | null; url: string; children: PhrasingContent[]; }; type TextDirective = Positioned & { type: "textDirective"; name: string; attributes?: Record | null; children: PhrasingContent[]; }; type PhrasingContent = | TextNode | (Positioned & { type: "inlineCode"; value: string }) | ParentInlineNode | LinkNode | TextDirective | (Positioned & { type: | "break" | "delete" | "html" | "image" | "imageReference" | "linkReference" | "footnoteReference"; }); type Paragraph = Positioned & { type: "paragraph"; children: PhrasingContent[]; }; type Heading = Positioned & { type: "heading"; depth: number; children: PhrasingContent[]; }; type MdastListItem = Positioned & { type: "listItem"; checked?: boolean | null; children: Content[]; }; type List = Positioned & { type: "list"; ordered?: boolean | null; start?: number | null; children: MdastListItem[]; }; type Code = Positioned & { type: "code"; value: string; lang?: string | null; meta?: string | null; }; type TableCell = Positioned & { type: "tableCell"; children: PhrasingContent[]; }; type TableRow = Positioned & { type: "tableRow"; children: TableCell[] }; type Table = Positioned & { type: "table"; align?: Array<"left" | "right" | "center" | null>; children: TableRow[]; }; type ContainerDirective = Positioned & { type: "containerDirective"; name: string; attributes?: Record | null; children: Content[]; }; type Content = | Heading | Paragraph | (Positioned & { type: "blockquote"; children: Content[] }) | List | Code | ContainerDirective | Table | (Positioned & { type: | "html" | "thematicBreak" | "definition" | "yaml" | "footnoteDefinition" | "leafDirective"; }); type Root = { children: Content[] }; export type ContentFormatIssue = { line: number; column: number; detail: string; }; export class ContentFormatError extends Error { readonly code = "CONTENT_FORMAT_INVALID"; constructor(readonly issues: ReadonlyArray) { super( `CONTENT_FORMAT_INVALID\n${issues .map((issue) => `${issue.line}:${issue.column} ${issue.detail}`) .join("\n")}`, ); this.name = "ContentFormatError"; } } function invalid(node: Positioned | null | undefined, detail: string): never { throw new ContentFormatError([ { line: node?.position?.start.line ?? 1, column: node?.position?.start.column ?? 1, detail, }, ]); } type Fence = { marker: "`" | "~"; length: number }; function normalizeDirectives(source: string): string { let fence: Fence | null = null; return source .split(/(?<=\n)/) .map((line) => { const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})([^\n]*)(?:\n)?$/); if (fence) { if ( fenceMatch && fenceMatch[1][0] === fence.marker && fenceMatch[1].length >= fence.length && fenceMatch[2].trim() === "" ) { fence = null; } return line; } if (fenceMatch) { fence = { marker: fenceMatch[1][0] as Fence["marker"], length: fenceMatch[1].length, }; return line; } return line.replace( /^(:::[a-z][a-z0-9-]*)([^\n{][^\n]*)(\n?)$/i, ( _match, marker: string, attributes: string, newline: string, ) => `${marker}{${attributes.trim()}}${newline}`, ); }) .join(""); } function assertNever(value: never): never { throw new Error(`Unsupported content node: ${JSON.stringify(value)}`); } const trustedRelativeLinkOrigin = "https://techlog.invalid"; function hasAsciiControlCharacter(value: string): boolean { return Array.from(value).some((character) => { const codePoint = character.codePointAt(0) ?? 0; return codePoint <= 0x1f || codePoint === 0x7f; }); } function isSafeLink(href: string): boolean { if (href.includes("\\") || hasAsciiControlCharacter(href)) { return false; } if (href.startsWith("#")) return true; if (href.startsWith("//")) return false; if (href.startsWith("/")) { try { return ( new URL(href, `${trustedRelativeLinkOrigin}/`).origin === trustedRelativeLinkOrigin ); } catch { return false; } } try { const url = new URL(href); return ( url.protocol === "http:" || url.protocol === "https:" || url.protocol === "mailto:" ); } catch { return false; } } function attributesOf( node: ContainerDirective | TextDirective, expected: ReadonlyArray, ): Record { const attributes = node.attributes ?? {}; const actual = Object.keys(attributes); if ( actual.length !== expected.length || expected.some((name) => !Object.hasOwn(attributes, name)) ) { invalid(node, `${node.name} requires attributes: ${expected.join(", ")}`); } const result: Record = {}; for (const name of expected) { const value = attributes[name]; if (typeof value !== "string") { invalid(node, `${node.name}.${name} must be a string`); } result[name] = value; } return result; } function inlineFromNode(node: PhrasingContent): Inline { switch (node.type) { case "text": return { type: "TEXT", text: node.value }; case "inlineCode": return { type: "INLINE_CODE", code: node.value }; case "emphasis": return { type: "EMPHASIS", children: inlineFromNodes(node.children) }; case "strong": return { type: "STRONG", children: inlineFromNodes(node.children) }; case "link": { if (node.title !== null && node.title !== undefined) { invalid(node, "link titles are not supported"); } if (!isSafeLink(node.url)) invalid(node, `unsafe link URL: ${node.url}`); return { type: "LINK", label: inlinePlainText(inlineFromNodes(node.children)), href: node.url, }; } case "textDirective": { if (node.name !== "status") { invalid(node, `unknown inline directive: ${node.name}`); } const attributes = attributesOf(node, ["tone"]); const tone = attributes.tone; if (tone !== "warning" && tone !== "evidence" && tone !== "neutral") { invalid(node, `unsupported status tone: ${tone}`); } return { type: "STATUS", label: inlinePlainText(inlineFromNodes(node.children)), tone, }; } case "break": case "delete": case "html": case "image": case "imageReference": case "linkReference": case "footnoteReference": return invalid(node, `unsupported inline syntax: ${node.type}`); default: return assertNever(node); } } function inlineFromNodes(nodes: ReadonlyArray): Inline[] { return nodes.map(inlineFromNode); } function isValidId(id: string): boolean { return /^[\p{L}\p{N}][\p{L}\p{N}_-]*$/u.test(id); } function reserveId(id: string, usedIds: Set, node: Positioned): void { if (!isValidId(id)) invalid(node, `invalid explicit ID: ${id}`); if (usedIds.has(id)) invalid(node, `duplicate explicit ID: ${id}`); usedIds.add(id); } function headingBlock( node: Heading, usedIds: Set, ): components["schemas"]["HeadingBlock"] { if (node.depth < 2 || node.depth > 4) { invalid(node, "only heading levels 2 through 4 are supported"); } const children = [...node.children]; const last = children.at(-1); let explicitId: string | null = null; if (last?.type === "text") { const match = last.value.match(/(?:^|\s)\{#([^{}\s]+)\}\s*$/u); if (match && match.index !== undefined) { explicitId = match[1]; const remaining = last.value.slice(0, match.index).replace(/\s+$/u, ""); if (remaining) children[children.length - 1] = { ...last, value: remaining }; else children.pop(); } else if (/\{#[^{}]*\}\s*$/u.test(last.value)) { invalid(last, "invalid explicit heading ID"); } } const content = inlineFromNodes(children); const title = inlinePlainText(content); if (!title.trim()) invalid(node, "heading content is required"); let id: string; if (explicitId) { reserveId(explicitId, usedIds, node); id = explicitId; } else { id = createHeadingId(title, usedIds); } return { type: "HEADING", id, level: node.depth, content }; } function codeBlock(node: Code): components["schemas"]["CodeBlock"] { let label: string | null = null; if (node.meta) { const match = node.meta.match(/^label="((?:[^"\\]|\\["\\])*)"$/u); if (!match) invalid(node, "code metadata must be one label attribute"); label = match[1].replace(/\\(["\\])/g, "$1"); } if (node.lang && !/^[A-Za-z0-9][A-Za-z0-9_.+-]*$/u.test(node.lang)) { invalid(node, `unsupported code language: ${node.lang}`); } return { type: "CODE_BLOCK", code: node.value, language: node.lang ?? null, label, }; } function listBlock( node: List, nextListItemId: () => string, ): | components["schemas"]["UnorderedListBlock"] | components["schemas"]["OrderedListBlock"] { if ( node.ordered && node.start !== null && node.start !== undefined && node.start !== 1 ) { invalid(node, "ordered lists must start at 1"); } const items = node.children.map((item: MdastListItem) => { if (item.checked !== null && item.checked !== undefined) { invalid(item, "task list items are not supported"); } if (item.children.length !== 1 || item.children[0].type !== "paragraph") { invalid(item, "list items must contain exactly one paragraph"); } return { id: nextListItemId(), content: inlineFromNodes(item.children[0].children), }; }); return node.ordered ? { type: "ORDERED_LIST", items } : { type: "UNORDERED_LIST", items }; } function tableCellContent(cell: TableCell): Inline[] { return inlineFromNodes(cell.children); } function tableBlock( node: ContainerDirective, usedIds: Set, ): components["schemas"]["DataTableBlock"] { const attributes = attributesOf(node, ["id", "caption", "rowHeaderColumn"]); if (node.children.length !== 1 || node.children[0].type !== "table") { invalid(node, "table directive must contain exactly one GFM table"); } const table = node.children[0] as Table; if (table.children.length === 0) invalid(table, "table header is required"); const id = attributes.id; reserveId(id, usedIds, node); const header = table.children[0]; if (header.children.length === 0) invalid(header, "table columns are required"); const columns = header.children.map((cell, index) => { const columnId = `${id}-column-${index + 1}`; reserveId(columnId, usedIds, cell); const alignment = table.align?.[index]; return { id: columnId, label: inlinePlainText(inlineFromNodes(cell.children)), alignment: alignment === "right" ? ("RIGHT" as const) : alignment === "center" ? ("CENTER" as const) : ("LEFT" as const), }; }); const rowHeaderColumn = attributes.rowHeaderColumn === "none" ? null : attributes.rowHeaderColumn === "1" ? 1 : invalid(node, "table rowHeaderColumn must be 1 or none"); const rows = table.children.slice(1).map((row, rowIndex) => { if (row.children.length !== columns.length) { invalid(row, "table rows must have the same number of cells as the header"); } const rowId = `${id}-row-${rowIndex + 1}`; reserveId(rowId, usedIds, row); return { id: rowId, cells: row.children.map((cell, columnIndex) => ({ columnId: columns[columnIndex].id, content: tableCellContent(cell), })), }; }); return { type: "DATA_TABLE", id, caption: attributes.caption, rowHeaderColumn, columns, rows, }; } function directiveBlock( node: ContainerDirective, usedIds: Set, ): | components["schemas"]["DataTableBlock"] | components["schemas"]["CalloutBlock"] | CaseAuthoringEvidenceFigureBlock { switch (node.name) { case "table": return tableBlock(node, usedIds); case "callout": { const attributes = attributesOf(node, ["tone", "label"]); if (attributes.tone !== "warning" && attributes.tone !== "info") { invalid(node, `unsupported callout tone: ${attributes.tone}`); } if (node.children.length !== 1 || node.children[0].type !== "paragraph") { invalid(node, "callout directive must contain exactly one paragraph"); } return { type: "CALLOUT", tone: attributes.tone, label: attributes.label, content: inlineFromNodes((node.children[0] as Paragraph).children), }; } case "evidence": { const attributes = attributesOf(node, ["key", "alt", "caption", "zoom"]); if (node.children.length !== 0) { invalid(node, "evidence directive cannot have a body"); } if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(attributes.key)) { invalid(node, `unsafe evidence key: ${attributes.key}`); } if (!attributes.alt) invalid(node, "evidence alt text is required"); if (attributes.zoom !== "true" && attributes.zoom !== "false") { invalid(node, "evidence zoom must be true or false"); } return { type: "EVIDENCE_FIGURE", key: attributes.key, alt: attributes.alt, caption: attributes.caption, zoom: attributes.zoom === "true", }; } default: return invalid(node, `unknown block directive: ${node.name}`); } } function paragraphBlock( node: Paragraph, ): components["schemas"]["ParagraphBlock"] { return { type: "PARAGRAPH", content: inlineFromNodes(node.children) }; } export function parseCaseContent(source: string): CaseAuthoringBlock[] { let tree: Root; try { tree = unified() .use(remarkParse) .use(remarkGfm) .use(remarkDirective) .parse(normalizeDirectives(source)) as Root; } catch (error) { if (error instanceof ContentFormatError) throw error; throw new ContentFormatError([ { line: 1, column: 1, detail: error instanceof Error ? error.message : "invalid content", }, ]); } const usedIds = new Set(); let listItemCount = 0; const nextListItemId = () => { listItemCount += 1; return `list-item-${listItemCount}`; }; return tree.children.map((node: Content): CaseAuthoringBlock => { switch (node.type) { case "heading": return headingBlock(node, usedIds); case "paragraph": return paragraphBlock(node); case "blockquote": if (node.children.length !== 1 || node.children[0].type !== "paragraph") { invalid(node, "blockquotes must contain exactly one paragraph"); } return { type: "BLOCKQUOTE", content: inlineFromNodes((node.children[0] as Paragraph).children), }; case "list": return listBlock(node, nextListItemId); case "code": return codeBlock(node); case "containerDirective": return directiveBlock(node, usedIds); case "html": case "thematicBreak": case "definition": case "yaml": case "table": case "footnoteDefinition": case "leafDirective": return invalid(node, `unsupported block syntax: ${node.type}`); default: { const unsupported = node as Positioned & { type: string }; return invalid( unsupported, `unsupported block syntax: ${unsupported.type}`, ); } } }); }