Files
blog/src/lib/projects.ts
T
wait4xandGLM-5.3 6096a9dafe feat(opensource): README 四级回落 + 兜底 JSON 8 仓 + 项目可见性告警
- site-content.ts:README 取数加 AbortSignal.timeout(10s)(原无超时,
  慢端点可卡整条构建);四级回落矩阵逐级 console.warn 点名原因:
  新鲜网络(内容有变才回写缓存)→ token 缺位/HTTP 非 2xx/空/超时/异常
  时吃缓存 → 缓存亦缺回落 yaml blurb → 缓存回写失败(构建仓只读)仅告警
- README 图片加固:markdown 层给每个 img 补 loading=lazy + 缺省 alt
  (幂等;只动这两个属性,因为过 rehype-content-sanitize 白名单后其余
  属性会被剥);相对链接不重写,限制写入 JSDoc 与文档
- projects.ts:单源失败点名、双源全失败点名回落 JSON、mode=only 的
  include 未命中双向点名(双源无此仓 / 被 perSource 过滤掉)
- projects-fallback.json:1 → 8 条真实仓(blog / open-earth-data-guide★58
  / AI-CLI / open-earth-data-kit / pi-mesh / pi-board / pi-jobd /
  ai-code-style-kit,字段对齐 RepoCard)

车道:worker-one(T5);收编复验:E2E 37/37、构建 48 页 0 ERROR、
兜底 JSON 8 条断言过

验证: E2E 37/37 PASS;pnpm astro build 48 页 exit 0;fallback 8 条

Spec: 2026-11-02_blog-5g-pages (T5)

Co-authored-by: GLM-5.3
2026-11-02 18:13:47 +08:00

