Files
blog/docs/research/10-页面后台化方案.md
wait4xandGLM-5.3 2cab243891 feat(typography): 可视化排版选择器(Sveltia inline 组件)+ 颜色扩充 + 方案 10
- 可视化选择(核心痛点解决):Sveltia 原生支持 registerEditorComponent
  的 inline 形态且 onInsert 能拿到 selectedText → 实现「选中文字 → Insert ▸
  排版 → 选字体/颜色 → 自动包 span class」完整链路;因 inline 组件只能进
  Insert 菜单(field-based 虽可上工具栏但拿不到 selectedText),两个独立组件
  会互相抢 pattern 匹配,故收敛为单入口 + 18 项分组面板(键盘可达/Esc 取消/
  onEdit 回填当前类)
- public/admin/typography-classes.js 为单一事实源(扩展与文档共用)
- 颜色扩充 4 类:ink-warm / ink-cool / ink-faint / ink-accent-soft,全部亮暗
  双态实测 ≥4.5:1(ink-faint 取白底达标的最淡值 4.69/4.75)
- 字体:体积账否决 webfont 新增(@fontsource zcool-xiaowei 用到即 +647.7KB,
  远超 150KB 阈值;smiley-sans 无可用版本、sarasa 包为空壳),改为落地 4 个
  **系统回退类**(font-song/font-kai-sys/font-hei-sys/font-fangsong,零下载,
  文档注明依赖访客设备)
- 白名单 17 类同步;docs/写作指南.md 三表更新
- docs/research/10-页面后台化方案.md:五方案对比 + 推荐方案 E(页面骨架 blocks
  / 区块字段 / 局部自由区 / 样式即数据四层)+ 关于页九类区块 + 消毒与验收断言
  + 分阶段清单(约 7.2d)

验证: 16/16 颜色 computed 与对比度实测;选择器 7/7(桩 CMS 覆盖注册/插入/
      编辑三层);构建 48 页 exit 0;E2E 37/37

Spec: none (排版能力 + 方案文档)

Co-authored-by: GLM-5.3
2026-11-02 16:57:54 +08:00

34 KiB
Raw Permalink Blame History

页面后台化方案:内容 / 布局 / 样式的可编辑边界

调研 + 提案,不改代码。基线 = 工作树 2026-11-02(含在途未提交改动)。 口径:所有结论落到本仓文件/字段;"能编辑什么"和"不能编辑什么"同等重要。 在途关系:工作树里另有一条未提交车道(git diff --stat:home.yaml / opensource.yaml / projects.ts / site-content.ts / content.config.ts / ProjectWall.astro / index.astro / en-index / tokens.css / global.css,共 306 增)已落地「首页区块开关 + 开源页可见性 + README 开篇」。本文把它当既有事实评估,只补缺口与验收,不重复设计。


0. 现状盘点(文件级锚点)

关注点 现有实现 位置
集合/单例 schema siteHome / siteAbout / siteOpenSource 三个 glob(pattern 精确到单文件) 单例 src/content.config.ts:78,114,119
后台字段 site_pages 一集合三 files,字段与 schema 一一对应 public/admin/config.yml:110-260
文案访问层(含字段级回落) pick()/pickBi() + SITE_DEFAULTS 逐字对齐历史文案 src/lib/site-content.ts:18-62
markdown 渲染 createMarkdownProcessor() 无参(关键,见 §7) src/lib/site-content.ts:122-127
关于页结构 h1 → intro(markdown) → IdCard → tracks(3 条硬编码) → build 段 src/components/pages/AboutPage.astro:1-70
关于页可变文案 tracks/build 段全部来自 t() 字典(非后台可改) src/i18n/ui.ts:73-84
首页 结构化区块:IdCard/PostList/最新影像/Heatmap/ProjectWall/心跳 src/pages/index.astro(zh/en 两份重复)
开源页 blurb + Heatmap + ProjectWall(limit 99) src/pages/opensource.astro
项目数据 构建期双源 fetch + 去重 + 过滤/排序 + 仓内 fallback JSON(现仅 1 条 blog) src/lib/projects.ts:24-134、src/data/projects-fallback.json
README 开篇 GET /repos/wait4xx/wait4xx/readme(Accept raw)+ content/.github-readme.md 缓存 src/lib/site-content.ts:204-256
markdown 净化 仅文章管线挂了白名单(class/style/on*) astro.config.mjs:28、src/lib/rehype-class-whitelist.ts
设计 tokens 全部 CSS 变量,明暗双套 src/styles/tokens.css(124 行,含 dark 覆盖)

