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:
1 parent
6096a9dafe
commit
2f7cbcf7bd
16 files changed
+1289
-72
No files matched your search
@@ -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;
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user