使用 Node.js 打造 Markdown 即时预览工具

2012-01-31 23:43

使用 Node.js 打造 Markdown 即时预览工具

by Xueqiao Xu

at 2012-01-31 15:43:38

original http://typedef.me/2012/01/31/nodejs-markdown-live-preview

前言

Markdown 现在无疑是最受程序员们欢迎的标记语言之一,Github 以及 StackOverflow 都使用它作为默认的撰写格式。 虽说它被创造的初衷之一便是让原始文档应当和最终文档相差不远,使其直观并易于读写,但在实际使用中,最终编译生成的文档有时并不符合我们的预期(问题往往是出现在换行和缩进的疏忽上)。

作为一个 Github 以及 Markdown 的重度用户,我经常 push 后才发现排版有些问题,于是不得不修改后再次 push,而这些额外的 push 除了增加了自己的工作量之外,对 followers 的 timeline 也是一种污染。我一直在寻求一种好的解决方案。

曾经的方法是用命令行在本地编译并用浏览器打开,但这无疑相当麻烦,每次修改文件后都得再次编译(可以使用watch + make自动化这步),并手动刷新浏览器才能看到更新;

再后来使用了 guard 这个 ruby 写的监控模块,组合使用 guard-markdownguard-livereload,前者用于监测markdown文件改动并自动编译成html,后者用于监测编译过后的html并自动刷新浏览器,但每次得要在目录下放个guardfile文件以指定监控的文件,而且需要浏览器插件,不由得再次觉得麻烦。

另外至于诸如 @shellex 写的 MadeEditor 这种运行在浏览器中的工具,虽然使用简单,但是众多的 vim、emacs 等编辑器的用户肯定是接受不了那个 ace editor 的。

于是,在寻觅已久未果后,我最终写了一个满足自己需求的程序,起名为 mdwatch (亦即 Markdown 的后缀名 md 加上 watch)。

mdwatch 可以监控一个markdown文件,并开启一个http服务器,使用浏览器打开指定的端口便可看到渲染后的结果,对文件的任何改动都将触发浏览器中内容的重新渲染。

代码很简单,在此简略的说下实现的过程。

Markdown

首先解决 Markdown 的渲染问题: 在众多 Markdown 的 Javascript 实现中,marked 是最强悍的,主要理由在于以下两点:

  1. 其性能非常出色,甚至超越了 Discount 这个用 C 写的同类产品。
  2. 它可以让你直接修改词法分析后产生的 token tree,然后再编译成最终文档,这样一来,你就可以对渲染的行为进行自定义的调整,例如对代码块加上语法高亮。

但是 Marked 有一个缺点需要注意:它不会过滤 html 标签,如果你要在自己的网站上使用它来渲染评论或者留言的话,将会造成极为严重的安全隐患。 为了弥补这个缺陷,可以使用 Google 出品的 Caja Sanitizer 来对渲染后的内容再过滤一遍,清除掉那些不安全的标签。

Express

然后是 http 服务器的问题,为了省事我就直接用 express 这个 web 框架而不是使用底层的 http 模块,虽然似乎有些大炮打蚊子的感觉,但是这也为以后加上更多的特性做了准备。

文件监控

文件监控直接用 fs 模块中的 watchFile 即可,在 Linux 平台上,该函数使用内核中集成的 inotify 来实现监控,因此具有非常好的即时性。(在 windows 平台上则采用轮询,其表现会差很多)。

示例代码:

fs.watchFile(filename, function(curr, prev) {
  if (curr.mtime.getTime() !== prev.mtime.getTime()) {
    // do something
  }
});

回调函数中的 currprev 分别是文件当前的状态信息和改动前的状态信息,均为 fs.Stat 类的实例。

注意它会监测文件的任何状态改变,例如访问、链接数的变化等等。而我们仅仅希望当文件内容真正被改动的时候才重新渲染,因此我们需要判断三个时间戳(access,modify、change)中当中的 modify 时间戳,亦即 mtime 是否被改动。这便是上面需要 if 判断的原因。

实时通信

浏览器与服务器的实时通信技术一直是 web 的热点之一,而 HTML5 规范中的 WebSocket 则可以非常好的服务于这一范畴。同时 Socket.IO 的出现更是方便了开发者们,在浏览器不支持 WebSocket 的情况下,它会退而使用 Flash 或者 Ajax 长轮询来实现通信。

mdwatch 中的 socket.io 服务端的相关片段:

// when the client first connects to the server, send the rendered file.
io.sockets.on('connection', function(socket) {
  socket.emit('config', { colorize: colorize });
  update(filename, socket);
});

// watch for file updates.
// we only issue an update to the client when the file is really being modified.
fs.watchFile(filename, function(curr, prev) {
  if (curr.mtime.getTime() !== prev.mtime.getTime()) {
    update(filename, io.sockets);
  }
});

其中 update 是个 helper function,用于读取文件内容,渲染后并发送给浏览器。

// read from the raw markdown file and send the rendered content to the client.
function update(filename, socket) {
  fs.readFile(filename, function(err, data) {
    if (!err) {
      socket.emit('update', marked.parse(data.toString()));
      console.log('update', (new Date()).toTimeString());
    } else {
      console.error(err);
    }
  });
}

浏览器端的 js 代码如下,在update事件触发的时候,直接将渲染好的html设置到 $container 上($container 是一个 jQuery 对象)。

socket.on('update', function(html) {
  console.log('update');
  // set document content
  $container.html(html);

  // colorize code blocks
  if (config.colorize) {
    colorize();
  }
});

代码块语法高亮

语法高亮我依然使用的是 pygments 做后端,同时使用 pygments.js 这个 wrapper 来实现在 node 中调用 pygments 并获取结果。(话说我总共给 pygments.js 的作者提了三个 issue,同时发了一个补丁)

话说 marked 在渲染markdown的时候会识别这种形式的代码块

```ruby
puts 'hello' 
``` 

并将其渲染成

<pre>
  <code class="ruby">puts 'hello'</code>
</pre>

因此我们要做的便是提取所有这类的元素,将其内容则发送到服务端,并获取染色后的结果,替换掉原先的元素。

function colorize() {
  $('code').each(function(idx, ele) {
    if (ele.className) {
      $.ajax({
        url: '/colorize',
        type: 'post',
        data: {
          code: $(ele).text(),
          lang: ele.className
        },
        success: function(data) {
          $(ele).parent().replaceWith($(data));
        }
      });
    }
  });
}

服务器端的处理如下:

app.post('/colorize', function(req, res, next) {
  var code = req.body.code
  var lang = req.body.lang;
  pygments.colorize(code, lang, 'html', function(data) {
    res.send(data, 200);
  });
});

也许有人会问为什么要通过发 Ajax 请求来染色而不是直接将 markdown 的渲染和语法高亮的过程整合到一起呢。

答案是性能问题,在 mdwatch 的最初版本中,我的确是将两者整合的,但是马上就发现由于 pygments 实在是太慢了,每次都会花很长的时间来染色,导致浏览器端的响应不及时。于是在后续的版本中,我就将它们分开来了,先呈现渲染好的 markdown 文档,然后再一批批的对代码进行染色,其体验好了很多。

项目地址

好了,如果你对这个小玩意有兴趣的话,可以访问 https://github.com/qiao/mdwatch ,上面包含了源码以及安装使用的说明,祝玩的开心。