结论:后台能力当前是「平铺标量字段」形态——能改文案与几个开关,不能改结构、顺序、字段种类。关于页的骨架(tracks/build 两段)完全在代码与 i18n 字典里,这正是用户"整页可编辑"诉求的断点。


1. 需求拆解(可验收能力清单)

编号 能力 现状 缺口
C1 关于页所有文案可改(含 tracks/build 段) intro 可改,其余在 ui.ts 全部结构化区块未入 CMS
C2 关于页结构可改(增删段、调顺序、开关名片区) 硬编码 无块模型
C3 首页区块开关 / 条数 / 副标题 在途已实现(sections.*、perTrackLimit、latestPhotos、subtitle) 缺"展示策略"类字段(见 §6)
C4 开源页"展示哪些项目" 在途已实现(mode/include/exclude/sort/perSource) 语义定稿 + 断言 + 现有 6 条截断副作用(见 §4)
C5 开源页开篇 = GitHub profile README 在途已实现(intro_from: github-readme) 缓存位置被 git clean 抹掉(见 §4.4)
C6 样式可调(字体/字号/间距/灰阶/圆角) 全在 tokens.css 无数据层
C7 双语 en 轨保真 字段级 {zh,en} + 回落 块模型需延续同规矩
C8 构建期零运行时 + SEO/灰阶不回归 现状成立 需把"不回归"写成断言

2. 五方案对比

A 结构化单例字段扩展 B 块构建器 C 富文本整页 D 样式即数据 E 混合(推荐)
能力上限 低:只能改预定义字段的值 高:增删/排序/差异化结构 最高:任意内容 中:只动感官参数 高(结构)+ 中(局部自由)
破版风险 极低 低(渲染器受控) 高:作者可写出非设计语言结构 中(对比度/尺度) 低-中
XSS 面 0(无 HTML) 小(组件渲染,URL/图片字段受控) 大:set:html 直通 0 小(唯一自由区走净化管线)
编辑体验 顺手但僵硬;20 字段后 config.yml 成清单墙 顺手(新增时选块型、拖拽排序) 最自由但无护栏 顺手(几个 color/number) 顺手(块为主,自由区为辅)
工作量 0.5d/页 3-4d(渲染器 + 9 类块 + 后台 types) 0.5d(已有 intro 通道)但治不住质量 1d 4-5d(含 B+D+净化)
维护成本 每加一项改两处(yml+schema) 每加块型改三处(yml+schema+组件),但互不干扰 内容问题无代码兜底,靠人审 用户改坏颜色需回滚 git 同 B,另加 token 白名单
对我们仓的结论 只适合开关/条数(首页现状即其上限形态),不作为关于页主路线 关于页主力 不作为主路线(灰阶/双语/SEO 三条红线都靠人守) 采纳为独立一层(受限白名单) 采纳

补充结论(逐条):

  • A 的极限量化:public/admin/config.yml 现已 260 行、覆盖 4 集合。关于页若走 A(名片/技能卡/时间线各 4-6 字段),预估 +60 行 yml、+40 行 schema、+30 行组件分支;且"某段删掉"只能靠 enabled: false 假装,顺序固定。→ 只保留给首页(首页是聚合页,区块种类稳定)。
  • B 的关键前提:Sveltia 0.252 支持 list 的 types + typeKey(异构块),证据见 §3.4;无此能力则 B 退化成"同构对象列表"(每块字段全冗)。
  • C 的现实定位:about.yaml 已有 markdown 整块通道(intro),可立刻扩成"多段 markdown 长文"。问题:长文无法表达 tracks 卡片(图标+双行)、无法控制段间节奏,且 raw HTML 通道当前未净化(§7.1)——直接放开等于开洞。
  • D 的边界:可调项必须白名单化(约 12 个),不是整份 tokens.css;--measure(9 处引用)、--accent(38 处)属高放大倍率旋钮,见 §3.6。

3. 推荐方案 E:结构与落点

3.1 四层数据分层

