feat: port TechLog content format and renderer
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
import type { components } from "../../../contracts/studio/generated.ts";
|
||||
import { InlineRenderer } from "./inline-renderer.tsx";
|
||||
|
||||
type CalloutBlock = components["schemas"]["CalloutBlock"];
|
||||
|
||||
export function Callout({ block }: { block: CalloutBlock }) {
|
||||
return (
|
||||
<aside
|
||||
className={`callout callout--${block.tone}`}
|
||||
aria-label={block.label}
|
||||
>
|
||||
<p className="callout-label">{block.label}</p>
|
||||
<p>
|
||||
<InlineRenderer content={block.content} />
|
||||
</p>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { createElement, Fragment, type ReactNode } from "react";
|
||||
|
||||
import type { components } from "../../../contracts/studio/generated.ts";
|
||||
import type { ResolveEvidenceAsset } from "../../../domain/public-render-content.ts";
|
||||
import { inlinePlainText } from "../../../domain/content-format/inline-plain-text.ts";
|
||||
import { Callout } from "./callout.tsx";
|
||||
import { CodeBlock } from "./code-block.tsx";
|
||||
import { DataTable } from "./data-table.tsx";
|
||||
import { PublicEvidenceFigure } from "./evidence-figure.tsx";
|
||||
import { InlineRenderer } from "./inline-renderer.tsx";
|
||||
|
||||
type CaseRenderBlock = components["schemas"]["CaseRenderBlock"];
|
||||
type HeadingBlock = components["schemas"]["HeadingBlock"];
|
||||
|
||||
function assertNever(value: never): never {
|
||||
throw new Error(`Unsupported Case render block: ${JSON.stringify(value)}`);
|
||||
}
|
||||
|
||||
function Heading({ block }: { block: HeadingBlock }) {
|
||||
const level = Math.min(4, Math.max(2, block.level));
|
||||
return createElement(
|
||||
`h${level}`,
|
||||
{ id: block.id },
|
||||
<a
|
||||
className="heading-anchor"
|
||||
href={`#${block.id}`}
|
||||
aria-label={`${inlinePlainText(block.content)} 바로가기`}
|
||||
>
|
||||
<InlineRenderer content={block.content} />
|
||||
<span aria-hidden="true">#</span>
|
||||
</a>,
|
||||
);
|
||||
}
|
||||
|
||||
function renderBlock(
|
||||
block: CaseRenderBlock,
|
||||
key: string | number,
|
||||
resolveEvidenceAsset: ResolveEvidenceAsset,
|
||||
): ReactNode {
|
||||
switch (block.type) {
|
||||
case "HEADING":
|
||||
return <Heading key={key} block={block} />;
|
||||
case "PARAGRAPH":
|
||||
return (
|
||||
<p key={key}>
|
||||
<InlineRenderer content={block.content} />
|
||||
</p>
|
||||
);
|
||||
case "BLOCKQUOTE":
|
||||
return (
|
||||
<blockquote key={key}>
|
||||
<InlineRenderer content={block.content} />
|
||||
</blockquote>
|
||||
);
|
||||
case "UNORDERED_LIST":
|
||||
return (
|
||||
<ul key={key}>
|
||||
{block.items.map((item) => (
|
||||
<li key={item.id}>
|
||||
<InlineRenderer content={item.content} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
case "ORDERED_LIST":
|
||||
return (
|
||||
<ol key={key}>
|
||||
{block.items.map((item) => (
|
||||
<li key={item.id}>
|
||||
<InlineRenderer content={item.content} />
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
case "CODE_BLOCK":
|
||||
return (
|
||||
<CodeBlock
|
||||
key={key}
|
||||
language={(block.language ?? "text").toUpperCase()}
|
||||
label={block.label ?? "코드"}
|
||||
code={block.code}
|
||||
contentRole={
|
||||
block.label === "실패한 목록 조회" ? "재현 쿼리" : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
case "DATA_TABLE":
|
||||
return <DataTable key={key} block={block} />;
|
||||
case "CALLOUT":
|
||||
return <Callout key={key} block={block} />;
|
||||
case "EVIDENCE_FIGURE":
|
||||
return (
|
||||
<PublicEvidenceFigure
|
||||
key={key}
|
||||
evidenceKey={block.key}
|
||||
alt={block.alt}
|
||||
caption={block.caption}
|
||||
zoom={block.zoom}
|
||||
resolveEvidenceAsset={resolveEvidenceAsset}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return assertNever(block);
|
||||
}
|
||||
}
|
||||
|
||||
type OwnedSection = {
|
||||
heading: HeadingBlock;
|
||||
blocks: CaseRenderBlock[];
|
||||
};
|
||||
|
||||
export function CaseBodyRenderer({
|
||||
blocks,
|
||||
resolveEvidenceAsset,
|
||||
}: {
|
||||
blocks: ReadonlyArray<CaseRenderBlock>;
|
||||
resolveEvidenceAsset: ResolveEvidenceAsset;
|
||||
}) {
|
||||
const rootBlocks: CaseRenderBlock[] = [];
|
||||
const sections: OwnedSection[] = [];
|
||||
let currentSection: OwnedSection | null = null;
|
||||
|
||||
for (const block of blocks) {
|
||||
if (block.type === "HEADING") {
|
||||
currentSection = { heading: block, blocks: [] };
|
||||
sections.push(currentSection);
|
||||
} else if (currentSection) {
|
||||
currentSection.blocks.push(block);
|
||||
} else {
|
||||
rootBlocks.push(block);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{rootBlocks.length > 0 ? (
|
||||
<Fragment>
|
||||
{rootBlocks.map((block, index) =>
|
||||
renderBlock(block, `root-${index}`, resolveEvidenceAsset),
|
||||
)}
|
||||
</Fragment>
|
||||
) : null}
|
||||
{sections.map((section) => (
|
||||
<section key={section.heading.id} aria-labelledby={section.heading.id}>
|
||||
<Heading block={section.heading} />
|
||||
{section.blocks.map((block, index) =>
|
||||
renderBlock(
|
||||
block,
|
||||
`${section.heading.id}-${index}`,
|
||||
resolveEvidenceAsset,
|
||||
),
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
type CodeBlockProps = {
|
||||
language: string;
|
||||
label: string;
|
||||
code: string;
|
||||
contentRole?: string;
|
||||
};
|
||||
|
||||
export function CodeBlock({
|
||||
language,
|
||||
label,
|
||||
code,
|
||||
contentRole,
|
||||
}: CodeBlockProps) {
|
||||
const [copyLabel, setCopyLabel] = useState("복사");
|
||||
const [copyStatus, setCopyStatus] = useState("");
|
||||
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (resetTimer.current) clearTimeout(resetTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function copy() {
|
||||
if (resetTimer.current) clearTimeout(resetTimer.current);
|
||||
setCopyStatus("");
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopyLabel("복사됨");
|
||||
setCopyStatus("코드를 클립보드에 복사했습니다.");
|
||||
} catch {
|
||||
setCopyLabel("복사 실패");
|
||||
setCopyStatus("코드를 클립보드에 복사하지 못했습니다.");
|
||||
}
|
||||
|
||||
resetTimer.current = setTimeout(() => {
|
||||
setCopyLabel("복사");
|
||||
setCopyStatus("");
|
||||
}, 2_000);
|
||||
}
|
||||
|
||||
return (
|
||||
<figure className="code-block" data-content-role={contentRole}>
|
||||
<figcaption>
|
||||
<span>{language}</span>
|
||||
<span>{label}</span>
|
||||
<button type="button" aria-label="코드 복사" onClick={copy}>
|
||||
{copyLabel}
|
||||
</button>
|
||||
</figcaption>
|
||||
<pre role="region" aria-label={`${label} 코드`} tabIndex={0}>
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
<span className="visually-hidden" aria-live="polite">
|
||||
{copyStatus}
|
||||
</span>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { components } from "../../../contracts/studio/generated.ts";
|
||||
import { InlineRenderer } from "./inline-renderer.tsx";
|
||||
|
||||
type DataTableBlock = components["schemas"]["DataTableBlock"];
|
||||
|
||||
export function DataTable({ block }: { block: DataTableBlock }) {
|
||||
const rowHeaderIndex =
|
||||
block.rowHeaderColumn === null ? null : block.rowHeaderColumn - 1;
|
||||
const isFetchObservation = block.id === "fetch-strategy-observation";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="data-table-wrap"
|
||||
role="region"
|
||||
aria-label={
|
||||
isFetchObservation ? "Fetch 전략 측정표" : `${block.caption} 표`
|
||||
}
|
||||
data-content-role={isFetchObservation ? "반환 손실" : undefined}
|
||||
tabIndex={0}
|
||||
>
|
||||
<table>
|
||||
<caption>{block.caption}</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
{block.columns.map((column) => (
|
||||
<th key={column.id} id={column.id} scope="col">
|
||||
{column.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{block.rows.map((row) => (
|
||||
<tr key={row.id}>
|
||||
{row.cells.map((cell, cellIndex) => {
|
||||
if (cellIndex === rowHeaderIndex) {
|
||||
return (
|
||||
<th key={cell.columnId} id={row.id} scope="row">
|
||||
<InlineRenderer content={cell.content} />
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
const headers =
|
||||
rowHeaderIndex === null
|
||||
? cell.columnId
|
||||
: `${row.id} ${cell.columnId}`;
|
||||
return (
|
||||
<td key={cell.columnId} headers={headers}>
|
||||
<InlineRenderer content={cell.content} />
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
type HeadingItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type DocumentTocProps = {
|
||||
headings: ReadonlyArray<HeadingItem>;
|
||||
variant: "desktop" | "mobile";
|
||||
};
|
||||
|
||||
export function DocumentToc({ headings, variant }: DocumentTocProps) {
|
||||
const [currentId, setCurrentId] = useState(headings[0]?.id ?? "");
|
||||
const detailsRef = useRef<HTMLDetailsElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const elements = headings
|
||||
.map((heading) => document.getElementById(heading.id))
|
||||
.filter((element): element is HTMLElement => Boolean(element));
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries
|
||||
.filter((entry) => entry.isIntersecting)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.boundingClientRect.top - right.boundingClientRect.top,
|
||||
);
|
||||
if (visible[0]) setCurrentId(visible[0].target.id);
|
||||
},
|
||||
{ rootMargin: "-12% 0px -72% 0px", threshold: [0, 1] },
|
||||
);
|
||||
|
||||
elements.forEach((element) => observer.observe(element));
|
||||
return () => observer.disconnect();
|
||||
}, [headings]);
|
||||
|
||||
function select(id: string) {
|
||||
setCurrentId(id);
|
||||
if (detailsRef.current) detailsRef.current.open = false;
|
||||
}
|
||||
|
||||
if (variant === "mobile") {
|
||||
const current =
|
||||
headings.find((heading) => heading.id === currentId) ?? headings[0];
|
||||
|
||||
return (
|
||||
<div className="shell mobile-toc-wrap">
|
||||
<details className="mobile-toc" ref={detailsRef}>
|
||||
<summary>목차 · {current?.label}</summary>
|
||||
<nav aria-label="문서 목차">
|
||||
{headings.map((heading) => (
|
||||
<a
|
||||
href={`#${heading.id}`}
|
||||
key={heading.id}
|
||||
aria-current={
|
||||
heading.id === currentId ? "location" : undefined
|
||||
}
|
||||
onClick={() => select(heading.id)}
|
||||
>
|
||||
{heading.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="document-toc" aria-label="문서 목차">
|
||||
<p>이 글에서</p>
|
||||
<nav>
|
||||
{headings.map((heading) => (
|
||||
<a
|
||||
href={`#${heading.id}`}
|
||||
key={heading.id}
|
||||
aria-current={heading.id === currentId ? "location" : undefined}
|
||||
onClick={() => select(heading.id)}
|
||||
>
|
||||
{heading.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useId, useRef } from "react";
|
||||
|
||||
import type { ResolveEvidenceAsset } from "../../../domain/public-render-content.ts";
|
||||
|
||||
type PublicEvidenceFigureProps = {
|
||||
evidenceKey: string;
|
||||
alt: string;
|
||||
caption: string;
|
||||
zoom: boolean;
|
||||
resolveEvidenceAsset: ResolveEvidenceAsset;
|
||||
};
|
||||
|
||||
export function PublicEvidenceFigure({
|
||||
evidenceKey,
|
||||
alt,
|
||||
caption,
|
||||
zoom,
|
||||
resolveEvidenceAsset,
|
||||
}: PublicEvidenceFigureProps) {
|
||||
const asset = resolveEvidenceAsset(evidenceKey);
|
||||
const descriptionId = useId();
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
function open() {
|
||||
dialogRef.current?.showModal();
|
||||
}
|
||||
|
||||
function close() {
|
||||
dialogRef.current?.close();
|
||||
}
|
||||
|
||||
if (!zoom) {
|
||||
return (
|
||||
<figure className="evidence-figure">
|
||||
<img
|
||||
src={asset.src}
|
||||
width={asset.width}
|
||||
height={asset.height}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
/>
|
||||
<figcaption>{caption}</figcaption>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<figure className="evidence-figure">
|
||||
<button
|
||||
ref={triggerRef}
|
||||
className="evidence-image-button"
|
||||
type="button"
|
||||
aria-label={asset.triggerLabel}
|
||||
aria-describedby={descriptionId}
|
||||
onClick={open}
|
||||
>
|
||||
<img
|
||||
src={asset.src}
|
||||
width={asset.width}
|
||||
height={asset.height}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
/>
|
||||
<span>크게 보기</span>
|
||||
<span className="visually-hidden" id={descriptionId}>
|
||||
{alt}
|
||||
</span>
|
||||
</button>
|
||||
<figcaption>{caption}</figcaption>
|
||||
</figure>
|
||||
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
className="figure-dialog"
|
||||
aria-label={asset.dialogLabel}
|
||||
onClose={() => triggerRef.current?.focus()}
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) close();
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<button type="button" className="dialog-close" onClick={close}>
|
||||
닫기
|
||||
</button>
|
||||
<img
|
||||
src={asset.src}
|
||||
width={asset.width}
|
||||
height={asset.height}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
/>
|
||||
<p>{caption}</p>
|
||||
</div>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import type { ResolveEvidenceAsset } from "../../../domain/public-render-content.ts";
|
||||
import { PublicEvidenceFigure } from "./evidence-figure.tsx";
|
||||
|
||||
const diagramAlt =
|
||||
"Fetch Join은 전체 조인 결과를 읽은 뒤 메모리에서 20개를 고르고, Batch Fetch는 부모 20개를 먼저 고른 뒤 해당 ID의 컬렉션만 조회한다.";
|
||||
|
||||
export function EvidenceFigure({
|
||||
resolveEvidenceAsset,
|
||||
}: {
|
||||
resolveEvidenceAsset: ResolveEvidenceAsset;
|
||||
}) {
|
||||
return (
|
||||
<PublicEvidenceFigure
|
||||
evidenceKey="fetch-strategy-boundary"
|
||||
alt={diagramAlt}
|
||||
caption="페이징이 적용된 지점이 부모 조회 앞으로 이동한다."
|
||||
zoom
|
||||
resolveEvidenceAsset={resolveEvidenceAsset}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Fragment } from "react";
|
||||
|
||||
import type { components } from "../../../contracts/studio/generated.ts";
|
||||
|
||||
type Inline = components["schemas"]["Inline"];
|
||||
|
||||
function assertNever(value: never): never {
|
||||
throw new Error(`Unsupported inline value: ${JSON.stringify(value)}`);
|
||||
}
|
||||
|
||||
function renderInline(inline: Inline, key: number) {
|
||||
switch (inline.type) {
|
||||
case "TEXT":
|
||||
return <Fragment key={key}>{inline.text}</Fragment>;
|
||||
case "INLINE_CODE":
|
||||
return <code key={key}>{inline.code}</code>;
|
||||
case "EMPHASIS":
|
||||
return (
|
||||
<em key={key}>
|
||||
<InlineRenderer content={inline.children} />
|
||||
</em>
|
||||
);
|
||||
case "STRONG":
|
||||
return (
|
||||
<strong key={key}>
|
||||
<InlineRenderer content={inline.children} />
|
||||
</strong>
|
||||
);
|
||||
case "LINK":
|
||||
return (
|
||||
<a key={key} href={inline.href}>
|
||||
{inline.label}
|
||||
</a>
|
||||
);
|
||||
case "STATUS":
|
||||
return (
|
||||
<span key={key} className={`status status--${inline.tone}`}>
|
||||
{inline.label}
|
||||
</span>
|
||||
);
|
||||
default:
|
||||
return assertNever(inline);
|
||||
}
|
||||
}
|
||||
|
||||
export function InlineRenderer({
|
||||
content,
|
||||
}: {
|
||||
content: ReadonlyArray<Inline>;
|
||||
}) {
|
||||
return <>{content.map(renderInline)}</>;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export type PublicRelation = Readonly<{
|
||||
reason: string;
|
||||
title: string;
|
||||
path: string;
|
||||
}>;
|
||||
|
||||
export function PublicDocumentRelations({
|
||||
relations,
|
||||
kicker = "Relations",
|
||||
title = "이 기록과 연결된 맥락",
|
||||
}: {
|
||||
relations: ReadonlyArray<PublicRelation>;
|
||||
kicker?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
if (relations.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="document-relations" aria-labelledby="relations-title">
|
||||
<p className="section-kicker">{kicker}</p>
|
||||
<h2 id="relations-title">{title}</h2>
|
||||
<ul>
|
||||
{relations.map((relation) => (
|
||||
<li key={relation.path}>
|
||||
<Link to={relation.path}>
|
||||
<span>{relation.reason}</span>
|
||||
<strong>{relation.title}</strong>
|
||||
<span aria-hidden="true">↗</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import type { components } from "../../../contracts/studio/generated.ts";
|
||||
import { inlinePlainText } from "../../../domain/content-format/inline-plain-text.ts";
|
||||
import type {
|
||||
ResolveEvidenceAsset,
|
||||
ResolvePublishedLabel,
|
||||
} from "../../../domain/public-render-content.ts";
|
||||
import { CaseBodyRenderer } from "./case-body-renderer.tsx";
|
||||
import { DocumentToc } from "./document-toc.tsx";
|
||||
import {
|
||||
PublicDocumentRelations,
|
||||
type PublicRelation,
|
||||
} from "./public-document-relations.tsx";
|
||||
|
||||
type PublicRenderModel = components["schemas"]["PublicRenderModel"];
|
||||
type PublicRenderModelBase = components["schemas"]["PublicRenderModelBase"];
|
||||
type ResolvedRelation = components["schemas"]["ResolvedRelation"];
|
||||
type CasePublicRenderModel = components["schemas"]["CasePublicRenderModel"];
|
||||
type ReferencePublicRenderModel =
|
||||
components["schemas"]["ReferencePublicRenderModel"];
|
||||
type QuestionPublicRenderModel =
|
||||
components["schemas"]["QuestionPublicRenderModel"];
|
||||
|
||||
type RenderDependencies = {
|
||||
resolveEvidenceAsset: ResolveEvidenceAsset;
|
||||
resolvePublishedLabel: ResolvePublishedLabel;
|
||||
};
|
||||
|
||||
const kindLabels = {
|
||||
CASE: "Case",
|
||||
REFERENCE: "Reference",
|
||||
QUESTION: "Open Question",
|
||||
} as const;
|
||||
|
||||
function assertNever(value: never): never {
|
||||
throw new Error(`Unsupported Public render model: ${JSON.stringify(value)}`);
|
||||
}
|
||||
|
||||
function displayDate(value: string) {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(value);
|
||||
return match ? `${match[1]}.${match[2]}.${match[3]}` : value;
|
||||
}
|
||||
|
||||
function publishedLabel(
|
||||
model: PublicRenderModelBase,
|
||||
resolvePublishedLabel: ResolvePublishedLabel,
|
||||
) {
|
||||
return resolvePublishedLabel(model.publicPath) ?? "게시 전";
|
||||
}
|
||||
|
||||
function explorePath(kind: PublicRenderModelBase["kind"]) {
|
||||
if (kind === "CASE") return "/explore/cases";
|
||||
if (kind === "REFERENCE") return "/explore/references";
|
||||
return "/explore/questions";
|
||||
}
|
||||
|
||||
function TargetLink({
|
||||
target,
|
||||
}: {
|
||||
target: components["schemas"]["DisplayTarget"];
|
||||
}) {
|
||||
return target.publicPath ? (
|
||||
<Link to={target.publicPath}>{target.label}</Link>
|
||||
) : (
|
||||
<span>{target.label}</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelDocumentHeader({
|
||||
model,
|
||||
resolvePublishedLabel,
|
||||
}: {
|
||||
model: PublicRenderModelBase;
|
||||
resolvePublishedLabel: ResolvePublishedLabel;
|
||||
}) {
|
||||
return (
|
||||
<header className="public-document-header">
|
||||
<nav aria-label="문서 경로">
|
||||
<Link to={explorePath(model.kind)}>{kindLabels[model.kind]}</Link>
|
||||
<span aria-hidden="true">/</span>
|
||||
<TargetLink target={model.topic} />
|
||||
{model.project ? (
|
||||
<>
|
||||
<span aria-hidden="true">/</span>
|
||||
<TargetLink target={model.project} />
|
||||
</>
|
||||
) : null}
|
||||
</nav>
|
||||
<h1>{model.title}</h1>
|
||||
<p>{model.summary}</p>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>유형</dt>
|
||||
<dd>{kindLabels[model.kind]}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>프로젝트</dt>
|
||||
<dd>{model.project?.label ?? "독립 기록"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>게시</dt>
|
||||
<dd>{publishedLabel(model, resolvePublishedLabel)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function publicRelations(
|
||||
relations: ReadonlyArray<ResolvedRelation>,
|
||||
): PublicRelation[] {
|
||||
return [...relations]
|
||||
.filter(
|
||||
(relation): relation is ResolvedRelation & { publicPath: string } =>
|
||||
relation.publicPath !== null,
|
||||
)
|
||||
.sort((left, right) => left.order - right.order)
|
||||
.map((relation) => ({
|
||||
reason: relation.reason,
|
||||
title: relation.title,
|
||||
path: relation.publicPath,
|
||||
}));
|
||||
}
|
||||
|
||||
function GenericCase({
|
||||
model,
|
||||
embedded,
|
||||
resolveEvidenceAsset,
|
||||
resolvePublishedLabel,
|
||||
}: {
|
||||
model: CasePublicRenderModel;
|
||||
embedded: boolean;
|
||||
} & RenderDependencies) {
|
||||
const Root = embedded ? "div" : "main";
|
||||
return (
|
||||
<Root
|
||||
id={embedded ? undefined : "main-content"}
|
||||
className={`shell public-document-page${embedded ? " public-record-embedded" : ""}`}
|
||||
>
|
||||
<ModelDocumentHeader
|
||||
model={model}
|
||||
resolvePublishedLabel={resolvePublishedLabel}
|
||||
/>
|
||||
<section className="document-snapshot" aria-label="문제와 결론">
|
||||
<div>
|
||||
<p className="snapshot-label">문제</p>
|
||||
<p>{model.problem}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="snapshot-label snapshot-label--answer">결론</p>
|
||||
<p>{model.conclusion}</p>
|
||||
</div>
|
||||
</section>
|
||||
<dl className="document-facts">
|
||||
<div>
|
||||
<dt>검증 환경</dt>
|
||||
<dd>{model.environment}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>검증 데이터</dt>
|
||||
<dd>{model.reproduction}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<article className="public-document-body">
|
||||
<CaseBodyRenderer
|
||||
blocks={model.bodyBlocks}
|
||||
resolveEvidenceAsset={resolveEvidenceAsset}
|
||||
/>
|
||||
</article>
|
||||
<PublicDocumentRelations relations={publicRelations(model.relations)} />
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
function FetchJoinCase({
|
||||
model,
|
||||
embedded,
|
||||
resolveEvidenceAsset,
|
||||
resolvePublishedLabel,
|
||||
}: {
|
||||
model: CasePublicRenderModel;
|
||||
embedded: boolean;
|
||||
} & RenderDependencies) {
|
||||
const headings = model.bodyBlocks
|
||||
.filter((block) => block.type === "HEADING")
|
||||
.map((heading) => ({
|
||||
id: heading.id,
|
||||
label: inlinePlainText(heading.content),
|
||||
}));
|
||||
|
||||
const Root = embedded ? "div" : "main";
|
||||
return (
|
||||
<Root
|
||||
id={embedded ? undefined : "main-content"}
|
||||
className={`case-page${embedded ? " public-record-embedded" : ""}`}
|
||||
>
|
||||
<header className="shell case-header">
|
||||
<nav className="case-breadcrumb" aria-label="문서 경로">
|
||||
<Link to="/explore/cases">Case</Link>
|
||||
<span aria-hidden="true">/</span>
|
||||
<TargetLink target={model.topic} />
|
||||
{model.project ? (
|
||||
<>
|
||||
<span aria-hidden="true">/</span>
|
||||
<TargetLink target={model.project} />
|
||||
</>
|
||||
) : null}
|
||||
</nav>
|
||||
|
||||
<h1>{model.title}</h1>
|
||||
<p className="case-summary">{model.summary}</p>
|
||||
|
||||
<section className="case-snapshot" aria-label="문제와 결론">
|
||||
<div>
|
||||
<p className="snapshot-label">문제</p>
|
||||
<p>{model.problem}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="snapshot-label snapshot-label--answer">결론</p>
|
||||
<p>{model.conclusion}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<dl className="case-meta">
|
||||
<div>
|
||||
<dt>검증 환경</dt>
|
||||
<dd>{model.environment}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>데이터셋</dt>
|
||||
<dd>{model.reproduction.replace(/^Dataset:\s*/, "")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>기록</dt>
|
||||
<dd>
|
||||
게시 {publishedLabel(model, resolvePublishedLabel)} · 마지막 검증{" "}
|
||||
{displayDate(model.lastVerifiedOn)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</header>
|
||||
|
||||
<DocumentToc headings={headings} variant="mobile" />
|
||||
|
||||
<div className="shell document-layout">
|
||||
<article className="document-body">
|
||||
<CaseBodyRenderer
|
||||
blocks={model.bodyBlocks}
|
||||
resolveEvidenceAsset={resolveEvidenceAsset}
|
||||
/>
|
||||
<PublicDocumentRelations
|
||||
relations={publicRelations(model.relations)}
|
||||
kicker="Explicit relations"
|
||||
title="이 기록의 연결"
|
||||
/>
|
||||
</article>
|
||||
<DocumentToc headings={headings} variant="desktop" />
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
function ReferenceDocument({
|
||||
model,
|
||||
embedded,
|
||||
resolvePublishedLabel,
|
||||
}: {
|
||||
model: ReferencePublicRenderModel;
|
||||
embedded: boolean;
|
||||
resolvePublishedLabel: ResolvePublishedLabel;
|
||||
}) {
|
||||
const Root = embedded ? "div" : "main";
|
||||
return (
|
||||
<Root
|
||||
id={embedded ? undefined : "main-content"}
|
||||
className={`shell public-document-page${embedded ? " public-record-embedded" : ""}`}
|
||||
>
|
||||
<ModelDocumentHeader
|
||||
model={model}
|
||||
resolvePublishedLabel={resolvePublishedLabel}
|
||||
/>
|
||||
<section className="reference-purpose" aria-labelledby="purpose-title">
|
||||
<p className="section-kicker">Purpose</p>
|
||||
<h2 id="purpose-title">이 기준을 쓰는 이유</h2>
|
||||
<p>{model.purpose}</p>
|
||||
</section>
|
||||
<article className="public-document-body reference-body">
|
||||
<section aria-labelledby="rules-title">
|
||||
<h2 id="rules-title">판단 기준</h2>
|
||||
<ol className="reference-rules">
|
||||
{[...model.rules]
|
||||
.sort((left, right) => left.order - right.order)
|
||||
.map((rule, index) => (
|
||||
<li key={rule.id}>
|
||||
<span>{String(index + 1).padStart(2, "0")}</span>
|
||||
<div>
|
||||
<h3>{rule.title}</h3>
|
||||
<p>{rule.body}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
<section aria-labelledby="apply-title">
|
||||
<h2 id="apply-title">적용할 때</h2>
|
||||
<ul>
|
||||
{[...model.applyWhen]
|
||||
.sort((left, right) => left.order - right.order)
|
||||
.map((item) => <li key={item.id}>{item.text}</li>)}
|
||||
</ul>
|
||||
</section>
|
||||
<section aria-labelledby="exceptions-title">
|
||||
<h2 id="exceptions-title">예외와 주의</h2>
|
||||
<ul>
|
||||
{[...model.exceptions]
|
||||
.sort((left, right) => left.order - right.order)
|
||||
.map((item) => <li key={item.id}>{item.text}</li>)}
|
||||
</ul>
|
||||
</section>
|
||||
<section aria-labelledby="examples-title">
|
||||
<h2 id="examples-title">예시</h2>
|
||||
<ul>
|
||||
{[...model.examples]
|
||||
.sort((left, right) => left.order - right.order)
|
||||
.map((item) => <li key={item.id}>{item.text}</li>)}
|
||||
</ul>
|
||||
<p className="verified-at">
|
||||
마지막 검증 {displayDate(model.verifiedOn)}
|
||||
</p>
|
||||
</section>
|
||||
</article>
|
||||
<PublicDocumentRelations relations={publicRelations(model.relations)} />
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
function OrderedItems({
|
||||
items,
|
||||
empty,
|
||||
}: {
|
||||
items: ReadonlyArray<components["schemas"]["OrderedText"]>;
|
||||
empty?: string;
|
||||
}) {
|
||||
if (items.length === 0 && empty) return <p>{empty}</p>;
|
||||
return (
|
||||
<ul>
|
||||
{[...items]
|
||||
.sort((left, right) => left.order - right.order)
|
||||
.map((item) => <li key={item.id}>{item.text}</li>)}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function QuestionDocument({
|
||||
model,
|
||||
embedded,
|
||||
resolvePublishedLabel,
|
||||
}: {
|
||||
model: QuestionPublicRenderModel;
|
||||
embedded: boolean;
|
||||
resolvePublishedLabel: ResolvePublishedLabel;
|
||||
}) {
|
||||
const resolutionPath = model.resolution?.evidenceTarget.publicPath;
|
||||
const Root = embedded ? "div" : "main";
|
||||
|
||||
return (
|
||||
<Root
|
||||
id={embedded ? undefined : "main-content"}
|
||||
className={`shell public-document-page${embedded ? " public-record-embedded" : ""}`}
|
||||
>
|
||||
<ModelDocumentHeader
|
||||
model={model}
|
||||
resolvePublishedLabel={resolvePublishedLabel}
|
||||
/>
|
||||
<p
|
||||
className={`question-status question-status--${model.status.toLowerCase()}`}
|
||||
>
|
||||
{model.status}
|
||||
</p>
|
||||
{model.resolution ? (
|
||||
<aside className="question-resolution">
|
||||
<strong>{model.resolution.summary}</strong>
|
||||
{resolutionPath ? (
|
||||
<Link to={resolutionPath}>{model.resolution.linkLabel}</Link>
|
||||
) : (
|
||||
<span>{model.resolution.linkLabel}</span>
|
||||
)}
|
||||
</aside>
|
||||
) : null}
|
||||
<article className="public-document-body question-body">
|
||||
<section aria-labelledby="facts-title">
|
||||
<h2 id="facts-title">확인한 사실</h2>
|
||||
<OrderedItems items={model.facts} />
|
||||
</section>
|
||||
<section aria-labelledby="assumptions-title">
|
||||
<h2 id="assumptions-title">가정</h2>
|
||||
<OrderedItems
|
||||
items={model.assumptions}
|
||||
empty="현재 기록된 가정이 없습니다."
|
||||
/>
|
||||
</section>
|
||||
<section aria-labelledby="unknowns-title">
|
||||
<h2 id="unknowns-title">남은 미지수</h2>
|
||||
<OrderedItems
|
||||
items={model.unknowns}
|
||||
empty="해결 과정에서 남은 미지수가 없습니다."
|
||||
/>
|
||||
</section>
|
||||
<section aria-labelledby="constraints-title">
|
||||
<h2 id="constraints-title">제약</h2>
|
||||
<OrderedItems items={model.constraints} />
|
||||
</section>
|
||||
<section aria-labelledby="options-title">
|
||||
<h2 id="options-title">검토한 선택지</h2>
|
||||
<ol className="question-options">
|
||||
{[...model.options]
|
||||
.sort((left, right) => left.order - right.order)
|
||||
.map((option) => (
|
||||
<li key={option.id}>
|
||||
<h3>{option.title}</h3>
|
||||
<p>{option.description}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
<section
|
||||
className="next-validation"
|
||||
aria-labelledby="next-validation-title"
|
||||
>
|
||||
<p className="section-kicker">Next</p>
|
||||
<h2 id="next-validation-title">다음 검증</h2>
|
||||
<p>{model.nextValidation}</p>
|
||||
</section>
|
||||
</article>
|
||||
<PublicDocumentRelations relations={publicRelations(model.relations)} />
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function PublicRecordRenderer({
|
||||
model,
|
||||
embedded = false,
|
||||
resolveEvidenceAsset,
|
||||
resolvePublishedLabel,
|
||||
}: {
|
||||
model: PublicRenderModel;
|
||||
embedded?: boolean;
|
||||
} & RenderDependencies) {
|
||||
switch (model.kind) {
|
||||
case "CASE":
|
||||
return model.publicPath ===
|
||||
"/cases/collection-fetch-join-pagination" ? (
|
||||
<FetchJoinCase
|
||||
model={model}
|
||||
embedded={embedded}
|
||||
resolveEvidenceAsset={resolveEvidenceAsset}
|
||||
resolvePublishedLabel={resolvePublishedLabel}
|
||||
/>
|
||||
) : (
|
||||
<GenericCase
|
||||
model={model}
|
||||
embedded={embedded}
|
||||
resolveEvidenceAsset={resolveEvidenceAsset}
|
||||
resolvePublishedLabel={resolvePublishedLabel}
|
||||
/>
|
||||
);
|
||||
case "REFERENCE":
|
||||
return (
|
||||
<ReferenceDocument
|
||||
model={model}
|
||||
embedded={embedded}
|
||||
resolvePublishedLabel={resolvePublishedLabel}
|
||||
/>
|
||||
);
|
||||
case "QUESTION":
|
||||
return (
|
||||
<QuestionDocument
|
||||
model={model}
|
||||
embedded={embedded}
|
||||
resolvePublishedLabel={resolvePublishedLabel}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return assertNever(model);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user