// ───────────────────────────────────────────────────────────────────────────── // 渲染数据唯一入口(五禁纪律的结构保证,spec §9.2/§10.4) // // 本主题的一切页面/组件只准经 loadRenderData() 取数——除此函数外,任何模块不得: // 禁① 连 PostgreSQL(或引入任何 DB 客户端) // 禁② 调 Control API // 禁③ 读 storage/ 原始输入 // 禁④ 使用站点专属 seed 数据 // 禁⑤ 构建期访问网络 // 输入只有一份:SF_BUILD_INPUT 指向的目录(Task 2 build:create 物化的六文件—— // site/content/taxonomies/routes/assets/render-manifest,canonical JSON)。 // 新数据需求 = 改本文件 + 改 Task 2 物化面,不开侧门。 // // 编码路径贯穿纪律(前置事实 7):slug/routePath 等字符串自落盘到 HTML 原样使用, // 本模块不得做百分号解码(percent-decode;Task 6 门禁按编码形态比对产物——测试对 // 源码做令牌扫描钉死此纪律)。 // // 缺省容忍(Task 1 审查移交契约):brand 恒可能为 {}(publish 首版恒写 {})、 // name 可能是 siteKey 派生占位甚至空串——loadRenderData 在此统一归一: // name 空 → 'Untitled Site';brand 缺席 → {}。消费方禁止直接读 brand.xxx 落空。 import { readFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { RENDER_SNAPSHOT_FILES } from '@sf/contracts' // ---- 类型定义(从 Task 2 物化面反推,与 render-snapshot.ts 落盘形状逐字段对齐) ---- /** site.json:config 修订 data(publish 首版 v1 = {name, origin, locale, brand:{}}) */ 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 } /** loadRenderData 的返回形状(assets 已建 public_path 索引) */ export interface RenderData { site: SiteConfig contents: ContentEntry[] taxonomies: TaxonomyEntry[] routes: RouteEntry[] /** public_path → 资产条目(快索引) */ assets: Map renderManifest: RenderManifestMeta } // ---- 读取与归一 ---- /** 缓存单例:同 build 多次调用只读一次盘(key = 解析后的输入目录绝对路径) */ const cache = new Map() /** 测试钩子:清缓存(正常构建路径不需要) */ export function resetRenderDataCache(): void { cache.clear() } function requireInputDir(): string { const v = process.env.SF_BUILD_INPUT if (v === undefined || v === '') { throw new Error( 'SF_BUILD_INPUT 未设置:load-render-data 需要 build:create 物化的六文件输入目录(五禁纪律——无环境变量之外的取数通道)', ) } return v } /** 读单文件并 JSON.parse:缺失/损坏 → 明确错误(§7.6 禁静默——不产残缺数据面) */ function readJsonFile(dir: string, file: string): unknown { const full = join(dir, file) let text: string try { text = readFileSync(full, 'utf8') } catch (e) { throw new Error( `Render Snapshot 文件缺失或不可读:${full}(SF_BUILD_INPUT=${dir})——build:create 物化产物不完整,拒绝静默空载`, { cause: e }, ) } try { return JSON.parse(text) as unknown } catch (e) { throw new Error(`Render Snapshot 文件不是合法 JSON:${full}`, { cause: e }) } } /** 顶层必须是数组:形状破裂 → 明确错误(不静默当空数组) */ function asArray(v: unknown, file: string): unknown[] { if (!Array.isArray(v)) { throw new Error(`Render Snapshot 文件 ${file} 顶层必须是数组(got ${typeof v})——拒绝静默空`) } return v } /** site 归一:name 空 → 'Untitled Site';brand 缺席/非对象 → {};origin/locale 类型防御 */ function normalizeSite(raw: unknown): SiteConfig { const o = (typeof raw === 'object' && raw !== null ? raw : {}) as { name?: unknown origin?: unknown locale?: unknown 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 return { name, origin: typeof o.origin === 'string' ? o.origin : '', locale: typeof o.locale === 'string' && o.locale !== '' ? o.locale : 'en', brand, } } function buildAssetIndex(entries: AssetEntry[]): Map { const index = new Map() for (const a of entries) index.set(a.publicPath, a) return index } /** * 加载渲染数据(全主题唯一取数通道)。 * @param inputDir 缺省读 SF_BUILD_INPUT 环境变量;显式传目录主要供测试。 * 目录 = Task 2 物化的 `artifacts/jobs//input/`(六文件,canonical JSON)。 * 缺文件/形状破裂/环境变量缺席均抛明确错误;成功结果按目录缓存(同 build 只读一次盘)。 */ export function loadRenderData(inputDir?: string): RenderData { const dir = resolve(inputDir ?? requireInputDir()) const cached = cache.get(dir) if (cached !== undefined) return cached // 以 contracts 冻结清单驱动读取(RENDER_SNAPSHOT_FILES):清单外文件不读、 // 清单内文件缺一即拒——齐备性与读取面都钉死在契约上 const parsed = new Map() for (const file of RENDER_SNAPSHOT_FILES) { parsed.set(file, readJsonFile(dir, file)) } const site = normalizeSite(parsed.get('site.json')) 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[] const assets = buildAssetIndex(asArray(parsed.get('assets.json'), 'assets.json') as AssetEntry[]) const renderManifest = parsed.get('render-manifest.json') as RenderManifestMeta const data: RenderData = { site, contents, taxonomies, routes, assets, renderManifest } cache.set(dir, data) return data }