基于NodeJS解析Markdown

前言

使用nodejs模块marked解析Markdown文章

  • 优点:

    1. 前后端,不同OS 都可使用
    2. 开源,易扩展
    3. 速度比较快 (通过RegEx匹配解析)
  • 应用举例:

    1. 作为markdown编辑器
      • 网上各个markdown编辑器的规则、功能、风格、依赖平台等都有不统一,导出不理想,自定制有局限,无法做扩展
    2. 自由加入各个开发项目中
      • static site generator
      • online editor
      • ...

1. 搭建测试环境

> mkdir node-marked
> cd node-marked
> npm init
> npm install marked --save-dev
> npm install highlight.js --save-dev

2. 使用 Marked Cmd

> npm install marked -g
> marked -i articles/01.md
> marked -i articles/01.md -t
> marked -i articles/01.md -o pages/01.html

参数说明

  • -o, –output: 指定输出文件,默认为当前控制台
  • -i, –input: 指定输入文件或最后一个参数,默认为当前控制台输入
  • -t, –tokens: 输出token流代替HTML
  • –pedantic: 只解析符合markdown.pl定义的,不修正markdown的错误
  • –gfm: 启动Github样式的Markdown,参考 github-flavored-markdown
  • –breaks: 支持Github换行符,必须打开gfm选项
  • –tables: 支持Github表格,必须打开gfm选项
  • –sanitize: 原始输出,忽略HTML标签
  • –smart-lists: 优化列表输出
  • –lang-prefix [prefix]: 设置前置样式
  • –no-etc: 选择的反正标识
  • –silent: 不输出错误信息
  • -h, –help: 帮助信息

3. 使用 Marked API

API 具体可参考 GitHub Marked

marked(markdownString [,options] [,callback])

3.1 Minimal usage

var marked=require("marked");
console.log(marked("I am using marked api!"));
> node 01
<p>I am using marked api!</p>

3.2 Setting options example

var marked=require("marked");
var highlight=require("highlight.js");
marked.setOptions({
    gfm: true,
    tables: true,
    breaks: true,
    pedantic: false,
    sanitize: false,
    smartLists: true,
    smartypants: false,
    codePrefix:"hljs",
    tableClass:"table",
    highlight:function (code,lang) {
        return highlight.highlightAuto(code,[lang]).value;
    }
});
console.log(marked(markdownString));
> node 01
<h3 id="i-am-using-marked-api-">I am using marked api!</h3>
<pre><code class="lang-js"> <span class="hljs-built_in">console</span>.log(<span class="hljs-string">'Hello world!'</span>);
</code></pre>

3.3 Browser

<!doctype html>
<html>
<head>
  <meta charset="utf-8"/>
  <title>Marked in the browser</title>
  <script src="lib/marked.js"></script>
</head>
<body>
  <div id="content"></div>
  <script type="text/javascript">

    document.getElementById('content').innerHTML = marked('# Marked in browser\n\nRendered by **marked**.');

  </script>
</body>
</html>

4. Marked 解析说明

function marked(src,opt,callback){
    ...
    opt = merge({}, marked.defaults, opt);
    ...
    tokens = Lexer.lex(src, opt);
    ...
    out=Parser.parse(tokens,opt);
    ...
}

4.1 解析过程

  1. Lexer: Block Level Render => tokens
  2. Parser: Parse tokens => html each token => Render && InlineLexer(Inline Level Render ) => html

4.2 核心类介绍

1. Lexer ( Block Level Render )

var block={...}        //定义块级解析规则(正则表达式)
//newline,code,fences,hr,heading,nptable,lheading,blockquote,list,html,def,table,paragraph,text

Lexer.rules=block;
Lexer.lex = function(src, options) {
  var lexer = new Lexer(options);
  return lexer.lex(src);
};

Lexer.prototype.lex         //调用 Lexer.prototype.token
Lexer.prototype.token       //根据rules解析返回tokens数组

2. Parser ( Parse Tokens to HTML )

Parser.parse = function(tokens, options, renderer) {
  var parser = new Parser(options, renderer);
  return parser.parse(tokens);
};

Parser.prototype.parse    //调用 Parser.prototype.tok 逐个解析token
Parser.prototype.tok      //根据token.type调用Render的相应方法进行渲染 
ps: 有些token在调用Render渲染前会预先调用InlineLex进行预处理

