Files
blog/docs/research/10-页面后台化方案.md
T
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

395 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 页面后台化方案:内容 / 布局 / 样式的可编辑边界
> 调研 + 提案,不改代码。基线 = 工作树 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)` | 主力自由区(受 §7 净化) |
| `tracks` | `title*` `items[]{icon, name*, desc*}` | 覆盖现有兴趣版图;`icon` 走内置键白名单 |
| `cards` | `title*?` `columns(2|3)` `items[]{title*, desc*, href?, tag?}` | 技能/资源卡(复用 `.track-row` 视觉语言) |
| `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(直接照抄可跑通现有关于页)
```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 | 需给 photos schema 加 `pinned` |
| — | `heroStyle(idcard|plain)` | select | `IdCard` 加 variant |
| — | `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)**
```ts
// 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 个、跑稳一版再扩。