PublishMarkdown技术实现

先附上Publish Markdown的GitHub主页地址:https://github.com/jzj1993/PublishMarkdown

背景

PublishMarkdown的背景详见另一篇文章 WordPress+PublishMarkdown快速构建个人博客

整体技术选型

因为希望这个工具可以同时支持主流桌面平台,并复用一套代码降低开发成本,对基于Java、Python等语言的常见GUI界面实现方案都做了一些研究,最后发现最容易的还是基于node.js的框架,一个是Electron,另一个是NW.js,最后选择了用户更多的Electron。

前端界面上,使用Web前端技术栈即可开发跨平台的界面,而基于electron-vue构建,还能直接用Vue做界面,更加方便了。

后台逻辑上,node.js可以直接使用的第三方node模块也非常多,其中包括最核心的Markdown渲染库,毕竟自己实现可靠的Markdown渲染还是有较大难度的。

整体渲染流程

PublishMarkdown的整体渲染流程示意图如下,后文会详细分析。

文件格式、文章属性与front-matter

PublishMarkdown允许打开扩展名为md、编码为UTF-8的文本文件,正文使用Markdown语法编写。

Markdown本身只包含文章正文内容。但是发布一篇博客,除了正文内容,还希望同时设置文章的很多额外属性(Meta信息),包括标题、摘要、作者、发布时间、标签、分类、固定链接等。这个时候就需要front-matter上场了。在md文件开头用---分隔一个区域,使用YAML语法格式配置文章的各种属性,之后才是Markdown正文内容。

解析源文件时,先整体经过front-matter将Meta信息解析出来,同时获得body,即Markdown的文本,再由Markdown渲染器渲染。

PublishMarkdown支持的文章属性、编写格式和相关说明如下。

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
---

# 注释:文件开头使用YAML语法配置文章信息,之后是正常的Markdown语法

# 此处如果不配置标题,则提取Markdown中的一级标题,或使用文件名
title: Sample

# 此处如果不配置摘要,则从正文提取开头若干文字
abstract: 你好!这是一篇示例文档!

# URL用于固定链接、编辑文章功能,建议所有文章都配置
url: sample

# 文章发布时间,使用的时区和系统设置一致,不设置则使用当前时间
date: 2015-07-30 18:35:43

# 文章分类
category:
- 分类1
- 分类2

# 文章标签
tags:
- 标签1
- 标签2

---

# Markdown内容
## 二级标题

段落

本地预览

在Electron环境下,因为本身就是基于HTML实现界面的,因此预览渲染好的HTML比较容易,直接把生成的HTML放到界面中就好了,用Vue的v-html指令即可实现。

远程发布

远程发布目前支持MetaWeblog接口,兼容WordPress等博客,使用node库metaweblog-api实现。

支持配置多个站点,同时发布到多个博客。

文章有创建和编辑两种操作模式。对于某一个站点,首次使用PublishMarkdown发布一篇文章时为创建模式,发布成功后会将文章的url和远程返回的id缓存起来,下次发布相同url的文章时,会提示编辑文章还是创建新文章。其中url为固定链接,在文章开头使用YAML格式配置。如果没有配置url则不会执行缓存策略,之后也无法使用本工具更新远程文章。

Markdown的渲染

NPM仓库中的Markdown渲染库有很多种。一开始用的是性能比较好的marked,但是后来发现性能虽然好,但是可扩展性不是很好,难以满足Markdown渲染的一些需要。受限于技术能力,自己直接去改源码又比较困难。

对比了很多同类模块,终于发现markdown-it才是我想要的渲染库,因为它支持各种插件,包括删除线、下划线、代码高亮、MathJax等语法的插件,灵活性很高。

图片的预览和上传

图片的预览

PublishMarkdown中,图片支持网络图片和本地图片,本地图片支持相对路径和绝对路径。

Markdown渲染器在渲染时,会把图片路径原封不动的放到img标签的src属性中,而Electron环境相当于浏览器,网络图片可以直接加载,但是本地图片不能通过src中的原始文件路径加载。

为了在Electron中显示本地图,通过DOM操作将src属性中的本地文件路径替换成完整的file://格式,例如/home/doc/article.md文件中通过相对路径引用了img/demo.png,src会被替换成file:///home/doc/img/demo.png。同时,还需要设置Electron中的webSecurity属性以允许加载本地图。

图片的上传

使用Markdown写博客的人在插入图片时常有两种方式,一种是写文章时直接把图片上传到图床;另一种是图片放在本地,发布时再统一上传,两种方式各有优缺点,在WordPress+PublishMarkdown快速构建个人博客一文中已经做过分析。

使用PublishMarkdown发布博客时,上述两种方式都是没问题的。PublishMarkdown会自动判断图片地址,如果是网络图片,就直接发布原始HTML引用图片链接;如果是本地图片文件,则批量上传图片到博客站点,获取到生成的URL,修改正文的HTML,把引用从file://改为图片URL。

