Files
cms-website/themes/pilot-minimal/src/lib/load-render-data.ts
T
浅念 eb81a57ebc fix(theme): payload 契约上提 contracts;空路由明错;ui.routeHrefFor;eslint astro ignore
审查修复(Plan 4 Task 3 review):
- 载荷类型单一事实来源:ContentEntry/TaxonomyEntry/TaxonomyTerm/RouteEntry/
  AssetEntry/RenderManifestMeta/SiteConfigPayload/BrandConfigPayload 上提
  @sf/contracts/render-snapshot(纯新增、不动既有导出——冻结面加法无需升版);
  物化端(apps/api build/render-snapshot.ts)与主题 load-render-data 同源引用,
  双份手写镜像漂移即编译期破裂;主题侧 re-export 保持既有 import 面
- loader 空路由表前置明错(正常发布站点至少含首页路由——静默渲染会产零页站点)
- ui.routeHrefFor:targetContentId → 纯路径 html 落点(redirect/查询行不参与,
  无落点 undefined 不虚构——Task 4 分派器入口)+ 测试
- 具夹 assets.json copyPath 去机器相关绝对路径(改仓库相对)
- eslint ignores 增 **/.astro/**(astro build 再生目录)

354 根测试 + 18 主题测试全绿;五段 typecheck;lint(.astro 存在态验证后清理)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-22 08:58:33 +08:00

182 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ─────────────────────────────────────────────────────────────────────────────
// 渲染数据唯一入口(五禁纪律的结构保证,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
}