remarkable搭配markdown-toc自动生成博文目录

image.png


文章目录


前段时间写过一篇关于showdown和showdown-toc插件自动生成目录的博客 ⬅️点击查看


为什么要改进


showdown-toc作为专门为showdown开发的 toc 插件,搭配使用都非常方便

  • 安装showdown ,showdown-toc
  • toc插件集成到showdown代码中
const Showdown = require('showdown');
const showdownToc = require('showdown-toc');
const showdownHighlight = require("showdown-highlight");

const toc = [{
   anchor: 'header-1',
   level: 1,
   text: 'header 1'
 }, // # header 1
 {
   anchor: 'header-2',
   level: 2,
   text: 'header 2'
 }
];
const showdown = new Showdown.Converter({
 extensions: [showdownToc({
   toc
 }), showdownHighlight]
});

module.exports = showdown;

引用上面的工具类, showdown.render(markdown文本内容) 即可渲染,
设置toc目录也非常简单方便,
在你的markdown文本中,只要加入占位符 [toc] 即可在渲染中生成目录替换占位符.

但是, showdown-toc 有个不太友好的地方:

  1. 生成的目录不能嵌套
    比如:
# 第一级标题
## 第二级标题
### 第三级标题
#### 第四级标题

我希望渲染后的效果是下面的:

  • 第一季标题
    • 第二级标题
      • 第三级标题
        • 第四级标题

但是实际效果并不是这样,或许是我试用中没有深入了解此插件.
导致我渲染出的结果只有一层...
所以决定尝试别的工具改进



如何改进


无意中发现了 markdown-toc 这个插件,
它的存在比较独立,也更加灵活,
你可以独立使用它,也可以和其他渲染markdown工具集成,听上去非常美好.

markdown-toc
开源地址:https://github.com/jonschlinkert/markdown-toc

安装:npm install markdown-toc --save

搭配:remarkable 渲染工具
开源地址:https://github.com/jonschlinkert/remarkable

安装:npm install remarkable --save



markdown-toc使用中遇到的问题


安装官方文档搭配 remarkable

var Remarkable = require('remarkable');
var toc = require('markdown-toc');

function render(str, options) {
 return new Remarkable()
   .use(toc.plugin(options)) // <= register the plugin
   .render(str);
}

上面的使用方法并没有像官方文档提示的一样,render渲染后得到的json对象,并不是html (一脸懵)
开始以为是 remarkable render 方法的问题,
最后发现问题出现的 markdown-toc,在注册插件以后,render 方法返回 json 对象, 如果不注册插件,直接用 remarkable 的 render方法,一点问题没有,直接渲染成 html .

但是 markdown-toc 生成的目录确实是我需要的.

  • 保证了正确的层级关系,而且没侵入性
  • 不需要占位符
  • 对于以前写的博文也可以直接生成目录

这决定了我想好好研究下 markdown-toc



如何解决问题



markdown-toc的返回值

直接用markdown-toc 渲染 markdown文档,返回类似下面的对象:

{
json:
[ 
{ content: 'AAA', slug: 'aaa', lvl: 1 },
 { content: 'BBB', slug: 'bbb', lvl: 2 },
 { content: 'CCC', slug: 'ccc', lvl: 3 }
 ],
tokens:
 [
 {
   "type": "heading_open",
   "tag": "h1",
   "attrs": null,
   "map": [
     0,
     1
   ],
   "nesting": 1,
   "level": 0,
   "children": null,
   "content": "",
   "markup": "#",
   "info": "",
   "meta": null,
   "block": true,
   "hidden": false
 },
 {
   "type": "inline",
   "tag": "",
   "attrs": null,
   "map": [
     0,
     1
   ],
   "nesting": 0,
   "level": 1,
   "children": [
     {
       "type": "text",
       "tag": "",
       "attrs": null,
       "map": null,
       "nesting": 0,
       "level": 0,
       "children": null,
       "content": "test",
       "markup": "",
       "info": "",
       "meta": null,
       "block": false,
       "hidden": false
     }
   ],
   "content": "test",
   "markup": "",
   "info": "",
   "meta": null,
   "block": true,
   "hidden": false
 },
 {
   "type": "heading_close",
   "tag": "h1",
   "attrs": null,
   "map": null,
   "nesting": -1,
   "level": 0,
   "children": null,
   "content": "",
   "markup": "#",
   "info": "",
   "meta": null,
   "block": true,
   "hidden": false
 }
],
content:"markdown格式目录字符串"
}

