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
+1 -1
View File
@@ -4,7 +4,7 @@
*
* 任何带 `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 切页后依然有效;
* 遮罩只在第一次点击时才创建,没点过就不占 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))
},
})
}