Files
tech-log-frontend/src/features/tech-log/domain/content-format/parse-case-content.ts
T
DongHyeonkaandClaude Opus 5 639e1a49c9 build: generate the TechLog Studio contract from canonical source
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>
2026-08-18 00:27:59 +09:00

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}`,
);
}
}
});
}