Author SHA1 Message Date
wait4xandGLM-5.3 6293fe25d5 chore(spec): 5i 勾选 T1
Spec: 2026-11-02_blog-5i-fonts-polish

Co-authored-by: GLM-5.3
2026-11-03 11:28:51 +08:00
wait4xandGLM-5.3 8d82aab213 fix(fonts): 字体切换真加载——按内容诉求注入,含瞬间排版类作用域缺口(5i T1)
用户实测「字族渲染没有成功,尤其是字体的切换」。根因不是字体没做,是加载门禁错:
BaseLayout 把 serif/kai/kuaile/hwmc 四份字体 CSS 全关在 `track === 'reading'` 分支里,
技术轨文章与全站页面(默认 track='tech')从不加载它们——于是 `.font-kai` 计算值虽是
`"LXGW WenKai Screen"`,但该 @font-face 根本不在文档字体集里,浏览器静默回退系统宋体。

- 新增 `src/lib/font-needs.ts`:`fontsFromText()` 纯函数 + `fontsForPage(pathname, track)`
  按路径取该页内容(文章正文 / home.yaml / about.yaml / opensource.yaml / 全部瞬间文本)
  与页面静态诉求(reading→serif;书架标题写死楷体→kai 等)算出所需字族集合
- BaseLayout 改为逐项按需注入(非阻塞 preload+onload 写法保留),noscript 同步;
  汇文明朝仍走 public 手工切片路径,network/font-display 未动
- **顺手补掉 5h 遗留的用户可见缺口**:瞬间富文本容器 class 是 `mom-text`,而全局排版类
  规则只覆盖 `.prose-*/.rich/.prose-body` → 瞬间里写 `font-kai`/`c-teal` 一直只有 class
  没有样式(新瞬间正是这么写的,用户看到的就是"没变色没换字")。global.css 给 38 处
  选择器组补 `.mom-text`
- 另修自写 bug:en 路由切分 `slice(3)` 残留前导 `/` 导致 en 页按需字体全部落空,改 `slice(4)`

验证: 构建 exit 0(content-lint 51 页 0/0/0);E2E 46/46;Playwright 实测
      技术轨文章 document.fonts 命中 LXGW WenKai Screen / WF-Hwmc / ZCOOL KuaiLe /
      Noto Serif SC,`.font-kai` 计算族 = LXGW 且 check=true;
      首页仍为基线(零增量);瞬间页因有 font-kai 内容而命中 LXGW;
      书评页只拉 serif(41 片,0 kai 0 汇文)

Spec: 2026-11-02_blog-5i-fonts-polish (T1)

Co-authored-by: GLM-5.3
2026-11-03 11:28:51 +08:00
wait4xandGLM-5.3 029633f00f chore(spec): 5i 勾选 T6/T8
Spec: 2026-11-02_blog-5i-fonts-polish

Co-authored-by: GLM-5.3
2026-11-03 11:24:29 +08:00
wait4xandGLM-5.3 0ff4ede91f feat(opensource): 项目墙恢复居中,README 动效以自托管方式回归(5i T6/T8)
用户两项要求:开源页 Projects「依旧采取居中即可,不用左对齐」;README 迁移后
「很多图片元素还有一些 svg 基本上都摒弃了,可以在 readme 的基础上进行再创新,
保留那些动效的图,以及创新更多一些显示方式」。

T6 项目墙居中:`.repo` 从 align-items:stretch/text-align:left 改回 center/center,
`.repo-desc` 加 max-width:34em 防居中后长描述散架;语言点保持灰阶(与对齐无关)。

T8 动效回归(零运行时外链):
- 新增 geek/TypingLines.astro:自绘打字机(SSR 先出首行防闪白,JS 逐字打出/删除/
  轮播,光标 CSS 闪烁,prefers-reduced-motion 与单条文案时保持静态,min-height
  防抖,astro:page-load 重挂兼容软导航)——替代 readme-typing-svg 外链,且跟随主题
- 新增 scripts/build-perf/fetch-readme-assets.mjs:抓 stats/top-langs/streak/snake
  到 public/github-assets/(10s 超时 + UA、透明底、亮暗两套站点色),产物入仓保证
  离线可构建;单资产失败保留旧文件、无旧文件则该卡不渲染
- GithubProfile 新增「数据板」:灰阶 hairline 卡 + mono 小标题 + 2 列网格(移动单列),
  与原生统计四格、语言占比、热力图组合成展示区;贡献蛇在暗色加 grayscale+invert
  滤镜(SVG 内部是自带绿阶,直接贴暗底刺眼)
- build.sh 只做存在性守卫 + 缺资产 WARN,**不挂抓取**(发布链不被外网抖动拖累)
- 后台开源页新增 typing 字段(list,zh/en 双语,≤6 条,summary 取 zh)+ schema 声明

验证: 构建 51 页 exit 0;config-check 211 字段/66 hint 全过;E2E 46/46;
      产物断言第三方外链零命中、本地引用 streak-light/streak-dark/snake;
      Playwright 实测打字机文本 900ms 内变化(动效在跑)、streak naturalWidth=495、
      snake=880、暗色自动切 streak-dark、项目墙 center/center、1280 与 390 均零横向溢出

备注: github-readme-stats 今日全局 503,stats/top-langs 两卡按契约跳过(原生四格与
      语言条已覆盖同一信息);服务恢复后跑一次 fetch-readme-assets.mjs 即自动补上。

Spec: 2026-11-02_blog-5i-fonts-polish (T6/T8)

Co-authored-by: GLM-5.3
2026-11-03 11:24:28 +08:00
wait4xandGLM-5.3 23303bd39a chore(spec): 5i 勾选 T2-T5
Spec: 2026-11-02_blog-5i-fonts-polish

Co-authored-by: GLM-5.3
2026-11-03 11:18:36 +08:00
wait4xandGLM-5.3 31eb44caee fix(comments): 评论失败提示误报与瞬间评论区配色脱同步(5i T2/T3)
用户实测:「评论区总是显示加载失败,但是实际上是正常显示的,另外瞬间的评论区
的颜色和主题色不同步」。

T2 误报根因:软导航/二次挂载时 `#artalk-lib` 脚本标签已在 DOM,但 `window.Artalk`
尚未就绪,旧代码直接 `initArtalk()` → `!lib` 分支 `showRetry(true)` → 失败提示
永久显示;随后库加载完成、评论正常渲染,提示却留在页面上。
- 脚本标签在但库未就绪 → 等它的 load 事件再 init,不再立即判失败
- 失败提示只留真失败:script.onerror,或超时且确实未挂载
- `mounting` 在所有成功/失败路径复位(旧代码失败后永真,把 IO/锚点/滚底/重试全堵死)
- 成功路径清提示;三条触发路径与重试钮、astro:page-load 复核全部保留

T3 配色脱同步:18 个 `--at-color-*` 映射原先只写在 `#artalk-container` 作用域,
瞬间容器是 `mom-atk-<slug>` 不在其内 → 回落到 Artalk 默认白底。
- 抽成共享片段 geek/ArtalkThemeStyles.astro(两处引用,杜绝双份漂移)
- **额外挖出的第 4 个坑**:Artalk init 成功会给容器加 `.artalk` 类,其 CDN 样式表
  里 `.artalk{--at-color-bg:#fff}` 与裸属性选择器 `[data-artalk-panel]` 同特异性
  且后加载 → 平级后来居上。瞬间容器选择器加 `body` 前缀 (0,1,1) 压住;
  文章页是 `#artalk-container`(1,0,0) 本来就压得住,所以只有瞬间翻白

验证: 构建 exit 0;E2E 46/46;我独立复测——
      ① 文章页挂载成功且 #artalk-retry 保持 hidden
      ② 连发 3 次 astro:page-load 仍不误报、仍挂载
      ③ 瞬间评论区明暗两态 --at-color-bg 实测 rgb(255,255,255)/rgb(25,29,35)
         分别等于 --bg,容器带 .artalk 类(说明是 init 后真态)

Spec: 2026-11-02_blog-5i-fonts-polish (T2/T3)

Co-authored-by: GLM-5.3
2026-11-03 11:18:35 +08:00
wait4xandGLM-5.3 5c015c6577 docs(writing): 取消彩色强调次数限制,补灯箱 caption 排版说明
用户原话:「色彩强调不要设置限制,取消一页不超过三处的限制」。原「一页 ≤3 处」
是文档纪律(代码层从未硬性拦截),故改动集中在文档、模板文章与后台提示:

- 写作指南 / 管理台:改为「彩色不限次数」,同时明确仍守的另一条红线是
  **外壳灰阶**(导航/页脚/按钮/背景不染色)——外壳是气质,正文是表达
- 写作模板文章中英双语同步:章节改名「想用多少用多少」,删掉"三处之后不再上色"
  的示范约束,表格里「一页 ≤3 处」改为「不限次数(外壳仍灰阶)」
- 顺带补灯箱 caption 文档:照片说明支持 markdown + 排版类,深底自动取暗档;
  以及灯箱四种关闭路径(右上 ×、Esc、点背景、1× 时点图)

验证: 构建 exit 0;全仓 grep 「≤3 处/页」只剩「已取消」的历史说明

Spec: 2026-11-02_blog-5i-fonts-polish (T5)

Co-authored-by: GLM-5.3
2026-11-03 11:18:14 +08:00
wait4xandGLM-5.3 138d3d976b fix(gallery): 修灯箱右上关闭点不动,caption 支持字体与颜色
用户实测:「图库中的图片点击查看之后,点击右上角的关闭无法关闭,其次就是右侧
的文字是否支持字体的更换和颜色的更换」。

