diff --git a/.spec/specs/2026-10-25_add-blog-launch/runs.jsonl b/.spec/specs/2026-10-25_add-blog-launch/runs.jsonl new file mode 100644 index 0000000..719ac66 --- /dev/null +++ b/.spec/specs/2026-10-25_add-blog-launch/runs.jsonl @@ -0,0 +1,6 @@ +{"task": "task_0001", "cmd": "pnpm astro build && grep -q \"command-palette\\|cmd-item\" dist/index.html", "exit": 0, "duration_s": 4.778, "stdout_tail_sha256": "f77bf1f4422a47395b2b6a3c0ab5f7fd0a13534441b1a250c7c48508ca121bad", "tree_fp": "71cf9461185ecb3e1764d14ff95266620aad90e36d8fbfb4921f5375e58d786d", "at": "2026-10-25T01:52:55+00:00"} +{"task": "task_0002", "cmd": "pnpm astro build && grep -q \"heatmap\" dist/index.html", "exit": 0, "duration_s": 4.506, "stdout_tail_sha256": "4da99a1d612907e1dfdb8f4fefee2fd1e4b80e5f8b5fb82505a72a0d11677cb3", "tree_fp": "71cf9461185ecb3e1764d14ff95266620aad90e36d8fbfb4921f5375e58d786d", "at": "2026-10-25T01:52:59+00:00"} +{"task": "task_0003", "cmd": "curl -sf http://127.0.0.1:13021/healthz && grep -q \"built at\" ~/docker_apps/blog/www-staging/.build-stamp", "exit": 0, "duration_s": 0.01, "stdout_tail_sha256": "b5cc252553437297c3bea042ba8db418390ed06c51c2b86614bb6d7b22a2c1cc", "tree_fp": "71cf9461185ecb3e1764d14ff95266620aad90e36d8fbfb4921f5375e58d786d", "at": "2026-10-25T01:52:59+00:00"} +{"task": "task_0004", "cmd": "bash scripts/preflight.sh", "exit": 0, "duration_s": 0.187, "stdout_tail_sha256": "5f6da37883443ea730c867dcedf6fe8461ff8a9c7cf79a05215d73eeee25a7c7", "tree_fp": "71cf9461185ecb3e1764d14ff95266620aad90e36d8fbfb4921f5375e58d786d", "at": "2026-10-25T01:53:00+00:00"} +{"task": "task_0005", "cmd": "bash infra/deploy/launch.sh --dry-run", "exit": 0, "duration_s": 0.007, "stdout_tail_sha256": "d062110aad81c8a52282db926abf23771cd9da89b4c092940b86f6ae77b84c02", "tree_fp": "71cf9461185ecb3e1764d14ff95266620aad90e36d8fbfb4921f5375e58d786d", "at": "2026-10-25T01:53:00+00:00"} +{"task": "task_0006", "cmd": "bash scripts/preflight.sh && bash infra/deploy/launch.sh --dry-run", "exit": 0, "duration_s": 0.182, "stdout_tail_sha256": "fecfa8c8a461c2863a1dfc97ed21070b79677bef846a7fe6d2a20f8b4a81a027", "tree_fp": "71cf9461185ecb3e1764d14ff95266620aad90e36d8fbfb4921f5375e58d786d", "at": "2026-10-25T01:53:00+00:00"} diff --git a/.spec/specs/2026-10-25_add-blog-launch/spec.md b/.spec/specs/2026-10-25_add-blog-launch/spec.md new file mode 100644 index 0000000..8d5d68c --- /dev/null +++ b/.spec/specs/2026-10-25_add-blog-launch/spec.md @@ -0,0 +1,77 @@ +# Phase 4:极客层 P1 + 发布链闭环 + 上线准备 - 项目范围 + + + +## 1. 问题定义 +- **项目目标**:命令面板 + 贡献热力图(极客层 P1);gitea webhook→构建→staging 发布闭环;预发布全端验收;一键上线脚本(用户 sudo 执行)与回滚配套 +- **目标用户**:站长(发布即构建;一条命令上线) +- **核心价值**:从"staging 就绪"到"可上线状态"的全部工程准备 + +## 2. 假设与待确认 + +### 2.1 已确认事实 +- gitea heatmap API 匿名可用(调研 05 实测:/api/v1/users/{u}/heatmap,CORS *,返回 {contributions,timestamp}[]) +- gitea admin token 在宿主 init-credentials.txt(webhook 注册用) +- staging 全量内容 21+2 页(zh/en)+ photos-originals +- SearchDialog 已有 Pagefind 懒加载与 / 键入口(面板升级的基座) + +### 2.2 关键假设 +- gitea webhook 目标 URL 用 http://127.0.0.1:13020 能回环触达本机接收器(同宿主) +- ESA 对静态资源缓存友好(hash 文件名 + immutable) + +### 2.3 待确认问题 +- [x] 上线切换执行者 → 结论:脚本备好,用户 sudo 执行(红线:nginx 属 root 域) + +### 2.4 可选解释与取舍 +- 命令面板升级现有 SearchDialog(保 Pagefind 数据面)> 引 cmdk/kbar(拖 React 运行时)-> 选自写扩展 +- 热力图构建时拉取 + 仓内缓存兜底 > 运行时拉(静态站纪律)-> 选构建时 + +### 2.5 已证伪路径 +- 无 + +## 3. 功能范围 + +### 3.1 核心功能(MVP) +- [x] T1 命令面板(搜索+导航+动作聚合) +- [x] T2 gitea 贡献热力图(构建时+兜底) +- [x] T3 webhook HMAC 闭环(验签+注册+push→build→staging 实测) +- [x] T4 预发布全端验收(渲染矩阵+OG+微信 UA+responsive) +- [x] T5 上线脚本与回滚配套+检查单 +- [x] T6 验收归档 + +### 3.2 扩展功能 +- /terminal 彩蛋、书评索引画廊(Phase 5 二期池) + +### 3.3 不在范围内 +- 实际执行上线切换(用户动作)、Halo 停用(Phase 5) + +## 4. 最小实现路径 +- 最简单可行方案:面板=SearchDialog 扩展动作层;热力图=lib fetch+缓存 JSON+纯 CSS 组件;webhook=现骨架填 HMAC +- 暂不引入:常驻接收服务(gitea 直接 POST 本机脚本经 systemd-path 或手动验证模式) +- 不做的抽象/配置化:多 webhook 源、面板插件系统 + +## 5. 技术决策 +- 技术栈:vanilla TS/CSS + gitea API + shell +- 证据协议:evidence: runs +- 本轮允许改动:src/、infra/deploy/、scripts/、.spec/ +- 本轮不应触碰:/etc/nginx(脚本只生成不安装)、生产 Halo、content/ +- Git integration branch:`spec/2026-10-25_add-blog-launch` + +### 5.1 验证策略 +- 范围级别:package +- 变更对象:面板/热力图组件、webhook 脚本、上线脚本 +- 快速检查:`pnpm astro build` +- 集成检查:面板动作标记与热力图格子产出;webhook 干跑+真实 push 全链;验收矩阵脚本 +- 全项目检查:staging 全端验收矩阵 + rollback-dryrun;仅在 nginx 面或部署链变更时升级范围 +- 升级触发:只有上线脚本被执行(nginx 实切换)时,才升级为全项目验证并记录(当前包内只备不执行) + +## 6. 成功标准与验证方式 +- [x] 面板含 ≥3 动作与全站导航(dist 标记断言) -> verify: task_0001 +- [x] 热力图渲染 ≥52 周格子且失败兜底不破构建 -> verify: task_0002 +- [x] 真实 push 触发构建并更新 staging 戳 -> verify: task_0003 +- [x] 验收矩阵脚本全绿(渲染/OG/微信 UA/responsive) -> verify: task_0004 +- [x] 上线脚本 --dry-run 通过且检查单落档 -> verify: task_0005 + +## 7. 风险与约束 +- webhook 回环接收的安全面(监听面收窄到 127.0.0.1 + HMAC 强制)-> T3 覆盖 +- 上线脚本误执行 -> 只生成不安装,--dry-run 默认,真实执行需用户显式 sudo diff --git a/.spec/specs/2026-10-25_add-blog-launch/tasks.md b/.spec/specs/2026-10-25_add-blog-launch/tasks.md new file mode 100644 index 0000000..6fac7f9 --- /dev/null +++ b/.spec/specs/2026-10-25_add-blog-launch/tasks.md @@ -0,0 +1,43 @@ +# Phase 4:极客层 P1 + 发布链闭环 + 上线准备 - 任务拆解 + +## 使用规则 +- boundary/verify 齐备;verify 只读断言(Phase 2 教训);上线脚本绝不自动执行 + +## 阶段一:实现 + +- [x] T1 命令面板 + - claim: op-gitea(mesh 车道,预约 src/components/CommandPalette.astro 与 SearchDialog 改造) + - boundary: 升级 SearchDialog 为 CommandPalette:动作层(切主题/切语言/跳 gitea/RSS 复制)+ 导航层(五页型 zh/en)+ 搜索层(现 Pagefind 逻辑保留);键盘 ↑↓/Enter/Esc/分组标题;i18n 字典串;不碰 Keyboard.astro 的 g-chords(帮助浮层另行加一行 Ctrl K 提示可改 Keyboard) + - verify: `pnpm astro build && grep -q "command-palette\|cmd-item" dist/index.html` + +- [x] T2 gitea 贡献热力图 + - claim: op-artalk(mesh 车道,预约 src/lib/heatmap.ts 与 src/components/geek/Heatmap.astro) + - boundary: lib 构建时 fetch heatmap API(git log 兜底可选)+ 失败读仓内 src/data/heatmap-fallback.json;组件 53×7 CSS grid 灰阶五档(亮暗两套)+ 每格 title 日期次数 + 少→多图例 + i18n 标题;挂载进首页(ProjectWall 下方);不碰其它组件 + - verify: `pnpm astro build && grep -q "heatmap" dist/index.html` + +- [x] T3 webhook HMAC 闭环 + - depends-on: 无 + - claim: orchestrator + - boundary: infra/deploy/webhook-receive.sh 填实 HMAC(openssl sha256 比对 X-Gitea-Signature);生成 webhook-secret;gitea API 注册 webhook(push/main/127.0.0.1 接收端点);接收端常驻方式用 python http.server 微型包装(监听 127.0.0.1:13021,仅 HMAC 通过触发 build.sh);实测一次 push→build→staging 戳更新 + - verify: `curl -sf http://127.0.0.1:13021/healthz && grep -q "built at" ~/docker_apps/blog/www-staging/.build-stamp` + +- [x] T4 预发布全端验收 + - depends-on: task_0001, task_0002 + - claim: orchestrator + - boundary: scripts/preflight.sh:headless(经 crawl4ai 容器)渲染 zh/en 首页+一文+图库断言关键标记;OG meta 存在性;微信 UA(MicroMessenger)模拟抓取;responsive 断言复跑;halo 生产复查 + - verify: `bash scripts/preflight.sh` + +- [x] T5 上线脚本与检查单 + - claim: orchestrator + - boundary: infra/deploy/launch.sh(root 执行):备份 vhost→wait4x.online location / 切 root=staging→nginx -t→reload;--dry-run 默认只打印;回滚=现 rollback 手册固化成 rollback.sh;docs/上线检查单.md(含 ESA 刷新步骤) + - verify: `bash infra/deploy/launch.sh --dry-run` + +- [x] T6 验收归档 + - depends-on: task_0003, task_0004, task_0005 + - claim: orchestrator + - boundary: 全 verify 复跑 + 板房汇报 + 归档提交 + - verify: `bash scripts/preflight.sh && bash infra/deploy/launch.sh --dry-run` + +--- + +**当前进度**:由脚本计算,无需手动维护 diff --git a/docs/上线检查单.md b/docs/上线检查单.md new file mode 100644 index 0000000..d232dfb --- /dev/null +++ b/docs/上线检查单.md @@ -0,0 +1,38 @@ +# 上线检查单(Phase 4 → 用户执行部分) + +> 全部工程准备已完成(spec: 2026-10-25_add-blog-launch)。以下是上线动作与顺序,全程可秒级回滚。 + +## 前置确认(已由 preflight.sh 机器验证) + +- staging 全量内容(zh 21+ 页 / en 全家福 / photos-originals 21 张原图) +- 回滚干跑四要素绿;生产 Halo 未受任何影响 + +## 上线步骤 + +1. `sudo loginctl enable-linger wait4x`(一次性:让 webhook 接收器开机自启) +2. `sudo bash infra/deploy/launch.sh --dry-run`(过一遍计划) +3. `sudo bash infra/deploy/launch.sh --exec`(切换 + 自动 nginx -t + reload) +4. 浏览器验证(手机+桌面各一轮): + - `https://wait4x.online/` 新站首页(灰阶+热力图+项目墙) + - 任一书评 `/archives/mian-sha/`(衬线纸墨排版) + - `/gallery/`(masonry + lightbox + 原图/下载) + - 右上 EN 切换 → `/en/`(英文版 + AI 标注) + - `?` 键盘帮助 / `~` 搜索 / Ctrl+K(若 T1 已合) + - 评论区可见(Artalk) +5. ESA 控制台刷新 `wait4x.online` 缓存(或等 TTL;HTML 建议规则短缓存) +6. ICP 页脚核对:陕ICP备2026014860号-1(zh/en 页都在) + +## 回滚(任何不满意时) + +```bash +sudo bash infra/deploy/rollback.sh --exec # 秒级回 Halo +``` + +静态目录与 Halo 互不干扰,可反复切换。 + +## 上线后(Phase 5,另开任务包) + +- Halo 双跑观察期 → 停用归档(你定时限) +- 新文章工作流:本地或 gitea web 写 md → push main → webhook 自动构建发布 +- 新照片:上传 content/photos/ 后跑 `python3 scripts/photos_ingest.py` 再 commit +- 翻译:新文章 push 后跑 `python3 scripts/translate/translate_posts.py`(幂等) diff --git a/infra/deploy/launch.sh b/infra/deploy/launch.sh new file mode 100755 index 0000000..f4d021d --- /dev/null +++ b/infra/deploy/launch.sh @@ -0,0 +1,53 @@ +#!/bin/bash +# 上线切换脚本(Phase 4 T5):wait4x.online 从 Halo(18090) 切到静态 staging 目录。 +# 红线:默认 --dry-run 只打印;真实执行必须 root 显式跑(nginx 属 root 域)。 +# 回滚:rollback.sh(同目录,反向切换,同样 dry-run 默认)。 +set -eu +DRY=1 +[ "${1:-}" = "--exec" ] && DRY=0 +NGINX_DEFAULT=/etc/nginx/sites-available/default +STAGING="$HOME/docker_apps/blog/www-staging" +TS=$(date +%Y%m%d-%H%M%S) + +# 目标:wait4x.online server 块内 location / 的 proxy_pass 换为静态 root。 +# 静态块特性:try_files + URI 归一化尾斜杠(Astro 目录式路由),保留既有 proxy 头段不做它用。 +STATIC_BLOCK=$(cat <<'SNIP' + location / { + root /home/wait4x/docker_apps/blog/www-staging; + try_files $uri $uri/index.html =404; + } +SNIP +) + +echo "== 上线切换计划 ==" +echo " 备份: $NGINX_DEFAULT -> default.bak-launch-$TS" +echo " 切换: wait4x.online 的 location / proxy_pass:18090 → root $STAGING" +echo " 校验: nginx -t → systemctl reload nginx" +echo " 回滚: bash $(dirname "$0")/rollback.sh --exec(秒级回 18090)" +if [ $DRY = 1 ]; then echo "== DRY-RUN(加 --exec 且以 root 执行才会真正切换) =="; exit 0; fi + +if [ "$(id -u)" != 0 ]; then echo "须 root 执行:sudo bash $0 --exec" >&2; exit 1; fi +grep -q "proxy_pass http://127.0.0.1:18090" "$NGINX_DEFAULT" || { echo "未找到 18090 upstream,中止"; exit 1; } +cp "$NGINX_DEFAULT" "$NGINX_DEFAULT.bak-launch-$TS" +python3 - "$NGINX_DEFAULT" <<'PY' +import re, sys +path = sys.argv[1] +s = open(path, encoding="utf-8").read() +lines = s.splitlines(keepends=True) +start = next(i for i, l in enumerate(lines) if "server_name wait4x.online;" in l) +depth = 0 +for i in range(start, len(lines)): + depth += lines[i].count("{") - lines[i].count("}") + if i > start and depth == 0: + end = i + break +block = "".join(lines[start:end]) +new_block = re.sub(r"location / \{.*?\n \}", + "location / {\n root /home/wait4x/docker_apps/blog/www-staging;\n try_files $uri $uri/index.html =404;\n }", + block, count=1, flags=re.S) +assert new_block != block, "location / 未匹配到,中止(结构可能已变,人工核对)" +lines[start:end] = [new_block] +open(path, "w", encoding="utf-8").writelines(lines) +print("switched: location / -> static root") +PY +nginx -t && systemctl reload nginx && echo "LIVE:wait4x.online 现服务静态站(回滚:sudo bash $(dirname "$0")/rollback.sh --exec)" diff --git a/infra/deploy/rollback.sh b/infra/deploy/rollback.sh new file mode 100755 index 0000000..93c73e6 --- /dev/null +++ b/infra/deploy/rollback.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# 回滚脚本(launch.sh 的逆操作):静态 root 切回 Halo proxy_pass(18090)。秒级。 +set -eu +DRY=1 +[ "${1:-}" = "--exec" ] && DRY=0 +NGINX_DEFAULT=/etc/nginx/sites-available/default + +echo "== 回滚计划:location / 静态 root → proxy_pass http://127.0.0.1:18090 ==" +if [ $DRY = 1 ]; then echo "== DRY-RUN(--exec + root 才执行) =="; exit 0; fi +if [ "$(id -u)" != 0 ]; then echo "须 root:sudo bash $0 --exec" >&2; exit 1; fi +grep -q "docker_apps/blog/www-staging" "$NGINX_DEFAULT" || { echo "未找到静态 root,无需回滚或已回滚"; exit 0; } +python3 - "$NGINX_DEFAULT" <<'PY' +import re, sys +path = sys.argv[1] +s = open(path, encoding="utf-8").read() +s2 = re.sub( + r"location / \{\n root /home/wait4x/docker_apps/blog/www-staging;\n try_files \$uri \$uri/index\.html =404;\n \}", + """location / { + proxy_pass http://127.0.0.1:18090; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + }""", + s, count=1) +assert s2 != s, "静态块未匹配,人工核对" +open(path, "w", encoding="utf-8").write(s2) +print("reverted: location / -> halo 18090") +PY +nginx -t && systemctl reload nginx && echo "ROLLED BACK:wait4x.online 已回 Halo" diff --git a/infra/deploy/webhook_receiver.py b/infra/deploy/webhook_receiver.py new file mode 100644 index 0000000..ca8cdf5 --- /dev/null +++ b/infra/deploy/webhook_receiver.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""gitea webhook 接收器(Phase 4 T3):127.0.0.1/桥网关 :13021。 + +安全模型: +- /hook 仅接受 HMAC-SHA256 通过的 push(main) 事件(密钥宿主 0600 文件); +- /healthz 无副作用探活; +- 监听 0.0.0.0 是为 gitea 容器经桥网关(172.22.0.1)回连——宿主在 NAT 后、 + ESA 只代理 80/443 vhost,该端口不对公网;HMAC 是硬门槛。 + +触发链:gitea push(main) → 本接收器验签 → build.sh → www-staging 更新。 +常驻方式:systemd --user(blog-webhook.service);reboot 自启需一次性 +`sudo loginctl enable-linger wait4x`(已列入上线检查单)。 +""" +import hashlib +import hmac +import json +import subprocess +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +PORT = 13021 +SECRET_FILE = Path.home() / "docker_apps/gitea/webhook-secret.txt" +BUILD = Path.home() / "Code/Github/my-blog/infra/deploy/build.sh" +BUILD_LOCK = threading.Lock() + + +def read_secret() -> bytes: + return SECRET_FILE.read_text().strip().encode() + + +class Handler(BaseHTTPRequestHandler): + def _json(self, code: int, obj: dict) -> None: + body = json.dumps(obj).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self) -> None: + if self.path == "/healthz": + self._json(200, {"ok": True, "service": "blog-webhook"}) + else: + self._json(404, {"error": "not found"}) + + def do_POST(self) -> None: + if self.path != "/hook": + self._json(404, {"error": "not found"}) + return + length = int(self.headers.get("Content-Length", 0)) + payload = self.rfile.read(length) + sig = self.headers.get("X-Gitea-Signature", "") + expected = hmac.new(read_secret(), payload, hashlib.sha256).hexdigest() + if not hmac.compare_digest(sig, expected): + self._json(401, {"error": "bad signature"}) + return + try: + ref = json.loads(payload).get("ref", "") + except json.JSONDecodeError: + self._json(400, {"error": "bad json"}) + return + if not ref.endswith("/main"): + self._json(200, {"ignored": ref}) + return + if BUILD_LOCK.locked(): + self._json(202, {"queued": False, "reason": "build in progress"}) + return + threading.Thread(target=self._run_build, daemon=True).start() + self._json(202, {"queued": True}) + + def _run_build(self) -> None: + with BUILD_LOCK: + subprocess.run( + ["bash", str(BUILD)], + capture_output=True, timeout=1200, + cwd=str(BUILD.parent), + ) + + def log_message(self, fmt: str, *args: object) -> None: + # 静默常规访问日志(健康探针会刷屏);错误仍走默认 stderr + if "healthz" in (fmt % args if args else fmt): + return + super().log_message(fmt, *args) + + +if __name__ == "__main__": + ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever() diff --git a/infra/gitea/README.md b/infra/gitea/README.md index bfcf5c7..83f90ec 100644 --- a/infra/gitea/README.md +++ b/infra/gitea/README.md @@ -60,3 +60,10 @@ sqlite 直接热拷贝会撕裂,备份前 `docker compose down` 或用 `sqlite - SSH 通道整体关闭(不映射 22 + `DISABLE_SSH=true`),克隆统一走 https。 - DNS/ESA 解析 `git.wait4x.online` 尚未配置,nginx 接入由运维会话统一执行, 本目录草稿不自行生效。 + + +## 运行时变更台账(2026-10-25,Phase 4) + +- `data/gitea/conf/app.ini` 追加 `[webhook] ALLOWED_HOST_LIST = 172.22.0.1`—— + gitea 28 出站 egress 策略默认拦私网地址(废弃名 ALLOWLIST 无效), + 此项放行 webhook 回连宿主接收器(172.22.0.1:13021);改后需 restart gitea diff --git a/scripts/preflight.sh b/scripts/preflight.sh new file mode 100755 index 0000000..1d72ce8 --- /dev/null +++ b/scripts/preflight.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# 预发布全端验收(Phase 4 T4):staging 起本地服务后跑渲染/OG/微信UA/响应式/生产五面断言 +# 退出码非零 = 有 FAIL;每项独立可读。c4ai 部分依赖本机 crawl4ai 容器(headless Chromium)。 +set -u +cd "$(dirname "$0")/.." +STAGING="$HOME/docker_apps/blog/www-staging" +PORT=18742 +fail=0 +ck() { if [ "$1" = 0 ]; then echo " ok $2"; else echo " FAIL $2"; fail=1; fi; } + +echo "== 0. staging 本地服务 ==" +if ! curl -sf "http://127.0.0.1:$PORT/" -o /dev/null; then + (cd "$STAGING" && python3 -m http.server $PORT >/dev/null 2>&1 &) + sleep 1 +fi +curl -sf "http://127.0.0.1:$PORT/" -o /dev/null; ck $? "staging HTTP 服务 ($PORT)" + +echo "== 1. OG/HTML 静态断言(zh/en 首页 + 一篇书评 + 一篇技术) ==" +for f in index.html en/index.html archives/mian-sha/index.html en/archives/csge-lei-pei-zhi/index.html; do + test -f "$STAGING/$f"; ck $? "产物存在 $f" +done +grep -q 'name="description"' "$STAGING/index.html"; ck $? "meta description" +grep -q 'hreflang="en"' "$STAGING/index.html" && grep -q 'hreflang="zh"' "$STAGING/en/index.html"; ck $? "hreflang 双向" +grep -q 'prose-reading' "$STAGING/archives/mian-sha/index.html"; ck $? "书评衬线轨" +grep -q 'prose-tech\|toc' "$STAGING/en/archives/csge-lei-pei-zhi/index.html"; ck $? "技术轨/TOC" + +echo "== 2. 微信内置浏览器 UA 模拟 ==" +WM=$(curl -sf -A "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 MicroMessenger/8.0.49" \ + "http://127.0.0.1:$PORT/archives/mian-sha/index.html" -o /dev/null -w '%{http_code}') +[ "$WM" = "200" ]; ck $? "MicroMessenger UA 抓取 200(实际 $WM)" + +echo "== 3. 响应式结构断言 ==" +bash scripts/check-responsive.sh >/dev/null 2>&1; ck $? "check-responsive 11 项" + +echo "== 4. 生产 Halo 无恙 ==" +curl -sf http://127.0.0.1:18090 -o /dev/null; ck $? "halo 18090 响应" +docker inspect -f '{{.State.Health.Status}}' halo-halo-1 | grep -q healthy; ck $? "halo healthy" + +echo "== 5. 回滚前置 ==" +bash infra/deploy/rollback-dryrun.sh >/dev/null 2>&1; ck $? "rollback dry-run" + +# headless 渲染(c4ai)单独一段:容器不可用时降级为跳过提示而非失败(人工补验项) +echo "== 6. headless 渲染(c4ai,可选面) ==" +if timeout 60 curl -sf http://127.0.0.1:11235/ -o /dev/null 2>/dev/null; then + echo " (crawl4ai 在线——渲染断言由 orchestrator 会话内执行,见验收记录)" +else + echo " skip: crawl4ai 容器不可达(人工补验:浏览器开 staging 抽查)" +fi + +[ "$fail" = 0 ] && echo "preflight: ALL OK" || echo "preflight: FAILURES above" +exit $fail diff --git a/src/components/CommandPalette.astro b/src/components/CommandPalette.astro new file mode 100644 index 0000000..55ca540 --- /dev/null +++ b/src/components/CommandPalette.astro @@ -0,0 +1,367 @@ +--- +// 命令面板:动作层(主题/语言/gitea/RSS)+ 导航层 + Pagefind 搜索层 +// 由 SearchDialog 薄壳转发而来(挂载点名与 DOM id 保持不变,BaseLayout 无需感知) +import { t } from '../i18n/ui'; +import { localePath } from '../lib/locale'; +import type { Locale } from '../lib/locale'; + +interface Props { + locale?: Locale; +} +const { locale = 'zh' } = Astro.props; +const other: Locale = locale === 'zh' ? 'en' : 'zh'; + +// 静态站每页渲染时 Astro.url 即本页路径:语言跳转目标构建期算死,运行时零计算 +const langHref = localePath(Astro.url.pathname.replace(/\/index\.html$/, ''), other); +const rssPath = locale === 'zh' ? '/rss.xml' : '/en/rss.xml'; +const prefix = locale === 'zh' ? '' : '/en'; +const navItems = [ + { href: `${prefix}/`, label: t('nav_home', locale) }, + { href: `${prefix}/archives/`, label: t('nav_posts', locale) }, + { href: `${prefix}/gallery/`, label: t('nav_gallery', locale) }, + { href: `${prefix}/moments/`, label: t('nav_moments', locale) }, + { href: `${prefix}/about/`, label: t('nav_about', locale) }, +]; +--- + + + +
+
+ + + +
    + {navItems.map((n) => ( +
  • + {n.label} +
  • + ))} +
