视觉终审对开源页改版判「需修」,逐条修完并量化复验。
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
96 lines
4.1 KiB
JavaScript
96 lines
4.1 KiB
JavaScript
#!/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} 配对警告`);
|