Files
blog/scripts/build-perf/content-lint.mjs
T
wait4xandGLM-5.3 d096d45c58 feat(books): 书架定稿方案 C——抽书阅读 + 多本置顶默认展开
用户拍板选 C,置顶标记改为向上箭头图标(不写文字),并确认支持多本置顶。

- 抽书列表:情绪色脊 + 定宽标题列 + 展开 chevron,details 原生展开看封面与简介
- 置顶:所有 pinned 书排最前且默认展开,标记换成 ↑+横线 图标
- 删除一次性预览路由 /books-preview/ 与其 lint 白名单

验证: 构建 exit 0;content-lint 53 页 0/0/0;Playwright 实测展开/箭头旋转/
      作者列对齐/390 无溢出;两本临置顶实测均排前且同时展开

Spec: none (先行上线,完整 5i 包仍在 spec/2026-11-02_blog-5i-fonts-polish)

Co-authored-by: GLM-5.3
2026-11-03 12:28:30 +08:00

91 lines
3.7 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,另有部署链保障)
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;
}
/** 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;
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} 配对警告`);