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>
This commit is contained in:
浅念
2026-10-22 08:58:33 +08:00
parent 1cd309d56d
commit eb81a57ebc
7 changed files with 202 additions and 96 deletions
@@ -22,93 +22,43 @@ import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { RENDER_SNAPSHOT_FILES } from '@sf/contracts'
// ---- 类型定义(从 Task 2 物化面反推,与 render-snapshot.ts 落盘形状逐字段对齐) ----
// ---- 类型定义(契约同源:Plan 4 Task 3 审查上提——载荷形状单一事实来源在
// @sf/contracts/render-snapshot,与物化端 apps/api build/render-snapshot.ts 同源引用,
// 镜像漂移即编译期破裂。此处 re-export 保持组件/测试的既有 import 面) ----
/** site.json:config 修订 data(publish 首版 v1 = {name, origin, locale, brand:{}}) */
// 六文件载荷类型:直接转发契约(字段语义见契约处 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: 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
brand: BrandConfigPayload
}
/** loadRenderData 的返回形状(assets 已建 public_path 索引) */
@@ -178,7 +128,7 @@ function normalizeSite(raw: unknown): SiteConfig {
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
const brand = (typeof o.brand === 'object' && o.brand !== null ? o.brand : {}) as BrandConfigPayload
return {
name,
origin: typeof o.origin === 'string' ? o.origin : '',
@@ -215,6 +165,13 @@ export function loadRenderData(inputDir?: string): RenderData {
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