3 Commits
Author SHA1 Message Date
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
13 changed files with 921 additions and 10 deletions

No files matched your search

+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>
+31
View File
@@ -0,0 +1,31 @@
---
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="font-kai-light">霞鹜文楷 Light(自托管 300):秋天的海面像一块被风抚平的铁皮。</p>
<p class="c-blue">蓝色标准:秋天的海面像一块被风抚平的铁皮。</p>
<p class="c-yellow">黄色标准(已压暗):秋天的海面像一块被风抚平的铁皮。</p>
<p class="c-red-soft">红色 soft:秋天的海面像一块被风抚平的铁皮。</p>
+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 个、跑稳一版再扩。
+33 -6
View File
@@ -88,19 +88,41 @@ 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` | 弱强调(比强调淡一档) |
### 字体类
| 类名 | 作用 | 备注 |
|---|---|---|
| `font-serif` | 思源宋(书评正文族) | 自托管 |
| `font-kai` | 霞鹜文楷(金句族) | 自托管 |
| `font-sans` | 思源黑(UI/技术族) | 自托管 |
| `font-mono` | JetBrains Mono(标签/数值) | 自托管 |
| `font-song` | 宋体族 | ⚠️ 系统字体,**依赖访客设备** |
| `font-kai-sys` | 楷体族 | ⚠️ 系统字体 |
| `font-hei-sys` | 黑体族 | ⚠️ 系统字体 |
| `font-fangsong` | 仿宋族 | ⚠️ 系统字体 |
### 文学排版类(段落级)
| 类名 | 作用 |
|---|---|
| `drop-cap` | 段落首字下沉(书评开篇) |
| `quote-line` | 金句块(左侧竖线 + 文楷) |
| `fig-cap` | 图注(居中小字) |
> **系统字体类的前提**:`-sys` / `font-song` / `font-fangsong` 不下载任何字体文件,命中访客本机字体(macOS/iOS 有宋楷黑、Windows 有宋黑仿宋,Android 常缺楷仿宋)。**跨设备不保证一致**——要稳定请用自托管四族。
```markdown
<span class="ink-muted">次要说明</span>
<span class="font-kai">金句可以换文楷</span>
@@ -111,6 +133,11 @@ https://astro.build/
- **颜色只能走上面这些类**(内部用 tokens,亮暗双态都可读);写 `style="color:red"` 会被构建期剔除
- 斜体就用普通 markdown `*斜体*`——行内强调,不会换行也不会居中
### 后台可视化选择(推荐,不用写 HTML)
后台编辑器里:**选中文字 → Insert 菜单 → 「排版(字体/颜色)」→ 面板里点一个类**,文字即被 `<span class="…">` 包住;点已套用的片段可改类,Backspace 可整体删除。
面板里的两类与上表一一对应(字体 8 / 颜色 9),类清单单一事实源在 `public/admin/typography-classes.js`。
## 发布提醒
- push main → webhook 自动构建发布(约 10 秒)
+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>
+28
View File
@@ -0,0 +1,28 @@
/**
* 排版类清单 —— 单一事实源(后台编辑器面板与文档共用)。
* 与 src/styles/global.css 的「受限排版类」段、src/lib/rehype-class-whitelist.ts 的白名单三处保持一致:
* 加类时三处同改,否则构建期白名单会把新类剥掉。
*/
window.TYPOGRAPHY_CLASSES = {
font: [
{ cls: 'font-serif', label: '思源宋(书评正文)' },
{ cls: 'font-kai', label: '霞鹜文楷(金句)' },
{ cls: 'font-sans', label: '思源黑(UI/技术)' },
{ cls: 'font-mono', label: 'JetBrains Mono(等宽)' },
{ cls: 'font-song', label: '宋体(系统)' },
{ cls: 'font-kai-sys', label: '楷体(系统)' },
{ cls: 'font-hei-sys', label: '黑体(系统)' },
{ cls: 'font-fangsong', label: '仿宋(系统)' },
],
color: [
{ cls: 'ink-1', label: '最重(标题级)' },
{ cls: 'ink-2', label: '正文' },
{ cls: 'ink-3', label: '次要(日期/元信息)' },
{ cls: 'ink-muted', label: '最淡(注释感)' },
{ cls: 'ink-accent', label: '强调(链接色)' },
{ cls: 'ink-accent-soft', label: '弱强调' },
{ cls: 'ink-warm', label: '暖调正文' },
{ cls: 'ink-cool', label: '冷调正文' },
{ cls: 'ink-faint', label: '最淡可读档' },
],
};
+130
View File
@@ -0,0 +1,130 @@
/**
* 后台可视化排版选择(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) => {
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.textContent = item.label;
b.dataset.cls = item.cls;
b.className = item.cls === current ? 'active' : '';
Object.assign(b.style, {
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',
});
row.appendChild(b);
buttons.push(b);
}
card.appendChild(row);
};
addGroup('字体', CLASSES.font);
addGroup('颜色', CLASSES.color);
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;
},
});
})();
+26
View File
@@ -14,11 +14,37 @@ const ALLOWED = new Set([
'ink-3',
'ink-muted',
'ink-accent',
'ink-accent-soft',
'ink-warm',
'ink-cool',
'ink-faint',
'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',
// 字族(与分轨排版共存的四个既有字族)
'font-serif',
'font-kai',
'font-sans',
'font-mono',
// 系统字族回退类(不下载字体,命中访客本机字体)
'font-song',
'font-kai-sys',
'font-hei-sys',
'font-fangsong',
'font-kai-light',
// 文学排版
'drop-cap',
'quote-line',
+93
View File
@@ -0,0 +1,93 @@
/**
* rehype 内容净化(标签/属性/协议级)——页面文案管线的第二道防线。
*
* 为什么需要:`renderMarkdown()` 走的是 `@astrojs/markdown-remark` 的独立处理器,
* 默认 `allowDangerousHtml: true` 且无条件挂 `rehype-raw`;若不筛标签,后台
* (或远程 README)写入的 `<script>`、`<iframe>`、`on*` 会直通 `set:html`。
* 文章管线靠 astro.config.mjs 的插件链兜住,页面文案此前没有任何兜底(2026-11-02
* 方案 10 §7.1 实锤漏洞)。
*
* 与 rehype-class-whitelist 的分工:那个管「class/style/on*」,本插件管「标签与属性
* 白名单 + 协议白名单」。两者同序串联(raw → sanitize → class → linkCards)。
*/
/** 允许保留的标签(不含 script/style/iframe/object/embed/form/input/link/meta/svg/base) */
const ALLOWED_TAGS = new Set([
'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',
]);
/** 危险标签:整棵子树直接丢弃(不是解包——解包会把内联脚本正文漏成文本) */
const DROP_TAGS = new Set([
'script', 'style', 'iframe', 'object', 'embed', 'form', 'input', 'textarea',
'select', 'button', 'link', 'meta', 'base', 'svg', 'math', 'template', 'noscript',
]);
/** 每标签允许的属性(class 交给 class 白名单插件再筛;id 仅供标题锚点) */
const ALLOWED_ATTRS: Record<string, Set<string>> = {
a: new Set(['href', 'title', 'rel']),
img: new Set(['src', 'alt', 'width', 'height', 'loading']),
// hast 里 class 属性名是 className(数组),不是 class——按 class 过滤会把
// 合法排版类一起剃掉(2026-11-02 实测:<p class="ink-2"> 只剩 <p>)
'*': new Set(['className', 'id']),
};
const SAFE_PROTOCOLS = new Set(['http:', 'https:', 'mailto:']);
/** 判断 URL 是否安全:站内相对路径 / 白名单协议 / 小图 data: 允许 */
function isSafeUrl(value: string): boolean {
const v = value.trim();
if (!v) return false;
if (v.startsWith('/') || v.startsWith('./') || v.startsWith('../') || v.startsWith('#')) return true;
if (/^data:image\/(png|jpe?g|gif|webp|svg\+xml);/i.test(v)) return true;
try {
const url = new URL(v, 'https://placeholder.invalid');
return SAFE_PROTOCOLS.has(url.protocol);
} catch {
return false;
}
}
interface HastElement {
type: string;
tagName?: string;
properties?: Record<string, unknown>;
children?: HastElement[];
value?: string;
}
function sanitizeNode(node: HastElement): HastElement[] | null {
if (node.type === 'element' && node.tagName) {
const tag = node.tagName.toLowerCase();
if (DROP_TAGS.has(tag)) return null; // 整棵子树丢弃
if (!ALLOWED_TAGS.has(tag)) {
// 未知但无害的标签(如 section/header)→ 解包保留内容
return (node.children ?? []).flatMap((c) => sanitizeNode(c) ?? []);
}
const allowed = new Set(
[...(ALLOWED_ATTRS[tag] ?? []), ...ALLOWED_ATTRS['*']!].map((a) => a.toLowerCase()),
);
const props: Record<string, unknown> = {};
for (const [key, value] of Object.entries(node.properties ?? {})) {
const k = key.toLowerCase(); // hast 用 className/charSet 等驼峰,比较前统一小写
if (!allowed.has(k)) continue;
if ((k === 'href' || k === 'src') && typeof value === 'string' && !isSafeUrl(value)) continue;
if (k === 'id' && typeof value === 'string' && !/^[A-Za-z0-9_-]{1,64}$/.test(value)) continue;
props[key] = value;
}
node.properties = props;
node.children = (node.children ?? []).flatMap((c) => sanitizeNode(c) ?? []);
} else if (node.children) {
node.children = node.children.flatMap((c) => sanitizeNode(c) ?? []);
}
return node;
}
/** rehype 插件主体 */
export default function rehypeContentSanitize() {
return (tree: HastElement) => {
sanitizeNode(tree);
};
}
+15 -1
View File
@@ -121,7 +121,21 @@ export async function siteFavicon(): Promise<{ href: string; type: string }> {
let mdProcessor: Promise<import('@astrojs/markdown-remark').MarkdownProcessor> | null = null;
export async function renderMarkdown(md: string): Promise<string> {
const { createMarkdownProcessor } = await import('@astrojs/markdown-remark');
mdProcessor ??= createMarkdownProcessor();
// 与 astro.config.mjs 的文章管线同序:raw → 内容净化(标签/属性/协议)→
// class 白名单 → 链接卡片。此前这里 createMarkdownProcessor() 无参,页面
// 文案的 raw HTML 直通 set:html(方案 10 §7.1 实锤漏洞:script/style/on* 不剥)。
mdProcessor ??= (async () => {
const [{ default: rehypeRaw }, { default: rehypeContentSanitize }, { default: rehypeClassWhitelist }, { default: rehypeLinkCards }] =
await Promise.all([
import('rehype-raw'),
import('./rehype-content-sanitize'),
import('./rehype-class-whitelist'),
import('./rehype-link-cards'),
]);
return createMarkdownProcessor({
rehypePlugins: [rehypeRaw, rehypeContentSanitize, rehypeClassWhitelist, rehypeLinkCards],
});
})();
const { code } = await (await mdProcessor).render(md);
return code;
}
+16 -3
View File
@@ -3,23 +3,36 @@ import ProjectWall from '../components/geek/ProjectWall.astro';
import Heatmap from '../components/geek/Heatmap.astro';
import BaseLayout from '../layouts/BaseLayout.astro';
import { t } from '../i18n/ui';
import { openSourceCopy } from '../lib/site-content';
import { githubReadmeMarkdown, openSourceCopy, openSourceOptions, renderMarkdown } from '../lib/site-content';
// 叙述文案 + 置顶名单均改由 content/site/opensource.yaml 供给(后台可改)
// 叙述文案 + 置顶名单 + 项目墙选项均改由 content/site/opensource.yaml 供给(后台可改)
const copy = await openSourceCopy('zh');
const options = await openSourceOptions();
// 开篇来源:github-readme → 构建期取 profile 仓 README(缓存 content/.github-readme.md,失败回落 yaml blurb)
const readmeMd = options.introFrom === 'github-readme' ? await githubReadmeMarkdown() : null;
const readmeHtml = readmeMd ? await renderMarkdown(readmeMd) : null;
---
<BaseLayout title="开源 · wait4x" description="我的开源活动:仓库、贡献与本站源码">
<h1 class="page-title">开源<span class="count mono">Open Source</span></h1>
{readmeHtml ? (
<div class="blurb readme-intro" set:html={readmeHtml} />
) : (
<p class="blurb">{copy.blurb}<a class="link" href={copy.repoUrl}>{copy.repoLabel}</a>{copy.blurbAfter}</p>
)}
<Heatmap labels={{
title: t('heatmap_title', 'zh'), summary: t('heatmap_summary', 'zh'),
less: t('heatmap_less', 'zh'), more: t('heatmap_more', 'zh'), note: t('heatmap_note', 'zh'),
}} />
<ProjectWall limit={99} pinned={copy.pinned} />
<ProjectWall limit={99} pinned={copy.pinned} options={options} />
</BaseLayout>
<style>
.page-title { font-size: var(--text-h1); color: var(--ink-1); display: flex; align-items: baseline; gap: var(--space-3); }
.count { font-size: 13px; color: var(--ink-4); }
.readme-intro :global(p) { margin-block: 0 var(--space-2); }
.readme-intro :global(ul) { margin-block: var(--space-1) var(--space-2); padding-left: 1.2em; list-style: disc; }
.readme-intro :global(h2), .readme-intro :global(h3) { font-size: 16px; margin: var(--space-3) 0 var(--space-1); }
.readme-intro :global(img) { max-width: 100%; height: auto; border-radius: 8px; }
.readme-intro :global(a) { color: var(--accent); }
.blurb { margin-top: var(--space-3); margin-bottom: var(--space-5); font-size: 15px; color: var(--ink-2); line-height: 1.7; max-width: 640px; }
</style>
+42
View File
@@ -172,10 +172,52 @@ pre code { background: none; border: none; padding: 0; font-size: inherit; }
.prose-reading .ink-3, .prose-tech .ink-3 { color: var(--ink-3); }
.prose-reading .ink-muted, .prose-tech .ink-muted { color: var(--ink-4); }
.prose-reading .ink-accent, .prose-tech .ink-accent { color: var(--accent); }
.prose-reading .ink-accent-soft, .prose-tech .ink-accent-soft { color: var(--accent-soft-text); }
.prose-reading .ink-warm, .prose-tech .ink-warm { color: var(--ink-warm); }
.prose-reading .ink-cool, .prose-tech .ink-cool { color: var(--ink-cool); }
.prose-reading .ink-faint, .prose-tech .ink-faint { color: var(--ink-faint); }
/* 语义色板:彩色只作用于正文强调(站点外壳保持灰阶)。
每色相两档:标准 / soft(阈值内最淡);亮暗各取独立值,均实测 ≥4.5:1。 */
.prose-reading .c-blue, .prose-tech .c-blue { color: var(--c-blue); }
.prose-reading .c-blue-soft, .prose-tech .c-blue-soft { color: var(--c-blue-soft); }
.prose-reading .c-teal, .prose-tech .c-teal { color: var(--c-teal); }
.prose-reading .c-teal-soft, .prose-tech .c-teal-soft { color: var(--c-teal-soft); }
.prose-reading .c-green, .prose-tech .c-green { color: var(--c-green); }
.prose-reading .c-green-soft, .prose-tech .c-green-soft { color: var(--c-green-soft); }
.prose-reading .c-yellow, .prose-tech .c-yellow { color: var(--c-yellow); }
.prose-reading .c-yellow-soft, .prose-tech .c-yellow-soft { color: var(--c-yellow-soft); }
.prose-reading .c-orange, .prose-tech .c-orange { color: var(--c-orange); }
.prose-reading .c-orange-soft, .prose-tech .c-orange-soft { color: var(--c-orange-soft); }
.prose-reading .c-red, .prose-tech .c-red { color: var(--c-red); }
.prose-reading .c-red-soft, .prose-tech .c-red-soft { color: var(--c-red-soft); }
.prose-reading .c-pink, .prose-tech .c-pink { color: var(--c-pink); }
.prose-reading .c-pink-soft, .prose-tech .c-pink-soft { color: var(--c-pink-soft); }
.prose-reading .c-purple, .prose-tech .c-purple { color: var(--c-purple); }
.prose-reading .c-purple-soft, .prose-tech .c-purple-soft { color: var(--c-purple-soft); }
.prose-reading .font-serif, .prose-tech .font-serif { font-family: var(--font-serif); }
.prose-reading .font-kai, .prose-tech .font-kai { font-family: var(--font-kai); }
.prose-reading .font-sans, .prose-tech .font-sans { font-family: var(--font-sans); }
.prose-reading .font-mono, .prose-tech .font-mono { font-family: var(--font-mono); font-size: 0.92em; }
/* 系统字族回退类:不下载字体,命中访客本机字体——跨设备不保证一致(docs 已注明) */
/* 自托管优先 + 系统兜底(2026-11-02 口径):跨设备一致性由站内 webfont 保证,
系统字族只作最后兜底;font-fangsong 暂无 OFL 自托管包,仍为纯系统回退(docs 已注明)。 */
.prose-reading .font-song, .prose-tech .font-song {
font-family: "Noto Serif SC Variable", "Noto Serif SC", "Songti SC", SimSun, serif;
}
.prose-reading .font-kai-sys, .prose-tech .font-kai-sys {
font-family: "LXGW WenKai Screen", "Kaiti SC", STKaiti, KaiTi, serif;
}
.prose-reading .font-hei-sys, .prose-tech .font-hei-sys {
font-family: "Noto Sans SC Variable", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
}
.prose-reading .font-fangsong, .prose-tech .font-fangsong {
font-family: FangSong, STFangsong, "FangSong_GB2312", serif;
}
/* 霞鹜文楷 Light(自托管 @fontsource,OFL-1.1):更轻的金句字重 */
.prose-reading .font-kai-light, .prose-tech .font-kai-light {
font-family: 'LXGW WenKai', var(--font-kai);
font-weight: 300;
}
/* 文学常用:首字下沉(书评开篇)与金句块(引文式的独立强调段) */
.prose-reading p.drop-cap::first-letter, .prose-tech p.drop-cap::first-letter {
float: left;
+44
View File
@@ -14,6 +14,29 @@
--ink-2: #33343a; /* 正文 */
--ink-3: #6b6e78; /* 次要:日期、元信息 */
--ink-4: #a0a3ad; /* 装饰:分隔线、icon 底 */
/* 语义色阶(2026-11-02 扩充;全部实测白底对比度 ≥4.5:1,仍属灰阶家族) */
--ink-warm: #3f362e; /* 暖调正文:11.81:1 */
--ink-cool: #2f3742; /* 冷调正文:12.03:1 */
--ink-faint: #70747c; /* 最淡的可读档(白底 4.69:1,再淡即不达 AA) */
--accent-soft-text: #4f66c2; /* 弱强调文字(比 accent 淡一档):5.23:1 */
/* 语义色板(2026-11-02 扩充;彩色仅用于正文强调,站点外壳仍灰阶——见 docs 使用纪律)
标准档 / soft 档(阈值内最淡值),全部实测白底对比度 ≥4.5:1 */
--c-blue: #2456c9;
--c-teal: #0d7d74;
--c-green: #1f7a3a;
--c-yellow: #7a5900;
--c-orange: #9a4a00;
--c-red: #b32020;
--c-pink: #a3246a;
--c-purple: #6a35b8;
--c-blue-soft: #4170dd;
--c-teal-soft: #0e847a;
--c-green-soft: #22853f;
--c-yellow-soft: #997000;
--c-orange-soft: #ba5900;
--c-red-soft: #db3737;
--c-pink-soft: #d3368c;
--c-purple-soft: #8c5ed1;
--line: #e6e7eb; /* 分隔线 */
/* ---- 强调色(全站唯一,链接/hover/选中) ---- */
@@ -105,6 +128,27 @@
--ink-3: #8b8f98;
--paper: #201d18; /* 暖棕夜灯(Q 卡视觉裁定) */
--ink-4: #5c6066;
--ink-warm: #d6cbbd; /* 暗态暖调:10.58:1 */
--ink-cool: #b9c6d6; /* 暗态冷调:9.76:1 */
--ink-faint: #84888f; /* 暗态最淡可读档:4.75:1 */
--accent-soft-text: #a8b7f7; /* 暗态弱强调文字:8.69:1 */
/* 语义色板暗态(各自独立取值,非亮态硬套):全部实测 #191d23 底 ≥4.5:1 */
--c-blue: #7fb0ff;
--c-teal: #6fd6c8;
--c-green: #7ddf99;
--c-yellow: #ffd166;
--c-orange: #ffb163;
--c-red: #ff8b8b;
--c-pink: #ff9ecb;
--c-purple: #c4a6ff;
--c-blue-soft: #3381ff;
--c-teal-soft: #2a9384;
--c-green-soft: #269a47;
--c-yellow-soft: #af7a00;
--c-orange-soft: #d16800;
--c-red-soft: #ff2828;
--c-pink-soft: #ff0d7e;
--c-purple-soft: #9a67ff;
--line: #262b33; /* 5f 降一档:暗色切分感偏强(裁定 #7) */
--accent: #8ea2ff;