Author SHA1 Message Date
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
37 changed files with 1586 additions and 118 deletions

No files matched your search

@@ -0,0 +1,14 @@
{"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"}
@@ -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", "image": "https://astro.build/og/astro.jpg",
"favicon": "https://astro.build/favicon.svg", "favicon": "https://astro.build/favicon.svg",
"ts": 1793593759816 "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: three per page at most
The rule comes from the design baseline: the shell is greyscale, so colour exists to weight one judgement in the body — three per page, maximum. More than that and it turns loud.
<span class="c-blue">First use: state one key conclusion in blue.</span> Then carry on normally.
<span class="c-red">Second use: warn about a trap in red.</span> For example, “after changing a Markdown plugin you must clear `node_modules/.astro`, or the change silently does nothing”.
<span class="c-green">Third use: confirm a working path in green.</span> After three, this section stays uncoloured.
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">` | three per page at most |
| 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)。
## 二、彩色强调:一页不超过三处
纪律来自设计基线:站点外壳是灰阶的,颜色只用来给正文某一个判断加重,一页最多三处。超过就变成花哨。
<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">` | 一页 ≤3 处 |
| 换字族 | `<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>
## 十、分隔线之后收尾
---
收尾三句就够:这篇讲了什么、读者该带走什么、下一步去哪。模板不是要你写满十节,而是**在你需要表达时,能力都在手边**。
下一篇开始,复制本文件,删到只剩你要说的。
+63
View File
@@ -62,6 +62,69 @@ blocks:
zh: 街拍与山野,随身一台无反,在等光的间隙按快门。 zh: 街拍与山野,随身一台无反,在等光的间隙按快门。
en: Street and landscapes with a mirrorless camera, pressing the shutter while waiting for the light. 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:admin@wait4x.online'
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 - type: prose
title: title:
zh: 本站如何构建 zh: 本站如何构建
+31 -8
View File
@@ -1,14 +1,24 @@
# 主页身份语(后台「页面文案 → 主页设置」可改)
# 全部字段留空 = 现有形态(字段级回落默认值,零配置零变化)
tagline: tagline:
zh: 写作者 · 读者 · 快门按压者,拥抱开源。 zh: 写作者 · 读者 · 快门按压者,拥抱开源。
en: Writer · Reader · Shutter-presser — embracing open source. en: Writer · Reader · Shutter-presser — embracing open source.
latestPhotos: 4
perTrackLimit: 6 # 副标题(可选,markdown 短文本):渲染在身份语下方,留空不渲染
sections: # subtitle:
tracks: true # zh: '正在把**阅读、代码与影像**收进同一个工作台'
photos: true # en: 'Collecting **reading, code and images** into one workbench'
heatmap: true
projects: true # 头像(可选):上传后自动写入 ./assets/avatar-xxx.jpg(走 Astro 图片管线)
# 留空 = 回退内置渐变占位
# avatar: ./assets/avatar.jpg
# 站点图标(favicon,全局生效):public 下的路径;留空回退 /favicon.svg
# 上传走 public/icons/(后台媒体库),可填 /icons/my.ico、/icons/my.svg
favicon: /favicon.svg favicon: /favicon.svg
# 社交链接(顺序即展示顺序,后台可增删拖动排序)
# icon 取值:github / rss / email / bilibili / x / 留空(留空按 label 显示纯文字)
socials: socials:
- label: GitHub - label: GitHub
url: https://github.com/wait4xx url: https://github.com/wait4xx
@@ -17,5 +27,18 @@ socials:
url: /rss.xml url: /rss.xml
icon: rss icon: rss
- label: 邮件 - label: 邮件
url: mailto:X@alumni.nudt.edu.cn url: mailto:admin@wait4x.online
icon: email icon: email
# 最新影像张数(1-6,默认 4;填 0 = 隐藏该区块)
latestPhotos: 4
# 首页每轨文章条数(4-8,默认 6)
perTrackLimit: 6
# 区块开关(默认全开;关掉的区块不渲染)
sections:
tracks: true
photos: true
heatmap: true
projects: true
+18 -4
View File
@@ -4,9 +4,23 @@ blurb:
repo_url: https://git.wait4x.online/wait4x/blog repo_url: https://git.wait4x.online/wait4x/blog
repo_label: git.wait4x.online/wait4x/blog repo_label: git.wait4x.online/wait4x/blog
blurb_after: blurb_after:
zh: —— Astro SSG、双写作流、构建发布全链皆在仓中。以下是我在自托管 gitea 与 GitHub 上的全部公开仓库,以及最近一年的贡献活动。 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. 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: all
# 白名单(mode=only 时生效)
include: []
# 黑名单(mode=exclude 时生效)
exclude: []
# 排序:pinned-first=置顶优先(默认) | stars=星数降序 | updated=最近更新
sort: pinned-first sort: pinned-first
perSource: github # 来源过滤:both=双源合并(默认) | github | gitea
intro_from: github-readme perSource: both
# 开篇来源(compat,**不推荐开启**):off=用本文件 blurb + 原生档案卡(默认)
# github-readme = 构建期取 wait4xx/wait4xx 的 README 原样渲染在档案卡下方
# ⚠ 开启会引入 7 类第三方外链图(readme-typing-svg / shields.io 徽章 / github-readme-stats /
# streak-stats / activity-graph / 贡献蛇 / komarev 访客徽章),慢网或被墙时整片空白;
# 且 README 用 <div align="center"> 居中,本站渲染不认该属性 → 居中失效。内容已由档案卡承接。
intro_from: "off"
+47
View File
@@ -2,6 +2,36 @@
> 适用:`content/posts/*.md`(书评与技术轨通用)。资产目录约定 + 三种媒体的写法 + 体积纪律。 > 适用:`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>` 折叠、分隔线。
写之前只需记住三条纪律:
- **彩色 ≤3 处/页**:外壳是灰阶,颜色只用来给某个判断加重;超过就花哨
- **整页换字族要谨慎**:会给访问者拉一份中文字体(汇文明朝整页约 420KB,已子集化)
- **折叠放细节不放结论**:`<details>` 里的内容可能永远没人展开
## 置顶文章
首页、归档、书架三处列表默认按日期倒序。想让某篇长期排在最前,frontmatter 加一行:
```yaml
pinned: true
```
语义边界(刻意设计成这样):
- **只影响列表顺序**,不改 `pubDate`、不改 URL、不影响 RSS 的时间序列(订阅者不会看到时间错乱)
- 多篇同时置顶时,它们内部仍按日期倒序
- 列表里会显示一个灰阶「置顶」小徽标,取消置顶后立刻回到时间序
- 后台「文章」编辑页有「置顶」开关,不必手改 frontmatter
## 目录约定 ## 目录约定
``` ```
@@ -223,6 +253,23 @@ typography:
**层 3 · 艺术字体**:`font-art-kuaile`(站酷快乐体,艺术字专用类)。⚠️ **仅用于标题/短句**:艺术字字形大,CJK 切片体积高(实测用到页 +163KB),长段使用会显著抬高单页字体成本。 **层 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,字段速查) ## 页面块(关于页 blocks,字段速查)
关于页正文可由九个块拼成(后台拖拽排序,每块有「显示」开关)。完整操作见 [管理台](./管理台.md);这里是写内容时对照的字段表——文本字段一律 `{zh, en}`。 关于页正文可由九个块拼成(后台拖拽排序,每块有「显示」开关)。完整操作见 [管理台](./管理台.md);这里是写内容时对照的字段表——文本字段一律 `{zh, en}`。
+38
View File
@@ -24,6 +24,27 @@
- **文章英文版**:不在后台写。跑 `python3 scripts/translate/translate_posts.py` 生成 `content/posts-en/` 同名文件;手写润色过的译文在 frontmatter 写 `locked: true`,翻译脚本会无条件跳过它。 - **文章英文版**:不在后台写。跑 `python3 scripts/translate/translate_posts.py` 生成 `content/posts-en/` 同名文件;手写润色过的译文在 frontmatter 写 `locked: true`,翻译脚本会无条件跳过它。
- **保存即提交**:后台保存等于向仓库提交,push 后约 10 秒自动构建上线。 - **保存即提交**:后台保存等于向仓库提交,push 后约 10 秒自动构建上线。
## 文章置顶(5h 新增)
文章编辑页有一个「置顶」开关。打开后这篇文章排在首页分轨、归档、书架三处列表的最前面,列表里显示一个灰阶「置顶」小徽标。
语义边界(刻意如此,别当 bug 报):
- **只影响列表顺序**:`pubDate` 不变、URL 不变、RSS 里的时间序列不变,订阅者不会看到时间错乱
- 多篇同时置顶时,置顶组内部仍按日期倒序
- 关掉开关立即恢复时间序,没有"冷却期"
## 瞬间(5h 起与文章同级)
「瞬间」集合的「文字」字段是 **markdown 编辑器**,与文章同一套表达力:
- markdown:`**加粗**`、`*斜体*`、列表、`> 引用`、`` `行内代码` ``
- 排版类 HTML:`<span class="c-blue font-kai">彩色 + 楷体</span>`(可用类清单见写作指南「文章排版类」一节,与文章完全同一份白名单)
- 仍守外壳灰阶纪律:彩色是**强调**不是装饰,一页 ≤3 处
**每条瞬间自带评论区**:时间线卡片下方有「评论」折叠条,读者点开才加载评论(不点开不为它付任何加载成本)。中文页与英文页共用同一条评论流,语言切换不会丢评论。
**每条的浏览 / 评论数字是只读的**:读者点开评论区不会虚增浏览数(只有整页访问才计一次)。
## 照片上传流 ## 照片上传流
1. 图库照片 → New Entry:填标题(**必填**,空标题会让构建炸——实测坑)、选分组(street/landscape)、可写 alt 1. 图库照片 → New Entry:填标题(**必填**,空标题会让构建炸——实测坑)、选分组(street/landscape)、可写 alt
@@ -118,6 +139,12 @@ blocks:
### 开源页(opensource.yaml) ### 开源页(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` | 仓短名数组 | 置顶顺序即展示顺序 | | `pinned` | 仓短名数组 | 置顶顺序即展示顺序 |
@@ -143,6 +170,17 @@ token 位在 `~/docker_apps/blog/github-token.txt`(本机,0600)。
- **README 里的相对链接不重写**(如 `./img/x.png`、`docs/foo.md`):它们相对 GitHub 仓根解析,在本站没有对应产物,点了多半 404。保原样比猜一个基址可信,所以不做改写。 - **README 里的相对链接不重写**(如 `./img/x.png`、`docs/foo.md`):它们相对 GitHub 仓根解析,在本站没有对应产物,点了多半 404。保原样比猜一个基址可信,所以不做改写。
- **外部徽章图片加固但不参与构建**:shields.io、readme-typing-svg 这类第三方徽章在构建期**不抓取**,渲染时统一补 `loading="lazy"`(缺 alt 补空),它们慢、被墙或 404 都不影响页面渲染;尺寸已由样式限制在内容宽度内。 - **外部徽章图片加固但不参与构建**:shields.io、readme-typing-svg 这类第三方徽章在构建期**不抓取**,渲染时统一补 `loading="lazy"`(缺 alt 补空),它们慢、被墙或 404 都不影响页面渲染;尺寸已由样式限制在内容宽度内。
## 浏览与评论计数(5h 新增)
文章页、瞬间页、开源页、关于页都用同一枚灰阶徽标:**眼睛图标 = 浏览数,气泡图标 = 条评论**,数据来自自建 Artalk,不依赖任何第三方徽章服务。
几条行为约定(省得当成 bug):
- **一页只计一次浏览**:「计一次」发生在这页唯一那个 `page` 模式徽标(文章页在标题下方),同一页其余徽标都是只读镜像,显示的是同一个数字——不会出现"页头 17、评论区 16"
- **开评论不算浏览**:瞬间逐条徽标是只读的,读者展开评论区不会给那条动态虚增浏览量
- **接口挂掉显示「–」而不是 0**:宁可留白,也不给读者看一个错误的零
- **换页重计、同页刷新重计**:这是浏览量的定义,不是重复计数的 bug(软导航回同一页也算新一次访问)
## 纪律(与写作指南同源) ## 纪律(与写作指南同源)
- **图片体积**:上传前自行压缩(长边 ≤2400px)——Sveltia 内建优化**刻意关闭**(会抹 EXIF 断构建期提取管线) - **图片体积**:上传前自行压缩(长边 ≤2400px)——Sveltia 内建优化**刻意关闭**(会抹 EXIF 断构建期提取管线)
+6 -1
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: 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: category, widget: select, options: [{label: 阅读, value: reading}, {label: 技术, value: tech}, {label: 照片, value: photo}] }
- { label: 标签, name: tags, widget: list, required: false } - { 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: mood, widget: color, required: false, hint: '#RRGGBB,仅阅读轨使用' }
- { label: 原书作者, name: bookAuthor, widget: string, required: false } - { label: 原书作者, name: bookAuthor, widget: string, required: false }
- { label: 一句话描述(书架策展语), name: description, widget: text, required: false } - { label: 一句话描述(书架策展语), name: description, widget: text, required: false }
@@ -97,7 +98,11 @@ collections:
sortable_fields: [pubDate, text] sortable_fields: [pubDate, text]
fields: fields:
- { label: 时间, name: pubDate, widget: datetime, date_format: 'YYYY-MM-DD', time_format: false, format: 'YYYY-MM-DD' } - { 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: image, widget: image, required: false, hint: '与文字至少填一项;建议先压缩' }
- label: 标签 - label: 标签
name: tags name: tags
+6 -33
View File
@@ -7,15 +7,14 @@ interface Props {
} }
const { pageKey, pageTitle } = Astro.props; const { pageKey, pageTitle } = Astro.props;
import { t } from '../i18n/ui'; import { t } from '../i18n/ui';
import ViewMeta from './geek/ViewMeta.astro';
import { localeOf } from '../lib/locale'; import { localeOf } from '../lib/locale';
const locale = localeOf(Astro.url.pathname); const locale = localeOf(Astro.url.pathname);
--- ---
<!-- pv 小字:计数由本组件下方脚本独立 POST(页面加载即计),与 Artalk init 的 pvAdd 解耦 --> <!-- 浏览/评论徽标(Phase 5h T4):评论区这枚是 **readonly** —— 整页的计数点唯一在
<p class="atk-pv-line" aria-label={t('pv_viewed', locale)}> 详情页标题下的 ViewMeta(mode="page"),此处只读展示,避免同页两处各计一次。 -->
<span data-artalk-pv data-page-key={pageKey} data-page-title={pageTitle}>–</span> <ViewMeta pageKey={pageKey} locale={locale} mode="readonly" variant="chip" />
{' '}{t('pv_viewed', locale)}
</p>
<div id="artalk-container" data-page-key={pageKey} data-page-title={pageTitle}></div> <div id="artalk-container" data-page-key={pageKey} data-page-title={pageTitle}></div>
<p class="atk-load-hint" id="artalk-retry" hidden> <p class="atk-load-hint" id="artalk-retry" hidden>
<span>评论区加载失败。</span> <span>评论区加载失败。</span>
@@ -100,37 +99,11 @@ const locale = localeOf(Astro.url.pathname);
cursor: pointer; cursor: pointer;
} }
.atk-retry-btn:hover { border-color: var(--accent); } .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> </style>
<script> <script>
// 访问量:POST /artalk/api/v2/pages/pv 计数并取数(POST 天然绕过 ESA 边缘缓存; // 浏览计数已移交 ViewMeta 组件(Phase 5h T4),此处只管评论挂载。
// 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();
// ── 挂载健壮化(用户实测「总没加载出来」)── // ── 挂载健壮化(用户实测「总没加载出来」)──
// 触发路径三条:IO 进入视口(主)/ 点击评论区锚点(兜底)/ 滚到页底 2s 后(兜底); // 触发路径三条:IO 进入视口(主)/ 点击评论区锚点(兜底)/ 滚到页底 2s 后(兜底);
// 失败路径:脚本 5s 未 onload → 灰阶提示 + 重试钮;成功后容器打 data-artalk-mounted 供 E2E 断言。 // 失败路径:脚本 5s 未 onload → 灰阶提示 + 重试钮;成功后容器打 data-artalk-mounted 供 E2E 断言。
+4
View File
@@ -154,6 +154,10 @@ const switchLabel = curLocale === 'zh' ? 'EN' : '中';
阈值 350→450(E 车道 6 项后 en 词长实测:360-430px 页溢 42-51px;zh 无感知) */ 阈值 350→450(E 车道 6 项后 en 词长实测:360-430px 页溢 42-51px;zh 无感知) */
@media (max-width: 450px) { @media (max-width: 450px) {
nav ul { overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch; } 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; } nav { min-width: 0; }
.header-inner { flex-wrap: nowrap; } .header-inner { flex-wrap: nowrap; }
/* 中段再收一档地板(en 五词在 360-430 差 55-61px):字号 11px 下限仍可读, /* 中段再收一档地板(en 五词在 360-430 差 55-61px):字号 11px 下限仍可读,
+237
View File
@@ -0,0 +1,237 @@
---
/**
* 瞬间逐条评论(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 { 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>
<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> <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' && {key !== 'reading' &&
post.data.tags.map((tag) => <code class="tag">{`#${tag}`}</code>)} post.data.tags.map((tag) => <code class="tag">{`#${tag}`}</code>)}
<time class="row-date" datetime={post.data.pubDate.toISOString()}> <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> <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.category !== 'reading' &&
post.data.tags.map((tag) => <code class="tag">{`#${tag}`}</code>)} post.data.tags.map((tag) => <code class="tag">{`#${tag}`}</code>)}
<time class="row-date" datetime={post.data.pubDate.toISOString()}> <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> <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' && {cat !== 'reading' &&
post.data.tags.map((tag) => <code class="tag">{`#${tag}`}</code>)} post.data.tags.map((tag) => <code class="tag">{`#${tag}`}</code>)}
<time class="row-date" datetime={post.data.pubDate.toISOString()}> <time class="row-date" datetime={post.data.pubDate.toISOString()}>
@@ -261,6 +270,18 @@ const byMonth = (list: CollectionEntry<'posts'>[]) => {
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; color: var(--accent); } 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 { .tag {
flex: none; flex: none;
font-family: var(--font-mono); font-family: var(--font-mono);
+3
View File
@@ -27,6 +27,9 @@ const { block } = Astro.props;
margin-top: var(--space-3); margin-top: var(--space-3);
} }
.prose-body.full { max-width: none; } .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(p) { margin-block: 0 var(--space-2); }
.prose-body :global(ul) { .prose-body :global(ul) {
margin: var(--space-1) 0 var(--space-2); margin: var(--space-1) 0 var(--space-2);
+1 -1
View File
@@ -7,7 +7,7 @@ const commitUrl = `https://git.wait4x.online/wait4x/blog/commit/${sha}`;
--- ---
<p class="build-info"> <p class="build-info">
built from built from{' '}
{short === 'dev' ? <span>dev</span> : <a href={commitUrl} class="link mono-link">{short}</a>} {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 · {new Date(date).toISOString().slice(0, 16).replace('T', ' ')}Z · Astro
</p> </p>
+260
View File
@@ -0,0 +1,260 @@
---
// 开源页原生档案卡(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 { 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();
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>
<p class="pf-bio">
{copy.blurb}<a class="pf-link" href={copy.repoUrl}>{copy.repoLabel}</a>{copy.blurbAfter}
</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>
)}
</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;
}
@media (max-width: 560px) {
.pf-lang { grid-template-columns: 5.5rem 1fr 2.6rem; }
.pf-stats { gap: var(--space-3); }
}
</style>
+12 -7
View File
@@ -23,11 +23,9 @@ interface Props {
} }
const { limit = 6, locale = 'zh', pinned, options } = Astro.props; const { limit = 6, locale = 'zh', pinned, options } = Astro.props;
const title = t('open_source_projects', locale); const title = t('open_source_projects', locale);
const LANG_COLORS: Record<string, string> = { // 2026-11-02 视觉终审:原先按 GitHub linguist 给每个仓染语言色点,开源页一次铺
Astro: '#ff5a03', TypeScript: '#3178c6', JavaScript: '#f1e05a', // 15+ 个仓 = 15 种彩色,破「外壳灰阶、彩色只作正文强调(≤3 处/页)」红线。
Python: '#3572a5', Go: '#00add8', Rust: '#dea584', Shell: '#89e051', // 改为统一灰阶点:语言信息靠文字承载,颜色不再做无意义的编码。
HTML: '#e34c26', CSS: '#563d7c', Dockerfile: '#384d54',
};
let projects: RepoCard[] = []; let projects: RepoCard[] = [];
try { try {
@@ -50,7 +48,7 @@ try {
<span class="repo-meta"> <span class="repo-meta">
{p.language && ( {p.language && (
<span class="lang"> <span class="lang">
<span class="dot" style={`background:${LANG_COLORS[p.language] ?? '#8b8f98'}`} /> <span class="dot" />
{p.language} {p.language}
</span> </span>
)} )}
@@ -87,5 +85,12 @@ try {
.repo-desc { font-size: 13px; color: var(--ink-3); } .repo-desc { font-size: 13px; color: var(--ink-3); }
.repo-meta { display: flex; gap: var(--space-3); font-size: 12px; color: var(--ink-3); margin-top: 4px; } .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; } .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> </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 BaseLayout from '../../layouts/BaseLayout.astro';
import { t } from '../../i18n/ui'; import { t } from '../../i18n/ui';
import type { Locale } from '../../lib/locale'; import type { Locale } from '../../lib/locale';
import { sortPosts } from '../../lib/post-order';
import { Image } from 'astro:assets'; import { Image } from 'astro:assets';
interface Props { locale?: Locale } interface Props { locale?: Locale }
const { locale = 'zh' } = Astro.props; const { locale = 'zh' } = Astro.props;
const books = (await getCollection('posts', ({ data }) => !data.draft && data.category === 'reading' && !data.tags.includes('hidden'))) const books = sortPosts(
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()); await getCollection('posts', ({ data }) => !data.draft && data.category === 'reading' && !data.tags.includes('hidden')),
);
// 回退契约(schema 注释声称而此前未实现——视觉复核抓到的缺口): // 回退契约(schema 注释声称而此前未实现——视觉复核抓到的缺口):
// description 缺省取正文第一个非空段落(去 markdown 记号)前 60 字 // description 缺省取正文第一个非空段落(去 markdown 记号)前 60 字
+5 -3
View File
@@ -10,6 +10,7 @@ import ProjectWall from '../geek/ProjectWall.astro';
import Heatmap from '../geek/Heatmap.astro'; import Heatmap from '../geek/Heatmap.astro';
import SocialLinks from '../SocialLinks.astro'; import SocialLinks from '../SocialLinks.astro';
import { t } from '../../i18n/ui'; import { t } from '../../i18n/ui';
import { sortPosts } from '../../lib/post-order';
import type { Locale } from '../../lib/locale'; import type { Locale } from '../../lib/locale';
import { import {
homeConfig, homeConfig,
@@ -30,9 +31,10 @@ const cfg = await homeConfig(locale);
const subtitleHtml = cfg.subtitle ? await renderMarkdown(cfg.subtitle) : ''; const subtitleHtml = cfg.subtitle ? await renderMarkdown(cfg.subtitle) : '';
const announcementHtml = cfg.announcement ? await renderRich(cfg.announcement) : ''; const announcementHtml = cfg.announcement ? await renderRich(cfg.announcement) : '';
const posts = ( // 置顶优先 → 时间倒序(sortPosts 契约见 src/lib/post-order.ts)
await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden')) const posts = sortPosts(
).sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime()); await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden')),
);
// en 轨列表标题吃 postsEn 译题(缺译回退 zh,与 /en/archives 同策略) // en 轨列表标题吃 postsEn 译题(缺译回退 zh,与 /en/archives 同策略)
const enTitle = locale === 'en' const enTitle = locale === 'en'
+61 -35
View File
@@ -2,7 +2,10 @@
import { Image } from 'astro:assets'; import { Image } from 'astro:assets';
import { getCollection } from 'astro:content'; import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro'; import BaseLayout from '../../layouts/BaseLayout.astro';
import MomentComments from '../MomentComments.astro';
import ViewMeta from '../geek/ViewMeta.astro';
import { t } from '../../i18n/ui'; import { t } from '../../i18n/ui';
import { renderRich } from '../../lib/site-content';
import type { Locale } from '../../lib/locale'; import type { Locale } from '../../lib/locale';
// /en/ 侧由 T4 薄页传 locale='en';zh 路由无人传参,默认 zh 零回归 // /en/ 侧由 T4 薄页传 locale='en';zh 路由无人传参,默认 zh 零回归
@@ -14,16 +17,25 @@ const { locale = 'zh' } = Astro.props;
const moments = (await getCollection('moments')).sort( const moments = (await getCollection('moments')).sort(
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(), (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(); const iso = (d: Date) => d.toISOString();
--- ---
<BaseLayout title={`${t('moments_title', locale)} · wait4x`}> <BaseLayout title={`${t('moments_title', locale)} · wait4x`}>
<h1 class="page-title">{t('moments_title', locale)}</h1> <h1 class="page-title">{t('moments_title', locale)}</h1>
<p class="page-note">{t('moments_note', locale)}</p> <p class="page-note">{t('moments_note', locale)}</p>
<p class="pv-line" aria-label={t('pv_viewed', locale)}> <!-- 整页计数(Phase 5h T4):这是本页唯一的计数点(page 模式);
<span data-artalk-pv data-page-key={locale === 'en' ? '/en/moments/' : '/moments/'}>–</span> 每条瞬间卡片底部的徽标是 readonly,读者开评论不会虚增浏览数 -->
{' '}{t('pv_viewed', locale)} <ViewMeta class="mom-page-meta" pageKey={locale === 'en' ? '/en/moments/' : '/moments/'} {locale} variant="chip" />
</p>
<ol class="tl"> <ol class="tl">
{moments.map((m) => ( {moments.map((m) => (
<li class="tl-item"> <li class="tl-item">
@@ -46,12 +58,13 @@ const iso = (d: Date) => d.toISOString();
/> />
</figure> </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 && ( {m.data.tags.length > 0 && (
<p class="mom-tags"> <p class="mom-tags">
{m.data.tags.map((tag) => <code class="tag">{`#${tag}`}</code>)} {m.data.tags.map((tag) => <code class="tag">{`#${tag}`}</code>)}
</p> </p>
)} )}
<MomentComments slug={m.id} locale={locale} />
</div> </div>
</li> </li>
))} ))}
@@ -67,25 +80,6 @@ const iso = (d: Date) => d.toISOString();
</BaseLayout> </BaseLayout>
<script> <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 时 // 时间线渐显:html[data-mom-anim] 由 JS 前置标记——无 JS / reduced-motion 时
// 标记缺席,CSS 初始隐藏规则整体不生效(渐进降级,元素永远可见) // 标记缺席,CSS 初始隐藏规则整体不生效(渐进降级,元素永远可见)
let io: IntersectionObserver | null = null; let io: IntersectionObserver | null = null;
@@ -109,11 +103,9 @@ const iso = (d: Date) => d.toISOString();
}; };
document.addEventListener('astro:page-load', () => { document.addEventListener('astro:page-load', () => {
void bumpPv();
armAnim(); armAnim();
refreshInstallCta(); refreshInstallCta();
}); });
void bumpPv();
armAnim(); armAnim();
// —— PWA 安装引导:beforeinstallprompt 早在页面加载初期触发, // —— PWA 安装引导:beforeinstallprompt 早在页面加载初期触发,
@@ -173,8 +165,11 @@ const iso = (d: Date) => d.toISOString();
} }
/* 时间线骨架:左 mono 日期 + 竖线 + --accent 节点 + 右侧内容卡 */ /* 时间线骨架:左 mono 日期 + 竖线 + --accent 节点 + 右侧内容卡 */
.tl-item { .tl-item {
/* 日期轨宽度集中一处:10 字符 mono 日期(2026-04-29)在 12.5px 下约 75px,
原来复用 var(--space-5)=40px 导致在「-」处折成两行(视觉终审 P1) */
--tl-rail: 5.4rem;
position: relative; 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); padding-left: var(--space-4);
border-left: 1px solid var(--line); border-left: 1px solid var(--line);
} }
@@ -192,9 +187,10 @@ const iso = (d: Date) => d.toISOString();
} }
.tl-date { .tl-date {
position: absolute; position: absolute;
left: calc(-1 * (var(--space-5) + var(--space-4))); left: calc(-1 * (var(--tl-rail) + var(--space-4)));
top: 4px; top: 4px;
width: var(--space-5); width: var(--tl-rail);
white-space: nowrap;
text-align: right; text-align: right;
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 12.5px; font-size: 12.5px;
@@ -208,6 +204,41 @@ const iso = (d: Date) => d.toISOString();
background: var(--bg-soft); background: var(--bg-soft);
} }
.mom-card > p { font-size: 15px; color: var(--ink-2); } .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 { .mom-fig {
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
@@ -270,12 +301,7 @@ const iso = (d: Date) => d.toISOString();
transition: border-color var(--dur) var(--ease); transition: border-color var(--dur) var(--ease);
} }
.install-btn:hover { border-color: var(--accent); } .install-btn:hover { border-color: var(--accent); }
.pv-line { .mom-page-meta { margin: 0 0 var(--space-4); }
font-family: var(--font-mono);
font-size: 12px;
color: var(--ink-4);
margin: 0 0 var(--space-4);
}
/* 初始隐藏仅在 JS 标记存在时生效:宁轻勿闹——透明度+8px 上移,进入视口一次性点亮 */ /* 初始隐藏仅在 JS 标记存在时生效:宁轻勿闹——透明度+8px 上移,进入视口一次性点亮 */
[data-mom-anim] .tl-item { [data-mom-anim] .tl-item {
opacity: 0; opacity: 0;
+2
View File
@@ -123,6 +123,8 @@ const postsSchema = z.object({
video: z.string().optional(), video: z.string().optional(),
/** 视频封面(可选):同 public/media 下的图片 URL */ /** 视频封面(可选):同 public/media 下的图片 URL */
poster: z.string().optional(), poster: z.string().optional(),
/** 置顶:列表排序优先(只影响顺序,不改 pubDate/URL/RSS 时间语义) */
pinned: z.boolean().optional(),
draft: z.boolean().optional(), draft: z.boolean().optional(),
}); });
+3
View File
@@ -112,6 +112,9 @@ const dict = {
// ===== F 车道:访问量小字(Artalk pv,组件内独立计数) ===== // ===== F 车道:访问量小字(Artalk pv,组件内独立计数) =====
pv_viewed: { zh: '人看过', en: 'views' }, 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: { zh: '滚轮缩放 · 双击放大 · Esc 关闭', en: 'Wheel to zoom · Double-click · Esc' },
lb_hint_touch: { zh: '双指缩放 · 双击放大', en: 'Pinch to zoom · Double-tap' }, lb_hint_touch: { zh: '双指缩放 · 双击放大', en: 'Pinch to zoom · Double-tap' },
+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):构建时双源拉取 + 仓内缓存兜底 // 项目墙数据(极客层 P0):构建时双源拉取 + 仓内缓存兜底
// 双源 = 自家 gitea(数据自控叙事)+ GitHub(社区 star 真值);置顶清单人工维护 // 双源 = 自家 gitea(数据自控叙事)+ GitHub(社区 star 真值);置顶清单人工维护
export interface RepoCard { 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']; export const PINNED_DEFAULT: string[] = ['blog'];
async function fetchJson(url: string): Promise<RawRepo[]> { 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, { const res = await fetch(url, {
headers: { Accept: 'application/json', 'User-Agent': 'wait4x-blog-build' }, headers: { ...headers, Accept: 'application/json' },
signal: AbortSignal.timeout(10_000), signal: AbortSignal.timeout(10_000),
}); });
if (!res.ok) throw new Error(`${url} -> ${res.status}`); if (!res.ok) throw new Error(`${url} -> ${res.status}`);
@@ -158,3 +170,43 @@ function arrange(
return b.stars - a.stars; 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;
}
}
+5
View File
@@ -5,6 +5,7 @@ import LinkCardStyles from '../../components/geek/LinkCardStyles.astro';
import PhotoZoom from '../../components/geek/PhotoZoom.astro'; import PhotoZoom from '../../components/geek/PhotoZoom.astro';
import BaseLayout from '../../layouts/BaseLayout.astro'; import BaseLayout from '../../layouts/BaseLayout.astro';
import ArtalkComments from '../../components/ArtalkComments.astro'; import ArtalkComments from '../../components/ArtalkComments.astro';
import ViewMeta from '../../components/geek/ViewMeta.astro';
import { trackOf } from '../../content.config'; import { trackOf } from '../../content.config';
// URL = /archives/{slug},slug 即文件名,与 Halo 旧结构 1:1(不可改) // 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 aria-hidden="true">·</span>
<span>阅读时长 {minutes} 分钟</span> <span>阅读时长 {minutes} 分钟</span>
</p> </p>
<ViewMeta pageKey={`/archives/${entry.id}/`} mode="page" variant="inline" />
</header> </header>
<div class="prose-reading"> <div class="prose-reading">
<Content /> <Content />
@@ -88,6 +90,7 @@ const toc = headings.filter((h) => h.depth === 2 || h.depth === 3);
<span aria-hidden="true">·</span> <span aria-hidden="true">·</span>
<span>阅读时长 {minutes} 分钟</span> <span>阅读时长 {minutes} 分钟</span>
</p> </p>
<ViewMeta pageKey={`/archives/${entry.id}/`} mode="page" variant="inline" />
</header> </header>
<div class="tech-grid"> <div class="tech-grid">
<div class="prose-tech"> <div class="prose-tech">
@@ -209,6 +212,8 @@ const toc = headings.filter((h) => h.depth === 2 || h.depth === 3);
margin-top: var(--space-2); margin-top: var(--space-2);
text-wrap: balance; text-wrap: balance;
} }
/* 计数徽标(ViewMeta 组件根元素)紧贴 meta 行;scoped 样式够不着子组件内部,需 :global */
.post-head :global(.viewmeta) { margin-top: 8px; }
.post-meta { .post-meta {
display: flex; display: flex;
align-items: center; align-items: center;
+4 -2
View File
@@ -3,9 +3,11 @@
import { getCollection } from 'astro:content'; import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro'; import BaseLayout from '../../layouts/BaseLayout.astro';
import PostList from '../../components/PostList.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( // 置顶优先 → 时间倒序(sortPosts 契约见 src/lib/post-order.ts)
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(), 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 PhotoZoom from '../../../components/geek/PhotoZoom.astro';
import BaseLayout from '../../../layouts/BaseLayout.astro'; import BaseLayout from '../../../layouts/BaseLayout.astro';
import ArtalkComments from '../../../components/ArtalkComments.astro'; import ArtalkComments from '../../../components/ArtalkComments.astro';
import ViewMeta from '../../../components/geek/ViewMeta.astro';
import AiTranslatedBadge from '../../../components/AiTranslatedBadge.astro'; import AiTranslatedBadge from '../../../components/AiTranslatedBadge.astro';
import { t } from '../../../i18n/ui'; import { t } from '../../../i18n/ui';
@@ -58,6 +59,7 @@ const minutes = Math.max(1, Math.ceil(words / 220));
<span aria-hidden="true">·</span> <span aria-hidden="true">·</span>
<span>{minutes} {t('reading_time_min', 'en')}</span> <span>{minutes} {t('reading_time_min', 'en')}</span>
</p> </p>
<ViewMeta pageKey={`/archives/${entry.id}/`} locale="en" mode="page" variant="inline" />
</header> </header>
<AiTranslatedBadge /> <AiTranslatedBadge />
<div class={isReading ? 'prose-reading' : 'prose-tech'}> <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-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-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; } .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); } .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%; } .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); } .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 { getCollection } from 'astro:content';
import BaseLayout from '../../../layouts/BaseLayout.astro'; import BaseLayout from '../../../layouts/BaseLayout.astro';
import PostList from '../../../components/PostList.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( const posts = sortPosts(
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(), await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden')),
); );
const enTitle = Object.fromEntries( const enTitle = Object.fromEntries(
(await getCollection('postsEn')).map((e) => [e.id, e.data.title]), (await getCollection('postsEn')).map((e) => [e.id, e.data.title]),
+6 -8
View File
@@ -2,25 +2,23 @@
import ProjectWall from '../../components/geek/ProjectWall.astro'; import ProjectWall from '../../components/geek/ProjectWall.astro';
import Heatmap from '../../components/geek/Heatmap.astro'; import Heatmap from '../../components/geek/Heatmap.astro';
import BaseLayout from '../../layouts/BaseLayout.astro'; import BaseLayout from '../../layouts/BaseLayout.astro';
import GithubProfile from '../../components/geek/GithubProfile.astro';
import { t } from '../../i18n/ui'; import { t } from '../../i18n/ui';
import { githubReadmeMarkdown, openSourceCopy, openSourceOptions, pageTypography, renderMarkdown, renderRich } from '../../lib/site-content'; import { githubReadmeMarkdown, openSourceCopy, openSourceOptions, pageTypography, renderMarkdown } from '../../lib/site-content';
const copy = await openSourceCopy('en'); const copy = await openSourceCopy('en');
const options = await openSourceOptions(); const options = await openSourceOptions();
const readmeMd = options.introFrom === 'github-readme' ? await githubReadmeMarkdown() : null; const readmeMd = options.introFrom === 'github-readme' ? await githubReadmeMarkdown() : null;
const readmeHtml = readmeMd ? await renderMarkdown(readmeMd) : null; const readmeHtml = readmeMd ? await renderMarkdown(readmeMd) : null;
const blurbHtml = await renderRich(copy.blurb, { inline: true });
const blurbAfterHtml = await renderRich(copy.blurbAfter, { inline: true });
const typo = await pageTypography('siteOpenSource', 'opensource'); const typo = await pageTypography('siteOpenSource', 'opensource');
--- ---
<BaseLayout title="Open Source · wait4x" description="My open-source activity: repos, contributions, and this site's source"> <BaseLayout title="Open Source · wait4x" description="My open-source activity: repos, contributions, and this site's source">
<h1 class="page-title">Open Source</h1> <h1 class="page-title">Open Source</h1>
<div class={typo.className || undefined} style={typo.style || undefined}> <div class={typo.className || undefined} style={typo.style || undefined}>
{readmeHtml ? ( <GithubProfile locale="en" />
<div class="blurb readme-intro" set:html={readmeHtml} /> {/* 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. */}
<p class="blurb rich" set:html={`${blurbHtml}<a class="link" href="${copy.repoUrl}">${copy.repoLabel}</a>${blurbAfterHtml}`} /> {readmeHtml && <div class="blurb readme-intro" set:html={readmeHtml} />}
)}
<Heatmap labels={{ <Heatmap labels={{
title: t('heatmap_title', 'en'), summary: t('heatmap_summary', 'en'), 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'), less: t('heatmap_less', 'en'), more: t('heatmap_more', 'en'), note: t('heatmap_note', 'en'),
+7 -9
View File
@@ -2,8 +2,9 @@
import ProjectWall from '../components/geek/ProjectWall.astro'; import ProjectWall from '../components/geek/ProjectWall.astro';
import Heatmap from '../components/geek/Heatmap.astro'; import Heatmap from '../components/geek/Heatmap.astro';
import BaseLayout from '../layouts/BaseLayout.astro'; import BaseLayout from '../layouts/BaseLayout.astro';
import GithubProfile from '../components/geek/GithubProfile.astro';
import { t } from '../i18n/ui'; import { t } from '../i18n/ui';
import { githubReadmeMarkdown, openSourceCopy, openSourceOptions, pageTypography, renderMarkdown, renderRich } from '../lib/site-content'; import { githubReadmeMarkdown, openSourceCopy, openSourceOptions, pageTypography, renderMarkdown } from '../lib/site-content';
// 叙述文案 + 置顶名单 + 项目墙选项均改由 content/site/opensource.yaml 供给(后台可改) // 叙述文案 + 置顶名单 + 项目墙选项均改由 content/site/opensource.yaml 供给(后台可改)
const copy = await openSourceCopy('zh'); const copy = await openSourceCopy('zh');
@@ -11,19 +12,16 @@ const options = await openSourceOptions();
// 开篇来源:github-readme → 构建期取 profile 仓 README(缓存 content/.github-readme.md,失败回落 yaml blurb) // 开篇来源:github-readme → 构建期取 profile 仓 README(缓存 content/.github-readme.md,失败回落 yaml blurb)
const readmeMd = options.introFrom === 'github-readme' ? await githubReadmeMarkdown() : null; const readmeMd = options.introFrom === 'github-readme' ? await githubReadmeMarkdown() : null;
const readmeHtml = readmeMd ? await renderMarkdown(readmeMd) : null; const readmeHtml = readmeMd ? await renderMarkdown(readmeMd) : null;
// 层 1:blurb 两段走统一富文本管线(后台可写 <span class="font-kai c-blue">)
const blurbHtml = await renderRich(copy.blurb, { inline: true });
const blurbAfterHtml = await renderRich(copy.blurbAfter, { inline: true });
const typo = await pageTypography('siteOpenSource', 'opensource'); const typo = await pageTypography('siteOpenSource', 'opensource');
--- ---
<BaseLayout title="开源 · wait4x" description="我的开源活动:仓库、贡献与本站源码"> <BaseLayout title="开源 · wait4x" description="我的开源活动:仓库、贡献与本站源码">
<h1 class="page-title">开源<span class="count mono">Open Source</span></h1> <h1 class="page-title">开源<span class="count mono">Open Source</span></h1>
<div class={typo.className || undefined} style={typo.style || undefined}> <div class={typo.className || undefined} style={typo.style || undefined}>
{readmeHtml ? ( <GithubProfile locale="zh" />
<div class="blurb readme-intro" set:html={readmeHtml} /> {/* 兼容通道(不推荐):intro_from=github-readme 时把 profile README 原样渲染在档案卡下方。
) : ( 它会引入 7 类第三方外链图(shields.io / readme-typing-svg / stats / snake / 访客徽章)并带失效的
<p class="blurb rich" set:html={`${blurbHtml}<a class="link" href="${copy.repoUrl}">${copy.repoLabel}</a>${blurbAfterHtml}`} /> align 居中;慢网或被墙时整片空白。默认 off,正常内容由上方原生档案卡 + opensource.yaml 叙述承载。 */}
)} {readmeHtml && <div class="blurb readme-intro" set:html={readmeHtml} />}
<Heatmap labels={{ <Heatmap labels={{
title: t('heatmap_title', 'zh'), summary: t('heatmap_summary', 'zh'), 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'), less: t('heatmap_less', 'zh'), more: t('heatmap_more', 'zh'), note: t('heatmap_note', 'zh'),
+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, --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; --font-kai: "LXGW WenKai Screen", "Songti SC", serif;
/* UI / 技术文:思源黑 */ /* UI / 技术文:思源黑 */
--font-sans: "Noto Sans SC Variable", "Noto Sans SC", system-ui, -apple-system, "PingFang SC", --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; --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('.hm-grid').count() > 0);
ok('项目墙渲染', await page.locator('.project-wall, .repo').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 键 // 1) 明暗切换:按钮 + 状态持久 + t 键
const before = await page.getAttribute('html', 'data-theme'); const before = await page.getAttribute('html', 'data-theme');
await page.click('[data-theme-toggle]'); await page.click('[data-theme-toggle]');