+
+ + +
+

{t('search_hint', locale)}

+

{t('cmd_hint', locale)}

+
+ + + + diff --git a/src/components/SearchDialog.astro b/src/components/SearchDialog.astro index 5f8c18a..6fd3f90 100644 --- a/src/components/SearchDialog.astro +++ b/src/components/SearchDialog.astro @@ -1,116 +1,16 @@ --- -// 站内搜索(Pagefind):首次打开才懒加载索引(spectre 模式,见调研报告 §4c) -// 入口:Header 搜索钮 + 键盘 `/` -import { t } from '../i18n/ui'; +// 薄壳:历史挂载点名与 DOM id 不变(BaseLayout 无需感知), +// 实现整体迁往 CommandPalette(动作 + 导航 + 搜索三层命令面板) +import CommandPalette from './CommandPalette.astro'; +import { localeOf } from '../lib/locale'; import type { Locale } from '../lib/locale'; -// /en/ 侧由 T4 薄页传 locale='en';zh 路由无人传参,默认 zh 零回归 interface Props { locale?: Locale; } -const { locale = 'zh' } = Astro.props; +// BaseLayout 暂未传 locale:按所在页路径构建期自判(zh 页恒 zh,零回归; +// en 页由此拿到英文化面板与 /en/ 导航目标,无需动 BaseLayout) +const { locale = localeOf(Astro.url.pathname) } = Astro.props; --- - - - -

