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
This commit is contained in:
1 parent
c5cdcd5bf9
commit
2cab243891
9 files changed
+731
-6
No files matched your search
@@ -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>
|
||||
@@ -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
@@ -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 秒)
|
||||
|
||||
@@ -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>
|
||||
@@ -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: '最淡可读档' },
|
||||
],
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
})();
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user