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:
@@ -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>
|
||||
Reference in New Issue
Block a user