feat: port TechLog content format and renderer

This commit is contained in:
DongHyeonka
2026-08-15 18:44:09 +09:00
parent 82f94423e5
commit 16753f53af
18 changed files with 3148 additions and 0 deletions
@@ -0,0 +1,569 @@
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 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)}`);
}
function isSafeLink(href: string): boolean {
if (href.startsWith("#")) return true;
if (href.startsWith("/")) return !href.startsWith("//");
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"]
| components["schemas"]["EvidenceFigureBlock"] {
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): CaseRenderBlock[] {
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): CaseRenderBlock => {
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}`,
);
}
}
});
}