import { projects, publicRecords, releases, type Project, type ProjectActivity, type ProjectDecision, type PublicRecord, type RecordKind, type Release, type HomeFocusItem, } from "./public-content.ts"; import type { LatestRecordEntry, PublicContentQueries, PublicTopic, } from "../../application/ports/public-content-queries.ts"; export type RecordFilters = { kind?: RecordKind; topic?: string; project?: string; openQuestionsOnly?: boolean; }; export type SearchablePublicEntity = { contentType: RecordKind | "PROJECT" | "RELEASE"; title: string; summary: string; path: string; topic?: string; topics?: ReadonlyArray; project?: string; publishedAt?: string; }; function comparePublishedAt( left: Pick, right: Pick, ) { return right.publishedAt.localeCompare(left.publishedAt); } export function listRecords(filters: RecordFilters = {}): PublicRecord[] { const hasTopicFilter = filters.topic !== undefined; const hasProjectFilter = filters.project !== undefined; const requestedTopic = filters.topic?.trim().toLocaleLowerCase("ko-KR"); const requestedProject = filters.project ?.trim() .toLocaleLowerCase("ko-KR"); return publicRecords .filter((record) => !filters.kind || record.kind === filters.kind) .filter( (record) => !hasTopicFilter || record.topic.toLocaleLowerCase("ko-KR") === requestedTopic, ) .filter( (record) => !hasProjectFilter || record.projectSlug.toLocaleLowerCase("ko-KR") === requestedProject || record.projectTitle.toLocaleLowerCase("ko-KR") === requestedProject, ) .filter( (record) => !filters.openQuestionsOnly || (record.kind === "QUESTION" && record.questionStatus !== "RESOLVED" && record.questionStatus !== "ARCHIVED"), ) .sort(comparePublishedAt); } export function getRecord( kind: K, slug: string, ): Extract | undefined { return publicRecords.find( (record) => record.kind === kind && record.slug === slug, ) as Extract | undefined; } export function getProject(slug: string): Project | undefined { return projects.find((project) => project.slug === slug); } export function getRelease(version: string): Release | undefined { return releases.find((release) => release.version === version); } export function getProjectRecords(projectSlug: string): PublicRecord[] { if (!getProject(projectSlug)) return []; return listRecords({ project: projectSlug }); } export function getProjectDecisions(projectSlug: string): ProjectDecision[] { return [...(getProject(projectSlug)?.decisions ?? [])]; } export function getProjectActivity(projectSlug: string): ProjectActivity[] { return [...(getProject(projectSlug)?.activity ?? [])]; } /** * 정적 카탈로그에는 주제 테이블이 없다 — 기록마다 붙은 주제 이름이 있을 뿐이다. 그것을 모아 * 세면 백엔드의 `listPublicTopics` 와 같은 모양이 되고, 이 어댑터의 목적(백엔드 없이 화면을 * 그린다)에도 맞는다. */ export function listTopics(): PublicTopic[] { const counts = new Map(); for (const record of listRecords()) { if (!record.topic) continue; const existing = counts.get(record.topicSlug); if (existing) existing.recordCount += 1; else counts.set(record.topicSlug, { name: record.topic, slug: record.topicSlug, recordCount: 1 }); } return [...counts.values()] .sort((left, right) => right.recordCount - left.recordCount || (left.name < right.name ? -1 : 1)) .map((entry) => Object.freeze(entry)); } /** * 픽스처판 "최근 기록". HTTP 어댑터가 서버에서 읽어 오는 것과 같은 의미를 픽스처에서 만든다 — * 프로젝트 활동과 릴리스가 원천이다. */ export function getLatestEntries(): LatestRecordEntry[] { const recordByPath = new Map(publicRecords.map((record) => [record.path, record])); const activities = projects.flatMap((project) => project.activity.map((activity) => { const record = recordByPath.get(activity.recordPath ?? activity.path); const published = activity.type === "PUBLICATION" && record; return { id: activity.id, entryType: (published ? record.kind : "PROJECT_ACTIVITY") as LatestRecordEntry["entryType"], title: published ? record.title : activity.title, summary: activity.summary, path: activity.path, publishedAt: activity.dateTime, topic: record?.topic ?? project.topics[0] ?? "", project: project.title, }; }), ); /* 릴리스는 넣지 않는다. 이 목록은 서버의 `latestEntries` 와 같은 의미여야 하고, 그쪽은 공개 투영에서 고르므로 릴리스가 없다 — 릴리스는 Publication 파이프라인을 거치지 않는다. 홈 화면이 릴리스를 따로 읽어 합치므로, 여기서도 넣으면 같은 릴리스가 두 번 나온다. */ return [...activities].sort((left, right) => right.publishedAt.localeCompare(left.publishedAt), ); } export function getHomeFocusItems(): HomeFocusItem[] { const project = getProject("backend-skeleton"); const question = getRecord("QUESTION", "validate-edge-token-again"); const decision = getProjectDecisions("backend-skeleton").find( (item) => item.id === "storage-port-unification", ); if (!project || !question || !decision) { throw new Error("Missing canonical entity required for the public home focus"); } return [ { key: "current", label: "현재 작업", title: project.title, summary: project.summary, details: [ { label: "단계", value: project.stage }, { label: "현재 목표", value: project.currentGoal }, { label: "다음 작업", value: project.nextStep }, ], targetPath: `/projects/${project.slug}`, }, { key: "question", label: "열린 질문", title: question.title, summary: question.summary, details: [ { label: "확인한 사실", value: question.facts[0] ?? "" }, { label: "남은 미지수", value: question.unknowns[0] ?? "" }, { label: "다음 검증", value: question.nextValidation }, ], targetPath: question.path, }, { key: "decision", label: "최근 결정", title: decision.statement, summary: decision.rationale, details: [ { label: "영향", value: decision.consequences[0] ?? "" }, { label: "근거", value: decision.evidence[0]?.title ?? "" }, ], targetPath: `/projects/${project.slug}/decisions#${decision.id}`, }, ]; } function recordSearchEntity(record: PublicRecord): SearchablePublicEntity { return { contentType: record.kind, title: record.title, summary: record.summary, path: record.path, topic: record.topic, project: record.projectTitle, publishedAt: record.publishedAt, }; } function projectSearchEntity(project: Project): SearchablePublicEntity { return { contentType: "PROJECT", title: project.title, summary: project.summary, path: `/projects/${project.slug}`, topics: project.topics, project: project.title, }; } function releaseSearchEntity(release: Release): SearchablePublicEntity { return { contentType: "RELEASE", title: release.title, summary: release.summary, path: release.path, topic: "TechLog", project: "TechLog", publishedAt: release.publishedAt, }; } export function searchPublicContent(query: string): SearchablePublicEntity[] { const normalizedQuery = query.trim().toLocaleLowerCase("ko-KR"); const entities = [ ...publicRecords.map(recordSearchEntity), ...projects.map(projectSearchEntity), ...releases.map(releaseSearchEntity), ]; if (!normalizedQuery) return entities; return entities.filter((entity) => [ entity.title, entity.summary, entity.topic, entity.project, ...(entity.topics ?? []), ] .filter((value): value is string => Boolean(value)) .some((value) => value.toLocaleLowerCase("ko-KR").includes(normalizedQuery), ), ); } /** * The MOCK source. The functions above stay synchronous — they filter arrays * that are already in the bundle, and making them async would only add a * microtask to every fixture test — so the port's async shape is applied here, * at the adapter boundary, rather than pushed into the query implementations. * * `async` rather than `Promise.resolve(...)` so a throw from one of these * becomes a rejected promise like the HTTP adapter's would, instead of * escaping synchronously past the caller's await. */ export const publicContentQueries = Object.freeze({ async listRecords(filters?: RecordFilters) { return listRecords(filters); }, async getRecord(kind: K, slug: string) { return getRecord(kind, slug); }, async getProject(slug: string) { return getProject(slug); }, async getRelease(version: string) { return getRelease(version); }, async getProjectRecords(projectSlug: string) { return getProjectRecords(projectSlug); }, async getProjectDecisions(projectSlug: string) { return getProjectDecisions(projectSlug); }, async getProjectActivity(projectSlug: string) { return getProjectActivity(projectSlug); }, async listTopics() { return listTopics(); }, async getLatestEntries() { return getLatestEntries(); }, async getHomeFocusItems() { return getHomeFocusItems(); }, async searchPublicContent(query: string) { return searchPublicContent(query); }, }) satisfies PublicContentQueries;