#!/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} 配对警告`);