手写 Remark 插件:为 Astro 博客添加 GitHub 风格 Admonition

发布于 2026年05月31日 23:00 #Astro#CLI

手写 Remark 插件:为 Astro 博客添加 GitHub 风格 Admonition 封面图

博客里的普通 blockquote 有个问题:它没有类型区分。一段引用、一条警告、一个提示、一句编辑推荐,渲染出来都是一条灰蓝色的左边框。读者一眼扫过去,分不清这是“值得注意的信息”还是“只是引用了一段话”。

GitHub 的解决方案是 >[!NOTE] 一类语法。我的解决方案是自己写了一个 remark 插件。

效果预览

先看成品。在 Markdown 里这样写:

> [!NOTE] 这是一条备注
> 普通文字用 `>` 开头,没有特殊标识。

会被渲染成带图标和颜色的提醒块。支持六种类型:

类型颜色场景
NOTE蓝色中性的补充说明
TIP绿色建议、技巧
WARNING黄色需要留意的情况
IMPORTANT红色关键信息
CAUTION灰色需要谨慎操作
EDITOR金色编辑推荐

每种类型在浅色和深色模式下都有对应的配色。

以下是完整的语法演示:

> [!EDITOR] (编辑推荐)
> Steve Yegge,前 Amazon Bar Raiser、Google Hiring Committee 成员,35 年技术面试的亲历者和改革者。这篇文章最有冲击力的不是他的结论,而是一个故事:Google 的招聘委员会曾投票拒绝了一批匿名面试资料包,结果发现那是他们自己的——十五位顶级工程师投票拒绝了自己。
✍️ 编辑推荐

Steve Yegge,前 Amazon Bar Raiser、Google Hiring Committee 成员,35 年技术面试的亲历者和改革者。这篇文章最有冲击力的不是他的结论,而是一个故事:Google 的招聘委员会曾投票拒绝了一批匿名面试资料包,结果发现那是他们自己的——十五位顶级工程师投票拒绝了自己。

> [!EDITOR] (自定义标题)
> 编辑推荐自定义标题
✍️ 自定义标题

编辑推荐自定义标题

> [!NOTE]
> 这是一条备注信息
📝 备注

这是一条备注信息

> [!TIP]
> 这是一条建议  
💡 建议

这是一条建议

> [!WARNING]
> 这是一个警告
⚠️ 注意

这是一个警告

> [!IMPORTANT]
> 这是重要信息
🔴 重要

这是重要信息

> [!CAUTION]
> 这是危险警告
⛔ 警告

这是危险警告

普通引用不受影响:

> 这是一条普通的引用文字

这是一条普通的引用文字

整体架构

整个功能分三层:

Markdown >[!NOTE] 语法

remark 插件(AST → HTML)

CSS 变量体系(6 类型 × 2 主题 = 12 套配色)

remark 插件负责在构建时拦截 blockquote 节点,识别 [!TYPE] 标记,输出结构化的 HTML。CSS 负责用自定义属性为每种类型分配颜色。

Remark 插件的核心逻辑

插件本身是一个标准的 remark 插件,接收一个 tree(mdast 抽象语法树),遍历 blockquote 节点,检查第一个段落的首个子节点是否匹配 admonition 语法。

正则匹配

两个正则搞定大部分情况:

// 匹配 [!NOTE]、[!WARNING] 等标记
const ADMONITION_RE = /^\[!(NOTE|TIP|WARNING|IMPORTANT|CAUTION|EDITOR)\]\s*([\s\S]*)/;

// 匹配自定义标签:(自定义文字) 
const CUSTOM_LABEL_RE = /^\(\s*(.+?)\s*\)\s*(.*)/;

第一个正则从文本中提取类型标识和剩余内容。第二个正则检测作者是否提供了自定义标签——比如 [!EDITOR](编辑推荐) 会把标题显示为“编辑推荐”而非默认的“编辑推荐”。

两种语法变体

remark 解析 [!TYPE] 时有两种表现,取决于语法细节。

变体 1:带括号的链接语法

> [!EDITOR](编辑推荐)
> 这是编辑推荐的内容

remark 的 markdown 解析器会把 [!EDITOR](编辑推荐) 解析为一个 link 节点——[!EDITOR] 是 link text,(编辑推荐) 是 url。我的插件需要检测 firstChild.type === "link" 的情况。

变体 2:纯文本语法

> [!NOTE] 这是一条备注
> 这是备注的内容

> [!WARNING]
> 留空类型标识后面的文字也没问题

这种情况下 [!NOTE] 被解析为普通 text 节点,用正则提取即可。

两个分支最终汇入同一个 HTML 生成函数。

HTML 输出

每个 admonition 被转换为这样一段 HTML:

<div class="admonition admonition-note">
  <div class="admonition-title">📝 备注</div>
  <div class="admonition-content">
    <p>这是一条备注信息</p>
  </div>
</div>

图标和标签通过一个配置对象映射:

const DEFAULT_TYPES = {
  note:    { icon: "📝", label: "备注" },
  tip:     { icon: "💡", label: "建议" },
  warning: { icon: "⚠️", label: "注意" },
  important: { icon: "🔴", label: "重要" },
  caution: { icon: "⛔", label: "警告" },
  editor:  { icon: "✍️", label: "编辑推荐" },
};

