- 可视化选择(核心痛点解决):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
34 KiB
页面后台化方案:内容 / 布局 / 样式的可编辑边界
调研 + 提案,不改代码。基线 = 工作树 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,否则用户会填错)
mode: only时 忽略exclude;mode: exclude时忽略include(v1 不做两者求交)。include/exclude填不存在的名字 = 静默无效(后台无下拉可选,因为仓库清单是构建期才拿到);→ 建议 P2 阶段把widget: string改成widget: select, options的静态镜像(yml 里手抄一份当前仓名)不划算,v1 保持手填 + 构建期 WARN 打印未命中的名字。sort: pinned-first仍是默认(首页要"置顶在前"叙事),sort: stars|updated时pinned让位(现实现如此:arrange里 stars/updated 分支直接 sort,不看 pinned)。- 首页固定
limit=6,开源页limit=99(ProjectWall.astroprops)。若仓库总数 >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缺省/空数组 → 走旧路径(intromarkdown → 默认文案)。这样 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 现状漏洞(三条,均已定位到行)
- 白名单对页面文案不生效:
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),页面文案管线没有。 - README 是远程内容:
githubReadmeMarkdown()把网上下来的 markdown 直接进set:html(src/pages/opensource.astro:17)。即便 README 属本人仓,也等于把"远程内容"当可信 HTML 处理。 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. 明确不做(附理由)
- 任意 HTML/
style块:等于把灰阶骨架与 XSS 面交给后台输入;08 文档红线(禁渐变/玻璃/大圆角)无法用代码守住。 - 可视化栅格/自由拖拽布局(columns、grid 编辑器):Astro 侧要一套运行时布局引擎,违"构建期零运行时";且移动端塌陷规则无法交给后台。
- 可编辑颜色集(多个强调色/任意 hex 用于文本):全站唯一强调色是丙路线根基(
tokens.css:21-22),只允许--accent一个旋钮并过对比度闸门。 --measure/ 断点可调:--measure9 处引用、断点写死在媒体查询里;改版心会连带 TOC/栅格/图库。- 任意字体名输入:字体子集化(
scripts/build-perf/subset-font-css.mjs)依赖固定四族,自由输入会静默失效或拖大 CSS。 - 块模型用于首页:首页是聚合页(§6),结构化字段更稳、更好审。
blocks_zh/blocks_en双数组:结构会分叉。- 构建期自动翻译页面文案:破坏构建确定性(产物含未提交内容),且无人校对。译文必须入仓(沿用
posts-en的locked: true纪律)。 - 运行时/客户端渲染页面内容(含客户端拉 GitHub README):CORS、SEO、性能三杀。
- 后台新建任意页面路由(v1 不做):需
[...slug].astro+ 导航/站点地图/双语配对/hidden语义,收益低于成本;等关于页验证过一轮再议(那时复用同一套 BlockRenderer,成本会低很多)。 - 换 CMS / 上 Netlify 式可视化编辑器:现有 Sveltia 0.252 已支持
types异构列表(§3.4),够用;迁移成本远高于收益。 - 每页独立 tokens(页面级主题覆盖):双层变量来源会让"某页为什么和别的页不一样"变成排查噩梦;v1 只有全局一套。
附:待用户拍板的三个岔路
- 关于页块模型 vs 长文:块模型(本文推荐,结构可控、视觉一致)vs 单一 markdown 长文(改起来最快,但 tracks 卡片做不出来)。
- README 开篇是否开:开(省手工维护,但版式受 GitHub README 影响)vs 关(
intro_from: "off",全手控)。 - 样式旋钮范围:只给 3 个(accent/radius/字号)还是一整套 12 个(§3.6)——旋钮越多越需要对比度闸门与视觉复核,建议先给 3 个、跑稳一版再扩。