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
的要求是完全忠实地显示文字,页面上显示的内容与原文应该一模一样。为此只需要做几个转义:
&
,这是为了让形如&
、<
这样的html转义字符失效。详细点说:假设代码内容中包含一个词“<
”,如果不做转义,它会在页面上显示为一个小于号!做完转义后文字变成了“&lt;
”,它正好在页面上显示为<
。<
和>
,这是因为最终解析完成的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. 第三项
会被解析为这样的显示文本:
实际上我使用了两轮。先将每个段落里的列表处理好,再遍历一遍所有段落,如果有相邻的列表就组合起来。
// 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标签不同而已。
而普通文本做的事情就多了:
<div>
”这样的文字内容,但我们不想让它变成html标签,那就可以写成“\<div\>
”这样。反斜杠会和后面的字符一起进行一遍html转义,“\<div\>
”会被转变为<div>
,显示出来就是“<div>
”。如果你仔细看了上一段代码,你可能会发现用来识别行内代码块的正则表达式有点怪:“/(?<!\\)`(.+?)`/g”。其实一开始它只有后半部分“/`(.+?)`/g”,用来识别被两个“`”包围起来的行内代码。但是如果文本是“\`whatever`”,那么这段文字不应该识别为行内代码,因为前一个“`”是转义字符的一部分。但是这里无法用转义(也就是将“\`”替换为“\`”)避免它被识别为行内代码,因为转义发生在行内代码识别之后。这是一个两难:如果不转义,“\`whatever`”就会被识别为行内代码;但如果先转义再识别,你并不知道当前内容是文本还是代码,会将代码块中的字符也一并转义。最后发现还是应该先识别,只是识别用的正则应该修改成现在这样,用“(?<!\\)”排除带有“\”前缀的情况。
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文件预览,在一些特殊情况下呈现出的结果不完全一样。但大差不差的,我也不想折腾了。
下一步可能考虑增加对嵌套列表的支持。表格的支持可能往后放放,因为我写博客不太用。