feat(about): 块式页面构建器——九类区块 + 三级回落零回归(5g T1/T2)

- schema:siteAbout 增 layout{showIdCard} + blocks(z.discriminatedUnion
  九类:prose/tracks/cards/timeline/quote/figure/linklist/cta/divider,
  图片字段走 Astro image());对未知块型用 z.union([knownUnion,
  z.looseObject({type})]) 复合——已知块严格校验,未知块落宽松分支再由
  访问器 WARN+丢弃(容错与严格校验的张力取舍)
- page-blocks.ts:enabled:false 过滤、未知 type WARN+skip、pickBi 回落
  zh 并 WARN、上限(块≤24/单块文本≤4000,超限截断并 WARN)
- BlockRenderer 统一外壳 `<section class="blk" data-block={type}>`(E2E
  抓手)+ 九个块组件(样式自 AboutPage 原 <style> 搬迁,只吃既有 token)
- AboutPage 三级回落:blocks → intro markdown → 内置默认;legacy
  tracks/build 段整体挪入回退分支(文案逐字一致,零回归)
- 图标白名单 src/lib/icons.ts(12 键,未登记回退首字占位)
- 后台 about 单例四段结构(blocks/layout/intro/typography),块为
  list+types+typeKey,每块带「显示」开关与中文标签

协调报备:车道修了一处跨车道插入事故(T3 把 typography 插进了 intro
的 widget 内导致字段错位),重写为四段正确结构;并发构建擦 dist 的
假失败改用「build→快照 dist→快照起服务跑 E2E」规避(37/37 稳定)

