feat: port TechLog content format and renderer
This commit is contained in:
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user