feat: port TechLog content format and renderer
This commit is contained in:
@@ -0,0 +1,18 @@
|
|||||||
|
export function createHeadingId(text: string, used: Set<string>): string {
|
||||||
|
const base =
|
||||||
|
text
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim()
|
||||||
|
.replace(/[A-Z]/g, (character) => character.toLowerCase())
|
||||||
|
.replace(/[^\p{L}\p{N}]+/gu, "-")
|
||||||
|
.replace(/^-+|-+$/g, "") || "section";
|
||||||
|
|
||||||
|
let candidate = base;
|
||||||
|
let suffix = 2;
|
||||||
|
while (used.has(candidate)) {
|
||||||
|
candidate = `${base}-${suffix}`;
|
||||||
|
suffix += 1;
|
||||||
|
}
|
||||||
|
used.add(candidate);
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { components } from "../../contracts/studio/generated.ts";
|
||||||
|
|
||||||
|
type Inline = components["schemas"]["Inline"];
|
||||||
|
|
||||||
|
function assertNever(value: never): never {
|
||||||
|
throw new Error(`Unsupported inline value: ${JSON.stringify(value)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inlinePlainText(inline: Inline): string;
|
||||||
|
export function inlinePlainText(inline: ReadonlyArray<Inline>): string;
|
||||||
|
export function inlinePlainText(
|
||||||
|
inline: Inline | ReadonlyArray<Inline>,
|
||||||
|
): string {
|
||||||
|
if (Array.isArray(inline)) {
|
||||||
|
return inline.map((item) => inlinePlainText(item)).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = inline as Inline;
|
||||||
|
switch (item.type) {
|
||||||
|
case "TEXT":
|
||||||
|
return item.text;
|
||||||
|
case "INLINE_CODE":
|
||||||
|
return item.code;
|
||||||
|
case "EMPHASIS":
|
||||||
|
case "STRONG":
|
||||||
|
return inlinePlainText(item.children);
|
||||||
|
case "LINK":
|
||||||
|
return item.label;
|
||||||
|
case "STATUS":
|
||||||
|
return item.label;
|
||||||
|
default:
|
||||||
|
return assertNever(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import type { components } from "../../contracts/studio/generated.ts";
|
||||||
|
import type { SupportsEvidenceKey } from "../public-render-content.ts";
|
||||||
|
import {
|
||||||
|
ContentFormatError,
|
||||||
|
parseCaseContent,
|
||||||
|
} from "./parse-case-content.ts";
|
||||||
|
|
||||||
|
type WorkingCopyInput = components["schemas"]["WorkingCopyInput"];
|
||||||
|
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
||||||
|
type PublicRenderModel = components["schemas"]["PublicRenderModel"];
|
||||||
|
type RenderContext = components["schemas"]["RenderContext"];
|
||||||
|
|
||||||
|
export type ProjectionContext =
|
||||||
|
| RenderContext
|
||||||
|
| {
|
||||||
|
mode: "PREVIEW" | "PUBLIC";
|
||||||
|
publishedAt: string | null;
|
||||||
|
generatedAt?: string;
|
||||||
|
dependencyRevision?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function fail(detail: string): never {
|
||||||
|
throw new ContentFormatError([{ line: 1, column: 1, detail }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function catalogEntry(
|
||||||
|
catalog: ReadonlyArray<CatalogEntry>,
|
||||||
|
id: string | null,
|
||||||
|
type: CatalogEntry["type"],
|
||||||
|
required: boolean,
|
||||||
|
): CatalogEntry | null {
|
||||||
|
if (!id) {
|
||||||
|
if (required) fail(`${type} catalog entry is required`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = catalog.find((candidate) => candidate.id === id);
|
||||||
|
if (!entry || entry.type !== type) {
|
||||||
|
fail(`${type} catalog entry not found: ${id}`);
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayTarget(
|
||||||
|
entry: CatalogEntry,
|
||||||
|
): components["schemas"]["DisplayTarget"] {
|
||||||
|
return {
|
||||||
|
id: entry.id,
|
||||||
|
label: entry.label,
|
||||||
|
publicPath: entry.publicPath ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderContext(
|
||||||
|
context: ProjectionContext,
|
||||||
|
catalog: ReadonlyArray<CatalogEntry>,
|
||||||
|
): RenderContext {
|
||||||
|
if (!("mode" in context)) return { ...context };
|
||||||
|
|
||||||
|
return {
|
||||||
|
generatedAt:
|
||||||
|
context.generatedAt ??
|
||||||
|
context.publishedAt ??
|
||||||
|
"1970-01-01T00:00:00.000Z",
|
||||||
|
dependencyRevision:
|
||||||
|
context.dependencyRevision ??
|
||||||
|
catalog
|
||||||
|
.map((entry) => entry.dependencyRevision)
|
||||||
|
.sort()
|
||||||
|
.at(-1) ??
|
||||||
|
"preview",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicPath(input: WorkingCopyInput) {
|
||||||
|
const prefix =
|
||||||
|
input.kind === "CASE"
|
||||||
|
? "cases"
|
||||||
|
: input.kind === "REFERENCE"
|
||||||
|
? "references"
|
||||||
|
: "questions";
|
||||||
|
return `/${prefix}/${input.slug}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvedRelations(
|
||||||
|
input: WorkingCopyInput,
|
||||||
|
catalog: ReadonlyArray<CatalogEntry>,
|
||||||
|
): components["schemas"]["ResolvedRelation"][] {
|
||||||
|
return [...input.relations]
|
||||||
|
.sort((left, right) => left.order - right.order)
|
||||||
|
.map((relation, index) => {
|
||||||
|
if (!relation.targetId) fail("RELATION catalog entry is required");
|
||||||
|
const target = catalogEntry(
|
||||||
|
catalog,
|
||||||
|
relation.targetId,
|
||||||
|
"RELATION",
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
if (!target?.kind) fail(`RELATION kind is required: ${relation.targetId}`);
|
||||||
|
return {
|
||||||
|
id: relation.id ?? `relation-${index + 1}`,
|
||||||
|
targetId: target.id,
|
||||||
|
targetKind: target.kind,
|
||||||
|
title: target.label,
|
||||||
|
publicPath: target.publicPath ?? null,
|
||||||
|
reason: relation.reason,
|
||||||
|
order: relation.order,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ordered<T extends { order: number }>(items: ReadonlyArray<T>): T[] {
|
||||||
|
return items
|
||||||
|
.map((item) => ({ ...item }))
|
||||||
|
.sort((left, right) => left.order - right.order);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectWorkingCopy(
|
||||||
|
input: WorkingCopyInput,
|
||||||
|
catalog: ReadonlyArray<CatalogEntry>,
|
||||||
|
context: ProjectionContext,
|
||||||
|
supportsEvidenceKey: SupportsEvidenceKey,
|
||||||
|
): PublicRenderModel {
|
||||||
|
const topic = catalogEntry(catalog, input.topicId, "TOPIC", true);
|
||||||
|
const project = catalogEntry(catalog, input.projectId, "PROJECT", false);
|
||||||
|
if (!topic) fail("TOPIC catalog entry is required");
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
kind: input.kind,
|
||||||
|
slug: input.slug,
|
||||||
|
title: input.title,
|
||||||
|
summary: input.summary,
|
||||||
|
publicPath: publicPath(input),
|
||||||
|
topic: displayTarget(topic),
|
||||||
|
project: project ? displayTarget(project) : null,
|
||||||
|
relations: resolvedRelations(input, catalog),
|
||||||
|
renderContext: renderContext(context, catalog),
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (input.kind) {
|
||||||
|
case "CASE": {
|
||||||
|
const bodyBlocks = parseCaseContent(input.bodyMarkdown);
|
||||||
|
for (const block of bodyBlocks) {
|
||||||
|
if (block.type !== "EVIDENCE_FIGURE") continue;
|
||||||
|
if (!supportsEvidenceKey(block.key)) {
|
||||||
|
fail(`supported local evidence key not found: ${block.key}`);
|
||||||
|
}
|
||||||
|
const evidence = catalog.find(
|
||||||
|
(entry) =>
|
||||||
|
entry.type === "EVIDENCE" &&
|
||||||
|
(entry.id === block.key ||
|
||||||
|
entry.label === block.key ||
|
||||||
|
entry.publicPath === `/media/${block.key}.svg`),
|
||||||
|
);
|
||||||
|
if (!evidence) fail(`EVIDENCE catalog entry not found: ${block.key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
kind: "CASE",
|
||||||
|
problem: input.problem,
|
||||||
|
conclusion: input.conclusion,
|
||||||
|
environment: input.environment,
|
||||||
|
reproduction: input.reproduction,
|
||||||
|
lastVerifiedOn: input.lastVerifiedOn ?? "",
|
||||||
|
bodyBlocks,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case "REFERENCE":
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
kind: "REFERENCE",
|
||||||
|
purpose: input.purpose,
|
||||||
|
rules: ordered(input.rules),
|
||||||
|
applyWhen: ordered(input.applyWhen),
|
||||||
|
exceptions: ordered(input.exceptions),
|
||||||
|
examples: ordered(input.examples),
|
||||||
|
verifiedOn: input.verifiedOn ?? "",
|
||||||
|
};
|
||||||
|
|
||||||
|
case "QUESTION": {
|
||||||
|
if (!input.questionStatus) fail("Question status is required");
|
||||||
|
let resolution:
|
||||||
|
| components["schemas"]["ResolvedQuestionResolution"]
|
||||||
|
| null = null;
|
||||||
|
if (input.resolution) {
|
||||||
|
const evidence = catalogEntry(
|
||||||
|
catalog,
|
||||||
|
input.resolution.evidenceTargetId,
|
||||||
|
"EVIDENCE",
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
if (!evidence) fail("EVIDENCE catalog entry is required");
|
||||||
|
resolution = {
|
||||||
|
summary: input.resolution.summary,
|
||||||
|
evidenceTarget: displayTarget(evidence),
|
||||||
|
linkLabel: input.resolution.linkLabel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
kind: "QUESTION",
|
||||||
|
status: input.questionStatus,
|
||||||
|
facts: ordered(input.facts),
|
||||||
|
assumptions: ordered(input.assumptions),
|
||||||
|
unknowns: ordered(input.unknowns),
|
||||||
|
constraints: ordered(input.constraints),
|
||||||
|
options: ordered(input.options),
|
||||||
|
nextValidation: input.nextValidation,
|
||||||
|
resolution,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import type { components } from "../../contracts/studio/generated.ts";
|
||||||
|
|
||||||
|
type Inline = components["schemas"]["Inline"];
|
||||||
|
type CaseRenderBlock = components["schemas"]["CaseRenderBlock"];
|
||||||
|
|
||||||
|
function assertNever(value: never): never {
|
||||||
|
throw new Error(`Unsupported render value: ${JSON.stringify(value)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function quoteAttribute(value: string): string {
|
||||||
|
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeText(value: string): string {
|
||||||
|
return value.replace(/([\\`*_[\]|<>])/g, "\\$1");
|
||||||
|
}
|
||||||
|
|
||||||
|
function longestBacktickRun(value: string): number {
|
||||||
|
let longest = 0;
|
||||||
|
for (const match of value.matchAll(/`+/g)) {
|
||||||
|
longest = Math.max(longest, match[0].length);
|
||||||
|
}
|
||||||
|
return longest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeInlineCode(code: string): string {
|
||||||
|
const delimiter = "`".repeat(longestBacktickRun(code) + 1);
|
||||||
|
if (/^ +$/u.test(code)) return `${delimiter}${code}${delimiter}`;
|
||||||
|
|
||||||
|
const needsPadding =
|
||||||
|
code.startsWith("`") ||
|
||||||
|
code.endsWith("`") ||
|
||||||
|
code.startsWith(" ") ||
|
||||||
|
code.endsWith(" ");
|
||||||
|
const padding = needsPadding ? " " : "";
|
||||||
|
return `${delimiter}${padding}${code}${padding}${delimiter}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeInline(inline: Inline): string {
|
||||||
|
switch (inline.type) {
|
||||||
|
case "TEXT":
|
||||||
|
return escapeText(inline.text);
|
||||||
|
case "INLINE_CODE":
|
||||||
|
return serializeInlineCode(inline.code);
|
||||||
|
case "EMPHASIS":
|
||||||
|
return `*${serializeInlines(inline.children)}*`;
|
||||||
|
case "STRONG":
|
||||||
|
return `**${serializeInlines(inline.children)}**`;
|
||||||
|
case "LINK":
|
||||||
|
return `[${escapeText(inline.label)}](${inline.href})`;
|
||||||
|
case "STATUS":
|
||||||
|
return `:status[${escapeText(inline.label)}]{tone=${quoteAttribute(inline.tone)}}`;
|
||||||
|
default:
|
||||||
|
return assertNever(inline);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeInlines(inlines: ReadonlyArray<Inline>): string {
|
||||||
|
return inlines.map(serializeInline).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeBlockLeadingSyntax(value: string): string {
|
||||||
|
return value
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => {
|
||||||
|
const match = /^( {0,3})(.*)$/u.exec(line);
|
||||||
|
if (!match) return line;
|
||||||
|
const [, indentation, body] = match;
|
||||||
|
|
||||||
|
if (/^\d{1,9}[.)](?:[ \t]|$)/u.test(body)) {
|
||||||
|
return `${indentation}${body.replace(/^(\d{1,9})([.)])/u, "$1\\$2")}`;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
/^(?:#{1,6}(?:[ \t]|$)|[-+>](?:[ \t]|$)|-{3,}[ \t]*$|~{3,}|:::[a-z])/iu.test(
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return `${indentation}\\${body}`;
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeParagraphInlines(inlines: ReadonlyArray<Inline>): string {
|
||||||
|
return escapeBlockLeadingSyntax(serializeInlines(inlines));
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeTablePipes(value: string): string {
|
||||||
|
let escaped = "";
|
||||||
|
let precedingBackslashes = 0;
|
||||||
|
for (const character of value) {
|
||||||
|
if (character === "|") {
|
||||||
|
if (precedingBackslashes % 2 === 0) escaped += "\\";
|
||||||
|
escaped += character;
|
||||||
|
precedingBackslashes = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
escaped += character;
|
||||||
|
if (character === "\\") precedingBackslashes += 1;
|
||||||
|
else precedingBackslashes = 0;
|
||||||
|
}
|
||||||
|
return escaped;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeTable(
|
||||||
|
block: components["schemas"]["DataTableBlock"],
|
||||||
|
): string {
|
||||||
|
const header = block.columns
|
||||||
|
.map((column) => escapeTablePipes(escapeText(column.label)))
|
||||||
|
.join(" | ");
|
||||||
|
const alignment = block.columns
|
||||||
|
.map((column) => {
|
||||||
|
switch (column.alignment) {
|
||||||
|
case "LEFT":
|
||||||
|
return "---";
|
||||||
|
case "CENTER":
|
||||||
|
return ":---:";
|
||||||
|
case "RIGHT":
|
||||||
|
return "---:";
|
||||||
|
default:
|
||||||
|
return assertNever(column.alignment);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.join(" | ");
|
||||||
|
const rows = block.rows.map((row) => {
|
||||||
|
const cells = block.columns.map((column) => {
|
||||||
|
const cell = row.cells.find(
|
||||||
|
(candidate) => candidate.columnId === column.id,
|
||||||
|
);
|
||||||
|
if (!cell) throw new Error(`Missing table cell for column: ${column.id}`);
|
||||||
|
return escapeTablePipes(serializeInlines(cell.content));
|
||||||
|
});
|
||||||
|
return `| ${cells.join(" | ")} |`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
`:::table id=${quoteAttribute(block.id)} caption=${quoteAttribute(block.caption)} rowHeaderColumn=${quoteAttribute(block.rowHeaderColumn === null ? "none" : String(block.rowHeaderColumn))}`,
|
||||||
|
`| ${header} |`,
|
||||||
|
`| ${alignment} |`,
|
||||||
|
...rows,
|
||||||
|
":::",
|
||||||
|
];
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeCode(block: components["schemas"]["CodeBlock"]): string {
|
||||||
|
const fence = "`".repeat(Math.max(3, longestBacktickRun(block.code) + 1));
|
||||||
|
const language = block.language ?? "";
|
||||||
|
const label = block.label ? ` label=${quoteAttribute(block.label)}` : "";
|
||||||
|
return `${fence}${language}${label}\n${block.code}\n${fence}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeBlock(block: CaseRenderBlock): string {
|
||||||
|
switch (block.type) {
|
||||||
|
case "HEADING":
|
||||||
|
return `${"#".repeat(block.level)} ${serializeInlines(block.content)} {#${block.id}}`;
|
||||||
|
case "PARAGRAPH":
|
||||||
|
return serializeParagraphInlines(block.content);
|
||||||
|
case "BLOCKQUOTE":
|
||||||
|
return `> ${serializeParagraphInlines(block.content)}`;
|
||||||
|
case "UNORDERED_LIST":
|
||||||
|
return block.items
|
||||||
|
.map((item) => `- ${serializeParagraphInlines(item.content)}`)
|
||||||
|
.join("\n");
|
||||||
|
case "ORDERED_LIST":
|
||||||
|
return block.items
|
||||||
|
.map(
|
||||||
|
(item, index) =>
|
||||||
|
`${index + 1}. ${serializeParagraphInlines(item.content)}`,
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
case "CODE_BLOCK":
|
||||||
|
return serializeCode(block);
|
||||||
|
case "DATA_TABLE":
|
||||||
|
return serializeTable(block);
|
||||||
|
case "CALLOUT":
|
||||||
|
return [
|
||||||
|
`:::callout tone=${quoteAttribute(block.tone)} label=${quoteAttribute(block.label)}`,
|
||||||
|
serializeParagraphInlines(block.content),
|
||||||
|
":::",
|
||||||
|
].join("\n");
|
||||||
|
case "EVIDENCE_FIGURE":
|
||||||
|
return [
|
||||||
|
`:::evidence key=${quoteAttribute(block.key)} alt=${quoteAttribute(block.alt)} caption=${quoteAttribute(block.caption)} zoom=${quoteAttribute(String(block.zoom))}`,
|
||||||
|
":::",
|
||||||
|
].join("\n");
|
||||||
|
default:
|
||||||
|
return assertNever(block);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeCaseContent(
|
||||||
|
blocks: ReadonlyArray<CaseRenderBlock>,
|
||||||
|
): string {
|
||||||
|
return blocks.map(serializeBlock).join("\n\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export type EvidenceAsset = Readonly<{
|
||||||
|
src: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
triggerLabel: string;
|
||||||
|
dialogLabel: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type ResolveEvidenceAsset = (key: string) => EvidenceAsset;
|
||||||
|
|
||||||
|
export type SupportsEvidenceKey = (key: string) => boolean;
|
||||||
|
|
||||||
|
export type ResolvePublishedLabel = (publicPath: string) => string | undefined;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { components } from "../../../contracts/studio/generated.ts";
|
||||||
|
import { InlineRenderer } from "./inline-renderer.tsx";
|
||||||
|
|
||||||
|
type CalloutBlock = components["schemas"]["CalloutBlock"];
|
||||||
|
|
||||||
|
export function Callout({ block }: { block: CalloutBlock }) {
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className={`callout callout--${block.tone}`}
|
||||||
|
aria-label={block.label}
|
||||||
|
>
|
||||||
|
<p className="callout-label">{block.label}</p>
|
||||||
|
<p>
|
||||||
|
<InlineRenderer content={block.content} />
|
||||||
|
</p>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { createElement, Fragment, type ReactNode } from "react";
|
||||||
|
|
||||||
|
import type { components } from "../../../contracts/studio/generated.ts";
|
||||||
|
import type { ResolveEvidenceAsset } from "../../../domain/public-render-content.ts";
|
||||||
|
import { inlinePlainText } from "../../../domain/content-format/inline-plain-text.ts";
|
||||||
|
import { Callout } from "./callout.tsx";
|
||||||
|
import { CodeBlock } from "./code-block.tsx";
|
||||||
|
import { DataTable } from "./data-table.tsx";
|
||||||
|
import { PublicEvidenceFigure } from "./evidence-figure.tsx";
|
||||||
|
import { InlineRenderer } from "./inline-renderer.tsx";
|
||||||
|
|
||||||
|
type CaseRenderBlock = components["schemas"]["CaseRenderBlock"];
|
||||||
|
type HeadingBlock = components["schemas"]["HeadingBlock"];
|
||||||
|
|
||||||
|
function assertNever(value: never): never {
|
||||||
|
throw new Error(`Unsupported Case render block: ${JSON.stringify(value)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Heading({ block }: { block: HeadingBlock }) {
|
||||||
|
const level = Math.min(4, Math.max(2, block.level));
|
||||||
|
return createElement(
|
||||||
|
`h${level}`,
|
||||||
|
{ id: block.id },
|
||||||
|
<a
|
||||||
|
className="heading-anchor"
|
||||||
|
href={`#${block.id}`}
|
||||||
|
aria-label={`${inlinePlainText(block.content)} 바로가기`}
|
||||||
|
>
|
||||||
|
<InlineRenderer content={block.content} />
|
||||||
|
<span aria-hidden="true">#</span>
|
||||||
|
</a>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBlock(
|
||||||
|
block: CaseRenderBlock,
|
||||||
|
key: string | number,
|
||||||
|
resolveEvidenceAsset: ResolveEvidenceAsset,
|
||||||
|
): ReactNode {
|
||||||
|
switch (block.type) {
|
||||||
|
case "HEADING":
|
||||||
|
return <Heading key={key} block={block} />;
|
||||||
|
case "PARAGRAPH":
|
||||||
|
return (
|
||||||
|
<p key={key}>
|
||||||
|
<InlineRenderer content={block.content} />
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
case "BLOCKQUOTE":
|
||||||
|
return (
|
||||||
|
<blockquote key={key}>
|
||||||
|
<InlineRenderer content={block.content} />
|
||||||
|
</blockquote>
|
||||||
|
);
|
||||||
|
case "UNORDERED_LIST":
|
||||||
|
return (
|
||||||
|
<ul key={key}>
|
||||||
|
{block.items.map((item) => (
|
||||||
|
<li key={item.id}>
|
||||||
|
<InlineRenderer content={item.content} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
case "ORDERED_LIST":
|
||||||
|
return (
|
||||||
|
<ol key={key}>
|
||||||
|
{block.items.map((item) => (
|
||||||
|
<li key={item.id}>
|
||||||
|
<InlineRenderer content={item.content} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
case "CODE_BLOCK":
|
||||||
|
return (
|
||||||
|
<CodeBlock
|
||||||
|
key={key}
|
||||||
|
language={(block.language ?? "text").toUpperCase()}
|
||||||
|
label={block.label ?? "코드"}
|
||||||
|
code={block.code}
|
||||||
|
contentRole={
|
||||||
|
block.label === "실패한 목록 조회" ? "재현 쿼리" : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "DATA_TABLE":
|
||||||
|
return <DataTable key={key} block={block} />;
|
||||||
|
case "CALLOUT":
|
||||||
|
return <Callout key={key} block={block} />;
|
||||||
|
case "EVIDENCE_FIGURE":
|
||||||
|
return (
|
||||||
|
<PublicEvidenceFigure
|
||||||
|
key={key}
|
||||||
|
evidenceKey={block.key}
|
||||||
|
alt={block.alt}
|
||||||
|
caption={block.caption}
|
||||||
|
zoom={block.zoom}
|
||||||
|
resolveEvidenceAsset={resolveEvidenceAsset}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return assertNever(block);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type OwnedSection = {
|
||||||
|
heading: HeadingBlock;
|
||||||
|
blocks: CaseRenderBlock[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CaseBodyRenderer({
|
||||||
|
blocks,
|
||||||
|
resolveEvidenceAsset,
|
||||||
|
}: {
|
||||||
|
blocks: ReadonlyArray<CaseRenderBlock>;
|
||||||
|
resolveEvidenceAsset: ResolveEvidenceAsset;
|
||||||
|
}) {
|
||||||
|
const rootBlocks: CaseRenderBlock[] = [];
|
||||||
|
const sections: OwnedSection[] = [];
|
||||||
|
let currentSection: OwnedSection | null = null;
|
||||||
|
|
||||||
|
for (const block of blocks) {
|
||||||
|
if (block.type === "HEADING") {
|
||||||
|
currentSection = { heading: block, blocks: [] };
|
||||||
|
sections.push(currentSection);
|
||||||
|
} else if (currentSection) {
|
||||||
|
currentSection.blocks.push(block);
|
||||||
|
} else {
|
||||||
|
rootBlocks.push(block);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{rootBlocks.length > 0 ? (
|
||||||
|
<Fragment>
|
||||||
|
{rootBlocks.map((block, index) =>
|
||||||
|
renderBlock(block, `root-${index}`, resolveEvidenceAsset),
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
) : null}
|
||||||
|
{sections.map((section) => (
|
||||||
|
<section key={section.heading.id} aria-labelledby={section.heading.id}>
|
||||||
|
<Heading block={section.heading} />
|
||||||
|
{section.blocks.map((block, index) =>
|
||||||
|
renderBlock(
|
||||||
|
block,
|
||||||
|
`${section.heading.id}-${index}`,
|
||||||
|
resolveEvidenceAsset,
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
type CodeBlockProps = {
|
||||||
|
language: string;
|
||||||
|
label: string;
|
||||||
|
code: string;
|
||||||
|
contentRole?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CodeBlock({
|
||||||
|
language,
|
||||||
|
label,
|
||||||
|
code,
|
||||||
|
contentRole,
|
||||||
|
}: CodeBlockProps) {
|
||||||
|
const [copyLabel, setCopyLabel] = useState("복사");
|
||||||
|
const [copyStatus, setCopyStatus] = useState("");
|
||||||
|
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (resetTimer.current) clearTimeout(resetTimer.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function copy() {
|
||||||
|
if (resetTimer.current) clearTimeout(resetTimer.current);
|
||||||
|
setCopyStatus("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(code);
|
||||||
|
setCopyLabel("복사됨");
|
||||||
|
setCopyStatus("코드를 클립보드에 복사했습니다.");
|
||||||
|
} catch {
|
||||||
|
setCopyLabel("복사 실패");
|
||||||
|
setCopyStatus("코드를 클립보드에 복사하지 못했습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
resetTimer.current = setTimeout(() => {
|
||||||
|
setCopyLabel("복사");
|
||||||
|
setCopyStatus("");
|
||||||
|
}, 2_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<figure className="code-block" data-content-role={contentRole}>
|
||||||
|
<figcaption>
|
||||||
|
<span>{language}</span>
|
||||||
|
<span>{label}</span>
|
||||||
|
<button type="button" aria-label="코드 복사" onClick={copy}>
|
||||||
|
{copyLabel}
|
||||||
|
</button>
|
||||||
|
</figcaption>
|
||||||
|
<pre role="region" aria-label={`${label} 코드`} tabIndex={0}>
|
||||||
|
<code>{code}</code>
|
||||||
|
</pre>
|
||||||
|
<span className="visually-hidden" aria-live="polite">
|
||||||
|
{copyStatus}
|
||||||
|
</span>
|
||||||
|
</figure>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type { components } from "../../../contracts/studio/generated.ts";
|
||||||
|
import { InlineRenderer } from "./inline-renderer.tsx";
|
||||||
|
|
||||||
|
type DataTableBlock = components["schemas"]["DataTableBlock"];
|
||||||
|
|
||||||
|
export function DataTable({ block }: { block: DataTableBlock }) {
|
||||||
|
const rowHeaderIndex =
|
||||||
|
block.rowHeaderColumn === null ? null : block.rowHeaderColumn - 1;
|
||||||
|
const isFetchObservation = block.id === "fetch-strategy-observation";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="data-table-wrap"
|
||||||
|
role="region"
|
||||||
|
aria-label={
|
||||||
|
isFetchObservation ? "Fetch 전략 측정표" : `${block.caption} 표`
|
||||||
|
}
|
||||||
|
data-content-role={isFetchObservation ? "반환 손실" : undefined}
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<table>
|
||||||
|
<caption>{block.caption}</caption>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{block.columns.map((column) => (
|
||||||
|
<th key={column.id} id={column.id} scope="col">
|
||||||
|
{column.label}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{block.rows.map((row) => (
|
||||||
|
<tr key={row.id}>
|
||||||
|
{row.cells.map((cell, cellIndex) => {
|
||||||
|
if (cellIndex === rowHeaderIndex) {
|
||||||
|
return (
|
||||||
|
<th key={cell.columnId} id={row.id} scope="row">
|
||||||
|
<InlineRenderer content={cell.content} />
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers =
|
||||||
|
rowHeaderIndex === null
|
||||||
|
? cell.columnId
|
||||||
|
: `${row.id} ${cell.columnId}`;
|
||||||
|
return (
|
||||||
|
<td key={cell.columnId} headers={headers}>
|
||||||
|
<InlineRenderer content={cell.content} />
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
type HeadingItem = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DocumentTocProps = {
|
||||||
|
headings: ReadonlyArray<HeadingItem>;
|
||||||
|
variant: "desktop" | "mobile";
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DocumentToc({ headings, variant }: DocumentTocProps) {
|
||||||
|
const [currentId, setCurrentId] = useState(headings[0]?.id ?? "");
|
||||||
|
const detailsRef = useRef<HTMLDetailsElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const elements = headings
|
||||||
|
.map((heading) => document.getElementById(heading.id))
|
||||||
|
.filter((element): element is HTMLElement => Boolean(element));
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
const visible = entries
|
||||||
|
.filter((entry) => entry.isIntersecting)
|
||||||
|
.sort(
|
||||||
|
(left, right) =>
|
||||||
|
left.boundingClientRect.top - right.boundingClientRect.top,
|
||||||
|
);
|
||||||
|
if (visible[0]) setCurrentId(visible[0].target.id);
|
||||||
|
},
|
||||||
|
{ rootMargin: "-12% 0px -72% 0px", threshold: [0, 1] },
|
||||||
|
);
|
||||||
|
|
||||||
|
elements.forEach((element) => observer.observe(element));
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [headings]);
|
||||||
|
|
||||||
|
function select(id: string) {
|
||||||
|
setCurrentId(id);
|
||||||
|
if (detailsRef.current) detailsRef.current.open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant === "mobile") {
|
||||||
|
const current =
|
||||||
|
headings.find((heading) => heading.id === currentId) ?? headings[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shell mobile-toc-wrap">
|
||||||
|
<details className="mobile-toc" ref={detailsRef}>
|
||||||
|
<summary>목차 · {current?.label}</summary>
|
||||||
|
<nav aria-label="문서 목차">
|
||||||
|
{headings.map((heading) => (
|
||||||
|
<a
|
||||||
|
href={`#${heading.id}`}
|
||||||
|
key={heading.id}
|
||||||
|
aria-current={
|
||||||
|
heading.id === currentId ? "location" : undefined
|
||||||
|
}
|
||||||
|
onClick={() => select(heading.id)}
|
||||||
|
>
|
||||||
|
{heading.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="document-toc" aria-label="문서 목차">
|
||||||
|
<p>이 글에서</p>
|
||||||
|
<nav>
|
||||||
|
{headings.map((heading) => (
|
||||||
|
<a
|
||||||
|
href={`#${heading.id}`}
|
||||||
|
key={heading.id}
|
||||||
|
aria-current={heading.id === currentId ? "location" : undefined}
|
||||||
|
onClick={() => select(heading.id)}
|
||||||
|
>
|
||||||
|
{heading.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { useId, useRef } from "react";
|
||||||
|
|
||||||
|
import type { ResolveEvidenceAsset } from "../../../domain/public-render-content.ts";
|
||||||
|
|
||||||
|
type PublicEvidenceFigureProps = {
|
||||||
|
evidenceKey: string;
|
||||||
|
alt: string;
|
||||||
|
caption: string;
|
||||||
|
zoom: boolean;
|
||||||
|
resolveEvidenceAsset: ResolveEvidenceAsset;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PublicEvidenceFigure({
|
||||||
|
evidenceKey,
|
||||||
|
alt,
|
||||||
|
caption,
|
||||||
|
zoom,
|
||||||
|
resolveEvidenceAsset,
|
||||||
|
}: PublicEvidenceFigureProps) {
|
||||||
|
const asset = resolveEvidenceAsset(evidenceKey);
|
||||||
|
const descriptionId = useId();
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
dialogRef.current?.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
dialogRef.current?.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!zoom) {
|
||||||
|
return (
|
||||||
|
<figure className="evidence-figure">
|
||||||
|
<img
|
||||||
|
src={asset.src}
|
||||||
|
width={asset.width}
|
||||||
|
height={asset.height}
|
||||||
|
alt={alt}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<figcaption>{caption}</figcaption>
|
||||||
|
</figure>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<figure className="evidence-figure">
|
||||||
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
|
className="evidence-image-button"
|
||||||
|
type="button"
|
||||||
|
aria-label={asset.triggerLabel}
|
||||||
|
aria-describedby={descriptionId}
|
||||||
|
onClick={open}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={asset.src}
|
||||||
|
width={asset.width}
|
||||||
|
height={asset.height}
|
||||||
|
alt={alt}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<span>크게 보기</span>
|
||||||
|
<span className="visually-hidden" id={descriptionId}>
|
||||||
|
{alt}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<figcaption>{caption}</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<dialog
|
||||||
|
ref={dialogRef}
|
||||||
|
className="figure-dialog"
|
||||||
|
aria-label={asset.dialogLabel}
|
||||||
|
onClose={() => triggerRef.current?.focus()}
|
||||||
|
onClick={(event) => {
|
||||||
|
if (event.target === event.currentTarget) close();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<button type="button" className="dialog-close" onClick={close}>
|
||||||
|
닫기
|
||||||
|
</button>
|
||||||
|
<img
|
||||||
|
src={asset.src}
|
||||||
|
width={asset.width}
|
||||||
|
height={asset.height}
|
||||||
|
alt={alt}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<p>{caption}</p>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
import type { ResolveEvidenceAsset } from "../../../domain/public-render-content.ts";
|
||||||
|
import { PublicEvidenceFigure } from "./evidence-figure.tsx";
|
||||||
|
|
||||||
|
const diagramAlt =
|
||||||
|
"Fetch Join은 전체 조인 결과를 읽은 뒤 메모리에서 20개를 고르고, Batch Fetch는 부모 20개를 먼저 고른 뒤 해당 ID의 컬렉션만 조회한다.";
|
||||||
|
|
||||||
|
export function EvidenceFigure({
|
||||||
|
resolveEvidenceAsset,
|
||||||
|
}: {
|
||||||
|
resolveEvidenceAsset: ResolveEvidenceAsset;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<PublicEvidenceFigure
|
||||||
|
evidenceKey="fetch-strategy-boundary"
|
||||||
|
alt={diagramAlt}
|
||||||
|
caption="페이징이 적용된 지점이 부모 조회 앞으로 이동한다."
|
||||||
|
zoom
|
||||||
|
resolveEvidenceAsset={resolveEvidenceAsset}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { Fragment } from "react";
|
||||||
|
|
||||||
|
import type { components } from "../../../contracts/studio/generated.ts";
|
||||||
|
|
||||||
|
type Inline = components["schemas"]["Inline"];
|
||||||
|
|
||||||
|
function assertNever(value: never): never {
|
||||||
|
throw new Error(`Unsupported inline value: ${JSON.stringify(value)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInline(inline: Inline, key: number) {
|
||||||
|
switch (inline.type) {
|
||||||
|
case "TEXT":
|
||||||
|
return <Fragment key={key}>{inline.text}</Fragment>;
|
||||||
|
case "INLINE_CODE":
|
||||||
|
return <code key={key}>{inline.code}</code>;
|
||||||
|
case "EMPHASIS":
|
||||||
|
return (
|
||||||
|
<em key={key}>
|
||||||
|
<InlineRenderer content={inline.children} />
|
||||||
|
</em>
|
||||||
|
);
|
||||||
|
case "STRONG":
|
||||||
|
return (
|
||||||
|
<strong key={key}>
|
||||||
|
<InlineRenderer content={inline.children} />
|
||||||
|
</strong>
|
||||||
|
);
|
||||||
|
case "LINK":
|
||||||
|
return (
|
||||||
|
<a key={key} href={inline.href}>
|
||||||
|
{inline.label}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
case "STATUS":
|
||||||
|
return (
|
||||||
|
<span key={key} className={`status status--${inline.tone}`}>
|
||||||
|
{inline.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return assertNever(inline);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InlineRenderer({
|
||||||
|
content,
|
||||||
|
}: {
|
||||||
|
content: ReadonlyArray<Inline>;
|
||||||
|
}) {
|
||||||
|
return <>{content.map(renderInline)}</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
export type PublicRelation = Readonly<{
|
||||||
|
reason: string;
|
||||||
|
title: string;
|
||||||
|
path: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export function PublicDocumentRelations({
|
||||||
|
relations,
|
||||||
|
kicker = "Relations",
|
||||||
|
title = "이 기록과 연결된 맥락",
|
||||||
|
}: {
|
||||||
|
relations: ReadonlyArray<PublicRelation>;
|
||||||
|
kicker?: string;
|
||||||
|
title?: string;
|
||||||
|
}) {
|
||||||
|
if (relations.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="document-relations" aria-labelledby="relations-title">
|
||||||
|
<p className="section-kicker">{kicker}</p>
|
||||||
|
<h2 id="relations-title">{title}</h2>
|
||||||
|
<ul>
|
||||||
|
{relations.map((relation) => (
|
||||||
|
<li key={relation.path}>
|
||||||
|
<Link to={relation.path}>
|
||||||
|
<span>{relation.reason}</span>
|
||||||
|
<strong>{relation.title}</strong>
|
||||||
|
<span aria-hidden="true">↗</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,487 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
import type { components } from "../../../contracts/studio/generated.ts";
|
||||||
|
import { inlinePlainText } from "../../../domain/content-format/inline-plain-text.ts";
|
||||||
|
import type {
|
||||||
|
ResolveEvidenceAsset,
|
||||||
|
ResolvePublishedLabel,
|
||||||
|
} from "../../../domain/public-render-content.ts";
|
||||||
|
import { CaseBodyRenderer } from "./case-body-renderer.tsx";
|
||||||
|
import { DocumentToc } from "./document-toc.tsx";
|
||||||
|
import {
|
||||||
|
PublicDocumentRelations,
|
||||||
|
type PublicRelation,
|
||||||
|
} from "./public-document-relations.tsx";
|
||||||
|
|
||||||
|
type PublicRenderModel = components["schemas"]["PublicRenderModel"];
|
||||||
|
type PublicRenderModelBase = components["schemas"]["PublicRenderModelBase"];
|
||||||
|
type ResolvedRelation = components["schemas"]["ResolvedRelation"];
|
||||||
|
type CasePublicRenderModel = components["schemas"]["CasePublicRenderModel"];
|
||||||
|
type ReferencePublicRenderModel =
|
||||||
|
components["schemas"]["ReferencePublicRenderModel"];
|
||||||
|
type QuestionPublicRenderModel =
|
||||||
|
components["schemas"]["QuestionPublicRenderModel"];
|
||||||
|
|
||||||
|
type RenderDependencies = {
|
||||||
|
resolveEvidenceAsset: ResolveEvidenceAsset;
|
||||||
|
resolvePublishedLabel: ResolvePublishedLabel;
|
||||||
|
};
|
||||||
|
|
||||||
|
const kindLabels = {
|
||||||
|
CASE: "Case",
|
||||||
|
REFERENCE: "Reference",
|
||||||
|
QUESTION: "Open Question",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function assertNever(value: never): never {
|
||||||
|
throw new Error(`Unsupported Public render model: ${JSON.stringify(value)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayDate(value: string) {
|
||||||
|
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(value);
|
||||||
|
return match ? `${match[1]}.${match[2]}.${match[3]}` : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function publishedLabel(
|
||||||
|
model: PublicRenderModelBase,
|
||||||
|
resolvePublishedLabel: ResolvePublishedLabel,
|
||||||
|
) {
|
||||||
|
return resolvePublishedLabel(model.publicPath) ?? "게시 전";
|
||||||
|
}
|
||||||
|
|
||||||
|
function explorePath(kind: PublicRenderModelBase["kind"]) {
|
||||||
|
if (kind === "CASE") return "/explore/cases";
|
||||||
|
if (kind === "REFERENCE") return "/explore/references";
|
||||||
|
return "/explore/questions";
|
||||||
|
}
|
||||||
|
|
||||||
|
function TargetLink({
|
||||||
|
target,
|
||||||
|
}: {
|
||||||
|
target: components["schemas"]["DisplayTarget"];
|
||||||
|
}) {
|
||||||
|
return target.publicPath ? (
|
||||||
|
<Link to={target.publicPath}>{target.label}</Link>
|
||||||
|
) : (
|
||||||
|
<span>{target.label}</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModelDocumentHeader({
|
||||||
|
model,
|
||||||
|
resolvePublishedLabel,
|
||||||
|
}: {
|
||||||
|
model: PublicRenderModelBase;
|
||||||
|
resolvePublishedLabel: ResolvePublishedLabel;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<header className="public-document-header">
|
||||||
|
<nav aria-label="문서 경로">
|
||||||
|
<Link to={explorePath(model.kind)}>{kindLabels[model.kind]}</Link>
|
||||||
|
<span aria-hidden="true">/</span>
|
||||||
|
<TargetLink target={model.topic} />
|
||||||
|
{model.project ? (
|
||||||
|
<>
|
||||||
|
<span aria-hidden="true">/</span>
|
||||||
|
<TargetLink target={model.project} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</nav>
|
||||||
|
<h1>{model.title}</h1>
|
||||||
|
<p>{model.summary}</p>
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>유형</dt>
|
||||||
|
<dd>{kindLabels[model.kind]}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>프로젝트</dt>
|
||||||
|
<dd>{model.project?.label ?? "독립 기록"}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>게시</dt>
|
||||||
|
<dd>{publishedLabel(model, resolvePublishedLabel)}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicRelations(
|
||||||
|
relations: ReadonlyArray<ResolvedRelation>,
|
||||||
|
): PublicRelation[] {
|
||||||
|
return [...relations]
|
||||||
|
.filter(
|
||||||
|
(relation): relation is ResolvedRelation & { publicPath: string } =>
|
||||||
|
relation.publicPath !== null,
|
||||||
|
)
|
||||||
|
.sort((left, right) => left.order - right.order)
|
||||||
|
.map((relation) => ({
|
||||||
|
reason: relation.reason,
|
||||||
|
title: relation.title,
|
||||||
|
path: relation.publicPath,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function GenericCase({
|
||||||
|
model,
|
||||||
|
embedded,
|
||||||
|
resolveEvidenceAsset,
|
||||||
|
resolvePublishedLabel,
|
||||||
|
}: {
|
||||||
|
model: CasePublicRenderModel;
|
||||||
|
embedded: boolean;
|
||||||
|
} & RenderDependencies) {
|
||||||
|
const Root = embedded ? "div" : "main";
|
||||||
|
return (
|
||||||
|
<Root
|
||||||
|
id={embedded ? undefined : "main-content"}
|
||||||
|
className={`shell public-document-page${embedded ? " public-record-embedded" : ""}`}
|
||||||
|
>
|
||||||
|
<ModelDocumentHeader
|
||||||
|
model={model}
|
||||||
|
resolvePublishedLabel={resolvePublishedLabel}
|
||||||
|
/>
|
||||||
|
<section className="document-snapshot" aria-label="문제와 결론">
|
||||||
|
<div>
|
||||||
|
<p className="snapshot-label">문제</p>
|
||||||
|
<p>{model.problem}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="snapshot-label snapshot-label--answer">결론</p>
|
||||||
|
<p>{model.conclusion}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<dl className="document-facts">
|
||||||
|
<div>
|
||||||
|
<dt>검증 환경</dt>
|
||||||
|
<dd>{model.environment}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>검증 데이터</dt>
|
||||||
|
<dd>{model.reproduction}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<article className="public-document-body">
|
||||||
|
<CaseBodyRenderer
|
||||||
|
blocks={model.bodyBlocks}
|
||||||
|
resolveEvidenceAsset={resolveEvidenceAsset}
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
|
<PublicDocumentRelations relations={publicRelations(model.relations)} />
|
||||||
|
</Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FetchJoinCase({
|
||||||
|
model,
|
||||||
|
embedded,
|
||||||
|
resolveEvidenceAsset,
|
||||||
|
resolvePublishedLabel,
|
||||||
|
}: {
|
||||||
|
model: CasePublicRenderModel;
|
||||||
|
embedded: boolean;
|
||||||
|
} & RenderDependencies) {
|
||||||
|
const headings = model.bodyBlocks
|
||||||
|
.filter((block) => block.type === "HEADING")
|
||||||
|
.map((heading) => ({
|
||||||
|
id: heading.id,
|
||||||
|
label: inlinePlainText(heading.content),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const Root = embedded ? "div" : "main";
|
||||||
|
return (
|
||||||
|
<Root
|
||||||
|
id={embedded ? undefined : "main-content"}
|
||||||
|
className={`case-page${embedded ? " public-record-embedded" : ""}`}
|
||||||
|
>
|
||||||
|
<header className="shell case-header">
|
||||||
|
<nav className="case-breadcrumb" aria-label="문서 경로">
|
||||||
|
<Link to="/explore/cases">Case</Link>
|
||||||
|
<span aria-hidden="true">/</span>
|
||||||
|
<TargetLink target={model.topic} />
|
||||||
|
{model.project ? (
|
||||||
|
<>
|
||||||
|
<span aria-hidden="true">/</span>
|
||||||
|
<TargetLink target={model.project} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<h1>{model.title}</h1>
|
||||||
|
<p className="case-summary">{model.summary}</p>
|
||||||
|
|
||||||
|
<section className="case-snapshot" aria-label="문제와 결론">
|
||||||
|
<div>
|
||||||
|
<p className="snapshot-label">문제</p>
|
||||||
|
<p>{model.problem}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="snapshot-label snapshot-label--answer">결론</p>
|
||||||
|
<p>{model.conclusion}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<dl className="case-meta">
|
||||||
|
<div>
|
||||||
|
<dt>검증 환경</dt>
|
||||||
|
<dd>{model.environment}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>데이터셋</dt>
|
||||||
|
<dd>{model.reproduction.replace(/^Dataset:\s*/, "")}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>기록</dt>
|
||||||
|
<dd>
|
||||||
|
게시 {publishedLabel(model, resolvePublishedLabel)} · 마지막 검증{" "}
|
||||||
|
{displayDate(model.lastVerifiedOn)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<DocumentToc headings={headings} variant="mobile" />
|
||||||
|
|
||||||
|
<div className="shell document-layout">
|
||||||
|
<article className="document-body">
|
||||||
|
<CaseBodyRenderer
|
||||||
|
blocks={model.bodyBlocks}
|
||||||
|
resolveEvidenceAsset={resolveEvidenceAsset}
|
||||||
|
/>
|
||||||
|
<PublicDocumentRelations
|
||||||
|
relations={publicRelations(model.relations)}
|
||||||
|
kicker="Explicit relations"
|
||||||
|
title="이 기록의 연결"
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
|
<DocumentToc headings={headings} variant="desktop" />
|
||||||
|
</div>
|
||||||
|
</Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReferenceDocument({
|
||||||
|
model,
|
||||||
|
embedded,
|
||||||
|
resolvePublishedLabel,
|
||||||
|
}: {
|
||||||
|
model: ReferencePublicRenderModel;
|
||||||
|
embedded: boolean;
|
||||||
|
resolvePublishedLabel: ResolvePublishedLabel;
|
||||||
|
}) {
|
||||||
|
const Root = embedded ? "div" : "main";
|
||||||
|
return (
|
||||||
|
<Root
|
||||||
|
id={embedded ? undefined : "main-content"}
|
||||||
|
className={`shell public-document-page${embedded ? " public-record-embedded" : ""}`}
|
||||||
|
>
|
||||||
|
<ModelDocumentHeader
|
||||||
|
model={model}
|
||||||
|
resolvePublishedLabel={resolvePublishedLabel}
|
||||||
|
/>
|
||||||
|
<section className="reference-purpose" aria-labelledby="purpose-title">
|
||||||
|
<p className="section-kicker">Purpose</p>
|
||||||
|
<h2 id="purpose-title">이 기준을 쓰는 이유</h2>
|
||||||
|
<p>{model.purpose}</p>
|
||||||
|
</section>
|
||||||
|
<article className="public-document-body reference-body">
|
||||||
|
<section aria-labelledby="rules-title">
|
||||||
|
<h2 id="rules-title">판단 기준</h2>
|
||||||
|
<ol className="reference-rules">
|
||||||
|
{[...model.rules]
|
||||||
|
.sort((left, right) => left.order - right.order)
|
||||||
|
.map((rule, index) => (
|
||||||
|
<li key={rule.id}>
|
||||||
|
<span>{String(index + 1).padStart(2, "0")}</span>
|
||||||
|
<div>
|
||||||
|
<h3>{rule.title}</h3>
|
||||||
|
<p>{rule.body}</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
<section aria-labelledby="apply-title">
|
||||||
|
<h2 id="apply-title">적용할 때</h2>
|
||||||
|
<ul>
|
||||||
|
{[...model.applyWhen]
|
||||||
|
.sort((left, right) => left.order - right.order)
|
||||||
|
.map((item) => <li key={item.id}>{item.text}</li>)}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
<section aria-labelledby="exceptions-title">
|
||||||
|
<h2 id="exceptions-title">예외와 주의</h2>
|
||||||
|
<ul>
|
||||||
|
{[...model.exceptions]
|
||||||
|
.sort((left, right) => left.order - right.order)
|
||||||
|
.map((item) => <li key={item.id}>{item.text}</li>)}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
<section aria-labelledby="examples-title">
|
||||||
|
<h2 id="examples-title">예시</h2>
|
||||||
|
<ul>
|
||||||
|
{[...model.examples]
|
||||||
|
.sort((left, right) => left.order - right.order)
|
||||||
|
.map((item) => <li key={item.id}>{item.text}</li>)}
|
||||||
|
</ul>
|
||||||
|
<p className="verified-at">
|
||||||
|
마지막 검증 {displayDate(model.verifiedOn)}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
|
<PublicDocumentRelations relations={publicRelations(model.relations)} />
|
||||||
|
</Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OrderedItems({
|
||||||
|
items,
|
||||||
|
empty,
|
||||||
|
}: {
|
||||||
|
items: ReadonlyArray<components["schemas"]["OrderedText"]>;
|
||||||
|
empty?: string;
|
||||||
|
}) {
|
||||||
|
if (items.length === 0 && empty) return <p>{empty}</p>;
|
||||||
|
return (
|
||||||
|
<ul>
|
||||||
|
{[...items]
|
||||||
|
.sort((left, right) => left.order - right.order)
|
||||||
|
.map((item) => <li key={item.id}>{item.text}</li>)}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuestionDocument({
|
||||||
|
model,
|
||||||
|
embedded,
|
||||||
|
resolvePublishedLabel,
|
||||||
|
}: {
|
||||||
|
model: QuestionPublicRenderModel;
|
||||||
|
embedded: boolean;
|
||||||
|
resolvePublishedLabel: ResolvePublishedLabel;
|
||||||
|
}) {
|
||||||
|
const resolutionPath = model.resolution?.evidenceTarget.publicPath;
|
||||||
|
const Root = embedded ? "div" : "main";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Root
|
||||||
|
id={embedded ? undefined : "main-content"}
|
||||||
|
className={`shell public-document-page${embedded ? " public-record-embedded" : ""}`}
|
||||||
|
>
|
||||||
|
<ModelDocumentHeader
|
||||||
|
model={model}
|
||||||
|
resolvePublishedLabel={resolvePublishedLabel}
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
className={`question-status question-status--${model.status.toLowerCase()}`}
|
||||||
|
>
|
||||||
|
{model.status}
|
||||||
|
</p>
|
||||||
|
{model.resolution ? (
|
||||||
|
<aside className="question-resolution">
|
||||||
|
<strong>{model.resolution.summary}</strong>
|
||||||
|
{resolutionPath ? (
|
||||||
|
<Link to={resolutionPath}>{model.resolution.linkLabel}</Link>
|
||||||
|
) : (
|
||||||
|
<span>{model.resolution.linkLabel}</span>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
) : null}
|
||||||
|
<article className="public-document-body question-body">
|
||||||
|
<section aria-labelledby="facts-title">
|
||||||
|
<h2 id="facts-title">확인한 사실</h2>
|
||||||
|
<OrderedItems items={model.facts} />
|
||||||
|
</section>
|
||||||
|
<section aria-labelledby="assumptions-title">
|
||||||
|
<h2 id="assumptions-title">가정</h2>
|
||||||
|
<OrderedItems
|
||||||
|
items={model.assumptions}
|
||||||
|
empty="현재 기록된 가정이 없습니다."
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
<section aria-labelledby="unknowns-title">
|
||||||
|
<h2 id="unknowns-title">남은 미지수</h2>
|
||||||
|
<OrderedItems
|
||||||
|
items={model.unknowns}
|
||||||
|
empty="해결 과정에서 남은 미지수가 없습니다."
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
<section aria-labelledby="constraints-title">
|
||||||
|
<h2 id="constraints-title">제약</h2>
|
||||||
|
<OrderedItems items={model.constraints} />
|
||||||
|
</section>
|
||||||
|
<section aria-labelledby="options-title">
|
||||||
|
<h2 id="options-title">검토한 선택지</h2>
|
||||||
|
<ol className="question-options">
|
||||||
|
{[...model.options]
|
||||||
|
.sort((left, right) => left.order - right.order)
|
||||||
|
.map((option) => (
|
||||||
|
<li key={option.id}>
|
||||||
|
<h3>{option.title}</h3>
|
||||||
|
<p>{option.description}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
<section
|
||||||
|
className="next-validation"
|
||||||
|
aria-labelledby="next-validation-title"
|
||||||
|
>
|
||||||
|
<p className="section-kicker">Next</p>
|
||||||
|
<h2 id="next-validation-title">다음 검증</h2>
|
||||||
|
<p>{model.nextValidation}</p>
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
|
<PublicDocumentRelations relations={publicRelations(model.relations)} />
|
||||||
|
</Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PublicRecordRenderer({
|
||||||
|
model,
|
||||||
|
embedded = false,
|
||||||
|
resolveEvidenceAsset,
|
||||||
|
resolvePublishedLabel,
|
||||||
|
}: {
|
||||||
|
model: PublicRenderModel;
|
||||||
|
embedded?: boolean;
|
||||||
|
} & RenderDependencies) {
|
||||||
|
switch (model.kind) {
|
||||||
|
case "CASE":
|
||||||
|
return model.publicPath ===
|
||||||
|
"/cases/collection-fetch-join-pagination" ? (
|
||||||
|
<FetchJoinCase
|
||||||
|
model={model}
|
||||||
|
embedded={embedded}
|
||||||
|
resolveEvidenceAsset={resolveEvidenceAsset}
|
||||||
|
resolvePublishedLabel={resolvePublishedLabel}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<GenericCase
|
||||||
|
model={model}
|
||||||
|
embedded={embedded}
|
||||||
|
resolveEvidenceAsset={resolveEvidenceAsset}
|
||||||
|
resolvePublishedLabel={resolvePublishedLabel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "REFERENCE":
|
||||||
|
return (
|
||||||
|
<ReferenceDocument
|
||||||
|
model={model}
|
||||||
|
embedded={embedded}
|
||||||
|
resolvePublishedLabel={resolvePublishedLabel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "QUESTION":
|
||||||
|
return (
|
||||||
|
<QuestionDocument
|
||||||
|
model={model}
|
||||||
|
embedded={embedded}
|
||||||
|
resolvePublishedLabel={resolvePublishedLabel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return assertNever(model);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,404 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { isSupportedEvidenceKey } from "../../../src/features/tech-log/adapters/static/evidence-assets.ts";
|
||||||
|
import {
|
||||||
|
ContentFormatError,
|
||||||
|
parseCaseContent,
|
||||||
|
} from "../../../src/features/tech-log/domain/content-format/parse-case-content.ts";
|
||||||
|
import { projectWorkingCopy } from "../../../src/features/tech-log/domain/content-format/project-public-render-model.ts";
|
||||||
|
import { serializeCaseContent } from "../../../src/features/tech-log/domain/content-format/serialize-case-content.ts";
|
||||||
|
import type { components } from "../../../src/features/tech-log/contracts/studio/generated.ts";
|
||||||
|
|
||||||
|
const rich = `## 측정 결과 {#measurements}
|
||||||
|
|
||||||
|
:::table id="fetch-loss" caption="반환 손실" rowHeaderColumn="1"
|
||||||
|
| 상태 | 결과 |
|
||||||
|
| --- | ---: |
|
||||||
|
| before | :status[20건]{tone="warning"} |
|
||||||
|
:::
|
||||||
|
|
||||||
|
\`\`\`sql label="재현 쿼리"
|
||||||
|
select * from feed_item;
|
||||||
|
\`\`\``;
|
||||||
|
|
||||||
|
describe("Content Format v1", () => {
|
||||||
|
it("parses and serializes the rich source fixture byte-for-byte", () => {
|
||||||
|
const parsed = parseCaseContent(rich);
|
||||||
|
|
||||||
|
expect(serializeCaseContent(parsed)).toBe(rich);
|
||||||
|
expect(parseCaseContent(serializeCaseContent(parsed))).toEqual(parsed);
|
||||||
|
expect(parsed.map((block) => block.type)).toEqual([
|
||||||
|
"HEADING",
|
||||||
|
"DATA_TABLE",
|
||||||
|
"CODE_BLOCK",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unsafe, raw HTML, and unsupported input with source positions", () => {
|
||||||
|
const rejected = [
|
||||||
|
"<script>alert(1)</script>",
|
||||||
|
"[x](javascript:alert(1))",
|
||||||
|
"[x](//evil.example/path)",
|
||||||
|
"- outer\n - nested",
|
||||||
|
"# level one",
|
||||||
|
"- [ ] task",
|
||||||
|
"> > nested",
|
||||||
|
':::unknown key="value"\ntext\n:::',
|
||||||
|
':::evidence key="https://example.com/x.png" alt="x" caption="x" zoom="true"\n:::',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const source of rejected) {
|
||||||
|
expect(() => parseCaseContent(source), source).toThrow(
|
||||||
|
/CONTENT_FORMAT_INVALID/,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
parseCaseContent("safe\n\n<script>alert(1)</script>");
|
||||||
|
throw new Error("expected malformed content to fail");
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(ContentFormatError);
|
||||||
|
expect((error as ContentFormatError).issues).toEqual([
|
||||||
|
{
|
||||||
|
line: 3,
|
||||||
|
column: 1,
|
||||||
|
detail: "unsupported block syntax: html",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates stable Korean heading IDs and suffixes duplicates", () => {
|
||||||
|
const blocks = parseCaseContent("## 한글 API!\n\n## 한글 API?\n\n## !!!");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
blocks.map((block) => (block.type === "HEADING" ? block.id : null)),
|
||||||
|
).toEqual(["한글-api", "한글-api-2", "section"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round trips every supported block and inline variant", () => {
|
||||||
|
const source = `## 본문 {#body}
|
||||||
|
|
||||||
|
일반 *강조* **강함** \`code\` [내부](/path) [메일](mailto:test@example.com)
|
||||||
|
|
||||||
|
> 한 문단 인용
|
||||||
|
|
||||||
|
- 첫째
|
||||||
|
- 둘째
|
||||||
|
|
||||||
|
1. 하나
|
||||||
|
2. 둘
|
||||||
|
|
||||||
|
:::callout tone="info" label="정보"
|
||||||
|
안전한 안내입니다.
|
||||||
|
:::
|
||||||
|
|
||||||
|
:::evidence key="fetch-strategy-boundary" alt="비교" caption="경계" zoom="false"
|
||||||
|
:::`;
|
||||||
|
const parsed = parseCaseContent(source);
|
||||||
|
|
||||||
|
expect(parseCaseContent(serializeCaseContent(parsed))).toEqual(parsed);
|
||||||
|
expect(parsed.map((block) => block.type)).toEqual([
|
||||||
|
"HEADING",
|
||||||
|
"PARAGRAPH",
|
||||||
|
"BLOCKQUOTE",
|
||||||
|
"UNORDERED_LIST",
|
||||||
|
"ORDERED_LIST",
|
||||||
|
"CALLOUT",
|
||||||
|
"EVIDENCE_FIGURE",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives stable table column and row IDs", () => {
|
||||||
|
const [table] = parseCaseContent(`:::table id="metrics" caption="측정" rowHeaderColumn="none"
|
||||||
|
| 이름 | 수치 |
|
||||||
|
| :--- | ---: |
|
||||||
|
| before | 20 |
|
||||||
|
:::`);
|
||||||
|
|
||||||
|
expect(table?.type).toBe("DATA_TABLE");
|
||||||
|
if (table?.type !== "DATA_TABLE") return;
|
||||||
|
expect(table.columns.map((column) => column.id)).toEqual([
|
||||||
|
"metrics-column-1",
|
||||||
|
"metrics-column-2",
|
||||||
|
]);
|
||||||
|
expect(table.rows.map((row) => row.id)).toEqual(["metrics-row-1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unsupported inline syntax in table headers", () => {
|
||||||
|
for (const header of [
|
||||||
|
"<script>alert(1)</script>",
|
||||||
|
"",
|
||||||
|
"~~deleted~~",
|
||||||
|
]) {
|
||||||
|
const source = `:::table id="unsafe-header" caption="검증" rowHeaderColumn="none"
|
||||||
|
| ${header} |
|
||||||
|
| --- |
|
||||||
|
| value |
|
||||||
|
:::`;
|
||||||
|
expect(() => parseCaseContent(source), header).toThrow(
|
||||||
|
/CONTENT_FORMAT_INVALID/,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const metricsTable = `:::table id="metrics" caption="측정" rowHeaderColumn="none"
|
||||||
|
| 이름 | 값 |
|
||||||
|
| --- | --- |
|
||||||
|
| before | 20 |
|
||||||
|
:::`;
|
||||||
|
|
||||||
|
it("rejects duplicate table IDs", () => {
|
||||||
|
expect(() =>
|
||||||
|
parseCaseContent(`${metricsTable}\n\n${metricsTable}`),
|
||||||
|
).toThrow(/duplicate explicit ID: metrics/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects heading and table base ID collisions in either order", () => {
|
||||||
|
expect(() =>
|
||||||
|
parseCaseContent(`## 제목 {#metrics}\n\n${metricsTable}`),
|
||||||
|
).toThrow(/duplicate explicit ID: metrics/);
|
||||||
|
expect(() =>
|
||||||
|
parseCaseContent(`${metricsTable}\n\n## 제목 {#metrics}`),
|
||||||
|
).toThrow(/duplicate explicit ID: metrics/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects heading IDs that collide with a later table column ID", () => {
|
||||||
|
expect(() =>
|
||||||
|
parseCaseContent(`## 열 {#metrics-column-1}\n\n${metricsTable}`),
|
||||||
|
).toThrow(/duplicate explicit ID: metrics-column-1/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects table row IDs that collide with a later heading ID", () => {
|
||||||
|
expect(() =>
|
||||||
|
parseCaseContent(`${metricsTable}\n\n## 행 {#metrics-row-1}`),
|
||||||
|
).toThrow(/duplicate explicit ID: metrics-row-1/);
|
||||||
|
});
|
||||||
|
|
||||||
|
function expectRoundTrip(source: string) {
|
||||||
|
const parsed = parseCaseContent(source);
|
||||||
|
expect(parseCaseContent(serializeCaseContent(parsed))).toEqual(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("round trips literal backslashes and intraword underscores", () => {
|
||||||
|
expectRoundTrip("literal_under_score and \\*star\\* and \\\\slash");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round trips literal emphasis-shaped underscores", () => {
|
||||||
|
expectRoundTrip("literal \\_emphasis\\_");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps escaped block-leading list and quote markers as paragraphs", () => {
|
||||||
|
for (const source of ["\\- literal", "\\> quote"]) {
|
||||||
|
const parsed = parseCaseContent(source);
|
||||||
|
expect(parsed[0]?.type).toBe("PARAGRAPH");
|
||||||
|
expect(parseCaseContent(serializeCaseContent(parsed))).toEqual(parsed);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round trips inline code containing backticks", () => {
|
||||||
|
expectRoundTrip("Use ``code ` tick`` here.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round trips all-space inline code without semantic padding", () => {
|
||||||
|
expectRoundTrip("Use `` `` here.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round trips escaped pipes in table cells", () => {
|
||||||
|
expectRoundTrip(`:::table id="pipes" caption="파이프" rowHeaderColumn="none"
|
||||||
|
| 표현 | 값 |
|
||||||
|
| --- | --- |
|
||||||
|
| a \\| b | c |
|
||||||
|
:::`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round trips directive-looking text inside a longer code fence", () => {
|
||||||
|
expectRoundTrip(`\`\`\`\`text label="문법 예시"
|
||||||
|
:::callout tone="warning" label="코드 안"
|
||||||
|
\`\`\`inner
|
||||||
|
literal
|
||||||
|
\`\`\`
|
||||||
|
\`\`\`\``);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const catalog: components["schemas"]["CatalogEntry"][] = [
|
||||||
|
{
|
||||||
|
id: "topic-jpa",
|
||||||
|
type: "TOPIC",
|
||||||
|
label: "JPA",
|
||||||
|
publicPath: "/topics/jpa",
|
||||||
|
dependencyRevision: "r1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "project-backend",
|
||||||
|
type: "PROJECT",
|
||||||
|
label: "Backend Skeleton",
|
||||||
|
publicPath: "/projects/backend-skeleton",
|
||||||
|
dependencyRevision: "r1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "case-fetch",
|
||||||
|
type: "RELATION",
|
||||||
|
kind: "CASE",
|
||||||
|
label: "Fetch Join Case",
|
||||||
|
publicPath: "/cases/collection-fetch-join-pagination",
|
||||||
|
dependencyRevision: "r1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "evidence-fetch",
|
||||||
|
type: "EVIDENCE",
|
||||||
|
label: "Fetch 전략 비교",
|
||||||
|
publicPath: "/media/fetch-strategy-boundary.svg",
|
||||||
|
dependencyRevision: "r1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "resolution-evidence",
|
||||||
|
type: "EVIDENCE",
|
||||||
|
label: "Fetch Join Case",
|
||||||
|
publicPath: "/cases/collection-fetch-join-pagination",
|
||||||
|
dependencyRevision: "r1",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function caseWithEvidence(
|
||||||
|
key: string,
|
||||||
|
): components["schemas"]["CaseInput"] {
|
||||||
|
return {
|
||||||
|
kind: "CASE",
|
||||||
|
title: "Case",
|
||||||
|
slug: "case",
|
||||||
|
summary: "summary",
|
||||||
|
topicId: "topic-jpa",
|
||||||
|
projectId: "project-backend",
|
||||||
|
relations: [
|
||||||
|
{
|
||||||
|
id: "relation-1",
|
||||||
|
targetId: "case-fetch",
|
||||||
|
reason: "근거",
|
||||||
|
order: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
problem: "problem",
|
||||||
|
conclusion: "conclusion",
|
||||||
|
environment: "environment",
|
||||||
|
reproduction: "dataset",
|
||||||
|
lastVerifiedOn: "2026-08-14",
|
||||||
|
bodyMarkdown: `## 본문\n\n:::evidence key="${key}" alt="설명" caption="근거" zoom="true"\n:::`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Public render model projection", () => {
|
||||||
|
it("is pure and resolves a supported catalog evidence entry", () => {
|
||||||
|
const input = caseWithEvidence("fetch-strategy-boundary");
|
||||||
|
const before = structuredClone(input);
|
||||||
|
const model = projectWorkingCopy(
|
||||||
|
input,
|
||||||
|
catalog,
|
||||||
|
{
|
||||||
|
generatedAt: "2026-08-14T00:00:00Z",
|
||||||
|
dependencyRevision: "r1",
|
||||||
|
},
|
||||||
|
isSupportedEvidenceKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.kind).toBe("CASE");
|
||||||
|
expect(model.topic.label).toBe("JPA");
|
||||||
|
expect(model.project?.label).toBe("Backend Skeleton");
|
||||||
|
expect(model.relations[0]?.title).toBe("Fetch Join Case");
|
||||||
|
expect(input).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves resolved Question fields and requires EVIDENCE", () => {
|
||||||
|
const input: components["schemas"]["QuestionInput"] = {
|
||||||
|
kind: "QUESTION",
|
||||||
|
title: "Question",
|
||||||
|
slug: "question",
|
||||||
|
summary: "summary",
|
||||||
|
topicId: "topic-jpa",
|
||||||
|
projectId: null,
|
||||||
|
relations: [],
|
||||||
|
questionStatus: "RESOLVED",
|
||||||
|
facts: [],
|
||||||
|
assumptions: [],
|
||||||
|
unknowns: [],
|
||||||
|
constraints: [],
|
||||||
|
options: [],
|
||||||
|
nextValidation: "다음에도 측정합니다.",
|
||||||
|
resolution: {
|
||||||
|
summary: "분리하기로 결정했습니다.",
|
||||||
|
evidenceTargetId: "resolution-evidence",
|
||||||
|
linkLabel: "Case 읽기",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const model = projectWorkingCopy(
|
||||||
|
input,
|
||||||
|
catalog,
|
||||||
|
{ mode: "PREVIEW", publishedAt: null },
|
||||||
|
isSupportedEvidenceKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.kind).toBe("QUESTION");
|
||||||
|
if (model.kind !== "QUESTION") return;
|
||||||
|
expect(model.nextValidation).toBe(input.nextValidation);
|
||||||
|
expect(model.resolution?.summary).toBe(input.resolution?.summary);
|
||||||
|
expect(model.resolution?.evidenceTarget.publicPath).toBe(
|
||||||
|
"/cases/collection-fetch-join-pagination",
|
||||||
|
);
|
||||||
|
expect(() =>
|
||||||
|
projectWorkingCopy(
|
||||||
|
{
|
||||||
|
...input,
|
||||||
|
resolution: {
|
||||||
|
...input.resolution!,
|
||||||
|
evidenceTargetId: "topic-jpa",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
catalog,
|
||||||
|
{ mode: "PREVIEW", publishedAt: null },
|
||||||
|
isSupportedEvidenceKey,
|
||||||
|
),
|
||||||
|
).toThrow(/EVIDENCE catalog/);
|
||||||
|
});
|
||||||
|
|
||||||
|
const unsupportedEvidenceCatalog = [
|
||||||
|
...catalog,
|
||||||
|
{
|
||||||
|
id: "unsupported-id",
|
||||||
|
type: "EVIDENCE",
|
||||||
|
label: "지원하지 않음",
|
||||||
|
publicPath: "/media/unsupported-id.svg",
|
||||||
|
dependencyRevision: "r1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "other-id",
|
||||||
|
type: "EVIDENCE",
|
||||||
|
label: "unsupported-label",
|
||||||
|
publicPath: "/media/unsupported-label.svg",
|
||||||
|
dependencyRevision: "r1",
|
||||||
|
},
|
||||||
|
] satisfies components["schemas"]["CatalogEntry"][];
|
||||||
|
|
||||||
|
function expectUnsupportedEvidence(key: string) {
|
||||||
|
expect(() =>
|
||||||
|
projectWorkingCopy(
|
||||||
|
caseWithEvidence(key),
|
||||||
|
unsupportedEvidenceCatalog,
|
||||||
|
{ mode: "PREVIEW", publishedAt: null },
|
||||||
|
isSupportedEvidenceKey,
|
||||||
|
),
|
||||||
|
).toThrow(/supported local evidence key/);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("rejects an unknown evidence key", () => {
|
||||||
|
expectUnsupportedEvidence("unknown-asset");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a catalog-matching unsupported evidence ID", () => {
|
||||||
|
expectUnsupportedEvidence("unsupported-id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a catalog-matching unsupported evidence label", () => {
|
||||||
|
expectUnsupportedEvidence("unsupported-label");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,617 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { getEvidenceAsset } from "../../../src/features/tech-log/adapters/static/evidence-assets.ts";
|
||||||
|
import type { components } from "../../../src/features/tech-log/contracts/studio/generated.ts";
|
||||||
|
import { parseCaseContent } from "../../../src/features/tech-log/domain/content-format/parse-case-content.ts";
|
||||||
|
import { Callout } from "../../../src/features/tech-log/presentation/shared/public-render/callout.tsx";
|
||||||
|
import { CaseBodyRenderer } from "../../../src/features/tech-log/presentation/shared/public-render/case-body-renderer.tsx";
|
||||||
|
import { CodeBlock } from "../../../src/features/tech-log/presentation/shared/public-render/code-block.tsx";
|
||||||
|
import { DataTable } from "../../../src/features/tech-log/presentation/shared/public-render/data-table.tsx";
|
||||||
|
import { DocumentToc } from "../../../src/features/tech-log/presentation/shared/public-render/document-toc.tsx";
|
||||||
|
import { EvidenceFigure } from "../../../src/features/tech-log/presentation/shared/public-render/fetch-strategy-evidence-figure.tsx";
|
||||||
|
import { InlineRenderer } from "../../../src/features/tech-log/presentation/shared/public-render/inline-renderer.tsx";
|
||||||
|
import { PublicEvidenceFigure } from "../../../src/features/tech-log/presentation/shared/public-render/evidence-figure.tsx";
|
||||||
|
import { PublicRecordRenderer } from "../../../src/features/tech-log/presentation/shared/public-render/public-record-renderer.tsx";
|
||||||
|
|
||||||
|
class NoopIntersectionObserver implements IntersectionObserver {
|
||||||
|
readonly root = null;
|
||||||
|
readonly rootMargin = "0px";
|
||||||
|
readonly scrollMargin = "0px";
|
||||||
|
readonly thresholds = [0];
|
||||||
|
|
||||||
|
disconnect() {}
|
||||||
|
observe() {}
|
||||||
|
takeRecords(): IntersectionObserverEntry[] {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
unobserve() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalShowModal = HTMLDialogElement.prototype.showModal;
|
||||||
|
const originalClose = HTMLDialogElement.prototype.close;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal("IntersectionObserver", NoopIntersectionObserver);
|
||||||
|
Object.defineProperty(HTMLDialogElement.prototype, "showModal", {
|
||||||
|
configurable: true,
|
||||||
|
value(this: HTMLDialogElement) {
|
||||||
|
this.setAttribute("open", "");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(HTMLDialogElement.prototype, "close", {
|
||||||
|
configurable: true,
|
||||||
|
value(this: HTMLDialogElement) {
|
||||||
|
this.removeAttribute("open");
|
||||||
|
this.dispatchEvent(new Event("close"));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
Object.defineProperty(HTMLDialogElement.prototype, "showModal", {
|
||||||
|
configurable: true,
|
||||||
|
value: originalShowModal,
|
||||||
|
});
|
||||||
|
Object.defineProperty(HTMLDialogElement.prototype, "close", {
|
||||||
|
configurable: true,
|
||||||
|
value: originalClose,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderDependencies = {
|
||||||
|
resolveEvidenceAsset: getEvidenceAsset,
|
||||||
|
resolvePublishedLabel: (path: string) =>
|
||||||
|
path === "/cases/collection-fetch-join-pagination"
|
||||||
|
? "2026.08.14"
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
function caseModel(
|
||||||
|
overrides: Partial<components["schemas"]["CasePublicRenderModel"]> = {},
|
||||||
|
): components["schemas"]["CasePublicRenderModel"] {
|
||||||
|
return {
|
||||||
|
kind: "CASE",
|
||||||
|
slug: "draft-case",
|
||||||
|
title: "Case 제목",
|
||||||
|
summary: "Case 요약",
|
||||||
|
publicPath: "/cases/draft-case",
|
||||||
|
topic: { id: "topic-jpa", label: "JPA", publicPath: "/topics/jpa" },
|
||||||
|
project: {
|
||||||
|
id: "project-backend",
|
||||||
|
label: "Backend Skeleton",
|
||||||
|
publicPath: "/projects/backend-skeleton",
|
||||||
|
},
|
||||||
|
relations: [
|
||||||
|
{
|
||||||
|
id: "relation-1",
|
||||||
|
targetId: "reference-1",
|
||||||
|
targetKind: "REFERENCE",
|
||||||
|
title: "연결된 기준",
|
||||||
|
publicPath: "/references/linked",
|
||||||
|
reason: "판단 기준",
|
||||||
|
order: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
renderContext: {
|
||||||
|
generatedAt: "2035-05-06T07:08:09Z",
|
||||||
|
dependencyRevision: "r1",
|
||||||
|
},
|
||||||
|
problem: "문제",
|
||||||
|
conclusion: "결론",
|
||||||
|
environment: "환경",
|
||||||
|
reproduction: "Dataset: 데이터셋",
|
||||||
|
lastVerifiedOn: "2030-01-02",
|
||||||
|
bodyBlocks: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInRouter(node: React.ReactNode) {
|
||||||
|
return render(<MemoryRouter>{node}</MemoryRouter>);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("shared Public block renderer", () => {
|
||||||
|
it("preserves rich tags, classes, labels, order, and escaped text", () => {
|
||||||
|
const blocks = parseCaseContent(`안전한 < 문장과 > 기호
|
||||||
|
|
||||||
|
## *강조* **강함** \`코드\` {#rich-heading}
|
||||||
|
|
||||||
|
문단 [내부](/safe) :status[20건]{tone="warning"}
|
||||||
|
|
||||||
|
> 인용
|
||||||
|
|
||||||
|
- 첫째
|
||||||
|
- 둘째
|
||||||
|
|
||||||
|
1. 하나
|
||||||
|
2. 둘
|
||||||
|
|
||||||
|
\`\`\`sql label="실패한 목록 조회"
|
||||||
|
select * from feed_item;
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
:::table id="metrics" caption="반환 손실" rowHeaderColumn="1"
|
||||||
|
| 상태 | 결과 |
|
||||||
|
| --- | ---: |
|
||||||
|
| before | 20 |
|
||||||
|
:::
|
||||||
|
|
||||||
|
:::callout tone="warning" label="주의"
|
||||||
|
안전한 안내입니다.
|
||||||
|
:::
|
||||||
|
|
||||||
|
:::evidence key="fetch-strategy-boundary" alt="비교 설명" caption="비교 근거" zoom="false"
|
||||||
|
:::`);
|
||||||
|
|
||||||
|
const view = render(
|
||||||
|
<CaseBodyRenderer
|
||||||
|
blocks={blocks}
|
||||||
|
resolveEvidenceAsset={getEvidenceAsset}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(view.container.firstElementChild?.textContent).toContain(
|
||||||
|
"안전한 < 문장과 > 기호",
|
||||||
|
);
|
||||||
|
expect(view.container.querySelector("script")).toBeNull();
|
||||||
|
const section = view.container.querySelector(
|
||||||
|
'section[aria-labelledby="rich-heading"]',
|
||||||
|
);
|
||||||
|
expect(section).not.toBeNull();
|
||||||
|
expect(section?.querySelector("h2#rich-heading .heading-anchor")).toHaveAttribute(
|
||||||
|
"aria-label",
|
||||||
|
"강조 강함 코드 바로가기",
|
||||||
|
);
|
||||||
|
expect(section?.querySelector("h2 em")?.textContent).toBe("강조");
|
||||||
|
expect(section?.querySelector("h2 strong")?.textContent).toBe("강함");
|
||||||
|
expect(section?.querySelector("h2 code")?.textContent).toBe("코드");
|
||||||
|
expect(section?.querySelector('a[href="/safe"]')?.textContent).toBe("내부");
|
||||||
|
expect(section?.querySelector(".status.status--warning")?.textContent).toBe(
|
||||||
|
"20건",
|
||||||
|
);
|
||||||
|
expect(section?.querySelector("blockquote")?.textContent).toBe("인용");
|
||||||
|
expect(
|
||||||
|
Array.from(section?.querySelectorAll("ul > li") ?? [], (item) =>
|
||||||
|
item.textContent,
|
||||||
|
),
|
||||||
|
).toEqual(["첫째", "둘째"]);
|
||||||
|
expect(
|
||||||
|
Array.from(section?.querySelectorAll("ol > li") ?? [], (item) =>
|
||||||
|
item.textContent,
|
||||||
|
),
|
||||||
|
).toEqual(["하나", "둘"]);
|
||||||
|
expect(
|
||||||
|
section?.querySelector('.code-block[data-content-role="재현 쿼리"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(screen.getByRole("region", { name: "반환 손실 표" })).toHaveClass(
|
||||||
|
"data-table-wrap",
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("complementary", { name: "주의" })).toHaveClass(
|
||||||
|
"callout--warning",
|
||||||
|
);
|
||||||
|
const image = screen.getByRole("img", { name: "비교 설명" });
|
||||||
|
expect(image).toHaveAttribute("src", "/media/fetch-strategy-boundary.svg");
|
||||||
|
expect(image).toHaveAttribute("width", "1080");
|
||||||
|
expect(image).toHaveAttribute("height", "420");
|
||||||
|
expect(image).toHaveAttribute("loading", "lazy");
|
||||||
|
expect(image.closest("figure")?.querySelector("figcaption")?.textContent).toBe(
|
||||||
|
"비교 근거",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders root blocks before the first owned heading section", () => {
|
||||||
|
const view = render(
|
||||||
|
<CaseBodyRenderer
|
||||||
|
resolveEvidenceAsset={getEvidenceAsset}
|
||||||
|
blocks={[
|
||||||
|
{
|
||||||
|
type: "PARAGRAPH",
|
||||||
|
content: [{ type: "TEXT", text: "도입 문단" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "UNORDERED_LIST",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "root-item",
|
||||||
|
content: [{ type: "TEXT", text: "도입 목록" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "HEADING",
|
||||||
|
id: "details",
|
||||||
|
level: 2,
|
||||||
|
content: [{ type: "TEXT", text: "세부 내용" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "PARAGRAPH",
|
||||||
|
content: [{ type: "TEXT", text: "세부 문단" }],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(view.container.innerHTML.indexOf("도입 문단")).toBeLessThan(
|
||||||
|
view.container.innerHTML.indexOf("details"),
|
||||||
|
);
|
||||||
|
expect(view.container.innerHTML.indexOf("도입 목록")).toBeLessThan(
|
||||||
|
view.container.innerHTML.indexOf("details"),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
view.container.querySelector('section[aria-labelledby="details"]'),
|
||||||
|
).toHaveTextContent("세부 내용#세부 문단");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes untrusted inline text instead of inserting HTML", () => {
|
||||||
|
const view = render(
|
||||||
|
<InlineRenderer
|
||||||
|
content={[
|
||||||
|
{
|
||||||
|
type: "TEXT",
|
||||||
|
text: '<img src=x onerror="alert(1)"><script>alert(2)</script>',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(view.container.querySelector("img")).toBeNull();
|
||||||
|
expect(view.container.querySelector("script")).toBeNull();
|
||||||
|
expect(view.container).toHaveTextContent(
|
||||||
|
'<img src=x onerror="alert(1)"><script>alert(2)</script>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves callout and table relationships", () => {
|
||||||
|
const table: components["schemas"]["DataTableBlock"] = {
|
||||||
|
type: "DATA_TABLE",
|
||||||
|
id: "metrics",
|
||||||
|
caption: "측정",
|
||||||
|
rowHeaderColumn: null,
|
||||||
|
columns: [
|
||||||
|
{ id: "metrics-column-1", label: "이름", alignment: "LEFT" },
|
||||||
|
{ id: "metrics-column-2", label: "값", alignment: "RIGHT" },
|
||||||
|
],
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
id: "metrics-row-1",
|
||||||
|
cells: [
|
||||||
|
{
|
||||||
|
columnId: "metrics-column-1",
|
||||||
|
content: [{ type: "TEXT", text: "before" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
columnId: "metrics-column-2",
|
||||||
|
content: [{ type: "TEXT", text: "20" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const without = render(<DataTable block={table} />);
|
||||||
|
expect(without.container.querySelector("td")?.getAttribute("headers")).toBe(
|
||||||
|
"metrics-column-1",
|
||||||
|
);
|
||||||
|
expect(without.container.querySelector("td")?.getAttribute("headers")).not.toContain(
|
||||||
|
"metrics-row-1",
|
||||||
|
);
|
||||||
|
without.unmount();
|
||||||
|
|
||||||
|
const withHeader = render(
|
||||||
|
<DataTable block={{ ...table, rowHeaderColumn: 1 }} />,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
withHeader.container.querySelector('th[id="metrics-row-1"][scope="row"]'),
|
||||||
|
).toHaveTextContent("before");
|
||||||
|
expect(
|
||||||
|
withHeader.container.querySelector('td[headers="metrics-row-1 metrics-column-2"]'),
|
||||||
|
).toHaveTextContent("20");
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Callout
|
||||||
|
block={{
|
||||||
|
type: "CALLOUT",
|
||||||
|
tone: "info",
|
||||||
|
label: "정보",
|
||||||
|
content: [{ type: "TEXT", text: "설명" }],
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("complementary", { name: "정보" })).toHaveTextContent(
|
||||||
|
"정보설명",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shared Public record renderer", () => {
|
||||||
|
it("preserves the canonical Fetch Join layout, TOC, metadata, and relations", () => {
|
||||||
|
renderInRouter(
|
||||||
|
<PublicRecordRenderer
|
||||||
|
{...renderDependencies}
|
||||||
|
model={caseModel({
|
||||||
|
slug: "collection-fetch-join-pagination",
|
||||||
|
title: "컬렉션 Fetch Join과 페이징은 왜 충돌하는가",
|
||||||
|
publicPath: "/cases/collection-fetch-join-pagination",
|
||||||
|
reproduction: "Dataset: 수정 중인 데이터셋",
|
||||||
|
bodyBlocks: parseCaseContent(
|
||||||
|
"## *강조* **강함** `코드` {#rich-heading}\n\n본문",
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole("main").querySelector("header.case-header"),
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
|
||||||
|
"컬렉션 Fetch Join과 페이징은 왜 충돌하는가",
|
||||||
|
);
|
||||||
|
expect(screen.getByText("수정 중인 데이터셋")).toBeVisible();
|
||||||
|
expect(screen.getByText(/게시 2026\.08\.14 · 마지막 검증 2030\.01\.02/)).toBeVisible();
|
||||||
|
expect(screen.getAllByRole("navigation", { name: "문서 목차" })).toHaveLength(
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("목차 · 강조 강함 코드")).toBeVisible();
|
||||||
|
expect(screen.getByRole("link", { name: "강조 강함 코드 바로가기" })).toBeVisible();
|
||||||
|
const relation = screen.getByRole("link", { name: /판단 기준연결된 기준/ });
|
||||||
|
expect(relation).toHaveAttribute("href", "/references/linked");
|
||||||
|
expect(relation.parentElement?.parentElement?.previousElementSibling).toHaveTextContent(
|
||||||
|
"이 기록의 연결",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses generic Case markup and does not present generatedAt as publication", () => {
|
||||||
|
renderInRouter(
|
||||||
|
<PublicRecordRenderer
|
||||||
|
{...renderDependencies}
|
||||||
|
model={caseModel({
|
||||||
|
bodyBlocks: [
|
||||||
|
{
|
||||||
|
type: "PARAGRAPH",
|
||||||
|
content: [{ type: "TEXT", text: "일반 본문" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const main = screen.getByRole("main");
|
||||||
|
expect(main).toHaveClass("shell", "public-document-page");
|
||||||
|
expect(main).toHaveAttribute("id", "main-content");
|
||||||
|
expect(screen.getByText("게시 전")).toBeVisible();
|
||||||
|
expect(main).not.toHaveTextContent("2035.05.06");
|
||||||
|
expect(screen.getByRole("region", { name: "문제와 결론" })).toHaveTextContent(
|
||||||
|
"문제문제결론결론",
|
||||||
|
);
|
||||||
|
expect(screen.getByText("일반 본문")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders supplied Reference and Question preview fields and empty copy", () => {
|
||||||
|
const base = {
|
||||||
|
slug: "preview",
|
||||||
|
title: "수정 중인 제목",
|
||||||
|
summary: "저장 전 요약",
|
||||||
|
publicPath: "/preview",
|
||||||
|
topic: { id: "topic", label: "수정 Topic", publicPath: null },
|
||||||
|
project: { id: "project", label: "수정 Project", publicPath: null },
|
||||||
|
relations: [],
|
||||||
|
renderContext: {
|
||||||
|
generatedAt: "2035-05-06T07:08:09Z",
|
||||||
|
dependencyRevision: "r1",
|
||||||
|
},
|
||||||
|
} satisfies Omit<components["schemas"]["PublicRenderModelBase"], "kind">;
|
||||||
|
const reference: components["schemas"]["ReferencePublicRenderModel"] = {
|
||||||
|
...base,
|
||||||
|
kind: "REFERENCE",
|
||||||
|
purpose: "기준 목적",
|
||||||
|
rules: [{ id: "rule", title: "규칙", body: "본문", order: 1 }],
|
||||||
|
applyWhen: [{ id: "apply", text: "적용", order: 1 }],
|
||||||
|
exceptions: [{ id: "exception", text: "예외", order: 1 }],
|
||||||
|
examples: [{ id: "example", text: "예시", order: 1 }],
|
||||||
|
verifiedOn: "2030-01-02",
|
||||||
|
};
|
||||||
|
const question: components["schemas"]["QuestionPublicRenderModel"] = {
|
||||||
|
...base,
|
||||||
|
kind: "QUESTION",
|
||||||
|
status: "OPEN",
|
||||||
|
facts: [{ id: "fact", text: "사실", order: 1 }],
|
||||||
|
assumptions: [],
|
||||||
|
unknowns: [],
|
||||||
|
constraints: [{ id: "constraint", text: "제약", order: 1 }],
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: "option",
|
||||||
|
title: "선택지",
|
||||||
|
description: "선택 설명",
|
||||||
|
order: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
nextValidation: "다음에도 측정합니다.",
|
||||||
|
resolution: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const referenceView = renderInRouter(
|
||||||
|
<PublicRecordRenderer {...renderDependencies} model={reference} embedded />,
|
||||||
|
);
|
||||||
|
expect(referenceView.container.querySelector("main")).toBeNull();
|
||||||
|
const embedded = referenceView.container.querySelector(
|
||||||
|
".public-record-embedded",
|
||||||
|
);
|
||||||
|
expect(embedded).toHaveTextContent("수정 중인 제목");
|
||||||
|
expect(embedded).toHaveTextContent("저장 전 요약");
|
||||||
|
expect(embedded).toHaveTextContent("수정 Topic");
|
||||||
|
expect(embedded).toHaveTextContent("수정 Project");
|
||||||
|
expect(screen.getByText("마지막 검증 2030.01.02")).toBeVisible();
|
||||||
|
referenceView.unmount();
|
||||||
|
|
||||||
|
renderInRouter(
|
||||||
|
<PublicRecordRenderer {...renderDependencies} model={question} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("현재 기록된 가정이 없습니다.")).toBeVisible();
|
||||||
|
expect(screen.getByText("해결 과정에서 남은 미지수가 없습니다.")).toBeVisible();
|
||||||
|
expect(screen.getByRole("heading", { name: "다음 검증" }).nextElementSibling).toHaveTextContent(
|
||||||
|
"다음에도 측정합니다.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shared renderer interactions", () => {
|
||||||
|
it("announces code-copy success and resets its visible/live feedback", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const writeText = vi.fn(async () => undefined);
|
||||||
|
Object.defineProperty(window.navigator, "clipboard", {
|
||||||
|
configurable: true,
|
||||||
|
value: { writeText },
|
||||||
|
});
|
||||||
|
const view = render(
|
||||||
|
<CodeBlock language="Java" label="테스트" code="return page;" />,
|
||||||
|
);
|
||||||
|
const liveRegion = view.container.querySelector('[aria-live="polite"]');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "코드 복사" }));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
expect(writeText).toHaveBeenCalledWith("return page;");
|
||||||
|
expect(screen.getByRole("button", { name: "코드 복사" })).toHaveTextContent(
|
||||||
|
"복사됨",
|
||||||
|
);
|
||||||
|
expect(liveRegion).toHaveTextContent("코드를 클립보드에 복사했습니다.");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(2_000);
|
||||||
|
});
|
||||||
|
expect(screen.getByRole("button", { name: "코드 복사" })).toHaveTextContent(
|
||||||
|
"복사",
|
||||||
|
);
|
||||||
|
expect(liveRegion).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("announces clipboard failure and keeps code keyboard-scrollable", async () => {
|
||||||
|
Object.defineProperty(window.navigator, "clipboard", {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
writeText: vi.fn(async () => {
|
||||||
|
throw new Error("clipboard unavailable");
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const view = render(
|
||||||
|
<CodeBlock language="Java" label="부모 페이징" code="return page;" />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "코드 복사" }));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
expect(screen.getByRole("button", { name: "코드 복사" })).toHaveTextContent(
|
||||||
|
"복사 실패",
|
||||||
|
);
|
||||||
|
expect(view.container.querySelector('[aria-live="polite"]')).toHaveTextContent(
|
||||||
|
"코드를 클립보드에 복사하지 못했습니다.",
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("region", { name: "부모 페이징 코드" })).toHaveAttribute(
|
||||||
|
"tabindex",
|
||||||
|
"0",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens evidence zoom, closes from backdrop and button, and restores trigger focus", () => {
|
||||||
|
render(<EvidenceFigure resolveEvidenceAsset={getEvidenceAsset} />);
|
||||||
|
const trigger = screen.getByRole("button", {
|
||||||
|
name: "Fetch Join과 Batch Fetch 비교 다이어그램 크게 보기",
|
||||||
|
});
|
||||||
|
const dialog = screen.getByRole("dialog", { hidden: true });
|
||||||
|
expect(within(dialog).getByRole("img", { hidden: true })).toHaveAttribute(
|
||||||
|
"loading",
|
||||||
|
"lazy",
|
||||||
|
);
|
||||||
|
|
||||||
|
trigger.focus();
|
||||||
|
fireEvent.click(trigger);
|
||||||
|
expect(dialog).toHaveAttribute("open");
|
||||||
|
fireEvent.click(dialog);
|
||||||
|
expect(dialog).not.toHaveAttribute("open");
|
||||||
|
expect(trigger).toHaveFocus();
|
||||||
|
|
||||||
|
fireEvent.click(trigger);
|
||||||
|
expect(dialog).toHaveAttribute("open");
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "닫기" }));
|
||||||
|
expect(dialog).not.toHaveAttribute("open");
|
||||||
|
expect(trigger).toHaveFocus();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the evidence explanation as the zoom trigger description", () => {
|
||||||
|
render(<EvidenceFigure resolveEvidenceAsset={getEvidenceAsset} />);
|
||||||
|
const trigger = screen.getByRole("button", {
|
||||||
|
name: "Fetch Join과 Batch Fetch 비교 다이어그램 크게 보기",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(trigger).toHaveAccessibleDescription(
|
||||||
|
"Fetch Join은 전체 조인 결과를 읽은 뒤 메모리에서 20개를 고르고, Batch Fetch는 부모 20개를 먼저 고른 뒤 해당 ID의 컬렉션만 조회한다.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unknown and arbitrary evidence asset keys before rendering media", () => {
|
||||||
|
expect(() =>
|
||||||
|
render(
|
||||||
|
<PublicEvidenceFigure
|
||||||
|
evidenceKey="https://example.com/arbitrary.png"
|
||||||
|
alt="unsafe"
|
||||||
|
caption="unsafe"
|
||||||
|
zoom={false}
|
||||||
|
resolveEvidenceAsset={getEvidenceAsset}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
).toThrow(/Unknown local evidence asset/);
|
||||||
|
expect(document.querySelector('img[src^="https://example.com"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates mobile and desktop TOC selection without changing labels", () => {
|
||||||
|
const mobile = render(
|
||||||
|
<>
|
||||||
|
<h2 id="first">첫째</h2>
|
||||||
|
<h2 id="second">둘째</h2>
|
||||||
|
<DocumentToc
|
||||||
|
headings={[
|
||||||
|
{ id: "first", label: "첫째" },
|
||||||
|
{ id: "second", label: "둘째" },
|
||||||
|
]}
|
||||||
|
variant="mobile"
|
||||||
|
/>
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
const details = mobile.container.querySelector("details.mobile-toc");
|
||||||
|
expect(details?.querySelector("summary")).toHaveTextContent("목차 · 첫째");
|
||||||
|
if (details instanceof HTMLDetailsElement) details.open = true;
|
||||||
|
fireEvent.click(screen.getByRole("link", { name: "둘째" }));
|
||||||
|
expect(details).not.toHaveAttribute("open");
|
||||||
|
expect(screen.getByRole("link", { name: "둘째" })).toHaveAttribute(
|
||||||
|
"aria-current",
|
||||||
|
"location",
|
||||||
|
);
|
||||||
|
mobile.unmount();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DocumentToc
|
||||||
|
headings={[
|
||||||
|
{ id: "first", label: "첫째" },
|
||||||
|
{ id: "second", label: "둘째" },
|
||||||
|
]}
|
||||||
|
variant="desktop"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("complementary", { name: "문서 목차" })).toHaveTextContent(
|
||||||
|
"이 글에서첫째둘째",
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("link", { name: "첫째" })).toHaveAttribute(
|
||||||
|
"aria-current",
|
||||||
|
"location",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user