Author SHA1 Message Date
wait4xandGLM-5.3 3e09a362cc refactor(home): 首页三字段默认值收敛到 SITE_DEFAULTS(5g T4 补)
- SITE_DEFAULTS 新增 homeProjectsLimit=6 / homeAnnouncement(空) /
  homePostsTracks(全开),访问器改为引用默认常量而非散落字面量
- 旧 yaml(无三字段)零回归实测:公告不渲染、项目墙默认 6 条、三轨全开

验证: 构建 48 页 exit 0;E2E 37/37(快照口径)

Spec: 2026-11-02_blog-5g-pages (T3/T4 补充)

Co-authored-by: GLM-5.3
2026-11-02 19:02:41 +08:00
wait4xandGLM-5.3 8debd99e61 feat(theme-home): 样式即数据 + 首页抽组件去重(5g T3/T4)
【T3 样式即数据】
- content/site/theme.yaml 九组白名单旋钮(accent 明暗两值 / radius_img /
  text_h1-h3 / prose_size_reading|tech / leading_reading|tech /
  space_3-6 / flow_p 与 flow_h2_post / dur);--measure、断点、纸墨、
  code 配色按方案 §3.6 红线不开放
- src/lib/theme.ts:getTheme() 钳制 + 单调性校验(h1>h2>h3、
  space3<4<5<6 任一不满足整组回落默认);themeVarsCss() 产出
  `:root:root{…}` 与 `[data-theme="dark"]:root:root{…}`,靠特异性压过
  tokens 的 :root,不依赖注入顺序
- BaseLayout head 内联 `<style is:inline set:html={themeStyle}>`:构建期
  静态、运行时零 JS 零请求
- scripts/build-perf/contrast-check.mjs 读产物断言正文/强调 × 亮暗四项
  ≥4.5:1,不达标 exit 1;build.sh 守卫挂载([ -f ] 守卫先行)
  实测:默认 12.40/10.53/6.30/7.06 全过;负例 accent #cccccc → 1.61:1 exit 1
  两坑入档:tokens 的 --bg 是 3 位简写需归一化;选 tokens CSS 要用
  `--bg:#` 定义断言(includes('--bg') 会误命中 var(--bg-soft) 引用)
- 后台 theme 单例 17 字段全中文 hint + 取值范围(分组约束写进 hint)

【T4 首页去重与细化】
- 新增 src/components/pages/HomePage.astro 承接全部区块(名片区富文本
  tagline/subtitle/社交、公告、文章列表、最新影像、热力图、项目墙、
  心跳),locale 一统;en 轨吃 postsEn 译题映射、图库链走 /en/gallery/,
  心跳仍仅 zh(零回归)
- 两 index.astro 缩成 4 行薄壳,双份漂移隐患消除
- home.yaml 三字段:projectsLimit(1-99 夹取,缺省 6)、announcement
  (双语 markdown 走 renderRich,空则不渲染)、postsTracks(三轨开关,
  在 HomePage 内过滤而非改 PostList,守边界)

验证: 构建 48 页 exit 0;contrast-check 四项 PASS;E2E 37/37(快照口径);
      实测 theme 变量内联、公告 markdown 粗体渲染、projectsLimit 限流、
      photo 轨关闭生效;验收数据已还原

Spec: 2026-11-02_blog-5g-pages (T3/T4)

Co-authored-by: GLM-5.3
2026-11-02 18:58:11 +08:00
wait4xandGLM-5.3 be87917443 chore(spec): 勾选已收编任务 T1/T2/T5/T7(T3/T4/T6 在途)
Spec: 2026-11-02_blog-5g-pages

Co-authored-by: GLM-5.3
2026-11-02 18:42:36 +08:00
wait4xandGLM-5.3 aaa9cd9274 feat(typography): 全站三层排版——富文本/页面级覆盖/艺术字体类
- 层 1 所有页面文案改富文本:site-content 新增 renderRich(md,{inline})
  复用既有全管线(raw→内容净化→class 白名单→链接卡片);接入
  home.tagline/subtitle、opensource.blurb、about.intro、blocks prose;
  新增 .rich 容器作用域并与 .prose-body 合并(77 处选择器覆盖四类容器)
  坑:renderRich 输出 <p> 塞进 <p class="tagline"> 会被浏览器拆散 →
  行内场景用 {inline:true} 剥外层 <p>
- 层 2 页面级排版覆盖:三单例加 typography{font?,accent?,scale?},读取器
  pageTypography() 走白名单(10 字族 / 18 色类,scale 夹 0.8–1.25,清单外
  忽略);页面容器挂 .page-typo + CSS 变量 --page-font/--page-accent/
  --page-scale,不动全站 tokens;后台三页加「页面排版覆盖」下拉
- 层 3 艺术字体:新增 font-art-kuaile(快乐体专用类,字距微调),白名单与
  可视化面板同步;站酷高端黑/阿里妈妈东方大楷/演示悠然小楷因无 npm 包或
  嵌入条款风险进候补清单
- 默认值清理:about.yaml 的 typography 测试值(整页汇文)清为注释示例——
  整页换字族会带来 694KB 字体成本,默认留空=全站默认排版

验证: 构建 48 页 exit 0;E2E 37/37;三页 page-typo 挂载与变量实测、四页
      (图库/书架/文章/装备)隔离为 0、font-art-kuaile 生效、about 清空后
      无覆盖

Spec: none (排版三层,先于 5g 的任务;T3/T4 另行交付)

Co-authored-by: GLM-5.3
2026-11-02 18:40:32 +08:00
wait4xandGLM-5.3 2f7cbcf7bd 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
2026-11-02 18:21:33 +08:00
wait4xandGLM-5.3 6096a9dafe feat(opensource): README 四级回落 + 兜底 JSON 8 仓 + 项目可见性告警
- site-content.ts:README 取数加 AbortSignal.timeout(10s)(原无超时,
  慢端点可卡整条构建);四级回落矩阵逐级 console.warn 点名原因:
  新鲜网络(内容有变才回写缓存)→ token 缺位/HTTP 非 2xx/空/超时/异常
  时吃缓存 → 缓存亦缺回落 yaml blurb → 缓存回写失败(构建仓只读)仅告警
- README 图片加固:markdown 层给每个 img 补 loading=lazy + 缺省 alt
  (幂等;只动这两个属性,因为过 rehype-content-sanitize 白名单后其余
  属性会被剥);相对链接不重写,限制写入 JSDoc 与文档
- projects.ts:单源失败点名、双源全失败点名回落 JSON、mode=only 的
  include 未命中双向点名(双源无此仓 / 被 perSource 过滤掉)
- projects-fallback.json:1 → 8 条真实仓(blog / open-earth-data-guide★58
  / AI-CLI / open-earth-data-kit / pi-mesh / pi-board / pi-jobd /
  ai-code-style-kit,字段对齐 RepoCard)

车道:worker-one(T5);收编复验:E2E 37/37、构建 48 页 0 ERROR、
兜底 JSON 8 条断言过

验证: E2E 37/37 PASS;pnpm astro build 48 页 exit 0;fallback 8 条

Spec: 2026-11-02_blog-5g-pages (T5)

Co-authored-by: GLM-5.3
2026-11-02 18:13:47 +08:00
wait4xandGLM-5.3 59eda420b3 perf(fonts): 字体子集化工具 + 汇文明朝单页 -40%(694KB→420KB)
- 新增 scripts/build-perf/subset-fonts.py:读字体 CSS 的 @font-face
  (unicode-range)→ 扫内容收集实际用字 → 逐片交集裁切(pyftsubset
  --flavor=woff2)→ 空片丢弃、重写 src 只留自产 woff2;支持
  --chars-from/--chars-mode(all|headings)/--min-bytes/--merge
- 实测口径修正后结论(关键):汇文明朝原 694KB/页 → 子集 420KB/页
  (-40%,真收益,已接入);站酷快乐体 163KB → 177KB(+9%,字跨片
  分散致片数增加,**不启用**,保留原 @fontsource 源)
- merge 模式(合并单文件)标记实验性:fontTools.merge 对 CJK 切片字表
  冲突敏感,实测产出异常,生产不用
