Obsidian 自动同步(3)- 语法转换

前言

继第二篇博客文章发布后,博客文章在阅读方面已基本不存在问题。然而,对于一些 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

我们需要引入四个库,分别是remarkremark-parseremark-stringifyunist-util-visitremark作为主库,通过插件的方式集成了remark-parseremark-stringifyremark-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
// 遍历并修改 AST 中的内容
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
// 解析出 ast
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) {
// 获取 呼出块 对应的 hexo tag 类型
const mappedType = BLOCK_TYPE_MAP[foundMatch] || 'info';
// hexo 对应的类型
const hexoBlockType = isFoldable ? 'fold' : 'note';
// 获取标题, hexo 中只有可折叠的 tag 才有标题
const foldLabel = isFoldable ? `@${match[3].trim().split('\n')[0]} ` : ''
const newNodes = [
// hexo tag 声明符号
{ type: 'html', value: `{% ${hexoBlockType} ${mappedType} ${foldLabel}%} \n` },
// 恢复呼出块的内容
{
type: 'paragraph',
children: [...firstChild.children],
},
...node.children.slice(1),
// hexo tag 结束符
{ type: 'html', value: `{% end${hexoBlockType} %}` },
];
// 替换掉当前的 `blockquote`
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) {
// 使用 html 替换高亮语法
newChildren.push({
type: 'html',
value: `<span style="color: orange">${trimmedPart}</span>`,
});
} else {
// 还原非高亮语法块
newChildren.push({ type: 'text', value: `==${part}==` });
}
}
});
parent.children.splice(index, 1, ...newChildren);
});
// 将转换好的 ast 还原为 markdown 文本
parsedContent.body = remark().use(remarkStringify).stringify(ast);

结语

基本语法转换就介绍到这里了。目前我遇到的需要兼容的语法也就这两个,所以也仅转换了这两个语法。

至此,博客文章的展示效果又得到了进一步优化。如果是一般的 Obsidian 用户,基本已经大功告成了。但如果你使用了 Excalidraw 插件,那么还需要进一步优化。


Obsidian 自动同步(3)- 语法转换
https://blog.pangcy.cn/2024/10/28/前端编程相关/前端框架与库/hexo/Obsidian 自动同步(3)- 语法转换/
作者
子洋
发布于
2024年10月28日
许可协议