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
+1232 -15

No files matched your search

+65 -6
View File
@@ -1,9 +1,17 @@
# 关于页正文(后台「页面文案 → 关于页」可改,markdown 编辑) # 关于页(后台「页面文案 → 关于页」可改)
# intro = 多段 markdown 正文,渲染在「关于」标题下方、名片之前;留空回落默认文案 # ── 一级:blocks 块式正文(按顺序渲染,可拖拽排序;某块 enabled: false 即隐藏)
# 支持的语法:段落、列表、粗斜体、行内代码、链接(渲染走 Astro 的 markdown 管线) # ── 二级:intro 多段 markdown(仅在 blocks 为空时使用)
intro: # ── 三级:内置默认文案(两者都空时兜底,构建永不炸)
layout:
# 名片区(头像/名字/身份语/社交)开关:头像等数据事实源在「主页设置」
showIdCard: true
blocks:
- type: prose
width: narrow
body:
zh: | zh: |
写作者 · 读者 · 快门按压者,拥抱开源。 <span class="font-huiwen c-red">写作者</span> · 读者 · 快门按压者,拥抱开源。
这里是我三条轨道的公开工作台:**读过的书**留下评注,**写过的代码**进项目墙,**按下的快门**收入图库。 这里是我三条轨道的公开工作台:**读过的书**留下评注,**写过的代码**进项目墙,**按下的快门**收入图库。
@@ -15,7 +23,7 @@ intro:
本站本身即开源,构建与发布全链都在仓里。 本站本身即开源,构建与发布全链都在仓里。
en: | en: |
Writer · Reader · Shutter-presser, embracing open source. <span class="font-huiwen c-red">Writer</span> · Reader · Shutter-presser, embracing open source.
This is the public workbench of my three tracks: **books** that left notes, **code** that reaches the project wall, and **shutters** I pressed that land in the gallery. This is the public workbench of my three tracks: **books** that left notes, **code** that reaches the project wall, and **shutters** I pressed that land in the gallery.
@@ -26,3 +34,54 @@ intro:
- Images go to the gallery, with a bit of EXIF and one line of curation - Images go to the gallery, with a bit of EXIF and one line of curation
This site is itself open source — the full build and deploy pipeline lives in the repo. This site is itself open source — the full build and deploy pipeline lives in the repo.
- type: tracks
title:
zh: 兴趣版图
en: Interests
items:
- icon: book
name:
zh: 阅读
en: Reading
desc:
zh: 文学与戏剧为主,读到哪写到哪。
en: Mostly literature and drama; I write as I read.
- icon: terminal
name:
zh: 技术
en: Tech
desc:
zh: Linux、容器与自托管,把家里的小机器折腾成顺手的工具。
en: Linux, containers, and self-hosting — turning the little machines at home into handy tools.
- icon: camera
name:
zh: 摄影
en: Photography
desc:
zh: 街拍与山野,随身一台无反,在等光的间隙按快门。
en: Street and landscapes with a mirrorless camera, pressing the shutter while waiting for the light.
- type: prose
title:
zh: 本站如何构建
en: How this site is built
body:
zh: 页面由 Astro 静态生成,不含运行时框架 JS;内容住在自托管 gitea——本地编辑器与 gitea 网页写的是同一个 git 仓库,push 之后由 homelab 自动构建发布;评论走自托管 Artalk。页脚 built from 一行链到本次构建对应的 commit,构建过程透明可追溯。
en: Pages are statically generated by Astro with no runtime framework JS. Content lives on a self-hosted gitea — my local editor and the gitea web UI write to the same git repository, and each push is built and published automatically by the homelab. Comments run on self-hosted Artalk. The built-from line in the footer links to the commit behind this build, keeping the process transparent and traceable.
# ---- 二级回退(blocks 为空时使用)----
intro:
zh: |
写作者 · 读者 · 快门按压者,拥抱开源。
这里是我三条轨道的公开工作台:**读过的书**留下评注,**写过的代码**进项目墙,**按下的快门**收入图库。
en: |
Writer · Reader · Shutter-presser, embracing open source.
This is the public workbench of my three tracks: **books** that left notes, **code** that reaches the project wall, and **shutters** I pressed that land in the gallery.
# 页面级排版覆盖(层 2)
typography:
font: font-huiwen
accent: c-red
+294 -3
View File
@@ -119,6 +119,23 @@ collections:
fields: fields:
- label: 身份语 - label: 身份语
name: tagline name: tagline
- label: 页面排版覆盖(可选)
name: typography
widget: object
required: false
hint: 本页正文默认字体 / 强调色 / 字号缩放(0.8–1.25);不填用全站默认,只影响本页
fields:
- label: 正文字体类
name: font
widget: select
required: false
options: [font-serif, font-kai, font-sans, font-mono, font-song, font-kai-sys, font-hei-sys, font-fangsong, font-huiwen, font-kuaile]
- label: 强调色类
name: accent
widget: select
required: false
options: [ink-accent, ink-accent-soft, c-blue, c-blue-soft, c-teal, c-teal-soft, c-green, c-green-soft, c-yellow, c-yellow-soft, c-orange, c-orange-soft, c-red, c-red-soft, c-pink, c-pink-soft, c-purple, c-purple-soft]
- { label: 字号缩放, name: scale, widget: number, required: false, value_type: float, min: 0.8, max: 1.25, step: 0.05 }
widget: object widget: object
fields: fields:
- { label: 中文, name: zh, widget: string, required: false } - { label: 中文, name: zh, widget: string, required: false }
@@ -183,20 +200,294 @@ collections:
- name: about - name: about
label: 关于页 label: 关于页
file: content/site/about.yaml file: content/site/about.yaml
# 块内图片上传落 content/site/assets/,yaml 写 ./assets/xxx(Astro 图片管线)
media_folder: content/site/assets
public_folder: ./assets
fields: fields:
- label: 正文(markdown,多段) - label: 页面块(拖拽排序;每块可折叠)
name: intro name: blocks
widget: list
typeKey: type
required: false
summary: '{{fields.type}}'
hint: '点「+ 添加」选块型。关掉某块用该块内的「显示」开关'
types:
- label: 正文(markdown)
name: prose
widget: object
fields:
- { label: 显示, name: enabled, widget: boolean, required: false, default: true }
- label: 小标题(可选)
name: title
widget: object
required: false
fields:
- { label: 中文, name: zh, widget: string, required: false }
- { label: 英文, name: en, widget: string, required: false }
- label: 正文(markdown)
name: body
widget: object widget: object
hint: 渲染在「关于」标题下方、名片之上;支持段落/列表/粗斜体/链接;留空用默认文案
fields: fields:
- { label: 中文, name: zh, widget: markdown, required: false } - { label: 中文, name: zh, widget: markdown, required: false }
- { label: 英文, name: en, widget: markdown, required: false } - { label: 英文, name: en, widget: markdown, required: false }
- label: 宽度
name: width
widget: select
required: false
default: narrow
options: [{ label: 版心宽度, value: narrow }, { label: 通栏, value: full }]
- label: 版图(图标+名称+描述)
name: tracks
widget: object
fields:
- { label: 显示, name: enabled, widget: boolean, required: false, default: true }
- label: 标题
name: title
widget: object
fields:
- { label: 中文, name: zh, widget: string, required: false }
- { label: 英文, name: en, widget: string, required: false }
- label: 条目
name: items
widget: list
summary: '{{fields.name.zh}}'
fields:
- label: 图标
name: icon
widget: select
required: false
options: [book, terminal, camera, code, git, rss, email, github, bilibili, x, star, link]
hint: 白名单外或留空 → 显示首字占位
- label: 名称
name: name
widget: object
fields:
- { label: 中文, name: zh, widget: string, required: false }
- { label: 英文, name: en, widget: string, required: false }
- label: 描述
name: desc
widget: object
required: false
fields:
- { label: 中文, name: zh, widget: text, required: false }
- { label: 英文, name: en, widget: text, required: false }
- label: 卡片(2/3 列)
name: cards
widget: object
fields:
- { label: 显示, name: enabled, widget: boolean, required: false, default: true }
- label: 标题(可选)
name: title
widget: object
required: false
fields:
- { label: 中文, name: zh, widget: string, required: false }
- { label: 英文, name: en, widget: string, required: false }
- { label: 列数, name: columns, widget: number, required: false, default: 3, min: 2, max: 3, value_type: int }
- label: 卡片条目
name: items
widget: list
summary: '{{fields.title.zh}}'
fields:
- label: 标题
name: title
widget: object
fields:
- { label: 中文, name: zh, widget: string, required: false }
- { label: 英文, name: en, widget: string, required: false }
- label: 描述
name: desc
widget: object
required: false
fields:
- { label: 中文, name: zh, widget: text, required: false }
- { label: 英文, name: en, widget: text, required: false }
- { label: 链接(可选), name: href, widget: string, required: false }
- label: 角标(可选)
name: tag
widget: object
required: false
fields:
- { label: 中文, name: zh, widget: string, required: false }
- { label: 英文, name: en, widget: string, required: false }
- label: 时间线
name: timeline
widget: object
fields:
- { label: 显示, name: enabled, widget: boolean, required: false, default: true }
- label: 标题(可选)
name: title
widget: object
required: false
fields:
- { label: 中文, name: zh, widget: string, required: false }
- { label: 英文, name: en, widget: string, required: false }
- label: 条目
name: items
widget: list
summary: '{{fields.time}} · {{fields.title.zh}}'
fields:
- { label: 时间, name: time, widget: string, hint: '自由文本,如 2024 — 2026' }
- label: 标题
name: title
widget: object
fields:
- { label: 中文, name: zh, widget: string, required: false }
- { label: 英文, name: en, widget: string, required: false }
- label: 描述(可选)
name: desc
widget: object
required: false
fields:
- { label: 中文, name: zh, widget: text, required: false }
- { label: 英文, name: en, widget: text, required: false }
- label: 引文
name: quote
widget: object
fields:
- { label: 显示, name: enabled, widget: boolean, required: false, default: true }
- label: 引文
name: text
widget: object
fields:
- { label: 中文, name: zh, widget: text, required: false }
- { label: 英文, name: en, widget: text, required: false }
- label: 出处(可选)
name: cite
widget: object
required: false
fields:
- { label: 中文, name: zh, widget: string, required: false }
- { label: 英文, name: en, widget: string, required: false }
- label: 图片
name: figure
widget: object
fields:
- { label: 显示, name: enabled, widget: boolean, required: false, default: true }
- { label: 图片(必填,请用后台上传), name: image, widget: image, hint: '禁止填外链:只能上传(落 content/site/assets/),走 Astro 图片管线' }
- { label: 替代文本(无障碍), name: alt, widget: string, required: false }
- label: 图注(可选)
name: caption
widget: object
required: false
fields:
- { label: 中文, name: zh, widget: string, required: false }
- { label: 英文, name: en, widget: string, required: false }
- label: 链接清单
name: linklist
widget: object
fields:
- { label: 显示, name: enabled, widget: boolean, required: false, default: true }
- label: 标题(可选)
name: title
widget: object
required: false
fields:
- { label: 中文, name: zh, widget: string, required: false }
- { label: 英文, name: en, widget: string, required: false }
- label: 链接条目
name: items
widget: list
summary: '{{fields.label.zh}} → {{fields.href}}'
fields:
- label: 名称
name: label
widget: object
fields:
- { label: 中文, name: zh, widget: string, required: false }
- { label: 英文, name: en, widget: string, required: false }
- { label: 链接, name: href, widget: string }
- label: 备注(可选)
name: note
widget: object
required: false
fields:
- { label: 中文, name: zh, widget: string, required: false }
- { label: 英文, name: en, widget: string, required: false }
- label: 行动按钮
name: cta
widget: object
fields:
- { label: 显示, name: enabled, widget: boolean, required: false, default: true }
- label: 说明文字
name: text
widget: object
fields:
- { label: 中文, name: zh, widget: string, required: false }
- { label: 英文, name: en, widget: string, required: false }
- { label: 按钮链接, name: href, widget: string }
- label: 按钮文字
name: label
widget: object
fields:
- { label: 中文, name: zh, widget: string, required: false }
- { label: 英文, name: en, widget: string, required: false }
- label: 分隔
name: divider
widget: object
fields:
- { label: 显示, name: enabled, widget: boolean, required: false, default: true }
- label: 样式
name: style
widget: select
required: false
default: line
options: [{ label: 细分隔线, value: line }, { label: 纯留白, value: space }]
- label: 名片区开关
name: layout
widget: object
required: false
hint: 关掉则不显示头像/名字/社交(它们的数据在「主页设置」)
fields:
- { label: 显示名片区, name: showIdCard, widget: boolean, required: false, default: true }
- label: 正文回退(blocks 为空时用,markdown,多段)
name: intro
widget: object
required: false
hint: 仅在「页面块」为空时渲染;与块同时存在时以块为准
fields:
- { label: 中文, name: zh, widget: markdown, required: false }
- { label: 英文, name: en, widget: markdown, required: false }
- label: 页面排版覆盖(可选)
name: typography
widget: object
required: false
hint: 本页正文默认字体 / 强调色 / 字号缩放(0.8–1.25);不填用全站默认,只影响本页
fields:
- label: 正文字体类
name: font
widget: select
required: false
options: [font-serif, font-kai, font-sans, font-mono, font-song, font-kai-sys, font-hei-sys, font-fangsong, font-huiwen, font-kuaile]
- label: 强调色类
name: accent
widget: select
required: false
options: [ink-accent, ink-accent-soft, c-blue, c-blue-soft, c-teal, c-teal-soft, c-green, c-green-soft, c-yellow, c-yellow-soft, c-orange, c-orange-soft, c-red, c-red-soft, c-pink, c-pink-soft, c-purple, c-purple-soft]
- { label: 字号缩放, name: scale, widget: number, required: false, value_type: float, min: 0.8, max: 1.25, step: 0.05 }
- name: opensource - name: opensource
label: 开源页 label: 开源页
file: content/site/opensource.yaml file: content/site/opensource.yaml
fields: fields:
- label: 叙述(链接前) - label: 叙述(链接前)
name: blurb name: blurb
- label: 页面排版覆盖(可选)
name: typography
widget: object
required: false
hint: 本页正文默认字体 / 强调色 / 字号缩放(0.8–1.25);不填用全站默认,只影响本页
fields:
- label: 正文字体类
name: font
widget: select
required: false
options: [font-serif, font-kai, font-sans, font-mono, font-song, font-kai-sys, font-hei-sys, font-fangsong, font-huiwen, font-kuaile]
- label: 强调色类
name: accent
widget: select
required: false
options: [ink-accent, ink-accent-soft, c-blue, c-blue-soft, c-teal, c-teal-soft, c-green, c-green-soft, c-yellow, c-yellow-soft, c-orange, c-orange-soft, c-red, c-red-soft, c-pink, c-pink-soft, c-purple, c-purple-soft]
- { label: 字号缩放, name: scale, widget: number, required: false, value_type: float, min: 0.8, max: 1.25, step: 0.05 }
widget: object widget: object
fields: fields:
- { label: 中文, name: zh, widget: string, required: false } - { label: 中文, name: zh, widget: string, required: false }
+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>
+20 -4
View File
@@ -3,21 +3,34 @@ import BaseLayout from '../../layouts/BaseLayout.astro';
import IdCard from '../../components/IdCard.astro'; import IdCard from '../../components/IdCard.astro';
import { t } from '../../i18n/ui'; import { t } from '../../i18n/ui';
import type { Locale } from '../../lib/locale'; 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 零回归 // /en/ 侧由 T4 薄页传 locale='en';zh 路由无人传参,默认 zh 零回归
interface Props { interface Props {
locale?: Locale; locale?: Locale;
} }
const { locale = 'zh' } = Astro.props; const { locale = 'zh' } = Astro.props;
// 身份陈述段改由 content/site/about.yaml 供给(多段 markdown,后台可改),缺值回落默认 // 三级回落(零回归):blocks 驱动 → intro markdown → 内置默认文案
const introHtml = await renderMarkdown(await aboutIntroMarkdown(locale)); 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`}> <BaseLayout title={`${t('about_title', locale)} · wait4x`}>
<h1 class="page-title">{t('about_title', locale)}</h1> <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 />
@@ -67,6 +80,9 @@ const introHtml = await renderMarkdown(await aboutIntroMarkdown(locale));
<h2 class="sec-title">{t('about_build_title', locale)}</h2> <h2 class="sec-title">{t('about_build_title', locale)}</h2>
<p>{t('about_build_desc', locale)}</p> <p>{t('about_build_desc', locale)}</p>
</section> </section>
</>
)}
</div>
</BaseLayout> </BaseLayout>
<style> <style>
+122 -2
View File
@@ -18,6 +18,92 @@ export type PostTrack = (typeof POST_TRACKS)[PostCategory];
/** 双语文案字段:两语言各自可选,缺一回落一(回退逻辑在 src/lib/site-content.ts) */ /** 双语文案字段:两语言各自可选,缺一回落一(回退逻辑在 src/lib/site-content.ts) */
const bilingual = z.object({ zh: z.string().optional(), en: z.string().optional() }); 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 { export function trackOf(category: PostCategory): PostTrack {
return POST_TRACKS[category]; return POST_TRACKS[category];
} }
@@ -82,6 +168,14 @@ export const collections = {
z.object({ z.object({
tagline: bilingual.optional(), tagline: bilingual.optional(),
avatar: image().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 */ /** 站点图标:public 下的路径(.svg/.ico 直出,不经图片管线);缺省回退 /favicon.svg */
favicon: z.string().optional(), favicon: z.string().optional(),
/** 副标题(markdown 短文本,渲染在身份语下);空 = 不渲染 */ /** 副标题(markdown 短文本,渲染在身份语下);空 = 不渲染 */
@@ -113,14 +207,40 @@ export const collections = {
}), }),
siteAbout: defineCollection({ siteAbout: defineCollection({
loader: glob({ pattern: 'about.yaml', base: './content/site' }), loader: glob({ pattern: 'about.yaml', base: './content/site' }),
// intro = 多段 markdown 正文(页面用 createMarkdownProcessor 渲染),字段级空值回落默认 // intro = 多段 markdown 正文(二级回退:blocks 为空时才用)
schema: z.object({ intro: bilingual.optional() }), // 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({ siteOpenSource: defineCollection({
loader: glob({ pattern: 'opensource.yaml', base: './content/site' }), loader: glob({ pattern: 'opensource.yaml', base: './content/site' }),
schema: z.object({ schema: z.object({
blurb: bilingual.optional(), blurb: bilingual.optional(),
blurb_after: 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_url: z.string().optional(),
repo_label: z.string().optional(), repo_label: z.string().optional(),
/** 置顶仓短名,顺序即展示顺序(原 projects.ts 硬编码名单迁于此) */ /** 置顶仓短名,顺序即展示顺序(原 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 };
}