用户实测:「有的文章同时选择了置顶和隐藏,还会显示并置顶」。
根因:后台「隐藏」开关(hiddenTag)是个假开关——它只写 `hiddenTag: true` 这个
没人读的字段,而全站过滤只认 `tags` 里的 `hidden`。于是隐藏从未生效;
叠加置顶后,这篇还在列表最前面。
- 新增 src/lib/post-visibility.ts:可见性契约单一事实源——`!draft && !hidden &&
!tags.includes('hidden')`(两条通道都认,历史写法兼容)
- postsSchema 增真字段 `hidden`;七处消费点(首页双轨/归档 zh/en/书架/RSS zh/en/
文章页导航)统一走 isVisiblePost
- 后台假开关 hiddenTag 换成真开关 hidden,hint 写清语义:不进列表/RSS/sitemap,
页面本身仍生成(链接可分享);与置顶同时打开时以隐藏为准
- sitemap 过滤隐藏文章(astro.config 配置期扫 frontmatter,两种写法都认;
内容目录不可读时宁可不排除也不炸构建)
贡献数据去重(5j T2,回答用户「连续贡献是否实时」):
- 连续贡献不再构建期抓取 streak.svg 快照(不实时、暗色需滤镜、与热力图同源重复),
改为热力图同一份 GraphQL 日历本地计算(src/lib/heatmap.ts streakStats):
当前连续(以最后有贡献日为终点)/ 最长 / 总贡献,界面上标注「数据更新于 X」
- 贡献蛇块与自托管 snake/streak SVG 删除,fetch 脚本 ASSETS 清空,
build.sh 守卫改为退役说明;开源页数据板整块并入热力图区
验证: 构建 51 页 exit 0;content-lint 52 页 0/0/0;E2E 56/56;
临时两篇「置顶+隐藏」(新字段/旧 tags 各一)实测——首页/归档/书架/
双语首页/RSS 双语/sitemap 全部零命中,页面本身仍生成;
清理后归档首条恢复 writing-template(普通置顶不受影响);
开源页 snake/streak 资产零残留、连续统计条与更新时间标注就位
Spec: 2026-11-03_blog-5j-content-refine (T2 + 隐藏 bug 修复)
Co-authored-by: GLM-5.3
131 lines
5.3 KiB
JavaScript
131 lines
5.3 KiB
JavaScript
#!/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: '语言分布',
|
||
},
|
||
{
|
||
// 贡献蛇: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);
|