- 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
161 lines
6.5 KiB
TypeScript
161 lines
6.5 KiB
TypeScript
// 项目墙数据(极客层 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;
|
||
});
|
||
}
|