PublishMarkdown会对每个站点上传过的图片MD5和URL分别做缓存。下次该站点上传相同图片时,如果根据MD5判断图片已上传,且验证图片URL可用(使用HEAD方法发HTTP请求),就不需要重复上传图片了。这个功能主要用于编辑已经发布的博客,避免重复上传相同图片。

代码高亮

代码高亮使用highlight.js渲染,支持180+种编程语言。

整体流程

1、代码在Markdown中的编写格式如下。

1
2
3
4
5
6
7
8
行内代码 `int a = 1`。

代码块(设置使用的编程语言为js):

​```js
const axios = require("axios");
// ...
​```

2、经过markdown-it处理,行内代码渲染成<code>原始代码</code>,代码块渲染成<pre><code class="language-js">原始代码</code></pre>

3、再经过highlight.js渲染,会根据语法规则拆分成不同类型的代码片段,最后用CSS实现不同的颜色。例如js代码块中的一行 const axios = require("axios"); 会被渲染成下面的HTML代码。

1
<pre><code class="language-js hljs javascript"><span class="hljs-keyword">const</span> axios = <span class="hljs-built_in">require</span>(<span class="hljs-string">"axios"</span>);</code></pre>

4、最终显示的效果如下:

行内代码 int a = 1

代码块:

1
2
const axios = require("axios");
// ...

发布博客时的渲染

在PublishMarkdown中,原始代码在预览和发布时经过的渲染步骤不完全相同,实现最终的代码高亮有多种方案。

1、发布时不渲染。只经过markdown-it,将行内代码转换成<code>原始代码</code>,代码块渲染成<pre><code lang="js">原始代码</code></pre>,发布到博客。在博客站点中配置highlight.js前端插件(WordPress中安装WP Code Highlight.js插件),用户访问博客时,在用户浏览器中对代码做高亮处理。这是PublishMarkdown推荐的做法,也是为知笔记最新版本发布时的做法。

2、发布时渲染。经过markdown-ithighlight.js, 直接将代码渲染成最终的HTML。博客中配置CSS实现高亮。这种方式适合第三方博客站点没法自行配置代码高亮插件、但是支持CSS配置的情形。这是为知笔记早期版本发布时的做法。在PublishMarkdown中,设置代码高亮在预览和发布时都渲染即可。

3、内联CSS样式。在方案2的基础上,把高亮用到的CSS也内嵌到HTML的style中。这种方式适合第三方站点完全不可配置但是又想使用高亮代码的情况。PublishMarkdown暂不支持CSS样式内链到HTML。

MathJax公式渲染

整体流程

1、在Pandoc中规定了插入MathJax公式使用的格式,即使用美元符号$分割,具体规则如下。PublishMarkdown中使用了相同的格式用于插入MathJax公式。

1
2
3
4
5
6
行内公式在单个美元符号之间 $\frac{a}{b}$。如果美元符号紧跟数字,不会识别为行内公式 $20,000 and $30,000。如果起始美元符号紧跟空格,或终止美元符号前面是空格,也不会识别为公式 $ \frac{a}{b} $。

行内代码不识别为公式 `$\frac{a}{b}$`。

行间公式在两个美元符号之间。同样,代码块不识别为公式。
$$ U_o = A^2 * ( U_+ - U_- ) $$

2、Markdown源码会通过MarkdownIt的插件markdown-it-mathjax,并将符合规则的公式识别出来,并替换成标准的MathJax分隔符格式,InlineMath分隔符为\(...\),DisplayMath分隔符为\[...\]

3、由MathJax渲染成相应的HTML代码。

4、渲染后的效果如下:

行内公式在单个美元符号之间 $\frac{a}{b}$。如果美元符号紧跟数字,不会识别为行内公式 $20,000 and $30,000。如果起始美元符号紧跟空格,或终止美元符号前面是空格,也不会识别为公式 $ \frac{a}{b} $。

行内代码不识别为公式 $\frac{a}{b}$

行间公式在两个美元符号之间。同样,代码块不识别为公式。

1
$$ U_o = A^2 * ( U_+ - U_- ) $$

技术细节

由于MathJax渲染比较慢,为了提高性能,仅在有MathJax数学公式时才会调用。为此对插件markdown-it-mathjax做了一点修改,Markdown渲染完成时会返回文章中是否有公式。

MathJax官方提供了前端渲染和后端渲染两种库,前端库用于在浏览器环境下渲染,后端库在Node.js环境下渲染,然而在Electron环境下都不能正常工作。被坑了之后才发现第三方的mathjax-electron前端库比较好用,为了获得渲染好的HTML同时用于预览和发布,渲染时先创建一个不可见的div,渲染完成后再将div中的HTML返回,最后删除div。

发布博客时的渲染

