feat(theme): 六类页面渲染与路由分派

单一动态路由 [...slug].astro 读 routes.json html 纯路径行分派六模板
(home/typeKey 详情/分类前缀列表/news 主列表),无法分派即构建明错;
404 独立静态页。夹具扩展为六页集(真实 hesupipe 抽样形状脱敏化:
knownFields.postDate/title、media 块 attachmentRefs.* fieldPath、
/products/ 内容页占据、编码路径 + .html 字面路径防御)。

字面 .html 路径对齐钩子(astro.config 内联 integration):Astro 页面
产物在 directory/file/preserve 三态均产不出 dist/news/x.html 字面文件,
构建收尾把包装目录折平为字面文件——target_file 契约对齐。

构建级测试 8 例(render-cases):全量映射/编码字节原样/首页截断/
产品参数相册/新闻字面 .html/列表两类/SEO 基线。372 → 380。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-22 09:34:58 +08:00
parent 097cfd5609
commit f916f723ab
17 changed files with 810 additions and 12 deletions
@@ -0,0 +1,49 @@
---
// 分类列表模板(快线 §8.1 第 4 序;前置事实:/products/ 被内容页占据——产品列表演示
// 走 /product-category/<term>/)。Breadcrumbs(首页 › 分类)+ term 名标题 + 该 term 关联
// 产品卡网格。关联面:taxonomies.json 的 term.contentEntryIds(content_terms 物化)反查
// contents——term 的 objectType 决定内容类型(product-category → product;/category/ 新闻
// 分类落 NewsCard 的扩展是 Plan 5 域,V1 按产品分类演示)。无落点内容不出卡(禁虚构路径)。
import BaseLayout from '../layouts/BaseLayout.astro'
import Breadcrumbs from '../components/Breadcrumbs.astro'
import ProductCard from '../components/ProductCard.astro'
import type { ContentEntry, RenderData, RouteEntry, TaxonomyTerm } from '../lib/load-render-data'
import { contentTitle, coverRef, metaDescription, routeHrefFor } from '../lib/ui'
interface Props {
data: RenderData
route: RouteEntry
term: TaxonomyTerm
}
const { data, route, term } = Astro.props
const page = { path: route.routePath, title: term.name }
const products = term.contentEntryIds
.map((id) => data.contents.find((c) => c.contentId === id))
.filter((c): c is ContentEntry => c !== undefined && c.typeKey === 'product')
---
<BaseLayout data={data} page={{ ...page, description: metaDescription(data.site, page) }}>
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: term.name }]} />
<h1>{term.name}</h1>
<div class="product-grid">
{products.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>
@@ -0,0 +1,36 @@
---
// 普通内容页模板(快线 §8.1 第 3 序):h1 标题 + RichText 正文。
// basic_page(及未知 typeKey 的内容页——通用呈现即忠实呈现,无静默)落此。
// 信任链:bodyHtml 已过导入清洗 + URL 重写,set:html 直出不二次清洗(ADR-018,Task 9 落笔)。
import BaseLayout from '../layouts/BaseLayout.astro'
import RichText from '../components/RichText.astro'
import type { ContentEntry, RenderData, RouteEntry } from '../lib/load-render-data'
import { contentBody, contentDescription, contentTitle, metaDescription } from '../lib/ui'
interface Props {
data: RenderData
route: RouteEntry
content: ContentEntry
}
const { data, route, content } = Astro.props
const page = {
path: route.routePath,
title: contentTitle(content),
description: contentDescription(content),
}
const body = contentBody(content)
---
<BaseLayout data={data} page={{ ...page, description: metaDescription(data.site, page) }}>
<article>
<h1>{page.title}</h1>
{body !== undefined && <RichText html={body} />}
</article>
</BaseLayout>
<style>
h1 {
margin-top: 0;
}
</style>
@@ -0,0 +1,87 @@
---
// 首页模板(快线 §8.1 第 2 序):hero(site name)+ 最新 3 新闻卡 + 最新 3 产品卡。
// 数据经 Props 注入([...slug] 分派层调用 loadRenderData 后下传——单一入口纪律)。
// 截断纪律:latestEntries 按 knownFields.postDate 降序取 3(分页 V1 不做,首页恒 3 条——
// 路由不登记分页路径会自破门禁①死链,ADR-018 移交 Plan 5)。无落点内容不出卡
// (routeHrefFor undefined → 跳过——不得虚构路径)。
import BaseLayout from '../layouts/BaseLayout.astro'
import NewsCard from '../components/NewsCard.astro'
import ProductCard from '../components/ProductCard.astro'
import type { RenderData, RouteEntry } from '../lib/load-render-data'
import {
contentDate,
contentTitle,
coverRef,
latestEntries,
metaDescription,
routeHrefFor,
} from '../lib/ui'
interface Props {
data: RenderData
route: RouteEntry
}
const { data, route } = Astro.props
const page = { path: route.routePath }
const latestNews = latestEntries(data.contents, 'news_article', 3)
const latestProducts = latestEntries(data.contents, 'product', 3)
---
<BaseLayout data={data} page={{ ...page, description: metaDescription(data.site, page) }}>
<section class="hero">
<h1>{data.site.name}</h1>
</section>
<section aria-label="最新动态" class="home-section">
<h2>News</h2>
<div class="news-list">
{latestNews.map((c) => {
const href = routeHrefFor(data.routes, c.contentId)
if (href === undefined) return null
return <NewsCard title={contentTitle(c)} date={contentDate(c)} href={href} />
})}
</div>
</section>
<section aria-label="最新产品" class="home-section">
<h2>Products</h2>
<div class="product-grid">
{latestProducts.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>
</section>
</BaseLayout>
<style>
.hero {
padding-block: var(--space-5);
border-bottom: 1px solid var(--color-border);
margin-bottom: var(--space-4);
}
.hero h1 {
margin: 0;
font-size: 2rem;
}
.home-section {
margin-block: var(--space-4);
}
.home-section h2 {
font-size: 1.25rem;
margin: 0 0 var(--space-2);
}
.product-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: var(--space-3);
}
</style>
@@ -0,0 +1,43 @@
---
// 新闻详情模板(快线 §8.1 第 6 序):h1 标题 + 日期(knownFields.postDate——真实数据日期
// 字段探测结论;缺席省略 time 块)+ 正文 RichText(URL 已根相对化重写)。
import BaseLayout from '../layouts/BaseLayout.astro'
import RichText from '../components/RichText.astro'
import type { ContentEntry, RenderData, RouteEntry } from '../lib/load-render-data'
import { contentBody, contentDate, contentDescription, contentTitle, metaDescription } from '../lib/ui'
interface Props {
data: RenderData
route: RouteEntry
content: ContentEntry
}
const { data, route, content } = Astro.props
const page = {
path: route.routePath,
title: contentTitle(content),
description: contentDescription(content),
}
const date = contentDate(content)
const body = contentBody(content)
---
<BaseLayout data={data} page={{ ...page, description: metaDescription(data.site, page) }}>
<article>
<h1>{page.title}</h1>
{date !== undefined && <time datetime={date}>{date}</time>}
{body !== undefined && <RichText html={body} />}
</article>
</BaseLayout>
<style>
h1 {
margin-top: 0;
}
time {
display: block;
color: var(--color-text-muted);
font-size: 0.875rem;
margin-block: var(--space-1) var(--space-3);
}
</style>
@@ -0,0 +1,35 @@
---
// 新闻列表模板(快线 §8.1 第 6 序):全部 news_article 卡片单页(分页 V1 一律不做——
// 分页路由不在 routes.json 会自破门禁①死链;触发条件与路由登记义务记 ADR-018 移交 Plan 5)。
// 排序 latestEntries(postDate 降序);无落点内容不出卡(禁虚构路径)。
import BaseLayout from '../layouts/BaseLayout.astro'
import NewsCard from '../components/NewsCard.astro'
import type { RenderData, RouteEntry } from '../lib/load-render-data'
import { contentDate, contentTitle, latestEntries, metaDescription, routeHrefFor } from '../lib/ui'
interface Props {
data: RenderData
route: RouteEntry
}
const { data, route } = Astro.props
const page = { path: route.routePath, title: 'News' }
const allNews = latestEntries(data.contents, 'news_article')
---
<BaseLayout data={data} page={{ ...page, description: metaDescription(data.site, page) }}>
<h1>News</h1>
<div class="news-list">
{allNews.map((c) => {
const href = routeHrefFor(data.routes, c.contentId)
if (href === undefined) return null
return <NewsCard title={contentTitle(c)} date={contentDate(c)} href={href} />
})}
</div>
</BaseLayout>
<style>
h1 {
margin-top: 0;
}
</style>
@@ -0,0 +1,84 @@
---
// 产品详情模板(快线 §8.1 第 5 序):h1 标题 + 相册(media 引用列表——featured 首图 +
// gallery[N] 按序号)+ 参数表(data.specifications 纯对象转表——pilot 约定键,真实 hesupipe
// 产品修订无结构化参数对象,缺省容忍;extras.phpRaw 明确忽略不渲染)+ 正文 RichText。
import BaseLayout from '../layouts/BaseLayout.astro'
import ResponsiveMedia from '../components/ResponsiveMedia.astro'
import RichText from '../components/RichText.astro'
import type { ContentEntry, RenderData, RouteEntry } from '../lib/load-render-data'
import {
contentBody,
contentDescription,
contentTitle,
mediaGallery,
metaDescription,
specificationRows,
} from '../lib/ui'
interface Props {
data: RenderData
route: RouteEntry
content: ContentEntry
}
const { data, route, content } = Astro.props
const page = {
path: route.routePath,
title: contentTitle(content),
description: contentDescription(content),
}
const gallery = mediaGallery(content, data.assets)
const specs = specificationRows(content)
const body = contentBody(content)
---
<BaseLayout data={data} page={{ ...page, description: metaDescription(data.site, page) }}>
<article class="product-detail">
<h1>{page.title}</h1>
{gallery.length > 0 && (
<div class="gallery">
{gallery.map((m) => (
<ResponsiveMedia src={m.src} alt={m.alt} width={m.width} height={m.height} />
))}
</div>
)}
{specs.length > 0 && (
<table class="specifications">
<tbody>
{specs.map(([k, v]) => (
<tr>
<th scope="row">{k}</th>
<td>{v}</td>
</tr>
))}
</tbody>
</table>
)}
{body !== undefined && <RichText html={body} />}
</article>
</BaseLayout>
<style>
h1 {
margin-top: 0;
}
.gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: var(--space-2);
margin-block: var(--space-3);
}
.specifications {
border-collapse: collapse;
margin-block: var(--space-3);
}
.specifications th,
.specifications td {
border: 1px solid var(--color-border);
padding: var(--space-1) var(--space-2);
text-align: left;
}
</style>