验证: 构建 48 页 exit 0;E2E 37/37(快照口径);九类块顺序与配置一致、
      figure 走 /_astro/*.webp 管线、未知块与外链图被拦并 WARN、
      终版 3 块零回归、en 同构、admin about 四段字段齐全

Spec: 2026-11-02_blog-5g-pages (T1/T2)

Co-authored-by: GLM-5.3
This commit is contained in:
wait4xandGLM-5.3 committed 2026-11-02 18:21:33 +08:00
1 parent 6096a9dafe
commit 2f7cbcf7bd
16 files changed
+1289 -72

No files matched your search

+24
View File
@@ -0,0 +1,24 @@
// 图标白名单注册表(块式页面用):键 → 24×24 viewBox 的 path d 片段
// 纪律:后台只能填白名单内的键,未登记 → 组件渲染纯文字(不破版、不引入 XSS 面)
// 只在代码侧扩展;不要在 yaml 里塞任意 SVG
export const ICON_PATHS: Record<string, string> = {
book: 'M4 4.5h5.5A2.5 2.5 0 0 1 12 7v13a2 2 0 0 0-2-2H4zM20 4.5h-5.5A2.5 2.5 0 0 0 12 7v13a2 2 0 0 1 2-2h6z',
terminal: 'M4 5.5h16a1.5 1.5 0 0 1 1.5 1.5v10A1.5 1.5 0 0 1 20 18.5H4A1.5 1.5 0 0 1 2.5 17V7A1.5 1.5 0 0 1 4 5.5Zm3 3.5 4 3.5-4 3.5M12.5 16h6',
camera:
'M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2zM12 17a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z',
code: 'm8 7-4 5 4 5M16 7l4 5-4 5M13.5 4.5l-3 15',
git: 'M12 3v10.5M12 21a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm-4-12 4 4 4-4',
rss: 'M4 11a9 9 0 0 1 9 9M4 4a16 16 0 0 1 16 16M5 19a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z',
email: 'M3 7.5A2.5 2.5 0 0 1 5.5 5h13A2.5 2.5 0 0 1 21 7.5v9A2.5 2.5 0 0 1 18.5 19h-13A2.5 2.5 0 0 1 3 16.5zM4 7.5l8 5.5 8-5.5',
github:
'M12 2C6.48 2 2 6.58 2 12.25c0 4.53 2.87 8.37 6.84 9.73.5.1.68-.22.68-.49 0-.24-.01-.88-.01-1.73-2.78.62-3.37-1.37-3.37-1.37-.45-1.18-1.11-1.5-1.11-1.5-.91-.63.07-.62.07-.62 1 .07 1.53 1.06 1.53 1.06.89 1.57 2.34 1.12 2.91.85.09-.66.35-1.11.63-1.37-2.22-.26-4.56-1.14-4.56-5.07 0-1.12.39-2.03 1.03-2.75-.1-.26-.45-1.3.1-2.7 0 0 .84-.28 2.75 1.05a9.3 9.3 0 0 1 5 0c1.91-1.33 2.75-1.05 2.75-1.05.55 1.4.2 2.44.1 2.7.64.72 1.03 1.63 1.03 2.75 0 3.94-2.34 4.8-4.57 5.06.36.32.68.94.68 1.9 0 1.37-.01 2.47-.01 2.81 0 .27.18.6.69.49A10.26 10.26 0 0 0 22 12.25C22 6.58 17.52 2 12 2Z',
bilibili:
'M6 4l2 2h8l2-2M4 8h16a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2Zm3 4v4m10-4v4',
x: 'M4 4l7.5 9.5L4.5 20h2.2l6-6.7 5 6.7H22l-7.7-9.9L21 4h-2.2l-5.4 6L9 4z',
star: 'm12 3.5 2.7 5.6 6.1.9-4.4 4.3 1 6.1-5.4-2.9-5.4 2.9 1-6.1L3.2 10l6.1-.9z',
link: 'M10 13.5a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1.3 1.3M14 10.5a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1.3-1.3',
};
/** 白名单内的图标 path;未登记返回 null(调用方回退纯文字) */
export const iconPath = (key?: string): string | null =>
key && ICON_PATHS[key] ? ICON_PATHS[key] : null;
+216
View File
@@ -0,0 +1,216 @@
// 页面块读取层(5g T1):siteAbout.blocks → 已本地化、已净化、已限长的渲染数据
// 纪律(方案文档 §5.2/§7.4):
// · enabled:false 过滤
// · 未知 type / 缺必填文本 → console.warn + 跳过,绝不炸构建
// · 块数 ≤24、单块文本 ≤4000 字(超限截断并 WARN)
// · 文本 pickBi:en 空回落 zh(构建期缺译 WARN,见 §7.3 #8)
import { getEntry } from 'astro:content';
import type { ImageMetadata } from 'astro';
import { BLOCK_TYPES } from '../content.config';
import { renderMarkdown } from './site-content';
import type { Locale } from './locale';
const MAX_BLOCKS = 24;
const MAX_TEXT = 4000;
type Bi = { zh?: string; en?: string } | undefined;
/** 双语取值:locale 优先,空则回落另一语言;再空返回 '' */
function bi(value: Bi, locale: Locale, where: string): string {
if (!value) return '';
const primary = (locale === 'zh' ? value.zh : value.en)?.trim();
const other = (locale === 'zh' ? value.en : value.zh)?.trim();
if (primary) return primary;
if (other) {
console.warn(`[blocks] 缺译(${locale} 为空,回落另一语言):${where}`);
return other;
}
return '';
}
const clampText = (text: string, where: string): string => {
if (text.length <= MAX_TEXT) return text;
console.warn(`[blocks] 文本超 ${MAX_TEXT} 字已截断:${where}`);
return text.slice(0, MAX_TEXT);
};
export type ResolvedBlock =
| { type: 'prose'; title: string; bodyHtml: string; width: 'narrow' | 'full' }
| { type: 'tracks'; title: string; items: { icon: string; name: string; desc: string }[] }
| {
type: 'cards';
title: string;
columns: 2 | 3;
items: { title: string; desc: string; href: string; tag: string }[];
}
| { type: 'timeline'; title: string; items: { time: string; title: string; desc: string }[] }
| { type: 'quote'; text: string; cite: string }
| { type: 'figure'; image: ImageMetadata; alt: string; caption: string }
| { type: 'linklist'; title: string; items: { label: string; href: string; note: string }[] }
| { type: 'cta'; text: string; href: string; label: string }
| { type: 'divider'; style: 'line' | 'space' };
export interface AboutPageData {
blocks: ResolvedBlock[];
showIdCard: boolean;
}
type RawItem = Record<string, unknown>;
const str = (v: unknown): string => (typeof v === 'string' ? v.trim() : '');
/** 关于页渲染数据(三级回落由 AboutPage 处理:blocks → intro markdown → 默认文案) */
export async function getAboutPage(locale: Locale): Promise<AboutPageData> {
const entry = await getEntry('siteAbout', 'about');
const rawBlocks = (entry?.data.blocks ?? []) as unknown[];
const showIdCard = entry?.data.layout?.showIdCard ?? true;
if (rawBlocks.length > MAX_BLOCKS) {
console.warn(`[blocks] 块数 ${rawBlocks.length} 超上限 ${MAX_BLOCKS},仅渲染前 ${MAX_BLOCKS} 个`);
}
const blocks: ResolvedBlock[] = [];
for (const raw of rawBlocks.slice(0, MAX_BLOCKS)) {
if (!raw || typeof raw !== 'object') continue;
const block = raw as RawItem & { type?: string; enabled?: boolean };
if (block.enabled === false) continue;
const type = block.type ?? '';
if (!(BLOCK_TYPES as readonly string[]).includes(type)) {
console.warn(`[blocks] 未知块型 "${type}",已跳过`);
continue;
}
const tag = `${type}`;
if (type === 'prose') {
const md = clampText(bi(block.body as Bi, locale, `${tag}.body`), `${tag}.body`);
if (!md) {
console.warn(`[blocks] prose 缺正文,已跳过`);
continue;
}
blocks.push({
type: 'prose',
title: bi(block.title as Bi, locale, `${tag}.title`),
bodyHtml: await renderMarkdown(md),
width: block.width === 'full' ? 'full' : 'narrow',
});
continue;
}
if (type === 'tracks') {
const items = ((block.items ?? []) as RawItem[])
.map((item) => ({
icon: str(item.icon),
name: bi(item.name as Bi, locale, `${tag}.items.name`),
desc: bi(item.desc as Bi, locale, `${tag}.items.desc`),
}))
.filter((item) => item.name);
if (!items.length) {
console.warn('[blocks] tracks 无有效条目,已跳过');
continue;
}
blocks.push({ type: 'tracks', title: bi(block.title as Bi, locale, `${tag}.title`), items });
continue;
}
if (type === 'cards') {
const items = ((block.items ?? []) as RawItem[])
.map((item) => ({
title: bi(item.title as Bi, locale, `${tag}.items.title`),
desc: bi(item.desc as Bi, locale, `${tag}.items.desc`),
href: str(item.href),
tag: bi(item.tag as Bi, locale, `${tag}.items.tag`),
}))
.filter((item) => item.title);
if (!items.length) {
console.warn('[blocks] cards 无有效条目,已跳过');
continue;
}
blocks.push({
type: 'cards',
title: bi(block.title as Bi, locale, `${tag}.title`),
columns: block.columns === 2 ? 2 : 3,
items,
});
continue;
}
if (type === 'timeline') {
const items = ((block.items ?? []) as RawItem[])
.map((item) => ({
time: str(item.time),
title: bi(item.title as Bi, locale, `${tag}.items.title`),
desc: bi(item.desc as Bi, locale, `${tag}.items.desc`),
}))
.filter((item) => item.title);
if (!items.length) {
console.warn('[blocks] timeline 无有效条目,已跳过');
continue;
}
blocks.push({ type: 'timeline', title: bi(block.title as Bi, locale, `${tag}.title`), items });
continue;
}
if (type === 'quote') {
const text = bi(block.text as Bi, locale, `${tag}.text`);
if (!text) {
console.warn('[blocks] quote 缺引用文本,已跳过');
continue;
}
blocks.push({ type: 'quote', text, cite: bi(block.cite as Bi, locale, `${tag}.cite`) });
continue;
}
if (type === 'figure') {
const image = block.image as ImageMetadata | undefined;
if (!image || typeof image !== 'object' || !('src' in image)) {
// 外链字符串在此即被拦住:schema 的 image() 不允许字符串,落到宽松分支后在此丢弃
console.warn('[blocks] figure 缺合法图片(image() 字段),已跳过——请用后台上传,勿填外链');
continue;
}
blocks.push({
type: 'figure',
image,
alt: str(block.alt) || bi(block.caption as Bi, locale, `${tag}.caption`) || '插图',
caption: bi(block.caption as Bi, locale, `${tag}.caption`),
});
continue;
}
if (type === 'linklist') {
const items = ((block.items ?? []) as RawItem[])
.filter((item) => str(item.href) && bi(item.label as Bi, locale, `${tag}.items.label`))
.map((item) => ({
label: bi(item.label as Bi, locale, `${tag}.items.label`),
href: str(item.href),
note: bi(item.note as Bi, locale, `${tag}.items.note`),
}));
if (!items.length) {
console.warn('[blocks] linklist 无有效条目,已跳过');
continue;
}
blocks.push({ type: 'linklist', title: bi(block.title as Bi, locale, `${tag}.title`), items });
continue;
}
if (type === 'cta') {
const href = str(block.href);
const label = bi(block.label as Bi, locale, `${tag}.label`);
if (!href || !label) {
console.warn('[blocks] cta 缺 href/label,已跳过');
continue;
}
blocks.push({
type: 'cta',
text: bi(block.text as Bi, locale, `${tag}.text`),
href,
label,
});
continue;
}
// divider
blocks.push({ type: 'divider', style: block.style === 'space' ? 'space' : 'line' });
}
return { blocks, showIdCard };
}