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>
This commit is contained in:
@@ -22,3 +22,6 @@ tmp/
|
||||
|
||||
# 本地会话产物
|
||||
.claude/
|
||||
|
||||
# astro 生成类型(astro build/sync 再生)
|
||||
.astro/
|
||||
|
||||
+2
-2
@@ -7,8 +7,8 @@
|
||||
"node": ">=22.12"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit && pnpm --filter @sf/api typecheck && pnpm --filter @sf/contracts typecheck && pnpm --filter @sf/adapter-wp typecheck",
|
||||
"test": "vitest run && pnpm --filter @sf/theme-pilot-minimal test",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit && pnpm --filter @sf/api typecheck && pnpm --filter @sf/contracts typecheck && pnpm --filter @sf/adapter-wp typecheck && pnpm --filter @sf/theme-pilot-minimal typecheck",
|
||||
"lint": "eslint .",
|
||||
"db:up": "docker compose -f infra/docker-compose.yml up -d --wait",
|
||||
"db:migrate": "pnpm --filter @sf/api db:migrate",
|
||||
|
||||
Generated
+2986
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
import { resolve } from 'node:path'
|
||||
import { defineConfig } from 'astro/config'
|
||||
|
||||
// 纯静态输出(Task 4/5 消费)。页面数据全部来自 SF_BUILD_INPUT 六文件(load-render-data
|
||||
// 单一入口),不设 site——canonical/og 的 origin 是每站点数据(site.json 的 origin),
|
||||
// 不是主题级配置。@sf/contracts 直连源码(与根 tsconfig/vitest 同映射——契约包零依赖
|
||||
// 纯 TS,vite 直接编译内联,无需预构建 dist)。
|
||||
export default defineConfig({
|
||||
output: 'static',
|
||||
srcDir: './src',
|
||||
outDir: './dist',
|
||||
vite: {
|
||||
resolve: {
|
||||
alias: {
|
||||
'@sf/contracts': resolve(import.meta.dirname, '../../packages/contracts/src/index.ts'),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
// tsc typecheck 段不解析 .astro 单文件组件——此处给 TS 侧的组件 import 一个最小模块面,
|
||||
// 让测试/源码引用 .astro 时拿到可调用组件类型(真实组件编译检查在 astro build 的 vite
|
||||
// 管线;astro check 专用类型检查不在本切片范围)。
|
||||
declare module '*.astro' {
|
||||
import type { AstroComponentFactory } from 'astro'
|
||||
const Component: AstroComponentFactory
|
||||
export default Component
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@sf/theme-pilot-minimal",
|
||||
"version": "0.1.0",
|
||||
"description": "SiteForge pilot 极简主题:纯静态 Astro 5,渲染数据唯一入口 load-render-data(五禁纪律的结构保证)",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "astro build",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"astro": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sf/contracts": "workspace:*",
|
||||
"@types/node": "^22",
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
// 面包屑(首页 > … > 当前页)。items 由页面层构造:末项是当前页(无 href → aria-current),
|
||||
// 其余项带 href。首项"首页"由调用方给出(href='/')——组件不做路径推断。
|
||||
interface Crumb {
|
||||
label: string
|
||||
href?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: Crumb[]
|
||||
}
|
||||
|
||||
const { items } = Astro.props
|
||||
const last = items.length - 1
|
||||
---
|
||||
|
||||
<nav class="breadcrumbs" aria-label="面包屑">
|
||||
<ol>
|
||||
{items.map((item, i) => (
|
||||
<li>
|
||||
{item.href !== undefined && i !== last ? (
|
||||
<a href={item.href}>{item.label}</a>
|
||||
) : (
|
||||
<span aria-current={i === last ? 'page' : undefined}>{item.label}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
ol {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
list-style: none;
|
||||
margin: 0 0 var(--space-3);
|
||||
padding: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
li + li::before {
|
||||
content: '›';
|
||||
margin-inline: var(--space-1);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
// 新闻卡片。href 由页面层从 routes.json 解析后传入(真实数据新闻详情是 /news/<slug>.html
|
||||
// 形态——路径约定归路由表与分派器,组件不内联猜测)。
|
||||
interface Props {
|
||||
title: string
|
||||
date?: string
|
||||
href: string
|
||||
}
|
||||
|
||||
const { title, date, href } = Astro.props
|
||||
---
|
||||
|
||||
<article class="card news-card">
|
||||
<a href={href}>{title}</a>
|
||||
{date !== undefined && date !== '' && <time datetime={date}>{date}</time>}
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.card {
|
||||
padding-block: var(--space-2);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
time {
|
||||
display: block;
|
||||
margin-top: var(--space-1);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
// 404 页内容块(构建产物 404.html 由 Task 4 的路由分派挂到 not-found 模板)。
|
||||
interface Props {
|
||||
homeHref?: string
|
||||
}
|
||||
|
||||
const { homeHref = '/' } = Astro.props
|
||||
---
|
||||
|
||||
<section class="not-found">
|
||||
<h1>404</h1>
|
||||
<p>页面不存在 / Page not found</p>
|
||||
<a href={homeHref}>返回首页</a>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.not-found {
|
||||
text-align: center;
|
||||
padding-block: var(--space-5);
|
||||
}
|
||||
h1 {
|
||||
font-size: 3rem;
|
||||
margin: 0;
|
||||
}
|
||||
p {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
// 分页(V1 禁用态组件)。Plan 4 钉死:/news/ 129 条单页全列、taxonomy 列表同——
|
||||
// 分页路由不在 routes.json 会自破门禁①死链(未登记路由即不可达)。组件保留接口
|
||||
// (page/totalPages/hrefFor)恒渲染空;解禁条件与路由登记义务记 ADR-018 移交 Plan 5。
|
||||
interface Props {
|
||||
page?: number
|
||||
totalPages?: number
|
||||
hrefFor?: (page: number) => string
|
||||
}
|
||||
---
|
||||
|
||||
{null}
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
// 产品卡片。href 由页面层从 routes.json 解析后传入——组件不猜路径约定
|
||||
// (slug→URL 的映射真相在路由表,Task 6 门禁①按真实路由查死链)。
|
||||
interface Props {
|
||||
title: string
|
||||
href: string
|
||||
cover?: {
|
||||
src: string
|
||||
alt?: string
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
}
|
||||
|
||||
const { title, href, cover } = Astro.props
|
||||
---
|
||||
|
||||
<article class="card product-card">
|
||||
<a href={href}>
|
||||
{cover && (
|
||||
<img
|
||||
src={cover.src}
|
||||
alt={cover.alt ?? ''}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
width={cover.width}
|
||||
height={cover.height}
|
||||
/>
|
||||
)}
|
||||
<h3>{title}</h3>
|
||||
</a>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.card a {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.card img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--space-1);
|
||||
}
|
||||
.card h3 {
|
||||
margin: var(--space-2) 0 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
// 媒体输出:assets.json 的 public_path(Task 2 重写后为根相对)直出 <img>。
|
||||
// alt 缺省空串:来源数据 altText 可为 null——不虚构描述(无障碍面如实呈现,Plan 5 补)。
|
||||
// width/height 缺席时省略属性(数据面 width/height 可 null——不猜测尺寸)。
|
||||
interface Props {
|
||||
src: string
|
||||
alt?: string
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
|
||||
const { src, alt = '', width, height } = Astro.props
|
||||
---
|
||||
|
||||
<img src={src} alt={alt} loading="lazy" decoding="async" width={width} height={height} />
|
||||
|
||||
<style>
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
// 富文本容器。信任链(勿"顺手加固"):html 输入已经过
|
||||
// ① Plan 2 导入清洗(sanitize 管线,scripts/属性面规整)
|
||||
// ② Plan 4 Task 2 bodyHtml URL 重写(媒体引用根相对化,匹配键钉死)
|
||||
// 主题端 set:html 直出、不二次清洗(渲染信任链决策记 ADR-018,Task 9 落笔)。
|
||||
interface Props {
|
||||
html: string
|
||||
}
|
||||
|
||||
const { html } = Astro.props
|
||||
---
|
||||
|
||||
<div class="prose" set:html={html} />
|
||||
|
||||
<style>
|
||||
.prose {
|
||||
line-height: 1.7;
|
||||
}
|
||||
/* set:html 注入的子树不带 scoped 属性——后代选择器必须 :global() */
|
||||
.prose :global(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.prose :global(h1),
|
||||
.prose :global(h2),
|
||||
.prose :global(h3) {
|
||||
line-height: 1.3;
|
||||
margin-block: var(--space-4) var(--space-2);
|
||||
}
|
||||
.prose :global(p) {
|
||||
margin-block: var(--space-2);
|
||||
}
|
||||
.prose :global(a) {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
.prose :global(table) {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.prose :global(td),
|
||||
.prose :global(th) {
|
||||
border: 1px solid var(--color-border);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
// SEO 头(title/description/canonical/og)。description 缺席时省略对应标签
|
||||
// (不渲染空 content 的 meta——Task 6 门禁③按"有则合规"检查)。
|
||||
import type { SiteConfig } from '../lib/load-render-data'
|
||||
import { canonicalUrl, pageTitle, type PageMeta } from '../lib/ui'
|
||||
|
||||
interface Props {
|
||||
site: SiteConfig
|
||||
page: PageMeta
|
||||
}
|
||||
|
||||
const { site, page } = Astro.props
|
||||
const title = pageTitle(site, page)
|
||||
const url = canonicalUrl(site, page.path)
|
||||
const description = page.description ?? ''
|
||||
---
|
||||
|
||||
<title>{title}</title>
|
||||
{description !== '' && <meta name="description" content={description} />}
|
||||
<link rel="canonical" href={url} />
|
||||
<meta property="og:title" content={title} />
|
||||
{description !== '' && <meta property="og:description" content={description} />}
|
||||
<meta property="og:url" content={url} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content={site.name} />
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
// 站点脚:© <year> <品牌文字>(brandText:logoText 缺省 → site.name——两态均不落空)。
|
||||
import type { SiteConfig } from '../lib/load-render-data'
|
||||
import { brandText } from '../lib/ui'
|
||||
|
||||
interface Props {
|
||||
site: SiteConfig
|
||||
}
|
||||
|
||||
const { site } = Astro.props
|
||||
const year = new Date().getFullYear()
|
||||
---
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="site-container">
|
||||
<small>© {year} {brandText(site)}</small>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
.site-footer {
|
||||
border-top: 1px solid var(--color-border);
|
||||
color: var(--color-text-muted);
|
||||
padding-block: var(--space-3);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
// 站点头:brandText(brand.logoText 缺省 → site.name——Task 1 契约:brand 恒可能 {},
|
||||
// 禁止直接读 brand.xxx)+ 简导航(/products/ /news/ 硬编码路径,路由表缺席时隐藏对应项)。
|
||||
import type { RouteEntry, SiteConfig } from '../lib/load-render-data'
|
||||
import { brandText, navItems } from '../lib/ui'
|
||||
|
||||
interface Props {
|
||||
site: SiteConfig
|
||||
routes: RouteEntry[]
|
||||
}
|
||||
|
||||
const { site, routes } = Astro.props
|
||||
const items = navItems(routes).filter((i) => i.visible)
|
||||
---
|
||||
|
||||
<header class="site-header">
|
||||
<div class="site-container header-inner">
|
||||
<a class="brand" href="/">{brandText(site)}</a>
|
||||
<nav aria-label="主导航">
|
||||
{items.map((i) => <a href={i.href}>{i.label}</a>)}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.site-header {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.header-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding-block: var(--space-3);
|
||||
}
|
||||
.brand {
|
||||
font-weight: 700;
|
||||
font-size: 1.125rem;
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
nav {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
// 页面骨架(spec §9.3 首批公共组件)。
|
||||
// 数据经 Props 注入(site + page meta)——本组件不直接调 loadRenderData;
|
||||
// 页面层取数后下传(单一入口纪律的组件侧配合:布局对数据来源保持无感)。
|
||||
import SeoHead from '../components/SeoHead.astro'
|
||||
import SiteFooter from '../components/SiteFooter.astro'
|
||||
import SiteHeader from '../components/SiteHeader.astro'
|
||||
import type { RenderData } from '../lib/load-render-data'
|
||||
import type { PageMeta } from '../lib/ui'
|
||||
import '../styles/tokens.css'
|
||||
|
||||
interface Props {
|
||||
/** 渲染数据(loadRenderData() 的返回值——页面层负责调用) */
|
||||
data: Pick<RenderData, 'site' | 'routes'>
|
||||
page: PageMeta
|
||||
}
|
||||
|
||||
const { data, page } = Astro.props
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang={data.site.locale}>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<SeoHead site={data.site} page={page} />
|
||||
</head>
|
||||
<body>
|
||||
<SiteHeader site={data.site} routes={data.routes} />
|
||||
<main class="site-main">
|
||||
<div class="site-container">
|
||||
<slot />
|
||||
</div>
|
||||
</main>
|
||||
<SiteFooter site={data.site} />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<style>
|
||||
.site-main {
|
||||
min-height: 60vh;
|
||||
padding-block: var(--space-4);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,224 @@
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 渲染数据唯一入口(五禁纪律的结构保证,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
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// 视图层纯函数:缺省容忍逻辑(Task 1 审查移交契约)集中于此,组件保持薄壳。
|
||||
// 全部函数只依赖 load-render-data 的类型——不取数、不副作用(五禁纪律下面上无缺口)。
|
||||
import type { RouteEntry, SiteConfig } from './load-render-data.js'
|
||||
|
||||
/** 页面元信息:SeoHead/BaseLayout 的 Props 面(title/description 缺省容忍) */
|
||||
export interface PageMeta {
|
||||
/** 根相对路径(含尾斜杠或 .html 后缀,编码形态原样)——canonical 拼接用 */
|
||||
path: string
|
||||
title?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
/** 页面标题:page.title 缺席/空 → site.name(loader 已保证 site.name 恒非空) */
|
||||
export function pageTitle(site: SiteConfig, page: PageMeta): string {
|
||||
if (page.title !== undefined && page.title !== '') return page.title
|
||||
return site.name
|
||||
}
|
||||
|
||||
/** canonical/og:url:origin + 根相对路径(origin 尾斜杠剥、path 保证首斜杠,编码形态原样) */
|
||||
export function canonicalUrl(site: SiteConfig, path: string): string {
|
||||
const origin = site.origin.replace(/\/+$/, '')
|
||||
const suffix = path.startsWith('/') ? path : `/${path}`
|
||||
return `${origin}${suffix}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 品牌文字:brand.logoText 缺席/空 → site.name(brand 恒可能 {} ——禁止直接读 brand.xxx 落空,
|
||||
* 一律经此函数)。Header 商标与 Footer 署名共用同一回退语义。
|
||||
*/
|
||||
export function brandText(site: SiteConfig): string {
|
||||
const t = site.brand.logoText
|
||||
if (t !== undefined && t !== '') return t
|
||||
return site.name
|
||||
}
|
||||
|
||||
/** 简导航项:/products/ /news/ 硬编码路径,但容忍路由表缺席时隐藏对应项(Home 恒显) */
|
||||
export interface NavItem {
|
||||
label: string
|
||||
href: string
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
/** 纯路径(queryKey=null)的 html 路由存在性——列表入口只在真有落点时展示 */
|
||||
function hasHtmlRoute(routes: RouteEntry[], routePath: string): boolean {
|
||||
return routes.some(
|
||||
(r) => r.routePath === routePath && r.routeKind === 'html' && r.queryKey === null,
|
||||
)
|
||||
}
|
||||
|
||||
export function navItems(routes: RouteEntry[]): NavItem[] {
|
||||
return [
|
||||
{ label: 'Home', href: '/', visible: true },
|
||||
{ label: 'Products', href: '/products/', visible: hasHtmlRoute(routes, '/products/') },
|
||||
{ label: 'News', href: '/news/', visible: hasHtmlRoute(routes, '/news/') },
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/* 设计令牌(快线 §8.5 视觉优先级:宽度/字体/颜色/间距先对,不做像素级还原)。
|
||||
令牌消费在各组件的 scoped <style>——本文件只定义变量 + 最小 body 基线。 */
|
||||
:root {
|
||||
/* 版心 */
|
||||
--site-max-width: 75rem;
|
||||
|
||||
/* 字体:系统默认栈(不引外部字体——五禁之禁⑤构建期无网,且快线明确不动字体还原) */
|
||||
--font-stack: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||||
'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
|
||||
/* 间距 4-8-16-24-48 */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 16px;
|
||||
--space-4: 24px;
|
||||
--space-5: 48px;
|
||||
|
||||
/* 中性色板 + 唯一 accent */
|
||||
--color-bg: #ffffff;
|
||||
--color-surface: #f6f8fa;
|
||||
--color-text: #1f2328;
|
||||
--color-text-muted: #59636e;
|
||||
--color-border: #d8dee4;
|
||||
--color-accent: #0b5fff;
|
||||
--color-accent-contrast: #ffffff;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-stack);
|
||||
color: var(--color-text);
|
||||
background: var(--color-bg);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 版心容器:Header/Footer/main 共用 */
|
||||
.site-container {
|
||||
max-width: var(--site-max-width);
|
||||
margin-inline: auto;
|
||||
padding-inline: var(--space-3);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Task 3 用例 3:brand 容忍——brand:{} 与 brand:{logoText:'X'} 两态下 Header/Footer
|
||||
// 均渲染不落空(logoText 缺省用 name);导航缺席容忍(routes 无对应路径 → 隐藏项)。
|
||||
// 经 Astro Container API 做真实组件渲染(非仅纯函数断言)。
|
||||
import { experimental_AstroContainer as AstroContainer } from 'astro/container'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import SiteFooter from '../src/components/SiteFooter.astro'
|
||||
import SiteHeader from '../src/components/SiteHeader.astro'
|
||||
import type { RouteEntry, SiteConfig } from '../src/lib/load-render-data.js'
|
||||
|
||||
const htmlRoute = (routePath: string): RouteEntry => ({
|
||||
host: '',
|
||||
routePath,
|
||||
routeKind: 'html',
|
||||
queryPolicy: 'ignore',
|
||||
queryKey: null,
|
||||
targetStatus: '200',
|
||||
targetContentId: null,
|
||||
targetFile: null,
|
||||
redirectStatus: null,
|
||||
redirectLocation: null,
|
||||
indexable: true,
|
||||
priority: 1000,
|
||||
verificationStatus: 'verified_html',
|
||||
})
|
||||
|
||||
const ROUTES: RouteEntry[] = [htmlRoute('/'), htmlRoute('/products/'), htmlRoute('/news/')]
|
||||
|
||||
const SITE_EMPTY_BRAND: SiteConfig = {
|
||||
name: 'demo-site',
|
||||
origin: 'https://demo.example',
|
||||
locale: 'zh-CN',
|
||||
brand: {},
|
||||
}
|
||||
|
||||
const SITE_LOGO_TEXT: SiteConfig = {
|
||||
...SITE_EMPTY_BRAND,
|
||||
brand: { logoText: 'ACME 商标' },
|
||||
}
|
||||
|
||||
type Container = Awaited<ReturnType<typeof AstroContainer.create>>
|
||||
let container: Container | undefined
|
||||
async function getContainer(): Promise<Container> {
|
||||
container ??= await AstroContainer.create()
|
||||
return container
|
||||
}
|
||||
|
||||
describe('brand 容忍(Task 1 审查移交契约)', () => {
|
||||
it('brand:{} → Header/Footer 渲染 site.name(logoText 缺省用 name),无 undefined 落空', async () => {
|
||||
const c = await getContainer()
|
||||
const header = await c.renderToString(SiteHeader, {
|
||||
props: { site: SITE_EMPTY_BRAND, routes: ROUTES },
|
||||
})
|
||||
expect(header).toContain('demo-site')
|
||||
expect(header).not.toContain('undefined')
|
||||
const footer = await c.renderToString(SiteFooter, { props: { site: SITE_EMPTY_BRAND } })
|
||||
expect(footer).toContain('demo-site')
|
||||
expect(footer).toContain(`© ${new Date().getFullYear()}`)
|
||||
expect(footer).not.toContain('undefined')
|
||||
})
|
||||
|
||||
it("brand:{logoText:'X'} → Header/Footer 显示 logoText", async () => {
|
||||
const c = await getContainer()
|
||||
const header = await c.renderToString(SiteHeader, {
|
||||
props: { site: SITE_LOGO_TEXT, routes: ROUTES },
|
||||
})
|
||||
expect(header).toContain('ACME 商标')
|
||||
const footer = await c.renderToString(SiteFooter, { props: { site: SITE_LOGO_TEXT } })
|
||||
expect(footer).toContain('ACME 商标')
|
||||
})
|
||||
|
||||
it('brand 对象上无 logoText 键之外的杂键也不炸(恒按缺省容忍读)', async () => {
|
||||
const c = await getContainer()
|
||||
const site: SiteConfig = { ...SITE_EMPTY_BRAND, brand: { unknownKey: 'v' } as SiteConfig['brand'] }
|
||||
const header = await c.renderToString(SiteHeader, { props: { site, routes: ROUTES } })
|
||||
expect(header).toContain('demo-site')
|
||||
expect(header).not.toContain('undefined')
|
||||
})
|
||||
|
||||
it('导航缺席容忍:routes 无 /products/ → 隐藏对应项,/news/ 存在则保留', async () => {
|
||||
const c = await getContainer()
|
||||
const partial = ROUTES.filter((r) => r.routePath !== '/products/')
|
||||
const header = await c.renderToString(SiteHeader, {
|
||||
props: { site: SITE_EMPTY_BRAND, routes: partial },
|
||||
})
|
||||
expect(header).not.toContain('href="/products/"')
|
||||
expect(header).toContain('href="/news/"')
|
||||
expect(header).toContain('href="/"')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
[{"altText":null,"byteSize":1234,"copyPath":"D:/project/cms-one/storage/snapshots/media_bundle/2026/10/fixture-a.png","detectedMimeType":"image/png","height":80,"publicPath":"/wp-content/uploads/2026/10/fixture-a.png","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","storagePath":"snapshots/media_bundle/2026/10/fixture-a.png","width":100}]
|
||||
@@ -0,0 +1 @@
|
||||
[{"checksum":"cafebabe-0000","contentId":"11111111-1111-4111-8111-111111111111","data":{"bodyHtml":"<p>关于我们页面正文。</p>","slug":"%e5%85%b3%e4%ba%8e%e6%88%91%e4%bb%ac","title":"关于我们"},"locale":"zh-CN","revisionId":"aaaa1aaa-1111-4111-8111-111111111111","slug":"%e5%85%b3%e4%ba%8e%e6%88%91%e4%bb%ac","typeKey":"basic_page"},{"checksum":"cafebabe-0001","contentId":"33333333-3333-4333-8333-333333333333","data":{"bodyHtml":"<p>正文引用图片 <img src=\"/wp-content/uploads/2026/10/fixture-a.png\" />。</p>","date":"2026-10-01","slug":"fixture-news-a","title":"Fixture News A"},"locale":"zh-CN","media":{"featured":"/wp-content/uploads/2026/10/fixture-a.png"},"revisionId":"aaaa2aaa-2222-4222-8222-222222222222","slug":"fixture-news-a","typeKey":"news_article"},{"checksum":"cafebabe-0002","contentId":"44444444-4444-4444-8444-444444444444","data":{"bodyHtml":"<p>产品说明。</p>","slug":"fixture-product","specs":[["材质","不锈钢"]],"title":"Fixture Product"},"locale":"zh-CN","revisionId":"aaaa3aaa-3333-4333-8333-333333333333","slug":"fixture-product","typeKey":"product"}]
|
||||
@@ -0,0 +1 @@
|
||||
{"counts":{"assets":1,"contents":3,"routes":7,"taxonomies":1},"generatedAt":"2026-10-22T00:00:00.000Z","renderSnapshotVersion":1,"sourceHashes":{"asset":"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc","content":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","route":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},"urlRewrite":{"rewritten":1,"unresolved":0},"versions":{"adapter":"0.1.0","mapping":"0.1.0","parser":"0.1.0","sanitizer":"0.1.0"}}
|
||||
@@ -0,0 +1 @@
|
||||
[{"host":"","indexable":true,"priority":1000,"queryKey":null,"queryPolicy":"ignore","redirectLocation":null,"redirectStatus":null,"routeKind":"html","routePath":"/","targetContentId":null,"targetFile":"index.html","targetStatus":"200","verificationStatus":"verified_html"},{"host":"","indexable":true,"priority":900,"queryKey":null,"queryPolicy":"ignore","redirectLocation":null,"redirectStatus":null,"routeKind":"html","routePath":"/%e5%85%b3%e4%ba%8e%e6%88%91%e4%bb%ac/","targetContentId":"11111111-1111-4111-8111-111111111111","targetFile":"%e5%85%b3%e4%ba%8e%e6%88%91%e4%bb%ac/index.html","targetStatus":"200","verificationStatus":"verified_html"},{"host":"","indexable":true,"priority":900,"queryKey":null,"queryPolicy":"ignore","redirectLocation":null,"redirectStatus":null,"routeKind":"html","routePath":"/news/","targetContentId":null,"targetFile":"news/index.html","targetStatus":"200","verificationStatus":"verified_html"},{"host":"","indexable":true,"priority":500,"queryKey":null,"queryPolicy":"ignore","redirectLocation":null,"redirectStatus":null,"routeKind":"html","routePath":"/news/fixture-news-a.html","targetContentId":"33333333-3333-4333-8333-333333333333","targetFile":"news/fixture-news-a.html","targetStatus":"200","verificationStatus":"verified_html"},{"host":"","indexable":false,"priority":0,"queryKey":null,"queryPolicy":"exact","redirectLocation":"/","redirectStatus":301,"routeKind":"redirect","routePath":"/old-path/","targetContentId":null,"targetFile":null,"targetStatus":"301","verificationStatus":"verified_redirect"},{"host":"","indexable":true,"priority":500,"queryKey":null,"queryPolicy":"ignore","redirectLocation":null,"redirectStatus":null,"routeKind":"html","routePath":"/product/fixture-product/","targetContentId":"44444444-4444-4444-8444-444444444444","targetFile":"product/fixture-product/index.html","targetStatus":"200","verificationStatus":"verified_html"},{"host":"","indexable":true,"priority":900,"queryKey":null,"queryPolicy":"ignore","redirectLocation":null,"redirectStatus":null,"routeKind":"html","routePath":"/products/","targetContentId":null,"targetFile":"products/index.html","targetStatus":"200","verificationStatus":"verified_html"}]
|
||||
@@ -0,0 +1 @@
|
||||
{"brand":{},"locale":"zh-CN","name":"demo-site","origin":"https://demo.example"}
|
||||
@@ -0,0 +1 @@
|
||||
[{"key":"product-category","objectType":"product","publicBasePath":"/product-category","terms":[{"contentEntryIds":["44444444-4444-4444-8444-444444444444"],"data":{},"id":"55555555-5555-4555-8555-555555555555","name":"Fixture 分类","parentTermId":null,"slug":"fixture-cat","sourceId":"12"}]}]
|
||||
@@ -0,0 +1,137 @@
|
||||
// Task 3 用例 2/4:load-render-data 对六文件夹具的加载形状、缺文件明错、name 兜底、
|
||||
// 缓存单例、编码 slug 原样贯穿(前置事实 7——不得 decodeURIComponent)。
|
||||
import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { loadRenderData, resetRenderDataCache } from '../src/lib/load-render-data.js'
|
||||
|
||||
const FIXTURE = fileURLToPath(new URL('./fixtures/render-input', import.meta.url))
|
||||
const LOADER_SRC = fileURLToPath(new URL('../src/lib/load-render-data.ts', import.meta.url))
|
||||
const PREV_ENV = process.env.SF_BUILD_INPUT
|
||||
|
||||
afterEach(() => {
|
||||
resetRenderDataCache()
|
||||
if (PREV_ENV === undefined) delete process.env.SF_BUILD_INPUT
|
||||
else process.env.SF_BUILD_INPUT = PREV_ENV
|
||||
})
|
||||
|
||||
/** 具夹目录的临时副本(变体测试可随意改写,用后即删) */
|
||||
function tempCopy(name: string): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), `sf-theme-${name}-`))
|
||||
cpSync(FIXTURE, dir, { recursive: true })
|
||||
return dir
|
||||
}
|
||||
|
||||
describe('load-render-data(渲染数据唯一入口)', () => {
|
||||
it('六文件具夹 → 返回形状:site/contents/taxonomies/routes/assets Map/renderManifest', () => {
|
||||
const data = loadRenderData(FIXTURE)
|
||||
// 占位 name(siteKey 派生)原样贯穿——兜底只对空值生效
|
||||
expect(data.site.name).toBe('demo-site')
|
||||
expect(data.site.origin).toBe('https://demo.example')
|
||||
expect(data.site.locale).toBe('zh-CN')
|
||||
expect(data.site.brand).toStrictEqual({})
|
||||
|
||||
expect(data.contents).toHaveLength(3)
|
||||
const news = data.contents.find((c) => c.slug === 'fixture-news-a')
|
||||
expect(news?.typeKey).toBe('news_article')
|
||||
expect(news?.data.bodyHtml).toContain('src="/wp-content/uploads/2026/10/fixture-a.png"')
|
||||
expect(news?.media?.featured).toBe('/wp-content/uploads/2026/10/fixture-a.png')
|
||||
|
||||
expect(data.taxonomies[0]).toMatchObject({ key: 'product-category', objectType: 'product' })
|
||||
expect(data.taxonomies[0]?.terms[0]?.contentEntryIds).toHaveLength(1)
|
||||
|
||||
const redirect = data.routes.find((r) => r.routeKind === 'redirect')
|
||||
expect(redirect).toMatchObject({
|
||||
routePath: '/old-path/',
|
||||
redirectStatus: 301,
|
||||
redirectLocation: '/',
|
||||
indexable: false,
|
||||
})
|
||||
|
||||
expect(data.assets.size).toBe(1)
|
||||
expect(data.assets.get('/wp-content/uploads/2026/10/fixture-a.png')).toMatchObject({
|
||||
detectedMimeType: 'image/png',
|
||||
byteSize: 1234,
|
||||
width: 100,
|
||||
height: 80,
|
||||
altText: null,
|
||||
})
|
||||
|
||||
expect(data.renderManifest.counts).toStrictEqual({ contents: 3, taxonomies: 1, routes: 7, assets: 1 })
|
||||
expect(data.renderManifest.renderSnapshotVersion).toBe(1)
|
||||
})
|
||||
|
||||
it("site.name 空 → 'Untitled Site';brand 键缺席 → {}(Task 1 审查移交:缺省容忍)", () => {
|
||||
const dir = tempCopy('name')
|
||||
try {
|
||||
writeFileSync(
|
||||
join(dir, 'site.json'),
|
||||
JSON.stringify({ name: '', origin: 'https://demo.example', locale: 'zh-CN' }),
|
||||
)
|
||||
const data = loadRenderData(dir)
|
||||
expect(data.site.name).toBe('Untitled Site')
|
||||
expect(data.site.brand).toStrictEqual({})
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('缺文件 → 明确错误(不静默空)', () => {
|
||||
const dir = tempCopy('missing')
|
||||
try {
|
||||
rmSync(join(dir, 'routes.json'))
|
||||
expect(() => loadRenderData(dir)).toThrow(/routes\.json/)
|
||||
expect(() => loadRenderData(dir)).toThrow(/缺失|不完整/)
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('SF_BUILD_INPUT 未设置且未传目录 → 明确错误', () => {
|
||||
delete process.env.SF_BUILD_INPUT
|
||||
expect(() => loadRenderData()).toThrow(/SF_BUILD_INPUT/)
|
||||
})
|
||||
|
||||
it('缺省读 SF_BUILD_INPUT 环境变量目录', () => {
|
||||
process.env.SF_BUILD_INPUT = FIXTURE
|
||||
expect(loadRenderData().site.name).toBe('demo-site')
|
||||
})
|
||||
|
||||
it('缓存单例:同目录多次调用只读一次盘(引用同一 + 落盘变化不可见)', () => {
|
||||
const dir = tempCopy('cache')
|
||||
try {
|
||||
const first = loadRenderData(dir)
|
||||
writeFileSync(
|
||||
join(dir, 'site.json'),
|
||||
JSON.stringify({ name: 'changed-after-load', origin: 'https://demo.example', locale: 'zh-CN', brand: {} }),
|
||||
)
|
||||
const second = loadRenderData(dir)
|
||||
expect(second).toBe(first)
|
||||
expect(second.site.name).toBe('demo-site') // 未重读盘
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('编码 slug 原样贯穿:字节不变、路由编码路径原样(前置事实 7)', () => {
|
||||
const data = loadRenderData(FIXTURE)
|
||||
const page = data.contents.find((c) => c.typeKey === 'basic_page')
|
||||
expect(page?.slug).toBe('%e5%85%b3%e4%ba%8e%e6%88%91%e4%bb%ac')
|
||||
expect(decodeURIComponent(page!.slug)).not.toBe(page!.slug) // 夹具确为编码形态
|
||||
expect(data.routes.some((r) => r.routePath === '/%e5%85%b3%e4%ba%8e%e6%88%91%e4%bb%ac/')).toBe(true)
|
||||
// 结构保证:loader 源码不得引入 decodeURIComponent
|
||||
expect(readFileSync(LOADER_SRC, 'utf8')).not.toContain('decodeURIComponent')
|
||||
})
|
||||
|
||||
it('content.json 顶层非数组 → 明确错误(不静默空)', () => {
|
||||
const dir = tempCopy('shape')
|
||||
try {
|
||||
writeFileSync(join(dir, 'content.json'), JSON.stringify({ oops: true }))
|
||||
expect(() => loadRenderData(dir)).toThrow(/content\.json/)
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
// Task 3 用例 1:theme.manifest 满足 contracts 冻结守卫 isThemeManifest,
|
||||
// 且 pilot 主题的关键字面量(key/version/renderContractVersion/内容类型/必填配置/七模板)钉死。
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isThemeManifest } from '@sf/contracts'
|
||||
import themeManifest from '../theme.manifest.js'
|
||||
|
||||
describe('theme.manifest(contracts 冻结面)', () => {
|
||||
it('isThemeManifest 守卫通过', () => {
|
||||
expect(isThemeManifest(themeManifest)).toBe(true)
|
||||
})
|
||||
|
||||
it('关键字段:key/version/renderContractVersion/supportedContentTypes/requiredSiteConfig', () => {
|
||||
expect(themeManifest.key).toBe('pilot-minimal')
|
||||
expect(themeManifest.version).toBe('0.1.0')
|
||||
expect(themeManifest.renderContractVersion).toBe('1')
|
||||
expect(themeManifest.supportedContentTypes).toStrictEqual(['news_article', 'product', 'basic_page'])
|
||||
expect(themeManifest.requiredSiteConfig).toStrictEqual(['name', 'origin', 'locale'])
|
||||
})
|
||||
|
||||
it('templates 恰七条目:home/page/product-list/product-detail/news-list/news-detail/not-found(key 唯一、appliesTo 非空)', () => {
|
||||
expect(themeManifest.templates.map((t) => t.key)).toStrictEqual([
|
||||
'home',
|
||||
'page',
|
||||
'product-list',
|
||||
'product-detail',
|
||||
'news-list',
|
||||
'news-detail',
|
||||
'not-found',
|
||||
])
|
||||
expect(new Set(themeManifest.templates.map((t) => t.key)).size).toBe(7)
|
||||
for (const t of themeManifest.templates) {
|
||||
expect(typeof t.appliesTo).toBe('string')
|
||||
expect(t.appliesTo).not.toBe('')
|
||||
}
|
||||
})
|
||||
|
||||
it('守卫面的反例仍然成立(守卫不是恒真)', () => {
|
||||
expect(isThemeManifest({ key: 'x', version: 'not-semver', renderContractVersion: '1' })).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
// pilot 主题清单(spec §9.1;类型面 = contracts 冻结的 ThemeManifest——守卫 isThemeManifest
|
||||
// 在测试里钉死通过)。import type 编译期擦除:astro build 不产生对 @sf/contracts 的运行时依赖。
|
||||
//
|
||||
// appliesTo 约定(声明性提示,派发真值在 Task 4 的模板分派器):
|
||||
// 'route:<path>' 精确路由命中(home= '/',not-found = '/404')
|
||||
// 'type:<typeKey>' 按内容类型分派(详情页)
|
||||
// 'path-prefix:<pfx>' 按路径前缀分派(列表页;product-list 的 /category/ 别名前缀
|
||||
// 由 Task 4 分派器一并处理——单一 appliesTo 字符串不承载多前缀)
|
||||
import type { ThemeManifest } from '@sf/contracts'
|
||||
|
||||
export const themeManifest: ThemeManifest = {
|
||||
key: 'pilot-minimal',
|
||||
version: '0.1.0',
|
||||
renderContractVersion: '1',
|
||||
// 快线三类内容(与 build_input_snapshots 物化面一致);'basic_page' 覆盖 generic page
|
||||
supportedContentTypes: ['news_article', 'product', 'basic_page'],
|
||||
// site.json 必须提供的键(publish 首版 config v1 恰好满足;brand 刻意不在必填列——
|
||||
// Task 1 审查移交:brand 恒可能 {},主题对 brand 全缺省容忍)
|
||||
requiredSiteConfig: ['name', 'origin', 'locale'],
|
||||
templates: [
|
||||
{ key: 'home', appliesTo: 'route:/' },
|
||||
{ key: 'page', appliesTo: 'type:basic_page' },
|
||||
{ key: 'product-list', appliesTo: 'path-prefix:/product-category/' },
|
||||
{ key: 'product-detail', appliesTo: 'type:product' },
|
||||
{ key: 'news-list', appliesTo: 'path-prefix:/news/' },
|
||||
{ key: 'news-detail', appliesTo: 'type:news_article' },
|
||||
{ key: 'not-found', appliesTo: 'route:/404' },
|
||||
],
|
||||
}
|
||||
|
||||
export default themeManifest
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"types": ["node"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@sf/contracts": ["../../packages/contracts/src/index.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "test", "theme.manifest.ts", "env.d.ts", "vitest.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { getViteConfig } from 'astro/config'
|
||||
|
||||
// 主题测试双面:纯 TS(manifest/loader)+ Astro 组件(Container API 渲染)。
|
||||
// getViteConfig 挂上 astro 的 vite 插件后 vitest 才能 import .astro 文件;
|
||||
// @sf/contracts 直连源码(与根 vitest.config.ts / 本包 tsconfig paths 同一映射)。
|
||||
const src = (p: string) => fileURLToPath(new URL(p, import.meta.url))
|
||||
|
||||
export default getViteConfig({
|
||||
test: {
|
||||
include: ['test/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@sf/contracts': src('../../packages/contracts/src/index.ts'),
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user