Compare commits

...
7 Commits
Author SHA1 Message Date
dsh ef72d52de4 标题后的 # 锚点:补上缺失的样式,并修跳转定位与无障碍
问题
- 插件 src/plugins/satteri/autolink-headings.mjs 会在每个标题末尾追加
  <a class="anchor"><span class="anchor-icon">#</span></a>,但项目里从来
  没给 .anchor / .anchor-icon 写过任何样式(源码和产物 CSS 都查过,零条规则)
- 结果是那个 # 以纯文本形式常驻显示在每一个标题后面,看起来像 Markdown 语法错误
- 另外标题有 id 却没有 scroll-margin-top,点 # 跳过去标题顶部紧贴视口上沿,
  正好藏在 69px 高的 sticky 导航栏下面

改法
- global.css 补样式:默认 opacity:0 隐藏,鼠标悬停标题(或键盘聚焦锚点)才浮现,
  悬停锚点本身转朱红;触屏没有 hover,直接 display:none(跨标题跳转用目录即可)
- 补 :is(h1..h6)[id] { scroll-margin-top: calc(var(--header-h,69px) + 1.25rem) }
- 插件里给锚点补无障碍名称:aria-label="链接到「标题文字」",图标加 aria-hidden。
  否则读屏会连着念二十几个「#,链接」,完全不知道跳到哪

踩到的坑(已写进操作指南)
Astro 的文章缓存只在 markdown 内容变化时失效,改插件代码不会重新渲染,
必须先 rm -rf node_modules/.astro .astro。第一次改完插件产物没变就是这个原因。

验证:清缓存重建后,产物 HTML 里锚点带上了 aria-label 与 aria-hidden;
产物 CSS 里 .anchor 的 5 条规则 + scroll-margin-top 均正确,
@media(hover:none) 的作用域也确认无误
2026-09-17 18:37:20 +08:00
TimeCrackbot 93362376f8 fix: 消除文章页 dev 下的 "Invalid hook call" 告警
现象
dev 下每次打开文章页,终端都会打印 2 次 React 的 "Invalid hook call"。
生产构建没有;首页、/thoughts/、/friends/ 也没有。

根因(用临时 Vite 插件抓的调用栈定位)
不是 React 多副本,也不是 hooks 写法有问题,而是 Astro 的渲染器探测:

1. TableOfContents 首屏 headings 为空 → `return null`(数据在 useEffect 里填,
   SSR 期不执行)
2. @astrojs/react 的 check 会试渲染一次,拿到 null 就认不出这是 React 组件 → false
3. Astro 转向下一个渲染器 @astrojs/mdx
4. MDX 的 check 把组件**当普通函数裸调** `await Component({...})`,不在 React
   渲染树里 → useState 找不到 dispatcher → 报 Invalid hook call → 抛错被 catch
5. 两个渲染器都说不是自己的,Astro 最终仍用 React 正确渲染了组件

所以页面和组件一直是对的,只有日志是错的。生产构建没有是因为 React 生产版
剥掉了这段 dev-only 警告(栈里是 react.development.js)。
Hero 之类的组件不报,是因为它们探测时返回真实 JSX,第 2 步就被 React 认出来了。

修法
去掉 `if (headings.length === 0) return null`,改为始终返回 <nav>,无标题时用
`hidden` 收起,视觉结果不变。这样第 2 步就能认出是 React 组件,根本不会走到
MDX 的探测。代价是文章页 SSR 多出 2 个 display:none 的空 <nav>(两个 TOC 实例各一个),
以及 island 上多了 Astro 的 await-children 标记。

另一条路(未采用):项目里 0 个 .mdx,直接删掉 @astrojs/mdx 也能消除告警,
且零 DOM 变化。选择保留 MDX 是为了不因一个纯噪音的警告砍掉一个能力。

验证
dev 下逐个请求 5 类页面(含之前必现的 2 个文章页)告警数全为 0;
生产构建与基线的差异只剩本次有意的隐藏 nav、资源哈希和 island uid;
pnpm build 41 页 + pagefind 通过;astro check 仍是 1 error/0 warnings(原有报错)。
2026-09-17 18:16:43 +08:00
TimeCrackbot f4a49ef94c feat: 支持 Obsidian 风格 callout,并把脚注文案中文化
Callout
- 接入 satteri-callouts(HAST 插件),用 Obsidian 语法:
    > [!tip] 标题
    > 内容
    > [!warning]- 默认折叠
  类型名大小写不敏感,支持嵌套、可折叠、标题内联 Markdown。
- 样式没有用它自带的主题 CSS(Obsidian 原版是高饱和蓝/青/紫,还靠
  mix-blend-mode 叠底色,在暖纸底上会发脏),改为新建 src/styles/callouts.css,
  复用主题已有的 --admonitions-color-* token,与 .admonition(免责声明块)
  统一成「3px 左边框 + 彩色标题、无底色」。
- 类型映射:tip/hint→tip,note/info/todo/abstract/summary/tldr→note,
  important/example→important,warning/attention/question/help/faq→warning,
  caution→caution,success/check/done→(新增的)success,
  danger/error/failure/fail/missing/bug→destructive,quote/cite→muted-foreground。
- global.css 补一个 --admonitions-color-success(浅色 oklch(0.62 0.16 150) /
  深色 oklch(0.7 0.18 150)),与 tip(180)/note(250)/important(310) 同明度彩度。

脚注中文化
features.gfm.footnotes 设 label「脚注」、backLabel「返回第 {reference} 条」、
backContent「↑」,替换默认的 Footnotes / Back to reference。

正文
- 周末愉快.md:`::: tip 下周计划`(冒号后有空格,从来不是合法指令,
  一直渲染成普通段落)改成 `> [!tip] 下周计划`,任务列表一并加 `>` 前缀。
- 关于价值感和归属感的思考.md 的 `> [!tip] Tip` 本来就合法,一行未改,
  现在自动变成真 callout。

顺带修
autolink-headings 不再给脚注区那个 sr-only 的「脚注」标题加 # 锚点。

验证
- 两处 callout 均正确渲染;callout 内的图片仍带 data-lightbox-src
  (说明 imageLightbox 与 satteriCallouts 的执行顺序没问题)。
- 临时加了一篇带脚注和折叠 callout 的文章验证:脚注文案、返回链接、
  折叠态 <details>/<summary> 结构都正确,验证后已删除。
- 整页对比基线:37/41 一致,4 处差异中 2 处是本次有意的 callout 生效,
  另 2 处是已知的智能标点引号方向(Sätteri 的结果更正确)。
- pnpm build 41 页 + pagefind 通过。
2026-09-17 18:01:44 +08:00
TimeCrackbot 5e808a9cd4 feat: 接入 KaTeX 渲染,完成 Sätteri 迁移(Phase 3)
方案:用现成的 satteri-katex(MDAST 插件,40 行,是 rehype-katex 的移植),
而不是自己写。它跑在 MDAST 阶段,天然绕开下面这个坑。

为什么不能用 HAST 阶段做
Sätteri 把块级公式输出成 <pre><code class="language-math math-display">,
而 expressive-code 过滤所有 <pre>,会把它当成未知语言渲染成带行号的
plaintext 代码块 —— 实测 10 个块级公式全部被吃掉(math-display: 0)。
unified 时期没这问题,是因为 rehype-katex 排在 rehypePlugins 第一位,
先把 <pre><code> 换掉了。satteri-katex 的源码注释里记录的是同一个坑。

插件顺序
satteriKatex() 放在 mdastPlugins 最后:它把 math 节点换成 KaTeX 的 html
节点,若排在 reading-time / excerpt 之前,字数统计会把 KaTeX 标记也算进去。

顺带修掉一个之前漏掉的回归
reading-time / excerpt 原本用 ctx.textContent(),但它只拼接纯 text 节点,
会漏掉代码块、行内代码、公式、图片 alt。实测 LaTeX 那篇字数 830 → 488。
改用 mdast-util-to-string 的 toString()(与迁移前的实现一致),字数完全复原。
之前没发现是因为我的对比脚本只截取了 <article> 区域,而字数在它外面的 header 里。
现改为整页对比。

清理依赖(10 个,已确认无引用)
remark-math / rehype-katex / remark-directive /
remark-github-admonitions-to-directives / remark-sectionize / rehype-slug /
rehype-components / rehype-autolink-headings / @astrojs/markdown-remark /
hastscript / unist-util-visit

验证(与 unified() 基线整页对比,41 页)
- 39 页语义完全一致;2 页只剩智能标点在中文/emoji 旁的引号方向差异
  (Sätteri 的结果更正确:把开引号排成 “ 而不是 ”)。
