feat: resolve evidence figures from backend asset descriptors

Public Preview and Publication Snapshot now resolve evidence figures from the
ResolvedAsset descriptor the server already attaches to each EVIDENCE_FIGURE
block, instead of a local literal that only knew one hardcoded key and threw
on anything else. Instant Preview gains an `assets` prop (defaults to `[]`,
unwired until the Asset Picker task) and stops throwing when a key has no
catalog match yet.

Builds on Task 1's existing seam (resolveCaseEvidenceAssets /
ResolveEvidenceAsset) via a new asset-resolvers.ts rather than a parallel
path. Kept out of adapters/ (presentation may not import it) by carrying a
small local copy of the one legacy fixture entry, checked before any
descriptor match so the pre-existing key keeps rendering byte-identically
across surfaces during the migration window. Non-READY assets never resolve;
unresolvable keys return a placeholder instead of throwing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-18 03:54:30 +09:00
co-authored by Claude Opus 5
parent 0c071aaabc
commit 9d91001a31
5 changed files with 279 additions and 62 deletions
@@ -0,0 +1,116 @@
import type { Asset } from "../../../contracts/studio/contract.ts";
import type {
EvidenceAsset,
ResolveEvidenceAsset,
} from "../../../domain/public-render-content.ts";
/**
* `presentation/` may not import `adapters/` (the
* `feature-presentation-does-not-know-outbound-adapters` architecture rule), so this
* cannot read `adapters/static/evidence-assets.ts` directly. It carries its own copy of
* that one legacy entry instead -- the same pattern `instant-preview.tsx`,
* `public-preview-screen.tsx`, `publication-event-preview-screen.tsx`, and
* `case-document-page.tsx` already each carry independently. This is not a second
* source of truth for new assets; it exists solely so the one pre-existing hardcoded
* key keeps resolving to byte-identical output once screens move onto this resolver.
*/
const LEGACY_STATIC_EVIDENCE_ASSETS: Readonly<Record<string, EvidenceAsset>> = {
"fetch-strategy-boundary": Object.freeze({
src: "/media/fetch-strategy-boundary.svg",
width: 1080,
height: 420,
triggerLabel: "Fetch Join과 Batch Fetch 비교 다이어그램 크게 보기",
dialogLabel: "Fetch Join과 Batch Fetch의 페이징 경계 확대",
}),
};
/**
* `ResolveEvidenceAsset` is a total function. Throwing on an unresolved key would
* take down the whole preview for one bad or not-yet-synced reference, so every
* resolver here returns this placeholder instead. Blocking a genuinely bad
* reference is Publish validation's job, not the renderer's.
*/
export const MISSING_EVIDENCE_ASSET: EvidenceAsset = Object.freeze({
src: "",
width: 1,
height: 1,
triggerLabel: "사용할 수 없는 Evidence",
dialogLabel: "사용할 수 없는 Evidence",
});
/** Structural shape of `components["schemas"]["ResolvedAsset"]`, loosened for reuse. */
export type ResolvedAssetLike = Readonly<{
assetId: string;
assetKey: string;
mediaType: string;
publicPath: string;
width: number | null;
height: number | null;
decorative: boolean;
}>;
function fromDescriptor(descriptor: ResolvedAssetLike): EvidenceAsset {
return Object.freeze({
src: descriptor.publicPath,
width: descriptor.width ?? 1,
height: descriptor.height ?? 1,
triggerLabel: `${descriptor.assetKey} 이미지 크게 보기`,
dialogLabel: `${descriptor.assetKey} 확대`,
});
}
/**
* Shared resolution order for both factories below: the legacy static key always
* wins on a match (protects the one pre-existing hardcoded fixture from splitting
* across render surfaces mid-migration), then the caller-supplied lookup (the real
* backend-sourced descriptor), then the safe placeholder.
*/
function resolveWith(
key: string,
lookup: (key: string) => ResolvedAssetLike | undefined,
): EvidenceAsset {
const legacy = LEGACY_STATIC_EVIDENCE_ASSETS[key];
if (legacy) return legacy;
const descriptor = lookup(key);
return descriptor ? fromDescriptor(descriptor) : MISSING_EVIDENCE_ASSET;
}
/** Instant Preview: resolves against the Asset list the editor has loaded. */
export function createAssetCatalogResolver(
assets: readonly Asset[],
): ResolveEvidenceAsset {
const byKey = new Map<string, Asset>();
for (const asset of assets) {
// QUARANTINED/REJECTED must never render on any surface.
if (asset.managementStatus !== "READY") continue;
byKey.set(asset.assetKey, asset);
}
return (key: string) =>
resolveWith(key, (lookupKey) => {
const asset = byKey.get(lookupKey);
if (!asset || asset.publicPath === null) return undefined;
return {
assetId: asset.id,
assetKey: asset.assetKey,
mediaType: asset.mediaType,
publicPath: asset.publicPath,
width: asset.width,
height: asset.height,
decorative: asset.decorative,
};
});
}
/** Public Preview · Snapshot: resolves from the descriptor the server already attached to the block. */
export function createResolvedAssetResolver(
blocks: readonly Readonly<{ type: string; key?: string; asset?: ResolvedAssetLike }>[],
): ResolveEvidenceAsset {
const byKey = new Map<string, ResolvedAssetLike>();
for (const block of blocks) {
if (block.type !== "EVIDENCE_FIGURE" || !block.asset) continue;
byKey.set(block.asset.assetKey, block.asset);
}
return (key: string) => resolveWith(key, (lookupKey) => byKey.get(lookupKey));
}
@@ -1,10 +1,11 @@
import type { components } from "../../../contracts/studio/generated.ts";
import type { WorkingCopyInput } from "../../../contracts/studio/contract.ts";
import type { Asset, WorkingCopyInput } from "../../../contracts/studio/contract.ts";
import { ContentFormatError } from "../../../domain/content-format/parse-case-content.ts";
import {
projectWorkingCopy,
resolveCaseEvidenceAssets,
} from "../../../domain/content-format/project-public-render-model.ts";
import { createAssetCatalogResolver } from "../../shared/public-render/asset-resolvers.ts";
import { PublicRecordRenderer } from "../../shared/public-render/public-record-renderer.tsx";
import { useStudio } from "../use-studio.ts";
@@ -16,39 +17,64 @@ function supportsPreviewEvidenceKey(key: string): boolean {
return key === "fetch-strategy-boundary";
}
function resolvePreviewEvidenceAsset(key: string) {
if (!supportsPreviewEvidenceKey(key)) {
throw new Error(`Unknown local evidence asset: ${key}`);
}
return {
src: "/media/fetch-strategy-boundary.svg",
width: 1080,
height: 420,
triggerLabel: "Fetch Join과 Batch Fetch 비교 다이어그램 크게 보기",
dialogLabel: "Fetch Join과 Batch Fetch의 페이징 경계 확대",
/**
* `resolveCaseEvidenceAssets` only needs *a* `ResolvedAsset` to turn the authoring
* model into a genuine `PublicRenderModel` -- nothing downstream reads `block.asset`
* for the pixels actually shown; that comes from `resolveEvidenceAsset` below
* (`createAssetCatalogResolver`). So this must never throw: a key the Asset Picker
* (Task 10) inserts, with no catalog match yet, still gets a placeholder descriptor
* instead of blanking the whole preview behind an error panel.
*/
function resolveAssetDescriptor(assets: readonly Asset[]) {
return (key: string): ResolvedAsset => {
const asset = assets.find(
(candidate) => candidate.assetKey === key && candidate.managementStatus === "READY",
);
if (asset?.publicPath) {
return {
assetId: asset.id,
assetKey: asset.assetKey,
mediaType: asset.mediaType,
publicPath: asset.publicPath,
width: asset.width,
height: asset.height,
decorative: asset.decorative,
};
}
if (supportsPreviewEvidenceKey(key)) {
// Fixed literal, not derived: this file only ever resolves this one legacy
// key today, so the id only needs to be stable, not computed.
return {
assetId: "00000000-0000-4000-8000-000000000001",
assetKey: key,
mediaType: "image/svg+xml",
publicPath: "/media/fetch-strategy-boundary.svg",
width: 1080,
height: 420,
decorative: false,
};
}
return {
assetId: "00000000-0000-4000-8000-000000000000",
assetKey: key,
mediaType: "application/octet-stream",
publicPath: "",
width: null,
height: null,
decorative: true,
};
};
}
// This preview has no adapter-level asset catalog access, so it resolves the
// canonical descriptor from the same local fixture data as `resolvePreviewEvidenceAsset`
// above. The `assetId` is a fixed literal (not derived) because this file only ever
// resolves this one key; it only needs to be stable, not computed.
function resolvePreviewEvidenceAssetDescriptor(key: string): ResolvedAsset {
if (!supportsPreviewEvidenceKey(key)) {
throw new Error(`Unknown local evidence asset: ${key}`);
}
return {
assetId: "00000000-0000-4000-8000-000000000001",
assetKey: key,
mediaType: "image/svg+xml",
publicPath: "/media/fetch-strategy-boundary.svg",
width: 1080,
height: 420,
decorative: false,
};
}
export function InstantPreview({ draft, catalog }: { draft: WorkingCopyInput; catalog: CatalogEntry[] }) {
export function InstantPreview({
draft,
catalog,
assets = [],
}: {
draft: WorkingCopyInput;
catalog: CatalogEntry[];
assets?: readonly Asset[];
}) {
const studio = useStudio();
let model: PublicRenderModel | null = null;
let issues: string[] | null = null;
@@ -60,7 +86,7 @@ export function InstantPreview({ draft, catalog }: { draft: WorkingCopyInput; ca
{ mode: "PREVIEW", publishedAt: null },
supportsPreviewEvidenceKey,
),
resolvePreviewEvidenceAssetDescriptor,
resolveAssetDescriptor(assets),
);
} catch (error) {
issues = error instanceof ContentFormatError
@@ -73,7 +99,7 @@ export function InstantPreview({ draft, catalog }: { draft: WorkingCopyInput; ca
<PublicRecordRenderer
model={model!}
embedded
resolveEvidenceAsset={resolvePreviewEvidenceAsset}
resolveEvidenceAsset={createAssetCatalogResolver(assets)}
resolvePublishedLabel={studio.resolvePublishedLabel}
/>
</div>
@@ -10,6 +10,7 @@ import type {
} from "../../../contracts/studio/contract.ts";
import { deriveValidationState } from "../../../domain/studio/document-state.ts";
import { createLocalId } from "../../../domain/studio/local-id.ts";
import { createResolvedAssetResolver } from "../../shared/public-render/asset-resolvers.ts";
import { PublicRecordRenderer } from "../../shared/public-render/public-record-renderer.tsx";
import { useStudio } from "../use-studio.ts";
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
@@ -45,19 +46,6 @@ function formatDateTime(value: string): string {
}).format(date);
}
function resolvePreviewEvidenceAsset(key: string) {
if (key !== "fetch-strategy-boundary") {
throw new Error(`Unknown local evidence asset: ${key}`);
}
return {
src: "/media/fetch-strategy-boundary.svg",
width: 1080,
height: 420,
triggerLabel: "Fetch Join과 Batch Fetch 비교 다이어그램 크게 보기",
dialogLabel: "Fetch Join과 Batch Fetch의 페이징 경계 확대",
};
}
export function PublicPreviewScreen({ documentId }: { documentId: string }) {
const studio = useStudio();
const [retryKey, setRetryKey] = useState(0);
@@ -287,7 +275,11 @@ export function PublicPreviewScreen({ documentId }: { documentId: string }) {
<PublicRecordRenderer
model={previewDetail.preview.renderModel}
embedded
resolveEvidenceAsset={resolvePreviewEvidenceAsset}
resolveEvidenceAsset={createResolvedAssetResolver(
previewDetail.preview.renderModel.kind === "CASE"
? previewDetail.preview.renderModel.bodyBlocks
: [],
)}
resolvePublishedLabel={studio.resolvePublishedLabel}
/>
</div>
@@ -5,7 +5,7 @@ import {
type StudioGatewayError,
} from "../../../application/ports/studio-gateway-error.ts";
import type { PublicationSnapshot } from "../../../contracts/studio/contract.ts";
import type { EvidenceAsset } from "../../../domain/public-render-content.ts";
import { createResolvedAssetResolver } from "../../shared/public-render/asset-resolvers.ts";
import { PublicRecordRenderer } from "../../shared/public-render/public-record-renderer.tsx";
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
import {
@@ -20,19 +20,6 @@ const eventLabels = {
UNPUBLISHED: "게시 취소",
} as const;
function resolveEvidenceAsset(key: string): EvidenceAsset {
if (key !== "fetch-strategy-boundary") {
throw new Error(`Unknown local evidence asset: ${key}`);
}
return {
src: "/media/fetch-strategy-boundary.svg",
width: 1080,
height: 420,
triggerLabel: "Fetch Join과 Batch Fetch 비교 다이어그램 크게 보기",
dialogLabel: "Fetch Join과 Batch Fetch의 페이징 경계 확대",
};
}
function dateTime(value: string) {
return new Intl.DateTimeFormat("ko-KR", {
dateStyle: "long",
@@ -166,7 +153,9 @@ export function PublicationEventPreviewScreen({
<PublicRecordRenderer
model={renderModel}
embedded
resolveEvidenceAsset={resolveEvidenceAsset}
resolveEvidenceAsset={createResolvedAssetResolver(
renderModel.kind === "CASE" ? renderModel.bodyBlocks : [],
)}
resolvePublishedLabel={studio.resolvePublishedLabel}
/>
</div>