From 6096a9dafed4fcb60e8925a3ae72cf2ce77c9347 Mon Sep 17 00:00:00 2001 From: wait4xx Date: Mon, 2 Nov 2026 18:13:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(opensource):=20README=20=E5=9B=9B=E7=BA=A7?= =?UTF-8?q?=E5=9B=9E=E8=90=BD=20+=20=E5=85=9C=E5=BA=95=20JSON=208=20?= =?UTF-8?q?=E4=BB=93=20+=20=E9=A1=B9=E7=9B=AE=E5=8F=AF=E8=A7=81=E6=80=A7?= =?UTF-8?q?=E5=91=8A=E8=AD=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/data/projects-fallback.json | 63 ++++++++++++ src/lib/projects.ts | 43 ++++++++- src/lib/site-content.ts | 163 +++++++++++++++++++++++++++----- 3 files changed, 244 insertions(+), 25 deletions(-) diff --git a/src/data/projects-fallback.json b/src/data/projects-fallback.json index fe3abcf..280d33c 100644 --- a/src/data/projects-fallback.json +++ b/src/data/projects-fallback.json @@ -7,5 +7,68 @@ "stars": 0, "url": "https://git.wait4x.online/wait4x/blog", "updatedAt": "" + }, + { + "source": "github", + "name": "open-earth-data-guide", + "description": "A one-stop index of public earth-system (meteo · ocean · climate · environment) data sources — download channels & access notes. Companion CLI toolkit: open-earth-data-kit.", + "language": "Python", + "stars": 58, + "url": "https://github.com/wait4xx/open-earth-data-guide", + "updatedAt": "" + }, + { + "source": "github", + "name": "AI-CLI", + "description": "Run Terminal or AI coding assistants (Claude Code, Aider, etc.) on your phone/tablet — a responsive web IDE with terminal, split-pane editor, file browser, and real-time collaboration", + "language": "TypeScript", + "stars": 2, + "url": "https://github.com/wait4xx/AI-CLI", + "updatedAt": "" + }, + { + "source": "github", + "name": "open-earth-data-kit", + "description": "Unified CLI toolkit for public meteorological, oceanographic, climate and environmental data access.", + "language": "Python", + "stars": 2, + "url": "https://github.com/wait4xx/open-earth-data-kit", + "updatedAt": "" + }, + { + "source": "github", + "name": "pi-mesh", + "description": "Live agent-to-agent communication for Pi — local broker, mesh.v1 protocol, honest delivered/read/answered statuses, hash-only ledger. Zero runtime deps.", + "language": "TypeScript", + "stars": 0, + "url": "https://github.com/wait4xx/pi-mesh", + "updatedAt": "" + }, + { + "source": "github", + "name": "pi-board", + "description": "Shared context board for pi-mesh — typed entries, bounded store, boolean grep. CLI-first; the mesh stays the router, never the database.", + "language": "TypeScript", + "stars": 1, + "url": "https://github.com/wait4xx/pi-board", + "updatedAt": "" + }, + { + "source": "github", + "name": "pi-jobd", + "description": "Detached local job runner for Pi — per-user daemon, honest statuses, crash adoption, cross-platform. Zero runtime dependencies.", + "language": null, + "stars": 0, + "url": "https://github.com/wait4xx/pi-jobd", + "updatedAt": "" + }, + { + "source": "github", + "name": "ai-code-style-kit", + "description": "Comment & commit-message style kit for AI-assisted coding: cross-tool skills, cross-platform git hook, pi extension", + "language": "Shell", + "stars": 0, + "url": "https://github.com/wait4xx/ai-code-style-kit", + "updatedAt": "" } ] diff --git a/src/lib/projects.ts b/src/lib/projects.ts index 32b9229..4057e3b 100644 --- a/src/lib/projects.ts +++ b/src/lib/projects.ts @@ -74,6 +74,13 @@ export interface LoadProjectsOptions { export async function loadProjects(options: LoadProjectsOptions = {}): Promise { 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(); for (const r of gitea.status === 'fulfilled' ? gitea.value : []) { @@ -89,10 +96,42 @@ export async function loadProjects(options: LoadProjectsOptions = {}): Promise 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); } /** 来源过滤 + 可见性过滤 + 排序(不截断:上限交给调用方) */ diff --git a/src/lib/site-content.ts b/src/lib/site-content.ts index fec22aa..07c2f11 100644 --- a/src/lib/site-content.ts +++ b/src/lib/site-content.ts @@ -140,6 +140,74 @@ export async function renderMarkdown(md: string): Promise { return code; } +/** + * 页面文案富文本统一入口(层 1):所有站点单例文案字段(tagline/subtitle/blurb/blurb_after/intro…) + * 都走同一条 markdown + raw + 内容净化 + class 白名单 + 链接卡片管线, + * 后台因此可在这些字段里写 ``。 + * 硬编码界面外壳(导航/页脚按钮)不走此路径——它们属 UI 而非内容。 + */ +export async function renderRich(md: string, opts: { inline?: boolean } = {}): Promise { + const html = await renderMarkdown(md); + if (!opts.inline) return html; + // 行内场景(tagline / blurb 这类已在外层

里的短文本):剥掉 markdown 包的外层 + //

,否则

套

会被浏览器解析拆散,富文本 span 逃出容器(实测坑) + return html.replace(/^\s*

/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 { + 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 = { + '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 { const entry = await getEntry('siteAbout', 'about'); @@ -217,55 +285,104 @@ export async function openSourceOptions(): Promise { /** 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 { 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 里的 (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(/]*)>/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 ``; + }); +}