Author SHA1 Message Date
wait4x cdd9c61641 Update 页面文案 “home” 2026-11-02 11:47:24 +00:00
wait4x e1fc8bd64c Update 页面文案 “opensource” 2026-11-02 11:45:26 +00:00
wait4x cc12e016ab Update 页面文案 “opensource” 2026-11-02 11:42:51 +00:00
37 changed files with 118 additions and 1586 deletions

No files matched your search

@@ -1,14 +0,0 @@
{"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"}
@@ -1,91 +0,0 @@
# 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 内文件
@@ -1,44 +0,0 @@
# 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,12 +27,5 @@
"image": "https://astro.build/og/astro.jpg",
"favicon": "https://astro.build/favicon.svg",
"ts": 1793593759816
},
"https://astro.build": {
"title": "Astro",
"desc": "Astro builds fast content sites, powerful web applications, dynamic server APIs, and everything in-between.",
"image": "https://astro.build/og/astro.jpg",
"favicon": "https://astro.build/favicon.svg",
"ts": 1793621147252
}
}
-10
View File
@@ -1,10 +0,0 @@
---
pubDate: 2026-11-02
tags: [站点更新]
text: |
瞬间现在和文章同级表达力:`text` 支持 markdown 与排版类,<span class="c-teal font-kai">这一句是青色 + 楷体</span>,也可以**加粗**。
另外每条瞬间自带评论区——展开才加载,不刷时间线就不为它付成本。
---
站点更新记录:瞬间富文本与逐条评论上线。
-125
View File
@@ -1,125 +0,0 @@
---
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
@@ -1,124 +0,0 @@
---
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,69 +62,6 @@ blocks:
zh: 街拍与山野,随身一台无反,在等光的间隙按快门。
en: Street and landscapes with a mirrorless camera, pressing the shutter while waiting for the light.
- type: prose
title:
zh: 关于我
en: About me
body:
zh: |
气象是我起点的专业——一个想做预报的人,依旧把云图当日常读物。
写代码是第二条主线:Linux、容器、自托管,把日常工具一件件磨顺手。
作品大多在 [wait4x.online](https://wait4x.online/)。
信条:**拥抱开源,热爱开源**。
en: |
Meteorology is where I started — an aspiring forecaster who still reads weather charts for fun.
Coding is the second thread: Linux, containers, self-hosting — polishing everyday tools one by one.
Most of my work lives at [wait4x.online](https://wait4x.online/).
Motto: **embrace open source, love open source**.
- type: linklist
title:
zh: 找到我
en: Find me
items:
- label:
zh: 本站
en: Website
href: 'https://wait4x.online/'
note:
zh: 文章、图库与瞬间
en: Posts, gallery and moments
- label:
zh: 邮件
en: Email
href: 'mailto: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
title:
zh: 本站如何构建
+8 -31
View File
@@ -1,24 +1,14 @@
# 主页身份语(后台「页面文案 → 主页设置」可改)
# 全部字段留空 = 现有形态(字段级回落默认值,零配置零变化)
tagline:
zh: 写作者 · 读者 · 快门按压者,拥抱开源。
en: Writer · Reader · Shutter-presser — embracing open source.
# 副标题(可选,markdown 短文本):渲染在身份语下方,留空不渲染
# subtitle:
# zh: '正在把**阅读、代码与影像**收进同一个工作台'
# en: 'Collecting **reading, code and images** into one workbench'
# 头像(可选):上传后自动写入 ./assets/avatar-xxx.jpg(走 Astro 图片管线)
# 留空 = 回退内置渐变占位
# avatar: ./assets/avatar.jpg
# 站点图标(favicon,全局生效):public 下的路径;留空回退 /favicon.svg
# 上传走 public/icons/(后台媒体库),可填 /icons/my.ico、/icons/my.svg
latestPhotos: 4
perTrackLimit: 6
sections:
tracks: true
photos: true
heatmap: true
projects: true
favicon: /favicon.svg
# 社交链接(顺序即展示顺序,后台可增删拖动排序)
# icon 取值:github / rss / email / bilibili / x / 留空(留空按 label 显示纯文字)
socials:
- label: GitHub
url: https://github.com/wait4xx
@@ -27,18 +17,5 @@ socials:
url: /rss.xml
icon: rss
- label: 邮件
url: mailto:admin@wait4x.online
url: mailto:X@alumni.nudt.edu.cn
icon: email
# 最新影像张数(1-6,默认 4;填 0 = 隐藏该区块)
latestPhotos: 4
# 首页每轨文章条数(4-8,默认 6)
perTrackLimit: 6
# 区块开关(默认全开;关掉的区块不渲染)
sections:
tracks: true
photos: true
heatmap: true
projects: true
+4 -18
View File
@@ -4,23 +4,9 @@ blurb:
repo_url: https://git.wait4x.online/wait4x/blog
repo_label: git.wait4x.online/wait4x/blog
blurb_after:
zh: ' —— Astro SSG、双写作流、构建发布全链皆在仓中。以下是我在自托管 gitea 与 GitHub 上的全部公开仓库,以及最近一年的贡献活动。'
en: ' — the Astro SSG, dual-writing workflow, and the full build & deploy pipeline live in the repo. Below are all my public repositories on self-hosted gitea and GitHub, plus contributions from the past year.'
# ---- 项目墙可见性与排序(后台「开源页」可改;留空/缺省 = 现状) ----
# mode: all=全部(默认) | only=仅 include 列出 | exclude=排除 exclude 列出
zh: —— Astro SSG、双写作流、构建发布全链皆在仓中。以下是我在自托管 gitea 与 GitHub 上的全部公开仓库,以及最近一年的贡献活动。
en: — the Astro SSG, dual-writing workflow, and the full build & deploy pipeline live in the repo. Below are all my public repositories on self-hosted gitea and GitHub, plus contributions from the past year.
mode: all
# 白名单(mode=only 时生效)
include: []
# 黑名单(mode=exclude 时生效)
exclude: []
# 排序:pinned-first=置顶优先(默认) | stars=星数降序 | updated=最近更新
sort: pinned-first
# 来源过滤:both=双源合并(默认) | github | gitea
perSource: both
# 开篇来源(compat,**不推荐开启**):off=用本文件 blurb + 原生档案卡(默认)
# github-readme = 构建期取 wait4xx/wait4xx 的 README 原样渲染在档案卡下方
# ⚠ 开启会引入 7 类第三方外链图(readme-typing-svg / shields.io 徽章 / github-readme-stats /
# streak-stats / activity-graph / 贡献蛇 / komarev 访客徽章),慢网或被墙时整片空白;
# 且 README 用 <div align="center"> 居中,本站渲染不认该属性 → 居中失效。内容已由档案卡承接。
intro_from: "off"
perSource: github
intro_from: github-readme
-47
View File
@@ -2,36 +2,6 @@
> 适用:`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
## 目录约定
```
@@ -253,23 +223,6 @@ typography:
**层 3 · 艺术字体**:`font-art-kuaile`(站酷快乐体,艺术字专用类)。⚠️ **仅用于标题/短句**:艺术字字形大,CJK 切片体积高(实测用到页 +163KB),长段使用会显著抬高单页字体成本。
## 瞬间(/moments)怎么写
瞬间与文章一样支持富文本,但入口在后台「瞬间」集合(`content/moments/*.md`):
```yaml
---
pubDate: 2026-11-02
tags: [随手拍]
text: 今天把**置顶**做完了,顺手试了 <span class="c-teal">彩色</span>
---
```
- `text` 支持 markdown(`**加粗**`、列表、`> 引用`)与**同一套排版类**(字体/颜色),因为走的是与全站文案相同的净化管线:危险标签整棵丢弃,只放行 35 项白名单类
- 一条瞬间 = 一行时间线卡片,**每条都带独立评论区**(展开才挂载,双语页共用同一评论流,评论不分裂)
- 每条的「浏览 / 评论」数字是只读统计(不会因为别人点开评论而虚增浏览)
- `text` 与 `image` 至少填一项;图片建议先压缩
## 页面块(关于页 blocks,字段速查)
关于页正文可由九个块拼成(后台拖拽排序,每块有「显示」开关)。完整操作见 [管理台](./管理台.md);这里是写内容时对照的字段表——文本字段一律 `{zh, en}`。
-38
View File
@@ -24,27 +24,6 @@
- **文章英文版**:不在后台写。跑 `python3 scripts/translate/translate_posts.py` 生成 `content/posts-en/` 同名文件;手写润色过的译文在 frontmatter 写 `locked: true`,翻译脚本会无条件跳过它。
- **保存即提交**:后台保存等于向仓库提交,push 后约 10 秒自动构建上线。
## 文章置顶(5h 新增)
文章编辑页有一个「置顶」开关。打开后这篇文章排在首页分轨、归档、书架三处列表的最前面,列表里显示一个灰阶「置顶」小徽标。
语义边界(刻意如此,别当 bug 报):
- **只影响列表顺序**:`pubDate` 不变、URL 不变、RSS 里的时间序列不变,订阅者不会看到时间错乱
- 多篇同时置顶时,置顶组内部仍按日期倒序
- 关掉开关立即恢复时间序,没有"冷却期"
## 瞬间(5h 起与文章同级)
「瞬间」集合的「文字」字段是 **markdown 编辑器**,与文章同一套表达力:
- markdown:`**加粗**`、`*斜体*`、列表、`> 引用`、`` `行内代码` ``
- 排版类 HTML:`<span class="c-blue font-kai">彩色 + 楷体</span>`(可用类清单见写作指南「文章排版类」一节,与文章完全同一份白名单)
- 仍守外壳灰阶纪律:彩色是**强调**不是装饰,一页 ≤3 处
**每条瞬间自带评论区**:时间线卡片下方有「评论」折叠条,读者点开才加载评论(不点开不为它付任何加载成本)。中文页与英文页共用同一条评论流,语言切换不会丢评论。
**每条的浏览 / 评论数字是只读的**:读者点开评论区不会虚增浏览数(只有整页访问才计一次)。
## 照片上传流
1. 图库照片 → New Entry:填标题(**必填**,空标题会让构建炸——实测坑)、选分组(street/landscape)、可写 alt
@@ -139,12 +118,6 @@ blocks:
### 开源页(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` | 仓短名数组 | 置顶顺序即展示顺序 |
@@ -170,17 +143,6 @@ token 位在 `~/docker_apps/blog/github-token.txt`(本机,0600)。
- **README 里的相对链接不重写**(如 `./img/x.png`、`docs/foo.md`):它们相对 GitHub 仓根解析,在本站没有对应产物,点了多半 404。保原样比猜一个基址可信,所以不做改写。
- **外部徽章图片加固但不参与构建**:shields.io、readme-typing-svg 这类第三方徽章在构建期**不抓取**,渲染时统一补 `loading="lazy"`(缺 alt 补空),它们慢、被墙或 404 都不影响页面渲染;尺寸已由样式限制在内容宽度内。
## 浏览与评论计数(5h 新增)
文章页、瞬间页、开源页、关于页都用同一枚灰阶徽标:**眼睛图标 = 浏览数,气泡图标 = 条评论**,数据来自自建 Artalk,不依赖任何第三方徽章服务。
几条行为约定(省得当成 bug):
- **一页只计一次浏览**:「计一次」发生在这页唯一那个 `page` 模式徽标(文章页在标题下方),同一页其余徽标都是只读镜像,显示的是同一个数字——不会出现"页头 17、评论区 16"
- **开评论不算浏览**:瞬间逐条徽标是只读的,读者展开评论区不会给那条动态虚增浏览量
- **接口挂掉显示「–」而不是 0**:宁可留白,也不给读者看一个错误的零
- **换页重计、同页刷新重计**:这是浏览量的定义,不是重复计数的 bug(软导航回同一页也算新一次访问)
## 纪律(与写作指南同源)
- **图片体积**:上传前自行压缩(长边 ≤2400px)——Sveltia 内建优化**刻意关闭**(会抹 EXIF 断构建期提取管线)
+1 -6
View File
@@ -38,7 +38,6 @@ collections:
- { label: 日期, name: pubDate, widget: datetime, date_format: 'YYYY-MM-DD', time_format: false, format: 'YYYY-MM-DD' }
- { label: 分轨, name: category, widget: select, options: [{label: 阅读, value: reading}, {label: 技术, value: tech}, {label: 照片, value: photo}] }
- { label: 标签, name: tags, widget: list, required: false }
- { label: 置顶, name: pinned, widget: boolean, required: false, default: false, hint: '置顶文章排在首页/归档/书架最前;只影响顺序,不改日期、URL 与 RSS 时间语义' }
- { label: 情绪色(书评色脊), name: mood, widget: color, required: false, hint: '#RRGGBB,仅阅读轨使用' }
- { label: 原书作者, name: bookAuthor, widget: string, required: false }
- { label: 一句话描述(书架策展语), name: description, widget: text, required: false }
@@ -98,11 +97,7 @@ collections:
sortable_fields: [pubDate, text]
fields:
- { label: 时间, name: pubDate, widget: datetime, date_format: 'YYYY-MM-DD', time_format: false, format: 'YYYY-MM-DD' }
- label: 文字
name: text
widget: markdown
required: false
hint: '与图片至少填一项。支持 markdown(**加粗**、列表、> 引用)与排版类 HTML,例如 <span class="c-blue font-kai">彩色+楷体</span>;装饰色仍守“外壳灰阶、彩色只作强调”的纪律'
- { label: 文字, name: text, widget: text, required: false, hint: '一句话(与图片至少填一项)' }
- { label: 图片, name: image, widget: image, required: false, hint: '与文字至少填一项;建议先压缩' }
- label: 标签
name: tags
+33 -6
View File
@@ -7,14 +7,15 @@ interface Props {
}
const { pageKey, pageTitle } = Astro.props;
import { t } from '../i18n/ui';
import ViewMeta from './geek/ViewMeta.astro';
import { localeOf } from '../lib/locale';
const locale = localeOf(Astro.url.pathname);
---
<!-- 浏览/评论徽标(Phase 5h T4):评论区这枚是 **readonly** —— 整页的计数点唯一在
详情页标题下的 ViewMeta(mode="page"),此处只读展示,避免同页两处各计一次。 -->
<ViewMeta pageKey={pageKey} locale={locale} mode="readonly" variant="chip" />
<!-- pv 小字:计数由本组件下方脚本独立 POST(页面加载即计),与 Artalk init 的 pvAdd 解耦 -->
<p class="atk-pv-line" aria-label={t('pv_viewed', locale)}>
<span data-artalk-pv data-page-key={pageKey} data-page-title={pageTitle}>–</span>
{' '}{t('pv_viewed', locale)}
</p>
<div id="artalk-container" data-page-key={pageKey} data-page-title={pageTitle}></div>
<p class="atk-load-hint" id="artalk-retry" hidden>
<span>评论区加载失败。</span>
@@ -99,11 +100,37 @@ const locale = localeOf(Astro.url.pathname);
cursor: pointer;
}
.atk-retry-btn:hover { border-color: var(--accent); }
.atk-pv-line {
font-family: var(--font-mono);
font-size: 12px;
color: var(--ink-4);
margin: var(--space-2) 0 0;
}
</style>
<script>
// 浏览计数已移交 ViewMeta 组件(Phase 5h T4),此处只管评论挂载。
//
// 访问量:POST /artalk/api/v2/pages/pv 计数并取数(POST 天然绕过 ESA 边缘缓存;
// GET /stats/* 会被 ESA 缓存出冻结数字,勿改查询式)。page-load 兼容软导航换页重计。
const bumpPv = async () => {
const el = document.querySelector('[data-artalk-pv]');
if (!el) return;
const key = el.dataset.pageKey ?? '';
if (!key) return;
try {
const res = await fetch('/artalk/api/v2/pages/pv', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ page_key: key, page_title: el.dataset.pageTitle ?? '', site_name: 'wait4x.online' }),
});
const d = await res.json();
el.textContent = String(d.pv ?? '–');
} catch {
el.textContent = '–'; // 计数失败静默:小字不可见性优于报错
}
};
document.addEventListener('astro:page-load', () => void bumpPv());
void bumpPv();
// ── 挂载健壮化(用户实测「总没加载出来」)──
// 触发路径三条:IO 进入视口(主)/ 点击评论区锚点(兜底)/ 滚到页底 2s 后(兜底);
// 失败路径:脚本 5s 未 onload → 灰阶提示 + 重试钮;成功后容器打 data-artalk-mounted 供 E2E 断言。
-4
View File
@@ -154,10 +154,6 @@ const switchLabel = curLocale === 'zh' ? 'EN' : '中';
阈值 350→450(E 车道 6 项后 en 词长实测:360-430px 页溢 42-51px;zh 无感知) */
@media (max-width: 450px) {
nav ul { overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
/* 视觉终审 P1:flex 子项默认可压缩,中文词被压到就地折成两行(开/源),
overflow-x 永远不触发。钉死不许压缩 + 不许换行,内滚才真正接管 */
nav ul > li { flex: none; }
.nav-link { white-space: nowrap; }
nav { min-width: 0; }
.header-inner { flex-wrap: nowrap; }
/* 中段再收一档地板(en 五词在 360-430 差 55-61px):字号 11px 下限仍可读,
-237
View File
@@ -1,237 +0,0 @@
---
/**
* 瞬间逐条评论(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,9 +89,6 @@ const byMonth = (list: CollectionEntry<'posts'>[]) => {
/>
)}
<span class="row-title">{titleOverrides?.[post.id] ?? post.data.title}</span>
{post.data.pinned && (
<span class="pin" title={t('pinned_label', locale)}>{t('pinned_label', locale)}</span>
)}
{key !== 'reading' &&
post.data.tags.map((tag) => <code class="tag">{`#${tag}`}</code>)}
<time class="row-date" datetime={post.data.pubDate.toISOString()}>
@@ -133,9 +130,6 @@ const byMonth = (list: CollectionEntry<'posts'>[]) => {
/>
)}
<span class="row-title">{titleOverrides?.[post.id] ?? post.data.title}</span>
{post.data.pinned && (
<span class="pin" title={t('pinned_label', locale)}>{t('pinned_label', locale)}</span>
)}
{post.data.category !== 'reading' &&
post.data.tags.map((tag) => <code class="tag">{`#${tag}`}</code>)}
<time class="row-date" datetime={post.data.pubDate.toISOString()}>
@@ -180,9 +174,6 @@ const byMonth = (list: CollectionEntry<'posts'>[]) => {
/>
)}
<span class="row-title">{titleOverrides?.[post.id] ?? post.data.title}</span>
{post.data.pinned && (
<span class="pin" title={t('pinned_label', locale)}>{t('pinned_label', locale)}</span>
)}
{cat !== 'reading' &&
post.data.tags.map((tag) => <code class="tag">{`#${tag}`}</code>)}
<time class="row-date" datetime={post.data.pubDate.toISOString()}>
@@ -270,18 +261,6 @@ const byMonth = (list: CollectionEntry<'posts'>[]) => {
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden; color: var(--accent); }
/* 置顶标记:灰阶 hairline 小徽标,不抢标题注意力 */
.pin {
font-family: var(--font-mono);
font-size: 10.5px;
line-height: 1.5;
color: var(--ink-4);
border: 1px solid var(--line);
border-radius: 4px;
padding: 0 5px;
white-space: nowrap;
flex: none;
}
.tag {
flex: none;
font-family: var(--font-mono);
-3
View File
@@ -27,9 +27,6 @@ const { block } = Astro.props;
margin-top: var(--space-3);
}
.prose-body.full { max-width: none; }
/* 标题下正文不再叠一档 margin:同级标题到内容统一 16px(tracks 块实测 16px,
prose 原来 16+16=32,两块相邻时节奏不齐——视觉终审) */
.sec-title + .prose-body { margin-top: 0; }
.prose-body :global(p) { margin-block: 0 var(--space-2); }
.prose-body :global(ul) {
margin: var(--space-1) 0 var(--space-2);
+1 -1
View File
@@ -7,7 +7,7 @@ const commitUrl = `https://git.wait4x.online/wait4x/blog/commit/${sha}`;
---
<p class="build-info">
built from{' '}
built from
{short === 'dev' ? <span>dev</span> : <a href={commitUrl} class="link mono-link">{short}</a>}
· {new Date(date).toISOString().slice(0, 16).replace('T', ' ')}Z · Astro
</p>
-260
View File
@@ -1,260 +0,0 @@
---
// 开源页原生档案卡(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>
+7 -12
View File
@@ -23,9 +23,11 @@ interface Props {
}
const { limit = 6, locale = 'zh', pinned, options } = Astro.props;
const title = t('open_source_projects', locale);
// 2026-11-02 视觉终审:原先按 GitHub linguist 给每个仓染语言色点,开源页一次铺
// 15+ 个仓 = 15 种彩色,破「外壳灰阶、彩色只作正文强调(≤3 处/页)」红线。
// 改为统一灰阶点:语言信息靠文字承载,颜色不再做无意义的编码。
const LANG_COLORS: Record<string, string> = {
Astro: '#ff5a03', TypeScript: '#3178c6', JavaScript: '#f1e05a',
Python: '#3572a5', Go: '#00add8', Rust: '#dea584', Shell: '#89e051',
HTML: '#e34c26', CSS: '#563d7c', Dockerfile: '#384d54',
};
let projects: RepoCard[] = [];
try {
@@ -48,7 +50,7 @@ try {
<span class="repo-meta">
{p.language && (
<span class="lang">
<span class="dot" />
<span class="dot" style={`background:${LANG_COLORS[p.language] ?? '#8b8f98'}`} />
{p.language}
</span>
)}
@@ -85,12 +87,5 @@ try {
.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; }
.lang { display: inline-flex; align-items: center; gap: 4px; }
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
display: inline-block;
background: var(--ink-4);
flex: none;
}
.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
</style>
-208
View File
@@ -1,208 +0,0 @@
---
/**
* 浏览 / 评论计数徽标(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>
+2 -4
View File
@@ -5,15 +5,13 @@ import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { t } from '../../i18n/ui';
import type { Locale } from '../../lib/locale';
import { sortPosts } from '../../lib/post-order';
import { Image } from 'astro:assets';
interface Props { locale?: Locale }
const { locale = 'zh' } = Astro.props;
const books = sortPosts(
await getCollection('posts', ({ data }) => !data.draft && data.category === 'reading' && !data.tags.includes('hidden')),
);
const books = (await getCollection('posts', ({ data }) => !data.draft && data.category === 'reading' && !data.tags.includes('hidden')))
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
// 回退契约(schema 注释声称而此前未实现——视觉复核抓到的缺口):
// description 缺省取正文第一个非空段落(去 markdown 记号)前 60 字
+3 -5
View File
@@ -10,7 +10,6 @@ import ProjectWall from '../geek/ProjectWall.astro';
import Heatmap from '../geek/Heatmap.astro';
import SocialLinks from '../SocialLinks.astro';
import { t } from '../../i18n/ui';
import { sortPosts } from '../../lib/post-order';
import type { Locale } from '../../lib/locale';
import {
homeConfig,
@@ -31,10 +30,9 @@ const cfg = await homeConfig(locale);
const subtitleHtml = cfg.subtitle ? await renderMarkdown(cfg.subtitle) : '';
const announcementHtml = cfg.announcement ? await renderRich(cfg.announcement) : '';
// 置顶优先 → 时间倒序(sortPosts 契约见 src/lib/post-order.ts)
const posts = sortPosts(
await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden')),
);
const posts = (
await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden'))
).sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime());
// en 轨列表标题吃 postsEn 译题(缺译回退 zh,与 /en/archives 同策略)
const enTitle = locale === 'en'
+35 -61
View File
@@ -2,10 +2,7 @@
import { Image } from 'astro:assets';
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import MomentComments from '../MomentComments.astro';
import ViewMeta from '../geek/ViewMeta.astro';
import { t } from '../../i18n/ui';
import { renderRich } from '../../lib/site-content';
import type { Locale } from '../../lib/locale';
// /en/ 侧由 T4 薄页传 locale='en';zh 路由无人传参,默认 zh 零回归
@@ -17,25 +14,16 @@ const { locale = 'zh' } = Astro.props;
const moments = (await getCollection('moments')).sort(
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
);
// 富文本预渲染(Phase 5h T2):text 走与页面文案同一条管线(markdown → 内容净化 →
// class 白名单 → 链接卡片),因此瞬间支持 **加粗**、<span class="c-blue font-kai">、列表与换行。
// 预渲染成 map 再进模板:renderRich 是 async,模板里不能 await。
// 纯文本老数据零回归:无 markdown 的文本会得到单个 <p>,与旧的 {text} 直出视觉等价。
const richText = new Map<string, string>();
for (const m of moments) {
if (!m.data.text) continue;
richText.set(m.id, await renderRich(m.data.text));
}
const iso = (d: Date) => d.toISOString();
---
<BaseLayout title={`${t('moments_title', locale)} · wait4x`}>
<h1 class="page-title">{t('moments_title', locale)}</h1>
<p class="page-note">{t('moments_note', locale)}</p>
<!-- 整页计数(Phase 5h T4):这是本页唯一的计数点(page 模式);
每条瞬间卡片底部的徽标是 readonly,读者开评论不会虚增浏览数 -->
<ViewMeta class="mom-page-meta" pageKey={locale === 'en' ? '/en/moments/' : '/moments/'} {locale} variant="chip" />
<p class="pv-line" aria-label={t('pv_viewed', locale)}>
<span data-artalk-pv data-page-key={locale === 'en' ? '/en/moments/' : '/moments/'}>–</span>
{' '}{t('pv_viewed', locale)}
</p>
<ol class="tl">
{moments.map((m) => (
<li class="tl-item">
@@ -58,13 +46,12 @@ const iso = (d: Date) => d.toISOString();
/>
</figure>
)}
{m.data.text && <div class="mom-text" set:html={richText.get(m.id) ?? ''} />}
{m.data.text && <p>{m.data.text}</p>}
{m.data.tags.length > 0 && (
<p class="mom-tags">
{m.data.tags.map((tag) => <code class="tag">{`#${tag}`}</code>)}
</p>
)}
<MomentComments slug={m.id} locale={locale} />
</div>
</li>
))}
@@ -80,6 +67,25 @@ const iso = (d: Date) => d.toISOString();
</BaseLayout>
<script>
// 访问量:与 ArtalkComments 同款独立 POST(双轨 pageKey 分开计数;POST 绕 ESA 缓存)
const bumpPv = async () => {
const el = document.querySelector('[data-artalk-pv]');
if (!el) return;
const key = el.dataset.pageKey ?? '';
if (!key) return;
try {
const res = await fetch('/artalk/api/v2/pages/pv', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ page_key: key, site_name: 'wait4x.online' }),
});
const d = await res.json();
el.textContent = String(d.pv ?? '–');
} catch {
el.textContent = '–';
}
};
// 时间线渐显:html[data-mom-anim] 由 JS 前置标记——无 JS / reduced-motion 时
// 标记缺席,CSS 初始隐藏规则整体不生效(渐进降级,元素永远可见)
let io: IntersectionObserver | null = null;
@@ -103,9 +109,11 @@ const iso = (d: Date) => d.toISOString();
};
document.addEventListener('astro:page-load', () => {
void bumpPv();
armAnim();
refreshInstallCta();
});
void bumpPv();
armAnim();
// —— PWA 安装引导:beforeinstallprompt 早在页面加载初期触发,
@@ -165,11 +173,8 @@ const iso = (d: Date) => d.toISOString();
}
/* 时间线骨架:左 mono 日期 + 竖线 + --accent 节点 + 右侧内容卡 */
.tl-item {
/* 日期轨宽度集中一处:10 字符 mono 日期(2026-04-29)在 12.5px 下约 75px,
原来复用 var(--space-5)=40px 导致在「-」处折成两行(视觉终审 P1) */
--tl-rail: 5.4rem;
position: relative;
margin: 0 0 var(--space-4) calc(var(--tl-rail) + var(--space-4));
margin: 0 0 var(--space-4) calc(var(--space-5) + var(--space-4));
padding-left: var(--space-4);
border-left: 1px solid var(--line);
}
@@ -187,10 +192,9 @@ const iso = (d: Date) => d.toISOString();
}
.tl-date {
position: absolute;
left: calc(-1 * (var(--tl-rail) + var(--space-4)));
left: calc(-1 * (var(--space-5) + var(--space-4)));
top: 4px;
width: var(--tl-rail);
white-space: nowrap;
width: var(--space-5);
text-align: right;
font-family: var(--font-mono);
font-size: 12.5px;
@@ -204,41 +208,6 @@ const iso = (d: Date) => d.toISOString();
background: var(--bg-soft);
}
.mom-card > p { font-size: 15px; color: var(--ink-2); }
/* 富文本正文(T2):首尾 margin 归零,避免卡片被 <p> 的默认外边距撑开;
段间只留半档间距,列表缩进对齐正文基线。基调保持 15px / ink-2。 */
.mom-text {
font-size: 15px;
color: var(--ink-2);
line-height: 1.7;
}
.mom-text :global(> :first-child) { margin-top: 0; }
.mom-text :global(> :last-child) { margin-bottom: 0; }
.mom-text :global(p) { margin: 0 0 var(--space-1); }
.mom-text :global(ul),
.mom-text :global(ol) {
margin: 0 0 var(--space-1);
padding-left: 1.35em;
}
.mom-text :global(li) { margin: 0; }
.mom-text :global(li + li) { margin-top: 2px; }
.mom-text :global(blockquote) {
margin: var(--space-1) 0;
padding-left: var(--space-2);
border-left: 2px solid var(--line);
color: var(--ink-3);
}
.mom-text :global(code) {
font-family: var(--font-mono);
font-size: 0.92em;
background: var(--bg);
border-radius: 4px;
padding: 1px 5px;
}
.mom-text :global(a) {
color: var(--accent);
text-decoration: underline;
text-underline-offset: 0.18em;
}
.mom-fig {
border-radius: 8px;
overflow: hidden;
@@ -301,7 +270,12 @@ const iso = (d: Date) => d.toISOString();
transition: border-color var(--dur) var(--ease);
}
.install-btn:hover { border-color: var(--accent); }
.mom-page-meta { margin: 0 0 var(--space-4); }
.pv-line {
font-family: var(--font-mono);
font-size: 12px;
color: var(--ink-4);
margin: 0 0 var(--space-4);
}
/* 初始隐藏仅在 JS 标记存在时生效:宁轻勿闹——透明度+8px 上移,进入视口一次性点亮 */
[data-mom-anim] .tl-item {
opacity: 0;
-2
View File
@@ -123,8 +123,6 @@ const postsSchema = z.object({
video: z.string().optional(),
/** 视频封面(可选):同 public/media 下的图片 URL */
poster: z.string().optional(),
/** 置顶:列表排序优先(只影响顺序,不改 pubDate/URL/RSS 时间语义) */
pinned: z.boolean().optional(),
draft: z.boolean().optional(),
});
-3
View File
@@ -112,9 +112,6 @@ const dict = {
// ===== F 车道:访问量小字(Artalk pv,组件内独立计数) =====
pv_viewed: { zh: '人看过', en: 'views' },
pinned_label: { zh: '置顶', en: 'Pinned' },
comment_count: { zh: '条评论', en: 'comments' },
moment_comments: { zh: '评论', en: 'Comments' },
lb_hint: { zh: '滚轮缩放 · 双击放大 · Esc 关闭', en: 'Wheel to zoom · Double-click · Esc' },
lb_hint_touch: { zh: '双指缩放 · 双击放大', en: 'Pinch to zoom · Double-tap' },
-31
View File
@@ -1,31 +0,0 @@
/**
* 文章列表排序契约(置顶功能,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;
});
}
+1 -53
View File
@@ -1,5 +1,3 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
// 项目墙数据(极客层 P0):构建时双源拉取 + 仓内缓存兜底
// 双源 = 自家 gitea(数据自控叙事)+ GitHub(社区 star 真值);置顶清单人工维护
export interface RepoCard {
@@ -30,18 +28,8 @@ export const GITHUB_API = 'https://api.github.com/users/wait4xx/repos?per_page=1
export const PINNED_DEFAULT: string[] = ['blog'];
async function fetchJson(url: string): Promise<RawRepo[]> {
const headers: Record<string, string> = { 'User-Agent': 'wait4x-blog-build' };
// GitHub 无鉴权仅 60 次/小时(多次构建就耗尽)→ 有 token 就带上(5000/小时)
if (url.includes('api.github.com')) {
try {
const token = readFileSync(join(process.env.HOME ?? '', 'docker_apps/blog/github-token.txt'), 'utf8').trim();
if (token) headers.Authorization = `Bearer ${token}`;
} catch {
/* token 缺位则匿名(可能被限流,走兜底) */
}
}
const res = await fetch(url, {
headers: { ...headers, Accept: 'application/json' },
headers: { Accept: 'application/json', 'User-Agent': 'wait4x-blog-build' },
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) throw new Error(`${url} -> ${res.status}`);
@@ -170,43 +158,3 @@ function arrange(
return b.stars - a.stars;
});
}
/** GitHub 账号统计(followers / public repos):token 缺位或请求失败一律返回 null(调用方隐藏数字) */
export interface GithubUserStats {
followers: number;
publicRepos: number;
}
export async function githubUserStats(
login = 'wait4xx',
): Promise<GithubUserStats | null> {
const { readFileSync } = await import('node:fs');
const { join } = await import('node:path');
let token: string | null = null;
try {
token = readFileSync(join(process.env.HOME ?? '', 'docker_apps/blog/github-token.txt'), 'utf8').trim() || null;
} catch {
token = null;
}
try {
const res = await fetch(`https://api.github.com/users/${login}`, {
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
Accept: 'application/vnd.github+json',
'User-Agent': 'wait4x-blog-build',
},
});
if (!res.ok) {
console.warn(`[profile] GitHub 用户统计获取失败 HTTP ${res.status},相关数字已隐藏`);
return null;
}
const data = (await res.json()) as { followers?: number; public_repos?: number };
return {
followers: Number(data.followers ?? 0),
publicRepos: Number(data.public_repos ?? 0),
};
} catch (err) {
console.warn(`[profile] GitHub 用户统计请求异常(${(err as Error).message}),相关数字已隐藏`);
return null;
}
}
-5
View File
@@ -5,7 +5,6 @@ import LinkCardStyles from '../../components/geek/LinkCardStyles.astro';
import PhotoZoom from '../../components/geek/PhotoZoom.astro';
import BaseLayout from '../../layouts/BaseLayout.astro';
import ArtalkComments from '../../components/ArtalkComments.astro';
import ViewMeta from '../../components/geek/ViewMeta.astro';
import { trackOf } from '../../content.config';
// URL = /archives/{slug},slug 即文件名,与 Halo 旧结构 1:1(不可改)
@@ -62,7 +61,6 @@ const toc = headings.filter((h) => h.depth === 2 || h.depth === 3);
<span aria-hidden="true">·</span>
<span>阅读时长 {minutes} 分钟</span>
</p>
<ViewMeta pageKey={`/archives/${entry.id}/`} mode="page" variant="inline" />
</header>
<div class="prose-reading">
<Content />
@@ -90,7 +88,6 @@ const toc = headings.filter((h) => h.depth === 2 || h.depth === 3);
<span aria-hidden="true">·</span>
<span>阅读时长 {minutes} 分钟</span>
</p>
<ViewMeta pageKey={`/archives/${entry.id}/`} mode="page" variant="inline" />
</header>
<div class="tech-grid">
<div class="prose-tech">
@@ -212,8 +209,6 @@ const toc = headings.filter((h) => h.depth === 2 || h.depth === 3);
margin-top: var(--space-2);
text-wrap: balance;
}
/* 计数徽标(ViewMeta 组件根元素)紧贴 meta 行;scoped 样式够不着子组件内部,需 :global */
.post-head :global(.viewmeta) { margin-top: 8px; }
.post-meta {
display: flex;
align-items: center;
+2 -4
View File
@@ -3,11 +3,9 @@
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import PostList from '../../components/PostList.astro';
import { sortPosts } from '../../lib/post-order';
// 置顶优先 → 时间倒序(sortPosts 契约见 src/lib/post-order.ts)
const posts = sortPosts(
await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden')),
const posts = (await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden'))).sort(
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
);
---
-4
View File
@@ -6,7 +6,6 @@ import LinkCardStyles from '../../../components/geek/LinkCardStyles.astro';
import PhotoZoom from '../../../components/geek/PhotoZoom.astro';
import BaseLayout from '../../../layouts/BaseLayout.astro';
import ArtalkComments from '../../../components/ArtalkComments.astro';
import ViewMeta from '../../../components/geek/ViewMeta.astro';
import AiTranslatedBadge from '../../../components/AiTranslatedBadge.astro';
import { t } from '../../../i18n/ui';
@@ -59,7 +58,6 @@ const minutes = Math.max(1, Math.ceil(words / 220));
<span aria-hidden="true">·</span>
<span>{minutes} {t('reading_time_min', 'en')}</span>
</p>
<ViewMeta pageKey={`/archives/${entry.id}/`} locale="en" mode="page" variant="inline" />
</header>
<AiTranslatedBadge />
<div class={isReading ? 'prose-reading' : 'prose-tech'}>
@@ -87,8 +85,6 @@ const minutes = Math.max(1, Math.ceil(words / 220));
.post-head { margin-bottom: var(--space-4); }
.post-cat { font-family: var(--font-mono); font-size: 12px; color: var(--ink-3); letter-spacing: 0.08em; }
.post-title { font-size: var(--text-h1); line-height: var(--leading-heading); color: var(--ink-1); text-wrap: balance; }
/* 计数徽标紧贴 meta 行(组件根元素在子作用域,需 :global) */
.post-head :global(.viewmeta) { margin-top: 8px; }
.post-meta { display: flex; align-items: center; gap: var(--space-2); margin-top: var(--space-2); font-size: 13px; color: var(--ink-3); }
.emo { display: inline-block; width: 10px; height: 10px; border-radius: 50%; }
.pn { display: flex; flex-direction: column; gap: var(--space-2); margin-top: var(--space-5); }
+2 -3
View File
@@ -3,10 +3,9 @@
import { getCollection } from 'astro:content';
import BaseLayout from '../../../layouts/BaseLayout.astro';
import PostList from '../../../components/PostList.astro';
import { sortPosts } from '../../../lib/post-order';
const posts = sortPosts(
await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden')),
const posts = (await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden'))).sort(
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
);
const enTitle = Object.fromEntries(
(await getCollection('postsEn')).map((e) => [e.id, e.data.title]),
+8 -6
View File
@@ -2,23 +2,25 @@
import ProjectWall from '../../components/geek/ProjectWall.astro';
import Heatmap from '../../components/geek/Heatmap.astro';
import BaseLayout from '../../layouts/BaseLayout.astro';
import GithubProfile from '../../components/geek/GithubProfile.astro';
import { t } from '../../i18n/ui';
import { githubReadmeMarkdown, openSourceCopy, openSourceOptions, pageTypography, renderMarkdown } from '../../lib/site-content';
import { githubReadmeMarkdown, openSourceCopy, openSourceOptions, pageTypography, renderMarkdown, renderRich } from '../../lib/site-content';
const copy = await openSourceCopy('en');
const options = await openSourceOptions();
const readmeMd = options.introFrom === 'github-readme' ? await githubReadmeMarkdown() : null;
const readmeHtml = readmeMd ? await renderMarkdown(readmeMd) : null;
const blurbHtml = await renderRich(copy.blurb, { inline: true });
const blurbAfterHtml = await renderRich(copy.blurbAfter, { inline: true });
const typo = await pageTypography('siteOpenSource', 'opensource');
---
<BaseLayout title="Open Source · wait4x" description="My open-source activity: repos, contributions, and this site's source">
<h1 class="page-title">Open Source</h1>
<div class={typo.className || undefined} style={typo.style || undefined}>
<GithubProfile locale="en" />
{/* compat path (not recommended): renders the profile README below the card when
intro_from=github-readme. It pulls 7 kinds of third-party images and loses align centering. */}
{readmeHtml && <div class="blurb readme-intro" set:html={readmeHtml} />}
{readmeHtml ? (
<div class="blurb readme-intro" set:html={readmeHtml} />
) : (
<p class="blurb rich" set:html={`${blurbHtml}<a class="link" href="${copy.repoUrl}">${copy.repoLabel}</a>${blurbAfterHtml}`} />
)}
<Heatmap labels={{
title: t('heatmap_title', 'en'), summary: t('heatmap_summary', 'en'),
less: t('heatmap_less', 'en'), more: t('heatmap_more', 'en'), note: t('heatmap_note', 'en'),
+9 -7
View File
@@ -2,9 +2,8 @@
import ProjectWall from '../components/geek/ProjectWall.astro';
import Heatmap from '../components/geek/Heatmap.astro';
import BaseLayout from '../layouts/BaseLayout.astro';
import GithubProfile from '../components/geek/GithubProfile.astro';
import { t } from '../i18n/ui';
import { githubReadmeMarkdown, openSourceCopy, openSourceOptions, pageTypography, renderMarkdown } from '../lib/site-content';
import { githubReadmeMarkdown, openSourceCopy, openSourceOptions, pageTypography, renderMarkdown, renderRich } from '../lib/site-content';
// 叙述文案 + 置顶名单 + 项目墙选项均改由 content/site/opensource.yaml 供给(后台可改)
const copy = await openSourceCopy('zh');
@@ -12,16 +11,19 @@ const options = await openSourceOptions();
// 开篇来源:github-readme → 构建期取 profile 仓 README(缓存 content/.github-readme.md,失败回落 yaml blurb)
const readmeMd = options.introFrom === 'github-readme' ? await githubReadmeMarkdown() : null;
const readmeHtml = readmeMd ? await renderMarkdown(readmeMd) : null;
// 层 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');
---
<BaseLayout title="开源 · wait4x" description="我的开源活动:仓库、贡献与本站源码">
<h1 class="page-title">开源<span class="count mono">Open Source</span></h1>
<div class={typo.className || undefined} style={typo.style || undefined}>
<GithubProfile locale="zh" />
{/* 兼容通道(不推荐):intro_from=github-readme 时把 profile README 原样渲染在档案卡下方。
它会引入 7 类第三方外链图(shields.io / readme-typing-svg / stats / snake / 访客徽章)并带失效的
align 居中;慢网或被墙时整片空白。默认 off,正常内容由上方原生档案卡 + opensource.yaml 叙述承载。 */}
{readmeHtml && <div class="blurb readme-intro" set:html={readmeHtml} />}
{readmeHtml ? (
<div class="blurb readme-intro" set:html={readmeHtml} />
) : (
<p class="blurb rich" set:html={`${blurbHtml}<a class="link" href="${copy.repoUrl}">${copy.repoLabel}</a>${blurbAfterHtml}`} />
)}
<Heatmap labels={{
title: t('heatmap_title', 'zh'), summary: t('heatmap_summary', 'zh'),
less: t('heatmap_less', 'zh'), more: t('heatmap_more', 'zh'), note: t('heatmap_note', 'zh'),
+2 -6
View File
@@ -72,16 +72,12 @@
/* ---- 字体三轨 ---- */
/* 书评正文:思源宋(用户对标模板的宋体质感拍板);文楷降级为金句点缀 */
/* 链尾 emoji 回退(视觉终审 2026-11-02):用户正文里的 emoji 若字体栈无覆盖会落
豆腐块(无彩色 emoji 字体的构建机/精简系统实测出黑方块) */
--font-serif: "Noto Serif SC Variable", "Noto Serif SC", Georgia, "Songti SC", STSong,
"Noto Serif CJK SC", "Source Han Serif SC", SimSun,
"Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", "Noto Emoji", serif;
"Noto Serif CJK SC", "Source Han Serif SC", SimSun, serif;
--font-kai: "LXGW WenKai Screen", "Songti SC", serif;
/* UI / 技术文:思源黑 */
--font-sans: "Noto Sans SC Variable", "Noto Sans SC", system-ui, -apple-system, "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei",
"Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", "Noto Emoji", sans-serif;
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
/* 代码 */
--font-mono: "JetBrains Mono", ui-monospace, Consolas, "Courier New", monospace;
-22
View File
@@ -34,28 +34,6 @@ ok('首页加载', (await page.title()).includes('wait4x'));
ok('热力图渲染', await page.locator('.hm-grid').count() > 0);
ok('项目墙渲染', await page.locator('.project-wall, .repo').count() > 0);
// 0) Phase 5h:置顶 / 计数徽标 / 瞬间富文本与逐条评论 / 块式关于页
await page.goto(BASE + '/archives/', { waitUntil: 'networkidle' });
const firstArchive = await page.locator('.post-list a.row').first().getAttribute('href');
ok('5h·置顶排在归档首位', (firstArchive ?? '').includes('/archives/writing-template/'), firstArchive ?? 'none');
ok('5h·置顶徽标可见', (await page.locator('.pin').count()) > 0);
await page.goto(BASE + '/archives/writing-template/', { waitUntil: 'networkidle' });
ok('5h·文章页计数组件挂载', (await page.locator('[data-viewmeta]').count()) >= 2, `count=${await page.locator('[data-viewmeta]').count()}`);
ok('5h·模板彩色强调生效', (await page.locator('.prose-tech .c-blue').count()) > 0);
ok('5h·模板折叠块渲染', (await page.locator('.prose-tech details').count()) > 0);
await page.goto(BASE + '/moments/', { waitUntil: 'networkidle' });
ok('5h·瞬间带逐条评论区', (await page.locator('details[data-mom-comments]').count()) > 0);
ok('5h·瞬间卡片带只读计数', (await page.locator('.mom-card [data-viewmeta]').count()) > 0);
await page.locator('details[data-mom-comments]').first().locator('summary').click();
await page.waitForTimeout(400);
// 计数徽标是 details 的兄弟(ViewMeta 根节点为 <p>,塞进 <summary> 属非法嵌套)
ok('5h·瞬间计数为只读模式', (await page.locator('.mom-cm-meta [data-viewmeta]').first().getAttribute('data-mode')) === 'readonly');
await page.goto(BASE + '/about/', { waitUntil: 'networkidle' });
ok('5h·关于页由块驱动', (await page.locator('[data-block]').count()) > 0, `blocks=${await page.locator('[data-block]').count()}`);
// 1) 明暗切换:按钮 + 状态持久 + t 键
const before = await page.getAttribute('html', 'data-theme');
await page.click('[data-theme-toggle]');