Files
blog/scripts/build-perf/content-lint.mjs
T
wait4xandGLM-5.3 cfe508ff14 fix(opensource): 修视觉终审的四个 P1 与灰阶/对齐细节
视觉终审对开源页改版判「需修」,逐条修完并量化复验。

P1-1 数据板空档与三段右缘不齐:streak 只占半栏、snake 通栏 → 右侧空 306px。
- 网格改 `repeat(auto-fit, minmax(320px, 1fr))`,仅一张非通栏卡时加 `.pf-card-solo`
  (`grid-column: 1/-1`),语言条去掉 max-width
- 实测:统计行/语言条/数据板右缘统一 959px,streak 卡宽 298→638

P1-2 卡内 SVG 文字不可读:streak 固有 495px 被压到 298px(0.6×),14px 标签变 8.4px。
- 卡改全宽后 1:1 渲染(实测 rendered=495=natural);抓取参数把次级灰换成站点 ink:
  亮色 #3f424a(10.05:1)、暗色 #b9bec8(9.07:1),旧值 #9aa0aa 仅 2.63:1

P1-3 移动端打字机折行推挤:390 下第 4 句 260px > 可用 230px 必折两行,
min-height:1.6em 挡不住 → 每轮下推 21px 再弹回。
- `@media(max-width:560px){.typing{min-height:calc(1.6em*2)}}`(实测 42px 恒定零推挤)
- 光标收成 2px 竖条 + baseline 对齐(原来是 0.5em 实心块,两行时被拉到行间)
- typing 第 4 句裁短(中文 ≤16 字、英文 ≤36 字符)

P1-4 streak 卡入场动画空壳帧:SVG 内 fadein 序列(环 0.4s/数字 0.6s/…/1.2s)+ lazy
加载 → 用户先看到 0.5–1.5s 半成品,截图也会随机拍到空卡。
- 抓取脚本新增 freezeAnimations():剥掉全部 animation 声明、opacity:0 改 1、
  删除 @keyframes;重抓后 grep animation:/opacity: 0 均为 0(streak 各 6KB、
  snake 35KB→20KB)

P2 一并:
- streak 亮色容器 grayscale(1) 回到灰阶基调;暗色蛇卡改 grayscale+invert+contrast+
  brightness 组合,空网格不再与底色同色
- 项目墙描述 text-wrap: pretty(长描述末行孤词)
- 统计口径注释改为「三格」,不硬塞第 4 格(贡献数已由热力图承载)
- 社交图标行负 margin 做光学对齐

附:content-lint 增加 SKIP_ALL 白名单(一次性预览路由 /books-preview/ 无 en 对应页,
语言切换链接会误报断链+配对缺)

验证: 构建 51 页 exit 0;content-lint 54 页 0/0/0;E2E 46/46;
      Playwright 实测三段右缘 959 一致、streak 1:1、1280/390 零横向溢出、
      移动打字机高度 42px 恒定、光标 2px、项目墙 text-wrap=pretty

Spec: 2026-11-02_blog-5i-fonts-polish (T8 修复)

Co-authored-by: GLM-5.3
2026-11-03 11:48:25 +08:00

96 lines
4.1 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 页不参与配对
// 完全不参与检查的产物(连站内链接也不查):仅用于一次性预览路由。
// 书架方案预览页没有 en 对应页,而 Header 的语言切换会自动生成 /en/… 链接,
// 会误报「断链 + 配对缺」;该路由在用户选定方案后即删除。
const SKIP_ALL = new Set(['/books-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;
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) || '/';
if (SKIP_ALL.has(rel)) continue;
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} 配对警告`);