Files
blog/scripts/build-perf/content-lint.mjs
T
wait4xandGLM-5.3 7e0167a8bb feat(opensource): 热力图五方案预览 + 开源页叙述合一(5j T1 + 预设)
用户两项新需求:
①「开源页的叙述为什么要区分链接前和链接后?完全可以用 markdown 富文本统一替代」
②「热力图可以有更多的动效,而不是静态的一张图,探索相关方案供我审阅」

T1 叙述合一:
- opensource.yaml 新增 story(双语 markdown,链接内联),blurb/blurb_after 保留为
  legacy(story 为空时才用)→ 旧 yaml 零回归
- site-content 访问器取 story,GithubProfile 走 renderRich 行内模式;markdown 生成的
  裸 <a> 补 .pf-bio :global(a) 样式,与 .pf-link 观感一致
- 实测产物:pf-bio 内联 <a> 渲染成功、pf-link 拼接痕迹 0 处

热力图五方案预览(/heatmap-preview/,供用户选型):
- 新增 geek/HeatGrid.astro:可复用网格渲染件(371 格带 --c/--r 供错峰延迟),
  沿用线上同款 ink 灰阶(accent 只做高亮,不参与数据编码)
- A 入场波纹(视口内一次性斜向漫过)、B 光标光晕(radial-gradient 跟随 + 日期读数)、
  C 年度回放(滑块 scrub + 累计计数 + 自动播放)、D 连续电流(最近连续段流光 +
  连续天数大数)、E 悬停周详情(整列抬起 + 周合计/最好一天面板)
- 五案均守灰阶红线、均在 prefers-reduced-motion 下静态化、零第三方依赖、无常驻 rAF
- **两处真坑**:①网格是子组件渲染,页面级 scoped 样式够不到其内部元素 → 与格内元素
  相关的选择器必须 :global()(否则 A 的动画与 E 的抬列静默不生效)②「当前连续」按
  今天起算会得 0(今天尚未贡献)→ 改为以最后一个有贡献的日子为终点往回数,并标注
  「截至 <日期>」

另修 E2E 过时断言:书架改版为抽书阅读后,原「年份折叠组」断言必然失败,
改为「抽书行渲染 + 点击可抽开」(56/56 PASS)

验证: 构建 51 页 exit 0;content-lint 52 页 0/0/0;E2E 56/56;
      Playwright 逐案实测:A 动画 hmRise 0.42s 且播完 opacity=1、B 光晕 opacity=1
      并显示「日期 · 次数」、C 拖到第 10 周读数与淡出正确、D 12 格流光 hmFlow +
      连续 5 天(截至 2026-10-24)、E 整列 lift matrix(…,-2);390 零横向溢出;
      reduced-motion 下动画 none、光晕 display:none

Spec: 2026-11-03_blog-5j-content-refine (T1 + 热力图预览)

Co-authored-by: GLM-5.3
2026-11-04 20:09:10 +08:00

95 lines
3.9 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 对应页):完全不参与检查。用户选定方案后随路由一起删除。
const SKIP_ALL = new Set(['/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;
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} 配对警告`);