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:
@@ -1,6 +1,41 @@
|
||||
import { resolve } from 'node:path'
|
||||
import { existsSync, renameSync, rmSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { defineConfig } from 'astro/config'
|
||||
|
||||
// 字面 .html 路径对齐(URL pathname 保真——快线 Task 4 钉死的映射规则之一):
|
||||
// Astro 页面产物在 build.format 三态下分别是 <path>/index.html(directory——尾斜杠路由
|
||||
// 的正确形态)或 <path>.html(file/preserve——会给无后缀路由追加后缀)。两者都产不出与
|
||||
// routes.json targetFile 逐字节一致的 dist/news/x.html 字面文件(file/preserve 会得到
|
||||
// x.html.html)。此钩子在构建收尾把 .html 后缀路由的包装目录折平为字面文件——
|
||||
// Task 5 管线的 manifest 对齐(target_file 是唯一权威)因此恒为 no-op。
|
||||
function literalHtmlPaths() {
|
||||
return {
|
||||
name: 'sf-literal-html-paths',
|
||||
hooks: {
|
||||
'astro:build:done': async ({ pages, dir, logger }) => {
|
||||
const dist = fileURLToPath(dir)
|
||||
let moved = 0
|
||||
for (const { pathname } of pages) {
|
||||
// pages 的 pathname 是相对 pageName(directory 格式恒带尾斜杠,如
|
||||
// 'news/x.html/')——剥尾斜杠后以 .html 后缀判定字面文件路由
|
||||
const rel = pathname.replace(/\/+$/, '')
|
||||
if (!rel.endsWith('.html')) continue
|
||||
const wrapper = join(dist, rel)
|
||||
const nested = join(wrapper, 'index.html')
|
||||
if (!existsSync(nested)) continue
|
||||
const tmp = join(dist, `.sf-literal-${moved}.tmp`)
|
||||
renameSync(nested, tmp)
|
||||
rmSync(wrapper, { recursive: true })
|
||||
renameSync(tmp, wrapper)
|
||||
moved++
|
||||
}
|
||||
if (moved > 0) logger.info(`字面 .html 路径对齐:${moved} 页`)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 纯静态输出(Task 4/5 消费)。页面数据全部来自 SF_BUILD_INPUT 六文件(load-render-data
|
||||
// 单一入口),不设 site——canonical/og 的 origin 是每站点数据(site.json 的 origin),
|
||||
// 不是主题级配置。@sf/contracts 直连源码(与根 tsconfig/vitest 同映射——契约包零依赖
|
||||
@@ -9,6 +44,7 @@ export default defineConfig({
|
||||
output: 'static',
|
||||
srcDir: './src',
|
||||
outDir: './dist',
|
||||
integrations: [literalHtmlPaths()],
|
||||
vite: {
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -1 +1 @@
|
||||
[{"altText":null,"byteSize":1234,"copyPath":"storage/snapshots/media_bundle/2026/10/fixture-a.png","detectedMimeType":"image/png","height":80,"publicPath":"/wp-content/uploads/2026/10/fixture-a.png","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","storagePath":"snapshots/media_bundle/2026/10/fixture-a.png","width":100}]
|
||||
[{"altText":null,"byteSize":1234,"copyPath":"storage/snapshots/media_bundle/2026/10/fixture-a.png","detectedMimeType":"image/png","height":80,"publicPath":"/wp-content/uploads/2026/10/fixture-a.png","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","storagePath":"snapshots/media_bundle/2026/10/fixture-a.png","width":100},{"altText":"Fixture product gallery image B","byteSize":2345,"copyPath":"storage/snapshots/media_bundle/2026/10/fixture-b.jpg","detectedMimeType":"image/jpeg","height":90,"publicPath":"/wp-content/uploads/2026/10/fixture-b.jpg","sha256":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","storagePath":"snapshots/media_bundle/2026/10/fixture-b.jpg","width":120},{"altText":null,"byteSize":3456,"copyPath":"storage/snapshots/media_bundle/2026/10/fixture-c.jpg","detectedMimeType":"image/jpeg","height":150,"publicPath":"/wp-content/uploads/2026/10/fixture-c.jpg","sha256":"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc","storagePath":"snapshots/media_bundle/2026/10/fixture-c.jpg","width":200}]
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
{"counts":{"assets":1,"contents":3,"routes":7,"taxonomies":1},"generatedAt":"2026-10-22T00:00:00.000Z","renderSnapshotVersion":1,"sourceHashes":{"asset":"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc","content":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","route":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},"urlRewrite":{"rewritten":1,"unresolved":0},"versions":{"adapter":"0.1.0","mapping":"0.1.0","parser":"0.1.0","sanitizer":"0.1.0"}}
|
||||
{"counts":{"assets":3,"contents":11,"routes":15,"taxonomies":1},"generatedAt":"2026-10-22T00:00:00.000Z","renderSnapshotVersion":1,"sourceHashes":{"asset":"2983bb5bd0383029c9e0d6f7892e034f7096004ed0c601eadb9c9aab3df456f0","content":"c57c18ddacc964c11b6f70c3b27d00420288aeb052261d86f6f93abebe7512c4","route":"8137f1efc594e7ae0b1d62398aee3fb64958ce2f33bee73be2a89dc2cec08948"},"urlRewrite":{"rewritten":1,"unresolved":0},"versions":{"adapter":"0.1.0","mapping":"0.1.0","parser":"0.1.0","sanitizer":"0.1.0"}}
|
||||
|
||||
@@ -1 +1 @@
|
||||
[{"host":"","indexable":true,"priority":1000,"queryKey":null,"queryPolicy":"ignore","redirectLocation":null,"redirectStatus":null,"routeKind":"html","routePath":"/","targetContentId":null,"targetFile":"index.html","targetStatus":"200","verificationStatus":"verified_html"},{"host":"","indexable":true,"priority":900,"queryKey":null,"queryPolicy":"ignore","redirectLocation":null,"redirectStatus":null,"routeKind":"html","routePath":"/%e5%85%b3%e4%ba%8e%e6%88%91%e4%bb%ac/","targetContentId":"11111111-1111-4111-8111-111111111111","targetFile":"%e5%85%b3%e4%ba%8e%e6%88%91%e4%bb%ac/index.html","targetStatus":"200","verificationStatus":"verified_html"},{"host":"","indexable":true,"priority":900,"queryKey":null,"queryPolicy":"ignore","redirectLocation":null,"redirectStatus":null,"routeKind":"html","routePath":"/news/","targetContentId":null,"targetFile":"news/index.html","targetStatus":"200","verificationStatus":"verified_html"},{"host":"","indexable":true,"priority":500,"queryKey":null,"queryPolicy":"ignore","redirectLocation":null,"redirectStatus":null,"routeKind":"html","routePath":"/news/fixture-news-a.html","targetContentId":"33333333-3333-4333-8333-333333333333","targetFile":"news/fixture-news-a.html","targetStatus":"200","verificationStatus":"verified_html"},{"host":"","indexable":false,"priority":0,"queryKey":null,"queryPolicy":"exact","redirectLocation":"/","redirectStatus":301,"routeKind":"redirect","routePath":"/old-path/","targetContentId":null,"targetFile":null,"targetStatus":"301","verificationStatus":"verified_redirect"},{"host":"","indexable":true,"priority":500,"queryKey":null,"queryPolicy":"ignore","redirectLocation":null,"redirectStatus":null,"routeKind":"html","routePath":"/product/fixture-product/","targetContentId":"44444444-4444-4444-8444-444444444444","targetFile":"product/fixture-product/index.html","targetStatus":"200","verificationStatus":"verified_html"},{"host":"","indexable":true,"priority":900,"queryKey":null,"queryPolicy":"ignore","redirectLocation":null,"redirectStatus":null,"routeKind":"html","routePath":"/products/","targetContentId":null,"targetFile":"products/index.html","targetStatus":"200","verificationStatus":"verified_html"}]
|
||||
[{"host":"","indexable":true,"priority":1000,"queryKey":null,"queryPolicy":"ignore","redirectLocation":null,"redirectStatus":null,"routeKind":"html","routePath":"/","targetContentId":null,"targetFile":"index.html","targetStatus":"200","verificationStatus":"verified_html"},{"host":"","indexable":true,"priority":900,"queryKey":null,"queryPolicy":"ignore","redirectLocation":null,"redirectStatus":null,"routeKind":"html","routePath":"/%e5%85%b3%e4%ba%8e%e6%88%91%e4%bb%ac/","targetContentId":"11111111-1111-4111-8111-111111111111","targetFile":"%e5%85%b3%e4%ba%8e%e6%88%91%e4%bb%ac/index.html","targetStatus":"200","verificationStatus":"verified_html"},{"host":"","indexable":true,"priority":900,"queryKey":null,"queryPolicy":"ignore","redirectLocation":null,"redirectStatus":null,"routeKind":"html","routePath":"/about-us/","targetContentId":"12121212-1212-4121-8121-121212121212","targetFile":"about-us/index.html","targetStatus":"200","verificationStatus":"verified_html"},{"host":"","indexable":true,"priority":500,"queryKey":null,"queryPolicy":"ignore","redirectLocation":null,"redirectStatus":null,"routeKind":"html","routePath":"/fixture-news-b/","targetContentId":"34343434-3434-4343-8343-343434343434","targetFile":"fixture-news-b/index.html","targetStatus":"200","verificationStatus":"verified_html"},{"host":"","indexable":true,"priority":500,"queryKey":null,"queryPolicy":"ignore","redirectLocation":null,"redirectStatus":null,"routeKind":"html","routePath":"/fixture-news-c/","targetContentId":"35353535-3535-4535-8535-353535353535","targetFile":"fixture-news-c/index.html","targetStatus":"200","verificationStatus":"verified_html"},{"host":"","indexable":true,"priority":500,"queryKey":null,"queryPolicy":"ignore","redirectLocation":null,"redirectStatus":null,"routeKind":"html","routePath":"/fixture-news-d/","targetContentId":"36363636-3636-4636-8636-363636363636","targetFile":"fixture-news-d/index.html","targetStatus":"200","verificationStatus":"verified_html"},{"host":"","indexable":true,"priority":900,"queryKey":null,"queryPolicy":"ignore","redirectLocation":null,"redirectStatus":null,"routeKind":"html","routePath":"/news/","targetContentId":null,"targetFile":"news/index.html","targetStatus":"200","verificationStatus":"verified_html"},{"host":"","indexable":true,"priority":500,"queryKey":null,"queryPolicy":"ignore","redirectLocation":null,"redirectStatus":null,"routeKind":"html","routePath":"/news/fixture-news-a.html","targetContentId":"33333333-3333-4333-8333-333333333333","targetFile":"news/fixture-news-a.html","targetStatus":"200","verificationStatus":"verified_html"},{"host":"","indexable":false,"priority":0,"queryKey":null,"queryPolicy":"ignore","redirectLocation":"/","redirectStatus":301,"routeKind":"redirect","routePath":"/old-path/","targetContentId":null,"targetFile":null,"targetStatus":"301","verificationStatus":"verified_redirect"},{"host":"","indexable":true,"priority":800,"queryKey":null,"queryPolicy":"ignore","redirectLocation":null,"redirectStatus":null,"routeKind":"html","routePath":"/product-category/pipe-fittings/","targetContentId":null,"targetFile":"product-category/pipe-fittings/index.html","targetStatus":"200","verificationStatus":"verified_html"},{"host":"","indexable":true,"priority":500,"queryKey":null,"queryPolicy":"ignore","redirectLocation":null,"redirectStatus":null,"routeKind":"html","routePath":"/product/fixture-product-b/","targetContentId":"45454545-4545-4545-8545-454545454545","targetFile":"product/fixture-product-b/index.html","targetStatus":"200","verificationStatus":"verified_html"},{"host":"","indexable":true,"priority":500,"queryKey":null,"queryPolicy":"ignore","redirectLocation":null,"redirectStatus":null,"routeKind":"html","routePath":"/product/fixture-product-c/","targetContentId":"46464646-4646-4646-8646-464646464646","targetFile":"product/fixture-product-c/index.html","targetStatus":"200","verificationStatus":"verified_html"},{"host":"","indexable":true,"priority":500,"queryKey":null,"queryPolicy":"ignore","redirectLocation":null,"redirectStatus":null,"routeKind":"html","routePath":"/product/fixture-product-old/","targetContentId":"47474747-4747-4747-8747-474747474747","targetFile":"product/fixture-product-old/index.html","targetStatus":"200","verificationStatus":"verified_html"},{"host":"","indexable":true,"priority":500,"queryKey":null,"queryPolicy":"ignore","redirectLocation":null,"redirectStatus":null,"routeKind":"html","routePath":"/product/fixture-product/","targetContentId":"44444444-4444-4444-8444-444444444444","targetFile":"product/fixture-product/index.html","targetStatus":"200","verificationStatus":"verified_html"},{"host":"","indexable":true,"priority":900,"queryKey":null,"queryPolicy":"ignore","redirectLocation":null,"redirectStatus":null,"routeKind":"html","routePath":"/products/","targetContentId":"22222222-2222-4222-8222-222222222222","targetFile":"products/index.html","targetStatus":"200","verificationStatus":"verified_html"}]
|
||||
|
||||
@@ -1 +1 @@
|
||||
[{"key":"product-category","objectType":"product","publicBasePath":"/product-category","terms":[{"contentEntryIds":["44444444-4444-4444-8444-444444444444"],"data":{},"id":"55555555-5555-4555-8555-555555555555","name":"Fixture 分类","parentTermId":null,"slug":"fixture-cat","sourceId":"12"}]}]
|
||||
[{"key":"product-category","objectType":"product","publicBasePath":"/product-category/","terms":[{"contentEntryIds":["44444444-4444-4444-8444-444444444444","45454545-4545-4545-8545-454545454545","46464646-4646-4646-8646-464646464646","47474747-4747-4747-8747-474747474747"],"data":{},"id":"55555555-5555-4555-8555-555555555555","name":"Pipe Fittings","parentTermId":null,"slug":"pipe-fittings","sourceId":"12"}]}]
|
||||
|
||||
@@ -34,14 +34,16 @@ describe('load-render-data(渲染数据唯一入口)', () => {
|
||||
expect(data.site.locale).toBe('zh-CN')
|
||||
expect(data.site.brand).toStrictEqual({})
|
||||
|
||||
expect(data.contents).toHaveLength(3)
|
||||
// Task 4 扩夹后:11 条(3 basic_page + 4 news_article + 4 product)——六页集 + 首页截断断言的第 4 条
|
||||
expect(data.contents).toHaveLength(11)
|
||||
const news = data.contents.find((c) => c.slug === 'fixture-news-a')
|
||||
expect(news?.typeKey).toBe('news_article')
|
||||
expect(news?.data.bodyHtml).toContain('src="/wp-content/uploads/2026/10/fixture-a.png"')
|
||||
expect(news?.media?.featured).toBe('/wp-content/uploads/2026/10/fixture-a.png')
|
||||
// media 块键 = media_usages 真实 fieldPath(Task 2 物化面),非裸 'featured'
|
||||
expect(news?.media?.['attachmentRefs.featured']).toBe('/wp-content/uploads/2026/10/fixture-a.png')
|
||||
|
||||
expect(data.taxonomies[0]).toMatchObject({ key: 'product-category', objectType: 'product' })
|
||||
expect(data.taxonomies[0]?.terms[0]?.contentEntryIds).toHaveLength(1)
|
||||
expect(data.taxonomies[0]?.terms[0]?.contentEntryIds).toHaveLength(4)
|
||||
|
||||
const redirect = data.routes.find((r) => r.routeKind === 'redirect')
|
||||
expect(redirect).toMatchObject({
|
||||
@@ -51,7 +53,7 @@ describe('load-render-data(渲染数据唯一入口)', () => {
|
||||
indexable: false,
|
||||
})
|
||||
|
||||
expect(data.assets.size).toBe(1)
|
||||
expect(data.assets.size).toBe(3)
|
||||
expect(data.assets.get('/wp-content/uploads/2026/10/fixture-a.png')).toMatchObject({
|
||||
detectedMimeType: 'image/png',
|
||||
byteSize: 1234,
|
||||
@@ -60,7 +62,7 @@ describe('load-render-data(渲染数据唯一入口)', () => {
|
||||
altText: null,
|
||||
})
|
||||
|
||||
expect(data.renderManifest.counts).toStrictEqual({ contents: 3, taxonomies: 1, routes: 7, assets: 1 })
|
||||
expect(data.renderManifest.counts).toStrictEqual({ contents: 11, taxonomies: 1, routes: 15, assets: 3 })
|
||||
expect(data.renderManifest.renderSnapshotVersion).toBe(1)
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
// Task 4 构建级用例:六页夹具(真实 hesupipe 抽样形状脱敏化)→ astro build 真跑 → dist/ 断言。
|
||||
// 与 Container API 组件测试(components.test.ts)分层:此处验证的是路由分派 + 模板组装 +
|
||||
// 产物映射(尾斜杠→<path>/index.html、.html 后缀→字面文件、百分号编码路径→编码原样目录)
|
||||
// 的整链,不只是单组件渲染。
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import type { RouteEntry } from '../src/lib/load-render-data.js'
|
||||
|
||||
const THEME_ROOT = fileURLToPath(new URL('..', import.meta.url))
|
||||
const FIXTURE = fileURLToPath(new URL('./fixtures/render-input', import.meta.url))
|
||||
const ASTRO_ENTRY = fileURLToPath(new URL('../node_modules/astro/astro.js', import.meta.url))
|
||||
const ORIGIN = 'https://demo.example'
|
||||
const ENCODED = '%e5%85%b3%e4%ba%8e%e6%88%91%e4%bb%ac'
|
||||
|
||||
/** 路由 → dist 相对文件路径(Task 5 管线同款映射规则:target_file 形态是唯一权威) */
|
||||
function distRel(routePath: string): string {
|
||||
if (routePath === '/') return 'index.html'
|
||||
const rel = routePath.replace(/^\//, '').replace(/\/+$/, '')
|
||||
return rel.endsWith('.html') ? rel : `${rel}/index.html`
|
||||
}
|
||||
|
||||
let dist = ''
|
||||
function page(routePath: string): string {
|
||||
return readFileSync(join(dist, distRel(routePath)), 'utf8')
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
dist = mkdtempSync(join(tmpdir(), 'sf-render-cases-'))
|
||||
execFileSync(process.execPath, [ASTRO_ENTRY, 'build', '--outDir', dist], {
|
||||
cwd: THEME_ROOT,
|
||||
env: { ...process.env, SF_BUILD_INPUT: FIXTURE },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: 120_000,
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
})
|
||||
}, 180_000)
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(dist, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('六类页面构建(astro build × render-input 夹具)', () => {
|
||||
it('全量落盘映射:html 纯路径行 → <path>/index.html(尾斜杠)或字面 .html 文件;redirect 不产页;404.html 存在', () => {
|
||||
const routes = JSON.parse(
|
||||
readFileSync(join(FIXTURE, 'routes.json'), 'utf8'),
|
||||
) as RouteEntry[]
|
||||
const htmlRoutes = routes.filter((r) => r.routeKind === 'html' && r.queryKey === null)
|
||||
expect(htmlRoutes).toHaveLength(14)
|
||||
for (const r of htmlRoutes) {
|
||||
const rel = distRel(r.routePath)
|
||||
const f = join(dist, rel)
|
||||
// isFile 而非 exists:.html 字面路径若被 Astro 折成同名目录,existsSync 仍真——形态必须钉死
|
||||
expect(statSync(f).isFile(), `${r.routePath} → ${rel}(须为文件)`).toBe(true)
|
||||
}
|
||||
// redirect 行不产页(301 的处置在 preview/route-manifest 域,不在静态产物域)
|
||||
expect(existsSync(join(dist, 'old-path'))).toBe(false)
|
||||
expect(existsSync(join(dist, 'old-path', 'index.html'))).toBe(false)
|
||||
// 404 独立静态页(Astro 特例 → 根级 404.html)
|
||||
expect(statSync(join(dist, '404.html')).isFile()).toBe(true)
|
||||
expect(page('/')).toContain('demo-site')
|
||||
})
|
||||
|
||||
it(`编码路径目录原样落盘:dist/${ENCODED}/ 字节不变(前置事实 7——禁 percent-decode)`, () => {
|
||||
const roots = readdirSync(dist)
|
||||
expect(roots).toContain(ENCODED)
|
||||
expect(roots).not.toContain('关于我们') // 无解码形态孪生目录
|
||||
const html = page(`/${ENCODED}/`)
|
||||
expect(html).toContain('<title>关于我们</title>')
|
||||
expect(html).toContain('关于我们页面正文。')
|
||||
// canonical 拼接保持编码形态(canonicalUrl 只做 origin + path 原样连接)
|
||||
expect(html).toContain(`href="${ORIGIN}/${ENCODED}/"`)
|
||||
})
|
||||
|
||||
it('首页:site name hero + 最新 3 新闻卡 + 最新 3 产品卡(第 4 条最旧被截断)+ 内链 routeHrefFor + 图片根相对', () => {
|
||||
const html = page('/')
|
||||
expect(html.match(/<h1[\s>]/g)).toHaveLength(1) // 恰一 h1(门禁③纪律)
|
||||
expect(html).toContain('demo-site')
|
||||
// 最新 3 新闻(postDate 降序:a 2026-10-01 / b 09-20 / c 08-15;d 07-01 被截断)
|
||||
expect(html).toContain('Fixture News A')
|
||||
expect(html).toContain('Fixture News B')
|
||||
expect(html).toContain('Fixture News C')
|
||||
expect(html).not.toContain('Fixture News D')
|
||||
// 最新 3 产品(old 2025-12-01 被截断)
|
||||
expect(html).toContain('Fixture Product')
|
||||
expect(html).toContain('Fixture Product B')
|
||||
expect(html).toContain('Fixture Product C')
|
||||
expect(html).not.toContain('Fixture Product Old')
|
||||
// 内链用路由表真值(routeHrefFor——产品卡可点到详情页)
|
||||
expect(html).toContain('href="/product/fixture-product/"')
|
||||
expect(html).toContain('href="/fixture-news-b/"')
|
||||
// 封面图 src = media 块引用的 public_path(根相对原样)
|
||||
expect(html).toContain('src="/wp-content/uploads/2026/10/fixture-a.png"')
|
||||
})
|
||||
|
||||
it('产品详情:h1 标题 + 参数表键值 + 相册三图(featured + gallery[0..1] 根相对)+ 正文;extras.phpRaw 不渲染', () => {
|
||||
const html = page('/product/fixture-product/')
|
||||
expect(html).toContain('Fixture Product')
|
||||
expect(html).toContain('产品说明。')
|
||||
// 参数表(data.specifications 纯对象转表)
|
||||
expect(html).toContain('specifications')
|
||||
expect(html).toContain('材质')
|
||||
expect(html).toContain('不锈钢')
|
||||
expect(html).toContain('DN400')
|
||||
// 相册:featured 首图 + gallery 按序号,src 根相对
|
||||
expect(html).toContain('src="/wp-content/uploads/2026/10/fixture-a.png"')
|
||||
expect(html).toContain('src="/wp-content/uploads/2026/10/fixture-b.jpg"')
|
||||
expect(html).toContain('src="/wp-content/uploads/2026/10/fixture-c.jpg"')
|
||||
// extras.phpRaw 明确忽略
|
||||
expect(html).not.toContain('a:1:{')
|
||||
})
|
||||
|
||||
it('新闻详情(.html 字面路径):dist/news/fixture-news-a.html 是文件而非目录;日期/正文/根相对图片/canonical', () => {
|
||||
const f = join(dist, 'news', 'fixture-news-a.html')
|
||||
expect(statSync(f).isFile()).toBe(true)
|
||||
const html = readFileSync(f, 'utf8')
|
||||
expect(html).toContain('Fixture News A')
|
||||
expect(html).toMatch(/<time[^>]*datetime="2026-10-01 09:00:00"/)
|
||||
expect(html).toContain('正文引用图片')
|
||||
expect(html).toContain('src="/wp-content/uploads/2026/10/fixture-a.png"')
|
||||
expect(html).toContain(`href="${ORIGIN}/news/fixture-news-a.html"`)
|
||||
})
|
||||
|
||||
it('新闻列表 /news/:News 标题 + 全部 4 条新闻卡(分页 V1 不做——单页全列)', () => {
|
||||
const html = page('/news/')
|
||||
expect(html).toContain('News')
|
||||
expect(html).toContain('href="/news/fixture-news-a.html"')
|
||||
expect(html).toContain('href="/fixture-news-b/"')
|
||||
expect(html).toContain('Fixture News C')
|
||||
expect(html).toContain('Fixture News D') // 列表全列,不截断
|
||||
})
|
||||
|
||||
it('分类列表 /product-category/pipe-fittings/:面包屑(首页 › 当前分类)+ term 名标题 + 4 产品卡可点', () => {
|
||||
const html = page('/product-category/pipe-fittings/')
|
||||
expect(html).toContain('aria-label="面包屑"')
|
||||
expect(html).toContain('href="/"')
|
||||
expect(html).toContain('aria-current="page"')
|
||||
expect(html).toContain('Pipe Fittings')
|
||||
// term.contentEntryIds 反查 contents——4 产品全卡片(含首页被截断的 old)
|
||||
expect(html).toContain('href="/product/fixture-product/"')
|
||||
expect(html).toContain('href="/product/fixture-product-b/"')
|
||||
expect(html).toContain('href="/product/fixture-product-c/"')
|
||||
expect(html).toContain('href="/product/fixture-product-old/"')
|
||||
expect(html).toContain('Fixture Product Old')
|
||||
})
|
||||
|
||||
it('SEO 基线:六页集 + /products/ 全部有 title / meta description / canonical(origin+自身路径)', () => {
|
||||
const cases: Array<[string, string, string]> = [
|
||||
['/', 'demo-site', `${ORIGIN}/`],
|
||||
['/about-us/', 'About Us', `${ORIGIN}/about-us/`],
|
||||
[`/${ENCODED}/`, '关于我们', `${ORIGIN}/${ENCODED}/`],
|
||||
['/product-category/pipe-fittings/', 'Pipe Fittings', `${ORIGIN}/product-category/pipe-fittings/`],
|
||||
['/product/fixture-product/', 'Fixture Product', `${ORIGIN}/product/fixture-product/`],
|
||||
['/news/', 'News', `${ORIGIN}/news/`],
|
||||
['/news/fixture-news-a.html', 'Fixture News A', `${ORIGIN}/news/fixture-news-a.html`],
|
||||
// 前置事实:/products/ 被 WP "Products" 内容页占据——落 GenericPage(非列表壳)
|
||||
['/products/', 'Products', `${ORIGIN}/products/`],
|
||||
]
|
||||
for (const [routePath, title, canonical] of cases) {
|
||||
const html = page(routePath)
|
||||
expect(html, routePath).toContain(`<title>${title}</title>`)
|
||||
expect(html, routePath).toMatch(/<meta name="description" content="[^"]+"/)
|
||||
expect(html, routePath).toContain(`<link rel="canonical" href="${canonical}">`)
|
||||
}
|
||||
// 内容页 description 优先取修订 seo.description(yoast 面)
|
||||
expect(page('/about-us/')).toContain('About us fixture page description.')
|
||||
expect(page('/news/fixture-news-a.html')).toContain('Fixture news A meta description.')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user