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
+24 -69
View File
@@ -5,30 +5,17 @@ import mdx from "@astrojs/mdx"
import { pluginCollapsibleSections } from "@expressive-code/plugin-collapsible-sections" import { pluginCollapsibleSections } from "@expressive-code/plugin-collapsible-sections"
import { pluginLineNumbers } from "@expressive-code/plugin-line-numbers" import { pluginLineNumbers } from "@expressive-code/plugin-line-numbers"
import expressiveCode from "astro-expressive-code" 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 { 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 { pluginLanguageBadge } from "./src/plugins/expressive-code/language-badge"
import { pluginCustomCopyButton } from "./src/plugins/expressive-code/custom-copy-button" import { pluginCustomCopyButton } from "./src/plugins/expressive-code/custom-copy-button"
import { unified } from "@astrojs/markdown-remark" import { satteri, satteriHeadingIdsPlugin } from "@astrojs/markdown-satteri"
import { rehypeImageLightbox } from "./src/plugins/rehype-image-lightbox.mjs" 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 { site } from "./src/data/site" 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({ export default defineConfig({
srcDir: "./src", srcDir: "./src",
output: "static", output: "static",
@@ -88,56 +75,24 @@ export default defineConfig({
], ],
compressHTML: true, compressHTML: true,
markdown: { markdown: {
processor: unified({ // Astro 7 的 Sätteri 管线。以下插件是 unified 时期 remark/rehype 插件的对应移植,
remarkPlugins: [ // 见 src/plugins/satteri/。
remarkMath, //
remarkReadingTime, // 注意:math 只负责「解析」,渲染成 KaTeX 需要额外的 HAST 插件 —— 尚未迁移。
remarkExcerpt, processor: satteri({
remarkShiftHeadings, // dashes: false 是为了和迁移前的输出逐字一致:Astro 的 unified 管线
remarkGithubAdmonitionsToDirectives, // 会把直引号排成弯引号,但不转换 --- (Sätteri 默认会转成破折号)。
remarkDirective, features: { math: true, smartPunctuation: { dashes: false } },
remarkSectionize, mdastPlugins: [
parseDirectiveNode, readingTime,
excerpt,
shiftHeadings(),
sectionize(),
], ],
rehypePlugins: [ hastPlugins: [
rehypeKatex, satteriHeadingIdsPlugin(),
rehypeSlug, autolinkHeadings,
[ imageLightbox,
rehypeComponents,
{
components: {
note: createAdmonitionComponent("note"),
tip: createAdmonitionComponent("tip"),
important: createAdmonitionComponent("important"),
caution: createAdmonitionComponent("caution"),
warning: createAdmonitionComponent("warning"),
},
},
],
[
rehypeAutolinkHeadings,
{
behavior: "append",
properties: {
className: ["anchor"],
},
content: {
type: "element",
tagName: "span",
properties: {
className: ["anchor-icon"],
"data-pagefind-ignore": true,
},
children: [
{
type: "text",
value: "#",
},
],
},
},
],
rehypeImageLightbox,
], ],
}), }),
}, },
+2
View File
@@ -15,6 +15,7 @@
}, },
"dependencies": { "dependencies": {
"@astrojs/markdown-remark": "^7.3.1", "@astrojs/markdown-remark": "^7.3.1",
"@astrojs/markdown-satteri": "^0.4.1",
"@astrojs/mdx": "^8.0.1", "@astrojs/mdx": "^8.0.1",
"@astrojs/react": "^6.0.6", "@astrojs/react": "^6.0.6",
"@astrojs/rss": "^4.0.19", "@astrojs/rss": "^4.0.19",
@@ -86,6 +87,7 @@
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"remark-sectionize": "^2.1.0", "remark-sectionize": "^2.1.0",
"sanitize-html": "^2.17.0", "sanitize-html": "^2.17.0",
"satteri": "^0.10.5",
"sonner": "^1.7.4", "sonner": "^1.7.4",
"tailwind-merge": "^2.5.5", "tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
+6
View File
@@ -11,6 +11,9 @@ importers:
'@astrojs/markdown-remark': '@astrojs/markdown-remark':
specifier: ^7.3.1 specifier: ^7.3.1
version: 7.3.1 version: 7.3.1
'@astrojs/markdown-satteri':
specifier: ^0.4.1
version: 0.4.1
'@astrojs/mdx': '@astrojs/mdx':
specifier: ^8.0.1 specifier: ^8.0.1
version: 8.0.1(@astrojs/markdown-remark@7.3.1)(@astrojs/markdown-satteri@0.4.1)(astro@7.3.3(@astrojs/markdown-remark@7.3.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@22.19.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1)) version: 8.0.1(@astrojs/markdown-remark@7.3.1)(@astrojs/markdown-satteri@0.4.1)(astro@7.3.3(@astrojs/markdown-remark@7.3.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@22.19.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1))
@@ -224,6 +227,9 @@ importers:
sanitize-html: sanitize-html:
specifier: ^2.17.0 specifier: ^2.17.0
version: 2.17.7 version: 2.17.7
satteri:
specifier: ^0.10.5
version: 0.10.5
sonner: sonner:
specifier: ^1.7.4 specifier: ^1.7.4
version: 1.7.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0) version: 1.7.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+1 -1
View File
@@ -4,7 +4,7 @@
* *
* 任何带 `data-lightbox-src` 的元素点击即可放大: * 任何带 `data-lightbox-src` 的元素点击即可放大:
* - 相册页的 `<button data-lightbox-src>` * - 相册页的 `<button data-lightbox-src>`
* - 文章正文里的 `<img data-lightbox-src>`(由 src/plugins/rehype-image-lightbox.mjs 注入) * - 文章正文里的 `<img data-lightbox-src>`(由 src/plugins/satteri/image-lightbox.mjs 注入)
* *
* 用事件委托 + 每次打开时重新收集当前页的图片,所以 ClientRouter 切页后依然有效; * 用事件委托 + 每次打开时重新收集当前页的图片,所以 ClientRouter 切页后依然有效;
* 遮罩只在第一次点击时才创建,没点过就不占 DOM。 * 遮罩只在第一次点击时才创建,没点过就不占 DOM。
@@ -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,
]);
}
-45
View File
@@ -1,45 +0,0 @@
/**
* 给正文里的图片注入 `data-lightbox-src`,交给全局灯箱(ImageLightbox.astro)处理。
*
* 两种情况跳过:
* - 图片外面包着 <a>:点击应该跳转,而不是放大
* - 图片上有 `data-lightbox-ignore`:明确表示不要放大
*
* 不用 unist-util-visit,是因为这里需要知道祖先里有没有 <a>,
* 手写一次递归比引入 visit-parents 更直白。
*/
/** @param {import('hast').Root} tree */
export function rehypeImageLightbox() {
/**
* @param {any} node
* @param {boolean} insideLink
*/
const walk = (node, insideLink) => {
if (!node || typeof node !== "object") return
if (node.type === "element") {
if (node.tagName === "img") {
// 链接里的图片保持跳转行为
if (insideLink) return
const props = node.properties || (node.properties = {})
if (props["data-lightbox-ignore"] !== undefined) return
const src = props.src
if (typeof src !== "string" || src.length === 0) return
if (props["data-lightbox-src"] === undefined) {
props["data-lightbox-src"] = src
}
return
}
if (node.tagName === "a") insideLink = true
}
if (Array.isArray(node.children)) {
for (const child of node.children) walk(child, insideLink)
}
}
return (tree) => {
walk(tree, false)
}
}
-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)
})
}
}
+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: "#" }],
},
],
})
},
},
})
+19
View File
@@ -0,0 +1,19 @@
import { defineMdastPlugin } from "satteri"
/**
* 取正文第一个段落作为摘要,写进 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
text = ctx.textContent(node, { includeImageAlt: true, includeHtml: true })
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)
},
},
})
+20
View File
@@ -0,0 +1,20 @@
import { defineMdastPlugin } from "satteri"
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 的默认行为对齐
const text = ctx.textContent(root, { includeImageAlt: true, includeHtml: true })
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))
},
})
}