手写 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,改一行配置。
评论互动