Files
cms-website/themes/pilot-minimal/src/lib/load-render-data.ts
T
浅念 1cd309d56d feat(theme): pilot-minimal 骨架与布局组件
Plan 4 Task 3:
- themes/pilot-minimal 入 workspace(astro ^5.18 纯静态;依赖仅 astro)
- theme.manifest:contracts isThemeManifest 守卫通过——key=pilot-minimal/
  七模板条目(home/page/product-list/product-detail/news-list/news-detail/
  not-found);brand 刻意不入 requiredSiteConfig(恒可能 {},Task 1 移交契约)
- load-render-data:全主题唯一取数入口(五禁纪律结构保证,JSDoc 钉死)——
  SF_BUILD_INPUT 六文件、缺文件/形状破裂明确错误不静默空、同目录缓存单例、
  name 空→'Untitled Site'/brand 缺席→{}、编码 slug 原样贯穿(源码令牌扫描
  禁 percent-decode)
- 布局/组件:BaseLayout/SeoHead(title/desc/canonical/og)/SiteHeader(导航
  路由缺席容忍隐藏)/SiteFooter/RichText(信任链:清洗+重写后直出)/
  ResponsiveMedia/Breadcrumbs/ProductCard/NewsCard/Pagination(V1 禁用态)/
  NotFound + tokens.css(75rem/系统字体/4-8-16-24-48/中性色+单 accent)
- 根 test 链并入主题(vitest 3 + astro getViteConfig,Container API 组件
  渲染测试);typecheck 四段变五段;.astro/ 入 gitignore

354 根测试 + 16 主题测试全绿。

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

225 lines
8.4 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'
// ---- 类型定义(从 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<string, unknown>
revisionId: string
checksum: string
/** fieldPath → public_path(仅 ready 资产引用;Task 2 first-wins 定序冻结) */
media?: Record<string, string>
}
/** 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<string, unknown>
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<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 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<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[]
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
}