fix(i18n): localePath 三处双 en 一刀治+canonical 全量(blog-5f T2/T3 车道收编)

- 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
This commit is contained in:
wait4xandGLM-5.3 committed 2026-11-01 09:12:01 +08:00
1 parent 8ec1712135
commit c307285a25
7 files changed
+131 -9

No files matched your search

+6 -6
View File
@@ -13,7 +13,7 @@
- [ ] T2 localePath 双 en 修复 + 自指 canonical + 尾斜杠归一 - [ ] T2 localePath 双 en 修复 + 自指 canonical + 尾斜杠归一
- claim: op-gitea(mesh 车道) - claim: op-gitea(mesh 车道)
- boundary: src/lib/locale.ts(先归一 zh 再拼前缀);BaseLayout.astro 加自指 canonical(Astro.site+withSlash);Header.astro nav-lang href 尾斜杠补齐;E2E 可加断言 - 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 守卫挂载 - [ ] T3 构建期内容 lint + 生产冒烟 + build.sh 守卫挂载
- claim: op-gitea(mesh 车道) - claim: op-gitea(mesh 车道)
@@ -23,24 +23,24 @@
- [ ] T4 reader 纯文本模式(html.reader + 命令面板 + r 键 + 持久化) - [ ] T4 reader 纯文本模式(html.reader + 命令面板 + r 键 + 持久化)
- claim: orchestrator - 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) - 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 标题+灰阶三点) - [ ] T5 代码块窗条(P 卡:≥4 行灰阶窗+mono 标题+灰阶三点)
- claim: op-artalk(mesh 车道) - claim: op-artalk(mesh 车道)
- boundary: 技术轨代码块渲染层(自查源码定落点:prose-tech 容器/全局 pre 样式);≥4 行块加窗条(灰阶三点非红黄绿+mono 语言/文件标签),单行块裸 pill;亮暗双态;scoped 样式优先不动 global.css(与 T7 划界) - 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 边框) - [ ] T6 书评纸面板(Q 卡:圆角米灰纸卡+暗色暖棕+hairline 边框)
- claim: op-artalk(mesh 车道) - claim: op-artalk(mesh 车道)
- boundary: 书评正文容器(prose-reading 所在组件 scoped 样式):圆角纸卡+米灰 #f4f4f2 系亮/暖棕暗态;边框 hairline 级防长文竖线感(08 裁定 §6-2);标题区外置结构不动;--paper 变量需求提给 orchestrator 入 tokens(T7 协同),不直接改 tokens.css - 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) - [ ] T7 版式基底(K/L/O 卡:流距 token+灰阶补档+暗色 hairline 降档+微交互曲线+focus ring)
- claim: orchestrator(tokens/global 专属车道) - 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);移动端密集扫描复测(涉布局面) - 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 卡:身份语+圆角统一+暗色衬底) - [ ] T8 首屏 tagline + 图库质感(M/N 卡:身份语+圆角统一+暗色衬底)
- claim: orchestrator - claim: orchestrator
- boundary: 首页身份区(IdCard 或等位组件)加 tagline ≤20 字(措辞备两案入完成总结供用户选,默认「写作者 · 读者 · 快门按压者」);GalleryPage 圆角统一 --radius-img 暗色 4% 衬底(裁定 §6-4 双模式分判;衬底圆角≤照片圆角防框中框);亮色直贴不动 - 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`
+9
View File
@@ -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/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 脚本未就绪,跳过" [ -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" echo "[build] rsync -> $STAGING"
mkdir -p "$STAGING" mkdir -p "$STAGING"
rsync -a --delete "$BUILD_REPO/dist/" "$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 if [ -d "$ORIG" ]; then rsync -a "$ORIG/" "$STAGING/photos-originals/"; fi
printf 'built %s at %s\n' "$SHA" "$(date -Is)" > "$STAGING/.build-stamp" printf 'built %s at %s\n' "$SHA" "$(date -Is)" > "$STAGING/.build-stamp"
echo "[build] done: $(find "$STAGING" -name '*.html' | wc -l) html files @ $SHA" 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
+80
View File
@@ -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) || '/';
// 站内 <a href>:仅根相对路径;#锚点/mailto/tel/外部/data: 全跳过
for (const m of html.matchAll(/<a\s[^>]*href="([^"]*)"/g)) {
const href = m[1];
if (!href.startsWith('/') || href.startsWith('//')) continue;
if (!targetExists(href)) fails.push(`断链 ${rel} → ${href}`);
}
// <img src> 与 srcset:产物必须存在(构建期副本检查,防部署后 404 图)
for (const m of html.matchAll(/<img\s[^>]*>/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} 配对警告`);
+25
View File
@@ -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
+3
View File
@@ -30,6 +30,8 @@ const rawPath = Astro.url.pathname.replace(/\/index\.html$/, '');
const zhPath = localeOf(rawPath) === 'en' ? localePath(rawPath, 'zh') : rawPath; const zhPath = localeOf(rawPath) === 'en' ? localePath(rawPath, 'zh') : rawPath;
const enPath = localePath(rawPath, 'en'); const enPath = localePath(rawPath, 'en');
const withSlash = (p: string) => (p.endsWith('/') ? p : `${p}/`); const withSlash = (p: string) => (p.endsWith('/') ? p : `${p}/`);
// 自指 canonical(T2):SEO 权重收敛到当前页自身,与 hreflang 组合覆盖双轨
const canonicalUrl = new URL(withSlash(rawPath), Astro.site).href;
--- ---
<!doctype html> <!doctype html>
@@ -87,6 +89,7 @@ const withSlash = (p: string) => (p.endsWith('/') ? p : `${p}/`);
addEventListener('load', () => navigator.serviceWorker.register('/sw.js')); addEventListener('load', () => navigator.serviceWorker.register('/sw.js'));
} }
</script> </script>
<link rel="canonical" href={canonicalUrl} />
<link rel="alternate" hreflang="zh" href={new URL(withSlash(zhPath), Astro.site)} /> <link rel="alternate" hreflang="zh" href={new URL(withSlash(zhPath), Astro.site)} />
<link rel="alternate" hreflang="en" href={new URL(withSlash(enPath), Astro.site)} /> <link rel="alternate" hreflang="en" href={new URL(withSlash(enPath), Astro.site)} />
<link rel="alternate" hreflang="x-default" href={new URL(withSlash(zhPath), Astro.site)} /> <link rel="alternate" hreflang="x-default" href={new URL(withSlash(zhPath), Astro.site)} />
+6 -3
View File
@@ -7,9 +7,12 @@ export type Locale = 'zh' | 'en';
export const localeOf = (pathname: string): Locale => export const localeOf = (pathname: string): Locale =>
pathname === '/en' || pathname.startsWith('/en/') ? 'en' : 'zh'; 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 { export function localePath(pathname: string, to: Locale): string {
const clean = pathname.replace(/\/index\.html$/, '').replace(/\/$/, '') || '/'; const clean = pathname.replace(/\/index\.html$/, '').replace(/\/$/, '') || '/';
if (to === 'en') return clean === '/' ? '/en/' : `/en${clean}`; const zh = clean.replace(/^\/en(?=\/|$)/, '') || '/';
return clean.replace(/^\/en(?=\/|$)/, '') || '/'; const slash = (p: string) => (p === '/' ? '/' : `${p}/`);
return to === 'en' ? slash(zh === '/' ? '/en' : `/en${zh}`) : slash(zh);
} }
+2
View File
@@ -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(' ')); 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'); const enBrand = await page.getAttribute('a.brand', 'href');
ok('en 品牌链接指 /en/', enBrand === '/en/', String(enBrand)); 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) 命令面板 + 搜索 // 4) 命令面板 + 搜索
await page.keyboard.press('Control+k'); await page.keyboard.press('Control+k');