前言

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

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 解析库

import { remark } from 'remark';
import remarkParse from 'remark-parse';
import remarkStringify from 'remark-stringify';
import { visit } from 'unist-util-visit';

首先,将 Markdown 正文转换为 AST。

const ast = remark().use(remarkParse).parse(parsedContent.body);

接着,遍历并修改 AST 中的内容。

// 遍历并修改 AST 中的内容
visit(ast, '节点类型', (node, index, parent) => {
  if (node.value === 'old text') { 
   node.value = 'new text'; 
 }
});

最后,将处理后的 AST 转换回 Markdown 文本。

parsedContent.body = remark().use(remarkStringify).stringify(ast);

具体代码实现

呼出块语法转换

Obsidian 呼出块的语法及样式如下:

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

我们首先通过visit方法,过滤出blockquot语法块内容,即:使用>开头的语法。

然后,通过正则匹配首行内容是否以[!xxx]开头,或者[!xxx]+[!xxx]-开头的,前者是正常的呼出块,后者是可折叠的呼出块。

通过正则匹配出呼出块的类型、是否可折叠以及呼出块的标题,再根据 Hexo 的语法转换进行转换。

Hexo 可折叠的 tag 语法如下:

我们需要将> [!INFO]语法转换为{% note info %}语法,> [!NOTE]+转换为{% fold info @标题 %}语法。

// 解析出 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标签,添加一个显著的高亮色 (这里就看个人喜好了,我更喜欢橘黄色的字体,而不是屎黄色的背景色)

// 实现高亮语法转换
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 插件,那么还需要进一步优化。