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,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>
|
||||
Reference in New Issue
Block a user