从上面可以看到 markdown-toc 渲染markdown文本返回值中有2部分是我们可以利用的:

  • content对应自动生成的目录文本(markdown格式)
  • tokens数组是返回markdown文本解析出来的rules解析规则数组

那也就是说我们可以拿到rules解析规则数组后,
再让 remarkable 工具render 一次即可.

于是 remarkable 再次render一次
这次渲染包含俩部分 ( markdown-toc 渲染后拿到的 content[ markdown格式目录字符串] 和 原来的 markdown文章 )

问题再一次出现:
文章头部有了目录,也有了目录层级结构,
但是markdown原文解析后没有加上锚点.....

引入remarkable 组件 linkify

const {
  linkify
} = require('remarkable/linkify');

详细代码如下:

const {
  Remarkable,
} = require('remarkable');
const {
  linkify
} = require('remarkable/linkify');
const hljs = require('highlight.js');
const toc = require('markdown-toc');

const md = new Remarkable('full', {
  html: true, 
  xhtmlOut: true, 
  breaks: true, 
  langPrefix: 'language-', 
  highlight: function (str, lang) {
    if (lang && hljs.getLanguage(lang)) {
      try {
        return hljs.highlight(lang, str).value;
      } catch (err) {}
    }

    try {
      return hljs.highlightAuto(str).value;
    } catch (err) {}

    return ''; // use external default escaping
  }
});
md.use(linkify);

这样去 render 后,一样出现上面的问题,内容没有锚点,
几次尝试修改配置依然不行,
最后只好重新rules规则方法.


找到我们目标渲染文本对应的rule规则

markdown文本中,我们需要自动加入锚点的标记符号是:

# (H1)
## (H2)
### (H3)
#### (H4)
##### (H5)
###### (H6)

这6类标题对应渲染规则是 :heading_open

rule数组中对应的标题解析元素如下:

[
  {
    "type": "heading_open",
    "hLevel":1,
    "tag": "h1",
    "attrs": null,
    "map": [
      0,
      1
    ],
    "nesting": 1,
    "level": 0,
    "children": null,
    "content": "",
    "markup": "#",
    "info": "",
    "meta": null,
    "block": true,
    "hidden": false
  },
  {
    "type": "inline",
    "tag": "",
    "attrs": null,
    "map": [
      0,
      1
    ],
    "nesting": 0,
    "level": 1,
    "children": [
      {
        "type": "text",
        "tag": "",
        "attrs": null,
        "map": null,
        "nesting": 0,
        "level": 0,
        "children": null,
        "content": "test",
        "markup": "",
        "info": "",
        "meta": null,
        "block": false,
        "hidden": false
      }
    ],
    "content": "test",
    "markup": "",
    "info": "",
    "meta": null,
    "block": true,
    "hidden": false
  },
  {
    "type": "heading_close",
    "hLevel":1,
    "tag": "h1",
    "attrs": null,
    "map": null,
    "nesting": -1,
    "level": 0,
    "children": null,
    "content": "",
    "markup": "#",
    "info": "",
    "meta": null,
    "block": true,
    "hidden": false
  }
]

heading_open 对应
下一个元素 "type": "inline", 对应的就是 文本 test
heading_close 对应

上面3个元素对应解析渲染的html是

test

这样思路就有了,我们手动重新 heading_open 和 heading_close 俩个渲染方法


重新rule渲染方法