- LaTeX 页:class="katex" 76、katex-display 10,与基线完全一致;
  块级公式不再被 expressive-code 吞掉。
- 字数/阅读时长与基线逐篇一致(830/4、1042/5、416/2)。
- pnpm build 41 页 + pagefind 通过;astro check 仍是 1 error/0 warnings
  (原有 footer.astro 报错,与本次无关)。
2026-09-17 17:52:56 +08:00
TimeCrackbot 26e873b9f6 refactor: Markdown 管线迁移到 Sätteri(Phase 1 + Phase 2)
把 markdown.processor 从 unified() 换成 Astro 7 的默认管线 satteri(),
并把原来 12 个 remark/rehype 插件逐一移植到 Sätteri 的 MDAST/HAST 插件模型。

内置能力直接替代(零代码)
- remark-math      → features.math
- remark-directive → 未启用(见下)
- rehype-slug      → satteriHeadingIdsPlugin()

新增 src/plugins/satteri/(6 个插件,约 200 行)
- reading-time.mjs    after 钩子写 ctx.data.astro.frontmatter.minutes/words
- excerpt.mjs         取第一段写 frontmatter.excerpt
- shift-headings.mjs  工厂按 fileURL 判断,只对 posts 下的文章生效
- sectionize.mjs      remark-sectionize 的等价实现(递归分组 + data.hName)
- autolink-headings.mjs  标题锚点,输出与 rehype-autolink-headings 逐字一致
- image-lightbox.mjs  上一版 rehype 图片灯箱的 HAST 版

删除(已迁移或本就无效)
- remark-reading-time / remark-excerpt / remark-shift-headings /
  remark-directive-rehype / rehype-component-admonition /
  rehype-image-lightbox —— 共 6 个旧插件文件
- 未启用 features.directive:内容里的 `::: tip`(冒号后有空格)和
  `> [!tip] Tip` 在迁移前后都渲染成普通段落/引用,属于死语法;
  保持现状以免升级顺手把它激活。

行为对齐
- smartPunctuation: { dashes: false } —— Astro 的 unified 管线会把直引号
  排成弯引号但不转换 ---,Sätteri 默认会转,显式关掉 dashes 才能逐字一致。

验证(与 unified() 构建逐页对比,41 页)
- 正文 HTML 完全一致 35 页;把 HTML 实体和表格对齐写法归一化后,
  只剩 3 页有差异:1 页是 KaTeX 尚未迁移(见下),2 页是中文/emoji 旁的
  引号方向(Sätteri 的结果更正确)。
- meta description 仍由 excerpt 正确生成;阅读时长与基线一致。
- astro check:1 error / 0 warnings,与迁移前完全相同(原有 footer.astro 报错)。
- pnpm build 41 页通过;dev server 正常。

尚未迁移
- KaTeX:Sätteri 的 math 只负责解析,不渲染。数学公式目前落成
  <code class="language-math">,是可见的回归,Phase 3 处理。
- package.json 里 remark-*/rehype-* 依赖暂未清理,等 Phase 3 确定后再收。
2026-09-17 17:38:37 +08:00
TimeCrackbot c1643ae95d feat: 文章正文图片支持灯箱;删除未使用的 GitHub Card 插件
灯箱
- 相册页原本已有一套无依赖灯箱(事件委托 + ClientRouter 友好),
  但它写在 gallery/[album].astro 里,只有相册页能用。
  现抽成 src/components/ImageLightbox.astro,挂到 BaseLayout,全站可用。
- 新增 src/plugins/rehype-image-lightbox.mjs:给正文里的 <img> 注入
  data-lightbox-src。跳过链接内的图片(点击应跳转)和显式标记
  data-lightbox-ignore 的图片。
- 顺带让灯箱大图继承原图的 alt 文本(原先固定为空)。
- 正文图片加 cursor: zoom-in 提示。

清理
- 删除 src/plugins/rehype-component-github-card.mjs(165 行)及其在
  rehypeComponents 里的 github 映射:内容里从未使用过 ::github 指令。

验证:构建 41 页通过;文章页正文图片 4/4 带上 data-lightbox-src,
相册页 5 个触发元素不受影响;灯箱脚本按页内联(2.8KB 压缩后);
正文渲染文本与线上基线一致,无回归。
2026-09-17 17:29:23 +08:00
TimeCrackbot 738fe3175e chore: 升级 Astro 5.16.5 → 7.3.3
依赖
- astro 5 → 7.3.3,@astrojs/{mdx,react,rss,sitemap,check}、astro-expressive-code
  与 @swup/astro 同步升到最新
- 新增 @astrojs/markdown-remark(Astro 7 默认改用 Sätteri 处理器,
  本项目依赖大量 remark/rehype 插件,故显式固定回 unified 管线)

内容集合迁移到 Content Layer API(Astro 6 已移除旧 API)
- src/content/config.ts → src/content.config.ts,三个集合改用 glob() loader
- z 改从 astro/zod 导入
- 所有 entry.slug → entry.id;entry.render() → render(entry)

Astro 7 兼容
- 修复 bookshelf-content.astro 里多行 {/* */} 注释导致的 Rust 编译器
  "Unterminated regular expression"
- 删除该文件里一处多余的 </div>(旧 Go 编译器会静默纠正,新编译器不会)
- global.css: @import "tailwindcss" → "tailwindcss/index.css"
  (Vite 8 不再解析裸包名的 style 条件)
- astro.config.mjs 显式设置 compressHTML: true,保持 v6 的空白处理行为

