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>