diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 3d1b3fa..2c7d9be 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1 +1,4 @@ export * from './universal-row.js' +export * from './route-manifest.js' +export * from './render-snapshot.js' +export * from './theme-manifest.js' diff --git a/packages/contracts/src/render-snapshot.ts b/packages/contracts/src/render-snapshot.ts new file mode 100644 index 0000000..b4e05e7 --- /dev/null +++ b/packages/contracts/src/render-snapshot.ts @@ -0,0 +1,17 @@ +export const RENDER_SNAPSHOT_VERSION = 1 + +export const RENDER_SNAPSHOT_FILES = [ + 'site.json', 'content.json', 'taxonomies.json', 'routes.json', 'assets.json', 'render-manifest.json', +] as const + +export type RenderSnapshotFile = (typeof RENDER_SNAPSHOT_FILES)[number] + +// 契约冻结锁(spec §9.2):清单与冻结集双向咬合——漏改/多改/改名任何一项 → TS2322 +type FrozenRenderSnapshotFiles = + | 'site.json' | 'content.json' | 'taxonomies.json' + | 'routes.json' | 'assets.json' | 'render-manifest.json' + +type _RenderFilesMatchSpec = [RenderSnapshotFile] extends [FrozenRenderSnapshotFiles] + ? ([FrozenRenderSnapshotFiles] extends [RenderSnapshotFile] ? true : never) + : never +const _renderFilesComplete: _RenderFilesMatchSpec = true diff --git a/packages/contracts/src/route-manifest.ts b/packages/contracts/src/route-manifest.ts new file mode 100644 index 0000000..18a0db2 --- /dev/null +++ b/packages/contracts/src/route-manifest.ts @@ -0,0 +1,45 @@ +export const ROUTE_MANIFEST_VERSION = 1 + +export type RouteKind = 'html' | 'asset' | 'redirect' | 'gone' | 'dynamic' | 'unresolved' +export type QueryPolicy = 'ignore' | 'exact' | 'preserve' | 'dynamic' + +export interface RouteManifestRoute { + exact_key: string + query_policy: QueryPolicy + query_key?: string | null + kind: RouteKind + target_file?: string + status: number + location?: string + content_type?: string + indexable: boolean +} + +export interface RouteManifest { + manifest_version: number + site_id: string + build_job_id: string + origin: string + generated_from_route_snapshot_hash: string + routes: RouteManifestRoute[] +} + +const KINDS = ['html', 'asset', 'redirect', 'gone', 'dynamic', 'unresolved'] as const +const QUERY_POLICIES = ['ignore', 'exact', 'preserve', 'dynamic'] as const + +// 守卫查值类型(不只查键存在):版本号、顶层必填字符串、routes 元素的枚举与标量类型全部校验 +export function isRouteManifest(v: unknown): v is RouteManifest { + if (typeof v !== 'object' || v === null) return false + const m = v as RouteManifest + return m.manifest_version === ROUTE_MANIFEST_VERSION + && typeof m.site_id === 'string' + && typeof m.build_job_id === 'string' + && typeof m.origin === 'string' + && typeof m.generated_from_route_snapshot_hash === 'string' + && Array.isArray(m.routes) && m.routes.every((r) => + typeof r.exact_key === 'string' + && (QUERY_POLICIES as readonly string[]).includes(r.query_policy) + && (KINDS as readonly string[]).includes(r.kind) + && typeof r.status === 'number' + && typeof r.indexable === 'boolean') +} diff --git a/packages/contracts/src/theme-manifest.ts b/packages/contracts/src/theme-manifest.ts new file mode 100644 index 0000000..1535c07 --- /dev/null +++ b/packages/contracts/src/theme-manifest.ts @@ -0,0 +1,29 @@ +export const THEME_MANIFEST_VERSION = 1 + +export interface ThemeManifest { + key: string + version: string + renderContractVersion: string + supportedContentTypes: string[] + requiredSiteConfig: string[] + templates: Array<{ key: string; appliesTo: string }> +} + +const SEMVER = /^\d+\.\d+\.\d+$/ + +// 守卫查值类型(不只查键存在):semver 格式、字符串字段、数组元素类型全部校验 +export function isThemeManifest(v: unknown): v is ThemeManifest { + if (typeof v !== 'object' || v === null) return false + const m = v as ThemeManifest + return typeof m.key === 'string' + && typeof m.version === 'string' && SEMVER.test(m.version) + && typeof m.renderContractVersion === 'string' + && Array.isArray(m.supportedContentTypes) + && m.supportedContentTypes.every((t) => typeof t === 'string') + && Array.isArray(m.requiredSiteConfig) + && m.requiredSiteConfig.every((t) => typeof t === 'string') + && Array.isArray(m.templates) + && m.templates.every((t) => typeof t === 'object' && t !== null + && typeof (t as { key: unknown }).key === 'string' + && typeof (t as { appliesTo: unknown }).appliesTo === 'string') +} diff --git a/packages/contracts/test/manifests.test.ts b/packages/contracts/test/manifests.test.ts new file mode 100644 index 0000000..3b24d7f --- /dev/null +++ b/packages/contracts/test/manifests.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect } from 'vitest' +import { + ROUTE_MANIFEST_VERSION, RENDER_SNAPSHOT_VERSION, THEME_MANIFEST_VERSION, + RENDER_SNAPSHOT_FILES, isRouteManifest, isThemeManifest, +} from '../src/index.js' + +const validRouteManifest: any = { + manifest_version: 1, site_id: 's1', build_job_id: 'b1', origin: 'https://fixture.example', + generated_from_route_snapshot_hash: 'abc', + routes: [{ exact_key: '/', query_policy: 'ignore', kind: 'html', target_file: 'index.html', status: 200, indexable: true }], +} + +const validThemeManifest: any = { + key: 'pilot-minimal', version: '0.1.0', renderContractVersion: '1', + supportedContentTypes: ['news_article', 'product', 'basic_page'], + requiredSiteConfig: ['origin', 'listPaths'], + templates: [{ key: 'home', appliesTo: 'home' }], +} + +describe('manifest 契约', () => { + it('三个版本号冻结为 1', () => { + expect([ROUTE_MANIFEST_VERSION, RENDER_SNAPSHOT_VERSION, THEME_MANIFEST_VERSION]).toEqual([1, 1, 1]) + }) + it('Render Snapshot 文件清单冻结(spec §9.2)', () => { + expect(RENDER_SNAPSHOT_FILES).toEqual([ + 'site.json', 'content.json', 'taxonomies.json', 'routes.json', 'assets.json', 'render-manifest.json', + ]) + }) + it('合法 RouteManifest 通过守卫', () => { + expect(isRouteManifest({ + manifest_version: 1, site_id: 's1', build_job_id: 'b1', origin: 'https://fixture.example', + generated_from_route_snapshot_hash: 'abc', + routes: [{ exact_key: '/', query_policy: 'ignore', kind: 'html', target_file: 'index.html', status: 200, indexable: true }], + })).toBe(true) + }) + it('非法 kind 拒绝', () => { + const m: any = { manifest_version: 1, site_id: 's', build_job_id: 'b', origin: 'o', generated_from_route_snapshot_hash: 'h', routes: [{ exact_key: '/', query_policy: 'ignore', kind: 'weird', status: 200, indexable: true }] } + expect(isRouteManifest(m)).toBe(false) + }) + it('合法 ThemeManifest 通过守卫', () => { + expect(isThemeManifest({ + key: 'pilot-minimal', version: '0.1.0', renderContractVersion: '1', + supportedContentTypes: ['news_article', 'product', 'basic_page'], + requiredSiteConfig: ['origin', 'listPaths'], + templates: [{ key: 'home', appliesTo: 'home' }], + })).toBe(true) + }) +}) + +describe('RouteManifest 守卫拒绝路径', () => { + it('manifest_version ≠ 1 拒绝(2 与字符串 "1")', () => { + expect(isRouteManifest({ ...validRouteManifest, manifest_version: 2 })).toBe(false) + expect(isRouteManifest({ ...validRouteManifest, manifest_version: '1' })).toBe(false) + }) + + it('routes 非数组拒绝(字符串)', () => { + expect(isRouteManifest({ ...validRouteManifest, routes: 'x' })).toBe(false) + }) + + it('routes 元素缺 exact_key 拒绝', () => { + const { exact_key: _omit, ...route } = validRouteManifest.routes[0] + expect(isRouteManifest({ ...validRouteManifest, routes: [route] })).toBe(false) + }) + + it('status 非数字拒绝(status: "200")', () => { + expect(isRouteManifest({ + ...validRouteManifest, + routes: [{ ...validRouteManifest.routes[0], status: '200' }], + })).toBe(false) + }) + + it('indexable 非布尔拒绝(indexable: 1)', () => { + expect(isRouteManifest({ + ...validRouteManifest, + routes: [{ ...validRouteManifest.routes[0], indexable: 1 }], + })).toBe(false) + }) + + it('query_policy 非四值之一拒绝', () => { + expect(isRouteManifest({ + ...validRouteManifest, + routes: [{ ...validRouteManifest.routes[0], query_policy: 'loose' }], + })).toBe(false) + }) + + it('顶层必填字符串字段非字符串拒绝(site_id 数字)', () => { + expect(isRouteManifest({ ...validRouteManifest, site_id: 101 })).toBe(false) + }) + + it('非对象输入拒绝(null / number / array)', () => { + expect(isRouteManifest(null)).toBe(false) + expect(isRouteManifest(42)).toBe(false) + expect(isRouteManifest([])).toBe(false) + }) +}) + +describe('ThemeManifest 守卫拒绝路径', () => { + it('version 非 semver 拒绝("abc" 与 "1")', () => { + expect(isThemeManifest({ ...validThemeManifest, version: 'abc' })).toBe(false) + expect(isThemeManifest({ ...validThemeManifest, version: '1' })).toBe(false) + }) + + it('key 非字符串拒绝(数字)', () => { + expect(isThemeManifest({ ...validThemeManifest, key: 42 })).toBe(false) + }) + + it('templates 非数组拒绝', () => { + expect(isThemeManifest({ ...validThemeManifest, templates: 'x' })).toBe(false) + }) + + it('templates 元素缺 appliesTo 拒绝(查值类型,不只查键存在)', () => { + expect(isThemeManifest({ + ...validThemeManifest, + templates: [{ key: 'home', appliesTo: 42 }], + })).toBe(false) + }) + + it('renderContractVersion 非字符串拒绝', () => { + expect(isThemeManifest({ ...validThemeManifest, renderContractVersion: 1 })).toBe(false) + }) + + it('非对象输入拒绝(null / number)', () => { + expect(isThemeManifest(null)).toBe(false) + expect(isThemeManifest(42)).toBe(false) + }) +})