From 6a74061689911633498a074a93cef8f23a77e18a Mon Sep 17 00:00:00 2001 From: dsh Date: Thu, 17 Sep 2026 20:15:48 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=81=B6=E5=BE=97=E9=87=8C?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E5=88=97=E8=A1=A8=E4=B8=8D=E6=B8=B2=E6=9F=93?= =?UTF-8?q?=EF=BC=9A=E7=BB=99=20markdown-it=20=E8=A1=A5=E4=B8=8A=20GFM=20?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题 站主反馈偶得里 `- [ ] 每天至少阅读1小时…` 没渲染成复选框,`[ ]` 原样显示; 同样的语法在文章里正常。原因不是语法写错,而是**两套渲染器**: 文章:Astro 内容管线 → Sätteri,配置里开了 gfm → 任务列表正常 偶得:src/lib/thoughts.ts 里裸的 new MarkdownIt() → 不含 GFM 扩展 实测确认:这条偶得(20260822-好好工作.md,任务列表嵌在引用块里)产出的是
改法 新增 src/plugins/markdown-it/task-lists.mjs,输出刻意与 Sätteri 那边逐字对齐: 这样以后给任务列表写 CSS,文章和偶得两边都吃得到。 几个实现细节 - 判断任务项看的是 list_item_open → paragraph_open → inline 这个固定结构; 紧凑列表里 paragraph 是 hidden 的但 token 仍在,所以照样能判 - 找父级列表是按 token 层级往回扫,不是假设它在最外层 —— 因为站主的用法 正是把任务列表放在引用块里 - class 用 addClass 去重:一个列表里每个任务项都会来加一次,直接用 attrJoin 会变成 class="contains-task-list contains-task-list contains-task-list …" - 方括号后必须跟空白才算任务项,`- [ ]紧贴文字` 按普通文本处理(与 GFM 一致) 验证 - 该偶得现在产出 contains-task-list / task-list-item / checkbox,与文章 树莓派云服务器的使用总结 里那处逐字一致 - 边界用例:`[x]`/`[X]` 正确勾选、有序任务列表可用、嵌套列表只标父级、 多个列表各自只加一次 class、普通列表项不受影响 - pnpm build 退出码 0 顺带在操作指南里记了「两套渲染器」这个根因,以及偶得目前还缺的 GFM 能力 (裸链接自动转换、脚注、数学公式、callout、智能标点)。 --- docs/操作指南.md | 26 +++++++ src/content/posts/生活/周末愉快.md | 7 +- src/content/thoughts/20260822-好好工作.md | 8 +-- src/data/reading.json | 2 +- src/lib/thoughts.ts | 11 ++- src/plugins/markdown-it/task-lists.mjs | 85 +++++++++++++++++++++++ 6 files changed, 127 insertions(+), 12 deletions(-) create mode 100644 src/plugins/markdown-it/task-lists.mjs diff --git a/docs/操作指南.md b/docs/操作指南.md index 3d50be1..4c998b6 100644 --- a/docs/操作指南.md +++ b/docs/操作指南.md @@ -299,6 +299,32 @@ sudo sysctl fs.inotify.max_user_watches=524288 如果连空态提示都看不到,检查 `src/data/reading.json` 是否存在(这个文件要提交进 git)。 +**同样的 Markdown,偶得和文章的渲染结果不一样** + +因为**它们走的根本不是同一套渲染器**: + +| | 渲染器 | 位置 | +|---|---|---| +| 文章 | Astro 内容管线 → **Sätteri**(`astro.config.mjs` 里开了 `gfm`)| `render(post)` | +| 偶得 | **markdown-it**,裸配置 | `src/lib/thoughts.ts` 里的 `new MarkdownIt()` | + +裸的 markdown-it 不含 GFM 扩展,所以要什么能力就得自己挂插件。 +目前只挂了任务列表(`src/plugins/markdown-it/task-lists.mjs`), +它输出的 HTML 刻意和 Sätteri 那边对齐(`contains-task-list` / +`task-list-item` / ``),以后加样式两边通用。 + +**已知还缺的 GFM 能力**(偶得里写了不会生效,文章里正常): + +- 裸链接自动转链接(markdown-it 需要 `linkify: true`,当前没开) +- 脚注 +- 数学公式(`$…$`)—— 文章那边有 KaTeX,偶得没有 +- Obsidian callout(`> [!tip]`) +- 智能标点(引号、破折号) + +要在偶得里补哪一项,就先看它在文章那边是由哪个管线功能提供的,再决定是给 +markdown-it 挂插件、还是把偶得也改用 Astro 内容管线(后者改动大:偶得页是 +React island,内容要序列化成 HTML 字符串传给客户端)。 + **改了 markdown 插件却不生效** Astro 会把渲染好的文章缓存到 `node_modules/.astro`,而这个缓存**只按 markdown diff --git a/src/content/posts/生活/周末愉快.md b/src/content/posts/生活/周末愉快.md index ac0dfa2..c3121c3 100644 --- a/src/content/posts/生活/周末愉快.md +++ b/src/content/posts/生活/周末愉快.md @@ -18,11 +18,6 @@ draft: false 另外要想清楚一点,持续的创作和更新其实才是最难的工作。 > [!tip] 下周计划 -> - [ ] 阅读累计4小时 +> - [ ] 阅读时间能够累计4小时 > - [ ] 汇总整理近期拍摄的照片 > - [ ] 写一篇原创文章 - - - -
- diff --git a/src/content/thoughts/20260822-好好工作.md b/src/content/thoughts/20260822-好好工作.md index 46c4811..5801fba 100644 --- a/src/content/thoughts/20260822-好好工作.md +++ b/src/content/thoughts/20260822-好好工作.md @@ -15,7 +15,7 @@ weather: 晴 ## 我必须改变,这需要自己去行动起来 -- [ ] 每天至少阅读1小时(晚饭后20分钟,睡觉前30分钟,午睡前10分钟),每天坚持! -- [ ] 不看视频(特别是抖音) -- [ ] 今日的事情,尽心尽力了,无论结果如何,都应该高高兴兴地上床恬睡。 -- [ ] 利用空闲时间更新一下自己的文章,对积淀一些东西 +> - [ ] 每天至少阅读1小时(晚饭后20分钟,睡觉前30分钟,午睡前10分钟),每天坚持! +> - [ ] 不看视频(特别是抖音) +> - [ ] 今日的事情,尽心尽力了,无论结果如何,都应该高高兴兴地上床恬睡。 +> - [ ] 利用空闲时间更新一下自己的文章,对积淀一些东西 diff --git a/src/data/reading.json b/src/data/reading.json index e962ec4..0ea79ee 100644 --- a/src/data/reading.json +++ b/src/data/reading.json @@ -1,5 +1,5 @@ { - "updatedAt": "2026-09-17T12:00:29.145Z", + "updatedAt": "2026-09-17T12:15:45.676Z", "source": "weread", "books": [ { diff --git a/src/lib/thoughts.ts b/src/lib/thoughts.ts index 7e6346b..f4b1686 100644 --- a/src/lib/thoughts.ts +++ b/src/lib/thoughts.ts @@ -1,10 +1,19 @@ import { getCollection, type CollectionEntry } from "astro:content" import MarkdownIt from "markdown-it" +import { taskLists } from "@/plugins/markdown-it/task-lists.mjs" import { site } from "@/data/site" export type ThoughtEntry = CollectionEntry<"thoughts"> -const md = new MarkdownIt() +/* + * 偶得用的渲染器。 + * + * 注意它和文章不是同一套:文章走 Astro 内容管线(Sätteri,开了 gfm), + * 偶得走这个 markdown-it 实例。裸的 markdown-it 不含 GFM 扩展, + * 所以需要什么 GFM 能力就得自己挂插件(目前挂了任务列表)。 + * 两边行为有差异时,优先看这里少挂了什么。 + */ +const md = new MarkdownIt().use(taskLists) /** * 按站点时区格式化成 YYYY-MM-DD。 diff --git a/src/plugins/markdown-it/task-lists.mjs b/src/plugins/markdown-it/task-lists.mjs new file mode 100644 index 0000000..1bf897c --- /dev/null +++ b/src/plugins/markdown-it/task-lists.mjs @@ -0,0 +1,85 @@ +/** + * markdown-it 的任务列表支持(GFM 的 `- [ ]` / `- [x]`)。 + * + * 为什么需要这个插件:偶得和文章走的是**两套渲染器**。 + * - 文章:Astro 内容管线 → Sätteri,配置里开了 gfm,所以 `- [ ]` 正常渲染成复选框 + * - 偶得:src/lib/thoughts.ts 里一句 `new MarkdownIt()`,裸配置 + * 裸的 markdown-it 不含 GFM 扩展,任务列表就是其中之一,所以 `- [ ] 每天…` + * 会被当成普通列表项,方括号原样显示在页面上。 + * + * 输出刻意与文章管线逐字对齐,这样以后给任务列表写 CSS 两边都吃得到: + * + * (对应 Sätteri 侧 mdast-util-gfm-task-list-item 的产物。) + * + * 任务列表可以出现在引用块里(`> - [ ] xxx`),所以找父级列表是按 token 层级 + * 往回扫,而不是假设它在最外层。 + */ + +/** `[ ] ` / `[x] ` / `[X] ` 开头,注意方括号后必须跟空白才算任务项 */ +const TASK_RE = /^\[([ xX])\][ \t]+/ + +/** + * @param {import("markdown-it").default} md + */ +export function taskLists(md) { + /** + * 判断第 i 个 token 是不是一个任务项的 inline。 + * 结构固定为:list_item_open → paragraph_open → inline + * (紧凑列表里 paragraph 两个 token 是 hidden 的,但依然存在) + */ + const isTaskItem = (tokens, i) => { + const inline = tokens[i] + if (!inline || inline.type !== "inline") return false + const pOpen = tokens[i - 1] + if (!pOpen || pOpen.type !== "paragraph_open") return false + const liOpen = tokens[i - 2] + if (!liOpen || liOpen.type !== "list_item_open") return false + const first = (inline.children || [])[0] + return Boolean(first && first.type === "text" && TASK_RE.test(first.content)) + } + + /** 加 class 且不重复 —— 一个列表里每个任务项都会来加一次,attrJoin 会越加越长 */ + const addClass = (token, cls) => { + const existing = token.attrGet("class") || "" + if (existing.split(/\s+/).includes(cls)) return + token.attrJoin("class", cls) + } + + /** 从 list_item_open 往回找它所属的列表容器,用于挂 contains-task-list */ + const parentList = (tokens, liIndex) => { + const liLevel = tokens[liIndex].level + for (let j = liIndex - 1; j >= 0; j--) { + const t = tokens[j] + if (t.type === "bullet_list_open" || t.type === "ordered_list_open") { + if (t.level < liLevel) return t + } + } + return null + } + + md.core.ruler.after("inline", "task-lists", (state) => { + const tokens = state.tokens + + for (let i = 0; i < tokens.length; i++) { + if (!isTaskItem(tokens, i)) continue + + const inline = tokens[i] + const first = inline.children[0] + const checked = first.content[1] !== " " + first.content = first.content.replace(TASK_RE, "") + + // 复选框本身用 html_inline 注入,和文章管线的产物保持一致 + // (那边是 ``,没有额外的 class) + const checkbox = new state.Token("html_inline", "", 0) + checkbox.content = ` ` + inline.children.unshift(checkbox) + + addClass(tokens[i - 2], "task-list-item") + + const list = parentList(tokens, i - 2) + if (list) addClass(list, "contains-task-list") + } + }) +}