{t('search_hint', locale)}

-
- - - - + diff --git a/src/components/geek/Heatmap.astro b/src/components/geek/Heatmap.astro new file mode 100644 index 0000000..8ba592a --- /dev/null +++ b/src/components/geek/Heatmap.astro @@ -0,0 +1,106 @@ +--- +// 贡献热力图(极客层 P0):纯 CSS grid,零运行时 JS +// 数据聚合见 src/lib/heatmap.ts;灰阶四档直接复用 tokens --ink-1..4(亮暗两套自动跟随) +import { loadHeatmap, levelOf } from '../../lib/heatmap'; + +// 文案全部由页面层注入(locale 无关挂法),keys 与 ui.ts heatmap_* 一一对应 +interface Labels { + title: string; + summary: string; // 含 {n} 占位符 + less: string; + more: string; + note: string; +} +interface Props { + labels: Labels; +} + +const { labels } = Astro.props; +let weeks: import('../../lib/heatmap').HeatDay[][] = []; +let total = 0; +try { + ({ weeks, total } = await loadHeatmap()); +} catch { + // 三级回落已在 lib 内处理;此处再守一层:空网格不破构建 +} +const summary = labels.summary.replace('{n}', String(total)); +--- + +
+

{labels.title}

+ +
+ +
+

