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]');