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
This commit is contained in:
1 parent
59eda420b3
commit
6096a9dafe
3 files changed
+244
-25
No files matched your search
+41
-2
@@ -74,6 +74,13 @@ export interface LoadProjectsOptions {
|
||||
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 : []) {
|
||||
@@ -89,10 +96,42 @@ export async function loadProjects(options: LoadProjectsOptions = {}): Promise<R
|
||||
}
|
||||
|
||||
if (byName.size === 0) {
|
||||
console.warn('[opensource] projects:双源均无可用数据,改用仓内兜底 src/data/projects-fallback.json');
|
||||
const fb = (await import('../data/projects-fallback.json')).default as RepoCard[];
|
||||
return arrange(fb, pinned, mode, include, exclude, sort, perSource);
|
||||
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}」在双源结果中没有命中(仓名拼写?私有仓?源刚失败?)——它不会出现在项目墙上`,
|
||||
);
|
||||
}
|
||||
return arrange([...byName.values()], pinned, mode, include, exclude, sort, perSource);
|
||||
}
|
||||
|
||||
/** 来源过滤 + 可见性过滤 + 排序(不截断:上限交给调用方) */
|
||||
|
||||
+140
-23
@@ -140,6 +140,74 @@ export async function renderMarkdown(md: string): Promise<string> {
|
||||
return code;
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面文案富文本统一入口(层 1):所有站点单例文案字段(tagline/subtitle/blurb/blurb_after/intro…)
|
||||
* 都走同一条 markdown + raw + 内容净化 + class 白名单 + 链接卡片管线,
|
||||
* 后台因此可在这些字段里写 `<span class="font-kai c-blue">`。
|
||||
* 硬编码界面外壳(导航/页脚按钮)不走此路径——它们属 UI 而非内容。
|
||||
*/
|
||||
export async function renderRich(md: string, opts: { inline?: boolean } = {}): Promise<string> {
|
||||
const html = await renderMarkdown(md);
|
||||
if (!opts.inline) return html;
|
||||
// 行内场景(tagline / blurb 这类已在外层 <p> 里的短文本):剥掉 markdown 包的外层
|
||||
// <p>,否则 <p> 套 <p> 会被浏览器解析拆散,富文本 span 逃出容器(实测坑)
|
||||
return html.replace(/^\s*<p>/i, '').replace(/<\/p>\s*$/i, '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面级排版覆盖(层 2):读单例的 typography 字段,产出该页容器要用的
|
||||
* 类名与 CSS 变量(--page-font / --accent / --page-scale)。
|
||||
* 只作用于该页内容容器,不写全站 tokens——其它页面零影响。
|
||||
*/
|
||||
export interface PageTypography {
|
||||
className: string;
|
||||
style: string;
|
||||
}
|
||||
|
||||
const ALLOWED_PAGE_FONTS = new Set([
|
||||
'font-serif', 'font-kai', 'font-sans', 'font-mono',
|
||||
'font-song', 'font-kai-sys', 'font-hei-sys', 'font-fangsong',
|
||||
'font-huiwen', 'font-kuaile',
|
||||
]);
|
||||
const ALLOWED_PAGE_ACCENTS = new Set([
|
||||
'ink-accent', 'ink-accent-soft',
|
||||
...['blue', 'teal', 'green', 'yellow', 'orange', 'red', 'pink', 'purple'].flatMap((h) => [`c-${h}`, `c-${h}-soft`]),
|
||||
]);
|
||||
|
||||
export async function pageTypography(
|
||||
collection: 'siteHome' | 'siteAbout' | 'siteOpenSource',
|
||||
id: string,
|
||||
): Promise<PageTypography> {
|
||||
const entry = await getEntry(collection, id);
|
||||
const t = entry?.data?.typography as { font?: string; accent?: string; scale?: number } | undefined;
|
||||
const font = t?.font && ALLOWED_PAGE_FONTS.has(t.font) ? t.font : '';
|
||||
const accent = t?.accent && ALLOWED_PAGE_ACCENTS.has(t.accent) ? t.accent : '';
|
||||
const scale = typeof t?.scale === 'number' && t.scale >= 0.8 && t.scale <= 1.25 ? t.scale : 1;
|
||||
// 白名单外的值直接忽略(与 rehype 白名单同纪律:只认清单内的类)
|
||||
const vars: string[] = [];
|
||||
if (font) vars.push(`--page-font: ${fontVarFor(font)}`);
|
||||
if (accent) vars.push(`--page-accent: ${accentVarFor(accent)}`);
|
||||
if (scale !== 1) vars.push(`--page-scale: ${scale}`);
|
||||
return { className: font || accent || scale !== 1 ? 'page-typo' : '', style: vars.join('; ') };
|
||||
}
|
||||
|
||||
function fontVarFor(cls: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'font-serif': 'var(--font-serif)', 'font-kai': 'var(--font-kai)',
|
||||
'font-sans': 'var(--font-sans)', 'font-mono': 'var(--font-mono)',
|
||||
'font-song': 'var(--font-serif)', 'font-kai-sys': 'var(--font-kai)',
|
||||
'font-hei-sys': 'var(--font-sans)', 'font-fangsong': '"FangSong", STFangsong, serif',
|
||||
'font-huiwen': '"WF-Hwmc", var(--font-serif)', 'font-kuaile': '"ZCOOL KuaiLe", var(--font-serif)',
|
||||
};
|
||||
return map[cls] ?? 'inherit';
|
||||
}
|
||||
function accentVarFor(cls: string): string {
|
||||
if (cls === 'ink-accent') return 'var(--accent)';
|
||||
if (cls === 'ink-accent-soft') return 'var(--accent-soft-text)';
|
||||
const m = /^c-([a-z]+)(-soft)?$/.exec(cls);
|
||||
return m ? `var(--c-${m[1]}${m[2] ? '-soft' : ''})` : 'var(--accent)';
|
||||
}
|
||||
|
||||
/** 关于页正文(markdown,未渲染);空 → 默认文案 */
|
||||
export async function aboutIntroMarkdown(locale: Locale): Promise<string> {
|
||||
const entry = await getEntry('siteAbout', 'about');
|
||||
@@ -217,55 +285,104 @@ export async function openSourceOptions(): Promise<OpenSourceOptions> {
|
||||
/** GitHub profile README 缓存:取不到网就吃缓存,都无则调用方回落 yaml blurb */
|
||||
const README_CACHE = 'content/.github-readme.md';
|
||||
const PROFILE_REPO = 'wait4xx/wait4xx';
|
||||
/** README 请求上限:构建期不能被单个慢端点或半开连接卡住 */
|
||||
const README_TIMEOUT_MS = 10_000;
|
||||
|
||||
/**
|
||||
* 构建期取 profile 仓 README(Accept: raw),成功后回写 content/.github-readme.md(入仓缓存)
|
||||
* @returns README markdown;网络/权限/仓无 README 时回落缓存,仍无 → null
|
||||
* 构建期取 profile 仓 README(Accept: raw),成功后回写 content/.github-readme.md(入仓缓存)。
|
||||
*
|
||||
* 回落矩阵(四级,每一级都以 console.warn 点名原因,构建日志即可定位是哪级兜住的):
|
||||
* 1. 新鲜网络 → 内容有变才回写缓存(console.log)。
|
||||
* 2. token 缺位 / HTTP 非 2xx / 空内容 / 超时 / 网络异常 → 吃缓存 `content/.github-readme.md`。
|
||||
* 3. 缓存缺失或不可读 → 返回 null,调用方回落 yaml blurb。
|
||||
* 4. 缓存回写失败(构建仓只读挂载等)→ 仅告警,本次渲染照常。
|
||||
*
|
||||
* 已知限制:README 内的**相对链接不重写**(如 `./img/x.png`、`docs/foo.md`)——它们相对 GitHub
|
||||
* 仓根解析,在本站没有对应产物,点到即 404。保原样比猜一个基址可信;该限制在 docs/管理台.md
|
||||
* “开源页开篇”一节向用户明示。危险 HTML 标签已由 renderMarkdown 管线里的
|
||||
* rehype-content-sanitize 统一剥离,此处不再重复过滤。
|
||||
*
|
||||
* @returns README markdown;四级回落见上
|
||||
*/
|
||||
export async function githubReadmeMarkdown(): Promise<string | null> {
|
||||
const { readFileSync, writeFileSync, existsSync } = await import('node:fs');
|
||||
const { join } = await import('node:path');
|
||||
const cachePath = join(process.cwd(), README_CACHE);
|
||||
const readCache = (): string | null => {
|
||||
|
||||
/** 静默读缓存:缺失/不可读/空 → null(不打印,供回落函数与回写比对复用) */
|
||||
const readCacheQuiet = (): string | null => {
|
||||
try {
|
||||
return existsSync(cachePath) ? readFileSync(cachePath, 'utf8') : null;
|
||||
const md = existsSync(cachePath) ? readFileSync(cachePath, 'utf8') : null;
|
||||
return md && md.trim() ? md : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/** 第 2/3 级回落:先报原因,再报缓存有无 */
|
||||
const fallback = (cause: string): string | null => {
|
||||
const cached = readCacheQuiet();
|
||||
if (!cached) {
|
||||
console.warn(`[opensource] github-readme:${cause};缓存 ${README_CACHE} 亦缺,回落 yaml blurb`);
|
||||
return null;
|
||||
}
|
||||
console.warn(`[opensource] github-readme:${cause};改用缓存 ${README_CACHE}(${cached.length} 字符,可能陈旧)`);
|
||||
return hardenReadmeImages(cached);
|
||||
};
|
||||
|
||||
let token: string | null = null;
|
||||
try {
|
||||
token = readFileSync(join(process.env.HOME ?? '', 'docker_apps/blog/github-token.txt'), 'utf8').trim() || null;
|
||||
} catch {
|
||||
token = null;
|
||||
}
|
||||
if (!token) {
|
||||
console.warn('[opensource] github-readme:token 缺位,用缓存/回落 yaml blurb');
|
||||
return readCache();
|
||||
}
|
||||
if (!token) return fallback('token 缺位(~/docker_apps/blog/github-token.txt 不存在)');
|
||||
|
||||
try {
|
||||
const res = await fetch(`https://api.github.com/repos/${PROFILE_REPO}/readme`, {
|
||||
headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github.raw', 'User-Agent': 'wait4x-blog-build' },
|
||||
signal: AbortSignal.timeout(README_TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.warn(`[opensource] github-readme:${PROFILE_REPO} 取 README 失败 HTTP ${res.status},用缓存/回落`);
|
||||
return readCache();
|
||||
}
|
||||
if (!res.ok) return fallback(`${PROFILE_REPO} 取 README 失败 HTTP ${res.status}`);
|
||||
const md = (await res.text()).trim();
|
||||
if (!md) {
|
||||
console.warn('[opensource] github-readme:README 为空,用缓存/回落');
|
||||
return readCache();
|
||||
if (!md) return fallback('README 内容为空');
|
||||
if (readCacheQuiet() !== md) {
|
||||
try {
|
||||
writeFileSync(cachePath, `${md}\n`, 'utf8');
|
||||
console.log(`[opensource] github-readme:已刷新缓存 ${README_CACHE}(${md.length} 字符)`);
|
||||
} catch (err) {
|
||||
// 构建仓可能是只读挂载:缓存写不进不该拖垮本次渲染
|
||||
console.warn(`[opensource] github-readme:缓存回写失败(${(err as Error).message}),本次渲染不受影响`);
|
||||
}
|
||||
}
|
||||
const cached = readCache();
|
||||
if (cached !== md) {
|
||||
writeFileSync(cachePath, `${md}\n`, 'utf8');
|
||||
console.log(`[opensource] github-readme:已刷新缓存 ${README_CACHE}(${md.length} 字符)`);
|
||||
}
|
||||
return md;
|
||||
return hardenReadmeImages(md);
|
||||
} catch (err) {
|
||||
console.warn(`[opensource] github-readme:请求异常(${(err as Error).message}),用缓存/回落`);
|
||||
return readCache();
|
||||
const e = err as Error;
|
||||
const cause = e.name === 'TimeoutError' || e.name === 'AbortError'
|
||||
? `请求超时(>${README_TIMEOUT_MS}ms)`
|
||||
: `请求异常(${e.message})`;
|
||||
return fallback(cause);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* README 里的 <img>(shields.io 徽章 / readme-typing-svg 之类)加固:惰性加载 + alt 兜底。
|
||||
*
|
||||
* 为何只动 `loading` 与 `alt`:README 走 renderMarkdown 管线,出的 HTML 还要过
|
||||
* rehype-content-sanitize 的白名单(img 只保留 src/alt/width/height/loading),
|
||||
* 写在 markdown 层的 `decoding`/`referrerpolicy` 会被剥掉。惰性加载是徽章不挤压首屏的
|
||||
* 主力手段:下方徽章不占首屏带宽、不拖慢 loading 事件;破图时还有空 alt 兜底不留噪声。
|
||||
* 尺寸约束已由页面 scoped 样式 `img { max-width: 100%; height: auto }` 扣住。
|
||||
*
|
||||
* 幂等:已存在 loading 属性时覆写为同一策略,不会重复叠加。
|
||||
* @param md README markdown 原文
|
||||
* @returns 加固后的 markdown(缓存仍写原始内容,保证缓存与上游可比对)
|
||||
*/
|
||||
function hardenReadmeImages(md: string): string {
|
||||
return md.replace(/<img\b([^>]*)>/gi, (_match, attrs: string) => {
|
||||
let out = attrs;
|
||||
if (!/\salt=/i.test(out)) out += ' alt=""';
|
||||
out = /\sloading\b/i.test(out) ? out.replace(/\sloading="[^"]*"/i, ' loading="lazy"') : `${out} loading="lazy"`;
|
||||
return `<img${out}>`;
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user