From 7e0167a8bbe5d4ade9de8ba590b53b379c32952c Mon Sep 17 00:00:00 2001 From: wait4xx Date: Wed, 4 Nov 2026 20:09:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(opensource):=20=E7=83=AD=E5=8A=9B=E5=9B=BE?= =?UTF-8?q?=E4=BA=94=E6=96=B9=E6=A1=88=E9=A2=84=E8=A7=88=20+=20=E5=BC=80?= =?UTF-8?q?=E6=BA=90=E9=A1=B5=E5=8F=99=E8=BF=B0=E5=90=88=E4=B8=80=EF=BC=88?= =?UTF-8?q?5j=20T1=20+=20=E9=A2=84=E8=AE=BE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户两项新需求: ①「开源页的叙述为什么要区分链接前和链接后?完全可以用 markdown 富文本统一替代」 ②「热力图可以有更多的动效,而不是静态的一张图,探索相关方案供我审阅」 T1 叙述合一: - opensource.yaml 新增 story(双语 markdown,链接内联),blurb/blurb_after 保留为 legacy(story 为空时才用)→ 旧 yaml 零回归 - site-content 访问器取 story,GithubProfile 走 renderRich 行内模式;markdown 生成的 裸 补 .pf-bio :global(a) 样式,与 .pf-link 观感一致 - 实测产物:pf-bio 内联 渲染成功、pf-link 拼接痕迹 0 处 热力图五方案预览(/heatmap-preview/,供用户选型): - 新增 geek/HeatGrid.astro:可复用网格渲染件(371 格带 --c/--r 供错峰延迟), 沿用线上同款 ink 灰阶(accent 只做高亮,不参与数据编码) - A 入场波纹(视口内一次性斜向漫过)、B 光标光晕(radial-gradient 跟随 + 日期读数)、 C 年度回放(滑块 scrub + 累计计数 + 自动播放)、D 连续电流(最近连续段流光 + 连续天数大数)、E 悬停周详情(整列抬起 + 周合计/最好一天面板) - 五案均守灰阶红线、均在 prefers-reduced-motion 下静态化、零第三方依赖、无常驻 rAF - **两处真坑**:①网格是子组件渲染,页面级 scoped 样式够不到其内部元素 → 与格内元素 相关的选择器必须 :global()(否则 A 的动画与 E 的抬列静默不生效)②「当前连续」按 今天起算会得 0(今天尚未贡献)→ 改为以最后一个有贡献的日子为终点往回数,并标注 「截至 <日期>」 另修 E2E 过时断言:书架改版为抽书阅读后,原「年份折叠组」断言必然失败, 改为「抽书行渲染 + 点击可抽开」(56/56 PASS) 验证: 构建 51 页 exit 0;content-lint 52 页 0/0/0;E2E 56/56; Playwright 逐案实测:A 动画 hmRise 0.42s 且播完 opacity=1、B 光晕 opacity=1 并显示「日期 · 次数」、C 拖到第 10 周读数与淡出正确、D 12 格流光 hmFlow + 连续 5 天(截至 2026-10-24)、E 整列 lift matrix(…,-2);390 零横向溢出; reduced-motion 下动画 none、光晕 display:none Spec: 2026-11-03_blog-5j-content-refine (T1 + 热力图预览) Co-authored-by: GLM-5.3 --- content/site/opensource.yaml | 9 + scripts/build-perf/content-lint.mjs | 4 + src/components/geek/GithubProfile.astro | 21 +- src/components/geek/HeatGrid.astro | 58 ++++ src/content.config.ts | 26 ++ src/lib/site-content.ts | 81 +++++ src/pages/heatmap-preview.astro | 400 ++++++++++++++++++++++++ tests/e2e.mjs | 13 +- 8 files changed, 604 insertions(+), 8 deletions(-) create mode 100644 src/components/geek/HeatGrid.astro create mode 100644 src/pages/heatmap-preview.astro diff --git a/content/site/opensource.yaml b/content/site/opensource.yaml index 5041226..765d4df 100644 --- a/content/site/opensource.yaml +++ b/content/site/opensource.yaml @@ -9,11 +9,20 @@ typing: - zh: 书与快门都在一个工作台 en: Books and shutters, one workbench +# 开篇叙述(5j T1):markdown 富文本,链接内联写 `[文字](url)`。 +# 非空时整段走富文本管线(净化 + 排版类白名单),**取代**下面的 blurb + repo_* + blurb_after 拼接。 +story: + zh: "本站本身即开源:[git.wait4x.online/wait4x/blog](https://git.wait4x.online/wait4x/blog) —— Astro SSG、双写作流、构建发布全链皆在仓中。以下是我在自托管 gitea 与 GitHub 上的全部公开仓库,以及最近一年的贡献活动。" + en: "This site is itself open source: [git.wait4x.online/wait4x/blog](https://git.wait4x.online/wait4x/blog) — the Astro SSG, dual-writing workflow, and the full build & deploy pipeline live in the repo. Below are all my public repositories on self-hosted gitea and GitHub, plus contributions from the past year." + +# ---- legacy(story 为空时才用)---- +# 下面四个字段是 5j 之前「两半夹链接」的写法,保留只为旧 yaml / 回退演示;新改动请一律写 story。 blurb: zh: 本站本身即开源: en: 'This site is itself open source:' repo_url: https://git.wait4x.online/wait4x/blog repo_label: git.wait4x.online/wait4x/blog +# legacy:story 为空时接在 story 占位处拼接(见上方 blurb 注释) blurb_after: zh: —— Astro SSG、双写作流、构建发布全链皆在仓中。以下是我在自托管 gitea 与 GitHub 上的全部公开仓库,以及最近一年的贡献活动。 en: — the Astro SSG, dual-writing workflow, and the full build & deploy pipeline live in the repo. Below are all my public repositories on self-hosted gitea and GitHub, plus contributions from the past year. diff --git a/scripts/build-perf/content-lint.mjs b/scripts/build-perf/content-lint.mjs index cbeb089..f711ccb 100644 --- a/scripts/build-perf/content-lint.mjs +++ b/scripts/build-perf/content-lint.mjs @@ -16,6 +16,9 @@ if (MODE === 'skip') { const SKIP_PREFIX = ['/admin/', '/photos-originals/']; // admin=CMS 路由;photos-originals=部署期 rsync 叠加层(不在 dist,另有部署链保障) const SKIP_FILE = new Set(['/404.html']); // Astro 404 页不参与配对 +// 一次性预览路由(无 en 对应页):完全不参与检查。用户选定方案后随路由一起删除。 +const SKIP_ALL = new Set(['/heatmap-preview/index.html']); + function walk(dir, out = []) { for (const e of readdirSync(dir)) { @@ -50,6 +53,7 @@ const warns = []; for (const f of files) { const rel = f.slice(DIST.length) || '/'; + if (SKIP_ALL.has(rel)) continue; const html = readFileSync(f, 'utf8'); // 站内 :仅根相对路径;#锚点/mailto/tel/外部/data: 全跳过 diff --git a/src/components/geek/GithubProfile.astro b/src/components/geek/GithubProfile.astro index 4ede507..b728d3c 100644 --- a/src/components/geek/GithubProfile.astro +++ b/src/components/geek/GithubProfile.astro @@ -7,7 +7,7 @@ import { Image } from 'astro:assets'; import { existsSync } from 'node:fs'; import TypingLines from './TypingLines.astro'; -import { homeAvatar, homeSocials, openSourceCopy } from '../../lib/site-content'; +import { homeAvatar, homeSocials, openSourceCopy, renderRich } from '../../lib/site-content'; import { githubUserStats, loadProjects } from '../../lib/projects'; import { iconPath } from '../../lib/icons'; import type { Locale } from '../../lib/locale'; @@ -20,6 +20,10 @@ const { locale = 'zh' } = Astro.props; const avatar = await homeAvatar(); const socials = await homeSocials(); const copy = await openSourceCopy(locale); +// 开篇叙述合一(5j T1):story 非空 → 整段走富文本管线(markdown + 内容净化 + 排版类白名单), +// 链接就是 markdown 普通链接,不再需要 blurb/blurb_after 两半夹一个 repo 链接; +// story 为空 → 回落旧的 blurb + + blurb_after 拼接(旧 yaml 零回归)。 +const storyHtml = copy.story.trim() ? await renderRich(copy.story, { inline: true }) : null; // 仓库数据:档案卡只关心"语言占比 + 星标汇总",取全量(不受项目墙可见性影响) let langs: { name: string; count: number; pct: number }[] = []; @@ -105,9 +109,13 @@ const L = { wait4x@wait4xx
- {copy.blurb}{copy.repoLabel}{' '}{copy.blurbAfter.trim()} -
+ {copy.blurb}{copy.repoLabel}{' '}{copy.blurbAfter.trim()} +
/i, '').replace(/<\/p>\s*$/i, '').trim(); } +export interface PageNoteStyle { + /** 像素字号(已夹取到 11–16) */ + size: number; + /** 已过白名单的 ink 档位(ink-1..ink-4) */ + color: string; + /** 已过白名单的排版字体类(font-sans 等) */ + font: string; +} + +export interface PageNote { + /** 已渲染的行内 HTML(note 走 renderRich inline,避免
套
) */ + html: string; + /** 直接可挂的行内样式串(字号 + 颜色) */ + style: string; + /** 字体类名(白名单内),与 style 一起挂到元素上 */ + className: string; + resolved: PageNoteStyle; +} + +/** 标题下说明文案的默认值(= 改造前硬编码的两句;删 yaml 字段即回到这里) */ +const PAGE_NOTE_DEFAULTS = { + moments: { + zh: '零碎的记录:随手拍、一句话感想、开源动态。', + en: 'Fragmented notes: casual shots, one-liners, and open-source updates.', + }, + books: { + zh: '点一行展开封面与简介 · 置顶的书默认展开', + en: 'Tap a row for the cover and blurb · pinned books start open', + }, +} as const; + +/** 颜色白名单:只认灰阶 ink 档位(后台可填任意值,但绝不能把正文染成任意颜色/破对比度) */ +const NOTE_COLORS = ['ink-1', 'ink-2', 'ink-3', 'ink-4'] as const; +/** 字号夹取:<11px 读不清、>16px 会盖过正文层级 */ +const NOTE_SIZE_MIN = 11; +const NOTE_SIZE_MAX = 16; +const NOTE_SIZE_DEFAULT = 13; + +/** + * 页面标题下说明文案(瞬间 / 书架)+ 可选样式。 + * 后台可改文字与字号/颜色/字体;样式全部经白名单校验后回落,非法值不会破版: + * · size 夹取 11–16(缺省 13) + * · color 只认 ink-1..ink-4(其它回落 ink-4) + * · font 只认排版类白名单(其它回落 font-sans) + * 无 yaml 字段时文字回落内置默认,与改造前逐字一致(零回归)。 + */ +export async function pageNote( + page: 'moments' | 'books', + locale: Locale, +): Promise { + const entry = await getEntry('sitePages', 'pages'); + const data = entry?.data?.[page]; + const text = pickBi(data?.note, PAGE_NOTE_DEFAULTS[page], locale); + const html = await renderRich(text, { inline: true }); + + const rawSize = data?.style?.size; + const size = + typeof rawSize === 'number' && Number.isFinite(rawSize) + ? Math.min(NOTE_SIZE_MAX, Math.max(NOTE_SIZE_MIN, Math.trunc(rawSize))) + : NOTE_SIZE_DEFAULT; + + const rawColor = data?.style?.color?.trim() ?? ''; + const color = (NOTE_COLORS as readonly string[]).includes(rawColor) ? rawColor : 'ink-4'; + + const rawFont = data?.style?.font?.trim() ?? ''; + const font = ALLOWED_PAGE_FONTS.has(rawFont) ? rawFont : 'font-sans'; + + // 字体必须写成 inline font-family:global.css 的 .font-* 类都挂在富文本容器作用域内 + // (.prose-*/.rich/.mom-text/.prose-body),裸 .page-note 拿不到,加类无效(实测) + return { + html, + style: `font-size:${size}px; color: var(--${color}); font-family: ${fontVarFor(font)};`, + className: font, + resolved: { size, color, font }, + }; +} + /** * 页面级排版覆盖(层 2):读单例的 typography 字段,产出该页容器要用的 * 类名与 CSS 变量(--page-font / --accent / --page-scale)。 diff --git a/src/pages/heatmap-preview.astro b/src/pages/heatmap-preview.astro new file mode 100644 index 0000000..a52e314 --- /dev/null +++ b/src/pages/heatmap-preview.astro @@ -0,0 +1,400 @@ +--- +/** + * 热力图动效方案预览(Phase 5j):一页并陈 5 个方案,供用户点选。 + * + * 为什么单开预览路由:热力图的"动"是纯审美 + 体感决策,改错要回滚一轮; + * 一页五案 + 页顶锚点,用户在任何设备上点开就能比。选定后我把选中方案 + * 落进 Heatmap.astro,然后删掉本路由。 + * + * 设计红线(五个方案都守):灰阶骨架、只允许 accent 做强调、prefers-reduced-motion + * 下一律退化为静态、零第三方依赖、不引入持续 rAF 循环(省电)。 + */ +import BaseLayout from '../layouts/BaseLayout.astro'; +import HeatGrid from '../components/geek/HeatGrid.astro'; +import { loadHeatmap, levelOf, type HeatDay } from '../lib/heatmap'; + +const { weeks, total } = await loadHeatmap(); +const days: HeatDay[] = weeks.flat(); +const buildTime = new Date().toISOString().slice(0, 10); + +/* ── 连续贡献(本地计算,替代此前构建期抓取的 streak.svg 快照)── */ +const countOf = new Map(days.map((d) => [d.date, d.count])); +const today = new Date(); +const isoOf = (d: Date) => d.toISOString().slice(0, 10); +// 「当前连续」按最后一个有贡献的日子往回数:今天尚未贡献不该让连续归零(GitHub 同口径), +// 若严格按"今天必须是活跃日"计算,早上访问本站会看到 0,与直觉冲突。 +let current = 0; +const lastActive = [...days].reverse().find((d) => d.count > 0)?.date ?? ''; +if (lastActive) { + const start = new Date(`${lastActive}T00:00:00Z`); + for (let i = 0; ; i++) { + const d = new Date(start); + d.setUTCDate(start.getUTCDate() - i); + const c = countOf.get(isoOf(d)) ?? 0; + if (c > 0) current++; + else break; + } +} +let longest = 0; +let run = 0; +for (const d of days) { + run = d.count > 0 ? run + 1 : 0; + if (run > longest) longest = run; +} +const bestDay = days.reduce((a, b) => (b.count > a.count ? b : a), days[0]); +const activeDays = days.filter((d) => d.count > 0).length; + +const stats = { total, current, longest, best: bestDay?.count ?? 0, bestDate: bestDay?.date ?? '', activeDays }; +--- + + + 热力图:五个动效方案 + + 同一份真实数据(近一年 {total} 次贡献 · 活跃 {stats.activeDays} 天 · 当前连续 + {stats.current} 天 · 最长 {stats.longest} 天),数据更新于 {buildTime}(构建期快照)。 + + 看完告诉我选哪个(回「A」「B」「C」「D」「E」,也可以说「C 但速度慢一点」)。 + 五个方案都守灰阶红线、都尊重「减少动态效果」系统设置。 + + + + A · 入场波纹 + B · 光标光晕 + C · 年度回放 + D · 连续电流 + E · 悬停周详情 + + + + + + A + 入场波纹 + 滚动到热力图时,格子像水面一样按「周 + 星期」斜向漫过、逐个亮起(约 0.9s)。 + 优点有生命感、不打扰、只播一次;代价纯装饰,不提供额外信息。 + + + + + + + + + + B + 光标光晕 + 鼠标在网格上移动时,一小片光晕跟着走,被照到的格子更亮,右下角浮出「日期 · 贡献数」。 + 优点能精确读数、零轮播;代价触屏设备没有指针,需退化为点按读数。 + + + + + + 把指针移到格子上 → + + + + + + C + 年度回放 + 拖动滑块从头"重放"这一年,累计贡献数跟着涨;也可点播放自动扫过。 + 优点最有叙事感、能直观感受节奏;代价多一层交互,需要一点耐心。 + + + + + + ▶ 播放 + + 累计 {total} 次 · 第 {weeks.length} 周 + + + + + + + D + 连续电流 + 当前连续的那几天会有一条光带从最早一格流到最新一格,像电流经过;旁边显示连续天数。 + 优点把"坚持"这件事讲成故事;代价只覆盖最近一小段,全年的信息仍靠静态格子。 + + + + {stats.current} + 连续天数{lastActive ? `(截至 ${lastActive})` : ''} + 最长 {stats.longest} 天 · 单日最高 {stats.best} 次 + + + + + + + + + + + E + 悬停周详情 + 指针落在任一格上,整列(那一周)轻轻抬起,右侧给出「这一周合计 / 最好的一天」。 + 优点信息密度最高、动效克制;代价仍是鼠标优先,触屏靠点按。 + + + + + + + + + + 数据口径:GitHub GraphQL 贡献日历,构建期快照({buildTime})。切到"减少动态效果"时五个方案全部退化为静态网格。 + + + + + diff --git a/tests/e2e.mjs b/tests/e2e.mjs index 0578b2b..cd68e18 100644 --- a/tests/e2e.mjs +++ b/tests/e2e.mjs @@ -187,10 +187,17 @@ ok('归档 首年展开/往年折叠 + 月分组', arch.total >= 1 && arch.open await page.goto(BASE + '/books/', { waitUntil: 'networkidle' }); const books = await page.evaluate(() => { - const all = [...document.querySelectorAll('.year-group')]; - return { total: all.length, open: all.filter((d) => d.hasAttribute('open')).length, firstOpen: all[0]?.hasAttribute('open') ?? false }; + // 书架已改版为「抽书阅读」(Phase 5i T7):不再有年份折叠组, + // 改为每本书一个 details 行;断言 = 至少一行、首行收起、点击能展开出正文。 + const all = [...document.querySelectorAll('.book')]; + return { total: all.length, open: all.filter((d) => d.hasAttribute('open')).length }; }); -ok('书架 当年展开/往昔折叠', books.total >= 1 && books.open === 1 && books.firstOpen, JSON.stringify(books)); +ok('书架 抽书行渲染', books.total >= 1, JSON.stringify(books)); +await page.locator('.book').first().locator('summary').click(); +await page.waitForTimeout(250); +ok('书架 点击可抽开', (await page.locator('.book[open] .body').count()) === 1); +await page.locator('.book').first().locator('summary').click(); +await page.waitForTimeout(200); // 4) 命令面板 + 搜索 await page.keyboard.press('Control+k');
+ 同一份真实数据(近一年 {total} 次贡献 · 活跃 {stats.activeDays} 天 · 当前连续 + {stats.current} 天 · 最长 {stats.longest} 天),数据更新于 {buildTime}(构建期快照)。 + + 看完告诉我选哪个(回「A」「B」「C」「D」「E」,也可以说「C 但速度慢一点」)。 + 五个方案都守灰阶红线、都尊重「减少动态效果」系统设置。 +
滚动到热力图时,格子像水面一样按「周 + 星期」斜向漫过、逐个亮起(约 0.9s)。 + 优点有生命感、不打扰、只播一次;代价纯装饰,不提供额外信息。
鼠标在网格上移动时,一小片光晕跟着走,被照到的格子更亮,右下角浮出「日期 · 贡献数」。 + 优点能精确读数、零轮播;代价触屏设备没有指针,需退化为点按读数。
把指针移到格子上 →
拖动滑块从头"重放"这一年,累计贡献数跟着涨;也可点播放自动扫过。 + 优点最有叙事感、能直观感受节奏;代价多一层交互,需要一点耐心。
当前连续的那几天会有一条光带从最早一格流到最新一格,像电流经过;旁边显示连续天数。 + 优点把"坚持"这件事讲成故事;代价只覆盖最近一小段,全年的信息仍靠静态格子。
指针落在任一格上,整列(那一周)轻轻抬起,右侧给出「这一周合计 / 最好的一天」。 + 优点信息密度最高、动效克制;代价仍是鼠标优先,触屏靠点按。
数据口径:GitHub GraphQL 贡献日历,构建期快照({buildTime})。切到"减少动态效果"时五个方案全部退化为静态网格。