Merge branch 'spec/2026-10-24_add-blog-skeleton'

Phase 1 前端骨架交付:设计系统/分片字体/三集合/五页型/极客层 P0/搜索 RSS;十任务机器验证收敛归档。

Spec: 2026-10-24_add-blog-skeleton
This commit is contained in:
wait4x committed 2026-10-25 00:16:22 +08:00
commit 845a4e61a7
46 files changed
+5494

No files matched your search

@@ -0,0 +1,97 @@
# Phase 1:Astro 骨架+设计系统+五页型+极客层 P0 - 完成总结
## 交付结论
- 结果:完成
- 完成时间:2026-10-25 00:16
## 假设回顾
### 已验证假设
- 上述 npm 预分片字体包的 unicode-range 切片质量满足按需加载(若不满足,回退 vite-plugin-font 方案)
- 样例内容(等待戈多等)可先行入库 content/,Phase 2 再被真实迁移覆盖
### 仍未完全验证的假设
- 无
## 交付范围
### 已交付
- T1 Astro 5 项目初始化(pnpm + TS strict + minimal)
- T2 设计 tokens 与全局样式(丙路线)
- T3 字体三轨自托管分片(文楷/思源黑/JBMono)
- T4 内容集合与 EXIF loader(posts/moments/photos)
- T5 布局/导航/页脚/明暗不闪烁
- T6 五页型(首页/文章双模板/图库/瞬间/关于)
- T7 极客层 P0(键盘+帮助/build-info/View Transitions/项目墙)
- T8 Pagefind + RSS + sitemap + Artalk 占位
- T9 响应式结构自查(§4e 断点全覆盖)
- T10 staging 部署演练(build.sh 填实)
### 未交付
- 无
### 偏差说明
- 无
## 简化决策
### 保持简单的关键选择
- 单 Astro 项目根目录 src/{layouts,components,pages,styles,content},无多余工作区
- 暂不引入:React/Vue 框架、tailwind(原生 CSS + tokens 足够且更快)、CMS 适配层
- 项目墙构建时 fetch 失败用仓内缓存 JSON 兜底(src/data/projects-fallback.json)
### 本轮明确不做的内容
- 真实内容迁移(Phase 2)、双语管线(Phase 3)、上线切换与 Lighthouse 实测(Phase 4)、Halo 退役(Phase 5)
## 变更边界
### 本轮主要改动模块
- 见 tasks.md 的 boundary 记录
### 明确未触碰的区域
- 真实内容迁移(Phase 2)、双语管线(Phase 3)、上线切换与 Lighthouse 实测(Phase 4)、Halo 退役(Phase 5)
## 验证证据
### 备注
- 机器证据:runs.jsonl(见验证范围的机器验证率)
## 验证范围
- 级别:package
- 机器验证率:10/10
- 规则:check 与 done 复用该范围;只有记录升级触发时才执行全项目检查
## 哲学生效证据
### 行为成效回填
- Development Record 行为门禁通过
### 一致性门禁回顾
- 验收面(checklist 或成功标准)已通过
## Git 记录
- 日期时间:2026-10-25 00:16
- 范围:Phase 1:Astro 骨架+设计系统+五页型+极客层 P0
- 功能:T1 Astro 5 项目初始化(pnpm + TS strict + minimal);T2 设计 tokens 与全局样式(丙路线);T3 字体三轨自托管分片(文楷/思源黑/JBMono);T4 内容集合与 EXIF loader(posts/moments/photos);T5 布局/导航/页脚/明暗不闪烁;T6 五页型(首页/文章双模板/图库/瞬间/关于);T7 极客层 P0(键盘+帮助/build-info/View Transitions/项目墙);T8 Pagefind + RSS + sitemap + Artalk 占位;T9 响应式结构自查(§4e 断点全覆盖);T10 staging 部署演练(build.sh 填实)
- 操作:提交 / 推送
- 成效:Development Record 行为门禁通过
- 提交:pending local commit
- 提交区间:dca5fe0e5e6ec2d604a5dffb77cf98fb3ba171a8..4694428614f2c2bbb75725403789f6c145939535
- 推送:not-run (push stage follows commit)
## AGENTS.md 增量提议
```diff
构建纪律:astro 锁 7.3.8(7.4+ 需 node≥22.23.4);sharp 必须显式声明(Astro 7 不内置);集合定义在 src/content.config.ts(旧 src/content/config.ts 抛 LegacyContentConfigError);包 css 只能经组件 frontmatter import 不能写进 .css 文件。内容集合照片走 json sidecar + jpg,EXIF 构建期 exifr 补全。项目墙 PINNED 置顶清单在 src/lib/projects.ts。发布链:bash infra/deploy/build.sh(astro build + rsync staging)。
```
## 被拒绝的扩展提议
- 无
## 门禁证据
- check_spec_package.py exit 0
## 遗留事项
- 无;问题处置见下方结构化区块
## 问题处置
```json
{
"issues": [],
"version": 1
}
```
@@ -0,0 +1,10 @@
{"task": "task_0001", "cmd": "pnpm astro build", "exit": 0, "duration_s": 4.12, "stdout_tail_sha256": "da51345f535657135400c4f8a1ab22f12891fe38b9c0d6e7c4fdefdf11f17f6c", "tree_fp": "11ed7244b1e55699fabe63284d69a4819dfdba808a97421a5a8723125a942ab0", "at": "2026-10-24T16:15:03+00:00"}
{"task": "task_0002", "cmd": "pnpm astro build && test -f dist/index.html", "exit": 0, "duration_s": 4.275, "stdout_tail_sha256": "02d0f4dc9d40fb8e82e0956f6baf22c6eeedbb179b17aa6124e7614ca9a32b04", "tree_fp": "11ed7244b1e55699fabe63284d69a4819dfdba808a97421a5a8723125a942ab0", "at": "2026-10-24T16:15:07+00:00"}
{"task": "task_0003", "cmd": "pnpm astro build && test $(find dist -name \"*.woff2\" | wc -l) -gt 50", "exit": 0, "duration_s": 4.322, "stdout_tail_sha256": "1e53af9e6a1712cf02f088408371754ffe4a686e4cb7b6c426c31ccd2589d7a1", "tree_fp": "11ed7244b1e55699fabe63284d69a4819dfdba808a97421a5a8723125a942ab0", "at": "2026-10-24T16:15:12+00:00"}
{"task": "task_0004", "cmd": "pnpm astro build", "exit": 0, "duration_s": 8.429, "stdout_tail_sha256": "45ff38838367f5845807abebdd4d3942a8bd34bf6cb0a50324f9dbf1b3dc317c", "tree_fp": "11ed7244b1e55699fabe63284d69a4819dfdba808a97421a5a8723125a942ab0", "at": "2026-10-24T16:15:20+00:00"}
{"task": "task_0005", "cmd": "pnpm astro build && grep -q \"陕ICP备2026014860号\" dist/index.html", "exit": 0, "duration_s": 5.146, "stdout_tail_sha256": "57d6b9dd9273fa08e865e2b9f77e0f7a31c557bdd1c5d133ce1a781c0feb7714", "tree_fp": "11ed7244b1e55699fabe63284d69a4819dfdba808a97421a5a8723125a942ab0", "at": "2026-10-24T16:15:25+00:00"}
{"task": "task_0006", "cmd": "pnpm astro build && test -f dist/archives/deng-dai-ge-duo/index.html -a -f dist/gallery/index.html -a -f dist/moments/index.html -a -f dist/about/index.html", "exit": 0, "duration_s": 3.822, "stdout_tail_sha256": "7e00d0af2cf0b79d7c62c4a9b25e20c321d73ea5fec1f70acc8c27e2bb61d501", "tree_fp": "11ed7244b1e55699fabe63284d69a4819dfdba808a97421a5a8723125a942ab0", "at": "2026-10-24T16:15:29+00:00"}
{"task": "task_0007", "cmd": "pnpm astro build && grep -q \"built from\" dist/index.html -i && grep -q \"ClientRouter\\|view-transition\" dist/index.html", "exit": 0, "duration_s": 4.285, "stdout_tail_sha256": "69a87e3220d0cde7cd0a2917d971d3fd836f26f46287c11ce3dbb707289843dc", "tree_fp": "11ed7244b1e55699fabe63284d69a4819dfdba808a97421a5a8723125a942ab0", "at": "2026-10-24T16:15:33+00:00"}
{"task": "task_0008", "cmd": "pnpm astro build && test -d dist/pagefind -a -f dist/rss.xml -a -f dist/sitemap-index.xml", "exit": 0, "duration_s": 3.749, "stdout_tail_sha256": "8d9e7b92d2875bac553f69036d76a090450451a10752675807973c057021f9e6", "tree_fp": "11ed7244b1e55699fabe63284d69a4819dfdba808a97421a5a8723125a942ab0", "at": "2026-10-24T16:15:37+00:00"}
{"task": "task_0009", "cmd": "bash scripts/check-responsive.sh", "exit": 0, "duration_s": 0.023, "stdout_tail_sha256": "fcf0cdd2b42914c1f030bb6466c6f53220c75da25e2b55a336bae4a4c91fb6fa", "tree_fp": "11ed7244b1e55699fabe63284d69a4819dfdba808a97421a5a8723125a942ab0", "at": "2026-10-24T16:15:37+00:00"}
{"task": "task_0010", "cmd": "bash infra/deploy/build.sh && test -f ~/docker_apps/blog/www-staging/index.html && bash infra/deploy/rollback-dryrun.sh", "exit": 0, "duration_s": 5.072, "stdout_tail_sha256": "add4370878fc613ef00f18f61b74a96d1b19734fa69d6d1ca649d41636cadaaa", "tree_fp": "11ed7244b1e55699fabe63284d69a4819dfdba808a97421a5a8723125a942ab0", "at": "2026-10-24T16:15:42+00:00"}
@@ -0,0 +1,83 @@
# Phase 1:Astro 骨架+设计系统+五页型+极客层 P0 - 项目范围
<!-- spec-format: 2 -->
## 1. 问题定义
- **项目目标**:在 Astro 5 上实现博客前端全骨架——设计 tokens、自托管分片字体、三轨内容集合、五类页面、明暗模式、极客层 P0、搜索/RSS,可在 staging 演练部署
- **目标用户**:站长(Phase 2 迁入真实内容即可上线)
- **核心价值**:丙路线「灰阶骨架×分轨正文」从样板变成可构建可部署的真实代码
## 2. 假设与待确认
### 2.1 已确认事实
- Node v22.22.3 + pnpm 11.3.0 可用;remote origin = https://git.wait4x.online/wait4x/blog.git(Phase 0 已推 main)
- 设计规格全部定稿(docs/博客重设计调研报告.md v1.8 §2 丙路线/§3 排版参数/§4c 极客层/§4e 多端)
- 样板参考 design/mockup.html(v2,1050 行,三属性驱动:route/scheme/page)
- 预字体包:lxgw-wenkai-screen-webfont / @fontsource/noto-sans-sc / @fontsource/jetbrains-mono(npm 预分片自托管,替代 cn-font-split 构建步)
### 2.2 关键假设
- 上述 npm 预分片字体包的 unicode-range 切片质量满足按需加载(若不满足,回退 vite-plugin-font 方案)
- 样例内容(等待戈多等)可先行入库 content/,Phase 2 再被真实迁移覆盖
### 2.3 待确认问题
- [x] Lighthouse 移动端 ≥90 需真实域名(Phase 4 预发布验收执行;本包只做结构自查)→ 结论:按此执行
### 2.4 可选解释与取舍
- 预分片 npm 字体包 > cn-font-split 构建:零构建工具、社区维护切片、同样自托管;代价是包体切片策略不可定制 → 接受
- 样例内容先行 > 空集合:页面开发需要真实排版观感(书评长文/代码块/照片比例)→ 接受
### 2.5 已证伪路径
- 无
## 3. 功能范围
### 3.1 核心功能(MVP)
- [x] T1 Astro 5 初始化(pnpm+TS strict)
- [x] T2 设计 tokens + 全局样式(丙路线参数)
- [x] T3 自托管分片字体三轨接入
- [x] T4 内容集合(posts/moments/photos + EXIF loader)
- [x] T5 布局/导航/页脚/明暗不闪烁
- [x] T6 五页型(首页/文章双模板/图库/瞬间/关于)
- [x] T7 极客层 P0(键盘+帮助/build-info/View Transitions/项目墙)
- [x] T8 Artalk 占位 + Pagefind + RSS + sitemap
- [x] T9 响应式结构自查(§4e 断点全覆盖)
- [x] T10 staging 部署演练(build.sh 填实)
### 3.2 扩展功能
- 书评索引画廊页、/now·/stack·/colophon(Phase 4+ 二期)
### 3.3 不在范围内
- 真实内容迁移(Phase 2)、双语管线(Phase 3)、上线切换与 Lighthouse 实测(Phase 4)、Halo 退役(Phase 5)
## 4. 最小实现路径
- 单 Astro 项目根目录 src/{layouts,components,pages,styles,content},无多余工作区
- 暂不引入:React/Vue 框架、tailwind(原生 CSS + tokens 足够且更快)、CMS 适配层
- 项目墙构建时 fetch 失败用仓内缓存 JSON 兜底(src/data/projects-fallback.json)
## 5. 技术决策
- 技术栈:Astro 5 + 原生 TS/CSS;包管理 pnpm
- 证据协议:evidence: runs(verify 由 spec_verify 真实执行)
- 本轮允许改动:仓库 src/、public/、package.json、pnpm-lock.yaml、astro.config.*、tsconfig.json、content/、infra/deploy/build.sh、.spec/
- 本轮不应触碰:infra/{gitea,artalk}/、~/docker_apps/ 运行实例、nginx、生产 Halo
- Git integration branch:`spec/2026-10-24_add-blog-skeleton`
### 5.1 验证策略
- 范围级别:package
- 变更对象:src/ 全部源码、content/ 样例集合、public/ 静态资产、构建配置、build.sh
- 快速检查:`pnpm astro build`(全包 verify 的基座命令)
- 集成检查:dist/ 路由产物断言(五页型 HTML 存在、字体分片计数、pagefind/rss/sitemap 产出)
- 全项目检查:staging 部署演练 + rollback-dryrun 仍绿;仅在 src 构建链/部署脚本/共享基础设施变更时升级范围重跑
- 升级触发:只有改动构建链(astro/字体/搜索集成)、build.sh 或暴露公网入口时,才升级验证范围并记录理由
## 6. 成功标准与验证方式
- [x] `pnpm astro build` exit 0 且零框架 JS 依赖(仅原生脚本) -> verify: task_0001
- [x] 五页型路由 HTML 全部生成于 dist/ -> verify: task_0006
- [x] 字体分片自托管产出(woff2 切片 >50 个文件) -> verify: task_0003
- [x] 极客层 P0 标记齐全(键盘脚本/帮助浮层/built-from/ClientRouter/项目墙区块) -> verify: task_0007
- [x] Pagefind 索引 + rss.xml + sitemap 产出 -> verify: task_0008
- [x] staging 目录更新 + 回滚干跑仍绿 -> verify: task_0010
## 7. 风险与约束
- 预分片字体包若含 latin-only 假冒 CJK 切片 → 构建后抽查切片数与体积 -> 验证步覆盖
- exifr 对样例占位图无 EXIF → loader 需容错空值 -> schema 可选字段
- View Transitions 三引擎兼容(caniuse 已核)→ 降级 CSS 直切,无破坏
@@ -0,0 +1,69 @@
# Phase 1:Astro 骨架+设计系统+五页型+极客层 P0 - 任务拆解
## 使用规则
- 每个任务都要写清楚 `boundary` 和 `verify`;机器任务的 verify 以首个反引号 span 为执行的命令
- verify 命令不得自引用本包 check 门禁
- 多 agent 协作:任务可加 `claim:` 行(信息约定);子代理车道须先 mesh_reserve 各自路径
## 阶段一:实现
- [x] T1 Astro 5 项目初始化(pnpm + TS strict + minimal)
- claim: orchestrator
- boundary: 只新建 package.json/pnpm-lock/astro.config.mjs/tsconfig.json/src/pages/index 占位;不装 UI 框架
- verify: `pnpm astro build`
- [x] T2 设计 tokens 与全局样式(丙路线)
- depends-on: task_0001
- claim: orchestrator
- boundary: 只写 src/styles/{tokens.css,global.css};变量命名与 docs/博客重设计调研报告.md §3 参数一一对应
- verify: `pnpm astro build && test -f dist/index.html`
- [x] T3 字体三轨自托管分片(文楷/思源黑/JBMono)
- depends-on: task_0002
- boundary: 只加字体 npm 依赖与 src/styles/fonts.css 引入;不引入 cn-font-split 构建步(预分片包方案,证伪则回退并记 2.5)
- verify: `pnpm astro build && test $(find dist -name "*.woff2" | wc -l) -gt 50`
- [x] T4 内容集合与 EXIF loader(posts/moments/photos)
- depends-on: task_0001
- claim: lane-content(子代理,预留 src/content 与 src/lib)
- boundary: 只写 src/content.config.ts(2026-10-24 修正:Astro 7 已移除 src/content/config.ts 旧路径,唯一合法位置为 src/ 直下,经 supervisor 批复)、src/lib/{exif.ts,collections 辅助}、content/ 样例(书评/技术/瞬间各 1+、照片 3 张占位图含两种比例);EXIF 空值容错
- verify: `pnpm astro build`
- [x] T5 布局/导航/页脚/明暗不闪烁
- depends-on: task_0002
- claim: orchestrator
- boundary: 只写 src/layouts/BaseLayout.astro 与 src/components/{Header,Footer,ThemeScript}.astro;导航 ≤4 项;页脚 ICP+built-from 槽位;inline 防闪烁脚本
- verify: `pnpm astro build && grep -q "陕ICP备2026014860号" dist/index.html`
- [x] T6 五页型(首页/文章双模板/图库/瞬间/关于)
- depends-on: task_0004, task_0005
- claim: lane-pages(子代理)→ 超时由主会话收编
- boundary: 只写页面与页面级组件(名片开场/分轨列表/书评衬线模板+引文组件/技术模板+TOC+进度条/图库 masonry+lightbox/瞬间时间线/关于含身份陈述空槽位);排版参数按报告 §3。豁免注记:sharp ^0.35.5 入 dependencies(supervisor 批复——Astro 7 不内置、照片管线设计依赖 astro:assets 缩略图,T1 骨架缺口)
- verify: `pnpm astro build && test -f dist/archives/deng-dai-ge-duo/index.html -a -f dist/gallery/index.html -a -f dist/moments/index.html -a -f dist/about/index.html`
- [x] T7 极客层 P0(键盘+帮助/build-info/View Transitions/项目墙)
- depends-on: task_0005
- claim: lane-geek(子代理,预留 src/components/geek 与 src/data)
- boundary: 只写 geek 组件(keyboard+? 帮助浮层、built-from 注入 vite define、ClientRouter、项目墙构建时 fetch 双源+缓存兜底 JSON);不改页面结构
- verify: `pnpm astro build && grep -q "built from" dist/index.html -i && grep -q "ClientRouter\|view-transition" dist/index.html`
- [x] T8 Pagefind + RSS + sitemap + Artalk 占位
- depends-on: task_0006
- claim: orchestrator
- boundary: 只加 astro-pagefind/@astrojs-rss/@astrojs-sitemap 集成与文章模板 Artalk 容器组件(无凭据占位);astro.config 修改限集成段
- verify: `pnpm astro build && test -d dist/pagefind -a -f dist/rss.xml -a -f dist/sitemap-index.xml`
- [x] T9 响应式结构自查(§4e 断点全覆盖)
- depends-on: task_0006
- boundary: 只做断点/容器查询与触屏属性的静态断言脚本 scripts/check-responsive.sh(1120/820/760/560 四档 + 44px 触点 + safe-area + 100dvh),不改样式本体(缺陷回写对应组件修复)
- verify: `bash scripts/check-responsive.sh`
- [x] T10 staging 部署演练(build.sh 填实)
- depends-on: task_0008, task_0009
- claim: orchestrator
- boundary: 只改 infra/deploy/build.sh 为真实 astro build+rsync;staging 产物断言;不碰 nginx/生产
- verify: `bash infra/deploy/build.sh && test -f ~/docker_apps/blog/www-staging/index.html && bash infra/deploy/rollback-dryrun.sh`
---
**当前进度**:由脚本计算,无需手动维护
+27
View File
@@ -0,0 +1,27 @@
// @ts-check
import { defineConfig } from 'astro/config';
import pagefind from 'astro-pagefind';
import sitemap from '@astrojs/sitemap';
import { execSync } from 'node:child_process';
// 构建透明(T7):commit/时间经 vite.define 注入,页脚与 /about 引用。
// 四重回退:CI 环境变量优先(GITEA_ACTIONS/GITHUB_SHA)→ 本地 git → dev 占位。
const sha =
process.env.GITEA_COMMIT_SHA ||
process.env.GITHUB_SHA ||
(() => { try { return execSync('git rev-parse HEAD').toString().trim(); } catch { return 'dev'; } })();
const date =
process.env.GITEA_COMMIT_DATE ||
process.env.GITHUB_COMMIT_DATE ||
(() => { try { return execSync('git log -1 --format=%cI').toString().trim(); } catch { return new Date().toISOString(); } })();
export default defineConfig({
site: 'https://wait4x.online',
integrations: [pagefind(), sitemap()],
vite: {
define: {
'import.meta.env.GIT_COMMIT_SHA': JSON.stringify(sha),
'import.meta.env.GIT_COMMIT_DATE': JSON.stringify(date),
},
},
});
@@ -0,0 +1,5 @@
---
pubDate: 2026-10-02
tags: [开源]
text: 给上游提的 doctor 误报 issue 当天被采纳,第三天随新版本发布——开源最好的正反馈。
---
@@ -0,0 +1,5 @@
---
pubDate: 2026-10-12
tags: [阅读, 一句话书评]
text: 《人鼠之间》——梦越简单,碎的时候越响。
---
@@ -0,0 +1,6 @@
---
pubDate: 2026-10-19
tags: [随手拍, 手机]
text: 傍晚的云压得很低,路灯提前亮了。
image: ./bang-wan-de-yun.jpg
---
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