和代码高亮相同,发布博客时,MathJax有发布时不渲染、发布时渲染、内联CSS样式三种方案,PublishMarkdown支持前两种方案,不再重复说明。对于WordPress,可以安装Simple Mathjax插件渲染公式,并设置Custom mathjax config参数如下。

1
2
3
4
5
6
MathJax.Hub.Config({
tex2jax: {
inlineMath: [['\\(', '\\)']],
displayMath: [['\\[','\\]']]
}
})

如果配置后博客加载慢,可尝试设置Custom mathjax CDN参数为https://cdn.bootcss.com/mathjax/2.7.3/MathJax.js?config=TeX-MML-AM_CHTML

遇到的困难

开发PublishMarkdown的过程中遇到了一些困难,这里做个简单的总结和回顾。

1、首先就是没有头绪

同类工具不是很常见,开源的完整项目更是少之又少,一些东西网上很难找到。不像之前做Android开发,很多问题在谷歌的第一页几乎就能找到答案并且是中文的(这里不得不感叹移动开发近几年的火爆程度),而这个工具遇到的一些问题要尝试各种中英文关键词,多看一些博客才有进展。

从产品设计的角度,没想好怎么更好的解决一些实际问题,同时兼顾技术实现的可能性。例如怎么给Markdown文件配置额外的Meta信息,是应该用一个独立的数据库统一管理,还是在文件中按照固定格式编写,还是给每个文章单独编写配置文件?

从技术层面来说,所有东西都自己实现不现实,必然要用一些第三方的库,但是需求本身也就不明确的情况下,也不知道搜索什么关键词好。例如front-matter,是在我看了很多篇讨论如何自行开发Markdown编辑器的博客才意外发现的(可惜大部分文章也只是文章,要么项目没开发完,要么没开源,要么效果太差)。

2、需要学习的新知识比较多

因为自己之前一直做Android为主,前端开发经验并不算丰富,很多东西都是临时各种找,包括一些CSS属性的使用、DOM操作等基础问题。

Electron、Vue都比较生疏,需要时不时去看入门基础知识。

还要临时查看各种第三方库的用法,包括做一些对比。例如marked.js,markdown-it的用法,在不太能看懂源码的情况下,实现某些特殊需求就有点难办,比如想从Markdown中提取标题。WordPress博客接口有很多种,一开始想用RestAPI,看了半天才发现RestAPI必须在博客中装插件用于用户身份鉴定。MathJax渲染填坑更是差点让人丧失信心,因为完全按照官方文档使用竟然不奏效,大概是因为和Electron特殊的运行环境有冲突。

3、开发环境相关问题较多

用Electron开发跨平台桌面应用很轻松高效,原因是Electron本身已经封装好了复杂的底层技术,既有node.js的东西,又有前端技术,还要内嵌浏览器,提供窗口管理的js接口,跨操作系统打包。

配置使用Electron的第一步就会感受到天朝特色,依赖下载不了,需要设置npm或者yarn使用taobao镜像。

Electron使用到了前端开发技术栈,而前端开发的环境配置向来比较复杂,幸亏有现成的electron-vue,不然仅仅是WebPack的配置就足够让人头大了。

使用electron-builder打包也遇到了不少意外。期间遇到一个问题,打出来的安装包尺寸非常大,达到了100MB(一个小工具尺寸这么大恐怕很多人难以接受),而同样基于Electron的其他应用,安装包只有不到50MB,网上找了不少Electron包瘦身的文章,还是百思不得其解。于是只好写一个空的Demo也打包,把两个程序包解压出来对比,发现多了一个app.asar.unpacked文件,还在GitHub给人提了个Issue。最后自己找到了原因出在vuex-electron声明的依赖不合理,导致无用文件被错误的打包了进去。跨平台打包和测试,还需要多种操作系统环境,倒腾虚拟机也花费了一些时间。

其他可开发功能

PublishMarkdown目前已经可以满足常见的博客发布需求。这里再列举一些其他可以开发的功能,但是不一定有时间开发这些了,有兴趣的人可以一起开发。

  • 本地直接查看渲染后的HTML。
  • 图片批量上传到指定图床,而不是博客站点。
  • 支持更多Meta属性,例如针对WordPress的特色图片、SEO参数等。
  • 支持更多扩展的Markdown语法,例如流程图 js-sequenceflowchart.jsmermaid 等。参考自Typora文档
  • 支持MetaWeblog博客接口以外的其他平台。例如同步发布到微信公众号(通过模拟网页点击实现,有一定的复杂度)。
  • 发布时支持内联CSS样式到HTML。感觉似乎不是很有必要。
  • Markdown编辑功能加强。开发成本较高,并且没有很大必要,毕竟跨平台好用的Markdown编辑器已经不少了,例如Typora、Haroopad等。推荐先用其他工具编辑好,再用PublishMarkdown发布。