feat: port TechLog discovery screens

This commit is contained in:
DongHyeonka
2026-08-15 22:29:57 +09:00
parent c9164c1a03
commit ef1d5cc548
12 changed files with 1130 additions and 15 deletions
@@ -0,0 +1,41 @@
export function normalizeFocus(
value: string | null | undefined,
availableKeys: readonly string[],
): string | null {
if (availableKeys.length === 0) return null;
return value && availableKeys.includes(value) ? value : availableKeys[0];
}
/**
* A missing query is the default home URL, while an explicitly empty or
* invalid query must be replaced with the selected fallback.
*/
export function shouldNormalizeFocusUrl(
requestedKey: string | undefined,
normalizedKey: string,
): boolean {
return requestedKey !== undefined && requestedKey !== normalizedKey;
}
export function moveFocus(
current: string,
key: string,
availableKeys: readonly string[],
): string | null {
if (availableKeys.length === 0) return null;
const currentIndex = Math.max(0, availableKeys.indexOf(current));
if (key === "Home") return availableKeys[0];
if (key === "End") return availableKeys[availableKeys.length - 1];
if (key === "ArrowLeft") {
return availableKeys[
(currentIndex - 1 + availableKeys.length) % availableKeys.length
];
}
if (key === "ArrowRight") {
return availableKeys[(currentIndex + 1) % availableKeys.length];
}
return availableKeys[currentIndex];
}
@@ -0,0 +1,75 @@
import { Link, useNavigate } from "react-router-dom";
import type { RecordKind } from "../../../application/ports/public-content-queries.ts";
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
export function ExploreFilterForm({
action,
kind,
topic,
project,
showType = true,
}: {
action: string;
kind?: RecordKind;
topic?: string;
project?: string;
showType?: boolean;
}) {
const navigate = useNavigate();
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
const publicRecords = publicContent.listRecords();
const topics = [...new Set(publicRecords.map((record) => record.topic))].sort();
const projectPrefix = "/projects/";
const projects = publicContent
.searchPublicContent("")
.filter((entity) => entity.contentType === "PROJECT")
.flatMap((entity) => {
if (!entity.path.startsWith(projectPrefix)) return [];
const item = publicContent.getProject(
decodeURIComponent(entity.path.slice(projectPrefix.length)),
);
return item ? [{ slug: item.slug, title: item.title }] : [];
});
const normalizedTopic = topic?.toLocaleLowerCase("ko-KR");
const selectedTopic = topics.find(
(item) => item.toLocaleLowerCase("ko-KR") === normalizedTopic,
);
const normalizedProject = project?.toLocaleLowerCase("ko-KR");
const selectedProject = projects.find(
(item) =>
item.slug.toLocaleLowerCase("ko-KR") === normalizedProject ||
item.title.toLocaleLowerCase("ko-KR") === normalizedProject,
)?.slug;
const hasActiveFilter = Boolean((showType && kind) || topic || project);
const formKey = [kind, topic, project, showType].join(":");
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
const search = new URLSearchParams();
for (const [key, value] of data) {
if (typeof value === "string") search.append(key, value);
}
void navigate(`${action}?${search.toString()}`);
}
return (
<form
key={formKey}
className="public-filter-form"
action={action}
method="get"
onSubmit={submit}
>
{showType ? (
<label><span></span><select name="type" defaultValue={kind ?? ""}><option value=""></option><option value="CASE">Case</option><option value="REFERENCE">Reference</option><option value="QUESTION">Open Question</option></select></label>
) : null}
<label><span></span><select name="topic" defaultValue={selectedTopic ?? ""}><option value=""></option>{topics.map((item) => <option key={item}>{item}</option>)}</select></label>
<label><span></span><select name="project" defaultValue={selectedProject ?? ""}><option value=""></option>{projects.map((item) => <option value={item.slug} key={item.slug}>{item.title}</option>)}</select></label>
<button type="submit"></button>
{hasActiveFilter ? <Link to={action}> </Link> : null}
</form>
);
}
@@ -0,0 +1,136 @@
import { useEffect, useRef, useState } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import type {
FocusKey,
HomeFocusItem,
} from "../../../application/ports/public-content-queries.ts";
import {
moveFocus,
shouldNormalizeFocusUrl,
} from "../../../domain/public/focus-state.ts";
type HomeFocusProps = {
items: ReadonlyArray<HomeFocusItem>;
initialKey: FocusKey;
requestedKey?: string;
};
export function HomeFocus({
items,
initialKey,
requestedKey,
}: HomeFocusProps) {
const [activeKey, setActiveKey] = useState<FocusKey>(initialKey);
const buttons = useRef<Array<HTMLButtonElement | null>>([]);
const location = useLocation();
const navigate = useNavigate();
const keys = items.map((item) => item.key);
const activeItem = items.find((item) => item.key === activeKey) ?? items[0];
function replaceFocus(key: FocusKey) {
const search = new URLSearchParams(location.search);
search.set("focus", key);
void navigate(
{
pathname: location.pathname,
search: `?${search.toString()}`,
hash: location.hash,
},
{ replace: true },
);
}
useEffect(() => {
if (!shouldNormalizeFocusUrl(requestedKey, activeKey)) return;
replaceFocus(activeKey);
});
function select(key: FocusKey, moveKeyboardFocus = false) {
setActiveKey(key);
replaceFocus(key);
if (moveKeyboardFocus) {
requestAnimationFrame(() => {
buttons.current[keys.indexOf(key)]?.focus();
});
}
}
function onKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) {
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) {
return;
}
event.preventDefault();
const next = moveFocus(activeKey, event.key, keys);
if (next === "current" || next === "question" || next === "decision") {
select(next, true);
}
}
if (!activeItem) return null;
const usesTabs = items.length > 1;
const renderedItems = usesTabs ? items : [activeItem];
return (
<>
{usesTabs ? (
<div className="focus-tabs" role="tablist" aria-label="집중 항목 선택">
{items.map((item, index) => {
const selected = item.key === activeKey;
return (
<button
key={item.key}
ref={(button) => {
buttons.current[index] = button;
}}
id={"focus-tab-" + item.key}
type="button"
role="tab"
aria-selected={selected}
aria-controls={"focus-panel-" + item.key}
tabIndex={selected ? 0 : -1}
onClick={() => select(item.key)}
onKeyDown={onKeyDown}
>
{item.label}
</button>
);
})}
</div>
) : null}
{renderedItems.map((item) => {
const selected = item.key === activeKey;
return (
<article
className="focus-content"
id={"focus-panel-" + item.key}
key={item.key}
role={usesTabs ? "tabpanel" : undefined}
aria-labelledby={usesTabs ? "focus-tab-" + item.key : undefined}
hidden={!selected}
>
<div className="focus-summary">
<h2>{item.title}</h2>
<p>{item.summary}</p>
<Link className="text-link" to={item.targetPath}>
<span aria-hidden="true"></span>
</Link>
</div>
<dl className="focus-details">
{item.details.map((detail) => (
<div key={detail.label}>
<dt>{detail.label}</dt>
<dd>{detail.value}</dd>
</div>
))}
</dl>
</article>
);
})}
</>
);
}
@@ -0,0 +1,75 @@
import { Link } from "react-router-dom";
export type LatestEntry = {
id: string;
typeLabel: string;
title: string;
summary: string;
date: string;
dateTime: string;
topic: string;
project: string;
path: string;
};
type LatestIndexProps = {
entries: ReadonlyArray<LatestEntry>;
status?: "ready" | "error";
};
export function LatestIndex({
entries,
status = "ready",
}: LatestIndexProps) {
return (
<section className="shell latest-section" id="latest" aria-labelledby="latest-title">
<div className="section-heading-row">
<div>
<p className="section-kicker">Index</p>
<h2 id="latest-title"> </h2>
</div>
<Link className="text-link section-action" to="/explore">
</Link>
</div>
{status === "error" ? (
<div className="latest-state latest-state--error" role="alert">
<p> .</p>
<Link className="text-link" to="/#latest">
</Link>
</div>
) : entries.length === 0 ? (
<p className="latest-state latest-state--empty">
.
</p>
) : (
<ol className="latest-list">
{entries.map((entry) => (
<li key={entry.id}>
<Link className="latest-row" to={entry.path}>
<div className="latest-time">
<span>{entry.typeLabel}</span>
<time dateTime={entry.dateTime}>{entry.date}</time>
</div>
<div className="latest-copy">
<h3>{entry.title}</h3>
<p>{entry.summary}</p>
<p className="latest-mobile-meta">
{entry.topic} · {entry.project}
</p>
</div>
<div className="latest-context">
<span>{entry.topic}</span>
<span>{entry.project}</span>
</div>
<span className="latest-arrow" aria-hidden="true"></span>
</Link>
</li>
))}
</ol>
)}
</section>
);
}
@@ -0,0 +1,41 @@
import { Link } from "react-router-dom";
import type { PublicRecord } from "../../../application/ports/public-content-queries.ts";
const kindLabels = {
CASE: "Case",
REFERENCE: "Reference",
QUESTION: "Open Question",
} as const;
export function PublicRecordList({
records,
emptyMessage = "조건에 맞는 공개 기록이 없습니다.",
}: {
records: ReadonlyArray<PublicRecord>;
emptyMessage?: string;
}) {
if (records.length === 0) {
return <p className="public-empty-state">{emptyMessage}</p>;
}
return (
<ol className="public-record-list">
{records.map((record) => (
<li key={record.path}>
<Link to={record.path}>
<span className="record-kind">{kindLabels[record.kind]}</span>
<div>
<strong>{record.title}</strong>
<p>{record.summary}</p>
<span className="record-context">
{record.topic} · {record.projectTitle}
</span>
</div>
<time dateTime={record.publishedAt}>{record.publishedLabel}</time>
</Link>
</li>
))}
</ol>
);
}
@@ -0,0 +1,49 @@
import { Link } from "react-router-dom";
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
import {
RegisteredNotFoundRoute,
useRouteInput,
} from "../../../../../presentation/routes/route-input.tsx";
import { ExploreFilterForm } from "../components/explore-filter-form.tsx";
import { PublicRecordList } from "../components/public-record-list.tsx";
const kinds = {
cases: { kind: "CASE", title: "Case", description: "문제를 재현하고 관찰한 값에서 설계 결론까지 따라갑니다." },
references: { kind: "REFERENCE", title: "Reference", description: "다시 확인할 수 있는 기술 기준과 적용 범위를 정리합니다." },
questions: { kind: "QUESTION", title: "Open Question", description: "확인한 사실과 미지수, 다음 검증을 공개적으로 추적합니다." },
} as const;
function optionalString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
function getKindConfig(value: string | undefined) {
if (value === "cases" || value === "references" || value === "questions") {
return kinds[value];
}
return undefined;
}
export function ExploreKindPage() {
const { params, search } = useRouteInput<"TECH_LOG_EXPLORE_KIND">();
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
const kind = optionalString(params.kind);
const config = getKindConfig(kind);
if (!config) return <RegisteredNotFoundRoute />;
const topic = optionalString(search.topic);
const project = optionalString(search.project);
const records = publicContent.listRecords({
kind: config.kind,
...(topic ? { topic } : {}),
...(project ? { project } : {}),
});
return <main id="main-content" className="shell public-index-page">
<header className="public-page-header"><p className="section-kicker">Explore</p><h1>{config.title}</h1><p>{config.description}</p></header>
<ExploreFilterForm action={`/explore/${kind}`} topic={topic} project={project} showType={false} />
<div className="public-result-heading"><h2> </h2><p>{records.length} </p></div><PublicRecordList records={records} />
<Link className="text-link public-back-link" to="/explore"> </Link>
</main>;
}
@@ -0,0 +1,33 @@
import type { RecordKind } from "../../../application/ports/public-content-queries.ts";
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
import { ExploreFilterForm } from "../components/explore-filter-form.tsx";
import { PublicRecordList } from "../components/public-record-list.tsx";
function optionalString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
export function ExplorePage() {
const { search } = useRouteInput<"TECH_LOG_EXPLORE">();
const requestedKind = optionalString(search.type);
const topic = optionalString(search.topic);
const project = optionalString(search.project);
const kind = (["CASE", "REFERENCE", "QUESTION"] as const).find(
(item) => item === requestedKind,
) satisfies RecordKind | undefined;
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
const records = publicContent.listRecords({
...(kind ? { kind } : {}),
...(topic ? { topic } : {}),
...(project ? { project } : {}),
});
return <main id="main-content" className="shell public-index-page">
<header className="public-page-header"><p className="section-kicker">Explore</p><h1></h1><p> , .</p></header>
<ExploreFilterForm action="/explore" kind={kind} topic={topic} project={project} />
<div className="public-result-heading"><h2> </h2><p>{records.length} </p></div>
<PublicRecordList records={records} />
</main>;
}
@@ -0,0 +1,182 @@
import { Link } from "react-router-dom";
import type { PublicContentQueries } from "../../../application/ports/public-content-queries.ts";
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
import { publicSiteConfig } from "../../../contracts/public-site-config.ts";
import { normalizeFocus } from "../../../domain/public/focus-state.ts";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
import { FatalErrorState } from "../components/fatal-error-state.tsx";
import { HomeFocus } from "../components/home-focus.tsx";
import {
LatestIndex,
type LatestEntry,
} from "../components/latest-index.tsx";
const exploreEntries = [
{
label: "Case",
description: "문제를 따라가며 검증 과정을 읽습니다",
path: "/explore/cases",
},
{
label: "Reference",
description: "다시 찾을 수 있는 기술 기준을 확인합니다",
path: "/explore/references",
},
{
label: "OpenQuestion",
description: "아직 끝나지 않은 판단과 다음 검증을 봅니다",
path: "/explore/questions",
},
{
label: "Project",
description: "여러 기록을 하나의 시스템 맥락에서 연결합니다",
path: "/projects",
},
] as const;
function optionalString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
function getLatestEntries(publicContent: PublicContentQueries): LatestEntry[] {
const publicRecords = publicContent.listRecords();
const publicRecordByPath = new Map(
publicRecords.map((record) => [record.path, record]),
);
const searchableEntities = publicContent.searchPublicContent("");
const projectPrefix = "/projects/";
const projectSlugs = searchableEntities
.filter((entity) => entity.contentType === "PROJECT")
.flatMap((entity) =>
entity.path.startsWith(projectPrefix)
? [decodeURIComponent(entity.path.slice(projectPrefix.length))]
: [],
);
const projectTimeline = projectSlugs.flatMap((projectSlug) => {
const project = publicContent.getProject(projectSlug);
if (!project) return [];
return publicContent.getProjectActivity(projectSlug).map((activity) => {
const record = publicRecordByPath.get(
activity.recordPath ?? activity.path,
);
return {
id: activity.id,
typeLabel:
activity.type === "PUBLICATION" && record
? record.kind
: "PROJECT ACTIVITY",
title:
activity.type === "PUBLICATION" && record
? record.title
: activity.title,
summary: activity.summary,
date: activity.date,
dateTime: activity.dateTime,
topic: record?.topic ?? project.topics[0] ?? "",
project: project.title,
path: activity.path,
};
});
});
const releaseTimeline = searchableEntities
.filter((entity) => entity.contentType === "RELEASE")
.flatMap((entity) => {
const prefix = "/releases/";
if (!entity.path.startsWith(prefix)) return [];
const release = publicContent.getRelease(
decodeURIComponent(entity.path.slice(prefix.length)),
);
if (!release) return [];
return [
{
id: `release-${release.version}`,
typeLabel: "RELEASE",
title: release.title,
summary: release.summary,
date: release.publishedLabel,
dateTime: release.publishedAt,
topic: "TechLog",
project: "TechLog",
path: release.path,
},
];
});
return [...projectTimeline, ...releaseTimeline].sort((left, right) =>
right.dateTime.localeCompare(left.dateTime),
);
}
export function HomePage() {
const { search } = useRouteInput<"TECH_LOG_HOME">();
const requestedKey = optionalString(search.focus);
const requestedState = optionalString(search.state);
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
const focusItems = publicContent.getHomeFocusItems();
const availableFocusItems = requestedState === "focus-empty" ? [] : focusItems;
const normalizedKey = normalizeFocus(
requestedKey,
availableFocusItems.map((item) => item.key),
);
const initialKey =
normalizedKey === "current" ||
normalizedKey === "question" ||
normalizedKey === "decision"
? normalizedKey
: null;
if (requestedState === "site-error") {
return <FatalErrorState traceId="PREVIEW-HOME-500" />;
}
const latestEntries = getLatestEntries(publicContent);
return (
<main id="main-content">
<section className="shell home-identity" aria-labelledby="home-title">
<h1 id="home-title">{publicSiteConfig.brandTitle}</h1>
<p className="identity-statement">{publicSiteConfig.identityStatement}</p>
</section>
{initialKey ? (
<section className="shell focus-section" aria-labelledby="focus-label">
<p className="section-kicker" id="focus-label">
</p>
<HomeFocus
items={availableFocusItems}
initialKey={initialKey}
requestedKey={requestedKey}
/>
</section>
) : null}
<LatestIndex
entries={requestedState === "latest-empty" ? [] : latestEntries}
status={requestedState === "latest-error" ? "error" : "ready"}
/>
<section className="shell explore-section" aria-labelledby="explore-title">
<div className="section-heading-row explore-heading">
<div>
<p className="section-kicker">Explore</p>
<h2 id="explore-title"> ?</h2>
</div>
</div>
<ul className="explore-list">
{exploreEntries.map((entry) => (
<li key={entry.label}>
<Link to={entry.path}>
<span>{entry.description}</span>
<strong>{entry.label}</strong>
<span aria-hidden="true"></span>
</Link>
</li>
))}
</ul>
</section>
</main>
);
}
@@ -0,0 +1,34 @@
import { Link, useNavigate } from "react-router-dom";
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
const labels = { CASE: "Case", REFERENCE: "Reference", QUESTION: "Open Question", PROJECT: "Project", RELEASE: "Release" } as const;
function optionalString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
export function SearchPage() {
const navigate = useNavigate();
const { search } = useRouteInput<"TECH_LOG_SEARCH">();
const query = optionalString(search.q)?.trim() ?? "";
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
const results = publicContent.searchPublicContent(query);
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
const value = data.get("q");
const nextQuery = typeof value === "string" ? value : "";
void navigate(`/search?q=${encodeURIComponent(nextQuery)}`);
}
return <main id="main-content" className="shell search-page">
<header className="public-page-header"><p className="section-kicker">Search</p><h1></h1><p> , , .</p></header>
<form key={query} className="search-page-form" action="/search" method="get" onSubmit={submit}><label><span className="visually-hidden"></span><input type="search" name="q" defaultValue={query} placeholder="검색어를 입력하세요" /></label><button type="submit"></button></form>
<div className="public-result-heading"><h2>{query ? `${query}” 검색 결과` : "전체 검색 결과"}</h2><p>{results.length} </p></div>
{results.length ? <ol className="search-page-results">{results.map((result) => <li key={result.path}><Link to={result.path}><span>{labels[result.contentType]}</span><div><strong>{result.title}</strong><p>{result.summary}</p>{result.topic || result.project ? <small>{[result.topic, result.project].filter(Boolean).join(" · ")}</small> : null}</div><span aria-hidden="true"></span></Link></li>)}</ol> : <p className="public-empty-state"> .</p>}
</main>;
}
+24 -15
View File
@@ -48,7 +48,10 @@ import {
ROUTE_RUNTIME,
} from "../../features/installed-feature-runtimes.tsx";
import type { ParsedRouteInput } from "./route-contract.ts";
import { RouteInputProvider } from "./route-input.tsx";
import {
RegisteredNotFoundProvider,
RouteInputProvider,
} from "./route-input.tsx";
function RouteLoadingSurface({ definition }: { definition: RouteDefinition }) {
const { message, resolve } = useLocale();
@@ -256,12 +259,14 @@ function RegisteredRoute<RouteIdValue extends string>({
definition,
runtime,
codecs,
NotFoundComponent,
}: {
routeId: RouteIdValue;
buildId: string;
definition: RouteDefinition;
runtime: GroupedRouteRuntimeDefinition;
codecs: RouteCodecRegistry;
NotFoundComponent?: ComponentType;
}) {
const params = useParams();
const [search] = useSearchParams();
@@ -280,20 +285,22 @@ function RegisteredRoute<RouteIdValue extends string>({
const content = (
<RouteInputProvider input={routeInput}>
<CanonicalRouteRedirect
input={routeInput}
definition={definition}
codecs={codecs}
/>
<RouteLifecycle definition={definition} buildId={buildId} />
<Suspense fallback={<RouteLoadingSurface definition={definition} />}>
<ChunkRecoveryBoundary
chunkId={definition.chunkId}
recover={recovery.recoverChunk}
>
<RuntimeComponent />
</ChunkRecoveryBoundary>
</Suspense>
<RegisteredNotFoundProvider Component={NotFoundComponent}>
<CanonicalRouteRedirect
input={routeInput}
definition={definition}
codecs={codecs}
/>
<RouteLifecycle definition={definition} buildId={buildId} />
<Suspense fallback={<RouteLoadingSurface definition={definition} />}>
<ChunkRecoveryBoundary
chunkId={definition.chunkId}
recover={recovery.recoverChunk}
>
<RuntimeComponent />
</ChunkRecoveryBoundary>
</Suspense>
</RegisteredNotFoundProvider>
</RouteInputProvider>
);
const protectedContent =
@@ -345,6 +352,7 @@ export function createGroupedRouteObjects(
PUBLIC: [],
STUDIO: [],
};
const NotFoundComponent = runtime.NOT_FOUND?.Component;
for (const definition of Object.values(registry)) {
const routeId = definition.routeId;
const routeRuntime = runtime[definition.routeId];
@@ -358,6 +366,7 @@ export function createGroupedRouteObjects(
definition={definition}
runtime={routeRuntime}
codecs={codecs}
NotFoundComponent={NotFoundComponent}
/>
);
if (definition.path === "/") {
+22
View File
@@ -1,4 +1,5 @@
import {
type ComponentType,
createContext,
type ReactNode,
useContext,
@@ -7,6 +8,7 @@ import {
import type { ParsedRouteInput, RouteId } from "./route-contract.ts";
const RouteInputContext = createContext<ParsedRouteInput<string> | null>(null);
const RegisteredNotFoundContext = createContext<ComponentType | null>(null);
export function RouteInputProvider<RouteIdValue extends string>({
input,
@@ -29,3 +31,23 @@ export function useRouteInput<
if (!input) throw new Error("Registered route input is required");
return input as ParsedRouteInput<RouteIdValue>;
}
export function RegisteredNotFoundProvider({
Component,
children,
}: Readonly<{
Component?: ComponentType;
children: ReactNode;
}>) {
return (
<RegisteredNotFoundContext.Provider value={Component ?? null}>
{children}
</RegisteredNotFoundContext.Provider>
);
}
export function RegisteredNotFoundRoute() {
const Component = useContext(RegisteredNotFoundContext);
if (!Component) throw new Error("Registered not-found runtime is required");
return <Component />;
}
@@ -0,0 +1,418 @@
// @vitest-environment jsdom
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
createMemoryRouter,
Outlet,
RouterProvider,
type RouterProviderProps,
} from "react-router-dom";
import { createTechLogFeatureInstalledInput } from "../../../src/features/tech-log/adapters/create-tech-log-feature-input.ts";
import {
TECH_LOG_ROUTE_REGISTRY,
TECH_LOG_ROUTE_RUNTIME_CONTRACT,
type TechLogRouteId,
} from "../../../src/features/tech-log/contracts/tech-log-route-contract.ts";
import {
moveFocus,
normalizeFocus,
shouldNormalizeFocusUrl,
} from "../../../src/features/tech-log/domain/public/focus-state.ts";
import { PublicShell } from "../../../src/features/tech-log/presentation/public/public-shell.tsx";
import { ExploreKindPage } from "../../../src/features/tech-log/presentation/public/pages/explore-kind-page.tsx";
import { ExplorePage } from "../../../src/features/tech-log/presentation/public/pages/explore-page.tsx";
import { HomePage } from "../../../src/features/tech-log/presentation/public/pages/home-page.tsx";
import { SearchPage } from "../../../src/features/tech-log/presentation/public/pages/search-page.tsx";
import { TECH_LOG_ROUTE_CODECS } from "../../../src/features/tech-log/presentation/tech-log-route-codecs.ts";
import { ApplicationProvider } from "../../../src/presentation/providers/application-provider.tsx";
import NotFoundPage from "../../../src/presentation/pages/not-found-page.tsx";
import { createGroupedRouteObjects } from "../../../src/presentation/routes/app-router.tsx";
import { PLATFORM_ROUTE_CODECS } from "../../../src/presentation/routes/platform-route-codecs.ts";
import { createTestApplication } from "../../helpers/create-test-application.ts";
const routeCodecs = Object.freeze({
...PLATFORM_ROUTE_CODECS,
...TECH_LOG_ROUTE_CODECS,
});
const routeComponents = {
TECH_LOG_HOME: HomePage,
TECH_LOG_EXPLORE: ExplorePage,
TECH_LOG_EXPLORE_KIND: ExploreKindPage,
TECH_LOG_SEARCH: SearchPage,
} as const;
type DiscoveryRouteId = keyof typeof routeComponents;
function renderDiscoveryRoute<RouteId extends DiscoveryRouteId>(
routeId: RouteId,
initialEntry: string,
) {
const definition = TECH_LOG_ROUTE_REGISTRY[routeId];
const runtime = TECH_LOG_ROUTE_RUNTIME_CONTRACT[routeId];
const Component = routeComponents[routeId];
const router = createMemoryRouter(
createGroupedRouteObjects(
{
[routeId]: definition,
NOT_FOUND: TECH_LOG_ROUTE_REGISTRY.NOT_FOUND,
},
{
[routeId]: {
moduleId: runtime.moduleId,
Component,
},
NOT_FOUND: {
moduleId: TECH_LOG_ROUTE_RUNTIME_CONTRACT.NOT_FOUND.moduleId,
Component: NotFoundPage,
},
},
{
PUBLIC: (
<PublicShell>
<Outlet />
</PublicShell>
),
STUDIO: <Outlet />,
},
"task-7-test-build",
routeCodecs,
),
{ initialEntries: [initialEntry] },
);
const techLog = createTechLogFeatureInstalledInput().input;
const view = render(
<ApplicationProvider
application={createTestApplication({
featureInputs: { "tech-log": techLog },
})}
>
<RouterProvider router={router} />
</ApplicationProvider>,
);
return { ...view, router } satisfies ReturnType<typeof render> & {
router: RouterProviderProps["router"];
};
}
beforeEach(() => {
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
callback(0);
return 1;
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("TechLog home discovery", () => {
it("renders the exact identity, focus, latest ordering, and explore choices", async () => {
const { router } = renderDiscoveryRoute(
"TECH_LOG_HOME",
"/?focus=invalid&focus=question",
);
expect(screen.getByRole("heading", { level: 1, name: "TechLog" })).toBeVisible();
expect(
within(screen.getByRole("main")).getByText(
"문제를 재현하고 검증해 운영 가능한 설계로 연결합니다.",
),
).toHaveClass("identity-statement");
expect(screen.getByText("지금 집중하는 것")).toHaveClass("section-kicker");
await waitFor(() => {
expect(router.state.location.search).toBe("?focus=current");
});
expect(screen.getByRole("tab", { name: "현재 작업" })).toHaveAttribute(
"aria-selected",
"true",
);
for (const tab of screen.getAllByRole("tab")) {
const panelId = tab.getAttribute("aria-controls");
expect(panelId).toBeTruthy();
expect(document.getElementById(panelId!)).toHaveAttribute(
"aria-labelledby",
tab.id,
);
}
const latest = screen.getByRole("region", { name: "최근 기록" });
expect(
within(latest).getAllByRole("heading", { level: 3 }).map((heading) =>
heading.textContent,
),
).toEqual([
"컬렉션 Fetch Join과 페이징은 왜 충돌하는가",
"파일 저장소 계약을 하나로 통합했습니다",
"Authorization Code Flow에서 state와 nonce의 경계",
"oauth2-proxy 뒤에서 토큰을 다시 검증할 것인가",
"Redis Adapter가 도메인 TTL 정책을 소유하지 않는 이유",
"TechLog Public·Studio 경계를 확정했습니다",
]);
expect(
screen.getByRole("link", { name: /문제를 따라가며 검증 과정을 읽습니다.*Case/ }),
).toHaveAttribute("href", "/explore/cases");
expect(
screen.getByRole("link", { name: /여러 기록을 하나의 시스템 맥락에서 연결합니다.*Project/ }),
).toHaveAttribute("href", "/projects");
});
it("moves linked tabs with arrows, Home, and End while synchronizing the URL", async () => {
const user = userEvent.setup();
const { router } = renderDiscoveryRoute("TECH_LOG_HOME", "/?focus=question");
const question = screen.getByRole("tab", { name: "열린 질문" });
question.focus();
await user.keyboard("{ArrowRight}");
expect(screen.getByRole("tab", { name: "최근 결정" })).toHaveFocus();
expect(router.state.location.search).toBe("?focus=decision");
expect(screen.getByRole("heading", { name: /Filesystem과 Object Storage/ })).toBeVisible();
await user.keyboard("{ArrowRight}");
expect(screen.getByRole("tab", { name: "현재 작업" })).toHaveFocus();
expect(router.state.location.search).toBe("?focus=current");
await user.keyboard("{End}");
expect(screen.getByRole("tab", { name: "최근 결정" })).toHaveFocus();
await user.keyboard("{Home}");
expect(screen.getByRole("tab", { name: "현재 작업" })).toHaveFocus();
expect(screen.getAllByRole("tabpanel")).toHaveLength(1);
});
it("preserves the source home empty and error states", () => {
const emptyView = renderDiscoveryRoute("TECH_LOG_HOME", "/?state=latest-empty");
expect(screen.getByText("아직 공개된 기록이 없습니다.")).toHaveClass(
"latest-state--empty",
);
emptyView.unmount();
const latestErrorView = renderDiscoveryRoute(
"TECH_LOG_HOME",
"/?state=latest-error",
);
expect(screen.getByRole("alert")).toHaveTextContent(
"최근 기록을 불러오지 못했습니다.",
);
expect(screen.getByRole("link", { name: "다시 시도" })).toHaveAttribute(
"href",
"/#latest",
);
latestErrorView.unmount();
const errorView = renderDiscoveryRoute("TECH_LOG_HOME", "/?state=site-error");
expect(
screen.getByRole("heading", { name: "페이지를 불러오지 못했습니다." }),
).toBeVisible();
expect(screen.getByText("PREVIEW-HOME-500")).toBeVisible();
errorView.unmount();
renderDiscoveryRoute("TECH_LOG_HOME", "/?state=focus-empty");
expect(screen.queryByText("지금 집중하는 것")).not.toBeInTheDocument();
});
});
describe("TechLog explore discovery", () => {
it("filters by kind, topic, and project and keeps source result structure", async () => {
const user = userEvent.setup();
const { router } = renderDiscoveryRoute(
"TECH_LOG_EXPLORE",
"/explore?type=CASE&topic=JPA&project=backend-skeleton",
);
expect(screen.getByRole("heading", { level: 1, name: "탐색" })).toBeVisible();
expect(
screen.getByText("유형과 기술 주제, 프로젝트를 조합해 공개 기록을 찾습니다."),
).toBeVisible();
expect(screen.getByLabelText("유형")).toHaveValue("CASE");
expect(screen.getByLabelText("주제")).toHaveValue("JPA");
expect(screen.getByLabelText("프로젝트")).toHaveValue("backend-skeleton");
expect(screen.getByText("1개의 공개 기록")).toBeVisible();
expect(
screen.getByRole("link", { name: /컬렉션 Fetch Join과 페이징은 왜 충돌하는가/ }),
).toHaveAttribute("href", "/cases/collection-fetch-join-pagination");
await user.selectOptions(screen.getByLabelText("유형"), "QUESTION");
await user.selectOptions(screen.getByLabelText("주제"), "Authentication");
await user.selectOptions(screen.getByLabelText("프로젝트"), "auth-lab");
await user.click(screen.getByRole("button", { name: "적용" }));
await waitFor(() => {
expect(router.state.location.search).toBe(
"?project=auth-lab&topic=Authentication&type=QUESTION",
);
});
expect(screen.getByText("1개의 공개 기록")).toBeVisible();
expect(
screen.getByRole("link", { name: /oauth2-proxy가 전달한 토큰을 다시 검증해야 하는가/ }),
).toBeVisible();
await user.click(screen.getByRole("link", { name: "필터 초기화" }));
await waitFor(() => {
expect(router.state.location.pathname).toBe("/explore");
expect(router.state.location.search).toBe("");
});
expect(screen.getByText("6개의 공개 기록")).toBeVisible();
expect(
within(screen.getByRole("main"))
.getAllByRole("listitem")
.map((item) => within(item).getByRole("link").getAttribute("href")),
).toEqual([
"/cases/collection-fetch-join-pagination",
"/references/jpa-list-fetch-strategy",
"/references/state-and-nonce-boundary",
"/questions/validate-edge-token-again",
"/cases/redis-adapter-ttl-boundary",
"/questions/collection-fetch-join-with-pagination",
]);
});
it("renders the distinct no-result state for an unmatched query filter", () => {
renderDiscoveryRoute("TECH_LOG_EXPLORE", "/explore?topic=missing");
expect(screen.getByText("0개의 공개 기록")).toBeVisible();
expect(screen.getByText("조건에 맞는 공개 기록이 없습니다.")).toHaveClass(
"public-empty-state",
);
expect(screen.queryByRole("list", { name: "공개 기록" })).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: "필터 초기화" })).toHaveAttribute(
"href",
"/explore",
);
});
it("keeps kind-specific copy, filtering, count, ordering, and back navigation", () => {
renderDiscoveryRoute(
"TECH_LOG_EXPLORE_KIND",
"/explore/questions?topic=JPA&project=backend-skeleton",
);
expect(
screen.getByRole("heading", { level: 1, name: "Open Question" }),
).toBeVisible();
expect(
screen.getByText("확인한 사실과 미지수, 다음 검증을 공개적으로 추적합니다."),
).toBeVisible();
expect(screen.queryByLabelText("유형")).not.toBeInTheDocument();
expect(screen.getByText("1개의 공개 기록")).toBeVisible();
expect(
screen.getByRole("link", { name: /컬렉션 Fetch Join을 유지하면서 페이징할 수 있는가/ }),
).toHaveAttribute("href", "/questions/collection-fetch-join-with-pagination");
expect(
screen.getByRole("link", { name: "전체 탐색으로 돌아가기" }),
).toHaveAttribute("href", "/explore");
});
it("renders an unknown kind through the registered Public not-found runtime", () => {
const { router, container } = renderDiscoveryRoute(
"TECH_LOG_EXPLORE_KIND",
"/explore/unknown",
);
expect(router.state.location.pathname).toBe("/explore/unknown");
expect(
screen.getByRole("heading", { name: "페이지를 찾을 수 없습니다." }),
).toBeVisible();
expect(
screen.queryByRole("heading", { name: "화면을 표시하지 못했습니다." }),
).not.toBeInTheDocument();
expect(container.querySelector(".site-frame")).not.toBeNull();
});
});
describe("TechLog search discovery", () => {
it("normalizes repeated queries, preserves result ordering, and navigates canonically", async () => {
const user = userEvent.setup();
const { router } = renderDiscoveryRoute(
"TECH_LOG_SEARCH",
"/search?q=%20JPA%20&q=Redis&unknown=drop",
);
await waitFor(() => {
expect(router.state.location.search).toBe("?q=JPA");
});
expect(screen.getByRole("searchbox", { name: "검색어" })).toHaveValue("JPA");
expect(screen.getByRole("heading", { name: "“JPA” 검색 결과" })).toBeVisible();
expect(screen.getByText("4개의 검색 결과")).toBeVisible();
expect(
screen.getAllByRole("listitem").map((item) =>
within(item).queryByRole("link")?.getAttribute("href"),
),
).toEqual([
"/cases/collection-fetch-join-pagination",
"/references/jpa-list-fetch-strategy",
"/questions/collection-fetch-join-with-pagination",
"/projects/backend-skeleton",
]);
await user.click(
screen.getByRole("link", { name: /JPA 목록 조회에서 Fetch 전략을 선택하는 기준/ }),
);
expect(router.state.location.pathname).toBe("/references/jpa-list-fetch-strategy");
});
it("submits from the keyboard and synchronizes the searchbox with URL changes", async () => {
const user = userEvent.setup();
const { router } = renderDiscoveryRoute("TECH_LOG_SEARCH", "/search");
const input = screen.getByRole("searchbox", { name: "검색어" });
await user.type(input, "Redis{Enter}");
await waitFor(() => {
expect(router.state.location.search).toBe("?q=Redis");
});
expect(screen.getByRole("heading", { name: "“Redis” 검색 결과" })).toBeVisible();
expect(screen.getByText("2개의 검색 결과")).toBeVisible();
await router.navigate("/search?q=Keycloak");
await waitFor(() => {
expect(screen.getByRole("searchbox", { name: "검색어" })).toHaveValue(
"Keycloak",
);
});
expect(screen.getByText("1개의 검색 결과")).toBeVisible();
});
it("normalizes an explicitly empty first query and renders all canonical results", async () => {
const { router } = renderDiscoveryRoute(
"TECH_LOG_SEARCH",
"/search?q=%20%20&q=JPA",
);
await waitFor(() => {
expect(router.state.location.search).toBe("");
});
expect(screen.getByRole("heading", { name: "전체 검색 결과" })).toBeVisible();
expect(screen.getByText("9개의 검색 결과")).toBeVisible();
expect(screen.getByRole("searchbox", { name: "검색어" })).toHaveValue("");
});
it("renders the exact zero-result state without a result list", () => {
renderDiscoveryRoute("TECH_LOG_SEARCH", "/search?q=존재하지않음");
expect(screen.getByText("0개의 검색 결과")).toBeVisible();
expect(screen.getByText("일치하는 공개 기록이 없습니다.")).toHaveClass(
"public-empty-state",
);
expect(within(screen.getByRole("main")).queryByRole("list")).toBeNull();
});
});
describe("home focus domain", () => {
it("normalizes selection and wraps all supported keyboard movements", () => {
const keys = ["current", "question", "decision"] as const;
expect(normalizeFocus(undefined, keys)).toBe("current");
expect(normalizeFocus("missing", keys)).toBe("current");
expect(normalizeFocus("question", keys)).toBe("question");
expect(normalizeFocus("question", [])).toBeNull();
expect(shouldNormalizeFocusUrl(undefined, "current")).toBe(false);
expect(shouldNormalizeFocusUrl("", "current")).toBe(true);
expect(moveFocus("current", "ArrowLeft", keys)).toBe("decision");
expect(moveFocus("decision", "ArrowRight", keys)).toBe("current");
expect(moveFocus("question", "Home", keys)).toBe("current");
expect(moveFocus("question", "End", keys)).toBe("decision");
});
});