验证:构建 41 页通过;与线上(Astro 5 构建)对比 39 个 URL 无一丢失,
仅新增新文章 DIY-computer 的 2 个 URL;逐页渲染文本与线上一致;
pnpm build 与 dev server 均正常。
2026-09-17 17:14:08 +08:00
35 changed files with 4569 additions and 4190 deletions
+42 -65
View File
@@ -5,29 +5,19 @@ import mdx from "@astrojs/mdx"
import { pluginCollapsibleSections } from "@expressive-code/plugin-collapsible-sections"
import { pluginLineNumbers } from "@expressive-code/plugin-line-numbers"
import expressiveCode from "astro-expressive-code"
import rehypeAutolinkHeadings from "rehype-autolink-headings"
import rehypeComponents from "rehype-components"
import rehypeKatex from "rehype-katex"
import rehypeSlug from "rehype-slug"
import remarkDirective from "remark-directive"
import remarkGithubAdmonitionsToDirectives from "remark-github-admonitions-to-directives"
import remarkMath from "remark-math"
import remarkSectionize from "remark-sectionize"
import { remarkShiftHeadings } from "./src/plugins/remark-shift-headings.mjs"
import { AdmonitionComponent } from "./src/plugins/rehype-component-admonition.mjs"
import { GithubCardComponent } from "./src/plugins/rehype-component-github-card.mjs"
import { parseDirectiveNode } from "./src/plugins/remark-directive-rehype.js"
import { remarkExcerpt } from "./src/plugins/remark-excerpt.js"
import { remarkReadingTime } from "./src/plugins/remark-reading-time.mjs"
import { pluginLanguageBadge } from "./src/plugins/expressive-code/language-badge"
import { pluginCustomCopyButton } from "./src/plugins/expressive-code/custom-copy-button"
import { satteri, satteriHeadingIdsPlugin } from "@astrojs/markdown-satteri"
import { excerpt } from "./src/plugins/satteri/excerpt.mjs"
import { readingTime } from "./src/plugins/satteri/reading-time.mjs"
import { sectionize } from "./src/plugins/satteri/sectionize.mjs"
import { shiftHeadings } from "./src/plugins/satteri/shift-headings.mjs"
import { autolinkHeadings } from "./src/plugins/satteri/autolink-headings.mjs"
import { imageLightbox } from "./src/plugins/satteri/image-lightbox.mjs"
import { satteriKatex } from "satteri-katex"
import satteriCallouts from "satteri-callouts"
import { site } from "./src/data/site"
const createAdmonitionComponent = (type) => (properties = {}, children = []) => {
const normalizedChildren = Array.isArray(children) ? children : []
return AdmonitionComponent(properties, normalizedChildren, type)
}
export default defineConfig({
srcDir: "./src",
output: "static",
@@ -85,57 +75,44 @@ export default defineConfig({
sitemap(),
mdx(),
],
compressHTML: true,
markdown: {
remarkPlugins: [
remarkMath,
remarkReadingTime,
remarkExcerpt,
remarkShiftHeadings,
remarkGithubAdmonitionsToDirectives,
remarkDirective,
remarkSectionize,
parseDirectiveNode,
],
rehypePlugins: [
rehypeKatex,
rehypeSlug,
[
rehypeComponents,
{
components: {
github: GithubCardComponent,
note: createAdmonitionComponent("note"),
tip: createAdmonitionComponent("tip"),
important: createAdmonitionComponent("important"),
caution: createAdmonitionComponent("caution"),
warning: createAdmonitionComponent("warning"),
// Astro 7 的 Sätteri 管线。以下插件是 unified 时期 remark/rehype 插件的对应移植,
// 见 src/plugins/satteri/。
//
// 注意:math 只负责「解析」,渲染成 KaTeX 需要额外的 HAST 插件 —— 尚未迁移。
processor: satteri({
// dashes: false 是为了和迁移前的输出逐字一致:Astro 的 unified 管线
// 会把直引号排成弯引号,但不转换 --- (Sätteri 默认会转成破折号)。
features: {
math: true,
smartPunctuation: { dashes: false },
// 脚注文案中文化(默认是 "Footnotes" / "Back to reference 1")
gfm: {
footnotes: {
label: "脚注",
backLabel: "返回第 {reference} 条",
backContent: "↑",
},
},
},
mdastPlugins: [
readingTime,
excerpt,
shiftHeadings(),
sectionize(),
// KaTeX 必须排在 reading-time / excerpt 之后:它把 math 节点换成 KaTeX 的
// HTML,先换的话 ctx.textContent() 会把 KaTeX 标记也算进字数。
satteriKatex(),
],
[
rehypeAutolinkHeadings,
{
behavior: "append",
properties: {
className: ["anchor"],
},
content: {
type: "element",
tagName: "span",
properties: {
className: ["anchor-icon"],
"data-pagefind-ignore": true,
},
children: [
{
type: "text",
value: "#",
},
],
},
},
hastPlugins: [
satteriHeadingIdsPlugin(),
autolinkHeadings,
imageLightbox,
// Obsidian 语法的 callout:> [!tip] 标题
satteriCallouts(),
],
],
}),
},
vite: {
server: {
+13
View File
@@ -299,6 +299,19 @@ sudo sysctl fs.inotify.max_user_watches=524288
如果连空态提示都看不到,检查 `src/data/reading.json` 是否存在(这个文件要提交进 git)。
**改了 markdown 插件却不生效**
Astro 会把渲染好的文章缓存到 `node_modules/.astro`,而这个缓存**只按 markdown
文件内容算 key,不看插件代码**。所以改完 `src/plugins/satteri/*.mjs` 之后,
已经渲染过的文章不会重新跑插件 —— 你会以为改动没生效,其实是吃了缓存。
```bash
rm -rf node_modules/.astro .astro && pnpm build
```
(这个坑是给标题锚点补 `aria-label` 时踩到的:插件明明改了,
产物里的 HTML 却还是老样子。)
**`pnpm dev` 启动时满屏刷红**
历史上踩过两个独立的原因,都已修好,这里记下来免得再犯:
+14 -20
View File
@@ -14,14 +14,15 @@
"reading": "node scripts/fetch-reading.mjs"
},
"dependencies": {
"@astrojs/mdx": "^5.0.6",
"@astrojs/react": "^4.4.2",
"@astrojs/rss": "^4.0.12",
"@astrojs/sitemap": "^3.5.1",
"@astrojs/markdown-satteri": "^0.4.1",
"@astrojs/mdx": "^8.0.1",
"@astrojs/react": "^6.0.6",
"@astrojs/rss": "^4.0.19",
"@astrojs/sitemap": "^3.7.4",
"@aws-sdk/client-s3": "^3.1037.0",
"@expressive-code/core": "^0.41.5",
"@expressive-code/plugin-collapsible-sections": "^0.41.5",
"@expressive-code/plugin-line-numbers": "^0.41.5",
"@expressive-code/core": "^0.44.2",
"@expressive-code/plugin-collapsible-sections": "^0.44.2",
"@expressive-code/plugin-line-numbers": "^0.44.2",
"@hookform/resolvers": "^3.10.0",
"@radix-ui/react-accordion": "1.2.2",
"@radix-ui/react-alert-dialog": "1.1.4",
@@ -50,9 +51,9 @@
"@radix-ui/react-toggle": "1.1.1",
"@radix-ui/react-toggle-group": "1.1.1",
"@radix-ui/react-tooltip": "1.1.6",
"@swup/astro": "^1.7.0",
"@swup/astro": "^1.8.0",
"@tabler/icons-react": "^3.40.0",
"astro-expressive-code": "^0.41.5",
"astro-expressive-code": "^0.44.2",
"autoprefixer": "^10.4.20",
"bcryptjs": "^3.0.2",
"class-variance-authority": "^0.7.1",
@@ -62,7 +63,6 @@
"date-fns": "4.1.0",
"embla-carousel-react": "8.5.1",
"gray-matter": "^4.0.3",
"hastscript": "^9.0.1",
"input-otp": "1.4.1",
"katex": "^0.16.22",
"lucide-react": "^0.454.0",
@@ -76,19 +76,13 @@
"react-resizable-panels": "^2.1.7",
"reading-time": "^1.5.0",
"recharts": "2.15.4",
"rehype-autolink-headings": "^7.1.0",
"rehype-components": "^0.3.0",
"rehype-katex": "^7.0.1",
"rehype-slug": "^6.0.0",
"remark-directive": "^3.0.1",
"remark-github-admonitions-to-directives": "^1.0.5",
"remark-math": "^6.0.0",
"remark-sectionize": "^2.1.0",
"sanitize-html": "^2.17.0",
"satteri": "^0.10.5",
"satteri-callouts": "^1.0.1",
"satteri-katex": "^0.1.1",
"sonner": "^1.7.4",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
"unist-util-visit": "^5.0.0",
"vaul": "^1.1.2",
"zod": "3.25.76"
},
@@ -102,7 +96,7 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/sanitize-html": "^2.16.0",
"astro": "^5.16.5",
"astro": "^7.3.3",
"postcss": "^8.5",
"tailwindcss": "^4.1.9",
"tw-animate-css": "1.3.3",
+3856 -3663
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -5,3 +5,7 @@ allowBuilds:
esbuild: true
sharp: true
swup: true
minimumReleaseAgeExclude:
- '@astrojs/react@6.0.6'
- astro@7.3.3
+132
View File
@@ -0,0 +1,132 @@
---
/**
* 无依赖灯箱。
*
* 任何带 `data-lightbox-src` 的元素点击即可放大:
* - 相册页的 `<button data-lightbox-src>`
* - 文章正文里的 `<img data-lightbox-src>`(由 src/plugins/satteri/image-lightbox.mjs 注入)
*
* 用事件委托 + 每次打开时重新收集当前页的图片,所以 ClientRouter 切页后依然有效;
* 遮罩只在第一次点击时才创建,没点过就不占 DOM。
*/
---
<style is:global>
/* 正文里可放大的图片给出「可点击」提示 */
.prose img[data-lightbox-src] {
cursor: zoom-in;
}
</style>
<script>
const LB_ID = "vv-lightbox"
const ensureOverlay = (): HTMLElement => {
let overlay = document.getElementById(LB_ID)
if (overlay) return overlay
overlay = document.createElement("div")
overlay.id = LB_ID
overlay.setAttribute("role", "dialog")
overlay.setAttribute("aria-modal", "true")
overlay.className =
"fixed inset-0 z-[100] hidden items-center justify-center bg-black/90 backdrop-blur-sm"
overlay.innerHTML = `
<button type="button" data-lb-close aria-label="关闭"
class="absolute right-4 top-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-2xl leading-none text-white/90 transition-colors hover:bg-white/20">×</button>
<button type="button" data-lb-prev aria-label="上一张"
class="absolute left-3 z-10 flex h-11 w-11 items-center justify-center rounded-full bg-white/10 text-2xl leading-none text-white/90 transition-colors hover:bg-white/20 sm:left-6">‹</button>
<button type="button" data-lb-next aria-label="下一张"
class="absolute right-3 z-10 flex h-11 w-11 items-center justify-center rounded-full bg-white/10 text-2xl leading-none text-white/90 transition-colors hover:bg-white/20 sm:right-6">›</button>
<img data-lb-img src="" alt="" class="max-h-[88vh] max-w-[92vw] object-contain" />
<div data-lb-count
class="absolute bottom-5 left-1/2 -translate-x-1/2 rounded-full bg-white/10 px-3 py-1 font-mono text-xs text-white/80"></div>
`
document.body.appendChild(overlay)
return overlay
}
let sources: string[] = []
let alts: string[] = []
let current = 0
const render = (overlay: HTMLElement) => {
const img = overlay.querySelector<HTMLImageElement>("[data-lb-img]")
const counter = overlay.querySelector<HTMLElement>("[data-lb-count]")
if (img) {
img.src = sources[current] ?? ""
img.alt = alts[current] ?? ""
}
if (counter) counter.textContent = `${current + 1} / ${sources.length}`
const single = sources.length < 2
for (const sel of ["[data-lb-prev]", "[data-lb-next]"]) {
const el = overlay.querySelector<HTMLElement>(sel)
if (el) el.style.display = single ? "none" : ""
}
}
const openAt = (index: number) => {
const overlay = ensureOverlay()
current = index
render(overlay)
overlay.classList.remove("hidden")
overlay.classList.add("flex")
document.body.style.overflow = "hidden"
}
const close = () => {
const overlay = document.getElementById(LB_ID)
if (!overlay) return
overlay.classList.add("hidden")
overlay.classList.remove("flex")
document.body.style.overflow = ""
}
const step = (delta: number) => {
if (sources.length < 2) return
current = (current + delta + sources.length) % sources.length
const overlay = document.getElementById(LB_ID)
if (overlay) render(overlay)
}
if (!(window as unknown as { __vvLightboxBound?: boolean }).__vvLightboxBound) {
;(window as unknown as { __vvLightboxBound?: boolean }).__vvLightboxBound = true
document.addEventListener("click", (event) => {
const target = event.target as Element | null
const trigger = target?.closest<HTMLElement>("[data-lightbox-src]")
if (trigger) {
// 每次打开都重新收集当前页的图片
const all = Array.from(document.querySelectorAll<HTMLElement>("[data-lightbox-src]"))
sources = all.map((el) => el.dataset.lightboxSrc ?? "")
alts = all.map((el) => (el instanceof HTMLImageElement ? el.alt : ""))
openAt(Number(trigger.dataset.lightboxIndex ?? all.indexOf(trigger)))
return
}
if (target?.closest("[data-lb-close]")) return close()
if (target?.closest("[data-lb-prev]")) return step(-1)
if (target?.closest("[data-lb-next]")) return step(1)
// 点背景关闭(点图片本身不关)
const overlay = document.getElementById(LB_ID)
if (overlay && target === overlay) close()
})
document.addEventListener("keydown", (event) => {
const overlay = document.getElementById(LB_ID)
if (!overlay || overlay.classList.contains("hidden")) return
if (event.key === "Escape") close()
if (event.key === "ArrowLeft") step(-1)
if (event.key === "ArrowRight") step(1)
})
// 切页时收拾干净,避免残留遮罩锁住滚动
document.addEventListener("astro:page-load", () => {
const overlay = document.getElementById(LB_ID)
if (overlay) overlay.remove()
document.body.style.overflow = ""
})
}
</script>
+3 -10
View File
@@ -163,9 +163,7 @@ const recommendBadge = (status?: string) => {
{/* ========================= Main ========================= */}
<section class="flex-1 min-w-0" data-bookshelf-main>
{/* ========================= 最近在读(微信读书) =========================
数据在构建期由 scripts/fetch-reading.mjs 抓到 src/data/reading.json,
这里只负责渲染,页面本身不发任何请求。 */}
{/* ========================= 最近在读(微信读书) ========================= 数据在构建期由 scripts/fetch-reading.mjs 抓到 src/data/reading.json, 这里只负责渲染,页面本身不发任何请求。 */}
{isReadingView && (
<div id="reading" class="mb-16 scroll-mt-24">
<div class="flex flex-wrap items-end justify-between gap-x-6 gap-y-2 mb-6">
@@ -205,9 +203,7 @@ const recommendBadge = (status?: string) => {
<div class="text-sm italic font-serif text-muted-foreground">博学而笃志,切问而近思</div>
</div>
{/* ---------- Stats + Search + filters ----------
只有存在手工书单时才显示。books.json 为空时这三行都是「0 本已读 /
0 本推荐 / 0 个领域」加一排没东西可筛的按钮,纯占地方。 */}
{/* ---------- Stats + Search + filters ---------- 只有存在手工书单时才显示。books.json 为空时这三行都是「0 本已读 / 0 本推荐 / 0 个领域」加一排没东西可筛的按钮,纯占地方。 */}
{hasManualBooks && (
<>
<div class="flex flex-wrap gap-x-7 gap-y-2 text-sm text-muted-foreground mb-7">
@@ -340,9 +336,7 @@ const recommendBadge = (status?: string) => {
</section>
))}
{/* 微信读书里已读完的书,并入「书籍」区。
卡片用和「最近在读」同一个 ReadingCard 组件,只是底部换成「开始/读完」两行、没有进度条。
data-weread-card 让搜索框和领域/评价筛选同样作用于这一组。 */}
{/* 微信读书里已读完的书,并入「书籍」区。 卡片用和「最近在读」同一个 ReadingCard 组件,只是底部换成「开始/读完」两行、没有进度条。 data-weread-card 让搜索框和领域/评价筛选同样作用于这一组。 */}
{finishedReading.length > 0 && (
<section data-books-section={WEREAD_CATEGORY}>
<div class="flex items-center gap-3 mb-5">
@@ -363,7 +357,6 @@ const recommendBadge = (status?: string) => {
/>
))}
</div>
</div>
</section>
)}
</div>
+13 -3
View File
@@ -285,10 +285,20 @@ export function TableOfContents({ showHeader = true }: TableOfContentsProps) {
}, 2500)
}
if (headings.length === 0) return null
// 注意:这里不能 `return null`。
//
// Astro 靠「渲染器探测」决定交给哪个框架渲染:@astrojs/react 的 check 会试渲染一次,
// 拿到 null 就认不出这是 React 组件;接着轮到 @astrojs/mdx 的 check,而它是把组件
// 当普通函数**裸调**(不在 React 渲染树里),useState 找不到 dispatcher,于是每次
// SSR 都往控制台打一条 "Invalid hook call"。页面本身没错,但那行日志很误导人。
//
// 始终返回 <nav>,没有标题时用 hidden 收起来 —— 视觉结果和 return null 一样。
return (
<nav ref={navRef} aria-label="目录" className="toc-nav">
<nav
ref={navRef}
aria-label="目录"
className={cn("toc-nav", headings.length === 0 && "hidden")}
>
{showHeader ? (
<div className="flex items-center gap-2 mb-3">
<div className="w-1 h-4 bg-primary rounded-full" />
@@ -1,7 +1,9 @@
import { defineCollection, z } from "astro:content"
import { defineCollection } from "astro:content"
import { glob } from "astro/loaders"
import { z } from "astro/zod"
const posts = defineCollection({
type: "content",
loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/posts" }),
schema: z.object({
title: z.string(),
published: z.date(),
@@ -26,7 +28,7 @@ const posts = defineCollection({
})
const thoughts = defineCollection({
type: "content",
loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/thoughts" }),
schema: z.object({
title: z.string().optional(),
published: z.date(),
@@ -43,9 +45,9 @@ const thoughts = defineCollection({
})
const studies = defineCollection({
type: "content",
// 注意:frontmatter 里的 `slug` 字段被 Astro 当 reserved key 处理(用作 URL slug),
// 不会出现在 schema 校验的 data 里。所以这里不声明 slug,直接读 entry.slug。
loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/studies" }),
// 注意:`id` 由 glob loader 从文件路径生成(Content Layer API),
// 所以这里不声明 slug,直接读 entry.id。
schema: z.object({
title: z.string(),
status: z.enum(["在读", "沉淀中", "暂搁", "已结"]),
+4 -7
View File
@@ -17,13 +17,10 @@ draft: false
其实现在看来,一个有趣的网站固然重要,但是毕竟是一个内容输出的平台,没有足够多的原创内容支持,也只能是一个测试站。
另外要想清楚一点,持续的创作和更新其实才是最难的工作。
::: tip 下周计划
- [ ] 阅读累计4小时
- [ ] 汇总整理近期拍摄的照片
- [ ] 写一篇原创文章
:::
> [!tip] 下周计划
> - [ ] 阅读累计4小时
> - [ ] 汇总整理近期拍摄的照片
> - [ ] 写一篇原创文章
+1 -1
View File
@@ -1,5 +1,5 @@
{
"updatedAt": "2026-09-17T08:50:20.126Z",
"updatedAt": "2026-09-17T10:36:53.871Z",
"source": "weread",
"books": [
{
+2
View File
@@ -6,6 +6,7 @@ import "katex/dist/katex.min.css"
import "@/styles/global.css"
import { ClientRouter } from "astro:transitions"
import { analytics, homeTitle, site } from "@/data/site"
import ImageLightbox from "@/components/ImageLightbox.astro"
interface Props {
title?: string
@@ -213,6 +214,7 @@ const resolvedTwitterDescription = twitterDescription || description
</head>
<body class:list={["bg-background text-foreground", { "font-profile-interface": useHarmonySans }]}>
<slot />
<ImageLightbox />
<script is:inline>
// View Transitions 页面切换后重应用主题
(function() {
+3 -3
View File
@@ -41,11 +41,11 @@ export async function getSortedPosts(ignorePinned = false): Promise<PostEntry[]>
export async function getPostsWithNav(): Promise<PostEntry[]> {
const sorted = await getSortedPosts()
for (let i = 1; i < sorted.length; i += 1) {
sorted[i].data.nextSlug = sorted[i - 1].slug
sorted[i].data.nextSlug = sorted[i - 1].id
sorted[i].data.nextTitle = sorted[i - 1].data.title
}
for (let i = 0; i < sorted.length - 1; i += 1) {
sorted[i].data.prevSlug = sorted[i + 1].slug
sorted[i].data.prevSlug = sorted[i + 1].id
sorted[i].data.prevTitle = sorted[i + 1].data.title
}
return sorted
@@ -63,7 +63,7 @@ export const toPostMeta = (post: PostEntry) => {
const category = (post.data.category ?? "").toString().trim() || "未分类"
return {
slug: post.slug,
slug: post.id,
title: post.data.title,
excerpt,
category,
+1 -1
View File
@@ -62,7 +62,7 @@ export async function getSiteStats(latestCount = 4): Promise<SiteStats> {
.slice(0, latestCount)
.map((post) => ({
title: post.data.title,
href: `/posts/${post.slug}/`,
href: `/posts/${post.id}/`,
date: new Date(post.data.published).toISOString().slice(0, 10),
}))
+2 -2
View File
@@ -307,8 +307,8 @@ function shape(entry: StudyEntry, idx: number): StudyData {
const no = String(idx + 1).padStart(2, "0")
return {
id: entry.slug,
slug: entry.slug,
id: entry.id,
slug: entry.id,
no,
title: entry.data.title,
subtitle: entry.data.subtitle,
+2 -2
View File
@@ -57,7 +57,7 @@ export const toThoughtMeta = (thought: ThoughtEntry) => {
const content = md.render(body)
return {
slug: thought.slug,
slug: thought.id,
title: thought.data.title,
content,
date: formatDate(thought.data.published),
@@ -75,7 +75,7 @@ export const toThoughtPreview = (thought: ThoughtEntry, maxLength = 96) => {
const text = stripHtml(content)
return {
slug: thought.slug,
slug: thought.id,
title: thought.data.title,
date: formatDate(thought.data.published),
excerpt: truncateText(text, maxLength),
-112
View File
@@ -122,115 +122,3 @@ const description = album.description ?? "相册"
break-inside: avoid;
}
</style>
<script>
/**
* 无依赖灯箱。
* 用事件委托 + 每次打开时重新收集当前页的照片,因此 ClientRouter 切页后依然有效。
*/
const LB_ID = "vv-lightbox"
const ensureOverlay = (): HTMLElement => {
let overlay = document.getElementById(LB_ID)
if (overlay) return overlay
overlay = document.createElement("div")
overlay.id = LB_ID
overlay.setAttribute("role", "dialog")
overlay.setAttribute("aria-modal", "true")
overlay.className =
"fixed inset-0 z-[100] hidden items-center justify-center bg-black/90 backdrop-blur-sm"
overlay.innerHTML = `
<button type="button" data-lb-close aria-label="关闭"
class="absolute right-4 top-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-2xl leading-none text-white/90 transition-colors hover:bg-white/20">×</button>
<button type="button" data-lb-prev aria-label="上一张"
class="absolute left-3 z-10 flex h-11 w-11 items-center justify-center rounded-full bg-white/10 text-2xl leading-none text-white/90 transition-colors hover:bg-white/20 sm:left-6">‹</button>
<button type="button" data-lb-next aria-label="下一张"
class="absolute right-3 z-10 flex h-11 w-11 items-center justify-center rounded-full bg-white/10 text-2xl leading-none text-white/90 transition-colors hover:bg-white/20 sm:right-6">›</button>
<img data-lb-img src="" alt="" class="max-h-[88vh] max-w-[92vw] object-contain" />
<div data-lb-count
class="absolute bottom-5 left-1/2 -translate-x-1/2 rounded-full bg-white/10 px-3 py-1 font-mono text-xs text-white/80"></div>
`
document.body.appendChild(overlay)
return overlay
}
let sources: string[] = []
let current = 0
const render = (overlay: HTMLElement) => {
const img = overlay.querySelector<HTMLImageElement>("[data-lb-img]")
const counter = overlay.querySelector<HTMLElement>("[data-lb-count]")
if (img) img.src = sources[current] ?? ""
if (counter) counter.textContent = `${current + 1} / ${sources.length}`
const single = sources.length < 2
for (const sel of ["[data-lb-prev]", "[data-lb-next]"]) {
const el = overlay.querySelector<HTMLElement>(sel)
if (el) el.style.display = single ? "none" : ""
}
}
const openAt = (index: number) => {
const overlay = ensureOverlay()
current = index
render(overlay)
overlay.classList.remove("hidden")
overlay.classList.add("flex")
document.body.style.overflow = "hidden"
}
const close = () => {
const overlay = document.getElementById(LB_ID)
if (!overlay) return
overlay.classList.add("hidden")
overlay.classList.remove("flex")
document.body.style.overflow = ""
}
const step = (delta: number) => {
if (sources.length < 2) return
current = (current + delta + sources.length) % sources.length
const overlay = document.getElementById(LB_ID)
if (overlay) render(overlay)
}
if (!(window as unknown as { __vvLightboxBound?: boolean }).__vvLightboxBound) {
;(window as unknown as { __vvLightboxBound?: boolean }).__vvLightboxBound = true
document.addEventListener("click", (event) => {
const target = event.target as Element | null
const trigger = target?.closest<HTMLElement>("[data-lightbox-src]")
if (trigger) {
// 每次打开都重新收集当前页的照片
const all = Array.from(document.querySelectorAll<HTMLElement>("[data-lightbox-src]"))
sources = all.map((el) => el.dataset.lightboxSrc ?? "")
openAt(Number(trigger.dataset.lightboxIndex ?? all.indexOf(trigger)))
return
}
if (target?.closest("[data-lb-close]")) return close()
if (target?.closest("[data-lb-prev]")) return step(-1)
if (target?.closest("[data-lb-next]")) return step(1)
// 点背景关闭(点图片本身不关)
const overlay = document.getElementById(LB_ID)
if (overlay && target === overlay) close()
})
document.addEventListener("keydown", (event) => {
const overlay = document.getElementById(LB_ID)
if (!overlay || overlay.classList.contains("hidden")) return
if (event.key === "Escape") close()
if (event.key === "ArrowLeft") step(-1)
if (event.key === "ArrowRight") step(1)
})
// 切页时收拾干净,避免残留遮罩锁住滚动
document.addEventListener("astro:page-load", () => {
const overlay = document.getElementById(LB_ID)
if (overlay) overlay.remove()
document.body.style.overflow = ""
})
}
</script>
+1 -1
View File
@@ -4,7 +4,7 @@ import { getSortedPosts } from "@/lib/posts"
export async function getStaticPaths() {
const posts = await getSortedPosts(true)
return posts.map((post) => ({
params: { slug: post.slug },
params: { slug: post.id },
}))
}
+5 -4
View File
@@ -4,6 +4,7 @@ import ArticleContent from "@/components/ArticleContent.astro"
import SiteHeader from "@/components/site-header.astro"
import Footer from "@/components/footer.astro"
import { getPostsWithNav, type PostEntry } from "@/lib/posts"
import { render } from "astro:content"
import Disclaimer from "@/components/Disclaimer.astro"
import PasswordProtection from "@/components/PasswordProtection.astro"
import Comments from "@/components/Comments.astro"
@@ -15,7 +16,7 @@ import { site, siteTitleSuffix } from "@/data/site"
export async function getStaticPaths() {
const posts = await getPostsWithNav()
return posts.map((post) => ({
params: { slug: post.slug },
params: { slug: post.id },
props: { post },
}))
}
@@ -26,7 +27,7 @@ if (!post) {
return Astro.redirect("/")
}
const { Content, remarkPluginFrontmatter } = await post.render()
const { Content, remarkPluginFrontmatter } = await render(post)
const words = remarkPluginFrontmatter?.words
const minutes = remarkPluginFrontmatter?.minutes
@@ -113,8 +114,8 @@ if (post.data.encrypted && post.data.password) {
</div>
{encryptedData ? (
<div class="mb-6">
<PasswordProtection encryptedData={encryptedData} slug={post.slug} class="mb-6 rounded-xl" />
<div id={`article-content-${post.slug}`} class="mb-6 hidden encrypted-content">
<PasswordProtection encryptedData={encryptedData} slug={post.id} class="mb-6 rounded-xl" />
<div id={`article-content-${post.id}`} class="mb-6 hidden encrypted-content">
<!-- 内容将在验证密码后动态插入 -->
</div>
</div>
+1 -1
View File
@@ -30,7 +30,7 @@ export async function GET(context: APIContext) {
title: post.data.title,
pubDate: post.data.published,
description: post.data.description || "",
link: `/posts/${post.slug}/`,
link: `/posts/${post.id}/`,
}
if (index >= FULL_CONTENT_LIMIT) return item
+1 -1
View File
@@ -12,7 +12,7 @@ const posts = await getSortedPosts(true)
// 转换为时间线所需的简化数据
const timelineItems = posts.map((post) => ({
slug: post.slug,
slug: post.id,
title: post.data.title,
date: post.data.published.toISOString().slice(0, 10),
}))
@@ -1,34 +0,0 @@
// @ts-nocheck
/// <reference types="mdast" />
import { h } from "hastscript";
/**
* Creates an admonition component.
*
* @param {Object} properties - The properties of the component.
* @param {string} [properties.title] - An optional title.
* @param {('tip'|'note'|'important'|'caution'|'warning')} type - The admonition type.
* @param {import('mdast').RootContent[]} children - The children elements of the component.
* @returns {import('mdast').Parent} The created admonition component.
*/
export function AdmonitionComponent(properties, children, type) {
if (!Array.isArray(children) || children.length === 0)
return h(
"div",
{ class: "hidden" },
'Invalid admonition directive. (Admonition directives must be of block type ":::note{name="name"} <content> :::")',
);
let label = null;
if (properties?.["has-directive-label"] || properties?.hasDirectiveLabel) {
label = children[0]; // The first child is the label
// biome-ignore lint/style/noParameterAssign: <check later>
children = children.slice(1);
label.tagName = "div"; // Change the tag <p> to <div>
}
return h("blockquote", { class: `admonition bdm-${type}` }, [
h("span", { class: "bdm-title" }, label ? label : type.toUpperCase()),
...children,
]);
}
@@ -1,165 +0,0 @@
/// <reference types="mdast" />
// @ts-nocheck
import { h } from "hastscript";
// GitHub language colors (subset of most common languages)
const LANGUAGE_COLORS = {
JavaScript: "#f1e05a",
TypeScript: "#3178c6",
Python: "#3572A5",
Java: "#b07219",
"C++": "#f34b7d",
C: "#555555",
"C#": "#178600",
Go: "#00ADD8",
Rust: "#dea584",
Ruby: "#701516",
PHP: "#4F5D95",
Swift: "#F05138",
Kotlin: "#A97BFF",
Dart: "#00B4AB",
Scala: "#c22d40",
Shell: "#89e051",
HTML: "#e34c26",
CSS: "#563d7c",
SCSS: "#c6538c",
Vue: "#41b883",
Svelte: "#ff3e00",
Astro: "#ff5a03",
MDX: "#fcb32c",
Markdown: "#083fa1",
JSON: "#292929",
YAML: "#cb171e",
Dockerfile: "#384d54",
Makefile: "#427819",
Lua: "#000080",
Perl: "#0298c3",
R: "#198CE7",
Julia: "#a270ba",
Haskell: "#5e5086",
Elixir: "#6e4a7e",
Clojure: "#db5855",
Objective_C: "#438eff",
"Objective-C": "#438eff",
Jupyter_Notebook: "#DA5B0B",
"Jupyter Notebook": "#DA5B0B",
Vim_Script: "#199f4b",
TeX: "#3D6117",
PowerShell: "#012456",
};
/**
* Creates a GitHub Card component.
*
* @param {Object} properties - The properties of the component.
* @param {string} properties.repo - The GitHub repository in the format "owner/repo".
* @param {import('mdast').RootContent[]} children - The children elements of the component.
* @returns {import('mdast').Parent} The created GitHub Card component.
*/
export function GithubCardComponent(properties, children) {
if (Array.isArray(children) && children.length !== 0)
return h("div", { class: "hidden" }, [
'Invalid directive. ("github" directive must be leaf type "::github{repo="owner/repo"}")',
]);
if (!properties.repo || !properties.repo.includes("/"))
return h(
"div",
{ class: "hidden" },
'Invalid repository. ("repo" attributte must be in the format "owner/repo")',
);
const repo = properties.repo;
const cardUuid = `GC${Math.random().toString(36).slice(-6)}`; // Collisions are not important
const nAvatar = h(`div#${cardUuid}-avatar`, { class: "gc-avatar" });
const nLanguage = h(
`span#${cardUuid}-language`,
{ class: "gc-language" },
"—",
);
const nTitle = h("div", { class: "gc-titlebar" }, [
h("div", { class: "gc-titlebar-left" }, [
h("div", { class: "gc-owner" }, [
nAvatar,
h("div", { class: "gc-user" }, repo.split("/")[0]),
]),
h("div", { class: "gc-divider" }, "/"),
h("div", { class: "gc-repo" }, repo.split("/")[1]),
]),
h("div", { class: "github-logo" }),
]);
const nDescription = h(
`div#${cardUuid}-description`,
{ class: "gc-description" },
"Loading...",
);
const nStars = h(`div#${cardUuid}-stars`, { class: "gc-stars" }, "—");
const nForks = h(`div#${cardUuid}-forks`, { class: "gc-forks" }, "—");
const nLicense = h(`div#${cardUuid}-license`, { class: "gc-license" }, "—");
const nScript = h(
`script#${cardUuid}-script`,
{ type: "text/javascript", defer: true, "data-astro-rerun": true },
`
(function() {
const __gcLangColorsMap = ${JSON.stringify(LANGUAGE_COLORS)};
const debugEnabled = () => localStorage.getItem("debugGithubCard") === "1";
const debug = (...args) => { if (debugEnabled()) console.log("[GITHUB-CARD]", ...args); };
const cardEl = document.getElementById('${cardUuid}-card');
if (cardEl && cardEl.dataset.githubLoaded === "true") {
// already loaded by fallback loader
debug("skip (already loaded)", "${repo}");
return;
}
if (cardEl) cardEl.dataset.githubLoaded = "loading";
debug("inline fetch", "${repo}");
fetch('https://api.github.com/repos/${repo}', { referrerPolicy: "no-referrer" }).then(response => response.json()).then(data => {
document.getElementById('${cardUuid}-description').innerText = data.description?.replace(/:[a-zA-Z0-9_]+:/g, '') || "No description provided";
const lang = data.language || "";
const langEl = document.getElementById('${cardUuid}-language');
langEl.innerText = lang || "—";
if (lang && __gcLangColorsMap[lang]) {
langEl.style.setProperty('--gc-lang-color', __gcLangColorsMap[lang]);
}
document.getElementById('${cardUuid}-forks').innerText = Intl.NumberFormat('en-us', { notation: "compact", maximumFractionDigits: 1 }).format(data.forks).replaceAll("\u202f", '');
document.getElementById('${cardUuid}-stars').innerText = Intl.NumberFormat('en-us', { notation: "compact", maximumFractionDigits: 1 }).format(data.stargazers_count).replaceAll("\u202f", '');
const avatarEl = document.getElementById('${cardUuid}-avatar');
avatarEl.style.backgroundImage = 'url(' + data.owner.avatar_url + ')';
avatarEl.style.backgroundColor = 'transparent';
document.getElementById('${cardUuid}-license').innerText = data.license?.spdx_id || "—";
document.getElementById('${cardUuid}-card').classList.remove("fetch-waiting");
if (cardEl) cardEl.dataset.githubLoaded = "true";
debug("inline loaded", "${repo}");
}).catch(() => {
const c = document.getElementById('${cardUuid}-card');
c?.classList.add("fetch-error");
if (cardEl) cardEl.dataset.githubLoaded = "error";
console.warn("[GITHUB-CARD] Error loading card for ${repo}");
});
})();
`,
);
return h(
`a#${cardUuid}-card`,
{
class: "card-github fetch-waiting not-prose",
href: `https://github.com/${repo}`,
target: "_blank",
rel: "noopener noreferrer",
"data-github-card": "",
"data-github-repo": repo,
repo,
},
[
nTitle,
nDescription,
h("div", { class: "gc-infobar" }, [nStars, nForks, nLicense, nLanguage]),
nScript,
],
);
}
-33
View File
@@ -1,33 +0,0 @@
// @ts-nocheck
import { h } from "hastscript";
import { visit } from "unist-util-visit";
/**
* @returns {import('unified').Transformer<import('mdast').Root>}
*/
export function parseDirectiveNode() {
return (tree) => {
visit(tree, (node) => {
if (
node.type === "containerDirective" ||
node.type === "leafDirective" ||
node.type === "textDirective"
) {
// biome-ignore lint/suspicious/noAssignInExpressions: <check later>
const data = node.data || (node.data = {});
node.attributes = node.attributes || {};
if (
node.children.length > 0 &&
node.children[0].data &&
node.children[0].data.directiveLabel
) {
node.attributes["has-directive-label"] = true;
}
const hast = h(node.name, node.attributes);
data.hName = hast.tagName;
data.hProperties = hast.properties;
}
});
};
}
-18
View File
@@ -1,18 +0,0 @@
// @ts-nocheck
// biome-ignore lint/suspicious/noShadowRestrictedNames: <toString from mdast-util-to-string>
import { toString } from "mdast-util-to-string";
/* Use the post's first paragraph as the excerpt */
export function remarkExcerpt() {
return (tree, { data }) => {
let excerpt = "";
for (const node of tree.children) {
if (node.type !== "paragraph") {
continue;
}
excerpt = toString(node);
break;
}
data.astro.frontmatter.excerpt = excerpt;
};
}
-16
View File
@@ -1,16 +0,0 @@
// @ts-nocheck
// biome-ignore lint/suspicious/noShadowRestrictedNames: <toString from mdast-util-to-string>
import { toString } from "mdast-util-to-string";
import getReadingTime from "reading-time";
export function remarkReadingTime() {
return (tree, { data }) => {
const textOnPage = toString(tree);
const readingTime = getReadingTime(textOnPage);
data.astro.frontmatter.minutes = Math.max(
1,
Math.round(readingTime.minutes),
);
data.astro.frontmatter.words = readingTime.words;
};
}
-21
View File
@@ -1,21 +0,0 @@
import { visit } from "unist-util-visit"
export function remarkShiftHeadings(options = {}) {
const {
shift = 1,
onlyIn = ["/src/content/posts/"]
} = options
return (tree, file) => {
const rawPath = typeof file?.path === "string" ? file.path : ""
const normalizedPath = rawPath.replace(/\\/g, "/")
const shouldShift = onlyIn.some((segment) => normalizedPath.includes(segment))
if (!shouldShift || shift <= 0) return
visit(tree, "heading", (node) => {
if (typeof node.depth !== "number") return
node.depth = Math.min(node.depth + shift, 6)
})
}
}
+81
View File
@@ -0,0 +1,81 @@
import { defineHastPlugin } from "satteri"
/**
* 在标题末尾追加一个 # 锚点链接(替代 rehype-autolink-headings)。
*
* 依赖 satteriHeadingIdsPlugin() 先生成 id,所以本插件必须排在它之后。
*
* 目标输出(与 unified 时期逐字一致):
* <h3 id="slug">标题<a class="anchor" href="#slug"><span class="anchor-icon" data-pagefind-ignore="">#</span></a></h3>
*/
const HEADING_TAGS = ["h1", "h2", "h3", "h4", "h5", "h6"]
/** @param {any} node */
const hasAnchor = (node) => {
if (!Array.isArray(node.children)) return false
return node.children.some(
(child) =>
child?.type === "element" &&
child.tagName === "a" &&
Array.isArray(child.properties?.className) &&
child.properties.className.includes("anchor"),
)
}
export const autolinkHeadings = defineHastPlugin({
name: "autolink-headings",
element: {
filter: HEADING_TAGS,
visit(node, ctx) {
const id = node.properties?.id
if (typeof id !== "string" || id.length === 0) return
if (hasAnchor(node)) return
// 取标题自身的文字,用作锚点的无障碍名称(递归收集,标题里可能有 code/strong)
const collectText = (children) =>
(children || [])
.map((child) => {
if (child.type === "text") return child.value
if (Array.isArray(child.children)) return collectText(child.children)
return ""
})
.join("")
.trim()
const headingText = collectText(node.children)
// 脚注区那个视觉隐藏的 "脚注" 标题不需要 # 锚点
let parent = ctx.parent(node)
while (parent) {
if (parent.type === "element" && parent.properties?.["data-footnotes"] !== undefined) {
return
}
parent = ctx.parent(parent)
}
ctx.appendChild(node, {
type: "element",
tagName: "a",
properties: {
className: ["anchor"],
href: `#${id}`,
// 锚点内容只有一个 #,不补名称的话读屏会念「#,链接」,
// 连着二十几个标题听下来完全不知道跳到哪
...(headingText ? { "aria-label": `链接到「${headingText}」` } : {}),
},
children: [
{
type: "element",
tagName: "span",
properties: {
className: ["anchor-icon"],
"data-pagefind-ignore": "",
"aria-hidden": "true",
},
children: [{ type: "text", value: "#" }],
},
],
})
},
},
})
+21
View File
@@ -0,0 +1,21 @@
import { defineMdastPlugin } from "satteri"
import { toString } from "mdast-util-to-string"
/**
* 取正文第一个段落作为摘要,写进 frontmatter.excerpt。
*
* 文章页会用它兜底 <meta name="description">(见 posts/[...slug].astro)。
*/
export const excerpt = defineMdastPlugin({
name: "excerpt",
after(root, ctx) {
let text = ""
for (const node of root.children) {
if (node.type !== "paragraph") continue
// 同 reading-time:ctx.textContent 不含行内代码/公式,用 toString 保持一致
text = toString(node)
break
}
ctx.data.astro.frontmatter.excerpt = text
},
})
+34
View File
@@ -0,0 +1,34 @@
import { defineHastPlugin } from "satteri"
/**
* 给正文里的图片注入 `data-lightbox-src`,交给全局灯箱(ImageLightbox.astro)处理。
*
* 原实现是 rehype 插件(src/plugins/rehype-image-lightbox.mjs),这里是 Sätteri HAST 版。
*
* 两种情况跳过:
* - 图片外面包着 <a>:点击应该跳转,而不是放大
* - 图片上有 `data-lightbox-ignore`:明确表示不要放大
*/
export const imageLightbox = defineHastPlugin({
name: "image-lightbox",
element: {
filter: ["img"],
visit(node, ctx) {
// 沿祖先链查有没有 <a>
let parent = ctx.parent(node)
while (parent) {
if (parent.type === "element" && parent.tagName === "a") return
parent = ctx.parent(parent)
}
const properties = node.properties ?? {}
if (properties["data-lightbox-ignore"] !== undefined) return
const src = properties.src
if (typeof src !== "string" || src.length === 0) return
if (properties["data-lightbox-src"] !== undefined) return
ctx.setProperty(node, "data-lightbox-src", src)
},
},
})
+23
View File
@@ -0,0 +1,23 @@
import { defineMdastPlugin } from "satteri"
import { toString } from "mdast-util-to-string"
import getReadingTime from "reading-time"
/**
* 把阅读时长写进 frontmatter(minutes / words)。
*
* 页面侧通过 render(entry).remarkPluginFrontmatter 读这两个值,
* 与 unified 时期完全一致,所以 posts/[...slug].astro 不用改。
*/
export const readingTime = defineMdastPlugin({
name: "reading-time",
after(root, ctx) {
// 用 mdast-util-to-string 而不是 ctx.textContent:
// 后者只拼接纯 text 节点,会漏掉代码块、行内代码、公式、图片 alt,
// 导致字数比迁移前少一截(实测 LaTeX 那篇 830 → 488 字)。
const text = toString(root)
const stats = getReadingTime(text)
const frontmatter = ctx.data.astro.frontmatter
frontmatter.minutes = Math.max(1, Math.round(stats.minutes))
frontmatter.words = stats.words
},
})
+65
View File
@@ -0,0 +1,65 @@
import { defineMdastPlugin } from "satteri"
/**
* 把每个标题连同它后面的内容包进一个 <section>,标题层级决定嵌套。
*
* 原实现是 remark-sectionize(unified 插件)。它按 6→1 的深度逐层包,
* 这里改成一次递归完成,结果相同但只需要一趟。
*
* 注意:必须在 `before` 里做,不能用 heading visitor —— 重建 children 之后
* 新生成的 section 节点还会被遍历到,heading visitor 会被重复触发,包出多余层级。
*/
/**
* @param {readonly any[]} children
* @returns {any[]}
*/
const groupIntoSections = (children) => {
/** @type {any[]} */
const out = []
let i = 0
while (i < children.length) {
const node = children[i]
if (node?.type === "heading" && typeof node.depth === "number") {
const depth = node.depth
/** @type {any[]} */
const group = [node]
let j = i + 1
while (j < children.length) {
const next = children[j]
// 遇到同级或更高级的标题就收束;export 也当作边界(同 remark-sectionize)
if (next?.type === "heading" && next.depth <= depth) break
if (next?.type === "export") break
group.push(next)
j++
}
out.push({
type: "section",
depth,
// 标题本身留在原层,其余内容递归分组,嵌套关系才对
children: [node, ...groupIntoSections(group.slice(1))],
data: { hName: "section" },
})
i = j
continue
}
out.push(node)
i++
}
return out
}
export const sectionize = () =>
defineMdastPlugin({
name: "sectionize",
before(root, ctx) {
ctx.setProperty(root, "children", groupIntoSections(root.children))
},
})
+27
View File
@@ -0,0 +1,27 @@
import { defineMdastPlugin } from "satteri"
/**
* 把标题层级整体下移 shift 级,只对指定目录下的文章生效。
*
* 原来是 remark 插件,靠 file.path 判断;这里用 Sätteri 的工厂上下文 fileURL,
* 只在匹配的文档上注册插件,其他文档直接跳过(省一次遍历)。
*
* @param {{ shift?: number, onlyIn?: string[] }} [options]
*/
export const shiftHeadings =
({ shift = 1, onlyIn = ["/src/content/posts/"] } = {}) =>
(factoryCtx) => {
const path = (factoryCtx.fileURL?.pathname ?? "").replace(/\\/g, "/")
if (shift <= 0 || !onlyIn.some((segment) => path.includes(segment))) {
return null
}
return defineMdastPlugin({
name: "shift-headings",
heading(node, ctx) {
if (typeof node.depth !== "number") return
ctx.setProperty(node, "depth", Math.min(node.depth + shift, 6))
},
})
}
+159
View File
@@ -0,0 +1,159 @@
/*
* Obsidian 风格 callout 的样式(DOM 由 satteri-callouts 生成)。
*
* 刻意**不引入** satteri-callouts 自带的主题 CSS:它那套是 Obsidian 原版配色
* (蓝/青/紫高饱和色),而且靠 mix-blend-mode 叠底色 —— 在「暖纸底 + 朱红」这套
* 配色上会发灰发脏。这里改为复用主题已有的 --admonitions-color-* token,
* 与 .admonition(免责声明块)保持同一种视觉语言:3px 左边框 + 彩色标题、无底色。
*
* 用法(frontmatter 里写):
* > [!tip] 标题
* > 内容
*
* > [!warning]- 默认折叠的
* > 展开后才看到
*/
.callout {
/* 未指定类型时的兜底色 */
--callout-accent: var(--admonitions-color-note);
border-left: 3px solid var(--callout-accent);
background: transparent;
padding: 0.75rem 1rem;
margin: 1.25rem 0;
color: var(--foreground);
}
/* ---------- 类型 → 主题配色 ---------- */
[data-callout="tip"],
[data-callout="hint"] {
--callout-accent: var(--admonitions-color-tip);
}
[data-callout="note"],
[data-callout="info"],
[data-callout="todo"],
[data-callout="abstract"],
[data-callout="summary"],
[data-callout="tldr"] {
--callout-accent: var(--admonitions-color-note);
}
[data-callout="important"],
[data-callout="example"] {
--callout-accent: var(--admonitions-color-important);
}
[data-callout="warning"],
[data-callout="attention"],
[data-callout="question"],
[data-callout="help"],
[data-callout="faq"] {
--callout-accent: var(--admonitions-color-warning);
}
[data-callout="caution"] {
--callout-accent: var(--admonitions-color-caution);
}
[data-callout="success"],
[data-callout="check"],
[data-callout="done"] {
--callout-accent: var(--admonitions-color-success);
}
[data-callout="danger"],
[data-callout="error"],
[data-callout="failure"],
[data-callout="fail"],
[data-callout="missing"],
[data-callout="bug"] {
--callout-accent: var(--destructive);
}
[data-callout="quote"],
[data-callout="cite"] {
--callout-accent: var(--muted-foreground);
}
/* ---------- 标题 ---------- */
.callout-title {
display: flex;
align-items: center;
gap: 0.375rem;
color: var(--callout-accent);
font-weight: 600;
font-size: 0.9375rem;
line-height: 1.5;
}
.callout-title-icon {
display: flex;
flex: 0 0 auto;
align-items: center;
}
.callout-title-icon svg {
width: 16px;
height: 16px;
}
.callout-title-text {
color: inherit;
font-weight: inherit;
}
/* 折叠版用的是 <summary>,去掉浏览器默认的三角标记 */
.callout-title::-webkit-details-marker {
display: none;
}
.callout-title::marker {
content: "";
}
/* ---------- 内容 ---------- */
/* prose 给段落 1.25rem 的上下边距,在 callout 里太松,收紧一点 */
.callout-content p {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
.callout-content > :first-child {
margin-top: 0.5rem;
}
.callout-content > :last-child {
margin-bottom: 0;
}
/* ---------- 折叠态 ---------- */
.callout[data-collapsible="true"] .callout-title {
cursor: pointer;
}
.callout[data-collapsible="true"] .callout-fold-icon {
display: flex;
align-items: center;
margin-inline-start: auto;
}
.callout-fold-icon svg {
width: 16px;
height: 16px;
}
.callout[data-collapsible="true"] > .callout-title .callout-fold-icon svg {
transform: rotate(-90deg);
transition: transform 150ms ease-in-out;
}
.callout[data-collapsible="true"][open] > .callout-title .callout-fold-icon svg {
transform: none;
}
+51 -1
View File
@@ -1,6 +1,7 @@
@import "./transition.css";
@import "tailwindcss";
@import "tailwindcss/index.css";
@import "tw-animate-css";
@import "./callouts.css";
@plugin "@tailwindcss/typography";
@custom-variant dark (&:is(.dark *));
@@ -88,6 +89,7 @@
--admonitions-color-important: oklch(0.62 0.16 310);
--admonitions-color-warning: oklch(0.7 0.18 70);
--admonitions-color-caution: oklch(0.62 0.2 var(--primary-hue, 25));
--admonitions-color-success: oklch(0.62 0.16 150);
/* Code block colors - light mode */
/* 暖色阴槽 #f2ece1。原来是 0.97/0.003 的中性灰白,在暖纸底(#f8f3e9)上
就是一块冷白补丁,很跳。现在带上同样的暖色相。
@@ -149,6 +151,7 @@
--admonitions-color-important: oklch(0.7 0.18 310);
--admonitions-color-warning: oklch(0.75 0.2 70);
--admonitions-color-caution: oklch(0.7 0.22 var(--primary-hue, 25));
--admonitions-color-success: oklch(0.7 0.18 150);
/* Code block colors - dark mode */
--codeblock-bg: oklch(0.20 0.015 250);
--codeblock-topbar-bg: oklch(0.24 0.015 250);
@@ -802,6 +805,53 @@ a.card-github:hover .github-logo {
}
}
/* ——— 文章标题末尾的 # 锚点 ———
*
* 由 src/plugins/satteri/autolink-headings.mjs 生成:
* <h2 id="slug">标题<a class="anchor" href="#slug"><span class="anchor-icon">#</span></a></h2>
*
* 插件一直在追加这个锚点,但项目里从来没给它写过样式,所以那个 # 是
* 以纯文本形式常驻显示在每一个标题后面的。这里按通行做法改成「悬停才浮现」。
*/
.anchor {
margin-left: 0.35em;
color: var(--muted-foreground);
text-decoration: none;
opacity: 0;
transition: opacity 200ms ease, color 200ms ease;
}
.anchor-icon {
font-weight: 400;
}
/* 鼠标悬停标题、或键盘 Tab 聚焦到锚点时浮现 */
:is(h1, h2, h3, h4, h5, h6):hover > .anchor,
:is(h1, h2, h3, h4, h5, h6):focus-within > .anchor,
.anchor:focus-visible {
opacity: 1;
}
.anchor:hover {
color: var(--primary);
}
/* 触屏没有 hover,常驻显示就是原来那副样子,所以也藏起来。
手机上跨标题跳转用右侧目录就够。 */
@media (hover: none) {
.anchor {
display: none;
}
}
/* 点 # 跳转时标题别被 sticky 导航栏盖住。
原来完全没有这个偏移,跳过去标题顶部紧贴视口上沿,
正好藏在 69px 高的导航栏下面。 */
:is(h1, h2, h3, h4, h5, h6)[id] {
scroll-margin-top: calc(var(--header-h, 69px) + 1.25rem);
}
/* ——— 首屏彩蛋:那句话出自王小波《万寿寺》 ———
*
* 那句话本身不做任何变化(不加下划线、不变色、不可点),保持纯排版。