Vendors the canonical studio-v1.yaml, generates types via an isolated `pnpm dlx` toolchain (openapi-typescript needs TypeScript 5's classic compiler API; this repo pins TypeScript 7.0.2 per VD-01, whose root export has none), and adds an offline drift gate that checks the vendored yaml/generated types/canonical-source.json against each other without touching the sibling design-package repo or the network. Regenerating from canonical surfaces real, new required fields on existing schemas (WorkingCopyDetail.nextAction, PreviewDetail/PublicPreview .dependencyRevision, StudioDashboard.totals.needsValidation, PublicationSnapshot.contentFormatVersion/rendererContractVersion) and a new required EvidenceFigureBlock.asset. The mock gateway and fixtures are updated to satisfy the former; the latter exposes a real authoring- vs-rendering conflation in the content-format parser (it declared its output as the server's fully-resolved PublicRenderModel type, which it has no asset catalog to satisfy). Split that boundary: the parser now produces an authoring block type omitting the resolved asset, and each of its three consumers (the mock gateway, the Studio instant preview, and the static Case demo page) attaches the resolved descriptor from its own asset source through a shared, pure domain-level resolver. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
604 lines
17 KiB
TypeScript
604 lines
17 KiB
TypeScript
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<EvidenceFigureBlock, "asset">;
|
|
export type CaseAuthoringBlock =
|
|
| Exclude<CaseRenderBlock, EvidenceFigureBlock>
|
|
| 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<string, string | null | undefined> | 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<string, string | null | undefined> | 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<ContentFormatIssue>) {
|
|
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<string>,
|
|
): Record<string, string> {
|
|
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<string, string> = {};
|
|
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<PhrasingContent>): 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<string>, 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<string>,
|
|
): 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<string>,
|
|
): 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<string>,
|
|
):
|
|
| 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<string>();
|
|
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}`,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
}
|