前言
继第二篇博客文章发布后,博客文章在阅读方面已基本不存在问题。然而,对于一些 Hexo 无法识别的语法,目前尚未进行处理。在本篇内容中,我们将把 Markdown 的呼出块语法与高亮语法进行转换。
markdown 转 AST
此次需要解析的 Markdown 语法相对较为复杂,不能像上一篇那样直接使用正则匹配语法,否则可能会导致不应该转换的地方也被转换了。因此,我们需要借助 Markdown 解析库,将其转换为抽象语法树(AST)。
什么是 AST
在深入探讨之前,我们先来简要介绍一下什么是 AST,以免有些朋友在阅读时感到困惑。
AST(抽象语法树)是一种将代码组织成树形结构的形式,使得计算机能够更轻松地理解代码的含义。可以将其看作是代码的 “骨架图”,每一个节点代表代码中的一个小结构(例如变量、运算符、函数等),而这些小结构通过分支连接起来,展示了代码的整体逻辑关系。
例如,假设我们有一段代码:a = 5 + 2
。转换为 AST 后,计算机能够识别出 “变量 a
被赋值给 5 + 2
”,而不仅仅是看到 “a = 5 + 2
” 这几个字符。这样,它就知道这段代码在进行一个赋值操作,左边是变量,右边是加法运算。
这个结构化的 “骨架” 对于许多工具都非常有用,比如编译器(将代码翻译成机器可以运行的指令)、代码检查工具(查找代码中的错误或优化点),甚至是自动格式化工具(调整代码风格)。通过 AST,这些工具能够准确地分析代码、优化代码,或者将其转换为其他语言。
JS AST
下面是一个简单的 JavaScript AST 示例:

markdown AST
下面是一个简单的 markdown AST 示例:

实现过程
ast 解析库
我们自然不可能手动实现一个 Markdown 的 AST 解析库,因此需要引入现有的知名库来帮助我们进行处理。
我们采用 remark
这个库来进行 Markdown 的转换。
1
| npm i remark remark-parse remark-stringify unist-util-visit
|
我们需要引入四个库,分别是remark
、remark-parse
、remark-stringify
和unist-util-visit
。remark
作为主库,通过插件的方式集成了remark-parse
和remark-stringify
。remark-parse
用于将 Markdown 转换为 AST 对象,而remark-stringify
则将 AST 对象再转换为 Markdown 文本。最后的unist-util-visit
用于处理转换好的 AST,通过这个库,我们可以轻松地处理需要处理的节点与类型,而无需自己编写递归遍历方法。
使用 ast 解析库
1 2 3 4
| import { remark } from 'remark'; import remarkParse from 'remark-parse'; import remarkStringify from 'remark-stringify'; import { visit } from 'unist-util-visit';
|
首先,将 Markdown 正文转换为 AST。
1
| const ast = remark().use(remarkParse).parse(parsedContent.body);
|
接着,遍历并修改 AST 中的内容。
1 2 3 4 5 6
| visit(ast, '节点类型', (node, index, parent) => { if (node.value === 'old text') { node.value = 'new text'; } });
|
最后,将处理后的 AST 转换回 Markdown 文本。
1
| parsedContent.body = remark().use(remarkStringify).stringify(ast);
|
具体代码实现
呼出块语法转换
Obsidian 呼出块的语法及样式如下:

同时,他还支持通过在在后面追加 +
、-
, 设置为可以展开的呼出块:

我们首先通过visit
方法,过滤出blockquot
语法块内容,即:使用>
开头的语法。
然后,通过正则匹配首行内容是否以[!xxx]
开头,或者[!xxx]+
、[!xxx]-
开头的,前者是正常的呼出块,后者是可折叠的呼出块。
通过正则匹配出呼出块的类型、是否可折叠以及呼出块的标题,再根据 Hexo 的语法转换进行转换。

Hexo 可折叠的 tag 语法如下:

我们需要将> [!INFO]
语法转换为{% note info %}
语法,> [!NOTE]+
转换为{% fold info @标题 %}
语法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
| const ast = remark().use(remarkParse).parse(parsedContent.body);
visit(ast, 'blockquote', (node, index, parent) => { const firstChild = node.children[0]; const childContent = firstChild.children[0].value; if (firstChild && firstChild.type === 'paragraph' && childContent?.startsWith('[!')) { let foundMatch = null; let isFoldable = false; const match = childContent.match(/\[!(\w+)\]([-+]?)\s+(.*)/); if (match) { isFoldable = ['-', '+'].includes(match[2]) foundMatch = match[1].toLowerCase(); const reg = isFoldable ? /\[!(\w+)\]([-+]?)\s+(.*)/ : /\[!\w+\][-+]?/ firstChild.children[0].value = childContent.replace(reg, '').trim(); } if (foundMatch) { const mappedType = BLOCK_TYPE_MAP[foundMatch] || 'info'; const hexoBlockType = isFoldable ? 'fold' : 'note'; const foldLabel = isFoldable ? `@${match[3].trim().split('\n')[0]} ` : '' const newNodes = [ { type: 'html', value: `{% ${hexoBlockType} ${mappedType} ${foldLabel}%} \n` }, { type: 'paragraph', children: [...firstChild.children], }, ...node.children.slice(1), { type: 'html', value: `{% end${hexoBlockType} %}` }, ]; parent.children.splice(index, 1, ...newNodes); } } });
|
高亮语法转换
高亮语法块通过四个等号作为高亮语法,效果如下:

Markdown 的语法很简单,我们直接匹配text
类型的节点,然后直接过滤掉code
类型的语法块,也就是代码块,因为代码块中出现这种类型的语法概率比较大,比如本文下面的这个代码块就用到了。
然后使用正则根据分割出高亮语法块,这里我们要手动判断一下匹配出的内容前后是否有空格,如果有空格则说明不是高亮语法块,所以需要还原回去,如果没有空格,则是高亮语法块,我们通过span
标签,添加一个显著的高亮色 (这里就看个人喜好了,我更喜欢橘黄色的字体,而不是屎黄色的背景色)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| visit(ast, 'text', (node, index, parent) => { if (parent.type === 'code') return; const regex = /==([^=]+)==/g; const parts = node.value.split(regex); const newChildren = []; parts.forEach((part, i) => { if (i % 2 === 0) { if (part) newChildren.push({ type: 'text', value: part }); } else { const trimmedPart = part.trim(); if (trimmedPart === part) { newChildren.push({ type: 'html', value: `<span style="color: orange">${trimmedPart}</span>`, }); } else { newChildren.push({ type: 'text', value: `==${part}==` }); } } }); parent.children.splice(index, 1, ...newChildren); });
parsedContent.body = remark().use(remarkStringify).stringify(ast);
|
结语
基本语法转换就介绍到这里了。目前我遇到的需要兼容的语法也就这两个,所以也仅转换了这两个语法。
至此,博客文章的展示效果又得到了进一步优化。如果是一般的 Obsidian 用户,基本已经大功告成了。但如果你使用了 Excalidraw 插件,那么还需要进一步优化。