3. Render ( Render Each Token to HTML )

Renderer.prototype.code
Renderer.prototype.blockquote
Renderer.prototype.html
Renderer.prototype.heading
Renderer.prototype.hr
Renderer.prototype.list
Renderer.prototype.listitem
Renderer.prototype.paragraph
Renderer.prototype.table 
Renderer.prototype.tablerow
Renderer.prototype.tablecell
Renderer.prototype.strong
Renderer.prototype.em
Renderer.prototype.codespan
Renderer.prototype.br
Renderer.prototype.del
Renderer.prototype.link
Renderer.prototype.image
Renderer.prototype.text

4. InlineLexer ( Inline Level Renderer )

var inline={...}     //定义行级解析规则(正则表达式)
//escape,autolink,url,tag,link,reflink,nolink,strong,em,code,br,del,text

InlineLexer.rules = inline;
InlineLexer.output = function(src, links, options) {
  var inline = new InlineLexer(links, options);
  return inline.output(src);
};

InlineLexer.prototype.output     //根据rules解析,返回字符串
ps: 有些匹配项会调用Render的相应方法进行渲染

5. Marked 扩展

在了解了marked的解析过程后, 对于一些无法通过marked本身提供的配置选项完成的特殊功能, 可以自己在上面拓展一些功能(这也是会选择marked的原因之一)

这里列举两个比较常用的拓展:

  • 增加TOC(HeaderAnchor)功能;
  • 增加解析meta功能;

5.1 拓展:增加 TOC 功能 ( HeaderAnchor )

[TOC]

1. Lexer

var block = {
    ...
     toc: /^\[TOC\]\n/,
};

Lexer.prototype.token = function(src, top, bq) {
    ...
    var tocIndex=-1;
    while(src){
        ...
        if(cap=this.rules.toc.exec(src)){
            //console.log("token toc");
            src = src.substring(cap[0].length);
            this.tokens.push({type:'toc'});
            tocIndex=this.tokens.length-1;
            continue;
        }
        ...
    }
    if(tocIndex>=0){
        var toc=[];
        for(var i=0;i<this.tokens.length;i++){
          if(this.tokens[i].type==='heading')
            toc.push(this.tokens[i]);
        }
        this.tokens[tocIndex]={type:"toc",datas:toc};
    }
    return this.tokens;
};

2. Parser

Parser.prototype.tok = function() {
  switch (this.token.type){
    case 'toc':{
      //console.log("parse toc");
      var datas=this.token.datas;
      for(var i=0;i<datas.length;i++){
        datas[i].raw=datas[i].text;
        datas[i].text=this.inline.output(datas[i].text);
      }
      return this.renderer.toc(datas);
    }
    ...
  }
};

3. Render

Renderer.prototype.toc = function(datas) {
  //console.log("render toc");
  var tocStr='',indentStr='',item=undefined;
  for(var i=0;i<datas.length;i++){
    item=datas[i];
    indentStr='';
    for(var j=0;j<item.depth-1;j++)
        indentStr+='&nbsp;&nbsp;';
    var id=this.options.headerPrefix+item.raw.toLowerCase().replace(/[^\w]+/g, '-');
    tocStr+="<li>"+indentStr+"<a href='#"+id+"'>"+item.text+"</a></li>";
  }
  return this.list(tocStr,false);
};

4. 使用示例

var marked=require("./marked-extend");
var fs=require("fs");
fs.readFile("articles/01.md",'utf8',function(err,data){
    var result=marked(data);
    fs.writeFile("pages/01-1.html",result);
});

5.2 拓展:增加解析 MetaHeader

--- 
title: 用Middleman搭建静态博客
tags: web, static
description: Build Static Blog by Middleman
---

1. Lexer

var block = {
    ...
    meta:/^-{3,}\s*?\n([\s\S]*?)\n-{3,}/, 
    metaItem:/(\S+)\s*?:\s*([\s\S]*?)(?=$|\n)/g,
};

