feat(opensource): 开源页改原生档案卡,个人叙述迁往关于页(5h T5/T6)
用户原话:开源页开篇是从 GitHub README 直接移植的,「部分内容没有居中,有些图片
也没有正常显示,可以自行进行美化,无需完全一致……有些可以直接移植到关于页面」。
病根:README 是 GitHub 专用 markdown——`<div align="center">` 靠 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
This commit is contained in:
1 parent
63c9c8ad4a
commit
3e377adc7f
8 files changed
+415
-21
No files matched your search
@@ -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)
|
- 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"`
|
- 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
|
- 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 现状)
|
- 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"`
|
- 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
|
- claim: op-gitea
|
||||||
- boundary: content/site/about.yaml(新增 blocks:关于/兴趣 prose、站点与项目 linklist、Find Me 社交块;双语 zh/en;测试值不得留在文件里)、src/pages/about.astro 与 en(仅当块渲染需要时改,优先零改动)
|
- 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"`
|
- 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"`
|
||||||
|
|||||||
@@ -62,6 +62,69 @@ blocks:
|
|||||||
zh: 街拍与山野,随身一台无反,在等光的间隙按快门。
|
zh: 街拍与山野,随身一台无反,在等光的间隙按快门。
|
||||||
en: Street and landscapes with a mirrorless camera, pressing the shutter while waiting for the light.
|
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
|
- type: prose
|
||||||
title:
|
title:
|
||||||
zh: 本站如何构建
|
zh: 本站如何构建
|
||||||
|
|||||||
@@ -18,5 +18,9 @@ exclude: []
|
|||||||
sort: pinned-first
|
sort: pinned-first
|
||||||
# 来源过滤:both=双源合并(默认) | github | gitea
|
# 来源过滤:both=双源合并(默认) | github | gitea
|
||||||
perSource: both
|
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 用 <div align="center"> 居中,本站渲染不认该属性 → 居中失效。内容已由档案卡承接。
|
||||||
intro_from: "off"
|
intro_from: "off"
|
||||||
@@ -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<string, number>();
|
||||||
|
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];
|
||||||
|
---
|
||||||
|
|
||||||
|
<section class="profile" aria-label={locale === 'zh' ? 'GitHub 档案' : 'GitHub profile'}>
|
||||||
|
<div class="who">
|
||||||
|
{avatar ? (
|
||||||
|
<Image class="pf-avatar" src={avatar} alt="wait4x" width={64} height={64} format="webp" quality={84} />
|
||||||
|
) : (
|
||||||
|
<span class="pf-avatar pf-avatar-fallback" aria-hidden="true">w4</span>
|
||||||
|
)}
|
||||||
|
<div class="who-main">
|
||||||
|
<p class="pf-name">
|
||||||
|
wait4x<span class="pf-handle mono">@wait4xx</span>
|
||||||
|
</p>
|
||||||
|
<p class="pf-bio">
|
||||||
|
{copy.blurb}<a class="pf-link" href={copy.repoUrl}>{copy.repoLabel}</a>{copy.blurbAfter}
|
||||||
|
</p>
|
||||||
|
<ul class="pf-socials">
|
||||||
|
{socials.map((s) => (
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class="tap"
|
||||||
|
href={s.url}
|
||||||
|
aria-label={s.label}
|
||||||
|
title={s.label}
|
||||||
|
rel={s.url.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||||
|
>
|
||||||
|
{iconPath(s.icon) ? (
|
||||||
|
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d={iconPath(s.icon)} />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<span class="pf-social-text mono">{s.label}</span>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(stats || totalStars !== null) && (
|
||||||
|
<dl class="pf-stats mono">
|
||||||
|
{stats && (
|
||||||
|
<>
|
||||||
|
<div><dt>{L.followers}</dt><dd>{stats.followers}</dd></div>
|
||||||
|
<div><dt>{L.repos}</dt><dd>{stats.publicRepos}</dd></div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{totalStars !== null && (
|
||||||
|
<div><dt>{L.stars}</dt><dd>{totalStars}</dd></div>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{langs.length > 0 && (
|
||||||
|
<div class="pf-langs">
|
||||||
|
<p class="pf-langs-title mono">{L.langs}</p>
|
||||||
|
<ul>
|
||||||
|
{langs.map((l, i) => (
|
||||||
|
<li class:list={['pf-lang', { top: i === 0 }]}>
|
||||||
|
<span class="pf-lang-name mono">{l.name}</span>
|
||||||
|
<span class="pf-bar" aria-hidden="true">
|
||||||
|
<span class="pf-bar-fill" style={`width:${Math.max(l.pct, 2)}%`} />
|
||||||
|
</span>
|
||||||
|
<span class="pf-lang-pct mono">{l.pct}%</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.profile {
|
||||||
|
margin: var(--space-3) 0 var(--space-5);
|
||||||
|
padding: var(--space-4);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
max-width: var(--measure);
|
||||||
|
}
|
||||||
|
.who {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.pf-avatar {
|
||||||
|
flex: none;
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 14px;
|
||||||
|
object-fit: cover;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
.pf-avatar-fallback {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink-2);
|
||||||
|
background: var(--bg-soft);
|
||||||
|
}
|
||||||
|
.who-main { min-width: 0; }
|
||||||
|
.pf-name {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink-1);
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.pf-handle { font-size: 12.5px; font-weight: 400; color: var(--ink-4); }
|
||||||
|
.pf-bio {
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
font-size: 14.5px;
|
||||||
|
line-height: 1.65;
|
||||||
|
color: var(--ink-2);
|
||||||
|
}
|
||||||
|
.pf-link {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
.pf-socials {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
list-style: none;
|
||||||
|
margin: var(--space-2) 0 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.pf-socials a { color: var(--ink-3); display: inline-flex; }
|
||||||
|
.pf-socials a:hover { color: var(--accent); }
|
||||||
|
.pf-social-text { font-size: 12px; }
|
||||||
|
|
||||||
|
.pf-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-4);
|
||||||
|
margin: var(--space-4) 0 0;
|
||||||
|
padding-top: var(--space-3);
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.pf-stats div { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.pf-stats dt {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--ink-4);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.pf-stats dd {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 17px;
|
||||||
|
color: var(--ink-1);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-langs { margin-top: var(--space-3); }
|
||||||
|
.pf-langs-title {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--ink-4);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
.pf-langs ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
max-width: 32rem;
|
||||||
|
}
|
||||||
|
.pf-lang {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 7.5rem 1fr 3rem;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
.pf-lang-name { font-size: 12px; color: var(--ink-3); }
|
||||||
|
.pf-bar {
|
||||||
|
display: block;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.pf-bar-fill {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--ink-3);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
/* 灰阶体系:仅第一名用 accent 强调,其余走 ink 灰阶(不沿用 GitHub 彩色语言点) */
|
||||||
|
.pf-lang.top .pf-bar-fill { background: var(--accent); }
|
||||||
|
.pf-lang.top .pf-lang-name { color: var(--ink-2); }
|
||||||
|
.pf-lang-pct {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--ink-4);
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.pf-lang { grid-template-columns: 5.5rem 1fr 2.6rem; }
|
||||||
|
.pf-stats { gap: var(--space-3); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+53
-1
@@ -1,3 +1,5 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
// 项目墙数据(极客层 P0):构建时双源拉取 + 仓内缓存兜底
|
// 项目墙数据(极客层 P0):构建时双源拉取 + 仓内缓存兜底
|
||||||
// 双源 = 自家 gitea(数据自控叙事)+ GitHub(社区 star 真值);置顶清单人工维护
|
// 双源 = 自家 gitea(数据自控叙事)+ GitHub(社区 star 真值);置顶清单人工维护
|
||||||
export interface RepoCard {
|
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'];
|
export const PINNED_DEFAULT: string[] = ['blog'];
|
||||||
|
|
||||||
async function fetchJson(url: string): Promise<RawRepo[]> {
|
async function fetchJson(url: string): Promise<RawRepo[]> {
|
||||||
|
const headers: Record<string, string> = { '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, {
|
const res = await fetch(url, {
|
||||||
headers: { Accept: 'application/json', 'User-Agent': 'wait4x-blog-build' },
|
headers: { ...headers, Accept: 'application/json' },
|
||||||
signal: AbortSignal.timeout(10_000),
|
signal: AbortSignal.timeout(10_000),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`${url} -> ${res.status}`);
|
if (!res.ok) throw new Error(`${url} -> ${res.status}`);
|
||||||
@@ -158,3 +170,43 @@ function arrange(
|
|||||||
return b.stars - a.stars;
|
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<GithubUserStats | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,25 +2,23 @@
|
|||||||
import ProjectWall from '../../components/geek/ProjectWall.astro';
|
import ProjectWall from '../../components/geek/ProjectWall.astro';
|
||||||
import Heatmap from '../../components/geek/Heatmap.astro';
|
import Heatmap from '../../components/geek/Heatmap.astro';
|
||||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
|
import GithubProfile from '../../components/geek/GithubProfile.astro';
|
||||||
import { t } from '../../i18n/ui';
|
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 copy = await openSourceCopy('en');
|
||||||
const options = await openSourceOptions();
|
const options = await openSourceOptions();
|
||||||
const readmeMd = options.introFrom === 'github-readme' ? await githubReadmeMarkdown() : null;
|
const readmeMd = options.introFrom === 'github-readme' ? await githubReadmeMarkdown() : null;
|
||||||
const readmeHtml = readmeMd ? await renderMarkdown(readmeMd) : 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');
|
const typo = await pageTypography('siteOpenSource', 'opensource');
|
||||||
---
|
---
|
||||||
<BaseLayout title="Open Source · wait4x" description="My open-source activity: repos, contributions, and this site's source">
|
<BaseLayout title="Open Source · wait4x" description="My open-source activity: repos, contributions, and this site's source">
|
||||||
<h1 class="page-title">Open Source</h1>
|
<h1 class="page-title">Open Source</h1>
|
||||||
<div class={typo.className || undefined} style={typo.style || undefined}>
|
<div class={typo.className || undefined} style={typo.style || undefined}>
|
||||||
{readmeHtml ? (
|
<GithubProfile locale="en" />
|
||||||
<div class="blurb readme-intro" set:html={readmeHtml} />
|
{/* 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. */}
|
||||||
<p class="blurb rich" set:html={`${blurbHtml}<a class="link" href="${copy.repoUrl}">${copy.repoLabel}</a>${blurbAfterHtml}`} />
|
{readmeHtml && <div class="blurb readme-intro" set:html={readmeHtml} />}
|
||||||
)}
|
|
||||||
<Heatmap labels={{
|
<Heatmap labels={{
|
||||||
title: t('heatmap_title', 'en'), summary: t('heatmap_summary', 'en'),
|
title: t('heatmap_title', 'en'), summary: t('heatmap_summary', 'en'),
|
||||||
less: t('heatmap_less', 'en'), more: t('heatmap_more', 'en'), note: t('heatmap_note', 'en'),
|
less: t('heatmap_less', 'en'), more: t('heatmap_more', 'en'), note: t('heatmap_note', 'en'),
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
import ProjectWall from '../components/geek/ProjectWall.astro';
|
import ProjectWall from '../components/geek/ProjectWall.astro';
|
||||||
import Heatmap from '../components/geek/Heatmap.astro';
|
import Heatmap from '../components/geek/Heatmap.astro';
|
||||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||||
|
import GithubProfile from '../components/geek/GithubProfile.astro';
|
||||||
import { t } from '../i18n/ui';
|
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';
|
||||||
|
|
||||||
// 叙述文案 + 置顶名单 + 项目墙选项均改由 content/site/opensource.yaml 供给(后台可改)
|
// 叙述文案 + 置顶名单 + 项目墙选项均改由 content/site/opensource.yaml 供给(后台可改)
|
||||||
const copy = await openSourceCopy('zh');
|
const copy = await openSourceCopy('zh');
|
||||||
@@ -11,19 +12,16 @@ const options = await openSourceOptions();
|
|||||||
// 开篇来源:github-readme → 构建期取 profile 仓 README(缓存 content/.github-readme.md,失败回落 yaml blurb)
|
// 开篇来源:github-readme → 构建期取 profile 仓 README(缓存 content/.github-readme.md,失败回落 yaml blurb)
|
||||||
const readmeMd = options.introFrom === 'github-readme' ? await githubReadmeMarkdown() : null;
|
const readmeMd = options.introFrom === 'github-readme' ? await githubReadmeMarkdown() : null;
|
||||||
const readmeHtml = readmeMd ? await renderMarkdown(readmeMd) : null;
|
const readmeHtml = readmeMd ? await renderMarkdown(readmeMd) : null;
|
||||||
// 层 1:blurb 两段走统一富文本管线(后台可写 <span class="font-kai c-blue">)
|
|
||||||
const blurbHtml = await renderRich(copy.blurb, { inline: true });
|
|
||||||
const blurbAfterHtml = await renderRich(copy.blurbAfter, { inline: true });
|
|
||||||
const typo = await pageTypography('siteOpenSource', 'opensource');
|
const typo = await pageTypography('siteOpenSource', 'opensource');
|
||||||
---
|
---
|
||||||
<BaseLayout title="开源 · wait4x" description="我的开源活动:仓库、贡献与本站源码">
|
<BaseLayout title="开源 · wait4x" description="我的开源活动:仓库、贡献与本站源码">
|
||||||
<h1 class="page-title">开源<span class="count mono">Open Source</span></h1>
|
<h1 class="page-title">开源<span class="count mono">Open Source</span></h1>
|
||||||
<div class={typo.className || undefined} style={typo.style || undefined}>
|
<div class={typo.className || undefined} style={typo.style || undefined}>
|
||||||
{readmeHtml ? (
|
<GithubProfile locale="zh" />
|
||||||
<div class="blurb readme-intro" set:html={readmeHtml} />
|
{/* 兼容通道(不推荐):intro_from=github-readme 时把 profile README 原样渲染在档案卡下方。
|
||||||
) : (
|
它会引入 7 类第三方外链图(shields.io / readme-typing-svg / stats / snake / 访客徽章)并带失效的
|
||||||
<p class="blurb rich" set:html={`${blurbHtml}<a class="link" href="${copy.repoUrl}">${copy.repoLabel}</a>${blurbAfterHtml}`} />
|
align 居中;慢网或被墙时整片空白。默认 off,正常内容由上方原生档案卡 + opensource.yaml 叙述承载。 */}
|
||||||
)}
|
{readmeHtml && <div class="blurb readme-intro" set:html={readmeHtml} />}
|
||||||
<Heatmap labels={{
|
<Heatmap labels={{
|
||||||
title: t('heatmap_title', 'zh'), summary: t('heatmap_summary', 'zh'),
|
title: t('heatmap_title', 'zh'), summary: t('heatmap_summary', 'zh'),
|
||||||
less: t('heatmap_less', 'zh'), more: t('heatmap_more', 'zh'), note: t('heatmap_note', 'zh'),
|
less: t('heatmap_less', 'zh'), more: t('heatmap_more', 'zh'), note: t('heatmap_note', 'zh'),
|
||||||
|
|||||||
@@ -34,6 +34,28 @@ ok('首页加载', (await page.title()).includes('wait4x'));
|
|||||||
ok('热力图渲染', await page.locator('.hm-grid').count() > 0);
|
ok('热力图渲染', await page.locator('.hm-grid').count() > 0);
|
||||||
ok('项目墙渲染', await page.locator('.project-wall, .repo').count() > 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 根节点为 <p>,塞进 <summary> 属非法嵌套)
|
||||||
|
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 键
|
// 1) 明暗切换:按钮 + 状态持久 + t 键
|
||||||
const before = await page.getAttribute('html', 'data-theme');
|
const before = await page.getAttribute('html', 'data-theme');
|
||||||
await page.click('[data-theme-toggle]');
|
await page.click('[data-theme-toggle]');
|
||||||
|
|||||||
Reference in new issue
Block a user