- 三处实现坑入档:①hast 与 @fontsource CSS 一个 face 常带多格式
  (woff2+woff),只替换首个 url 会残留失效引用(首版缺 58 个 .woff)
  → 改整段替换 src;②无 unicode-range 的 face 需按全范围处理,否则
  原样保留同样残留引用;③裸跑 pyftsubset 需 fonttools+brotli

验证: pnpm astro build 48 页 exit 0;E2E 37/37;playwright 实测汇文
      694KB→420KB 且字体栈生效(WF-Hwmc 命中)

Spec: 2026-11-02_blog-5g-pages (T7)

Co-authored-by: GLM-5.3
2026-11-02 18:13:47 +08:00
wait4xandGLM-5.3 740ec470fa chore(spec): 立项 blog-5g——页面后台化(blocks)与字体子集化
按 docs/research/10 方案 E 拆七任务:块式页面构建器(T1/T2)、样式即数据
+对比度闸门(T3)、首页抽 HomePage 去双份(T4)、开源页收尾(T5)、文档
与 hint(T6)、字体子集化工具(T7)。三车道分配:op-gitea(T1/T2)、
op-artalk(T3/T4)、worker-one(T5/T6)、orchestrator(T7+统筹验收)。

Spec: 2026-11-02_blog-5g-pages

Co-authored-by: GLM-5.3
2026-11-02 17:50:19 +08:00
wait4xandGLM-5.3 3822e150a8 feat(typography): 首批 6 字族落地(新增汇文明朝/站酷快乐)+ 13 款候补清单
- 新增两款自托管字族:font-huiwen(汇文明朝体,文风开源 cn-font-split
  339 片切片包,用到页 +535KB、按需拉取实测 19/339 片)|font-kuaile
  (站酷快乐体 +163KB)——补齐「宋明气质」与「轻松标题」两个字体缺口
- 首批 6 族全部 OFL-1.1 且允许 web 嵌入;未用字体的页面增量恒 +0.0KB
  (按页子集化生效)
- 候补 13 款逐款标注「许可证 + web 嵌入」:HarmonyOS Sans/MiSans/OPPO
  Sans/阿里普惠体(免费商用 + 明确允许嵌入,但 npm 无包/整包过大)、
  得意黑/更纱/站酷高端黑/花园明朝(OFL 但无可用 npm 分发)、霞鹜文楷
  Light(+8605KB 否决)、演示悠然小楷("个人免费"常禁 @font-face,风险)
- docs 写入「加字四步流程」(核双授权 → 装包或放 public/fonts → 加类+
  白名单+面板项 → 跑体积账)
- 可视化面板 35 项(10 字族 + 25 颜色);白名单放行 10 字族类
- 资产:public/fonts/hwmc(339 woff2 13MB + fonts.css 162KB)入仓

车道:op-artalk;收编复验:构建 48 页、E2E 37/37、两款新字体 computed
与切片按需加载实测(hwmc/27、52 等 20 个请求)

验证: pnpm astro build 48 页 exit 0;E2E 37/37;playwright 新字体 computed
      + 字体请求观测

Spec: none (字体扩展,车道级交付)

Co-authored-by: GLM-5.3
2026-11-02 17:47:13 +08:00
wait4xandGLM-5.3 26bbb66a72 feat(typography): 16 类语义色板(8 色相×2 档)+ 字体回退升级 + 面板 v2
- 颜色:c-{blue,teal,green,yellow,orange,red,pink,purple} × {标准,soft}
  共 16 类;tokens 明暗各一套值,32 项浏览器实测对比度 4.50–11.73(零失败);
  黄色压到 #7a5900(原色白底仅 ~1.8:1)、黄/粉 soft 压深留余量
- 使用纪律入档:彩色只作用于正文强调,站点外壳(导航/页脚/按钮/背景/边框)
  保持灰阶,单页彩色强调建议 ≤3 处
- 字体:3/4 回退类升级为「自托管优先 + 系统兜底」(font-song→Noto Serif SC、
  font-kai-sys→霞鹜文楷、font-hei-sys→Noto Sans SC,+0.0KB 跨设备一致);
  仿宋无 OFL 自托管包(fandol/朱雀仿宋均无版本)→ 纯系统回退,缺口待决策
- webfont 候选全部按体积账否决并留档:霞鹜文楷 Light 用到页 +8605KB、站酷小薇
  +648KB、得意黑/更纱/汇文无可用 npm 包;根因=@fontsource CJK 切片粒度
- 可视化面板 v2:33 项(8 字族 + 25 颜色),颜色项带 16px 色块预览、字体项带
  示例字形「秋海」并标注自托管/优先/系统;桩 CMS 7 项验证全过
- 白名单放行 25 色类 + 8 字族类;docs 三表(灰阶层级/语义色板含对比度/字体
  含许可证与体积账)更新

车道:op-artalk(改口径后重跑,实测表齐全);收编复验:CSS 16 色类定义与
白名单一致、暗态值在 [data-theme="dark"] 段、字体回退类 computed 生效、
构建 48 页、E2E 37/37

验证: pnpm astro build 48 页 exit 0;E2E 37/37;playwright 抽查彩色类与
      字体回退 computed

Spec: none (排版色板与字体回退,车道级交付)

Co-authored-by: GLM-5.3
2026-11-02 17:14:12 +08:00
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
wait4xandGLM-5.3 c5cdcd5bf9 feat(opensource): README 开篇接线+缓存入仓(收编遗漏补齐)
zh 开源页补收编(此前只提交了 en):githubReadmeMarkdown + renderMarkdown
接线,content/.github-readme.md 缓存入仓(现状 intro_from=off,开启即生效)。

验证: 构建 48 页 exit 0

Spec: none (收编遗漏补齐)

Co-authored-by: GLM-5.3
2026-11-02 16:57:53 +08:00
wait4xandGLM-5.3 b0a3881725 fix(security): 页面文案 markdown 管线加标签/属性/协议净化(P0)
方案 10 §7.1 实锤漏洞:renderMarkdown() 用无参 createMarkdownProcessor,
默认 allowDangerousHtml + 无条件 rehype-raw,页面文案(about intro、开源页
叙述、README)的 raw HTML 直通 set:html——script/style/iframe/on* 全不剥。

- 新增 src/lib/rehype-content-sanitize.ts:标签白名单(不含 script/style/
  iframe/object/form/link/meta/svg 等,危险标签整棵丢弃、未知标签解包保内容)
  + 属性白名单(a[href,title,rel]、img[src,alt,width,height,loading]、class/id)
  + 协议白名单(http/https/mailto + 站内相对 + 小图 data:)
- site-content.ts 插件链与文章管线同序:rehypeRaw → 内容净化 → class 白名单
  → 链接卡片(副作用为正:页面文案里的独立链接也会成 OG 卡片)
- 修两处自身实现坑(实测驱动):hast 的 class 属性名是 className(按 class
  过滤会把合法排版类一起剃掉);属性白名单需统一小写比较(驼峰 key 比不到)

验证: 注入载荷实测(<script>/<iframe>/onclick/style="color:red"/
      href="javascript:")全部剥离、合法 class="ink-2" 与文本保留;
      构建 48 页 exit 0;E2E 37/37

Spec: none (安全修复,方案 P0)

Co-authored-by: GLM-5.3
2026-11-02 16:57:52 +08:00
486 changed files with 3753 additions and 361 deletions

No files matched your search

