diff --git a/src/features/tech-log/domain/content-format/heading-id.ts b/src/features/tech-log/domain/content-format/heading-id.ts new file mode 100644 index 0000000..42fc158 --- /dev/null +++ b/src/features/tech-log/domain/content-format/heading-id.ts @@ -0,0 +1,18 @@ +export function createHeadingId(text: string, used: Set): 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; +} diff --git a/src/features/tech-log/domain/content-format/inline-plain-text.ts b/src/features/tech-log/domain/content-format/inline-plain-text.ts new file mode 100644 index 0000000..e61abfb --- /dev/null +++ b/src/features/tech-log/domain/content-format/inline-plain-text.ts @@ -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): string; +export function inlinePlainText( + inline: Inline | ReadonlyArray, +): 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); + } +} diff --git a/src/features/tech-log/domain/content-format/parse-case-content.ts b/src/features/tech-log/domain/content-format/parse-case-content.ts new file mode 100644 index 0000000..9efa574 --- /dev/null +++ b/src/features/tech-log/domain/content-format/parse-case-content.ts @@ -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 | 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 | 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) { + 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, +): Record { + 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 = {}; + 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): 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, 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, +): 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, +): 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, +): + | 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(); + 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}`, + ); + } + } + }); +} diff --git a/src/features/tech-log/domain/content-format/project-public-render-model.ts b/src/features/tech-log/domain/content-format/project-public-render-model.ts new file mode 100644 index 0000000..ced551c --- /dev/null +++ b/src/features/tech-log/domain/content-format/project-public-render-model.ts @@ -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, + 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, +): 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, +): 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(items: ReadonlyArray): T[] { + return items + .map((item) => ({ ...item })) + .sort((left, right) => left.order - right.order); +} + +export function projectWorkingCopy( + input: WorkingCopyInput, + catalog: ReadonlyArray, + 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, + }; + } + } +} diff --git a/src/features/tech-log/domain/content-format/serialize-case-content.ts b/src/features/tech-log/domain/content-format/serialize-case-content.ts new file mode 100644 index 0000000..5df1ceb --- /dev/null +++ b/src/features/tech-log/domain/content-format/serialize-case-content.ts @@ -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): 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): 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, +): string { + return blocks.map(serializeBlock).join("\n\n"); +} diff --git a/src/features/tech-log/domain/public-render-content.ts b/src/features/tech-log/domain/public-render-content.ts new file mode 100644 index 0000000..6802904 --- /dev/null +++ b/src/features/tech-log/domain/public-render-content.ts @@ -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; diff --git a/src/features/tech-log/presentation/shared/public-render/callout.tsx b/src/features/tech-log/presentation/shared/public-render/callout.tsx new file mode 100644 index 0000000..64d0355 --- /dev/null +++ b/src/features/tech-log/presentation/shared/public-render/callout.tsx @@ -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 ( + + ); +} diff --git a/src/features/tech-log/presentation/shared/public-render/case-body-renderer.tsx b/src/features/tech-log/presentation/shared/public-render/case-body-renderer.tsx new file mode 100644 index 0000000..59dc451 --- /dev/null +++ b/src/features/tech-log/presentation/shared/public-render/case-body-renderer.tsx @@ -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 }, + + + + , + ); +} + +function renderBlock( + block: CaseRenderBlock, + key: string | number, + resolveEvidenceAsset: ResolveEvidenceAsset, +): ReactNode { + switch (block.type) { + case "HEADING": + return ; + case "PARAGRAPH": + return ( +

+ +

+ ); + case "BLOCKQUOTE": + return ( +
+ +
+ ); + case "UNORDERED_LIST": + return ( +
    + {block.items.map((item) => ( +
  • + +
  • + ))} +
+ ); + case "ORDERED_LIST": + return ( +
    + {block.items.map((item) => ( +
  1. + +
  2. + ))} +
+ ); + case "CODE_BLOCK": + return ( + + ); + case "DATA_TABLE": + return ; + case "CALLOUT": + return ; + case "EVIDENCE_FIGURE": + return ( + + ); + default: + return assertNever(block); + } +} + +type OwnedSection = { + heading: HeadingBlock; + blocks: CaseRenderBlock[]; +}; + +export function CaseBodyRenderer({ + blocks, + resolveEvidenceAsset, +}: { + blocks: ReadonlyArray; + 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 ? ( + + {rootBlocks.map((block, index) => + renderBlock(block, `root-${index}`, resolveEvidenceAsset), + )} + + ) : null} + {sections.map((section) => ( +
+ + {section.blocks.map((block, index) => + renderBlock( + block, + `${section.heading.id}-${index}`, + resolveEvidenceAsset, + ), + )} +
+ ))} + + ); +} diff --git a/src/features/tech-log/presentation/shared/public-render/code-block.tsx b/src/features/tech-log/presentation/shared/public-render/code-block.tsx new file mode 100644 index 0000000..bbcd3c9 --- /dev/null +++ b/src/features/tech-log/presentation/shared/public-render/code-block.tsx @@ -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 | 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 ( +
+
+ {language} + {label} + +
+
+        {code}
+      
+ + {copyStatus} + +
+ ); +} diff --git a/src/features/tech-log/presentation/shared/public-render/data-table.tsx b/src/features/tech-log/presentation/shared/public-render/data-table.tsx new file mode 100644 index 0000000..a5d9de2 --- /dev/null +++ b/src/features/tech-log/presentation/shared/public-render/data-table.tsx @@ -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 ( +
+ + + + + {block.columns.map((column) => ( + + ))} + + + + {block.rows.map((row) => ( + + {row.cells.map((cell, cellIndex) => { + if (cellIndex === rowHeaderIndex) { + return ( + + ); + } + + const headers = + rowHeaderIndex === null + ? cell.columnId + : `${row.id} ${cell.columnId}`; + return ( + + ); + })} + + ))} + +
{block.caption}
+ {column.label} +
+ + + +
+
+ ); +} diff --git a/src/features/tech-log/presentation/shared/public-render/document-toc.tsx b/src/features/tech-log/presentation/shared/public-render/document-toc.tsx new file mode 100644 index 0000000..a8752de --- /dev/null +++ b/src/features/tech-log/presentation/shared/public-render/document-toc.tsx @@ -0,0 +1,88 @@ +import { useEffect, useRef, useState } from "react"; + +type HeadingItem = { + id: string; + label: string; +}; + +type DocumentTocProps = { + headings: ReadonlyArray; + variant: "desktop" | "mobile"; +}; + +export function DocumentToc({ headings, variant }: DocumentTocProps) { + const [currentId, setCurrentId] = useState(headings[0]?.id ?? ""); + const detailsRef = useRef(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 ( +
+
+ 목차 · {current?.label} + +
+
+ ); + } + + return ( + + ); +} diff --git a/src/features/tech-log/presentation/shared/public-render/evidence-figure.tsx b/src/features/tech-log/presentation/shared/public-render/evidence-figure.tsx new file mode 100644 index 0000000..47b188e --- /dev/null +++ b/src/features/tech-log/presentation/shared/public-render/evidence-figure.tsx @@ -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(null); + const dialogRef = useRef(null); + + function open() { + dialogRef.current?.showModal(); + } + + function close() { + dialogRef.current?.close(); + } + + if (!zoom) { + return ( +
+ {alt} +
{caption}
+
+ ); + } + + return ( + <> +
+ +
{caption}
+
+ + triggerRef.current?.focus()} + onClick={(event) => { + if (event.target === event.currentTarget) close(); + }} + > +
+ + {alt} +

{caption}

+
+
+ + ); +} diff --git a/src/features/tech-log/presentation/shared/public-render/fetch-strategy-evidence-figure.tsx b/src/features/tech-log/presentation/shared/public-render/fetch-strategy-evidence-figure.tsx new file mode 100644 index 0000000..94d6ab2 --- /dev/null +++ b/src/features/tech-log/presentation/shared/public-render/fetch-strategy-evidence-figure.tsx @@ -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 ( + + ); +} diff --git a/src/features/tech-log/presentation/shared/public-render/inline-renderer.tsx b/src/features/tech-log/presentation/shared/public-render/inline-renderer.tsx new file mode 100644 index 0000000..9d94a25 --- /dev/null +++ b/src/features/tech-log/presentation/shared/public-render/inline-renderer.tsx @@ -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 {inline.text}; + case "INLINE_CODE": + return {inline.code}; + case "EMPHASIS": + return ( + + + + ); + case "STRONG": + return ( + + + + ); + case "LINK": + return ( + + {inline.label} + + ); + case "STATUS": + return ( + + {inline.label} + + ); + default: + return assertNever(inline); + } +} + +export function InlineRenderer({ + content, +}: { + content: ReadonlyArray; +}) { + return <>{content.map(renderInline)}; +} diff --git a/src/features/tech-log/presentation/shared/public-render/public-document-relations.tsx b/src/features/tech-log/presentation/shared/public-render/public-document-relations.tsx new file mode 100644 index 0000000..41cad1c --- /dev/null +++ b/src/features/tech-log/presentation/shared/public-render/public-document-relations.tsx @@ -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; + kicker?: string; + title?: string; +}) { + if (relations.length === 0) return null; + + return ( +
+

{kicker}

+

{title}

+
    + {relations.map((relation) => ( +
  • + + {relation.reason} + {relation.title} + + +
  • + ))} +
+
+ ); +} diff --git a/src/features/tech-log/presentation/shared/public-render/public-record-renderer.tsx b/src/features/tech-log/presentation/shared/public-render/public-record-renderer.tsx new file mode 100644 index 0000000..46cfdb1 --- /dev/null +++ b/src/features/tech-log/presentation/shared/public-render/public-record-renderer.tsx @@ -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 ? ( + {target.label} + ) : ( + {target.label} + ); +} + +function ModelDocumentHeader({ + model, + resolvePublishedLabel, +}: { + model: PublicRenderModelBase; + resolvePublishedLabel: ResolvePublishedLabel; +}) { + return ( +
+ +

{model.title}

+

{model.summary}

+
+
+
유형
+
{kindLabels[model.kind]}
+
+
+
프로젝트
+
{model.project?.label ?? "독립 기록"}
+
+
+
게시
+
{publishedLabel(model, resolvePublishedLabel)}
+
+
+
+ ); +} + +function publicRelations( + relations: ReadonlyArray, +): 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 ( + + +
+
+

문제

+

{model.problem}

+
+
+

결론

+

{model.conclusion}

+
+
+
+
+
검증 환경
+
{model.environment}
+
+
+
검증 데이터
+
{model.reproduction}
+
+
+
+ +
+ +
+ ); +} + +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 ( + +
+ + +

{model.title}

+

{model.summary}

+ +
+
+

문제

+

{model.problem}

+
+
+

결론

+

{model.conclusion}

+
+
+ +
+
+
검증 환경
+
{model.environment}
+
+
+
데이터셋
+
{model.reproduction.replace(/^Dataset:\s*/, "")}
+
+
+
기록
+
+ 게시 {publishedLabel(model, resolvePublishedLabel)} · 마지막 검증{" "} + {displayDate(model.lastVerifiedOn)} +
+
+
+
+ + + +
+
+ + +
+ +
+
+ ); +} + +function ReferenceDocument({ + model, + embedded, + resolvePublishedLabel, +}: { + model: ReferencePublicRenderModel; + embedded: boolean; + resolvePublishedLabel: ResolvePublishedLabel; +}) { + const Root = embedded ? "div" : "main"; + return ( + + +
+

Purpose

+

이 기준을 쓰는 이유

+

{model.purpose}

+
+
+
+

판단 기준

+
    + {[...model.rules] + .sort((left, right) => left.order - right.order) + .map((rule, index) => ( +
  1. + {String(index + 1).padStart(2, "0")} +
    +

    {rule.title}

    +

    {rule.body}

    +
    +
  2. + ))} +
+
+
+

적용할 때

+
    + {[...model.applyWhen] + .sort((left, right) => left.order - right.order) + .map((item) =>
  • {item.text}
  • )} +
+
+
+

예외와 주의

+
    + {[...model.exceptions] + .sort((left, right) => left.order - right.order) + .map((item) =>
  • {item.text}
  • )} +
+
+
+

예시

+
    + {[...model.examples] + .sort((left, right) => left.order - right.order) + .map((item) =>
  • {item.text}
  • )} +
+

+ 마지막 검증 {displayDate(model.verifiedOn)} +

+
+
+ +
+ ); +} + +function OrderedItems({ + items, + empty, +}: { + items: ReadonlyArray; + empty?: string; +}) { + if (items.length === 0 && empty) return

{empty}

; + return ( +
    + {[...items] + .sort((left, right) => left.order - right.order) + .map((item) =>
  • {item.text}
  • )} +
+ ); +} + +function QuestionDocument({ + model, + embedded, + resolvePublishedLabel, +}: { + model: QuestionPublicRenderModel; + embedded: boolean; + resolvePublishedLabel: ResolvePublishedLabel; +}) { + const resolutionPath = model.resolution?.evidenceTarget.publicPath; + const Root = embedded ? "div" : "main"; + + return ( + + +

+ {model.status} +

+ {model.resolution ? ( + + ) : null} +
+
+

확인한 사실

+ +
+
+

가정

+ +
+
+

남은 미지수

+ +
+
+

제약

+ +
+
+

검토한 선택지

+
    + {[...model.options] + .sort((left, right) => left.order - right.order) + .map((option) => ( +
  1. +

    {option.title}

    +

    {option.description}

    +
  2. + ))} +
+
+
+

Next

+

다음 검증

+

{model.nextValidation}

+
+
+ +
+ ); +} + +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" ? ( + + ) : ( + + ); + case "REFERENCE": + return ( + + ); + case "QUESTION": + return ( + + ); + default: + return assertNever(model); + } +} diff --git a/tests/features/tech-log/content-format.test.ts b/tests/features/tech-log/content-format.test.ts new file mode 100644 index 0000000..bb9660c --- /dev/null +++ b/tests/features/tech-log/content-format.test.ts @@ -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 = [ + "", + "[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"); + 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 [ + "", + "![image](https://example.com/image.png)", + "~~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"); + }); +}); diff --git a/tests/features/tech-log/public-render.test.tsx b/tests/features/tech-log/public-render.test.tsx new file mode 100644 index 0000000..97b95c4 --- /dev/null +++ b/tests/features/tech-log/public-render.test.tsx @@ -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"] { + 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({node}); +} + +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( + , + ); + + 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( + , + ); + + 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( + ', + }, + ]} + />, + ); + + expect(view.container.querySelector("img")).toBeNull(); + expect(view.container.querySelector("script")).toBeNull(); + expect(view.container).toHaveTextContent( + '', + ); + }); + + 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(); + 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( + , + ); + 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( + , + ); + expect(screen.getByRole("complementary", { name: "정보" })).toHaveTextContent( + "정보설명", + ); + }); +}); + +describe("shared Public record renderer", () => { + it("preserves the canonical Fetch Join layout, TOC, metadata, and relations", () => { + renderInRouter( + , + ); + + 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( + , + ); + + 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; + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + + 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(); + 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(); + 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( + , + ), + ).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( + <> +

첫째

+

둘째

+ + , + ); + 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( + , + ); + expect(screen.getByRole("complementary", { name: "문서 목차" })).toHaveTextContent( + "이 글에서첫째둘째", + ); + expect(screen.getByRole("link", { name: "첫째" })).toHaveAttribute( + "aria-current", + "location", + ); + }); +});