Files
blog/scripts/build-perf/fetch-readme-assets.mjs
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

143 lines
5.8 KiB
JavaScript
Raw Permalink 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
// README 动效资产抓取器(5i T8):把只能靠外部生成的 SVG 数据卡抓成本地静态文件
// 设计取舍:
// · 产物入仓(public/github-assets/)→ 构建确定性与离线可构建优先,不在 CI 里跑本脚本
// · 单资产失败保留旧文件;无旧文件则跳过(组件侧按存在性决定是否渲染该块)
// · 配色按站点 tokens 近似:透明底 + 文字 #33343a(亮)/#c9ccd4(暗) + 线条 #3454d1
// · manifest.json 记录来源 URL 与抓取时间(可追溯,不含任何凭据)
// 用法:node scripts/build-perf/fetch-readme-assets.mjs [--force]
import { mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
const OUT_DIR = 'public/github-assets';
const LOGIN = 'wait4xx';
const TIMEOUT_MS = 10_000;
const UA = 'wait4x-blog-assets/1.0 (+https://wait4x.online)';
// 站点 tokens 近似值(与灰阶纪律一致:底透明,线条单一强调色)
const ACCENT = '3454d1';
const INK_LIGHT = '33343a';
const INK_DARK = 'c9ccd4';
// 次级文字(Total Contributions / dates)也用 ink 档:旧的 #8b8f98/#9aa0aa 缩小后对比不足 4.5:1
const MUTED_LIGHT = '3f424a';
const MUTED_DARK = 'b9bec8';
/** 同一张卡按明暗两套参数各存一份(组件内用 CSS 切换,零运行时请求) */
const ASSETS = [
{
name: 'stats',
url: (theme) =>
`https://github-readme-stats.vercel.app/api?username=${LOGIN}` +
'&show_icons=true&hide_border=true&include_all_commits=true&count_private=true&rank_icon=github' +
`&bg_color=00000000&title_color=${ACCENT}&icon_color=${ACCENT}` +
`&text_color=${theme === 'dark' ? INK_DARK : INK_LIGHT}`,
label: 'GitHub 统计',
},
{
name: 'top-langs',
url: (theme) =>
`https://github-readme-stats.vercel.app/api/top-langs/?username=${LOGIN}` +
'&layout=compact&hide_border=true&langs_count=6' +
`&bg_color=00000000&title_color=${ACCENT}` +
`&text_color=${theme === 'dark' ? INK_DARK : INK_LIGHT}`,
label: '语言分布',
},
{
name: 'streak',
url: (theme) =>
`https://streak-stats.demolab.com/?user=${LOGIN}&hide_border=true` +
'&background=00000000' +
`&ring=${ACCENT}&fire=${ACCENT}&currStreakLabel=${ACCENT}` +
`&currStreakNum=${theme === 'dark' ? INK_DARK : INK_LIGHT}` +
`&sideNums=${theme === 'dark' ? INK_DARK : INK_LIGHT}` +
`&sideLabels=${theme === 'dark' ? MUTED_DARK : MUTED_LIGHT}` +
`&dates=${theme === 'dark' ? MUTED_DARK : MUTED_LIGHT}`,
label: '连续贡献',
},
{
// 贡献蛇:GitHub Action 生成,配色由 action 侧决定,无法传参 → 只抓一份
name: 'snake',
url: () => `https://raw.githubusercontent.com/${LOGIN}/${LOGIN}/output/github-contribution-grid-snake.svg`,
label: '贡献蛇',
single: true,
},
];
/**
* 冻结进场动画:streak-stats 的 SVG 里元素带 `style='opacity: 0; animation: fadein … forwards'`,
* loading=lazy 时用户会先看到 0.5–1.5s 的半成品。这里把动画声明剥掉、opacity 归 1,
* 静态呈现最终态(同时删掉 @keyframes 块,减小体积)。
*/
function freezeAnimations(svg) {
let out = svg.replace(/style='([^']*)'/g, (_, css) => {
const cleaned = css
.split(';')
.map((decl) => decl.trim())
.filter((decl) => decl && !decl.startsWith('animation'))
.map((decl) => (/^opacity\s*:\s*0\b/.test(decl) ? 'opacity: 1' : decl))
.join('; ');
return cleaned ? `style='${cleaned}'` : '';
});
out = out.replace(/animation\s*:\s*[^;'"]*;?/g, '');
out = out.replace(/@keyframes[^{]*\{(?:[^{}]*\{[^{}]*\})*[^{}]*\}/g, '');
return out;
}
/** 10s 超时抓取;返回 SVG 文本或 null */
async function grab(url) {
try {
const res = await fetch(url, {
headers: { 'User-Agent': UA, Accept: 'image/svg+xml,image/*;q=0.8,*/*;q=0.5' },
signal: AbortSignal.timeout(TIMEOUT_MS),
});
if (!res.ok) {
console.warn(`[assets] HTTP ${res.status}: ${url.slice(0, 90)}…`);
return null;
}
const text = await res.text();
if (!text.includes('<svg')) {
console.warn(`[assets] 非 SVG 响应,已跳过: ${url.slice(0, 90)}…`);
return null;
}
return freezeAnimations(text);
} catch (err) {
console.warn(`[assets] 抓取失败(${err.name === 'TimeoutError' ? '超时' : err.message}): ${url.slice(0, 90)}…`);
return null;
}
}
const force = process.argv.includes('--force');
mkdirSync(OUT_DIR, { recursive: true });
const manifestPath = join(OUT_DIR, 'manifest.json');
const manifest = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, 'utf8')) : { assets: {} };
const now = new Date().toISOString();
let ok = 0;
let kept = 0;
let skipped = 0;
for (const asset of ASSETS) {
const themes = asset.single ? [null] : ['light', 'dark'];
for (const theme of themes) {
const fileName = theme ? `${asset.name}-${theme}.svg` : `${asset.name}.svg`;
const filePath = join(OUT_DIR, fileName);
const url = asset.url(theme);
const svg = await grab(url);
if (svg) {
writeFileSync(filePath, svg, 'utf8');
manifest.assets[fileName] = { source: url, fetchedAt: now, label: asset.label, theme: theme ?? 'any' };
console.log(`[assets] ✓ ${fileName} (${Math.round(svg.length / 1024)}KB)`);
ok += 1;
} else if (existsSync(filePath)) {
console.log(`[assets] ↺ ${fileName} 抓取失败,保留旧文件`);
kept += 1;
} else {
console.warn(`[assets] ⊘ ${fileName} 抓取失败且无旧文件 → 构建时将跳过该块`);
skipped += 1;
}
}
}
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
console.log(`[assets] 完成:新增/更新 ${ok},保留旧文件 ${kept},跳过 ${skipped};manifest → ${manifestPath}`);
process.exit(0);