Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6293fe25d5 | ||
|
|
8d82aab213 | ||
|
|
029633f00f | ||
|
|
0ff4ede91f | ||
|
|
23303bd39a | ||
|
|
31eb44caee | ||
|
|
5c015c6577 | ||
|
|
138d3d976b | ||
|
|
d30b5decbb | ||
|
|
36643f5cf0 | ||
|
|
52188d5b12 | ||
|
|
fef2de1d37 | ||
|
|
bdaf02f9ca | ||
|
|
c7d1e7b975 | ||
|
|
550f6d15b4 | ||
|
|
cdd9c61641 | ||
|
|
e1fc8bd64c | ||
|
|
cc12e016ab |
No files matched your search
@@ -0,0 +1,92 @@
|
||||
# Phase 5i:字体加载根治、交互缺陷修复与书架/开源页再设计 - 项目范围
|
||||
|
||||
<!-- spec-format: 2 -->
|
||||
|
||||
## 1. 问题定义
|
||||
- **项目目标**:①**字体切换全部失效的根因修复**(不是字体没做,是加载门禁错了)②按需扩展更多字族 ③Artalk「加载失败」误报与瞬间评论区配色脱同步 ④图库灯箱右上关闭被遮挡点不动,且右侧文字要能换字体/颜色 ⑤取消「彩色 ≤3 处/页」限制 ⑥开源页项目墙恢复居中 ⑦书架页重做 ⑧GitHub README 的动效图与更多展示形式回归(自托管,不再外链)
|
||||
- **目标用户**:站长(写得更自由、字体真的能换)与读者(交互不再有假失败、书架更耐看)
|
||||
- **核心价值**:把「看起来能用」变成「真的能用」——本轮多数是用户实测踩到的静默失效,根因都在加载门禁与层级覆盖
|
||||
|
||||
## 2. 假设与待确认
|
||||
|
||||
### 2.1 已确认事实(本轮实测取证,2026-11-02)
|
||||
- **字体失效根因**:`src/layouts/BaseLayout.astro:76` 把 serif/kai/kuaile/hwmc 四份字体 CSS 全部关在 `track === 'reading'` 里;技术轨与全站页面(默认 `track='tech'`)从不加载它们 → `.font-kai` 计算值虽为 `"LXGW WenKai Screen"` 但该 @font-face 不在文档字体集内(`document.fonts` 实测仅 Noto Sans/JetBrains),浏览器回退系统宋体/衬线,表现为「字体没换」
|
||||
- **汇文明朝链路**:`/fonts/subset/hwmc/fonts.css` 由 public 手工切片提供(339 片 unicode-range),仅在阅读轨被 preload;技术轨整条链路不加载
|
||||
- **Artalk 误报根因**:`ArtalkComments.astro:126` `if (document.getElementById('artalk-lib')) return initArtalk();` —— 软导航后脚本标签已在但 `window.Artalk` 尚未就绪,走 `initArtalk()` → `!lib` → `showRetry(true)` 永久显示失败提示;而评论随后正常渲染(用户所见「总显示失败但实际正常」)
|
||||
- **瞬间评论区配色脱同步**:Artalk 主题变量块选择器是 `#artalk-container` 与 `body .atk-layer-wrap`;瞬间的容器 id 为 `mom-atk-<slug>`,不在作用域内 → 回落到 Artalk 默认色
|
||||
- **灯箱关闭失灵根因**:`#lb-close` 位于 (1220,16,44×44),`document.elementFromPoint` 命中的是 `.lb-meta.lb-l2`(右侧信息栏压在按钮上),点击被信息栏吃掉 → `lb.close()` 永不触发(生产实测 `open` 保持 true)
|
||||
- 书架页现状:色脊卡 + 年份分组网格;信息密度低、悬停无反馈、无简介/评分等可读线索
|
||||
- README 动效资源清单(7 类):readme-typing-svg、github-readme-stats、streak-stats、activity-graph、贡献蛇(daily Action 产出的 SVG)、shields 徽章、komarev 访客
|
||||
|
||||
### 2.2 关键假设
|
||||
- 字体按「内容实际用到的字族」按需加载即可兼顾体积与正确性(构建期扫正文/页面文案里的 `font-*` 类)
|
||||
- README 动效图均为 SVG(含 SMIL/CSS 动画),可在构建期抓取并自托管,运行时零第三方请求,动效保留
|
||||
- 灯箱信息栏与关闭钮的层叠冲突用 z-index + 命中区域划分即可解,无需改结构
|
||||
|
||||
### 2.3 待确认问题
|
||||
- [ ] 字体扩展款式(用户选:宋系 / 楷系 / 手写系 / 现代黑系各补几款)
|
||||
- [ ] 书架重做方向(A 色脊墙精修 / B 封面网格 / C 书脊展开卡)
|
||||
- [ ] README 动效图确认「构建期抓取 + 自托管」路线
|
||||
|
||||
### 2.4 可选解释与取舍
|
||||
- 字体:只按需加载,不做「全站全字族常开」(那会把单页体积拉到数 MB)
|
||||
- README 动效:自托管后仍保留原服务生成逻辑(GitHub Action / 第三方接口),只是把产物入仓并在构建期更新
|
||||
- 彩色限制取消后仍保留「灰阶外壳」红线(导航/页脚/按钮/背景不染色),只取消正文次数限制
|
||||
|
||||
### 2.5 已证伪路径
|
||||
- 「字族没做」判断错误:@font-face 与切片文件都在,问题在加载门禁(本轮实测推翻)
|
||||
- 外链 README 图直接保留:既有 502/超时与外链依赖问题(上一阶段已判弃),改自托管
|
||||
|
||||
## 3. 功能范围
|
||||
|
||||
### 3.1 核心功能(MVP)
|
||||
- [ ] T1 字族按需加载根治 + 字族扩展机制:BaseLayout 由「轨道门禁」改为「内容诉求驱动」,扫描正文与页面文案中的 `font-*` 类决定加载哪些字体 CSS;补字体扩展清单与接入流程
|
||||
- [ ] T2 Artalk 误报与配色同步:失败提示改为「真失败才提示」(脚本 onerror / 库未就绪超时且未挂载才提示),重置挂载状态机;主题变量覆盖全部 Artalk 实例容器(含瞬间逐条)
|
||||
- [ ] T3 灯箱:右上关闭恢复可点(层级与命中区域),右侧信息栏文字支持 markdown + 排版类(字体/颜色)
|
||||
- [ ] T4 取消彩色强调次数限制(文档 + 后台 hint + 示例文章同步)
|
||||
- [ ] T5 开源页项目墙恢复居中排版
|
||||
- [ ] T6 书架页重做(按用户选定方向)
|
||||
- [ ] T7 README 动效图自托管与展示创新(typing / stats / streak / activity-graph / snake 等构建期抓取入仓,配合原生数据卡与热力图组合呈现)
|
||||
|
||||
### 3.2 扩展功能
|
||||
- 字体扩展的实际接入(款式待用户定)
|
||||
|
||||
### 3.3 不在范围内
|
||||
- 换 CMS、评论系统替换、Artalk 服务端改造
|
||||
|
||||
## 4. 最小实现路径
|
||||
- 最简可行:字体先修门禁(用现有 6 族验证通过),再谈扩展;灯箱只改层级与命中;README 动效先接 3 类(typing / stats / snake),验证体积与稳定性后再补
|
||||
- 暂不引入:字体子集化的自动重跑编排、灯箱信息栏的富文本编辑器 UI
|
||||
- 不做的抽象:通用「动效资源托管」服务
|
||||
|
||||
## 5. 技术决策
|
||||
- 技术栈:Astro 7.4.4 + @fontsource(自托管字体 CSS)+ Artalk v2 + 构建期 SVG 抓取缓存(复用 README 缓存模式)
|
||||
- 证据协议:evidence: runs
|
||||
- 本轮允许改动:src/layouts/BaseLayout.astro、src/styles/{global,tokens}.css、src/components/{ArtalkComments,MomentComments}.astro、src/components/geek/{ViewMeta,ProjectWall,GithubProfile}.astro、src/components/pages/{GalleryPage,BooksPage}.astro、src/pages/{gallery,books}.astro、src/lib/{projects,site-content}.ts、content/site/*.yaml、content/photos/*.json(caption 富文本)、content/.github-assets/(新缓存目录)、public/admin/*、scripts/build-perf/*、infra/deploy/build.sh、docs/*、tests/e2e.mjs
|
||||
- 本轮不应触碰:nginx/ESA、构建发布链主干顺序、双语路由结构、Artalk 服务端配置
|
||||
- Git integration branch:`spec/2026-11-02_blog-5i-fonts-polish`
|
||||
|
||||
### 5.1 验证策略
|
||||
- 范围级别:package
|
||||
- 变更对象:字体加载策略、Artalk 挂载状态机与主题作用域、灯箱层级与富文本、书架与开源页版式、自托管动效资源
|
||||
- 快速检查:`pnpm astro build`(node26)+ `python3 scripts/build-perf/config-check.py .`
|
||||
- 集成检查:E2E(当前 46 项,本包新增字族加载/灯箱关闭/Artalk 误报断言)+ content-lint 0/0/0
|
||||
- 全项目检查:生产实测五条(字体真加载 `document.fonts` 命中、灯箱点关闭真关、评论失败提示不再误报、瞬间评论区配色与页面一致、开源页动效图零外链);**升级条件**——仅当改动触及 BaseLayout 字体策略或 Artalk 挂载状态机时升级为全站视觉复核,本包因两者都动,已排一轮视觉终审
|
||||
|
||||
## 6. 成功标准与验证方式
|
||||
- [ ] 技术轨文章里的 `.font-kai/.font-song/.font-huiwen/.font-kuaile` 真加载对应字体(`document.fonts` 命中且渲染变化) -> verify: task_0001
|
||||
- [ ] 评论失败提示只在真失败时出现,正常挂载不提示 -> verify: task_0002
|
||||
- [ ] 瞬间评论区配色与页面主题一致(明暗双态) -> verify: task_0003
|
||||
- [ ] 灯箱右上关闭按钮点击必关(含缩放/信息栏展开态) -> verify: task_0004
|
||||
- [ ] 灯箱右侧文字支持字体与颜色类 -> verify: task_0005
|
||||
- [ ] 彩色强调不再有限次约束且文档同步 -> verify: task_0006
|
||||
- [ ] 开源页项目墙内容居中 -> verify: task_0007
|
||||
- [ ] 书架页信息密度与视觉达设计稿并过视觉终审 -> verify: task_0008
|
||||
- [ ] README 动效图自托管且生产零第三方请求 -> verify: task_0009
|
||||
|
||||
## 7. 风险与约束
|
||||
- 灰阶外壳红线不变,仅取消正文彩色次数限制
|
||||
- 自制切片/新增字体必须守单页增量 ≤150KB 阈值,超阈值只做子集化或降级为系统回退
|
||||
- README 动效自托管:抓取失败必须回落(隐藏该块,绝不炸构建),且产物必须入仓保证构建确定性
|
||||
- Artalk 状态机改动需覆盖三条触发路径(IO / 锚点 / 滚底),不得回归「评论区空着」
|
||||
- 多车道并行:BaseLayout、ArtalkComments、GalleryPage、BooksPage 各归单一车道,admin config 与 docs 由 orchestrator 统一改
|
||||
@@ -0,0 +1,53 @@
|
||||
# Phase 5i:字体加载根治、交互缺陷修复与书架/开源页再设计 - 任务拆解
|
||||
|
||||
## 使用规则
|
||||
- boundary/verify 齐备;verify 只读断言;车道工作树开发禁 git,收编由 orchestrator 统一提交
|
||||
- 构建一律 `export PATH="$HOME/.local/node/bin:$PATH"`(Node 26)+ `pnpm astro build`
|
||||
- E2E 口径:`pnpm astro build → cp -a dist /tmp/dist-e2e-5i → gz-server.py 18925 /tmp/dist-e2e-5i → E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs`
|
||||
|
||||
## 阶段一:实现
|
||||
|
||||
- [x] T1 字族按需加载根治
|
||||
- claim: worker-one
|
||||
- boundary: src/layouts/BaseLayout.astro(去掉 `track === 'reading'` 的字体门禁,改为「本页内容实际用到的字族」驱动:构建期扫正文/页面文案里的 `font-kai|font-song|font-fangsong|font-huiwen|font-kuaile|font-serif` 决定注入哪几份字体 CSS;零命中时只加载 sans/mono)、src/lib/ 新增字体诉求探测工具
|
||||
- verify: `grep -q "FONT_CSS\|neededFonts" src/layouts/BaseLayout.astro && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE "汇总: [0-9]+/[0-9]+ PASS"`
|
||||
|
||||
- [x] T2 Artalk 失败误报修复
|
||||
- claim: op-artalk
|
||||
- boundary: src/components/ArtalkComments.astro(状态机重写:脚本 onerror 才是失败;`artalk-lib` 已存在但库未就绪时等 load 事件而非立即判失败;失败提示只在「确实未挂载且已超时」出现;`mounting` 在所有失败/成功路径复位)
|
||||
- verify: `grep -q "onerror" src/components/ArtalkComments.astro && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE "汇总: [0-9]+/[0-9]+ PASS"`
|
||||
|
||||
- [x] T3 瞬间评论区配色同步
|
||||
- claim: op-artalk
|
||||
- boundary: src/components/MomentComments.astro(Artalk 主题变量作用域覆盖瞬间容器 `[data-artalk-panel]`/`mom-atk-*`,复用文章页那套 `--at-color-*` 映射:建议抽出共享样式片段避免两份漂移)
|
||||
- verify: `grep -qE "data-artalk-panel|mom-atk" src/components/MomentComments.astro && grep -q "at-color-bg" src/components/MomentComments.astro`
|
||||
|
||||
- [x] T4 灯箱关闭修复 + 右侧文字可排版
|
||||
- claim: orchestrator
|
||||
- boundary: src/components/pages/GalleryPage.astro(关闭钮层级/命中区域;右侧信息栏不再拦截点击)、content/photos/*.json(caption 允许 markdown + 排版类)、必要时 src/lib/exif.ts 注释同步
|
||||
- verify: `grep -q "lb-close" src/components/pages/GalleryPage.astro && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE "汇总: [0-9]+/[0-9]+ PASS"`
|
||||
|
||||
- [x] T5 取消彩色强调次数限制
|
||||
- claim: orchestrator
|
||||
- boundary: docs/写作指南.md、docs/管理台.md、public/admin/config.yml(hint 里去掉「≤3 处」表述)、content/posts/writing-template.md + posts-en 同名(示例文章说明同步)
|
||||
- verify: `! grep -rq "一页 ≤3 处\|≤3 处/页" docs/ content/posts/writing-template.md && grep -q "彩色" docs/写作指南.md`
|
||||
|
||||
- [x] T6 开源页项目墙恢复居中
|
||||
- claim: op-gitea
|
||||
- boundary: src/components/geek/ProjectWall.astro(`align-items: center` + `text-align: center`,与用户审美偏好一致)
|
||||
- verify: `grep -q "align-items: center" src/components/geek/ProjectWall.astro`
|
||||
|
||||
- [ ] T7 书架页重做
|
||||
- claim: orchestrator(方向待用户拍板)
|
||||
- boundary: src/components/pages/BooksPage.astro、可选 content/site/books.yaml 新配置、src/i18n/ui.ts
|
||||
- verify: `E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE "汇总: [0-9]+/[0-9]+ PASS"`
|
||||
|
||||
- [x] T8 README 动效图自托管与展示创新
|
||||
- claim: op-gitea
|
||||
- boundary: 新增 scripts/build-perf/fetch-readme-assets.mjs(构建期抓 typing/stats/streak/activity-graph/snake 等 SVG 到 content/.github-assets/,失败回落隐藏该块)、src/components/geek/GithubProfile.astro 或新增动效展示组件、build.sh 守卫挂载、docs 说明
|
||||
- verify: `test -f scripts/build-perf/fetch-readme-assets.mjs && grep -q "github-assets" scripts/build-perf/fetch-readme-assets.mjs && python3 -c "import pathlib;p=pathlib.Path('dist/opensource/index.html').read_text();bad=[u for u in ['img.shields.io','readme-typing-svg','github-readme-stats','streak-stats','komarev'] if u in p];assert not bad,bad"`
|
||||
|
||||
- [ ] T9 字族扩展(款式待用户拍板)
|
||||
- claim: worker-one
|
||||
- boundary: src/styles/tokens.css、src/styles/global.css、public/admin/typography-classes.js、public/fonts/(新增字体切片)、docs/写作指南.md 字体清单
|
||||
- verify: `grep -c "font-" public/admin/typography-classes.js | awk '$1>=40'`
|
||||
@@ -0,0 +1,95 @@
|
||||
# Phase 5h:内容表达能力与社交反馈 - 完成总结
|
||||
|
||||
## 交付结论
|
||||
- 结果:完成
|
||||
- 完成时间:2026-11-02 21:16
|
||||
|
||||
## 假设回顾
|
||||
### 已验证假设
|
||||
- 瞬间富文本走与全站文案同一条净化管线即可满足「字体/颜色可换」且不引入 XSS 面(危险标签整棵丢弃、协议白名单 http/https/mailto)
|
||||
- 每条瞬间一个 Artalk page_key(`/moments/<slug>/`)不会与页面级 pv 冲突;Artalk 实例按需挂载(展开才 init)可避免 "one page one Artalk" 的全局实例冲突
|
||||
- 开源页可用本地数据(已有 repo 元数据 + GitHub GraphQL 拉取)自绘 followers/stars/语言占比,无需任何第三方徽章服务
|
||||
|
||||
### 仍未完全验证的假设
|
||||
- 无
|
||||
|
||||
## 交付范围
|
||||
### 已交付
|
||||
- T1 文章置顶(契约 + 排序 + 标记 + 后台)
|
||||
- T2 瞬间富文本(字体可换 / 颜色可换 / 多形式)
|
||||
- T3 瞬间逐条评论(Artalk 独立 page_key + 折叠懒挂载)
|
||||
- T4 计数徽标(眼睛图标 = 浏览数,气泡图标 = 评论数)
|
||||
- T5 开源页重构(原生 GitHub 档案卡,去外链图依赖)
|
||||
- T6 关于页承接 README 的个人叙述
|
||||
- T7 写作模板示例文章 + 文档与 hint 收尾
|
||||
|
||||
### 未交付
|
||||
- 无
|
||||
|
||||
### 偏差说明
|
||||
- 无
|
||||
|
||||
## 简化决策
|
||||
### 保持简单的关键选择
|
||||
- 最简单可行方案:置顶先用 boolean + 排序键;瞬间富文本直接把 `text` 交给既有 `renderRich`;评论用现有 Artalk 组件克隆一份带折叠的;计数徽标只做 pv + count 两个数
|
||||
- 暂不引入:置顶权重/多级置顶、瞬间的图片轮播、评论楼中楼定制、开源页的实时 Star 曲线
|
||||
- 不做的抽象:通用「内容社交层」框架(只服务现有页面)
|
||||
|
||||
### 本轮明确不做的内容
|
||||
- 评论回复通知/邮件推送、点赞/投票、阅读进度、站点级总访问量统计(Artalk 无匿名聚合端点)
|
||||
|
||||
## 变更边界
|
||||
### 本轮主要改动模块
|
||||
- 见 tasks.md 的 boundary 记录
|
||||
|
||||
### 明确未触碰的区域
|
||||
- 评论回复通知/邮件推送、点赞/投票、阅读进度、站点级总访问量统计(Artalk 无匿名聚合端点)
|
||||
|
||||
## 验证证据
|
||||
### 备注
|
||||
- 机器证据:runs.jsonl(见验证范围的机器验证率)
|
||||
|
||||
## 验证范围
|
||||
- 级别:package
|
||||
- 机器验证率:7/7
|
||||
- 规则:check 与 done 复用该范围;只有记录升级触发时才执行全项目检查
|
||||
|
||||
## 哲学生效证据
|
||||
### 行为成效回填
|
||||
- Development Record 行为门禁通过
|
||||
|
||||
### 一致性门禁回顾
|
||||
- 验收面(checklist 或成功标准)已通过
|
||||
|
||||
## Git 记录
|
||||
- 日期时间:2026-11-02 21:16
|
||||
- 范围:Phase 5h:内容表达能力与社交反馈
|
||||
- 功能:T1 文章置顶(契约 + 排序 + 标记 + 后台);T2 瞬间富文本(字体可换 / 颜色可换 / 多形式);T3 瞬间逐条评论(Artalk 独立 page_key + 折叠懒挂载);T4 计数徽标(眼睛图标 = 浏览数,气泡图标 = 评论数);T5 开源页重构(原生 GitHub 档案卡,去外链图依赖);T6 关于页承接 README 的个人叙述;T7 写作模板示例文章 + 文档与 hint 收尾
|
||||
- 操作:提交 / 推送
|
||||
- 成效:Development Record 行为门禁通过
|
||||
- 提交:pending local commit
|
||||
- 提交区间:ed473e996a3707f98015a40e4388aa4fa58b79ac..bdaf02f9cae774e2a094eb716a95be3f0eddab56
|
||||
- 推送:not-run (push stage follows commit)
|
||||
|
||||
## AGENTS.md 增量提议
|
||||
```diff
|
||||
Phase 5h 资产:①置顶契约(sortPosts 统一四处排序,只改顺序不动 pubDate/URL/RSS)②瞬间富文本(复用 renderRich 净化链,危险标签整棵丢弃)③瞬间逐条评论(展开才挂 Artalk;评论键用虚拟路径 /moments/<slug>/——实测 # 在查询串会被截断成页面级键,导致所有瞬间共用一个评论流)④计数徽标 ViewMeta(眼睛/气泡、零第三方图片;修同载双计与同页数字差 1 两个自伤 bug,口径=「请求次数对」不等于「展示自洽」)⑤开源页去 README 外链依赖(7 类第三方图与 align 居中失效是「没居中/图片不显示」的根因)⑥灰阶纪律清剿:项目墙 15 个彩色语言点、并列 36% 单着色、全局 .tap 泄漏致卡片居中 ⑦视觉验收新增两条可复用断言法:Range.getClientRects 判折行、scrollWidth==innerWidth 判横向溢出。
|
||||
```
|
||||
|
||||
## 被拒绝的扩展提议
|
||||
- 无
|
||||
|
||||
## 门禁证据
|
||||
- check_spec_package.py exit 0
|
||||
|
||||
## 遗留事项
|
||||
- 无;问题处置见下方结构化区块
|
||||
|
||||
## 问题处置
|
||||
|
||||
```json
|
||||
{
|
||||
"issues": [],
|
||||
"version": 1
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,28 @@
|
||||
{"task": "task_0001", "cmd": "E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE \"汇总: [0-9]+/[0-9]+ PASS\" && grep -q \"pinned\" src/content.config.ts && grep -q \"pinned\" src/components/PostList.astro && grep -q \"pinned\" public/admin/config.yml", "exit": 0, "duration_s": 21.239, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "a4c5572a52c4f546900c03b86c7cfd32056ad32ec27194018ffc175ac700f3c5", "at": "2026-11-02T12:58:45+00:00"}
|
||||
{"task": "task_0002", "cmd": "E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE \"汇总: [0-9]+/[0-9]+ PASS\" && grep -qE \"renderRich|renderMarkdown\" src/components/pages/MomentsPage.astro && python3 -c \"import yaml;d=yaml.safe_load(open('public/admin/config.yml'));m=[c for c in d['collections'] if c['name']=='moments'][0];t=[f for f in m['fields'] if f['name']=='text'][0];assert t['widget']=='markdown',t\"", "exit": 0, "duration_s": 21.893, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "a4c5572a52c4f546900c03b86c7cfd32056ad32ec27194018ffc175ac700f3c5", "at": "2026-11-02T12:59:07+00:00"}
|
||||
{"task": "task_0003", "cmd": "test -f src/components/MomentComments.astro && grep -q \"MomentComments\" src/components/pages/MomentsPage.astro && grep -q \"moments/#\" src/components/MomentComments.astro && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE \"汇总: [0-9]+/[0-9]+ PASS\"", "exit": 0, "duration_s": 21.073, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "a4c5572a52c4f546900c03b86c7cfd32056ad32ec27194018ffc175ac700f3c5", "at": "2026-11-02T12:59:28+00:00"}
|
||||
{"task": "task_0004", "cmd": "test -f src/components/geek/ViewMeta.astro && grep -q \"ViewMeta\" src/components/ArtalkComments.astro && grep -q \"ViewMeta\" src/pages/archives/[...slug].astro && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE \"汇总: [0-9]+/[0-9]+ PASS\"", "exit": 0, "duration_s": 23.015, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "a4c5572a52c4f546900c03b86c7cfd32056ad32ec27194018ffc175ac700f3c5", "at": "2026-11-02T12:59:51+00:00"}
|
||||
{"task": "task_0005", "cmd": "grep -q \"GithubProfile\" src/pages/opensource.astro && python3 -c \"import re,pathlib;p=pathlib.Path('dist/opensource/index.html').read_text();bad=[u for u in ['img.shields.io','readme-typing-svg','github-readme-stats','streak-stats','komarev','github-readme-activity-graph'] if u in p];assert not bad,bad\"", "exit": 0, "duration_s": 0.03, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "a4c5572a52c4f546900c03b86c7cfd32056ad32ec27194018ffc175ac700f3c5", "at": "2026-11-02T12:59:52+00:00"}
|
||||
{"task": "task_0006", "cmd": "python3 -c \"import yaml;d=yaml.safe_load(open('content/site/about.yaml'));types=[b['type'] for b in d.get('blocks') or []];assert 'prose' in types and ('linklist' in types or 'cards' in types),types\" && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE \"汇总: [0-9]+/[0-9]+ PASS\"", "exit": 0, "duration_s": 21.067, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "a4c5572a52c4f546900c03b86c7cfd32056ad32ec27194018ffc175ac700f3c5", "at": "2026-11-02T13:00:13+00:00"}
|
||||
{"task": "task_0007", "cmd": "test -f content/posts/writing-template.md && test -f content/posts-en/writing-template.md && grep -q \"pinned: true\" content/posts/writing-template.md && grep -qE \"font-kai|c-blue\" content/posts/writing-template.md && grep -q \"写作模板\" docs/写作指南.md", "exit": 0, "duration_s": 0.005, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "a4c5572a52c4f546900c03b86c7cfd32056ad32ec27194018ffc175ac700f3c5", "at": "2026-11-02T13:00:13+00:00"}
|
||||
{"task": "task_0001", "cmd": "E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE \"汇总: [0-9]+/[0-9]+ PASS\" && grep -q \"pinned\" src/content.config.ts && grep -q \"pinned\" src/components/PostList.astro && grep -q \"pinned\" public/admin/config.yml", "exit": 0, "duration_s": 20.732, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "f35452f48bd7d96babf34565aeef8d17c25191be02e07d93fd27922795604348", "at": "2026-11-02T13:04:09+00:00"}
|
||||
{"task": "task_0002", "cmd": "E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE \"汇总: [0-9]+/[0-9]+ PASS\" && grep -qE \"renderRich|renderMarkdown\" src/components/pages/MomentsPage.astro && python3 -c \"import yaml;d=yaml.safe_load(open('public/admin/config.yml'));m=[c for c in d['collections'] if c['name']=='moments'][0];t=[f for f in m['fields'] if f['name']=='text'][0];assert t['widget']=='markdown',t\"", "exit": 0, "duration_s": 20.695, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "f35452f48bd7d96babf34565aeef8d17c25191be02e07d93fd27922795604348", "at": "2026-11-02T13:04:30+00:00"}
|
||||
{"task": "task_0003", "cmd": "test -f src/components/MomentComments.astro && grep -q \"MomentComments\" src/components/pages/MomentsPage.astro && grep -q 'moments/${slug}/' src/components/MomentComments.astro && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE \"汇总: [0-9]+/[0-9]+ PASS\"", "exit": 0, "duration_s": 20.501, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "f35452f48bd7d96babf34565aeef8d17c25191be02e07d93fd27922795604348", "at": "2026-11-02T13:04:50+00:00"}
|
||||
{"task": "task_0004", "cmd": "test -f src/components/geek/ViewMeta.astro && grep -q \"ViewMeta\" src/components/ArtalkComments.astro && grep -q \"ViewMeta\" src/pages/archives/[...slug].astro && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE \"汇总: [0-9]+/[0-9]+ PASS\"", "exit": 0, "duration_s": 20.83, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "f35452f48bd7d96babf34565aeef8d17c25191be02e07d93fd27922795604348", "at": "2026-11-02T13:05:11+00:00"}
|
||||
{"task": "task_0005", "cmd": "grep -q \"GithubProfile\" src/pages/opensource.astro && python3 -c \"import re,pathlib;p=pathlib.Path('dist/opensource/index.html').read_text();bad=[u for u in ['img.shields.io','readme-typing-svg','github-readme-stats','streak-stats','komarev','github-readme-activity-graph'] if u in p];assert not bad,bad\"", "exit": 0, "duration_s": 0.029, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "f35452f48bd7d96babf34565aeef8d17c25191be02e07d93fd27922795604348", "at": "2026-11-02T13:05:11+00:00"}
|
||||
{"task": "task_0006", "cmd": "python3 -c \"import yaml;d=yaml.safe_load(open('content/site/about.yaml'));types=[b['type'] for b in d.get('blocks') or []];assert 'prose' in types and ('linklist' in types or 'cards' in types),types\" && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE \"汇总: [0-9]+/[0-9]+ PASS\"", "exit": 0, "duration_s": 21.719, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "f35452f48bd7d96babf34565aeef8d17c25191be02e07d93fd27922795604348", "at": "2026-11-02T13:05:33+00:00"}
|
||||
{"task": "task_0007", "cmd": "test -f content/posts/writing-template.md && test -f content/posts-en/writing-template.md && grep -q \"pinned: true\" content/posts/writing-template.md && grep -qE \"font-kai|c-blue\" content/posts/writing-template.md && grep -q \"写作模板\" docs/写作指南.md", "exit": 0, "duration_s": 0.005, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "f35452f48bd7d96babf34565aeef8d17c25191be02e07d93fd27922795604348", "at": "2026-11-02T13:05:33+00:00"}
|
||||
{"task": "task_0001", "cmd": "E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE \"汇总: [0-9]+/[0-9]+ PASS\" && grep -q \"pinned\" src/content.config.ts && grep -q \"pinned\" src/components/PostList.astro && grep -q \"pinned\" public/admin/config.yml", "exit": 0, "duration_s": 21.074, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "62086ce4cccd8c576913526a7ae517471278660c8e6cc6d7e24f9ea632038195", "at": "2026-11-02T13:12:48+00:00"}
|
||||
{"task": "task_0002", "cmd": "E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE \"汇总: [0-9]+/[0-9]+ PASS\" && grep -qE \"renderRich|renderMarkdown\" src/components/pages/MomentsPage.astro && python3 -c \"import yaml;d=yaml.safe_load(open('public/admin/config.yml'));m=[c for c in d['collections'] if c['name']=='moments'][0];t=[f for f in m['fields'] if f['name']=='text'][0];assert t['widget']=='markdown',t\"", "exit": 0, "duration_s": 20.656, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "62086ce4cccd8c576913526a7ae517471278660c8e6cc6d7e24f9ea632038195", "at": "2026-11-02T13:13:09+00:00"}
|
||||
{"task": "task_0003", "cmd": "test -f src/components/MomentComments.astro && grep -q \"MomentComments\" src/components/pages/MomentsPage.astro && grep -q 'moments/${slug}/' src/components/MomentComments.astro && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE \"汇总: [0-9]+/[0-9]+ PASS\"", "exit": 0, "duration_s": 20.386, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "62086ce4cccd8c576913526a7ae517471278660c8e6cc6d7e24f9ea632038195", "at": "2026-11-02T13:13:29+00:00"}
|
||||
{"task": "task_0004", "cmd": "test -f src/components/geek/ViewMeta.astro && grep -q \"ViewMeta\" src/components/ArtalkComments.astro && grep -q \"ViewMeta\" src/pages/archives/[...slug].astro && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE \"汇总: [0-9]+/[0-9]+ PASS\"", "exit": 0, "duration_s": 20.357, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "62086ce4cccd8c576913526a7ae517471278660c8e6cc6d7e24f9ea632038195", "at": "2026-11-02T13:13:49+00:00"}
|
||||
{"task": "task_0005", "cmd": "grep -q \"GithubProfile\" src/pages/opensource.astro && python3 -c \"import re,pathlib;p=pathlib.Path('dist/opensource/index.html').read_text();bad=[u for u in ['img.shields.io','readme-typing-svg','github-readme-stats','streak-stats','komarev','github-readme-activity-graph'] if u in p];assert not bad,bad\"", "exit": 0, "duration_s": 0.029, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "62086ce4cccd8c576913526a7ae517471278660c8e6cc6d7e24f9ea632038195", "at": "2026-11-02T13:13:49+00:00"}
|
||||
{"task": "task_0006", "cmd": "python3 -c \"import yaml;d=yaml.safe_load(open('content/site/about.yaml'));types=[b['type'] for b in d.get('blocks') or []];assert 'prose' in types and ('linklist' in types or 'cards' in types),types\" && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE \"汇总: [0-9]+/[0-9]+ PASS\"", "exit": 0, "duration_s": 21.589, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "62086ce4cccd8c576913526a7ae517471278660c8e6cc6d7e24f9ea632038195", "at": "2026-11-02T13:14:11+00:00"}
|
||||
{"task": "task_0007", "cmd": "test -f content/posts/writing-template.md && test -f content/posts-en/writing-template.md && grep -q \"pinned: true\" content/posts/writing-template.md && grep -qE \"font-kai|c-blue\" content/posts/writing-template.md && grep -q \"写作模板\" docs/写作指南.md", "exit": 0, "duration_s": 0.005, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "62086ce4cccd8c576913526a7ae517471278660c8e6cc6d7e24f9ea632038195", "at": "2026-11-02T13:14:11+00:00"}
|
||||
{"task": "task_0001", "cmd": "E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE \"汇总: [0-9]+/[0-9]+ PASS\" && grep -q \"pinned\" src/content.config.ts && grep -q \"pinned\" src/components/PostList.astro && grep -q \"pinned\" public/admin/config.yml", "exit": 0, "duration_s": 20.396, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "4af92016715e63fb205156cc420c1d1a9f264ede8fd6bb358ffc8099741b2cef", "at": "2026-11-02T13:14:57+00:00"}
|
||||
{"task": "task_0002", "cmd": "E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE \"汇总: [0-9]+/[0-9]+ PASS\" && grep -qE \"renderRich|renderMarkdown\" src/components/pages/MomentsPage.astro && python3 -c \"import yaml;d=yaml.safe_load(open('public/admin/config.yml'));m=[c for c in d['collections'] if c['name']=='moments'][0];t=[f for f in m['fields'] if f['name']=='text'][0];assert t['widget']=='markdown',t\"", "exit": 0, "duration_s": 21.377, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "4af92016715e63fb205156cc420c1d1a9f264ede8fd6bb358ffc8099741b2cef", "at": "2026-11-02T13:15:19+00:00"}
|
||||
{"task": "task_0003", "cmd": "test -f src/components/MomentComments.astro && grep -q \"MomentComments\" src/components/pages/MomentsPage.astro && grep -q 'moments/${slug}/' src/components/MomentComments.astro && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE \"汇总: [0-9]+/[0-9]+ PASS\"", "exit": 0, "duration_s": 20.908, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "4af92016715e63fb205156cc420c1d1a9f264ede8fd6bb358ffc8099741b2cef", "at": "2026-11-02T13:15:40+00:00"}
|
||||
{"task": "task_0004", "cmd": "test -f src/components/geek/ViewMeta.astro && grep -q \"ViewMeta\" src/components/ArtalkComments.astro && grep -q \"ViewMeta\" src/pages/archives/[...slug].astro && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE \"汇总: [0-9]+/[0-9]+ PASS\"", "exit": 0, "duration_s": 21.893, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "4af92016715e63fb205156cc420c1d1a9f264ede8fd6bb358ffc8099741b2cef", "at": "2026-11-02T13:16:02+00:00"}
|
||||
{"task": "task_0005", "cmd": "grep -q \"GithubProfile\" src/pages/opensource.astro && python3 -c \"import re,pathlib;p=pathlib.Path('dist/opensource/index.html').read_text();bad=[u for u in ['img.shields.io','readme-typing-svg','github-readme-stats','streak-stats','komarev','github-readme-activity-graph'] if u in p];assert not bad,bad\"", "exit": 0, "duration_s": 0.029, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "4af92016715e63fb205156cc420c1d1a9f264ede8fd6bb358ffc8099741b2cef", "at": "2026-11-02T13:16:02+00:00"}
|
||||
{"task": "task_0006", "cmd": "python3 -c \"import yaml;d=yaml.safe_load(open('content/site/about.yaml'));types=[b['type'] for b in d.get('blocks') or []];assert 'prose' in types and ('linklist' in types or 'cards' in types),types\" && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE \"汇总: [0-9]+/[0-9]+ PASS\"", "exit": 0, "duration_s": 20.399, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "4af92016715e63fb205156cc420c1d1a9f264ede8fd6bb358ffc8099741b2cef", "at": "2026-11-02T13:16:22+00:00"}
|
||||
{"task": "task_0007", "cmd": "test -f content/posts/writing-template.md && test -f content/posts-en/writing-template.md && grep -q \"pinned: true\" content/posts/writing-template.md && grep -qE \"font-kai|c-blue\" content/posts/writing-template.md && grep -q \"写作模板\" docs/写作指南.md", "exit": 0, "duration_s": 0.005, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "4af92016715e63fb205156cc420c1d1a9f264ede8fd6bb358ffc8099741b2cef", "at": "2026-11-02T13:16:22+00:00"}
|
||||
+16
-16
@@ -18,7 +18,7 @@
|
||||
|
||||
### 2.2 关键假设
|
||||
- 瞬间富文本走与全站文案同一条净化管线即可满足「字体/颜色可换」且不引入 XSS 面(危险标签整棵丢弃、协议白名单 http/https/mailto)
|
||||
- 每条瞬间一个 Artalk page_key(`/moments/#<slug>`)不会与页面级 pv 冲突;Artalk 实例按需挂载(展开才 init)可避免 "one page one Artalk" 的全局实例冲突
|
||||
- 每条瞬间一个 Artalk page_key(`/moments/<slug>/`)不会与页面级 pv 冲突;Artalk 实例按需挂载(展开才 init)可避免 "one page one Artalk" 的全局实例冲突
|
||||
- 开源页可用本地数据(已有 repo 元数据 + GitHub GraphQL 拉取)自绘 followers/stars/语言占比,无需任何第三方徽章服务
|
||||
|
||||
### 2.3 待确认问题
|
||||
@@ -39,13 +39,13 @@
|
||||
## 3. 功能范围
|
||||
|
||||
### 3.1 核心功能(MVP)
|
||||
- [ ] T1 文章置顶:posts/postsEn schema `pinned`、四处列表排序(首页双轨/归档/书架)、视觉标记(灰阶 pin)、后台开关
|
||||
- [ ] T2 瞬间富文本:moments `text` 支持 markdown + 白名单排版类(字体/颜色/强调/多段/列表)、净化管线复用、后台改 markdown 编辑器、旧纯文本零回归
|
||||
- [ ] T3 瞬间逐条评论:每条 moment 独立 page_key 的 Artalk 评论区(折叠、展开时挂载)、评论数联动、双语页共用同一 key
|
||||
- [ ] T4 计数徽标:新增 `ViewMeta` 组件(眼睛图标 = 浏览数、气泡图标 = 评论数),接入文章页/瞬间页/关于页/开源页
|
||||
- [ ] T5 开源页重构:原生 GitHub 档案卡(头像/名字/关注者/星标/语言占比,本地数据自绘)替换 README 外链图;`align` 失效与破图问题消失;README 通道降级为可选
|
||||
- [ ] T6 关于页承接:把 README 的 About Me / Find Me 迁为 about.yaml blocks(prose + linklist),开源页只留开源相关
|
||||
- [ ] T7 写作模板示例:中英双语示例文章,演示标题层级/彩色强调/字族/引用/代码/图/表/链接卡片/折叠,并作为置顶功能的活样本;docs 与后台 hint 同步
|
||||
- [x] T1 文章置顶:posts/postsEn schema `pinned`、四处列表排序(首页双轨/归档/书架)、视觉标记(灰阶 pin)、后台开关
|
||||
- [x] T2 瞬间富文本:moments `text` 支持 markdown + 白名单排版类(字体/颜色/强调/多段/列表)、净化管线复用、后台改 markdown 编辑器、旧纯文本零回归
|
||||
- [x] T3 瞬间逐条评论:每条 moment 独立 page_key 的 Artalk 评论区(折叠、展开时挂载)、评论数联动、双语页共用同一 key
|
||||
- [x] T4 计数徽标:新增 `ViewMeta` 组件(眼睛图标 = 浏览数、气泡图标 = 评论数),接入文章页/瞬间页/关于页/开源页
|
||||
- [x] T5 开源页重构:原生 GitHub 档案卡(头像/名字/关注者/星标/语言占比,本地数据自绘)替换 README 外链图;`align` 失效与破图问题消失;README 通道降级为可选
|
||||
- [x] T6 关于页承接:把 README 的 About Me / Find Me 迁为 about.yaml blocks(prose + linklist),开源页只留开源相关
|
||||
- [x] T7 写作模板示例:中英双语示例文章,演示标题层级/彩色强调/字族/引用/代码/图/表/链接卡片/折叠,并作为置顶功能的活样本;docs 与后台 hint 同步
|
||||
|
||||
### 3.2 扩展功能
|
||||
- 无
|
||||
@@ -70,17 +70,17 @@
|
||||
- 变更对象:内容模型(置顶/瞬间富文本)、渲染组件(计数/评论/档案卡)、后台配置、示例内容
|
||||
- 快速检查:`pnpm astro build`(node26)+ `python3 scripts/build-perf/config-check.py .`
|
||||
- 集成检查:`E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs`(37 项,本包新增置顶顺序/徽标存在/瞬间富文本断言)+ content-lint 0/0/0
|
||||
- 全项目检查:生产实测四条(锁屏外链零依赖、瞬间评论可发可见、徽标数值与 Artalk 后台一致、关于页承接内容渲染)
|
||||
- 全项目检查:生产实测四条(开源页零第三方外链图、瞬间评论可发可见、徽标数值与 Artalk 端点对账一致、关于页承接内容渲染);**升级条件**——仅当改动触及 BaseLayout/全局样式(tokens/字体栈)或 Artalk 主题接管层时才执行,本包因往 tokens 补 emoji 回退链已触发一次并记录了理由
|
||||
- 升级触发:若动到 BaseLayout/全局样式或 Artalk 主题接管层,升级为全站视觉复核(glm-5.3-flash:max,≤6 图/轮)
|
||||
|
||||
## 6. 成功标准与验证方式
|
||||
- [ ] 置顶文章排在列表首位且带可见标记,取消置顶后恢复时间序 -> verify: task_0001
|
||||
- [ ] 瞬间文字可用字体类与彩色强调,危险标签被净化丢弃,旧纯文本渲染不变 -> verify: task_0002
|
||||
- [ ] 每条瞬间可展开评论区并成功发帖,双语页共用同一评论流 -> verify: task_0003
|
||||
- [ ] 文章/瞬间/关于/开源四页显示「浏览数 + 评论数」徽标且数值与 Artalk 一致 -> verify: task_0004
|
||||
- [ ] 开源页零第三方图片请求,档案卡数据来自本地/GraphQL,居中与破图问题不再出现 -> verify: task_0005
|
||||
- [ ] README 的个人叙述出现在关于页,开源页只保留开源相关内容 -> verify: task_0006
|
||||
- [ ] 示例文章渲染全部演示能力且构建通过,docs/hint 同步 -> verify: task_0007
|
||||
- [x] 置顶文章排在列表首位且带可见标记,取消置顶后恢复时间序 -> verify: task_0001
|
||||
- [x] 瞬间文字可用字体类与彩色强调,危险标签被净化丢弃,旧纯文本渲染不变 -> verify: task_0002
|
||||
- [x] 每条瞬间可展开评论区并成功发帖,双语页共用同一评论流 -> verify: task_0003
|
||||
- [x] 文章/瞬间/关于/开源四页显示「浏览数 + 评论数」徽标且数值与 Artalk 一致 -> verify: task_0004
|
||||
- [x] 开源页零第三方图片请求,档案卡数据来自本地/GraphQL,居中与破图问题不再出现 -> verify: task_0005
|
||||
- [x] README 的个人叙述出现在关于页,开源页只保留开源相关内容 -> verify: task_0006
|
||||
- [x] 示例文章渲染全部演示能力且构建通过,docs/hint 同步 -> verify: task_0007
|
||||
|
||||
## 7. 风险与约束
|
||||
- 灰阶外壳红线不变:徽标图标与数字用 tokens(`--ink-*`/`--line`),不引入第三方彩色徽章
|
||||
+7
-7
@@ -8,25 +8,25 @@
|
||||
|
||||
## 阶段一:实现
|
||||
|
||||
- [ ] T1 文章置顶(契约 + 排序 + 标记 + 后台)
|
||||
- [x] T1 文章置顶(契约 + 排序 + 标记 + 后台)
|
||||
- claim: orchestrator
|
||||
- boundary: src/content.config.ts(postsSchema 加 `pinned: z.boolean().optional()`,posts/postsEn 共用)、src/components/PostList.astro(置顶优先排序 + pin 标记)、src/pages/archives/index.astro(排序)、src/components/pages/{HomePage,BooksPage}.astro(排序)、public/admin/config.yml(posts 集合 pinned 开关 + 中文 hint)、src/i18n/ui.ts(置顶标签)
|
||||
- verify: `E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -q "37/37 PASS" && grep -q "pinned" src/content.config.ts && grep -q "pinned" src/components/PostList.astro && grep -q "pinned" public/admin/config.yml`
|
||||
- verify: `E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE "汇总: [0-9]+/[0-9]+ PASS" && grep -q "pinned" src/content.config.ts && grep -q "pinned" src/components/PostList.astro && grep -q "pinned" public/admin/config.yml`
|
||||
|
||||
- [x] T2 瞬间富文本(字体可换 / 颜色可换 / 多形式)
|
||||
- claim: worker-one
|
||||
- boundary: src/components/pages/MomentsPage.astro(`text` 由纯文本 `<p>` 改为走 `renderRich` 净化+排版类管线,保留 inline 语义;空文本老数据零回归)、public/admin/config.yml 中 moments 集合 `text` 字段改 markdown widget + 提示可用排版类(**仅在 orchestrator 提交的字段块内改 widget 类型,不改结构**)、docs 提示留给 T7
|
||||
- verify: `E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -q "37/37 PASS" && grep -qE "renderRich|renderMarkdown" src/components/pages/MomentsPage.astro && python3 -c "import yaml;d=yaml.safe_load(open('public/admin/config.yml'));m=[c for c in d['collections'] if c['name']=='moments'][0];t=[f for f in m['fields'] if f['name']=='text'][0];assert t['widget']=='markdown',t"`
|
||||
- verify: `E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE "汇总: [0-9]+/[0-9]+ PASS" && grep -qE "renderRich|renderMarkdown" src/components/pages/MomentsPage.astro && python3 -c "import yaml;d=yaml.safe_load(open('public/admin/config.yml'));m=[c for c in d['collections'] if c['name']=='moments'][0];t=[f for f in m['fields'] if f['name']=='text'][0];assert t['widget']=='markdown',t"`
|
||||
|
||||
- [x] T3 瞬间逐条评论(Artalk 独立 page_key + 折叠懒挂载)
|
||||
- claim: worker-one
|
||||
- boundary: 新增 src/components/MomentComments.astro(折叠 disclosure,展开时首次挂载 Artalk;page_key 约定 `/moments/#<slug>`,en 页同一 key 不分裂;评论数徽标复用 T4 的 ViewMeta 计数接口)、src/components/pages/MomentsPage.astro(每条卡片挂评论区)
|
||||
- verify: `test -f src/components/MomentComments.astro && grep -q "MomentComments" src/components/pages/MomentsPage.astro && grep -q "moments/#" src/components/MomentComments.astro && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -q "37/37 PASS"`
|
||||
- verify: `test -f src/components/MomentComments.astro && grep -q "MomentComments" src/components/pages/MomentsPage.astro && grep -q 'moments/${slug}/' src/components/MomentComments.astro && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE "汇总: [0-9]+/[0-9]+ PASS"`
|
||||
|
||||
- [x] T4 计数徽标(眼睛图标 = 浏览数,气泡图标 = 评论数)
|
||||
- claim: op-artalk
|
||||
- boundary: src/components/geek/ViewMeta.astro(**契约组件,由 orchestrator 在 T1 提交时一并落盘**,车道只消费不改语义)、src/components/ArtalkComments.astro(pv 小字行改为 ViewMeta,评论数取 `comments?limit=1` 的 count)、src/pages/archives/[...slug].astro、src/pages/en/archives/[...slug].astro(挂 ViewMeta)
|
||||
- verify: `test -f src/components/geek/ViewMeta.astro && grep -q "ViewMeta" src/components/ArtalkComments.astro && grep -q "ViewMeta" src/pages/archives/[...slug].astro && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -q "37/37 PASS"`
|
||||
- verify: `test -f src/components/geek/ViewMeta.astro && grep -q "ViewMeta" src/components/ArtalkComments.astro && grep -q "ViewMeta" src/pages/archives/[...slug].astro && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE "汇总: [0-9]+/[0-9]+ PASS"`
|
||||
|
||||
- [x] T5 开源页重构(原生 GitHub 档案卡,去外链图依赖)
|
||||
- claim: op-gitea
|
||||
@@ -36,9 +36,9 @@
|
||||
- [x] T6 关于页承接 README 的个人叙述
|
||||
- claim: op-gitea
|
||||
- boundary: content/site/about.yaml(新增 blocks:关于/兴趣 prose、站点与项目 linklist、Find Me 社交块;双语 zh/en;测试值不得留在文件里)、src/pages/about.astro 与 en(仅当块渲染需要时改,优先零改动)
|
||||
- verify: `python3 -c "import yaml;d=yaml.safe_load(open('content/site/about.yaml'));types=[b['type'] for b in d.get('blocks') or []];assert 'prose' in types and ('linklist' in types or 'cards' in types),types" && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -q "37/37 PASS"`
|
||||
- verify: `python3 -c "import yaml;d=yaml.safe_load(open('content/site/about.yaml'));types=[b['type'] for b in d.get('blocks') or []];assert 'prose' in types and ('linklist' in types or 'cards' in types),types" && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE "汇总: [0-9]+/[0-9]+ PASS"`
|
||||
|
||||
- [ ] T7 写作模板示例文章 + 文档与 hint 收尾
|
||||
- [x] T7 写作模板示例文章 + 文档与 hint 收尾
|
||||
- claim: orchestrator
|
||||
- boundary: content/posts/writing-template.md、content/posts-en/writing-template.md(en 手写 locked:true 直译,不交给 TMT 重译)、docs/写作指南.md(模板用法 + 排版类清单去重指引)、docs/管理台.md(瞬间富文本/逐条评论/置顶/徽标说明)、public/admin/config.yml(moments text hint、posts pinned 已含)
|
||||
- verify: `test -f content/posts/writing-template.md && test -f content/posts-en/writing-template.md && grep -q "pinned: true" content/posts/writing-template.md && grep -qE "font-kai|c-blue" content/posts/writing-template.md && grep -q "写作模板" docs/写作指南.md`
|
||||
@@ -18,15 +18,15 @@ Use `##` for a section and `###` for a subsection. **Never use `#`** — the pos
|
||||
|
||||
Leave a blank line between paragraphs. Inline you can use **bold**, *italic*, ~~strikethrough~~, `inline code`, and [ordinary links](https://astro.build).
|
||||
|
||||
## 2. Colour emphasis: three per page at most
|
||||
## 2. Colour emphasis: as much as you need
|
||||
|
||||
The rule comes from the design baseline: the shell is greyscale, so colour exists to weight one judgement in the body — three per page, maximum. More than that and it turns loud.
|
||||
Colour is **no longer capped** (the old "three per page" limit was lifted on 2026-11-02). The one rule that remains is the shell: navigation, footer, buttons and background stay greyscale — the shell is temperament, the body is expression.
|
||||
|
||||
<span class="c-blue">First use: state one key conclusion in blue.</span> Then carry on normally.
|
||||
|
||||
<span class="c-red">Second use: warn about a trap in red.</span> For example, “after changing a Markdown plugin you must clear `node_modules/.astro`, or the change silently does nothing”.
|
||||
|
||||
<span class="c-green">Third use: confirm a working path in green.</span> After three, this section stays uncoloured.
|
||||
<span class="c-green">Confirm a working path in green.</span> If a whole paragraph deserves emphasis, mark the whole paragraph.
|
||||
|
||||
There are eight hues (blue / teal / green / yellow / orange / red / pink / purple), each with a standard and a `-soft` variant, plus four greyscale classes (`ink-muted` / `ink-warm` / `ink-cool` / `ink-faint`) for lowering tone — those do not count against the colour budget.
|
||||
|
||||
@@ -84,7 +84,7 @@ Refer to variables inline like `posts`.
|
||||
|
||||
| Capability | Syntax | Note |
|
||||
| --- | --- | --- |
|
||||
| Colour emphasis | `<span class="c-blue">` | three per page at most |
|
||||
| Colour emphasis | `<span class="c-blue">` | no limit (shell stays greyscale) |
|
||||
| Typeface | `<span class="font-kai">` | a whole page adds font weight |
|
||||
| Image | `` | see the writing guide for size rules |
|
||||
| Video | frontmatter `video:` | lives in public/media, ≤50MB |
|
||||
|
||||
@@ -17,15 +17,15 @@ description: 一篇文章演示全站可用的排版能力:标题层级、彩
|
||||
|
||||
段落之间空一行即可。行内可以**加粗**、*斜体*、~~删除线~~、`行内代码`,也可以放[普通链接](https://astro.build)。
|
||||
|
||||
## 二、彩色强调:一页不超过三处
|
||||
## 二、彩色强调:想用多少用多少
|
||||
|
||||
纪律来自设计基线:站点外壳是灰阶的,颜色只用来给正文某一个判断加重,一页最多三处。超过就变成花哨。
|
||||
颜色现在**没有次数限制**(2026-11-02 起取消了原来的「一页最多三处」)。唯一还守着的是外壳:导航、页脚、按钮、背景保持灰阶——外壳是气质,正文是表达。
|
||||
|
||||
<span class="c-blue">第一处:一个关键结论用蓝色提出。</span> 后面照常叙述。
|
||||
|
||||
<span class="c-red">第二处:需要警示的坑用红色。</span> 比如「改完 Markdown 插件必须清 `node_modules/.astro`,否则改动静默不生效」。
|
||||
|
||||
<span class="c-green">第三处:确认可行的路径用绿色。</span> 三处之后,本节不再上色。
|
||||
<span class="c-green">确认可行的路径用绿色。</span> 想让整段都是重点也不必克制,把它们都标出来即可。
|
||||
|
||||
可用色相共 8 组(blue / teal / green / yellow / orange / red / pink / purple),每组有标准与 `-soft` 两档;另有 4 个灰阶层级类(`ink-muted` / `ink-warm` / `ink-cool` / `ink-faint`)用于降调,不算彩色额度。
|
||||
|
||||
@@ -83,7 +83,7 @@ export function sortPosts<T extends Post>(posts: T[]): T[] {
|
||||
|
||||
| 能力 | 写法 | 注意 |
|
||||
| --- | --- | --- |
|
||||
| 彩色强调 | `<span class="c-blue">` | 一页 ≤3 处 |
|
||||
| 彩色强调 | `<span class="c-blue">` | 不限次数(外壳仍灰阶) |
|
||||
| 换字族 | `<span class="font-kai">` | 整页换会拉字体体积 |
|
||||
| 图片 | `` | 体积纪律见写作指南 |
|
||||
| 视频 | frontmatter `video:` | 放 public/media,≤50MB |
|
||||
|
||||
@@ -99,7 +99,7 @@ blocks:
|
||||
- label:
|
||||
zh: 邮件
|
||||
en: Email
|
||||
href: 'mailto:admin@wait4x.online'
|
||||
href: 'mailto:X@alumni.nudt.edu.cn'
|
||||
note:
|
||||
zh: 有话直说
|
||||
en: Say hello directly
|
||||
|
||||
+8
-31
@@ -1,24 +1,14 @@
|
||||
# 主页身份语(后台「页面文案 → 主页设置」可改)
|
||||
# 全部字段留空 = 现有形态(字段级回落默认值,零配置零变化)
|
||||
tagline:
|
||||
zh: 写作者 · 读者 · 快门按压者,拥抱开源。
|
||||
en: Writer · Reader · Shutter-presser — embracing open source.
|
||||
|
||||
# 副标题(可选,markdown 短文本):渲染在身份语下方,留空不渲染
|
||||
# subtitle:
|
||||
# zh: '正在把**阅读、代码与影像**收进同一个工作台'
|
||||
# en: 'Collecting **reading, code and images** into one workbench'
|
||||
|
||||
# 头像(可选):上传后自动写入 ./assets/avatar-xxx.jpg(走 Astro 图片管线)
|
||||
# 留空 = 回退内置渐变占位
|
||||
# avatar: ./assets/avatar.jpg
|
||||
|
||||
# 站点图标(favicon,全局生效):public 下的路径;留空回退 /favicon.svg
|
||||
# 上传走 public/icons/(后台媒体库),可填 /icons/my.ico、/icons/my.svg
|
||||
latestPhotos: 4
|
||||
perTrackLimit: 6
|
||||
sections:
|
||||
tracks: true
|
||||
photos: true
|
||||
heatmap: true
|
||||
projects: true
|
||||
favicon: /favicon.svg
|
||||
|
||||
# 社交链接(顺序即展示顺序,后台可增删拖动排序)
|
||||
# icon 取值:github / rss / email / bilibili / x / 留空(留空按 label 显示纯文字)
|
||||
socials:
|
||||
- label: GitHub
|
||||
url: https://github.com/wait4xx
|
||||
@@ -27,18 +17,5 @@ socials:
|
||||
url: /rss.xml
|
||||
icon: rss
|
||||
- label: 邮件
|
||||
url: mailto:admin@wait4x.online
|
||||
url: mailto:X@alumni.nudt.edu.cn
|
||||
icon: email
|
||||
|
||||
# 最新影像张数(1-6,默认 4;填 0 = 隐藏该区块)
|
||||
latestPhotos: 4
|
||||
|
||||
# 首页每轨文章条数(4-8,默认 6)
|
||||
perTrackLimit: 6
|
||||
|
||||
# 区块开关(默认全开;关掉的区块不渲染)
|
||||
sections:
|
||||
tracks: true
|
||||
photos: true
|
||||
heatmap: true
|
||||
projects: true
|
||||
@@ -1,26 +1,30 @@
|
||||
# 打字机动效文案(档案卡顶部逐字轮播;每行双语,最多 6 行;留空用内置默认三行)
|
||||
typing:
|
||||
- zh: 气象出身,写代码为乐
|
||||
en: Trained in meteorology, coding for fun
|
||||
- zh: 自托管一切能自托管的东西
|
||||
en: Self-hosting whatever can be self-hosted
|
||||
- zh: 拥抱开源,热爱开源
|
||||
en: Embrace open source, love open source
|
||||
- zh: 把读过的书、按下的快门都放进同一个工作台
|
||||
en: Books read and shutters pressed, gathered in one workbench
|
||||
|
||||
blurb:
|
||||
zh: 本站本身即开源:
|
||||
en: 'This site is itself open source:'
|
||||
repo_url: https://git.wait4x.online/wait4x/blog
|
||||
repo_label: git.wait4x.online/wait4x/blog
|
||||
blurb_after:
|
||||
zh: ' —— Astro SSG、双写作流、构建发布全链皆在仓中。以下是我在自托管 gitea 与 GitHub 上的全部公开仓库,以及最近一年的贡献活动。'
|
||||
en: ' — the Astro SSG, dual-writing workflow, and the full build & deploy pipeline live in the repo. Below are all my public repositories on self-hosted gitea and GitHub, plus contributions from the past year.'
|
||||
|
||||
# ---- 项目墙可见性与排序(后台「开源页」可改;留空/缺省 = 现状) ----
|
||||
# mode: all=全部(默认) | only=仅 include 列出 | exclude=排除 exclude 列出
|
||||
zh: —— Astro SSG、双写作流、构建发布全链皆在仓中。以下是我在自托管 gitea 与 GitHub 上的全部公开仓库,以及最近一年的贡献活动。
|
||||
en: — the Astro SSG, dual-writing workflow, and the full build & deploy pipeline live in the repo. Below are all my public repositories on self-hosted gitea and GitHub, plus contributions from the past year.
|
||||
mode: all
|
||||
# 白名单(mode=only 时生效)
|
||||
include: []
|
||||
# 黑名单(mode=exclude 时生效)
|
||||
exclude: []
|
||||
# 排序:pinned-first=置顶优先(默认) | stars=星数降序 | updated=最近更新
|
||||
sort: pinned-first
|
||||
# 来源过滤:both=双源合并(默认) | github | gitea
|
||||
perSource: both
|
||||
# 开篇来源(compat,**不推荐开启**):off=用本文件 blurb + 原生档案卡(默认)
|
||||
# github-readme = 构建期取 wait4xx/wait4xx 的 README 原样渲染在档案卡下方
|
||||
# ⚠ 开启会引入 7 类第三方外链图(readme-typing-svg / shields.io 徽章 / github-readme-stats /
|
||||
# streak-stats / activity-graph / 贡献蛇 / komarev 访客徽章),慢网或被墙时整片空白;
|
||||
# 且 README 用 <div align="center"> 居中,本站渲染不认该属性 → 居中失效。内容已由档案卡承接。
|
||||
perSource: github
|
||||
intro_from: "off"
|
||||
|
||||
# ---- 开篇来源说明(2026-11-02 Phase 5h)----
|
||||
# 开篇已改原生 GitHub 档案卡(头像 / 统计四格 / 语言占比灰阶条),不再渲染 README 原文。
|
||||
# intro_from: "off" = 用档案卡 + 上面的 blurb(当前)
|
||||
# intro_from: github-readme = 构建期取 wait4xx/wait4xx 的 README 原样渲染(**不推荐**:
|
||||
# 会引入 7 类第三方外链图,慢网或被墙时整片空白;README 用 <div align="center"> 居中,
|
||||
# 本站不认该属性 → 居中失效。这正是当初"内容没居中、图片不显示"的根因。)
|
||||
+8
-2
@@ -11,10 +11,10 @@ cp content/posts/writing-template.md content/posts/my-new-post.md
|
||||
# 改 frontmatter 的 title / pubDate / tags / description,删掉不需要的段落
|
||||
```
|
||||
|
||||
模板本身是一篇真文章(线上读:`/archives/writing-template/`),每节都在跑一种能力,你看到的效果就是源码的效果:标题层级、三处彩色强调、两族字体、引用、列表、代码窗条、表格、图片放大、链接卡片、`<details>` 折叠、分隔线。
|
||||
模板本身是一篇真文章(线上读:`/archives/writing-template/`),每节都在跑一种能力,你看到的效果就是源码的效果:标题层级、彩色强调、字族切换、引用、列表、代码窗条、表格、图片放大、链接卡片、`<details>` 折叠、分隔线。
|
||||
|
||||
写之前只需记住三条纪律:
|
||||
- **彩色 ≤3 处/页**:外壳是灰阶,颜色只用来给某个判断加重;超过就花哨
|
||||
- **彩色不限次数**(2026-11-02 用户拍板取消原「一页 ≤3 处」限制):正文想用多少处颜色都可以;仍守的是另一条——**外壳灰阶**(导航/页脚/按钮/背景不染色),因为外壳是气质,正文是表达
|
||||
- **整页换字族要谨慎**:会给访问者拉一份中文字体(汇文明朝整页约 420KB,已子集化)
|
||||
- **折叠放细节不放结论**:`<details>` 里的内容可能永远没人展开
|
||||
|
||||
@@ -96,6 +96,12 @@ public/media/my-post/clip.mp4 ← 文件放这里
|
||||
|
||||
见 `content/posts/media-sample.md`(draft 文章,仅构建验证用,不上线)。
|
||||
|
||||
## 灯箱说明文字(图库 caption)
|
||||
|
||||
图库照片的 `caption` 字段走同一套富文本管线:markdown(`**加粗**`、换行)+ 排版类(`<span class="c-blue font-kai">`)。灯箱里深底上的彩色会自动取暗底档。
|
||||
|
||||
图库灯箱关闭:右上 `×`、`Esc`、点背景、点图(1× 时)都能关;缩放后移动鼠标召唤控件。
|
||||
|
||||
## 链接卡片(OG 预览卡)
|
||||
|
||||
**一行只放一个链接、且它独立成段**时,构建期会抓取该页的 OG 信息渲染成卡片(标题 / 描述 / 站点图标 / 可选缩略图):
|
||||
|
||||
+15
-1
@@ -39,12 +39,26 @@
|
||||
|
||||
- markdown:`**加粗**`、`*斜体*`、列表、`> 引用`、`` `行内代码` ``
|
||||
- 排版类 HTML:`<span class="c-blue font-kai">彩色 + 楷体</span>`(可用类清单见写作指南「文章排版类」一节,与文章完全同一份白名单)
|
||||
- 仍守外壳灰阶纪律:彩色是**强调**不是装饰,一页 ≤3 处
|
||||
- 彩色**不再限次**(原「≤3 处/页」已取消);仍守外壳灰阶:导航/页脚/按钮/背景不染色
|
||||
|
||||
**每条瞬间自带评论区**:时间线卡片下方有「评论」折叠条,读者点开才加载评论(不点开不为它付任何加载成本)。中文页与英文页共用同一条评论流,语言切换不会丢评论。
|
||||
|
||||
**每条的浏览 / 评论数字是只读的**:读者点开评论区不会虚增浏览数(只有整页访问才计一次)。
|
||||
|
||||
## 照片说明文字(灯箱右侧)支持排版(5i 新增)
|
||||
|
||||
照片的「说明」(caption)现在支持 markdown 与排版类,与文章正文同一套:
|
||||
|
||||
```yaml
|
||||
caption: 落日把海与城一齐烫成金箔,**这一帧等了二十分钟**
|
||||
```
|
||||
|
||||
```html
|
||||
<span class="c-blue">蓝色</span>、<span class="font-kai">楷体</span> 这些类都能用。
|
||||
```
|
||||
|
||||
灯箱是深底,颜色类会自动取柔和对暗底的那一档,不会刺眼。EXIF 那几行是相机数据,不做排版。
|
||||
|
||||
## 照片上传流
|
||||
|
||||
1. 图库照片 → New Entry:填标题(**必填**,空标题会让构建炸——实测坑)、选分组(street/landscape)、可写 alt
|
||||
|
||||
@@ -39,6 +39,13 @@ pnpm astro build
|
||||
[ -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 脚本未就绪,跳过"
|
||||
|
||||
# README 动效资产(5i T8):**不在此跑抓取**(避免发布链被外网抖动拖累),只做存在性守卫 + 缺资产告警
|
||||
if [ -f public/github-assets/manifest.json ]; then
|
||||
echo "[build] github-assets 就绪:$(find public/github-assets -name '*.svg' | wc -l) 个自托管 SVG"
|
||||
else
|
||||
echo "[build] WARN public/github-assets/manifest.json 缺失——开源页数据板将只显示原生统计(可手动跑 node scripts/build-perf/fetch-readme-assets.mjs 补齐)"
|
||||
fi
|
||||
|
||||
# 内容 lint(T3):断链/缺图在部署前拦截;CONTENT_LINT=skip|warn 逃生门见脚本头注
|
||||
[ -f scripts/build-perf/content-lint.mjs ] && node scripts/build-perf/content-lint.mjs dist || echo "[build] content-lint 未就绪,跳过"
|
||||
|
||||
|
||||
@@ -535,6 +535,15 @@ collections:
|
||||
file: content/site/opensource.yaml
|
||||
format: yaml
|
||||
fields:
|
||||
- label: 打字机动效文案(可选,≤6 条)
|
||||
name: typing
|
||||
widget: list
|
||||
required: false
|
||||
summary: '{{fields.zh}}'
|
||||
hint: '档案卡顶部逐字打出的几行文案,2–6 条最佳;留空用内置默认。中英各一行,切换语言同步'
|
||||
fields:
|
||||
- { label: 中文, name: zh, widget: string }
|
||||
- { label: English, name: en, widget: string }
|
||||
- label: 叙述(链接前)
|
||||
name: blurb
|
||||
widget: object
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"assets": {
|
||||
"streak-light.svg": {
|
||||
"source": "https://streak-stats.demolab.com/?user=wait4xx&hide_border=true&background=00000000&ring=3454d1&fire=3454d1&currStreakLabel=3454d1&currStreakNum=33343a&sideNums=33343a&sideLabels=8b8f98&dates=8b8f98",
|
||||
"fetchedAt": "2026-11-03T03:07:28.521Z",
|
||||
"label": "连续贡献",
|
||||
"theme": "light"
|
||||
},
|
||||
"streak-dark.svg": {
|
||||
"source": "https://streak-stats.demolab.com/?user=wait4xx&hide_border=true&background=00000000&ring=3454d1&fire=3454d1&currStreakLabel=3454d1&currStreakNum=c9ccd4&sideNums=c9ccd4&sideLabels=9aa0aa&dates=9aa0aa",
|
||||
"fetchedAt": "2026-11-03T03:07:28.521Z",
|
||||
"label": "连续贡献",
|
||||
"theme": "dark"
|
||||
},
|
||||
"snake.svg": {
|
||||
"source": "https://raw.githubusercontent.com/wait4xx/wait4xx/output/github-contribution-grid-snake.svg",
|
||||
"fetchedAt": "2026-11-03T03:07:28.521Z",
|
||||
"label": "贡献蛇",
|
||||
"theme": "any"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg viewBox="-16 -32 880 192" width="880" height="192" xmlns="http://www.w3.org/2000/svg"><desc>Generated with https://github.com/Platane/snk</desc><style>:root{--cb:#1b1f230a;--cs:purple;--ce:#ebedf0;--c0:#ebedf0;--c1:#9be9a8;--c2:#40c463;--c3:#30a14e;--c4:#216e39}.c{shape-rendering:geometricPrecision;fill:var(--ce);stroke-width:1px;stroke:var(--cb);animation:none 35300ms linear infinite;width:12px;height:12px}@keyframes c0{4.52%{fill:var(--c1)}4.54%,100%{fill:var(--ce)}}.c.c0{fill:var(--c1);animation-name:c0}@keyframes c1{66.85%{fill:var(--c2)}66.87%,100%{fill:var(--ce)}}.c.c1{fill:var(--c2);animation-name:c1}@keyframes c2{3.67%{fill:var(--c1)}3.69%,100%{fill:var(--ce)}}.c.c2{fill:var(--c1);animation-name:c2}@keyframes c3{1.97%{fill:var(--c1)}1.99%,100%{fill:var(--ce)}}.c.c3{fill:var(--c1);animation-name:c3}@keyframes c4{2.82%{fill:var(--c1)}2.84%,100%{fill:var(--ce)}}.c.c4{fill:var(--c1);animation-name:c4}@keyframes c5{3.39%{fill:var(--c1)}3.41%,100%{fill:var(--ce)}}.c.c5{fill:var(--c1);animation-name:c5}@keyframes c6{2.26%{fill:var(--c1)}2.28%,100%{fill:var(--ce)}}.c.c6{fill:var(--c1);animation-name:c6}@keyframes c7{10.19%{fill:var(--c1)}10.21%,100%{fill:var(--ce)}}.c.c7{fill:var(--c1);animation-name:c7}@keyframes c8{9.06%{fill:var(--c1)}9.08%,100%{fill:var(--ce)}}.c.c8{fill:var(--c1);animation-name:c8}@keyframes c9{41.63%{fill:var(--c1)}41.65%,100%{fill:var(--ce)}}.c.c9{fill:var(--c1);animation-name:c9}@keyframes ca{12.74%{fill:var(--c1)}12.76%,100%{fill:var(--ce)}}.c.ca{fill:var(--c1);animation-name:ca}@keyframes cb{13.59%{fill:var(--c1)}13.61%,100%{fill:var(--ce)}}.c.cb{fill:var(--c1);animation-name:cb}@keyframes cc{14.15%{fill:var(--c1)}14.17%,100%{fill:var(--ce)}}.c.cc{fill:var(--c1);animation-name:cc}@keyframes cd{14.44%{fill:var(--c1)}14.46%,100%{fill:var(--ce)}}.c.cd{fill:var(--c1);animation-name:cd}@keyframes ce{21.8%{fill:var(--c1)}21.82%,100%{fill:var(--ce)}}.c.ce{fill:var(--c1);animation-name:ce}@keyframes cf{19.82%{fill:var(--c1)}19.84%,100%{fill:var(--ce)}}.c.cf{fill:var(--c1);animation-name:cf}@keyframes cg{20.67%{fill:var(--c1)}20.69%,100%{fill:var(--ce)}}.c.cg{fill:var(--c1);animation-name:cg}@keyframes ch{14.72%{fill:var(--c1)}14.74%,100%{fill:var(--ce)}}.c.ch{fill:var(--c1);animation-name:ch}@keyframes ci{21.52%{fill:var(--c1)}21.54%,100%{fill:var(--ce)}}.c.ci{fill:var(--c1);animation-name:ci}@keyframes cj{19.25%{fill:var(--c1)}19.27%,100%{fill:var(--ce)}}.c.cj{fill:var(--c1);animation-name:cj}@keyframes ck{15%{fill:var(--c1)}15.02%,100%{fill:var(--ce)}}.c.ck{fill:var(--c1);animation-name:ck}@keyframes cl{15.29%{fill:var(--c1)}15.31%,100%{fill:var(--ce)}}.c.cl{fill:var(--c1);animation-name:cl}@keyframes cm{44.18%{fill:var(--c2)}44.2%,100%{fill:var(--ce)}}.c.cm{fill:var(--c2);animation-name:cm}@keyframes cn{18.4%{fill:var(--c1)}18.42%,100%{fill:var(--ce)}}.c.cn{fill:var(--c1);animation-name:cn}@keyframes co{18.69%{fill:var(--c1)}18.71%,100%{fill:var(--ce)}}.c.co{fill:var(--c1);animation-name:co}@keyframes cp{16.14%{fill:var(--c1)}16.16%,100%{fill:var(--ce)}}.c.cp{fill:var(--c1);animation-name:cp}@keyframes cq{15.85%{fill:var(--c1)}15.87%,100%{fill:var(--ce)}}.c.cq{fill:var(--c1);animation-name:cq}@keyframes cr{23.5%{fill:var(--c1)}23.52%,100%{fill:var(--ce)}}.c.cr{fill:var(--c1);animation-name:cr}@keyframes cs{18.12%{fill:var(--c1)}18.14%,100%{fill:var(--ce)}}.c.cs{fill:var(--c1);animation-name:cs}@keyframes ct{89.79%{fill:var(--c4)}89.81%,100%{fill:var(--ce)}}.c.ct{fill:var(--c4);animation-name:ct}@keyframes cu{16.99%{fill:var(--c1)}17.01%,100%{fill:var(--ce)}}.c.cu{fill:var(--c1);animation-name:cu}@keyframes cv{76.19%{fill:var(--c4)}76.21%,100%{fill:var(--ce)}}.c.cv{fill:var(--c4);animation-name:cv}@keyframes cw{47.02%{fill:var(--c2)}47.04%,100%{fill:var(--ce)}}.c.cw{fill:var(--c2);animation-name:cw}@keyframes cx{35.4%{fill:var(--c1)}35.42%,100%{fill:var(--ce)}}.c.cx{fill:var(--c1);animation-name:cx}@keyframes cy{35.12%{fill:var(--c1)}35.14%,100%{fill:var(--ce)}}.c.cy{fill:var(--c1);animation-name:cy}@keyframes cz{26.34%{fill:var(--c1)}26.36%,100%{fill:var(--ce)}}.c.cz{fill:var(--c1);animation-name:cz}@keyframes c10{33.7%{fill:var(--c1)}33.72%,100%{fill:var(--ce)}}.c.c10{fill:var(--c1);animation-name:c10}@keyframes c11{33.98%{fill:var(--c1)}34%,100%{fill:var(--ce)}}.c.c11{fill:var(--c1);animation-name:c11}@keyframes c12{50.41%{fill:var(--c2)}50.43%,100%{fill:var(--ce)}}.c.c12{fill:var(--c2);animation-name:c12}@keyframes c13{78.46%{fill:var(--c4)}78.48%,100%{fill:var(--ce)}}.c.c13{fill:var(--c4);animation-name:c13}@keyframes c14{49.28%{fill:var(--c2)}49.3%,100%{fill:var(--ce)}}.c.c14{fill:var(--c2);animation-name:c14}@keyframes c15{27.47%{fill:var(--c1)}27.49%,100%{fill:var(--ce)}}.c.c15{fill:var(--c1);animation-name:c15}@keyframes c16{80.44%{fill:var(--c4)}80.46%,100%{fill:var(--ce)}}.c.c16{fill:var(--c4);animation-name:c16}@keyframes c17{27.75%{fill:var(--c1)}27.77%,100%{fill:var(--ce)}}.c.c17{fill:var(--c1);animation-name:c17}@keyframes c18{32.28%{fill:var(--c1)}32.3%,100%{fill:vLine truncated
|
||||
|
After Width: | Height: | Size: 35 KiB |
@@ -0,0 +1,111 @@
|
||||
<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'
|
||||
style='isolation: isolate' viewBox='0 0 495 195' width='495px' height='195px' direction='ltr'>
|
||||
<style>
|
||||
@keyframes currstreak {
|
||||
0% { font-size: 3px; opacity: 0.2; }
|
||||
80% { font-size: 34px; opacity: 1; }
|
||||
100% { font-size: 28px; opacity: 1; }
|
||||
}
|
||||
@keyframes fadein {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
<defs>
|
||||
<clipPath id='outer_rectangle'>
|
||||
<rect width='495' height='195' rx='4.5'/>
|
||||
</clipPath>
|
||||
<mask id='mask_out_ring_behind_fire'>
|
||||
<rect width='495' height='195' fill='white'/>
|
||||
<ellipse id='mask-ellipse' cx='247.5' cy='32' rx='13' ry='18' fill='black'/>
|
||||
</mask>
|
||||
|
||||
</defs>
|
||||
<g clip-path='url(#outer_rectangle)'>
|
||||
<g style='isolation: isolate'>
|
||||
<rect stroke='#000000' stroke-opacity='0' fill='#000000' fill-opacity='0' rx='4.5' x='0.5' y='0.5' width='494' height='194'/>
|
||||
</g>
|
||||
<g style='isolation: isolate'>
|
||||
<line x1='165' y1='28' x2='165' y2='170' vector-effect='non-scaling-stroke' stroke-width='1' stroke='#E4E2E2' stroke-linejoin='miter' stroke-linecap='square' stroke-miterlimit='3'/>
|
||||
<line x1='330' y1='28' x2='330' y2='170' vector-effect='non-scaling-stroke' stroke-width='1' stroke='#E4E2E2' stroke-linejoin='miter' stroke-linecap='square' stroke-miterlimit='3'/>
|
||||
</g>
|
||||
<g style='isolation: isolate'>
|
||||
<!-- Total Contributions big number -->
|
||||
<g transform='translate(82.5, 48)'>
|
||||
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#c9ccd4' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='700' font-size='28px' font-style='normal' style='opacity: 0; animation: fadein 0.5s linear forwards 0.6s'>
|
||||
344
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Total Contributions label -->
|
||||
<g transform='translate(82.5, 84)'>
|
||||
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#9aa0aa' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='14px' font-style='normal' style='opacity: 0; animation: fadein 0.5s linear forwards 0.7s'>
|
||||
Total Contributions
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Total Contributions range -->
|
||||
<g transform='translate(82.5, 114)'>
|
||||
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#9aa0aa' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='12px' font-style='normal' style='opacity: 0; animation: fadein 0.5s linear forwards 0.8s'>
|
||||
May 24, 2019 - Present
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
<g style='isolation: isolate'>
|
||||
<!-- Current Streak label -->
|
||||
<g transform='translate(247.5, 108)'>
|
||||
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#3454d1' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='700' font-size='14px' font-style='normal' style='opacity: 0; animation: fadein 0.5s linear forwards 0.9s'>
|
||||
Current Streak
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Current Streak range -->
|
||||
<g transform='translate(247.5, 145)'>
|
||||
<text x='0' y='21' stroke-width='0' text-anchor='middle' fill='#9aa0aa' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='12px' font-style='normal' style='opacity: 0; animation: fadein 0.5s linear forwards 0.9s'>
|
||||
Nov 3
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Ring around number -->
|
||||
<g mask='url(#mask_out_ring_behind_fire)'>
|
||||
<circle cx='247.5' cy='71' r='40' fill='none' stroke='#3454d1' stroke-width='5' style='opacity: 0; animation: fadein 0.5s linear forwards 0.4s'></circle>
|
||||
</g>
|
||||
<!-- Fire icon -->
|
||||
<g transform='translate(247.5, 19.5)' stroke-opacity='0' style='opacity: 0; animation: fadein 0.5s linear forwards 0.6s'>
|
||||
<path d='M -12 -0.5 L 15 -0.5 L 15 23.5 L -12 23.5 L -12 -0.5 Z' fill='none'/>
|
||||
<path d='M 1.5 0.67 C 1.5 0.67 2.24 3.32 2.24 5.47 C 2.24 7.53 0.89 9.2 -1.17 9.2 C -3.23 9.2 -4.79 7.53 -4.79 5.47 L -4.76 5.11 C -6.78 7.51 -8 10.62 -8 13.99 C -8 18.41 -4.42 22 0 22 C 4.42 22 8 18.41 8 13.99 C 8 8.6 5.41 3.79 1.5 0.67 Z M -0.29 19 C -2.07 19 -3.51 17.6 -3.51 15.86 C -3.51 14.24 -2.46 13.1 -0.7 12.74 C 1.07 12.38 2.9 11.53 3.92 10.16 C 4.31 11.45 4.51 12.81 4.51 14.2 C 4.51 16.85 2.36 19 -0.29 19 Z' fill='#3454d1' stroke-opacity='0'/>
|
||||
</g>
|
||||
|
||||
<!-- Current Streak big number -->
|
||||
<g transform='translate(247.5, 48)'>
|
||||
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#c9ccd4' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='700' font-size='28px' font-style='normal' style='animation: currstreak 0.6s linear forwards'>
|
||||
0
|
||||
</text>
|
||||
</g>
|
||||
|
||||
</g>
|
||||
<g style='isolation: isolate'>
|
||||
<!-- Longest Streak big number -->
|
||||
<g transform='translate(412.5, 48)'>
|
||||
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#c9ccd4' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='700' font-size='28px' font-style='normal' style='opacity: 0; animation: fadein 0.5s linear forwards 1.2s'>
|
||||
6
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Longest Streak label -->
|
||||
<g transform='translate(412.5, 84)'>
|
||||
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#9aa0aa' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='14px' font-style='normal' style='opacity: 0; animation: fadein 0.5s linear forwards 1.3s'>
|
||||
Longest Streak
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Longest Streak range -->
|
||||
<g transform='translate(412.5, 114)'>
|
||||
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#9aa0aa' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='12px' font-style='normal' style='opacity: 0; animation: fadein 0.5s linear forwards 1.4s'>
|
||||
Oct 19 - Oct 24
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.9 KiB |
@@ -0,0 +1,111 @@
|
||||
<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'
|
||||
style='isolation: isolate' viewBox='0 0 495 195' width='495px' height='195px' direction='ltr'>
|
||||
<style>
|
||||
@keyframes currstreak {
|
||||
0% { font-size: 3px; opacity: 0.2; }
|
||||
80% { font-size: 34px; opacity: 1; }
|
||||
100% { font-size: 28px; opacity: 1; }
|
||||
}
|
||||
@keyframes fadein {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
<defs>
|
||||
<clipPath id='outer_rectangle'>
|
||||
<rect width='495' height='195' rx='4.5'/>
|
||||
</clipPath>
|
||||
<mask id='mask_out_ring_behind_fire'>
|
||||
<rect width='495' height='195' fill='white'/>
|
||||
<ellipse id='mask-ellipse' cx='247.5' cy='32' rx='13' ry='18' fill='black'/>
|
||||
</mask>
|
||||
|
||||
</defs>
|
||||
<g clip-path='url(#outer_rectangle)'>
|
||||
<g style='isolation: isolate'>
|
||||
<rect stroke='#000000' stroke-opacity='0' fill='#000000' fill-opacity='0' rx='4.5' x='0.5' y='0.5' width='494' height='194'/>
|
||||
</g>
|
||||
<g style='isolation: isolate'>
|
||||
<line x1='165' y1='28' x2='165' y2='170' vector-effect='non-scaling-stroke' stroke-width='1' stroke='#E4E2E2' stroke-linejoin='miter' stroke-linecap='square' stroke-miterlimit='3'/>
|
||||
<line x1='330' y1='28' x2='330' y2='170' vector-effect='non-scaling-stroke' stroke-width='1' stroke='#E4E2E2' stroke-linejoin='miter' stroke-linecap='square' stroke-miterlimit='3'/>
|
||||
</g>
|
||||
<g style='isolation: isolate'>
|
||||
<!-- Total Contributions big number -->
|
||||
<g transform='translate(82.5, 48)'>
|
||||
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#33343a' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='700' font-size='28px' font-style='normal' style='opacity: 0; animation: fadein 0.5s linear forwards 0.6s'>
|
||||
344
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Total Contributions label -->
|
||||
<g transform='translate(82.5, 84)'>
|
||||
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#8b8f98' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='14px' font-style='normal' style='opacity: 0; animation: fadein 0.5s linear forwards 0.7s'>
|
||||
Total Contributions
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Total Contributions range -->
|
||||
<g transform='translate(82.5, 114)'>
|
||||
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#8b8f98' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='12px' font-style='normal' style='opacity: 0; animation: fadein 0.5s linear forwards 0.8s'>
|
||||
May 24, 2019 - Present
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
<g style='isolation: isolate'>
|
||||
<!-- Current Streak label -->
|
||||
<g transform='translate(247.5, 108)'>
|
||||
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#3454d1' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='700' font-size='14px' font-style='normal' style='opacity: 0; animation: fadein 0.5s linear forwards 0.9s'>
|
||||
Current Streak
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Current Streak range -->
|
||||
<g transform='translate(247.5, 145)'>
|
||||
<text x='0' y='21' stroke-width='0' text-anchor='middle' fill='#8b8f98' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='12px' font-style='normal' style='opacity: 0; animation: fadein 0.5s linear forwards 0.9s'>
|
||||
Nov 3
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Ring around number -->
|
||||
<g mask='url(#mask_out_ring_behind_fire)'>
|
||||
<circle cx='247.5' cy='71' r='40' fill='none' stroke='#3454d1' stroke-width='5' style='opacity: 0; animation: fadein 0.5s linear forwards 0.4s'></circle>
|
||||
</g>
|
||||
<!-- Fire icon -->
|
||||
<g transform='translate(247.5, 19.5)' stroke-opacity='0' style='opacity: 0; animation: fadein 0.5s linear forwards 0.6s'>
|
||||
<path d='M -12 -0.5 L 15 -0.5 L 15 23.5 L -12 23.5 L -12 -0.5 Z' fill='none'/>
|
||||
<path d='M 1.5 0.67 C 1.5 0.67 2.24 3.32 2.24 5.47 C 2.24 7.53 0.89 9.2 -1.17 9.2 C -3.23 9.2 -4.79 7.53 -4.79 5.47 L -4.76 5.11 C -6.78 7.51 -8 10.62 -8 13.99 C -8 18.41 -4.42 22 0 22 C 4.42 22 8 18.41 8 13.99 C 8 8.6 5.41 3.79 1.5 0.67 Z M -0.29 19 C -2.07 19 -3.51 17.6 -3.51 15.86 C -3.51 14.24 -2.46 13.1 -0.7 12.74 C 1.07 12.38 2.9 11.53 3.92 10.16 C 4.31 11.45 4.51 12.81 4.51 14.2 C 4.51 16.85 2.36 19 -0.29 19 Z' fill='#3454d1' stroke-opacity='0'/>
|
||||
</g>
|
||||
|
||||
<!-- Current Streak big number -->
|
||||
<g transform='translate(247.5, 48)'>
|
||||
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#33343a' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='700' font-size='28px' font-style='normal' style='animation: currstreak 0.6s linear forwards'>
|
||||
0
|
||||
</text>
|
||||
</g>
|
||||
|
||||
</g>
|
||||
<g style='isolation: isolate'>
|
||||
<!-- Longest Streak big number -->
|
||||
<g transform='translate(412.5, 48)'>
|
||||
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#33343a' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='700' font-size='28px' font-style='normal' style='opacity: 0; animation: fadein 0.5s linear forwards 1.2s'>
|
||||
6
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Longest Streak label -->
|
||||
<g transform='translate(412.5, 84)'>
|
||||
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#8b8f98' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='14px' font-style='normal' style='opacity: 0; animation: fadein 0.5s linear forwards 1.3s'>
|
||||
Longest Streak
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Longest Streak range -->
|
||||
<g transform='translate(412.5, 114)'>
|
||||
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#8b8f98' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='12px' font-style='normal' style='opacity: 0; animation: fadein 0.5s linear forwards 1.4s'>
|
||||
Oct 19 - Oct 24
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.9 KiB |
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env node
|
||||
// README 动效资产抓取器(5i T8):把只能靠外部生成的 SVG 数据卡抓成本地静态文件
|
||||
// 设计取舍:
|
||||
// · 产物入仓(public/github-assets/)→ 构建确定性与离线可构建优先,不在 CI 里跑本脚本
|
||||
// · 单资产失败保留旧文件;无旧文件则跳过(组件侧按存在性决定是否渲染该块)
|
||||
// · 配色按站点 tokens 近似:透明底 + 文字 #33343a(亮)/#c9ccd4(暗) + 线条 #3454d1
|
||||
// · manifest.json 记录来源 URL 与抓取时间(可追溯,不含任何凭据)
|
||||
// 用法:node scripts/build-perf/fetch-readme-assets.mjs [--force]
|
||||
import { mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const OUT_DIR = 'public/github-assets';
|
||||
const LOGIN = 'wait4xx';
|
||||
const TIMEOUT_MS = 10_000;
|
||||
const UA = 'wait4x-blog-assets/1.0 (+https://wait4x.online)';
|
||||
|
||||
// 站点 tokens 近似值(与灰阶纪律一致:底透明,线条单一强调色)
|
||||
const ACCENT = '3454d1';
|
||||
const INK_LIGHT = '33343a';
|
||||
const INK_DARK = 'c9ccd4';
|
||||
const MUTED_LIGHT = '8b8f98';
|
||||
const MUTED_DARK = '9aa0aa';
|
||||
|
||||
/** 同一张卡按明暗两套参数各存一份(组件内用 CSS 切换,零运行时请求) */
|
||||
const ASSETS = [
|
||||
{
|
||||
name: 'stats',
|
||||
url: (theme) =>
|
||||
`https://github-readme-stats.vercel.app/api?username=${LOGIN}` +
|
||||
'&show_icons=true&hide_border=true&include_all_commits=true&count_private=true&rank_icon=github' +
|
||||
`&bg_color=00000000&title_color=${ACCENT}&icon_color=${ACCENT}` +
|
||||
`&text_color=${theme === 'dark' ? INK_DARK : INK_LIGHT}`,
|
||||
label: 'GitHub 统计',
|
||||
},
|
||||
{
|
||||
name: 'top-langs',
|
||||
url: (theme) =>
|
||||
`https://github-readme-stats.vercel.app/api/top-langs/?username=${LOGIN}` +
|
||||
'&layout=compact&hide_border=true&langs_count=6' +
|
||||
`&bg_color=00000000&title_color=${ACCENT}` +
|
||||
`&text_color=${theme === 'dark' ? INK_DARK : INK_LIGHT}`,
|
||||
label: '语言分布',
|
||||
},
|
||||
{
|
||||
name: 'streak',
|
||||
url: (theme) =>
|
||||
`https://streak-stats.demolab.com/?user=${LOGIN}&hide_border=true` +
|
||||
'&background=00000000' +
|
||||
`&ring=${ACCENT}&fire=${ACCENT}&currStreakLabel=${ACCENT}` +
|
||||
`&currStreakNum=${theme === 'dark' ? INK_DARK : INK_LIGHT}` +
|
||||
`&sideNums=${theme === 'dark' ? INK_DARK : INK_LIGHT}` +
|
||||
`&sideLabels=${theme === 'dark' ? MUTED_DARK : MUTED_LIGHT}` +
|
||||
`&dates=${theme === 'dark' ? MUTED_DARK : MUTED_LIGHT}`,
|
||||
label: '连续贡献',
|
||||
},
|
||||
{
|
||||
// 贡献蛇:GitHub Action 生成,配色由 action 侧决定,无法传参 → 只抓一份
|
||||
name: 'snake',
|
||||
url: () => `https://raw.githubusercontent.com/${LOGIN}/${LOGIN}/output/github-contribution-grid-snake.svg`,
|
||||
label: '贡献蛇',
|
||||
single: true,
|
||||
},
|
||||
];
|
||||
|
||||
/** 10s 超时抓取;返回 SVG 文本或 null */
|
||||
async function grab(url) {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { 'User-Agent': UA, Accept: 'image/svg+xml,image/*;q=0.8,*/*;q=0.5' },
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.warn(`[assets] HTTP ${res.status}: ${url.slice(0, 90)}…`);
|
||||
return null;
|
||||
}
|
||||
const text = await res.text();
|
||||
if (!text.includes('<svg')) {
|
||||
console.warn(`[assets] 非 SVG 响应,已跳过: ${url.slice(0, 90)}…`);
|
||||
return null;
|
||||
}
|
||||
return text;
|
||||
} catch (err) {
|
||||
console.warn(`[assets] 抓取失败(${err.name === 'TimeoutError' ? '超时' : err.message}): ${url.slice(0, 90)}…`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const force = process.argv.includes('--force');
|
||||
mkdirSync(OUT_DIR, { recursive: true });
|
||||
const manifestPath = join(OUT_DIR, 'manifest.json');
|
||||
const manifest = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, 'utf8')) : { assets: {} };
|
||||
const now = new Date().toISOString();
|
||||
let ok = 0;
|
||||
let kept = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const asset of ASSETS) {
|
||||
const themes = asset.single ? [null] : ['light', 'dark'];
|
||||
for (const theme of themes) {
|
||||
const fileName = theme ? `${asset.name}-${theme}.svg` : `${asset.name}.svg`;
|
||||
const filePath = join(OUT_DIR, fileName);
|
||||
const url = asset.url(theme);
|
||||
const svg = await grab(url);
|
||||
if (svg) {
|
||||
writeFileSync(filePath, svg, 'utf8');
|
||||
manifest.assets[fileName] = { source: url, fetchedAt: now, label: asset.label, theme: theme ?? 'any' };
|
||||
console.log(`[assets] ✓ ${fileName} (${Math.round(svg.length / 1024)}KB)`);
|
||||
ok += 1;
|
||||
} else if (existsSync(filePath)) {
|
||||
console.log(`[assets] ↺ ${fileName} 抓取失败,保留旧文件`);
|
||||
kept += 1;
|
||||
} else {
|
||||
console.warn(`[assets] ⊘ ${fileName} 抓取失败且无旧文件 → 构建时将跳过该块`);
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
||||
console.log(`[assets] 完成:新增/更新 ${ok},保留旧文件 ${kept},跳过 ${skipped};manifest → ${manifestPath}`);
|
||||
process.exit(0);
|
||||
@@ -8,6 +8,7 @@ interface Props {
|
||||
const { pageKey, pageTitle } = Astro.props;
|
||||
import { t } from '../i18n/ui';
|
||||
import ViewMeta from './geek/ViewMeta.astro';
|
||||
import ArtalkThemeStyles from './geek/ArtalkThemeStyles.astro';
|
||||
import { localeOf } from '../lib/locale';
|
||||
const locale = localeOf(Astro.url.pathname);
|
||||
---
|
||||
@@ -21,64 +22,10 @@ const locale = localeOf(Astro.url.pathname);
|
||||
<button type="button" class="atk-retry-btn" id="artalk-retry-btn">重试</button>
|
||||
</p>
|
||||
|
||||
<!-- Artalk 主题接管:--at-color-* 全套映射到站点 tokens(值均为 var(),明暗随
|
||||
html[data-theme] 翻转 tokens 自动生效,一套规则双态成立)。
|
||||
依据:Artalk.css(v2.10) 全部表面由这 18 个变量驱动;init 锁 darkMode:false
|
||||
防 Artalk 自带 atk-dark-mode 类与接管层打架。弹层挂 body 下需单独同款映射 -->
|
||||
<!-- 主题接管(--at-color-* 全套映射 + 气质规则)已抽到共享片段:
|
||||
文章容器 #artalk-container 与瞬间容器 [data-artalk-panel] 用同一份,避免漂移 -->
|
||||
<ArtalkThemeStyles />
|
||||
<style is:global>
|
||||
#artalk-container,
|
||||
/* Artalk.css 运行时才挂 head,平级选择器后来居上:body 前缀提一级特异性保住覆盖 */
|
||||
body .atk-layer-wrap {
|
||||
--at-color-bg: var(--bg);
|
||||
--at-color-bg-transl: color-mix(in oklab, var(--bg) 95%, transparent);
|
||||
--at-color-bg-grey: var(--bg-soft);
|
||||
--at-color-bg-grey-transl: color-mix(in oklab, var(--bg-soft) 95%, transparent);
|
||||
--at-color-bg-light: var(--accent-soft);
|
||||
--at-color-border: color-mix(in oklab, var(--ink-4) 55%, transparent); /* --line 在白底近不可见(视觉终审建议项),加深一档 */
|
||||
--at-color-font: var(--ink-2);
|
||||
--at-color-deep: var(--ink-1);
|
||||
--at-color-sub: var(--ink-3);
|
||||
--at-color-grey: var(--ink-3);
|
||||
--at-color-meta: var(--ink-4);
|
||||
--at-color-light: var(--ink-4);
|
||||
--at-color-main: var(--accent);
|
||||
/* 高度限高渐隐遮罩必须与 bg 同源,否则暗色下露白腿 */
|
||||
--at-color-gradient: linear-gradient(180deg, transparent, var(--bg));
|
||||
/* red/pink/yellow/green 语义功能色保留官方默认:纪律只管气质,不损语义 */
|
||||
}
|
||||
|
||||
/* 气质对齐(不动排版):字体、头像圆角、时间戳 mono、链接纪律 */
|
||||
#artalk-container {
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
#artalk-container .atk-avatar img {
|
||||
border-radius: 8px;
|
||||
}
|
||||
#artalk-container .atk-meta,
|
||||
#artalk-container .atk-ua {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
#artalk-container .atk-copyright {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--ink-4);
|
||||
}
|
||||
#artalk-container .atk-textarea,
|
||||
#artalk-container .atk-input,
|
||||
#artalk-container .atk-send-btn,
|
||||
#artalk-container .atk-btn {
|
||||
border-radius: 8px;
|
||||
}
|
||||
#artalk-container .atk-content a {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
text-decoration-color: color-mix(in oklab, var(--accent) 40%, transparent);
|
||||
text-underline-offset: 0.2em;
|
||||
}
|
||||
#artalk-container .atk-content a:hover {
|
||||
text-decoration-color: var(--accent);
|
||||
}
|
||||
.atk-load-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -113,16 +60,33 @@ const locale = localeOf(Astro.url.pathname);
|
||||
let mounted = false;
|
||||
const retryEl = document.getElementById('artalk-retry');
|
||||
const showRetry = (show: boolean) => { if (retryEl) retryEl.hidden = !show; };
|
||||
const libReady = () => (window as unknown as { Artalk?: { init: (o: object) => void } }).Artalk;
|
||||
// 超时兜底:5s 确实未挂载才提示;同时**复位 mounting** —— 否则一次超时会把后续
|
||||
// 所有触发(IO / 锚点 / 滚底 / 重试)全堵死,评论区从此空着
|
||||
const armTimeout = () => {
|
||||
window.setTimeout(() => {
|
||||
if (!mounted) showRetry(true);
|
||||
if (mounted) return;
|
||||
mounting = false;
|
||||
showRetry(true);
|
||||
}, 5000);
|
||||
};
|
||||
const mount = () => {
|
||||
if (mounting || mounted) return;
|
||||
mounting = true;
|
||||
armTimeout();
|
||||
if (document.getElementById('artalk-lib')) return initArtalk();
|
||||
// 库已就绪(软导航二次进入、文章间跳转最常见)→ 直接 init
|
||||
if (libReady()) return initArtalk();
|
||||
const existing = document.getElementById('artalk-lib') as HTMLScriptElement | null;
|
||||
if (existing) {
|
||||
// 脚本标签已在、但库还没就绪(软导航后二次挂载的典型态):**等它 load**。
|
||||
// 这里直接判失败,正是用户看到的「评论区正常显示却挂着加载失败提示」的根因。
|
||||
const onLoad = () => {
|
||||
existing.removeEventListener('load', onLoad);
|
||||
initArtalk();
|
||||
};
|
||||
existing.addEventListener('load', onLoad);
|
||||
return;
|
||||
}
|
||||
// 官方部署文档要求 CSS 手动引入(JS 不自动注入);此前缺失导致评论区自上线起跑在 UA 默认样式上
|
||||
const cssReady = new Promise<void>((done) => {
|
||||
if (document.getElementById('artalk-css')) return done();
|
||||
@@ -138,18 +102,21 @@ const locale = localeOf(Astro.url.pathname);
|
||||
s.id = 'artalk-lib';
|
||||
s.src = 'https://wait4x.online/artalk/dist/Artalk.js';
|
||||
s.onload = () => { void cssReady.then(initArtalk); };
|
||||
// 真失败之一:脚本拉不到(404 / 被拦截)——只有这时才报错
|
||||
s.onerror = () => { mounting = false; showRetry(true); };
|
||||
document.head.appendChild(s);
|
||||
};
|
||||
const initArtalk = () => {
|
||||
const lib = (window as unknown as { Artalk?: { init: (o: object) => void } }).Artalk;
|
||||
// 只有库真正就绪才算挂载成功:否则脚本 404/被拦截时会把"空容器"误标成已挂载,
|
||||
// 5s 兜底提示也被一并绕过(用户看到的正是"评论区一直空着")
|
||||
const lib = libReady();
|
||||
// 库仍未就绪:可能是 onload 早于 window.Artalk 赋值,也可能是慢网。此处**不报错**
|
||||
// (报错就是用户看到的误报),复位后交给超时兜底或后续触发再试。
|
||||
if (!lib) {
|
||||
showRetry(true);
|
||||
mounting = false;
|
||||
return;
|
||||
}
|
||||
mounted = true;
|
||||
showRetry(false);
|
||||
mounting = false;
|
||||
showRetry(false); // 挂载成功 → 清掉任何已显示的提示
|
||||
el.dataset.artalkMounted = '1';
|
||||
lib?.init({
|
||||
el: '#artalk-container',
|
||||
|
||||
@@ -6,13 +6,17 @@
|
||||
* 都白付;Artalk 官方也未背书"同页多实例长期并存"。于是「展开才挂」是折中:
|
||||
* 读者点了才付成本,未点零开销(连 Artalk.js 都不下载)。
|
||||
*
|
||||
* 评论流约定:`page_key = /moments/#<slug>`,**中英双语共用同一键**(评论不分裂,
|
||||
* 与文章页 `/archives/<slug>/` 双语共用同理);site 恒为 wait4x.online。
|
||||
* 评论流约定:`page_key = /moments/<slug>/`(虚拟路径),**中英双语共用同一键**
|
||||
* (评论不分裂,与文章页 `/archives/<slug>/` 同理);site 恒为 wait4x.online。
|
||||
* 为什么不用 `#<slug>` 形态:`#` 在查询串里是 fragment 分隔符,裸传会被服务端截断成
|
||||
* 页面级键 `/moments/`(实测),届时所有瞬间会共用同一个评论流、pv 也串在一起。
|
||||
* 虚拟路径既无转义风险,将来真做单条瞬间页时键也天然对齐。
|
||||
*
|
||||
* 计数不在这里自己 fetch:复用 geek/ViewMeta(mode="readonly" 只读,不写 pv),
|
||||
* 放在卡片底部让读者不开评论也能看见条数。
|
||||
*/
|
||||
import ViewMeta from './geek/ViewMeta.astro';
|
||||
import ArtalkThemeStyles from './geek/ArtalkThemeStyles.astro';
|
||||
import { t } from '../i18n/ui';
|
||||
import type { Locale } from '../lib/locale';
|
||||
|
||||
@@ -24,8 +28,8 @@ interface Props {
|
||||
|
||||
const { slug, locale = 'zh' } = Astro.props;
|
||||
|
||||
/** 评论流键:双语共用;`#<slug>` 与文章页 page_key 形状保持一致的可读性 */
|
||||
const pageKey = `/moments/#${slug}`;
|
||||
/** 评论流键:双语共用的虚拟路径(形如 /moments/2026-11-02/),无转义风险 */
|
||||
const pageKey = `/moments/${slug}/`;
|
||||
/** 容器 id 只允许 CSS 安全字符(slug 理论上是文件名,仍兜一层) */
|
||||
const panelId = `mom-atk-${slug.replace(/[^a-zA-Z0-9_-]/g, '-')}`;
|
||||
const label = t('moment_comments', locale);
|
||||
@@ -49,6 +53,8 @@ const retryText = locale === 'zh' ? '重试' : 'Retry';
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
<!-- 与文章页共用同一份 Artalk 主题映射(瞬间容器 mom-atk-* 无此映射时会回落官方默认色) -->
|
||||
<ArtalkThemeStyles />
|
||||
<div class="mom-cm-meta">
|
||||
<ViewMeta pageKey={pageKey} locale={locale} mode="readonly" showPv={false} />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
/**
|
||||
* Artalk 主题接管(Phase 5i T3:从 ArtalkComments 抽出的共享片段,两处组件共用,不复制两份)。
|
||||
*
|
||||
* 为什么要抽:文章页容器是 `#artalk-container`,瞬间页容器是 `mom-atk-<slug>`
|
||||
* (带 `[data-artalk-panel]`)。原先 18 个 `--at-color-*` 映射只写在 `#artalk-container`
|
||||
* 作用域,瞬间评论区因此回落 Artalk 官方默认色(白底深字)——暗色主题下尤其突兀。
|
||||
* 现在一套片段覆盖三种宿主:文章容器 / 瞬间容器 / Artalk 弹层(挂在 body 下)。
|
||||
*
|
||||
* 依据:Artalk.css(v2.10) 全部表面由这 18 个变量驱动;值均为 `var(--token)`,
|
||||
* 明暗随 `html[data-theme]` 翻转自动生效,一套规则双态成立。init 侧统一锁
|
||||
* `darkMode: false`,防 Artalk 自带 `atk-dark-mode` 类与接管层打架。
|
||||
*/
|
||||
---
|
||||
|
||||
<style is:global>
|
||||
/* ── 18 个色彩变量:三种宿主一套映射 ──
|
||||
瞬间容器必须用 `body [data-artalk-panel]`(0,1,1):Artalk init 后会给容器加 `.artalk` 类,
|
||||
它从 CDN 后加载的样式表里有 `.artalk{--at-color-bg:#fff…}`,与裸属性选择器 (0,1,0) 同特异性
|
||||
且后来居上 → 暗色下瞬间评论区会翻白底(实测踩到)。文章容器是 ID(1,0,0) 本就压得住。 */
|
||||
#artalk-container,
|
||||
body [data-artalk-panel],
|
||||
/* Artalk.css 运行时才挂 head,平级选择器后来居上:body 前缀提一级特异性保住覆盖 */
|
||||
body .atk-layer-wrap {
|
||||
--at-color-bg: var(--bg);
|
||||
--at-color-bg-transl: color-mix(in oklab, var(--bg) 95%, transparent);
|
||||
--at-color-bg-grey: var(--bg-soft);
|
||||
--at-color-bg-grey-transl: color-mix(in oklab, var(--bg-soft) 95%, transparent);
|
||||
--at-color-bg-light: var(--accent-soft);
|
||||
--at-color-border: color-mix(in oklab, var(--ink-4) 55%, transparent); /* --line 在白底近不可见(视觉终审建议项),加深一档 */
|
||||
--at-color-font: var(--ink-2);
|
||||
--at-color-deep: var(--ink-1);
|
||||
--at-color-sub: var(--ink-3);
|
||||
--at-color-grey: var(--ink-3);
|
||||
--at-color-meta: var(--ink-4);
|
||||
--at-color-light: var(--ink-4);
|
||||
--at-color-main: var(--accent);
|
||||
/* 高度限高渐隐遮罩必须与 bg 同源,否则暗色下露白腿 */
|
||||
--at-color-gradient: linear-gradient(180deg, transparent, var(--bg));
|
||||
/* red/pink/yellow/green 语义功能色保留官方默认:纪律只管气质,不损语义 */
|
||||
}
|
||||
|
||||
/* ── 气质对齐(不动排版):字体、头像圆角、时间戳 mono、链接纪律 ── */
|
||||
#artalk-container,
|
||||
body [data-artalk-panel] {
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
#artalk-container .atk-avatar img,
|
||||
body [data-artalk-panel] .atk-avatar img {
|
||||
border-radius: 8px;
|
||||
}
|
||||
#artalk-container .atk-meta,
|
||||
#artalk-container .atk-ua,
|
||||
body [data-artalk-panel] .atk-meta,
|
||||
body [data-artalk-panel] .atk-ua {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
#artalk-container .atk-copyright,
|
||||
body [data-artalk-panel] .atk-copyright {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--ink-4);
|
||||
}
|
||||
#artalk-container .atk-textarea,
|
||||
#artalk-container .atk-input,
|
||||
#artalk-container .atk-send-btn,
|
||||
#artalk-container .atk-btn,
|
||||
body [data-artalk-panel] .atk-textarea,
|
||||
body [data-artalk-panel] .atk-input,
|
||||
body [data-artalk-panel] .atk-send-btn,
|
||||
body [data-artalk-panel] .atk-btn {
|
||||
border-radius: 8px;
|
||||
}
|
||||
#artalk-container .atk-content a,
|
||||
body [data-artalk-panel] .atk-content a {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
text-decoration-color: color-mix(in oklab, var(--accent) 40%, transparent);
|
||||
text-underline-offset: 0.2em;
|
||||
}
|
||||
#artalk-container .atk-content a:hover,
|
||||
body [data-artalk-panel] .atk-content a:hover {
|
||||
text-decoration-color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
@@ -5,6 +5,8 @@
|
||||
// 0600 文件,缺位或失败即隐藏该数字)④src/lib/projects.ts 的仓库列表(语言占比与星标汇总)
|
||||
// 降级纪律:任何一项取数失败只隐藏该项,绝不炸构建、不出现空图片框
|
||||
import { Image } from 'astro:assets';
|
||||
import { existsSync } from 'node:fs';
|
||||
import TypingLines from './TypingLines.astro';
|
||||
import { homeAvatar, homeSocials, openSourceCopy } from '../../lib/site-content';
|
||||
import { githubUserStats, loadProjects } from '../../lib/projects';
|
||||
import { iconPath } from '../../lib/icons';
|
||||
@@ -49,6 +51,40 @@ try {
|
||||
// 只给第一根上强调色会让并列者显得"落后",故并列者一并着色
|
||||
const maxPct = langs.reduce((m, l) => Math.max(m, l.pct), 0);
|
||||
const stats = await githubUserStats();
|
||||
|
||||
// 打字机动效文案:content/site/opensource.yaml 的 typing[](双语,后台可改)
|
||||
// 注:schema 由 orchestrator 统一扩字段;未声明时这里的类型断言取不到值 → 走内置默认
|
||||
type TypingEntry = { zh?: string; en?: string };
|
||||
const typingRaw = (copy as unknown as { typing?: TypingEntry[] }).typing ?? [];
|
||||
const typingLines = typingRaw
|
||||
.map((t) => (locale === 'zh' ? t.zh : (t.en ?? t.zh)) ?? '')
|
||||
.filter(Boolean);
|
||||
const DEFAULT_TYPING = {
|
||||
zh: ['气象出身,写代码为乐', '自托管一切能自托管的东西', '拥抱开源,热爱开源'],
|
||||
en: ['Trained in meteorology, coding for fun', 'Self-hosting whatever can be self-hosted', 'Embrace open source, love open source'],
|
||||
}[locale];
|
||||
const lines = typingLines.length ? typingLines : DEFAULT_TYPING;
|
||||
|
||||
// 自托管 SVG 数据卡(public/github-assets/,由 scripts/build-perf/fetch-readme-assets.mjs 产出入仓)
|
||||
// 缺文件即跳过该卡(异步抓取失败不影响构建),明暗各一份时用 CSS 切换
|
||||
const assetPath = (name: string) => `/github-assets/${name}`;
|
||||
const hasAsset = (name: string) => existsSync(`public/github-assets/${name}`);
|
||||
const boardCards: { label: string; light: string; dark?: string }[] = [];
|
||||
for (const [key, label] of [
|
||||
['stats', locale === 'zh' ? '统计' : 'Stats'],
|
||||
['top-langs', locale === 'zh' ? '语言分布' : 'Top languages'],
|
||||
['streak', locale === 'zh' ? '连续贡献' : 'Streak'],
|
||||
] as const) {
|
||||
if (!hasAsset(`${key}-light.svg`)) continue;
|
||||
boardCards.push({
|
||||
label,
|
||||
light: assetPath(`${key}-light.svg`),
|
||||
dark: hasAsset(`${key}-dark.svg`) ? assetPath(`${key}-dark.svg`) : undefined,
|
||||
});
|
||||
}
|
||||
const snakeCard = hasAsset('snake.svg')
|
||||
? { label: locale === 'zh' ? '贡献蛇' : 'Contribution snake', src: assetPath('snake.svg') }
|
||||
: null;
|
||||
const L = {
|
||||
zh: { followers: '关注者', repos: '公开仓库', stars: '总星标', langs: '语言占比' },
|
||||
en: { followers: 'Followers', repos: 'Public repos', stars: 'Total stars', langs: 'Languages' },
|
||||
@@ -66,8 +102,9 @@ const L = {
|
||||
<p class="pf-name">
|
||||
wait4x<span class="pf-handle mono">@wait4xx</span>
|
||||
</p>
|
||||
<TypingLines lines={lines} />
|
||||
<p class="pf-bio">
|
||||
{copy.blurb}<a class="pf-link" href={copy.repoUrl}>{copy.repoLabel}</a>{copy.blurbAfter}
|
||||
{copy.blurb}<a class="pf-link" href={copy.repoUrl}>{copy.repoLabel}</a>{' '}{copy.blurbAfter.trim()}
|
||||
</p>
|
||||
<ul class="pf-socials">
|
||||
{socials.map((s) => (
|
||||
@@ -123,6 +160,28 @@ const L = {
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{(boardCards.length > 0 || snakeCard) && (
|
||||
<div class="pf-board">
|
||||
<p class="pf-board-title mono">{locale === 'zh' ? '数据板' : 'Data board'}</p>
|
||||
<div class="pf-board-grid">
|
||||
{boardCards.map((card) => (
|
||||
<figure class="pf-card">
|
||||
<img class="pf-card-img only-light" src={card.light} alt={card.label} loading="lazy" decoding="async" />
|
||||
{card.dark && (
|
||||
<img class="pf-card-img only-dark" src={card.dark} alt={card.label} loading="lazy" decoding="async" />
|
||||
)}
|
||||
<figcaption class="mono">{card.label}</figcaption>
|
||||
</figure>
|
||||
))}
|
||||
{snakeCard && (
|
||||
<figure class="pf-card pf-card-wide pf-card-snake">
|
||||
<img class="pf-card-img" src={snakeCard.src} alt={snakeCard.label} loading="lazy" decoding="async" />
|
||||
<figcaption class="mono">{snakeCard.label}</figcaption>
|
||||
</figure>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
@@ -253,8 +312,55 @@ const L = {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.pf-board {
|
||||
margin-top: var(--space-3);
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.pf-board-title {
|
||||
font-size: 11.5px;
|
||||
color: var(--ink-4);
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.pf-board-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.pf-card {
|
||||
margin: 0;
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.pf-card-wide { grid-column: 1 / -1; }
|
||||
.pf-card-img { max-width: 100%; height: auto; display: block; }
|
||||
.pf-card figcaption {
|
||||
font-size: 11px;
|
||||
color: var(--ink-4);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
/* 明暗两版自托管 SVG:默认只显示亮色,暗色主题切到暗色版 */
|
||||
/* 贡献蛇:SVG 内部用自带 CSS 变量(绿阶 + #ebedf0 空格),<img> 无法改写内部配色 →
|
||||
滤镜归一到站点灰阶;暗色下反相,避免浅灰格子贴在深底上刺眼(想去掉绿→灰只改这一行) */
|
||||
.pf-card-snake .pf-card-img { filter: grayscale(1) contrast(1.03); }
|
||||
:global([data-theme='dark']) .pf-card-snake .pf-card-img {
|
||||
filter: grayscale(1) invert(1) contrast(0.92);
|
||||
}
|
||||
|
||||
.only-dark { display: none; }
|
||||
:global([data-theme='dark']) .only-light { display: none; }
|
||||
:global([data-theme='dark']) .only-dark { display: block; }
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.pf-lang { grid-template-columns: 5.5rem 1fr 2.6rem; }
|
||||
.pf-stats { gap: var(--space-3); }
|
||||
.pf-board-grid { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
</style>
|
||||
@@ -73,6 +73,9 @@ try {
|
||||
.repo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* 用户拍板(5i T6):Projects 卡片内容居中,不用左对齐 */
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 2px;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--line);
|
||||
@@ -82,7 +85,12 @@ try {
|
||||
}
|
||||
.repo:hover { border-color: var(--accent); }
|
||||
.repo-name { font-family: var(--font-mono); font-size: 14px; color: var(--ink-1); }
|
||||
.repo-desc { font-size: 13px; color: var(--ink-3); }
|
||||
.repo-desc {
|
||||
font-size: 13px;
|
||||
color: var(--ink-3);
|
||||
/* 居中后长描述限行宽,避免整行摊平散架 */
|
||||
max-width: 34em;
|
||||
}
|
||||
.repo-meta { display: flex; gap: var(--space-3); font-size: 12px; color: var(--ink-3); margin-top: 4px; }
|
||||
.lang { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.dot {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
---
|
||||
// 自绘打字机动效(5i T8):替代外链 readme-typing-svg
|
||||
// 纯 CSS + 极少 JS:SSR 先渲染第一行(无 JS / reduced-motion 时就是静态首行,不闪不跳),
|
||||
// JS 接管后循环「逐字打出 → 停留 → 逐字删除 → 下一行」;光标闪烁走 CSS keyframes。
|
||||
interface Props {
|
||||
/** 已本地化的文案行(保持 ≤6 行) */
|
||||
lines: string[];
|
||||
/** 每字毫秒 */
|
||||
speed?: number;
|
||||
}
|
||||
const { lines, speed = 55 } = Astro.props;
|
||||
const safe = lines.map((l) => l.trim()).filter(Boolean).slice(0, 6);
|
||||
const first = safe[0] ?? '';
|
||||
---
|
||||
|
||||
{safe.length > 0 && (
|
||||
<p
|
||||
class="typing"
|
||||
data-lines={JSON.stringify(safe)}
|
||||
data-speed={speed}
|
||||
aria-label={safe.join(';')}
|
||||
>
|
||||
<span class="typing-text" aria-hidden="true">{first}</span>
|
||||
<span class="typing-caret" aria-hidden="true"></span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<script>
|
||||
// 每次软导航重挂:ClientRouter 下模块脚本只跑一次 → 用 astro:page-load 统一初始化
|
||||
const reducedMotion = () => matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
function initTyping() {
|
||||
document.querySelectorAll<HTMLElement>('.typing').forEach((el) => {
|
||||
if (el.dataset.typingOn === '1') return;
|
||||
el.dataset.typingOn = '1';
|
||||
const lines: string[] = JSON.parse(el.dataset.lines ?? '[]');
|
||||
const text = el.querySelector<HTMLElement>('.typing-text');
|
||||
if (!text || lines.length < 2 || reducedMotion()) return; // 单行/减动效:保留 SSR 首行
|
||||
|
||||
const speed = Number(el.dataset.speed ?? 55);
|
||||
let line = 0;
|
||||
let char = lines[0].length;
|
||||
let erasing = true;
|
||||
let timer: number | undefined;
|
||||
|
||||
const step = () => {
|
||||
const current = lines[line % lines.length];
|
||||
if (erasing) {
|
||||
char -= 1;
|
||||
text.textContent = current.slice(0, Math.max(char, 0));
|
||||
if (char <= 0) {
|
||||
erasing = false;
|
||||
line += 1;
|
||||
timer = window.setTimeout(step, 260);
|
||||
return;
|
||||
}
|
||||
timer = window.setTimeout(step, Math.max(18, speed / 2));
|
||||
} else {
|
||||
char += 1;
|
||||
text.textContent = lines[line % lines.length].slice(0, char);
|
||||
if (char >= lines[line % lines.length].length) {
|
||||
erasing = true;
|
||||
timer = window.setTimeout(step, 1800);
|
||||
return;
|
||||
}
|
||||
timer = window.setTimeout(step, speed);
|
||||
}
|
||||
};
|
||||
// 首行打完再停留一下开始删除
|
||||
timer = window.setTimeout(step, 1800);
|
||||
el.addEventListener('astro:before-swap', () => window.clearTimeout(timer), { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
initTyping();
|
||||
document.addEventListener('astro:page-load', initTyping);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.typing {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--ink-3);
|
||||
margin: 0 0 var(--space-2);
|
||||
min-height: 1.6em; /* 固定行高,打字期间不推挤下方内容 */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
}
|
||||
.typing-text { white-space: pre-wrap; }
|
||||
.typing-caret {
|
||||
display: inline-block;
|
||||
width: 0.5em;
|
||||
height: 1em;
|
||||
background: var(--accent);
|
||||
animation: caret-blink 1.05s steps(1) infinite;
|
||||
}
|
||||
@keyframes caret-blink {
|
||||
0%, 49% { opacity: 1; }
|
||||
50%, 100% { opacity: 0; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.typing-caret { animation: none; opacity: 0.6; }
|
||||
}
|
||||
</style>
|
||||
@@ -5,6 +5,7 @@ import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import { t } from '../../i18n/ui';
|
||||
import type { Locale } from '../../lib/locale';
|
||||
import type { PhotoExif } from '../../lib/exif';
|
||||
import { renderRich } from '../../lib/site-content';
|
||||
|
||||
// /en/ 侧由 T4 薄页传 locale='en';zh 路由无人传参,默认 zh 零回归
|
||||
interface Props {
|
||||
@@ -54,7 +55,10 @@ const cards = await Promise.all(
|
||||
return {
|
||||
group: photo.data.group,
|
||||
title: photo.data.title,
|
||||
// caption 支持 markdown + 排版类(字体/颜色):与全站文案同一条净化管线,
|
||||
// 因此灯箱右侧文字也能换字体、换颜色(2026-11-02 用户要求)
|
||||
caption: photo.data.caption ?? '',
|
||||
captionHtml: photo.data.caption ? await renderRich(photo.data.caption) : '',
|
||||
alt: photo.data.alt ?? photo.data.title,
|
||||
image: photo.data.image,
|
||||
exif: formatExif(photo.data.exif),
|
||||
@@ -97,6 +101,7 @@ const first = cards[0];
|
||||
class="shot-btn"
|
||||
data-title={card.title}
|
||||
data-caption={card.caption}
|
||||
data-caption-html={card.captionHtml}
|
||||
data-exif={card.exif}
|
||||
data-full={card.fullSrc}
|
||||
data-original={card.original}
|
||||
@@ -143,7 +148,8 @@ const first = cards[0];
|
||||
</svg>
|
||||
</button>
|
||||
<div class="lb-meta lb-l2">
|
||||
<p id="lb-caption" class="lb-caption">{first?.caption ?? ''}</p>
|
||||
<!-- rich:让 global.css 里的 .rich .c-blue / .rich .font-kai 生效(灯箱文字也能换字体/颜色) -->
|
||||
<div id="lb-caption" class="lb-caption rich" set:html={first?.captionHtml ?? ''}></div>
|
||||
<button
|
||||
id="lb-cap-more"
|
||||
class="lb-cap-more"
|
||||
@@ -497,8 +503,10 @@ const first = cards[0];
|
||||
titleEl.textContent = title;
|
||||
titleEl.style.display = title ? '' : 'none';
|
||||
if (captionEl) {
|
||||
captionEl.textContent = btn.dataset.caption ?? '';
|
||||
captionEl.style.display = captionEl.textContent ? '' : 'none';
|
||||
// 富文本 caption:HTML 由 renderRich 在构建期净化(危险标签整棵丢弃),此处可直接写入
|
||||
const html = btn.dataset.captionHtml ?? '';
|
||||
captionEl.innerHTML = html;
|
||||
captionEl.style.display = captionEl.textContent?.trim() ? '' : 'none';
|
||||
}
|
||||
// EXIF 字段级渲染:每段 nowrap(分隔符由 CSS ::after 粘在段尾,永不悬空行首/行尾)
|
||||
const exifText = btn.dataset.exif ?? '';
|
||||
@@ -716,6 +724,14 @@ const first = cards[0];
|
||||
color: #dfe6f3;
|
||||
max-width: 640px;
|
||||
}
|
||||
/* caption 富文本(动态写入,scoped 够不着):段间距收紧、首尾不撑高 */
|
||||
.lb-caption :global(p) { margin: 0 0 0.4em; }
|
||||
.lb-caption :global(> :first-child) { margin-top: 0; }
|
||||
.lb-caption :global(> :last-child) { margin-bottom: 0; }
|
||||
.lb-caption :global(strong) { color: #fff; }
|
||||
.lb-caption :global(a) { color: #8ea2ff; }
|
||||
/* 灯箱底是深色:彩色语义类在暗底上取 soft 档更稳(tokens 的 soft 值本就为暗底调过) */
|
||||
.lb-caption :global(.c-blue) { color: #8ea2ff; }
|
||||
.lb-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -804,6 +820,9 @@ const first = cards[0];
|
||||
position: absolute;
|
||||
top: var(--space-3);
|
||||
right: var(--space-3);
|
||||
/* 桌面端信息栏是 top:0→bottom:0 的右侧全高侧栏,且 DOM 在其后 → 命中测试被它吃掉,
|
||||
右上关闭点不动(elementFromPoint 实锤命中 .lb-meta)。抬层级让按钮始终在最上 */
|
||||
z-index: 12;
|
||||
}
|
||||
@media (max-width: 819px) {
|
||||
/* 触屏条:‹ 图 › 一行,caption/meta 整行在下(flex 生效后 order 才有意义) */
|
||||
|
||||
@@ -292,6 +292,11 @@ export const collections = {
|
||||
perSource: z.enum(['both', 'github', 'gitea']).optional(),
|
||||
/** 开篇文案来源:off(默认,用本文件 blurb)/ github-readme(构建期取 profile 仓 README) */
|
||||
intro_from: z.enum(['off', 'github-readme']).optional(),
|
||||
/**
|
||||
* 档案卡顶部的打字机动效文案(自绘 CSS/JS,非第三方 SVG)。
|
||||
* 越界(>6 条)由访问器截断;全空则组件用内置默认三行。
|
||||
*/
|
||||
typing: z.array(bilingual).optional(),
|
||||
}),
|
||||
}),
|
||||
// slug 即文件名(glob loader 默认按文件名生成 id)
|
||||
|
||||
@@ -15,6 +15,7 @@ import ThemeScript from '../components/ThemeScript.astro';
|
||||
import Keyboard from '../components/geek/Keyboard.astro';
|
||||
import { localeOf, localePath } from '../lib/locale';
|
||||
import { siteFavicon } from '../lib/site-content';
|
||||
import { fontsForPage } from '../lib/font-needs';
|
||||
import SearchDialog from '../components/SearchDialog.astro';
|
||||
import BuildInfo from '../components/geek/BuildInfo.astro';
|
||||
import { ClientRouter } from 'astro:transitions';
|
||||
@@ -38,6 +39,10 @@ const favicon = await siteFavicon();
|
||||
// 自指 canonical(T2):SEO 权重收敛到当前页自身,与 hreflang 组合覆盖双轨
|
||||
const canonicalUrl = new URL(withSlash(rawPath), Astro.site).href;
|
||||
const themeStyle = await themeCss();
|
||||
// 字体按需加载(Phase 5i T1):不再按 track 一刀切——旧实现把 serif/kai/kuaile/汇文
|
||||
// 全关在 reading 分支里,技术轨用 .font-kai 时字体 CSS 从不加载、浏览器静默回退系统宋体。
|
||||
// 现改为:本页内容里扫到什么字族就加载什么(详见 src/lib/font-needs.ts 头注释)。
|
||||
const fontNeeds = await fontsForPage(rawPath, track);
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
@@ -70,22 +75,31 @@ const themeStyle = await themeCss();
|
||||
</script>
|
||||
<ClientRouter />
|
||||
<!-- 非阻塞字体 CSS:preload+onload 回切(Lighthouse P0-1:渲染阻塞 708KB 元凶) -->
|
||||
<!-- 常驻:无衬线正文 + 代码 mono;按需:serif/kai/kuaile/汇文——取决于本页内容诉求 -->
|
||||
<link rel="preload" as="style" href={sansVar} onload="this.rel='stylesheet'" />
|
||||
<link rel="preload" as="style" href={mono400} onload="this.rel='stylesheet'" />
|
||||
<link rel="preload" as="style" href={mono700} onload="this.rel='stylesheet'" />
|
||||
{track === 'reading' && (
|
||||
<>
|
||||
<link rel="preload" as="style" href={serifVar} onload="this.rel='stylesheet'" />
|
||||
<link rel="preload" as="style" href={kai} onload="this.rel='stylesheet'" />
|
||||
<link rel="preload" as="style" href={kuaile} onload="this.rel='stylesheet'" />
|
||||
<!-- 汇文明朝(OFL-1.1,文风开源切片包 339 片,按 unicode-range 按需拉取) -->
|
||||
<link rel="preload" as="style" href="/fonts/subset/hwmc/fonts.css" onload="this.rel='stylesheet'" />
|
||||
</>
|
||||
{fontNeeds.has('serif') && (
|
||||
<link rel="preload" as="style" href={serifVar} onload="this.rel='stylesheet'" />
|
||||
)}
|
||||
{fontNeeds.has('kai') && (
|
||||
<link rel="preload" as="style" href={kai} onload="this.rel='stylesheet'" />
|
||||
)}
|
||||
{fontNeeds.has('kuaile') && (
|
||||
<link rel="preload" as="style" href={kuaile} onload="this.rel='stylesheet'" />
|
||||
)}
|
||||
<!-- 汇文明朝(OFL-1.1,文风开源切片包 339 片,按 unicode-range 按需拉取;
|
||||
手工切片产物路径固定,勿动) -->
|
||||
{fontNeeds.has('huiwen') && (
|
||||
<link rel="preload" as="style" href="/fonts/subset/hwmc/fonts.css" onload="this.rel='stylesheet'" />
|
||||
)}
|
||||
<noscript>
|
||||
<link rel="stylesheet" href={sansVar} />
|
||||
<link rel="stylesheet" href={mono400} />
|
||||
{track === 'reading' && <link rel="stylesheet" href={serifVar} />}
|
||||
{fontNeeds.has('serif') && <link rel="stylesheet" href={serifVar} />}
|
||||
{fontNeeds.has('kai') && <link rel="stylesheet" href={kai} />}
|
||||
{fontNeeds.has('kuaile') && <link rel="stylesheet" href={kuaile} />}
|
||||
{fontNeeds.has('huiwen') && <link rel="stylesheet" href="/fonts/subset/hwmc/fonts.css" />}
|
||||
</noscript>
|
||||
<!-- PWA:manifest + 双方案 theme-color(明暗随系统)+ iOS 图标 -->
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* 字体按需加载(Phase 5i T1):把「轨道门禁」换成「内容诉求驱动」。
|
||||
*
|
||||
* ## 为什么要有这个文件(2026-11-02 用户实测踩的坑,勿删)
|
||||
*
|
||||
* 旧实现把 serif / kai / kuaile / 汇文四份字体 CSS 全关在
|
||||
* `track === 'reading'` 分支里(BaseLayout)。但技术轨才是全站默认
|
||||
* (`track` 缺省 `tech`),于是出现一类**最隐蔽的失效**:
|
||||
*
|
||||
* - 技术文里写 `<span class="font-kai">`:class 在、`font-family` 计算值也对
|
||||
* (`"LXGW WenKai Screen", …`),但那份字体 CSS 从未加载 →
|
||||
* `document.fonts` 里根本没有这个族 → 浏览器静默回退系统宋体。
|
||||
* - 它不报错、不报警,用户看到的现象只有一句:「字体没换」。
|
||||
*
|
||||
* 教训:字体是「外部资源 + 静默回退」,门禁不能按页面所属栏目拍脑袋,
|
||||
* 只能看这一页**真正声明的诉求**——扫出来的字族才加载,扫不到就不加载。
|
||||
*
|
||||
* ## 本文件的两类输入
|
||||
*
|
||||
* 1. **内容扫描**:正文 markdown、站点单例 yaml(页面文案/块)、瞬间文本里
|
||||
* 出现的类名(含 renderRich 白名单放行的排版类)。
|
||||
* 2. **页面静态需求**:某些页面把字族写死在组件里(书架卡标题用楷体、
|
||||
* 关于页的 quote 块用 `.quote-line` 楷体),内容里扫不到类名,也要算。
|
||||
*
|
||||
* @module font-needs
|
||||
*/
|
||||
import { getCollection, getEntry } from 'astro:content';
|
||||
|
||||
/** 可独立加载的字体 CSS 包(sans / mono 常驻,不在此列) */
|
||||
export type FontKey = 'serif' | 'kai' | 'kuaile' | 'huiwen';
|
||||
|
||||
/**
|
||||
* 类名 → 需要的字体包。
|
||||
*
|
||||
* 只列「会让浏览器拉 webfont」的类;以下三类**不产生**字体请求,故不入表:
|
||||
* - `font-hei-sys`:首选 Noto Sans SC(常驻已加载)
|
||||
* - `font-fangsong`:纯系统回退(无 OFL 自托管包,docs 已注明)
|
||||
* - 普通 `font-sans` / `font-mono`:常驻
|
||||
*
|
||||
* 另外把两个「文学排版类」也计入,因为它们各自改字体:
|
||||
* `quote-line` → 楷体;`drop-cap` → 首字衬线。
|
||||
*/
|
||||
export const FONT_CLASS_NEEDS: Record<string, FontKey[]> = {
|
||||
'font-serif': ['serif'],
|
||||
'font-song': ['serif'], // 首选 "Noto Serif SC Variable"(= serif 包)
|
||||
'font-kai': ['kai'],
|
||||
'font-kai-sys': ['kai'], // 首选 "LXGW WenKai Screen"(= kai 包)
|
||||
'font-huiwen': ['huiwen'],
|
||||
'font-kuaile': ['kuaile'],
|
||||
'font-art-kuaile': ['kuaile'], // 同一个 ZCOOL KuaiLe 族
|
||||
'quote-line': ['kai'],
|
||||
'drop-cap': ['serif'],
|
||||
};
|
||||
|
||||
/** 拼接成一条正则:`\bfont-kai\b` 这类词边界匹配(避免 font-kuaile 误配 font-kuaile-x) */
|
||||
const CLASS_PATTERN = new RegExp(
|
||||
`\\b(?:${Object.keys(FONT_CLASS_NEEDS)
|
||||
.map((c) => c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
||||
.join('|')})\\b`,
|
||||
'g',
|
||||
);
|
||||
|
||||
/**
|
||||
* 从若干段文本里探测需要加载的字体包(纯函数,无 IO)。
|
||||
*
|
||||
* @param texts 页面上可能出现排版类的原始文本(正文 markdown / yaml 序列化 / 瞬间文本等)
|
||||
* @returns 命中的字体包集合;空集表示本页只需常驻的 sans + mono
|
||||
*
|
||||
* @example
|
||||
* fontsFromText(['<span class="font-kai">字</span>']); // Set { 'kai' }
|
||||
* fontsFromText(['纯文本没有类名']); // Set {}
|
||||
*/
|
||||
export function fontsFromText(texts: (string | undefined | null)[]): Set<FontKey> {
|
||||
const out = new Set<FontKey>();
|
||||
for (const text of texts) {
|
||||
if (!text) continue;
|
||||
for (const hit of text.matchAll(CLASS_PATTERN)) {
|
||||
for (const key of FONT_CLASS_NEEDS[hit[0]] ?? []) out.add(key);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 把 yaml 单例的 data 摊平成可扫描文本(序列化即可:类名都是字符串字面量) */
|
||||
const flatten = (data: unknown): string => {
|
||||
try {
|
||||
return JSON.stringify(data);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
/** 路径归一:去 query/hash、去 index.html、保留结尾斜杠,便于前缀匹配 */
|
||||
const normalizePath = (pathname: string): string => {
|
||||
const p = pathname.split(/[?#]/)[0].replace(/\/index\.html$/, '/').replace(/\.html$/, '/');
|
||||
const withSlash = p.endsWith('/') ? p : `${p}/`;
|
||||
return withSlash.startsWith('/') ? withSlash : `/${withSlash}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 解析一个页面需要哪些字体包(构建期,供 BaseLayout 注入)。
|
||||
*
|
||||
* 规则 = **页面静态需求 ∪ 内容扫描结果**:
|
||||
* - 书评轨(`track='reading'`)正文默认字族就是衬线 → serif 必载;
|
||||
* - 书架页标题写死楷体、英文书架写死衬线 → 该两页固定 kai(+en 加 serif);
|
||||
* - 关于页的 quote 块写死 `.quote-line` → 有 quote 块即需 kai;
|
||||
* - 其余全部靠内容扫描(文章正文 / 页面单例 yaml / 瞬间文本)。
|
||||
*
|
||||
* 未识别的页面返回空集:只加载常驻 sans + mono,绝不凭栏目名猜字体。
|
||||
*
|
||||
* @param pathname 当前页面路径(`Astro.url.pathname`)
|
||||
* @param track 正文轨(只影响书评默认衬线这一条静态需求)
|
||||
* @returns 需要加载的字体包集合
|
||||
*/
|
||||
export async function fontsForPage(pathname: string, track: 'reading' | 'tech' = 'tech'): Promise<Set<FontKey>> {
|
||||
const path = normalizePath(pathname);
|
||||
const isEn = path.startsWith('/en/');
|
||||
// 去掉语言前缀与开头斜杠,得到形如 'archives/x/' 的路由键。
|
||||
// 注意 slice(4)('/en/' 四个字符)而不是 slice(3):后者会残留一个前导 '/',
|
||||
// 导致 en 页全部匹配不上(2026-11-03 实测:en/moments、en/archives/* 的按需字体全丢)。
|
||||
const route = isEn ? path.slice(4) : path.slice(1);
|
||||
const needs = new Set<FontKey>();
|
||||
|
||||
// ① 书评轨:正文默认字体就是衬线(`.prose-reading { font-family: var(--font-serif) }`)
|
||||
if (track === 'reading') needs.add('serif');
|
||||
|
||||
// ② 文章页:扫正文 markdown(中文缺译时回退中文正文,与页面的回落一致)
|
||||
const article = route.match(/^archives\/([^/]+)\/$/);
|
||||
if (article) {
|
||||
const slug = article[1];
|
||||
const enEntry = isEn ? await getEntry('postsEn', slug) : undefined;
|
||||
const entry = enEntry ?? (await getEntry('posts', slug));
|
||||
const texts = [entry?.body ?? ''];
|
||||
for (const key of fontsFromText(texts)) needs.add(key);
|
||||
return needs;
|
||||
}
|
||||
|
||||
// ③ 书架页:书脊卡标题写死楷体(BooksPage.astro),英文书架再写死衬线
|
||||
if (route === 'books/') {
|
||||
needs.add('kai');
|
||||
if (isEn) needs.add('serif');
|
||||
return needs;
|
||||
}
|
||||
|
||||
// ④ 首页 / 关于页 / 开源页:扫各自单例(页面文案与块内容都走 renderRich 白名单)
|
||||
if (route === '') {
|
||||
const home = await getEntry('siteHome', 'home');
|
||||
for (const key of fontsFromText([flatten(home?.data)])) needs.add(key);
|
||||
return needs;
|
||||
}
|
||||
if (route === 'about/') {
|
||||
const about = await getEntry('siteAbout', 'about');
|
||||
const text = flatten(about?.data);
|
||||
for (const key of fontsFromText([text])) needs.add(key);
|
||||
// quote 块在组件里写死 `.quote-line`(楷体),内容里没有类名字面量 → 按块型补
|
||||
if (text.includes('"type":"quote"')) needs.add('kai');
|
||||
return needs;
|
||||
}
|
||||
if (route === 'opensource/') {
|
||||
const os = await getEntry('siteOpenSource', 'opensource');
|
||||
for (const key of fontsFromText([flatten(os?.data)])) needs.add(key);
|
||||
return needs;
|
||||
}
|
||||
|
||||
// ⑤ 瞬间页:T2 起瞬间正文走富文本管线,可能带排版类 → 扫全部瞬间文本
|
||||
if (route === 'moments/') {
|
||||
const moments = await getCollection('moments');
|
||||
for (const key of fontsFromText(moments.map((m) => m.data.text))) needs.add(key);
|
||||
return needs;
|
||||
}
|
||||
|
||||
// ⑥ 其余页面(归档列表 / 图库 / 终端 / 栈 / 纸面…):只吃常驻 sans + mono
|
||||
return needs;
|
||||
}
|
||||
+39
-39
@@ -148,7 +148,7 @@ pre code { background: none; border: none; padding: 0; font-size: inherit; }
|
||||
}
|
||||
/* 图注:**显式类驱动**(`<p class="fig-cap">说明</p>`)。此前按 `p > em:only-child` 自动命中,
|
||||
把正文里任何独占成段的斜体都变成块级居中——用户实测的排版 bug(斜体单独一行并居中)由此而来。 */
|
||||
.prose-reading p.fig-cap, .prose-tech p.fig-cap, .rich p.fig-cap, .prose-body p.fig-cap,
|
||||
.prose-reading p.fig-cap, .prose-tech p.fig-cap, .rich p.fig-cap, .prose-body p.fig-cap, .mom-text p.fig-cap,
|
||||
.prose-reading p.fig-cap em, .prose-tech p.fig-cap em {
|
||||
display: block;
|
||||
text-align: center;
|
||||
@@ -167,66 +167,66 @@ pre code { background: none; border: none; padding: 0; font-size: inherit; }
|
||||
}
|
||||
/* ── 受限排版类(文章内通过 <span class> 使用;rehype 白名单只放行这些类名) ──
|
||||
颜色一律走 tokens,亮暗双态都可读;禁任意 hex/内联样式(保持灰阶骨架)。 */
|
||||
.prose-reading .ink-1, .prose-tech .ink-1, .rich .ink-1, .prose-body .ink-1 { color: var(--ink-1); }
|
||||
.prose-reading .ink-2, .prose-tech .ink-2, .rich .ink-2, .prose-body .ink-2 { color: var(--ink-2); }
|
||||
.prose-reading .ink-3, .prose-tech .ink-3, .rich .ink-3, .prose-body .ink-3 { color: var(--ink-3); }
|
||||
.prose-reading .ink-muted, .prose-tech .ink-muted, .rich .ink-muted, .prose-body .ink-muted { color: var(--ink-4); }
|
||||
.prose-reading .ink-accent, .prose-tech .ink-accent, .rich .ink-accent, .prose-body .ink-accent { color: var(--accent); }
|
||||
.prose-reading .ink-accent-soft, .prose-tech .ink-accent-soft, .rich .ink-accent-soft, .prose-body .ink-accent-soft { color: var(--accent-soft-text); }
|
||||
.prose-reading .ink-warm, .prose-tech .ink-warm, .rich .ink-warm, .prose-body .ink-warm { color: var(--ink-warm); }
|
||||
.prose-reading .ink-cool, .prose-tech .ink-cool, .rich .ink-cool, .prose-body .ink-cool { color: var(--ink-cool); }
|
||||
.prose-reading .ink-faint, .prose-tech .ink-faint, .rich .ink-faint, .prose-body .ink-faint { color: var(--ink-faint); }
|
||||
.prose-reading .ink-1, .prose-tech .ink-1, .rich .ink-1, .mom-text .ink-1, .prose-body .ink-1 { color: var(--ink-1); }
|
||||
.prose-reading .ink-2, .prose-tech .ink-2, .rich .ink-2, .mom-text .ink-2, .prose-body .ink-2 { color: var(--ink-2); }
|
||||
.prose-reading .ink-3, .prose-tech .ink-3, .rich .ink-3, .mom-text .ink-3, .prose-body .ink-3 { color: var(--ink-3); }
|
||||
.prose-reading .ink-muted, .prose-tech .ink-muted, .rich .ink-muted, .mom-text .ink-muted, .prose-body .ink-muted { color: var(--ink-4); }
|
||||
.prose-reading .ink-accent, .prose-tech .ink-accent, .rich .ink-accent, .mom-text .ink-accent, .prose-body .ink-accent { color: var(--accent); }
|
||||
.prose-reading .ink-accent-soft, .prose-tech .ink-accent-soft, .rich .ink-accent-soft, .mom-text .ink-accent-soft, .prose-body .ink-accent-soft { color: var(--accent-soft-text); }
|
||||
.prose-reading .ink-warm, .prose-tech .ink-warm, .rich .ink-warm, .mom-text .ink-warm, .prose-body .ink-warm { color: var(--ink-warm); }
|
||||
.prose-reading .ink-cool, .prose-tech .ink-cool, .rich .ink-cool, .mom-text .ink-cool, .prose-body .ink-cool { color: var(--ink-cool); }
|
||||
.prose-reading .ink-faint, .prose-tech .ink-faint, .rich .ink-faint, .mom-text .ink-faint, .prose-body .ink-faint { color: var(--ink-faint); }
|
||||
/* 语义色板:彩色只作用于正文强调(站点外壳保持灰阶)。
|
||||
每色相两档:标准 / soft(阈值内最淡);亮暗各取独立值,均实测 ≥4.5:1。 */
|
||||
.prose-reading .c-blue, .prose-tech .c-blue, .rich .c-blue, .prose-body .c-blue { color: var(--c-blue); }
|
||||
.prose-reading .c-blue-soft, .prose-tech .c-blue-soft, .rich .c-blue-soft, .prose-body .c-blue-soft { color: var(--c-blue-soft); }
|
||||
.prose-reading .c-teal, .prose-tech .c-teal, .rich .c-teal, .prose-body .c-teal { color: var(--c-teal); }
|
||||
.prose-reading .c-teal-soft, .prose-tech .c-teal-soft, .rich .c-teal-soft, .prose-body .c-teal-soft { color: var(--c-teal-soft); }
|
||||
.prose-reading .c-green, .prose-tech .c-green, .rich .c-green, .prose-body .c-green { color: var(--c-green); }
|
||||
.prose-reading .c-green-soft, .prose-tech .c-green-soft, .rich .c-green-soft, .prose-body .c-green-soft { color: var(--c-green-soft); }
|
||||
.prose-reading .c-yellow, .prose-tech .c-yellow, .rich .c-yellow, .prose-body .c-yellow { color: var(--c-yellow); }
|
||||
.prose-reading .c-yellow-soft, .prose-tech .c-yellow-soft, .rich .c-yellow-soft, .prose-body .c-yellow-soft { color: var(--c-yellow-soft); }
|
||||
.prose-reading .c-orange, .prose-tech .c-orange, .rich .c-orange, .prose-body .c-orange { color: var(--c-orange); }
|
||||
.prose-reading .c-orange-soft, .prose-tech .c-orange-soft, .rich .c-orange-soft, .prose-body .c-orange-soft { color: var(--c-orange-soft); }
|
||||
.prose-reading .c-red, .prose-tech .c-red, .rich .c-red, .prose-body .c-red { color: var(--c-red); }
|
||||
.prose-reading .c-red-soft, .prose-tech .c-red-soft, .rich .c-red-soft, .prose-body .c-red-soft { color: var(--c-red-soft); }
|
||||
.prose-reading .c-pink, .prose-tech .c-pink, .rich .c-pink, .prose-body .c-pink { color: var(--c-pink); }
|
||||
.prose-reading .c-pink-soft, .prose-tech .c-pink-soft, .rich .c-pink-soft, .prose-body .c-pink-soft { color: var(--c-pink-soft); }
|
||||
.prose-reading .c-purple, .prose-tech .c-purple, .rich .c-purple, .prose-body .c-purple { color: var(--c-purple); }
|
||||
.prose-reading .c-purple-soft, .prose-tech .c-purple-soft, .rich .c-purple-soft, .prose-body .c-purple-soft { color: var(--c-purple-soft); }
|
||||
.prose-reading .font-serif, .prose-tech .font-serif, .rich .font-serif, .prose-body .font-serif { font-family: var(--font-serif); }
|
||||
.prose-reading .font-kai, .prose-tech .font-kai, .rich .font-kai, .prose-body .font-kai { font-family: var(--font-kai); }
|
||||
.prose-reading .font-sans, .prose-tech .font-sans, .rich .font-sans, .prose-body .font-sans { font-family: var(--font-sans); }
|
||||
.prose-reading .font-mono, .prose-tech .font-mono, .rich .font-mono, .prose-body .font-mono { font-family: var(--font-mono); font-size: 0.92em; }
|
||||
.prose-reading .c-blue, .prose-tech .c-blue, .rich .c-blue, .mom-text .c-blue, .prose-body .c-blue { color: var(--c-blue); }
|
||||
.prose-reading .c-blue-soft, .prose-tech .c-blue-soft, .rich .c-blue-soft, .mom-text .c-blue-soft, .prose-body .c-blue-soft { color: var(--c-blue-soft); }
|
||||
.prose-reading .c-teal, .prose-tech .c-teal, .rich .c-teal, .mom-text .c-teal, .prose-body .c-teal { color: var(--c-teal); }
|
||||
.prose-reading .c-teal-soft, .prose-tech .c-teal-soft, .rich .c-teal-soft, .mom-text .c-teal-soft, .prose-body .c-teal-soft { color: var(--c-teal-soft); }
|
||||
.prose-reading .c-green, .prose-tech .c-green, .rich .c-green, .mom-text .c-green, .prose-body .c-green { color: var(--c-green); }
|
||||
.prose-reading .c-green-soft, .prose-tech .c-green-soft, .rich .c-green-soft, .mom-text .c-green-soft, .prose-body .c-green-soft { color: var(--c-green-soft); }
|
||||
.prose-reading .c-yellow, .prose-tech .c-yellow, .rich .c-yellow, .mom-text .c-yellow, .prose-body .c-yellow { color: var(--c-yellow); }
|
||||
.prose-reading .c-yellow-soft, .prose-tech .c-yellow-soft, .rich .c-yellow-soft, .mom-text .c-yellow-soft, .prose-body .c-yellow-soft { color: var(--c-yellow-soft); }
|
||||
.prose-reading .c-orange, .prose-tech .c-orange, .rich .c-orange, .mom-text .c-orange, .prose-body .c-orange { color: var(--c-orange); }
|
||||
.prose-reading .c-orange-soft, .prose-tech .c-orange-soft, .rich .c-orange-soft, .mom-text .c-orange-soft, .prose-body .c-orange-soft { color: var(--c-orange-soft); }
|
||||
.prose-reading .c-red, .prose-tech .c-red, .rich .c-red, .mom-text .c-red, .prose-body .c-red { color: var(--c-red); }
|
||||
.prose-reading .c-red-soft, .prose-tech .c-red-soft, .rich .c-red-soft, .mom-text .c-red-soft, .prose-body .c-red-soft { color: var(--c-red-soft); }
|
||||
.prose-reading .c-pink, .prose-tech .c-pink, .rich .c-pink, .mom-text .c-pink, .prose-body .c-pink { color: var(--c-pink); }
|
||||
.prose-reading .c-pink-soft, .prose-tech .c-pink-soft, .rich .c-pink-soft, .mom-text .c-pink-soft, .prose-body .c-pink-soft { color: var(--c-pink-soft); }
|
||||
.prose-reading .c-purple, .prose-tech .c-purple, .rich .c-purple, .mom-text .c-purple, .prose-body .c-purple { color: var(--c-purple); }
|
||||
.prose-reading .c-purple-soft, .prose-tech .c-purple-soft, .rich .c-purple-soft, .mom-text .c-purple-soft, .prose-body .c-purple-soft { color: var(--c-purple-soft); }
|
||||
.prose-reading .font-serif, .prose-tech .font-serif, .rich .font-serif, .mom-text .font-serif, .prose-body .font-serif { font-family: var(--font-serif); }
|
||||
.prose-reading .font-kai, .prose-tech .font-kai, .rich .font-kai, .mom-text .font-kai, .prose-body .font-kai { font-family: var(--font-kai); }
|
||||
.prose-reading .font-sans, .prose-tech .font-sans, .rich .font-sans, .mom-text .font-sans, .prose-body .font-sans { font-family: var(--font-sans); }
|
||||
.prose-reading .font-mono, .prose-tech .font-mono, .rich .font-mono, .mom-text .font-mono, .prose-body .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, .rich .font-song, .prose-body .font-song {
|
||||
.prose-reading .font-song, .prose-tech .font-song, .rich .font-song, .mom-text .font-song, .prose-body .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, .rich .font-kai-sys, .prose-body .font-kai-sys {
|
||||
.prose-reading .font-kai-sys, .prose-tech .font-kai-sys, .rich .font-kai-sys, .mom-text .font-kai-sys, .prose-body .font-kai-sys {
|
||||
font-family: "LXGW WenKai Screen", "Kaiti SC", STKaiti, KaiTi, serif;
|
||||
}
|
||||
.prose-reading .font-hei-sys, .prose-tech .font-hei-sys, .rich .font-hei-sys, .prose-body .font-hei-sys {
|
||||
.prose-reading .font-hei-sys, .prose-tech .font-hei-sys, .rich .font-hei-sys, .mom-text .font-hei-sys, .prose-body .font-hei-sys {
|
||||
font-family: "Noto Sans SC Variable", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
.prose-reading .font-huiwen, .prose-tech .font-huiwen, .rich .font-huiwen, .prose-body .font-huiwen {
|
||||
.prose-reading .font-huiwen, .prose-tech .font-huiwen, .rich .font-huiwen, .mom-text .font-huiwen, .prose-body .font-huiwen {
|
||||
font-family: 'WF-Hwmc', var(--font-serif);
|
||||
}
|
||||
.prose-reading .font-kuaile, .prose-tech .font-kuaile, .rich .font-kuaile, .prose-body .font-kuaile {
|
||||
.prose-reading .font-kuaile, .prose-tech .font-kuaile, .rich .font-kuaile, .mom-text .font-kuaile, .prose-body .font-kuaile {
|
||||
font-family: 'ZCOOL KuaiLe', var(--font-serif);
|
||||
}
|
||||
/* 艺术字体专用类(层 3):仅用于标题/短句。艺术字字形大、CJK 切片体积高
|
||||
(实测 163KB/用到页),长段使用会显著抬高单页字体成本——docs 有体积警告。 */
|
||||
.prose-reading .font-art-kuaile, .prose-tech .font-art-kuaile, .rich .font-art-kuaile, .prose-body .font-art-kuaile {
|
||||
.prose-reading .font-art-kuaile, .prose-tech .font-art-kuaile, .rich .font-art-kuaile, .mom-text .font-art-kuaile, .prose-body .font-art-kuaile {
|
||||
font-family: 'ZCOOL KuaiLe', var(--font-serif);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.prose-reading .font-fangsong, .prose-tech .font-fangsong, .rich .font-fangsong, .prose-body .font-fangsong {
|
||||
.prose-reading .font-fangsong, .prose-tech .font-fangsong, .rich .font-fangsong, .mom-text .font-fangsong, .prose-body .font-fangsong {
|
||||
font-family: FangSong, STFangsong, "FangSong_GB2312", serif;
|
||||
}
|
||||
/* 文学常用:首字下沉(书评开篇)与金句块(引文式的独立强调段) */
|
||||
.prose-reading p.drop-cap::first-letter, .prose-tech p.drop-cap::first-letter, .rich p.drop-cap::first-letter, .prose-body p.drop-cap::first-letter {
|
||||
.prose-reading p.drop-cap::first-letter, .prose-tech p.drop-cap::first-letter, .rich p.drop-cap::first-letter, .mom-text p.drop-cap::first-letter, .prose-body p.drop-cap::first-letter {
|
||||
float: left;
|
||||
font-family: var(--font-serif);
|
||||
font-size: 3.1em;
|
||||
@@ -235,7 +235,7 @@ pre code { background: none; border: none; padding: 0; font-size: inherit; }
|
||||
margin-top: 0.05em;
|
||||
color: var(--paper-ink);
|
||||
}
|
||||
.prose-reading .quote-line, .prose-tech .quote-line, .rich .quote-line, .prose-body .quote-line {
|
||||
.prose-reading .quote-line, .prose-tech .quote-line, .rich .quote-line, .mom-text .quote-line, .prose-body .quote-line {
|
||||
display: block;
|
||||
font-family: var(--font-kai);
|
||||
color: var(--paper-ink);
|
||||
|
||||
Reference in new issue
Block a user