diff --git a/apps/api/src/modules/build/render-snapshot.ts b/apps/api/src/modules/build/render-snapshot.ts index d7ac50f..f9e91a9 100644 --- a/apps/api/src/modules/build/render-snapshot.ts +++ b/apps/api/src/modules/build/render-snapshot.ts @@ -2,6 +2,9 @@ // 从 published 修订 + route_registry + media ready 集合物化冻结六文件 // (RENDER_SNAPSHOT_FILES 契约清单),**db 只读、零写入**(磁盘仅做副本存在性探测)。 // 构建编排(job 状态机/落盘/快照行)在 build.service.ts——本文件只负责"读出什么"。 +// 载荷类型(ContentEntry/RouteEntry/AssetEntry/TaxonomyEntry/RenderManifestMeta)与 +// 主题消费端同源于 @sf/contracts/render-snapshot(Plan 4 Task 3 审查上提——双份 +// 手写镜像漂移即编译期破裂)。 // // 发布语义(ADR-010):只读 entry_locales.published_revision_id 指向的修订——current // 分叉(发布后再导入产新修订)不影响本快照;发布后的人工/导入变化须重新 publish + @@ -38,7 +41,15 @@ import { PARSER_VERSION, SANITIZER_VERSION, } from '@sf/adapter-wp' -import { RENDER_SNAPSHOT_VERSION, type RenderSnapshotFile } from '@sf/contracts' +import { + RENDER_SNAPSHOT_VERSION, + type AssetEntry, + type ContentEntry, + type RenderManifestMeta, + type RenderSnapshotFile, + type RouteEntry, + type TaxonomyEntry, +} from '@sf/contracts' import { canonicalJson } from '../../common/canonical.js' import { contentEntries, @@ -325,7 +336,7 @@ export async function materializeRenderSnapshot( } } - const contentPayload = published.map((row) => { + const contentPayload: ContentEntry[] = published.map((row) => { const data = (row.data ?? {}) as Record let entryData = data if (typeof data.bodyHtml === 'string') { @@ -337,14 +348,14 @@ export async function materializeRenderSnapshot( // slug 以修订 data.slug(发布时点冻结值)为准,缺席回退 entry_locales.slug(current 跟随面) const slug = typeof data.slug === 'string' && data.slug !== '' ? data.slug : row.localeSlug const media = usageMap.get(row.revisionId!) - const entry: Record = { + const entry: ContentEntry = { contentId: row.contentId, typeKey: row.typeKey, locale: row.locale, slug, data: entryData, - revisionId: row.revisionId, - checksum: row.checksum, + revisionId: row.revisionId!, + checksum: row.checksum!, } if (media !== undefined && media.size > 0) { entry.media = Object.fromEntries([...media.entries()].sort(([a], [b]) => cmp(a, b))) @@ -352,10 +363,10 @@ export async function materializeRenderSnapshot( return entry }) contentPayload.sort((a, b) => - cmp(a.typeKey as string, b.typeKey as string) - || cmp(a.slug as string, b.slug as string) - || cmp(a.locale as string, b.locale as string) - || cmp(a.contentId as string, b.contentId as string), + cmp(a.typeKey, b.typeKey) + || cmp(a.slug, b.slug) + || cmp(a.locale, b.locale) + || cmp(a.contentId, b.contentId), ) // taxonomies.json:taxonomies × terms 全量 + content_terms 关联(auto: 自建行 source_id 原样) @@ -396,7 +407,7 @@ export async function materializeRenderSnapshot( list.push(t) termsByTaxonomy.set(t.taxonomyId, list) } - const taxonomiesPayload = taxonomyRows.map((tax) => { + const taxonomiesPayload: TaxonomyEntry[] = taxonomyRows.map((tax) => { const group = (termsByTaxonomy.get(tax.id) ?? []).map((t) => ({ id: t.id, sourceId: t.sourceId, @@ -437,7 +448,9 @@ export async function materializeRenderSnapshot( `路由表含 ${unservicable.length} 条不可服务形态(dynamic/unresolved)——不进 routes.json(处置是 SP3+ 查询路由域)`, ) } - const routesPayload = routeRows.filter((r) => SERVICABLE.has(r.routeKind)) + // 契约载荷类型同源(Task 3 审查上提):filter 收窄 routeKind 枚举面(html/asset/redirect/gone) + // TS 不可静态感知——as 是 Set 过滤的运行时保证,非断言放宽 + const routesPayload = routeRows.filter((r) => SERVICABLE.has(r.routeKind)) as RouteEntry[] routesPayload.sort( (a, b) => cmp(a.routePath, b.routePath) || cmpNull(a.queryKey, b.queryKey) || cmp(a.host, b.host), ) @@ -452,7 +465,7 @@ export async function materializeRenderSnapshot( const bundleRootRaw = bundleRows[0]?.metadata as { storageRoot?: unknown } | undefined const bundleRoot = typeof bundleRootRaw?.storageRoot === 'string' ? bundleRootRaw.storageRoot : null - const assetsPayload = assetRows.map((a) => { + const assetsPayload: AssetEntry[] = assetRows.map((a) => { const candidates: string[] = [] if (bundleRoot !== null && bundleRoot !== '') candidates.push(join(bundleRoot, a.storagePath)) const repoCopy = join(REPO_STORAGE, a.storagePath) @@ -496,7 +509,7 @@ export async function materializeRenderSnapshot( routes: routesPayload.length, assets: assetsPayload.length, } - const manifestPayload = { + const manifestPayload: RenderManifestMeta = { renderSnapshotVersion: RENDER_SNAPSHOT_VERSION, versions: { adapter: ADAPTER_VERSION, diff --git a/eslint.config.js b/eslint.config.js index 95c20fd..15a4d6c 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,7 +5,7 @@ import tseslint from 'typescript-eslint' export default tseslint.config( // 依赖与构建产物不参与 lint - { ignores: ['**/node_modules/**', '**/dist/**', 'coverage/**', '.artifacts/**'] }, + { ignores: ['**/node_modules/**', '**/dist/**', 'coverage/**', '.artifacts/**', '**/.astro/**'] }, eslint.configs.recommended, ...tseslint.configs.recommended, { diff --git a/packages/contracts/src/render-snapshot.ts b/packages/contracts/src/render-snapshot.ts index b4e05e7..21f5070 100644 --- a/packages/contracts/src/render-snapshot.ts +++ b/packages/contracts/src/render-snapshot.ts @@ -15,3 +15,93 @@ type _RenderFilesMatchSpec = [RenderSnapshotFile] extends [FrozenRenderSnapshotF ? ([FrozenRenderSnapshotFiles] extends [RenderSnapshotFile] ? true : never) : never const _renderFilesComplete: _RenderFilesMatchSpec = true + +// ---- 六文件载荷类型(Plan 4 Task 3 审查上提:单一事实来源) ---- +// 物化端(apps/api build/render-snapshot.ts)与消费端(themes/*/load-render-data) +// 的载荷形状此前双份手写——此处上提后两端同源引用,杜绝镜像漂移。 +// 契约冻结注记:本节是**纯新增**(既有导出一个未动、零运行时值),对冻结面是加法, +// 无需升版;已上线消费方(物化器/主题)不受影响。 + +/** site.json 载荷:config 修订 data 的物化面(publish 首版 v1 = {name,origin,locale,brand:{}})。name 可为空串/占位、brand 可为 {}——消费端须缺省容忍,不得假设非空 */ +export interface SiteConfigPayload { + name: string + origin: string + locale: string + brand: BrandConfigPayload +} + +/** 品牌面(V1 唯一键 logoText;恒可能为 {} ——全部键可选) */ +export interface BrandConfigPayload { + logoText?: string +} + +/** content.json 条目(published 修订冻结值)。slug 可能含 %xx 百分号编码序列——全链路原样贯穿,禁止 percent-decode */ +export interface ContentEntry { + contentId: string + typeKey: string + locale: string + slug: string + data: Record + revisionId: string + checksum: string + /** fieldPath → public_path(仅 ready 资产引用);无引用时键缺席 */ + media?: Record +} + +/** taxonomies.json 条目 */ +export interface TaxonomyEntry { + key: string + objectType: string + publicBasePath: string + terms: TaxonomyTerm[] +} + +export interface TaxonomyTerm { + id: string + sourceId: string + slug: string + name: string + parentTermId: string | null + data: Record + contentEntryIds: string[] +} + +/** routes.json 条目(物化端已过滤:只剩可服务形态 html/asset/redirect/gone,dynamic/unresolved 不进产物) */ +export interface RouteEntry { + host: string + routePath: string + routeKind: 'html' | 'asset' | 'redirect' | 'gone' + queryPolicy: 'ignore' | 'exact' | 'preserve' | 'dynamic' + queryKey: string | null + targetStatus: string + targetContentId: string | null + targetFile: string | null + redirectStatus: number | null + redirectLocation: string | null + indexable: boolean + priority: number + verificationStatus: string +} + +/** assets.json 条目(ready 资产 + 实体副本路径解析结果) */ +export interface AssetEntry { + publicPath: string + storagePath: string + copyPath: string + sha256: string + detectedMimeType: string + byteSize: number + width: number | null + height: number | null + altText: string | null +} + +/** render-manifest.json 载荷(版本块 + 计数 + 三哈希 + URL 重写统计) */ +export interface RenderManifestMeta { + renderSnapshotVersion: number + versions: { adapter: string; mapping: string; sanitizer: string; parser: string } + counts: { contents: number; taxonomies: number; routes: number; assets: number } + sourceHashes: { content: string; route: string; asset: string } + urlRewrite: { rewritten: number; unresolved: number } + generatedAt: string +} diff --git a/themes/pilot-minimal/src/lib/load-render-data.ts b/themes/pilot-minimal/src/lib/load-render-data.ts index 0d9a204..d88ac30 100644 --- a/themes/pilot-minimal/src/lib/load-render-data.ts +++ b/themes/pilot-minimal/src/lib/load-render-data.ts @@ -22,93 +22,43 @@ import { readFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { RENDER_SNAPSHOT_FILES } from '@sf/contracts' -// ---- 类型定义(从 Task 2 物化面反推,与 render-snapshot.ts 落盘形状逐字段对齐) ---- +// ---- 类型定义(契约同源:Plan 4 Task 3 审查上提——载荷形状单一事实来源在 +// @sf/contracts/render-snapshot,与物化端 apps/api build/render-snapshot.ts 同源引用, +// 镜像漂移即编译期破裂。此处 re-export 保持组件/测试的既有 import 面) ---- -/** site.json:config 修订 data(publish 首版 v1 = {name, origin, locale, brand:{}}) */ +// 六文件载荷类型:直接转发契约(字段语义见契约处 JSDoc) +export type { + ContentEntry, + TaxonomyEntry, + TaxonomyTerm, + RouteEntry, + AssetEntry, + RenderManifestMeta, +} from '@sf/contracts' +export type { BrandConfigPayload as BrandConfig } from '@sf/contracts' + +import type { + AssetEntry, + BrandConfigPayload, + ContentEntry, + RenderManifestMeta, + RouteEntry, + TaxonomyEntry, +} from '@sf/contracts' + +/** + * 归一后的 site 面(本主题的消费视图,与载荷面 SiteConfigPayload 的差异): + * - name 恒非空(载荷允许空串/占位——空值在 loadRenderData 归一为 'Untitled Site') + * - brand 恒为对象(载荷端 publish v1 恒写 {},此处对缺席/非对象再兜底) + * 消费方禁止直接读 brand.xxx 落空——一律经 ui.brandText 等纯函数。 + */ export interface SiteConfig { /** 归一后恒非空(空串/缺席 → 'Untitled Site') */ name: string origin: string locale: string /** 恒为对象(缺席 → {});键内容按缺省容忍读取 */ - brand: BrandConfig -} - -/** 品牌面(V1 唯一消费键:logoText——缺省回退 site.name) */ -export interface BrandConfig { - logoText?: string -} - -/** content.json 条目(published 修订冻结值) */ -export interface ContentEntry { - contentId: string - typeKey: string - locale: string - /** 可能含 %xx 百分号编码序列——原样贯穿,禁止 decode */ - slug: string - data: Record - revisionId: string - checksum: string - /** fieldPath → public_path(仅 ready 资产引用;Task 2 first-wins 定序冻结) */ - media?: Record -} - -/** taxonomies.json 条目 */ -export interface TaxonomyEntry { - key: string - objectType: string - publicBasePath: string - terms: TaxonomyTerm[] -} - -export interface TaxonomyTerm { - id: string - sourceId: string - slug: string - name: string - parentTermId: string | null - data: Record - contentEntryIds: string[] -} - -/** routes.json 条目(物化端已过滤:只剩可服务形态,dynamic/unresolved 不进产物) */ -export interface RouteEntry { - host: string - routePath: string - routeKind: 'html' | 'asset' | 'redirect' | 'gone' - queryPolicy: 'ignore' | 'exact' | 'preserve' | 'dynamic' - queryKey: string | null - targetStatus: string - targetContentId: string | null - targetFile: string | null - redirectStatus: number | null - redirectLocation: string | null - indexable: boolean - priority: number - verificationStatus: string -} - -/** assets.json 条目(ready 资产) */ -export interface AssetEntry { - publicPath: string - storagePath: string - copyPath: string - sha256: string - detectedMimeType: string - byteSize: number - width: number | null - height: number | null - altText: string | null -} - -/** render-manifest.json(版本块 + 计数 + 三哈希 + 重写统计) */ -export interface RenderManifestMeta { - renderSnapshotVersion: number - versions: { adapter: string; mapping: string; sanitizer: string; parser: string } - counts: { contents: number; taxonomies: number; routes: number; assets: number } - sourceHashes: { content: string; route: string; asset: string } - urlRewrite: { rewritten: number; unresolved: number } - generatedAt: string + brand: BrandConfigPayload } /** loadRenderData 的返回形状(assets 已建 public_path 索引) */ @@ -178,7 +128,7 @@ function normalizeSite(raw: unknown): SiteConfig { brand?: unknown } const name = typeof o.name === 'string' && o.name.trim() !== '' ? o.name : 'Untitled Site' - const brand = (typeof o.brand === 'object' && o.brand !== null ? o.brand : {}) as BrandConfig + const brand = (typeof o.brand === 'object' && o.brand !== null ? o.brand : {}) as BrandConfigPayload return { name, origin: typeof o.origin === 'string' ? o.origin : '', @@ -215,6 +165,13 @@ export function loadRenderData(inputDir?: string): RenderData { const contents = asArray(parsed.get('content.json'), 'content.json') as ContentEntry[] const taxonomies = asArray(parsed.get('taxonomies.json'), 'taxonomies.json') as TaxonomyEntry[] const routes = asArray(parsed.get('routes.json'), 'routes.json') as RouteEntry[] + // 空路由表拒绝:正常发布站点至少含首页路由——空表面意味着上游物化/发布破裂; + // 静默渲染会产零页站点,此处前置明错(§7.6 禁静默) + if (routes.length === 0) { + throw new Error( + `routes.json 路由表为空(SF_BUILD_INPUT=${dir})——正常发布站点至少含首页路由,上游构建可能破裂;拒绝静默空载`, + ) + } const assets = buildAssetIndex(asArray(parsed.get('assets.json'), 'assets.json') as AssetEntry[]) const renderManifest = parsed.get('render-manifest.json') as RenderManifestMeta diff --git a/themes/pilot-minimal/src/lib/ui.ts b/themes/pilot-minimal/src/lib/ui.ts index 5f55b94..f692919 100644 --- a/themes/pilot-minimal/src/lib/ui.ts +++ b/themes/pilot-minimal/src/lib/ui.ts @@ -54,3 +54,19 @@ export function navItems(routes: RouteEntry[]): NavItem[] { { label: 'News', href: '/news/', visible: hasHtmlRoute(routes, '/news/') }, ] } + +/** + * 内容 → 落点路由(Task 4 分派器入口):按 targetContentId 查首条纯路径 html 路由, + * 返回其 routePath 作为 href。无落点 → undefined(调用方决定跳过/兜底——不得虚构路径, + * Task 6 门禁①按真实路由查死链)。多落点时取 routes.json 顺序首位(物化端按 + * routePath 字典序冻结,确定性有保证);redirect/asset 行与带 query_key 的行不参与 + * (内容页的公开落点恒为纯路径 html)。 + */ +export function routeHrefFor(routes: RouteEntry[], contentId: string): string | undefined { + for (const r of routes) { + if (r.routeKind === 'html' && r.queryKey === null && r.targetContentId === contentId) { + return r.routePath + } + } + return undefined +} diff --git a/themes/pilot-minimal/test/fixtures/render-input/assets.json b/themes/pilot-minimal/test/fixtures/render-input/assets.json index 1c78910..d73b1a3 100644 --- a/themes/pilot-minimal/test/fixtures/render-input/assets.json +++ b/themes/pilot-minimal/test/fixtures/render-input/assets.json @@ -1 +1 @@ -[{"altText":null,"byteSize":1234,"copyPath":"D:/project/cms-one/storage/snapshots/media_bundle/2026/10/fixture-a.png","detectedMimeType":"image/png","height":80,"publicPath":"/wp-content/uploads/2026/10/fixture-a.png","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","storagePath":"snapshots/media_bundle/2026/10/fixture-a.png","width":100}] +[{"altText":null,"byteSize":1234,"copyPath":"storage/snapshots/media_bundle/2026/10/fixture-a.png","detectedMimeType":"image/png","height":80,"publicPath":"/wp-content/uploads/2026/10/fixture-a.png","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","storagePath":"snapshots/media_bundle/2026/10/fixture-a.png","width":100}] diff --git a/themes/pilot-minimal/test/load-data.test.ts b/themes/pilot-minimal/test/load-data.test.ts index 822ea78..528e133 100644 --- a/themes/pilot-minimal/test/load-data.test.ts +++ b/themes/pilot-minimal/test/load-data.test.ts @@ -6,6 +6,7 @@ import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' import { loadRenderData, resetRenderDataCache } from '../src/lib/load-render-data.js' +import { routeHrefFor } from '../src/lib/ui.js' const FIXTURE = fileURLToPath(new URL('./fixtures/render-input', import.meta.url)) const LOADER_SRC = fileURLToPath(new URL('../src/lib/load-render-data.ts', import.meta.url)) @@ -134,4 +135,33 @@ describe('load-render-data(渲染数据唯一入口)', () => { rmSync(dir, { recursive: true, force: true }) } }) + + it('routes.json 空表 → 明确错误(正常发布站点至少含首页路由)', () => { + const dir = tempCopy('noroutes') + try { + writeFileSync(join(dir, 'routes.json'), JSON.stringify([])) + expect(() => loadRenderData(dir)).toThrow(/路由表为空/) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('routeHrefFor:targetContentId → 落点路由;无落点 undefined;redirect/查询行不参与', () => { + const data = loadRenderData(FIXTURE) + expect(routeHrefFor(data.routes, '33333333-3333-4333-8333-333333333333')).toBe('/news/fixture-news-a.html') + expect(routeHrefFor(data.routes, '44444444-4444-4444-8444-444444444444')).toBe('/product/fixture-product/') + // 无落点(含 redirect 行即便带 targetContentId 也不算内容页公开落点) + expect(routeHrefFor(data.routes, '00000000-0000-4000-8000-000000000000')).toBeUndefined() + const redirectWithTarget: typeof data.routes[number] = { + ...data.routes.find((r) => r.routeKind === 'redirect')!, + targetContentId: '33333333-3333-4333-8333-333333333333', + } + expect(routeHrefFor([redirectWithTarget], '33333333-3333-4333-8333-333333333333')).toBeUndefined() + // 带 query_key 的 html 行不是内容页纯路径落点 + const queryRoute: typeof data.routes[number] = { + ...data.routes.find((r) => r.routePath === '/news/fixture-news-a.html')!, + queryKey: 'attachment_id', + } + expect(routeHrefFor([queryRoute], '33333333-3333-4333-8333-333333333333')).toBeUndefined() + }) })