Merge branch 'spec/2026-11-03_blog-5j-content-refine'

Phase 5j 进行中合并(T1/T2/T3 已交付):开源页叙述合一(story 单字段 markdown)+
贡献数据去重与时效(连续贡献改热力图同源本地计算并标注更新时间,贡献蛇与
streak.svg 退役)+ 瞬间/书架标题下话语改为后台可编辑(pages.yaml 单例,字号/
颜色/字体三档夹取)+ **隐藏开关假开关修复**(hiddenTag 只写没人读的字段导致
隐藏+置顶仍显示并排第一;新增 hidden 真字段与可见性契约 post-visibility,
RSS/sitemap 一并过滤)+ 热力图蛇路径优化三案预览(P1 智能寻食/P2 平滑蛇体/
P3 分月清扫,均带月柱联动)。

Spec: 2026-11-03_blog-5j-content-refine
This commit is contained in:
wait4x committed 2026-11-04 22:01:06 +08:00
commit 7401b512e6
28 files changed
+675 -600

No files matched your search

@@ -0,0 +1,79 @@
# Phase 5j:内容表达去重与页面文案可编辑 - 项目范围
<!-- spec-format: 2 -->
## 1. 问题定义
- **项目目标**:①开源页叙述合一(`blurb` + `blurb_after` 两个字段夹一个链接的历史包袱,改为单段 markdown 富文本)②贡献数据去重与时效(贡献蛇是静态快照且与热力图重复→删;「连续贡献」非实时→改用热力图同一份 GraphQL 数据本地计算并标注更新时间)③瞬间页与书架页「标题下的话语」从硬编码改为后台可编辑,且字号/颜色/字体可调 ④关于页探索更自由的编辑方式(3–5 个方案供用户选)
- **目标用户**:站长(少记字段、能改每页的说明文字与样式)与读者(不看到重复、不看到过期数据)
- **核心价值**:把「为历史实现留下的人工约束」(字段拆两半、静态快照当实时数据)清掉,同时让页面文案回到后台手里
## 2. 假设与待确认
### 2.1 已确认事实
- `content/site/opensource.yaml` 的 `blurb` / `blurb_after` 只为把 `repo_url` 链接夹在中间(`GithubProfile.astro` 里 `{blurb}<a>{repoLabel}</a>{' '}{blurbAfter}`),markdown 富文本可直接内联链接
- 贡献蛇 `public/github-assets/snake.svg` 是 GitHub Action 每日生成的**静态 SVG 快照**(抓取时已剥掉动画);`Heatmap.astro` 展示的是同一份 GitHub 贡献数据 → 信息重复
- `streak.svg`(当前/最长连续贡献)同为构建期抓取的快照,`public/github-assets/manifest.json` 记录抓取时间,构建不跑不更新
- `src/lib/heatmap.ts` 已能用 token 拉 `contributionsCollection.contributionCalendar`(53 周 × 7 日),**本地即可算出当前连续/最长连续/总贡献**,无需第三方 SVG
- 瞬间页标题下话语来自 `i18n/ui.ts` 的 `moments_note`;书架页来自 `BooksPage.astro` 里的字面量 `L.note` → 均不可后台编辑
### 2.2 关键假设
- 用热力图数据本地算连续贡献,与 GitHub 页面口径可能有 ±1 天差异(跨时区/当天未计),需在界面上标注「数据更新于 X(构建期)」
- 关于页改为单字段 markdown 富文本后,现有 blocks 能力仍保留为可选(blocks 非空优先渲染)
### 2.3 待确认问题
- [ ] 关于页形态:单字段 markdown 直编 / 名片+正文双栏 / 杂志分节带目录 / 时间线主导 / 卡片流(将做 5 案预览供选)
- [ ] 连续贡献:接受「构建期计算 + 更新时间标注」,还是需要每日定时刷新
### 2.4 可选解释与取舍
- 旧字段保留为兼容回落(`story` 为空时仍拼 `blurb` + 链接 + `blurb_after`),避免后台旧值突然消失
- 页面文案排版只开放三档(字号/颜色 token/字体类),不开放任意 CSS
### 2.5 已证伪路径
- 继续用 `streak.svg`:无法实时、暗色需滤镜、与热力图数据源重复 → 弃
## 3. 功能范围
### 3.1 核心功能(MVP)
- [ ] T1 开源页叙述合一:`story` 单字段 markdown(内联链接),旧 `blurb/blurb_after` 作为兼容回落
- [ ] T2 贡献数据去重与时效:删除贡献蛇块与抓取;连续贡献改为热力图同源本地计算(当前/最长/总计)+ 数据时间标注
- [ ] T3 页面文案可编辑:新增 `content/site/pages.yaml` 单例(瞬间/书架 note 双语 + 字号/颜色/字体),两页消费,后台可改
- [ ] T4 关于页 5 案预览页(供用户选型),选定后落地并把 blocks 降级为可选
- [ ] T5 文档与后台 hint 同步
### 3.2 扩展功能
- 连续贡献如需每日刷新,后续加 systemd timer 跑 fetch 脚本(本包只做标注,不引入定时任务)
### 3.3 不在范围内
- 换 GitHub 数据源、Artalk/灯箱改造、书架与开源页版式重做
## 4. 最小实现路径
- 最简可行:`story` 先做兼容(新字段空则走旧逻辑);连续贡献先用现有 heatmap 数据算三个数;页面文案先只开放「文本 + 字号 + 颜色 + 字体」四项
- 暂不引入:页面文案的可视化编辑器、关于页拖拽布局
## 5. 技术决策
- 技术栈:Astro 7.4.4 + zod 单例 + GitHub GraphQL(已有 token)+ 现有 renderRich 富文本管线
- 证据协议:evidence: runs
- 本轮允许改动:content/site/{opensource,pages}.yaml、src/content.config.ts、src/lib/{site-content,heatmap,projects}.ts、src/components/geek/{GithubProfile,Heatmap}.astro、src/components/pages/{MomentsPage,BooksPage,AboutPage}.astro、src/pages/{about,en/about,about-preview}.astro(新)、public/admin/config.yml、public/github-assets/(删蛇)、scripts/build-perf/fetch-readme-assets.mjs、docs/*、tests/e2e.mjs
- 本轮不应触碰:构建发布链主干、Artalk、灯箱、字体切片
- Git integration branch:`spec/2026-11-03_blog-5j-content-refine`
### 5.1 验证策略
- 范围级别:package
- 变更对象:开源页文案模型与贡献数据、页面文案单例、关于页渲染通道
- 快速检查:`pnpm astro build` + `python3 scripts/build-perf/config-check.py .`
- 集成检查:`E2E_BASE=… node tests/e2e.mjs`(55 项)+ content-lint 0/0/0
- 全项目检查:生产实测(开源页无蛇、连续贡献与 GitHub 页面对账、页面文案改动生效、关于页预览 5 案可达)
- 升级触发:若改动触及 BaseLayout、全局样式或 Artalk 接管层,升级为全站视觉复核;本包因新增页面级排版变量,需一轮关于页预览视觉复核
## 6. 成功标准与验证方式
- [ ] 开源页叙述为单段富文本且可内联链接,旧 yaml 零回归 -> verify: task_0001
- [ ] 贡献蛇消失、连续贡献由本地计算并显示更新时间 -> verify: task_0002
- [ ] 瞬间/书架标题下话语可从后台改,字号/颜色/字体生效 -> verify: task_0003
- [ ] 关于页 5 案预览可访问,用户选定 -> verify: task_0004
- [ ] 文档与后台 hint 同步(无过时字段说明) -> verify: task_0005
## 7. 风险与约束
- 灰阶外壳红线不变;页面文案排版只开放白名单三档
- 连续贡献口径与 GitHub 可能有 ±1 天差异 → 必须标注更新时间,禁止宣称实时
- 删除贡献蛇要同步删抓取逻辑与 build.sh 守卫,避免死代码
- 多车道并行:`GithubProfile/Heatmap`(贡献数据)与 `MomentsPage/BooksPage`(页面文案)分属不同车道;admin config 与 docs 由 orchestrator 统一改
@@ -0,0 +1,33 @@
# Phase 5j:内容表达去重与页面文案可编辑 - 任务拆解
## 使用规则
- boundary/verify 齐备;verify 只读断言;车道工作树开发禁 git,收编由 orchestrator 统一提交
- 构建一律 `export PATH="$HOME/.local/node/bin:$PATH"`(Node 26)+ `pnpm astro build`
- E2E 口径:`pnpm astro build → cp -a dist /tmp/dist-e2e-5j → gz-server.py 18925 /tmp/dist-e2e-5j(第二参数才是目录)→ E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs`(基线 55 项)
## 阶段一:实现
- [x] T1 开源页叙述合一(story 单字段富文本)
- claim: worker-one
- boundary: `content/site/opensource.yaml`(新增 `story`,保留 `blurb`/`blurb_after` 并标注 legacy)、`src/lib/site-content.ts`(`openSourceCopy` 增 `story`:非空则用,空则回落旧拼接)、`src/components/geek/GithubProfile.astro`(消费 story 的富文本渲染,链接用 markdown 内联 `[label](url)`)、`src/content.config.ts`(schema 加 story)、`public/admin/config.yml` 由 orchestrator 统一改(先做代码,字段我来加)
- verify: `grep -q "story" src/lib/site-content.ts && grep -q "story" src/content.config.ts && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE "汇总: [0-9]+/[0-9]+ PASS"`
- [x] T2 贡献数据去重与时效(删蛇 + 连续贡献本地计算)
- claim: worker-one
- boundary: `src/lib/heatmap.ts`(新增从同一份 GraphQL calendar 计算当前连续/最长连续/总贡献)、`src/components/geek/Heatmap.astro`(展示三个数 + 「数据更新于 <构建时间>」)、`src/components/geek/GithubProfile.astro`(删除贡献蛇块与 streak 卡引用)、`scripts/build-perf/fetch-readme-assets.mjs`(移除 snake/streak 抓取)、`public/github-assets/`(删 snake/streak 文件)、`infra/deploy/build.sh`(同步守卫)
- verify: `! test -f public/github-assets/snake.svg && grep -q "currentStreak\|longestStreak" src/lib/heatmap.ts && python3 -c "import pathlib;p=pathlib.Path('dist/opensource/index.html').read_text();assert 'snake' not in p and 'streak-' not in p"`
- [x] T3 页面文案可编辑(瞬间/书架标题下话语)
- claim: op-gitea
- boundary: `src/components/pages/MomentsPage.astro`(改用可配置 note,替换 i18n 硬编码)、`src/components/pages/BooksPage.astro`(同上,替换字面量 `L.note`)、`src/lib/site-content.ts`(新增 `pageNote(page, locale)` 访问器:文本 + 字号 + 颜色 token + 字体类)、`content/site/pages.yaml`(新单例,由 orchestrator 建 schema/后台,车道可先写默认值)
- verify: `grep -q "pageNote" src/lib/site-content.ts && grep -q "pages.yaml" src/content.config.ts && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE "汇总: [0-9]+/[0-9]+ PASS"`
- [ ] T4 关于页 5 案预览与落地
- claim: orchestrator
- boundary: `src/pages/about-preview.astro`(新,一次性预览路由)、`src/components/pages/AboutPage.astro`、`content/site/about.yaml`
- verify: `test -f src/pages/about-preview.astro && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE "汇总: [0-9]+/[0-9]+ PASS"`
- [ ] T5 文档与后台 hint
- claim: orchestrator
- boundary: `docs/管理台.md`、`docs/写作指南.md`、`public/admin/config.yml`
- verify: `grep -q "story" docs/管理台.md && grep -q "pages.yaml\|页面文案" docs/管理台.md`
+31 -1
View File
@@ -3,6 +3,7 @@ import { defineConfig } from 'astro/config';
import pagefind from 'astro-pagefind'; import pagefind from 'astro-pagefind';
import sitemap from '@astrojs/sitemap'; import sitemap from '@astrojs/sitemap';
import { execSync } from 'node:child_process'; import { execSync } from 'node:child_process';
import { readdirSync, readFileSync } from 'node:fs';
import rehypeLinkCards, { linkCardsReport } from './src/lib/rehype-link-cards'; import rehypeLinkCards, { linkCardsReport } from './src/lib/rehype-link-cards';
import rehypeClassWhitelist from './src/lib/rehype-class-whitelist'; import rehypeClassWhitelist from './src/lib/rehype-class-whitelist';
import rehypeRaw from 'rehype-raw'; import rehypeRaw from 'rehype-raw';
@@ -18,9 +19,38 @@ const date =
process.env.GITHUB_COMMIT_DATE || process.env.GITHUB_COMMIT_DATE ||
(() => { try { return execSync('git log -1 --format=%cI').toString().trim(); } catch { return new Date().toISOString(); } })(); (() => { try { return execSync('git log -1 --format=%cI').toString().trim(); } catch { return new Date().toISOString(); } })();
// 隐藏文章不进 sitemap(内容侧契约见 src/lib/post-visibility.ts)。
// 这里在配置期直接扫 frontmatter:Astro 的 content collections 在配置上下文不可用,
// 而 sitemap 的 filter 只拿到 URL。判定两条通道:`hidden: true` 与 tags 里的 hidden。
const hiddenSlugs = (() => {
const set = new Set();
try {
const dir = 'content/posts';
for (const file of readdirSync(dir)) {
if (!file.endsWith('.md')) continue;
const raw = readFileSync(`${dir}/${file}`, 'utf8');
const head = (raw.split('\n---\n')[0] ?? '').replace(/^---\n/, '');
if (/^hidden:\s*true\s*$/m.test(head)) { set.add(file.replace(/\.md$/, '')); continue; }
// tags 可能是行内数组,也可能是多行列表:抓出 tags 段再找 hidden 词
const m = /^tags:[ \t]*([\s\S]*?)(?=^\w|\s*$)/m.exec(head);
const tagBlock = m ? m[1] : '';
if (/\bhidden\b/.test(tagBlock)) set.add(file.replace(/\.md$/, ''));
}
} catch {
/* 内容目录不可读时宁可不排除,也不让构建挂掉 */
}
return [...set];
})();
export default defineConfig({ export default defineConfig({
site: 'https://wait4x.online', site: 'https://wait4x.online',
integrations: [pagefind(), sitemap(), linkCardsReport()], integrations: [
pagefind(),
sitemap({
filter: (page) => !hiddenSlugs.some((slug) => page.includes(`/archives/${slug}/`)),
}),
linkCardsReport(),
],
markdown: { markdown: {
// 独立成段的裸链接 → OG 预览卡(构建期抓取,失败回落普通链接;见 src/lib/rehype-link-cards.ts) // 独立成段的裸链接 → OG 预览卡(构建期抓取,失败回落普通链接;见 src/lib/rehype-link-cards.ts)
// 顺序:rehypeRaw 先把 markdown 里的 raw HTML 解析成 hast element(否则白名单看不到 span), // 顺序:rehypeRaw 先把 markdown 里的 raw HTML 解析成 hast element(否则白名单看不到 span),
+12
View File
@@ -5,11 +5,23 @@ typing:
en: Self-hosting whatever can be self-hosted en: Self-hosting whatever can be self-hosted
- zh: 拥抱开源,热爱开源 - zh: 拥抱开源,热爱开源
en: Embrace open source, love open source en: Embrace open source, love open source
- 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: blurb:
zh: 本站本身即开源: zh: 本站本身即开源:
en: 'This site is itself open source:' en: 'This site is itself open source:'
repo_url: https://git.wait4x.online/wait4x/blog repo_url: https://git.wait4x.online/wait4x/blog
repo_label: git.wait4x.online/wait4x/blog repo_label: git.wait4x.online/wait4x/blog
# legacy:story 为空时接在 story 占位处拼接(见上方 blurb 注释)
blurb_after: blurb_after:
zh: —— Astro SSG、双写作流、构建发布全链皆在仓中。以下是我在自托管 gitea 与 GitHub 上的全部公开仓库,以及最近一年的贡献活动。 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. 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.
+21
View File
@@ -0,0 +1,21 @@
# 页面标题下的说明文案(瞬间 / 书架):文字、字号、颜色、字体都在这里改。
# 缺省即回落内置默认,删字段不会破版。
# 说明:color 取 tokens 里的灰阶层级(ink-1..ink-4);font 取排版类白名单里的字体类。
moments:
note:
zh: 零碎的记录:随手拍、一句话感想、开源动态。
en: 'Fragmented notes: casual shots, one-liners, and open-source updates.'
style:
size: 13
color: ink-4
font: font-sans
books:
note:
zh: 点一行展开封面与简介 · 置顶的书默认展开
en: 'Tap a row for the cover and blurb · pinned books start open'
style:
size: 13
color: ink-4
font: font-sans # 与改造前一致;想用楷体改成 font-kai
+3 -6
View File
@@ -39,12 +39,9 @@ pnpm astro build
[ -f scripts/build-perf/gen-sw-precache.mjs ] && node scripts/build-perf/gen-sw-precache.mjs dist || echo "[build] sw-precache 生成器未就绪,跳过" [ -f scripts/build-perf/gen-sw-precache.mjs ] && node scripts/build-perf/gen-sw-precache.mjs dist || echo "[build] sw-precache 生成器未就绪,跳过"
[ -f scripts/build-perf/inline-home-css.mjs ] && node scripts/build-perf/inline-home-css.mjs || echo "[build] inline-home 脚本未就绪,跳过" [ -f scripts/build-perf/inline-home-css.mjs ] && node scripts/build-perf/inline-home-css.mjs || echo "[build] inline-home 脚本未就绪,跳过"
# README 动效资产(5i T8):**不在此跑抓取**(避免发布链被外网抖动拖累),只做存在性守卫 + 缺资产告警 # README 动效资产(5i 引入、5j T2 退役):贡献蛇与 streak 快照已删——连续贡献改为
if [ -f public/github-assets/manifest.json ]; then # 热力图同源本地计算(见 src/lib/heatmap.ts 的 streakStats),界面上标注"数据更新于"。
echo "[build] github-assets 就绪:$(find public/github-assets -name '*.svg' | wc -l) 个自托管 SVG" # fetch-readme-assets.mjs 保留骨架但 ASSETS 已清空,如将来要加新的自托管资产再启用。
else
echo "[build] WARN public/github-assets/manifest.json 缺失——开源页数据板将只显示原生统计(可手动跑 node scripts/build-perf/fetch-readme-assets.mjs 补齐)"
fi
# 内容 lint(T3):断链/缺图在部署前拦截;CONTENT_LINT=skip|warn 逃生门见脚本头注 # 内容 lint(T3):断链/缺图在部署前拦截;CONTENT_LINT=skip|warn 逃生门见脚本头注
[ -f scripts/build-perf/content-lint.mjs ] && node scripts/build-perf/content-lint.mjs dist || echo "[build] content-lint 未就绪,跳过" [ -f scripts/build-perf/content-lint.mjs ] && node scripts/build-perf/content-lint.mjs dist || echo "[build] content-lint 未就绪,跳过"
+6 -1
View File
@@ -55,7 +55,12 @@ collections:
widget: string widget: string
required: false required: false
hint: '填 /media/xxx.jpg(先上传到 public/media 取路径);缺省浏览器自取首帧' hint: '填 /media/xxx.jpg(先上传到 public/media 取路径);缺省浏览器自取首帧'
- { label: 隐藏(不进列表/sitemap), name: hiddenTag, widget: boolean, required: false, hint: '对应 tags 含 hidden;保存后请人工核对' } - label: 隐藏(不进列表 / RSS / sitemap)
name: hidden
widget: boolean
required: false
default: false
hint: '打开后这篇不进首页/归档/书架/RSS/sitemap,但页面本身仍生成、链接可分享(想彻底不生成请用草稿)。与「置顶」同时打开时以隐藏为准——2026-11-04 修:旧开关只写一个没人读的字段,导致隐藏+置顶的文章照样显示还排第一'
- { label: 草稿, name: draft, widget: boolean, required: false, default: false } - { label: 草稿, name: draft, widget: boolean, required: false, default: false }
- { label: 正文, name: body, widget: markdown } - { label: 正文, name: body, widget: markdown }
-1
View File
@@ -1 +0,0 @@
<svg viewBox="-16 -32 880 192" width="880" height="192" xmlns="http://www.w3.org/2000/svg"><desc>Generated with https://github.com/Platane/snk</desc><style>:root{--cb:#1b1f230a;--cs:purple;--ce:#ebedf0;--c0:#ebedf0;--c1:#9be9a8;--c2:#40c463;--c3:#30a14e;--c4:#216e39}.c{shape-rendering:geometricPrecision;fill:var(--ce);stroke-width:1px;stroke:var(--cb);width:12px;height:12px}.c.c0{fill:var(--c1);animation-name:c0}.c.c1{fill:var(--c2);animation-name:c1}.c.c2{fill:var(--c1);animation-name:c2}.c.c3{fill:var(--c1);animation-name:c3}.c.c4{fill:var(--c1);animation-name:c4}.c.c5{fill:var(--c1);animation-name:c5}.c.c6{fill:var(--c1);animation-name:c6}.c.c7{fill:var(--c1);animation-name:c7}.c.c8{fill:var(--c1);animation-name:c8}.c.c9{fill:var(--c1);animation-name:c9}.c.ca{fill:var(--c1);animation-name:ca}.c.cb{fill:var(--c1);animation-name:cb}.c.cc{fill:var(--c1);animation-name:cc}.c.cd{fill:var(--c1);animation-name:cd}.c.ce{fill:var(--c1);animation-name:ce}.c.cf{fill:var(--c1);animation-name:cf}.c.cg{fill:var(--c1);animation-name:cg}.c.ch{fill:var(--c1);animation-name:ch}.c.ci{fill:var(--c1);animation-name:ci}.c.cj{fill:var(--c1);animation-name:cj}.c.ck{fill:var(--c1);animation-name:ck}.c.cl{fill:var(--c1);animation-name:cl}.c.cm{fill:var(--c2);animation-name:cm}.c.cn{fill:var(--c1);animation-name:cn}.c.co{fill:var(--c1);animation-name:co}.c.cp{fill:var(--c1);animation-name:cp}.c.cq{fill:var(--c1);animation-name:cq}.c.cr{fill:var(--c1);animation-name:cr}.c.cs{fill:var(--c1);animation-name:cs}.c.ct{fill:var(--c4);animation-name:ct}.c.cu{fill:var(--c1);animation-name:cu}.c.cv{fill:var(--c4);animation-name:cv}.c.cw{fill:var(--c2);animation-name:cw}.c.cx{fill:var(--c1);animation-name:cx}.c.cy{fill:var(--c1);animation-name:cy}.c.cz{fill:var(--c1);animation-name:cz}.c.c10{fill:var(--c1);animation-name:c10}.c.c11{fill:var(--c1);animation-name:c11}.c.c12{fill:var(--c2);animation-name:c12}.c.c13{fill:var(--c4);animation-name:c13}.c.c14{fill:var(--c2);animation-name:c14}.c.c15{fill:var(--c1);animation-name:c15}.c.c16{fill:var(--c4);animation-name:c16}.c.c17{fill:var(--c1);animation-name:c17}.c.c18{fill:var(--c1);animation-name:c18}.c.c19{fill:var(--c1);animation-name:c19}.c.c1a{fill:var(--c2);animation-name:c1a}.c.c1b{fill:var(--c1);animation-name:c1b}.c.c1c{fill:var(--c1);animation-name:c1c}.c.c1d{fill:var(--c4);animation-name:c1d}.c.c1e{fill:var(--c4);animation-name:c1e}.u{transform-origin:0 0;transform:scale(0,1);animation-name:u0;transform-origin:0.0px 0}.u.u1{fill:var(--c2);animation-name:u1;transform-origin:648.5px 0}.u.u2{fill:var(--c4);animation-name:u2;transform-origin:748.2px 0}.s{shape-rendering:geometricPrecision;fill:var(--cs);animation-name:s0}.s.s1{transform:translate(16px,-16px);animation-name:s1}.s.s2{transform:translate(32px,-16px);animation-name:s2}.s.s3{transform:translate(48px,-16px);animation-name:s3}</style><rect class="c" x="2" y="2" rx="2" ry="2"/><rect class="c" x="2" y="18" rx="2" ry="2"/><rect class="c" x="2" y="34" rx="2" ry="2"/><rect class="c" x="2" y="50" rx="2" ry="2"/><rect class="c" x="2" y="66" rx="2" ry="2"/><rect class="c" x="2" y="82" rx="2" ry="2"/><rect class="c" x="2" y="98" rx="2" ry="2"/><rect class="c" x="18" y="2" rx="2" ry="2"/><rect class="c" x="18" y="18" rx="2" ry="2"/><rect class="c" x="18" y="34" rx="2" ry="2"/><rect class="c" x="18" y="50" rx="2" ry="2"/><rect class="c" x="18" y="66" rx="2" ry="2"/><rect class="c" x="18" y="82" rx="2" ry="2"/><rect class="c c0" x="18" y="98" rx="2" ry="2"/><rect class="c" x="34" y="2" rx="2" ry="2"/><rect class="c" x="34" y="18" rx="2" ry="2"/><rect class="c" x="34" y="34" rx="2" ry="2"/><rect class="c" x="34" y="50" rx="2" ry="2"/><rect class="c" x="34" y="66" rx="2" ry="2"/><rect class="c" x="34" y="82" rx="2" ry="2"/><rect class="c" x="34" y="98" rx="2" ry="2"/><rect class="c" x="50" y="2" rx="2" ry="2"/><rect class="c" x="50" y="18" rx="2" ry="2"/><rect class="c" x="50" y="34" rx="2" ry="2"/><rect class="c" x="50" y="50" rx="2" ry="2"/><rect class="c c1" x="50" y="66" rx="2" ry="2"/><rect class="c c2" x="50" y="82" rx="2" ry="2"/><rect class="c" x="50" y="98" rx="2" ry="2"/><rect class="c" x="66" y="2" rx="2" ry="2"/><rect class="c" x="66" y="18" rx="2" ry="2"/><rect class="c c3" x="66" y="34" rx="2" ry="2"/><rect class="c c4" x="66" y="50" rx="2" ry="2"/><rect class="c" x="66" y="66" rx="2" ry="2"/><rect class="c c5" x="66" y="82" rx="2" ry="2"/><rect class="c" x="66" y="98" rx="2" ry="2"/><rect class="c" x="82" y="2" rx="2" ry="2"/><rect class="c" x="82" y="18" rx="2" ry="2"/><rect class="c c6" x="82" y="34" rx="2" ry="2"/><rect class="c" x="82" y="50" rx="2" ry="2"/><rect class="c" x="82" y="66" rx="2" ry="2"/><rect class="c" x="82" y="82" rx="2" ry="2"/><rect class="c" x="82" y="98" rx="2" ry="2"/><rect class="c" x="98" y="2" rx="2" ry="2"/><rect class="c" x="98" y="18" rx="2" ry="2"/><rect class="c" x="98" y="34" rx="2" ry="2"/><rect class="c" x="98" y="50" rx="2" ry="2"/><rect class="c" x="98" y="66" rx="2" ry="2"/><rect class="cLine truncated

Before

Width:  |  Height:  |  Size: 20 KiB

-104
View File
@@ -1,104 +0,0 @@
<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'
style='isolation: isolate' viewBox='0 0 495 195' width='495px' height='195px' direction='ltr'>
<style>
</style>
<defs>
<clipPath id='outer_rectangle'>
<rect width='495' height='195' rx='4.5'/>
</clipPath>
<mask id='mask_out_ring_behind_fire'>
<rect width='495' height='195' fill='white'/>
<ellipse id='mask-ellipse' cx='247.5' cy='32' rx='13' ry='18' fill='black'/>
</mask>
</defs>
<g clip-path='url(#outer_rectangle)'>
<g style='isolation: isolate'>
<rect stroke='#000000' stroke-opacity='0' fill='#000000' fill-opacity='0' rx='4.5' x='0.5' y='0.5' width='494' height='194'/>
</g>
<g style='isolation: isolate'>
<line x1='165' y1='28' x2='165' y2='170' vector-effect='non-scaling-stroke' stroke-width='1' stroke='#E4E2E2' stroke-linejoin='miter' stroke-linecap='square' stroke-miterlimit='3'/>
<line x1='330' y1='28' x2='330' y2='170' vector-effect='non-scaling-stroke' stroke-width='1' stroke='#E4E2E2' stroke-linejoin='miter' stroke-linecap='square' stroke-miterlimit='3'/>
</g>
<g style='isolation: isolate'>
<!-- Total Contributions big number -->
<g transform='translate(82.5, 48)'>
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#c9ccd4' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='700' font-size='28px' font-style='normal' style='opacity: 1'>
344
</text>
</g>
<!-- Total Contributions label -->
<g transform='translate(82.5, 84)'>
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#b9bec8' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='14px' font-style='normal' style='opacity: 1'>
Total Contributions
</text>
</g>
<!-- Total Contributions range -->
<g transform='translate(82.5, 114)'>
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#b9bec8' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='12px' font-style='normal' style='opacity: 1'>
May 24, 2019 - Present
</text>
</g>
</g>
<g style='isolation: isolate'>
<!-- Current Streak label -->
<g transform='translate(247.5, 108)'>
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#3454d1' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='700' font-size='14px' font-style='normal' style='opacity: 1'>
Current Streak
</text>
</g>
<!-- Current Streak range -->
<g transform='translate(247.5, 145)'>
<text x='0' y='21' stroke-width='0' text-anchor='middle' fill='#b9bec8' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='12px' font-style='normal' style='opacity: 1'>
Nov 3
</text>
</g>
<!-- Ring around number -->
<g mask='url(#mask_out_ring_behind_fire)'>
<circle cx='247.5' cy='71' r='40' fill='none' stroke='#3454d1' stroke-width='5' style='opacity: 1'></circle>
</g>
<!-- Fire icon -->
<g transform='translate(247.5, 19.5)' stroke-opacity='0' style='opacity: 1'>
<path d='M -12 -0.5 L 15 -0.5 L 15 23.5 L -12 23.5 L -12 -0.5 Z' fill='none'/>
<path d='M 1.5 0.67 C 1.5 0.67 2.24 3.32 2.24 5.47 C 2.24 7.53 0.89 9.2 -1.17 9.2 C -3.23 9.2 -4.79 7.53 -4.79 5.47 L -4.76 5.11 C -6.78 7.51 -8 10.62 -8 13.99 C -8 18.41 -4.42 22 0 22 C 4.42 22 8 18.41 8 13.99 C 8 8.6 5.41 3.79 1.5 0.67 Z M -0.29 19 C -2.07 19 -3.51 17.6 -3.51 15.86 C -3.51 14.24 -2.46 13.1 -0.7 12.74 C 1.07 12.38 2.9 11.53 3.92 10.16 C 4.31 11.45 4.51 12.81 4.51 14.2 C 4.51 16.85 2.36 19 -0.29 19 Z' fill='#3454d1' stroke-opacity='0'/>
</g>
<!-- Current Streak big number -->
<g transform='translate(247.5, 48)'>
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#c9ccd4' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='700' font-size='28px' font-style='normal' >
0
</text>
</g>
</g>
<g style='isolation: isolate'>
<!-- Longest Streak big number -->
<g transform='translate(412.5, 48)'>
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#c9ccd4' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='700' font-size='28px' font-style='normal' style='opacity: 1'>
6
</text>
</g>
<!-- Longest Streak label -->
<g transform='translate(412.5, 84)'>
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#b9bec8' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='14px' font-style='normal' style='opacity: 1'>
Longest Streak
</text>
</g>
<!-- Longest Streak range -->
<g transform='translate(412.5, 114)'>
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#b9bec8' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='12px' font-style='normal' style='opacity: 1'>
Oct 19 - Oct 24
</text>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 6.1 KiB

-104
View File
@@ -1,104 +0,0 @@
<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'
style='isolation: isolate' viewBox='0 0 495 195' width='495px' height='195px' direction='ltr'>
<style>
</style>
<defs>
<clipPath id='outer_rectangle'>
<rect width='495' height='195' rx='4.5'/>
</clipPath>
<mask id='mask_out_ring_behind_fire'>
<rect width='495' height='195' fill='white'/>
<ellipse id='mask-ellipse' cx='247.5' cy='32' rx='13' ry='18' fill='black'/>
</mask>
</defs>
<g clip-path='url(#outer_rectangle)'>
<g style='isolation: isolate'>
<rect stroke='#000000' stroke-opacity='0' fill='#000000' fill-opacity='0' rx='4.5' x='0.5' y='0.5' width='494' height='194'/>
</g>
<g style='isolation: isolate'>
<line x1='165' y1='28' x2='165' y2='170' vector-effect='non-scaling-stroke' stroke-width='1' stroke='#E4E2E2' stroke-linejoin='miter' stroke-linecap='square' stroke-miterlimit='3'/>
<line x1='330' y1='28' x2='330' y2='170' vector-effect='non-scaling-stroke' stroke-width='1' stroke='#E4E2E2' stroke-linejoin='miter' stroke-linecap='square' stroke-miterlimit='3'/>
</g>
<g style='isolation: isolate'>
<!-- Total Contributions big number -->
<g transform='translate(82.5, 48)'>
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#33343a' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='700' font-size='28px' font-style='normal' style='opacity: 1'>
344
</text>
</g>
<!-- Total Contributions label -->
<g transform='translate(82.5, 84)'>
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#3f424a' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='14px' font-style='normal' style='opacity: 1'>
Total Contributions
</text>
</g>
<!-- Total Contributions range -->
<g transform='translate(82.5, 114)'>
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#3f424a' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='12px' font-style='normal' style='opacity: 1'>
May 24, 2019 - Present
</text>
</g>
</g>
<g style='isolation: isolate'>
<!-- Current Streak label -->
<g transform='translate(247.5, 108)'>
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#3454d1' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='700' font-size='14px' font-style='normal' style='opacity: 1'>
Current Streak
</text>
</g>
<!-- Current Streak range -->
<g transform='translate(247.5, 145)'>
<text x='0' y='21' stroke-width='0' text-anchor='middle' fill='#3f424a' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='12px' font-style='normal' style='opacity: 1'>
Nov 3
</text>
</g>
<!-- Ring around number -->
<g mask='url(#mask_out_ring_behind_fire)'>
<circle cx='247.5' cy='71' r='40' fill='none' stroke='#3454d1' stroke-width='5' style='opacity: 1'></circle>
</g>
<!-- Fire icon -->
<g transform='translate(247.5, 19.5)' stroke-opacity='0' style='opacity: 1'>
<path d='M -12 -0.5 L 15 -0.5 L 15 23.5 L -12 23.5 L -12 -0.5 Z' fill='none'/>
<path d='M 1.5 0.67 C 1.5 0.67 2.24 3.32 2.24 5.47 C 2.24 7.53 0.89 9.2 -1.17 9.2 C -3.23 9.2 -4.79 7.53 -4.79 5.47 L -4.76 5.11 C -6.78 7.51 -8 10.62 -8 13.99 C -8 18.41 -4.42 22 0 22 C 4.42 22 8 18.41 8 13.99 C 8 8.6 5.41 3.79 1.5 0.67 Z M -0.29 19 C -2.07 19 -3.51 17.6 -3.51 15.86 C -3.51 14.24 -2.46 13.1 -0.7 12.74 C 1.07 12.38 2.9 11.53 3.92 10.16 C 4.31 11.45 4.51 12.81 4.51 14.2 C 4.51 16.85 2.36 19 -0.29 19 Z' fill='#3454d1' stroke-opacity='0'/>
</g>
<!-- Current Streak big number -->
<g transform='translate(247.5, 48)'>
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#33343a' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='700' font-size='28px' font-style='normal' >
0
</text>
</g>
</g>
<g style='isolation: isolate'>
<!-- Longest Streak big number -->
<g transform='translate(412.5, 48)'>
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#33343a' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='700' font-size='28px' font-style='normal' style='opacity: 1'>
6
</text>
</g>
<!-- Longest Streak label -->
<g transform='translate(412.5, 84)'>
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#3f424a' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='14px' font-style='normal' style='opacity: 1'>
Longest Streak
</text>
</g>
<!-- Longest Streak range -->
<g transform='translate(412.5, 114)'>
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#3f424a' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='12px' font-style='normal' style='opacity: 1'>
Oct 19 - Oct 24
</text>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 6.1 KiB

@@ -42,18 +42,6 @@ const ASSETS = [
`&text_color=${theme === 'dark' ? INK_DARK : INK_LIGHT}`, `&text_color=${theme === 'dark' ? INK_DARK : INK_LIGHT}`,
label: '语言分布', label: '语言分布',
}, },
{
name: 'streak',
url: (theme) =>
`https://streak-stats.demolab.com/?user=${LOGIN}&hide_border=true` +
'&background=00000000' +
`&ring=${ACCENT}&fire=${ACCENT}&currStreakLabel=${ACCENT}` +
`&currStreakNum=${theme === 'dark' ? INK_DARK : INK_LIGHT}` +
`&sideNums=${theme === 'dark' ? INK_DARK : INK_LIGHT}` +
`&sideLabels=${theme === 'dark' ? MUTED_DARK : MUTED_LIGHT}` +
`&dates=${theme === 'dark' ? MUTED_DARK : MUTED_LIGHT}`,
label: '连续贡献',
},
{ {
// 贡献蛇:GitHub Action 生成,配色由 action 侧决定,无法传参 → 只抓一份 // 贡献蛇:GitHub Action 生成,配色由 action 侧决定,无法传参 → 只抓一份
name: 'snake', name: 'snake',
+14 -79
View File
@@ -5,9 +5,8 @@
// 0600 文件,缺位或失败即隐藏该数字)④src/lib/projects.ts 的仓库列表(语言占比与星标汇总) // 0600 文件,缺位或失败即隐藏该数字)④src/lib/projects.ts 的仓库列表(语言占比与星标汇总)
// 降级纪律:任何一项取数失败只隐藏该项,绝不炸构建、不出现空图片框 // 降级纪律:任何一项取数失败只隐藏该项,绝不炸构建、不出现空图片框
import { Image } from 'astro:assets'; import { Image } from 'astro:assets';
import { existsSync } from 'node:fs';
import TypingLines from './TypingLines.astro'; 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 { githubUserStats, loadProjects } from '../../lib/projects';
import { iconPath } from '../../lib/icons'; import { iconPath } from '../../lib/icons';
import type { Locale } from '../../lib/locale'; import type { Locale } from '../../lib/locale';
@@ -20,6 +19,10 @@ const { locale = 'zh' } = Astro.props;
const avatar = await homeAvatar(); const avatar = await homeAvatar();
const socials = await homeSocials(); const socials = await homeSocials();
const copy = await openSourceCopy(locale); const copy = await openSourceCopy(locale);
// 开篇叙述合一(5j T1):story 非空 → 整段走富文本管线(markdown + 内容净化 + 排版类白名单),
// 链接就是 markdown 普通链接,不再需要 blurb/blurb_after 两半夹一个 repo 链接;
// story 为空 → 回落旧的 blurb + <a> + blurb_after 拼接(旧 yaml 零回归)。
const storyHtml = copy.story.trim() ? await renderRich(copy.story, { inline: true }) : null;
// 仓库数据:档案卡只关心"语言占比 + 星标汇总",取全量(不受项目墙可见性影响) // 仓库数据:档案卡只关心"语言占比 + 星标汇总",取全量(不受项目墙可见性影响)
let langs: { name: string; count: number; pct: number }[] = []; let langs: { name: string; count: number; pct: number }[] = [];
@@ -67,26 +70,7 @@ const lines = typingLines.length ? typingLines : DEFAULT_TYPING;
// 自托管 SVG 数据卡(public/github-assets/,由 scripts/build-perf/fetch-readme-assets.mjs 产出入仓) // 自托管 SVG 数据卡(public/github-assets/,由 scripts/build-perf/fetch-readme-assets.mjs 产出入仓)
// 缺文件即跳过该卡(异步抓取失败不影响构建),明暗各一份时用 CSS 切换 // 缺文件即跳过该卡(异步抓取失败不影响构建),明暗各一份时用 CSS 切换
const assetPath = (name: string) => `/github-assets/${name}`; // 数据板已并入热力图区(Phase 5j T2):连续贡献由同源计算 + 更新时间标注,不再使用第三方 SVG
const hasAsset = (name: string) => existsSync(`public/github-assets/${name}`);
const boardCards: { label: string; light: string; dark?: string }[] = [];
for (const [key, label] of [
['stats', locale === 'zh' ? '统计' : 'Stats'],
['top-langs', locale === 'zh' ? '语言分布' : 'Top languages'],
['streak', locale === 'zh' ? '连续贡献' : 'Streak'],
] as const) {
if (!hasAsset(`${key}-light.svg`)) continue;
boardCards.push({
label,
light: assetPath(`${key}-light.svg`),
dark: hasAsset(`${key}-dark.svg`) ? assetPath(`${key}-dark.svg`) : undefined,
});
}
// 只有一张非通栏卡时让它占满整行(否则右半空档;stats/top-langs 上游 503 时就是这种情形)
const soloCard = boardCards.length === 1;
const snakeCard = hasAsset('snake.svg')
? { label: locale === 'zh' ? '贡献蛇' : 'Contribution snake', src: assetPath('snake.svg') }
: null;
const L = { const L = {
zh: { followers: '关注者', repos: '公开仓库', stars: '总星标', langs: '语言占比' }, zh: { followers: '关注者', repos: '公开仓库', stars: '总星标', langs: '语言占比' },
en: { followers: 'Followers', repos: 'Public repos', stars: 'Total stars', langs: 'Languages' }, en: { followers: 'Followers', repos: 'Public repos', stars: 'Total stars', langs: 'Languages' },
@@ -105,9 +89,13 @@ const L = {
wait4x<span class="pf-handle mono">@wait4xx</span> wait4x<span class="pf-handle mono">@wait4xx</span>
</p> </p>
<TypingLines lines={lines} /> <TypingLines lines={lines} />
{storyHtml ? (
<p class="pf-bio" set:html={storyHtml} />
) : (
<p class="pf-bio"> <p class="pf-bio">
{copy.blurb}<a class="pf-link" href={copy.repoUrl}>{copy.repoLabel}</a>{' '}{copy.blurbAfter.trim()} {copy.blurb}<a class="pf-link" href={copy.repoUrl}>{copy.repoLabel}</a>{' '}{copy.blurbAfter.trim()}
</p> </p>
)}
<ul class="pf-socials"> <ul class="pf-socials">
{socials.map((s) => ( {socials.map((s) => (
<li> <li>
@@ -163,33 +151,6 @@ const L = {
</ul> </ul>
</div> </div>
)} )}
{(boardCards.length > 0 || snakeCard) && (
<div class="pf-board">
<p class="pf-board-title mono">{locale === 'zh' ? '数据板' : 'Data board'}</p>
<div class="pf-board-grid">
{boardCards.map((card) => (
<figure
class:list={[
'pf-card',
{ 'pf-card-streak': card.light.includes('streak'), 'pf-card-solo': soloCard },
]}
>
<img class="pf-card-img only-light" src={card.light} alt={card.label} loading="lazy" decoding="async" />
{card.dark && (
<img class="pf-card-img only-dark" src={card.dark} alt={card.label} loading="lazy" decoding="async" />
)}
<figcaption class="mono">{card.label}</figcaption>
</figure>
))}
{snakeCard && (
<figure class="pf-card pf-card-wide pf-card-snake">
<img class="pf-card-img" src={snakeCard.src} alt={snakeCard.label} loading="lazy" decoding="async" />
<figcaption class="mono">{snakeCard.label}</figcaption>
</figure>
)}
</div>
</div>
)}
</section> </section>
<style> <style>
@@ -239,7 +200,10 @@ const L = {
line-height: 1.65; line-height: 1.65;
color: var(--ink-2); color: var(--ink-2);
} }
.pf-link { /* 5j T1:story 走 markdown 渲染,链接不再是 .pf-link 而是裸 <a>(set:html 内容不参与 scoped),
两路共用同一套链接样式,观感不因改写法而漂移 */
.pf-link,
.pf-bio :global(a) {
color: var(--accent); color: var(--accent);
text-decoration: underline; text-decoration: underline;
text-underline-offset: 3px; text-underline-offset: 3px;
@@ -320,11 +284,6 @@ const L = {
text-align: right; text-align: right;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.pf-board {
margin-top: var(--space-3);
padding-top: var(--space-3);
border-top: 1px solid var(--line);
}
.pf-board-title { .pf-board-title {
font-size: 11.5px; font-size: 11.5px;
color: var(--ink-4); color: var(--ink-4);
@@ -333,29 +292,6 @@ const L = {
} }
/* 自适应列:只有 1 张卡时自动占满整行(避免右半空档),≥2 张时并排; /* 自适应列:只有 1 张卡时自动占满整行(避免右半空档),≥2 张时并排;
320px 下限保证车卡内 SVG 不被压到字号过小 */ 320px 下限保证车卡内 SVG 不被压到字号过小 */
.pf-board-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: var(--space-2);
}
.pf-card {
margin: 0;
padding: var(--space-2);
border: 1px solid var(--line);
border-radius: 10px;
background: transparent;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.pf-card-wide, .pf-card-solo { grid-column: 1 / -1; }
.pf-card-img { max-width: 100%; height: auto; display: block; }
.pf-card figcaption {
font-size: 11px;
color: var(--ink-4);
letter-spacing: 0.03em;
}
/* 明暗两版自托管 SVG:默认只显示亮色,暗色主题切到暗色版 */ /* 明暗两版自托管 SVG:默认只显示亮色,暗色主题切到暗色版 */
/* 贡献蛇:SVG 内部用自带 CSS 变量(绿阶 + #ebedf0 空格),<img> 无法改写内部配色 → /* 贡献蛇:SVG 内部用自带 CSS 变量(绿阶 + #ebedf0 空格),<img> 无法改写内部配色 →
滤镜归一到站点灰阶;暗色下反相,避免浅灰格子贴在深底上刺眼(想去掉绿→灰只改这一行) */ 滤镜归一到站点灰阶;暗色下反相,避免浅灰格子贴在深底上刺眼(想去掉绿→灰只改这一行) */
@@ -367,7 +303,6 @@ const L = {
filter: grayscale(1) invert(1) contrast(1.18) brightness(1.06); filter: grayscale(1) invert(1) contrast(1.18) brightness(1.06);
} }
.only-dark { display: none; }
:global([data-theme='dark']) .only-light { display: none; } :global([data-theme='dark']) .only-light { display: none; }
:global([data-theme='dark']) .only-dark { display: block; } :global([data-theme='dark']) .only-dark { display: block; }
+21 -1
View File
@@ -1,7 +1,8 @@
--- ---
// 贡献热力图(极客层 P0):纯 CSS grid,零运行时 JS // 贡献热力图(极客层 P0):纯 CSS grid,零运行时 JS
// 数据聚合见 src/lib/heatmap.ts;灰阶四档直接复用 tokens --ink-1..4(亮暗两套自动跟随) // 数据聚合见 src/lib/heatmap.ts;灰阶四档直接复用 tokens --ink-1..4(亮暗两套自动跟随)
import { loadHeatmap, levelOf } from '../../lib/heatmap'; import { loadHeatmap, levelOf, streakStats } from '../../lib/heatmap';
import { localeOf } from '../../lib/locale';
// 文案全部由页面层注入(locale 无关挂法),keys 与 ui.ts heatmap_* 一一对应 // 文案全部由页面层注入(locale 无关挂法),keys 与 ui.ts heatmap_* 一一对应
interface Labels { interface Labels {
@@ -16,6 +17,7 @@ interface Props {
} }
const { labels } = Astro.props; const { labels } = Astro.props;
const locale = localeOf(Astro.url.pathname);
let weeks: import('../../lib/heatmap').HeatDay[][] = []; let weeks: import('../../lib/heatmap').HeatDay[][] = [];
let total = 0; let total = 0;
try { try {
@@ -23,12 +25,21 @@ try {
} catch { } catch {
// 三级回落已在 lib 内处理;此处再守一层:空网格不破构建 // 三级回落已在 lib 内处理;此处再守一层:空网格不破构建
} }
// 连续贡献(Phase 5j T2):与热力图同源本地计算 + 标注更新时间(不宣称实时)
const streak = streakStats(weeks.flat());
const updatedOn = new Date().toISOString().slice(0, 10);
const summary = labels.summary.replace('{n}', String(total)); const summary = labels.summary.replace('{n}', String(total));
--- ---
<section class="heatmap"> <section class="heatmap">
<h2 class="hm-title">{labels.title}</h2> <h2 class="hm-title">{labels.title}</h2>
<!-- 53 列窄栏触屏放不下,容器横滚(格子原生 title 提示,无 JS) --> <!-- 53 列窄栏触屏放不下,容器横滚(格子原生 title 提示,无 JS) -->
<p class="hm-streak mono">
<span class="hm-streak-item"><strong>{streak.current}</strong>{locale === 'en' ? 'day streak' : '天连续'}</span>
<span class="hm-streak-item"><strong>{streak.longest}</strong>{locale === 'en' ? 'longest' : '最长'}</span>
<span class="hm-streak-item"><strong>{streak.total}</strong>{locale === 'en' ? 'total' : '总贡献'}</span>
<span class="hm-streak-note">数据更新于 {updatedOn}</span>
</p>
<div class="hm-scroll" data-anchor-latest> <div class="hm-scroll" data-anchor-latest>
<div class="hm-grid" role="img" aria-label={summary}> <div class="hm-grid" role="img" aria-label={summary}>
{weeks.map((week) => {weeks.map((week) =>
@@ -91,6 +102,15 @@ const summary = labels.summary.replace('{n}', String(total));
.hm-l2 { background: var(--ink-3); } .hm-l2 { background: var(--ink-3); }
.hm-l3 { background: var(--ink-2); } .hm-l3 { background: var(--ink-2); }
.hm-l4 { background: var(--ink-1); } .hm-l4 { background: var(--ink-1); }
.hm-streak {
display: flex; align-items: baseline; gap: var(--space-4); flex-wrap: wrap;
margin: 0 0 var(--space-2); font-size: 12.5px; color: var(--ink-3);
}
.hm-streak-item strong {
font-size: 20px; color: var(--ink-1); font-variant-numeric: tabular-nums;
margin-right: 4px;
}
.hm-streak-note { color: var(--ink-4); font-size: 11.5px; }
.hm-legend { .hm-legend {
display: flex; display: flex;
align-items: center; align-items: center;
+7 -6
View File
@@ -13,21 +13,25 @@
* 展开/收起是另一个形状的 chevron,避免两个箭头混读 * 展开/收起是另一个形状的 chevron,避免两个箭头混读
*/ */
import { getCollection } from 'astro:content'; import { getCollection } from 'astro:content';
import { isVisiblePost } from '../../lib/post-visibility';
import { Image } from 'astro:assets'; import { Image } from 'astro:assets';
import BaseLayout from '../../layouts/BaseLayout.astro'; import BaseLayout from '../../layouts/BaseLayout.astro';
import { t } from '../../i18n/ui'; import { t } from '../../i18n/ui';
import type { Locale } from '../../lib/locale'; import type { Locale } from '../../lib/locale';
import { sortPosts } from '../../lib/post-order'; import { sortPosts } from '../../lib/post-order';
import { pageNote } from '../../lib/site-content';
interface Props { interface Props {
locale?: Locale; locale?: Locale;
} }
const { locale = 'zh' } = Astro.props; const { locale = 'zh' } = Astro.props;
// 标题下说明:文字与样式都可后台改(白名单校验,非法值回落)
const note = await pageNote('books', locale);
const books = sortPosts( const books = sortPosts(
await getCollection( await getCollection(
'posts', 'posts',
({ data }) => !data.draft && data.category === 'reading' && !data.tags.includes('hidden'), ({ data }) => isVisiblePost(data) && data.category === 'reading',
), ),
); );
@@ -58,10 +62,6 @@ const pinnedCount = items.filter((i) => i.pinned).length;
const L = { const L = {
shelf: t('books_title', locale), shelf: t('books_title', locale),
count: locale === 'zh' ? `${items.length} 本` : `${items.length} books`, count: locale === 'zh' ? `${items.length} 本` : `${items.length} books`,
note:
locale === 'zh'
? '点一行展开封面与简介 · 置顶的书默认展开'
: 'Tap a row for the cover and blurb · pinned books start open',
read: locale === 'zh' ? '读这篇书评 →' : 'Read the review →', read: locale === 'zh' ? '读这篇书评 →' : 'Read the review →',
pinned: t('pinned_label', locale), pinned: t('pinned_label', locale),
expand: locale === 'zh' ? '展开封面与简介' : 'Expand cover and blurb', expand: locale === 'zh' ? '展开封面与简介' : 'Expand cover and blurb',
@@ -77,7 +77,8 @@ const L = {
{L.shelf}<span class="count mono">{L.count}</span> {L.shelf}<span class="count mono">{L.count}</span>
{pinnedCount > 0 && <span class="pinned-count mono">{pinnedCount} {L.pinned}</span>} {pinnedCount > 0 && <span class="pinned-count mono">{pinnedCount} {L.pinned}</span>}
</h1> </h1>
<p class="page-note">{L.note}</p> <!-- 标题下说明:由 content/site/pages.yaml 的 books.note/style 驱动 -->
<p class:list={['page-note', note.className]} style={note.style} set:html={note.html} />
<ul class="books"> <ul class="books">
{items.map((b) => ( {items.map((b) => (
+2 -1
View File
@@ -2,6 +2,7 @@
// 首页(zh/en 共用):名片区 / 文章列表 / 最新影像 / 热力图 / 项目墙 / 心跳 // 首页(zh/en 共用):名片区 / 文章列表 / 最新影像 / 热力图 / 项目墙 / 心跳
// 两个 index.astro 只是薄壳(传 locale),区块逻辑与样式集中在这里,避免双份漂移。 // 两个 index.astro 只是薄壳(传 locale),区块逻辑与样式集中在这里,避免双份漂移。
import { Image } from 'astro:assets'; import { Image } from 'astro:assets';
import { isVisiblePost } from '../../lib/post-visibility';
import { getCollection } from 'astro:content'; import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro'; import BaseLayout from '../../layouts/BaseLayout.astro';
import IdCard from '../IdCard.astro'; import IdCard from '../IdCard.astro';
@@ -33,7 +34,7 @@ const announcementHtml = cfg.announcement ? await renderRich(cfg.announcement) :
// 置顶优先 → 时间倒序(sortPosts 契约见 src/lib/post-order.ts) // 置顶优先 → 时间倒序(sortPosts 契约见 src/lib/post-order.ts)
const posts = sortPosts( const posts = sortPosts(
await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden')), await getCollection('posts', ({ data }) => isVisiblePost(data)),
); );
// en 轨列表标题吃 postsEn 译题(缺译回退 zh,与 /en/archives 同策略) // en 轨列表标题吃 postsEn 译题(缺译回退 zh,与 /en/archives 同策略)
+5 -2
View File
@@ -5,7 +5,7 @@ import BaseLayout from '../../layouts/BaseLayout.astro';
import MomentComments from '../MomentComments.astro'; import MomentComments from '../MomentComments.astro';
import ViewMeta from '../geek/ViewMeta.astro'; import ViewMeta from '../geek/ViewMeta.astro';
import { t } from '../../i18n/ui'; import { t } from '../../i18n/ui';
import { renderRich } from '../../lib/site-content'; import { pageNote, renderRich } from '../../lib/site-content';
import type { Locale } from '../../lib/locale'; import type { Locale } from '../../lib/locale';
// /en/ 侧由 T4 薄页传 locale='en';zh 路由无人传参,默认 zh 零回归 // /en/ 侧由 T4 薄页传 locale='en';zh 路由无人传参,默认 zh 零回归
@@ -13,6 +13,8 @@ interface Props {
locale?: Locale; locale?: Locale;
} }
const { locale = 'zh' } = Astro.props; const { locale = 'zh' } = Astro.props;
// 标题下说明:文字与样式都可后台改(白名单校验,非法值回落)
const note = await pageNote('moments', locale);
const moments = (await getCollection('moments')).sort( const moments = (await getCollection('moments')).sort(
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(), (a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
@@ -32,7 +34,8 @@ const iso = (d: Date) => d.toISOString();
<BaseLayout title={`${t('moments_title', locale)} · wait4x`}> <BaseLayout title={`${t('moments_title', locale)} · wait4x`}>
<h1 class="page-title">{t('moments_title', locale)}</h1> <h1 class="page-title">{t('moments_title', locale)}</h1>
<p class="page-note">{t('moments_note', locale)}</p> <!-- 标题下说明:文案/字号/颜色/字体均由后台「页面文案 → 页面说明」控制(site-content.pageNote) -->
<p class:list={['page-note', note.className]} style={note.style} set:html={note.html} />
<!-- 整页计数(Phase 5h T4):这是本页唯一的计数点(page 模式); <!-- 整页计数(Phase 5h T4):这是本页唯一的计数点(page 模式);
每条瞬间卡片底部的徽标是 readonly,读者开评论不会虚增浏览数 --> 每条瞬间卡片底部的徽标是 readonly,读者开评论不会虚增浏览数 -->
<ViewMeta class="mom-page-meta" pageKey={locale === 'en' ? '/en/moments/' : '/moments/'} {locale} variant="chip" /> <ViewMeta class="mom-page-meta" pageKey={locale === 'en' ? '/en/moments/' : '/moments/'} {locale} variant="chip" />
+32
View File
@@ -125,6 +125,12 @@ const postsSchema = z.object({
poster: z.string().optional(), poster: z.string().optional(),
/** 置顶:列表排序优先(只影响顺序,不改 pubDate/URL/RSS 时间语义) */ /** 置顶:列表排序优先(只影响顺序,不改 pubDate/URL/RSS 时间语义) */
pinned: z.boolean().optional(), pinned: z.boolean().optional(),
/**
* 隐藏:不进首页/归档/书架/RSS/sitemap(页面本身仍生成,链接可分享)。
* 与置顶可以同时为真——此时隐藏优先,不显示也不排序。
* 历史写法 `tags: [hidden]` 仍然生效,判定收敛在 src/lib/post-visibility.ts。
*/
hidden: z.boolean().optional(),
draft: z.boolean().optional(), draft: z.boolean().optional(),
}); });
@@ -157,6 +163,18 @@ const _exifShapeOk: _PhotoExifOut extends PhotoExif
: never : never
: never = true; : never = true;
/** 页面说明文案的字段形状(瞬间 / 书架共用):文本双语 + 三档样式 */
const pageNoteSchema = z.object({
note: bilingual.optional(),
style: z
.object({
size: z.number().optional(),
color: z.string().optional(),
font: z.string().optional(),
})
.optional(),
});
export const collections = { export const collections = {
// 站点文案单例(后台「页面文案」可改):一文件一集合 // 站点文案单例(后台「页面文案」可改):一文件一集合
// 用 glob(pattern 精确到单文件) 而非 file():Astro 的 file() 把 YAML 顶层对象当“条目映射” // 用 glob(pattern 精确到单文件) 而非 file():Astro 的 file() 把 YAML 顶层对象当“条目映射”
@@ -220,6 +238,18 @@ export const collections = {
}), }),
}), }),
// 主题旋钮(样式即数据 · 层 4):只放 9 组白名单旋钮,钳制与单调性在 src/lib/theme.ts // 主题旋钮(样式即数据 · 层 4):只放 9 组白名单旋钮,钳制与单调性在 src/lib/theme.ts
/**
* 页面说明文案(瞬间 / 书架):标题下那一行字。
* 为什么单列单例而不是塞进 i18n 字典:这是内容不是界面文案,站长该能在后台改。
* `style` 只开放三档(字号 / 灰阶层级 / 字体类),避免变成任意 CSS 入口。
*/
sitePages: defineCollection({
loader: glob({ pattern: 'pages.yaml', base: './content/site' }),
schema: z.object({
moments: pageNoteSchema.optional(),
books: pageNoteSchema.optional(),
}),
}),
siteTheme: defineCollection({ siteTheme: defineCollection({
loader: glob({ pattern: 'theme.yaml', base: './content/site' }), loader: glob({ pattern: 'theme.yaml', base: './content/site' }),
schema: z.object({ schema: z.object({
@@ -270,6 +300,8 @@ export const collections = {
schema: z.object({ schema: z.object({
blurb: bilingual.optional(), blurb: bilingual.optional(),
blurb_after: bilingual.optional(), blurb_after: bilingual.optional(),
/** 开篇叙述合一(5j T1):markdown 富文本,链接内联写;非空则取代 blurb + repo_* + blurb_after 拼接 */
story: bilingual.optional(),
/** 页面级排版覆盖(层 2):正文默认字体类 / 强调色类 / 字号缩放;不配置即全站默认 */ /** 页面级排版覆盖(层 2):正文默认字体类 / 强调色类 / 字号缩放;不配置即全站默认 */
typography: z typography: z
.object({ .object({
+44
View File
@@ -114,6 +114,50 @@ async function loadFallbackDays(): Promise<Map<string, number>> {
} }
} }
/**
* 连续贡献统计(Phase 5j T2):从同一份日历数据本地计算,不再构建期抓取 streak.svg。
*
* 为什么这么做(用户 2026-11-03 的问题:「连续贡献是否是实时更新的?」)——旧实现抓的是
* 第三方 SVG 快照,构建不跑就不更新,且与热力图数据源重复。现在热力图与连续天数共用
* 一份 GraphQL 日历,天然同源;界面标注「数据更新于 <构建日期>」,不宣称实时。
*
* 口径:「当前连续」以最后一个有贡献的日子为终点往回数——今天还没贡献不该把连续归零
* (GitHub 同口径);「最长连续」按日历顺序数。
*/
export interface StreakStats {
total: number;
current: number;
longest: number;
/** 最后一个有贡献的日子(YYYY-MM-DD),用于界面标注截至日期 */
lastActive: string;
}
export function streakStats(days: HeatDay[]): StreakStats {
const countOf = new Map(days.map((d) => [d.date, d.count]));
const isoOf = (d: Date) => d.toISOString().slice(0, 10);
const lastActive = [...days].reverse().find((d) => d.count > 0)?.date ?? '';
let current = 0;
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);
if ((countOf.get(isoOf(d)) ?? 0) > 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;
}
return { total: days.reduce((a, b) => a + b.count, 0), current, longest, lastActive };
}
export async function loadHeatmap(): Promise<HeatmapYear> { export async function loadHeatmap(): Promise<HeatmapYear> {
const byDate = (await fetchGitHubDays()) ?? (await loadFallbackDays()); const byDate = (await fetchGitHubDays()) ?? (await loadFallbackDays());
// 53 周网格装配:窗口内逐日取数,未来日 0(渲染空档) // 53 周网格装配:窗口内逐日取数,未来日 0(渲染空档)
+22
View File
@@ -0,0 +1,22 @@
/**
* 文章可见性契约(单一事实源)。
*
* 为什么需要它:后台「隐藏」开关长期是个假开关——它写的是 `hiddenTag: true`,
* 而全站过滤只认 `tags` 里有没有 `hidden`,于是「隐藏 + 置顶」的文章照样出现在
* 首页/归档/书架,还因为置顶排在第一位(2026-11-04 用户实测报障)。
*
* 现在两条通道都认:
* - `hidden: true`(后台开关写入的真字段)
* - `tags` 含 `hidden`(历史写法,保留兼容)
* - `draft: true`(草稿,构建期即不生成页面的语义保持由各处 getCollection 过滤承担)
*/
type PostVisibility = {
draft?: boolean;
hidden?: boolean;
tags?: string[];
};
/** 文章是否应在列表/RSS/站点地图等处出现 */
export function isVisiblePost(data: PostVisibility): boolean {
return !data.draft && !data.hidden && !(data.tags ?? []).includes('hidden');
}
+81
View File
@@ -60,6 +60,8 @@ export async function aboutIntro(locale: Locale): Promise<string> {
export interface OpenSourceCopy { export interface OpenSourceCopy {
blurb: string; blurb: string;
blurbAfter: string; blurbAfter: string;
/** 开篇叙述(5j T1,markdown):非空则取代 blurb + repoUrl/repoLabel + blurbAfter 拼接 */
story: string;
repoUrl: string; repoUrl: string;
repoLabel: string; repoLabel: string;
/** 置顶仓短名(顺序即顺序);yaml 空数组/缺省 → 默认 ['blog'] */ /** 置顶仓短名(顺序即顺序);yaml 空数组/缺省 → 默认 ['blog'] */
@@ -74,6 +76,8 @@ export async function openSourceCopy(locale: Locale): Promise<OpenSourceCopy> {
return { return {
blurb: pickBi(entry?.data.blurb, d.blurb, locale), blurb: pickBi(entry?.data.blurb, d.blurb, locale),
blurbAfter: pickBi(entry?.data.blurb_after, d.blurbAfter, locale), blurbAfter: pickBi(entry?.data.blurb_after, d.blurbAfter, locale),
// 5j T1:story 为空时保留旧四件拼接的回落(旧 yaml 零回归)
story: pickBi(entry?.data.story, { zh: '', en: '' }, locale),
repoUrl: pick(entry?.data.repo_url, d.repoUrl), repoUrl: pick(entry?.data.repo_url, d.repoUrl),
repoLabel: pick(entry?.data.repo_label, d.repoLabel), repoLabel: pick(entry?.data.repo_label, d.repoLabel),
pinned: pinned.length ? pinned : [...d.pinned], pinned: pinned.length ? pinned : [...d.pinned],
@@ -160,6 +164,83 @@ export async function renderRich(md: string, opts: { inline?: boolean } = {}): P
return html.replace(/^\s*<p>/i, '').replace(/<\/p>\s*$/i, '').trim(); return html.replace(/^\s*<p>/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,避免 <p> 套 <p>) */
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<PageNote> {
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 字段,产出该页容器要用的 * 页面级排版覆盖(层 2):读单例的 typography 字段,产出该页容器要用的
* 类名与 CSS 变量(--page-font / --accent / --page-scale)。 * 类名与 CSS 变量(--page-font / --accent / --page-scale)。
+2 -1
View File
@@ -1,5 +1,6 @@
--- ---
import type { CollectionEntry } from 'astro:content'; import type { CollectionEntry } from 'astro:content';
import { isVisiblePost } from '../../lib/post-visibility';
import { getCollection, render } from 'astro:content'; import { getCollection, render } from 'astro:content';
import LinkCardStyles from '../../components/geek/LinkCardStyles.astro'; import LinkCardStyles from '../../components/geek/LinkCardStyles.astro';
import PhotoZoom from '../../components/geek/PhotoZoom.astro'; import PhotoZoom from '../../components/geek/PhotoZoom.astro';
@@ -15,7 +16,7 @@ export async function getStaticPaths() {
const all = (await getCollection('posts', ({ data }) => !data.draft)).sort( const all = (await getCollection('posts', ({ data }) => !data.draft)).sort(
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(), (a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
); );
const nav = all.filter(({ data }) => !data.tags.includes('hidden')); const nav = all.filter(({ data }) => isVisiblePost(data));
return all.map((entry) => { return all.map((entry) => {
const i = nav.findIndex((e) => e.id === entry.id); const i = nav.findIndex((e) => e.id === entry.id);
return { return {
+2 -1
View File
@@ -1,13 +1,14 @@
--- ---
// 导航「文章」落点:全量归档列表(年份/轨道分节逻辑与首页共用 PostList) // 导航「文章」落点:全量归档列表(年份/轨道分节逻辑与首页共用 PostList)
import { getCollection } from 'astro:content'; import { getCollection } from 'astro:content';
import { isVisiblePost } from '../../lib/post-visibility';
import BaseLayout from '../../layouts/BaseLayout.astro'; import BaseLayout from '../../layouts/BaseLayout.astro';
import PostList from '../../components/PostList.astro'; import PostList from '../../components/PostList.astro';
import { sortPosts } from '../../lib/post-order'; import { sortPosts } from '../../lib/post-order';
// 置顶优先 → 时间倒序(sortPosts 契约见 src/lib/post-order.ts) // 置顶优先 → 时间倒序(sortPosts 契约见 src/lib/post-order.ts)
const posts = sortPosts( const posts = sortPosts(
await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden')), await getCollection('posts', ({ data }) => isVisiblePost(data)),
); );
--- ---
+2 -1
View File
@@ -1,6 +1,7 @@
--- ---
// /en/archives/{slug}:英文版文章页——postsEn 优先;缺译回退 zh 正文 + 醒目标注 // /en/archives/{slug}:英文版文章页——postsEn 优先;缺译回退 zh 正文 + 醒目标注
import type { CollectionEntry } from 'astro:content'; import type { CollectionEntry } from 'astro:content';
import { isVisiblePost } from '../../../lib/post-visibility';
import { getCollection, render } from 'astro:content'; import { getCollection, render } from 'astro:content';
import LinkCardStyles from '../../../components/geek/LinkCardStyles.astro'; import LinkCardStyles from '../../../components/geek/LinkCardStyles.astro';
import PhotoZoom from '../../../components/geek/PhotoZoom.astro'; import PhotoZoom from '../../../components/geek/PhotoZoom.astro';
@@ -15,7 +16,7 @@ export async function getStaticPaths() {
const all = (await getCollection('posts', ({ data }) => !data.draft)).sort( const all = (await getCollection('posts', ({ data }) => !data.draft)).sort(
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(), (a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
); );
const nav = all.filter(({ data }) => !data.tags.includes('hidden')); const nav = all.filter(({ data }) => isVisiblePost(data));
const en = new Map((await getCollection('postsEn')).map((e) => [e.id, e])); const en = new Map((await getCollection('postsEn')).map((e) => [e.id, e]));
return all.map((entry) => { return all.map((entry) => {
const i = nav.findIndex((e) => e.id === entry.id); const i = nav.findIndex((e) => e.id === entry.id);
+2 -1
View File
@@ -1,12 +1,13 @@
--- ---
// /en/archives/ 列表页(补缺:此前 404) // /en/archives/ 列表页(补缺:此前 404)
import { getCollection } from 'astro:content'; import { getCollection } from 'astro:content';
import { isVisiblePost } from '../../../lib/post-visibility';
import BaseLayout from '../../../layouts/BaseLayout.astro'; import BaseLayout from '../../../layouts/BaseLayout.astro';
import PostList from '../../../components/PostList.astro'; import PostList from '../../../components/PostList.astro';
import { sortPosts } from '../../../lib/post-order'; import { sortPosts } from '../../../lib/post-order';
const posts = sortPosts( const posts = sortPosts(
await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden')), await getCollection('posts', ({ data }) => isVisiblePost(data)),
); );
const enTitle = Object.fromEntries( const enTitle = Object.fromEntries(
(await getCollection('postsEn')).map((e) => [e.id, e.data.title]), (await getCollection('postsEn')).map((e) => [e.id, e.data.title]),
+2 -1
View File
@@ -1,11 +1,12 @@
import rss from '@astrojs/rss'; import rss from '@astrojs/rss';
import { isVisiblePost } from '../../lib/post-visibility';
import { getCollection } from 'astro:content'; import { getCollection } from 'astro:content';
import type { APIContext } from 'astro'; import type { APIContext } from 'astro';
// 英文 RSS:postsEn 全量;标题缺译时回退 zh 标题(与 /en/ 页同策略) // 英文 RSS:postsEn 全量;标题缺译时回退 zh 标题(与 /en/ 页同策略)
export async function GET(context: APIContext) { export async function GET(context: APIContext) {
const en = new Map((await getCollection('postsEn')).map((e) => [e.id, e])); const en = new Map((await getCollection('postsEn')).map((e) => [e.id, e]));
const posts = (await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden'))).sort( const posts = (await getCollection('posts', ({ data }) => isVisiblePost(data))).sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(), (a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(),
); );
return rss({ return rss({
+240 -273
View File
@@ -1,16 +1,14 @@
--- ---
/** /**
* 热力图动效方案 v3(Phase 5j)——用户选定方向「F+J」,但要求贪吃蛇能**循环**, * 热力图方案 v4(Phase 5j):用户认可 FJ(蛇 + 月柱联动),要求**优化蛇的行动路径**。
* 并希望再看几个新变体。故本页给四个蛇家族方案 + J 柱状图参考:
* *
* F1 循环贪吃蛇(GitHub README 蛇的循环逻辑) * 现状问题(真实数据):371 格里只有 51 天有贡献(14%),原路径是"按列扫整张网格",
* F2 生长蛇(吃得越多身体越长) * 于是 86% 的行程在空格上、且格子跳变(每 26ms 瞬移一格)显得机械。
* F3 双蛇对开(两头同时吃,交汇于年中)
* FJ 蛇 + 月度柱联动(用户要的 F+J 组合)
* J 月度柱状图(单独保留作参考)
* *
* 设计红线:accent 只做高亮不参与数据编码(数据密度仍由 ink 四档承载)、 * 本版给三条路径优化,都保留 FJ 的月柱联动:
* prefers-reduced-motion 静态化、离开视口即停、零第三方依赖。 * P1 智能寻食:只按"最近的有贡献格子"走,中间用子格插值滑行——蛇总在吃东西附近
* P2 平滑蛇体:仍扫全网,但头是连续坐标滑行 + 拖尾,看起来像真蛇在游
* P3 分月清扫:按月成块清扫,月底停一拍并让该月柱子立起(节奏感来自"月")
*/ */
import BaseLayout from '../layouts/BaseLayout.astro'; import BaseLayout from '../layouts/BaseLayout.astro';
import HeatGrid from '../components/geek/HeatGrid.astro'; import HeatGrid from '../components/geek/HeatGrid.astro';
@@ -19,29 +17,9 @@ import { loadHeatmap, type HeatDay } from '../lib/heatmap';
const { weeks, total } = await loadHeatmap(); const { weeks, total } = await loadHeatmap();
const days: HeatDay[] = weeks.flat(); const days: HeatDay[] = weeks.flat();
const buildTime = new Date().toISOString().slice(0, 10); const buildTime = new Date().toISOString().slice(0, 10);
const activeDays = days.filter((d) => d.count > 0).length;
/* 连续贡献(本地计算,口径:以最后一个有贡献的日子为终点往回数) */ /* 月度聚合(柱子) */
const countOf = new Map(days.map((d) => [d.date, d.count]));
const isoOf = (d: Date) => d.toISOString().slice(0, 10);
const lastActive = [...days].reverse().find((d) => d.count > 0)?.date ?? '';
let current = 0;
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);
if ((countOf.get(isoOf(d)) ?? 0) > 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;
}
/* 月度聚合(J / FJ 用) */
const byMonth = new Map<string, number>(); const byMonth = new Map<string, number>();
for (const d of days) { for (const d of days) {
const key = d.date.slice(0, 7); const key = d.date.slice(0, 7);
@@ -49,81 +27,38 @@ for (const d of days) {
} }
const months = [...byMonth.entries()].sort(([a], [b]) => a.localeCompare(b)); const months = [...byMonth.entries()].sort(([a], [b]) => a.localeCompare(b));
const maxMonth = Math.max(...months.map(([, v]) => v)); const maxMonth = Math.max(...months.map(([, v]) => v));
const activeDays = days.filter((d) => d.count > 0).length;
const stats = { total, current, longest, activeDays };
--- ---
<BaseLayout title="热力图动效方案 v3 · wait4x" description="贪吃蛇的四个循环变体 + 月度柱状图"> <BaseLayout title="热力图方案 v4 · wait4x" description="蛇的行动路径优化三案(FJ 骨架)">
<h1 class="pv-title">热力图:蛇的四个变体</h1> <h1 class="pv-title">蛇的路径优化(FJ 骨架)</h1>
<p class="pv-note"> <p class="pv-note">
按你的选择做成 <strong>F + J</strong> 方向,并让蛇<strong>循环</strong>爬(学 GitHub README 蛇的 你认可 FJ,问题在路径。真实数据:371 格里只有 <strong>{activeDays}</strong> 天有贡献(
节奏:吃完一轮 → 停顿 → 清空 → 再来)。真实数据:近一年 <strong>{stats.total}</strong> 次贡献 · {Math.round((activeDays / days.length) * 100)}%),原路径"按列扫全网"意味着
{stats.activeDays} 天有贡献 · 当前连续 {stats.current} 天 · 数据更新于 {buildTime}。 <strong>{100 - Math.round((activeDays / days.length) * 100)}% 的行程在空转</strong>,
<br /> 而且每 26ms 瞬移一格,看着机械。三案都保留月柱联动。<strong>回 P1 / P2 / P3 告诉我选哪个。</strong>
<strong>回字母告诉我选哪个</strong>(F1 / F2 / F3 / FJ / J),也可以说「FJ 但循环慢一点」。
</p> </p>
<nav class="pv-switch" aria-label="方案跳转"> <nav class="pv-switch" aria-label="方案跳转">
<a href="#f1" class="pv-chip">F1 · 循环贪吃蛇</a> <a href="#p1" class="pv-chip">P1 · 智能寻食</a>
<a href="#f2" class="pv-chip">F2 · 生长蛇</a> <a href="#p2" class="pv-chip">P2 · 平滑蛇体</a>
<a href="#f3" class="pv-chip">F3 · 双蛇对开</a> <a href="#p3" class="pv-chip">P3 · 分月清扫</a>
<a href="#fj" class="pv-chip">FJ · 蛇 + 柱联动</a>
<a href="#j" class="pv-chip">J · 月度柱</a>
</nav> </nav>
<!-- ══════ F1 · 循环贪吃蛇 ══════ --> <!-- ══════ P1 · 智能寻食 ══════ -->
<section id="f1" class="pv-sec"> <section id="p1" class="pv-sec">
<header class="pv-head"> <header class="pv-head">
<span class="pv-tag mono">F1</span> <span class="pv-tag mono">P1</span>
<h2>循环贪吃蛇</h2> <h2>智能寻食</h2>
<p class="pv-pros">蛇沿格子蛇形爬一年,吃到有贡献的日子就点亮;爬到尽头停一拍、清空、重新开始, <p class="pv-pros">
<b>无限循环</b>。身体保持 6 格长度(GitHub README 蛇手感)。</p> 蛇只按「离当前最近的有贡献格子」前进(最近邻路径),两格之间用<strong>子格插值滑行</strong>,
不再逐格瞬移。<b>优点</b>行程从 371 格压到 ~<span data-calc="p1">—</span> 步,动作始终有目的、
永远在吃东西;<b>代价</b>不再"扫过整年",没贡献的日子只是路过。
</p>
</header> </header>
<div class="hm-wrap" data-snake data-body={6}> <div class="hm-stage" data-run data-mode="smart" data-body="5">
<HeatGrid weeks={weeks} variant="f1" /> <div class="hm-wrap"><HeatGrid weeks={weeks} variant="p1" /></div>
</div> <span class="hm-head" data-head aria-hidden="true"></span>
<p class="hm-read mono"><span data-snake-read>准备出发…</span> · 第 <span data-snake-loop>1</span> 轮 · <span class="hm-trail" data-trail aria-hidden="true"></span>
<button class="hm-btn" type="button" data-snake-toggle>暂停</button></p>
</section>
<!-- ══════ F2 · 生长蛇 ══════ -->
<section id="f2" class="pv-sec">
<header class="pv-head">
<span class="pv-tag mono">F2</span>
<h2>生长蛇</h2>
<p class="pv-pros">每吃到一天就长一节(最长 26 节),前半程是细蛇、后半程拖成长尾,吃完循环重置。
<b>优点</b>"积累"的隐喻最强;<b>代价</b>尾长时略挡格子。</p>
</header>
<div class="hm-wrap" data-snake data-body={3} data-grow={26}>
<HeatGrid weeks={weeks} variant="f2" />
</div>
<p class="hm-read mono"><span data-snake-read>准备出发…</span> · 蛇长 <span data-snake-len>3</span> 节</p>
</section>
<!-- ══════ F3 · 双蛇对开 ══════ -->
<section id="f3" class="pv-sec">
<header class="pv-head">
<span class="pv-tag mono">F3</span>
<h2>双蛇对开</h2>
<p class="pv-pros">两条蛇分别从最早与最新一头出发对向而吃,在年中附近交汇,吃完一起循环。
<b>优点</b>更有戏剧性、时间感双向;<b>代价</b>两条同时动,注意力会被分走。</p>
</header>
<div class="hm-wrap" data-snake data-body={5} data-dual="1">
<HeatGrid weeks={weeks} variant="f3" />
</div>
<p class="hm-read mono"><span data-snake-read>准备出发…</span></p>
</section>
<!-- ══════ FJ · 蛇 + 月柱联动 ══════ -->
<section id="fj" class="pv-sec">
<header class="pv-head">
<span class="pv-tag mono">FJ</span>
<h2>蛇 + 月度柱联动 <span class="pv-rec">你的选择方向</span></h2>
<p class="pv-pros">蛇每爬进一个月,对应的柱子就同步升起并高亮;爬到年末时十二根柱子全部立起,
然后蛇回到起点、柱子清零、再来一轮。<b>优点</b>格子与柱状图互相解释,信息与动感兼得。</p>
</header>
<div class="hm-wrap" data-snake data-body={6} data-bars-link="1">
<HeatGrid weeks={weeks} variant="fj" />
</div> </div>
<ul class="hm-bars" data-months> <ul class="hm-bars" data-months>
{months.map(([m, v], i) => ( {months.map(([m, v], i) => (
@@ -133,216 +68,256 @@ const stats = { total, current, longest, activeDays };
</li> </li>
))} ))}
</ul> </ul>
<p class="hm-read mono"><span data-snake-read>准备出发…</span></p> <p class="hm-read mono"><span data-read>准备出发…</span> · 第 <span data-loop>1</span> 轮</p>
</section> </section>
<!-- ══════ J · 月度柱(单独参考)══════ --> <!-- ══════ P2 · 平滑蛇体 ══════ -->
<section id="j" class="pv-sec"> <section id="p2" class="pv-sec">
<header class="pv-head"> <header class="pv-head">
<span class="pv-tag mono">J</span> <span class="pv-tag mono">P2</span>
<h2>月度柱状图</h2> <h2>平滑蛇体</h2>
<p class="pv-pros">不带动效时的样子:进视口逐根升起,指针移到柱子上,对应月份的格子同时高亮。 <p class="pv-pros">
<b>可单独用,也可像 FJ 那样与蛇联动。</b></p> 仍然扫过整年,但蛇头是<strong>连续坐标滑行</strong>(不是格子跳变),身后拖 8 节渐隐的身体,
转弯处带一点缓动。<b>优点</b>最像真蛇、动作最顺;<b>代价</b>依旧会经过空格(但不再"顿")。
</p>
</header> </header>
<div class="hm-wrap" data-bars> <div class="hm-stage" data-run data-mode="smooth" data-body="8">
<HeatGrid weeks={weeks} variant="j" /> <div class="hm-wrap"><HeatGrid weeks={weeks} variant="p2" /></div>
<span class="hm-head" data-head aria-hidden="true"></span>
<span class="hm-trail" data-trail aria-hidden="true"></span>
</div> </div>
<ul class="hm-bars" data-bars-chart> <ul class="hm-bars" data-months>
{months.map(([m, v]) => ( {months.map(([m, v], i) => (
<li> <li>
<button class="hm-bar-btn" type="button" data-month={m} aria-label={`${m} 共 ${v} 次`}> <span class="hm-bar-btn"><span class="hm-bar-fill" data-month-idx={i} style={`--h:${Math.round((v / maxMonth) * 100)}%`} /></span>
<span class="hm-bar-fill" style={`--h:${Math.round((v / maxMonth) * 100)}%`} />
</button>
<span class="hm-bar-label mono">{m.slice(5)}</span> <span class="hm-bar-label mono">{m.slice(5)}</span>
</li> </li>
))} ))}
</ul> </ul>
<p class="hm-read mono"><span data-read>准备出发…</span> · 第 <span data-loop>1</span> 轮</p>
</section> </section>
<p class="pv-foot mono">数据口径:GitHub GraphQL 贡献日历,构建期快照({buildTime})。"减少动态效果"下全部静态;离开视口自动暂停。</p> <!-- ══════ P3 · 分月清扫 ══════ -->
<section id="p3" class="pv-sec">
<header class="pv-head">
<span class="pv-tag mono">P3</span>
<h2>分月清扫</h2>
<p class="pv-pros">
按月成块清扫:一个月扫完停一拍(180ms)并让该月柱子"立起来",再进下个月。
<b>优点</b>节奏由"月"提供、与柱子天然同步、扫过空格时速度更快;<b>代价</b>每月有个小停顿。
</p>
</header>
<div class="hm-stage" data-run data-mode="month" data-body="6">
<div class="hm-wrap"><HeatGrid weeks={weeks} variant="p3" /></div>
<span class="hm-head" data-head aria-hidden="true"></span>
<span class="hm-trail" data-trail aria-hidden="true"></span>
</div>
<ul class="hm-bars" data-months>
{months.map(([m, v], i) => (
<li>
<span class="hm-bar-btn"><span class="hm-bar-fill" data-month-idx={i} style={`--h:${Math.round((v / maxMonth) * 100)}%`} /></span>
<span class="hm-bar-label mono">{m.slice(5)}</span>
</li>
))}
</ul>
<p class="hm-read mono"><span data-read>准备出发…</span> · 当前 <span data-month-label>—</span></p>
</section>
<span hidden data-months-map={JSON.stringify(months.map(([m]) => m))} data-weeks={weeks.length}></span> <p class="pv-foot mono">
数据口径:GitHub GraphQL 贡献日历,构建期快照({buildTime})· 共 {total} 次贡献 / {activeDays} 天。
三案共用同一套「头 + 拖尾 + 月柱」引擎,只换路径策略;"减少动态效果"下静态呈终态。
</p>
</BaseLayout> </BaseLayout>
<script> <script>
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches; const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
const weeksN = Number(document.querySelector<HTMLElement>('[data-weeks]')?.dataset.weeks ?? 53); const CELL = 10;
const monthKeys: string[] = JSON.parse( const GAP = 3;
document.querySelector<HTMLElement>('[data-months-map]')?.dataset.monthsMap ?? '[]', const PITCH = CELL + GAP;
);
/** 蛇形路径:按列推进,奇数列自下而上(与 GitHub README 蛇同构) */ for (const stage of document.querySelectorAll<HTMLElement>('[data-run]')) {
const buildPath = () => { const wrap = stage.querySelector<HTMLElement>('.hm-wrap');
const head = stage.querySelector<HTMLElement>('[data-head]');
const trailEl = stage.querySelector<HTMLElement>('[data-trail]');
const bars = [...stage.parentElement!.querySelectorAll<HTMLElement>('[data-month-idx]')];
const read = stage.parentElement?.querySelector<HTMLElement>('[data-read]');
const loopEl = stage.parentElement?.querySelector<HTMLElement>('[data-loop]');
const monthLabel = stage.parentElement?.querySelector<HTMLElement>('[data-month-label]');
if (!wrap || !head || !trailEl) continue;
const cells = [...wrap.querySelectorAll<HTMLElement>('.hg-c')];
const mode = stage.dataset.mode ?? 'smart';
const bodyLen = Number(stage.dataset.body ?? 5);
const weeks = Math.ceil(cells.length / 7);
const posOf = (idx: number) => ({ x: (idx % 7) * PITCH, y: Math.floor(idx / 7) * PITCH });
/** 最近邻路径:只走有贡献的格子(P1 用;P2/P3 用蛇形全网) */
const smartPath = () => {
const startAt = cells.findIndex((c) => Number(c.dataset.count ?? 0) > 0);
const pool = cells
.map((c, i) => ({ i, n: Number(c.dataset.count ?? 0) }))
.filter((c) => c.n > 0)
.map((c) => c.i);
if (!pool.length) return [];
const order: number[] = [];
let cur = pool.includes(startAt) ? startAt : pool[0];
pool.splice(pool.indexOf(cur), 1);
order.push(cur);
while (pool.length) {
let best = 0;
let bestD = Infinity;
for (let k = 0; k < pool.length; k++) {
const a = posOf(cur);
const b = posOf(pool[k]);
const d = Math.hypot(a.x - b.x, a.y - b.y) + Math.abs(a.y - b.y) * 0.3; // 轻微偏好同一列,少横跳
if (d < bestD) {
bestD = d;
best = k;
}
}
cur = pool[best];
pool.splice(best, 1);
order.push(cur);
}
return order;
};
const serpentine = () => {
const p: number[] = []; const p: number[] = [];
for (let c = 0; c < weeksN; c++) { for (let c = 0; c < weeks; c++) {
const rows = [0, 1, 2, 3, 4, 5, 6]; const rows = [0, 1, 2, 3, 4, 5, 6];
if (c % 2 === 1) rows.reverse(); if (c % 2 === 1) rows.reverse();
for (const r of rows) p.push(c * 7 + r); for (const r of rows) p.push(c * 7 + r);
} }
return p; return p.filter((i) => i < cells.length);
}; };
const PATH = buildPath();
for (const wrap of document.querySelectorAll<HTMLElement>('[data-snake]')) { /** 真实月份序列(从格子日期去重排序):柱子的顺序与它一致 */
const cells = [...wrap.querySelectorAll<HTMLElement>('.hg-c')]; const monthsReal = (() => {
const readEl = wrap.parentElement?.querySelector<HTMLElement>('[data-snake-read]'); const set = new Set<string>();
const loopEl = wrap.parentElement?.querySelector<HTMLElement>('[data-snake-loop]'); cells.forEach((c) => set.add((c.dataset.date ?? '').slice(0, 7)));
const lenEl = wrap.parentElement?.querySelector<HTMLElement>('[data-snake-len]'); return [...set].filter(Boolean).sort();
const toggle = wrap.parentElement?.querySelector<HTMLButtonElement>('[data-snake-toggle]'); })();
const barsEl = wrap.parentElement?.querySelector<HTMLElement>('[data-months]');
const bars = barsEl ? [...barsEl.querySelectorAll<HTMLElement>('[data-month-idx]')] : [];
const baseBody = Number(wrap.dataset.body ?? 6); const waypoints = mode === 'smart' ? smartPath() : serpentine();
const growTo = Number(wrap.dataset.grow ?? 0); // 子格插值:把路径展开成"连续坐标序列"(越长的段走越快,节奏才不拖)
const dual = wrap.dataset.dual === '1'; const samples: { x: number; y: number; idx: number }[] = [];
const barsLink = wrap.dataset.barsLink === '1'; for (let k = 0; k < waypoints.length; k++) {
const from = posOf(waypoints[k]);
const to = posOf(waypoints[(k + 1) % waypoints.length]);
const dist = Math.hypot(to.x - from.x, to.y - from.y);
const steps = Math.max(1, Math.round(dist / (mode === 'smart' ? 7 : 5)));
for (let s = 0; s < steps; s++) {
const t = s / steps;
samples.push({
x: from.x + (to.x - from.x) * t,
y: from.y + (to.y - from.y) * t,
idx: waypoints[k],
});
}
}
let body = baseBody; const calc = stage.parentElement?.querySelector<HTMLElement>('[data-calc="p1"]');
if (calc) calc.textContent = String(waypoints.length);
let i = 0;
let loop = 1;
let eaten = 0; let eaten = 0;
let loopNo = 1;
let timer = 0; let timer = 0;
let paused = false; let paused = false;
// 双蛇:两条独立游标,反向推进 let monthSeen = '';
const cursors = dual ? [0, PATH.length - 1] : [0];
const dirs = dual ? [1, -1] : [1];
const trails: number[][] = cursors.map(() => []);
const paint = () => { const setHead = (x: number, y: number, visible = true) => {
cells.forEach((c) => c.classList.remove('snake-head', 'snake-body', 'snake-eaten')); head.style.transform = `translate(${x}px, ${y}px)`;
trails.forEach((t) => t.forEach((i) => cells[i]?.classList.add('snake-body'))); head.style.opacity = visible ? '1' : '0';
cursors.forEach((cur, si) => { };
const cell = cells[PATH[cur]]; const trail: { x: number; y: number }[] = [];
if (cell) cell.classList.add('snake-head'); const paintTrail = () => {
const e = trails[si]; trailEl.style.boxShadow = trail
if (e) e[e.length - 1] = PATH[cur]; .map((p, k) => `${p.x}px ${p.y}px 0 ${Math.max(1, 4 - k)}px color-mix(in oklab, var(--accent) ${45 - k * 4}%, transparent)`)
void cur; .join(',');
});
}; };
const reset = (full: boolean) => { const reset = () => {
i = 0;
eaten = 0; eaten = 0;
body = baseBody; trail.length = 0;
cursors.forEach((_, i) => (cursors[i] = i === 0 ? 0 : PATH.length - 1)); cells.forEach((c) => c.classList.remove('snake-eaton', 'snake-path'));
trails.forEach((t) => (t.length = 0)); bars.forEach((b) => b.classList.remove('on'));
cells.forEach((c) => c.classList.remove('snake-head', 'snake-body', 'snake-eaten')); monthSeen = '';
if (full) bars.forEach((b) => b.classList.remove('on')); paintTrail();
if (lenEl) lenEl.textContent = String(body);
if (loopEl) loopEl.textContent = String(loopNo);
}; };
const step = () => { let holdUntil = 0;
let moved = false; const tick = () => {
cursors.forEach((cur, si) => { if (holdUntil && Date.now() < holdUntil) return; // 换月小停顿
const idx = PATH[cur]; holdUntil = 0;
if (idx === undefined) return; if (i >= samples.length) {
moved = true; if (read) read.textContent = `第 ${loop} 轮完成:吃到 ${eaten} 天`;
const cell = cells[idx]; loop++;
if (!cell) return; if (loopEl) loopEl.textContent = String(loop);
// 轨迹队列:保持 body 长度 window.setTimeout(() => {
const trail = trails[si]; reset();
trail.push(idx); if (!paused) start();
if (trail.length > body) { }, 700);
const dropped = trail.shift(); window.clearInterval(timer);
cells[dropped ?? -1]?.classList.remove('snake-body'); timer = 0;
return;
} }
cells.forEach((c) => c.classList.remove('snake-head')); const s = samples[i++];
cursors.forEach((c2) => cells[PATH[c2]]?.classList.add('snake-head')); setHead(s.x + CELL / 2, s.y + CELL / 2);
cells[idx].classList.add('snake-body'); trail.unshift({ x: s.x + CELL / 2, y: s.y + CELL / 2 });
if (trail.length > bodyLen) trail.pop();
paintTrail();
const count = Number(cell.dataset.count ?? 0); const cell = cells[s.idx];
if (count > 0) { if (!cell) return;
// 月柱联动(三案共用):柱子点亮到当前格所属月份
const mk = (cell.dataset.date ?? '').slice(0, 7);
const mi = monthsReal.indexOf(mk);
if (mi >= 0) bars.slice(0, mi + 1).forEach((b) => b.classList.add('on'));
// P3:换月时额外停一拍 + 显示月份(只影响节奏,不重建计时器)
if (mode === 'month' && mk && mk !== monthSeen) {
monthSeen = mk;
if (monthLabel) monthLabel.textContent = mk;
if (holdUntil === 0) holdUntil = Date.now() + 180;
}
cell.classList.add('snake-path');
if (Number(cell.dataset.count ?? 0) > 0) {
cell.classList.add('snake-eaten'); cell.classList.add('snake-eaten');
eaten++; eaten++;
if (growTo) body = Math.min(growTo, body + 1); if (read) read.textContent = `${cell.dataset.date} · 吃到 ${cell.dataset.count} 次(累计 ${eaten} 天)`;
if (readEl)
readEl.textContent = `${cell.dataset.date} · 吃到 ${count} 次(累计 ${eaten} 天)`;
} }
// 月份联动:柱子按当前列所属月份点亮
if (barsLink && bars.length) {
const col = Math.floor(idx / 7);
const date = cell.dataset.date ?? '';
const mi = monthKeys.indexOf(date.slice(0, 7));
if (mi >= 0) bars.slice(0, mi + 1).forEach((b) => b.classList.add('on'));
}
if (lenEl) lenEl.textContent = String(body);
// 推进游标
const next = cur + dirs[si];
cursors[si] = next;
if (next < 0 || next >= PATH.length) cursors[si] = -1 as number;
});
// 全部游标到端点 → 一轮结束
if (!moved || cursors.every((c) => c < 0)) {
clearInterval(timer);
timer = 0;
if (readEl) readEl.textContent = `第 ${loopNo} 轮完成:吃到 ${eaten} 天`;
loopNo++;
setTimeout(() => {
reset(true);
if (!paused) start();
}, 900);
}
void paint;
}; };
const speed = mode === 'smart' ? 22 : mode === 'smooth' ? 20 : 18;
const start = () => { const start = () => {
if (timer || paused) return; if (timer || paused) return;
reset(false); if (!samples.length) return;
timer = window.setInterval(step, 26); timer = window.setInterval(tick, speed);
}; };
const stop = () => { // 减少动态:静态终态
if (timer) clearInterval(timer);
timer = 0;
};
toggle?.addEventListener('click', () => {
paused = !paused;
toggle.textContent = paused ? '继续' : '暂停';
if (paused) stop();
else start();
});
if (reduce) { if (reduce) {
// 减少动态:直接展示终态(所有有贡献的格子点亮 + 柱子立起)
cells.forEach((c) => { cells.forEach((c) => {
if (Number(c.dataset.count ?? 0) > 0) c.classList.add('snake-eaten'); if (Number(c.dataset.count ?? 0) > 0) c.classList.add('snake-eaten');
}); });
bars.forEach((b) => b.classList.add('on')); bars.forEach((b) => b.classList.add('on'));
if (readEl) readEl.textContent = '已按"减少动态效果"静态呈现'; setHead(0, 0, false);
if (read) read.textContent = '已按"减少动态效果"静态呈现';
} else { } else {
new IntersectionObserver( new IntersectionObserver(
(entries) => { (entries) => {
for (const e of entries) { for (const e of entries) {
if (e.isIntersecting) start(); if (e.isIntersecting) start();
else stop(); else {
if (timer) window.clearInterval(timer);
timer = 0;
}
} }
}, },
{ threshold: 0.15 }, { threshold: 0.15 },
).observe(wrap); ).observe(stage);
}
}
/* J:单独的柱状图(进视口升起 + hover 联动) */
const chart = document.querySelector<HTMLElement>('[data-bars-chart]');
if (chart) {
new IntersectionObserver(
(entries) => {
for (const e of entries) if (e.isIntersecting) e.target.classList.add('grow');
},
{ threshold: 0.2 },
).observe(chart);
const grid = document.querySelector<HTMLElement>('[data-bars]');
for (const btn of chart.querySelectorAll<HTMLElement>('[data-month]')) {
const m = btn.dataset.month ?? '';
const on = () =>
grid?.querySelectorAll<HTMLElement>('.hg-c').forEach((c) =>
c.classList.toggle('hg-month', (c.dataset.date ?? '').startsWith(m)),
);
const off = () => grid?.querySelectorAll<HTMLElement>('.hg-c').forEach((c) => c.classList.remove('hg-month'));
btn.addEventListener('pointerenter', on);
btn.addEventListener('pointerleave', off);
btn.addEventListener('focus', on);
btn.addEventListener('blur', off);
} }
} }
</script> </script>
@@ -364,55 +339,47 @@ const stats = { total, current, longest, activeDays };
.pv-sec { margin-top: var(--space-6); scroll-margin-top: 56px; } .pv-sec { margin-top: var(--space-6); scroll-margin-top: 56px; }
.pv-head { border-top: 1px solid var(--line); padding-top: var(--space-3); } .pv-head { border-top: 1px solid var(--line); padding-top: var(--space-3); }
.pv-tag { display: inline-block; font-size: 11px; color: var(--ink-4); border: 1px solid var(--line); border-radius: 4px; padding: 0 5px; } .pv-tag { display: inline-block; font-size: 11px; color: var(--ink-4); border: 1px solid var(--line); border-radius: 4px; padding: 0 5px; }
.pv-rec { font-size: 11px; color: var(--accent); border: 1px solid color-mix(in oklab, var(--accent) 40%, var(--line)); border-radius: 4px; padding: 0 5px; margin-left: 6px; font-family: var(--font-mono); }
.pv-head h2 { font-size: 20px; color: var(--ink-1); margin: var(--space-1) 0; } .pv-head h2 { font-size: 20px; color: var(--ink-1); margin: var(--space-1) 0; }
.pv-pros { font-size: 13px; color: var(--ink-3); line-height: 1.7; max-width: var(--measure); margin: 0 0 var(--space-3); } .pv-pros { font-size: 13px; color: var(--ink-3); line-height: 1.7; max-width: var(--measure); margin: 0 0 var(--space-3); }
.pv-pros b { color: var(--ink-2); } .pv-pros b { color: var(--ink-2); }
.pv-foot { font-size: 12px; color: var(--ink-4); margin-top: var(--space-6); } .pv-foot { font-size: 12px; color: var(--ink-4); margin-top: var(--space-6); }
.hm-wrap { overflow-x: auto; padding: 6px 0; } .hm-read { font-size: 12px; color: var(--ink-4); margin-top: var(--space-2); }
.hm-read { font-size: 12px; color: var(--ink-4); margin-top: var(--space-2); display: flex; gap: var(--space-2); align-items: center; flex-wrap: wrap; }
.hm-btn {
font-family: var(--font-mono); font-size: 12px; color: var(--ink-2);
background: var(--bg-soft); border: 1px solid var(--line); border-radius: 6px; padding: 3px 10px; cursor: pointer;
}
.hm-btn:hover { border-color: var(--accent); }
/* 蛇:头 / 身 / 吃过 */ /* 舞台:网格 + 浮动的蛇头/拖尾(位置由 JS 用 transform 控制) */
:global(.snake-head) { .hm-stage { position: relative; }
outline: 2px solid var(--accent); .hm-stage .hm-wrap { overflow-x: auto; padding: 6px 0; }
outline-offset: 1px; .hm-head {
z-index: 2; position: absolute; left: 0; top: 6px; width: 10px; height: 10px; border-radius: 50%;
position: relative; background: var(--accent);
} box-shadow: 0 0 0 2px color-mix(in oklab, var(--accent) 28%, transparent), 0 0 10px color-mix(in oklab, var(--accent) 55%, transparent);
:global(.snake-body) { pointer-events: none;
background: color-mix(in oklab, var(--accent) 32%, transparent) !important; transition: transform 90ms linear, opacity 200ms var(--ease);
z-index: 3;
} }
.hm-trail { position: absolute; left: 0; top: 6px; width: 1px; height: 1px; pointer-events: none; z-index: 2; }
:global(.snake-path) { background: color-mix(in oklab, var(--accent) 18%, transparent); }
:global(.snake-eaten) { :global(.snake-eaten) {
background: var(--accent) !important; background: var(--accent) !important;
box-shadow: 0 0 0 1px color-mix(in oklab, var(--accent) 45%, transparent); transition: background 160ms var(--ease);
transition: background 180ms var(--ease);
} }
/* 月度柱 */
.hm-bars { .hm-bars {
display: flex; align-items: flex-end; gap: 6px; list-style: none; display: flex; align-items: flex-end; gap: 6px; list-style: none;
padding: 0; margin: var(--space-2) 0 0; height: 84px; padding: 0; margin: var(--space-2) 0 0; height: 76px;
} }
.hm-bars li { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; height: 100%; justify-content: flex-end; } .hm-bars li { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; height: 100%; justify-content: flex-end; }
.hm-bar-btn { width: 100%; height: 100%; display: flex; align-items: flex-end; background: none; border: 0; padding: 0; cursor: pointer; } .hm-bar-btn { width: 100%; height: 100%; display: flex; align-items: flex-end; }
.hm-bar-fill { .hm-bar-fill {
display: block; width: 100%; height: 0; display: block; width: 100%; height: 0;
background: var(--ink-4); border-radius: 2px 2px 0 0; background: var(--ink-4); border-radius: 2px 2px 0 0;
transition: height 420ms cubic-bezier(0.22, 0.8, 0.3, 1), background 160ms var(--ease); transition: height 380ms cubic-bezier(0.22, 0.8, 0.3, 1), background 160ms var(--ease);
} }
.grow .hm-bar-fill { height: var(--h, 0%); }
/* FJ:蛇爬到该月时柱子升起(由 JS 加 .on) */
.hm-bar-fill.on { height: var(--h, 0%); background: var(--accent); } .hm-bar-fill.on { height: var(--h, 0%); background: var(--accent); }
.hm-bar-btn:hover .hm-bar-fill { background: var(--accent); }
.hm-bar-label { font-size: 10.5px; color: var(--ink-4); } .hm-bar-label { font-size: 10.5px; color: var(--ink-4); }
:global(.hg-month) { background: var(--accent) !important; }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.hm-head { transition: none; }
.hm-bar-fill { transition: none; } .hm-bar-fill { transition: none; }
} }
</style> </style>
+2 -1
View File
@@ -1,10 +1,11 @@
import rss from '@astrojs/rss'; import rss from '@astrojs/rss';
import { isVisiblePost } from '../lib/post-visibility';
import { getCollection } from 'astro:content'; import { getCollection } from 'astro:content';
import type { APIContext } from 'astro'; import type { APIContext } from 'astro';
// RSS:posts 集合全量(draft 排除);Phase 3 起双语各出一路(/rss.xml 与 /en/rss.xml) // RSS:posts 集合全量(draft 排除);Phase 3 起双语各出一路(/rss.xml 与 /en/rss.xml)
export async function GET(context: APIContext) { export async function GET(context: APIContext) {
const posts = (await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden'))) const posts = (await getCollection('posts', ({ data }) => isVisiblePost(data)))
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()); .sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
return rss({ return rss({
title: 'wait4x', title: 'wait4x',
+10 -3
View File
@@ -187,10 +187,17 @@ ok('归档 首年展开/往年折叠 + 月分组', arch.total >= 1 && arch.open
await page.goto(BASE + '/books/', { waitUntil: 'networkidle' }); await page.goto(BASE + '/books/', { waitUntil: 'networkidle' });
const books = await page.evaluate(() => { const books = await page.evaluate(() => {
const all = [...document.querySelectorAll('.year-group')]; // 书架已改版为「抽书阅读」(Phase 5i T7):不再有年份折叠组,
return { total: all.length, open: all.filter((d) => d.hasAttribute('open')).length, firstOpen: all[0]?.hasAttribute('open') ?? false }; // 改为每本书一个 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) 命令面板 + 搜索 // 4) 命令面板 + 搜索
await page.keyboard.press('Control+k'); await page.keyboard.press('Control+k');