博客改版,从json换成了md

2023-10-31 21:20


最近又想写博客,于是重写了博客系统。

以前的系统是把文章保存成json文件,这样虽然可以按照我自己乱七八糟的想法去自定义很多东西,但是保存的文章只在我这里能打开,想复制到别的地方就丢失了排版和样式。

现在换成了用正常的markdown语法写的md文件。复制到哪里都是一样的。就是在github repo页面里也能查看文章样式。

目前能够解析大部分markdown语法,比如代码块、文字样式、图片、链接、列表等。但还不支持表格和嵌套列表。

等于手写了一个markdown解析器。感兴趣的可以到这个链接试试手感。点“预览”按钮然后在左边开始输入markdown语法的文字。最后别点保存,那个保存按钮是方便我自己用的,你点没用。

Markdown语法的解析过程这样做的:

首先统一换行符,将\r\n全部替换为\n

然后全文查找“```“这个标记。用两个“```”围起来的部分是段落式的代码块,代码和正文有完全不同的排版、转义要求,所以从一开始就要分开。

mdToHtml(md) {
    const paragraphs = md
        // Remove carriage returns
        .replace(/\r\n/g, '\n')
        // Split paragraphs to isolate code blocks
        .split(/\n```\n|\n```$/)
        .flatMap((textOrCode, index) => {
            if (index % 2 === 0) {
                return this.textBlockToHtml(textOrCode);
            } else {
                // Convert code blocks
                return [this.codeBlockToHtml(textOrCode)];
            }
        });

    // console.log(paragraphs)
    return paragraphs.join('');
}

flatMap()要求返回一个数组。textBlockToHtml返回的已经是数组,见下文;但是codeBlockToHtml不是,所以需要套一个“[ ]”变成数组。

接下来先说简单的,codeBlockToHtml的要求是完全忠实地显示文字,页面上显示的内容与原文应该一模一样。为此只需要做几个转义:

  1. 先将“&”转义为&,这是为了让形如&<这样的html转义字符失效。详细点说:假设代码内容中包含一个词“<”,如果不做转义,它会在页面上显示为一个小于号!做完转义后文字变成了“<”,它正好在页面上显示为<
  2. 然后将“<”和“>”转义为&lt;&gt;,这是因为最终解析完成的html会通过设置标签的innerHtml属性来呈现,因此所有“<…>”形式的文字会被当作html标签来处理。这就需要通过转义字符替换来阻止。

然后就OK了,将文字包上<pre><code>...</code></pre>标签就可以呈现了。

另一方面,textBlockToHtml就复杂一些。首先,markdown中的段落是用两个以上换行符来分开的。所以我们可以通过这样的代码来将文本打碎为段落:

text.split(/\n{2,}/)

但是这里存在着几个特例。对于以#开头的大小标题,和“—”这样的分隔线,即使前后只有一个换行符,也不影响它们的功能。因此首先对它们特殊处理,在前后加上额外的换行符以确保被划分为单独的段落。

