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 确定后再收。
This commit is contained in:
2026-09-17 17:38:37 +08:00
parent c1643ae95d
commit 26e873b9f6
16 changed files with 248 additions and 237 deletions
+50
View File
@@ -0,0 +1,50 @@
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
ctx.appendChild(node, {
type: "element",
tagName: "a",
properties: { className: ["anchor"], href: `#${id}` },
children: [
{
type: "element",
tagName: "span",
properties: { className: ["anchor-icon"], "data-pagefind-ignore": "" },
children: [{ type: "text", value: "#" }],
},
],
})
},
},
})