161 lines
6.5 KiB
TypeScript
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.
// 项目墙数据(极客层 P0):构建时双源拉取 + 仓内缓存兜底
// 双源 = 自家 gitea(数据自控叙事)+ GitHub(社区 star 真值);置顶清单人工维护
export interface RepoCard {
source: 'gitea' | 'github';
name: string;
description: string;
language: string | null;
stars: number;
url: string;
updatedAt: string;
}
interface RawRepo {
full_name?: string;
name?: string;
description?: string | null;
language?: string | null;
stars_count?: number;
stargazers_count?: number;
html_url?: string;
updated_at?: string;
}
export const GITEA_API = 'https://git.wait4x.online/api/v1/users/wait4x/repos?limit=50';
export const GITHUB_API = 'https://api.github.com/users/wait4xx/repos?per_page=100&sort=updated';
/** 默认置顶(仅在调用方未传 pinned 时生效):现由 content/site/opensource.yaml 的 pinned 供数 */
export const PINNED_DEFAULT: string[] = ['blog'];
async function fetchJson(url: string): Promise<RawRepo[]> {
const res = await fetch(url, {
headers: { Accept: 'application/json', 'User-Agent': 'wait4x-blog-build' },
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) throw new Error(`${url} -> ${res.status}`);
return (await res.json()) as RawRepo[];
}
function toCard(r: RawRepo, source: RepoCard['source']): RepoCard | null {
const name = r.name ?? r.full_name?.split('/')[1];
if (!name || !r.html_url) return null;
return {
source,
name,
description: r.description ?? '',
language: r.language ?? null,
stars: r.stars_count ?? r.stargazers_count ?? 0,
url: r.html_url,
updatedAt: r.updated_at ?? '',
};
}
/**
* 拉双源合并去重(同名优先 gitea 自托管叙事,star 取 GitHub 真值)。
* 任一源失败不抛:置顶项回落仓内缓存 JSON(构建产物绝不被网络单点卡死)。
*/
export interface LoadProjectsOptions {
/** 置顶仓短名(顺序即顺序),缺省 PINNED_DEFAULT */
pinned?: string[];
/** 可见性:all=全部 / only=仅 include / exclude=排除 exclude */
mode?: 'all' | 'only' | 'exclude';
include?: string[];
exclude?: string[];
/** 排序:pinned-first(默认)/ stars / updated */
sort?: 'pinned-first' | 'stars' | 'updated';
/** 来源过滤:both(默认)/ github / gitea */
perSource?: 'both' | 'github' | 'gitea';
}
/**
* 拉取双源并按选项过滤/排序(选项来自 content/site/opensource.yaml,缺省 = 现状行为)
* @returns 未截断的完整列表:展示上限由调用方 slice(首页 6 / 开源页 99)
*/
export async function loadProjects(options: LoadProjectsOptions = {}): Promise<RepoCard[]> {
const { pinned = PINNED_DEFAULT, mode = 'all', include = [], exclude = [], sort = 'pinned-first', perSource = 'both' } = options;
const [gitea, github] = await Promise.allSettled([fetchJson(GITEA_API), fetchJson(GITHUB_API)]);
// 单源失败不拦构建,但必须在日志里点名——否则“怪了,这个仓怎么没显示”要排查很久
if (gitea.status === 'rejected') {
console.warn(`[opensource] projects:gitea 源不可用(${(gitea.reason as Error).message}),仅用 GitHub 源`);
}
if (github.status === 'rejected') {
console.warn(`[opensource] projects:GitHub 源不可用(${(github.reason as Error).message}),仅用 gitea 源`);
}
const byName = new Map<string, RepoCard>();
for (const r of gitea.status === 'fulfilled' ? gitea.value : []) {
const c = toCard(r, 'gitea');
if (c) byName.set(c.name, c);
}
for (const r of github.status === 'fulfilled' ? github.value : []) {
const c = toCard(r, 'github');
if (!c) continue;
const existing = byName.get(c.name);
if (!existing) byName.set(c.name, c);
else if (c.stars > existing.stars) existing.stars = c.stars; // 社区真值覆盖镜像计数
}
if (byName.size === 0) {
console.warn('[opensource] projects:双源均无可用数据,改用仓内兜底 src/data/projects-fallback.json');
const fb = (await import('../data/projects-fallback.json')).default as RepoCard[];
const out = arrange(fb, pinned, mode, include, exclude, sort, perSource);
warnMissedIncludes(out, fb, include, mode, perSource);
return out;
}
const merged = [...byName.values()];
const out = arrange(merged, pinned, mode, include, exclude, sort, perSource);
warnMissedIncludes(out, merged, include, mode, perSource);
return out;
}
/**
* mode=only 时点名 include 里“写了却没显示”的仓名——避开设了一个看不见的黑洞。
* 未命中分两类,日志措词区分:①双源结果里根本没有(拼写错 / 私有仓 / 该源刚失败);
* ②有,但被 perSource 过滤掉了(配置与仓来源不匹配)。
*/
function warnMissedIncludes(
showing: RepoCard[],
pool: RepoCard[],
include: string[],
mode: 'all' | 'only' | 'exclude',
perSource: 'both' | 'github' | 'gitea',
): void {
if (mode !== 'only' || include.length === 0) return;
const shown = new Set(showing.map((r) => r.name));
const available = new Map(pool.map((r) => [r.name, r.source]));
for (const name of include) {
if (shown.has(name)) continue;
const src = available.get(name);
console.warn(
src
? `[opensource] projects:include 里的 「${name}」命中 ${src} 源,但被 perSource=${perSource} 过滤掉——改 perSource=both 或把它移出 include`
: `[opensource] projects:include 里的 「${name}」在双源结果中没有命中(仓名拼写?私有仓?源刚失败?)——它不会出现在项目墙上`,
);
}
}
/** 来源过滤 + 可见性过滤 + 排序(不截断:上限交给调用方) */
function arrange(
list: RepoCard[],
pinned: string[],
mode: 'all' | 'only' | 'exclude',
include: string[],
exclude: string[],
sort: 'pinned-first' | 'stars' | 'updated',
perSource: 'both' | 'github' | 'gitea',
): RepoCard[] {
let out = perSource === 'both' ? list : list.filter((r) => r.source === perSource);
if (mode === 'only') out = out.filter((r) => include.includes(r.name));
else if (mode === 'exclude') out = out.filter((r) => !exclude.includes(r.name));
if (sort === 'stars') return [...out].sort((a, b) => b.stars - a.stars);
if (sort === 'updated') {
return [...out].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
}
return [...out].sort((a, b) => {
const pa = pinned.indexOf(a.name), pb = pinned.indexOf(b.name);
if (pa !== -1 || pb !== -1) return (pa === -1 ? 99 : pa) - (pb === -1 ? 99 : pb);
return b.stars - a.stars;
});
}