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
+139 -1
View File
@@ -1,6 +1,17 @@
// 视图层纯函数:缺省容忍逻辑(Task 1 审查移交契约)集中于此,组件保持薄壳。
// 全部函数只依赖 load-render-data 的类型——不取数、不副作用(五禁纪律下面上无缺口)。
import type { RouteEntry, SiteConfig } from './load-render-data.js'
//
// Task 4 新增内容取值面(真实 hesupipe 数据形状探测结论,夹具同形):
// - 标题在 data.knownFields.title(适配器字段提升纪律:WXR 的 title 留在 knownFields,
// data.title 镜像键对导入修订恒缺席)——回退序 data.title → knownFields.title → slug;
// - 日期在 data.knownFields.postDate(WXR 原串 "YYYY-MM-DD HH:mm:ss",字典序即时间序);
// ContentEntry 无 publishedAt 顶层字段;
// - 产品参数表 data.specifications 是 pilot 约定键(真实 hesupipe 产品修订无结构化参数
// 对象——最接近的 extras.excerpt 是 HTML 表、wppop_* 是解析产物;extras.phpRaw 明确忽略);
// - media 块键 = media_usages 真实 fieldPath:attachmentRefs.featured /
// attachmentRefs.gallery[N](N 为导入提取序——canonical JSON 键排序是字典序,gallery[10]
// 会排在 gallery[2] 前,须按 N 数值序还原)。
import type { AssetEntry, ContentEntry, RouteEntry, SiteConfig } from './load-render-data.js'
/** 页面元信息:SeoHead/BaseLayout 的 Props 面(title/description 缺省容忍) */
export interface PageMeta {
@@ -70,3 +81,130 @@ export function routeHrefFor(routes: RouteEntry[], contentId: string): string |
}
return undefined
}
// ---- 内容取值面(Task 4:六模板共用的缺省容忍读取) ----
/** 内容标题:data.title → knownFields.title → slug(真实导入修订 title 在 knownFields) */
export function contentTitle(c: ContentEntry): string {
const direct = c.data.title
if (typeof direct === 'string' && direct !== '') return direct
const known = (c.data.knownFields as { title?: unknown } | undefined)?.title
if (typeof known === 'string' && known !== '') return known
return c.slug
}
/** 内容日期:knownFields.postDate(WXR 原串,字典序即时间序);缺席 → undefined(不虚构) */
export function contentDate(c: ContentEntry): string | undefined {
const d = (c.data.knownFields as { postDate?: unknown } | undefined)?.postDate
return typeof d === 'string' && d !== '' ? d : undefined
}
/** 正文 HTML:data.bodyHtml 缺席/非串 → undefined(页面省略正文块,不渲染空容器) */
export function contentBody(c: ContentEntry): string | undefined {
const b = c.data.bodyHtml
return typeof b === 'string' && b !== '' ? b : undefined
}
/** SEO 描述:data.seo.description(导入修订的 yoast 面);缺席/非串 → undefined */
export function contentDescription(c: ContentEntry): string | undefined {
const seo = c.data.seo as { description?: unknown } | undefined
const d = seo?.description
return typeof d === 'string' && d !== '' ? d : undefined
}
/**
* 页面 description 兜底:page.description 缺席/空 → 页面标题文本(快线 §8.7/门禁③
* "每 html 有 title/description/canonical"——标题兜底保证恒非空,站点级文案是 Plan 5 SEO 域)。
*/
export function metaDescription(site: SiteConfig, page: PageMeta): string {
if (page.description !== undefined && page.description !== '') return page.description
return pageTitle(site, page)
}
/**
* 某类型内容按日期取最新(首页/列表共用):postDate 降序;无日期条目排在有日期者之后
* (稳定排序保持 contents 数组序——物化端 (typeKey,slug) 冻结序)。limit 缺省全取。
*/
export function latestEntries(contents: ContentEntry[], typeKey: string, limit?: number): ContentEntry[] {
const of = contents.filter((c) => c.typeKey === typeKey)
const dated = of.filter((c) => contentDate(c) !== undefined)
const undated = of.filter((c) => contentDate(c) === undefined)
dated.sort((a, b) => (contentDate(a)! < contentDate(b)! ? 1 : contentDate(a)! > contentDate(b)! ? -1 : 0))
const sorted = [...dated, ...undated]
return limit === undefined ? sorted : sorted.slice(0, Math.max(0, limit))
}
/** 参数表行:data.specifications 为纯对象(值为 string|number|boolean)时取其键值对;否则空 */
export function specificationRows(c: ContentEntry): Array<[string, string]> {
const spec = c.data.specifications
if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) return []
const rows: Array<[string, string]> = []
for (const [k, v] of Object.entries(spec as Record<string, unknown>)) {
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') {
rows.push([k, String(v)])
}
}
return rows
}
/** 媒体引用(assets 索引联查后的展示面):src 恒为 media 块的 public_path(根相对原样) */
export interface MediaRef {
src: string
alt: string
width?: number | undefined
height?: number | undefined
}
const FEATURED_KEY = 'attachmentRefs.featured'
const GALLERY_RE = /^attachmentRefs\.gallery\[(\d+)\]$/
/**
* 内容媒体展示序:featured 首、gallery[N] 按 N 数值序随后(media 块键是 media_usages 的
* fieldPath——canonical JSON 字典序会把 gallery[10] 排到 gallery[2] 前,须按序号还原)。
* alt/width/height 从 assets 索引补齐(条目缺席 → alt 空串、尺寸省略——不虚构)。
*/
export function mediaGallery(c: ContentEntry, assets: Map<string, AssetEntry>): MediaRef[] {
const media = c.media
if (media === undefined) return []
const toRef = (publicPath: string): MediaRef | undefined => {
if (typeof publicPath !== 'string' || publicPath === '') return undefined
const a = assets.get(publicPath)
return {
src: publicPath,
alt: a?.altText ?? '',
width: a?.width ?? undefined,
height: a?.height ?? undefined,
}
}
const refs: MediaRef[] = []
const featuredPath = media[FEATURED_KEY]
const featured = featuredPath === undefined ? undefined : toRef(featuredPath)
if (featured !== undefined) refs.push(featured)
const gallery = Object.entries(media)
.map(([k, v]) => {
const m = GALLERY_RE.exec(k)
return m === null ? null : { n: Number(m[1]), v }
})
.filter((x): x is { n: number; v: string } => x !== null)
.sort((a, b) => a.n - b.n)
for (const g of gallery) {
const ref = toRef(g.v)
if (ref !== undefined) refs.push(ref)
}
return refs
}
/** 卡片封面(ProductCard 等):featured 引用解析;无引用 → undefined(卡片省图,不虚构) */
export function coverRef(c: ContentEntry, assets: Map<string, AssetEntry>): MediaRef | undefined {
const media = c.media
if (media === undefined) return undefined
const publicPath = media[FEATURED_KEY]
if (typeof publicPath !== 'string' || publicPath === '') return undefined
const a = assets.get(publicPath)
return {
src: publicPath,
alt: a?.altText ?? '',
width: a?.width ?? undefined,
height: a?.height ?? undefined,
}
}
+15
View File
@@ -0,0 +1,15 @@
---
// 404 独立静态页(快线 §8.1 第 7 序;Astro 静态构建特例 → dist/404.html)。
// 不经 [...slug] 分派(路由表无 /404 行——它是站点兜底,不是内容路由)。
import BaseLayout from '../layouts/BaseLayout.astro'
import NotFound from '../components/NotFound.astro'
import { loadRenderData } from '../lib/load-render-data'
import { metaDescription } from '../lib/ui'
const data = loadRenderData()
const page = { path: '/404' }
---
<BaseLayout data={data} page={{ ...page, description: metaDescription(data.site, page) }}>
<NotFound />
</BaseLayout>
@@ -0,0 +1,101 @@
---
// 单一动态路由 + 模板分派(快线 §8.1 六类页面,Plan 4 Task 4)。
//
// 路由真值 = routes.json(SF_BUILD_INPUT 六文件,loadRenderData 单一入口):
// 只取 html 纯路径行(routeKind==='html' && queryKey===null);redirect/gone/asset 行
// 不产页(preview 域按 route-manifest 处置),带 query_key 的行不参与(查询路由是 SP3+)。
//
// params 纪律(Astro 5.18 实测约束):rest 参数值必须是 string(数组会触发
// GetStaticPathsInvalidRouteParam)——routePath 去首尾斜杠整串作 slug(内斜杠合法,生成
// 嵌套路径);'/' → slug undefined(根路由)。生成器对字符串只做 NFC + #/? 转义,
// 百分号编码形态原样贯穿(前置事实 7——dist 目录名字节不变,Task 6 门禁按编码形态比对)。
//
// 分派表(theme.manifest 的 appliesTo 声明在此落地):
// '/' → Home
// targetContentId 有值 → 按 content.typeKey:product→ProductDetail、
// news_article→NewsDetail、其余(basic_page/未知)→GenericPage
// 前缀 /product-category/ 或 /category/ → CategoryList(term 从 taxonomies 按 slug 段查)
// '/news/' → NewsList(主列表特判——真实数据 posts 落根路径,
// /news/ 是 route-writer finish 期生成的主列表行)
// 无法分派的 html 行 → 构建期明错(§7.6 禁静默——不允许空页混过)。
import { loadRenderData } from '../lib/load-render-data'
import type { ContentEntry, RenderData, RouteEntry, TaxonomyTerm } from '../lib/load-render-data'
import CategoryList from '../templates/CategoryList.astro'
import GenericPage from '../templates/GenericPage.astro'
import Home from '../templates/Home.astro'
import NewsDetail from '../templates/NewsDetail.astro'
import NewsList from '../templates/NewsList.astro'
import ProductDetail from '../templates/ProductDetail.astro'
/** 分类列表前缀(term-writer 前置事实 9 常量的两族 basePath;publicBasePath 尾斜杠已归一) */
const CATEGORY_PREFIXES = ['/product-category/', '/category/']
type View =
| { kind: 'home' }
| { kind: 'generic'; content: ContentEntry }
| { kind: 'category'; term: TaxonomyTerm }
| { kind: 'product'; content: ContentEntry }
| { kind: 'news-detail'; content: ContentEntry }
| { kind: 'news-list' }
function dispatch(route: RouteEntry, data: RenderData): View {
if (route.routePath === '/') return { kind: 'home' }
if (route.targetContentId !== null) {
const content = data.contents.find((c) => c.contentId === route.targetContentId)
if (content === undefined) {
throw new Error(
`路由 ${route.routePath} 的 targetContentId=${route.targetContentId} 在 content.json 无对应条目(published 悬空或物化破裂)——拒绝静默空页`,
)
}
if (content.typeKey === 'product') return { kind: 'product', content }
if (content.typeKey === 'news_article') return { kind: 'news-detail', content }
// basic_page 及未知 typeKey:通用模板忠实呈现标题+正文(无静默——内容原样渲染)
return { kind: 'generic', content }
}
for (const prefix of CATEGORY_PREFIXES) {
if (!route.routePath.startsWith(prefix)) continue
const termSlug = route.routePath.slice(prefix.length).replace(/\/+$/, '')
const base = prefix.replace(/\/+$/, '')
for (const tax of data.taxonomies) {
if (tax.publicBasePath.replace(/\/+$/, '') !== base) continue
const term = tax.terms.find((t) => t.slug === termSlug)
if (term !== undefined) return { kind: 'category', term }
}
throw new Error(
`分类路由 ${route.routePath} 在 taxonomies.json 无对应 term(slug=${termSlug})——拒绝静默空页`,
)
}
if (route.routePath === '/news/') return { kind: 'news-list' }
throw new Error(
`路由 ${route.routePath} 无法分派到任何模板(html 纯路径行必须有落点;主列表/分类/内容页规则均未命中)——拒绝静默空页`,
)
}
export function getStaticPaths() {
const data = loadRenderData()
return data.routes
.filter((r) => r.routeKind === 'html' && r.queryKey === null)
.map((route) => {
const rest = route.routePath.replace(/^\/+/, '').replace(/\/+$/, '')
return {
params: { slug: rest === '' ? undefined : rest },
props: { route },
}
})
}
const { route } = Astro.props
const data = loadRenderData()
const view = dispatch(route, data)
---
{view.kind === 'home' && <Home data={data} route={route} />}
{view.kind === 'generic' && <GenericPage data={data} route={route} content={view.content} />}
{view.kind === 'category' && <CategoryList data={data} route={route} term={view.term} />}
{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} />}
@@ -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>