md.renderer.rules.heading_open = function (tokens, idx /*, options, env */ ) {
  const anchorName = tokens[idx + 1].content;
  const anchor = `<a name="${encodeURI(anchorName)}" ></a><br/>`;
  return anchor + `<h${tokens[idx].hLevel } >`;
};

md.renderer.rules.heading_close = function (tokens, idx /*, options, env */ ) {
  return '</h' + tokens[idx].hLevel + ' > ';
};

我们在标签前面生成一个锚点链接
a链接锚点name值就是标签的文本, 通过 tokens[idx + 1].content 来获取 (这里需要url编码和特殊符号替换)


包装remarkable render工具类


const {
  Remarkable,
} = require('remarkable');
const {
  linkify
} = require('remarkable/linkify');
const hljs = require('highlight.js');
const toc = require('markdown-toc');

const md = new Remarkable('full', {
  html: true, // Enable HTML tags in source
  xhtmlOut: true, // Use '/' to close single tags (<br />)
  breaks: true, // Convert '\n' in paragraphs into <br>
  langPrefix: 'language-', // CSS language prefix for fenced blocks

  // Enable some language-neutral replacement + quotes beautification
  typographer: false,

  // Double + single quotes replacement pairs, when typographer enabled,
  // and smartquotes on. Set doubles to '«»' for Russian, '„“' for German.
  quotes: '“”‘’',

  // Highlighter function. Should return escaped HTML,
  // or '' if the source string is not changed
  highlight: function (str, lang) {
    if (lang && hljs.getLanguage(lang)) {
      try {
        return hljs.highlight(lang, str).value;
      } catch (err) {}
    }

    try {
      return hljs.highlightAuto(str).value;
    } catch (err) {}

    return ''; // use external default escaping
  }
});
md.use(linkify);

const option = {
  html: true, // Enable HTML tags in source
  xhtmlOut: true, // Use '/' to close single tags (<br />)
  breaks: true, // Convert '\n' in paragraphs into <br>
  langPrefix: 'language-', // CSS language prefix for fenced blocks

  // Enable some language-neutral replacement + quotes beautification
  typographer: false,

  // Double + single quotes replacement pairs, when typographer enabled,
  // and smartquotes on. Set doubles to '«»' for Russian, '„“' for German.
  quotes: '“”‘’',

  // Highlighter function. Should return escaped HTML,
  // or '' if the source string is not changed
  highlight: function (str, lang) {
    if (lang && hljs.getLanguage(lang)) {
      try {
        return hljs.highlight(lang, str).value;
      } catch (err) {}
    }

    try {
      return hljs.highlightAuto(str).value;
    } catch (err) {}

    return ''; // use external default escaping
  }
}


md.renderer.rules.heading_open = function (tokens, idx /*, options, env */ ) {
  const anchorName = tokens[idx + 1].content;
  const anchor = `<a name="${encodeURI(anchorName)}" ></a><p style="color:white;">出自:<a style="color:white;" href="https://yijiebuyi.com/blog/50af73b51d4a400d7412a277295afa15.html" >remarkable搭配markdown-toc自动生成博文目录</a></p><br/>`;
  return anchor + `<h${tokens[idx].hLevel } >`;
};

md.renderer.rules.heading_close = function (tokens, idx /*, options, env */ ) {
  return '</h' + tokens[idx].hLevel + ' > ';
};


module.exports = {
  md,
  option,
  toc
}

上面是完成的工具类,
md 是remankable 类,
option 是渲染参数,
toc 是 markdonwn-toc 类.


使用

const {
  md,
  toc
} = require('../utils/remarkable _helper');

const markdonwContent='这里是markdonw文章内容';
const tocMarkDown = '\n\n---\n**文章目录**\n' + toc(markDownContent).content + '\n\n---'; //自动生成的目录
const rebuildMarkDown = tocMarkDown + markdonwContent; // 目录+文章内容
blogInfo.content = md.render(rebuildMarkDown); //remarkable 渲染全部markdown 文本

回到顶部