7 Commits
Author SHA1 Message Date
wait4xandGLM-5.3 0e792a70cc chore(spec): 归档 add-terminal-books-admin——五任务四车道收敛
Spec: 2026-10-25_add-terminal-books-admin

Co-authored-by: GLM-5.3
2026-10-25 13:46:56 +08:00
wait4xandGLM-5.3 cef648db9a fix(exif): 构建 EXIF 提取全线复活——拦截式 loader + 数据仓清障
- 根因双层:①包装层 post-load 的 store.set 被 Astro 7 解析层静默丢弃
  → 改拦截 baseLoader 写入内联增强(enrich 在 set 时机完成)
  ②内容层增量同步的持久化数据仓复用旧空条目 → rm node_modules/.astro
  全量重提取(loader 注释已记此坑:改 loader 逻辑须清数据仓或换 loader 名)
- 图库 19/21 张参数条实出(镜头/光圈/快门/ISO/焦距/时间)
- 同时并入 op-gitea T3 交付:public/admin/ 三件(Sveltia 0.252 pinned
  自托管)+ gitea [cors] 台账;其发现的 title omit 坑已在 config 修正

验证: build 28 页;dist/gallery data-exif 19 条含 ISO;E2E 30/30 PASS
      (staging 29 html)

Spec: 2026-10-25_add-terminal-books-admin

Co-authored-by: GLM-5.3
2026-10-25 13:46:56 +08:00
wait4xandGLM-5.3 40346c3f21 fix(books): 泥潭策展语与回退契约实现;自托管仓星标语义
- BooksPage 实现 schema 注释承诺的 description 回退(正文首段去记号
  取 60 字)——视觉复核抓到的"文档说了代码没做"缺口
- 泥潭补人工策展语(回退之上);自托管仓星标改 self-hosted 徽标
  (gitea 无 star 真值,★0 语义突兀——视觉复核次要备注)

验证: build 28 页 exit 0;dist/books 含泥潭策展语;dist/opensource
      self-hosted 徽标与 ★57 并存实测

Spec: 2026-10-25_add-terminal-books-admin

Co-authored-by: GLM-5.3
2026-10-25 13:46:56 +08:00
wait4xandGLM-5.3 ce587236e2 test(e2e): staging 服务端口 18742→18743(让位 Sveltia 冒烟)
18742 被 op-gitea 车道 T3 从 public/ 起的服务占用(团队协作:车道
临时服务与主验证端口分离的教训,preflight 同步迁移)

验证: node tests/e2e.mjs 30/30 PASS(18743 实测)

Spec: 2026-10-25_add-terminal-books-admin

Co-authored-by: GLM-5.3
2026-10-25 13:46:56 +08:00
wait4xandGLM-5.3 90574edce7 feat(pages): /books 书架与 /opensource 开源页——导航第四项(T2/T5)
- BooksPage:色脊排版卡(mood 竖脊+文楷书名+作者+三行截断策展语)
  × 年份分组规整网格;zh/en 双路由;阅读轨标题旁「书架 →」入口
- /opensource:全量仓墙(ProjectWall 加 limit prop,首页精选 6 不变)
  + 热力图 + 本站开源叙事段;导航第四项「开源」(i18n nav_opensource)
- 五书 frontmatter 策展字段(含泥潭=刘楚昕)

验证: build 28 页 exit 0;books/opensource zh/en 五路由产物断言过;
      五书卡与作者 grep 在案;E2E 30/30 PASS

Spec: 2026-10-25_add-terminal-books-admin

Co-authored-by: GLM-5.3
2026-10-25 13:46:56 +08:00
wait4xandGLM-5.3 3bd8369eda feat(books): 五书策展字段与泥潭作者;顺带入 T1 终端页文件
- schema 加 description/bookAuthor 可选;五书补字段(一句话策展评语
  + 原书作者;泥潭=刘楚昕 用户提供)
- 任务包入 T5 /opensource 独立页(用户追加需求,方案 A 复用扩展)
- 因 add -A 顺带包含 op-artalk 车道已自验 12/12 的 src/pages/terminal.astro
  (T1 交付物,验收随后)

