diff --git a/.spec/specs/2026-10-26_blog-5f/tasks.md b/.spec/specs/2026-10-26_blog-5f/tasks.md
index fdce25a..21f2bce 100644
--- a/.spec/specs/2026-10-26_blog-5f/tasks.md
+++ b/.spec/specs/2026-10-26_blog-5f/tasks.md
@@ -13,7 +13,7 @@
- [ ] T2 localePath 双 en 修复 + 自指 canonical + 尾斜杠归一
- claim: op-gitea(mesh 车道)
- boundary: src/lib/locale.ts(先归一 zh 再拼前缀);BaseLayout.astro 加自指 canonical(Astro.site+withSlash);Header.astro nav-lang href 尾斜杠补齐;E2E 可加断言
- - verify: `! grep -r "/en/en/" dist/ && grep -q 'rel="canonical"' dist/en/archives/mian-sha/index.html && node tests/e2e.mjs 2>&1 | grep -q "33/33 PASS"`
+ - verify: `! grep -r "/en/en/" dist/ && grep -q 'rel="canonical"' dist/en/archives/mian-sha/index.html && node tests/e2e.mjs 2>&1 | grep -q "34/34 PASS"`
- [ ] T3 构建期内容 lint + 生产冒烟 + build.sh 守卫挂载
- claim: op-gitea(mesh 车道)
@@ -23,24 +23,24 @@
- [ ] T4 reader 纯文本模式(html.reader + 命令面板 + r 键 + 持久化)
- claim: orchestrator
- boundary: global.css 尾部集中 html.reader 段(按 08 文档 §4.4 清单:隐 bg-glow/进度条/收 Header 尾部钮/Footer 收单行 ICP 必留;正文字号微升行宽收窄);CommandPalette data-cmd="reader" + ui.ts cmd_reader 双语键;Keyboard r 单键;localStorage 持久化;reduced-motion/无 JS 天然兼容(纯 class)
- - verify: `node tests/e2e.mjs 2>&1 | grep -q "33/33 PASS" && grep -q 'data-cmd="reader"' src/components/CommandPalette.astro && grep -q "html.reader" src/styles/global.css`
+ - verify: `node tests/e2e.mjs 2>&1 | grep -q "34/34 PASS" && grep -q 'data-cmd="reader"' src/components/CommandPalette.astro && grep -q "html.reader" src/styles/global.css`
- [ ] T5 代码块窗条(P 卡:≥4 行灰阶窗+mono 标题+灰阶三点)
- claim: op-artalk(mesh 车道)
- boundary: 技术轨代码块渲染层(自查源码定落点:prose-tech 容器/全局 pre 样式);≥4 行块加窗条(灰阶三点非红黄绿+mono 语言/文件标签),单行块裸 pill;亮暗双态;scoped 样式优先不动 global.css(与 T7 划界)
- - verify: `node tests/e2e.mjs 2>&1 | grep -q "33/33 PASS" && grep -rqE "code-win|codebar|窗条" src/styles/ src/components/`
+ - verify: `node tests/e2e.mjs 2>&1 | grep -q "34/34 PASS" && grep -rqE "code-win|codebar|窗条" src/styles/ src/components/`
- [ ] T6 书评纸面板(Q 卡:圆角米灰纸卡+暗色暖棕+hairline 边框)
- claim: op-artalk(mesh 车道)
- boundary: 书评正文容器(prose-reading 所在组件 scoped 样式):圆角纸卡+米灰 #f4f4f2 系亮/暖棕暗态;边框 hairline 级防长文竖线感(08 裁定 §6-2);标题区外置结构不动;--paper 变量需求提给 orchestrator 入 tokens(T7 协同),不直接改 tokens.css
- - verify: `node tests/e2e.mjs 2>&1 | grep -q "33/33 PASS" && grep -rq "paper" src/components/ | grep -v node_modules`
+ - verify: `node tests/e2e.mjs 2>&1 | grep -q "34/34 PASS" && grep -rq "paper" src/components/ | grep -v node_modules`
- [ ] T7 版式基底(K/L/O 卡:流距 token+灰阶补档+暗色 hairline 降档+微交互曲线+focus ring)
- claim: orchestrator(tokens/global 专属车道)
- boundary: tokens.css(--flow-* 流距五变量+灰阶补档评估+--paper 纸色变量);global.css(h2/h3/p+p 改用流距变量、暗色 hairline 降一档、过渡曲线统一 token :where 一段、focus-visible ring 统一);标题字重 700→600(裁定 #6);行距 +2~4px(裁定 #7);移动端密集扫描复测(涉布局面)
- - verify: `node tests/e2e.mjs 2>&1 | grep -q "33/33 PASS" && grep -q "flow-h2-pre" src/styles/tokens.css && grep -q "focus-visible" src/styles/global.css`
+ - verify: `node tests/e2e.mjs 2>&1 | grep -q "34/34 PASS" && grep -q "flow-h2-pre" src/styles/tokens.css && grep -q "focus-visible" src/styles/global.css`
- [ ] T8 首屏 tagline + 图库质感(M/N 卡:身份语+圆角统一+暗色衬底)
- claim: orchestrator
- boundary: 首页身份区(IdCard 或等位组件)加 tagline ≤20 字(措辞备两案入完成总结供用户选,默认「写作者 · 读者 · 快门按压者」);GalleryPage 圆角统一 --radius-img 暗色 4% 衬底(裁定 §6-4 双模式分判;衬底圆角≤照片圆角防框中框);亮色直贴不动
- - verify: `node tests/e2e.mjs 2>&1 | grep -q "33/33 PASS" && grep -rq "tagline" src/pages/index.astro src/components/ && grep -q "radius-img" src/styles/tokens.css`
+ - verify: `node tests/e2e.mjs 2>&1 | grep -q "34/34 PASS" && grep -rq "tagline" src/pages/index.astro src/components/ && grep -q "radius-img" src/styles/tokens.css`
diff --git a/infra/deploy/build.sh b/infra/deploy/build.sh
index 33c07d3..0d76e43 100755
--- a/infra/deploy/build.sh
+++ b/infra/deploy/build.sh
@@ -31,6 +31,9 @@ pnpm astro build
[ -f scripts/build-perf/gen-sw-precache.mjs ] && node scripts/build-perf/gen-sw-precache.mjs dist || echo "[build] sw-precache 生成器未就绪,跳过"
[ -f scripts/build-perf/inline-home-css.mjs ] && node scripts/build-perf/inline-home-css.mjs || echo "[build] inline-home 脚本未就绪,跳过"
+# 内容 lint(T3):断链/缺图在部署前拦截;CONTENT_LINT=skip|warn 逃生门见脚本头注
+[ -f scripts/build-perf/content-lint.mjs ] && node scripts/build-perf/content-lint.mjs dist || echo "[build] content-lint 未就绪,跳过"
+
echo "[build] rsync -> $STAGING"
mkdir -p "$STAGING"
rsync -a --delete "$BUILD_REPO/dist/" "$STAGING/"
@@ -38,3 +41,9 @@ ORIG="$HOME/docker_apps/blog/photos-originals"
if [ -d "$ORIG" ]; then rsync -a "$ORIG/" "$STAGING/photos-originals/"; fi
printf 'built %s at %s\n' "$SHA" "$(date -Is)" > "$STAGING/.build-stamp"
echo "[build] done: $(find "$STAGING" -name '*.html' | wc -l) html files @ $SHA"
+
+# 生产冒烟(T3):staging 三断言。首版容忍失败(robots 待 T1 落地,smoke FAIL 属预期),
+# T1 收编后改硬门:去掉 `|| echo` 让断言失败直接炸构建
+if [ -f scripts/build-perf/prod-smoke.sh ] && [ "${SMOKE_BASE:-http://127.0.0.1:18743}" != "off" ]; then
+ bash scripts/build-perf/prod-smoke.sh "${SMOKE_BASE:-http://127.0.0.1:18743}" || echo "[build] prod-smoke 未过(见上;T1 robots 落地后转硬门)"
+fi
diff --git a/scripts/build-perf/content-lint.mjs b/scripts/build-perf/content-lint.mjs
new file mode 100644
index 0000000..3374766
--- /dev/null
+++ b/scripts/build-perf/content-lint.mjs
@@ -0,0 +1,80 @@
+#!/usr/bin/env node
+// 构建期内容 lint(T3,零依赖):扫 dist 产物三检——站内断链 / 图片存在 / zh↔en 配对
+// 用法:node scripts/build-perf/content-lint.mjs [dist目录=dist]
+// 逃生门:CONTENT_LINT=skip 整体跳过;CONTENT_LINT=warn 只警告不炸构建(首版兼容期)
+// 口径:断链/缺图 = FAIL(exit 1);配对缺失 = WARN(en 缺页可能是有意的,人工裁决)
+import { readdirSync, readFileSync, existsSync, statSync } from 'node:fs';
+import { join, resolve as pathResolve } from 'node:path';
+
+const DIST = pathResolve(process.argv[2] ?? 'dist');
+const MODE = process.env.CONTENT_LINT ?? 'fail';
+if (MODE === 'skip') {
+ console.log('[content-lint] CONTENT_LINT=skip,整体跳过');
+ process.exit(0);
+}
+
+const SKIP_PREFIX = ['/admin/', '/photos-originals/']; // admin=CMS 路由;photos-originals=部署期 rsync 叠加层(不在 dist,另有部署链保障)
+const SKIP_FILE = new Set(['/404.html']); // Astro 404 页不参与配对
+
+function walk(dir, out = []) {
+ for (const e of readdirSync(dir)) {
+ const p = join(dir, e);
+ if (statSync(p).isDirectory()) walk(p, out);
+ else if (e.endsWith('.html')) out.push(p);
+ }
+ return out;
+}
+
+/** /x/ → dist/x/index.html;/a.svg → dist/a.svg;找不到即 null */
+function targetExists(url) {
+ if (SKIP_PREFIX.some((s) => url.startsWith(s))) return true;
+ const p = url.split('#')[0].split('?')[0];
+ if (!p || p === '/') return existsSync(join(DIST, 'index.html'));
+ if (p.endsWith('/')) return existsSync(join(DIST, p, 'index.html'));
+ return existsSync(join(DIST, p)) || existsSync(join(DIST, `${p}.html`)) || existsSync(join(DIST, p, 'index.html'));
+}
+
+const files = walk(DIST);
+const fails = [];
+const warns = [];
+
+for (const f of files) {
+ const html = readFileSync(f, 'utf8');
+ const rel = f.slice(DIST.length) || '/';
+
+ // 站内 :仅根相对路径;#锚点/mailto/tel/外部/data: 全跳过
+ for (const m of html.matchAll(/]*href="([^"]*)"/g)) {
+ const href = m[1];
+ if (!href.startsWith('/') || href.startsWith('//')) continue;
+ if (!targetExists(href)) fails.push(`断链 ${rel} → ${href}`);
+ }
+
+ //
与 srcset:产物必须存在(构建期副本检查,防部署后 404 图)
+ for (const m of html.matchAll(/
]*>/g)) {
+ const tag = m[0];
+ const src = /(?:\ssrc="([^"]*)")/.exec(tag)?.[1];
+ const srcset = /(?:\ssrcset="([^"]*)")/.exec(tag)?.[1];
+ for (const u of [src, ...(srcset ?? '').split(',').map((s) => s.trim().split(/\s+/)[0])]) {
+ if (!u || !u.startsWith('/') || u.startsWith('//')) continue;
+ if (!targetExists(u)) fails.push(`缺图 ${rel} → ${u}`);
+ }
+ }
+
+ // zh↔en 配对:dist/en/x/index.html ↔ dist/x/index.html,双向 WARN
+ if (!SKIP_FILE.has(rel) && !SKIP_PREFIX.some((s) => rel.startsWith(s))) {
+ const other = rel.startsWith('/en/') ? rel.slice(3) : `/en${rel === '/index.html' ? '/index.html' : rel}`;
+ if (!existsSync(join(DIST, other))) warns.push(`配对缺 ${rel} ↔ ${other}`);
+ }
+}
+
+for (const w of warns) console.warn(`[content-lint] WARN ${w}`);
+if (fails.length) {
+ for (const x of fails) console.error(`[content-lint] FAIL ${x}`);
+ if (MODE === 'warn') {
+ console.warn(`[content-lint] WARN 模式:${fails.length} 个失败降级为警告`);
+ process.exit(0);
+ }
+ console.error(`[content-lint] ${fails.length} 失败 / ${warns.length} 警告`);
+ process.exit(1);
+}
+console.log(`[content-lint] 通过:${files.length} 页扫描,0 断链 0 缺图,${warns.length} 配对警告`);
diff --git a/scripts/build-perf/prod-smoke.sh b/scripts/build-perf/prod-smoke.sh
new file mode 100755
index 0000000..72f56fc
--- /dev/null
+++ b/scripts/build-perf/prod-smoke.sh
@@ -0,0 +1,25 @@
+#!/usr/bin/env bash
+# 生产冒烟(T3):部署后三断言——robots 带 Sitemap / sitemap-index 可达 / 首页无 x-robots-tag
+# 用法:./prod-smoke.sh [BASE_URL](默认 https://wait4x.online;构建机对 staging 传 http://127.0.0.1:18743)
+# 任一断言失败 exit 1;robots 缺失常见于 SEO 解封前(T1 未落),会明确报 FAIL 而非静默
+set -u
+BASE="${1:-${SMOKE_BASE:-https://wait4x.online}}"
+fail=0
+
+robots="$(curl -fsS --max-time 15 "${BASE}/robots.txt" 2>/dev/null)" \
+ && grep -q 'Sitemap:' <<<"$robots" \
+ && echo "[smoke] robots.txt 200 含 Sitemap ✓" \
+ || { echo "[smoke] FAIL robots.txt 缺失/无 Sitemap 行:${BASE}/robots.txt"; fail=1; }
+
+curl -fsS --max-time 15 -o /dev/null "${BASE}/sitemap-index.xml" \
+ && echo "[smoke] sitemap-index 200 ✓" \
+ || { echo "[smoke] FAIL sitemap-index 不可达:${BASE}/sitemap-index.xml"; fail=1; }
+
+# x-robots-tag 出现即 FAIL(noindex 头会废掉整站收录,SEO 解封的反面)
+if curl -fsS --max-time 15 -I "${BASE}/" | grep -qi 'x-robots-tag'; then
+ echo "[smoke] FAIL 首页响应头含 x-robots-tag"; fail=1
+else
+ echo "[smoke] 首页无 x-robots-tag ✓"
+fi
+
+exit $fail
diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro
index 0f3c529..b42c977 100644
--- a/src/layouts/BaseLayout.astro
+++ b/src/layouts/BaseLayout.astro
@@ -30,6 +30,8 @@ const rawPath = Astro.url.pathname.replace(/\/index\.html$/, '');
const zhPath = localeOf(rawPath) === 'en' ? localePath(rawPath, 'zh') : rawPath;
const enPath = localePath(rawPath, 'en');
const withSlash = (p: string) => (p.endsWith('/') ? p : `${p}/`);
+// 自指 canonical(T2):SEO 权重收敛到当前页自身,与 hreflang 组合覆盖双轨
+const canonicalUrl = new URL(withSlash(rawPath), Astro.site).href;
---
@@ -87,6 +89,7 @@ const withSlash = (p: string) => (p.endsWith('/') ? p : `${p}/`);
addEventListener('load', () => navigator.serviceWorker.register('/sw.js'));
}
+
diff --git a/src/lib/locale.ts b/src/lib/locale.ts
index 8d4d712..fa3ee65 100644
--- a/src/lib/locale.ts
+++ b/src/lib/locale.ts
@@ -7,9 +7,12 @@ export type Locale = 'zh' | 'en';
export const localeOf = (pathname: string): Locale =>
pathname === '/en' || pathname.startsWith('/en/') ? 'en' : 'zh';
-/** 当前路径在另一语言下的对应路径(zh → /en/...,en → 去 /en 前缀)。 */
+/** 当前路径在另一语言下的对应路径(zh → /en/...,en → 去 /en 前缀)。
+ * 尾斜杠归一:返回值一律带尾斜杠(与全站/sitemap 口径一致)。
+ * 先归一 zh 再拼前缀——en→en 场景若直接 /en+clean 会产出 /en/en/(T2 病根) */
export function localePath(pathname: string, to: Locale): string {
const clean = pathname.replace(/\/index\.html$/, '').replace(/\/$/, '') || '/';
- if (to === 'en') return clean === '/' ? '/en/' : `/en${clean}`;
- return clean.replace(/^\/en(?=\/|$)/, '') || '/';
+ const zh = clean.replace(/^\/en(?=\/|$)/, '') || '/';
+ const slash = (p: string) => (p === '/' ? '/' : `${p}/`);
+ return to === 'en' ? slash(zh === '/' ? '/en' : `/en${zh}`) : slash(zh);
}
diff --git a/tests/e2e.mjs b/tests/e2e.mjs
index 52101b3..e3aa105 100644
--- a/tests/e2e.mjs
+++ b/tests/e2e.mjs
@@ -86,6 +86,8 @@ const enNavBad = await page.$$eval('nav a[href^="/"]:not(.nav-lang)', (as) => as
ok('en 导航全轨 /en/ 前缀', enNavBad.length === 0 && enNavHrefs.length >= 6 && enNavHrefs.includes('/en/books/'), enNavBad.join(' ; ') || enNavHrefs.join(' '));
const enBrand = await page.getAttribute('a.brand', 'href');
ok('en 品牌链接指 /en/', enBrand === '/en/', String(enBrand));
+const enCanon = await page.getAttribute('link[rel="canonical"]', 'href');
+ok('en 页 canonical 自指', !!enCanon && new URL(enCanon).pathname === new URL(page.url()).pathname, enCanon ?? '无 canonical');
// 4) 命令面板 + 搜索
await page.keyboard.press('Control+k');