@@ -0,0 +1,84 @@
# Phase 5g:页面后台化(blocks 模型)与字体子集化 - 项目范围
<!-- spec-format: 2 -->
## 1. 问题定义
- **项目目标**:①按 docs/research/10 方案 E 把关于页改造成「块式页面构建器」(结构可增删排序)②样式即数据(theme.yaml → 构建期 CSS 变量,含对比度闸门)③首页字段细化并抽 HomePage 消 zh/en 双份 ④开源页收尾(README 缓存/回落/告警)⑤后台文档与 hint 补齐 ⑥字体子集化工具(把艺术字体单页增量从数百 KB 降到数十 KB)
- **目标用户**:站长(希望整页可编辑、能上更多艺术字体)与读者(版式与可读性不退化)
- **核心价值**:内容与结构的编辑权交回后台,同时守住灰阶体系/双语/SEO/构建期零运行时四条红线
## 2. 假设与待确认
### 2.1 已确认事实
- Sveltia 0.252 支持 list 的 types + typeKey(方案 10 §3.4 从本仓 bundle 取证),块模型可行
- 页面文案 markdown 净化(D1/D2)已在 2cab243 落地(P0 完成)
- 字体实测:@fontsource CJK 切片粒度导致单页 +163KB~+8605KB;现有 6 自托管族为基线
### 2.2 关键假设
- 块渲染器受控,破版风险低于整页 HTML 直编(方案 10 §2 对比表)
- 字体子集化(pyftsubset 按页实际用字)能把艺术字体单页增量压到 ≤150KB 阈值内
### 2.3 待确认问题
- [x] 关于页整页可编辑路线 → 用户 2026-11-02 批准方案 E(blocks + theme.yaml + 局部自由区)
- [x] 字体扩充路线 → 先落 6 族,其余按子集化工具就绪后再评估(本包 T7 交付工具)
### 2.4 可选解释与取舍
- 关于页保留 intro markdown 通道作为 compat(blocks 为空时三级回落:blocks → intro → 默认文案)
- 样式即数据只开放白名单旋钮(约 12 项),不开放整份 tokens.css
- 图片只走 Astro image() 管线,禁止块内填外链/绝对路径(schema 层拦)
### 2.5 已证伪路径
- 整页富文本直编(C 方案):XSS 面大、无设计兜底,弃
- 新增 webfont 直接上(不子集化):单页 +535KB~+8605KB,超阈值,弃(改走 T7 工具)
## 3. 功能范围
### 3.1 核心功能(MVP)
- [ ] T1 blocks 骨架:schema(discriminatedUnion)+ page-blocks.ts + BlockRenderer + prose/tracks/divider 三块 + 后台 types 声明
- [ ] T2 块型扩充:cards/timeline/quote/figure/linklist/cta + 图标键白名单 + 图片 image() 管线
- [ ] T3 样式即数据:theme.yaml + theme.ts + BaseLayout 内联变量 + 对比度闸门脚本
- [ ] T4 首页细化与去重:抽 HomePage.astro 消 zh/en 双份 + 字段细化(projectsLimit/announcement 等)
- [ ] T5 开源页收尾:README 缓存策略/超时/回落矩阵 + fallback JSON 回填 + include 未命中 WARN
- [ ] T6 文档与 hint:docs/管理台.md(块编辑用法)+ 写作指南扩充 + admin 全字段中文 hint
- [ ] T7 字体子集化工具:按页用字裁剪艺术字体为小切片 + build.sh 守卫挂载 + 实测对比
### 3.2 扩展功能
- 无
### 3.3 不在范围内
- 换 CMS、页面布局的任意拖拽网格、运行时编辑器
## 4. 最小实现路径
- 最简单可行方案:块类型先用 3 类跑通闭环(prose/tracks/divider),再扩到 9 类;theme 只做 12 个白名单旋钮
- 暂不引入:可视化块预览、块级动画配置
- 不做的抽象:通用页面 DSL(只服务现有页面)
## 5. 技术决策
- 技术栈:Astro 7.4.4 + zod discriminatedUnion + Sveltia list/types + pyftsubset(fonttools)
- 证据协议:evidence: runs
- 本轮允许改动:src/content.config.ts、src/lib/{page-blocks,theme,site-content}.ts、src/components/blocks/*、src/components/pages/{AboutPage,HomePage}.astro、src/pages/{index,en/index,opensource,en/opensource}.astro、src/layouts/BaseLayout.astro、public/admin/*、content/site/*.yaml、scripts/build-perf/*、infra/deploy/build.sh、docs/*、tests/e2e.mjs
- 本轮不应触碰:nginx/ESA、图库灯箱(GalleryPage/PhotoZoom)、Header/Footer 结构
- Git integration branch:`spec/2026-11-02_blog-5g-pages`
### 5.1 验证策略
- 范围级别:package
- 变更对象:页面数据模型与渲染器、后台配置、样式变量生成、字体产物、文档
- 快速检查:`pnpm astro build`(node26)
- 集成检查:`node tests/e2e.mjs`(37 项,本包预期新增块/主题断言)+ content-lint 0/0/0
- 全项目检查:字体体积账(三页增量)+ 视觉复核(glm-5.3-flash:max,块版式与主题变量双态);仅在构建链或 BaseLayout 结构再变更时升级范围
- 升级触发:若 theme 变量波及全局排版(字体字号全站变化)或 blocks 改动触及首页/文章页结构,升级为全站视觉复核与 E2E 全量并记录理由
## 6. 成功标准与验证方式
- [ ] 关于页整页由 yaml blocks 驱动,增删排序生效 -> verify: task_0001
- [ ] 九类块全部可渲染且旧内容零回归(三级回落) -> verify: task_0002
- [ ] theme.yaml 改值 → 产物 CSS 变量变化,对比度闸门拦截不达标 -> verify: task_0003
- [ ] 首页 zh/en 由同一组件渲染且字段可配 -> verify: task_0004
- [ ] 开源页 README 失败矩阵与 fallback 告警可用 -> verify: task_0005
- [ ] 后台文档与 hint 完整 -> verify: task_0006
- [ ] 字体子集化工具把指定字体单页增量压到 ≤150KB -> verify: task_0007
## 7. 风险与约束
- 灰阶体系与双语红线不变;彩色仅正文强调
- 块渲染器未知类型跳过 + WARN,绝不炸构建
- 字体子集化需保证字形覆盖(裁错字会出现豆腐块)→ 断言:子集后页面文本逐字命中
- 多车道并行:共享文件(content.config.ts/admin config/docs)按 boundary 划界,tokens/global 由 orchestrator 或指定单车道
@@ -0,0 +1,42 @@
# Phase 5g:页面后台化(blocks 模型)与字体子集化 - 任务拆解
## 使用规则
- boundary/verify 齐备;verify 只读断言;车道工作树开发禁 git,收编由 orchestrator 统一提交
- 构建一律 `export PATH="$HOME/.local/node/bin:$PATH"`(Node 26)
## 阶段一:实现
- [x] T1 blocks 骨架(关于页块式页面构建器)
- claim: op-gitea
- boundary: src/content.config.ts(siteAbout 加 blocks: z.array(discriminatedUnion))、新增 src/lib/page-blocks.ts(getAboutBlocks:过滤 enabled、未知 type 跳过+WARN、文本 pickBi 回落、块数≤24/单块≤4000 字)、新增 src/components/blocks/{BlockRenderer,Prose,Tracks,Divider}.astro、AboutPage.astro 改由 blocks 驱动(blocks 空→intro markdown→默认文案三级回落)、public/admin/config.yml 加 list+types(typeKey: type)
- verify: `E2E_BASE=http://127.0.0.1:18924 node tests/e2e.mjs 2>&1 | grep -q "37/37 PASS" && grep -q "discriminatedUnion" src/content.config.ts && grep -q "BlockRenderer" src/components/pages/AboutPage.astro && grep -q "typeKey" public/admin/config.yml`
- [x] T2 块型扩充与图片管线
- claim: op-gitea
- boundary: 新增 src/components/blocks/{Cards,Timeline,Quote,Figure,LinkList,Cta}.astro + 图标键白名单(复用 SocialLinks 注册表或新建 icons 注册表)、schema 块型补全(图片字段用 image(),禁止外链字符串)、content/site/assets/ 目录、near docs 图表说明
- verify: `E2E_BASE=http://127.0.0.1:18924 node tests/e2e.mjs 2>&1 | grep -q "37/37 PASS" && ls src/components/blocks/ | grep -qC1 "Cards\|Timeline" && grep -q "image()" src/content.config.ts`
- [x] T3 样式即数据(theme.yaml + 对比度闸门)
- claim: op-artalk
- boundary: 新增 content/site/theme.yaml(12 个白名单旋钮:prose-size/measure/radius/accent/leading 等)、新增 src/lib/theme.ts(getTheme + themeVarsCss 生成 :root:root{…})、BaseLayout.astro head 内联 theme 变量(构建期静态)、新增 scripts/build-perf/contrast-check.mjs(断言 theme 结果下正文对比度 ≥4.5)、public/admin/config.yml 加 theme 单例、build.sh 守卫挂载
- verify: `E2E_BASE=http://127.0.0.1:18924 node tests/e2e.mjs 2>&1 | grep -q "37/37 PASS" && test -f content/site/theme.yaml && node scripts/build-perf/contrast-check.mjs dist`
- [ ] T4 首页细化与去重
- claim: op-artalk
- boundary: 新增 src/components/pages/HomePage.astro(承接现 index.astro 区块逻辑,locale prop)、src/pages/{index,en/index}.astro 改为薄壳、home.yaml 增 projectsLimit / announcement(markdown)/ postsTracks(轨开关细分),site-content 访问器同步
- verify: `E2E_BASE=http://127.0.0.1:18924 node tests/e2e.mjs 2>&1 | grep -q "37/37 PASS" && test -f src/components/pages/HomePage.astro && grep -q "HomePage" src/pages/en/index.astro`
- [x] T5 开源页收尾(缓存/回落/告警)
- claim: worker-one
- boundary: src/lib/site-content.ts(README 缓存策略与超时、回落矩阵)、content/.github-readme.md 与 README HTML 剥离策略(复用 rehype-content-sanitize 已挡危险标签;补: 外链徽章不阻断)、src/data/projects-fallback.json 回填 5-8 条真实仓、projects.ts include 未命中 console.warn
- verify: `E2E_BASE=http://127.0.0.1:18924 node tests/e2e.mjs 2>&1 | grep -q "37/37 PASS" && python3 -c "import json;d=json.load(open('src/data/projects-fallback.json'));assert len(d)>=5"`
- [ ] T6 文档与后台 hint 补齐
- claim: worker-one
- boundary: docs/管理台.md(块编辑怎么用、增删排序、预览限制)、docs/写作指南.md(块字段表/受限类/主题项)、public/admin/config.yml 全字段中文 hint(含 T1-T4 新增字段)
- verify: `test -f docs/管理台.md && grep -q "blocks\|区块" docs/管理台.md && grep -c "hint" public/admin/config.yml | awk '$1>=20'`
- [x] T7 字体子集化工具
- claim: orchestrator
- boundary: 新增 scripts/build-perf/subset-fonts.py(pyftsubset 按指定字体 + 页面实际用字生成子集 woff2 + 重写 fonts.css 指向子集文件)、infra/deploy/build.sh 守卫挂载、docs/写作指南加字流程补"子集化"一节
- verify: `test -f scripts/build-perf/subset-fonts.py && python3 -c "import fontTools; print('fonttools ok')" && E2E_BASE=http://127.0.0.1:18924 node tests/e2e.mjs 2>&1 | grep -q "37/37 PASS"`
+66
View File
@@ -0,0 +1,66 @@
<div align="center">
<img src="https://readme-typing-svg.demolab.com?font=Fira+Code&weight=600&size=26&duration=3000&pause=1000&color=00D9FF&center=true&vCenter=true&random=false&width=620&lines=Hi+there+%F0%9F%91%8B+I'm+wait4x;Open+Source+Enthusiast+%E2%9D%A4%EF%B8%8F;Meteorology+%E2%9B%85" alt="Typing SVG" />
<br>
<a href="https://github.com/wait4xx?tab=followers"><img src="https://img.shields.io/github/followers/wait4xx?label=Followers&style=for-the-badge&color=00D9FF&labelColor=0D1117" /></a>
<a href="https://github.com/wait4xx?tab=stars"><img src="https://img.shields.io/github/stars/wait4xx?label=Stars&style=for-the-badge&color=00D9FF&labelColor=0D1117" /></a>
<a href="https://github.com/wait4xx"><img src="https://img.shields.io/badge/Open%20Source-%E2%9D%A4%EF%B8%8F-00D9FF?style=for-the-badge&labelColor=0D1117" /></a>
</div>
---
### 👋 About Me
- ⛅ An aspiring forecaster rooted in **meteorology**.
- 💻 Passionate about **computer science** and coding.
- 🌐 My web: **[wait4x.online](https://wait4x.online/)**
- ⚡ *"Embrace open source, love open source."*
---
### 📊 GitHub Stats
<div align="center">
<img height="170" src="https://github-readme-stats.vercel.app/api?username=wait4xx&show_icons=true&hide_border=true&bg_color=0D1117&title_color=00D9FF&icon_color=00D9FF&text_color=C9D1D9&include_all_commits=true&count_private=true&line_height=28" alt="stats" />
<img height="170" src="https://github-readme-stats.vercel.app/api/top-langs/?username=wait4xx&layout=compact&hide_border=true&bg_color=0D1117&title_color=00D9FF&text_color=C9D1D9&langs_count=8" alt="top-langs" />
</div>
<br>
<div align="center">
<img src="https://streak-stats.demolab.com?user=wait4xx&hide_border=true&background=0D1117&ring=00D9FF&fire=00D9FF&currStreakLabel=00D9FF&sideLabels=C9D1D9&dates=C9D1D9&currStreakNum=C9D1D9" alt="streak" />
</div>
---
### 📈 Activity Graph
<div align="center">
<img src="https://github-readme-activity-graph.vercel.app/graph?username=wait4xx&bg_color=0D1117&color=C9D1D9&line=00D9FF&point=00D9FF&area=true&area_color=00D9FF&hide_border=true" alt="activity-graph" />
</div>
---
### 🐍 Contribution Snake
<div align="center">
<img src="https://raw.githubusercontent.com/wait4xx/wait4xx/output/github-contribution-grid-snake.svg" alt="snake" />
</div>
> *This snake is auto-fed every day by a GitHub Action in this repo 🍎*
---
### 📫 Find Me
<div align="center">
<a href="https://github.com/wait4xx"><img src="https://img.shields.io/badge/GitHub-181717?style=for-the-badge&logo=github&logoColor=white" /></a>
<a href="mailto:X@alumni.nudt.edu.cn"><img src="https://img.shields.io/badge/Email-D14836?style=for-the-badge&logo=gmail&logoColor=white" /></a>
</div>
---
<div align="center">
<img src="https://komarev.com/ghpvc/?username=wait4xx&color=00D9FF&style=flat-square&label=%F0%9F%91%80+Visitors" />
<br><br>
<img src="https://readme-typing-svg.demolab.com?font=Fira+Code&size=14&duration=2000&pause=500&color=8B949E&center=true&vCenter=true&random=false&width=300&lines=Thanks+for+visiting!;See+you+in+the+commits+%F0%9F%8C%BF" alt="footer" />
</div>
+33
View File
@@ -0,0 +1,33 @@
---
title: "字体族样例"
pubDate: 2026-11-02
category: reading
tags: [test, hidden]
draft: false
---
字体测试样张(hidden):验证系统回退字族的渲染。
<p class="font-song">宋体族(系统回退):秋天的海面像一块被风抚平的铁皮。</p>
<p class="font-kai-sys">楷体族(系统回退):秋天的海面像一块被风抚平的铁皮。</p>
<p class="font-hei-sys">黑体族(系统回退):秋天的海面像一块被风抚平的铁皮。</p>
<p class="font-fangsong">仿宋族(系统回退):秋天的海面像一块被风抚平的铁皮。</p>
<p class="ink-warm">暖调正文:秋天的海面像一块被风抚平的铁皮。</p>
<p class="ink-cool">冷调正文:秋天的海面像一块被风抚平的铁皮。</p>
<p class="ink-faint">最淡可读档:秋天的海面像一块被风抚平的铁皮。</p>
<p class="ink-accent-soft">弱强调:秋天的海面像一块被风抚平的铁皮。</p>
<p class="c-blue">蓝色标准:秋天的海面像一块被风抚平的铁皮。</p>
<p class="c-yellow">黄色标准(已压暗):秋天的海面像一块被风抚平的铁皮。</p>
<p class="c-red-soft">红色 soft:秋天的海面像一块被风抚平的铁皮。</p>
<p class="font-huiwen">汇文明朝体(OFL,切片自托管):秋天的海面像一块被风抚平的铁皮。</p>
<p class="font-kuaile">站酷快乐体(OFL,艺术字):秋天的海面像一块被风抚平的铁皮。</p>
+68 -6
View File
@@ -1,9 +1,17 @@
# 关于页正文(后台「页面文案 → 关于页」可改,markdown 编辑)
# intro = 多段 markdown 正文,渲染在「关于」标题下方、名片之前;留空回落默认文案
# 支持的语法:段落、列表、粗斜体、行内代码、链接(渲染走 Astro 的 markdown 管线)
intro:
# 关于页(后台「页面文案 → 关于页」可改)
# ── 一级:blocks 块式正文(按顺序渲染,可拖拽排序;某块 enabled: false 即隐藏)
# ── 二级:intro 多段 markdown(仅在 blocks 为空时使用)
# ── 三级:内置默认文案(两者都空时兜底,构建永不炸)
layout:
# 名片区(头像/名字/身份语/社交)开关:头像等数据事实源在「主页设置」
showIdCard: true
blocks:
- type: prose
width: narrow
body:
zh: |
写作者 · 读者 · 快门按压者,拥抱开源。
<span class="font-huiwen c-red">写作者</span> · 读者 · 快门按压者,拥抱开源。
这里是我三条轨道的公开工作台:**读过的书**留下评注,**写过的代码**进项目墙,**按下的快门**收入图库。
@@ -15,7 +23,7 @@ intro:
本站本身即开源,构建与发布全链都在仓里。
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.
@@ -26,3 +34,57 @@ intro:
- 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.
- 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)
# 页面级排版覆盖(后台「关于页 → 页面排版覆盖」可改):
# 整页换字族会带来字体体积成本(如 font-huiwen 整页 ≈694KB),默认留空=全站默认排版
# typography:
# font: font-huiwen # 字族:font-serif/font-sans/font-kai/font-huiwen/font-kuaile…
# accent: c-red # 强调色类:c-blue/c-red/…(外壳仍灰阶)
# scale: 1.05 # 字号缩放 0.8–1.25
+37
View File
@@ -0,0 +1,37 @@
# 主题旋钮(样式即数据 · 层 4):只放白名单内的 9 组旋钮。
# 红线:--measure(版心)与断点/纸墨/code 配色不在此列——它们牵动版面结构与分轨身份。
# 全部留空 = 沿用 src/styles/tokens.css 的既有值(零配置零变化)。
# 钳制与单调性在 src/lib/theme.ts 里做:越界值被夹取而非报错,构建不会因主题配置失败。
# ① 强调色(明/暗两值;构建期闸门校验正文对比度 ≥4.5,不达标回落默认)
accent_light: '#3454d1'
accent_dark: '#8ea2ff'
# ② 图片圆角(4–16;08 文档裁定上限 14)
radius_img: 10
# ③ 标题字号(22–32;必须 h1 > h2 > h3,否则整体回落默认)
text_h1: 27
text_h2: 22
text_h3: 19
# ④ 正文字号(16–22)
prose_size_reading: 20
prose_size_tech: 19
# ⑤ 正文行高(1.5–2.0)
leading_reading: 1.75
leading_tech: 1.7
# ⑥ 间距梯度(8–80;必须严格递增 3 < 4 < 5 < 6,否则回落默认)
space_3: 16
space_4: 24
space_5: 40
space_6: 64
# ⑦ 段落流距(0–2em)
flow_p: 0.9
flow_h2_post: 0.6
# ⑧ 过渡时长(150–600ms)
dur: 350
+394
View File
@@ -0,0 +1,394 @@
# 页面后台化方案:内容 / 布局 / 样式的可编辑边界
> 调研 + 提案,不改代码。基线 = 工作树 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 个、跑稳一版再扩。
+106 -6
View File
@@ -88,19 +88,88 @@ https://astro.build/
正文里可用 `<span class="…">` / `<p class="…">` 做**受限**排版。构建期有白名单过滤:**只放行下表类名,其它 class 与 `style`、`on*` 属性一律被剥离**(防注入 + 保护灰阶骨架)。
### 灰阶层级类(全部实测亮暗对比度 ≥4.5:1)
| 类名 | 作用 |
|---|---|
| `ink-1` / `ink-2` / `ink-3` | 指定灰阶层(正文/次要/更淡) |
| `ink-1` / `ink-2` / `ink-3` | 指定灰阶层(标题/正文/次要) |
| `ink-muted` | 最淡灰(注释感) |
| `ink-accent` | 强调色(链接色,仅少量点缀) |
| `font-serif` | 思源宋(书评正文族) |
| `font-kai` | 霞鹜文楷(金句族) |
| `font-sans` | 思源黑(UI/技术族) |
| `font-mono` | 等宽族(标签/数值) |
| `ink-faint` | 最淡**可读**档(再淡即不达 AA) |
| `ink-warm` | 暖调正文(书评气质) |
| `ink-cool` | 冷调正文(技术气质) |
| `ink-accent` | 强调(链接色,少量点缀) |
| `ink-accent-soft` | 弱强调(比强调淡一档) |
### 语义色板(8 色相 × 标准/soft;亮暗各自取值,实测全部 ≥4.5:1)
| 色相 | 标准类 | soft 类 | 亮态对比度(标准 / soft) | 暗态(标准 / soft) |
|---|---|---|---|---|
| 蓝 | `c-blue` | `c-blue-soft` | 6.46 / 4.58 | 7.70 / 4.61 |
| 青 | `c-teal` | `c-teal-soft` | 5.00 / 4.57 | 9.77 / 4.52 |
| 绿 | `c-green` | `c-green-soft` | 5.38 / 4.67 | 10.38 / 4.67 |
| 黄 | `c-yellow` | `c-yellow-soft` | 6.45 / 4.57 | 11.73 / 4.52 |
| 橙 | `c-orange` | `c-orange-soft` | 6.26 / 4.65 | 9.44 / 4.58 |
| 红 | `c-red` | `c-red-soft` | 6.68 / 4.55 | 7.51 / 4.50 |
| 粉 | `c-pink` | `c-pink-soft` | 6.96 / 4.73 | 8.87 / 4.52 |
| 紫 | `c-purple` | `c-purple-soft` | 7.46 / 4.53 | 8.25 / 4.64 |
- 黄色在亮态被压暗到 `#7a5900`(原色在白底只有 ~1.8:1),这是**必须**的:彩色文字要能读
- `soft` 档是"阈值内最淡值",适合次要强调;标准档用于关键强调
> **使用纪律(重要)**:彩色**只作用于文章正文的强调文字**。站点外壳(导航、页脚、按钮、背景、卡片边框)一律保持灰阶——整站不因正文用色而花掉。写作时一个页面里的彩色强调建议不超过 3 处。
### 字体类
| 类名 | 作用 | 字体 | 许可证 | 加载 |
|---|---|---|---|---|
| `font-serif` | 书评正文族 | Noto Serif SC | OFL-1.1 | 自托管 |
| `font-kai` | 金句族 | 霞鹜文楷 Screen | OFL-1.1 | 自托管 |
| `font-sans` | UI/技术族 | Noto Sans SC | OFL-1.1 | 自托管 |
| `font-mono` | 标签/数值 | JetBrains Mono | OFL-1.1 | 自托管 |
| `font-song` | 宋体族 | Noto Serif SC 优先,系统宋体兜底 | OFL-1.1 | **自托管优先** |
| `font-kai-sys` | 楷体族 | 霞鹜文楷优先,系统楷体兜底 | OFL-1.1 | **自托管优先** |
| `font-hei-sys` | 黑体族 | Noto Sans SC 优先,系统黑体兜底 | OFL-1.1 | **自托管优先** |
| `font-fangsong` | 仿宋族 | FangSong/STFangsong | — | ⚠️ **纯系统回退**(暂无 OFL 自托管仿宋包) |
### 文学排版类(段落级)
| 类名 | 作用 |
|---|---|
| `drop-cap` | 段落首字下沉(书评开篇) |
| `quote-line` | 金句块(左侧竖线 + 文楷) |
| `fig-cap` | 图注(居中小字) |
### 首批字体(已落地,6 族 + 3 档自托管优先)
| 类名 | 字体 | 许可证 | webfont 嵌入 | 用到该字体的页面增量 |
|---|---|---|---|---|
| `font-serif` | Noto Serif SC | OFL-1.1 | ✅ OFL 允许 | 基线内 |
| `font-sans` | Noto Sans SC | OFL-1.1 | ✅ | 基线内 |
| `font-kai` | 霞鹜文楷 Screen | OFL-1.1 | ✅ | 基线内 |
| `font-mono` | JetBrains Mono | OFL-1.1 | ✅ | 基线内 |
| `font-huiwen` | 汇文明朝体(文风切片包 339 片) | OFL-1.1 | ✅ | **535KB** |
| `font-kuaile` | 站酷快乐体 | OFL-1.1 | ✅ | **163KB** |
| `font-song` / `font-kai-sys` / `font-hei-sys` | 上述自托管字体优先,系统字族兜底 | OFL-1.1 | ✅ | **+0KB** |
体积账结论(2026-11-02 实测,本地无 gzip 的相对值):**未使用某字体的页面,字体字节增量恒为 0**(构建期按页子集化 + unicode-range 按需拉取)。汇文采用文风开源的 cn-font-split 切片包(339 片),实测只拉用到字符的 19 片。
### 候补字体清单(未落地,加一款约"一条命令 + 一次体积账")
| 字体 | 许可证 | 允许 webfont 嵌入 | 未落地原因 |
|---|---|---|---|
| HarmonyOS Sans | 华为字体许可(免费商用) | ✅ 明文允许"嵌入、捆绑、重新分发",需标注 | npm 无包,官网整包 52MB |
| MiSans | 小米字体许可(免费商用) | ✅ 官方 FAQ 明确 "embedded fonts: Yes",需注明 | npm 无包 |
| OPPO Sans | 免费授权(含商用) | ✅(附带"不向他方提供下载渠道"限制) | npm 无包 |
| 阿里巴巴普惠体 | 免费商用全场景 | ✅ | npm 无包,9 字重体积巨大 |
| 得意黑 Smiley Sans | OFL-1.1 | ✅ | 仅 GitHub release 整包,直链在当前网络不可达 |
| 更纱黑体 Sarasa Gothic / Sarasa Mono | OFL-1.1 | ✅ | npm 包为空壳(需 cli 自生成切片) |
| 花园明朝 HanaMinA/B | IPA Font License | ✅(允许嵌入) | 无 npm 包,仅兜底用 |
| 站酷高端黑 / 站酷快乐体(已落) | OFL-1.1 | ✅ | 高端黑无 @fontsource 版本 |
| 阿里妈妈东方大楷 / 演示悠然小楷 | 免费商用 / 个人免费 | ⚠️ 需逐款核嵌入条款 | 无包;"个人免费"字体常禁 @font-face |
| 霞鹜文楷 Light/Bright | OFL-1.1 | ✅ | 实测 **+8605KB**(CJK 切片粒度所致) |
> 加新字体的流程:①核许可证与 **web 嵌入条款**(很多"个人免费"字体明文禁 @font-face,只能进系统回退)②npm 装或放入 `public/fonts/<family>/`(优选 cn-font-split 类切片包)③`global.css` 加类 + `rehype-class-whitelist` 放行 + `public/admin/typography-classes.js` 加一项 ④跑体积账,超 150KB/页 的要评估是否值得。
```markdown
<span class="ink-muted">次要说明</span>
<span class="font-kai">金句可以换文楷</span>
@@ -111,6 +180,37 @@ https://astro.build/
- **颜色只能走上面这些类**(内部用 tokens,亮暗双态都可读);写 `style="color:red"` 会被构建期剔除
- 斜体就用普通 markdown `*斜体*`——行内强调,不会换行也不会居中
### 后台可视化选择(推荐,不用写 HTML)
后台编辑器里:**选中文字 → Insert 菜单 → 「排版(字体/颜色)」→ 面板里点一个类**,文字即被 `<span class="…">` 包住;点已套用的片段可改类,Backspace 可整体删除。
面板里的两类与上表一一对应(字体 8 / 颜色 9),类清单单一事实源在 `public/admin/typography-classes.js`。
## 三层排版架构(全站,不只文章正文)
**层 1 · 页面文案富文本**:后台「页面文案」里的 tagline / subtitle / blurb / blurb_after / intro / 关于页 blocks 的 prose 块,全部走同一条富文本管线(markdown + raw + 内容净化 + class 白名单 + 链接卡片)。可以直接写:
```yaml
tagline:
zh: '<span class="font-kai c-blue">写作者</span> · 读者 · 快门按压者,拥抱开源。'
```
渲染入口唯一:`src/lib/site-content.ts` 的 `renderRich()`(行内场景传 `{ inline: true }` 自动剥外层 `<p>`,否则 `<p>` 套 `<p>` 会被浏览器拆散)。
**不开放**:导航/页脚/按钮等硬编码界面外壳——那是 UI 不是内容。
**层 2 · 页面级排版覆盖**:每个页面单例(`content/site/*.yaml`)可加:
```yaml
typography:
font: font-kai # 本页正文默认字体类(可选)
accent: c-blue # 本页强调色类(可选)
scale: 1.1 # 字号缩放 0.8–1.25(可选)
```
实现是在该页内容容器上挂 `.page-typo` + CSS 变量(`--page-font / --page-accent / --page-scale`),**不写全站 tokens**——其它页面零影响(已在图库/书架/文章/装备页验证隔离)。白名单外的类名会被忽略。
后台入口:「页面文案 → 各自页面 → 页面排版覆盖」。
**层 3 · 艺术字体**:`font-art-kuaile`(站酷快乐体,艺术字专用类)。⚠️ **仅用于标题/短句**:艺术字字形大,CJK 切片体积高(实测用到页 +163KB),长段使用会显著抬高单页字体成本。
## 发布提醒
- push main → webhook 自动构建发布(约 10 秒)
+1
View File
@@ -33,6 +33,7 @@ pnpm astro build
# 按页字体 CSS 子集化(车道 A 任务 1):裁 @font-face 声明数,构建仓与本地同链;
# 存在性守卫:新文件未推送到 main 前构建仓无此脚本,绝不让发布链因车道在途文件断掉
[ -f scripts/build-perf/subset-font-css.mjs ] && node scripts/build-perf/subset-font-css.mjs dist || echo "[build] subset 脚本未就绪,跳过"
[ -f scripts/build-perf/contrast-check.mjs ] && node scripts/build-perf/contrast-check.mjs dist || echo "[build] contrast 脚本未就绪,跳过"
# 首页阻塞 CSS 内联(车道 D):守卫先行同上——脚本未到 main 前绝不炸链
[ -f scripts/build-perf/gen-sw-precache.mjs ] && node scripts/build-perf/gen-sw-precache.mjs dist || echo "[build] sw-precache 生成器未就绪,跳过"
[ -f scripts/build-perf/inline-home-css.mjs ] && node scripts/build-perf/inline-home-css.mjs || echo "[build] inline-home 脚本未就绪,跳过"
+1
View File
@@ -16,6 +16,7 @@
"@fontsource-variable/noto-serif-sc": "^5.3.1",
"@fontsource/jetbrains-mono": "^5.3.0",
"@fontsource/noto-sans-sc": "^5.3.1",
"@fontsource/zcool-kuaile": "^5.3.0",
"astro": "^7.4.4",
"astro-pagefind": "^2.0.1",
"exifr": "^7.1.3",
+8
View File
@@ -26,6 +26,9 @@ importers:
'@fontsource/noto-sans-sc':
specifier: ^5.3.1
version: 5.3.1
'@fontsource/zcool-kuaile':
specifier: ^5.3.0
version: 5.3.0
astro:
specifier: ^7.4.4
version: 7.4.4(@astrojs/markdown-remark@7.3.5)(@types/node@24.19.2)(yaml@2.9.1)
@@ -265,6 +268,9 @@ packages:
'@fontsource/noto-serif-sc@5.3.1':
resolution: {integrity: sha512-nIK2HyNGcUi2TGwIfrk2MItkh0O3Y5+TI8iPfPSzoNY6uyXYfQ9qqch8czk3aa1VhV4EU52uwCYCe1UDKJELpg==}
'@fontsource/zcool-kuaile@5.3.0':
resolution: {integrity: sha512-jn4vpQ6QSVEckUn7uk5a9XcZMxEksTRqljmeE1VKRGMjGd6VoXdMyLGSdnTXSVwZLCCEHaIcm/2cdQuwvasJ9w==}
'@img/colour@1.1.0':
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
@@ -2113,6 +2119,8 @@ snapshots:
'@fontsource/noto-serif-sc@5.3.1': {}
'@fontsource/zcool-kuaile@5.3.0': {}
'@img/colour@1.1.0': {}
'@img/sharp-darwin-arm64@0.35.5':
+335 -3
View File
@@ -58,6 +58,29 @@ collections:
- { label: 草稿, name: draft, widget: boolean, required: false, default: false }
- { label: 正文, name: body, widget: markdown }
# 主题旋钮(样式即数据 · 层 4):只放白名单内的 9 组,越界值会被构建期钳制
- name: site_theme
label: 主题设置
file: content/site/theme.yaml
fields:
- { label: 强调色(亮), name: accent_light, widget: color, required: false, hint: '亮色模式强调色,如 #3454d1;构建期校验正文对比度 ≥4.5' }
- { label: 强调色(暗), name: accent_dark, widget: color, required: false, hint: '暗色模式强调色,如 #8ea2ff' }
- { label: 图片圆角, name: radius_img, widget: number, required: false, value_type: int, min: 4, max: 16, hint: '范围 4–16(建议 ≤14)' }
- { label: 一级标题字号, name: text_h1, widget: number, required: false, value_type: int, min: 22, max: 32, hint: '22–32,且必须大于 h2' }
- { label: 二级标题字号, name: text_h2, widget: number, required: false, value_type: int, min: 22, max: 32, hint: '22–32,必须小于 h1、大于 h3' }
- { label: 三级标题字号, name: text_h3, widget: number, required: false, value_type: int, min: 16, max: 32, hint: '16–32,必须小于 h2' }
- { label: 书评正文字号, name: prose_size_reading, widget: number, required: false, value_type: int, min: 16, max: 22, hint: '16–22' }
- { label: 技术文正文字号, name: prose_size_tech, widget: number, required: false, value_type: int, min: 16, max: 22, hint: '16–22' }
- { label: 书评行高, name: leading_reading, widget: number, required: false, value_type: float, min: 1.5, max: 2.0, step: 0.05, hint: '1.5–2.0' }
- { label: 技术文行高, name: leading_tech, widget: number, required: false, value_type: float, min: 1.5, max: 2.0, step: 0.05, hint: '1.5–2.0' }
- { label: 间距 3, name: space_3, widget: number, required: false, value_type: int, min: 8, max: 80, hint: '8–80,必须 < 间距 4' }
- { label: 间距 4, name: space_4, widget: number, required: false, value_type: int, min: 8, max: 80, hint: '8–80,必须 > 间距 3 且 < 间距 5' }
- { label: 间距 5, name: space_5, widget: number, required: false, value_type: int, min: 8, max: 80, hint: '8–80,必须 > 间距 4 且 < 间距 6' }
- { label: 间距 6, name: space_6, widget: number, required: false, value_type: int, min: 8, max: 80, hint: '8–80,必须 > 间距 5' }
- { label: 段间距, name: flow_p, widget: number, required: false, value_type: float, min: 0, max: 2, step: 0.05, hint: '0–2(em)' }
- { label: 标题下间距, name: flow_h2_post, widget: number, required: false, value_type: float, min: 0, max: 2, step: 0.05, hint: '0–2(em)' }
- { label: 过渡时长, name: dur, widget: number, required: false, value_type: int, min: 150, max: 600, hint: '150–600(毫秒)' }
# 瞬间:随手发(text/image 至少其一——Sveltia 无法表达条件必填,两者都设可选,
# 保存前自查;pubDate 必填)。图片走图库媒体库(content/photos 同源可复用)
- name: moments
@@ -119,10 +142,45 @@ collections:
fields:
- label: 身份语
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
fields:
- { label: 中文, name: zh, widget: string, required: false }
- { label: 英文, name: en, widget: string, required: false }
- label: 公告(可选,markdown)
name: announcement
widget: object
required: false
hint: 渲染在名片区下方;留空则不显示公告块
fields:
- { label: 中文, name: zh, widget: markdown, required: false }
- { label: 英文, name: en, widget: markdown, required: false }
- { label: 项目墙条数, name: projectsLimit, widget: number, required: false, value_type: int, min: 1, max: 99, hint: '首页项目墙显示几条(1–99,缺省 6)' }
- label: 轨细分开关(首页文章列表)
name: postsTracks
widget: object
required: false
hint: 关掉某轨则该轨文章不在首页列出(全开=现状)
fields:
- { label: 阅读轨, name: reading, widget: boolean, required: false, default: true }
- { label: 技术轨, name: tech, widget: boolean, required: false, default: true }
- { label: 照片轨, name: photo, widget: boolean, required: false, default: true }
- label: 副标题(可选,markdown 短文本)
name: subtitle
widget: object
@@ -183,20 +241,294 @@ collections:
- name: about
label: 关于页
file: content/site/about.yaml
# 块内图片上传落 content/site/assets/,yaml 写 ./assets/xxx(Astro 图片管线)
media_folder: content/site/assets
public_folder: ./assets
fields:
- label: 正文(markdown,多段)
name: intro
- label: 页面块(拖拽排序;每块可折叠)
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
hint: 渲染在「关于」标题下方、名片之上;支持段落/列表/粗斜体/链接;留空用默认文案
fields:
- { label: 中文, name: zh, 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
label: 开源页
file: content/site/opensource.yaml
fields:
- label: 叙述(链接前)
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
fields:
- { label: 中文, name: zh, widget: string, required: false }
+3
View File
@@ -8,5 +8,8 @@
<body>
<!-- pinned 本地 bundle(0.252.0):官方模板仅此一个 script,无 CSS link、无 type=module -->
<script src="./sveltia-cms.js"></script>
<!-- 可视化排版选择:类清单(单一数据源)→ 编辑器组件注册(须在 CMS 之后、init 之前注册) -->
<script src="./typography-classes.js"></script>
<script src="./typography-editor.js"></script>
</body>
</html>
+50
View File
@@ -0,0 +1,50 @@
/**
* 排版类清单 —— 单一事实源(后台编辑器面板与文档共用)。
* 与 src/styles/global.css 的「受限排版类/语义色板」段、src/lib/rehype-class-whitelist.ts 的白名单三处保持一致:
* 加类时三处同改,否则构建期白名单会把新类剥掉。
*
* swatch / family 仅供**后台面板预览**使用(后台无站点 CSS 变量,故给具体值):
* swatch 取亮态值(后台是浅色 UI),family 为该类的真实字体栈。
*/
window.TYPOGRAPHY_CLASSES = {
font: [
{ cls: 'font-serif', label: '思源宋(书评正文)', family: '"Noto Serif SC", Georgia, serif', kind: '自托管' },
{ cls: 'font-kai', label: '霞鹜文楷(金句)', family: '"LXGW WenKai Screen", "Songti SC", serif', kind: '自托管' },
{ cls: 'font-sans', label: '思源黑(UI/技术)', family: '"Noto Sans SC", system-ui, sans-serif', kind: '自托管' },
{ cls: 'font-mono', label: 'JetBrains Mono(等宽)', family: '"JetBrains Mono", ui-monospace, monospace', kind: '自托管' },
{ cls: 'font-song', label: '宋体族', family: '"Noto Serif SC", "Songti SC", SimSun, serif', kind: '自托管优先' },
{ cls: 'font-kai-sys', label: '楷体族', family: '"LXGW WenKai Screen", "Kaiti SC", STKaiti, serif', kind: '自托管优先' },
{ cls: 'font-hei-sys', label: '黑体族', family: '"Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif', kind: '自托管优先' },
{ cls: 'font-fangsong', label: '仿宋族', family: 'FangSong, STFangsong, serif', kind: '系统回退' },
{ cls: 'font-huiwen', label: '汇文明朝体', family: '"WF-Hwmc", serif', kind: '自托管' },
{ cls: 'font-kuaile', label: '站酷快乐体', family: '"ZCOOL KuaiLe", serif', kind: '自托管' },
{ cls: 'font-art-kuaile', label: '快乐体(艺术·仅标题)', family: '"ZCOOL KuaiLe", serif', kind: '艺术字' },
],
color: [
{ cls: 'ink-1', label: '最重(标题级)', swatch: '#111113' },
{ cls: 'ink-2', label: '正文', swatch: '#33343a' },
{ cls: 'ink-3', label: '次要(日期/元信息)', swatch: '#6b6e78' },
{ cls: 'ink-muted', label: '最淡(注释感)', swatch: '#70747c' },
{ cls: 'ink-warm', label: '暖调正文', swatch: '#3f362e' },
{ cls: 'ink-cool', label: '冷调正文', swatch: '#2f3742' },
{ cls: 'ink-faint', label: '最淡可读档', swatch: '#70747c' },
{ cls: 'ink-accent', label: '强调(链接色)', swatch: '#3454d1' },
{ cls: 'ink-accent-soft', label: '弱强调', swatch: '#4f66c2' },
{ cls: 'c-blue', label: '蓝', swatch: '#2456c9' },
{ cls: 'c-blue-soft', label: '蓝(soft)', swatch: '#4170dd' },
{ cls: 'c-teal', label: '青', swatch: '#0d7d74' },
{ cls: 'c-teal-soft', label: '青(soft)', swatch: '#0e847a' },
{ cls: 'c-green', label: '绿', swatch: '#1f7a3a' },
{ cls: 'c-green-soft', label: '绿(soft)', swatch: '#22853f' },
{ cls: 'c-yellow', label: '黄(已压暗)', swatch: '#7a5900' },
{ cls: 'c-yellow-soft', label: '黄(soft)', swatch: '#926a00' },
{ cls: 'c-orange', label: '橙', swatch: '#9a4a00' },
{ cls: 'c-orange-soft', label: '橙(soft)', swatch: '#ba5900' },
{ cls: 'c-red', label: '红', swatch: '#b32020' },
{ cls: 'c-red-soft', label: '红(soft)', swatch: '#db3737' },
{ cls: 'c-pink', label: '粉', swatch: '#a3246a' },
{ cls: 'c-pink-soft', label: '粉(soft)', swatch: '#ce3386' },
{ cls: 'c-purple', label: '紫', swatch: '#6a35b8' },
{ cls: 'c-purple-soft', label: '紫(soft)', swatch: '#8c5ed1' },
],
};
+153
View File
@@ -0,0 +1,153 @@
/**
* 后台可视化排版选择(Sveltia CMS 自定义编辑器组件)。
*
* 能力依据(sveltiacms.app/en/docs/api/editor-components,2026-11 核实):
* - inline 型组件(type:'inline')在段落内以 pattern 匹配,可与选中文字互转;
* - onInsert({ selectedText }) 拿到编辑器当前选中的文字,返回值经 toInline 生成 Markdown;
* - inline 组件固定在 Insert 菜单下(trigger 对 inline 是文本触发,不能上工具栏)。
*
* 因此「选中文字 → 套字体/颜色」由**一个「排版」组件 + 自建分组面板**实现:
* 两个独立工具栏下拉(字体/颜色)会共用同一 span pattern 互相抢匹配,这里收成一个入口分两组,
* 交互上等价(选中 → Insert ▸ 排版 → 选类 → 文字被 <span class> 包住)。
* onEdit 支持点开已套用的片段改类。
*/
(function () {
const CLASSES = window.TYPOGRAPHY_CLASSES;
if (!CLASSES || !window.CMS) return;
/** 自建选择面板:返回 Promise<选中类名 | null>;无依赖、可键盘操作 */
function pickClass(current) {
return new Promise((resolve) => {
const wrap = document.createElement('div');
wrap.setAttribute('role', 'dialog');
wrap.setAttribute('aria-label', '选择排版');
Object.assign(wrap.style, {
position: 'fixed', inset: '0', zIndex: '99999', display: 'flex',
alignItems: 'center', justifyContent: 'center', background: 'rgba(8,10,14,.55)',
});
const card = document.createElement('div');
Object.assign(card.style, {
background: '#fff', color: '#111113', borderRadius: '12px', padding: '18px 20px',
width: 'min(560px, 92vw)', maxHeight: '80vh', overflowY: 'auto',
boxShadow: '0 8px 32px rgba(0,0,0,.28)', font: '14px/1.5 system-ui, sans-serif',
});
const title = document.createElement('p');
title.textContent = '选择排版(作用于选中文字)';
Object.assign(title.style, { margin: '0 0 12px', fontWeight: '600' });
card.appendChild(title);
const buttons = [];
const addGroup = (name, list, withSwatch) => {
const h = document.createElement('p');
h.textContent = name;
Object.assign(h.style, { margin: '12px 0 6px', fontSize: '12px', color: '#6b6e78' });
card.appendChild(h);
const row = document.createElement('div');
Object.assign(row.style, { display: 'flex', flexWrap: 'wrap', gap: '8px' });
for (const item of list) {
const b = document.createElement('button');
b.type = 'button';
b.dataset.cls = item.cls;
b.className = item.cls === current ? 'active' : '';
Object.assign(b.style, {
display: 'inline-flex', alignItems: 'center', gap: '8px',
padding: '6px 12px', borderRadius: '8px', cursor: 'pointer',
border: '1px solid ' + (item.cls === current ? '#3454d1' : '#e6e7eb'),
background: item.cls === current ? '#eef1fd' : '#fff',
color: '#111113', fontSize: '13px',
});
if (withSwatch) {
// 色块预览:直接展示该类在亮态下的实际颜色
const chip = document.createElement('span');
Object.assign(chip.style, {
width: '16px', height: '16px', borderRadius: '4px', flex: 'none',
background: item.swatch || '#ccc',
border: '1px solid rgba(0,0,0,.12)',
});
chip.setAttribute('aria-hidden', 'true');
b.appendChild(chip);
} else {
// 字体项:名称 + 用该字体栈渲染的示例字形
const sample = document.createElement('span');
sample.textContent = '秋海';
Object.assign(sample.style, {
fontFamily: item.family || 'inherit', fontSize: '15px', lineHeight: '1',
padding: '0 4px', borderLeft: '1px solid #e6e7eb', color: '#33343a',
});
b.appendChild(sample);
}
const label = document.createElement('span');
label.textContent = item.label + (item.kind ? `(${item.kind})` : '');
b.appendChild(label);
row.appendChild(b);
buttons.push(b);
}
card.appendChild(row);
};
addGroup('字体', CLASSES.font, false);
addGroup('颜色', CLASSES.color, true);
const foot = document.createElement('div');
Object.assign(foot.style, { marginTop: '16px', display: 'flex', justifyContent: 'flex-end', gap: '8px' });
const cancel = document.createElement('button');
cancel.type = 'button';
cancel.textContent = '取消(Esc)';
Object.assign(cancel.style, { padding: '6px 12px', borderRadius: '8px', border: '1px solid #e6e7eb', background: '#fff', cursor: 'pointer' });
foot.appendChild(cancel);
card.appendChild(foot);
wrap.appendChild(card);
const prevFocus = document.activeElement;
const close = (val) => {
document.removeEventListener('keydown', onKey, true);
wrap.remove();
if (prevFocus && prevFocus.focus) prevFocus.focus();
resolve(val);
};
const onKey = (e) => {
if (e.key === 'Escape') {
e.preventDefault();
close(null);
} else if (e.key === 'Tab') {
// 焦点循环留在面板内
const list = [...wrap.querySelectorAll('button')];
const first = list[0], last = list[list.length - 1];
if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
else if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
}
};
wrap.addEventListener('click', (e) => {
const t = e.target;
if (t === wrap) return close(null);
if (t.dataset && t.dataset.cls) close(t.dataset.cls);
if (t === cancel) close(null);
});
document.addEventListener('keydown', onKey, true);
document.body.appendChild(wrap);
(buttons[0] || cancel).focus();
});
}
window.CMS.registerEditorComponent({
id: 'typography',
label: '排版(字体/颜色)',
icon: 'format_paint',
type: 'inline',
pattern: /<span class="(?<cls>[a-z0-9-]+)">(?<text>[^<]*?)<\/span>/,
toInline: ({ cls = '', text = '' }) => `<span class="${cls}">${text}</span>`,
toPreview: ({ cls = '', text = '' }) => {
const span = document.createElement('span');
span.className = cls;
span.textContent = text; // DOM 预览:textContent 天然转义
return span;
},
onInsert: async ({ selectedText = '' }) => {
const cls = await pickClass(null);
return cls ? { cls, text: selectedText } : null;
},
onEdit: async ({ data }) => {
const cls = await pickClass(data.cls);
return cls ? { ...data, cls } : null;
},
});
})();
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loaded 100 of 486 files, more files were not shown because too many files have changed in this diff. Show more