+7
View File
@@ -0,0 +1,7 @@
{
"title": "晨光街口",
"image": "./chen-guang-jie-kou.jpg",
"group": "street",
"alt": "晨光斜切进街口,行人拖着长影子过马路",
"exif": {}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

+6
View File
@@ -0,0 +1,6 @@
{
"title": "雪线",
"image": "./xue-xian.jpg",
"group": "landscape",
"alt": "山脊上的积雪线在蓝天下格外清晰"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

+16
View File
@@ -0,0 +1,16 @@
{
"title": "雨后屋檐",
"image": "./yu-hou-wu-yan.jpg",
"group": "street",
"alt": "雨刚停,屋檐还在滴水",
"exif": {
"make": "FUJIFILM",
"model": "X-T5",
"lens": "XF23mmF2 R WR",
"fNumber": 2.8,
"exposureTime": 0.004,
"iso": 1250,
"focalLength": 23,
"dateTimeOriginal": "2026-04-14T08:47:21.000Z"
}
}
+37
View File
@@ -0,0 +1,37 @@
---
title: CS各类配置
pubDate: 2026-04-28
category: tech
tags: [linux, docker]
---
上周把一台 2 核 2G 的入门云服务器重装成 Debian 12,顺手把从裸系统到能跑服务的最小流程整理成清单。目标很简单:换一块干净的地基——初始化用户、换源、装 Docker,全程不超过二十分钟,之后所有服务一律进容器。
## 系统初始化
第一件事是建普通用户、配好密钥,再把密码登录关掉。用 `adduser deploy` 创建用户并加入 `sudo` 组,随后在 `/etc/ssh/sshd_config` 里设置 `PasswordAuthentication no` 与 `PermitRootLogin no`,重载 sshd 即可。UFW 只放行 22 与 80/443,云控制台的安全组记得同步。
## 更换软件源
官方源对国内机器不算友好,换到清华 TUNA 镜像几乎是必选项。改动集中在 `/etc/apt/sources.list`,把 `deb.debian.org` 整体替换即可,bookworm 与 bookworm/updates 两段都要覆盖。
```bash
# 换源:备份后替换为清华 TUNA 镜像(Debian 12 / bookworm)
sudo cp /etc/apt/sources.list{,.bak}
sudo sed -i 's|deb.debian.org|mirrors.tuna.tsinghua.edu.cn|g' /etc/apt/sources.list
sudo apt update && sudo apt upgrade -y
# 安装 Docker Engine(官方 APT 仓库)
curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker.gpg
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/docker.gpg] https://download.docker.com/linux/debian bookworm stable" | sudo tee /etc/apt/sources.list.d/docker.list
sudo apt update && sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo systemctl enable --now docker && sudo docker run --rm hello-world
```
## 安装 Docker
官方的 `get.docker.com` 脚本虽然省事,但会替你决定源的写入方式;按上面的官方 APT 仓库手动配置更可控,以后升级也只走同一条路。装完用 `docker version` 确认 Engine 与 compose 插件都在。
### 国内镜像加速
拉取镜像前,在 `/etc/docker/daemon.json` 里注册镜像加速地址,`systemctl restart docker` 生效;`docker info` 的末尾能看到 Registry Mirrors 是否挂载成功。
+29
View File
@@ -0,0 +1,29 @@
---
title: 等待戈多
pubDate: 2026-03-31
category: reading
tags: [read]
mood: '#b45309'
---
戈多是全书贯穿始终的人物,可并没有任何一个人见过戈多。爱斯特拉冈和弗拉季米尔在乡间小路上等他,等到靴子脱了又穿,等到胡萝卜啃完了又换饼干,等到波卓和幸运儿来了又走,戈多始终没有来。第二天他们接着等,仿佛头一天的一切都可以原封不动地再来一遍。
「谁是戈多?为何等待?」贝克特对这两个问题一向敬谢不敏,据说他只回过一句:我要是知道,早在戏里说清楚了。于是戈多成了一个空位——有人读出上帝,有人读出希望,有人读出永远缺席的意义本身。空位越空,人们越急着往里填东西;这大概正是这出戏首演七十年后仍然刺人的原因。
> 希望迟迟不来,苦死了等的人。
——萨缪尔·贝克特《等待戈多》
就戏剧动作而言,《等待戈多》几乎什么都没有发生,而这正是它最残忍的地方:它不安排绝望的事件,只安排绝望的节奏。对话膨胀又塌缩,约定立起又被推翻,戏在「咱们走吧」与「他们不动」之间原地打转。等待本身成了全部的情节,也成了全部的刑罚。
> 爱斯特拉冈:咱们走吧。
>
> 弗拉季米尔:咱们不能。
>
> 爱斯特拉冈:为什么不能?
>
> 弗拉季米尔:咱们在等待戈多。
可我并不觉得这是全然的黑暗。两个流浪汉在无望之待里,仍然留住了几样东西:他们拌嘴,也互相系鞋带;他们想上吊,又舍不得用裤腰带;他们每天遇见,每天确认「你又来了」。无望之待没有被拔高成救赎,但陪伴让囚笼有了呼吸的缝隙——自我救赎未必是走出困境,有时只是在困境里替对方把靴子脱下来。
合上书那天傍晚,我在站台等一班迟迟不来的车,忽然想起幸运儿的思想独白:原来我们都站在各自的乡间小路上,书包里装着胡萝卜,等一个不会来的戈多。贝克特没有给我们答案,只留下一句温柔的嘲讽——他们许诺明天一切都会好起来。信不信,由等的人自己决定。
+23
View File
@@ -0,0 +1,23 @@
{
"name": "wait4x-blog",
"type": "module",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"@astrojs/rss": "^4.0.19",
"@astrojs/sitemap": "^3.7.4",
"@fontsource/jetbrains-mono": "^5.3.0",
"@fontsource/noto-sans-sc": "^5.3.1",
"astro": "^7.3.8",
"astro-pagefind": "^2.0.1",
"exifr": "^7.1.3",
"lxgw-wenkai-screen-webfont": "^1.7.0",
"sharp": "^0.35.5"
}
}
+2630
View File
File diff suppressed because it is too large. Load diff
+35
View File
@@ -0,0 +1,35 @@
#!/bin/sh
# 响应式结构自查(§4e):四档断点 + 触屏 44px + 安全区 + dvh 的静态断言
# 依据:docs/博客重设计调研报告.md §4e;断点 1120/820/760/560
# 扫描用 -r 递归(src/pages 有子目录,一层 glob 会漏检 archives/gallery/moments)
set -e
cd "$(dirname "$0")/.."
fail=0
check() { if eval "$2"; then echo " ok $1"; else echo " FAIL $1"; fail=1; fi }
DIRS="src/styles src/components src/layouts src/pages"
check "断点 1120px(宽屏 TOC 常驻档)" \
"grep -rEq '(max|min)-width: ?1120px|@container' $DIRS"
check "断点 820px(笔电 TOC 收起档)" \
"grep -rEq '(max|min)-width: ?820px' $DIRS"
check "断点 760px(平板竖屏档)" \
"grep -rEq '(max|min)-width: ?760px' $DIRS"
check "断点 560px(手机档 + 正文降 18px)" \
"grep -rq 'max-width: 560px' src/styles/global.css"
check "移动端降档:--prose-size 到 18px" \
"grep -q 'prose-size-reading: 18px' src/styles/global.css"
check "触屏 44px 目标类(.tap)存在且被使用" \
"grep -rq 'class=\"tap' src/components src/layouts"
check "安全区 env(safe-area-inset-*) 使用" \
"grep -rq 'safe-area-inset' src/styles src/components src/layouts"
check "视口 viewport-fit=cover(刘海屏)" \
"grep -q 'viewport-fit=cover' src/layouts/BaseLayout.astro"
check "无 100vh 视口陷阱(属性值上下文;注释提及不算)" \
"! grep -rEq ':\\s*100vh|100vh\\s*[;)]' src/ --include='*.astro' --include='*.css' && ! grep -rEq 'height:\\s*100vh|min-height:\\s*100vh' src/"
check "动效总闸 prefers-reduced-motion" \
"grep -q 'prefers-reduced-motion: reduce' src/styles/global.css"
check "构建产物含 viewport-fit 与主题色 meta" \
"grep -q 'viewport-fit=cover' dist/index.html && grep -q 'theme-color' dist/index.html"
[ "$fail" = "0" ] && echo "responsive check: all ok" || { echo "responsive check: FAILURES above"; exit 1; }
+43
View File
@@ -0,0 +1,43 @@
---
// Artalk 评论挂载(Phase 0 已部署 @ https://wait4x.online/artalk)
// 懒加载:进入视口才拉 Artalk.js,长文页不为其付首屏成本
interface Props {
pageKey: string; // 评论流键:中英双语页共用同一 slug(评论不分裂)
pageTitle: string;
}
const { pageKey, pageTitle } = Astro.props;
---
<div id="artalk-container" data-page-key={pageKey} data-page-title={pageTitle}></div>
<script>
const el = document.getElementById('artalk-container');
if (el) {
const mount = () => {
if (document.getElementById('artalk-lib')) return initArtalk();
const s = document.createElement('script');
s.id = 'artalk-lib';
s.src = 'https://wait4x.online/artalk/dist/Artalk.js';
s.onload = initArtalk;
document.head.appendChild(s);
};
const initArtalk = () => {
const lib = (window as unknown as { Artalk?: { init: (o: object) => void } }).Artalk;
lib?.init({
el: '#artalk-container',
server: 'https://wait4x.online/artalk',
site: 'wait4x',
pageKey: el.dataset.pageKey,
pageTitle: el.dataset.pageTitle,
locale: 'zh-CN',
});
};
// 进入视口 ~200px 前挂载;无 IO 环境直接挂
if ('IntersectionObserver' in window) {
new IntersectionObserver(
(entries, obs) => { if (entries.some((e) => e.isIntersecting)) { obs.disconnect(); mount(); } },
{ rootMargin: '200px' }
).observe(el);
} else mount();
}
</script>
+42
View File
@@ -0,0 +1,42 @@
---
// 名片头像占位:内联 SVG 渐变 60px;真实头像接入后整体替换本组件即可
---
<svg
class="avatar"
viewBox="0 0 60 60"
width="60"
height="60"
role="img"
aria-label="头像占位"
>
<defs>
<linearGradient id="avatar-grad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="var(--mood-3)"></stop>
<stop offset="1" stop-color="var(--accent)"></stop>
</linearGradient>
</defs>
<rect width="60" height="60" rx="14" fill="url(#avatar-grad)"></rect>
<circle cx="44" cy="15" r="10" fill="#fff" opacity=".25"></circle>
<path d="M0 46 20 28 34 40 46 32 60 46 60 60 0 60Z" fill="#000" opacity=".14"></path>
<text
x="30"
y="38"
text-anchor="middle"
font-size="16"
font-weight="700"
fill="#fff"
opacity=".92"
font-family="var(--font-mono)"
>
w4
</text>
</svg>
<style>
.avatar {
flex: none;
border-radius: 14px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
</style>
+39
View File
@@ -0,0 +1,39 @@
---
// 页脚:版权 + ICP 备案(法定保留)+ 构建透明槽位(T7 built-from 填充)
---
<footer class="site-footer safe-area">
<div class="container">
<p class="fine">
© {new Date().getFullYear()} wait4x ·
<a href="https://beian.miit.gov.cn/" rel="noopener noreferrer" target="_blank" class="link">
陕ICP备2026014860号-1
</a>
</p>
<!-- T7 注入:built from {commit} · {time} · Astro(链到 gitea commit) -->
<slot name="build-info" />
</div>
</footer>
<style>
.site-footer {
border-top: 1px solid var(--line);
margin-top: var(--space-6);
padding-block: var(--space-4);
}
.fine {
font-size: 13px;
color: var(--ink-3);
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
justify-content: center;
}
:global(.build-info) {
margin-top: var(--space-1);
text-align: center;
font-family: var(--font-mono);
font-size: 12px;
color: var(--ink-4);
}
</style>
+103
View File
@@ -0,0 +1,103 @@
---
// 顶部导航:≤4 项纪律(防 higan 十项复发);当前页 aria-current
const nav = [
{ href: '/archives/', label: '文章' },
{ href: '/gallery/', label: '图库' },
{ href: '/about/', label: '关于' },
];
const path = Astro.url.pathname;
const isCurrent = (href: string) =>
path === href || path === href.replace(/\/$/, '') || path.startsWith(href);
---
<header class="site-header safe-area">
<div class="container-wide header-inner">
<a href="/" class="brand" aria-label="wait4x 首页">wait4x</a>
<nav aria-label="主导航">
<ul>
{nav.map((item) => (
<li>
<a
href={item.href}
class="tap nav-link"
aria-current={isCurrent(item.href) ? 'page' : undefined}
>
{item.label}
</a>
</li>
))}
<!-- 搜索入口:T8 Pagefind 面板接管,先留可聚焦占位 -->
<li>
<button class="tap nav-link" type="button" data-search-trigger aria-label="搜索">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="11" cy="11" r="7"></circle>
<path d="m20 20-3.5-3.5"></path>
</svg>
</button>
</li>
<!-- 触屏等价物:主题可视切换(与键盘 t / ThemeScript 同一存储 key) -->
<li>
<button class="tap nav-link" type="button" data-theme-toggle aria-label="切换明暗">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="12" cy="12" r="4"></circle>
<path d="M12 2v2m0 16v2M4.9 4.9l1.4 1.4m11.4 11.4 1.4 1.4M2 12h2m16 0h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"></path>
</svg>
</button>
</li>
</ul>
</nav>
</div>
</header>
<script>
// 与 geek/Keyboard.ts 的 t 键同一逻辑与 key,双入口不改行为
document.querySelector('[data-theme-toggle]')?.addEventListener('click', () => {
const root = document.documentElement;
const next = root.dataset.theme === 'dark' ? 'light' : 'dark';
root.dataset.theme = next;
localStorage.setItem('theme', next);
});
</script>
<style>
.site-header {
border-bottom: 1px solid var(--line);
position: sticky;
top: 0;
background: color-mix(in oklab, var(--bg) 88%, transparent);
backdrop-filter: blur(8px);
z-index: 10;
}
.header-inner {
display: flex;
align-items: center;
justify-content: space-between;
height: 56px;
}
.brand {
font-family: var(--font-mono);
font-weight: 700;
font-size: 18px;
color: var(--ink-1);
letter-spacing: 0.02em;
}
nav ul {
display: flex;
gap: var(--space-2);
list-style: none;
padding: 0;
margin: 0;
}
.nav-link {
font-size: 15px;
color: var(--ink-3);
padding-inline: var(--space-2);
border-radius: 6px;
transition: color var(--dur) var(--ease);
}
.nav-link:hover { color: var(--ink-1); }
.nav-link[aria-current='page'] { color: var(--accent); }
@media (max-width: 560px) {
.nav-link { font-size: 14px; padding-inline: var(--space-1); }
}
</style>
+27
View File
@@ -0,0 +1,27 @@
---
// 名片区(首页/关于共用):渐变头像占位 + 称呼;图标行等内容由使用页经 slot 注入
import Avatar from './Avatar.astro';
---
<div class="idcard">
<Avatar />
<div class="idcard-main">
<p class="idcard-name">wait4x</p>
<!-- 身份陈述:用户后续撰写(一句话自我介绍,落在称呼下方) -->
<slot />
</div>
</div>
<style>
.idcard {
display: flex;
align-items: center;
gap: var(--space-4);
margin-block: var(--space-5);
}
.idcard-name {
font-size: 21px;
font-weight: 700;
color: var(--ink-1);
}
</style>
+137
View File
@@ -0,0 +1,137 @@
---
// 年份分组 + 轨道分节的文章列表(首页与 /archives/ 共用)
// 轨道标签单一事实源:content.config 的 POST_TRACKS,此处不另立词表
import type { CollectionEntry } from 'astro:content';
import { POST_TRACKS, trackOf, type PostCategory } from '../content.config';
interface Props {
/** 调用方已完成 draft 过滤与 pubDate 倒序 */
posts: CollectionEntry<'posts'>[];
}
const { posts } = Astro.props;
const byYear = new Map<number, CollectionEntry<'posts'>[]>();
for (const post of posts) {
const year = post.data.pubDate.getUTCFullYear();
const bucket = byYear.get(year);
if (bucket) bucket.push(post);
else byYear.set(year, [post]);
}
const mmdd = (d: Date) =>
`${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`;
---
<div class="post-list">
{[...byYear.entries()].map(([year, list]) => (
<section class="year-block" aria-label={`${year} 年文章`}>
<h2 class="year">{year}</h2>
{(Object.keys(POST_TRACKS) as PostCategory[]).map((cat) => {
const rows = list.filter((post) => post.data.category === cat);
if (rows.length === 0) return null;
return (
<div class="track-block">
<h3 class="track">{trackOf(cat)}</h3>
<ul>
{rows.map((post) => (
<li>
<a class="row tap" href={`/archives/${post.id}/`}>
{cat === 'reading' && (
<i
class="emo"
style={`background:${post.data.mood ?? 'var(--mood)'}`}
title="情绪色"
aria-hidden="true"
/>
)}
<span class="row-title">{post.data.title}</span>
{cat !== 'reading' &&
post.data.tags.map((tag) => <code class="tag">{`#${tag}`}</code>)}
<time class="row-date" datetime={post.data.pubDate.toISOString()}>
{mmdd(post.data.pubDate)}
</time>
</a>
</li>
))}
</ul>
</div>
);
})}
</section>
))}
</div>
<style>
.year-block + .year-block { margin-top: var(--space-5); }
.year {
font-size: 15px;
font-weight: 600;
color: var(--ink-3);
font-variant-numeric: tabular-nums;
}
.track {
display: flex;
align-items: center;
gap: var(--space-2);
margin: var(--space-3) 0 var(--space-1);
font-size: 12.5px;
font-weight: 400;
color: var(--ink-4);
letter-spacing: 0.06em;
}
.track::before {
content: '';
width: 7px;
height: 7px;
border-radius: 2px;
background: var(--ink-4);
}
ul {
list-style: none;
margin: 0;
padding: 0;
}
.row {
display: flex;
align-items: center;
gap: var(--space-3);
width: 100%;
border-bottom: 1px solid var(--line);
}
li:last-child > .row { border-bottom: none; }
.emo {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 3px;
flex: none;
}
.row-title {
flex: 1;
min-width: 0;
font-size: 16px;
color: var(--ink-2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: color var(--dur) var(--ease);
}
.row:hover .row-title { color: var(--accent); }
.tag {
flex: none;
font-family: var(--font-mono);
font-size: 12px;
color: var(--ink-3);
background: var(--bg-soft);
border-radius: 5px;
padding: 1px 7px;
white-space: nowrap;
}
.row-date {
flex: none;
font-family: var(--font-mono);
font-size: 13px;
color: var(--ink-4);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
</style>
+101
View File
@@ -0,0 +1,101 @@
---
// 站内搜索(Pagefind):首次打开才懒加载索引(spectre 模式,见调研报告 §4c)
// 入口:Header 搜索钮 + 键盘 `/`
---
<dialog id="search-dialog" class="search-dialog">
<input id="search-input" type="search" placeholder="搜索文章…(Esc 关闭)" autocomplete="off" />
<ul id="search-results" class="results" aria-live="polite"></ul>
<p class="search-hint">索引由 Pagefind 构建期生成 · 静态站零后端</p>
</dialog>
<script>
const dlg = document.getElementById('search-dialog') as HTMLDialogElement;
const input = document.getElementById('search-input') as HTMLInputElement;
const list = document.getElementById('search-results')!;
type PagefindLike = {
search: (q: string) => Promise<{ results: { data: () => Promise<{ url: string; excerpt: string; meta: { title: string } }> }[] }>;
};
let pf: PagefindLike | null = null;
let loadStarted = false;
async function ensureIndex() {
if (loadStarted) return;
loadStarted = true;
pf = (await import(/* @vite-ignore */ '/pagefind/pagefind.js')) as unknown as PagefindLike;
}
function open() {
dlg.showModal();
input.value = '';
list.innerHTML = '';
void ensureIndex();
input.focus();
}
document.querySelector('[data-search-trigger]')?.addEventListener('click', open);
document.addEventListener('keydown', (e) => {
const inField = document.activeElement instanceof HTMLElement &&
(document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA');
if (e.key === '/' && !inField) { e.preventDefault(); open(); }
});
let timer: number | undefined;
input.addEventListener('input', () => {
clearTimeout(timer);
timer = window.setTimeout(async () => {
if (!pf) return;
const q = input.value.trim();
if (!q) { list.innerHTML = ''; return; }
const res = await pf.search(q);
list.innerHTML = '';
for (const r of res.results.slice(0, 8)) {
const d = await r.data();
const li = document.createElement('li');
const a = document.createElement('a');
a.href = d.url;
a.className = 'tap';
a.innerHTML = `<strong>${d.meta.title}</strong><span>${d.excerpt}</span>`;
li.appendChild(a);
list.appendChild(li);
}
if (!list.children.length) list.innerHTML = '<li class="empty">无结果</li>';
}, 200);
});
</script>
<style>
.search-dialog {
margin-top: 12vh;
width: min(560px, calc(100vw - 32px));
border: 1px solid var(--line);
border-radius: 10px;
background: var(--bg);
color: var(--ink-2);
padding: var(--space-3);
}
.search-dialog::backdrop { background: rgba(0, 0, 0, 0.45); }
input[type='search'] {
width: 100%;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--line);
border-radius: 8px;
background: var(--bg-soft);
font-size: 15px;
}
.results { list-style: none; padding: 0; margin: var(--space-2) 0 0; max-height: 50vh; overflow-y: auto; }
.results a {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--space-2) var(--space-3);
border-radius: 8px;
text-decoration: none;
}
.results a:hover { background: var(--bg-soft); }
.results strong { color: var(--ink-1); font-size: 14px; }
.results span { font-size: 12px; color: var(--ink-3); }
.results a span :global(mark) { background: var(--accent-soft); }
.empty { padding: var(--space-3); font-size: 13px; color: var(--ink-3); }
.search-hint { margin-top: var(--space-2); font-size: 11px; color: var(--ink-4); text-align: center; font-family: var(--font-mono); }
</style>
+14
View File
@@ -0,0 +1,14 @@
---
// 明暗不闪烁脚本:<head> 内联、先于首帧。
// 优先级:用户手选(localStorage) > 系统偏好;切换按钮(T7 键盘/组件)写同一 key。
---
<script is:inline>
(function () {
var saved = localStorage.getItem('theme');
var theme = saved === 'light' || saved === 'dark'
? saved
: (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.dataset.theme = theme;
})();
</script>
+13
View File
@@ -0,0 +1,13 @@
---
// 构建透明(极客层 P0):页脚 built-from 行,hash 链到自家 gitea commit
const sha = import.meta.env.GIT_COMMIT_SHA as string;
const date = import.meta.env.GIT_COMMIT_DATE as string;
const short = sha === 'dev' ? 'dev' : sha.slice(0, 7);
const commitUrl = `https://git.wait4x.online/wait4x/blog/commit/${sha}`;
---
<p class="build-info">
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>
+98
View File
@@ -0,0 +1,98 @@
---
// 键盘层(极客层 P0):? 帮助 / t 主题 / g-chords 导航
// 惯例来源:GitHub 键位(g+N、? 帮助);每项能力都有可视入口(触屏等价物原则)
const routes: Record<string, string> = {
h: '/',
a: '/archives/',
g: '/gallery/',
m: '/moments/',
b: '/about/',
};
---
<dialog id="kbd-help" class="kbd-help" data-routes={JSON.stringify(routes)}>
<h2>键盘快捷键</h2>
<table>
<tbody>
<tr><td><kbd>t</kbd></td><td>切换明暗</td></tr>
<tr><td><kbd>?</kbd></td><td>本帮助</td></tr>
<tr><td><kbd>g</kbd> <kbd>h</kbd></td><td>首页</td></tr>
<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>Esc</kbd></td><td>关闭浮层</td></tr>
</tbody>
</table>
<p class="hint">提示:试试终端彩蛋(Phase 4 上线 <code>~</code>)</p>
</dialog>
<script>
// chord 缓冲 300ms;输入域聚焦时整体旁路;Esc 关一切浮层
// 路由表由 data-routes 注入(module 脚本里 currentScript 恒为 null,不能走内联取参)
const helpEl = document.getElementById('kbd-help') as HTMLElement & { dataset: { routes?: string } };
const routes: Record<string, string> = JSON.parse(helpEl?.dataset.routes ?? '{}');
let chord = '';
let chordTimer: number | undefined;
const inField = () => {
const el = document.activeElement;
return el instanceof HTMLElement &&
(el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable);
};
const toggleTheme = () => {
const root = document.documentElement;
const next = root.dataset.theme === 'dark' ? 'light' : 'dark';
root.dataset.theme = next;
localStorage.setItem('theme', next);
};
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
document.querySelector('dialog[open]')?.close();
return;
}
if (inField() || e.metaKey || e.ctrlKey || e.altKey) return;
if (e.key === 't') { toggleTheme(); return; }
if (e.key === '?') {
(document.getElementById('kbd-help') as HTMLDialogElement | null)?.showModal();
return;
}
if (e.key === 'g') {
chord = 'g';
clearTimeout(chordTimer);
chordTimer = window.setTimeout(() => (chord = ''), 300);
return;
}
if (chord === 'g' && routes[e.key]) {
location.href = routes[e.key]!;
chord = '';
}
});
</script>
<style>
.kbd-help {
border: 1px solid var(--line);
border-radius: 10px;
background: var(--bg);
color: var(--ink-2);
padding: var(--space-4);
max-width: 340px;
}
.kbd-help::backdrop { background: rgba(0, 0, 0, 0.4); }
.kbd-help h2 { font-size: var(--text-h3); color: var(--ink-1); margin-bottom: var(--space-3); }
table { border-collapse: collapse; }
td { padding: 4px 10px 4px 0; font-size: 14px; }
kbd {
font-family: var(--font-mono);
font-size: 12px;
border: 1px solid var(--line);
border-bottom-width: 2px;
border-radius: 4px;
padding: 1px 6px;
background: var(--bg-soft);
}
.hint { margin-top: var(--space-3); font-size: 12px; color: var(--ink-3); }
</style>
+71
View File
@@ -0,0 +1,71 @@
---
// 开源项目墙(极客层 P0):构建时渲染,零运行时 JS
// 双源数据见 src/lib/projects.ts;语言色点用 linguist 常用色映射
import { loadProjects, type RepoCard } from '../../lib/projects';
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 {
projects = await loadProjects();
} catch {
projects = []; // 兜底已在 lib 内处理,此处再守一层:空墙不破构建
}
---
<section class="project-wall" aria-label="开源项目">
<h2 class="wall-title">开源 · Projects</h2>
<ul>
{projects.map((p) => (
<li>
<a href={p.url} class="repo tap" rel="noopener noreferrer">
<span class="repo-name">
{p.source === 'gitea' ? '自托管' : 'GitHub'} / {p.name}
</span>
<span class="repo-desc">{p.description}</span>
<span class="repo-meta">
{p.language && (
<span class="lang">
<span class="dot" style={`background:${LANG_COLORS[p.language] ?? '#8b8f98'}`} />
{p.language}
</span>
)}
<span class="stars">★ {p.stars}</span>
</span>
</a>
</li>
))}
</ul>
</section>
<style>
.project-wall { margin-top: var(--space-6); }
.wall-title {
font-size: 14px;
font-family: var(--font-mono);
color: var(--ink-3);
letter-spacing: 0.08em;
margin-bottom: var(--space-3);
}
ul { list-style: none; padding: 0; display: grid; gap: var(--space-2); }
.repo {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--space-3);
border: 1px solid var(--line);
border-radius: 8px;
transition: border-color var(--dur) var(--ease);
min-height: 44px;
}
.repo:hover { border-color: var(--accent); }
.repo-name { font-family: var(--font-mono); font-size: 14px; color: var(--ink-1); }
.repo-desc { font-size: 13px; color: var(--ink-3); }
.repo-meta { display: flex; gap: var(--space-3); font-size: 12px; color: var(--ink-3); margin-top: 4px; }
.lang { display: inline-flex; align-items: center; gap: 4px; }
.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
</style>
+94
View File
@@ -0,0 +1,94 @@
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
import { photosWithExifLoader, type PhotoExif } from './lib/exif';
/**
* posts 的 track(首页分轨标签)由 category 派生,不接受 frontmatter 直写,
* 单一事实源在此映射,消费端用 trackOf(entry.data.category)。
*/
export const POST_TRACKS = {
reading: '阅读',
tech: '技术',
photo: '照片',
} as const;
export type PostCategory = keyof typeof POST_TRACKS;
export type PostTrack = (typeof POST_TRACKS)[PostCategory];
export function trackOf(category: PostCategory): PostTrack {
return POST_TRACKS[category];
}
const postsSchema = z.object({
title: z.string(),
pubDate: z.coerce.date(),
category: z.enum(['reading', 'tech', 'photo']),
tags: z.array(z.string()),
/** 情绪色,仅书评轨使用;#RRGGBB */
mood: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional(),
draft: z.boolean().optional(),
});
// 与 src/lib/exif.ts 的 PhotoExif 对齐;loader 已保证构建期补全,frontmatter 只需部分覆盖
const photoExifSchema = z.object({
make: z.string().optional(),
model: z.string().optional(),
lens: z.string().optional(),
fNumber: z.number().optional(),
exposureTime: z.number().optional(),
iso: z.number().optional(),
focalLength: z.number().optional(),
dateTimeOriginal: z.string().optional(),
});
const photosFields = {
title: z.string(),
group: z.enum(['street', 'landscape']),
alt: z.string().optional(),
exif: photoExifSchema.optional(),
};
// 编译期双向断言:photoExifSchema 与 PhotoExif 漂移时在此报错,防止两处形状各自演化
type _PhotoExifOut = z.infer<typeof photoExifSchema>;
const _exifShapeOk: _PhotoExifOut extends PhotoExif
? PhotoExif extends _PhotoExifOut
? true
: never
: never = true;
export const collections = {
// slug 即文件名(glob loader 默认按文件名生成 id)
posts: defineCollection({
loader: glob({ pattern: '**/*.md', base: './content/posts' }),
schema: postsSchema,
}),
// image 字段必须走函数式 schema 的 image() helper:zod/v4 的 z 上没有 .image(),
// 相对路径由 Astro 传入的 helper 按 entry 文件位置解析
moments: defineCollection({
loader: glob({ pattern: '**/*.md', base: './content/moments' }),
schema: ({ image }) =>
z
.object({
pubDate: z.coerce.date(),
tags: z.array(z.string()),
text: z.string().optional(),
image: image().optional(),
})
// 瞬间的存在性约束:一句话、一张图至少有其一
.refine((moment) => moment.text !== undefined || moment.image !== undefined, {
message: 'text 与 image 至少提供一项',
}),
}),
// photos 的 exif 字段在加载期由 exif 车道补全,schema 只声明形状
photos: defineCollection({
loader: photosWithExifLoader(),
schema: ({ image }) =>
z.object({
...photosFields,
/** 相对本条 JSON 文件的图片路径 */
image: image(),
}),
}),
};
export type PostData = z.infer<typeof postsSchema>;
+11
View File
@@ -0,0 +1,11 @@
[
{
"source": "gitea",
"name": "blog",
"description": "本站源码:Astro SSG + 双写作流,内容即代码",
"language": "Astro",
"stars": 0,
"url": "https://git.wait4x.online/wait4x/blog",
"updatedAt": ""
}
]
+53
View File
@@ -0,0 +1,53 @@
---
// 基础布局:字体/样式统一在此持有(页面不再各自引包)
// 明暗不闪烁:ThemeScript 必须在 <head> 内联先于首帧执行
import 'lxgw-wenkai-screen-webfont/lxgwwenkaiscreen.css';
import '@fontsource/noto-sans-sc/400.css';
import '@fontsource/noto-sans-sc/500.css';
import '@fontsource/noto-sans-sc/700.css';
import '@fontsource/jetbrains-mono/400.css';
import '@fontsource/jetbrains-mono/700.css';
import '../styles/global.css';
import Header from '../components/Header.astro';
import Footer from '../components/Footer.astro';
import ThemeScript from '../components/ThemeScript.astro';
import Keyboard from '../components/geek/Keyboard.astro';
import SearchDialog from '../components/SearchDialog.astro';
import BuildInfo from '../components/geek/BuildInfo.astro';
import { ClientRouter } from 'astro:transitions';
interface Props {
title: string;
description?: string;
/** 正文轨:reading 书评纸墨 / tech 技术(body 级字体氛围) */
track?: 'reading' | 'tech';
}
const { title, description = 'wait4x 的博客:阅读 · 技术 · 摄影', track = 'tech' } = Astro.props;
---
<!doctype html>
<html lang="zh" data-track={track}>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="generator" content={Astro.generator} />
<meta name="description" content={description} />
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#191d23" media="(prefers-color-scheme: dark)" />
<title>{title}</title>
<ThemeScript />
<ClientRouter />
</head>
<body>
<a class="skip-link" href="#main">跳到正文</a>
<Header />
<main id="main" class="container">
<slot />
</main>
<Footer>
<BuildInfo slot="build-info" />
</Footer>
<Keyboard />
<SearchDialog />
</body>
</html>
+120
View File
@@ -0,0 +1,120 @@
import { readFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { glob } from 'astro/loaders';
import type { Loader } from 'astro/loaders';
// exifr 的 Node 出口是 UMD/CJS(package.json 无 exports,main 指向 full.umd.js),
// 其上没有 `exifr` 命名导出,具名导入在裸 Node(cjs-module-lexer 不识别 UMD 包装)下会炸;
// default 导入在 Vite SSR interop 与裸 Node 两种语境都拿到同一 namespace 对象。
import exifr from 'exifr';
/**
* 照片 EXIF 的统一形状,photos 集合的 `exif` 字段与灯箱/卡片展示都消费它。
* 单位约定:exposureTime 为秒(1/500s → 0.002)、focalLength 为毫米、
* dateTimeOriginal 为 ISO 8601 字符串;任何字段缺失即为 undefined(空值容错)。
*/
export interface PhotoExif {
make?: string;
model?: string;
lens?: string;
fNumber?: number;
exposureTime?: number;
iso?: number;
focalLength?: number;
dateTimeOriginal?: string;
}
/** exifr.parse 对无 EXIF 的图返回 undefined;有 EXIF 时字段也可能残缺,故值全部按 unknown 取 */
type ExifRaw = Record<string, unknown> | undefined;
function firstString(raw: ExifRaw, keys: string[]): string | undefined {
for (const key of keys) {
const value = raw?.[key];
if (typeof value === 'string' && value.length > 0) return value;
}
return undefined;
}
function firstNumber(raw: ExifRaw, keys: string[]): number | undefined {
for (const key of keys) {
const value = raw?.[key];
if (typeof value === 'number' && Number.isFinite(value)) return value;
}
return undefined;
}
/** exifr 原始 tag 输出 → PhotoExif;大小写两种键名都兼容(translateKeys 行为随版本/配置浮动) */
export function mapExifTags(raw: ExifRaw): PhotoExif {
const dto = raw?.DateTimeOriginal ?? raw?.dateTimeOriginal;
return {
make: firstString(raw, ['Make', 'make']),
model: firstString(raw, ['Model', 'model']),
lens: firstString(raw, ['LensModel', 'lensModel']),
fNumber: firstNumber(raw, ['FNumber', 'fNumber']),
exposureTime: firstNumber(raw, ['ExposureTime', 'exposureTime']),
iso: firstNumber(raw, ['ISO', 'iso']),
focalLength: firstNumber(raw, ['FocalLength', 'focalLength']),
dateTimeOriginal:
dto instanceof Date ? dto.toISOString() : typeof dto === 'string' ? dto : undefined,
};
}
/**
* 从图片文件提取 EXIF(接受文件路径 / URL / 字节)。
* 无 EXIF、损坏文件、读取失败一律返回空对象——单张图的意外不许炸掉整次构建。
* 坑:exifr 的 Input 白名单不含 URL 对象(抛 Invalid input argument),
* 必须先归一成文件路径字符串。
*/
export async function extractPhotoExif(input: string | URL | Uint8Array): Promise<PhotoExif> {
try {
const source = input instanceof URL ? fileURLToPath(input) : input;
return mapExifTags(await exifr.parse(source, { tiff: true, exif: true }));
} catch {
return {};
}
}
/**
* 构建期补全的覆盖语义:frontmatter 写过的字段优先,缺口由图片 EXIF 填补。
* overrides 为 undefined / 空对象时等于纯提取。
*/
export async function resolvePhotoExif(
overrides: Partial<PhotoExif> | undefined,
imageInput: string | URL | Uint8Array,
): Promise<PhotoExif> {
return { ...(await extractPhotoExif(imageInput)), ...overrides };
}
/**
* photos 集合加载器 = glob(JSON) + EXIF 构建期补全。
* entry.data.image 经 z.image() 校验后已是 ImageMetadata(不含源文件路径),
* 因此从 JSON 原文重读 `image` 字符串,按“相对 JSON 文件”定位源图交给 exifr。
*
* 坑:dev 模式下单文件变更由内层 glob loader 的 watcher 增量处理,会绕过本包装层,
* 改图或改 JSON 后需重启 dev server 才能补全 EXIF;SSG 构建每次全量 sync,无此问题。
*/
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 } });
}),
);
},
};
}
+86
View File
@@ -0,0 +1,86 @@
// 项目墙数据(极客层 P0):构建时双源拉取 + 仓内缓存兜底
// 双源 = 自家 gitea(数据自控叙事)+ GitHub(社区 star 真值);置顶清单人工维护
export interface RepoCard {
source: 'gitea' | 'github';
name: string;
description: string;
language: string | null;
stars: number;
url: string;
updatedAt: string;
}
interface RawRepo {
full_name?: string;
name?: string;
description?: string | null;
language?: string | null;
stars_count?: number;
stargazers_count?: number;
html_url?: string;
updated_at?: string;
}
export const GITEA_API = 'https://git.wait4x.online/api/v1/users/wait4x/repos?limit=50';
export const GITHUB_API = 'https://api.github.com/users/wait4xx/repos?per_page=100&sort=updated';
/** 置顶清单:跨源手选,图钉顺序即展示顺序(name = 仓库短名) */
export const PINNED: string[] = ['blog'];
async function fetchJson(url: string): Promise<RawRepo[]> {
const res = await fetch(url, {
headers: { Accept: 'application/json', 'User-Agent': 'wait4x-blog-build' },
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) throw new Error(`${url} -> ${res.status}`);
return (await res.json()) as RawRepo[];
}
function toCard(r: RawRepo, source: RepoCard['source']): RepoCard | null {
const name = r.name ?? r.full_name?.split('/')[1];
if (!name || !r.html_url) return null;
return {
source,
name,
description: r.description ?? '',
language: r.language ?? null,
stars: r.stars_count ?? r.stargazers_count ?? 0,
url: r.html_url,
updatedAt: r.updated_at ?? '',
};
}
/**
* 拉双源合并去重(同名优先 gitea 自托管叙事,star 取 GitHub 真值)。
* 任一源失败不抛:置顶项回落仓内缓存 JSON(构建产物绝不被网络单点卡死)。
*/
export async function loadProjects(): Promise<RepoCard[]> {
const [gitea, github] = await Promise.allSettled([fetchJson(GITEA_API), fetchJson(GITHUB_API)]);
const byName = new Map<string, RepoCard>();
for (const r of gitea.status === 'fulfilled' ? gitea.value : []) {
const c = toCard(r, 'gitea');
if (c) byName.set(c.name, c);
}
for (const r of github.status === 'fulfilled' ? github.value : []) {
const c = toCard(r, 'github');
if (!c) continue;
const existing = byName.get(c.name);
if (!existing) byName.set(c.name, c);
else if (c.stars > existing.stars) existing.stars = c.stars; // 社区真值覆盖镜像计数
}
if (byName.size === 0) {
const fb = (await import('../data/projects-fallback.json')).default as RepoCard[];
return orderPinned(fb);
}
return orderPinned([...byName.values()]);
}
function orderPinned(list: RepoCard[]): RepoCard[] {
return [...list].sort((a, b) => {
const pa = PINNED.indexOf(a.name), pb = PINNED.indexOf(b.name);
if (pa !== -1 || pb !== -1) return (pa === -1 ? 99 : pa) - (pb === -1 ? 99 : pb);
return b.stars - a.stars;
}).slice(0, 6);
}
+118
View File
@@ -0,0 +1,118 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import IdCard from '../components/IdCard.astro';
---
<BaseLayout title="关于 · wait4x">
<h1 class="page-title">关于</h1>
<IdCard />
<section class="tracks" aria-label="兴趣版图">
<h2 class="sec-title">兴趣版图</h2>
<ul>
<li class="track-row">
<span class="track-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 4h6a4 4 0 0 1 4 4v13a3 3 0 0 0-3-3H2z"></path>
<path d="M22 4h-6a4 4 0 0 0-4 4v13a3 3 0 0 1 3-3h7z"></path>
</svg>
</span>
<div>
<strong>阅读</strong>
<p>文学与戏剧为主,读到哪写到哪。</p>
</div>
</li>
<li class="track-row">
<span class="track-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="m4 5.5 6.5 6.5L4 18.5"></path>
<path d="M12.5 18.5H20"></path>
</svg>
</span>
<div>
<strong>技术</strong>
<p>Linux、容器与自托管,把家里的小机器折腾成顺手的工具。</p>
</div>
</li>
<li class="track-row">
<span class="track-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"></path>
<circle cx="12" cy="13" r="4"></circle>
</svg>
</span>
<div>
<strong>摄影</strong>
<p>街拍与山野,随身一台无反,在等光的间隙按快门。</p>
</div>
</li>
</ul>
</section>
<section class="build" aria-label="本站构建说明">
<h2 class="sec-title">本站如何构建</h2>
<p>
页面由 Astro 静态生成,不含运行时框架 JS;内容住在自托管 gitea——本地编辑器与 gitea
网页写的是同一个 git 仓库,push 之后由 homelab 自动构建发布;评论走自托管
Artalk。页脚 built from 一行链到本次构建对应的 commit,构建过程透明可追溯。
</p>
</section>
</BaseLayout>
<style>
.page-title {
font-size: var(--text-h1);
line-height: var(--leading-heading);
color: var(--ink-1);
margin-top: var(--space-5);
text-wrap: balance;
}
.sec-title {
font-size: 14px;
font-family: var(--font-mono);
color: var(--ink-3);
letter-spacing: 0.08em;
margin: var(--space-5) 0 var(--space-3);
}
.tracks ul {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: var(--space-2);
}
.track-row {
display: flex;
align-items: flex-start;
gap: var(--space-3);
border: 1px solid var(--line);
border-radius: 10px;
padding: var(--space-3) var(--space-4);
}
.track-icon {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
background: var(--bg-soft);
color: var(--ink-3);
}
.track-row strong {
font-size: 15px;
color: var(--ink-1);
}
.track-row p {
font-size: 14px;
color: var(--ink-3);
margin-top: 2px;
}
.build p {
font-size: 15px;
color: var(--ink-2);
max-width: var(--measure);
}
</style>
+270
View File
@@ -0,0 +1,270 @@
---
import type { CollectionEntry } from 'astro:content';
import { getCollection, render } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import ArtalkComments from '../../components/ArtalkComments.astro';
import { trackOf } from '../../content.config';
// URL = /archives/{slug},slug 即文件名,与 Halo 旧结构 1:1(不可改)
export async function getStaticPaths() {
const posts = (await getCollection('posts', ({ data }) => !data.draft)).sort(
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
);
return posts.map((entry, i) => ({
params: { slug: entry.id },
props: { entry, prev: posts[i + 1] ?? null, next: posts[i - 1] ?? null },
}));
}
interface Props {
entry: CollectionEntry<'posts'>;
prev: CollectionEntry<'posts'> | null;
next: CollectionEntry<'posts'> | null;
}
const { entry, prev, next } = Astro.props;
const { Content, headings } = await render(entry);
const isReading = entry.data.category === 'reading';
const date = entry.data.pubDate.toISOString().slice(0, 10);
// 阅读时长:正文汉字数 / 400 向上取整(最低 1 分钟)
const hanzi = entry.body?.match(/[\u4e00-\u9fff]/g)?.length ?? 0;
const minutes = Math.max(1, Math.ceil(hanzi / 400));
const toc = headings.filter((h) => h.depth === 2 || h.depth === 3);
---
<BaseLayout title={`${entry.data.title} · wait4x`} track={isReading ? 'reading' : 'tech'}>
{isReading ? (
<article class="post post-reading">
<header class="post-head">
<p class="post-cat">Reading · {trackOf(entry.data.category)}</p>
<h1 class="post-title">{entry.data.title}</h1>
<p class="post-meta">
<i
class="emo"
style={`background:${entry.data.mood ?? 'var(--mood)'}`}
title="情绪色"
aria-hidden="true"
/>
<time datetime={entry.data.pubDate.toISOString()}>{date}</time>
<span aria-hidden="true">·</span>
<span>阅读时长 {minutes} 分钟</span>
</p>
</header>
<div class="prose-reading">
<Content />
</div>
</article>
) : (
<>
<div class="read-progress" id="read-progress" aria-hidden="true"></div>
<article class="post post-tech">
<header class="post-head">
<p class="post-cat">Tech · {trackOf(entry.data.category)}</p>
<h1 class="post-title">{entry.data.title}</h1>
<p class="post-meta">
{entry.data.tags.map((tag) => <code class="tag">{`#${tag}`}</code>)}
<span aria-hidden="true">·</span>
<time datetime={entry.data.pubDate.toISOString()}>{date}</time>
<span aria-hidden="true">·</span>
<span>阅读时长 {minutes} 分钟</span>
</p>
</header>
<div class="tech-grid">
<div class="prose-tech">
<Content />
</div>
{toc.length > 0 && (
<aside class="toc" aria-label="目录">
<p class="toc-h">目录</p>
{toc.map((h) => (
<a class={h.depth === 3 ? 'toc-sub' : undefined} href={`#${h.slug}`}>{h.text}</a>
))}
</aside>
)}
</div>
</article>
</>
)}
<nav class="pn" aria-label="上一篇下一篇">
{prev ? (
<a class="pn-item" href={`/archives/${prev.id}/`}>
<span>← 上一篇</span>
<strong>{prev.data.title}</strong>
</a>
) : (
<span class="pn-item" aria-hidden="true"></span>
)}
{next ? (
<a class="pn-item right" href={`/archives/${next.id}/`}>
<span>下一篇 →</span>
<strong>{next.data.title}</strong>
</a>
) : (
<span class="pn-item right" aria-hidden="true"></span>
)}
</nav>
<ArtalkComments pageKey={`/archives/${entry.id}/`} pageTitle={entry.data.title} />
</BaseLayout>
<script>
// 阅读进度条(技术轨):ClientRouter 下模块脚本只执行一次,
// 单例滚动监听 + astro:page-load 重绘,条不在当前页时零开销;
// prefers-reduced-motion 时整条不初始化(CSS 层同步隐藏)
const paint = () => {
const bar = document.getElementById('read-progress');
if (!bar) return;
const el = document.documentElement;
const total = el.scrollHeight - el.clientHeight;
bar.style.transform = `scaleX(${total > 0 ? el.scrollTop / total : 0})`;
};
if (!matchMedia('(prefers-reduced-motion: reduce)').matches) {
addEventListener('scroll', paint, { passive: true });
document.addEventListener('astro:page-load', paint);
}
</script>
<style>
/* 版心放行:技术轨带 TOC 时加宽到 measure+200(丙路线 §3;BaseLayout 默认 720) */
@media (min-width: 1120px) {
:global(main#main:has(.post-tech)) {
max-width: calc(var(--measure) + 200px);
}
}
.read-progress {
position: fixed;
top: 0;
left: 0;
z-index: 20;
height: 2px;
width: 100%;
background: var(--accent);
transform: scaleX(0);
transform-origin: 0 0;
}
@media (prefers-reduced-motion: reduce) {
.read-progress { display: none; }
}
.post { margin-top: var(--space-5); }
.post-cat {
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--ink-4);
}
.post-title {
font-size: var(--text-h1);
line-height: var(--leading-heading);
color: var(--ink-1);
margin-top: var(--space-2);
text-wrap: balance;
}
.post-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2);
margin-top: var(--space-3);
font-size: 14px;
color: var(--ink-3);
}
.emo {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 3px;
flex: none;
}
.tag {
font-family: var(--font-mono);
font-size: 12px;
color: var(--ink-3);
background: var(--bg-soft);
border-radius: 5px;
padding: 1px 7px;
white-space: nowrap;
}
/* 书评轨:Content 渲染的 blockquote 打不了 .quote-block 类,此处镜像同一引文形状 */
.prose-reading { margin-top: var(--space-4); }
.prose-reading :global(blockquote) {
border-left: 2px solid var(--paper-line);
padding-left: 1.35em;
margin-block: 1.2em;
color: color-mix(in oklab, var(--paper-ink) 72%, transparent);
}
/* 技术轨:TOC 侧栏仅 ≥1120 常驻,窄屏隐藏 */
.tech-grid { margin-top: var(--space-4); }
.toc { display: none; }
@media (min-width: 1120px) {
.tech-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 176px;
gap: var(--space-5);
align-items: start;
}
.toc {
display: block;
position: sticky;
top: 80px;
max-height: calc(100dvh - 120px);
overflow: auto;
font-size: 13px;
padding-left: var(--space-3);
border-left: 1px solid var(--line);
}
}
.toc-h {
font-weight: 600;
color: var(--ink-1);
margin-bottom: var(--space-2);
}
.toc a {
display: block;
padding: var(--space-1) 0;
color: var(--ink-3);
transition: color var(--dur) var(--ease);
}
.toc a:hover { color: var(--accent); }
.toc .toc-sub { padding-left: var(--space-3); }
.prose-tech :global(h2),
.prose-tech :global(h3),
.prose-reading :global(h2),
.prose-reading :global(h3) {
scroll-margin-top: 80px;
}
.pn {
display: flex;
justify-content: space-between;
gap: var(--space-4);
margin-top: var(--space-5);
}
.pn-item {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
padding: var(--space-1) var(--space-2);
}
.pn-item.right {
text-align: right;
align-items: flex-end;
}
.pn-item span { font-size: 12px; color: var(--ink-4); }
.pn-item strong {
font-size: 15px;
font-weight: 600;
color: var(--ink-1);
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: color var(--dur) var(--ease);
}
.pn-item:hover strong { color: var(--accent); }
#artalk-container { margin-top: var(--space-5); }
</style>
+31
View File
@@ -0,0 +1,31 @@
---
// 导航「文章」落点:全量归档列表(年份/轨道分节逻辑与首页共用 PostList)
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import PostList from '../../components/PostList.astro';
const posts = (await getCollection('posts', ({ data }) => !data.draft)).sort(
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
);
---
<BaseLayout title="文章 · wait4x">
<h1 class="page-title">文章</h1>
<p class="page-note">按年份归档 · 轨道分节 · 共 {posts.length} 篇</p>
<PostList {posts} />
</BaseLayout>
<style>
.page-title {
font-size: var(--text-h1);
line-height: var(--leading-heading);
color: var(--ink-1);
margin-top: var(--space-5);
text-wrap: balance;
}
.page-note {
font-size: 13px;
color: var(--ink-4);
margin-block: var(--space-1) var(--space-4);
}
</style>
+357
View File
@@ -0,0 +1,357 @@
---
import { Image, getImage } from 'astro:assets';
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import type { PhotoExif } from '../../lib/exif';
const photos = await getCollection('photos');
// EXIF 参数条(机身/镜头/光圈/快门/ISO)构建期拼好整串,灯箱运行时只做文本替换
function formatExif(exif: PhotoExif | undefined): string {
if (!exif) return '';
const shutter =
exif.exposureTime === undefined
? undefined
: exif.exposureTime >= 1
? `${exif.exposureTime}s`
: `1/${Math.round(1 / exif.exposureTime)}s`;
return [
[exif.make, exif.model].filter(Boolean).join(' ') || undefined,
exif.lens,
exif.fNumber === undefined ? undefined : `f/${exif.fNumber}`,
shutter,
exif.iso === undefined ? undefined : `ISO ${exif.iso}`,
]
.filter(Boolean)
.join(' · ');
}
/* masonry:行高/列距用容器宽百分比(0.92cqw/1.62cqw,在 864px 版心即 8px/14px 档),
行高随断点等比缩放,构建期一份 row-span 在 2/3 列全断点精确成立——
px 定值行高会让格高随列宽漂移,窄屏裁切失控 */
const ROW = 0.0092;
const GAP = 0.0162;
const spanFor = (w: number, h: number, cols: number) =>
Math.round(((1 - GAP * (cols - 1)) / (cols * (w / h)) + GAP) / (ROW + GAP));
const cards = await Promise.all(
photos.map(async (photo) => {
// 宽高比来源:photos loader 产出 ImageMetadata 的 width/height(orientation 已修正)
const w = photo.data.image.width ?? 4;
const h = photo.data.image.height ?? 3;
const full = await getImage({ src: photo.data.image, width: 1440 });
return {
group: photo.data.group,
title: photo.data.title,
alt: photo.data.alt ?? photo.data.title,
image: photo.data.image,
exif: formatExif(photo.data.exif),
fullSrc: full.src,
w,
h,
s3: spanFor(w, h, 3),
s2: spanFor(w, h, 2),
};
}),
);
const first = cards[0];
---
<BaseLayout title="图库 · wait4x">
<h1 class="page-title">图库</h1>
<div class="g-main">
<fieldset class="filters">
<legend class="sr-only">分组筛选</legend>
<input class="f-input" type="radio" name="gallery-group" id="f-all" checked />
<label class="tap chip" for="f-all">全部</label>
<input class="f-input" type="radio" name="gallery-group" id="f-street" />
<label class="tap chip" for="f-street">街拍</label>
<input class="f-input" type="radio" name="gallery-group" id="f-landscape" />
<label class="tap chip" for="f-landscape">风景</label>
</fieldset>
<div class="stage">
<ul class="wall">
{cards.map((card) => (
<li
class="shot"
data-group={card.group}
style={`aspect-ratio:${card.w}/${card.h}; --s3:${card.s3}; --s2:${card.s2}`}
>
<button
type="button"
class="shot-btn"
data-title={card.title}
data-exif={card.exif}
data-full={card.fullSrc}
aria-label={`查看大图:${card.title}${card.exif ? `(${card.exif})` : ''}`}
>
<Image
src={card.image}
alt={card.alt}
widths={[320, 560, 720]}
sizes="(min-width: 820px) 30vw, 46vw"
loading="lazy"
/>
</button>
</li>
))}
</ul>
</div>
<p class="g-note">共 {cards.length} 张 · EXIF 构建期自动提取</p>
</div>
<dialog id="lb" aria-label="照片查看">
<button id="lb-close" class="tap lbnav" type="button" aria-label="关闭(Esc)">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<path d="m5 5 14 14M19 5 5 19"></path>
</svg>
</button>
<div class="lb-figure">
<img id="lb-img" src={first?.fullSrc} alt={first?.alt ?? ''} />
</div>
<div class="lb-bottom">
<button id="lb-prev" class="tap lbnav" type="button" aria-label="上一张(←)">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m14.5 5.5-6.5 6.5 6.5 6.5"></path>
</svg>
</button>
<div class="lb-meta">
<strong id="lb-title">{first?.title}</strong>
<span id="lb-exif">{first?.exif}</span>
</div>
<button id="lb-next" class="tap lbnav" type="button" aria-label="下一张(→)">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m9.5 5.5 6.5 6.5-6.5 6.5"></path>
</svg>
</button>
</div>
</dialog>
</BaseLayout>
<script>
// 灯箱:原生 dialog,←→ 切换、Esc 原生关闭;触屏快滑翻页是键盘的等价物。
// ClientRouter 下模块脚本只执行一次,接线挂在 astro:page-load 按当前 DOM 重装
document.addEventListener('astro:page-load', () => {
const lb = document.getElementById('lb') as HTMLDialogElement | null;
const shots = [...document.querySelectorAll<HTMLButtonElement>('.shot-btn')];
if (!lb || shots.length === 0) return;
const img = document.getElementById('lb-img') as HTMLImageElement;
const titleEl = document.getElementById('lb-title') as HTMLElement;
const exifEl = document.getElementById('lb-exif') as HTMLElement;
const reduced = matchMedia('(prefers-reduced-motion: reduce)');
let idx = 0;
const show = (i: number) => {
idx = (i + shots.length) % shots.length;
const btn = shots[idx]!;
img.src = btn.dataset.full ?? '';
img.alt = btn.ariaLabel ?? '';
titleEl.textContent = btn.dataset.title ?? '';
exifEl.textContent = btn.dataset.exif ?? '';
if (!reduced.matches) {
img.style.opacity = '0';
requestAnimationFrame(() => {
img.style.opacity = '1';
});
}
if (!lb.open) lb.showModal();
};
shots.forEach((btn, i) => btn.addEventListener('click', () => show(i)));
document.getElementById('lb-prev')?.addEventListener('click', () => show(idx - 1));
document.getElementById('lb-next')?.addEventListener('click', () => show(idx + 1));
document.getElementById('lb-close')?.addEventListener('click', () => lb.close());
lb.addEventListener('keydown', (e) => {
if (e.key === 'ArrowLeft') {
e.preventDefault();
show(idx - 1);
} else if (e.key === 'ArrowRight') {
e.preventDefault();
show(idx + 1);
}
});
let x0: number | null = null;
lb.addEventListener('touchstart', (e) => {
x0 = e.touches[0]!.clientX;
}, { passive: true });
lb.addEventListener('touchend', (e) => {
if (x0 === null) return;
const dx = e.changedTouches[0]!.clientX - x0;
if (Math.abs(dx) > 40) show(idx + (dx < 0 ? 1 : -1));
x0 = null;
}, { passive: true });
});
</script>
<style>
/* 版心放行:图库加宽到 measure+176(丙路线 §3;BaseLayout 默认 720) */
:global(main#main:has(.g-main)) {
max-width: calc(var(--measure) + 176px);
}
.page-title {
font-size: var(--text-h1);
line-height: var(--leading-heading);
color: var(--ink-1);
margin-top: var(--space-5);
text-wrap: balance;
}
/* 分组筛选:纯 CSS :checked + :has,radio 原生键盘可达(方向键切换) */
.filters {
border: none;
padding: 0;
margin: var(--space-3) 0 var(--space-4);
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.sr-only,
.f-input {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
clip-path: inset(50%);
overflow: hidden;
white-space: nowrap;
}
.chip {
padding-inline: 13px;
border: 1px solid var(--line);
border-radius: 999px;
font-size: 13px;
color: var(--ink-3);
cursor: pointer;
transition: color var(--dur) var(--ease), border-color var(--dur) var(--ease);
}
.chip:hover { color: var(--ink-1); border-color: var(--ink-4); }
.f-input:focus-visible + .chip { outline: 2px solid var(--accent); outline-offset: 2px; }
.f-input:checked + .chip { color: var(--ink-1); border-color: var(--ink-3); font-weight: 500; }
.g-main:has(#f-street:checked) .shot:not([data-group='street']) { display: none; }
.g-main:has(#f-landscape:checked) .shot:not([data-group='landscape']) { display: none; }
/* masonry:stage 是容器查询锚,行高/列距以 stage 宽为基(cqw) */
.stage { container-type: inline-size; }
.wall {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-auto-rows: 0.92cqw; /* 8px 档 @ 864px 版心 */
grid-auto-flow: dense;
gap: 1.62cqw; /* 14px 档 @ 864px 版心 */
}
@media (min-width: 820px) {
.wall { grid-template-columns: repeat(3, 1fr); }
}
.shot {
grid-row-end: span var(--s2, 10);
border-radius: 10px;
overflow: hidden;
}
@media (min-width: 820px) {
.shot { grid-row-end: span var(--s3, 12); }
}
.shot-btn {
display: block;
width: 100%;
height: 100%;
padding: 0;
border: none;
background: none;
cursor: zoom-in;
}
.shot-btn :global(img) {
width: 100%;
height: 100%;
object-fit: cover;
}
.g-note {
margin-top: var(--space-3);
font-size: 12.5px;
color: var(--ink-4);
}
/* 灯箱:全屏原生 dialog,高度用 dvh(规避 100vh 移动端地址栏陷阱) */
#lb {
inset: 0;
width: 100%;
height: 100dvh;
max-width: none;
max-height: none;
margin: 0;
border: none;
border-radius: 0;
background: transparent;
display: flex;
flex-direction: column;
padding: 0;
}
#lb::backdrop { background: rgba(8, 10, 14, 0.95); }
.lb-figure {
flex: 1;
min-height: 0;
display: grid;
place-items: center;
padding: var(--space-5) var(--space-4) var(--space-2);
}
#lb-img {
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
border-radius: 6px;
transition: opacity 160ms var(--ease);
}
.lb-bottom {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-4) var(--space-4);
color: var(--code-ink);
font-family: var(--font-mono);
font-size: 12.5px;
}
.lb-meta {
flex: 1;
min-width: 0;
text-align: center;
display: flex;
flex-direction: column;
gap: 2px;
}
.lb-meta strong {
color: #fff;
font-size: 13px;
font-family: var(--font-sans);
font-weight: 600;
}
.lb-meta span {
opacity: 0.82;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lbnav {
flex: none;
width: 44px;
height: 44px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.06);
color: #e6e9ee;
}
.lbnav:hover { background: rgba(255, 255, 255, 0.12); }
#lb-close {
position: absolute;
top: var(--space-3);
right: var(--space-3);
}
@media (max-width: 560px) {
.lb-bottom { flex-wrap: wrap; }
.lb-meta { order: 3; flex-basis: 100%; }
}
</style>
+143
View File
@@ -0,0 +1,143 @@
---
import { Image } from 'astro:assets';
import { getCollection } from 'astro:content';
import BaseLayout from '../layouts/BaseLayout.astro';
import IdCard from '../components/IdCard.astro';
import PostList from '../components/PostList.astro';
import ProjectWall from '../components/geek/ProjectWall.astro';
const posts = (await getCollection('posts', ({ data }) => !data.draft)).sort(
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
);
// 最新影像取集合前 4 张;照片时间序由照片管线(exif 车道)治理,此处只裁切
const photos = (await getCollection('photos')).slice(0, 4);
---
<BaseLayout title="wait4x">
<IdCard>
<div class="socials">
<a
class="tap social"
href="https://github.com/wait4xx"
rel="noopener noreferrer"
aria-label="GitHub"
title="GitHub"
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" aria-hidden="true">
<path
d="M12 2C6.48 2 2 6.58 2 12.25c0 4.53 2.87 8.37 6.84 9.73.5.1.68-.22.68-.49 0-.24-.01-.88-.01-1.73-2.78.62-3.37-1.37-3.37-1.37-.45-1.18-1.11-1.5-1.11-1.5-.91-.63.07-.62.07-.62 1 .07 1.53 1.06 1.53 1.06.89 1.57 2.34 1.12 2.91.85.09-.66.35-1.11.63-1.37-2.22-.26-4.56-1.14-4.56-5.07 0-1.12.39-2.03 1.03-2.75-.1-.26-.45-1.3.1-2.7 0 0 .84-.28 2.75 1.05a9.3 9.3 0 0 1 5 0c1.91-1.33 2.75-1.05 2.75-1.05.55 1.4.2 2.44.1 2.7.64.72 1.03 1.63 1.03 2.75 0 3.94-2.34 4.8-4.57 5.06.36.32.68.94.68 1.9 0 1.37-.01 2.47-.01 2.81 0 .27.18.6.69.49A10.26 10.26 0 0 0 22 12.25C22 6.58 17.52 2 12 2Z"
></path>
</svg>
</a>
<a class="tap social" href="/rss.xml" aria-label="RSS 订阅" title="RSS 订阅">
<svg
viewBox="0 0 24 24"
width="16"
height="16"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
aria-hidden="true"
>
<path d="M4 11.5a8.5 8.5 0 0 1 8.5 8.5M4 4.5a15.5 15.5 0 0 1 15.5 15.5"></path>
<circle cx="5.4" cy="18.6" r="1.5" fill="currentColor" stroke="none"></circle>
</svg>
</a>
<a class="tap social" href="mailto:admin@wait4x.online" aria-label="邮件" title="邮件">
<svg
viewBox="0 0 24 24"
width="16"
height="16"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<rect x="3" y="5.5" width="18" height="13" rx="2.5"></rect>
<path d="m4 7.5 8 5.5 8-5.5"></path>
</svg>
</a>
</div>
</IdCard>
<PostList {posts} />
<section class="latest" aria-label="最新影像">
<div class="latest-head">
<span class="lab">最新影像</span>
<a class="tap more" href="/gallery/">进入图库 →</a>
</div>
<div class="thumbs">
{photos.map((photo) => (
<a class="thumb" href="/gallery/" aria-label={`查看图库:${photo.data.title}`}>
<Image
src={photo.data.image}
alt={photo.data.alt ?? photo.data.title}
widths={[180, 360]}
sizes="(min-width: 820px) 20vw, 42vw"
/>
</a>
))}
</div>
</section>
<ProjectWall />
</BaseLayout>
<style>
.socials {
display: flex;
align-items: center;
gap: var(--space-2);
margin-top: var(--space-2);
}
.social {
color: var(--ink-3);
border-radius: 8px;
transition: color var(--dur) var(--ease);
}
.social:hover { color: var(--accent); }
.latest { margin-top: var(--space-6); }
.latest-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-3);
}
.lab {
font-size: 12.5px;
color: var(--ink-4);
letter-spacing: 0.08em;
}
.more {
font-size: 13px;
color: var(--accent);
padding-inline: var(--space-2);
transition: color var(--dur) var(--ease);
}
.more:hover { color: var(--ink-1); }
.thumbs {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-2);
margin-top: var(--space-3);
}
.thumb {
aspect-ratio: 1;
border-radius: 8px;
overflow: hidden;
}
.thumb :global(img) {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform var(--dur) var(--ease);
}
.thumb:hover :global(img) { transform: scale(1.03); }
@media (min-width: 820px) {
.thumbs { grid-template-columns: repeat(4, 1fr); }
}
</style>
+142
View File
@@ -0,0 +1,142 @@
---
import { Image } from 'astro:assets';
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
const moments = (await getCollection('moments')).sort(
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
);
const iso = (d: Date) => d.toISOString();
---
<BaseLayout title="瞬间 · wait4x">
<h1 class="page-title">瞬间</h1>
<p class="page-note">零碎的记录:随手拍、一句话感想、开源动态。</p>
<ol class="tl">
{moments.map((m) => (
<li class="tl-item">
<time class="tl-date" datetime={iso(m.data.pubDate)}>
{iso(m.data.pubDate).slice(0, 10)}
</time>
<div class="mom-card">
{m.data.image && (
<figure
class:list={[
'mom-fig',
(m.data.image.width ?? 0) < (m.data.image.height ?? 0) && 'vt',
]}
>
<Image
src={m.data.image}
alt={m.data.text ?? m.data.tags.join(' ')}
widths={[480, 840]}
sizes="(min-width: 760px) 420px, calc(100vw - 140px)"
/>
</figure>
)}
{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>
)}
</div>
</li>
))}
</ol>
</BaseLayout>
<style>
.page-title {
font-size: var(--text-h1);
line-height: var(--leading-heading);
color: var(--ink-1);
margin-top: var(--space-5);
text-wrap: balance;
}
.page-note {
font-size: 13px;
color: var(--ink-4);
margin-block: var(--space-1) var(--space-4);
}
.tl {
list-style: none;
margin: var(--space-4) 0 0;
padding: 0;
}
/* 时间线骨架:左 mono 日期 + 竖线 + --accent 节点 + 右侧内容卡 */
.tl-item {
position: relative;
margin: 0 0 var(--space-4) calc(var(--space-5) + var(--space-4));
padding-left: var(--space-4);
border-left: 1px solid var(--line);
}
.tl-item:last-child { margin-bottom: 0; }
.tl-item::before {
content: '';
position: absolute;
left: -5px;
top: 7px;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 0 3px var(--bg);
}
.tl-date {
position: absolute;
left: calc(-1 * (var(--space-5) + var(--space-4)));
top: 4px;
width: var(--space-5);
text-align: right;
font-family: var(--font-mono);
font-size: 12.5px;
color: var(--ink-4);
font-variant-numeric: tabular-nums;
}
.mom-card {
border: 1px solid var(--line);
border-radius: 12px;
padding: var(--space-3) var(--space-4);
background: var(--bg-soft);
}
.mom-card > p { font-size: 15px; color: var(--ink-2); }
.mom-fig {
border-radius: 8px;
overflow: hidden;
margin: 0 0 var(--space-2);
max-width: 420px;
}
.mom-fig.vt { max-width: 260px; }
.mom-fig :global(img) { width: 100%; height: auto; }
.mom-tags {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
margin-top: var(--space-2);
}
.tag {
font-family: var(--font-mono);
font-size: 12px;
color: var(--ink-3);
background: var(--bg);
border-radius: 5px;
padding: 1px 7px;
white-space: nowrap;
}
/* 窄屏:日期卡到卡片上方,竖线左移收窄 */
@media (max-width: 760px) {
.tl-item { margin-left: var(--space-3); }
.tl-date {
position: static;
display: block;
width: auto;
text-align: left;
margin-bottom: var(--space-1);
}
}
@media (max-width: 560px) {
.mom-card { padding: var(--space-2) var(--space-3); }
.mom-fig { max-width: 100%; }
}
</style>
+21
View File
@@ -0,0 +1,21 @@
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
import type { APIContext } from 'astro';
// RSS:posts 集合全量(draft 排除);Phase 3 起双语各出一路(/rss.xml 与 /en/rss.xml)
export async function GET(context: APIContext) {
const posts = (await getCollection('posts', ({ data }) => !data.draft))
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
return rss({
title: 'wait4x',
description: '阅读 · 技术 · 摄影',
site: context.site ?? 'https://wait4x.online',
items: posts.map((p) => ({
title: p.data.title,
pubDate: p.data.pubDate,
description: p.data.tags.join(', '),
link: `/archives/${p.id}/`,
})),
trailingSlash: true,
});
}
+162
View File
@@ -0,0 +1,162 @@
/* ============================================================
全局样式 —— 建立在 tokens.css 之上
约束:零运行时框架;动效全部包 prefers-reduced-motion
============================================================ */
@import "./tokens.css";
/* ---------- 轻量 reset ---------- */
*, *::before, *::after { box-sizing: border-box; }
* { margin: 0; }
html { -webkit-text-size-adjust: 100%; }
body {
font-family: var(--font-sans);
font-size: 16px;
line-height: 1.6;
color: var(--ink-2);
background: var(--bg);
}
img, svg, video { display: block; max-width: 100%; height: auto; }
input, button, textarea, select { font: inherit; color: inherit; }
/* 键盘焦点可见但克制 */
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* 跳过导航(无障碍动线) */
.skip-link {
position: absolute; left: -9999px; top: var(--space-2);
padding: var(--space-2) var(--space-3); background: var(--bg); z-index: 100;
}
.skip-link:focus { left: var(--space-2); }
/* ---------- 链接(全站唯一强调色纪律) ---------- */
a { color: inherit; text-decoration: none; }
.link, .prose a {
color: var(--accent);
text-decoration: underline;
text-decoration-color: color-mix(in oklab, var(--accent) 40%, transparent);
text-underline-offset: 0.2em;
transition: text-decoration-color var(--dur) var(--ease), color var(--dur) var(--ease);
}
.link:hover, .prose a:hover { text-decoration-color: var(--accent); }
/* ---------- 版心 ---------- */
.container { max-width: var(--measure); margin-inline: auto; padding-inline: var(--space-3); }
.container-wide { max-width: calc(var(--measure) + 200px); margin-inline: auto; padding-inline: var(--space-3); }
/* ============================================================
正文分轨(丙路线核心:书评纸墨 × 技术灰阶)
============================================================ */
/* ---- 书评轨:纸面板 + 衬线 + 20/1.75 ---- */
.prose-reading {
font-family: var(--font-serif);
font-size: var(--prose-size-reading);
line-height: var(--leading-reading);
color: var(--paper-ink);
background: var(--paper);
max-width: var(--measure);
padding: var(--space-5) var(--space-4);
border-block: 1px solid var(--paper-line);
overflow-wrap: break-word;
}
.prose-reading p + p { margin-top: 0.9em; } /* 段间距制,无首行缩进 */
.prose-reading h2, .prose-reading h3 { line-height: var(--leading-heading); text-wrap: balance; }
.prose-reading h2 { font-size: var(--text-h2); margin-top: 1.8em; }
.prose-reading h3 { font-size: var(--text-h3); margin-top: 1.5em; }
/* ---- 技术轨:无衬线 + 19/1.7 ---- */
.prose-tech {
font-family: var(--font-sans);
font-size: var(--prose-size-tech);
line-height: var(--leading-tech);
color: var(--ink-2);
max-width: var(--measure);
}
.prose-tech p + p { margin-top: 0.9em; }
.prose-tech h2 { font-size: var(--text-h2); margin-top: 1.8em; line-height: var(--leading-heading); text-wrap: balance; }
.prose-tech h3 { font-size: var(--text-h3); margin-top: 1.5em; line-height: var(--leading-heading); }
/* ---------- 引文组件(报告 §3:A 墨线长书摘 / D 金句) ---------- */
/* 裸 blockquote(markdown 原生输出无类名)默认即模式 A;显式类可覆盖 */
.prose-reading blockquote, blockquote.quote-block {
border-left: 1px solid var(--paper-line);
border-left-width: 2px;
padding-left: 1.35em;
margin-block: 1.2em;
color: color-mix(in oklab, var(--paper-ink) 72%, transparent);
font-size: 1em;
font-style: normal; /* 反例修正:higan 整段加粗弃用 */
font-weight: 400;
}
.quote-cite {
display: block;
margin-top: 0.6em;
font-size: 0.875rem;
font-family: var(--font-sans);
color: var(--ink-3);
}
/* 模式 D:金句(62px 巨型引号水印) */
.pull-quote {
position: relative;
margin-block: 1.6em;
padding-left: 2.2em;
font-size: 0.96em;
}
.pull-quote::before {
content: "\201C";
position: absolute; left: 0; top: -0.12em;
font-family: Georgia, serif;
font-size: 62px;
line-height: 1;
opacity: 0.18;
color: var(--mood);
}
/* ---------- 代码(终端风,亮暗同底) ---------- */
code, kbd, samp { font-family: var(--font-mono); }
:not(pre) > code {
font-size: 0.875em;
background: var(--bg-soft);
padding: 0.15em 0.4em;
border-radius: 4px;
border: 1px solid var(--line);
}
pre {
background: var(--code-bg);
color: var(--code-ink);
font-size: 16px;
line-height: 1.6;
padding: var(--space-3) var(--space-4);
border-radius: 8px;
overflow-x: auto; /* 禁 break-all:正文横向滚动绝不发生 */
tab-size: 2;
}
pre code { background: none; border: none; padding: 0; font-size: inherit; }
/* 终端窗点缀(圆点由组件层加,此处只管字色纪律) */
.term-dots { display: flex; gap: 6px; margin-bottom: var(--space-2); }
.term-dots span { width: 10px; height: 10px; border-radius: 50%; background: #30363d; }
/* ---------- 列表/分隔 ---------- */
hr { border: none; border-top: 1px solid var(--line); margin-block: var(--space-4); }
/* ---------- 选区(半透明强调,避免刺眼实心) ---------- */
::selection { background: var(--accent-soft); }
/* ---------- 触屏与安全区(§4e:交互组件显式 .tap 类达 44px 目标) ---------- */
.tap { min-height: 44px; min-width: 44px; display: inline-flex; align-items: center; justify-content: center; }
.safe-area { padding-left: env(safe-area-inset-left); padding-right: env(safe-area-inset-right); }
/* ---------- 移动端排版降档(≤560px:书评 20→18) ---------- */
@media (max-width: 560px) {
:root { --prose-size-reading: 18px; --prose-size-tech: 18px; --text-h1: 24px; }
.prose-reading { padding: var(--space-4) var(--space-3); }
}
/* ---------- 动效总闸(唯一曲线已入 tokens) ---------- */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
+108
View File
@@ -0,0 +1,108 @@
/* ============================================================
设计 tokens —— 丙路线「灰阶骨架 × 分轨正文」
参数依据:docs/博客重设计调研报告.md §2/§3(v1.8 定稿)
命名约定:--<类别>-<语义>,暗色经 [data-theme="dark"] 整套覆盖
============================================================ */
:root {
color-scheme: light dark;
/* ---- 底色与文字(骨架四档灰阶) ---- */
--bg: #ffffff;
--bg-soft: #f7f7f8; /* 次级面板:代码块外框、卡片衬底 */
--ink-1: #111113; /* 标题 */
--ink-2: #33343a; /* 正文 */
--ink-3: #6b6e78; /* 次要:日期、元信息 */
--ink-4: #a0a3ad; /* 装饰:分隔线、icon 底 */
--line: #e6e7eb; /* 分隔线 */
/* ---- 强调色(全站唯一,链接/hover/选中) ---- */
--accent: #3454d1;
--accent-soft: rgba(52, 84, 209, 0.12); /* ::selection / 轻底 */
/* ---- 分轨:书评纸面板(正文阅读区) ---- */
--paper: #f8f6f1; /* 米白纸底 */
--paper-ink: #2c2a26; /* 纸上墨字(比骨架 ink 暖) */
--paper-line: #d9d4c8;
/* ---- 书评情绪色(frontmatter mood 覆盖,此处为缺省五档) ---- */
--mood: var(--ink-3);
--mood-1: #b45309; /* 琥珀 */
--mood-2: #0f766e; /* 松绿 */
--mood-3: #7c3aed; /* 紫晶 */
--mood-4: #9f1239; /* 绛红 */
--mood-5: #166534; /* 苔绿 */
/* ---- 字体三轨 ---- */
/* 书评正文:霞鹜文楷屏显版(T3 以预分片自托管替换栈首) */
--font-serif: "LXGW WenKai Screen", "Songti SC", STSong, "Noto Serif CJK SC",
"Source Han Serif SC", SimSun, serif;
/* UI / 技术文:思源黑 */
--font-sans: "Noto Sans SC", system-ui, -apple-system, "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
/* 代码 */
--font-mono: "JetBrains Mono", ui-monospace, Consolas, "Courier New", monospace;
/* ---- 排版三角(报告 §3:书评 20/1.75/720;技术 19/1.7) ---- */
--prose-size-reading: 20px;
--prose-size-tech: 19px;
--leading-reading: 1.75;
--leading-tech: 1.7;
--measure: 720px; /* 版心 ≈36 汉字 */
--leading-heading: 1.3;
/* ---- 标题字阶 ---- */
--text-h1: 27px;
--text-h2: 22px;
--text-h3: 19px;
/* ---- 代码块(终端风,亮暗同底) ---- */
--code-bg: #0d1117;
--code-ink: #e6edf3;
/* ---- 动效纪律:全站唯一曲线与时长 ---- */
--ease: cubic-bezier(0.19, 1, 0.22, 1);
--dur: 350ms;
/* ---- 间距阶 ---- */
--space-1: 4px;
--space-2: 8px;
--space-3: 16px;
--space-4: 24px;
--space-5: 40px;
--space-6: 64px;
/* ---- 布局断点(§4e:1120/820/760/560,供媒体查询引用的文档锚) ----
--bp-1: 1120px 宽屏:TOC 侧栏常驻
--bp-2: 820px 笔电:TOC 收起
--bp-3: 760px 平板竖屏
--bp-4: 560px 手机(正文降到 18px,见 global.css) */
}
/* ---------- 暗色:蓝黑骨架(#191d23 系),纸面板转暗室书页 ---------- */
[data-theme="dark"] {
color-scheme: dark;
--bg: #191d23;
--bg-soft: #1f242b;
--ink-1: #f5f5f7;
--ink-2: #c9ccd4;
--ink-3: #8b8f98;
--ink-4: #5c6066;
--line: #2c313a;
--accent: #8ea2ff;
--accent-soft: rgba(142, 162, 255, 0.18);
--paper: #201d18; /* 暗室里的书页 */
--paper-ink: #d8d2c4;
--paper-line: #38342c;
--code-bg: #0d1117;
--code-ink: #e6edf3;
}
/* 用户显式选择 light 时钉住亮色(防系统翻转闪烁的配合面) */
[data-theme="light"] {
color-scheme: light;
}
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
}