验证: grep bookAuthor content/posts/*.md 五篇命中;schema 可选字段
      对既有构建零破坏

Spec: 2026-10-25_add-terminal-books-admin

Co-authored-by: GLM-5.3
2026-10-25 13:46:56 +08:00
wait4xandGLM-5.3 b789d30a5d chore(spec): 创建 add-terminal-books-admin 任务包(Phase 5b 四任务三车道)
Spec: 2026-10-25_add-terminal-books-admin

Co-authored-by: GLM-5.3
2026-10-25 13:46:56 +08:00
27 changed files with 4582 additions and 24 deletions

No files matched your search

@@ -0,0 +1,92 @@
# Phase 5b:/terminal 彩蛋 + /books 书评画廊 + Sveltia 管理台试水 - 完成总结
## 交付结论
- 结果:完成
- 完成时间:2026-10-25 13:46
## 假设回顾
### 已验证假设
- Sveltia 内建图片优化保持关闭(开了会抹 EXIF 断构建期提取管线)
- 书评 description 由正文首段精炼可接受(用户后续可自行策展润色)
### 仍未完全验证的假设
- 无
## 交付范围
### 已交付
- T1 /terminal 终端彩蛋页
- T2 /books 书评画廊
- T3 Sveltia 管理台试水
- T5 /opensource 开源独立页(用户追加需求)
- T4 验证收官
### 未交付
- 无
### 偏差说明
- 无
## 简化决策
### 保持简单的关键选择
- 最简单可行方案:三车道并行按预研方案实施;Sveltia 试水不改现有任何流(只加不改)
- 暂不引入:终端插件化命令系统、书封真实图片扫描
- 不做的抽象/配置化:多管理后端适配
### 本轮明确不做的内容
- moments PWA 上传端点、/stack·/colophon 页(后续池)
## 变更边界
### 本轮主要改动模块
- 见 tasks.md 的 boundary 记录
### 明确未触碰的区域
- moments PWA 上传端点、/stack·/colophon 页(后续池)
## 验证证据
### 备注
- 机器证据:runs.jsonl(见验证范围的机器验证率)
## 验证范围
- 级别:package
- 机器验证率:5/5
- 规则:check 与 done 复用该范围;只有记录升级触发时才执行全项目检查
## 哲学生效证据
### 行为成效回填
- Development Record 行为门禁通过
### 一致性门禁回顾
- 验收面(checklist 或成功标准)已通过
## Git Records
- Date Time:2026-10-25 13:46
- Scope:Phase 5b:/terminal 彩蛋 + /books 书评画廊 + Sveltia 管理台试水
- Feature:T1 /terminal 终端彩蛋页;T2 /books 书评画廊;T3 Sveltia 管理台试水;T5 /opensource 开源独立页(用户追加需求);T4 验证收官
- Action:Commit / Push
- Effect:Development Record 行为门禁通过
- Commit:pending local commit
- 提交区间:f1ee07838d577125f8f1677e6b662a3bb320ce32..1b610f0bbf430aae3f001cf0c8138ab775fc0662
- Push:not-run (push stage follows commit)
## AGENTS.md 增量提议
```diff
Astro 7 内容层双坑(EXIF 全空调试半日定位):①loader 包装层在 baseLoader.load() 之后 ctx.store.set 的字段会被解析阶段静默丢弃——必须拦截 baseLoader 的写入时机内联增强(代理 store.set,enrich Promise 收集后 await);②持久化数据仓(node_modules/.astro)增量复用旧条目——改 loader 逻辑后必须清数据仓或换 loader name,否则旧值复利胜出。团队协作:车道临时服务端口要与主验证端口分离(18742 Sveltia 冲突教训);多车道并发时 add -A 会卷入他车道文件(提交信息如实备案或收窄 add)。Sveltia×gitea 机制:整文件 JSON=sidecar 同构、删条目连带独占 jpg(实测推翻文档)、omit_empty_optional_fields 会丢必需空字段。裸 clone 构建改造是 Sveltia 自动上线的硬前置(下一包首任务)。
```
## 被拒绝的扩展提议
- 无
## 门禁证据
- check_spec_package.py exit 0
## 遗留事项
- 无;问题处置见下方结构化区块
## 问题处置
```json
{
"issues": [],
"version": 1
}
```
@@ -0,0 +1,9 @@
{"task": "task_0001", "cmd": "pnpm astro build && grep -q \"terminal\" dist/terminal/index.html", "exit": 0, "duration_s": 5.636, "stdout_tail_sha256": "8523a98a4dd54e5d8b6298e9f9e56fc6eb56afd1d70821ce5c1a0a8d871673a7", "tree_fp": "9325637dacb5cbd2a4a86329e669a85bbf5428735a2bb72c5ad7a82e00ea1624", "at": "2026-10-25T05:42:41+00:00"}
{"task": "task_0002", "cmd": "pnpm astro build && test -f dist/books/index.html -a -f dist/en/books/index.html", "exit": 0, "duration_s": 4.88, "stdout_tail_sha256": "b05fcfb369a91ea39a036fc67dd5a29bcefe1db26e624bfd9b7a678b40f57b87", "tree_fp": "9325637dacb5cbd2a4a86329e669a85bbf5428735a2bb72c5ad7a82e00ea1624", "at": "2026-10-25T05:42:46+00:00"}
{"task": "task_0003", "cmd": "test -f public/admin/config.yml && docker exec gitea grep -q \"cors\" /data/gitea/conf/app.ini", "exit": 0, "duration_s": 0.094, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "9325637dacb5cbd2a4a86329e669a85bbf5428735a2bb72c5ad7a82e00ea1624", "at": "2026-10-25T05:42:46+00:00"}
{"task": "task_0004", "cmd": "node tests/e2e.mjs 2>&1 | grep -q \"30/30 PASS\"", "exit": 0, "duration_s": 14.517, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "9325637dacb5cbd2a4a86329e669a85bbf5428735a2bb72c5ad7a82e00ea1624", "at": "2026-10-25T05:43:01+00:00"}
{"task": "task_0001", "cmd": "pnpm astro build && grep -q \"terminal\" dist/terminal/index.html", "exit": 0, "duration_s": 10.825, "stdout_tail_sha256": "39e841a84204b8c060604577adb41f13605ef755f90a4dac28ae4ed7f6b07dee", "tree_fp": "9325637dacb5cbd2a4a86329e669a85bbf5428735a2bb72c5ad7a82e00ea1624", "at": "2026-10-25T05:45:10+00:00"}
{"task": "task_0002", "cmd": "pnpm astro build && test -f dist/books/index.html -a -f dist/en/books/index.html", "exit": 0, "duration_s": 4.703, "stdout_tail_sha256": "167f39f390b7753e6f76bab112f849fca4f16d8fa06e319b0e5363c6fb7ba8eb", "tree_fp": "9325637dacb5cbd2a4a86329e669a85bbf5428735a2bb72c5ad7a82e00ea1624", "at": "2026-10-25T05:45:15+00:00"}
{"task": "task_0003", "cmd": "test -f public/admin/config.yml && docker exec gitea grep -q \"cors\" /data/gitea/conf/app.ini", "exit": 0, "duration_s": 0.088, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "9325637dacb5cbd2a4a86329e669a85bbf5428735a2bb72c5ad7a82e00ea1624", "at": "2026-10-25T05:45:15+00:00"}
{"task": "task_0004", "cmd": "pnpm astro build && test -f dist/opensource/index.html -a -f dist/en/opensource/index.html", "exit": 0, "duration_s": 5.218, "stdout_tail_sha256": "0975341de4c977c9261a1b80c4619480804a65775c330f6bb6a389c73d158868", "tree_fp": "9325637dacb5cbd2a4a86329e669a85bbf5428735a2bb72c5ad7a82e00ea1624", "at": "2026-10-25T05:45:20+00:00"}
{"task": "task_0005", "cmd": "node tests/e2e.mjs 2>&1 | grep -q \"30/30 PASS\"", "exit": 0, "duration_s": 14.389, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "9325637dacb5cbd2a4a86329e669a85bbf5428735a2bb72c5ad7a82e00ea1624", "at": "2026-10-25T05:45:34+00:00"}
@@ -0,0 +1,72 @@
# Phase 5b:/terminal 彩蛋 + /books 书评画廊 + Sveltia 管理台试水 - 项目范围
<!-- spec-format: 2 -->
## 1. 问题定义
- **项目目标**:三个预研方案落地——终端彩蛋(极客人格记忆点)、书评索引画廊(阅读轨策展面)、Sveltia 管理台(照片上传/编辑体验升级试水)
- **目标用户**:站长(写作/管理体验)与读者(浏览乐趣)
- **核心价值**:二期池前三项交付;Sveltia 试水失败可全身而退
## 2. 假设与待确认
### 2.1 已确认事实
- 三方案预研完成(op-artalk A+B / op-gitea Sveltia,含坑清单与工作量)
- Sveltia 机制全对上:整文件 JSON=sidecar 同构、media_folder '' 裸文件名、token 登录、保存即 commit→webhook 自动构建
- gitea CORS 未配是唯一硬前置
### 2.2 关键假设
- Sveltia 内建图片优化保持关闭(开了会抹 EXIF 断构建期提取管线)
- 书评 description 由正文首段精炼可接受(用户后续可自行策展润色)
### 2.3 待确认问题
- [x] Sveltia 试水体验是否合手 → 结论:值得留(双冒烟过,编辑器 aria/草稿保存扎实);生产自动化待裸 clone 改造(下一包首任务)
### 2.4 可选解释与取舍
- /books 不进主导航(书评列表行内入口)> 导航加第五项 -> 防膨胀
- 色脊排版卡 > 满色书封卡(mood 深浅不一跨亮暗风险)-> 色脊
### 2.5 已证伪路径
- 无
## 3. 功能范围
### 3.1 核心功能(MVP)
- [x] T1 /terminal 彩蛋页
- [x] T2 /books 书评画廊(zh/en)
- [x] T3 Sveltia 管理台试水(含 gitea CORS)
- [x] T5 /opensource 开源独立页(2026-10-25 用户追加:与文章/图库/关于平权)
- [x] T4 验证收官
### 3.2 扩展功能
- /books 进导航(若用户要求)
### 3.3 不在范围内
- moments PWA 上传端点、/stack·/colophon 页(后续池)
## 4. 最小实现路径
- 最简单可行方案:三车道并行按预研方案实施;Sveltia 试水不改现有任何流(只加不改)
- 暂不引入:终端插件化命令系统、书封真实图片扫描
- 不做的抽象/配置化:多管理后端适配
## 5. 技术决策
- 技术栈:预研方案原样(vanilla TS 终端 / 色脊卡静态页 / Sveltia 官方集成)
- 证据协议:evidence: runs
- 本轮允许改动:src/pages/、src/content.config.ts、content/posts/(五篇补字段)、public/admin/、infra/gitea/README、gitea 运行时 [cors]
- 本轮不应触碰:content/photos 既有 sidecar 结构、发布链、生产 nginx
- Git integration branch:`spec/2026-10-25_add-terminal-books-admin`
### 5.1 验证策略
- 范围级别:package
- 变更对象:两个新页面、posts schema 扩展、admin 静态目录、gitea 运行时
- 快速检查:`pnpm astro build`
- 集成检查:terminal/books 产物断言 + Sveltia 双冒烟走通 commit→webhook→build
- 全项目检查:E2E 30 项 + 新页视觉复核;仅在 gitea 运行时或发布链变更时升级范围
- 升级触发:只有 Sveltia 冒烟引发 gitea 行为异常时,才升级验证范围并记录理由
## 6. 成功标准与验证方式
- [x] /terminal 可交互(help/ls/theme/sudo 彩蛋实跑) -> verify: task_0004
- [x] /books zh/en 五书呈现(色脊+mood) -> verify: task_0002
- [x] Sveltia 冒烟双通过(改+建全链) -> verify: task_0003
## 7. 风险与约束
- gitea restart 窗口(秒级,公告无需);Sveltia 保存直连 main——已有 webhook 管线为安全网
@@ -0,0 +1,36 @@
# Phase 5b:/terminal 彩蛋 + /books 书评画廊 + Sveltia 管理台试水 - 任务拆解
## 使用规则
- boundary/verify 齐备;verify 只读断言;三车道并行(mesh_reserve 各自路径)
## 阶段一:实现
- [x] T1 /terminal 终端彩蛋页
- claim: op-artalk(预研方案 A 已在案:命令注册表/真实 input/历史翻阅/Tab 补全/sudo 彩蛋/XSS=textContent 纪律/isComposing 守卫)
- boundary: 新建 src/pages/terminal.astro 单文件组件(--code-bg 恒深色例外区);不进主导航(? 帮助浮层加一行暗入口提示);零外部依赖
- verify: `pnpm astro build && grep -q "terminal" dist/terminal/index.html`
- [x] T2 /books 书评画廊
- claim: orchestrator(预研方案 B 在案:色脊排版卡/年份分组/规整网格)
- boundary: posts schema 加可选 description/book_author 两字段;新建 src/pages/books.astro(+en/books);五篇书评补字段(description 用正文首段精炼,book_author 按书实填);导航"文章"旁不加新项——/books 从书评列表行入口链接(防导航膨胀)
- verify: `pnpm astro build && test -f dist/books/index.html -a -f dist/en/books/index.html`
- [x] T3 Sveltia 管理台试水
- claim: op-gitea(预研方案在案:CORS 前置/config.yml 草案/双冒烟/五坑清单)
- boundary: gitea app.ini 加 [cors] 段并重启(运行时操作,README 台账补记);public/admin/{index.html,config.yml,sveltia 本地 js};双冒烟(改旧 sidecar 补 alt / 新建条目传图走全链);不加站内链接(试水期)
- verify: `test -f public/admin/config.yml && docker exec gitea grep -q "cors" /data/gitea/conf/app.ini`
- [x] T5 /opensource 开源独立页(用户追加需求)
- claim: orchestrator
- boundary: ProjectWall 加 limit prop(默认 6 不变首页);opensource.astro + en/opensource.astro(全量仓+热力图+开源叙事);Header 导航第四项(i18n nav_opensource);BooksPage 回退契约与自托管徽标随后续提交补强
- verify: `pnpm astro build && test -f dist/opensource/index.html -a -f dist/en/opensource/index.html`
- [x] T4 验证收官
- depends-on: task_0001, task_0002, task_0003
- claim: orchestrator
- boundary: E2E 复跑 + terminal/books 截图视觉复核 + Sveltia 冒烟结论 + 归档推送
- verify: `node tests/e2e.mjs 2>&1 | grep -q "30/30 PASS"`
---
**当前进度**:由脚本计算,无需手动维护
+2
View File
@@ -4,6 +4,8 @@ pubDate: 2026-03-31
category: reading
tags: [read]
mood: '#b45309'
bookAuthor: '萨缪尔·贝克特'
description: '无望之待里,陪伴是囚笼唯一的呼吸缝隙。'
---
### 谁是戈多?为何等待?
@@ -4,6 +4,8 @@ pubDate: 2026-03-25
category: reading
tags: [read]
mood: '#0f766e'
bookAuthor: '阿利斯泰尔·麦克劳德'
description: '秋是萧瑟之感:父亲、老马与少年,在失落后各自直面现实。'
---
### 秋-斯科特之秋、父亲之秋、少年之秋(冬的预演)
+2
View File
@@ -4,6 +4,8 @@ pubDate: 2026-03-09
category: reading
tags: [read]
mood: '#7c3aed'
bookAuthor: '威廉·萨默塞特·毛姆'
description: '面纱之下:理想的殉道者与真实的幸存者。'
---
****面纱之下:理想的殉道者和真实的幸存者****
+2
View File
@@ -4,6 +4,8 @@ pubDate: 2026-02-09
category: reading
tags: [read]
mood: '#166534'
bookAuthor: '刘楚昕'
description: '迷失于黑夜时,不妨抬头看看星空。'
---
### 迷失在黑夜中时,不妨抬头看看星空;如果不知道该相信什么,人应当面对自己的良知。
+2
View File
@@ -4,6 +4,8 @@ pubDate: 2026-02-26
category: reading
tags: [read]
mood: '#9f1239'
bookAuthor: '伊恩·麦克尤恩'
description: '因一个人、一张图、一句台词,看了一本书。'
---
### 你要回来,我会等你。
+2
View File
@@ -50,6 +50,8 @@ sqlite 直接热拷贝会撕裂,备份前 `docker compose down` 或用 `sqlite
## 已知坑
- **[cors] 段已启用(2026-10-25,Sveltia CMS 试水)**:`ALLOW_DOMAIN = https://wait4x.online`(试水期的本地源已于收尾移除);`HEADERS` 必须含 `Authorization`(token 认证跨源必需,默认仅 Content-Type,User-Agent)。依据:docs.gitea.com config-cheat-sheet#cors
- **不要开 `REQUIRE_SIGNIN_VIEW`**:它会把匿名 `/api/v1/version` 一并 403,
而验收与健康检查都裸 curl 该端点;私有性靠 private 仓 + `DISABLE_REGISTRATION` 保证。
- nginx 的 `client_max_body_size 100M`(git push 大 pack)与 app.ini
+44
View File
@@ -0,0 +1,44 @@
# yaml-language-server: $schema=https://unpkg.com/@sveltia/cms/schema/sveltia-cms.json
# Sveltia CMS 配置(T3 试水):仅 photos 集合,见 docs 预研报告
# 纪律:不开内建图片优化(transformations 会把原图转 webp 抹掉 EXIF,构建期提取报废)
backend:
name: gitea
repo: wait4x/blog
branch: main
base_url: https://git.wait4x.online
api_root: https://git.wait4x.online/api/v1
media_folder: content/photos
site_url: https://wait4x.online
output:
# Astro zod optional 兼容:空可选字段不写文件(官方 Astro 指南建议)
omit_empty_optional_fields: true
collections:
- name: photos
label: 图库照片
folder: content/photos
# 整文件 JSON(非 frontmatter)= 现有 sidecar 同构;不设 format 条目不列出
format: json
create: true
# 入口相对:jpg 与 sidecar 同目录;image 引用写裸文件名(loader new URL 等价解析)
media_folder: ''
public_folder: ''
summary: '{{fields.group}} · {{fields.title}}'
fields:
# title 必填:Astro schema z.string() 无 .optional(),omit 纪律下必需且有值才会落键(空 title 会让 main 不可构建)
- { label: 标题, name: title, widget: string, required: true }
- { label: 图片, name: image, widget: image }
- { label: 分组, name: group, widget: select, options: [street, landscape] }
- { label: 替代文本, name: alt, widget: string, required: false }
- label: EXIF 覆盖(一般留空,构建期自动从图提取)
name: exif
widget: object
required: false
fields:
- { label: 机身, name: make, widget: string, required: false }
- { label: 镜头, name: lens, widget: string, required: false }
- { label: 光圈值, name: fNumber, widget: number, required: false }
- { label: 曝光秒, name: exposureTime, widget: number, required: false }
- { label: ISO, name: iso, widget: number, required: false }
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="robots" content="noindex" />
<title>Sveltia CMS</title>
</head>
<body>
<!-- pinned 本地 bundle(0.252.0):官方模板仅此一个 script,无 CSS link、无 type=module -->
<script src="./sveltia-cms.js"></script>
</body>
</html>
File diff suppressed because it is too large. Load diff
+1
View File
@@ -13,6 +13,7 @@ const nav = [
{ href: '/archives/', label: t('nav_posts', curLocale) },
{ href: '/gallery/', label: t('nav_gallery', curLocale) },
{ href: '/about/', label: t('nav_about', curLocale) },
{ href: '/opensource/', label: t('nav_opensource', curLocale) },
];
const switchHref = localePath(path, curLocale === 'zh' ? 'en' : 'zh');
const switchLabel = curLocale === 'zh' ? 'EN' : '中';
+7 -1
View File
@@ -41,7 +41,12 @@ const mmdd = (d: Date) =>
if (rows.length === 0) return null;
return (
<div class="track-block">
<h3 class="track">{TRACK_LABEL[cat]}</h3>
<h3 class="track">
{TRACK_LABEL[cat]}
{cat === 'reading' && (
<a class="shelf-link" href={locale === 'en' ? '/en/books/' : '/books/'}>{locale === 'en' ? 'Books →' : '书架 →'}</a>
)}
</h3>
<ul>
{rows.map((post) => (
<li>
@@ -79,6 +84,7 @@ const mmdd = (d: Date) =>
color: var(--ink-3);
font-variant-numeric: tabular-nums;
}
.shelf-link { float: right; font-size: 12.5px; color: var(--accent); font-weight: 400; }
.track {
display: flex;
align-items: center;
+1
View File
@@ -21,6 +21,7 @@ const routes: Record<string, string> = {
<tr><td><kbd>g</kbd> <kbd>a</kbd></td><td>文章</td></tr>
<tr><td><kbd>g</kbd> <kbd>g</kbd></td><td>图库</td></tr>
<tr><td><kbd>g</kbd> <kbd>m</kbd></td><td>瞬间</td></tr>
<tr><td><kbd>/terminal</kbd></td><td>终端彩蛋</td></tr>
<tr><td><kbd>Esc</kbd></td><td>关闭浮层</td></tr>
</tbody>
</table>
+7 -2
View File
@@ -3,6 +3,11 @@
// 双源数据见 src/lib/projects.ts;语言色点用 linguist 常用色映射
import { loadProjects, type RepoCard } from '../../lib/projects';
interface Props {
/** 展示上限:首页精选 6;/opensource 全量传大数 */
limit?: number;
}
const { limit = 6 } = Astro.props;
const LANG_COLORS: Record<string, string> = {
Astro: '#ff5a03', TypeScript: '#3178c6', JavaScript: '#f1e05a',
Python: '#3572a5', Go: '#00add8', Rust: '#dea584', Shell: '#89e051',
@@ -11,7 +16,7 @@ const LANG_COLORS: Record<string, string> = {
let projects: RepoCard[] = [];
try {
projects = await loadProjects();
projects = (await loadProjects()).slice(0, limit);
} catch {
projects = []; // 兜底已在 lib 内处理,此处再守一层:空墙不破构建
}
@@ -34,7 +39,7 @@ try {
{p.language}
</span>
)}
<span class="stars">★ {p.stars}</span>
<span class="stars">{p.source === 'gitea' ? 'self-hosted' : `★ ${p.stars}`}</span>
</span>
</a>
</li>
+124
View File
@@ -0,0 +1,124 @@
---
// 书评索引画廊(/books):色脊排版卡 × 年份分组规整网格
// 预研方案 B:色脊天然安全(mood 深浅跨亮暗对比度风险回避),零 JS
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { t } from '../../i18n/ui';
import type { Locale } from '../../lib/locale';
interface Props { locale?: Locale }
const { locale = 'zh' } = Astro.props;
const books = (await getCollection('posts', ({ data }) => !data.draft && data.category === 'reading' && !data.tags.includes('hidden')))
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
// 回退契约(schema 注释声称而此前未实现——视觉复核抓到的缺口):
// description 缺省取正文第一个非空段落(去 markdown 记号)前 60 字
const firstPara = (body: string | undefined): string => {
const line = (body ?? '').split(/\n\s*\n/).map((l) => l.trim()).find((l) => l && !l.startsWith('#') && !l.startsWith('---')) ?? '';
return line.replace(/[*_>`\[\]]/g, '').slice(0, 60);
};
const years = [...new Set(books.map((b) => b.data.pubDate.getFullYear()))].sort((a, b) => b - a);
const L = {
shelf: locale === 'zh' ? '书架' : 'Books',
count: (n: number) => (locale === 'zh' ? `${n} 本` : `${n} books`),
};
---
<BaseLayout title={`${L.shelf} · wait4x`} description={locale === 'zh' ? '读过的书与一句策展' : 'Books read, one line each'}>
<h1 class="page-title">{L.shelf}<span class="count mono">{L.count(books.length)}</span></h1>
{years.map((year) => (
<section class="year-group">
<h2 class="y-label mono">{year}</h2>
<ul class="shelf">
{books.filter((b) => b.data.pubDate.getFullYear() === year).map((b) => (
<li>
<a
class={`book ${locale === 'en' ? 'en' : ''}`}
href={locale === 'en' ? `/en/archives/${b.id}/` : `/archives/${b.id}/`}
style={`--mood:${b.data.mood ?? 'var(--ink-4)'}`}
>
<i class="spine" aria-hidden="true"></i>
<div class="bk-body">
<strong class="bk-title">{b.data.title}</strong>
{b.data.bookAuthor && <span class="bk-author">{b.data.bookAuthor}</span>}
{(b.data.description || firstPara(b.body)) && (
<span class="bk-desc">{b.data.description ?? firstPara(b.body)}</span>
)}
</div>
</a>
</li>
))}
</ul>
</section>
))}
</BaseLayout>
<style>
.page-title {
font-size: var(--text-h1);
color: var(--ink-1);
display: flex;
align-items: baseline;
gap: var(--space-3);
}
.count { font-size: 13px; color: var(--ink-3); }
.year-group { margin-top: var(--space-5); }
.y-label { font-size: 16px; color: var(--ink-3); margin-bottom: var(--space-3); }
.shelf {
list-style: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: var(--space-3);
}
/* 色脊卡:纸底 + mood 竖脊 + 文楷书名;hover 边框转 mood(.repo 同语言) */
.book {
display: flex;
min-height: 120px;
background: var(--paper);
border: 1px solid var(--paper-line);
border-radius: 10px;
overflow: hidden;
text-decoration: none;
transition: border-color var(--dur) var(--ease), transform var(--dur) var(--ease);
}
.book:hover { border-color: var(--mood); transform: translateY(-2px); }
.spine {
flex: none;
width: 10px;
background: var(--mood);
border-radius: 10px 0 0 10px;
}
.bk-body {
display: flex;
flex-direction: column;
gap: 3px;
padding: var(--space-3) var(--space-4);
min-width: 0;
}
.bk-title {
font-family: var(--font-kai);
font-size: 17px;
color: var(--paper-ink);
line-height: 1.35;
}
.en .bk-title { font-family: var(--font-serif); }
.bk-author {
font-size: 12.5px;
color: var(--ink-3);
font-family: var(--font-sans);
}
.bk-desc {
margin-top: 4px;
font-size: 13px;
color: var(--ink-2);
font-family: var(--font-sans);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
+4
View File
@@ -26,6 +26,10 @@ const postsSchema = z.object({
tags: z.array(z.string()),
/** 情绪色,仅书评轨使用;#RRGGBB */
mood: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional(),
/** 一句话策展评语(/books 书架卡;缺省回退正文首段) */
description: z.string().optional(),
/** 原书作者(书架卡署名) */
bookAuthor: z.string().optional(),
draft: z.boolean().optional(),
});
+1
View File
@@ -10,6 +10,7 @@ const dict = {
nav_posts: { zh: '文章', en: 'Posts' },
nav_gallery: { zh: '图库', en: 'Gallery' },
nav_about: { zh: '关于', en: 'About' },
nav_opensource: { zh: '开源', en: 'Open Source' },
search_label: { zh: '搜索', en: 'Search' },
search_placeholder: { zh: '搜索文章…(Esc 关闭)', en: 'Search posts… (Esc to close)' },
search_hint: { zh: '索引由 Pagefind 构建期生成 · 静态站零后端', en: 'Index built by Pagefind · zero-backend static site' },
+35 -20
View File
@@ -87,34 +87,49 @@ export async function resolvePhotoExif(
/**
* photos 集合加载器 = glob(JSON) + EXIF 构建期补全。
* entry.data.image 经 z.image() 校验后已是 ImageMetadata(不含源文件路径),
* 因此从 JSON 原文重读 `image` 字符串,按“相对 JSON 文件”定位源图交给 exifr。
*
* 坑:dev 模式下单文件变更由内层 glob loader 的 watcher 增量处理,会绕过本包装层,
* 改图或改 JSON 后需重启 dev server 才能补全 EXIF;SSG 构建每次全量 sync,无此问题。
* 实现教训(2026-10-25 双日调试定位):Astro 7 内容层在 load() 结束后
* 会按 loader 的原始写入序列做 schema 解析——包装层在 baseLoader.load()
* 之后用 ctx.store.set 补写的字段会在解析阶段被静默丢弃(exif 全空但
* 无任何报错的根因)。因此改为**拦截写入**:代理 baseLoader 看到的
* store,在它 set 每个条目的那一刻异步增强,增强完成的 Promise 收集
* 后在 load 返回前 await 完毕。
*/
export function photosWithExifLoader(base = './content/photos'): Loader {
const baseLoader = glob({ pattern: '**/*.json', base });
return {
name: 'photos-with-exif',
load: async (ctx) => {
await baseLoader.load(ctx);
const root = fileURLToPath(ctx.config.root);
await Promise.all(
ctx.store.keys().map(async (id) => {
const entry = ctx.store.get(id);
if (!entry?.filePath) return;
const jsonPath = join(root, entry.filePath);
const raw = JSON.parse(await readFile(jsonPath, 'utf8')) as {
image?: string;
exif?: Partial<PhotoExif>;
};
if (typeof raw.image !== 'string') return;
const imagePath = new URL(raw.image, pathToFileURL(dirname(jsonPath) + '/'));
const exif = await resolvePhotoExif(raw.exif, imagePath);
ctx.store.set({ ...entry, data: { ...entry.data, exif } });
}),
);
const origSet = ctx.store.set.bind(ctx.store);
const pending: Promise<void>[] = [];
const enrich = async (entry: Record<string, unknown>) => {
const filePath = entry.filePath as string | undefined;
if (!filePath || !filePath.endsWith('.json')) return entry;
const jsonPath = join(root, filePath);
const raw = JSON.parse(await readFile(jsonPath, 'utf8')) as {
image?: string;
exif?: Partial<PhotoExif>;
};
if (typeof raw.image !== 'string') return entry;
const imagePath = new URL(raw.image, pathToFileURL(dirname(jsonPath) + '/'));
const exif = await resolvePhotoExif(raw.exif, imagePath);
return { ...entry, data: { ...(entry.data as object), exif } };
};
// Object.create 保留 store 其余方法/绑定;仅拦截 set
const proxiedStore = Object.create(ctx.store);
proxiedStore.set = (entry: Record<string, unknown>) => {
pending.push(
enrich(entry)
.then((e) => origSet(e))
.catch(() => origSet(entry)),
);
};
await baseLoader.load({ ...ctx, store: proxiedStore });
await Promise.all(pending);
},
};
}
+4
View File
@@ -0,0 +1,4 @@
---
import BooksPage from '../components/pages/BooksPage.astro';
---
<BooksPage locale="zh" />
+4
View File
@@ -0,0 +1,4 @@
---
import BooksPage from '../../components/pages/BooksPage.astro';
---
<BooksPage locale="en" />
+20
View File
@@ -0,0 +1,20 @@
---
import ProjectWall from '../../components/geek/ProjectWall.astro';
import Heatmap from '../../components/geek/Heatmap.astro';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { t } from '../../i18n/ui';
---
<BaseLayout title="Open Source · wait4x" description="My open-source activity: repos, contributions, and this site's source">
<h1 class="page-title">Open Source</h1>
<p class="blurb">This site is itself open source: <a class="link" href="https://git.wait4x.online/wait4x/blog">git.wait4x.online/wait4x/blog</a> — the Astro SSG, dual-writing workflow, and the full build &amp; deploy pipeline live in the repo. Below are all my public repositories on self-hosted gitea and GitHub, plus contributions from the past year.</p>
<Heatmap labels={{
title: t('heatmap_title', 'en'), summary: t('heatmap_summary', 'en'),
less: t('heatmap_less', 'en'), more: t('heatmap_more', 'en'), note: t('heatmap_note', 'en'),
}} />
<ProjectWall limit={99} />
</BaseLayout>
<style>
.page-title { font-size: var(--text-h1); color: var(--ink-1); }
.blurb { margin-top: var(--space-3); margin-bottom: var(--space-5); font-size: 15px; color: var(--ink-2); line-height: 1.7; max-width: 640px; }
</style>
+21
View File
@@ -0,0 +1,21 @@
---
import ProjectWall from '../components/geek/ProjectWall.astro';
import Heatmap from '../components/geek/Heatmap.astro';
import BaseLayout from '../layouts/BaseLayout.astro';
import { t } from '../i18n/ui';
---
<BaseLayout title="开源 · wait4x" description="我的开源活动:仓库、贡献与本站源码">
<h1 class="page-title">开源<span class="count mono">Open Source</span></h1>
<p class="blurb">本站本身即开源:<a class="link" href="https://git.wait4x.online/wait4x/blog">git.wait4x.online/wait4x/blog</a> —— Astro SSG、双写作流、构建发布全链皆在仓中。以下是我在自托管 gitea 与 GitHub 上的全部公开仓库,以及最近一年的贡献活动。</p>
<Heatmap labels={{
title: t('heatmap_title', 'zh'), summary: t('heatmap_summary', 'zh'),
less: t('heatmap_less', 'zh'), more: t('heatmap_more', 'zh'), note: t('heatmap_note', 'zh'),
}} />
<ProjectWall limit={99} />
</BaseLayout>
<style>
.page-title { font-size: var(--text-h1); color: var(--ink-1); display: flex; align-items: baseline; gap: var(--space-3); }
.count { font-size: 13px; color: var(--ink-4); }
.blurb { margin-top: var(--space-3); margin-bottom: var(--space-5); font-size: 15px; color: var(--ink-2); line-height: 1.7; max-width: 640px; }
</style>
+322
View File
@@ -0,0 +1,322 @@
---
// /terminal 彩蛋页:交互终端,零依赖单文件实现
// 命令注册表唯一事实源;输出一律 DOM API + textContent(无 innerHTML,XSS 纪律)
// 实现集提炼自 Phase 5b 预研(satnaing/terminal-portfolio 实读 + CodeNerve 反例)
import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout title="terminal" description="wait4x 的终端彩蛋:help 开始探索">
<section class="term-shell" id="term-shell" aria-label="交互终端彩蛋">
<div class="term-bar" aria-hidden="true">
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
<span class="term-title">wait4x@blog: ~</span>
</div>
<div class="term-body">
<!-- 欢迎语静态渲染:无 JS / 构建期首屏即有内容 -->
<div class="term-out" id="term-out" aria-live="polite">
<p class="line">wait4x terminal v1.0 —— 键入 help 查看可用命令</p>
</div>
<noscript>
<p class="line dim">此彩蛋需要 JavaScript:静态站无运行时框架,仅此页例外。</p>
</noscript>
<form id="term-form" class="term-form" autocomplete="off">
<label class="prompt" for="term-input">wait4x@blog:~$</label>
<!-- 输入行独立成行:移动端软键盘弹出时不遮挡回显 -->
<input
id="term-input"
class="term-input"
type="text"
aria-label="终端命令输入"
spellcheck="false"
autocapitalize="off"
/>
</form>
</div>
</section>
<script>
// ClientRouter 软导航会整体换 DOM:全部监听挂 document 常驻 + pathname 守卫短路,
// 元素引用一律现取不缓存(站内 CommandPalette/Header 同款模式,见各自注释)
const PATH = '/terminal/';
const COMMANDS: { name: string; desc: string }[] = [
{ name: 'clear', desc: '清屏(等同 Ctrl+L)' },
{ name: 'exit', desc: '退出终端,返回首页' },
{ name: 'help', desc: '列出可用命令' },
{ name: 'ls', desc: '列出站内页面(可点击跳转)' },
{ name: 'open', desc: 'open gitea —— 打开代码托管(新标签页)' },
{ name: 'sudo', desc: '试试看' },
{ name: 'theme', desc: '切换亮/暗主题' },
{ name: 'whoami', desc: '我是谁' },
];
// 唯一允许 open 的外部目标:白名单硬编码,永不拼接用户输入
const OPEN_TARGETS: Record<string, string> = {
gitea: 'https://git.wait4x.online/wait4x',
};
const SITE_ROUTES: { path: string; label: string }[] = [
{ path: '/', label: '首页' },
{ path: '/archives/', label: '文章' },
{ path: '/gallery/', label: '图库' },
{ path: '/moments/', label: '瞬间' },
{ path: '/about/', label: '关于' },
{ path: '/en/', label: 'English home' },
];
// 模块级仅存终端语义状态(不存 DOM 引用);history 顺序旧→新
const history: string[] = [];
let histPos = 0;
function isTerminalPage(): boolean {
return location.pathname === PATH || location.pathname === '/terminal';
}
function out(): HTMLElement | null {
return document.getElementById('term-out');
}
function input(): HTMLInputElement | null {
return document.getElementById('term-input') as HTMLInputElement | null;
}
function print(text: string, cls = ''): void {
const box = out();
if (!box) return;
const p = document.createElement('p');
p.className = cls ? `line ${cls}` : 'line';
p.textContent = text; // 纪律:一切回显走 textContent
box.appendChild(p);
}
function printLink(path: string, label: string): void {
const box = out();
if (!box) return;
const p = document.createElement('p');
p.className = 'line';
const a = document.createElement('a');
a.href = path;
a.textContent = `${path.padEnd(14)}${label}`;
a.className = 'term-link';
p.appendChild(a);
box.appendChild(p);
}
function scrollToBottom(): void {
const shell = document.getElementById('term-shell');
shell?.scrollIntoView({ block: 'nearest' });
const box = out();
if (box) box.scrollTop = box.scrollHeight;
}
function run(rawLine: string): void {
const line = rawLine.trim();
print(`wait4x@blog:~$ ${rawLine}`, 'echo');
if (!line) return;
history.push(line);
histPos = history.length;
const [cmd, ...args] = line.split(/\s+/);
switch (cmd) {
case 'help': {
// 注册表即帮助:单一事实源
const width = Math.max(...COMMANDS.map((c) => c.name.length)) + 2;
for (const c of COMMANDS) print(`${c.name.padEnd(width)}${c.desc}`);
break;
}
case 'whoami':
print('wait4x —— 阅读 · 技术 · 摄影,博客在 gitea 开源(open gitea)');
break;
case 'ls':
for (const r of SITE_ROUTES) printLink(r.path, r.label);
break;
case 'theme': {
const root = document.documentElement;
const next = root.dataset.theme === 'dark' ? 'light' : 'dark';
root.dataset.theme = next;
localStorage.setItem('theme', next); // 与 Header/Keyboard 的 t 键同存储键
print(`theme → ${next}`);
break;
}
case 'open': {
const target = args[0];
const url = target ? OPEN_TARGETS[target] : undefined;
if (!url) {
print(`用法: open <target>;可用: ${Object.keys(OPEN_TARGETS).join(', ')}`, 'dim');
} else {
window.open(url, '_blank', 'noopener');
print(`opening ${url} …`, 'dim');
}
break;
}
case 'sudo':
print('wait4x is not in the sudoers file. This incident will be reported.', 'err');
break;
case 'clear': {
out()?.replaceChildren();
break;
}
case 'exit':
print('logout', 'dim');
setTimeout(() => {
location.href = '/';
}, 600);
break;
default:
print(`command not found: ${cmd}(help 查看可用命令)`, 'err');
}
}
function submitLine(): void {
const el = input();
if (!el) return;
run(el.value);
el.value = '';
scrollToBottom();
}
function tabComplete(current: string): void {
if (!current) return;
const hits = COMMANDS.filter((c) => c.name.startsWith(current)).map((c) => c.name);
if (hits.length === 1) {
const el = input();
if (el) el.value = hits[0];
} else if (hits.length > 1) {
// 多命中:先回显当前输入再列候选,与真实 shell 一致
print(`wait4x@blog:~$ ${current}`, 'echo');
print(hits.join(' '), 'dim');
scrollToBottom();
}
}
function historyStep(dir: -1 | 1): void {
const el = input();
if (!el) return;
const next = histPos + dir;
if (next < 0 || next > history.length) return;
histPos = next;
el.value = next === history.length ? '' : history[next];
}
document.addEventListener('submit', (e) => {
if (!isTerminalPage()) return;
const form = e.target as HTMLElement;
if (form.id !== 'term-form') return;
e.preventDefault();
submitLine();
});
document.addEventListener('keydown', (e) => {
if (!isTerminalPage()) return;
const el = e.target as HTMLElement;
if (el.id !== 'term-input') return;
// 中文输入法组词中的 Enter 不是提交;Tab 亦然
if (e.isComposing) return;
if (e.key === 'Enter') {
e.preventDefault();
submitLine();
} else if (e.key === 'Tab') {
e.preventDefault();
const cur = (el as HTMLInputElement).value.trim();
tabComplete(cur);
} else if (e.ctrlKey && e.key.toLowerCase() === 'l') {
e.preventDefault();
out()?.replaceChildren();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
historyStep(-1);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
historyStep(1);
}
});
// 点击终端区域聚焦输入框;有选区(用户在复制输出)时不抢焦点
document.addEventListener('click', (e) => {
if (!isTerminalPage()) return;
const shell = (e.target as HTMLElement).closest?.('#term-shell');
if (!shell) return;
const sel = window.getSelection();
if (sel && !sel.isCollapsed) return;
if ((e.target as HTMLElement).tagName === 'A') return;
input()?.focus();
});
// View Transitions:软导航抵达/回到本页时恢复焦点与光标末位
document.addEventListener('astro:after-swap', () => {
if (!isTerminalPage()) return;
const el = input();
if (el) {
el.focus();
const end = el.value.length;
el.setSelectionRange(end, end);
}
});
</script>
<style>
/* 恒深色例外区:--code-bg 亮暗同值,终端不跟随主题(本页唯一例外,彩蛋语义) */
.term-shell {
max-width: 720px;
margin: var(--space-6) auto;
background: var(--code-bg);
border: 1px solid var(--line);
border-radius: 10px;
overflow: hidden;
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.65;
}
.term-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: rgba(255, 255, 255, 0.03);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #3d4149;
}
.term-title {
margin-left: 8px;
color: #6e7681;
font-size: 11px;
letter-spacing: 0.05em;
}
.term-body { padding: 14px 16px 16px; }
.term-out { max-height: 60vh; overflow-y: auto; }
.line { margin: 0; color: var(--code-ink); white-space: pre-wrap; word-break: break-word; }
.line.echo { color: #adbac7; }
.line.dim { color: #6e7681; }
.line.err { color: #f85149; }
.term-link { color: #539bf5; text-decoration: none; }
.term-link:hover { text-decoration: underline; }
.term-form {
display: flex;
align-items: baseline;
gap: 8px;
margin-top: 4px;
}
.prompt { color: #7ee787; white-space: nowrap; }
.term-input {
flex: 1;
min-width: 0; /* flex 子项收缩时保输入可用(窄屏) */
background: transparent;
border: none;
outline: none;
color: var(--code-ink);
font: inherit;
caret-color: #7ee787;
padding: 0;
}
</style>
</BaseLayout>
+1 -1
View File
@@ -7,7 +7,7 @@
import { chromium } from 'playwright';
import { mkdirSync } from 'node:fs';
const BASE = 'http://127.0.0.1:18742';
const BASE = 'http://127.0.0.1:18743';
const SHOTS = '/tmp/e2e-shots';
mkdirSync(SHOTS, { recursive: true });