feat: port TechLog content format and renderer

This commit is contained in:
DongHyeonka
2026-08-15 18:44:09 +09:00
parent 82f94423e5
commit 16753f53af
18 changed files with 3148 additions and 0 deletions
@@ -0,0 +1,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>
</>
);
}
@@ -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);
}
}