关闭失灵的根因(生产实测取证):桌面端信息栏 `.lb-l2` 是 top:0→bottom:0 的
右侧全高侧栏(宽 200px),且 DOM 顺序在关闭钮之后;`document.elementFromPoint`
在按钮中心命中 `.lb-meta.lb-l2` —— 点击被侧栏吃掉,`lb.close()` 永不触发
(实测 Playwright 点击 #lb-close 30s 超时、dialog 保持 open)。给 `#lb-close`
提层级 z-index:12 后「点击 → open=false」通过。

caption 富文本:
- 照片 caption 走全站同一条管线(markdown → 内容净化三层白名单 → class 白名单),
  构建期预渲染成 HTML 挂在卡片的 data-caption-html 上,换图时写 innerHTML
- 灯箱容器加 `.rich`,global.css 的 .rich .c-blue / .rich .font-kai 才生效(此前
  只有触发类名的选择器、容器无 .rich 前缀,故颜色字体都不生效)
- 深底适配:灯箱内的彩色取柔和对暗底的档位;段间距收紧、首尾不撑高
- 容器由 <p> 改 <div>:caption 可含多段落,<p> 套 <p> 会被浏览器拆散

验证: 构建 exit 0;生产同款探针实测关闭生效(此前超时);
      注入测试 caption 后产物含 class="c-blue"(保留)、font-kai(保留)、
      **加粗**→<strong>、<script> 被净化丢弃(验收数据已还原)

Spec: 2026-11-02_blog-5i-fonts-polish (T4)

Co-authored-by: GLM-5.3
2026-11-03 11:18:13 +08:00
wait4xandGLM-5.3 d30b5decbb docs(spec): Phase 5i 立项——字体加载根治与交互缺陷修复
七项用户实测问题的取证结论写入 spec:字体切换失效根因是 BaseLayout 把四份
字体 CSS 关在 track==='reading' 里(技术轨与全站页面从不加载);Artalk 失败提示
误报源于软导航后脚本标签存在但库未就绪即判失败;灯箱右上关闭被 .lb-meta 命中
遮挡(elementFromPoint 实测)。任务拆 9 项,字族款式与书架方向待用户拍板。

Spec: 2026-11-02_blog-5i-fonts-polish

Co-authored-by: GLM-5.3
2026-11-03 11:03:32 +08:00
wait4xandGLM-5.3 36643f5cf0 chore(spec): 合入 CMS 侧内容编辑并解决 opensource.yaml 冲突
合远端 main(后台 CMS 期间产出 3 个「Update 页面文案」提交)时的两处冲突:

- blurb_after:取 CMS 版本(用户的编辑为准);折号间隙已由模板
  `{a}{' '}{blurbAfter.trim()}` 保证,不再依赖数据前导空格
- opensource.yaml 尾部:CMS 侧把 perSource 改成 github、intro_from 切到
  github-readme。**perSource 尊重用户选择**;intro_from 改回 "off" 并补注释——
  本阶段的目的正是用原生档案卡替换 README 原样渲染(README 带 7 类第三方外链图,
  且 `<div align="center">` 在本站失效,正是用户报告的「没居中、图片不显示」根因)。
  想回退在后台把该字段切回 github-readme 即可。

顺带对齐:关于页「找到我」的邮箱原来是 admin@wait4x.online(我拟的),
改为与站点 socials 一致的 X@alumni.nudt.edu.cn,避免出现一个不存在的地址。

验证: 构建 51 页 exit 0;content-lint 51 页 0/0/0;config-check 全过;
      E2E 46/46 PASS

Spec: 2026-11-02_blog-5h-content-social

Co-authored-by: GLM-5.3
2026-11-02 21:21:41 +08:00
wait4x 52188d5b12 Merge branch 'spec/2026-11-02_blog-5h-content-social'
Phase 5h 交付:文章置顶(sortPosts 统一口径 + 灰阶 pin 徽标 + 后台开关)、瞬间富文本(复用净化管线,字体与颜色可换)、瞬间逐条评论(展开才挂 Artalk,评论键用虚拟路径避开 # 截断)、浏览/评论计数徽标(眼睛+气泡本地 SVG,修同载双计与同页数字不一致)、开源页去 README 外链依赖(原生 GitHub 档案卡 + 语言占比灰阶 + 修 GitHub API 无 token 被打满)、关于页承接个人叙述、写作模板示例文章(中英双语,本身即置顶活样本),以及视觉终审两轮修复(日期折行、emoji 豆腐块、移动导航折行、灰阶纪律清剿)。

Spec: 2026-11-02_blog-5h-content-social
2026-11-02 21:16:43 +08:00
wait4xandGLM-5.3 fef2de1d37 chore(spec): 归档 blog-5h——七任务收敛
Spec: 2026-11-02_blog-5h-content-social

Co-authored-by: GLM-5.3
2026-11-02 21:16:42 +08:00
wait4xandGLM-5.3 bdaf02f9ca fix(opensource): 项目墙卡片左对齐与 bio 间隙兜底(视觉终审二轮)
Finding 1(P1)项目墙卡片内容被居中:
- 根因是全局 `.tap { display:inline-flex; align-items:center; justify-content:center }`
  沿列向 flex 的交叉轴生效,`.repo`(scoped)只赢下 display 没接管 align-items,
  于是标题与「● 语言 ★ n」行落在卡中部、卡片间左缘参差
- `.repo` 显式 align-items: stretch + text-align: left;实测四张卡的
  .repo-name 与 .repo-meta 左缘全部 313px(= 卡左 296 + 内距 16 + 边框 1)

Finding 2(P2)bio 破折号间隙修在数据不在模板:
- 原靠 opensource.yaml 串前导空格产生间隙,后台清空走兜底串时缺陷回归
- 模板改为 `{a}{' '}{blurbAfter.trim()}`:间隙由模板保证,数据怎么改都不会贴链接

验证: 构建 51 页 exit 0;content-lint 0/0/0;spec_verify 七项 exit=0(新指纹
      62086ce4cccd);check 收敛;DOM 实测对齐与间隙均符合预期

Spec: 2026-11-02_blog-5h-content-social (视觉终审二轮)

Co-authored-by: GLM-5.3
2026-11-02 21:14:37 +08:00
wait4xandGLM-5.3 c7d1e7b975 fix(moments): 评论流键从 #<slug> 改为虚拟路径,避免键被截断
实测发现 `#` 在查询串里是 fragment 分隔符:裸传 `/moments/#2026-11-02` 会被服务端
截断成页面级键 `/moments/`(生产 Artalk 实测,响应里 key 变成 "/moments/"),
后果是所有瞬间共用同一个评论流、pv 也串在一起。

- page_key 改为虚拟路径 `/moments/<slug>/`(生产实测键原样保留,零转义风险;
  将来真做单条瞬间页时键天然对齐)
- spec 与 runs.jsonl 口径同步(七项 verify 在新指纹下重跑全绿)

验证: 构建 51 页 exit 0;产物含 /moments/2026-11-02/ 键;content-lint 0/0/0;
      spec_verify 七项 exit=0;check 收敛

Spec: 2026-11-02_blog-5h-content-social (T3 修正)

Co-authored-by: GLM-5.3
2026-11-02 21:05:51 +08:00
wait4xandGLM-5.3 550f6d15b4 chore(spec): 5h 任务全勾与运行证据入库
- tasks.md 七任务全勾;spec.md 功能框与成功标准全勾
- §5.1 全项目检查补升级条件(触及 BaseLayout/全局样式或 Artalk 接管层才升级)
- runs.jsonl 记录七项 verify 全部 exit=0(含本包新增的 E2E 46 项口径)
- content/.link-cache.json:链接卡片缓存随新文章构建刷新(入仓保证构建确定性)

Spec: 2026-11-02_blog-5h-content-social

Co-authored-by: GLM-5.3
2026-11-02 21:01:47 +08:00
wait4xandGLM-5.3 d4fe273812 fix(visual): 修视觉终审的折行/豆腐块与灰阶纪律违规
视觉终审(glm-5.3-flash:max)判 BLOCK,逐条修完并复测。

P1 阻断:
- 瞬间页日期折成两行(`2026-` / `04-29`):日期列复用 var(--space-5)=40px,
  而 10 字符 mono 日期需 ≈75px 且缺 nowrap。抽 --tl-rail(5.4rem) 统一驱动
  轨道宽度与两处偏移,加 nowrap;实测日期单行(高度 20px)且圆点与卡片对齐

P2 交付可见破形:
- 关于页「关于我」行首 emoji 在无彩色 emoji 字体的环境渲染成黑方块:
  正文行首 emoji 全部去掉(改纯文字段落),并给 --font-serif/--font-sans
  链尾补 Apple/Segoe/Noto emoji 回退——用户内容里的 emoji(瞬间、书评)
  同样不再落豆腐块

移动端导航两字折行(开/源、文/章):
- ≤450px 的 overflow-x 内滚从未生效:flex 子项默认可压缩,中文词被压到就地
  折行。给 nav ul > li 加 flex:none + .nav-link nowrap;实测 360/390/430
  三档全部单行(Range.getClientRects 计数逐项验证)

灰阶纪律:
- 项目墙原来按 GitHub linguist 给每个仓染语言色点,开源页铺 15 个仓 = 15 种
  彩色,破「外壳灰阶、彩色只作正文强调」红线 → 统一改灰阶点,语言靠文字承载
- 语言占比并列(Python/TypeScript 各 36%)只有第一名着色,读起来像谁领先 →
  判定改为「百分比等于最大值者一并着色」

版式与文案细节:
- prose 标题到正文比 tracks 多一档(16+16=32 vs 16)→ .sec-title + .prose-body
  margin-top: 0
- 开源页 bio 破折号贴链接(`blog—— Astro`)→ 破折号前补空格
- 页脚 built from<hash> 缺空格(Astro 吃掉换行处空白)→ 显式 {' '}
- 新增一条真实瞬间(含青色楷体富文本)替代只有占位串的样本,让评论区与标签行
  的间距可在真实数据下验收

验证: 构建 51 页 exit 0;content-lint 51 页 0/0/0;E2E **46/46 PASS**;
      390px 四页 scrollWidth == innerWidth(无横向滚动);
      导航 360/390/430 全部单行;日期单行;config-check 全过

Spec: 2026-11-02_blog-5h-content-social (视觉修复)

Co-authored-by: GLM-5.3
2026-11-02 20:56:00 +08:00
wait4xandGLM-5.3 0b0307b12f docs(admin): 补档案卡与计数徽标说明(5h T7 文档收尾)
- 管理台「开源页」节:说明开篇已改原生 GitHub 档案卡(零第三方图片、
  语言占比灰阶、取数失败只隐藏该项),并把 intro_from: github-readme
  标为「仍可用但不推荐」——点名 7 类外链图与 align 居中失效正是用户
  报告的"没居中/图片不显示"根因
- 新增「浏览与评论计数」节:眼睛/气泡含义、一页只计一次(其余为只读镜像,
  不会出现两个不同数字)、开评论不算浏览、接口挂掉显示「–」不给假零、
  换页重计属定义而非双计

验证: pnpm astro build exit 0

Spec: 2026-11-02_blog-5h-content-social (T7 收尾)

Co-authored-by: GLM-5.3
2026-11-02 20:37:01 +08:00
wait4xandGLM-5.3 3e377adc7f feat(opensource): 开源页改原生档案卡,个人叙述迁往关于页(5h T5/T6)
用户原话:开源页开篇是从 GitHub README 直接移植的,「部分内容没有居中,有些图片
也没有正常显示,可以自行进行美化,无需完全一致……有些可以直接移植到关于页面」。

病根:README 是 GitHub 专用 markdown——`<div align="center">` 靠 HTML 属性居中
(本站按普通块渲染,故"没居中"),且依赖 7 类外链图(readme-typing-svg /
shields.io / github-readme-stats / streak-stats / activity-graph / snake /
komarev),慢网或被墙时全空白(故"图片不显示")。

T5 开源页重构:
- 新增 geek/GithubProfile.astro:头像 / 名字@handle / 定位叙述 / 社交图标 /
  统计四格(followers、公开仓、总星标)/ 语言占比灰阶条(仅第一名 accent,
  其余 tokens 灰阶)——**零外链图、零 iframe、零第三方徽章**
- 语言聚合复用 loadProjects 的结果;followers 与公开仓数走 GitHub REST
- 任一取数失败只隐藏该项(catch + warn),绝不炸构建
- opensource.astro / en:档案卡承接原 README 位置 → 热力图 → 项目墙;
  README 渲染降级为兼容通道(intro_from=off 时不执行),yaml 注释写明
  7 类外链图与 align 失效原因并标「不推荐」
- **修既有隐患**:GitHub API 请求原先不带 token,匿名 60 次/小时在密集构建下
  被打满 → 项目墙退化成兜底 JSON(今天一度只剩 1 条)。现对 api.github.com
  自动附 Bearer(token 读宿主 0600 文件,不入仓),墙恢复 16 条、总星标 0→66、
  语言占比变真值

T6 关于页承接:
- about.yaml blocks 扩为 prose / tracks(兴趣版图)/ prose(关于我)/
  linklist(找到我:本站·邮件·GitHub·自建 gitea·RSS)/ prose(本站如何构建),
  双语齐备;AboutPage 零改动(blocks 早已驱动)
- 原则落地:关于页讲人,开源页讲开源,两边不再重复

验证: 构建 51 页 exit 0;content-lint 51 页 0/0/0;config-check 全过;
      E2E **46/46 PASS**(37 原有 + 本包新增 9 项:置顶顺序与徽标、文章页计数、
      模板彩色与折叠、瞬间评论区与只读计数、关于页块驱动);
      dist/opensource 七类第三方外链零命中、第三方 img 数 0;数据实值:
      语言占比 Python/TypeScript 各 36%、Shell/Rust/JS 各 9%

Spec: 2026-11-02_blog-5h-content-social (T5/T6)

Co-authored-by: GLM-5.3
2026-11-02 20:35:56 +08:00
wait4xandGLM-5.3 63c9c8ad4a feat(counts): 浏览/评论徽标接入文章与瞬间页,修同载双计(5h T4)
用户要「加一个访客数量、评论数量,采用类似于开源页面从 GitHub 移植过来的那个
眼睛图标」——落地为 ViewMeta 本地 SVG 徽标,无第三方图片依赖。

接入(op-artalk 车道):
- 文章详情页 reading/tech 两分支各挂一枚 mode="page"(两分支互斥,同页仅一处计数)
- ArtalkComments 顶部旧的纯文本 pv 行与自成一套的 bumpPv 脚本整段删除,改为
  readonly chip:评论区只读展示,不再各计一次
- 瞬间页顶部整页 pv 行同样改 chip;每条瞬间卡片底部为 readonly 徽标

组件修复(本人,op-artalk 实测揪出):
- **同载双计**:脚本既注册 astro:page-load 又立即调用 run(),首屏两者都会触发
  → 每元素 POST 两次、浏览量翻倍(实测 15→17→19 每载 +2)。加每元素一次性
  守卫后 1/2/3 每载 +1;软导航新元素仍照常计数
- **同页两枚数字差 1**:readonly 实例独立 GET 到的 pv 可能比计过数的少 1;
  改为同键存在 page 实例时 readonly 让位,由 page 实例回填同一份结果
- page 实例请求失败时释放兄弟自读,避免评论区永远停在「–」

验证: 构建 exit 0;E2E 37/37;Playwright 拦截对账——
      每载 POST=1、同页两枚徽标文本一致(101/101)、刷新后累计 2、
      接口全挂时两枚均显示占位「–」不报错

Spec: 2026-11-02_blog-5h-content-social (T4)

Co-authored-by: GLM-5.3
2026-11-02 20:29:44 +08:00
wait4xandGLM-5.3 8e74bead62 feat(moments): 瞬间富文本与逐条评论(5h T2/T3)
瞬间文字从纯文本直出升级到与文章同级表达力,每条动态获得独立评论流。

T2 富文本:
- text 在 frontmatter 预渲染成 momentId → html 的 Map(renderRich 是 async,
  模板内不能 await),模板只此一处 set:html 且喂的是净化产物
- 走与全站文案同一条管线:markdown → 内容净化(标签/属性/协议三层白名单)
  → class 白名单 35 项,故 <span class="c-blue font-kai"> 生效、
  <script> 整棵子树被丢弃(实测)
- 纯文本老数据零回归:无 markdown 的 text 得到单个 <p>,与旧直出视觉等价
- 卡片内富文本首尾 margin 归零、段间半档、列表缩进 1.35em,15px/ink-2 基调不变

T3 逐条评论:
- 新增 MomentComments.astro:<details> 折叠,**展开才拉 Artalk 并 init**
  (未展开连 Artalk.js 都不下载,时间线一屏十几条不白付成本)
- page_key = /moments/#<slug>,中英双语共用同一评论流(与文章页同策略)
- pvAdd: false 且复用 ViewMeta readonly 计数:读者开评论不会虚增浏览数
- 5s 兜底失败提示 + 重试;软导航回页对已展开的框补挂
- Playwright 实测同页两个容器各自 init 成功(各渲染 31 个 atk-* 节点,无异常),
  故无需降级;若日后内存吃紧可对上一实例调 destroy() 一行切换

验证: 构建 exit 0;E2E 37/37(快照口径);临时瞬间实测 markdown 粗体、
      彩色+楷体类保留、行内码、<script> 净化丢弃、details 与 /moments/# 键、
      ViewMeta 只读挂载全部命中(验收数据已还原)

Spec: 2026-11-02_blog-5h-content-social (T2/T3)

Co-authored-by: GLM-5.3
2026-11-02 20:20:31 +08:00
wait4xandGLM-5.3 5bf0c327bf feat(content): 写作模板示例文章与文档(5h T7 主体)
用户要「在文章中加一篇关于文章书写的模板示例」,故交付一篇**可复制**的模板
文章而非干条文:正文每节都在真跑一种能力,读者看到的效果就是源码的效果。

- content/posts/writing-template.md(zh)与 posts-en 同名 en 版(locked:true
  手写直译,不交给 TMT 重译):标题层级纪律、彩色强调(一页三处限额)、
  字族(楷/汇文明朝各一处 + 六族清单)、引用/列表/代码窗条/表格/图片放大/
  链接卡片/details 折叠/分隔线
- pinned: true —— 这篇文章同时是置顶功能的活样本(归档首条实测)
- 后台 moments.text 由 text widget 改 markdown widget + 排版类提示:瞬间从此
  与文章同级表达力(字体/颜色/多段),提示里写明外壳灰阶纪律
- docs/写作指南.md 新增三节:写作模板(cp 起步 + 三条纪律)、置顶文章
  (语义边界:只改顺序不改 pubDate/URL/RSS)、瞬间怎么写(富文本 + 逐条评论
  + 只读计数 + text/image 至少一项)

验证: pnpm astro build 51 页 exit 0;content-lint 51 页 0 断链 0 缺图;
      产物断言 c-blue/font-kai/font-huiwen/details/table 全部生效;
      dist/archives 首条 = writing-template(置顶排序生效)

Spec: 2026-11-02_blog-5h-content-social (T7)

Co-authored-by: GLM-5.3
2026-11-02 20:08:21 +08:00
wait4xandGLM-5.3 6538311387 feat(content): 置顶契约与计数徽标组件(5h T1 契约部分)
置顶(T1):
- src/lib/post-order.ts 新增 sortPosts:置顶优先 → pubDate 倒序 → id 兜底,
  把首页双轨/归档/书架四处排序口径收敛到一处
- postsSchema 增 pinned;PostList 三处渲染路径加灰阶 pin 小徽标;后台置顶开关
- 语义边界:只影响列表顺序,不改 pubDate/URL/RSS 时间语义

计数徽标(T4 契约组件):
- src/components/geek/ViewMeta.astro:眼睛 = 浏览数、气泡 = 评论数,
  本地 SVG + tokens 着色,不引第三方图片
- 数据只用 Artalk 匿名端点:GET comments?limit=1 一次拿到 count 与 page.pv;
  page 模式先 POST pages/pv 计一次浏览,readonly 模式零写入
- SSR 占位「–」:无 JS/接口失败不显示 0 误导读者

验证: pnpm astro build 48 页 exit 0;config-check 208 字段/65 hint 全过;
      sortPosts 四调用点替换后构建通过

Spec: 2026-11-02_blog-5h-content-social (T1 契约)

Co-authored-by: GLM-5.3
2026-11-02 20:02:08 +08:00
wait4x cdd9c61641 Update 页面文案 “home” 2026-11-02 11:47:24 +00:00
wait4x e1fc8bd64c Update 页面文案 “opensource” 2026-11-02 11:45:26 +00:00
wait4x cc12e016ab Update 页面文案 “opensource” 2026-11-02 11:42:51 +00:00
wait4xandGLM-5.3 ed473e996a fix(admin): 主题设置集合结构修复 + 集合结构构建校验
用户实测后台报错:「主题设置 集合: The file option content/site/theme.yaml is
invalid. It must be the path to a JSON file」——根因是 site_theme 把 `file:`
直接写在集合层级,而 Decap/Sveltia 的文件集合必须是
`files: [{ name, label, file, format, fields }]` 结构;缺 files 包装时 Sveltia
按单文件变体解析并期待 JSON 路径,故报该错。

- site_theme 改为标准 files 集合(format: yaml 显式声明)
- site_pages 三单例补显式 format: yaml(扩展名推断可用但显式更稳)
- config-check.py 增加结构断言:每个集合必须 folder 或 files 二选一、files
  项必须齐 file/label/fields、file 必须指向真实文件——这类"后台整体打不开"
  的结构错从此在构建期拦截

验证: config-check 五文件全 OK(207 字段/64 hint)+ 结构断言通过;
      pnpm astro build exit 0

Spec: none (后台配置结构修复,构建校验加固)

Co-authored-by: GLM-5.3
2026-11-02 19:35:28 +08:00
wait4x c772236d1a Merge branch 'spec/2026-11-02_blog-5g-pages'
Phase 5g 交付:块式页面构建器(关于页九类区块驱动)+ 样式即数据(theme.yaml + 对比度闸门)+ 首页抽 HomePage 消双份与三字段 + 开源页收尾(README 四级回落/兜底 8 仓/可见性告警)+ 后台文档与 hint(config.yml 重复键结构缺陷修复)+ 字体子集化(汇文 -40%)+ 全站三层排版(富文本/页面级覆盖/艺术字体类)。

Spec: 2026-11-02_blog-5g-pages
2026-11-02 19:20:17 +08:00
wait4xandGLM-5.3 a89c443c25 chore(spec): 归档 blog-5g——七任务收敛
Spec: 2026-11-02_blog-5g-pages

Co-authored-by: GLM-5.3
2026-11-02 19:20:17 +08:00
wait4xandGLM-5.3 23b79a2473 chore(spec): blog-5g 全任务勾选(T4 措辞修正)
Spec: 2026-11-02_blog-5g-pages

Co-authored-by: GLM-5.3
2026-11-02 19:18:03 +08:00
wait4xandGLM-5.3 8274180f81 docs(admin): 后台文档与 hint 补齐 + 修复 config.yml 重复键结构缺陷(5g T6)
- docs/管理台.md 扩写 25→156 行(原文要点全保留,131 增 0 删):四集合
  总表、块编辑九类(字段+示例 yaml+预览限制)、页面排版覆盖(含整页换字体
  的体积成本提醒)、theme 17 旋钮分组约束、首页三字段、开源页六字段 +
  include 未命中排查 + README 回落矩阵四级(把 T5 的"相对链接不重写/
  徽章不阻断"两条限制落到用户文档)
- docs/写作指南.md:首页可配项补三行、新增「页面块」字段速查节(含 ≤24
  块/≤4000 字/未知块跳过/空则回落 intro 的边界)、字体子集化段
  (694KB→420KB −40% + subset-fonts.py 命令)、加字流程第④步挂子集化
- 后台 hint 49→64:九类块型说明、tagline/blurb、font 下拉体积提醒、scale
  取值范围
- **修 config.yml 结构缺陷(重要)**:typography 对象被插进双语字段的
  name: 与 widget: object 之间,导致重复键 widget/fields(后者覆盖前者)→
  后台 tagline/blurb 退化成字符串写入、排版选择器字段被劫持。已挪到双语
  字段之后,恢复 object+zh/en 结构(重复键检测:修前 4 处 → 修后 0)
- 新增 scripts/build-perf/config-check.py:用自定义 StrictLoader 在解析阶段
  对重复键报错(Python 默认 safe_load 静默覆盖是本次漏检根因)+ hint 覆盖
  率体检;build.sh 守卫挂载([ -f ] 先行,与既有闸门同级)

验证: 构建 48 页 exit 0;E2E 37/37;content-lint 49 页 0/0/0;对比度闸门
      4 项 PASS;config-check 五文件全 OK + 206 字段/64 hint

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: GLM-5.3
2026-11-02 18:40:32 +08:00
65 changed files with 3969 additions and 581 deletions

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,94 @@
# Phase 5g:页面后台化(blocks 模型)与字体子集化 - 完成总结
## 交付结论
- 结果:完成
- 完成时间:2026-11-02 19:19
## 假设回顾
### 已验证假设
- 块渲染器受控,破版风险低于整页 HTML 直编(方案 10 §2 对比表)
- 字体子集化(pyftsubset 按页实际用字)能把艺术字体单页增量压到 ≤150KB 阈值内
### 仍未完全验证的假设
- 无
## 交付范围
### 已交付
- T1 blocks 骨架(关于页块式页面构建器)
- T2 块型扩充与图片管线
- T3 样式即数据(theme.yaml + 对比度闸门)
- T4 首页细化与去重
- T5 开源页收尾(缓存/回落/告警)
- T6 文档与后台 hint 补齐
- T7 字体子集化工具
### 未交付
- 无
### 偏差说明
- 无
## 简化决策
### 保持简单的关键选择
- 最简单可行方案:块类型先用 3 类跑通闭环(prose/tracks/divider),再扩到 9 类;theme 只做 12 个白名单旋钮
- 暂不引入:可视化块预览、块级动画配置
- 不做的抽象:通用页面 DSL(只服务现有页面)
### 本轮明确不做的内容
- 换 CMS、页面布局的任意拖拽网格、运行时编辑器
## 变更边界
### 本轮主要改动模块
- 见 tasks.md 的 boundary 记录
### 明确未触碰的区域
- 换 CMS、页面布局的任意拖拽网格、运行时编辑器
## 验证证据
### 备注
- 机器证据:runs.jsonl(见验证范围的机器验证率)
## 验证范围
- 级别:package
- 机器验证率:7/7
- 规则:check 与 done 复用该范围;只有记录升级触发时才执行全项目检查
## 哲学生效证据
### 行为成效回填
- Development Record 行为门禁通过
### 一致性门禁回顾
- 验收面(checklist 或成功标准)已通过
## Git 记录
- 日期时间:2026-11-02 19:19
- 范围:Phase 5g:页面后台化(blocks 模型)与字体子集化
- 功能:T1 blocks 骨架(关于页块式页面构建器);T2 块型扩充与图片管线;T3 样式即数据(theme.yaml + 对比度闸门);T4 首页细化与去重;T5 开源页收尾(缓存/回落/告警);T6 文档与后台 hint 补齐;T7 字体子集化工具
- 操作:提交 / 推送
- 成效:Development Record 行为门禁通过
- 提交:pending local commit
- 提交区间:3822e150a87ac5fee56c9b2724eae23389e7c34e..23b79a2473674e75608736e2cac491b163a49c35
- 推送:not-run (push stage follows commit)
## AGENTS.md 增量提议
```diff
Phase 5g 资产:①块式页面构建器(九类 discriminatedUnion + 宽松分支容错:已知块严格校验、未知块 WARN 丢弃不炸构建);②样式即数据(theme.yaml 九组白名单旋钮 + :root:root 特异性注入 + 产物级对比度闸门,负例 1.61:1 被拦);③首页抽 HomePage 消 zh/en 双份;④字体子集化工具(汇文 694→420KB −40%;快乐体实测反向 −9% 故不启用,结论入档避免后人重试);⑤config.yml 重复键结构缺陷的教训——Python yaml 静默覆盖,必须用 StrictLoader/Node yaml 校验,已固化为 build 闸门 config-check.py;⑥并发构建擦 dist 的解法:build→cp -a dist 快照→快照起服务跑 E2E。
```
## 被拒绝的扩展提议
- 无
## 门禁证据
- check_spec_package.py exit 0
## 遗留事项
- 无;问题处置见下方结构化区块
## 问题处置
```json
{
"issues": [],
"version": 1
}
```
@@ -0,0 +1,7 @@
{"task": "task_0001", "cmd": "E2E_BASE=http://127.0.0.1:18924 node tests/e2e.mjs 2>&1 | grep -q \"37/37 PASS\" && grep -q \"discriminatedUnion\" src/content.config.ts && grep -q \"BlockRenderer\" src/components/pages/AboutPage.astro && grep -q \"typeKey\" public/admin/config.yml", "exit": 0, "duration_s": 18.466, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "7a7e460dbaff3855c2da899e62a1489b19a1d25a4a91ecfb211fadec40bff343", "at": "2026-11-02T11:18:21+00:00"}
{"task": "task_0002", "cmd": "E2E_BASE=http://127.0.0.1:18924 node tests/e2e.mjs 2>&1 | grep -q \"37/37 PASS\" && ls src/components/blocks/ | grep -qC1 \"Cards\\|Timeline\" && grep -q \"image()\" src/content.config.ts", "exit": 0, "duration_s": 17.177, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "7a7e460dbaff3855c2da899e62a1489b19a1d25a4a91ecfb211fadec40bff343", "at": "2026-11-02T11:18:39+00:00"}
{"task": "task_0003", "cmd": "E2E_BASE=http://127.0.0.1:18924 node tests/e2e.mjs 2>&1 | grep -q \"37/37 PASS\" && test -f content/site/theme.yaml && node scripts/build-perf/contrast-check.mjs dist", "exit": 0, "duration_s": 17.193, "stdout_tail_sha256": "f66c66c7ce879dad3f789790f8607fbd50e7a5a894e12912a55fd47d082e4f77", "tree_fp": "7a7e460dbaff3855c2da899e62a1489b19a1d25a4a91ecfb211fadec40bff343", "at": "2026-11-02T11:18:56+00:00"}
{"task": "task_0004", "cmd": "E2E_BASE=http://127.0.0.1:18924 node tests/e2e.mjs 2>&1 | grep -q \"37/37 PASS\" && test -f src/components/pages/HomePage.astro && grep -q \"HomePage\" src/pages/en/index.astro", "exit": 0, "duration_s": 17.189, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "7a7e460dbaff3855c2da899e62a1489b19a1d25a4a91ecfb211fadec40bff343", "at": "2026-11-02T11:19:13+00:00"}
{"task": "task_0005", "cmd": "E2E_BASE=http://127.0.0.1:18924 node tests/e2e.mjs 2>&1 | grep -q \"37/37 PASS\" && python3 -c \"import json;d=json.load(open('src/data/projects-fallback.json'));assert len(d)>=5\"", "exit": 0, "duration_s": 17.214, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "7a7e460dbaff3855c2da899e62a1489b19a1d25a4a91ecfb211fadec40bff343", "at": "2026-11-02T11:19:30+00:00"}
{"task": "task_0006", "cmd": "test -f docs/管理台.md && grep -q \"blocks\\|区块\" docs/管理台.md && grep -c \"hint\" public/admin/config.yml | awk '$1>=20'", "exit": 0, "duration_s": 0.004, "stdout_tail_sha256": "913f5d1da2feaf4deeccc9e55cbb350a20f12b3f507e87be85dbb77fdd3cb9bc", "tree_fp": "7a7e460dbaff3855c2da899e62a1489b19a1d25a4a91ecfb211fadec40bff343", "at": "2026-11-02T11:19:30+00:00"}
{"task": "task_0007", "cmd": "test -f scripts/build-perf/subset-fonts.py && python3 -c \"import fontTools; print('fonttools ok')\" && E2E_BASE=http://127.0.0.1:18924 node tests/e2e.mjs 2>&1 | grep -q \"37/37 PASS\"", "exit": 0, "duration_s": 17.19, "stdout_tail_sha256": "3bcefe2ef2e30a5bbe067d31368a9765d7843d0cc24738a60c946c020e581348", "tree_fp": "7a7e460dbaff3855c2da899e62a1489b19a1d25a4a91ecfb211fadec40bff343", "at": "2026-11-02T11:19:47+00:00"}
@@ -34,13 +34,13 @@
## 3. 功能范围
### 3.1 核心功能(MVP)
- [ ] T1 blocks 骨架:schema(discriminatedUnion)+ page-blocks.ts + BlockRenderer + prose/tracks/divider 三块 + 后台 types 声明
- [ ] T2 块型扩充:cards/timeline/quote/figure/linklist/cta + 图标键白名单 + 图片 image() 管线
- [ ] T3 样式即数据:theme.yaml + theme.ts + BaseLayout 内联变量 + 对比度闸门脚本
- [ ] T4 首页细化与去重:抽 HomePage.astro 消 zh/en 双份 + 字段细化(projectsLimit/announcement 等)
- [ ] T5 开源页收尾:README 缓存策略/超时/回落矩阵 + fallback JSON 回填 + include 未命中 WARN
- [ ] T6 文档与 hint:docs/管理台.md(块编辑用法)+ 写作指南扩充 + admin 全字段中文 hint
- [ ] T7 字体子集化工具:按页用字裁剪艺术字体为小切片 + build.sh 守卫挂载 + 实测对比
- [x] T1 blocks 骨架:schema(discriminatedUnion)+ page-blocks.ts + BlockRenderer + prose/tracks/divider 三块 + 后台 types 声明
- [x] T2 块型扩充:cards/timeline/quote/figure/linklist/cta + 图标键白名单 + 图片 image() 管线
- [x] T3 样式即数据:theme.yaml + theme.ts + BaseLayout 内联变量 + 对比度闸门脚本
- [x] T4 首页细化与去重:抽 HomePage.astro 消 zh/en 双份 + 字段细化(projectsLimit/announcement 等)
- [x] T5 开源页收尾:README 缓存策略/超时/回落矩阵 + fallback JSON 回填 + include 未命中 WARN
- [x] T6 文档与 hint:docs/管理台.md(块编辑用法)+ 写作指南扩充 + admin 全字段中文 hint
- [x] T7 字体子集化工具:按页用字裁剪艺术字体为小切片 + build.sh 守卫挂载 + 实测对比
### 3.2 扩展功能
- 无
@@ -69,13 +69,13 @@
- 升级触发:若 theme 变量波及全局排版(字体字号全站变化)或 blocks 改动触及首页/文章页结构,升级为全站视觉复核与 E2E 全量并记录理由
## 6. 成功标准与验证方式
- [ ] 关于页整页由 yaml blocks 驱动,增删排序生效 -> verify: task_0001
- [ ] 九类块全部可渲染且旧内容零回归(三级回落) -> verify: task_0002
- [ ] theme.yaml 改值 → 产物 CSS 变量变化,对比度闸门拦截不达标 -> verify: task_0003
- [ ] 首页 zh/en 由同一组件渲染且字段可配 -> verify: task_0004
- [ ] 开源页 README 失败矩阵与 fallback 告警可用 -> verify: task_0005
- [ ] 后台文档与 hint 完整 -> verify: task_0006
- [ ] 字体子集化工具把指定字体单页增量压到 ≤150KB -> verify: task_0007
- [x] 关于页整页由 yaml blocks 驱动,增删排序生效 -> verify: task_0001
- [x] 九类块全部可渲染且旧内容零回归(三级回落) -> verify: task_0002
- [x] theme.yaml 改值 → 产物 CSS 变量变化,对比度闸门拦截不达标 -> verify: task_0003
- [x] 首页 zh/en 由同一组件渲染且字段可配 -> verify: task_0004
- [x] 开源页 README 失败矩阵与 fallback 告警可用 -> verify: task_0005
- [x] 后台文档与 hint 完整 -> verify: task_0006
- [x] 字体子集化工具把指定字体单页增量压到 ≤150KB -> verify: task_0007
## 7. 风险与约束
- 灰阶体系与双语红线不变;彩色仅正文强调
@@ -6,37 +6,37 @@
## 阶段一:实现
- [ ] T1 blocks 骨架(关于页块式页面构建器)
- [x] T1 blocks 骨架(关于页块式页面构建器)
- claim: op-gitea
- boundary: src/content.config.ts(siteAbout 加 blocks: z.array(discriminatedUnion))、新增 src/lib/page-blocks.ts(getAboutBlocks:过滤 enabled、未知 type 跳过+WARN、文本 pickBi 回落、块数≤24/单块≤4000 字)、新增 src/components/blocks/{BlockRenderer,Prose,Tracks,Divider}.astro、AboutPage.astro 改由 blocks 驱动(blocks 空→intro markdown→默认文案三级回落)、public/admin/config.yml 加 list+types(typeKey: type)
- verify: `E2E_BASE=http://127.0.0.1:18924 node tests/e2e.mjs 2>&1 | grep -q "37/37 PASS" && grep -q "discriminatedUnion" src/content.config.ts && grep -q "BlockRenderer" src/components/pages/AboutPage.astro && grep -q "typeKey" public/admin/config.yml`
- [ ] T2 块型扩充与图片管线
- [x] T2 块型扩充与图片管线
- claim: op-gitea
- boundary: 新增 src/components/blocks/{Cards,Timeline,Quote,Figure,LinkList,Cta}.astro + 图标键白名单(复用 SocialLinks 注册表或新建 icons 注册表)、schema 块型补全(图片字段用 image(),禁止外链字符串)、content/site/assets/ 目录、near docs 图表说明
- verify: `E2E_BASE=http://127.0.0.1:18924 node tests/e2e.mjs 2>&1 | grep -q "37/37 PASS" && ls src/components/blocks/ | grep -qC1 "Cards\|Timeline" && grep -q "image()" src/content.config.ts`
- [ ] T3 样式即数据(theme.yaml + 对比度闸门)
- [x] T3 样式即数据(theme.yaml + 对比度闸门)
- claim: op-artalk
- boundary: 新增 content/site/theme.yaml(12 个白名单旋钮:prose-size/measure/radius/accent/leading 等)、新增 src/lib/theme.ts(getTheme + themeVarsCss 生成 :root:root{…})、BaseLayout.astro head 内联 theme 变量(构建期静态)、新增 scripts/build-perf/contrast-check.mjs(断言 theme 结果下正文对比度 ≥4.5)、public/admin/config.yml 加 theme 单例、build.sh 守卫挂载
- verify: `E2E_BASE=http://127.0.0.1:18924 node tests/e2e.mjs 2>&1 | grep -q "37/37 PASS" && test -f content/site/theme.yaml && node scripts/build-perf/contrast-check.mjs dist`
- [ ] T4 首页细化与去重
- [x] T4 首页细化与去重
- claim: op-artalk
- boundary: 新增 src/components/pages/HomePage.astro(承接现 index.astro 区块逻辑,locale prop)、src/pages/{index,en/index}.astro 改为薄壳、home.yaml 增 projectsLimit / announcement(markdown)/ postsTracks(轨开关细分),site-content 访问器同步
- verify: `E2E_BASE=http://127.0.0.1:18924 node tests/e2e.mjs 2>&1 | grep -q "37/37 PASS" && test -f src/components/pages/HomePage.astro && grep -q "HomePage" src/pages/en/index.astro`
- [ ] T5 开源页收尾(缓存/回落/告警)
- [x] T5 开源页收尾(缓存/回落/告警)
- claim: worker-one
- boundary: src/lib/site-content.ts(README 缓存策略与超时、回落矩阵)、content/.github-readme.md 与 README HTML 剥离策略(复用 rehype-content-sanitize 已挡危险标签;补: 外链徽章不阻断)、src/data/projects-fallback.json 回填 5-8 条真实仓、projects.ts include 未命中 console.warn
- verify: `E2E_BASE=http://127.0.0.1:18924 node tests/e2e.mjs 2>&1 | grep -q "37/37 PASS" && python3 -c "import json;d=json.load(open('src/data/projects-fallback.json'));assert len(d)>=5"`
- [ ] T6 文档与后台 hint 补齐
- [x] T6 文档与后台 hint 补齐
- claim: worker-one
- boundary: docs/管理台.md(块编辑怎么用、增删排序、预览限制)、docs/写作指南.md(块字段表/受限类/主题项)、public/admin/config.yml 全字段中文 hint(含 T1-T4 新增字段)
- verify: `test -f docs/管理台.md && grep -q "blocks\|区块" docs/管理台.md && grep -c "hint" public/admin/config.yml | awk '$1>=20'`
- [ ] T7 字体子集化工具
- [x] T7 字体子集化工具
- claim: orchestrator
- boundary: 新增 scripts/build-perf/subset-fonts.py(pyftsubset 按指定字体 + 页面实际用字生成子集 woff2 + 重写 fonts.css 指向子集文件)、infra/deploy/build.sh 守卫挂载、docs/写作指南加字流程补"子集化"一节
- verify: `test -f scripts/build-perf/subset-fonts.py && python3 -c "import fontTools; print('fonttools ok')" && E2E_BASE=http://127.0.0.1:18924 node tests/e2e.mjs 2>&1 | grep -q "37/37 PASS"`
@@ -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"}
@@ -0,0 +1,91 @@
# Phase 5h:内容表达能力与社交反馈 - 项目范围
<!-- spec-format: 2 -->
## 1. 问题定义
- **项目目标**:①文章支持置顶(作者指定优先)②瞬间的文字与文章同级表达力(字体可换、颜色可换、多段落/列表/强调)③瞬间每条可独立评论 ④浏览量与评论数用统一的「眼睛/气泡」计数徽标呈现(对齐开源页 GitHub 档案的眼图标语汇)⑤开源页开篇从 GitHub README 直接移植导致:`<div align="center">` 内联对齐失效、shields/vercel/snake/komarev 等外链图在慢网下空白破版、README 的「About Me」与开源页主题无关——重构为原生组件并从 README 解耦 ⑥关于页承接读完 README 后仍想保留的个人叙述 ⑦新增一篇「写作模板」示例文章,把可用排版能力一次演示完
- **目标用户**:站长(写得更自由,少打字面 HTML)、读者(读到更丰富且不破版的页)
- **核心价值**:内容表达力与后台编辑力对上,同时把外链依赖与版式事故面砍掉;社交反馈(浏览/评论)在灰阶体系内可量化可见
## 2. 假设与待确认
### 2.1 已确认事实
- Artalk v2 匿名可读:`GET /artalk/api/v2/comments?site_name=…&page_key=…&limit=1` 返回 `{count, roots_count, page:{pv}}`(生产实测,2026-11-02);`POST /artalk/api/v2/pages/pv` 返回 `{pv}`(现有组件在用)
- 富文本管线已存在且已净化:`renderRich`(markdown + rehype-content-sanitize 标签/属性/协议三层白名单)与 `rehype-class-whitelist`(35 项排版类,单一事实源 `public/admin/typography-classes.js`)
- 现有 6 自托管字族(含 font-huiwen 子集化 420KB 单页)与 16 彩色语义类 + 4 灰阶语义类
- 开源页 README 现由 `intro_from: "off"` 关闭,缓存文件 `content/.github-readme.md`(3138B)含 6 类外链图(readme-typing-svg / shields.io / github-readme-stats / streak-stats / activity-graph / snake / komarev)
- 首页/归档/书架三处列表排序均为 `pubDate` 降序,无置顶概念(`grep pinned` 只命中开源页仓库白名单)
### 2.2 关键假设
- 瞬间富文本走与全站文案同一条净化管线即可满足「字体/颜色可换」且不引入 XSS 面(危险标签整棵丢弃、协议白名单 http/https/mailto)
- 每条瞬间一个 Artalk page_key(`/moments/<slug>/`)不会与页面级 pv 冲突;Artalk 实例按需挂载(展开才 init)可避免 "one page one Artalk" 的全局实例冲突
- 开源页可用本地数据(已有 repo 元数据 + GitHub GraphQL 拉取)自绘 followers/stars/语言占比,无需任何第三方徽章服务
### 2.3 待确认问题
- [x] 瞬间文字是否允许 HTML 类名(字体/颜色)→ 用户原话「字体可换、颜色可换等」,按富文本管线放行 35 项白名单类
- [x] 开源页要不要与 README 完全一致 → 用户原话「可以自行进行美化,无需完全一致」,按原生组件重构
- [x] About Me 类内容放哪 → 用户原话「有些可以直接移植到关于页面中」,迁到 about.yaml blocks
### 2.4 可选解释与取舍
- 置顶只作用于列表排序与视觉标记,不改 URL / 不改 pubDate 语义(订阅源按时间仍正确)
- 计数徽标一次只拉当前页的 pv(POST 计一次)与评论数(GET 读);瞬间列表按可视区懒拉,避免 N 次请求把首屏拖慢
- 开源页保留 `intro_from` 开关作为 compat,但默认 off 且文档标注「不推荐」;README 渲染通道不删除(后台仍可切回)
### 2.5 已证伪路径
- 直接渲染 README HTML 并试图用 CSS 修 `align="center"`:内联属性与 sanitize 的交互不可控,且外链图仍会空白破版 → 弃,改原生组件
- 每条瞬间都 init 一个 Artalk 实例常驻:内存与请求成本高、且 Artalk 对多实例的支持未验证 → 改为展开时挂载
- 用第三方徽章服务显示访客数(komarev 等):外链依赖 + 灰阶体系外颜色 → 弃,用 Artalk pv 自绘
## 3. 功能范围
### 3.1 核心功能(MVP)
- [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 扩展功能
- 无
### 3.3 不在范围内
- 评论回复通知/邮件推送、点赞/投票、阅读进度、站点级总访问量统计(Artalk 无匿名聚合端点)
## 4. 最小实现路径
- 最简单可行方案:置顶先用 boolean + 排序键;瞬间富文本直接把 `text` 交给既有 `renderRich`;评论用现有 Artalk 组件克隆一份带折叠的;计数徽标只做 pv + count 两个数
- 暂不引入:置顶权重/多级置顶、瞬间的图片轮播、评论楼中楼定制、开源页的实时 Star 曲线
- 不做的抽象:通用「内容社交层」框架(只服务现有页面)
## 5. 技术决策
- 技术栈:Astro 7.4.4 + zod schema + Artalk v2 HTTP API + GitHub GraphQL(已有 token)
- 证据协议:evidence: runs
- 本轮允许改动:src/content.config.ts、src/components/{PostList,ArtalkComments}.astro、src/components/pages/{HomePage,MomentsPage,BooksPage}.astro、src/components/geek/{ViewMeta,GithubProfile}.astro(新)、src/pages/archives/[...slug].astro、src/pages/en/archives/[...slug].astro、src/pages/{about,opensource,en/about,en/opensource}.astro、src/lib/{projects,site-content,heatmap}.ts、content/site/{about,opensource}.yaml、content/posts/*(新增示例文章)、content/posts-en/*、public/admin/{config.yml,typography-classes.js}、docs/*、tests/e2e.mjs
- 本轮不应触碰:nginx/ESA、灯箱与图库(GalleryPage/PhotoZoom)、Header/Footer 结构、构建链顺序、tokens.css/global.css 基础尺度
- Git integration branch:`spec/2026-11-02_blog-5h-content-social`
### 5.1 验证策略
- 范围级别:package
- 变更对象:内容模型(置顶/瞬间富文本)、渲染组件(计数/评论/档案卡)、后台配置、示例内容
- 快速检查:`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 端点对账一致、关于页承接内容渲染);**升级条件**——仅当改动触及 BaseLayout/全局样式(tokens/字体栈)或 Artalk 主题接管层时才执行,本包因往 tokens 补 emoji 回退链已触发一次并记录了理由
- 升级触发:若动到 BaseLayout/全局样式或 Artalk 主题接管层,升级为全站视觉复核(glm-5.3-flash:max,≤6 图/轮)
## 6. 成功标准与验证方式
- [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`),不引入第三方彩色徽章
- 彩色仅正文强调(≤3 处/页)——示例文章本身要守纪律,成为正面样本而非反例
- 瞬间富文本必须复用 `renderRich` 净化链,禁止新增 set:html 直通路径
- Artalk 多实例风险:评论区折叠 + 展开挂载,若实测冲突则退回「逐条跳转到锚点页」
- 双语链路:所有新文案走 `t()`/`pickBi`,en 轨不得出现中文硬编码
- 多车道并行:content.config.ts / public/admin/config.yml 由 orchestrator 先落契约再放行,车道只改各自 boundary 内文件
@@ -0,0 +1,44 @@
# Phase 5h:内容表达能力与社交反馈 - 任务拆解
## 使用规则
- boundary/verify 齐备;verify 只读断言;车道工作树开发禁 git,收编由 orchestrator 统一提交
- 构建一律 `export PATH="$HOME/.local/node/bin:$PATH"`(Node 26)+ `pnpm astro build`
- **契约先行**:`src/content.config.ts` 与 `public/admin/config.yml` 由 orchestrator 先落契约(T1 时一并提交),车道不得再改这两个文件;需要新字段一律回报 orchestrator
- E2E 口径:`pnpm astro build → cp -a dist /tmp/dist-e2e → 用 18925 端口起快照服务 → E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs`
## 阶段一:实现
- [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 -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 -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/${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 -qE "汇总: [0-9]+/[0-9]+ PASS"`
- [x] T5 开源页重构(原生 GitHub 档案卡,去外链图依赖)
- claim: op-gitea
- boundary: 新增 src/components/geek/GithubProfile.astro(头像/名字/简介/关注者/总星标/语言占比,数据来自 projects.ts 与 GraphQL;零第三方图片与徽章)、src/lib/projects.ts(补统计聚合:followers/stars/language 汇总,失败降级不炸构建)、src/pages/opensource.astro、src/pages/en/opensource.astro(用 GithubProfile 替换 readme 渲染段;README 通道保留开关但默认 off 且标注不推荐)、content/site/opensource.yaml(注释说明 intro_from 现状)
- verify: `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"`
- [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 -qE "汇总: [0-9]+/[0-9]+ PASS"`
- [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`
+7
View File
@@ -27,5 +27,12 @@
"image": "https://astro.build/og/astro.jpg",
"favicon": "https://astro.build/favicon.svg",
"ts": 1793593759816
},
"https://astro.build": {
"title": "Astro",
"desc": "Astro builds fast content sites, powerful web applications, dynamic server APIs, and everything in-between.",
"image": "https://astro.build/og/astro.jpg",
"favicon": "https://astro.build/favicon.svg",
"ts": 1793621147252
}
}
+10
View File
@@ -0,0 +1,10 @@
---
pubDate: 2026-11-02
tags: [站点更新]
text: |
瞬间现在和文章同级表达力:`text` 支持 markdown 与排版类,<span class="c-teal font-kai">这一句是青色 + 楷体</span>,也可以**加粗**。
另外每条瞬间自带评论区——展开才加载,不刷时间线就不为它付成本。
---
站点更新记录:瞬间富文本与逐条评论上线。
+125
View File
@@ -0,0 +1,125 @@
---
title: "Writing template: this post is the template"
pubDate: 2026-11-02
category: tech
tags: [writing, template]
pinned: true
locked: true
description: One post that exercises every typographic capability on the site — heading levels, colour emphasis, typefaces, quotes, code windows, tables, images, link cards, folding. Copy it to start a new post.
---
This is not a tutorial — it is a **template you can copy**. Every section below actually runs: what you see is what Markdown produces. To start a new post, copy this file, change the slug, and delete what you do not need.
It is also the live sample for **pinning**: `pinned: true` in the frontmatter lifts a post to the front of the home page, the archive and the bookshelf, while its date, URL and RSS timestamps stay untouched.
## 1. Heading levels and paragraphs
Use `##` for a section and `###` for a subsection. **Never use `#`** — the post title comes from the frontmatter `title`, and a second level-one heading confuses both screen readers and search snippets.
Leave a blank line between paragraphs. Inline you can use **bold**, *italic*, ~~strikethrough~~, `inline code`, and [ordinary links](https://astro.build).
## 2. Colour emphasis: as much as you need
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">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.
## 3. Typefaces: change only what you wrap
Body text is sans-serif by default. To give one sentence a different temperament, wrap it:
<span class="font-kai">This sentence is in Kai. Good for epigraphs, quoted lines in a review, or paragraphs that should be read slowly.</span>
<span class="font-huiwen">This sentence is in Huiwen Ming. Over a long paragraph it noticeably slows reading, so use it for short lines only.</span>
Six families are available (`font-kai`, `font-song`, `font-hei-sys`, `font-fangsong`, `font-huiwen`, `font-kuaile`), plus an artistic heading class `font-art-kuaile`. **Mind the weight**: switching a whole page to a family makes every visitor download a CJK font, so avoid changing the body default casually.
### A level-three heading looks like this
One step smaller than level two, bold, with tighter spacing. Use it to split a section into topics.
## 4. Quotes and lists
> Block quotes are for quoting someone. An attribution can follow as italics.
>
> — an invented attribution
Unordered list:
- First item
- Second item, with `inline code`
- Third item, nested:
- Child one
- Child two
Ordered lists suit steps:
1. Copy the template
2. Change the slug and title in the frontmatter
3. Delete the sections you do not need
## 5. Code blocks
Blocks of four lines or more get a greyscale window bar (three dots); shorter fragments do not, so small snippets do not look like screenshots.
```ts
// Sort contract: pinned first → newest first → id as a tie-breaker
export function sortPosts<T extends Post>(posts: T[]): T[] {
return [...posts].sort((a, b) => {
const pin = Number(b.data.pinned === true) - Number(a.data.pinned === true);
return pin !== 0 ? pin : b.data.pubDate.getTime() - a.data.pubDate.getTime();
});
}
```
Refer to variables inline like `posts`.
## 6. Tables
| Capability | Syntax | Note |
| --- | --- | --- |
| Colour emphasis | `<span class="c-blue">` | no limit (shell stays greyscale) |
| Typeface | `<span class="font-kai">` | a whole page adds font weight |
| Image | `![alt](./assets/<slug>/x.jpg)` | see the writing guide for size rules |
| Video | frontmatter `video:` | lives in public/media, ≤50MB |
| Link card | a bare URL on its own line | OG fetched at build time, falls back to a plain link |
## 7. Images
Reference images by relative path, stored under `content/posts/assets/<slug>/`; they expand on click and vectors stay sharp:
![Sample image: a greyscale coast](../posts/assets/media-sample/sample-photo.jpg)
## 8. Link cards
A bare URL on its own line becomes an OG preview card at build time; if the fetch fails it degrades to a plain link, never breaking the layout:
https://astro.build
## 9. Folding long content
Long appendices, full logs and big configs can fold away with zero JavaScript, using native `details`:
<details>
<summary>Open the folded example</summary>
A folded region can hold anything — paragraphs, lists, code blocks.
- Best for “read on demand” detail
- Not for conclusions (readers may never open it)
</details>
## 10. Closing after the rule
---
Three sentences are enough to close: what this covered, what the reader should take away, and where to go next. The template is not a demand to write ten sections — it is a promise that **the capabilities are at hand when you need to express something**.
Next time, copy this file and delete until only your point remains.
+124
View File
@@ -0,0 +1,124 @@
---
title: "写作模板:这篇文章本身就是模板"
pubDate: 2026-11-02
category: tech
tags: [写作, 模板]
pinned: true
description: 一篇文章演示全站可用的排版能力:标题层级、彩色强调、字族、引用、代码窗、表格、图、链接卡、折叠。复制它开始写新文。
---
这篇不是教程,是**可复制的模板**。下面每一节都在真跑——你看到的效果,就是 Markdown 源码能产出的效果。写新文章时,把本文件复制一份、改个 slug、删掉不需要的段落即可。
它同时是**置顶功能**的活样本:`frontmatter` 里的 `pinned: true` 会让它排在首页、归档与书架的最前面,而日期、URL、订阅源里的时间都不变。
## 一、标题层级与段落
正文用 `##` 作一级节标题,`###` 作二级。**不要用 `#`**——文章标题由 frontmatter 的 `title` 渲染,正文再写 `#` 会出现两个一级标题,屏幕阅读器与搜索摘要都会困惑。
段落之间空一行即可。行内可以**加粗**、*斜体*、~~删除线~~、`行内代码`,也可以放[普通链接](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> 想让整段都是重点也不必克制,把它们都标出来即可。
可用色相共 8 组(blue / teal / green / yellow / orange / red / pink / purple),每组有标准与 `-soft` 两档;另有 4 个灰阶层级类(`ink-muted` / `ink-warm` / `ink-cool` / `ink-faint`)用于降调,不算彩色额度。
## 三、字族:换字体只作用于被包裹的文字
正文默认是无衬线。想给某句话换气质,用类包起来就行:
<span class="font-kai">这一句是楷体。适合题记、书评里引的原句,或者需要慢一点读的段落。</span>
<span class="font-huiwen">这一句是汇文明朝体。长段落用它会明显降低阅读速度,所以只用在短句上。</span>
可用字族共 6 族(`font-kai` 楷、`font-song` 宋、`font-hei-sys` 黑、`font-fangsong` 仿宋、`font-huiwen` 汇文明朝、`font-kuaile` 快乐体),另有 `font-art-kuaile` 艺术字标题类。**注意体积**:整页换字族会让访问者下载一份中文字体,正文默认字体不要轻易改。
### 三级标题长这样
比二级小一档、加粗、上下留白更紧。用于把一节拆成几个小话题。
## 四、引用与列表
> 引用块用于摘录别人的话。署名可以换成斜体放在最后。
>
> —— 一句虚构的署名
无序列表:
- 第一项
- 第二项,可以带 `行内代码`
- 第三项,可以嵌套:
- 子项一
- 子项二
有序列表适合步骤:
1. 复制模板
2. 改 frontmatter 的 slug 与标题
3. 删掉不需要的段落
## 五、代码块
四行及以上的代码块会带上灰阶窗条(三个点),短代码块不加——避免小片段看着像截图。
```ts
// 排序契约:置顶优先 → 时间倒序 → id 兜底
export function sortPosts<T extends Post>(posts: T[]): T[] {
return [...posts].sort((a, b) => {
const pin = Number(b.data.pinned === true) - Number(a.data.pinned === true);
return pin !== 0 ? pin : b.data.pubDate.getTime() - a.data.pubDate.getTime();
});
}
```
行内提到变量时用 `posts` 这种形式即可。
## 六、表格
| 能力 | 写法 | 注意 |
| --- | --- | --- |
| 彩色强调 | `<span class="c-blue">` | 不限次数(外壳仍灰阶) |
| 换字族 | `<span class="font-kai">` | 整页换会拉字体体积 |
| 图片 | `![说明](./assets/<slug>/x.jpg)` | 体积纪律见写作指南 |
| 视频 | frontmatter `video:` | 放 public/media,≤50MB |
| 链接卡片 | 裸链接单独成段 | 构建期抓 OG,失败回落普通链接 |
## 七、图片
图片用相对路径引用,挂在 `content/posts/assets/<slug>/` 下,点击可放大(矢量图不糊):
![示例配图:一片灰阶海岸](./assets/media-sample/sample-photo.jpg)
## 八、链接卡片
一个裸链接单独成段,构建期会抓取 OG 预览生成卡片;抓不到就退回普通链接,不会坏版:
https://astro.build
## 九、折叠长内容
长附录、完整日志、大段配置可以折叠,零 JS,原生 `details`:
<details>
<summary>展开看一个被折叠的长例子</summary>
折叠区里可以放任何东西——段落、列表、代码块。
- 适合放「按需查看」的细节
- 不适合放结论(读者可能永远不展开)
</details>
## 十、分隔线之后收尾
---
收尾三句就够:这篇讲了什么、读者该带走什么、下一步去哪。模板不是要你写满十节,而是**在你需要表达时,能力都在手边**。
下一篇开始,复制本文件,删到只剩你要说的。
+69 -3
View File
@@ -62,6 +62,69 @@ blocks:
zh: 街拍与山野,随身一台无反,在等光的间隙按快门。
en: Street and landscapes with a mirrorless camera, pressing the shutter while waiting for the light.
- type: prose
title:
zh: 关于我
en: About me
body:
zh: |
气象是我起点的专业——一个想做预报的人,依旧把云图当日常读物。
写代码是第二条主线:Linux、容器、自托管,把日常工具一件件磨顺手。
作品大多在 [wait4x.online](https://wait4x.online/)。
信条:**拥抱开源,热爱开源**。
en: |
Meteorology is where I started — an aspiring forecaster who still reads weather charts for fun.
Coding is the second thread: Linux, containers, self-hosting — polishing everyday tools one by one.
Most of my work lives at [wait4x.online](https://wait4x.online/).
Motto: **embrace open source, love open source**.
- type: linklist
title:
zh: 找到我
en: Find me
items:
- label:
zh: 本站
en: Website
href: 'https://wait4x.online/'
note:
zh: 文章、图库与瞬间
en: Posts, gallery and moments
- label:
zh: 邮件
en: Email
href: 'mailto:X@alumni.nudt.edu.cn'
note:
zh: 有话直说
en: Say hello directly
- label:
zh: GitHub
en: GitHub
href: 'https://github.com/wait4xx'
note:
zh: 开源项目与提交
en: Open-source projects and commits
- label:
zh: 自托管 gitea
en: Self-hosted gitea
href: 'https://git.wait4x.online/wait4x/blog'
note:
zh: 本站源码
en: Source of this site
- label:
zh: RSS
en: RSS
href: /rss.xml
note:
zh: 订阅更新
en: Subscribe to updates
- type: prose
title:
zh: 本站如何构建
@@ -82,6 +145,9 @@ intro:
This is the public workbench of my three tracks: **books** that left notes, **code** that reaches the project wall, and **shutters** I pressed that land in the gallery.
# 页面级排版覆盖(层 2)
typography:
font: font-huiwen
accent: c-red
# 页面级排版覆盖(后台「关于页 → 页面排版覆盖」可改):
# 整页换字族会带来字体体积成本(如 font-huiwen 整页 ≈694KB),默认留空=全站默认排版
# typography:
# font: font-huiwen # 字族:font-serif/font-sans/font-kai/font-huiwen/font-kuaile…
# accent: c-red # 强调色类:c-blue/c-red/…(外壳仍灰阶)
# scale: 1.05 # 字号缩放 0.8–1.25
+8 -31
View File
@@ -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
+19 -11
View File
@@ -1,3 +1,14 @@
# 打字机动效文案(档案卡顶部逐字轮播;每行双语,最多 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:'
@@ -6,17 +17,14 @@ 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 列出
mode: all
# 白名单(mode=only 时生效)
include: []
# 黑名单(mode=exclude 时生效)
exclude: []
# 排序:pinned-first=置顶优先(默认) | stars=星数降序 | updated=最近更新
sort: pinned-first
# 来源过滤:both=双源合并(默认) | github | gitea
perSource: both
# 开篇文案来源:off=用本文件 blurb(默认) | github-readme=构建期取 wait4xx/wait4xx 的 README 渲染
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"> 居中,
# 本站不认该属性 → 居中失效。这正是当初"内容没居中、图片不显示"的根因。)
+37
View File
@@ -0,0 +1,37 @@
# 主题旋钮(样式即数据 · 层 4):只放白名单内的 9 组旋钮。
# 红线:--measure(版心)与断点/纸墨/code 配色不在此列——它们牵动版面结构与分轨身份。
# 全部留空 = 沿用 src/styles/tokens.css 的既有值(零配置零变化)。
# 钳制与单调性在 src/lib/theme.ts 里做:越界值被夹取而非报错,构建不会因主题配置失败。
# ① 强调色(明/暗两值;构建期闸门校验正文对比度 ≥4.5,不达标回落默认)
accent_light: '#3454d1'
accent_dark: '#8ea2ff'
# ② 图片圆角(4–16;08 文档裁定上限 14)
radius_img: 10
# ③ 标题字号(22–32;必须 h1 > h2 > h3,否则整体回落默认)
text_h1: 27
text_h2: 22
text_h3: 19
# ④ 正文字号(16–22)
prose_size_reading: 20
prose_size_tech: 19
# ⑤ 正文行高(1.5–2.0)
leading_reading: 1.75
leading_tech: 1.7
# ⑥ 间距梯度(8–80;必须严格递增 3 < 4 < 5 < 6,否则回落默认)
space_3: 16
space_4: 24
space_5: 40
space_6: 64
# ⑦ 段落流距(0–2em)
flow_p: 0.9
flow_h2_post: 0.6
# ⑧ 过渡时长(150–600ms)
dur: 350
+123 -1
View File
@@ -2,6 +2,36 @@
> 适用:`content/posts/*.md`(书评与技术轨通用)。资产目录约定 + 三种媒体的写法 + 体积纪律。
## 写作模板(从这里开始,2026-11-02 起)
别从空白文件起步——**复制现成的模板**:
```bash
cp content/posts/writing-template.md content/posts/my-new-post.md
# 改 frontmatter 的 title / pubDate / tags / description,删掉不需要的段落
```
模板本身是一篇真文章(线上读:`/archives/writing-template/`),每节都在跑一种能力,你看到的效果就是源码的效果:标题层级、彩色强调、字族切换、引用、列表、代码窗条、表格、图片放大、链接卡片、`<details>` 折叠、分隔线。
写之前只需记住三条纪律:
- **彩色不限次数**(2026-11-02 用户拍板取消原「一页 ≤3 处」限制):正文想用多少处颜色都可以;仍守的是另一条——**外壳灰阶**(导航/页脚/按钮/背景不染色),因为外壳是气质,正文是表达
- **整页换字族要谨慎**:会给访问者拉一份中文字体(汇文明朝整页约 420KB,已子集化)
- **折叠放细节不放结论**:`<details>` 里的内容可能永远没人展开
## 置顶文章
首页、归档、书架三处列表默认按日期倒序。想让某篇长期排在最前,frontmatter 加一行:
```yaml
pinned: true
```
语义边界(刻意设计成这样):
- **只影响列表顺序**,不改 `pubDate`、不改 URL、不影响 RSS 的时间序列(订阅者不会看到时间错乱)
- 多篇同时置顶时,它们内部仍按日期倒序
- 列表里会显示一个灰阶「置顶」小徽标,取消置顶后立刻回到时间序
- 后台「文章」编辑页有「置顶」开关,不必手改 frontmatter
## 目录约定
```
@@ -66,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 信息渲染成卡片(标题 / 描述 / 站点图标 / 可选缩略图):
@@ -153,6 +189,18 @@ https://astro.build/
体积账结论(2026-11-02 实测,本地无 gzip 的相对值):**未使用某字体的页面,字体字节增量恒为 0**(构建期按页子集化 + unicode-range 按需拉取)。汇文采用文风开源的 cn-font-split 切片包(339 片),实测只拉用到字符的 19 片。
**再裁一刀(按本站实际用字子集化)**:切片包本身粒度粗(一片含数百字),只做标题/短句的艺术字体往往只用到几十个字符。`scripts/build-perf/subset-fonts.py` 读字体 CSS 的 `@font-face`、收集内容里真实出现的字符、逐片求交集后用 `pyftsubset` 重写——实测汇文整页 **694KB → 420KB**(约 −40%)。命令:
```bash
python3 scripts/build-perf/subset-fonts.py \
--css node_modules/@fontsource/huiwen-mincho/index.css \
--out public/fonts/subset/huiwen \
--chars-from content/posts content/moments content/site/*.yaml
# 可选:--chars-mode all|headings(只统计标题字)、--min-bytes 4096(小于该值的片不裁)
```
子集化产物输出到 `--out` 指定目录(如 `public/fonts/subset/`);把它接进站点(构建链挂守卫 + 字体 CSS 指向子集)是独立一步,接线后随构建自动生成,未接线前需手动跑。**换字/加字后重跑一次**,否则新字会回落到下一个切片或系统字体。
### 候补字体清单(未落地,加一款约"一条命令 + 一次体积账")
| 字体 | 许可证 | 允许 webfont 嵌入 | 未落地原因 |
@@ -168,7 +216,7 @@ https://astro.build/
| 阿里妈妈东方大楷 / 演示悠然小楷 | 免费商用 / 个人免费 | ⚠️ 需逐款核嵌入条款 | 无包;"个人免费"字体常禁 @font-face |
| 霞鹜文楷 Light/Bright | OFL-1.1 | ✅ | 实测 **+8605KB**(CJK 切片粒度所致) |
> 加新字体的流程:①核许可证与 **web 嵌入条款**(很多"个人免费"字体明文禁 @font-face,只能进系统回退)②npm 装或放入 `public/fonts/<family>/`(优选 cn-font-split 类切片包)③`global.css` 加类 + `rehype-class-whitelist` 放行 + `public/admin/typography-classes.js` 加一项 ④跑体积账,超 150KB/页 的要评估是否值得。
> 加新字体的流程:①核许可证与 **web 嵌入条款**(很多"个人免费"字体明文禁 @font-face,只能进系统回退)②npm 装或放入 `public/fonts/<family>/`(优选 cn-font-split 类切片包)③`global.css` 加类 + `rehype-class-whitelist` 放行 + `public/admin/typography-classes.js` 加一项 ④跑体积账,超 150KB/页 的要评估是否值得;展示类字体再跑一次 `scripts/build-perf/subset-fonts.py` 按实际用字裁切(命令见上节)。
```markdown
<span class="ink-muted">次要说明</span>
@@ -185,6 +233,77 @@ https://astro.build/
后台编辑器里:**选中文字 → Insert 菜单 → 「排版(字体/颜色)」→ 面板里点一个类**,文字即被 `<span class="…">` 包住;点已套用的片段可改类,Backspace 可整体删除。
面板里的两类与上表一一对应(字体 8 / 颜色 9),类清单单一事实源在 `public/admin/typography-classes.js`。
## 三层排版架构(全站,不只文章正文)
**层 1 · 页面文案富文本**:后台「页面文案」里的 tagline / subtitle / blurb / blurb_after / intro / 关于页 blocks 的 prose 块,全部走同一条富文本管线(markdown + raw + 内容净化 + class 白名单 + 链接卡片)。可以直接写:
```yaml
tagline:
zh: '<span class="font-kai c-blue">写作者</span> · 读者 · 快门按压者,拥抱开源。'
```
渲染入口唯一:`src/lib/site-content.ts` 的 `renderRich()`(行内场景传 `{ inline: true }` 自动剥外层 `<p>`,否则 `<p>` 套 `<p>` 会被浏览器拆散)。
**不开放**:导航/页脚/按钮等硬编码界面外壳——那是 UI 不是内容。
**层 2 · 页面级排版覆盖**:每个页面单例(`content/site/*.yaml`)可加:
```yaml
typography:
font: font-kai # 本页正文默认字体类(可选)
accent: c-blue # 本页强调色类(可选)
scale: 1.1 # 字号缩放 0.8–1.25(可选)
```
实现是在该页内容容器上挂 `.page-typo` + CSS 变量(`--page-font / --page-accent / --page-scale`),**不写全站 tokens**——其它页面零影响(已在图库/书架/文章/装备页验证隔离)。白名单外的类名会被忽略。
后台入口:「页面文案 → 各自页面 → 页面排版覆盖」。
**层 3 · 艺术字体**:`font-art-kuaile`(站酷快乐体,艺术字专用类)。⚠️ **仅用于标题/短句**:艺术字字形大,CJK 切片体积高(实测用到页 +163KB),长段使用会显著抬高单页字体成本。
## 瞬间(/moments)怎么写
瞬间与文章一样支持富文本,但入口在后台「瞬间」集合(`content/moments/*.md`):
```yaml
---
pubDate: 2026-11-02
tags: [随手拍]
text: 今天把**置顶**做完了,顺手试了 <span class="c-teal">彩色</span>
---
```
- `text` 支持 markdown(`**加粗**`、列表、`> 引用`)与**同一套排版类**(字体/颜色),因为走的是与全站文案相同的净化管线:危险标签整棵丢弃,只放行 35 项白名单类
- 一条瞬间 = 一行时间线卡片,**每条都带独立评论区**(展开才挂载,双语页共用同一评论流,评论不分裂)
- 每条的「浏览 / 评论」数字是只读统计(不会因为别人点开评论而虚增浏览)
- `text` 与 `image` 至少填一项;图片建议先压缩
## 页面块(关于页 blocks,字段速查)
关于页正文可由九个块拼成(后台拖拽排序,每块有「显示」开关)。完整操作见 [管理台](./管理台.md);这里是写内容时对照的字段表——文本字段一律 `{zh, en}`。
| 块型 | 必填 | 可选 | 一句话 |
|---|---|---|---|
| `prose` | `body` | `title`、`width`(narrow/full) | markdown 长文 |
| `tracks` | `title`、`items[].name` | `items[].icon`、`items[].desc` | 主线/能力面 |
| `cards` | `items[].title` | `title`、`columns`(2/3)、`items[].desc/href/tag` | 并列卡片墙 |
| `timeline` | `items[].time`、`items[].title` | `title`、`items[].desc` | 时间线(时间可自由文本) |
| `quote` | `text` | `cite` | 单句引文 |
| `figure` | `image` | `alt`、`caption` | 单图(必须上传,禁外链) |
| `linklist` | `items[].label`、`items[].href` | `title`、`items[].note` | 链接清单 |
| `cta` | `text`、`href`、`label` | — | 行动按钮 |
| `divider` | — | `style`(line/space) | 分隔 |
```yaml
blocks:
- type: prose
body: { zh: 正文 markdown,可含排版类 span, en: Body markdown }
- type: cards
columns: 3
items:
- { title: { zh: 写作, en: Writing }, desc: { zh: 书评与技术文, en: Reviews & tech } }
```
边界:块数 ≤24、单块文本 ≤4000 字(超限截断并 WARN);未知块型跳过 + WARN;`blocks` 为空时回落 `intro` markdown,再空回落内置文案。图片字段走 `image()`,只能后台上传进 `content/site/assets/`。
## 发布提醒
- push main → webhook 自动构建发布(约 10 秒)
@@ -277,6 +396,9 @@ markdown 原生语法,无需任何配置:
| `latestPhotos` | 0-6 | 4 | 「最新影像」张数;**0 = 隐藏该区块** |
| `perTrackLimit` | 4-8 | 6 | 首页阅读/技术两轨各显示条数 |
| `sections` | `{tracks, photos, heatmap, projects}` 布尔 | 全开 | 区块开关,关掉即不渲染 |
| `projectsLimit` | 1-99 | 6 | 首页项目墙条数(越界夹取) |
| `announcement` | `{zh, en}`(markdown 富文本) | 空 | 名片区下方公告;空 = 不渲染 |
| `postsTracks` | `{reading, tech, photo}` 布尔 | 全开 | 关掉某轨则该轨文章不在首页列出(归档页仍可见) |
越界值会被夹取(如 perTrackLimit=20 按 8 处理),不报错;**全部留空 = 现有形态不变**。
+183
View File
@@ -1,17 +1,200 @@
# 管理台(Sveltia CMS)使用指南
> 地址:`https://wait4x.online/admin/`(页脚「管理」入口)| 用途:照片上传与 sidecar 编辑的图形界面 | 保存即 commit 到 main → webhook 自动构建发布(约 10 秒上线)
>
> 这份是**操作手册**:每个入口能改什么、字段怎么填、改完会发生什么。写文章正文的排版技巧见 [写作指南](./写作指南.md)——两份分工:这里讲"后台怎么用",那里讲"正文怎么写"。
## 登录
选 **Sign In with Token** → 用 gitea 的 access token(宿主 `~/docker_apps/gitea/init-templates.txt` 里的 api-token,或 gitea 网页新建 scope=read/write:repository)。token 存浏览器 localStorage,下次免登。
## 四类内容集合
| 入口 | 文件位置 | 用来放什么 | 关键约束 |
|---|---|---|---|
| 文章 | `content/posts/<slug>.md` | 书评 / 技术文 / 摄影札记三轨 | `category` 三选一(`reading` / `tech` / `photo`),首页分轨由它派生 |
| 瞬间 | `content/moments/*.md` | 一句话 + 一张图的时间线 | `text` 与 `image` **至少填一项**(后台无法表达条件必填,保存前自查) |
| 照片 | `content/photos/*.json` + 图 | 图库(街拍 / 风景两组) | 标题**必填**(空标题会让构建炸,实测坑);EXIF 由构建期 loader 自动补全 |
| 站点页面 | `content/site/*.yaml` | 首页、关于页、开源页、主题 | 后台改的是**单例文件**,没有"新建" |
几条通用规则:
- **双语字段成对出现**:中文 / 英文两个输入框。英文留空不是错误——构建期回落中文并在日志里打 WARN(`[blocks] 缺译…`),页面不会破版。
- **留空 = 用默认**:任何字段清空都回到仓库自带的默认文案 / 默认值,构建永远成功。想让某段彻底消失,请看字段自带的"留空则不渲染"提示。
- **文章英文版**:不在后台写。跑 `python3 scripts/translate/translate_posts.py` 生成 `content/posts-en/` 同名文件;手写润色过的译文在 frontmatter 写 `locked: true`,翻译脚本会无条件跳过它。
- **保存即提交**:后台保存等于向仓库提交,push 后约 10 秒自动构建上线。
## 文章置顶(5h 新增)
文章编辑页有一个「置顶」开关。打开后这篇文章排在首页分轨、归档、书架三处列表的最前面,列表里显示一个灰阶「置顶」小徽标。
语义边界(刻意如此,别当 bug 报):
- **只影响列表顺序**:`pubDate` 不变、URL 不变、RSS 里的时间序列不变,订阅者不会看到时间错乱
- 多篇同时置顶时,置顶组内部仍按日期倒序
- 关掉开关立即恢复时间序,没有"冷却期"
## 瞬间(5h 起与文章同级)
「瞬间」集合的「文字」字段是 **markdown 编辑器**,与文章同一套表达力:
- markdown:`**加粗**`、`*斜体*`、列表、`> 引用`、`` `行内代码` ``
- 排版类 HTML:`<span class="c-blue font-kai">彩色 + 楷体</span>`(可用类清单见写作指南「文章排版类」一节,与文章完全同一份白名单)
- 彩色**不再限次**(原「≤3 处/页」已取消);仍守外壳灰阶:导航/页脚/按钮/背景不染色
**每条瞬间自带评论区**:时间线卡片下方有「评论」折叠条,读者点开才加载评论(不点开不为它付任何加载成本)。中文页与英文页共用同一条评论流,语言切换不会丢评论。
**每条的浏览 / 评论数字是只读的**:读者点开评论区不会虚增浏览数(只有整页访问才计一次)。
## 照片说明文字(灯箱右侧)支持排版(5i 新增)
照片的「说明」(caption)现在支持 markdown 与排版类,与文章正文同一套:
```yaml
caption: 落日把海与城一齐烫成金箔,**这一帧等了二十分钟**
```
```html
<span class="c-blue">蓝色</span>、<span class="font-kai">楷体</span> 这些类都能用。
```
灯箱是深底,颜色类会自动取柔和对暗底的那一档,不会刺眼。EXIF 那几行是相机数据,不做排版。
## 照片上传流
1. 图库照片 → New Entry:填标题(**必填**,空标题会让构建炸——实测坑)、选分组(street/landscape)、可写 alt
2. 图片字段 → 资产库上传 jpg(与 sidecar 同目录落位,零重压缩)
3. Save → 自动 commit + 构建 → 画廊出现(EXIF 构建期自动提取,exif 字段留空即可)
## 块编辑:关于页的块式页面构建器
关于页(`content/site/about.yaml`)的正文由 **blocks 列表**构成,后台里是一串可拖拽排序的卡片。每个块都有「显示」开关;关掉只是不渲染,块本身留在数据里。
两条渲染纪律:
- 列表为空(或全关)时,页面回落到 `intro` 字段的 markdown 正文;`intro` 也空则用内置默认文案。三级回落,任何一级为空都不会出白页。
- 未知块型(手工编辑 yaml 写错的 `type`)会被**跳过并打印 `[blocks]` 警告**,不会炸构建;块数上限 24,单块文本上限 4000 字,超限截断并 WARN。
九类块与字段(文本字段一律 `{zh, en}` 双语对象):
| 块型 | 必填 | 可选 | 用途 |
|---|---|---|---|
| `prose` 正文 | `body` | `title`、`width`(`narrow` 版心 / `full` 通栏) | markdown 长文,最常用 |
| `tracks` 版图 | `title`、`items[].name` | `items[].icon`、`items[].desc` | 几条主线 / 能力面,图标白名单外或留空显示首字占位 |
| `cards` 卡片墙 | `items[].title` | `title`、`columns`(2 或 3,默认 3)、`items[].desc/href/tag` | 并列展示若干条目 |
| `timeline` 时间线 | `items[].time`、`items[].title` | `title`、`items[].desc` | 时间列是自由文本(`2024 — 2026` 也行) |
| `quote` 引文 | `text` | `cite` | 单句金句,配 `font-kai` 更出效果 |
| `figure` 图片 | `image` | `alt`、`caption` | **只能后台上传**(落 `content/site/assets/`,走图片管线),禁止外链 |
| `linklist` 链接清单 | `items[].label`、`items[].href` | `title`、`items[].note` | 相关阅读 / 外部资源 |
| `cta` 行动按钮 | `text`、`href`、`label` | — | 一页建议不超过一个 |
| `divider` 分隔 | — | `style`(`line` 细线 / `space` 纯留白) | 长页分段 |
yaml 形态示例(手工编辑时照此写):
```yaml
blocks:
- type: prose
title: { zh: 关于这个站点, en: About this site }
body:
zh: 这里写 markdown 正文,支持列表、粗斜体、链接与排版类。
en: Markdown body here.
width: narrow
- type: quote
text: { zh: 所谓暮色,就是光慢慢地承认自己要走。, en: Dusk is light admitting it has to go. }
cite: { zh: 本站, en: this site }
- type: figure
image: ./assets/desk.jpg # 后台会替你写成这个相对路径
caption: { zh: 工作台一角, en: A corner of the desk }
```
其它可用字段:`layout.showIdCard`(关掉则不渲染名片区,默认开)、`typography`(见下一节)。
**预览限制**:后台只提供字段编辑与列表摘要(摘要显示块型名),块的真实排版以下次构建后的页面为准——想调间距 / 字号,请用「主题设置」而不是在块里硬凑空行。
## 页面排版覆盖(每页一处)
首页 / 关于页 / 开源页各自有一个「页面排版覆盖(可选)」对象:
| 字段 | 取值 | 说明 |
|---|---|---|
| `font` | 字族下拉(10 项) | 本页**正文**默认字体类 |
| `accent` | 颜色下拉(18 项) | 本页强调色类 |
| `scale` | 0.8–1.25 | 字号缩放,1 = 全站默认 |
- 只作用于**该页内容容器**(挂 `.page-typo` + CSS 变量),不写全站 tokens,其它页面零影响。
- **体积成本提醒**:整页换艺术/展示字族(`font-huiwen` 汇文、`font-kuaile` 快乐体)意味着整页正文都要下载对应切片——单页字体成本会明显抬升,见写作指南的体积账。
- 白名单外的类名会被忽略(构建期过滤),写错不报错、也不生效。
## 主题设置(全站 17 个旋钮)
「主题设置」入口写 `content/site/theme.yaml`,17 项全部可选,留空 = 仓库默认值。**配错不会炸站**:越界值回落默认,组内单调性被破坏则整组回落。
| 组 | 字段 | 范围 | 约束语义 |
|---|---|---|---|
| 强调色 | `accent_light` / `accent_dark` | `#rrggbb` | 明暗各一份;构建期对比度闸门断言正文 ≥4.5:1,不达标构建失败 |
| 圆角 | `radius_img` | 4–16 | 图片圆角,建议 ≤14 |
| 标题字号 | `text_h1` / `text_h2` / `text_h3` | h1/h2:22–32,h3:16–32 | **必须严格递减**(h1 > h2 > h3),否则三档一起回落默认 |
| 正文字号 | `prose_size_reading` / `prose_size_tech` | 16–22 | 书评 / 技术文分轨独立 |
| 行高 | `leading_reading` / `leading_tech` | 1.5–2.0 | 同上分轨 |
| 间距梯度 | `space_3` / `space_4` / `space_5` / `space_6` | 8–80 | **必须严格递增**,否则整组回落 |
| 流距 | `flow_p` / `flow_h2_post` | 0–2(em) | 段间距 / 标题下间距 |
| 动效 | `dur` | 150–600(ms) | 过渡时长;`prefers-reduced-motion` 下自动归零 |
不能配的东西(有意为止):版心宽度、断点、纸墨色、代码配色——它们牵动版面结构与分轨身份,属于设计红线。注入方式是构建期内联 `:root:root{…}`,运行时零 JS、零请求。
## 首页与开源页的其余旋钮
### 首页三字段(5g 新增)
| 字段 | 取值 | 默认 | 作用 |
|---|---|---|---|
| `projectsLimit` | 1–99 | 6 | 首页项目墙条数(越界夹取) |
| `announcement` | `{zh, en}` markdown | 空 | 名片区下方公告;**空 = 不渲染**,走富文本管线(可写排版类 span) |
| `postsTracks` | `{reading, tech, photo}` 布尔 | 全开 | 关掉某轨,该轨文章不在首页列出(归档页仍可见) |
### 开源页(opensource.yaml)
**开篇是原生「GitHub 档案卡」(5h 起)**:头像 / 名字@handle / 定位叙述 / 社交图标 / 统计四格(关注者、公开仓、总星标)/ 语言占比灰阶条(只有第一名用强调色,其余灰阶)。**零第三方图片、零 iframe**。数据失败只隐藏对应数字,不会破版。
想换那段「定位叙述」,改本文件的 `blurb` + `blurb_after`(双语,后台可改)——它们现在同时供给档案卡与项目墙下方的说明。
下面的 `intro_from: "github-readme"` **仍可用但不推荐**:那会把 GitHub 专用 README 原样渲染,带 7 类外链图(shields.io、readme-typing-svg、github-readme-stats、streak-stats、activity-graph、snake、komarev 访客徽章)与 `<div align="center">` 居中——本站按普通块渲染,故居中失效;外链图在慢网或被墙时全空白。这也是当初"部分内容没居中、有些图片没显示"的根因。
| 字段 | 取值 | 说明 |
|---|---|---|
| `pinned` | 仓短名数组 | 置顶顺序即展示顺序 |
| `mode` | `all` / `only` / `exclude` | 可见性模式 |
| `include` / `exclude` | 仓短名数组 | 对应模式的白 / 黑名单 |
| `sort` | `pinned-first` / `stars` / `updated` | 排序 |
| `perSource` | `both` / `github` / `gitea` | 来源过滤 |
| `intro_from` | `"off"` / `"github-readme"` | 开篇文案来源(**带引号写**,YAML 1.1 里裸 `off` 是布尔 false) |
**`mode: only` 的"看不见"排查**:构建日志会给每个没显示出来的 include 项打一行 `[opensource] projects:include 里的「X」…`,并区分两种原因——双源结果里根本没有这个仓(拼写 / 私有仓 / 该源刚失败),或者命中了但被 `perSource` 过滤掉。看到哪一行就知道该改什么。
**README 开篇(`intro_from: "github-readme"`)的回落矩阵**,构建日志逐级点名:
1. 取到 → 内容有变化才刷新缓存 `content/.github-readme.md`(入仓,保证构建确定性)
2. token 缺位 / HTTP 非 2xx / 内容为空 / 超时(10 秒)/ 网络异常 → 改用缓存(日志标注"可能陈旧")
3. 缓存也不在 → 回落本文件 `blurb` 的叙述文案
4. 缓存回写失败(构建仓只读挂载等)→ 只告警,本次渲染照常
token 位在 `~/docker_apps/blog/github-token.txt`(本机,0600)。
**两条已知限制**:
- **README 里的相对链接不重写**(如 `./img/x.png`、`docs/foo.md`):它们相对 GitHub 仓根解析,在本站没有对应产物,点了多半 404。保原样比猜一个基址可信,所以不做改写。
- **外部徽章图片加固但不参与构建**:shields.io、readme-typing-svg 这类第三方徽章在构建期**不抓取**,渲染时统一补 `loading="lazy"`(缺 alt 补空),它们慢、被墙或 404 都不影响页面渲染;尺寸已由样式限制在内容宽度内。
## 浏览与评论计数(5h 新增)
文章页、瞬间页、开源页、关于页都用同一枚灰阶徽标:**眼睛图标 = 浏览数,气泡图标 = 条评论**,数据来自自建 Artalk,不依赖任何第三方徽章服务。
几条行为约定(省得当成 bug):
- **一页只计一次浏览**:「计一次」发生在这页唯一那个 `page` 模式徽标(文章页在标题下方),同一页其余徽标都是只读镜像,显示的是同一个数字——不会出现"页头 17、评论区 16"
- **开评论不算浏览**:瞬间逐条徽标是只读的,读者展开评论区不会给那条动态虚增浏览量
- **接口挂掉显示「–」而不是 0**:宁可留白,也不给读者看一个错误的零
- **换页重计、同页刷新重计**:这是浏览量的定义,不是重复计数的 bug(软导航回同一页也算新一次访问)
## 纪律(与写作指南同源)
- **图片体积**:上传前自行压缩(长边 ≤2400px)——Sveltia 内建优化**刻意关闭**(会抹 EXIF 断构建期提取管线)
+9
View File
@@ -33,10 +33,19 @@ pnpm astro build
# 按页字体 CSS 子集化(车道 A 任务 1):裁 @font-face 声明数,构建仓与本地同链;
# 存在性守卫:新文件未推送到 main 前构建仓无此脚本,绝不让发布链因车道在途文件断掉
[ -f scripts/build-perf/subset-font-css.mjs ] && node scripts/build-perf/subset-font-css.mjs dist || echo "[build] subset 脚本未就绪,跳过"
[ -f scripts/build-perf/config-check.py ] && python3 scripts/build-perf/config-check.py || echo "[build] config-check 未就绪,跳过"
[ -f scripts/build-perf/contrast-check.mjs ] && node scripts/build-perf/contrast-check.mjs dist || echo "[build] contrast 脚本未就绪,跳过"
# 首页阻塞 CSS 内联(车道 D):守卫先行同上——脚本未到 main 前绝不炸链
[ -f scripts/build-perf/gen-sw-precache.mjs ] && node scripts/build-perf/gen-sw-precache.mjs dist || echo "[build] sw-precache 生成器未就绪,跳过"
[ -f scripts/build-perf/inline-home-css.mjs ] && node scripts/build-perf/inline-home-css.mjs || echo "[build] inline-home 脚本未就绪,跳过"
# 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 未就绪,跳过"
+87 -9
View File
@@ -38,6 +38,7 @@ collections:
- { label: 日期, name: pubDate, widget: datetime, date_format: 'YYYY-MM-DD', time_format: false, format: 'YYYY-MM-DD' }
- { label: 分轨, name: category, widget: select, options: [{label: 阅读, value: reading}, {label: 技术, value: tech}, {label: 照片, value: photo}] }
- { label: 标签, name: tags, widget: list, required: false }
- { label: 置顶, name: pinned, widget: boolean, required: false, default: false, hint: '置顶文章排在首页/归档/书架最前;只影响顺序,不改日期、URL 与 RSS 时间语义' }
- { label: 情绪色(书评色脊), name: mood, widget: color, required: false, hint: '#RRGGBB,仅阅读轨使用' }
- { label: 原书作者, name: bookAuthor, widget: string, required: false }
- { label: 一句话描述(书架策展语), name: description, widget: text, required: false }
@@ -58,6 +59,34 @@ collections:
- { label: 草稿, name: draft, widget: boolean, required: false, default: false }
- { label: 正文, name: body, widget: markdown }
# 主题旋钮(样式即数据 · 层 4):只放白名单内的 9 组,越界值会被构建期钳制
- name: site_theme
label: 主题设置
# 文件集合必须是 files: [{file,label,fields}] 结构;把 file 直接写在集合层级会被 Sveltia 判非法(2026-11-02 实测报 "The file option ... is invalid")
files:
- name: theme
label: 主题设置
file: content/site/theme.yaml
format: yaml
fields:
- { label: 强调色(亮), name: accent_light, widget: color, required: false, hint: '亮色模式强调色,如 #3454d1;构建期校验正文对比度 ≥4.5' }
- { label: 强调色(暗), name: accent_dark, widget: color, required: false, hint: '暗色模式强调色,如 #8ea2ff' }
- { label: 图片圆角, name: radius_img, widget: number, required: false, value_type: int, min: 4, max: 16, hint: '范围 4–16(建议 ≤14)' }
- { label: 一级标题字号, name: text_h1, widget: number, required: false, value_type: int, min: 22, max: 32, hint: '22–32,且必须大于 h2' }
- { label: 二级标题字号, name: text_h2, widget: number, required: false, value_type: int, min: 22, max: 32, hint: '22–32,必须小于 h1、大于 h3' }
- { label: 三级标题字号, name: text_h3, widget: number, required: false, value_type: int, min: 16, max: 32, hint: '16–32,必须小于 h2' }
- { label: 书评正文字号, name: prose_size_reading, widget: number, required: false, value_type: int, min: 16, max: 22, hint: '16–22' }
- { label: 技术文正文字号, name: prose_size_tech, widget: number, required: false, value_type: int, min: 16, max: 22, hint: '16–22' }
- { label: 书评行高, name: leading_reading, widget: number, required: false, value_type: float, min: 1.5, max: 2.0, step: 0.05, hint: '1.5–2.0' }
- { label: 技术文行高, name: leading_tech, widget: number, required: false, value_type: float, min: 1.5, max: 2.0, step: 0.05, hint: '1.5–2.0' }
- { label: 间距 3, name: space_3, widget: number, required: false, value_type: int, min: 8, max: 80, hint: '8–80,必须 < 间距 4' }
- { label: 间距 4, name: space_4, widget: number, required: false, value_type: int, min: 8, max: 80, hint: '8–80,必须 > 间距 3 且 < 间距 5' }
- { label: 间距 5, name: space_5, widget: number, required: false, value_type: int, min: 8, max: 80, hint: '8–80,必须 > 间距 4 且 < 间距 6' }
- { label: 间距 6, name: space_6, widget: number, required: false, value_type: int, min: 8, max: 80, hint: '8–80,必须 > 间距 5' }
- { label: 段间距, name: flow_p, widget: number, required: false, value_type: float, min: 0, max: 2, step: 0.05, hint: '0–2(em)' }
- { label: 标题下间距, name: flow_h2_post, widget: number, required: false, value_type: float, min: 0, max: 2, step: 0.05, hint: '0–2(em)' }
- { label: 过渡时长, name: dur, widget: number, required: false, value_type: int, min: 150, max: 600, hint: '150–600(毫秒)' }
# 瞬间:随手发(text/image 至少其一——Sveltia 无法表达条件必填,两者都设可选,
# 保存前自查;pubDate 必填)。图片走图库媒体库(content/photos 同源可复用)
- name: moments
@@ -69,7 +98,11 @@ collections:
sortable_fields: [pubDate, text]
fields:
- { label: 时间, name: pubDate, widget: datetime, date_format: 'YYYY-MM-DD', time_format: false, format: 'YYYY-MM-DD' }
- { label: 文字, name: text, widget: text, required: false, hint: '一句话(与图片至少填一项)' }
- label: 文字
name: text
widget: markdown
required: false
hint: '与图片至少填一项。支持 markdown(**加粗**、列表、> 引用)与排版类 HTML,例如 <span class="c-blue font-kai">彩色+楷体</span>;装饰色仍守“外壳灰阶、彩色只作强调”的纪律'
- { label: 图片, name: image, widget: image, required: false, hint: '与文字至少填一项;建议先压缩' }
- label: 标签
name: tags
@@ -113,12 +146,19 @@ collections:
- name: home
label: 主页设置
file: content/site/home.yaml
format: yaml
# 头像上传落 content/site/assets/,yaml 写 ./assets/xxx(Astro 图片管线优化)
media_folder: content/site/assets
public_folder: ./assets
fields:
- label: 身份语
name: tagline
widget: object
required: false
hint: 首页大字身份语(双语,markdown 富文本);可写 <span class="font-kai c-blue">…</span> 做局部强调
fields:
- { label: 中文, name: zh, widget: string, required: false }
- { label: 英文, name: en, widget: string, required: false }
- label: 页面排版覆盖(可选)
name: typography
widget: object
@@ -129,17 +169,32 @@ collections:
name: font
widget: select
required: false
hint: 整页正文换字族;艺术/展示类字族(汇文、快乐体)体积大,单页成本会上升
options: [font-serif, font-kai, font-sans, font-mono, font-song, font-kai-sys, font-hei-sys, font-fangsong, font-huiwen, font-kuaile]
- label: 强调色类
name: accent
widget: select
required: false
options: [ink-accent, ink-accent-soft, c-blue, c-blue-soft, c-teal, c-teal-soft, c-green, c-green-soft, c-yellow, c-yellow-soft, c-orange, c-orange-soft, c-red, c-red-soft, c-pink, c-pink-soft, c-purple, c-purple-soft]
- { label: 字号缩放, name: scale, widget: number, required: false, value_type: float, min: 0.8, max: 1.25, step: 0.05 }
- { label: 字号缩放, name: scale, widget: number, required: false, value_type: float, min: 0.8, max: 1.25, step: 0.05, hint: '0.8–1.25;1 = 全站默认' }
- label: 公告(可选,markdown)
name: announcement
widget: object
required: false
hint: 渲染在名片区下方;留空则不显示公告块
fields:
- { label: 中文, name: zh, widget: string, required: false }
- { label: 英文, name: en, widget: string, required: false }
- { label: 中文, name: zh, widget: markdown, required: false }
- { label: 英文, name: en, widget: markdown, required: false }
- { label: 项目墙条数, name: projectsLimit, widget: number, required: false, value_type: int, min: 1, max: 99, hint: '首页项目墙显示几条(1–99,缺省 6)' }
- label: 轨细分开关(首页文章列表)
name: postsTracks
widget: object
required: false
hint: 关掉某轨则该轨文章不在首页列出(全开=现状)
fields:
- { label: 阅读轨, name: reading, widget: boolean, required: false, default: true }
- { label: 技术轨, name: tech, widget: boolean, required: false, default: true }
- { label: 照片轨, name: photo, widget: boolean, required: false, default: true }
- label: 副标题(可选,markdown 短文本)
name: subtitle
widget: object
@@ -200,6 +255,7 @@ collections:
- name: about
label: 关于页
file: content/site/about.yaml
format: yaml
# 块内图片上传落 content/site/assets/,yaml 写 ./assets/xxx(Astro 图片管线)
media_folder: content/site/assets
public_folder: ./assets
@@ -215,6 +271,7 @@ collections:
- label: 正文(markdown)
name: prose
widget: object
hint: markdown 正文块;可选小标题与版心/通栏宽度,长文分段改内容用
fields:
- { label: 显示, name: enabled, widget: boolean, required: false, default: true }
- label: 小标题(可选)
@@ -239,6 +296,7 @@ collections:
- label: 版图(图标+名称+描述)
name: tracks
widget: object
hint: 版图块:图标 + 名称 + 描述,适合「几条主线/能力面」式介绍
fields:
- { label: 显示, name: enabled, widget: boolean, required: false, default: true }
- label: 标题
@@ -274,6 +332,7 @@ collections:
- label: 卡片(2/3 列)
name: cards
widget: object
hint: 卡片墙(2/3 列):每卡 = 标题 + 描述 + 可选链接/角标
fields:
- { label: 显示, name: enabled, widget: boolean, required: false, default: true }
- label: 标题(可选)
@@ -313,6 +372,7 @@ collections:
- label: 时间线
name: timeline
widget: object
hint: 时间线:一条时间 + 标题 + 描述;时间可写自由文本(如 2024 — 2026)
fields:
- { label: 显示, name: enabled, widget: boolean, required: false, default: true }
- label: 标题(可选)
@@ -344,6 +404,7 @@ collections:
- label: 引文
name: quote
widget: object
hint: 单句引文 + 可选署名;配 font-kai 排版效果更好
fields:
- { label: 显示, name: enabled, widget: boolean, required: false, default: true }
- label: 引文
@@ -362,6 +423,7 @@ collections:
- label: 图片
name: figure
widget: object
hint: 单图 + 可选说明;图片只能后台上传(落 content/site/assets),禁外链
fields:
- { label: 显示, name: enabled, widget: boolean, required: false, default: true }
- { label: 图片(必填,请用后台上传), name: image, widget: image, hint: '禁止填外链:只能上传(落 content/site/assets/),走 Astro 图片管线' }
@@ -376,6 +438,7 @@ collections:
- label: 链接清单
name: linklist
widget: object
hint: 链接清单(标签 + 链接 + 备注),适合「相关阅读 / 外部资源」
fields:
- { label: 显示, name: enabled, widget: boolean, required: false, default: true }
- label: 标题(可选)
@@ -407,6 +470,7 @@ collections:
- label: 行动按钮
name: cta
widget: object
hint: 行动按钮(文案 + 链接 + 按钮字);一页建议不超过一个
fields:
- { label: 显示, name: enabled, widget: boolean, required: false, default: true }
- label: 说明文字
@@ -425,6 +489,7 @@ collections:
- label: 分隔
name: divider
widget: object
hint: 分隔块:细分隔线或纯留白,给长页分段
fields:
- { label: 显示, name: enabled, widget: boolean, required: false, default: true }
- label: 样式
@@ -468,9 +533,25 @@ collections:
- name: opensource
label: 开源页
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
required: false
hint: 富文本(markdown + 排版类),渲染在仓库链接之前
fields:
- { label: 中文, name: zh, widget: string, required: false }
- { label: 英文, name: en, widget: string, required: false }
- label: 页面排版覆盖(可选)
name: typography
widget: object
@@ -481,17 +562,14 @@ collections:
name: font
widget: select
required: false
hint: 整页正文换字族;艺术/展示类字族(汇文、快乐体)体积大,单页成本会上升
options: [font-serif, font-kai, font-sans, font-mono, font-song, font-kai-sys, font-hei-sys, font-fangsong, font-huiwen, font-kuaile]
- label: 强调色类
name: accent
widget: select
required: false
options: [ink-accent, ink-accent-soft, c-blue, c-blue-soft, c-teal, c-teal-soft, c-green, c-green-soft, c-yellow, c-yellow-soft, c-orange, c-orange-soft, c-red, c-red-soft, c-pink, c-pink-soft, c-purple, c-purple-soft]
- { label: 字号缩放, name: scale, widget: number, required: false, value_type: float, min: 0.8, max: 1.25, step: 0.05 }
widget: object
fields:
- { label: 中文, name: zh, widget: string, required: false }
- { label: 英文, name: en, widget: string, required: false }
- { label: 字号缩放, name: scale, widget: number, required: false, value_type: float, min: 0.8, max: 1.25, step: 0.05, hint: '0.8–1.25;1 = 全站默认' }
- { label: 仓库链接地址, name: repo_url, widget: string, required: false }
- { label: 仓库链接文字, name: repo_label, widget: string, required: false }
- label: 叙述(链接后)
+1
View File
@@ -18,6 +18,7 @@ window.TYPOGRAPHY_CLASSES = {
{ cls: 'font-fangsong', label: '仿宋族', family: 'FangSong, STFangsong, serif', kind: '系统回退' },
{ cls: 'font-huiwen', label: '汇文明朝体', family: '"WF-Hwmc", serif', kind: '自托管' },
{ cls: 'font-kuaile', label: '站酷快乐体', family: '"ZCOOL KuaiLe", serif', kind: '自托管' },
{ cls: 'font-art-kuaile', label: '快乐体(艺术·仅标题)', family: '"ZCOOL KuaiLe", serif', kind: '艺术字' },
],
color: [
{ cls: 'ink-1', label: '最重(标题级)', swatch: '#111113' },
+22
View File
@@ -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"
}
}
}
+1
View File
@@ -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

+111
View File
@@ -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

+111
View File
@@ -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

+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""
站点 YAML 配置结构校验(2026-11-02 教训驱动)
为什么需要:Python 默认 yaml.safe_load 对重复键是"后者覆盖前者"的静默行为,
收编时用它验收会漏。本包实测 public/admin/config.yml 里 typography 被插进双语
字段的 name: 与 widget: object 之间 → 同一 mapping 出现重复键 widget/fields,
后台双语编辑器退化成字符串、排版选择器字段被劫持。本脚本用自定义构造器在
解析阶段对重复键直接报错,并附带后台 hint 覆盖率体检。
用法:python3 scripts/build-perf/config-check.py [repo根=.]
退出码:0 通过;1 有失败项
"""
import sys
from pathlib import Path
try:
import yaml
except ImportError:
print('[config-check] 缺少 pyyaml:pip install pyyaml')
sys.exit(1)
FILES = [
'public/admin/config.yml',
'content/site/home.yaml',
'content/site/about.yaml',
'content/site/opensource.yaml',
'content/site/theme.yaml',
]
class StrictLoader(yaml.SafeLoader):
"""对重复键报错的 SafeLoader(默认行为是静默覆盖)。"""
def _construct_mapping(loader, node, deep=False):
seen = set()
for key_node, _value_node in node.value:
key = loader.construct_object(key_node, deep=deep)
if key in seen:
raise yaml.YAMLError(
f'duplicate key {key!r} at line {key_node.start_mark.line + 1}'
)
seen.add(key)
return yaml.SafeLoader.construct_mapping(loader, node, deep=deep)
StrictLoader.add_constructor(
yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _construct_mapping
)
def main() -> int:
root = Path(sys.argv[1] if len(sys.argv) > 1 else '.')
failed = 0
loaded = {}
for rel in FILES:
path = root / rel
if not path.exists():
print(f'[config-check] SKIP {rel}(不存在)')
continue
try:
loaded[rel] = yaml.load(path.read_text(encoding='utf-8'), Loader=StrictLoader)
print(f'[config-check] OK {rel}')
except yaml.YAMLError as exc:
failed += 1
first = str(exc).splitlines()[0]
print(f'[config-check] FAIL {rel}\n {first}')
admin = loaded.get('public/admin/config.yml')
if admin:
# 结构断言(2026-11-02 教训):每个集合必须二选一——folder 集合或
# files 文件集合;files 项必须齐 file/label/fields,且 file 指向真实文件。
# 把 file 直接写在集合层级会被 Sveltia 判非法(后台整体报错)。
for coll in admin.get('collections', []):
name = coll.get('name', '<unnamed>')
has_folder, has_files = 'folder' in coll, 'files' in coll
if not has_folder and not has_files:
failed += 1
print(f'[config-check] FAIL 集合 {name}: 既无 folder 也无 files(Sveltia 会拒绝加载)')
if has_files:
for entry in coll.get('files') or []:
miss = [k for k in ('file', 'label', 'fields') if k not in entry]
if miss:
failed += 1
print(f'[config-check] FAIL 集合 {name} 的文件项缺 {miss}')
continue
if not (root / entry['file']).exists():
failed += 1
print(f"[config-check] FAIL 集合 {name}: 文件不存在 {entry['file']}")
if admin:
fields = hints = 0
def walk(node):
nonlocal fields, hints
if isinstance(node, dict):
if 'name' in node:
fields += 1
if 'hint' in node:
hints += 1
for value in node.values():
walk(value)
elif isinstance(node, list):
for value in node:
walk(value)
walk(admin)
print(f'[config-check] 后台字段 {fields} 个,其中带 hint {hints} 个')
if hints < 20:
failed += 1
print('[config-check] FAIL hint 覆盖不足(<20),后台字段缺少中文说明')
if failed:
print(f'[config-check] {failed} 项未通过')
return 1
print('[config-check] 全部通过')
return 0
if __name__ == '__main__':
raise SystemExit(main())
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env node
/**
* 主题对比度闸门(样式即数据 · 层 4):从**构建产物**里读实际生效的 CSS 变量,
* 断言正文与强调色在两态下的对比度都 ≥4.5:1。构建期跑,失败即 exit 1 拦住发布。
*
* 为什么读产物而不是读 yaml:断言的对象必须是"用户最终看到的颜色"——
* theme.yaml 经 theme.ts 钳制/回落后的真值只存在于 dist 内联样式里。
*
* 用法:node scripts/build-perf/contrast-check.mjs [distRoot]
*/
import { readFileSync, existsSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
const distRoot = process.argv[2] ?? 'dist';
const MIN = 4.5;
const read = (p) => readFileSync(p, 'utf8');
function normalize(hex) {
const h = hex.replace('#', '');
return '#' + (h.length === 3 ? h.split('').map((c) => c + c).join('') : h);
}
function lum(hex) {
const h = normalize(hex).replace('#', '');
const [r, g, b] = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255);
const f = (c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
}
function ratio(a, b) {
const [la, lb] = [lum(a), lum(b)];
const [hi, lo] = [Math.max(la, lb), Math.min(la, lb)];
return (hi + 0.05) / (lo + 0.05);
}
// ── 1. 从产物取主题变量(内联 style 的 :root:root / [data-theme=dark] 块) ──
let html = '';
if (!existsSync(distRoot)) {
console.error(`[contrast] dist 不存在:${distRoot}(先 build)`);
process.exit(1);
}
const htmlFile = join(distRoot, 'index.html');
if (existsSync(htmlFile)) html = read(htmlFile);
else {
// 兜底:任取一个页面
const walk = (d) => {
for (const e of readdirSync(d, { withFileTypes: true })) {
const p = join(d, e.name);
if (e.isDirectory()) {
const r = walk(p);
if (r) return r;
} else if (e.name === 'index.html') return p;
}
return null;
};
const f = walk(distRoot);
if (f) html = read(f);
}
if (!html) {
console.error('[contrast] 产物里找不到任何页面');
process.exit(1);
}
const varOf = (scope, name) => {
const re = new RegExp(`${scope.replace(/[[\]]/g, '\\$&')}[^{]*\\{([^}]*)\\}`, 'g');
let m;
while ((m = re.exec(html))) {
const hit = new RegExp(`${name}\\s*:\\s*(#[0-9a-fA-F]{3,6})`).exec(m[1]);
if (hit) return hit[1];
}
return null;
};
// tokens.css 的底色与正文色(不在主题旋钮白名单内,故从产物 CSS 取)
const cssFiles = [];
const walkCss = (d) => {
for (const e of readdirSync(d, { withFileTypes: true })) {
const p = join(d, e.name);
if (e.isDirectory()) walkCss(p);
else if (e.name.endsWith('.css')) cssFiles.push(p);
}
};
walkCss(distRoot);
let tokensCss = '';
for (const f of cssFiles) {
const c = read(f);
// 必须是**定义**(--bg:#xxx),而不是 var(--bg-soft) 这类引用——
// 后者会误命中只用到变量名、并不定义 base token 的页面级 CSS
if (/--ink-2\s*:\s*#/.test(c) && /--bg\s*:\s*#/.test(c)) { tokensCss = c; break; }
}
const tokenVar = (name) => {
const m = new RegExp(`${name}\\s*:\\s*(#[0-9a-fA-F]{3,6})`).exec(tokensCss);
return m ? m[1] : null;
};
const checks = [];
const bgLight = tokenVar('--bg');
const bgDark = (() => {
// 暗态 --bg 在 [data-theme=dark] 块里
const m = /\[data-theme=["']?dark["']?\][^{]*\{([^}]*)\}/.exec(tokensCss);
if (!m) return null;
const h = /--bg\s*:\s*(#[0-9a-fA-F]{3,6})/.exec(m[1]);
return h ? h[1] : null;
})();
const ink2Light = tokenVar('--ink-2');
const ink2Dark = (() => {
const m = /\[data-theme=["']?dark["']?\][^{]*\{([^}]*)\}/.exec(tokensCss);
if (!m) return null;
const h = /--ink-2\s*:\s*(#[0-9a-fA-F]{3,6})/.exec(m[1]);
return h ? h[1] : null;
})();
const accentLight = varOf(':root:root', '--accent');
const accentDark = (() => {
const m = /\[data-theme=["']?dark["']?\]:root:root\{([^}]*)\}/.exec(html);
if (!m) return null;
const h = /--accent\s*:\s*(#[0-9a-fA-F]{3,6})/.exec(m[1]);
return h ? h[1] : null;
})();
if (bgLight && ink2Light) checks.push(['正文(亮)', ink2Light, bgLight]);
if (bgDark && ink2Dark) checks.push(['正文(暗)', ink2Dark, bgDark]);
if (accentLight && bgLight) checks.push(['强调色(亮)', accentLight, bgLight]);
if (accentDark && bgDark) checks.push(['强调色(暗)', accentDark, bgDark]);
if (checks.length === 0) {
console.error('[contrast] 未能从产物解析出主题/正文色变量,请检查注入是否生效');
process.exit(1);
}
let failed = 0;
for (const [label, fg, bg] of checks) {
const r = ratio(fg, bg);
const ok = r >= MIN;
if (!ok) failed++;
console.log(`[contrast] ${ok ? 'PASS' : 'FAIL'} ${label} ${fg} on ${bg} = ${r.toFixed(2)}:1`);
}
if (failed > 0) {
console.error(`[contrast] ${failed} 项低于 ${MIN}:1 —— 主题配色不可读,请调亮/调暗后再构建`);
process.exit(1);
}
console.log(`[contrast] 全部通过(${checks.length} 项 ≥ ${MIN}:1)`);
+121
View File
@@ -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);
+37 -97
View File
@@ -7,79 +7,25 @@ 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);
---
<!-- pv 小字:计数由本组件下方脚本独立 POST(页面加载即计),与 Artalk init 的 pvAdd 解耦 -->
<p class="atk-pv-line" aria-label={t('pv_viewed', locale)}>
<span data-artalk-pv data-page-key={pageKey} data-page-title={pageTitle}>–</span>
{' '}{t('pv_viewed', locale)}
</p>
<!-- 浏览/评论徽标(Phase 5h T4):评论区这枚是 **readonly** —— 整页的计数点唯一在
详情页标题下的 ViewMeta(mode="page"),此处只读展示,避免同页两处各计一次。 -->
<ViewMeta pageKey={pageKey} locale={locale} mode="readonly" variant="chip" />
<div id="artalk-container" data-page-key={pageKey} data-page-title={pageTitle}></div>
<p class="atk-load-hint" id="artalk-retry" hidden>
<span>评论区加载失败。</span>
<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;
@@ -100,37 +46,11 @@ const locale = localeOf(Astro.url.pathname);
cursor: pointer;
}
.atk-retry-btn:hover { border-color: var(--accent); }
.atk-pv-line {
font-family: var(--font-mono);
font-size: 12px;
color: var(--ink-4);
margin: var(--space-2) 0 0;
}
</style>
<script>
// 访问量:POST /artalk/api/v2/pages/pv 计数并取数(POST 天然绕过 ESA 边缘缓存;
// GET /stats/* 会被 ESA 缓存出冻结数字,勿改查询式)。page-load 兼容软导航换页重计。
const bumpPv = async () => {
const el = document.querySelector('[data-artalk-pv]');
if (!el) return;
const key = el.dataset.pageKey ?? '';
if (!key) return;
try {
const res = await fetch('/artalk/api/v2/pages/pv', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ page_key: key, page_title: el.dataset.pageTitle ?? '', site_name: 'wait4x.online' }),
});
const d = await res.json();
el.textContent = String(d.pv ?? '–');
} catch {
el.textContent = '–'; // 计数失败静默:小字不可见性优于报错
}
};
document.addEventListener('astro:page-load', () => void bumpPv());
void bumpPv();
// 浏览计数已移交 ViewMeta 组件(Phase 5h T4),此处只管评论挂载。
//
// ── 挂载健壮化(用户实测「总没加载出来」)──
// 触发路径三条:IO 进入视口(主)/ 点击评论区锚点(兜底)/ 滚到页底 2s 后(兜底);
// 失败路径:脚本 5s 未 onload → 灰阶提示 + 重试钮;成功后容器打 data-artalk-mounted 供 E2E 断言。
@@ -140,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();
@@ -165,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',
+4
View File
@@ -154,6 +154,10 @@ const switchLabel = curLocale === 'zh' ? 'EN' : '中';
阈值 350→450(E 车道 6 项后 en 词长实测:360-430px 页溢 42-51px;zh 无感知) */
@media (max-width: 450px) {
nav ul { overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
/* 视觉终审 P1:flex 子项默认可压缩,中文词被压到就地折成两行(开/源),
overflow-x 永远不触发。钉死不许压缩 + 不许换行,内滚才真正接管 */
nav ul > li { flex: none; }
.nav-link { white-space: nowrap; }
nav { min-width: 0; }
.header-inner { flex-wrap: nowrap; }
/* 中段再收一档地板(en 五词在 360-430 差 55-61px):字号 11px 下限仍可读,
+240
View File
@@ -0,0 +1,240 @@
---
/**
* 瞬间逐条评论(Phase 5h T3):每条瞬间一个 `<details>` 折叠区,**展开才首次挂载 Artalk**。
*
* 为什么惰性挂载:时间线一屏能有十几条,若每张卡常驻一个 Artalk 实例,内存与请求
* 都白付;Artalk 官方也未背书"同页多实例长期并存"。于是「展开才挂」是折中:
* 读者点了才付成本,未点零开销(连 Artalk.js 都不下载)。
*
* 评论流约定:`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';
interface Props {
/** 瞬间条目 id(即文件名 slug),用于生成评论流键与容器 id */
slug: string;
locale?: Locale;
}
const { slug, locale = 'zh' } = Astro.props;
/** 评论流键:双语共用的虚拟路径(形如 /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);
const failText = locale === 'zh' ? '评论加载失败' : 'Comments failed to load';
const retryText = locale === 'zh' ? '重试' : 'Retry';
---
<div class="mom-cm">
<details class="mom-cm-box" data-mom-comments data-page-key={pageKey} data-page-title={`瞬间 · ${slug}`}>
<summary class="mom-cm-sum">
<span class="mom-cm-label">{label}</span>
<svg class="mom-cm-chev" viewBox="0 0 16 16" width="12" height="12" aria-hidden="true">
<path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
</summary>
<div class="mom-cm-body">
<div class="mom-cm-panel" id={panelId} data-artalk-panel></div>
<p class="mom-cm-fail" hidden>
<span>{failText}</span>
<button type="button" class="mom-cm-retry">{retryText}</button>
</p>
</div>
</details>
<!-- 与文章页共用同一份 Artalk 主题映射(瞬间容器 mom-atk-* 无此映射时会回落官方默认色) -->
<ArtalkThemeStyles />
<div class="mom-cm-meta">
<ViewMeta pageKey={pageKey} locale={locale} mode="readonly" showPv={false} />
</div>
</div>
<script>
// ── 惰性挂载:details 首次展开才拉 Artalk 库并 init ──
// toggle 事件不冒泡,但捕获阶段能拿到;astro:page-load 后重新扫描(软导航换页)
const SITE = 'wait4x.online';
const SERVER = 'https://wait4x.online/artalk';
const LIB_CSS = 'https://wait4x.online/artalk/dist/Artalk.css';
const LIB_JS = 'https://wait4x.online/artalk/dist/Artalk.js';
type ArtalkLib = { init: (opts: Record<string, unknown>) => unknown };
interface Mounted {
/** 已 init 的容器 id,避免重复 init(详情反复开合只在首次付成本) */
[key: string]: boolean;
}
const mounted: Mounted = {};
const pending: { [key: string]: boolean } = {};
/** 同页多实例:记录已 init 的容器,供排障(window.__momArtalk 可查) */
const instances: string[] = [];
(window as unknown as { __momArtalk?: string[] }).__momArtalk = instances;
const lib = () => (window as unknown as { Artalk?: ArtalkLib }).Artalk;
const loadAssets = () =>
new Promise<void>((done) => {
// 复用全站同一对资产 id:文章页已加载过就不再重复拉
if (document.getElementById('artalk-lib') && lib()) return done();
const cssReady = new Promise<void>((cssDone) => {
if (document.getElementById('artalk-css')) return cssDone();
const link = document.createElement('link');
link.id = 'artalk-css';
link.rel = 'stylesheet';
link.href = LIB_CSS;
link.onload = () => cssDone();
link.onerror = () => cssDone(); // CSS 失败不阻塞挂载(降级 UA 样式)
document.head.appendChild(link);
});
const existing = document.getElementById('artalk-lib') as HTMLScriptElement | null;
if (existing) {
// 脚本标签已在但库还没就绪:等它 onload
existing.addEventListener('load', () => void cssReady.then(() => done()));
if (lib()) void cssReady.then(() => done());
return;
}
const s = document.createElement('script');
s.id = 'artalk-lib';
s.src = LIB_JS;
s.onload = () => void cssReady.then(() => done());
s.onerror = () => done(); // 失败也让调用方走"库不存在"分支显示重试
document.head.appendChild(s);
});
const mountOne = async (box: HTMLDetailsElement) => {
const key = box.dataset.pageKey ?? '';
const panel = box.querySelector<HTMLElement>('[data-artalk-panel]');
const fail = box.querySelector<HTMLElement>('.mom-cm-fail');
if (!key || !panel) return;
const id = panel.id;
if (mounted[id] || pending[id]) return;
pending[id] = true;
const showFail = (show: boolean) => {
if (fail) fail.hidden = !show;
};
const timer = window.setTimeout(() => {
if (!mounted[id]) showFail(true);
}, 5000);
await loadAssets();
const a = lib();
if (!a) {
window.clearTimeout(timer);
pending[id] = false;
showFail(true);
return;
}
try {
a.init({
el: `#${id}`,
server: SERVER,
site: SITE,
pageKey: key,
pageTitle: box.dataset.pageTitle ?? '',
locale: 'zh-CN',
// 明暗交给站点 tokens 接管(与文章页同策略),pv 不计(ViewMeta 只读)
darkMode: false,
pvAdd: false,
});
mounted[id] = true;
panel.dataset.artalkMounted = '1';
instances.push(id);
showFail(false);
} catch {
showFail(true);
} finally {
window.clearTimeout(timer);
pending[id] = false;
}
};
/** 展开时挂载;重复展开只挂一次 */
const onToggle = (target: EventTarget | null) => {
const box = target as HTMLDetailsElement | null;
if (!box || box.tagName !== 'DETAILS' || !box.hasAttribute('data-mom-comments')) return;
if (box.open) void mountOne(box);
};
document.addEventListener('toggle', (e) => onToggle(e.target), true);
// 重试钮:失败后点一下重新挂(清掉挂载标记再走一遍)
document.addEventListener('click', (e) => {
const btn = (e.target as HTMLElement)?.closest?.('.mom-cm-retry');
if (!btn) return;
const box = btn.closest<HTMLDetailsElement>('details[data-mom-comments]');
const panel = box?.querySelector<HTMLElement>('[data-artalk-panel]');
if (!box || !panel) return;
delete mounted[panel.id];
void mountOne(box);
});
// 软导航回到本页时,已展开的(浏览器可能保留 open 状态)补挂一次
document.addEventListener('astro:page-load', () => {
for (const box of document.querySelectorAll<HTMLDetailsElement>('details[data-mom-comments][open]')) {
void mountOne(box);
}
});
</script>
<style>
.mom-cm {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
margin-top: var(--space-2);
padding-top: var(--space-2);
border-top: 1px dashed var(--line);
}
.mom-cm-box {
flex: 1;
min-width: 0;
}
.mom-cm-sum {
display: inline-flex;
align-items: center;
gap: 5px;
font-family: var(--font-mono);
font-size: 12px;
color: var(--ink-3);
cursor: pointer;
list-style: none;
user-select: none;
}
.mom-cm-sum::-webkit-details-marker { display: none; }
.mom-cm-sum:hover { color: var(--ink-2); }
.mom-cm-chev { transition: rotate var(--dur) var(--ease); }
details[open] > .mom-cm-sum .mom-cm-chev { rotate: 180deg; }
.mom-cm-body { margin-top: var(--space-2); }
.mom-cm-panel { font-size: 13px; }
.mom-cm-meta { flex: none; }
.mom-cm-fail {
display: flex;
align-items: center;
gap: var(--space-2);
margin: var(--space-2) 0 0;
font-size: 12.5px;
color: var(--ink-4);
}
.mom-cm-retry {
font-family: var(--font-mono);
font-size: 12px;
color: var(--ink-2);
background: var(--bg);
border: 1px solid var(--line);
border-radius: 6px;
padding: 2px 10px;
cursor: pointer;
}
.mom-cm-retry:hover { border-color: var(--accent); }
</style>
+21
View File
@@ -89,6 +89,9 @@ const byMonth = (list: CollectionEntry<'posts'>[]) => {
/>
)}
<span class="row-title">{titleOverrides?.[post.id] ?? post.data.title}</span>
{post.data.pinned && (
<span class="pin" title={t('pinned_label', locale)}>{t('pinned_label', locale)}</span>
)}
{key !== 'reading' &&
post.data.tags.map((tag) => <code class="tag">{`#${tag}`}</code>)}
<time class="row-date" datetime={post.data.pubDate.toISOString()}>
@@ -130,6 +133,9 @@ const byMonth = (list: CollectionEntry<'posts'>[]) => {
/>
)}
<span class="row-title">{titleOverrides?.[post.id] ?? post.data.title}</span>
{post.data.pinned && (
<span class="pin" title={t('pinned_label', locale)}>{t('pinned_label', locale)}</span>
)}
{post.data.category !== 'reading' &&
post.data.tags.map((tag) => <code class="tag">{`#${tag}`}</code>)}
<time class="row-date" datetime={post.data.pubDate.toISOString()}>
@@ -174,6 +180,9 @@ const byMonth = (list: CollectionEntry<'posts'>[]) => {
/>
)}
<span class="row-title">{titleOverrides?.[post.id] ?? post.data.title}</span>
{post.data.pinned && (
<span class="pin" title={t('pinned_label', locale)}>{t('pinned_label', locale)}</span>
)}
{cat !== 'reading' &&
post.data.tags.map((tag) => <code class="tag">{`#${tag}`}</code>)}
<time class="row-date" datetime={post.data.pubDate.toISOString()}>
@@ -261,6 +270,18 @@ const byMonth = (list: CollectionEntry<'posts'>[]) => {
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden; color: var(--accent); }
/* 置顶标记:灰阶 hairline 小徽标,不抢标题注意力 */
.pin {
font-family: var(--font-mono);
font-size: 10.5px;
line-height: 1.5;
color: var(--ink-4);
border: 1px solid var(--line);
border-radius: 4px;
padding: 0 5px;
white-space: nowrap;
flex: none;
}
.tag {
flex: none;
font-family: var(--font-mono);
+3
View File
@@ -27,6 +27,9 @@ const { block } = Astro.props;
margin-top: var(--space-3);
}
.prose-body.full { max-width: none; }
/* 标题下正文不再叠一档 margin:同级标题到内容统一 16px(tracks 块实测 16px,
prose 原来 16+16=32,两块相邻时节奏不齐——视觉终审) */
.sec-title + .prose-body { margin-top: 0; }
.prose-body :global(p) { margin-block: 0 var(--space-2); }
.prose-body :global(ul) {
margin: var(--space-1) 0 var(--space-2);
@@ -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>
+1 -1
View File
@@ -7,7 +7,7 @@ const commitUrl = `https://git.wait4x.online/wait4x/blog/commit/${sha}`;
---
<p class="build-info">
built from
built from{' '}
{short === 'dev' ? <span>dev</span> : <a href={commitUrl} class="link mono-link">{short}</a>}
· {new Date(date).toISOString().slice(0, 16).replace('T', ' ')}Z · Astro
</p>
+366
View File
@@ -0,0 +1,366 @@
---
// 开源页原生档案卡(5h T5):零第三方图片、零 iframe
// 数据来源:①content/site/home.yaml 的头像/社交(单一事实源)②content/site/opensource.yaml 的
// 定位叙述(blurb/blurb_after,双语可后台改)③GitHub REST 的 followers/公开仓数(token 在宿主
// 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';
import type { Locale } from '../../lib/locale';
interface Props {
locale?: Locale;
}
const { locale = 'zh' } = Astro.props;
const avatar = await homeAvatar();
const socials = await homeSocials();
const copy = await openSourceCopy(locale);
// 仓库数据:档案卡只关心"语言占比 + 星标汇总",取全量(不受项目墙可见性影响)
let langs: { name: string; count: number; pct: number }[] = [];
let totalStars: number | null = null;
try {
const repos = await loadProjects({ mode: 'all', sort: 'stars' });
if (repos.length) {
totalStars = repos.reduce((sum, r) => sum + (r.stars ?? 0), 0);
const tally = new Map<string, number>();
for (const r of repos) {
if (!r.language) continue;
tally.set(r.language, (tally.get(r.language) ?? 0) + 1);
}
const total = [...tally.values()].reduce((a, b) => a + b, 0);
langs = [...tally.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([name, count]) => ({
name,
count,
pct: total ? Math.round((count / total) * 100) : 0,
}));
}
} catch {
console.warn('[profile] 仓库数据不可用,语言占比与星标汇总已隐藏');
}
// 并列判定用百分比最大值(四舍五入后并列很常见:Python/TS 各 36%):
// 只给第一根上强调色会让并列者显得"落后",故并列者一并着色
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' },
}[locale];
---
<section class="profile" aria-label={locale === 'zh' ? 'GitHub 档案' : 'GitHub profile'}>
<div class="who">
{avatar ? (
<Image class="pf-avatar" src={avatar} alt="wait4x" width={64} height={64} format="webp" quality={84} />
) : (
<span class="pf-avatar pf-avatar-fallback" aria-hidden="true">w4</span>
)}
<div class="who-main">
<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.trim()}
</p>
<ul class="pf-socials">
{socials.map((s) => (
<li>
<a
class="tap"
href={s.url}
aria-label={s.label}
title={s.label}
rel={s.url.startsWith('http') ? 'noopener noreferrer' : undefined}
>
{iconPath(s.icon) ? (
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d={iconPath(s.icon)} />
</svg>
) : (
<span class="pf-social-text mono">{s.label}</span>
)}
</a>
</li>
))}
</ul>
</div>
</div>
{(stats || totalStars !== null) && (
<dl class="pf-stats mono">
{stats && (
<>
<div><dt>{L.followers}</dt><dd>{stats.followers}</dd></div>
<div><dt>{L.repos}</dt><dd>{stats.publicRepos}</dd></div>
</>
)}
{totalStars !== null && (
<div><dt>{L.stars}</dt><dd>{totalStars}</dd></div>
)}
</dl>
)}
{langs.length > 0 && (
<div class="pf-langs">
<p class="pf-langs-title mono">{L.langs}</p>
<ul>
{langs.map((l) => (
<li class:list={['pf-lang', { top: l.pct === maxPct }]}>
<span class="pf-lang-name mono">{l.name}</span>
<span class="pf-bar" aria-hidden="true">
<span class="pf-bar-fill" style={`width:${Math.max(l.pct, 2)}%`} />
</span>
<span class="pf-lang-pct mono">{l.pct}%</span>
</li>
))}
</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>
.profile {
margin: var(--space-3) 0 var(--space-5);
padding: var(--space-4);
border: 1px solid var(--line);
border-radius: 12px;
max-width: var(--measure);
}
.who {
display: flex;
gap: var(--space-3);
align-items: flex-start;
}
.pf-avatar {
flex: none;
width: 64px;
height: 64px;
border-radius: 14px;
object-fit: cover;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.pf-avatar-fallback {
display: inline-flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono);
font-size: 15px;
font-weight: 700;
color: var(--ink-2);
background: var(--bg-soft);
}
.who-main { min-width: 0; }
.pf-name {
font-size: 18px;
font-weight: 700;
color: var(--ink-1);
display: flex;
align-items: baseline;
gap: 0.4rem;
}
.pf-handle { font-size: 12.5px; font-weight: 400; color: var(--ink-4); }
.pf-bio {
margin-top: 0.3rem;
font-size: 14.5px;
line-height: 1.65;
color: var(--ink-2);
}
.pf-link {
color: var(--accent);
text-decoration: underline;
text-underline-offset: 3px;
}
.pf-socials {
display: flex;
gap: var(--space-2);
list-style: none;
margin: var(--space-2) 0 0;
padding: 0;
}
.pf-socials a { color: var(--ink-3); display: inline-flex; }
.pf-socials a:hover { color: var(--accent); }
.pf-social-text { font-size: 12px; }
.pf-stats {
display: flex;
flex-wrap: wrap;
gap: var(--space-4);
margin: var(--space-4) 0 0;
padding-top: var(--space-3);
border-top: 1px solid var(--line);
}
.pf-stats div { display: flex; flex-direction: column; gap: 2px; }
.pf-stats dt {
font-size: 11.5px;
color: var(--ink-4);
letter-spacing: 0.04em;
}
.pf-stats dd {
margin: 0;
font-size: 17px;
color: var(--ink-1);
font-variant-numeric: tabular-nums;
}
.pf-langs { margin-top: var(--space-3); }
.pf-langs-title {
font-size: 11.5px;
color: var(--ink-4);
letter-spacing: 0.04em;
margin-bottom: var(--space-2);
}
.pf-langs ul {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.35rem;
max-width: 32rem;
}
.pf-lang {
display: grid;
grid-template-columns: 7.5rem 1fr 3rem;
align-items: center;
gap: var(--space-2);
}
.pf-lang-name { font-size: 12px; color: var(--ink-3); }
.pf-bar {
display: block;
height: 6px;
border-radius: 3px;
background: var(--bg-soft);
overflow: hidden;
}
.pf-bar-fill {
display: block;
height: 100%;
background: var(--ink-3);
border-radius: 3px;
}
/* 灰阶体系:仅第一名用 accent 强调,其余走 ink 灰阶(不沿用 GitHub 彩色语言点) */
.pf-lang.top .pf-bar-fill { background: var(--accent); }
.pf-lang.top .pf-lang-name { color: var(--ink-2); }
.pf-lang-pct {
font-size: 11.5px;
color: var(--ink-4);
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>
+21 -8
View File
@@ -23,11 +23,9 @@ interface Props {
}
const { limit = 6, locale = 'zh', pinned, options } = Astro.props;
const title = t('open_source_projects', locale);
const LANG_COLORS: Record<string, string> = {
Astro: '#ff5a03', TypeScript: '#3178c6', JavaScript: '#f1e05a',
Python: '#3572a5', Go: '#00add8', Rust: '#dea584', Shell: '#89e051',
HTML: '#e34c26', CSS: '#563d7c', Dockerfile: '#384d54',
};
// 2026-11-02 视觉终审:原先按 GitHub linguist 给每个仓染语言色点,开源页一次铺
// 15+ 个仓 = 15 种彩色,破「外壳灰阶、彩色只作正文强调(≤3 处/页)」红线。
// 改为统一灰阶点:语言信息靠文字承载,颜色不再做无意义的编码。
let projects: RepoCard[] = [];
try {
@@ -50,7 +48,7 @@ try {
<span class="repo-meta">
{p.language && (
<span class="lang">
<span class="dot" style={`background:${LANG_COLORS[p.language] ?? '#8b8f98'}`} />
<span class="dot" />
{p.language}
</span>
)}
@@ -75,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);
@@ -84,8 +85,20 @@ 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 { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
display: inline-block;
background: var(--ink-4);
flex: none;
}
</style>
+106
View File
@@ -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>
+208
View File
@@ -0,0 +1,208 @@
---
/**
* 浏览 / 评论计数徽标(Phase 5h T4):眼睛图标 = 浏览数,气泡图标 = 评论数。
*
* 为什么这么设计:
* - 语汇对齐开源页从 GitHub README 移植过来的「👀 Visitors」徽章,但改成本地
* SVG + tokens 着色,不引入任何第三方图片(komarev/shields 在慢网下空白破版)。
* - 数据只用 Artalk 匿名可读端点(2026-11-02 生产实测):
* GET /artalk/api/v2/comments?site_name=…&page_key=…&limit=1
* → { count, roots_count, page: { pv } } (一次拿到评论数与浏览量)
* POST /artalk/api/v2/pages/pv { page_key, site_name } → { pv }
* - `mode="readonly"` 只读(瞬间列表每条都要显示,逐条计数会污染数据);
* `mode="page"` 计入一次浏览(文章/关于/开源等整页场景)。
*
* SSR 渲染占位「–」:无 JS / 接口失败时退化为一个短横,不显示 0 误导读者。
*/
interface Props {
/** 评论流键(与 ArtalkComments 的 pageKey 同语义,双语页共用) */
pageKey: string;
locale?: 'zh' | 'en';
/** page = 计入一次浏览;readonly = 只读,不改动计数 */
mode?: 'page' | 'readonly';
/** 是否显示评论数(瞬间逐条场景也显示) */
showComments?: boolean;
/** 是否显示浏览数 */
showPv?: boolean;
/** 视觉变体:inline = 行内小字(默认);chip = 带底框的徽标条 */
variant?: 'inline' | 'chip';
}
const {
pageKey,
locale = 'zh',
mode = 'page',
showComments = true,
showPv = true,
variant = 'inline',
} = Astro.props;
const L = {
pv: locale === 'zh' ? '次浏览' : 'views',
cm: locale === 'zh' ? '条评论' : 'comments',
};
---
<p
class:list={['viewmeta', variant === 'chip' && 'chip']}
data-viewmeta
data-page-key={pageKey}
data-mode={mode}
data-i18n-pv={L.pv}
data-i18n-cm={L.cm}
>
{showPv && (
<span class="vm-item" title={L.pv}>
<svg class="vm-icon" viewBox="0 0 16 16" width="13" height="13" aria-hidden="true">
<path
d="M1.3 8S3.9 3.9 8 3.9 14.7 8 14.7 8 12.1 12.1 8 12.1 1.3 8 1.3 8Z"
fill="none"
stroke="currentColor"
stroke-width="1.25"
stroke-linecap="round"
stroke-linejoin="round"></path>
<circle cx="8" cy="8" r="1.9" fill="none" stroke="currentColor" stroke-width="1.25"></circle>
</svg>
<span data-vm-pv aria-label={L.pv}>–</span>
</span>
)}
{showComments && (
<span class="vm-item" title={L.cm}>
<svg class="vm-icon" viewBox="0 0 16 16" width="13" height="13" aria-hidden="true">
<path
d="M2.4 3.2h11.2v7.3H7.1L4 13.2v-2.7H2.4z"
fill="none"
stroke="currentColor"
stroke-width="1.25"
stroke-linecap="round"
stroke-linejoin="round"></path>
</svg>
<span data-vm-cm aria-label={L.cm}>–</span>
</span>
)}
</p>
<script>
// 同一页可能有多个实例(瞬间时间线逐条),脚本只跑一份,统一批量处理。
const SITE = 'wait4x.online';
const API = '/artalk/api/v2';
interface Meta {
count?: number;
page?: { pv?: number };
}
const fill = (root: HTMLElement, meta: Meta) => {
const pv = root.querySelector<HTMLElement>('[data-vm-pv]');
const cm = root.querySelector<HTMLElement>('[data-vm-cm]');
const locale = document.documentElement.lang === 'en' ? 'en' : 'zh';
const unitPv = locale === 'zh' ? '次浏览' : 'views';
const unitCm = locale === 'zh' ? '条评论' : 'comments';
if (pv) {
const n = meta.page?.pv;
pv.textContent = typeof n === 'number' ? `${n} ${unitPv}` : '–';
}
if (cm) {
const n = meta.count;
cm.textContent = typeof n === 'number' ? `${n} ${unitCm}` : '–';
}
};
const loadOne = async (root: HTMLElement) => {
const key = root.dataset.pageKey;
if (!key) return;
// 每元素只处理一次(2026-11-02 实测教训):首屏 astro:page-load 与本脚本的
// 立即调用会双触发,page 模式每元素会 POST 两次 → 浏览量翻倍(实测
// 15→17→19 每载 +2,加此守卫后 1/2/3 每载 +1)。软导航新页面元素是新的,
// 故不牺牲「换页重计」语义。
if (root.dataset.vmDone === '1') return;
root.dataset.vmDone = '1';
const mode = root.dataset.mode ?? 'page';
try {
// page 模式先计一次浏览(POST 绕 ESA 缓存),再读评论数;
// readonly 模式只读:一次 GET 同时拿到 pv 与 count,零写入。
if (mode === 'page') {
const bumped = await fetch(`${API}/pages/pv`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ page_key: key, site_name: SITE }),
});
const data = (await bumped.json()) as Meta;
const res = await fetch(
`${API}/comments?site_name=${encodeURIComponent(SITE)}&page_key=${encodeURIComponent(key)}&limit=1`,
);
const meta = (await res.json()) as Meta;
const merged: Meta = { count: meta.count, page: { pv: data.pv ?? meta.page?.pv } };
fill(root, merged);
// 同页同键的其它实例(如评论区 chip)复用本次结果:否则它独立 GET 到的
// pv 可能比计过数的这份少 1,两个徽标同页显示不同数字会像 bug。
for (const other of document.querySelectorAll<HTMLElement>('[data-viewmeta]')) {
if (other === root || other.dataset.pageKey !== key) continue;
other.dataset.vmDone = '1';
fill(other, merged);
}
return;
}
const res = await fetch(
`${API}/comments?site_name=${encodeURIComponent(SITE)}&page_key=${encodeURIComponent(key)}&limit=1`,
);
fill(root, (await res.json()) as Meta);
} catch {
// 页模式失败:同键 readonly 兄弟此前被跳过,这里放它们自己读,避免永远「–」
for (const other of document.querySelectorAll<HTMLElement>('[data-viewmeta]')) {
if (other === root || other.dataset.pageKey !== key || other.dataset.vmDone === '1') continue;
delete other.dataset.vmDone;
void loadOne(other);
}
/* 接口不可用:保留占位「–」,不显示 0 误导读者 */
}
};
const run = () => {
const all = [...document.querySelectorAll<HTMLElement>('[data-viewmeta]')];
// 同键存在 page 实例时,readonly 实例等它回填(它拿到的是计过数的 pv,
// 与 section 内另一枚徽标必然一致);否则同页两枚会显示差 1 的两个数字。
const pageKeys = new Set(
all
.filter((el) => (el.dataset.mode ?? 'page') === 'page' && el.dataset.pageKey)
.map((el) => el.dataset.pageKey as string),
);
for (const root of all) {
if ((root.dataset.mode ?? 'page') === 'readonly' && pageKeys.has(root.dataset.pageKey ?? '')) continue;
void loadOne(root);
}
};
document.addEventListener('astro:page-load', run);
run();
</script>
<style>
.viewmeta {
display: inline-flex;
align-items: center;
gap: var(--space-2);
margin: 0;
font-family: var(--font-mono);
font-size: 12px;
color: var(--ink-4);
font-variant-numeric: tabular-nums;
}
.viewmeta.chip {
padding: 3px 9px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--bg-soft);
gap: var(--space-3);
}
.vm-item {
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.vm-icon {
flex: none;
opacity: 0.85;
}
</style>
+4 -2
View File
@@ -5,13 +5,15 @@ import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { t } from '../../i18n/ui';
import type { Locale } from '../../lib/locale';
import { sortPosts } from '../../lib/post-order';
import { Image } from 'astro:assets';
interface Props { locale?: Locale }
const { locale = 'zh' } = Astro.props;
const books = (await getCollection('posts', ({ data }) => !data.draft && data.category === 'reading' && !data.tags.includes('hidden')))
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
const books = sortPosts(
await getCollection('posts', ({ data }) => !data.draft && data.category === 'reading' && !data.tags.includes('hidden')),
);
// 回退契约(schema 注释声称而此前未实现——视觉复核抓到的缺口):
// description 缺省取正文第一个非空段落(去 markdown 记号)前 60 字
+22 -3
View File
@@ -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 才有意义) */
+216
View File
@@ -0,0 +1,216 @@
---
// 首页(zh/en 共用):名片区 / 文章列表 / 最新影像 / 热力图 / 项目墙 / 心跳
// 两个 index.astro 只是薄壳(传 locale),区块逻辑与样式集中在这里,避免双份漂移。
import { Image } from 'astro:assets';
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import IdCard from '../IdCard.astro';
import PostList from '../PostList.astro';
import ProjectWall from '../geek/ProjectWall.astro';
import Heatmap from '../geek/Heatmap.astro';
import SocialLinks from '../SocialLinks.astro';
import { t } from '../../i18n/ui';
import { sortPosts } from '../../lib/post-order';
import type { Locale } from '../../lib/locale';
import {
homeConfig,
homeTagline,
pageTypography,
renderMarkdown,
renderRich,
} from '../../lib/site-content';
import { fetchHeartbeat, agoZh } from '../../lib/heartbeat';
interface Props {
locale?: Locale;
}
const { locale = 'zh' } = Astro.props;
// 首页可配项(后台主页设置):副标题/影像张数/区块开关/每轨条数/项目墙条数/公告/轨细分
const cfg = await homeConfig(locale);
const subtitleHtml = cfg.subtitle ? await renderMarkdown(cfg.subtitle) : '';
const announcementHtml = cfg.announcement ? await renderRich(cfg.announcement) : '';
// 置顶优先 → 时间倒序(sortPosts 契约见 src/lib/post-order.ts)
const posts = sortPosts(
await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden')),
);
// en 轨列表标题吃 postsEn 译题(缺译回退 zh,与 /en/archives 同策略)
const enTitle = locale === 'en'
? Object.fromEntries((await getCollection('postsEn')).map((e) => [e.id, e.data.title]))
: undefined;
// 最新影像取集合前 N 张;照片时间序由照片管线治理,此处只裁切
const photos = (await getCollection('photos')).slice(0, Math.max(1, cfg.latestPhotos));
const tagline = await homeTagline(locale);
const taglineHtml = await renderRich(tagline, { inline: true });
const typo = await pageTypography('siteHome', 'home');
// 心跳只出现在中文首页(en 轨历史形态如此,保持零回归)
const heartbeat = locale === 'zh' ? await fetchHeartbeat() : null;
const galleryHref = locale === 'en' ? '/en/gallery/' : '/gallery/';
// 轨细分开关:只保留开启的轨道(全开时等价于不过滤,零回归)
const activeTracks = Object.entries(cfg.postsTracks)
.filter(([, on]) => on)
.map(([k]) => k);
const allTracksOn = activeTracks.length === Object.keys(cfg.postsTracks).length;
const visiblePosts = allTracksOn
? posts
: posts.filter((p) => activeTracks.includes(p.data.category));
---
<BaseLayout
title="wait4x"
description={locale === 'en' ? "wait4x's blog: reading · code · photography" : undefined}
>
<div class={typo.className || undefined} style={typo.style || undefined}>
<IdCard>
<p class="tagline rich" set:html={taglineHtml} />
{subtitleHtml && <div class="subtitle" set:html={subtitleHtml} />}
<SocialLinks />
</IdCard>
{announcementHtml && (
<div class="announcement rich" set:html={announcementHtml} aria-label={locale === 'en' ? 'Announcement' : '公告'} />
)}
{cfg.sections.tracks && (
<PostList
posts={visiblePosts}
locale={locale}
titleOverrides={enTitle}
perTrack={cfg.perTrackLimit}
/>
)}
{cfg.sections.photos && cfg.latestPhotos > 0 && (
<section class="latest" aria-label={t('latest_photos', locale)}>
<div class="latest-head">
<span class="lab">{t('latest_photos', locale)}</span>
<a class="tap more" href={galleryHref}>{t('enter_gallery', locale)}</a>
</div>
<div class="thumbs">
{photos.map((photo, i) => (
<a class="thumb" href={galleryHref} aria-label={`${t('latest_photos', locale)}: ${photo.data.title}`}>
{/* 首两图 eager(Astro Image 默认 lazy 会把 LCP 压到 6s+),首图再拔 fetchpriority */}
<Image
src={photo.data.image}
alt={photo.data.alt ?? photo.data.title}
widths={[180, 360]}
sizes="(min-width: 820px) 20vw, 42vw"
loading={i < 2 ? 'eager' : 'lazy'}
fetchpriority={i === 0 ? 'high' : undefined}
/>
</a>
))}
</div>
</section>
)}
{cfg.sections.heatmap && (
<Heatmap
labels={{
title: t('heatmap_title', locale),
summary: t('heatmap_summary', locale),
less: t('heatmap_less', locale),
more: t('heatmap_more', locale),
note: t('heatmap_note', locale),
}}
/>
)}
{cfg.sections.projects && <ProjectWall limit={cfg.projectsLimit} />}
{heartbeat && (
<p class="heartbeat" aria-label="最近心跳">
<span class="beat-dot" aria-hidden="true"></span>
最近心跳:{agoZh(heartbeat.when)} · {heartbeat.msg}
</p>
)}
</div>
</BaseLayout>
<style>
.subtitle {
font-size: 13.5px;
color: var(--ink-4);
margin-top: 2px;
letter-spacing: 0.01em;
}
.subtitle :global(p) { margin: 0; }
.subtitle :global(a) { color: var(--accent); }
.tagline {
font-size: 14px;
color: var(--ink-3);
margin-top: 4px;
letter-spacing: 0.01em;
}
/* 公告块:独立于区块开关,空则不渲染(markdown 富文本,左缘细线做提示) */
.announcement {
margin-top: var(--space-4);
padding: var(--space-2) 0 var(--space-2) var(--space-3);
border-left: 2px solid var(--line);
font-size: 14px;
line-height: 1.7;
color: var(--ink-2);
}
.announcement :global(p) { margin: 0; }
.announcement :global(p + p) { margin-top: 0.6em; }
.announcement :global(a) { color: var(--accent); }
.heartbeat {
font-family: var(--font-mono);
font-size: 12.5px;
color: var(--ink-3);
margin-top: var(--space-2);
display: flex;
align-items: center;
gap: 8px;
}
.beat-dot {
width: 7px; height: 7px; border-radius: 50%;
background: var(--ink-2);
animation: beat 2.4s ease-in-out infinite;
}
@keyframes beat { 0%, 100% { opacity: 0.35; } 50% { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .beat-dot { animation: none; } }
.latest { margin-top: var(--space-6); }
.latest-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-3);
}
.lab {
font-size: 12.5px;
color: var(--ink-4);
letter-spacing: 0.08em;
}
.more {
font-size: 13px;
color: var(--accent);
padding-inline: var(--space-2);
transition: color var(--dur) var(--ease);
}
.more:hover { color: var(--ink-1); }
.thumbs {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-2);
margin-top: var(--space-3);
}
.thumb {
aspect-ratio: 1;
border-radius: 8px;
overflow: hidden;
}
.thumb :global(img) {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform var(--dur) var(--ease);
}
.thumb:hover :global(img) { transform: scale(1.03); }
@media (min-width: 820px) {
.thumbs { grid-template-columns: repeat(4, 1fr); }
}
</style>
+61 -35
View File
@@ -2,7 +2,10 @@
import { Image } from 'astro:assets';
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import MomentComments from '../MomentComments.astro';
import ViewMeta from '../geek/ViewMeta.astro';
import { t } from '../../i18n/ui';
import { renderRich } from '../../lib/site-content';
import type { Locale } from '../../lib/locale';
// /en/ 侧由 T4 薄页传 locale='en';zh 路由无人传参,默认 zh 零回归
@@ -14,16 +17,25 @@ const { locale = 'zh' } = Astro.props;
const moments = (await getCollection('moments')).sort(
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
);
// 富文本预渲染(Phase 5h T2):text 走与页面文案同一条管线(markdown → 内容净化 →
// class 白名单 → 链接卡片),因此瞬间支持 **加粗**、<span class="c-blue font-kai">、列表与换行。
// 预渲染成 map 再进模板:renderRich 是 async,模板里不能 await。
// 纯文本老数据零回归:无 markdown 的文本会得到单个 <p>,与旧的 {text} 直出视觉等价。
const richText = new Map<string, string>();
for (const m of moments) {
if (!m.data.text) continue;
richText.set(m.id, await renderRich(m.data.text));
}
const iso = (d: Date) => d.toISOString();
---
<BaseLayout title={`${t('moments_title', locale)} · wait4x`}>
<h1 class="page-title">{t('moments_title', locale)}</h1>
<p class="page-note">{t('moments_note', locale)}</p>
<p class="pv-line" aria-label={t('pv_viewed', locale)}>
<span data-artalk-pv data-page-key={locale === 'en' ? '/en/moments/' : '/moments/'}>–</span>
{' '}{t('pv_viewed', locale)}
</p>
<!-- 整页计数(Phase 5h T4):这是本页唯一的计数点(page 模式);
每条瞬间卡片底部的徽标是 readonly,读者开评论不会虚增浏览数 -->
<ViewMeta class="mom-page-meta" pageKey={locale === 'en' ? '/en/moments/' : '/moments/'} {locale} variant="chip" />
<ol class="tl">
{moments.map((m) => (
<li class="tl-item">
@@ -46,12 +58,13 @@ const iso = (d: Date) => d.toISOString();
/>
</figure>
)}
{m.data.text && <p>{m.data.text}</p>}
{m.data.text && <div class="mom-text" set:html={richText.get(m.id) ?? ''} />}
{m.data.tags.length > 0 && (
<p class="mom-tags">
{m.data.tags.map((tag) => <code class="tag">{`#${tag}`}</code>)}
</p>
)}
<MomentComments slug={m.id} locale={locale} />
</div>
</li>
))}
@@ -67,25 +80,6 @@ const iso = (d: Date) => d.toISOString();
</BaseLayout>
<script>
// 访问量:与 ArtalkComments 同款独立 POST(双轨 pageKey 分开计数;POST 绕 ESA 缓存)
const bumpPv = async () => {
const el = document.querySelector('[data-artalk-pv]');
if (!el) return;
const key = el.dataset.pageKey ?? '';
if (!key) return;
try {
const res = await fetch('/artalk/api/v2/pages/pv', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ page_key: key, site_name: 'wait4x.online' }),
});
const d = await res.json();
el.textContent = String(d.pv ?? '–');
} catch {
el.textContent = '–';
}
};
// 时间线渐显:html[data-mom-anim] 由 JS 前置标记——无 JS / reduced-motion 时
// 标记缺席,CSS 初始隐藏规则整体不生效(渐进降级,元素永远可见)
let io: IntersectionObserver | null = null;
@@ -109,11 +103,9 @@ const iso = (d: Date) => d.toISOString();
};
document.addEventListener('astro:page-load', () => {
void bumpPv();
armAnim();
refreshInstallCta();
});
void bumpPv();
armAnim();
// —— PWA 安装引导:beforeinstallprompt 早在页面加载初期触发,
@@ -173,8 +165,11 @@ const iso = (d: Date) => d.toISOString();
}
/* 时间线骨架:左 mono 日期 + 竖线 + --accent 节点 + 右侧内容卡 */
.tl-item {
/* 日期轨宽度集中一处:10 字符 mono 日期(2026-04-29)在 12.5px 下约 75px,
原来复用 var(--space-5)=40px 导致在「-」处折成两行(视觉终审 P1) */
--tl-rail: 5.4rem;
position: relative;
margin: 0 0 var(--space-4) calc(var(--space-5) + var(--space-4));
margin: 0 0 var(--space-4) calc(var(--tl-rail) + var(--space-4));
padding-left: var(--space-4);
border-left: 1px solid var(--line);
}
@@ -192,9 +187,10 @@ const iso = (d: Date) => d.toISOString();
}
.tl-date {
position: absolute;
left: calc(-1 * (var(--space-5) + var(--space-4)));
left: calc(-1 * (var(--tl-rail) + var(--space-4)));
top: 4px;
width: var(--space-5);
width: var(--tl-rail);
white-space: nowrap;
text-align: right;
font-family: var(--font-mono);
font-size: 12.5px;
@@ -208,6 +204,41 @@ const iso = (d: Date) => d.toISOString();
background: var(--bg-soft);
}
.mom-card > p { font-size: 15px; color: var(--ink-2); }
/* 富文本正文(T2):首尾 margin 归零,避免卡片被 <p> 的默认外边距撑开;
段间只留半档间距,列表缩进对齐正文基线。基调保持 15px / ink-2。 */
.mom-text {
font-size: 15px;
color: var(--ink-2);
line-height: 1.7;
}
.mom-text :global(> :first-child) { margin-top: 0; }
.mom-text :global(> :last-child) { margin-bottom: 0; }
.mom-text :global(p) { margin: 0 0 var(--space-1); }
.mom-text :global(ul),
.mom-text :global(ol) {
margin: 0 0 var(--space-1);
padding-left: 1.35em;
}
.mom-text :global(li) { margin: 0; }
.mom-text :global(li + li) { margin-top: 2px; }
.mom-text :global(blockquote) {
margin: var(--space-1) 0;
padding-left: var(--space-2);
border-left: 2px solid var(--line);
color: var(--ink-3);
}
.mom-text :global(code) {
font-family: var(--font-mono);
font-size: 0.92em;
background: var(--bg);
border-radius: 4px;
padding: 1px 5px;
}
.mom-text :global(a) {
color: var(--accent);
text-decoration: underline;
text-underline-offset: 0.18em;
}
.mom-fig {
border-radius: 8px;
overflow: hidden;
@@ -270,12 +301,7 @@ const iso = (d: Date) => d.toISOString();
transition: border-color var(--dur) var(--ease);
}
.install-btn:hover { border-color: var(--accent); }
.pv-line {
font-family: var(--font-mono);
font-size: 12px;
color: var(--ink-4);
margin: 0 0 var(--space-4);
}
.mom-page-meta { margin: 0 0 var(--space-4); }
/* 初始隐藏仅在 JS 标记存在时生效:宁轻勿闹——透明度+8px 上移,进入视口一次性点亮 */
[data-mom-anim] .tl-item {
opacity: 0;
+42
View File
@@ -123,6 +123,8 @@ const postsSchema = z.object({
video: z.string().optional(),
/** 视频封面(可选):同 public/media 下的图片 URL */
poster: z.string().optional(),
/** 置顶:列表排序优先(只影响顺序,不改 pubDate/URL/RSS 时间语义) */
pinned: z.boolean().optional(),
draft: z.boolean().optional(),
});
@@ -184,6 +186,18 @@ export const collections = {
latestPhotos: z.number().int().optional(),
/** 首页每轨条数:4-8(越界由访问器夹取,缺省 6) */
perTrackLimit: z.number().int().optional(),
/** 首页项目墙条数:1-99(缺省 6;越界夹取) */
projectsLimit: z.number().int().optional(),
/** 首页公告(markdown 富文本,走富文本管线);空 = 不渲染 */
announcement: bilingual.optional(),
/** 首页轨细分开关:分开关阅读/技术/照片三轨(缺省全开) */
postsTracks: z
.object({
reading: z.boolean().optional(),
tech: z.boolean().optional(),
photo: z.boolean().optional(),
})
.optional(),
/** 区块开关:缺省全开 */
sections: z
.object({
@@ -205,6 +219,29 @@ export const collections = {
.optional(),
}),
}),
// 主题旋钮(样式即数据 · 层 4):只放 9 组白名单旋钮,钳制与单调性在 src/lib/theme.ts
siteTheme: defineCollection({
loader: glob({ pattern: 'theme.yaml', base: './content/site' }),
schema: z.object({
accent_light: z.string().optional(),
accent_dark: z.string().optional(),
radius_img: z.number().optional(),
text_h1: z.number().optional(),
text_h2: z.number().optional(),
text_h3: z.number().optional(),
prose_size_reading: z.number().optional(),
prose_size_tech: z.number().optional(),
leading_reading: z.number().optional(),
leading_tech: z.number().optional(),
space_3: z.number().optional(),
space_4: z.number().optional(),
space_5: z.number().optional(),
space_6: z.number().optional(),
flow_p: z.number().optional(),
flow_h2_post: z.number().optional(),
dur: z.number().optional(),
}),
}),
siteAbout: defineCollection({
loader: glob({ pattern: 'about.yaml', base: './content/site' }),
// intro = 多段 markdown 正文(二级回退:blocks 为空时才用)
@@ -255,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)
+3
View File
@@ -112,6 +112,9 @@ const dict = {
// ===== F 车道:访问量小字(Artalk pv,组件内独立计数) =====
pv_viewed: { zh: '人看过', en: 'views' },
pinned_label: { zh: '置顶', en: 'Pinned' },
comment_count: { zh: '条评论', en: 'comments' },
moment_comments: { zh: '评论', en: 'Comments' },
lb_hint: { zh: '滚轮缩放 · 双击放大 · Esc 关闭', en: 'Wheel to zoom · Double-click · Esc' },
lb_hint_touch: { zh: '双指缩放 · 双击放大', en: 'Pinch to zoom · Double-tap' },
+24 -6
View File
@@ -15,9 +15,11 @@ 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';
import { themeCss } from '../lib/theme';
interface Props {
title: string;
@@ -36,6 +38,11 @@ const withSlash = (p: string) => (p.endsWith('/') ? p : `${p}/`);
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>
@@ -68,22 +75,31 @@ const canonicalUrl = new URL(withSlash(rawPath), Astro.site).href;
</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' && (
<>
{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 按需拉取) -->
)}
<!-- 汇文明朝(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" />
@@ -100,7 +116,9 @@ const canonicalUrl = new URL(withSlash(rawPath), Astro.site).href;
<link rel="alternate" hreflang="zh" href={new URL(withSlash(zhPath), Astro.site)} />
<link rel="alternate" hreflang="en" href={new URL(withSlash(enPath), Astro.site)} />
<link rel="alternate" hreflang="x-default" href={new URL(withSlash(zhPath), Astro.site)} />
</head>
<!-- 样式即数据:theme.yaml 白名单旋钮(构建期内联,零运行时;:root:root 靠特异性压 tokens.css) -->
<style is:inline set:html={themeStyle}></style>
</head>
<body>
<!-- 背景辉光:跟随鼠标的 ink 色径向渐变(丙灰阶语言,3-4% 透明度;
pointer:fine + 非 reduced-motion 才有 JS 驱动,其余静态隐藏) -->
+174
View File
@@ -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;
}
+31
View File
@@ -0,0 +1,31 @@
/**
* 文章列表排序契约(置顶功能,Phase 5h T1)
*
* 为什么集中在这里:首页双轨 / 归档折叠 / 书架 / en 归档四处列表各自写
* `sort((a,b)=>b.pubDate-a.pubDate)`,加置顶必须四处同步,否则同一篇文章
* 在不同页面位置不一致。排序键固定为「置顶优先 → 时间倒序」,时间相等时
* 用 id 兜底保证构建确定(同一份内容两次构建产出一致,便于快照比对)。
*
* 置顶语义:`pinned: true` 只影响列表展示顺序,不改 pubDate、不改 URL、
* 不影响 RSS/订阅源的时间语义。
*/
import type { CollectionEntry } from 'astro:content';
type SortablePost = { id: string; data: { pubDate: Date; pinned?: boolean } };
/**
* 置顶优先、其次按发布日期倒序。
*
* @param posts 任意文章条目数组(不修改入参,返回新数组)
* @returns 新数组;`pinned` 为真者在前,其内部与其余条目均按 pubDate 倒序,
* 同日按 id 升序保证确定性
*/
export function sortPosts<T extends SortablePost>(posts: T[]): T[] {
return [...posts].sort((a, b) => {
const pin = Number(b.data.pinned === true) - Number(a.data.pinned === true);
if (pin !== 0) return pin;
const dt = b.data.pubDate.getTime() - a.data.pubDate.getTime();
if (dt !== 0) return dt;
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
});
}
+53 -1
View File
@@ -1,3 +1,5 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
// 项目墙数据(极客层 P0):构建时双源拉取 + 仓内缓存兜底
// 双源 = 自家 gitea(数据自控叙事)+ GitHub(社区 star 真值);置顶清单人工维护
export interface RepoCard {
@@ -28,8 +30,18 @@ export const GITHUB_API = 'https://api.github.com/users/wait4xx/repos?per_page=1
export const PINNED_DEFAULT: string[] = ['blog'];
async function fetchJson(url: string): Promise<RawRepo[]> {
const headers: Record<string, string> = { 'User-Agent': 'wait4x-blog-build' };
// GitHub 无鉴权仅 60 次/小时(多次构建就耗尽)→ 有 token 就带上(5000/小时)
if (url.includes('api.github.com')) {
try {
const token = readFileSync(join(process.env.HOME ?? '', 'docker_apps/blog/github-token.txt'), 'utf8').trim();
if (token) headers.Authorization = `Bearer ${token}`;
} catch {
/* token 缺位则匿名(可能被限流,走兜底) */
}
}
const res = await fetch(url, {
headers: { Accept: 'application/json', 'User-Agent': 'wait4x-blog-build' },
headers: { ...headers, Accept: 'application/json' },
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) throw new Error(`${url} -> ${res.status}`);
@@ -158,3 +170,43 @@ function arrange(
return b.stars - a.stars;
});
}
/** GitHub 账号统计(followers / public repos):token 缺位或请求失败一律返回 null(调用方隐藏数字) */
export interface GithubUserStats {
followers: number;
publicRepos: number;
}
export async function githubUserStats(
login = 'wait4xx',
): Promise<GithubUserStats | null> {
const { readFileSync } = await import('node:fs');
const { join } = await import('node:path');
let token: string | null = null;
try {
token = readFileSync(join(process.env.HOME ?? '', 'docker_apps/blog/github-token.txt'), 'utf8').trim() || null;
} catch {
token = null;
}
try {
const res = await fetch(`https://api.github.com/users/${login}`, {
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
Accept: 'application/vnd.github+json',
'User-Agent': 'wait4x-blog-build',
},
});
if (!res.ok) {
console.warn(`[profile] GitHub 用户统计获取失败 HTTP ${res.status},相关数字已隐藏`);
return null;
}
const data = (await res.json()) as { followers?: number; public_repos?: number };
return {
followers: Number(data.followers ?? 0),
publicRepos: Number(data.public_repos ?? 0),
};
} catch (err) {
console.warn(`[profile] GitHub 用户统计请求异常(${(err as Error).message}),相关数字已隐藏`);
return null;
}
}
+1
View File
@@ -46,6 +46,7 @@ const ALLOWED = new Set([
'font-fangsong',
'font-huiwen',
'font-kuaile',
'font-art-kuaile',
// 文学排版
'drop-cap',
'quote-line',
+25
View File
@@ -12,6 +12,12 @@ export interface Bi {
/** 与仓库现状逐字一致的默认文案:yaml 全空时产物不变(零回归可视验证锚点) */
export const SITE_DEFAULTS = {
/** 首页项目墙条数(旧 yaml 无此字段时的默认:6 条) */
homeProjectsLimit: 6,
/** 首页公告(默认空 = 不渲染) */
homeAnnouncement: { zh: '', en: '' },
/** 首页轨细分(默认全开 = 现状) */
homePostsTracks: { reading: true, tech: true, photo: true },
homeTagline: {
zh: '写作者 · 读者 · 快门按压者,拥抱开源。',
en: 'Writer · Reader · Shutter-presser — embracing open source.',
@@ -223,9 +229,21 @@ export interface HomeSections {
projects: boolean;
}
export interface HomeTracks {
reading: boolean;
tech: boolean;
photo: boolean;
}
export interface HomeConfig {
/** 副标题 markdown 原文;空串 = 不渲染 */
subtitle: string;
/** 公告 markdown 原文;空串 = 不渲染 */
announcement: string;
/** 首页项目墙条数(1-99,缺省 6) */
projectsLimit: number;
/** 轨细分开关(缺省全开) */
postsTracks: HomeTracks;
/** 最新影像张数:0 = 隐藏区块 */
latestPhotos: number;
/** 每轨文章条数 */
@@ -244,6 +262,13 @@ export async function homeConfig(locale: Locale): Promise<HomeConfig> {
const d = entry?.data;
return {
subtitle: pickBi(d?.subtitle, { zh: '', en: '' }, locale),
announcement: pickBi(d?.announcement, SITE_DEFAULTS.homeAnnouncement, locale),
projectsLimit: clampInt(d?.projectsLimit, 1, 99, SITE_DEFAULTS.homeProjectsLimit),
postsTracks: {
reading: d?.postsTracks?.reading ?? SITE_DEFAULTS.homePostsTracks.reading,
tech: d?.postsTracks?.tech ?? SITE_DEFAULTS.homePostsTracks.tech,
photo: d?.postsTracks?.photo ?? SITE_DEFAULTS.homePostsTracks.photo,
},
latestPhotos: clampInt(d?.latestPhotos, 0, 6, 4),
perTrackLimit: clampInt(d?.perTrackLimit, 4, 8, 6),
sections: {
+143
View File
@@ -0,0 +1,143 @@
/**
* 样式即数据(层 4):把 content/site/theme.yaml 的白名单旋钮编译成 CSS 变量注入串。
*
* 三条红线(docs/research/10 方案 §3.6):
* ① 只认 9 组白名单旋钮;--measure / 断点 / 纸墨 / code 配色一律不可配(牵动版面结构与分轨身份)。
* ② 注入用 `:root:root{…}`(0,2,0)与 `[data-theme="dark"]:root:root{…}`(0,3,0)——
* 必须靠特异性压过 tokens.css 的 `:root`(0,1,0),**不能靠 DOM 顺序**(link 与内联 style 的相对次序不可控)。
* ③ 值全部钳制 + 单调性校验:越界只回落默认,绝不让构建失败(主题配错不该炸站)。
*
* 构建期静态:BaseLayout 直接把返回串内联进 <style>,运行时零 JS、零请求。
*/
import { getEntry } from 'astro:content';
/** 与 tokens.css 完全一致的默认值(零配置 = 现状) */
const DEFAULTS = {
accentLight: '#3454d1',
accentDark: '#8ea2ff',
radiusImg: 10,
textH1: 27,
textH2: 22,
textH3: 19,
proseReading: 20,
proseTech: 19,
leadingReading: 1.75,
leadingTech: 1.7,
space3: 16,
space4: 24,
space5: 40,
space6: 64,
flowP: 0.9,
flowH2Post: 0.6,
dur: 350,
} as const;
const clamp = (v: unknown, min: number, max: number, fallback: number): number =>
typeof v === 'number' && Number.isFinite(v) ? Math.min(max, Math.max(min, v)) : fallback;
const isHex = (v: unknown): v is string =>
typeof v === 'string' && /^#[0-9a-fA-F]{6}$/.test(v.trim());
export interface Theme {
accentLight: string;
accentDark: string;
radiusImg: number;
textH1: number;
textH2: number;
textH3: number;
proseReading: number;
proseTech: number;
leadingReading: number;
leadingTech: number;
space3: number;
space4: number;
space5: number;
space6: number;
flowP: number;
flowH2Post: number;
dur: number;
}
/** 读 theme 单例并做钳制/单调校验(任一约束不满足即整组回落默认) */
export async function getTheme(): Promise<Theme> {
const entry = await getEntry('siteTheme', 'theme');
const d = (entry?.data ?? {}) as Record<string, unknown>;
const num = (k: string) => (typeof d[k] === 'number' ? (d[k] as number) : undefined);
// 标题三级必须单调递减,否则整组回落(避免 h2 比 h1 大的畸形版式)
let textH1 = clamp(num('text_h1'), 22, 32, DEFAULTS.textH1);
let textH2 = clamp(num('text_h2'), 22, 32, DEFAULTS.textH2);
let textH3 = clamp(num('text_h3'), 16, 32, DEFAULTS.textH3);
if (!(textH1 > textH2 && textH2 > textH3)) {
textH1 = DEFAULTS.textH1;
textH2 = DEFAULTS.textH2;
textH3 = DEFAULTS.textH3;
}
// 间距梯度必须严格递增,否则整组回落
let space3 = clamp(num('space_3'), 8, 80, DEFAULTS.space3);
let space4 = clamp(num('space_4'), 8, 80, DEFAULTS.space4);
let space5 = clamp(num('space_5'), 8, 80, DEFAULTS.space5);
let space6 = clamp(num('space_6'), 8, 80, DEFAULTS.space6);
if (!(space3 < space4 && space4 < space5 && space5 < space6)) {
space3 = DEFAULTS.space3;
space4 = DEFAULTS.space4;
space5 = DEFAULTS.space5;
space6 = DEFAULTS.space6;
}
return {
accentLight: isHex(d.accent_light) ? d.accent_light.trim() : DEFAULTS.accentLight,
accentDark: isHex(d.accent_dark) ? d.accent_dark.trim() : DEFAULTS.accentDark,
radiusImg: clamp(num('radius_img'), 4, 16, DEFAULTS.radiusImg),
textH1,
textH2,
textH3,
proseReading: clamp(num('prose_size_reading'), 16, 22, DEFAULTS.proseReading),
proseTech: clamp(num('prose_size_tech'), 16, 22, DEFAULTS.proseTech),
leadingReading: clamp(num('leading_reading'), 1.5, 2.0, DEFAULTS.leadingReading),
leadingTech: clamp(num('leading_tech'), 1.5, 2.0, DEFAULTS.leadingTech),
space3,
space4,
space5,
space6,
flowP: clamp(num('flow_p'), 0, 2, DEFAULTS.flowP),
flowH2Post: clamp(num('flow_h2_post'), 0, 2, DEFAULTS.flowH2Post),
dur: clamp(num('dur'), 150, 600, DEFAULTS.dur),
};
}
/** 亮态变量块(不含 accent,accent 分态) */
function lightVars(t: Theme): string {
return [
`--accent:${t.accentLight}`,
`--radius-img:${t.radiusImg}px`,
`--text-h1:${t.textH1}px`,
`--text-h2:${t.textH2}px`,
`--text-h3:${t.textH3}px`,
`--prose-size-reading:${t.proseReading}px`,
`--prose-size-tech:${t.proseTech}px`,
`--leading-reading:${t.leadingReading}`,
`--leading-tech:${t.leadingTech}`,
`--space-3:${t.space3}px`,
`--space-4:${t.space4}px`,
`--space-5:${t.space5}px`,
`--space-6:${t.space6}px`,
`--flow-p:${t.flowP}em`,
`--flow-h2-post:${t.flowH2Post}em`,
`--dur:${t.dur}ms`,
].join(';');
}
/**
* 生成内联样式串:`:root:root{…}` 压 tokens.css 的 `:root`(0,1,0),
* `[data-theme="dark"]:root:root{…}` 压其暗态覆盖——不依赖注入顺序。
*/
export function themeVarsCss(t: Theme): string {
return `:root:root{${lightVars(t)}}[data-theme="dark"]:root:root{--accent:${t.accentDark}}`;
}
/** 一次取全(BaseLayout 用):返回内联 CSS 串 */
export async function themeCss(): Promise<string> {
return themeVarsCss(await getTheme());
}
+5
View File
@@ -5,6 +5,7 @@ import LinkCardStyles from '../../components/geek/LinkCardStyles.astro';
import PhotoZoom from '../../components/geek/PhotoZoom.astro';
import BaseLayout from '../../layouts/BaseLayout.astro';
import ArtalkComments from '../../components/ArtalkComments.astro';
import ViewMeta from '../../components/geek/ViewMeta.astro';
import { trackOf } from '../../content.config';
// URL = /archives/{slug},slug 即文件名,与 Halo 旧结构 1:1(不可改)
@@ -61,6 +62,7 @@ const toc = headings.filter((h) => h.depth === 2 || h.depth === 3);
<span aria-hidden="true">·</span>
<span>阅读时长 {minutes} 分钟</span>
</p>
<ViewMeta pageKey={`/archives/${entry.id}/`} mode="page" variant="inline" />
</header>
<div class="prose-reading">
<Content />
@@ -88,6 +90,7 @@ const toc = headings.filter((h) => h.depth === 2 || h.depth === 3);
<span aria-hidden="true">·</span>
<span>阅读时长 {minutes} 分钟</span>
</p>
<ViewMeta pageKey={`/archives/${entry.id}/`} mode="page" variant="inline" />
</header>
<div class="tech-grid">
<div class="prose-tech">
@@ -209,6 +212,8 @@ const toc = headings.filter((h) => h.depth === 2 || h.depth === 3);
margin-top: var(--space-2);
text-wrap: balance;
}
/* 计数徽标(ViewMeta 组件根元素)紧贴 meta 行;scoped 样式够不着子组件内部,需 :global */
.post-head :global(.viewmeta) { margin-top: 8px; }
.post-meta {
display: flex;
align-items: center;
+4 -2
View File
@@ -3,9 +3,11 @@
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import PostList from '../../components/PostList.astro';
import { sortPosts } from '../../lib/post-order';
const posts = (await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden'))).sort(
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
// 置顶优先 → 时间倒序(sortPosts 契约见 src/lib/post-order.ts)
const posts = sortPosts(
await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden')),
);
---
+4
View File
@@ -6,6 +6,7 @@ import LinkCardStyles from '../../../components/geek/LinkCardStyles.astro';
import PhotoZoom from '../../../components/geek/PhotoZoom.astro';
import BaseLayout from '../../../layouts/BaseLayout.astro';
import ArtalkComments from '../../../components/ArtalkComments.astro';
import ViewMeta from '../../../components/geek/ViewMeta.astro';
import AiTranslatedBadge from '../../../components/AiTranslatedBadge.astro';
import { t } from '../../../i18n/ui';
@@ -58,6 +59,7 @@ const minutes = Math.max(1, Math.ceil(words / 220));
<span aria-hidden="true">·</span>
<span>{minutes} {t('reading_time_min', 'en')}</span>
</p>
<ViewMeta pageKey={`/archives/${entry.id}/`} locale="en" mode="page" variant="inline" />
</header>
<AiTranslatedBadge />
<div class={isReading ? 'prose-reading' : 'prose-tech'}>
@@ -85,6 +87,8 @@ const minutes = Math.max(1, Math.ceil(words / 220));
.post-head { margin-bottom: var(--space-4); }
.post-cat { font-family: var(--font-mono); font-size: 12px; color: var(--ink-3); letter-spacing: 0.08em; }
.post-title { font-size: var(--text-h1); line-height: var(--leading-heading); color: var(--ink-1); text-wrap: balance; }
/* 计数徽标紧贴 meta 行(组件根元素在子作用域,需 :global) */
.post-head :global(.viewmeta) { margin-top: 8px; }
.post-meta { display: flex; align-items: center; gap: var(--space-2); margin-top: var(--space-2); font-size: 13px; color: var(--ink-3); }
.emo { display: inline-block; width: 10px; height: 10px; border-radius: 50%; }
.pn { display: flex; flex-direction: column; gap: var(--space-2); margin-top: var(--space-5); }
+3 -2
View File
@@ -3,9 +3,10 @@
import { getCollection } from 'astro:content';
import BaseLayout from '../../../layouts/BaseLayout.astro';
import PostList from '../../../components/PostList.astro';
import { sortPosts } from '../../../lib/post-order';
const posts = (await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden'))).sort(
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
const posts = sortPosts(
await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden')),
);
const enTitle = Object.fromEntries(
(await getCollection('postsEn')).map((e) => [e.id, e.data.title]),
+3 -137
View File
@@ -1,139 +1,5 @@
---
// /en/ 首页:与 zh 版结构逐区对齐(名片区/文章列表/最新影像/热力图/项目墙)
import { Image } from 'astro:assets';
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import IdCard from '../../components/IdCard.astro';
import PostList from '../../components/PostList.astro';
import ProjectWall from '../../components/geek/ProjectWall.astro';
import Heatmap from '../../components/geek/Heatmap.astro';
import { t } from '../../i18n/ui';
import { homeConfig, homeTagline, renderMarkdown } from '../../lib/site-content';
import SocialLinks from '../../components/SocialLinks.astro';
// 首页可配项(后台主页设置):副标题/影像张数/区块开关/每轨条数,字段级回落
const cfg = await homeConfig('en');
const subtitleHtml = cfg.subtitle ? await renderMarkdown(cfg.subtitle) : '';
const posts = (await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden'))).sort(
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
);
// 列表标题吃 postsEn 译题(缺译回退 zh,与 /en/archives 同策略)
const enTitle = Object.fromEntries(
(await getCollection('postsEn')).map((e) => [e.id, e.data.title]),
);
const photos = (await getCollection('photos')).slice(0, Math.max(1, cfg.latestPhotos));
// 身份语与 zh 版同源(content/site/home.yaml 的 en 字段),缺值回落默认
const tagline = await homeTagline('en');
// /en/ 首页薄壳(与 zh 共用 HomePage 组件,仅 locale 不同)
import HomePage from '../../components/pages/HomePage.astro';
---
<BaseLayout title="wait4x" description="wait4x's blog: reading · code · photography">
<IdCard>
<p class="tagline">{tagline}</p>
{subtitleHtml && <div class="subtitle" set:html={subtitleHtml} />}
<SocialLinks />
</IdCard>
{cfg.sections.tracks && <PostList {posts} locale="en" titleOverrides={enTitle} perTrack={cfg.perTrackLimit} />}
{cfg.sections.photos && cfg.latestPhotos > 0 && (
<section class="latest" aria-label={t('latest_photos', 'en')}>
<div class="latest-head">
<span class="lab">{t('latest_photos', 'en')}</span>
<a class="tap more" href="/en/gallery/">{t('enter_gallery', 'en')}</a>
</div>
<div class="thumbs">
{photos.map((photo) => (
<a class="thumb" href="/en/gallery/" aria-label={`Gallery: ${photo.data.title}`}>
<Image
src={photo.data.image}
alt={photo.data.alt ?? photo.data.title}
widths={[180, 360]}
sizes="(min-width: 820px) 20vw, 42vw"
/>
</a>
))}
</div>
</section>
)}
{cfg.sections.heatmap && <Heatmap
labels={{
title: t('heatmap_title', 'en'),
summary: t('heatmap_summary', 'en'),
less: t('heatmap_less', 'en'),
more: t('heatmap_more', 'en'),
note: t('heatmap_note', 'en'),
}}
/>}
{cfg.sections.projects && <ProjectWall locale="en" />}
</BaseLayout>
<style>
.subtitle {
font-size: 13.5px;
color: var(--ink-4);
margin-top: 2px;
letter-spacing: 0.01em;
}
.subtitle :global(p) { margin: 0; }
.subtitle :global(a) { color: var(--accent); }
.tagline {
font-size: 14px;
color: var(--ink-3);
margin-top: 4px;
letter-spacing: 0.01em;
}
.socials {
display: flex;
align-items: center;
gap: var(--space-2);
margin-top: var(--space-2);
}
.social {
color: var(--ink-3);
border-radius: 8px;
transition: color var(--dur) var(--ease);
}
.social:hover { color: var(--accent); }
.latest { margin-top: var(--space-6); }
.latest-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-3);
}
.lab {
font-size: 12.5px;
color: var(--ink-4);
letter-spacing: 0.08em;
}
.more {
font-size: 13px;
color: var(--accent);
padding-inline: var(--space-2);
transition: color var(--dur) var(--ease);
}
.more:hover { color: var(--ink-1); }
.thumbs {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-2);
margin-top: var(--space-3);
}
.thumb {
aspect-ratio: 1;
border-radius: 8px;
overflow: hidden;
}
.thumb :global(img) {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform var(--dur) var(--ease);
}
.thumb:hover :global(img) { transform: scale(1.03); }
@media (min-width: 820px) {
.thumbs { grid-template-columns: repeat(4, 1fr); }
}
</style>
<HomePage locale="en" />
+9 -6
View File
@@ -2,26 +2,29 @@
import ProjectWall from '../../components/geek/ProjectWall.astro';
import Heatmap from '../../components/geek/Heatmap.astro';
import BaseLayout from '../../layouts/BaseLayout.astro';
import GithubProfile from '../../components/geek/GithubProfile.astro';
import { t } from '../../i18n/ui';
import { githubReadmeMarkdown, openSourceCopy, openSourceOptions, renderMarkdown } from '../../lib/site-content';
import { githubReadmeMarkdown, openSourceCopy, openSourceOptions, pageTypography, renderMarkdown } from '../../lib/site-content';
const copy = await openSourceCopy('en');
const options = await openSourceOptions();
const readmeMd = options.introFrom === 'github-readme' ? await githubReadmeMarkdown() : null;
const readmeHtml = readmeMd ? await renderMarkdown(readmeMd) : null;
const typo = await pageTypography('siteOpenSource', 'opensource');
---
<BaseLayout title="Open Source · wait4x" description="My open-source activity: repos, contributions, and this site's source">
<h1 class="page-title">Open Source</h1>
{readmeHtml ? (
<div class="blurb readme-intro" set:html={readmeHtml} />
) : (
<p class="blurb">{copy.blurb}<a class="link" href={copy.repoUrl}>{copy.repoLabel}</a>{copy.blurbAfter}</p>
)}
<div class={typo.className || undefined} style={typo.style || undefined}>
<GithubProfile locale="en" />
{/* compat path (not recommended): renders the profile README below the card when
intro_from=github-readme. It pulls 7 kinds of third-party images and loses align centering. */}
{readmeHtml && <div class="blurb readme-intro" set:html={readmeHtml} />}
<Heatmap labels={{
title: t('heatmap_title', 'en'), summary: t('heatmap_summary', 'en'),
less: t('heatmap_less', 'en'), more: t('heatmap_more', 'en'), note: t('heatmap_note', 'en'),
}} />
<ProjectWall limit={99} locale="en" pinned={copy.pinned} options={options} />
</div>
</BaseLayout>
<style>
+3 -159
View File
@@ -1,161 +1,5 @@
---
import { Image } from 'astro:assets';
import { getCollection } from 'astro:content';
import BaseLayout from '../layouts/BaseLayout.astro';
import IdCard from '../components/IdCard.astro';
import PostList from '../components/PostList.astro';
import ProjectWall from '../components/geek/ProjectWall.astro';
import Heatmap from '../components/geek/Heatmap.astro';
import { fetchHeartbeat, agoZh } from '../lib/heartbeat';
import { t } from '../i18n/ui';
import { homeConfig, homeTagline, renderMarkdown } from '../lib/site-content';
import SocialLinks from '../components/SocialLinks.astro';
// 首页可配项(后台主页设置):副标题/影像张数/区块开关/每轨条数,字段级回落
const cfg = await homeConfig('zh');
const subtitleHtml = cfg.subtitle ? await renderMarkdown(cfg.subtitle) : '';
const posts = (await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden'))).sort(
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
);
// 最新影像取集合前 4 张;照片时间序由照片管线(exif 车道)治理,此处只裁切
const photos = (await getCollection('photos')).slice(0, Math.max(1, cfg.latestPhotos));
const heartbeat = await fetchHeartbeat();
// 身份语改由 content/site/home.yaml 供给(后台可改),缺值回落默认文案
const tagline = await homeTagline('zh');
// 首页薄壳(逻辑与样式在 src/components/pages/HomePage.astro,zh/en 共用)
import HomePage from '../components/pages/HomePage.astro';
---
<BaseLayout title="wait4x">
<IdCard>
<p class="tagline">{tagline}</p>
{subtitleHtml && <div class="subtitle" set:html={subtitleHtml} />}
<SocialLinks />
</IdCard>
{cfg.sections.tracks && <PostList {posts} perTrack={cfg.perTrackLimit} />}
{cfg.sections.photos && cfg.latestPhotos > 0 && (
<section class="latest" aria-label="最新影像">
<div class="latest-head">
<span class="lab">最新影像</span>
<a class="tap more" href="/gallery/">进入图库 →</a>
</div>
<div class="thumbs">
{photos.map((photo, i) => (
<a class="thumb" href="/gallery/" aria-label={`查看图库:${photo.data.title}`}>
{/* 首两图 eager(Astro Image 默认 lazy 会把 LCP 压到 6s+),首图再拔 fetchpriority */}
<Image
src={photo.data.image}
alt={photo.data.alt ?? photo.data.title}
widths={[180, 360]}
sizes="(min-width: 820px) 20vw, 42vw"
loading={i < 2 ? 'eager' : 'lazy'}
fetchpriority={i === 0 ? 'high' : undefined}
/>
</a>
))}
</div>
</section>
)}
{cfg.sections.heatmap && <Heatmap
labels={{
title: t('heatmap_title', 'zh'),
summary: t('heatmap_summary', 'zh'),
less: t('heatmap_less', 'zh'),
more: t('heatmap_more', 'zh'),
note: t('heatmap_note', 'zh'),
}}
/>}
{cfg.sections.projects && <ProjectWall />}
{heartbeat && (
<p class="heartbeat" aria-label="最近心跳">
<span class="beat-dot" aria-hidden="true"></span>
最近心跳:{agoZh(heartbeat.when)} · {heartbeat.msg}
</p>
)}
</BaseLayout>
<style>
.subtitle {
font-size: 13.5px;
color: var(--ink-4);
margin-top: 2px;
letter-spacing: 0.01em;
}
.subtitle :global(p) { margin: 0; }
.subtitle :global(a) { color: var(--accent); }
.tagline {
font-size: 14px;
color: var(--ink-3);
margin-top: 4px;
letter-spacing: 0.01em;
}
.heartbeat {
font-family: var(--font-mono);
font-size: 12.5px;
color: var(--ink-3);
margin-top: var(--space-2);
display: flex;
align-items: center;
gap: 8px;
}
.beat-dot {
width: 7px; height: 7px; border-radius: 50%;
background: var(--ink-2);
animation: beat 2.4s ease-in-out infinite;
}
@keyframes beat { 0%, 100% { opacity: 0.35; } 50% { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .beat-dot { animation: none; } }
.socials {
display: flex;
align-items: center;
gap: var(--space-2);
margin-top: var(--space-2);
}
.social {
color: var(--ink-3);
border-radius: 8px;
transition: color var(--dur) var(--ease);
}
.social:hover { color: var(--accent); }
.latest { margin-top: var(--space-6); }
.latest-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-3);
}
.lab {
font-size: 12.5px;
color: var(--ink-4);
letter-spacing: 0.08em;
}
.more {
font-size: 13px;
color: var(--accent);
padding-inline: var(--space-2);
transition: color var(--dur) var(--ease);
}
.more:hover { color: var(--ink-1); }
.thumbs {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-2);
margin-top: var(--space-3);
}
.thumb {
aspect-ratio: 1;
border-radius: 8px;
overflow: hidden;
}
.thumb :global(img) {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform var(--dur) var(--ease);
}
.thumb:hover :global(img) { transform: scale(1.03); }
@media (min-width: 820px) {
.thumbs { grid-template-columns: repeat(4, 1fr); }
}
</style>
<HomePage locale="zh" />
+10 -6
View File
@@ -2,8 +2,9 @@
import ProjectWall from '../components/geek/ProjectWall.astro';
import Heatmap from '../components/geek/Heatmap.astro';
import BaseLayout from '../layouts/BaseLayout.astro';
import GithubProfile from '../components/geek/GithubProfile.astro';
import { t } from '../i18n/ui';
import { githubReadmeMarkdown, openSourceCopy, openSourceOptions, renderMarkdown } from '../lib/site-content';
import { githubReadmeMarkdown, openSourceCopy, openSourceOptions, pageTypography, renderMarkdown } from '../lib/site-content';
// 叙述文案 + 置顶名单 + 项目墙选项均改由 content/site/opensource.yaml 供给(后台可改)
const copy = await openSourceCopy('zh');
@@ -11,19 +12,22 @@ const options = await openSourceOptions();
// 开篇来源:github-readme → 构建期取 profile 仓 README(缓存 content/.github-readme.md,失败回落 yaml blurb)
const readmeMd = options.introFrom === 'github-readme' ? await githubReadmeMarkdown() : null;
const readmeHtml = readmeMd ? await renderMarkdown(readmeMd) : null;
const typo = await pageTypography('siteOpenSource', 'opensource');
---
<BaseLayout title="开源 · wait4x" description="我的开源活动:仓库、贡献与本站源码">
<h1 class="page-title">开源<span class="count mono">Open Source</span></h1>
{readmeHtml ? (
<div class="blurb readme-intro" set:html={readmeHtml} />
) : (
<p class="blurb">{copy.blurb}<a class="link" href={copy.repoUrl}>{copy.repoLabel}</a>{copy.blurbAfter}</p>
)}
<div class={typo.className || undefined} style={typo.style || undefined}>
<GithubProfile locale="zh" />
{/* 兼容通道(不推荐):intro_from=github-readme 时把 profile README 原样渲染在档案卡下方。
它会引入 7 类第三方外链图(shields.io / readme-typing-svg / stats / snake / 访客徽章)并带失效的
align 居中;慢网或被墙时整片空白。默认 off,正常内容由上方原生档案卡 + opensource.yaml 叙述承载。 */}
{readmeHtml && <div class="blurb readme-intro" set:html={readmeHtml} />}
<Heatmap labels={{
title: t('heatmap_title', 'zh'), summary: t('heatmap_summary', 'zh'),
less: t('heatmap_less', 'zh'), more: t('heatmap_more', 'zh'), note: t('heatmap_note', 'zh'),
}} />
<ProjectWall limit={99} pinned={copy.pinned} options={options} />
</div>
</BaseLayout>
<style>
+51 -38
View File
@@ -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,
.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,60 +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 { color: var(--ink-1); }
.prose-reading .ink-2, .prose-tech .ink-2 { color: var(--ink-2); }
.prose-reading .ink-3, .prose-tech .ink-3 { color: var(--ink-3); }
.prose-reading .ink-muted, .prose-tech .ink-muted { color: var(--ink-4); }
.prose-reading .ink-accent, .prose-tech .ink-accent { color: var(--accent); }
.prose-reading .ink-accent-soft, .prose-tech .ink-accent-soft { color: var(--accent-soft-text); }
.prose-reading .ink-warm, .prose-tech .ink-warm { color: var(--ink-warm); }
.prose-reading .ink-cool, .prose-tech .ink-cool { color: var(--ink-cool); }
.prose-reading .ink-faint, .prose-tech .ink-faint { color: var(--ink-faint); }
.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 { color: var(--c-blue); }
.prose-reading .c-blue-soft, .prose-tech .c-blue-soft { color: var(--c-blue-soft); }
.prose-reading .c-teal, .prose-tech .c-teal { color: var(--c-teal); }
.prose-reading .c-teal-soft, .prose-tech .c-teal-soft { color: var(--c-teal-soft); }
.prose-reading .c-green, .prose-tech .c-green { color: var(--c-green); }
.prose-reading .c-green-soft, .prose-tech .c-green-soft { color: var(--c-green-soft); }
.prose-reading .c-yellow, .prose-tech .c-yellow { color: var(--c-yellow); }
.prose-reading .c-yellow-soft, .prose-tech .c-yellow-soft { color: var(--c-yellow-soft); }
.prose-reading .c-orange, .prose-tech .c-orange { color: var(--c-orange); }
.prose-reading .c-orange-soft, .prose-tech .c-orange-soft { color: var(--c-orange-soft); }
.prose-reading .c-red, .prose-tech .c-red { color: var(--c-red); }
.prose-reading .c-red-soft, .prose-tech .c-red-soft { color: var(--c-red-soft); }
.prose-reading .c-pink, .prose-tech .c-pink { color: var(--c-pink); }
.prose-reading .c-pink-soft, .prose-tech .c-pink-soft { color: var(--c-pink-soft); }
.prose-reading .c-purple, .prose-tech .c-purple { color: var(--c-purple); }
.prose-reading .c-purple-soft, .prose-tech .c-purple-soft { color: var(--c-purple-soft); }
.prose-reading .font-serif, .prose-tech .font-serif { font-family: var(--font-serif); }
.prose-reading .font-kai, .prose-tech .font-kai { font-family: var(--font-kai); }
.prose-reading .font-sans, .prose-tech .font-sans { font-family: var(--font-sans); }
.prose-reading .font-mono, .prose-tech .font-mono { font-family: var(--font-mono); font-size: 0.92em; }
.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 {
.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 {
.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 {
.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 {
.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 {
.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);
}
.prose-reading .font-fangsong, .prose-tech .font-fangsong {
/* 艺术字体专用类(层 3):仅用于标题/短句。艺术字字形大、CJK 切片体积高
(实测 163KB/用到页),长段使用会显著抬高单页字体成本——docs 有体积警告。 */
.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, .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 {
.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;
@@ -229,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 {
.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);
@@ -327,3 +333,10 @@ html.reader .prose-reading, html.reader .prose-tech {
}
html.reader #main > * { animation: none; } /* 入场动效关闭 */
/* ICP 备案链接永不隐藏(合规底线);分隔点号随之保留必要最小集 */
/* ── 层 2:页面级排版覆盖容器(只作用于当前页内容区,不动全站 tokens) ── */
.page-typo {
font-family: var(--page-font, inherit);
font-size: calc(1em * var(--page-scale, 1));
}
.page-typo .page-accent, .page-typo .link { color: var(--page-accent, var(--accent)); }
+6 -2
View File
@@ -72,12 +72,16 @@
/* ---- 字体三轨 ---- */
/* 书评正文:思源宋(用户对标模板的宋体质感拍板);文楷降级为金句点缀 */
/* 链尾 emoji 回退(视觉终审 2026-11-02):用户正文里的 emoji 若字体栈无覆盖会落
豆腐块(无彩色 emoji 字体的构建机/精简系统实测出黑方块) */
--font-serif: "Noto Serif SC Variable", "Noto Serif SC", Georgia, "Songti SC", STSong,
"Noto Serif CJK SC", "Source Han Serif SC", SimSun, serif;
"Noto Serif CJK SC", "Source Han Serif SC", SimSun,
"Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", "Noto Emoji", serif;
--font-kai: "LXGW WenKai Screen", "Songti SC", serif;
/* UI / 技术文:思源黑 */
--font-sans: "Noto Sans SC Variable", "Noto Sans SC", system-ui, -apple-system, "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
"Hiragino Sans GB", "Microsoft YaHei",
"Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", "Noto Emoji", sans-serif;
/* 代码 */
--font-mono: "JetBrains Mono", ui-monospace, Consolas, "Courier New", monospace;
+22
View File
@@ -34,6 +34,28 @@ ok('首页加载', (await page.title()).includes('wait4x'));
ok('热力图渲染', await page.locator('.hm-grid').count() > 0);
ok('项目墙渲染', await page.locator('.project-wall, .repo').count() > 0);
// 0) Phase 5h:置顶 / 计数徽标 / 瞬间富文本与逐条评论 / 块式关于页
await page.goto(BASE + '/archives/', { waitUntil: 'networkidle' });
const firstArchive = await page.locator('.post-list a.row').first().getAttribute('href');
ok('5h·置顶排在归档首位', (firstArchive ?? '').includes('/archives/writing-template/'), firstArchive ?? 'none');
ok('5h·置顶徽标可见', (await page.locator('.pin').count()) > 0);
await page.goto(BASE + '/archives/writing-template/', { waitUntil: 'networkidle' });
ok('5h·文章页计数组件挂载', (await page.locator('[data-viewmeta]').count()) >= 2, `count=${await page.locator('[data-viewmeta]').count()}`);
ok('5h·模板彩色强调生效', (await page.locator('.prose-tech .c-blue').count()) > 0);
ok('5h·模板折叠块渲染', (await page.locator('.prose-tech details').count()) > 0);
await page.goto(BASE + '/moments/', { waitUntil: 'networkidle' });
ok('5h·瞬间带逐条评论区', (await page.locator('details[data-mom-comments]').count()) > 0);
ok('5h·瞬间卡片带只读计数', (await page.locator('.mom-card [data-viewmeta]').count()) > 0);
await page.locator('details[data-mom-comments]').first().locator('summary').click();
await page.waitForTimeout(400);
// 计数徽标是 details 的兄弟(ViewMeta 根节点为 <p>,塞进 <summary> 属非法嵌套)
ok('5h·瞬间计数为只读模式', (await page.locator('.mom-cm-meta [data-viewmeta]').first().getAttribute('data-mode')) === 'readonly');
await page.goto(BASE + '/about/', { waitUntil: 'networkidle' });
ok('5h·关于页由块驱动', (await page.locator('[data-block]').count()) > 0, `blocks=${await page.locator('[data-block]').count()}`);
// 1) 明暗切换:按钮 + 状态持久 + t 键
const before = await page.getAttribute('html', 'data-theme');
await page.click('[data-theme-toggle]');