From 3e377adc7fb4a8f2f0be56633d35b2413965b458 Mon Sep 17 00:00:00 2001 From: wait4xx Date: Mon, 2 Nov 2026 20:35:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(opensource):=20=E5=BC=80=E6=BA=90=E9=A1=B5?= =?UTF-8?q?=E6=94=B9=E5=8E=9F=E7=94=9F=E6=A1=A3=E6=A1=88=E5=8D=A1=EF=BC=8C?= =?UTF-8?q?=E4=B8=AA=E4=BA=BA=E5=8F=99=E8=BF=B0=E8=BF=81=E5=BE=80=E5=85=B3?= =?UTF-8?q?=E4=BA=8E=E9=A1=B5=EF=BC=885h=20T5/T6=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户原话:开源页开篇是从 GitHub README 直接移植的,「部分内容没有居中,有些图片 也没有正常显示,可以自行进行美化,无需完全一致……有些可以直接移植到关于页面」。 病根:README 是 GitHub 专用 markdown——`
` 靠 HTML 属性居中 (本站按普通块渲染,故"没居中"),且依赖 7 类外链图(readme-typing-svg / shields.io / github-readme-stats / streak-stats / activity-graph / snake / komarev),慢网或被墙时全空白(故"图片不显示")。 T5 开源页重构: - 新增 geek/GithubProfile.astro:头像 / 名字@handle / 定位叙述 / 社交图标 / 统计四格(followers、公开仓、总星标)/ 语言占比灰阶条(仅第一名 accent, 其余 tokens 灰阶)——**零外链图、零 iframe、零第三方徽章** - 语言聚合复用 loadProjects 的结果;followers 与公开仓数走 GitHub REST - 任一取数失败只隐藏该项(catch + warn),绝不炸构建 - opensource.astro / en:档案卡承接原 README 位置 → 热力图 → 项目墙; README 渲染降级为兼容通道(intro_from=off 时不执行),yaml 注释写明 7 类外链图与 align 失效原因并标「不推荐」 - **修既有隐患**:GitHub API 请求原先不带 token,匿名 60 次/小时在密集构建下 被打满 → 项目墙退化成兜底 JSON(今天一度只剩 1 条)。现对 api.github.com 自动附 Bearer(token 读宿主 0600 文件,不入仓),墙恢复 16 条、总星标 0→66、 语言占比变真值 T6 关于页承接: - about.yaml blocks 扩为 prose / tracks(兴趣版图)/ prose(关于我)/ linklist(找到我:本站·邮件·GitHub·自建 gitea·RSS)/ prose(本站如何构建), 双语齐备;AboutPage 零改动(blocks 早已驱动) - 原则落地:关于页讲人,开源页讲开源,两边不再重复 验证: 构建 51 页 exit 0;content-lint 51 页 0/0/0;config-check 全过; E2E **46/46 PASS**(37 原有 + 本包新增 9 项:置顶顺序与徽标、文章页计数、 模板彩色与折叠、瞬间评论区与只读计数、关于页块驱动); dist/opensource 七类第三方外链零命中、第三方 img 数 0;数据实值: 语言占比 Python/TypeScript 各 36%、Shell/Rust/JS 各 9% Spec: 2026-11-02_blog-5h-content-social (T5/T6) Co-authored-by: GLM-5.3 --- .../tasks.md | 4 +- content/site/about.yaml | 63 +++++ content/site/opensource.yaml | 6 +- src/components/geek/GithubProfile.astro | 257 ++++++++++++++++++ src/lib/projects.ts | 54 +++- src/pages/en/opensource.astro | 14 +- src/pages/opensource.astro | 16 +- tests/e2e.mjs | 22 ++ 8 files changed, 415 insertions(+), 21 deletions(-) create mode 100644 src/components/geek/GithubProfile.astro diff --git a/.spec/specs/2026-11-02_blog-5h-content-social/tasks.md b/.spec/specs/2026-11-02_blog-5h-content-social/tasks.md index d31a50c..08fc7d2 100644 --- a/.spec/specs/2026-11-02_blog-5h-content-social/tasks.md +++ b/.spec/specs/2026-11-02_blog-5h-content-social/tasks.md @@ -28,12 +28,12 @@ - boundary: src/components/geek/ViewMeta.astro(**契约组件,由 orchestrator 在 T1 提交时一并落盘**,车道只消费不改语义)、src/components/ArtalkComments.astro(pv 小字行改为 ViewMeta,评论数取 `comments?limit=1` 的 count)、src/pages/archives/[...slug].astro、src/pages/en/archives/[...slug].astro(挂 ViewMeta) - verify: `test -f src/components/geek/ViewMeta.astro && grep -q "ViewMeta" src/components/ArtalkComments.astro && grep -q "ViewMeta" src/pages/archives/[...slug].astro && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -q "37/37 PASS"` -- [ ] T5 开源页重构(原生 GitHub 档案卡,去外链图依赖) +- [x] T5 开源页重构(原生 GitHub 档案卡,去外链图依赖) - claim: op-gitea - boundary: 新增 src/components/geek/GithubProfile.astro(头像/名字/简介/关注者/总星标/语言占比,数据来自 projects.ts 与 GraphQL;零第三方图片与徽章)、src/lib/projects.ts(补统计聚合:followers/stars/language 汇总,失败降级不炸构建)、src/pages/opensource.astro、src/pages/en/opensource.astro(用 GithubProfile 替换 readme 渲染段;README 通道保留开关但默认 off 且标注不推荐)、content/site/opensource.yaml(注释说明 intro_from 现状) - verify: `grep -q "GithubProfile" src/pages/opensource.astro && python3 -c "import re,pathlib;p=pathlib.Path('dist/opensource/index.html').read_text();bad=[u for u in ['img.shields.io','readme-typing-svg','github-readme-stats','streak-stats','komarev','github-readme-activity-graph'] if u in p];assert not bad,bad"` -- [ ] T6 关于页承接 README 的个人叙述 +- [x] T6 关于页承接 README 的个人叙述 - claim: op-gitea - boundary: content/site/about.yaml(新增 blocks:关于/兴趣 prose、站点与项目 linklist、Find Me 社交块;双语 zh/en;测试值不得留在文件里)、src/pages/about.astro 与 en(仅当块渲染需要时改,优先零改动) - verify: `python3 -c "import yaml;d=yaml.safe_load(open('content/site/about.yaml'));types=[b['type'] for b in d.get('blocks') or []];assert 'prose' in types and ('linklist' in types or 'cards' in types),types" && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -q "37/37 PASS"` diff --git a/content/site/about.yaml b/content/site/about.yaml index 0e07f45..0be8622 100644 --- a/content/site/about.yaml +++ b/content/site/about.yaml @@ -62,6 +62,69 @@ blocks: zh: 街拍与山野,随身一台无反,在等光的间隙按快门。 en: Street and landscapes with a mirrorless camera, pressing the shutter while waiting for the light. + - type: prose + title: + zh: 关于我 + en: About me + body: + zh: | + 气象是我起点的专业——一个想做预报的人,依旧把云图当日常读物。 + + 💻 写代码是第二条主线:Linux、容器、自托管,把日常工具一件件磨顺手。 + + 🌐 作品大多在 [wait4x.online](https://wait4x.online/)。 + + ⚡ 信条:**拥抱开源,热爱开源**。 + en: | + Meteorology is where I started — an aspiring forecaster who still reads weather charts for fun. + + 💻 Coding is the second thread: Linux, containers, self-hosting — polishing everyday tools one by one. + + 🌐 Most of my work lives at [wait4x.online](https://wait4x.online/). + + ⚡ Motto: **embrace open source, love open source**. + + - type: linklist + title: + zh: 找到我 + en: Find me + items: + - label: + zh: 本站 + en: Website + href: 'https://wait4x.online/' + note: + zh: 文章、图库与瞬间 + en: Posts, gallery and moments + - label: + zh: 邮件 + en: Email + href: 'mailto:admin@wait4x.online' + note: + zh: 有话直说 + en: Say hello directly + - label: + zh: GitHub + en: GitHub + href: 'https://github.com/wait4xx' + note: + zh: 开源项目与提交 + en: Open-source projects and commits + - label: + zh: 自托管 gitea + en: Self-hosted gitea + href: 'https://git.wait4x.online/wait4x/blog' + note: + zh: 本站源码 + en: Source of this site + - label: + zh: RSS + en: RSS + href: /rss.xml + note: + zh: 订阅更新 + en: Subscribe to updates + - type: prose title: zh: 本站如何构建 diff --git a/content/site/opensource.yaml b/content/site/opensource.yaml index b0dff4e..da5eb7f 100644 --- a/content/site/opensource.yaml +++ b/content/site/opensource.yaml @@ -18,5 +18,9 @@ exclude: [] sort: pinned-first # 来源过滤:both=双源合并(默认) | github | gitea perSource: both -# 开篇文案来源:off=用本文件 blurb(默认) | github-readme=构建期取 wait4xx/wait4xx 的 README 渲染 +# 开篇来源(compat,**不推荐开启**):off=用本文件 blurb + 原生档案卡(默认) +# github-readme = 构建期取 wait4xx/wait4xx 的 README 原样渲染在档案卡下方 +# ⚠ 开启会引入 7 类第三方外链图(readme-typing-svg / shields.io 徽章 / github-readme-stats / +# streak-stats / activity-graph / 贡献蛇 / komarev 访客徽章),慢网或被墙时整片空白; +# 且 README 用
居中,本站渲染不认该属性 → 居中失效。内容已由档案卡承接。 intro_from: "off" diff --git a/src/components/geek/GithubProfile.astro b/src/components/geek/GithubProfile.astro new file mode 100644 index 0000000..ce4372f --- /dev/null +++ b/src/components/geek/GithubProfile.astro @@ -0,0 +1,257 @@ +--- +// 开源页原生档案卡(5h T5):零第三方图片、零 iframe +// 数据来源:①content/site/home.yaml 的头像/社交(单一事实源)②content/site/opensource.yaml 的 +// 定位叙述(blurb/blurb_after,双语可后台改)③GitHub REST 的 followers/公开仓数(token 在宿主 +// 0600 文件,缺位或失败即隐藏该数字)④src/lib/projects.ts 的仓库列表(语言占比与星标汇总) +// 降级纪律:任何一项取数失败只隐藏该项,绝不炸构建、不出现空图片框 +import { Image } from 'astro:assets'; +import { homeAvatar, homeSocials, openSourceCopy } from '../../lib/site-content'; +import { githubUserStats, loadProjects } from '../../lib/projects'; +import { iconPath } from '../../lib/icons'; +import type { Locale } from '../../lib/locale'; + +interface Props { + locale?: Locale; +} +const { locale = 'zh' } = Astro.props; + +const avatar = await homeAvatar(); +const socials = await homeSocials(); +const copy = await openSourceCopy(locale); + +// 仓库数据:档案卡只关心"语言占比 + 星标汇总",取全量(不受项目墙可见性影响) +let langs: { name: string; count: number; pct: number }[] = []; +let totalStars: number | null = null; +try { + const repos = await loadProjects({ mode: 'all', sort: 'stars' }); + if (repos.length) { + totalStars = repos.reduce((sum, r) => sum + (r.stars ?? 0), 0); + const tally = new Map(); + for (const r of repos) { + if (!r.language) continue; + tally.set(r.language, (tally.get(r.language) ?? 0) + 1); + } + const total = [...tally.values()].reduce((a, b) => a + b, 0); + langs = [...tally.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 5) + .map(([name, count]) => ({ + name, + count, + pct: total ? Math.round((count / total) * 100) : 0, + })); + } +} catch { + console.warn('[profile] 仓库数据不可用,语言占比与星标汇总已隐藏'); +} + +const stats = await githubUserStats(); +const L = { + zh: { followers: '关注者', repos: '公开仓库', stars: '总星标', langs: '语言占比' }, + en: { followers: 'Followers', repos: 'Public repos', stars: 'Total stars', langs: 'Languages' }, +}[locale]; +--- + +
+
+ {avatar ? ( + wait4x + ) : ( + + )} +
+

+ wait4x@wait4xx +

+

+ {copy.blurb}{copy.repoLabel}{copy.blurbAfter} +

+ +
+
+ + {(stats || totalStars !== null) && ( +
+ {stats && ( + <> +
{L.followers}
{stats.followers}
+
{L.repos}
{stats.publicRepos}
+ + )} + {totalStars !== null && ( +
{L.stars}
{totalStars}
+ )} +
+ )} + + {langs.length > 0 && ( +
+

{L.langs}

+
    + {langs.map((l, i) => ( +
  • + {l.name} +
  • + ))} +
+
+ )} +
+ + diff --git a/src/lib/projects.ts b/src/lib/projects.ts index 4057e3b..799e49d 100644 --- a/src/lib/projects.ts +++ b/src/lib/projects.ts @@ -1,3 +1,5 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; // 项目墙数据(极客层 P0):构建时双源拉取 + 仓内缓存兜底 // 双源 = 自家 gitea(数据自控叙事)+ GitHub(社区 star 真值);置顶清单人工维护 export interface RepoCard { @@ -28,8 +30,18 @@ export const GITHUB_API = 'https://api.github.com/users/wait4xx/repos?per_page=1 export const PINNED_DEFAULT: string[] = ['blog']; async function fetchJson(url: string): Promise { + const headers: Record = { 'User-Agent': 'wait4x-blog-build' }; + // GitHub 无鉴权仅 60 次/小时(多次构建就耗尽)→ 有 token 就带上(5000/小时) + if (url.includes('api.github.com')) { + try { + const token = readFileSync(join(process.env.HOME ?? '', 'docker_apps/blog/github-token.txt'), 'utf8').trim(); + if (token) headers.Authorization = `Bearer ${token}`; + } catch { + /* token 缺位则匿名(可能被限流,走兜底) */ + } + } const res = await fetch(url, { - headers: { Accept: 'application/json', 'User-Agent': 'wait4x-blog-build' }, + headers: { ...headers, Accept: 'application/json' }, signal: AbortSignal.timeout(10_000), }); if (!res.ok) throw new Error(`${url} -> ${res.status}`); @@ -158,3 +170,43 @@ function arrange( return b.stars - a.stars; }); } + +/** GitHub 账号统计(followers / public repos):token 缺位或请求失败一律返回 null(调用方隐藏数字) */ +export interface GithubUserStats { + followers: number; + publicRepos: number; +} + +export async function githubUserStats( + login = 'wait4xx', +): Promise { + const { readFileSync } = await import('node:fs'); + const { join } = await import('node:path'); + let token: string | null = null; + try { + token = readFileSync(join(process.env.HOME ?? '', 'docker_apps/blog/github-token.txt'), 'utf8').trim() || null; + } catch { + token = null; + } + try { + const res = await fetch(`https://api.github.com/users/${login}`, { + headers: { + ...(token ? { Authorization: `Bearer ${token}` } : {}), + Accept: 'application/vnd.github+json', + 'User-Agent': 'wait4x-blog-build', + }, + }); + if (!res.ok) { + console.warn(`[profile] GitHub 用户统计获取失败 HTTP ${res.status},相关数字已隐藏`); + return null; + } + const data = (await res.json()) as { followers?: number; public_repos?: number }; + return { + followers: Number(data.followers ?? 0), + publicRepos: Number(data.public_repos ?? 0), + }; + } catch (err) { + console.warn(`[profile] GitHub 用户统计请求异常(${(err as Error).message}),相关数字已隐藏`); + return null; + } +} diff --git a/src/pages/en/opensource.astro b/src/pages/en/opensource.astro index 27d7f2a..6289e1d 100644 --- a/src/pages/en/opensource.astro +++ b/src/pages/en/opensource.astro @@ -2,25 +2,23 @@ import ProjectWall from '../../components/geek/ProjectWall.astro'; import Heatmap from '../../components/geek/Heatmap.astro'; import BaseLayout from '../../layouts/BaseLayout.astro'; +import GithubProfile from '../../components/geek/GithubProfile.astro'; import { t } from '../../i18n/ui'; -import { githubReadmeMarkdown, openSourceCopy, openSourceOptions, pageTypography, renderMarkdown, renderRich } from '../../lib/site-content'; +import { githubReadmeMarkdown, openSourceCopy, openSourceOptions, pageTypography, renderMarkdown } from '../../lib/site-content'; const copy = await openSourceCopy('en'); const options = await openSourceOptions(); const readmeMd = options.introFrom === 'github-readme' ? await githubReadmeMarkdown() : null; const readmeHtml = readmeMd ? await renderMarkdown(readmeMd) : null; -const blurbHtml = await renderRich(copy.blurb, { inline: true }); -const blurbAfterHtml = await renderRich(copy.blurbAfter, { inline: true }); const typo = await pageTypography('siteOpenSource', 'opensource'); ---

Open Source

- {readmeHtml ? ( -
- ) : ( -

${copy.repoLabel}${blurbAfterHtml}`} /> - )} + + {/* compat path (not recommended): renders the profile README below the card when + intro_from=github-readme. It pulls 7 kinds of third-party images and loses align centering. */} + {readmeHtml &&

} ) -const blurbHtml = await renderRich(copy.blurb, { inline: true }); -const blurbAfterHtml = await renderRich(copy.blurbAfter, { inline: true }); const typo = await pageTypography('siteOpenSource', 'opensource'); ---

开源Open Source

- {readmeHtml ? ( -
- ) : ( -

${copy.repoLabel}${blurbAfterHtml}`} /> - )} + + {/* 兼容通道(不推荐):intro_from=github-readme 时把 profile README 原样渲染在档案卡下方。 + 它会引入 7 类第三方外链图(shields.io / readme-typing-svg / stats / snake / 访客徽章)并带失效的 + align 居中;慢网或被墙时整片空白。默认 off,正常内容由上方原生档案卡 + opensource.yaml 叙述承载。 */} + {readmeHtml &&

} 0); ok('项目墙渲染', await page.locator('.project-wall, .repo').count() > 0); +// 0) Phase 5h:置顶 / 计数徽标 / 瞬间富文本与逐条评论 / 块式关于页 +await page.goto(BASE + '/archives/', { waitUntil: 'networkidle' }); +const firstArchive = await page.locator('.post-list a.row').first().getAttribute('href'); +ok('5h·置顶排在归档首位', (firstArchive ?? '').includes('/archives/writing-template/'), firstArchive ?? 'none'); +ok('5h·置顶徽标可见', (await page.locator('.pin').count()) > 0); + +await page.goto(BASE + '/archives/writing-template/', { waitUntil: 'networkidle' }); +ok('5h·文章页计数组件挂载', (await page.locator('[data-viewmeta]').count()) >= 2, `count=${await page.locator('[data-viewmeta]').count()}`); +ok('5h·模板彩色强调生效', (await page.locator('.prose-tech .c-blue').count()) > 0); +ok('5h·模板折叠块渲染', (await page.locator('.prose-tech details').count()) > 0); + +await page.goto(BASE + '/moments/', { waitUntil: 'networkidle' }); +ok('5h·瞬间带逐条评论区', (await page.locator('details[data-mom-comments]').count()) > 0); +ok('5h·瞬间卡片带只读计数', (await page.locator('.mom-card [data-viewmeta]').count()) > 0); +await page.locator('details[data-mom-comments]').first().locator('summary').click(); +await page.waitForTimeout(400); +// 计数徽标是 details 的兄弟(ViewMeta 根节点为

,塞进

属非法嵌套) +ok('5h·瞬间计数为只读模式', (await page.locator('.mom-cm-meta [data-viewmeta]').first().getAttribute('data-mode')) === 'readonly'); + +await page.goto(BASE + '/about/', { waitUntil: 'networkidle' }); +ok('5h·关于页由块驱动', (await page.locator('[data-block]').count()) > 0, `blocks=${await page.locator('[data-block]').count()}`); + // 1) 明暗切换:按钮 + 状态持久 + t 键 const before = await page.getAttribute('html', 'data-theme'); await page.click('[data-theme-toggle]');