层 载体 谁能改 例子
L1 页面骨架 content/site/*.yaml 的 blocks: [] 后台(增删排序) 关于页整页
L2 区块字段 每块 type 决定字段集(zod discriminatedUnion) 后台 prose / tracks / cards / timeline / quote / figure / linklist / cta / divider
L3 局部自由区 prose / richtext 块内的 markdown(受白名单类 + tag 白名单) 后台 一段带列表的说明
L4 感官参数 content/site/theme.yaml → 构建期生成 CSS 变量 后台(白名单旋钮) 正文字号、圆角、强调色

3.2 yaml 结构总览(一眼看全)

content/site/
├── home.yaml          # 已有:tagline/avatar/favicon/socials/subtitle/latestPhotos/perTrackLimit/sections/*
│                      # 新增:heroStyle / projectsLimit / photosSource / postsTracks / announcement(markdown)
├── about.yaml         # 已有:intro{zh,en}(保留为兼容字段,见 §5.5)
│                      # 新增:layout{showIdCard,container} + blocks:[]
├── opensource.yaml    # 已有(在途):blurb/blurb_after/repo_url/repo_label/pinned/mode/include/exclude/sort/perSource/intro_from
│                      # 新增:projectsTitle / showHeatmap / intro_fallback_blurb(README 取不到时的专用文案)
└── theme.yaml         # 新增:白名单感官参数(§3.6)

3.3 组件与读取层落点(写代码时照此建文件)

落点 职责
src/lib/page-blocks.ts(新) getAboutBlocks(locale):读 siteAbout → 过滤 enabled:false → 未知 type 跳过 + console.warn → 文本字段 pickBi 回落 zh → 长度上限(块数 ≤ 24、单块文本 ≤ 4000 字)
src/components/blocks/BlockRenderer.astro(新) switch (block.type) → 渲染子组件;统一外壳 <section class="blk" data-block={block.type}>(data 属性是 E2E 断言的抓手)
src/components/blocks/{Prose,Tracks,Cards,Timeline,Quote,Figure,LinkList,Cta,Divider}.astro(新) 每类一个组件,样式从 AboutPage.astro 现状 <style> 段搬迁(.sec-title/.track-row/.track-icon 原样保留)
src/components/pages/AboutPage.astro(改) 保留 h1 + 可选 IdCard;blocks 为空 → intro markdown → 若再空则 SITE_DEFAULTS.aboutIntro(零回归三级回落)
src/content.config.ts(改) siteAbout schema 扩展:blocks 用 z.array(z.discriminatedUnion('type',[...])),图片字段用 image()
public/admin/config.yml(改) site_pages → about 加 blocks 的 list+types 声明,typeKey: type
src/lib/site-content.ts(改) renderMarkdown 补 rehype 插件(§7.2 一笔修两洞)
src/lib/theme.ts(新) getTheme() + themeVarsCss() 生成 :root:root{…} 字符串
src/layouts/BaseLayout.astro(改) head 内联 <style set:html={themeVarsCss()}>(构建期静态,零运行时);main 容器档位由页面 layout.container 决定(.container / .container-wide,global.css:43-44)

3.4 Sveltia 0.252 能力边界(本仓 bundle 实测取证)

证据文件:public/admin/index.html(pinned 本地 bundle 0.252.0,2.5MB)。

能力 结论 取证
list 异构块(types + typeKey) 支持:新增条目时弹「Select List Type」,按 name 选型 bundle 内 i18n 键 select_list_type、list_type 组件、{types, typeKey='type'} 解析路径
内建 widget 全集 boolean/color/code/compute/datetime/file/hidden/image/keyvalue/list/map/markdown/number/object/relation/richtext/select/string/text/uuid bundle 中 ok=[…] 常量
richtext(所见即所得) 支持,落盘默认 markdown(field_defaults.richtext.format,非 html) bundle 中 t===\html`?`html`:`markdown``
嵌套深度硬限制 未发现:bundle 里唯一 nestedDepth 属 folder 集合路径深度,与字段嵌套无关 全量 grep
list 内再放 list 类型表未排除,但无保证 → 必须做一次 10 分钟后台实测冒烟(见 §8 P1 验收) —
拖拽排序 / 每块 summary 支持(summary 模板 + reorder) 现网 posts/moments 已在用

编辑体验判断:blocks 用 types 后,后台表现为「+ 添加 → 选块型 → 填该块字段」,列表可拖拽,每块可折叠。代价:字段标签必须写成中文短标签,否则块型选择弹窗会退化成英文 key。

3.5 双语策略(三条路线,选 A)

方案 做法 判断
A 字段级 {zh,en}(选) 每个文本字段仍是 {zh,en},en 留空回落 zh 与现状全站一致(bilingual schema + pickBi),后台一屏可见双语对照;缺点是块内字段数 ×2
B 两套块数组(blocks_zh/blocks_en) 结构可漂移 否决:块数与顺序会分叉,维护地狱
C en 构建期自动翻译 复用 scripts/translate/ 否决:违反构建确定性(产物含未提交内容),且页面级译文无人校对

配套:pickBi 之外新增构建期缺译清单(§7.3 断言 #8),把"哪些 en 字段回落成了 zh"打成 WARN,避免静默全英文页变中文。

3.6 样式即数据(L4)边界表

允许(12 项) 形态 风险控制
--accent color(明/暗两值) 构建期算对比度(对 --bg)< 4.5 → 报警并回落默认(红线"全站唯一强调色"只能调色相,不能变多色)
--radius-img number 4-16 钳制(08 文档裁定圆角上限 14)
--text-h1/h2/h3 number 22-32 单调性校验 h1>h2>h3,否则回落
--prose-size-reading/tech number 16-22 钳制
--leading-reading/leading-tech number 1.5-2.0 钳制
--space-3/4/5/6 number 8-80 单调递增校验
--flow-p / --flow-h2-post number 0-2(em) 钳制
--dur number 150-600(ms) 钳制
--font-* select(内置四族,不含任意字体名) 必须枚举:任意字体名会让 614 个 @font-face 瘦身成果失效

禁止:--measure(9 处引用,改版心会连带 TOC/图库/首页栅格)、--bp-*(断点是媒体查询字面量)、--code-bg/--code-ink(Shiki css-variables 主题在 astro.config.mjs 之外耦合)、--paper*(书评纸墨是分轨身份)。 注入方式:BaseLayout head 内联 <style>,选择器用 :root:root{…}(0,2,0)与 [data-theme="dark"]:root:root{…}(0,3,0)压过 tokens.css 的 :root(0,1,0)/[data-theme="dark"](0,1,0)——不能靠 DOM 顺序(global.css 的 <link> 注入位置与 inline <style> 的相对次序不可控)。断言:明暗两态 getComputedStyle(html).getPropertyValue('--accent') 都等于 yaml 值。


4. 开源页:项目可见性 + GitHub README

4.1 在途现状(已实现,不再设计,只定稿语义)

  • 字段:mode(all|only|exclude) / include[] / exclude[] / sort(pinned-first|stars|updated) / perSource(both|github|gitea) / pinned[] / intro_from(off|github-readme)(content/site/opensource.yaml、src/content.config.ts:128-138、src/lib/site-content.ts:180-201)。
  • 过滤实现:perSource → mode → sort,不截断,上限交给调用方(src/lib/projects.ts:99-134;这修掉了旧 orderPinned 内 .slice(0,6) 导致 /opensource 文案说"全部仓库"实际只显示 6 条的旧账)。
  • 匹配键 = 仓库短名(name),跨源同名先去重(同名优先 gitea 叙事,star 取 GitHub 真值,src/lib/projects.ts:76-88)。

4.2 语义定稿(必须写进后台 hint,否则用户会填错)

  1. mode: only 时 忽略 exclude;mode: exclude 时忽略 include(v1 不做两者求交)。
  2. include/exclude 填不存在的名字 = 静默无效(后台无下拉可选,因为仓库清单是构建期才拿到);→ 建议 P2 阶段把 widget: string 改成 widget: select, options 的静态镜像(yml 里手抄一份当前仓名)不划算,v1 保持手填 + 构建期 WARN 打印未命中的名字。
  3. sort: pinned-first 仍是默认(首页要"置顶在前"叙事),sort: stars|updated 时 pinned 让位(现实现如此:arrange 里 stars/updated 分支直接 sort,不看 pinned)。
  4. 首页固定 limit=6,开源页 limit=99(ProjectWall.astro props)。若仓库总数 >99 需调大,非阻塞。

4.3 是否用 GitHub profile README 作为开源页开篇 —— 可行,推荐按"默认关、用户开"上线

评估项 结论
接口 GET /repos/wait4xx/wait4xx/readme + Accept: application/vnd.github.raw = 一次请求返回 markdown 原文(现实现已如此,site-content.ts:235-238)
限流 认证 5000 req/h;构建按 push 触发(webhook ~10 秒级),每天最多几十次 → 无压力。匿名 60 req/h 会更紧张,必须走 token
token 复用 ~/docker_apps/blog/github-token.txt(src/lib/heatmap.ts:21 同一份,0600,read 权限足够);缺位是合法降级路径,不报错只 WARN
CORS 不涉及:node 侧构建期 fetch,不是浏览器请求。(若哪天想做成客户端拉取,CORS 会直接堵死,且违反"构建期零运行时")
许可 内容属本人 profile 仓(wait4xx/wait4xx),无第三方版权问题;README 若嵌第三方徽章图 = 外链图片,构建期不下载、浏览器直连,需接受
渲染质量 README 常见 <p align="center">、徽章 <img>、<details>、表格、外链图片的相对路径(渲染到 /opensource/ 下会 404)。现状只做了基础排版类(opensource.astro 的 .readme-intro :global(...) 段)
破版风险 中:README 常含居中对齐/大标题/emoji 徽章,与"灰阶克制 + 版心 720px"张力大。→ 建议 P5 做一次视觉复核,必要时给 .readme-intro 加 img{max-height}、h1{display:none} 折叠

缓存实现缺陷(必改):README_CACHE = 'content/.github-readme.md'(site-content.ts:204)写在仓内,而构建仓每次 git fetch && reset --hard && git clean -fd(infra/deploy/build.sh:26)会抹掉未入仓缓存——即"网络失败回落缓存"这层在生产构建里永远命中不了,实际回落链退化为"网络 → 无 → yaml blurb"。 修法(二选一,推荐后者):

  • (a) 把缓存文件提交入仓,靠人工/定时提交更新 —— 引入"构建机写仓"脏状态,与"构建仓硬跟 origin/main"纪律冲突;
  • (b) 移到仓外:~/docker_apps/blog/github-readme-cache.md(与 token 同目录,天然持久,不被 clean 波及),TTL 24h(符合 GitHub API 缓存建议),并在 writeFileSync 失败时只 WARN。

4.4 失败回落矩阵(开源页)

失败点 现状行为 目标
token 缺位 WARN → 缓存 → null → 用 yaml blurb 同(缓存改仓外后才有意义)
README 404 / 仓不存在 WARN → 缓存 → blurb 同
网络超时 现实现无 AbortSignal 超时(projects.ts 有 10s,README 没有)→ 挂起风险 补 AbortSignal.timeout(8000)
README 为空 WARN → 缓存 → blurb 同
gitea 源整站 522(历史事故) Promise.allSettled 单源失败不影响另一源 保持
双源全失败 仓内 projects-fallback.json(现仅 1 条) 建议 P5 用一次真实构建产物回填 5-8 条

5. 关于页整页化

5.1 现有结构 → 区块映射(逐元素,无遗漏)

现有元素 文案来源 现状可否后台改 对应块型
<h1>关于 ui.ts:73 ✗ 页面 title 字段(或沿用字典)
intro 多段 markdown about.yaml intro.{zh,en} ✓ prose 块(intro 字段保留为兼容回退)
名片区(头像+名字+tagline+社交) IdCard + home.yaml 部分 ✓(tagline/socials 来自 home) layout.showIdCard 开关(不拆成块:头像数据单一事实源在 home.yaml)
「兴趣版图」标题 ui.ts:74 ✗ tracks 块的 title
3 条 track(图标+名称+描述) ui.ts:75-80 ✗ tracks 块的 items[](icon 键 + name/desc 双语)
「本站如何构建」标题+正文 ui.ts:81-83 ✗ prose 块的 title + body

→ 覆盖现有 About 只需 3 个块(prose + tracks + prose)。其余块型是为"用户以后想加东西"准备的(§5.2)。

5.2 区块类型清单(v1 九类,够用且不失控)

type 字段(* = {zh,en}) 说明
prose title*? body*(richtext/markdown) `width(narrow full)`
tracks title* items[]{icon, name*, desc*} 覆盖现有兴趣版图;icon 走内置键白名单
cards title*? `columns(2 3) items[]{title*, desc*, href?, tag?}`
timeline title*? items[]{time, title*, desc*?} 经历/项目时间线(复用 MomentsPage 的 .tl-* 竖线语言)
quote text* cite? 复用 .quote-line + --font-kai(08 文档 D 金卡)
figure image(image()) caption*? alt 必须走 Astro 图片管线
linklist title*? items[]{label*, href, note*?} 外链清单(比 markdown 裸链接可控)
cta text* href label* 单按钮行动点(克制:同屏最多 1 个)
divider `style(line space)`

不做(放进 §9):html 块、style 块、columns[grid] 栅格构建器、任意 svg 块。

5.3 示例 yaml(直接照抄可跑通现有关于页)

# content/site/about.yaml
layout:
  showIdCard: true
  container: narrow          # narrow=.container(720) / wide=.container-wide(+200px)
  # 以下为可选:不填则沿用标题「关于」
  # title: { zh: 关于, en: About }
blocks:
  - type: prose
    title: { zh: '', en: '' }        # 留空不渲染标题
    width: narrow
    body:
      zh: |
        写作者 · 读者 · 快门按压者,拥抱开源。

        这里是我三条轨道的公开工作台:**读过的书**留下评注,**写过的代码**进项目墙,**按下的快门**收入图库。
      en: |
        Writer · Reader · Shutter-presser, embracing open source.
  - 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 — …' }
      - icon: camera
        name: { zh: 摄影, en: Photography }
        desc: { zh: 街拍与山野……, en: 'Street and landscapes with a mirrorless camera, …' }
  - type: prose
    title: { zh: 本站如何构建, en: How this site is built }
    body:
      zh: 静态生成,构建即发布;字体自托管,评论自托管,全文索引在构建期生成。
      en: Statically generated, publish-on-build; self-hosted fonts, comments, and a build-time search index.

icon 键白名单(代码侧注册,同 SocialLinks 纪律 src/components/SocialLinks.astro:7-19):book / terminal / camera / code / git / rss / email / github / bilibili / x / star / link。后台填表外的键 → 渲染纯文字(不破版)。

5.4 视觉保留(不动设计体系)

  • 类名与样式从 AboutPage.astro:70-143 原样搬迁到 src/components/blocks/*.astro:.sec-title(mono 14px/ink-3/0.08em)、.track-row(1px --line + radius 10px + --space-3/4 内距)、.track-icon(36×36 / --bg-soft)。
  • 新增块只允许用既有 token;新组件不得引入新颜色字面量(用 --ink-*/--line/--bg-soft)。这条要在评审时按 grep '#[0-9a-fA-F]\{6\}' src/components/blocks/ 结果把关(ProjectWall 的语言色点是既存例外,不得扩散)。
  • 关于页 --measure 版心不变(prose 块 max-width: var(--measure),AboutPage.astro:85)。

