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 @@
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);
}
}