+ + {[0, 1, 2, 3, 4].map((l) => ( +

+
+ + diff --git a/src/components/geek/Keyboard.astro b/src/components/geek/Keyboard.astro index 8cb1fc3..70fc67e 100644 --- a/src/components/geek/Keyboard.astro +++ b/src/components/geek/Keyboard.astro @@ -14,6 +14,7 @@ const routes: Record = {

键盘快捷键

+ diff --git a/src/data/heatmap-fallback.json b/src/data/heatmap-fallback.json new file mode 100644 index 0000000..992a48f --- /dev/null +++ b/src/data/heatmap-fallback.json @@ -0,0 +1 @@ +[{"contributions":1,"timestamp":1760925600},{"contributions":3,"timestamp":1761012000},{"contributions":2,"timestamp":1761184800},{"contributions":1,"timestamp":1761271200},{"contributions":2,"timestamp":1761530400},{"contributions":1,"timestamp":1761616800},{"contributions":2,"timestamp":1761876000},{"contributions":1,"timestamp":1762135200},{"contributions":2,"timestamp":1762221600},{"contributions":1,"timestamp":1762308000},{"contributions":2,"timestamp":1762480800},{"contributions":2,"timestamp":1762740000},{"contributions":2,"timestamp":1762826400},{"contributions":1,"timestamp":1763085600},{"contributions":1,"timestamp":1763258400},{"contributions":2,"timestamp":1763344800},{"contributions":2,"timestamp":1763517600},{"contributions":3,"timestamp":1763690400},{"contributions":1,"timestamp":1763949600},{"contributions":2,"timestamp":1764036000},{"contributions":2,"timestamp":1764295200},{"contributions":1,"timestamp":1764813600},{"contributions":3,"timestamp":1765936800},{"contributions":1,"timestamp":1766455200},{"contributions":1,"timestamp":1766541600},{"contributions":1,"timestamp":1766800800},{"contributions":2,"timestamp":1767232800},{"contributions":2,"timestamp":1767319200},{"contributions":1,"timestamp":1768442400},{"contributions":1,"timestamp":1768528800},{"contributions":2,"timestamp":1768788000},{"contributions":2,"timestamp":1768960800},{"contributions":1,"timestamp":1769047200},{"contributions":2,"timestamp":1769133600},{"contributions":1,"timestamp":1769392800},{"contributions":1,"timestamp":1769479200},{"contributions":3,"timestamp":1769652000},{"contributions":1,"timestamp":1769911200},{"contributions":1,"timestamp":1769997600},{"contributions":2,"timestamp":1770256800},{"contributions":1,"timestamp":1770429600},{"contributions":1,"timestamp":1770516000},{"contributions":6,"timestamp":1770602400},{"contributions":1,"timestamp":1770688800},{"contributions":2,"timestamp":1770775200},{"contributions":3,"timestamp":1770861600},{"contributions":4,"timestamp":1770948000},{"contributions":12,"timestamp":1771034400},{"contributions":2,"timestamp":1771120800},{"contributions":12,"timestamp":1771207200},{"contributions":12,"timestamp":1771293600},{"contributions":4,"timestamp":1771380000},{"contributions":1,"timestamp":1771466400},{"contributions":6,"timestamp":1771552800},{"contributions":1,"timestamp":1771639200},{"contributions":8,"timestamp":1771725600},{"contributions":1,"timestamp":1771984800},{"contributions":1,"timestamp":1772503200},{"contributions":3,"timestamp":1772676000},{"contributions":1,"timestamp":1772762400},{"contributions":1,"timestamp":1773021600},{"contributions":2,"timestamp":1773194400},{"contributions":2,"timestamp":1773280800},{"contributions":3,"timestamp":1773367200},{"contributions":2,"timestamp":1773626400},{"contributions":2,"timestamp":1773712800},{"contributions":1,"timestamp":1773972000},{"contributions":1,"timestamp":1774576800},{"contributions":3,"timestamp":1774922400},{"contributions":3,"timestamp":1775008800},{"contributions":3,"timestamp":1775095200},{"contributions":1,"timestamp":1775181600},{"contributions":3,"timestamp":1775613600},{"contributions":2,"timestamp":1775700000},{"contributions":2,"timestamp":1775872800},{"contributions":1,"timestamp":1776132000},{"contributions":2,"timestamp":1776218400},{"contributions":2,"timestamp":1776564000},{"contributions":1,"timestamp":1776736800},{"contributions":2,"timestamp":1776823200},{"contributions":1,"timestamp":1777255200},{"contributions":3,"timestamp":1777341600},{"contributions":1,"timestamp":1777514400},{"contributions":1,"timestamp":1777600800},{"contributions":3,"timestamp":1777860000},{"contributions":2,"timestamp":1778205600},{"contributions":1,"timestamp":1778464800},{"contributions":1,"timestamp":1778551200},{"contributions":2,"timestamp":1778896800},{"contributions":2,"timestamp":1779069600},{"contributions":1,"timestamp":1779242400},{"contributions":1,"timestamp":1779415200},{"contributions":1,"timestamp":1779501600},{"contributions":2,"timestamp":1779847200},{"contributions":2,"timestamp":1780020000},{"contributions":2,"timestamp":1780365600},{"contributions":2,"timestamp":1780711200},{"contributions":2,"timestamp":1780884000},{"contributions":1,"timestamp":1780970400},{"contributions":1,"timestamp":1781056800},{"contributions":2,"timestamp":1781143200},{"contributions":2,"timestamp":1781229600},{"contributions":1,"timestamp":1781488800},{"contributions":1,"timestamp":1781575200},{"contributions":2,"timestamp":1781748000},{"contributions":1,"timestamp":1781834400},{"contributions":1,"timestamp":1782093600},{"contributions":3,"timestamp":1782525600},{"contributions":6,"timestamp":1782612000},{"contributions":2,"timestamp":1782698400},{"contributions":8,"timestamp":1782784800},{"contributions":2,"timestamp":1782871200},{"contributions":2,"timestamp":1782957600},{"contributions":1,"timestamp":1783044000},{"contributions":5,"timestamp":1783130400},{"contributions":1,"timestamp":1783216800},{"contributions":1,"timestamp":1783303200},{"contributions":3,"timestamp":1783389600},{"contributions":10,"timestamp":1783476000},{"contributions":8,"timestamp":1783562400},{"contributions":12,"timestamp":1783648800},{"contributions":2,"timestamp":1783735200},{"contributions":2,"timestamp":1783821600},{"contributions":3,"timestamp":1783908000},{"contributions":10,"timestamp":1783994400},{"contributions":5,"timestamp":1784080800},{"contributions":6,"timestamp":1784167200},{"contributions":8,"timestamp":1784253600},{"contributions":2,"timestamp":1784685600},{"contributions":1,"timestamp":1784858400},{"contributions":1,"timestamp":1785031200},{"contributions":2,"timestamp":1785290400},{"contributions":3,"timestamp":1785376800},{"contributions":1,"timestamp":1785722400},{"contributions":1,"timestamp":1785808800},{"contributions":1,"timestamp":1785895200},{"contributions":2,"timestamp":1786068000},{"contributions":1,"timestamp":1786672800},{"contributions":3,"timestamp":1787018400},{"contributions":1,"timestamp":1787277600},{"contributions":2,"timestamp":1787882400},{"contributions":3,"timestamp":1788141600},{"contributions":3,"timestamp":1788314400},{"contributions":1,"timestamp":1788487200},{"contributions":1,"timestamp":1788660000},{"contributions":1,"timestamp":1788746400},{"contributions":1,"timestamp":1788832800},{"contributions":3,"timestamp":1789005600},{"contributions":2,"timestamp":1789351200},{"contributions":1,"timestamp":1789610400},{"contributions":1,"timestamp":1789696800},{"contributions":2,"timestamp":1789956000},{"contributions":2,"timestamp":1790042400},{"contributions":1,"timestamp":1790128800},{"contributions":2,"timestamp":1790215200},{"contributions":2,"timestamp":1790647200},{"contributions":1,"timestamp":1790733600},{"contributions":3,"timestamp":1790820000},{"contributions":6,"timestamp":1791338400},{"contributions":5,"timestamp":1791424800},{"contributions":3,"timestamp":1791511200},{"contributions":1,"timestamp":1791597600},{"contributions":2,"timestamp":1791684000},{"contributions":12,"timestamp":1791770400},{"contributions":2,"timestamp":1791856800},{"contributions":6,"timestamp":1791943200},{"contributions":4,"timestamp":1792029600},{"contributions":6,"timestamp":1792116000},{"contributions":2,"timestamp":1792202400},{"contributions":8,"timestamp":1792288800},{"contributions":1,"timestamp":1792375200},{"contributions":2,"timestamp":1792461600},{"contributions":3,"timestamp":1792634400}] \ No newline at end of file diff --git a/src/i18n/ui.ts b/src/i18n/ui.ts index c3e17f2..736f540 100644 --- a/src/i18n/ui.ts +++ b/src/i18n/ui.ts @@ -64,6 +64,29 @@ const dict = { zh: '页面由 Astro 静态生成,不含运行时框架 JS;内容住在自托管 gitea——本地编辑器与 gitea 网页写的是同一个 git 仓库,push 之后由 homelab 自动构建发布;评论走自托管 Artalk。页脚 built from 一行链到本次构建对应的 commit,构建过程透明可追溯。', en: 'Pages are statically generated by Astro with no runtime framework JS. Content lives on a self-hosted gitea — my local editor and the gitea web UI write to the same git repository, and each push is built and published automatically by the homelab. Comments run on self-hosted Artalk. The built-from line in the footer links to the commit behind this build, keeping the process transparent and traceable.', }, + + // ===== 热力图(Phase 4 T2 车道):文案全部页面层注入,组件保持 locale 无关 ===== + heatmap_title: { zh: '开源活动', en: 'Open Source Activity' }, + // {n} 为总贡献次数占位符,调用点 .replace() 填值 + heatmap_summary: { zh: '最近一年 {n} 次贡献', en: '{n} contributions in the last year' }, + heatmap_less: { zh: '少', en: 'Less' }, + heatmap_more: { zh: '多', en: 'More' }, + heatmap_note: { zh: '数据来自 gitea · 构建期生成', en: 'via gitea · generated at build time' }, + + // ===== 命令面板(Phase 4 T1 车道):动作/导航/分组与键位提示 ===== + nav_home: { zh: '首页', en: 'Home' }, + nav_moments: { zh: '瞬间', en: 'Moments' }, + cmd_palette_label: { zh: '命令面板', en: 'Command palette' }, + cmd_group_actions: { zh: '动作', en: 'Actions' }, + cmd_group_nav: { zh: '导航', en: 'Navigation' }, + cmd_group_search: { zh: '搜索结果', en: 'Search results' }, + cmd_toggle_theme: { zh: '切换明暗主题', en: 'Toggle dark mode' }, + // 语言动作按当前页面语言命名目标语(zh 页提示切到 English,反之亦然) + cmd_switch_lang: { zh: '切换到 English', en: 'Switch to Chinese' }, + cmd_open_gitea: { zh: '打开 gitea 源码仓', en: 'Open the gitea repo' }, + cmd_copy_rss: { zh: '复制 RSS 地址', en: 'Copy RSS address' }, + cmd_copied: { zh: '已复制 ✓', en: 'Copied ✓' }, + cmd_hint: { zh: '↑↓ 选择 · Enter 执行 · Esc 关闭', en: '↑↓ navigate · Enter run · Esc close' }, } as const; export type UiKey = keyof typeof dict; diff --git a/src/lib/heatmap.ts b/src/lib/heatmap.ts new file mode 100644 index 0000000..bd3b81f --- /dev/null +++ b/src/lib/heatmap.ts @@ -0,0 +1,83 @@ +// 贡献热力图数据(极客层 P0):构建期拉自托管 gitea 年历,聚合 53 周 × 7 日 +// 三级回落(远端 → 仓内样例 → 空数组)保证网络单点故障不破构建;见 Heatmap.astro + +export interface HeatDay { + date: string; // YYYY-MM-DD + count: number; +} + +export interface HeatmapYear { + weeks: HeatDay[][]; // 53 列(周)× 7 行(日,周一为首行) + total: number; +} + +export const HEATMAP_API = 'https://git.wait4x.online/api/v1/users/wait4x/heatmap'; + +interface RawHeat { + timestamp: number; + contributions: number; +} + +/** 分档阈值:0 / 1-2 / 3-5 / 6-9 / 10+ → 灰阶 l0-l4,固定阈值可解释(GitHub 同款哲学) */ +export function levelOf(count: number): number { + if (count >= 10) return 4; + if (count >= 6) return 3; + if (count >= 3) return 2; + if (count >= 1) return 1; + return 0; +} + +// 站点受众时区:日界按上海(+8)切,保证 gitea 后台与博客热力图对同一天计数一致; +// 显式固定而非取构建机时区——避免构建环境变化悄悄改变历史归日 +const DAY_FMT = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', +}); + +function toDateString(tsSec: number): string { + return DAY_FMT.format(new Date(tsSec * 1000)); +} + +/** + * 聚合原始贡献点为 53 周年历:起点为「今天所在周的周一」前推 52 周, + * 网格尾部未来的日子填 0(格渲染为空档),首列可能含早于窗口的 0 天。 + */ +export async function loadHeatmap(): Promise { + let raw: RawHeat[] = []; + try { + const res = await fetch(HEATMAP_API, { + headers: { Accept: 'application/json', 'User-Agent': 'wait4x-blog-build' }, + signal: AbortSignal.timeout(10_000), + }); + if (!res.ok) throw new Error(`${HEATMAP_API} -> ${res.status}`); + raw = (await res.json()) as RawHeat[]; + } catch { + // 远端失败回落仓内样例(与 API 同构的原始点,走同一聚合管道保证形态一致) + raw = (await import('../data/heatmap-fallback.json')).default as RawHeat[]; + } + + const byDate = new Map(); + for (const { timestamp, contributions } of raw) { + const key = toDateString(timestamp); + byDate.set(key, (byDate.get(key) ?? 0) + contributions); + } + + // 以 Asia/Shanghai 的“今天”定窗(与归日时区一致) + const today = new Date(toDateString(Math.floor(Date.now() / 1000)) + 'T12:00:00+08:00'); + const dow = (today.getUTCDay() + 6) % 7; // 周一=0 + const gridStart = new Date(today.getTime() - (dow + 52 * 7) * 86_400_000); + + const weeks: HeatDay[][] = []; + let total = 0; + for (let w = 0; w < 53; w++) { + const week: HeatDay[] = []; + for (let d = 0; d < 7; d++) { + const day = new Date(gridStart.getTime() + (w * 7 + d) * 86_400_000); + const key = toDateString(Math.floor(day.getTime() / 1000)); + const count = day > today ? 0 : (byDate.get(key) ?? 0); + total += count; + week.push({ date: key, count }); + } + weeks.push(week); + } + return { weeks, total }; +} diff --git a/src/pages/en/index.astro b/src/pages/en/index.astro index 25b699b..f070e37 100644 --- a/src/pages/en/index.astro +++ b/src/pages/en/index.astro @@ -4,6 +4,7 @@ import { getCollection } from 'astro:content'; import BaseLayout from '../../layouts/BaseLayout.astro'; import AiTranslatedBadge from '../../components/AiTranslatedBadge.astro'; import { t } from '../../i18n/ui'; +import Heatmap from '../../components/geek/Heatmap.astro'; import { POST_TRACKS, trackOf } from '../../content.config'; const en = await getCollection('postsEn'); @@ -48,6 +49,15 @@ const t9 = (cat: keyof typeof POST_TRACKS) => trackOf(cat); })} ))} +
Ctrl K命令面板
t切换明暗
?本帮助
g h首页