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

+34
View File
@@ -0,0 +1,34 @@
---
// 块统一渲染器:switch(type) → 子组件;外壳带 data-block 属性(E2E 断言抓手)
import type { ResolvedBlock } from '../../lib/page-blocks';
import Prose from './Prose.astro';
import Tracks from './Tracks.astro';
import Cards from './Cards.astro';
import Timeline from './Timeline.astro';
import Quote from './Quote.astro';
import Figure from './Figure.astro';
import LinkList from './LinkList.astro';
import Cta from './Cta.astro';
import Divider from './Divider.astro';
interface Props {
block: ResolvedBlock;
}
const { block } = Astro.props;
---
<section class="blk" data-block={block.type}>
{block.type === 'prose' && <Prose block={block} />}
{block.type === 'tracks' && <Tracks block={block} />}
{block.type === 'cards' && <Cards block={block} />}
{block.type === 'timeline' && <Timeline block={block} />}
{block.type === 'quote' && <Quote block={block} />}
{block.type === 'figure' && <Figure block={block} />}
{block.type === 'linklist' && <LinkList block={block} />}
{block.type === 'cta' && <Cta block={block} />}
{block.type === 'divider' && <Divider block={block} />}
</section>
<style>
.blk { margin-block: 0; }
</style>
+75
View File
@@ -0,0 +1,75 @@
---
// cards 块:技能/资源卡(2 或 3 列;复用 .track-row 的边框与内距语言)
import type { ResolvedBlock } from '../../lib/page-blocks';
interface Props {
block: Extract<ResolvedBlock, { type: 'cards' }>;
}
const { block } = Astro.props;
---
{block.title && <h2 class="sec-title">{block.title}</h2>}
<ul class:list={['cards', `cols-${block.columns}`]}>
{block.items.map((item) => {
const body = (
<>
{item.tag && <span class="card-tag mono">{item.tag}</span>}
<strong class="card-title">{item.title}</strong>
{item.desc && <p class="card-desc">{item.desc}</p>}
</>
);
return (
<li class="card">
{item.href ? (
<a class="card-link" href={item.href}>{body}</a>
) : (
<div class="card-link">{body}</div>
)}
</li>
);
})}
</ul>
<style>
.sec-title {
font-size: 14px;
font-family: var(--font-mono);
color: var(--ink-3);
letter-spacing: 0.08em;
margin: var(--space-5) 0 var(--space-3);
}
.cards {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: var(--space-2);
}
.cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
@media (max-width: 640px) {
.cols-2, .cols-3 { grid-template-columns: minmax(0, 1fr); }
}
.card {
border: 1px solid var(--line);
border-radius: 10px;
padding: var(--space-3) var(--space-4);
display: flex;
}
.card-link {
display: flex;
flex-direction: column;
gap: 2px;
color: inherit;
text-decoration: none;
width: 100%;
}
a.card-link:hover .card-title { color: var(--accent); }
.card-tag {
font-size: 11.5px;
color: var(--ink-4);
letter-spacing: 0.04em;
}
.card-title { font-size: 15px; color: var(--ink-1); transition: color var(--dur) var(--ease); }
.card-desc { font-size: 14px; color: var(--ink-3); margin-top: 2px; }
</style>
+48
View File
@@ -0,0 +1,48 @@
---
// cta 块:单按钮行动点(克制:同屏最多 1 个,由编辑纪律保证)
import type { ResolvedBlock } from '../../lib/page-blocks';
interface Props {
block: Extract<ResolvedBlock, { type: 'cta' }>;
}
const { block } = Astro.props;
const isExternal = block.href.startsWith('http');
---
<div class="cta">
{block.text && <p class="cta-text">{block.text}</p>}
<a
class="cta-btn tap mono"
href={block.href}
rel={isExternal ? 'noopener noreferrer' : undefined}
target={isExternal ? '_blank' : undefined}
>
{block.label}
</a>
</div>
<style>
.cta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
margin: var(--space-4) 0;
padding: var(--space-3) var(--space-4);
border: 1px solid var(--line);
border-radius: 10px;
max-width: var(--measure);
}
.cta-text { font-size: 14.5px; color: var(--ink-2); }
.cta-btn {
margin-left: auto;
font-size: 13px;
color: var(--ink-1);
border: 1px solid var(--line);
border-radius: 999px;
padding: 0.32rem 0.9rem;
text-decoration: none;
white-space: nowrap;
}
.cta-btn:hover { border-color: var(--accent); color: var(--accent); }
</style>
+20
View File
@@ -0,0 +1,20 @@
---
// divider 块:节奏控制(line = hairline 分隔;space = 纯留白)
import type { ResolvedBlock } from '../../lib/page-blocks';
interface Props {
block: Extract<ResolvedBlock, { type: 'divider' }>;
}
const { block } = Astro.props;
---
{block.style === 'space' ? <div class="gap" aria-hidden="true"></div> : <hr />}
<style>
hr {
border: 0;
border-top: 1px solid var(--line);
margin: var(--space-5) 0;
}
.gap { height: var(--space-6); }
</style>
+38
View File
@@ -0,0 +1,38 @@
---
// figure 块:图片(Astro 图片管线,宽度自适应;caption 图注)
import { Image } from 'astro:assets';
import type { ResolvedBlock } from '../../lib/page-blocks';
interface Props {
block: Extract<ResolvedBlock, { type: 'figure' }>;
}
const { block } = Astro.props;
---
<figure class="fig">
<Image
src={block.image}
alt={block.alt}
widths={[480, 960]}
sizes="(min-width: 820px) 640px, 92vw"
loading="lazy"
class="fig-img"
/>
{block.caption && <figcaption>{block.caption}</figcaption>}
</figure>
<style>
.fig { margin: var(--space-4) 0; }
.fig-img {
width: 100%;
height: auto;
border-radius: 10px;
border: 1px solid var(--line);
}
figcaption {
margin-top: var(--space-1);
font-size: 12.5px;
color: var(--ink-4);
text-align: center;
}
</style>
+58
View File
@@ -0,0 +1,58 @@
---
// linklist 块:外链清单(比裸 markdown 链接可控:带 note 与统一行距)
import type { ResolvedBlock } from '../../lib/page-blocks';
interface Props {
block: Extract<ResolvedBlock, { type: 'linklist' }>;
}
const { block } = Astro.props;
const isExternal = (href: string) => href.startsWith('http');
---
{block.title && <h2 class="sec-title">{block.title}</h2>}
<ul class="links">
{block.items.map((item) => (
<li>
<a
href={item.href}
rel={isExternal(item.href) ? 'noopener noreferrer' : undefined}
target={isExternal(item.href) ? '_blank' : undefined}
>
<span class="link-label">{item.label}</span>
{item.note && <span class="link-note">{item.note}</span>}
{isExternal(item.href) && <span class="link-mark" aria-hidden="true">↗</span>}
</a>
</li>
))}
</ul>
<style>
.sec-title {
font-size: 14px;
font-family: var(--font-mono);
color: var(--ink-3);
letter-spacing: 0.08em;
margin: var(--space-5) 0 var(--space-3);
}
.links {
list-style: none;
margin: 0;
padding: 0;
max-width: var(--measure);
}
.links li { border-bottom: 1px solid var(--line); }
.links li:last-child { border-bottom: none; }
.links a {
display: flex;
align-items: baseline;
gap: var(--space-2);
padding: var(--space-2) 0;
color: var(--ink-2);
text-decoration: none;
}
.links a:hover .link-label { color: var(--accent); }
.link-label { font-size: 15px; transition: color var(--dur) var(--ease); }
.link-note { font-size: 13px; color: var(--ink-4); flex: 1; }
.link-mark { font-size: 12px; color: var(--ink-4); }
</style>
+46
View File
@@ -0,0 +1,46 @@
---
// prose 块:自由正文(markdown 已由读取层渲染为 HTML,净化在 site-content 的插件链)
import type { ResolvedBlock } from '../../lib/page-blocks';
interface Props {
block: Extract<ResolvedBlock, { type: 'prose' }>;
}
const { block } = Astro.props;
---
{block.title && <h2 class="sec-title">{block.title}</h2>}
<div class:list={['prose-body', { full: block.width === 'full' }]} set:html={block.bodyHtml} />
<style>
.sec-title {
font-size: 14px;
font-family: var(--font-mono);
color: var(--ink-3);
letter-spacing: 0.08em;
margin: var(--space-5) 0 var(--space-3);
}
.prose-body {
font-size: 15.5px;
line-height: 1.75;
color: var(--ink-2);
max-width: var(--measure);
margin-top: var(--space-3);
}
.prose-body.full { max-width: none; }
.prose-body :global(p) { margin-block: 0 var(--space-2); }
.prose-body :global(ul) {
margin: var(--space-1) 0 var(--space-2);
padding-left: 1.2em;
list-style: disc;
}
.prose-body :global(li) { margin-block: 0.2em; }
.prose-body :global(a) { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
.prose-body :global(strong) { color: var(--ink-1); font-weight: 600; }
.prose-body :global(code) {
font-family: var(--font-mono);
font-size: 0.88em;
background: var(--bg-soft);
border-radius: 5px;
padding: 1px 6px;
}
</style>
+36
View File
@@ -0,0 +1,36 @@
---
// quote 块:引文(复用 .quote-line 语言 + 楷体)
import type { ResolvedBlock } from '../../lib/page-blocks';
interface Props {
block: Extract<ResolvedBlock, { type: 'quote' }>;
}
const { block } = Astro.props;
---
<blockquote class="quote-line">
<p>{block.text}</p>
{block.cite && <cite>— {block.cite}</cite>}
</blockquote>
<style>
.quote-line {
margin: var(--space-4) 0;
padding-left: var(--space-4);
border-left: 2px solid var(--line);
max-width: var(--measure);
}
.quote-line p {
font-family: var(--font-kai, var(--font-serif));
font-size: 16.5px;
line-height: 1.8;
color: var(--ink-2);
}
.quote-line cite {
display: block;
margin-top: var(--space-1);
font-style: normal;
font-size: 13px;
color: var(--ink-4);
}
</style>
+66
View File
@@ -0,0 +1,66 @@
---
// timeline 块:经历/项目时间线(竖线语言,仅用既有 token)
import type { ResolvedBlock } from '../../lib/page-blocks';
interface Props {
block: Extract<ResolvedBlock, { type: 'timeline' }>;
}
const { block } = Astro.props;
---
{block.title && <h2 class="sec-title">{block.title}</h2>}
<ol class="tl">
{block.items.map((item) => (
<li class="tl-item">
<span class="tl-time mono">{item.time}</span>
<div class="tl-body">
<strong>{item.title}</strong>
{item.desc && <p>{item.desc}</p>}
</div>
</li>
))}
</ol>
<style>
.sec-title {
font-size: 14px;
font-family: var(--font-mono);
color: var(--ink-3);
letter-spacing: 0.08em;
margin: var(--space-5) 0 var(--space-3);
}
.tl {
list-style: none;
margin: 0;
padding: 0 0 0 var(--space-3);
border-left: 1px solid var(--line);
display: grid;
gap: var(--space-3);
}
.tl-item {
position: relative;
display: flex;
gap: var(--space-3);
align-items: baseline;
}
.tl-item::before {
content: '';
position: absolute;
left: calc(-1 * var(--space-3) - 4px);
top: 0.55em;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--line);
border: 1px solid var(--bg-1, transparent);
}
.tl-time {
flex: none;
font-size: 12.5px;
color: var(--ink-4);
font-variant-numeric: tabular-nums;
min-width: 5.5em;
}
.tl-body strong { font-size: 15px; color: var(--ink-1); }
.tl-body p { font-size: 14px; color: var(--ink-3); margin-top: 2px; }
</style>
+70
View File
@@ -0,0 +1,70 @@
---
// tracks 块:兴趣/能力版图(复用原 AboutPage 的 .track-row 视觉语言)
import type { ResolvedBlock } from '../../lib/page-blocks';
import { iconPath } from '../../lib/icons';
interface Props {
block: Extract<ResolvedBlock, { type: 'tracks' }>;
}
const { block } = Astro.props;
---
<h2 class="sec-title">{block.title}</h2>
<ul>
{block.items.map((item) => (
<li class="track-row">
<span class="track-icon" aria-hidden="true">
{iconPath(item.icon) ? (
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d={iconPath(item.icon)}></path>
</svg>
) : (
<span class="track-icon-fallback">{item.name.slice(0, 1)}</span>
)}
</span>
<div>
<strong>{item.name}</strong>
{item.desc && <p>{item.desc}</p>}
</div>
</li>
))}
</ul>
<style>
.sec-title {
font-size: 14px;
font-family: var(--font-mono);
color: var(--ink-3);
letter-spacing: 0.08em;
margin: var(--space-5) 0 var(--space-3);
}
ul {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: var(--space-2);
}
.track-row {
display: flex;
align-items: flex-start;
gap: var(--space-3);
border: 1px solid var(--line);
border-radius: 10px;
padding: var(--space-3) var(--space-4);
}
.track-icon {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
background: var(--bg-soft);
color: var(--ink-3);
}
.track-icon-fallback { font-family: var(--font-mono); font-size: 13px; }
.track-row strong { font-size: 15px; color: var(--ink-1); }
.track-row p { font-size: 14px; color: var(--ink-3); margin-top: 2px; }
</style>
+66 -50
View File
@@ -3,70 +3,86 @@ import BaseLayout from '../../layouts/BaseLayout.astro';
import IdCard from '../../components/IdCard.astro';
import { t } from '../../i18n/ui';
import type { Locale } from '../../lib/locale';
import { aboutIntroMarkdown, renderMarkdown } from '../../lib/site-content';
import { aboutIntroMarkdown, pageTypography, renderMarkdown } from '../../lib/site-content';
import { getAboutPage } from '../../lib/page-blocks';
import BlockRenderer from '../blocks/BlockRenderer.astro';
// /en/ 侧由 T4 薄页传 locale='en';zh 路由无人传参,默认 zh 零回归
interface Props {
locale?: Locale;
}
const { locale = 'zh' } = Astro.props;
// 身份陈述段改由 content/site/about.yaml 供给(多段 markdown,后台可改),缺值回落默认
const introHtml = await renderMarkdown(await aboutIntroMarkdown(locale));
// 三级回落(零回归):blocks 驱动 → intro markdown → 内置默认文案
const { blocks, showIdCard } = await getAboutPage(locale);
const introHtml =
blocks.length === 0 ? await renderMarkdown(await aboutIntroMarkdown(locale)) : '';
const typo = await pageTypography('siteAbout', 'about');
---
<BaseLayout title={`${t('about_title', locale)} · wait4x`}>
<h1 class="page-title">{t('about_title', locale)}</h1>
<div class="intro" set:html={introHtml} />
<div class={typo.className || undefined} style={typo.style || undefined}>
{blocks.length > 0 ? (
<>
{blocks.map((block) => <BlockRenderer block={block} />)}
{showIdCard && <IdCard />}
</>
) : (
<>
<div class="intro rich" set:html={introHtml} />
<IdCard />
<IdCard />
<section class="tracks" aria-label={t('about_tracks_title', locale)}>
<h2 class="sec-title">{t('about_tracks_title', locale)}</h2>
<ul>
<li class="track-row">
<span class="track-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 4h6a4 4 0 0 1 4 4v13a3 3 0 0 0-3-3H2z"></path>
<path d="M22 4h-6a4 4 0 0 0-4 4v13a3 3 0 0 1 3-3h7z"></path>
</svg>
</span>
<div>
<strong>{t('about_track_reading_name', locale)}</strong>
<p>{t('about_track_reading_desc', locale)}</p>
</div>
</li>
<li class="track-row">
<span class="track-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="m4 5.5 6.5 6.5L4 18.5"></path>
<path d="M12.5 18.5H20"></path>
</svg>
</span>
<div>
<strong>{t('about_track_tech_name', locale)}</strong>
<p>{t('about_track_tech_desc', locale)}</p>
</div>
</li>
<li class="track-row">
<span class="track-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="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 2z"></path>
<circle cx="12" cy="13" r="4"></circle>
</svg>
</span>
<div>
<strong>{t('about_track_photo_name', locale)}</strong>
<p>{t('about_track_photo_desc', locale)}</p>
</div>
</li>
</ul>
</section>
<section class="tracks" aria-label={t('about_tracks_title', locale)}>
<h2 class="sec-title">{t('about_tracks_title', locale)}</h2>
<ul>
<li class="track-row">
<span class="track-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 4h6a4 4 0 0 1 4 4v13a3 3 0 0 0-3-3H2z"></path>
<path d="M22 4h-6a4 4 0 0 0-4 4v13a3 3 0 0 1 3-3h7z"></path>
</svg>
</span>
<div>
<strong>{t('about_track_reading_name', locale)}</strong>
<p>{t('about_track_reading_desc', locale)}</p>
</div>
</li>
<li class="track-row">
<span class="track-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="m4 5.5 6.5 6.5L4 18.5"></path>
<path d="M12.5 18.5H20"></path>
</svg>
</span>
<div>
<strong>{t('about_track_tech_name', locale)}</strong>
<p>{t('about_track_tech_desc', locale)}</p>
</div>
</li>
<li class="track-row">
<span class="track-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="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 2z"></path>
<circle cx="12" cy="13" r="4"></circle>
</svg>
</span>
<div>
<strong>{t('about_track_photo_name', locale)}</strong>
<p>{t('about_track_photo_desc', locale)}</p>
</div>
</li>
</ul>
</section>
<section class="build" aria-label={t('about_build_aria', locale)}>
<h2 class="sec-title">{t('about_build_title', locale)}</h2>
<p>{t('about_build_desc', locale)}</p>
</section>
<section class="build" aria-label={t('about_build_aria', locale)}>
<h2 class="sec-title">{t('about_build_title', locale)}</h2>
<p>{t('about_build_desc', locale)}</p>
</section>
</>
)}
</div>
</BaseLayout>
<style>
+122 -2
View File
@@ -18,6 +18,92 @@ export type PostTrack = (typeof POST_TRACKS)[PostCategory];
/** 双语文案字段:两语言各自可选,缺一回落一(回退逻辑在 src/lib/site-content.ts) */
const bilingual = z.object({ zh: z.string().optional(), en: z.string().optional() });
/** 块型清单(5g 页面构建器 v1 九类):访问器据此判未知 type */
export const BLOCK_TYPES = [
'prose',
'tracks',
'cards',
'timeline',
'quote',
'figure',
'linklist',
'cta',
'divider',
] as const;
type ImageHelper = (opts?: unknown) => z.ZodTypeAny;
/** 关于页块型 union:图片字段强制 image()(外链字符串在 schema 层即失败,永不渲染) */
const aboutBlockUnion = (image: ImageHelper) =>
z.discriminatedUnion('type', [
z.object({
type: z.literal('prose'),
enabled: z.boolean().optional(),
title: bilingual.optional(),
body: bilingual,
width: z.enum(['narrow', 'full']).optional(),
}),
z.object({
type: z.literal('tracks'),
enabled: z.boolean().optional(),
title: bilingual,
items: z.array(
z.object({ icon: z.string().optional(), name: bilingual, desc: bilingual.optional() }),
),
}),
z.object({
type: z.literal('cards'),
enabled: z.boolean().optional(),
title: bilingual.optional(),
columns: z.union([z.literal(2), z.literal(3)]).optional(),
items: z.array(
z.object({
title: bilingual,
desc: bilingual.optional(),
href: z.string().optional(),
tag: bilingual.optional(),
}),
),
}),
z.object({
type: z.literal('timeline'),
enabled: z.boolean().optional(),
title: bilingual.optional(),
items: z.array(z.object({ time: z.string(), title: bilingual, desc: bilingual.optional() })),
}),
z.object({
type: z.literal('quote'),
enabled: z.boolean().optional(),
text: bilingual,
cite: bilingual.optional(),
}),
z.object({
type: z.literal('figure'),
enabled: z.boolean().optional(),
image: image(),
alt: z.string().optional(),
caption: bilingual.optional(),
}),
z.object({
type: z.literal('linklist'),
enabled: z.boolean().optional(),
title: bilingual.optional(),
items: z.array(z.object({ label: bilingual, href: z.string(), note: bilingual.optional() })),
}),
z.object({
type: z.literal('cta'),
enabled: z.boolean().optional(),
text: bilingual,
href: z.string(),
label: bilingual,
}),
z.object({
type: z.literal('divider'),
enabled: z.boolean().optional(),
style: z.enum(['line', 'space']).optional(),
}),
]);
export function trackOf(category: PostCategory): PostTrack {
return POST_TRACKS[category];
}
@@ -82,6 +168,14 @@ export const collections = {
z.object({
tagline: bilingual.optional(),
avatar: image().optional(),
/** 页面级排版覆盖(层 2):正文默认字体类 / 强调色类 / 字号缩放;不配置即全站默认 */
typography: z
.object({
font: z.string().optional(),
accent: z.string().optional(),
scale: z.number().optional(),
})
.optional(),
/** 站点图标:public 下的路径(.svg/.ico 直出,不经图片管线);缺省回退 /favicon.svg */
favicon: z.string().optional(),
/** 副标题(markdown 短文本,渲染在身份语下);空 = 不渲染 */
@@ -113,14 +207,40 @@ export const collections = {
}),
siteAbout: defineCollection({
loader: glob({ pattern: 'about.yaml', base: './content/site' }),
// intro = 多段 markdown 正文(页面用 createMarkdownProcessor 渲染),字段级空值回落默认
schema: z.object({ intro: bilingual.optional() }),
// intro = 多段 markdown 正文(二级回退:blocks 为空时才用)
// blocks = 块式页面构建器(5g):文本一律 {zh,en},图片字段必须 image();未知 type 落宽松分支
// → src/lib/page-blocks.ts warn + skip,保证后台填错块型不炸构建
schema: ({ image }) =>
z.object({
intro: bilingual.optional(),
/** 页面级排版覆盖(层 2,T3 车道):正文默认字体类 / 强调色类 / 字号缩放 */
typography: z
.object({
font: z.string().optional(),
accent: z.string().optional(),
scale: z.number().optional(),
})
.optional(),
/** 版式:是否渲染名片区(头像/名字/社交),默认 true */
layout: z.object({ showIdCard: z.boolean().optional() }).optional(),
blocks: z
.array(z.union([aboutBlockUnion(image), z.looseObject({ type: z.string() })]))
.optional(),
}),
}),
siteOpenSource: defineCollection({
loader: glob({ pattern: 'opensource.yaml', base: './content/site' }),
schema: z.object({
blurb: bilingual.optional(),
blurb_after: bilingual.optional(),
/** 页面级排版覆盖(层 2):正文默认字体类 / 强调色类 / 字号缩放;不配置即全站默认 */
typography: z
.object({
font: z.string().optional(),
accent: z.string().optional(),
scale: z.number().optional(),
})
.optional(),
repo_url: z.string().optional(),
repo_label: z.string().optional(),
/** 置顶仓短名,顺序即展示顺序(原 projects.ts 硬编码名单迁于此) */
+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 };
}