diff --git a/public/release-manifest.json b/public/release-manifest.json index d6e1080..9ca56a2 100644 --- a/public/release-manifest.json +++ b/public/release-manifest.json @@ -38,7 +38,7 @@ }, "contractSet": { "setAlgorithm": "CA_CONTRACT_SET_V1", - "setDigest": "sha256:8b5bcbc235bc825483002374f84d433e2668a613e270e6558934fa277bd62762", + "setDigest": "sha256:7ee35548d2a84b744f8c17c7b785a79b328dcf756f5c9df8d77d37ff1535b087", "packages": [ { "packageId": "@tech-log/management-contract", @@ -49,8 +49,8 @@ }, { "packageId": "@tech-log/public-contract", - "version": "2.0.0", - "digest": "sha256:6575a09317a1ffe951747b12102ad2cf884110007426a45d6f59a53b65612d59", + "version": "2.1.0", + "digest": "sha256:37e6f804165de3e492e975075bea563ee41ae74076222a3562d3452631bfdb2b", "runtimeProtocolVersion": 1, "sourceRevision": "b195b29" }, diff --git a/src/features/tech-log/adapters/http/http-public-content-gateway.ts b/src/features/tech-log/adapters/http/http-public-content-gateway.ts index faed127..f0c1116 100644 --- a/src/features/tech-log/adapters/http/http-public-content-gateway.ts +++ b/src/features/tech-log/adapters/http/http-public-content-gateway.ts @@ -162,12 +162,27 @@ export function createHttpPublicContentGateway( kind: "CASE", problem: (body.problemSummary as string) ?? "", conclusion: (body.conclusionSummary as string) ?? "", - environment: ((body.environmentSummary as readonly string[]) ?? []).join(", "), - // The Case document renders a verification line. The contract has no - // field for it — verification lives in the body — so it stays empty - // rather than being guessed from a heading. - verification: "", + // `environmentSummary` 는 검증 환경과 재현 조건을 그 순서로 담는다 — 서버가 비어 있지 + // 않은 것만 순서대로 넣는다. 예전에는 둘을 쉼표로 이어 붙여 한 칸에 넣고 재현 조건 칸은 + // "계약에 없다"며 비워 두었는데, 계약에는 있었고 채우는 쪽이 없었을 뿐이다. + environment: ((body.environmentSummary as readonly string[]) ?? [])[0] ?? "", + verification: ((body.environmentSummary as readonly string[]) ?? [])[1] ?? "", lastVerifiedLabel: dateLabel(body.lastVerifiedAt as string), + content: (body.content as string) ?? "", + bodyAssets: Object.freeze( + ((body.bodyAssets as readonly Readonly>[]) ?? []).map((asset) => + Object.freeze({ + assetKey: asset.assetKey as string, + assetId: asset.assetId as string, + url: asset.url as string, + contentType: asset.contentType as string, + altText: (asset.altText as string) ?? "", + width: (asset.width as number) ?? null, + height: (asset.height as number) ?? null, + decorative: Boolean(asset.decorative), + }), + ), + ), sections: markdownSections(body.content as string), }) as unknown as Extract; } diff --git a/src/features/tech-log/adapters/http/public-content-mapping.ts b/src/features/tech-log/adapters/http/public-content-mapping.ts index a81384b..22a87ca 100644 --- a/src/features/tech-log/adapters/http/public-content-mapping.ts +++ b/src/features/tech-log/adapters/http/public-content-mapping.ts @@ -194,6 +194,9 @@ export function knowledgeListItemToRecord(item: Readonly environment: "", verification: "", lastVerifiedLabel: dateLabel(item.lastVerifiedAt as string), + // 목록 항목은 본문을 담지 않는다 — 본문은 상세 조회에서만 온다. + content: "", + bodyAssets: Object.freeze([]), sections: Object.freeze([]), }) as CaseRecord; } diff --git a/src/features/tech-log/adapters/static/public-content.ts b/src/features/tech-log/adapters/static/public-content.ts index 00437bf..777cba5 100644 --- a/src/features/tech-log/adapters/static/public-content.ts +++ b/src/features/tech-log/adapters/static/public-content.ts @@ -35,6 +35,18 @@ type PublicRecordBase = { relations: ReadonlyArray; }; +/** 본문이 `:::evidence key="..."` 로 가리키는 Asset. 계약의 `BodyAsset` 과 같은 모양이다. */ +export type PublicBodyAsset = { + assetKey: string; + assetId: string; + url: string; + contentType: string; + altText: string; + width: number | null; + height: number | null; + decorative: boolean; +}; + export type CaseRecord = PublicRecordBase & { kind: "CASE"; problem: string; @@ -42,6 +54,9 @@ export type CaseRecord = PublicRecordBase & { environment: string; verification: string; lastVerifiedLabel: string; + /** 본문 Markdown 원문. 정적 기록은 문서 화면이 자체 본문을 쓰므로 비어 있다. */ + content: string; + bodyAssets: ReadonlyArray; sections: ReadonlyArray; }; @@ -173,6 +188,8 @@ export const publicRecords: ReadonlyArray = [ environment: "PostgreSQL 16 · Hibernate 6 · Spring Data JPA", verification: "FeedItem 100개, Zipf 편중 Highlight/Mention", lastVerifiedLabel: "2026.08.11", + content: "", + bodyAssets: [], sections: [ { id: "fix-the-problem", @@ -256,6 +273,8 @@ export const publicRecords: ReadonlyArray = [ environment: "Spring Boot · Redis · Testcontainers", verification: "동일한 Port 계약으로 In-memory와 Redis Adapter 계약 테스트 실행", lastVerifiedLabel: "2026.08.07", + content: "", + bodyAssets: [], sections: [ { id: "ownership", diff --git a/src/features/tech-log/application/ports/public-content-queries.ts b/src/features/tech-log/application/ports/public-content-queries.ts index 354626c..a78f04c 100644 --- a/src/features/tech-log/application/ports/public-content-queries.ts +++ b/src/features/tech-log/application/ports/public-content-queries.ts @@ -36,6 +36,23 @@ type PublicRecordBase = { relations: ReadonlyArray; }; +/** + * 본문이 `:::evidence key="..."` 로 가리키는 Asset. + * + *

본문에는 key 만 있고 `/media/{assetId}` 는 UUID 로만 서빙하므로 — 주소가 추측 불가능한 것이 + * 의도된 성질이다 — 공개 화면이 key 를 주소로 바꾸려면 이 대응이 함께 와야 한다. + */ +export type PublicBodyAsset = { + assetKey: string; + assetId: string; + url: string; + contentType: string; + altText: string; + width: number | null; + height: number | null; + decorative: boolean; +}; + export type CaseRecord = PublicRecordBase & { kind: "CASE"; problem: string; @@ -43,6 +60,14 @@ export type CaseRecord = PublicRecordBase & { environment: string; verification: string; lastVerifiedLabel: string; + /** + * 본문 Markdown 원문. + * + *

`sections` 는 이것을 제목·문단·불릿으로만 줄인 것이라 표·코드·callout·evidence 가 사라진다. + * 문서 화면은 원문을 직접 파싱한다. + */ + content: string; + bodyAssets: ReadonlyArray; sections: ReadonlyArray; }; diff --git a/src/features/tech-log/contracts/public/canonical-source.json b/src/features/tech-log/contracts/public/canonical-source.json index 756e5b8..c00217c 100644 --- a/src/features/tech-log/contracts/public/canonical-source.json +++ b/src/features/tech-log/contracts/public/canonical-source.json @@ -1,7 +1,7 @@ { "packageId": "@tech-log/public-contract", - "version": "2.0.0", - "digest": "sha256:6575a09317a1ffe951747b12102ad2cf884110007426a45d6f59a53b65612d59", + "version": "2.1.0", + "digest": "sha256:37e6f804165de3e492e975075bea563ee41ae74076222a3562d3452631bfdb2b", "sourceRevision": "b195b29", "operationIds": [ "getPublicSite", diff --git a/src/features/tech-log/contracts/public/generated.ts b/src/features/tech-log/contracts/public/generated.ts index a0e3c39..0fd42cc 100644 --- a/src/features/tech-log/contracts/public/generated.ts +++ b/src/features/tech-log/contracts/public/generated.ts @@ -373,6 +373,30 @@ export interface components { height?: number; contentType?: string; }; + /** @description 본문이 `:::evidence key="..."` 로 가리키는 Asset 이다. + * + * 본문은 Markdown 원문으로 나가고 그 안에는 key 만 있는데, `/media/{assetId}` 는 UUID + * 로만 서빙한다 — 주소가 추측 불가능한 것이 의도된 성질이므로 key 에서 주소를 만들 수 + * 없다. 그래서 공개 화면이 key 를 해석할 수 있도록, 게시된 기록이 실제로 참조하는 Asset 을 + * 함께 준다. + * + * 목록은 게시 시점에 고정된 `PUBLISHED` scope 의 참조에서 온다. 게시 이후 작업본이 Asset + * 을 바꿔도 이미 공개된 본문이 가리키는 대상은 달라지지 않는다. + * */ + BodyAsset: { + assetKey: string; + /** Format: uuid */ + assetId: string; + url: string; + contentType: string; + altText?: string; + width?: number; + height?: number; + /** @description 장식용이면 대체 텍스트가 비어 있어도 된다. 게시 검증이 이 값으로 판정하므로 공개 + * 화면도 같은 값을 보고 `alt` 를 정해야 판정과 표시가 어긋나지 않는다. + * */ + decorative: boolean; + }; RelatedEntry: { /** @enum {string} */ type: "CASE" | "REFERENCE" | "QUESTION" | "PROJECT" | "PROJECT_DECISION" | "RELEASE"; @@ -523,6 +547,10 @@ export interface components { tags: components["schemas"]["TagSummary"][]; primaryProject?: components["schemas"]["ProjectSummary"]; coverAsset?: components["schemas"]["AssetReference"]; + /** @description 본문이 참조하는 Asset. 비어 있을 수 있다 — 본문에 evidence 가 없거나, 참조한 + * Asset 이 더 이상 서빙되지 않는 경우다. + * */ + bodyAssets?: components["schemas"]["BodyAsset"][]; /** Format: date-time */ publishedAt: string; /** Format: date-time */ diff --git a/src/features/tech-log/contracts/public/public-api.openapi.yaml b/src/features/tech-log/contracts/public/public-api.openapi.yaml index 42935c1..1c7cf2e 100644 --- a/src/features/tech-log/contracts/public/public-api.openapi.yaml +++ b/src/features/tech-log/contracts/public/public-api.openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: Tech Log Public API - version: 2.0.0 + version: 2.1.0 description: | Tech Log 공개 조회 계약이다. 인증이 필요하지 않다. @@ -815,6 +815,47 @@ components: type: integer contentType: type: string + BodyAsset: + type: object + description: | + 본문이 `:::evidence key="..."` 로 가리키는 Asset 이다. + + 본문은 Markdown 원문으로 나가고 그 안에는 key 만 있는데, `/media/{assetId}` 는 UUID + 로만 서빙한다 — 주소가 추측 불가능한 것이 의도된 성질이므로 key 에서 주소를 만들 수 + 없다. 그래서 공개 화면이 key 를 해석할 수 있도록, 게시된 기록이 실제로 참조하는 Asset 을 + 함께 준다. + + 목록은 게시 시점에 고정된 `PUBLISHED` scope 의 참조에서 온다. 게시 이후 작업본이 Asset + 을 바꿔도 이미 공개된 본문이 가리키는 대상은 달라지지 않는다. + required: + - assetKey + - assetId + - url + - contentType + - decorative + properties: + assetKey: + type: string + minLength: 1 + maxLength: 200 + assetId: + type: string + format: uuid + url: + type: string + contentType: + type: string + altText: + type: string + width: + type: integer + height: + type: integer + decorative: + type: boolean + description: | + 장식용이면 대체 텍스트가 비어 있어도 된다. 게시 검증이 이 값으로 판정하므로 공개 + 화면도 같은 값을 보고 `alt` 를 정해야 판정과 표시가 어긋나지 않는다. RelatedEntry: type: object required: @@ -1224,6 +1265,13 @@ components: $ref: '#/components/schemas/ProjectSummary' coverAsset: $ref: '#/components/schemas/AssetReference' + bodyAssets: + type: array + description: | + 본문이 참조하는 Asset. 비어 있을 수 있다 — 본문에 evidence 가 없거나, 참조한 + Asset 이 더 이상 서빙되지 않는 경우다. + items: + $ref: '#/components/schemas/BodyAsset' publishedAt: *id003 updatedAt: *id003 lastVerifiedAt: *id003 diff --git a/src/features/tech-log/presentation/public/components/case-document-page.tsx b/src/features/tech-log/presentation/public/components/case-document-page.tsx index f722e1e..c731387 100644 --- a/src/features/tech-log/presentation/public/components/case-document-page.tsx +++ b/src/features/tech-log/presentation/public/components/case-document-page.tsx @@ -180,7 +180,42 @@ function resolvePublicEvidenceAssetDescriptor( }; } +/** + * 게시된 본문을 블록으로 바꾼다. + * + *

예전에는 하드코딩된 슬러그 하나만 진짜 파서를 탔고 나머지는 모두 {@link genericCaseBlocks} + * 를 거쳤다 — 정규식이 `##` 제목과 `-` 불릿만 알아보므로 표·코드·callout 은 물론 evidence + * directive 까지 글자 그대로 문단이 되어 공개 화면에 그대로 보였다. + * + *

본문이 지원하지 않는 문법을 담고 있으면 화면 전체를 잃는 대신 예전 방식으로 돌아간다. + * 읽는 사람에게는 덜 정확한 화면이 빈 화면보다 낫다. + */ +function caseBodyBlocks(record: CaseRecord): CaseAuthoringBlock[] { + if (record.slug === "collection-fetch-join-pagination") { + return parseCaseContent(fetchJoinBody); + } + if (!record.content.trim()) return genericCaseBlocks(record); + try { + return parseCaseContent(record.content); + } catch { + return genericCaseBlocks(record); + } +} + export function CaseDocumentPage({ record }: { record: CaseRecord }) { + /* + 본문은 evidence 를 key 로만 가리키고 `/media/{assetId}` 는 UUID 로만 서빙하므로, 계약이 함께 + 준 `bodyAssets` 로 key 를 주소로 바꾼다. 대응이 없는 key 는 그 블록을 지운다 — 해석기가 + 던지면 문서 전체가 사라지고, 남겨 두면 주소 없는 그림 자리가 남는다. + */ + const assetsByKey = new Map(record.bodyAssets.map((asset) => [asset.assetKey, asset])); + const blocks = caseBodyBlocks(record).filter( + (block) => + block.type !== "EVIDENCE_FIGURE" || + record.slug === "collection-fetch-join-pagination" || + assetsByKey.has(block.key), + ); + const model = resolveCaseEvidenceAssets( { ...publicRenderModelBase(record), @@ -190,18 +225,37 @@ export function CaseDocumentPage({ record }: { record: CaseRecord }) { environment: record.environment, reproduction: record.verification, lastVerifiedOn: record.lastVerifiedLabel.replaceAll(".", "-"), - bodyBlocks: - record.slug === "collection-fetch-join-pagination" - ? parseCaseContent(fetchJoinBody) - : genericCaseBlocks(record), + bodyBlocks: blocks, + }, + (key) => { + const asset = assetsByKey.get(key); + if (!asset) return resolvePublicEvidenceAssetDescriptor(key); + return { + assetId: asset.assetId, + assetKey: asset.assetKey, + mediaType: asset.contentType, + publicPath: asset.url, + width: asset.width, + height: asset.height, + decorative: asset.decorative, + }; }, - resolvePublicEvidenceAssetDescriptor, ); return ( { + const asset = assetsByKey.get(key); + if (!asset) return resolvePublicEvidenceAsset(key); + return { + src: asset.url, + width: asset.width ?? 0, + height: asset.height ?? 0, + triggerLabel: `${asset.altText || asset.assetKey} 크게 보기`, + dialogLabel: asset.altText || asset.assetKey, + }; + }} resolvePublishedLabel={(path) => path === record.path ? record.publishedLabel : undefined } diff --git a/src/features/tech-log/presentation/studio/components/case-fields.tsx b/src/features/tech-log/presentation/studio/components/case-fields.tsx index 6ec28db..687d1ea 100644 --- a/src/features/tech-log/presentation/studio/components/case-fields.tsx +++ b/src/features/tech-log/presentation/studio/components/case-fields.tsx @@ -5,9 +5,20 @@ import type { Asset, AssetKind } from "../../../contracts/studio/contract.ts"; import { useStudioAssetGateway } from "../use-studio.ts"; import { AssetPicker, buildEvidenceDirective } from "./asset-picker.tsx"; import { AssetUploadDialog } from "./asset-upload-dialog.tsx"; +import { FieldNotice, type FieldIssue } from "./field-issues.tsx"; type CaseInput = components["schemas"]["CaseInput"]; +/** 이 화면이 자기 칸 아래에 보여 줄 수 있는 경로. */ +export const CASE_FIELD_PATHS = [ + "/problem", + "/conclusion", + "/environment", + "/reproduction", + "/lastVerifiedOn", + "/bodyMarkdown", +] as const; + const ASSET_KIND_OPTIONS: ReadonlyArray<{ value: AssetKind; label: string }> = [ { value: "IMAGE", label: "이미지" }, { value: "DIAGRAM", label: "다이어그램" }, @@ -36,11 +47,14 @@ function insertAtCursor( export function CaseFields({ draft, + issues, onChange, onAssetsObserved, onAssetUploaded, }: { draft: CaseInput; + /** 마지막 게시 시도가 남긴 지적. 각 칸 아래에는 그 칸의 것만 붙는다. */ + issues: readonly FieldIssue[]; onChange(draft: CaseInput): void; /** * The editor screen owns the resolution catalog Instant Preview reads, and @@ -81,12 +95,12 @@ export function CaseFields({

CASE

문제와 검증

-