用户两项新需求:
①「开源页的叙述为什么要区分链接前和链接后?完全可以用 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
95 lines
3.9 KiB
JavaScript
95 lines
3.9 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 对应页):完全不参与检查。用户选定方案后随路由一起删除。
|
||
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} 配对警告`);
|