feat(build): astro 构建管线与媒体落盘

Plan 4 Task 5:build.service snapshotting 后接真实构建——building 态进
pipeline.ts(spawn `pnpm --filter @sf/theme-pilot-minimal exec astro build`
(SF_BUILD_INPUT env;非零退出+stderr 标记判据)→ dist 定位与 target_file
完整性 → 媒体 copyPath→dist/public_path(sha256 全量校验)→ sitemap.xml/
robots.txt(编码 URL 原样)→ route-manifest.json(§12.1 字段面、哈希链复用)
→ build_artifacts dist/manifest 登记)→ validating(空,Task 6) → 报告+report
行 → succeeded;失败任何一步 failed+error_code/error_summary(stderr 摘要)
+input/dist 清残。

dist 双位置纪律:主题包 dist 是暂存,产物汇齐后拷出 <artifactsRoot>/jobs/
<jobId>/dist(job 级权威),暂存随即清空。无网证明:ASTRO_TELEMETRY_DISABLED=1
(实测 @astrojs/telemetry 构建期真调 fetch)+ NODE_OPTIONS 透传(测试注入
fetch/net stub 构建仍成功)。主题集成补面:ProductList 模板 + /products/
主列表分派特判(DB 物化形状 targetContentId=null,Task 4 夹具曾被占位绕过)。

测试:tests/integration/build-pipeline.test.ts 五用例(全链/夹具直跑/五禁
断言/失败路径);build-snapshot 状态机补 building + 清场补 build_artifacts。
root 359 + theme 26 全绿。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-22 10:52:13 +08:00
parent 107642c1b3
commit 1ed3ac062a
8 changed files with 1200 additions and 30 deletions
@@ -17,6 +17,8 @@
// 前缀 /product-category/ 或 /category/ → CategoryList(term 从 taxonomies 按 slug 段查)
// '/news/' → NewsList(主列表特判——真实数据 posts 落根路径,
// /news/ 是 route-writer finish 期生成的主列表行)
// '/products/' → ProductList(产品主列表特判——同 finish 期主列表行,
// targetContentId=null 无内容锚点;Task 5 集成补面)
// 无法分派的 html 行 → 构建期明错(§7.6 禁静默——不允许空页混过)。
import { loadRenderData } from '../lib/load-render-data'
import type { ContentEntry, RenderData, RouteEntry, TaxonomyTerm } from '../lib/load-render-data'
@@ -26,6 +28,7 @@ import Home from '../templates/Home.astro'
import NewsDetail from '../templates/NewsDetail.astro'
import NewsList from '../templates/NewsList.astro'
import ProductDetail from '../templates/ProductDetail.astro'
import ProductList from '../templates/ProductList.astro'
/** 分类列表前缀(term-writer 前置事实 9 常量的两族 basePath;publicBasePath 尾斜杠已归一) */
const CATEGORY_PREFIXES = ['/product-category/', '/category/']
@@ -37,6 +40,7 @@ type View =
| { kind: 'product'; content: ContentEntry }
| { kind: 'news-detail'; content: ContentEntry }
| { kind: 'news-list' }
| { kind: 'product-list' }
function dispatch(route: RouteEntry, data: RenderData): View {
if (route.routePath === '/') return { kind: 'home' }
@@ -70,6 +74,10 @@ function dispatch(route: RouteEntry, data: RenderData): View {
if (route.routePath === '/news/') return { kind: 'news-list' }
// 产品主列表特判(Task 5 集成补面):route-writer finish() 对 productsPath 落的主列表
// 行 targetContentId=null——真实物化形状无内容锚点,须与 /news/ 对称的列表落点
if (route.routePath === '/products/') return { kind: 'product-list' }
throw new Error(
`路由 ${route.routePath} 无法分派到任何模板(html 纯路径行必须有落点;主列表/分类/内容页规则均未命中)——拒绝静默空页`,
)
@@ -99,3 +107,4 @@ const view = dispatch(route, data)
{view.kind === 'product' && <ProductDetail data={data} route={route} content={view.content} />}
{view.kind === 'news-detail' && <NewsDetail data={data} route={route} content={view.content} />}
{view.kind === 'news-list' && <NewsList data={data} route={route} />}
{view.kind === 'product-list' && <ProductList data={data} route={route} />}
@@ -0,0 +1,47 @@
---
// 产品主列表模板(Plan 4 Task 5 集成补面):route-writer finish() 对 productsPath(默认
// /products/)落的主列表行 targetContentId=null——Task 4 的分派器只覆盖了 news 主列表
// 特判,产品主列表在主题夹具里被"targetContentId 占位"绕过(见 CategoryList 头注记),
// DB 真实物化形状(targetContentId=null)即无处落点。本模板补齐:全部 product 卡片
// 单页(latestEntries 同款排序),无落点内容不出卡(禁虚构路径)。
// 与 news-list 同构(主列表两兄弟);manifest 的 'product-list' 模板键声明性覆盖本页
// (appliesTo 单字符串不承载多前缀——/products/ 主列表由分派器特判,/category/ 别名
// 前缀同先例)。
import BaseLayout from '../layouts/BaseLayout.astro'
import Breadcrumbs from '../components/Breadcrumbs.astro'
import ProductCard from '../components/ProductCard.astro'
import type { RenderData, RouteEntry } from '../lib/load-render-data'
import { contentTitle, coverRef, latestEntries, metaDescription, routeHrefFor } from '../lib/ui'
interface Props {
data: RenderData
route: RouteEntry
}
const { data, route } = Astro.props
const page = { path: route.routePath, title: 'Products' }
const allProducts = latestEntries(data.contents, 'product')
---
<BaseLayout data={data} page={{ ...page, description: metaDescription(data.site, page) }}>
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Products' }]} />
<h1>Products</h1>
<div class="product-grid">
{allProducts.map((c) => {
const href = routeHrefFor(data.routes, c.contentId)
if (href === undefined) return null
return <ProductCard title={contentTitle(c)} href={href} cover={coverRef(c, data.assets)} />
})}
</div>
</BaseLayout>
<style>
h1 {
margin-top: 0;
}
.product-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: var(--space-3);
}
</style>