diff --git a/themes/pilot-minimal/astro.config.mjs b/themes/pilot-minimal/astro.config.mjs index 37832c5..f479e08 100644 --- a/themes/pilot-minimal/astro.config.mjs +++ b/themes/pilot-minimal/astro.config.mjs @@ -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 三态下分别是 /index.html(directory——尾斜杠路由 +// 的正确形态)或 .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: { diff --git a/themes/pilot-minimal/src/lib/ui.ts b/themes/pilot-minimal/src/lib/ui.ts index f692919..5baac8a 100644 --- a/themes/pilot-minimal/src/lib/ui.ts +++ b/themes/pilot-minimal/src/lib/ui.ts @@ -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)) { + 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): 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): 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, + } +} diff --git a/themes/pilot-minimal/src/pages/404.astro b/themes/pilot-minimal/src/pages/404.astro new file mode 100644 index 0000000..766843e --- /dev/null +++ b/themes/pilot-minimal/src/pages/404.astro @@ -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' } +--- + + + + diff --git a/themes/pilot-minimal/src/pages/[...slug].astro b/themes/pilot-minimal/src/pages/[...slug].astro new file mode 100644 index 0000000..0db6cd3 --- /dev/null +++ b/themes/pilot-minimal/src/pages/[...slug].astro @@ -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' && } +{view.kind === 'generic' && } +{view.kind === 'category' && } +{view.kind === 'product' && } +{view.kind === 'news-detail' && } +{view.kind === 'news-list' && } diff --git a/themes/pilot-minimal/src/templates/CategoryList.astro b/themes/pilot-minimal/src/templates/CategoryList.astro new file mode 100644 index 0000000..ac07b42 --- /dev/null +++ b/themes/pilot-minimal/src/templates/CategoryList.astro @@ -0,0 +1,49 @@ +--- +// 分类列表模板(快线 §8.1 第 4 序;前置事实:/products/ 被内容页占据——产品列表演示 +// 走 /product-category//)。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') +--- + + + +

{term.name}

+
+ {products.map((c) => { + const href = routeHrefFor(data.routes, c.contentId) + if (href === undefined) return null + return ( + + ) + })} +
+
+ + diff --git a/themes/pilot-minimal/src/templates/GenericPage.astro b/themes/pilot-minimal/src/templates/GenericPage.astro new file mode 100644 index 0000000..b4d8779 --- /dev/null +++ b/themes/pilot-minimal/src/templates/GenericPage.astro @@ -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) +--- + + +
+

{page.title}

+ {body !== undefined && } +
+
+ + diff --git a/themes/pilot-minimal/src/templates/Home.astro b/themes/pilot-minimal/src/templates/Home.astro new file mode 100644 index 0000000..efa5623 --- /dev/null +++ b/themes/pilot-minimal/src/templates/Home.astro @@ -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) +--- + + +
+

{data.site.name}

+
+ +
+

News

+
+ {latestNews.map((c) => { + const href = routeHrefFor(data.routes, c.contentId) + if (href === undefined) return null + return + })} +
+
+ +
+

Products

+
+ {latestProducts.map((c) => { + const href = routeHrefFor(data.routes, c.contentId) + if (href === undefined) return null + return ( + + ) + })} +
+
+
+ + diff --git a/themes/pilot-minimal/src/templates/NewsDetail.astro b/themes/pilot-minimal/src/templates/NewsDetail.astro new file mode 100644 index 0000000..53918ec --- /dev/null +++ b/themes/pilot-minimal/src/templates/NewsDetail.astro @@ -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) +--- + + +
+

{page.title}

+ {date !== undefined && } + {body !== undefined && } +
+
+ + diff --git a/themes/pilot-minimal/src/templates/NewsList.astro b/themes/pilot-minimal/src/templates/NewsList.astro new file mode 100644 index 0000000..0a1e85d --- /dev/null +++ b/themes/pilot-minimal/src/templates/NewsList.astro @@ -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') +--- + + +