5.5 兼容与零回归

  • intro 字段不删:getAboutBlocks() 见 blocks 缺省/空数组 → 走旧路径(intro markdown → 默认文案)。这样 Sveltia 里手滑清空 blocks 不会白页。
  • t('about_track_*') 等字典键不删:作为 SITE_DEFAULTS 的英文默认源;字典继续服务"没有 blocks 的旧数据"。
  • zh/en 双路由共用 AboutPage.astro(src/pages/about.astro、src/pages/en/about.astro),块数据只存一份,locale 在读取层选字段 —— 这是 en 轨不漂移的结构保证。

6. 首页扩展(结构化,不上 blocks)

理由:首页是聚合页(列表/影像/热力图/项目墙都是"取集合切片"),块构建器在这里只会增加"选块型 → 填聚合配置"的绕路,且首页 zh/en 是两份重复文件(src/pages/index.astro vs src/pages/en/index.astro),更适合"字段少而稳"。

已有(在途) 建议补 字段形态 落地
tagline subtitle — {zh,en} site-content.ts:160-175
sections{tracks,photos,heatmap,projects} heartbeat(心跳行开关) boolean 同上
perTrackLimit(4-8) postsTracks(显示哪几条轨,默认 reading+tech) select multiple 需给 PostList 加 tracks prop
latestPhotos(0-6) `photosSource(curated latest):curated 读 photos集合的pinned` 标记 select
— `heroStyle(idcard plain)` select
— projectsLimit(0-8,0=隐藏) number 传给 ProjectWall limit
— announcement(一段 markdown 短公告,渲染在 tagline 下) {zh,en} markdown 复用 renderMarkdown(须走净化)

