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:
+24
-69
@@ -5,30 +5,17 @@ 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 { 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 { unified } from "@astrojs/markdown-remark"
|
||||
import { rehypeImageLightbox } from "./src/plugins/rehype-image-lightbox.mjs"
|
||||
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 { 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",
|
||||
@@ -88,56 +75,24 @@ export default defineConfig({
|
||||
],
|
||||
compressHTML: true,
|
||||
markdown: {
|
||||
processor: unified({
|
||||
remarkPlugins: [
|
||||
remarkMath,
|
||||
remarkReadingTime,
|
||||
remarkExcerpt,
|
||||
remarkShiftHeadings,
|
||||
remarkGithubAdmonitionsToDirectives,
|
||||
remarkDirective,
|
||||
remarkSectionize,
|
||||
parseDirectiveNode,
|
||||
// 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 } },
|
||||
mdastPlugins: [
|
||||
readingTime,
|
||||
excerpt,
|
||||
shiftHeadings(),
|
||||
sectionize(),
|
||||
],
|
||||
rehypePlugins: [
|
||||
rehypeKatex,
|
||||
rehypeSlug,
|
||||
[
|
||||
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,
|
||||
hastPlugins: [
|
||||
satteriHeadingIdsPlugin(),
|
||||
autolinkHeadings,
|
||||
imageLightbox,
|
||||
],
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/markdown-remark": "^7.3.1",
|
||||
"@astrojs/markdown-satteri": "^0.4.1",
|
||||
"@astrojs/mdx": "^8.0.1",
|
||||
"@astrojs/react": "^6.0.6",
|
||||
"@astrojs/rss": "^4.0.19",
|
||||
@@ -86,6 +87,7 @@
|
||||
"remark-math": "^6.0.0",
|
||||
"remark-sectionize": "^2.1.0",
|
||||
"sanitize-html": "^2.17.0",
|
||||
"satteri": "^0.10.5",
|
||||
"sonner": "^1.7.4",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
|
||||
Generated
+6
@@ -11,6 +11,9 @@ importers:
|
||||
'@astrojs/markdown-remark':
|
||||
specifier: ^7.3.1
|
||||
version: 7.3.1
|
||||
'@astrojs/markdown-satteri':
|
||||
specifier: ^0.4.1
|
||||
version: 0.4.1
|
||||
'@astrojs/mdx':
|
||||
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))
|
||||
@@ -224,6 +227,9 @@ importers:
|
||||
sanitize-html:
|
||||
specifier: ^2.17.0
|
||||
version: 2.17.7
|
||||
satteri:
|
||||
specifier: ^0.10.5
|
||||
version: 0.10.5
|
||||
sonner:
|
||||
specifier: ^1.7.4
|
||||
version: 1.7.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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: "#" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
@@ -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)
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
@@ -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))
|
||||
},
|
||||
})
|
||||
@@ -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))
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user