Files
blog/scripts/build-perf/content-lint.mjs
T
wait4xandGLM-5.3 44cd62e9a2 fix(lint): 豁免预览路由的 en 孪生断链——语言切换器给 zh-only 预览页生成 /en/ 链接
验证: node scripts/build-perf/content-lint.mjs dist → 通过(54 页 0 断链)

Spec: none (预览页需先上线供用户选择)

Co-authored-by: GLM-5.3
2026-11-05 11:02:58 +08:00

101 lines
4.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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,另有部署链保障)
// 已知不存在的 en 孪生目标(见 targetExists 内注释)
const KNOWN_MISSING_TWIN = new Set(['/en/heatmap-preview/']);
const SKIP_FILE = new Set([
'/404.html', // Astro 404 页不参与配对
// 一次性预览路由(预览-选型-落地模式):只有 zh 版,配对检查豁免;选定后随路由一起删
'/heatmap-preview/index.html',
]);
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;
}
/** URL 路径 → 磁盘路径:先解百分号编码(中文/空格文件名在 HTML 里是 %XX 形式) */
function decodePath(p) {
try {
return decodeURI(p);
} catch {
return p;
}
}
/** /x/ → dist/x/index.html;/a.svg → dist/a.svg;找不到即 null */
function targetExists(url) {
if (SKIP_PREFIX.some((s) => url.startsWith(s))) return true;
// 一次性预览路由的 en 孪生:语言切换器会给预览页生成 /en/… 链接,而预览只有 zh 版
// (页面本体已在 SKIP_FILE 豁免配对检查;此处豁免切换器链接,选定后随路由一起删)
if (KNOWN_MISSING_TWIN.has(url.split('#')[0].split('?')[0])) return true;
const p = decodePath(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 rel = f.slice(DIST.length) || '/';
const html = readFileSync(f, 'utf8');
// 站内 <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} 配对警告`);