风险:index.astro 与 en/index.astro 是手抄双份,任何新字段必须在两处同时接;建议 P4 顺手抽出 src/components/pages/HomePage.astro(吃 locale prop),双页变薄壳 —— 这是"双语不漂移"的结构性修法,先于新字段做。


7. 安全与构建可靠性

7.1 现状漏洞(三条,均已定位到行)

  1. 白名单对页面文案不生效:renderMarkdown() 调 createMarkdownProcessor() 无参(src/lib/site-content.ts:124),@astrojs/markdown-remark 用默认 rehypePlugins: [](node_modules/.pnpm/@astrojs+internal-helpers@0.13.0/.../markdown.js:21),且默认 allowDangerousHtml: true + 结尾无条件挂 rehype-raw(@astrojs/markdown-remark/dist/index.js:53-56,80)。→ about.yaml 的 raw HTML 直通 set:html(AboutPage.astro:20),<script>/style/on* 全不被剥。文章管线有白名单(astro.config.mjs:28),页面文案管线没有。
  2. README 是远程内容:githubReadmeMarkdown() 把网上下来的 markdown 直接进 set:html(src/pages/opensource.astro:17)。即便 README 属本人仓,也等于把"远程内容"当可信 HTML 处理。
  3. style 属性会用内联样式破灰阶:现白名单剥 style,但只对文章生效;块模型若不统一走净化管线,同样的洞会出现在关于页。