Lexer.prototype.token = function(src, top, bq) {
    ...
    var tocIndex=-1;
    while(src){
        ...
        if(cap=this.rules.meta.exec(src)){
            src=src.substring(cap[0].length);
            var meta={};
            while((item=this.rules.metaItem.exec(cap[1]))!=null){
                //console.log(item[1]+":"+item[2]);
                meta[item[1]]=item[2];
            }
            this.tokens.push({type:"meta",datas:meta});
            continue;
        }
        ...
    }
    return this.tokens;
};

Lexer.prototype.getToken=function(tokens,type){
    var token=undefined;
    for(var i=0;i<tokens.length;i++){
        if(tokens[i].type===type){
          token=tokens[i];
          break;
        }
    }
    return token;
};
// Static Method
Lexer.getToken=Lexer.prototype.getToken;

2. Parser

Parser.prototype.tok = function() {
  switch (this.token.type){
    case 'meta':{
        return "";
    }
    ...
  }
};

3. 使用示例

var marked=require("./marked-extend");
var fs=require("fs");
fs.readFile("articles/01.md",'utf8',function(err,data){
    var Lexer=marked.Lexer;
    var lexer=new Lexer();
    var tokens=lexer.lex(data);

    var tocToken=lexer.getToken(tokens,"toc");
    console.log("toc:");
    console.log(tocToken);

    var metaToken=lexer.getToken(tokens,"meta");
    console.log("meta:");
    console.log(metaToken);

    var result=marked.parser(tokens);
    fs.writeFile("pages/01-2.html",result);
});

6. HTML 结果页面扩展

6.1 使用 Highlight.js 进行语法高亮

1.Highlight介绍 ( 参考 LiveDemo | Usage | Doc&API )

使用在线Highligh ( 可在线定制,参考 Getting highlight.js,CDN highlight.js )

<link rel="stylesheet" type="text/css" href="http://cdnjs.cloudflare.com/ajax/libs/highlight.js/8.9.1/styles/default.min.css" />

<script type="text/javascript" src="http://cdnjs.cloudflare.com/ajax/libs/highlight.js/8.9.1/highlight.min.js"/>
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/8.9.1/languages/markdown.min.js"></script>
<script type="text/javascript">
    hljs.initHighlightingOnLoad();
</script>

使用本地构建的Highligh

> bower install highlight --save-dev
> cd bower_components/highlight

//构建Highligh
> npm install
> node tools/build.js -t all    // build all [ node cdn  browser]
(或者只构建browser,使用 node tools/build.js -t browser)

PS: 若执行报类似如下错误,请升级node,或下载highlight#8 ( bower install highlight#8 )

build = require(`./${commander.target}`);
                    ^
SyntaxError: Unexpected token ILLEGAL
    at Module._compile (module.js:439:25)
    at Object.Module._extensions..js (module.js:474:10)
    at Module.load (module.js:356:32)
    at Function.Module._load (module.js:312:12)
    at Function.Module.runMain (module.js:497:10)
    at startup (node.js:119:16)
    at node.js:902:3
使用构建的browser highlight
<link rel="stylesheet" type="text/css" href="../bower_components/highlight/build/browser/demo/styles/default.css" />

<script type="text/javascript" src="../bower_components/highlight/build/browser/highlight.pack.js"></script>
<script type="text/javascript">
    hljs.initHighlightingOnLoad();
</script>

PS:

  • 构建出的node highlight 和通过 npm install highlight.js 获取的highlight是一致的
  • 要在前端网页中使用node highlight,还需通过browserify插件转换 (具体使用可参考gulp篇)

2. 在Marked中使用highlight.js

前端只加载highlight css文件

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Marked Highlight</title>
  <link rel="stylesheet" type="text/css" href="../bower_components/highlight/build/browser/demo/styles/tomorrow-night-bright.css" />
</head>
<body>
    {content}
</body>
</html>

后端调用highlight解析markdown的code内容

var marked=require("./marked-extend");
var fs=require("fs");
var highlight=require("highlight.js");

marked.setOptions({
    langPrefix:"hljs ",
    highlight:function (code,lang) {
        return highlight.highlightAuto(code,[lang]).value;
    }
});

fs.readFile("articles/test.md",'utf8',function(err,data){
    var result=marked(data);
    var tpl=fs.readFileSync("template/highlight-03.html",'utf-8');
    fs.writeFile("pages/highlight-03.html",tpl.replace("{content}",result));
});

