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:
浅念
2026-10-22 08:38:00 +08:00
parent 56e4ca96e7
commit 1cd309d56d
32 changed files with 4068 additions and 2 deletions
@@ -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"}]}]
+137
View File
@@ -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)
})
})