7.2 三道防线(按顺序实施)

D1 修管线(一处修两洞,P0)

// src/lib/site-content.ts
const { createMarkdownProcessor } = await import('@astrojs/markdown-remark');
mdProcessor ??= createMarkdownProcessor({
  rehypePlugins: [rehypeRaw, rehypeClassWhitelist, rehypeLinkCards], // 与 astro.config.mjs:28 同序
});

副作用(正向):页面 markdown 里的独立成段链接也会变成 OG 卡片(rehypeLinkCards 现已带 content/.link-cache.json 缓存),这是"免费能力",需在写作指南里说明。

D2 加 tag 级允许清单(P0,扩展 src/lib/rehype-class-whitelist.ts 或新建 rehype-content-sanitize.ts)

  • 允许标签:p br strong em del code pre blockquote ul ol li a img h2 h3 h4 hr table thead tbody tr th td figure figcaption span div(不含 script style iframe object embed form input link meta svg)。
  • 属性允许:a[href,title,rel]、img[src,alt,width,height,loading]、其余仅 class(白名单)+ id(heading 锚)。
  • 协议允许:http: https: mailto: + 站内相对路径;javascript:/data:(除 image/* 小图外)一律剥。
  • 未知块 type → 跳过 + WARN,不炸构建。

D3 图片必须走管线(P1,随块模型)

  • 块内 image 字段声明为 image()(Astro helper),媒体目录 content/site/assets(public_folder: ./assets,与 avatar 现状一致,config.yml:117-118)。
  • 禁止在块里填 /media/... 或外链图(schema 层拦:z.string().refine(s => !/^https?:/.test(s)),只放行 image() 类型)。体积纪律沿用写作指南(单张 ≤1MB)。

7.3 验收断言清单(可直接写成 Playwright / grep 断言)

# 断言 手段
1 关于页块数与顺序 == yaml(含 enabled:false 被滤掉) page.locator('[data-block]') 逐个取 data-block 属性拼串比对
2 零配置零回归:about.yaml 只有 intro 时,/about/ 与改动前快照一致 旧产物 dist 对比(diff <(grep -o 'class="[^"]*"' old) new)
3 注入负向测试:intro 写 <script>alert(1)</script> 与 <span style="color:red"> → 构建后 dist 不含 alert(1)、不含 style="color grep -c 必须为 0
4 类白名单:<span class="font-kai foo"> → 只留 font-kai 同上 grep
5 未知块型不炸:yaml 写 type: not-a-block → 构建成功 + stderr 含 WARN + 页面无该块 手跑一次 build
6 缺译可见:en 全空的页面 → 渲染 zh 文案 + 构建 WARN 列出字段路径 静态检查 build 输出
7 tokens 生效与压过:明暗两态 --accent == yaml 值;--radius-img 同 getComputedStyle(document.documentElement).getPropertyValue('--accent')
8 对比度闸门:token 化颜色对 --bg ≥4.5:1(含暗色态) 构建期脚本 scripts/build-perf/contrast-check.mjs(相对亮度公式,08 文档同口径)
9 图片走管线:dist 中关于页图片 src 含 /_astro/ 且带 width/height grep
10 项目墙可见性:mode: only, include: [blog, pi-board] → dist /opensource/ 的 .repo-name 恰 2 条;mode: exclude 同理 Playwright 计数
11 README 回落:把 token 文件改名后构建 → /opensource/ 仍出开篇(缓存或 blurb),构建 exit 0 手工一次
12 构建期零运行时:dist/about/index.html 新增 inline <script> 数为 0(相对基线) grep 计数对比

7.4 失败回落纪律(写进读取层,不散在组件里)

数据 缺省/损坏时 目标
blocks 空/缺 → intro → SITE_DEFAULTS.aboutIntro 页面永不空
某块缺必填字段 zod 报错即构建失败(块内必填要谨慎:title 设 optional,body 必填) 宁可构建失败也不出半块
块文本超长 读取层截断 + WARN 防"一顿贴整篇长文"
theme.yaml 非法值 钳制/回落默认 + WARN 绝不让一个 #zzz 拖垮构建
项目 API 全挂 fallback JSON 已有
README 全挂 仓外缓存 → yaml blurb §4.4

8. 分阶段实施清单

阶段 内容 工作量 依赖 落地文件 验收
P0 修 markdown 管线(D1)+ tag 白名单(D2)+ 负向断言 #3/#4 0.5d 无 src/lib/site-content.ts、src/lib/rehype-content-sanitize.ts(新)、tests/e2e.mjs 或 scripts/build-perf/content-lint.mjs 断言 3/4 绿,现有文章渲染零回归
P0.5 后台实测冒烟:list 内嵌 list、richtext 嵌套在 object 内是否可用 0.2d 无(需 token 登录一次) 无代码,结论回写本文 §3.4 能新增/拖拽/保存并构建通过
P1 blocks 骨架:schema + page-blocks.ts + BlockRenderer + prose/tracks/divider 三块 + 后台 types 声明 2d P0、P0.5 src/content.config.ts、src/lib/page-blocks.ts、src/components/blocks/*、src/components/pages/AboutPage.astro、public/admin/config.yml 断言 1/2/5/6
P2 块型扩充(cards/timeline/quote/figure/linklist/cta)+ 图标键白名单 + 图片走管线(D3) 1.5d P1 同上 + content/site/assets/ 断言 9;视觉复核一轮(glm-5.3-flash)
P3 theme.yaml + theme.ts + BaseLayout 内联变量 + 对比度闸门脚本 1d P0(净化不相关,可并行) content/site/theme.yaml、src/lib/theme.ts、src/layouts/BaseLayout.astro、scripts/build-perf/contrast-check.mjs、public/admin/config.yml 断言 7/8
P4 首页字段细化 + 抽 HomePage.astro 消双份 1d P0 src/components/pages/HomePage.astro(新)、src/pages/index.astro、src/pages/en/index.astro、site-content.ts zh/en 区块数一致;开关组合抽测
P5 开源页收尾:README 缓存移仓外 + 超时 + fallback JSON 回填 5-8 条 + include 未命中 WARN 0.5d 在途车道合并 src/lib/site-content.ts、src/data/projects-fallback.json 断言 10/11
P6 文档:docs/管理台.md(块编辑怎么用)、docs/写作指南.md(受限类 + 块字段)、public/admin/config.yml hint 全量中文 0.5d P1-P4 三文件 后台每字段都有中文 hint

合计 ≈7.2d(不含视觉复核排队)。建议顺序:P0 → P0.5 →(P1 与 P3 可并行)→ P2 → P5 → P4 → P6。P5 最便宜且能立刻消掉在途车道的生产隐患,可提前插队。


9. 明确不做(附理由)

  1. 任意 HTML/style 块:等于把灰阶骨架与 XSS 面交给后台输入;08 文档红线(禁渐变/玻璃/大圆角)无法用代码守住。
  2. 可视化栅格/自由拖拽布局(columns、grid 编辑器):Astro 侧要一套运行时布局引擎,违"构建期零运行时";且移动端塌陷规则无法交给后台。
  3. 可编辑颜色集(多个强调色/任意 hex 用于文本):全站唯一强调色是丙路线根基(tokens.css:21-22),只允许 --accent 一个旋钮并过对比度闸门。
  4. --measure / 断点可调:--measure 9 处引用、断点写死在媒体查询里;改版心会连带 TOC/栅格/图库。
  5. 任意字体名输入:字体子集化(scripts/build-perf/subset-font-css.mjs)依赖固定四族,自由输入会静默失效或拖大 CSS。
  6. 块模型用于首页:首页是聚合页(§6),结构化字段更稳、更好审。
  7. blocks_zh/blocks_en 双数组:结构会分叉。
  8. 构建期自动翻译页面文案:破坏构建确定性(产物含未提交内容),且无人校对。译文必须入仓(沿用 posts-en 的 locked: true 纪律)。
  9. 运行时/客户端渲染页面内容(含客户端拉 GitHub README):CORS、SEO、性能三杀。
  10. 后台新建任意页面路由(v1 不做):需 [...slug].astro + 导航/站点地图/双语配对/hidden 语义,收益低于成本;等关于页验证过一轮再议(那时复用同一套 BlockRenderer,成本会低很多)。
  11. 换 CMS / 上 Netlify 式可视化编辑器:现有 Sveltia 0.252 已支持 types 异构列表(§3.4),够用;迁移成本远高于收益。
  12. 每页独立 tokens(页面级主题覆盖):双层变量来源会让"某页为什么和别的页不一样"变成排查噩梦;v1 只有全局一套。

附:待用户拍板的三个岔路

  1. 关于页块模型 vs 长文:块模型(本文推荐,结构可控、视觉一致)vs 单一 markdown 长文(改起来最快,但 tracks 卡片做不出来)。
  2. README 开篇是否开:开(省手工维护,但版式受 GitHub README 影响)vs 关(intro_from: "off",全手控)。
  3. 样式旋钮范围:只给 3 个(accent/radius/字号)还是一整套 12 个(§3.6)——旋钮越多越需要对比度闸门与视觉复核,建议先给 3 个、跑稳一版再扩。