3.调用区别

  • 前端调用hljs.initHighlightingOnLoad(); 识别解析<pre><code>...</code></pre>
    • 若无language设置,则自动识别添加一种languange <pre><code class="hljs html">...</code></pre>
    • 若有languange设置
      • 是已注册的languange,则使用相应的languange js进行解析 <pre><code class="hljs javascript">...</code></pre>
      • 是未注册的languange,则不解析 <pre><code class="nohighlight">...</code></pre>
  • 后端marked解析code
    • 若无languange设置,则不会在<code>标签上增加class
    • 若有languange设置,则会在<code>标签上增加class(langPrefix+languange)
Renderer.prototype.code = function(code, lang, escaped) {
  if (this.options.highlight) {
    var out = this.options.highlight(code, lang);
    if (out != null && out !== code) {
      escaped = true;
      code = out;
    }
  }

  if (!lang) {
    return '<pre><code>'+ (escaped ? code : escape(code, true)) + '\n</code></pre>';
  }

  return '<pre><code class="'
    + this.options.langPrefix
    + escape(lang, true)
    + '">'
    + (escaped ? code : escape(code, true))
    + '\n</code></pre>\n';
};

PS: 可以修改前后端以统一两种调用highlight的效果

6.2 增加 LaTex 公式支持

使用 MathJax (参考 LaTex,MathJax Doc

//从CDN加载MathJax
<script type="text/javascript"
src="https://cdn.mathjax.org/mathjax/latest/MathJax.js?config=TeX-AMS-MML_HTMLorMML">
</script>
//或加载通过bower install MathJax --save-dev 获取到本地的MathJax
 <script type="text/javascript" src="../bower_components/MathJax/MathJax.js?config=TeX-AMS-MML_HTMLorMML"></script>

//可添加配置,eg:
<script type="text/x-mathjax-config">
    MathJax.Hub.Config({
    ...
    });
</script>

7. Issues

7.1 Marked TOC heading id 乱码

marked中解析出的<hx>标签id生成规则为: this.options.headerPrefix+ raw.toLowerCase().replace(/[^\w]+/g, '-') ( 将文本中空格替换为- )

若是中文或其他特殊字符会导致难以Anchor

自定义h标签的id生成规则:

1.Lexer

Lexer.prototype.token = function(src, top, bq) {
    ...
    while(src){
        ...
    }
     if(tocIndex>=0 || this.options.reHeader){
        var toc=[];
        for(var i=0;i<this.tokens.length;i++){
          if(this.tokens[i].type==='heading'){
            toc.push(this.tokens[i]);
            this.tokens[i].index=toc.length;   // add heading index
          }
        }
        this.tokens[tocIndex]={type:"toc",datas:toc};
    }
    return this.tokens;
};

2.Parser

Parser.prototype.tok = function() {
     switch (this.token.type) {
        ...
        case 'heading': {
          return this.renderer.heading(
            this.inline.output(this.token.text),
            this.token.depth,
            this.token.text,this.token.index);  // add heading index
        }
        ...
    }
}

3.Render

Renderer.prototype.heading = function(text, level, raw, index) {
  return '<h'+ level
    + ' id="'
    + this.options.headerPrefix
    //+ raw.toLowerCase().replace(/[^\w]+/g, '-')
    + (index?index:raw.toLowerCase().replace(/[^\w]+/g, '-'))
    + '">'
    + text+ '</h'+ level+ '>\n';
};
Renderer.prototype.toc = function(datas) {
    ...
    //var id=this.options.headerPrefix+item.raw.toLowerCase().replace(/[^\w]+/g, '-');
    var id=this.options.headerPrefix+(item.index?item.index:item.raw.toLowerCase().replace(/[^\w]+/g, '-'));
    ...
};

7.2 Highlight & Markdown Conflict with MathJax

1.约定markdown中的LaxTex公式 含有LaxTex公式的包含在<pre></pre> 标签中

2. 添加MathJax配置

<script type="text/x-mathjax-config">
    MathJax.Hub.Config({
          tex2jax: {
              //skipTags remove 'pre' entry
              skipTags: ['script', 'noscript', 'style', 'textarea','code']
        }
    });
</script>

8. 最终版

9. 各种Markdown编辑器

10. 参考