textBlockToHtml(text) {
    // Wrap heading lines with line breaks to prevent them from being merged into paragraphs
    text = text.replace(/^(#{1,6} .*)$/gm, '\n$1\n');
    // Convert horizontal rules
    text = text.replace(/^\-\-\-$|!\_\_\_$/gm, '\n<hr>\n');
    // Convert text to paragraphs
    text = text
        .split(/\n{2,}/)
        .map(paragraph => {

对于单独的段落,要分成不同的类型处理:

text = text
    .split(/\n{2,}/)
    .map(paragraph => {
        paragraph = paragraph.trim();
        // Wrap paragraphs in tags
        if (/^#{1,6} /.test(paragraph)) {
            paragraph = this.headingToHtml(paragraph);
        } else if (/^\>/.test(paragraph)) {
            paragraph = this.blockquoteToHtml(paragraph);
        } else if (/^\d+\.\s+/.test(paragraph)) {
            paragraph = this.orderedListToHtml(paragraph);
        } else if (/^[\*\-\+]s+/.test(paragraph)) {
            paragraph = this.unorderedListToHtml(paragraph);
        } else {
            paragraph = this.textToHtml(paragraph);
        }
        return paragraph;
    });

基本上就是包上不同的标签。

其中有序列表的处理稍微麻烦一点,Markdown似乎是这样规定的:列表项之间不管有一个还是多个换行符,都应该组合成一个<ol>标签。组合后的列表只看第一个列表项的数字,其后的列表项忽略原本的数字,跟着第一个往后排。

所以:以下的文本:

3. 第一项
9. 第二项

1. 第三项

会被解析为这样的显示文本:

  1. 第一项
  1. 第二项
  1. 第三项

实际上我使用了两轮。先将每个段落里的列表处理好,再遍历一遍所有段落,如果有相邻的列表就组合起来。

// Find consecutive ordered/unordered lists
// remove </ol> or </ul> from the first one and <ol> or <ul> from the second one
for (let i = 0; i < text.length - 1; i++) {
    if (text[i].endsWith('</ol>') && /^\<ol\>|^\<ol start\=\"\d+\"\>/.test(text[i + 1])) {
        text[i] = text[i].slice(0, -5);
        text[i + 1] = text[i + 1].replace(/^\<ol\>|^\<ol start\=\"\d+\"\>/, '');
    } else if (text[i].endsWith('</ul>') && text[i + 1].startsWith('<ul>')) {
        text[i] = text[i].slice(0, -5);
        text[i + 1] = text[i + 1].slice(4);
    }
}

然而这还没完,由于存在行内代码块,接下来我们需要像最开始一样,将包好标签的文字再打碎为普通文本和行内代码块来分开处理:

return text
    .flatMap(paragraph => {
        return paragraph.split(/(?<!\\)`(.+?)`/gs).map((textOrCode, index) => {
            if (index % 2 === 0) {
                // Convert common text
                ...
            } else {
                // Convert inline code
                return this.codeToHtml(textOrCode);
            }
        });
    });

其中行内代码块的处理和段落代码块几乎是一样的。最后使用的html标签不同而已。

而普通文本做的事情就多了:

  1. 普通文本支持html转义和html标签,所以它不会像代码那样对&、<和>进行转义。因此你可以直接在md文本里使用转义字符和标签,它们都会生效。
  2. 但是普通文本同时也支持反斜杠开头的转义字符。设想一下:如果文本中恰好有形如“<div>”这样的文字内容,但我们不想让它变成html标签,那就可以写成“\<div\>”这样。反斜杠会和后面的字符一起进行一遍html转义,“\<div\>”会被转变为&lt;div&gt;,显示出来就是“<div>”。

如果你仔细看了上一段代码,你可能会发现用来识别行内代码块的正则表达式有点怪:“/(?<!\\)`(.+?)`/g”。其实一开始它只有后半部分“/`(.+?)`/g”,用来识别被两个“`”包围起来的行内代码。但是如果文本是“\`whatever`”,那么这段文字不应该识别为行内代码,因为前一个“`”是转义字符的一部分。但是这里无法用转义(也就是将“\`”替换为“\`”)避免它被识别为行内代码,因为转义发生在行内代码识别之后。这是一个两难:如果不转义,“\`whatever`”就会被识别为行内代码;但如果先转义再识别,你并不知道当前内容是文本还是代码,会将代码块中的字符也一并转义。最后发现还是应该先识别,只是识别用的正则应该修改成现在这样,用“(?<!\\)”排除带有“\”前缀的情况。

  1. 做完转义之后的文本,接下来要进行图片、链接和样式的识别。markdown中插入图片的语法是:“![替换文字](图片链接)”。插入链接的语法是“[文字](链接)”。样式目前我这边支持粗体斜体删除线
return text
    .flatMap(paragraph => {
        return paragraph.split(/(?<!\\)`(.+?)`/gs).map((textOrCode, index) => {
            if (index % 2 === 0) {
                return this.escapeText(textOrCode)
                    // Convert images
                    .replace(/!\[(.*?)\]\((.*?)\)/gs, '<img src="$2" alt="$1" class="img-fluid mb-3" style="max-width: 100%;">')
                    // Convert links
                    .replace(/\[(.*?)\]\((.*?)\)/gs, '<a href="$2">$1</a>')
                    // Convert emphasis
                    .replace(/\*\*(.+?)\*\*/gs, '<strong>$1</strong>')
                    .replace(/\*(.+?)\*/gs, '<em>$1</em>')
                    // Convert strikethrough
                    .replace(/~~(.+?)~~/gs, '<del>$1</del>');
            } else {
                // Convert inline code
                return this.codeToHtml(textOrCode);
            }
        });
    });

这样就是最终的结果。

我这个也不是正规的md解析器,它和别的解析器,比如VSCode的md文件预览,在一些特殊情况下呈现出的结果不完全一样。但大差不差的,我也不想折腾了。

下一步可能考虑增加对嵌套列表的支持。表格的支持可能往后放放,因为我写博客不太用。