News

+
+ {allNews.map((c) => { + const href = routeHrefFor(data.routes, c.contentId) + if (href === undefined) return null + return + })} +
+
+ + diff --git a/themes/pilot-minimal/src/templates/ProductDetail.astro b/themes/pilot-minimal/src/templates/ProductDetail.astro new file mode 100644 index 0000000..6e4fe67 --- /dev/null +++ b/themes/pilot-minimal/src/templates/ProductDetail.astro @@ -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) +--- + + +
+

{page.title}

+ + {gallery.length > 0 && ( + + )} + + {specs.length > 0 && ( + + + {specs.map(([k, v]) => ( + + + + + ))} + +
{k}{v}
+ )} + + {body !== undefined && } +
+
+ + diff --git a/themes/pilot-minimal/test/fixtures/render-input/assets.json b/themes/pilot-minimal/test/fixtures/render-input/assets.json index d73b1a3..a1230ac 100644 --- a/themes/pilot-minimal/test/fixtures/render-input/assets.json +++ b/themes/pilot-minimal/test/fixtures/render-input/assets.json @@ -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}] diff --git a/themes/pilot-minimal/test/fixtures/render-input/content.json b/themes/pilot-minimal/test/fixtures/render-input/content.json index 1f65d84..defd29d 100644 --- a/themes/pilot-minimal/test/fixtures/render-input/content.json +++ b/themes/pilot-minimal/test/fixtures/render-input/content.json @@ -1 +1 @@ -[{"checksum":"cafebabe-0000","contentId":"11111111-1111-4111-8111-111111111111","data":{"bodyHtml":"

关于我们页面正文。

","slug":"%e5%85%b3%e4%ba%8e%e6%88%91%e4%bb%ac","title":"关于我们"},"locale":"zh-CN","revisionId":"aaaa1aaa-1111-4111-8111-111111111111","slug":"%e5%85%b3%e4%ba%8e%e6%88%91%e4%bb%ac","typeKey":"basic_page"},{"checksum":"cafebabe-0001","contentId":"33333333-3333-4333-8333-333333333333","data":{"bodyHtml":"

正文引用图片 。

","date":"2026-10-01","slug":"fixture-news-a","title":"Fixture News A"},"locale":"zh-CN","media":{"featured":"/wp-content/uploads/2026/10/fixture-a.png"},"revisionId":"aaaa2aaa-2222-4222-8222-222222222222","slug":"fixture-news-a","typeKey":"news_article"},{"checksum":"cafebabe-0002","contentId":"44444444-4444-4444-8444-444444444444","data":{"bodyHtml":"

产品说明。

","slug":"fixture-product","specs":[["材质","不锈钢"]],"title":"Fixture Product"},"locale":"zh-CN","revisionId":"aaaa3aaa-3333-4333-8333-333333333333","slug":"fixture-product","typeKey":"product"}] +[{"checksum":"cafebabe-0000","contentId":"11111111-1111-4111-8111-111111111111","data":{"bodyHtml":"

关于我们页面正文。

","extras":{},"knownFields":{"postDate":"2026-01-10 09:00:00","postModified":"2026-01-11 09:00:00","title":"关于我们"},"seo":{},"slug":"%e5%85%b3%e4%ba%8e%e6%88%91%e4%bb%ac","status":"publish"},"locale":"zh-CN","revisionId":"aaaa1aaa-1111-4111-8111-111111111111","slug":"%e5%85%b3%e4%ba%8e%e6%88%91%e4%bb%ac","typeKey":"basic_page"},{"checksum":"cafebabe-0010","contentId":"12121212-1212-4121-8121-121212121212","data":{"bodyHtml":"

Who we are

About us fixture body paragraph.

","extras":{},"knownFields":{"postDate":"2026-02-15 08:30:00","postModified":"2026-02-16 08:30:00","title":"About Us"},"seo":{"description":"About us fixture page description."},"slug":"about-us","status":"publish"},"locale":"zh-CN","revisionId":"aaaa1aaa-1212-4121-8121-121212121212","slug":"about-us","typeKey":"basic_page"},{"checksum":"cafebabe-0011","contentId":"22222222-2222-4222-8222-222222222222","data":{"bodyHtml":"

Products overview page body.

","extras":{},"knownFields":{"postDate":"2026-03-01 10:00:00","postModified":"2026-03-02 10:00:00","title":"Products"},"seo":{},"slug":"products","status":"publish"},"locale":"zh-CN","revisionId":"aaaa1aaa-2222-4222-8222-222222222222","slug":"products","typeKey":"basic_page"},{"checksum":"cafebabe-0001","contentId":"33333333-3333-4333-8333-333333333333","data":{"bodyHtml":"

正文引用图片 。

","extras":{},"knownFields":{"postDate":"2026-10-01 09:00:00","postModified":"2026-10-01 09:30:00","title":"Fixture News A"},"seo":{"description":"Fixture news A meta description."},"slug":"fixture-news-a","status":"publish"},"locale":"zh-CN","media":{"attachmentRefs.featured":"/wp-content/uploads/2026/10/fixture-a.png"},"revisionId":"aaaa2aaa-2222-4222-8222-222222222222","slug":"fixture-news-a","typeKey":"news_article"},{"checksum":"cafebabe-0002","contentId":"34343434-3434-4343-8343-343434343434","data":{"bodyHtml":"

Fixture News B body paragraph.

","extras":{},"knownFields":{"postDate":"2026-09-20 12:00:00","postModified":"2026-09-20 12:00:00","title":"Fixture News B"},"seo":{},"slug":"fixture-news-b","status":"publish"},"locale":"zh-CN","revisionId":"aaaa2aaa-3434-4343-8343-343434343434","slug":"fixture-news-b","typeKey":"news_article"},{"checksum":"cafebabe-0003","contentId":"35353535-3535-4535-8535-353535353535","data":{"bodyHtml":"

Fixture News C body paragraph.

","extras":{},"knownFields":{"postDate":"2026-08-15 08:00:00","postModified":"2026-08-15 08:00:00","title":"Fixture News C"},"seo":{},"slug":"fixture-news-c","status":"publish"},"locale":"zh-CN","revisionId":"aaaa2aaa-3535-4535-8535-353535353535","slug":"fixture-news-c","typeKey":"news_article"},{"checksum":"cafebabe-0004","contentId":"36363636-3636-4636-8636-363636363636","data":{"bodyHtml":"

Fixture News D body paragraph.

","extras":{},"knownFields":{"postDate":"2026-07-01 07:00:00","postModified":"2026-07-01 07:00:00","title":"Fixture News D"},"seo":{},"slug":"fixture-news-d","status":"publish"},"locale":"zh-CN","revisionId":"aaaa2aaa-3636-4636-8636-363636363636","slug":"fixture-news-d","typeKey":"news_article"},{"checksum":"cafebabe-0005","contentId":"44444444-4444-4444-8444-444444444444","data":{"bodyHtml":"

产品说明。

","extras":{"phpRaw":"a:1:{s:3:\"foo\";s:3:\"bar\";}"},"knownFields":{"postDate":"2026-09-10 10:00:00","postModified":"2026-09-11 10:00:00","title":"Fixture Product"},"seo":{},"slug":"fixture-product","specifications":{"压力等级":"PN16","口径":"DN400","材质":"不锈钢"},"status":"publish"},"locale":"zh-CN","media":{"attachmentRefs.featured":"/wp-content/uploads/2026/10/fixture-a.png","attachmentRefs.gallery[0]":"/wp-content/uploads/2026/10/fixture-b.jpg","attachmentRefs.gallery[1]":"/wp-content/uploads/2026/10/fixture-c.jpg"},"revisionId":"aaaa3aaa-3333-4333-8333-333333333333","slug":"fixture-product","typeKey":"product"},{"checksum":"cafebabe-0006","contentId":"45454545-4545-4545-8545-454545454545","data":{"bodyHtml":"

Fixture Product B body.

","extras":{},"knownFields":{"postDate":"2026-08-20 10:00:00","postModified":"2026-08-20 10:00:00","title":"Fixture Product B"},"seo":{},"slug":"fixture-product-b","specifications":{"材质":"PE"},"status":"publish"},"locale":"zh-CN","revisionId":"aaaa3aaa-4545-4545-8545-454545454545","slug":"fixture-product-b","typeKey":"product"},{"checksum":"cafebabe-0007","contentId":"46464646-4646-4646-8646-464646464646","data":{"bodyHtml":"

Fixture Product C body.

","extras":{},"knownFields":{"postDate":"2026-06-30 10:00:00","postModified":"2026-06-30 10:00:00","title":"Fixture Product C"},"seo":{},"slug":"fixture-product-c","specifications":{"材质":"PVC"},"status":"publish"},"locale":"zh-CN","revisionId":"aaaa3aaa-4646-4646-8646-464646464646","slug":"fixture-product-c","typeKey":"product"},{"checksum":"cafebabe-0008","contentId":"47474747-4747-4747-8747-474747474747","data":{"bodyHtml":"

Fixture Product Old body.

","extras":{},"knownFields":{"postDate":"2025-12-01 10:00:00","postModified":"2025-12-01 10:00:00","title":"Fixture Product Old"},"seo":{},"slug":"fixture-product-old","specifications":{"材质":"PP"},"status":"publish"},"locale":"zh-CN","revisionId":"aaaa3aaa-4747-4747-8747-474747474747","slug":"fixture-product-old","typeKey":"product"}] diff --git a/themes/pilot-minimal/test/fixtures/render-input/render-manifest.json b/themes/pilot-minimal/test/fixtures/render-input/render-manifest.json index f5f667c..851c2ff 100644 --- a/themes/pilot-minimal/test/fixtures/render-input/render-manifest.json +++ b/themes/pilot-minimal/test/fixtures/render-input/render-manifest.json @@ -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"}} diff --git a/themes/pilot-minimal/test/fixtures/render-input/routes.json b/themes/pilot-minimal/test/fixtures/render-input/routes.json index 46405aa..0cab83e 100644 --- a/themes/pilot-minimal/test/fixtures/render-input/routes.json +++ b/themes/pilot-minimal/test/fixtures/render-input/routes.json @@ -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"}] diff --git a/themes/pilot-minimal/test/fixtures/render-input/taxonomies.json b/themes/pilot-minimal/test/fixtures/render-input/taxonomies.json index a59b8cc..5457e90 100644 --- a/themes/pilot-minimal/test/fixtures/render-input/taxonomies.json +++ b/themes/pilot-minimal/test/fixtures/render-input/taxonomies.json @@ -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"}]}] diff --git a/themes/pilot-minimal/test/load-data.test.ts b/themes/pilot-minimal/test/load-data.test.ts index 528e133..d9ad222 100644 --- a/themes/pilot-minimal/test/load-data.test.ts +++ b/themes/pilot-minimal/test/load-data.test.ts @@ -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) }) diff --git a/themes/pilot-minimal/test/render-cases.test.ts b/themes/pilot-minimal/test/render-cases.test.ts new file mode 100644 index 0000000..2cccb20 --- /dev/null +++ b/themes/pilot-minimal/test/render-cases.test.ts @@ -0,0 +1,172 @@ +// Task 4 构建级用例:六页夹具(真实 hesupipe 抽样形状脱敏化)→ astro build 真跑 → dist/ 断言。 +// 与 Container API 组件测试(components.test.ts)分层:此处验证的是路由分派 + 模板组装 + +// 产物映射(尾斜杠→/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 纯路径行 → /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('关于我们') + 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(/]/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(/]*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}`) + expect(html, routePath).toMatch(/`) + } + // 内容页 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.') + }) +})