Files
cms-website/themes/pilot-minimal/src/lib/load-render-data.ts
T

182 lines
7.6 KiB
TypeScript
Raw Normal View History

// ─────────────────────────────────────────────────────────────────────────────
// 渲染数据唯一入口(五禁纪律的结构保证,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'
// ---- 类型定义(契约同源:Plan 4 Task 3 审查上提——载荷形状单一事实来源在
// @sf/contracts/render-snapshot,与物化端 apps/api build/render-snapshot.ts 同源引用,
// 镜像漂移即编译期破裂。此处 re-export 保持组件/测试的既有 import 面) ----
// 六文件载荷类型:直接转发契约(字段语义见契约处 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: BrandConfigPayload
}
/** loadRenderData 的返回形状(assets 已建 public_path 索引) */
export interface RenderData {
site: SiteConfig
contents: ContentEntry[]
taxonomies: TaxonomyEntry[]
routes: RouteEntry[]
/** public_path → 资产条目(快索引) */
assets: Map<string, AssetEntry>
renderManifest: RenderManifestMeta
}
// ---- 读取与归一 ----
/** 缓存单例:同 build 多次调用只读一次盘(key = 解析后的输入目录绝对路径) */
const cache = new Map<string, RenderData>()
/** 测试钩子:清缓存(正常构建路径不需要) */
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 BrandConfigPayload
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<string, AssetEntry> {
const index = new Map<string, AssetEntry>()
for (const a of entries) index.set(a.publicPath, a)
return index
}
/**
* 加载渲染数据(全主题唯一取数通道)。
* @param inputDir 缺省读 SF_BUILD_INPUT 环境变量;显式传目录主要供测试。
* 目录 = Task 2 物化的 `artifacts/jobs/<jobId>/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<string, unknown>()
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[]
// 空路由表拒绝:正常发布站点至少含首页路由——空表面意味着上游物化/发布破裂;
// 静默渲染会产零页站点,此处前置明错(§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
const data: RenderData = { site, contents, taxonomies, routes, assets, renderManifest }
cache.set(dir, data)
return data
}