From c307285a25ee341e754158a84050275c43f6c7b5 Mon Sep 17 00:00:00 2001 From: wait4xx Date: Sun, 1 Nov 2026 09:12:01 +0800 Subject: [PATCH] =?UTF-8?q?fix(i18n):=20localePath=20=E4=B8=89=E5=A4=84?= =?UTF-8?q?=E5=8F=8C=20en=20=E4=B8=80=E5=88=80=E6=B2=BB+canonical=20?= =?UTF-8?q?=E5=85=A8=E9=87=8F=EF=BC=88blog-5f=20T2/T3=20=E8=BD=A6=E9=81=93?= =?UTF-8?q?=E6=94=B6=E7=BC=96=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - T2:localePath 先归一 zh 再拼前缀(Header/CommandPalette/ BaseLayout 三处 /en/en/ 同源病根一刀),返回值统一尾斜杠; BaseLayout 自指 canonical 全量 35 页;E2E 新增 en canonical 自指断言(33→34) - T3:content-lint.mjs 零依赖三检(断链 FAIL/缺图 FAIL/zh-en 配对 WARN;CONTENT_LINT 逃生门;photos-originals 叠加层与 /admin 单语两误报已修);prod-smoke.sh 三断言(robots 未落明 确 FAIL 不静默);build.sh 守卫行挂载(smoke 对 staging 首版 容忍,T1 robots 上线后转硬门,触发条件注释在案) - tasks.md verify 字面量 33→34 联动同步(6 处) 车道:op-gitea(verify 六条实录);收编验收:E2E 34/34+产物零 /en/en/+lint 0/0/0 实跑复现 验证: node tests/e2e.mjs 34/34 PASS;! grep -r /en/en/ dist/; content-lint 三检 0 违例;bash -n+test -x 过 Spec: 2026-10-26_blog-5f Co-authored-by: GLM-5.3 --- .spec/specs/2026-10-26_blog-5f/tasks.md | 12 ++-- infra/deploy/build.sh | 9 +++ scripts/build-perf/content-lint.mjs | 80 +++++++++++++++++++++++++ scripts/build-perf/prod-smoke.sh | 25 ++++++++ src/layouts/BaseLayout.astro | 3 + src/lib/locale.ts | 9 ++- tests/e2e.mjs | 2 + 7 files changed, 131 insertions(+), 9 deletions(-) create mode 100644 scripts/build-perf/content-lint.mjs create mode 100755 scripts/build-perf/prod-smoke.sh 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');