插件接受 options.types 参数,允许在 astro.config.mjs 中覆盖任意类型的图标和标签。

段落序列化

这里有个值得一提的细节:插件需要把剩余 mdast 节点序列化为 HTML。remark 插件通常只操作 AST,不直接拼 HTML——但这里选择了直接输出 type: "html" 节点。

所以插件里实现了一个 serializeMdastToHtml 函数,递归处理 paragraph、strong、emphasis、inlineCode、link、list、code 等常见节点类型。不支持的类型直接返回空字符串,避免破坏页面结构。

这个做法比“把内容节点挂到 AST 的 div 节点下”简单得多——不需要自定义 mdast 节点类型,不需要注册自定义 node 的 toHTML 序列化器。一个函数搞定。

CSS 实现

样式分两层。

基础结构

.article :global(.admonition) {
  margin: 1.5rem 0;
  padding: 0;
  border-left: 4px solid var(--adm-border);
  border-radius: 0 8px 8px 0;
  background: var(--adm-bg);
  overflow: hidden;
}

.article :global(.admonition-title) {
  padding: 0.5rem 1rem 0.25rem;
  font-weight: 600;
  font-size: 0.95em;
  color: var(--adm-border);
}

.article :global(.admonition-content) {
  padding: 0.25rem 1rem 0.75rem;
}

关键设计:所有的颜色信息都通过 CSS 自定义属性 --adm-border--adm-bg 传递。每种类型只定义这两个变量。

六种类型的颜色分配

.article :global(.admonition-note) {
  --adm-border: #2563eb;
  --adm-bg: rgba(37, 99, 235, 0.08);
}
.article :global(.admonition-tip) {
  --adm-border: #059669;
  --adm-bg: rgba(5, 150, 105, 0.08);
}
/* ... 其余类型类似 ... */

深色模式适配

深色模式通过 [data-theme="dark"] 选择器覆盖:

[data-theme="dark"] .article :global(.admonition-note) {
  --adm-border: #60a5fa;
  --adm-bg: rgba(96, 165, 250, 0.12);
}

原则是:深色模式下适当降低边框饱和度、提高背景透明度,避免亮色主题色在暗色背景上刺眼。

普通 blockquote 的样式不受影响,没有 [!TYPE] 标记的 > 引用保持原样。

在 Astro 中注册

astro.config.mjs 里加入 remark 插件列表即可:

import remarkAdmonition from "./src/plugins/remark-admonition.mjs";

export default defineConfig({
  markdown: {
    remarkPlugins: [remarkAdmonition, /* ... */],
  },
});

因为是 .mjs 文件,直接 import 函数即可,不需要.default 前缀。

边界情况处理

开发过程中处理了几种边界情况:

空内容[!NOTE] 后没有文字,内容区域直接使用后续段落。插件检查 contentText 为空时不创建额外的 paragraph。

嵌套行内格式:admonition 内容里的粗体、行内代码、链接等需要正确序列化。serializeMdastToHtml 对每种节点类型单独处理。

多段落内容:首段标记后的内容、以及 blockquote 内的其它段落,都会被合入 admonition-content

安全转义:所有输出到 HTML 的文本都经过 escapeHtml 处理,防止 XSS。

与 GitHub 语法的差异

GitHub 的 admonition 语法支持五类(NOTE、TIP、IMPORTANT、WARNING、CAUTION)。我多加了一个 EDITOR 类型——因为博客里常需要标注“编辑注”或“编辑推荐”,作为一种文章内注释。

GitHub 的语法要求在首行使用 >[!TYPE] 且内容需从下一行开始,冒号后不允许跟文字。我的实现更宽松,[!TYPE] 后可以直接跟内容文字,也支持在同一行指定自定义标签。

写 remark 插件的感受

之前我对 remark 插件体系的理解停留在“知道有这回事”的层面。实际写起来发现,remark 的 AST 结构比想象中直观——每个节点就是一个 plain object,type 字段标识类型,children 数组包含子节点。插件函数就是 (tree) => tree 的变换函数。

最难的部分不是插件本身,而是理解 markdown 解析器在不同语法写法下会生成什么样的 AST。同一个 [!EDITOR],加括号和不加括号,解析出来的节点类型完全不同。这也是为什么调试时用 console.log(JSON.stringify(tree, null, 2)) 看了好多次 AST。

但反过来,一旦理解了 AST 的结构,remark 插件能做的事情就非常灵活——不只是 blockquote,你可以对任何 Markdown 节点进行变换、注入、包裹。

一点总结

这个功能的核心价值在于:给纯文本的 blockquote 增加了类型语义。读者通过颜色和图标一眼就能判断“这是一条建议”还是“这是一个警告”。对于技术博客这种信息密集的场景,这点视觉区分能显著降低阅读时的认知负担。

从实现角度看,最让我满意的不是插件本身,而是 CSS 变量体系的简洁性:6 种类型 × 2 种主题,只需在 .admonition 基类里引用 var(--adm-border)var(--adm-bg),每种类型单独定义这两个变量即可。新增一种类型只需要加两行 CSS,改一行配置。

评论互动

© 2026 王若风的技术博客 · Powered by Astro