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,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
}
+56
View File
@@ -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);
}