1cd309d56d
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>
225 lines
8.4 KiB
TypeScript
225 lines
8.4 KiB
TypeScript
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 渲染数据唯一入口(五禁纪律的结构保证,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
|
||
}
|