背景

最近在移植WordPress的Sakura主题到Halo上(实际上只是参照样式重写了)。评论这里需要使用marked,Halo官方提供的表情不太适合我,且我早就想扩展一个带表情的marked了。因此正好借着这个机会,扩展一个带表情的marked。

后续也想扩展一下文章页面的marked,只是目前还没有插件。因此暂时先只改评论的即可,到时候通用一套语法即可。

【注:当前文章大部分内容为分析,篇幅较长,如果不想看过程,直接跳转至扩展即可】

本篇文章基于如下内容

marked.js:https://github.com/markedjs/marked
说明文档:https://marked.js.org/#/README.md#README.md

计划

根据我的计划,目前需要实现增加如下的功能

  •  扩展bilibili表情
  •  扩展文字表情
  •  扩展通用的Emoji表情
  •  增加通用的动态表情
  •  将自定义的marked打包至npm

分析

工欲善其事必先利其器。 扩展marked之前一定要了解清楚,如何扩展?是否拥有不修改源码的扩展方法?如果有如何扩展?如果没有,如何修改源码?抱着这个想法,我阅读了 marked.js 的官方文档以及源码。

分析文档

在marked.js的官方文档中,我找到了 Extending Marked 这章内容。

刚开始,我认为也许通过官方的扩展Marked文档即可实现,但仔细阅读之后,发现并没有那么简单。

使用官方的扩展语法,只能扩展已有的渲染器方法。因为他们需要一个方法,例如  _heading(string src)  ,进而通过该方法的返回值,来修改渲染样式。_简而言之,官方的方法只能针对于已有的语法,然后修改其渲染方式,如渲染标题时,我们可以不使用默认的H1、H2,而改用div自定义渲染等等。即官方已经替你解析完毕,你要做的只是按照自己想要的方式去渲染即可

而我们想实现的功能,是新添加一个解析,使用自己的解析语法,因此官方的这种方法不符合我们的要求。

继续翻看文章,在最后发现了如下内容
image.png

这里可以看到大致流程,marked 使用 lexer 解析 markdown 文档成一个 tokens,然后使用 tokens 转换成 html。这是最重要的两步,那么问题来了,自定义的markdown语法如何解析成tokens? 又如何从 tokens 渲染成 html? 这里并没有提及,那么剩下的就只能从源码去看了。

分析源码

Lexer.js

根据文档,marked使用Lexer将markdown转换成tokens,则直接查看 Lexer.js,下图是Lexer的方法
image.png

其中获取Tokens分为了blockTokens/inlineTokens。根据方法名就能联想到分别是块级和行内的区别。由于我们想要新添加的表情属于行内,因此只需在inlineTokens中添加即可。

继续分析Lexer.js,inlineTokens方法的代码如下所示

inlineTokens(src, tokens = [], inLink = false, inRawBlock = false) {
    let token;

    while (src) {
      // escape
      if (token = this.tokenizer.escape(src)) {
        src = src.substring(token.raw.length);
        tokens.push(token);
        continue;
      }
      ......
    }
    return tokens;

src即为当前需要解析的字符串,然后当前方法循环解析字符串,并将字符串转换成token,之后保存在tokens中。
很容易可以看出,当前方法使用了一个tokenizer对象将字符串解析成Token对象。该对象存在于Tokenizer.js中

Tokenizer.js

直接查看Tokenizer.js,找到如下代码

...... 
	escape(src) {
    const cap = this.rules.inline.escape.exec(src);
    if (cap) {
      return {
        type: 'escape',
        raw: cap[0],
        text: escape(cap[1])
      };
    }
  }
......

在这个方法中,很明显使用了 exec() 方法,该方法会检索字符串中的正则表达式的匹配,返回一个数组。如果未能找到,则返回null。
所以此方法调用了rules对象,将字符串进行解析,然后返回一个数组。那么该对象内存的则应该是正则表达式。该对象存在于rules.js中,我们接着看rules.js。

rules.js

image.png
很明显,这里即为保存各种正则表达式的地方。
到这一步位置,将字符串转换为Tokens应该就很明确了。

那么,接下来的重点是,如果将Tokens渲染成html。前面也都没有看到有如何渲染的方法,那么,现在就需要我们找到调用Tokens的地方,根据代码,得到在marked.js中调用了Lexer.lex() 方法来获取Tokens。

marked.js

image.png
根据源码可以知道,在Parser.parse()中调用了tokens。

Parser.js

  parseInline(tokens, renderer) {
    renderer = renderer || this.renderer;
    let out = '',
      i,
      token;

    const l = tokens.length;
    for (i = 0; i < l; i++) {
      token = tokens[i];
      switch (token.type) {
        ......
        case 'escape': {
          out += renderer.text(token.text);
          break;
        }
      }
    }
    return out;
  }

parseInline即为行内的解析器。它按顺序循环Tokens,取出Token中保存的数据,而后调用renderer对象将token渲染成字符串并拼接起来。
renderer对象在Renderer.js中。

Renderer.js

......  
heading(text, level, raw, slugger) {
    if (this.options.headerIds) {
      return '<h'
        + level
        + ' id="'
        + this.options.headerPrefix
        + slugger.slug(raw)
        + '">'
        + text
        + '</h'
        + level
        + '>\n';
    }
    // ignore IDs
    return '<h' + level + '>' + text + '</h' + level + '>\n';
  }
......

很容易可以看出来,Renderer就是渲染字符串的地方。使用Parse中解析的值然后渲染成字符串并返回。
分析到这一步,整个思路就非常清楚了。

分析总结

经过上面的分析,可以得出marked的渲染步骤,共有如下几个步骤

  1. 编写正则表达式,用于将字符串解析成数组
// rules.js
escape: /^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,
  1. 使用正则表达式解析目标字符串,并转化成token
// Tokenizer.js
  escape(src) {
    const cap = this.rules.inline.escape.exec(src);
    if (cap) {
      return {
        type: 'escape',
        raw: cap[0],
        text: escape(cap[1])
      };
    }
  }
  1. 循环解析字符串,将其转换成tokens
// Lexer.js
token = this.tokenizer.escape(src)
// 添加至tokens中
tokens.push(token);
  1. 将tokens按照特定的格式,使用渲染器进行渲染
// Parser.js
out += renderer.text(token.text);
  1. 编写某个格式的HTML渲染
// Renderer.js
  br() {
    return this.options.xhtml ? '<br/>' : '<br>';
  }

根据以上思路,就可以立马开工添加表情了。甚至以后如果有其他东西也很方便进行扩展。

扩展

由于我们添加的表情属于行内元素,因此均只考虑行内代码。

bilibiliEmoji对应的markdown语法为: f(x)=∫(xxx)sec²xdx
textEmoji对应的markdown语法为:(⌒▽⌒) 
codeEmoji对应的markdown语法为: :xxx: 

其中xxx为表情的名字

编写正则

找到rules.js文件,在行内元素的对象中添加三条解析语句

// rules.js
// 正则表达式
const inline = {
  bilibiliEmoji: /^f\(x\)=∫\(([^A-Z]\w+?)\)sec²xdx/,
  textEmoji: /^`([^a-zA-Z]+?)`/,
  codeEmoji: /^:([^A-Z]\w+?):/
  ......
}

另外,还要确保不能将:,`以及f开头的字符串识别为文本,因此还需要改动一下text的正则表达式

const inline = {
  // 增加了!\[`:f*]
  text: /^(`+|[^`])(?:[\s\S]*?(?:(?=[\\<!\[`:f*]|\b_|$)|[^ ](?= {2,}\n))|(?= {2,}\n))/
}
// 如果开启了gfm,则还需要改动这个!\[`:f*]
inline.gfm = merge({}, inline.normal, {
  text: /^(`+|[^`])(?:[\s\S]*?(?:(?=[\\<!\[`:f*~]|\b_|https?:\/\/|ftp:\/\/|www\.|$)|[^ ](?= {2,}\n)|[^a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-](?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@))|(?= {2,}\n|[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@))/
});

转换字符串为Token

找到Tokenizer.js,在构造函数下新增三条转换语句

//  Tokenizer.js
bilibiliEmoji(src) {
    const cap = this.rules.inline.bilibiliEmoji.exec(src);
    if (cap) {
      if (cap[0].length > 1) {
        return {
          type: 'bilibiliEmoji',
          raw: cap[0],
          text: cap[1]
        };
      }
    }
  }

  textEmoji(src) {
    const cap = this.rules.inline.textEmoji.exec(src);
    if (cap) {
      if (cap[0].length > 1) {
        return {
          type: 'textEmoji',
          raw: cap[0],
          text: cap[1]
        };
      }
    }
  }

  codeEmoji(src) {
    const cap = this.rules.inline.codeEmoji.exec(src);
    if (cap) {
      if (cap[0].length > 1) {
        return {
          type: 'codeEmoji',
          raw: cap[0],
          text: cap[1]
        };
      }
    }
  }

将表情token纳入tokens中

在Lexer.js的inlineTokens中,判断字符串的类别并将token添加到tokens中去

// Lexer.js
  inlineTokens(src, tokens = [], inLink = false, inRawBlock = false) {
    let token;

    while (src) {
      // bilibili表情 f(x)=∫(xxx)sec²xdx
      if (token = this.tokenizer.bilibiliEmoji(src)) {
        src = src.substring(token.raw.length);
        if (token.type) {
          tokens.push(token);
        }
        continue;
      }

      // 文字表情
      if (token = this.tokenizer.textEmoji(src)) {
        src = src.substring(token.raw.length);
        if (token.type) {
          tokens.push(token);
        }
        continue;
      }

      // 帖吧表情/BBcodeEmoji
      if (token = this.tokenizer.codeEmoji(src)) {
        src = src.substring(token.raw.length);
        if (token.type) {
          tokens.push(token);
        }
        continue;
      }
      ......
    }
  }

使用tokens进行解析

在Parser.js中,循环tokens,并对每个token按照类型进行解析
之后将获取到的html片段拼接

// Parser.js
parseInline(tokens, renderer) {
    renderer = renderer || this.renderer;
    let out = '',
      i,
      token;

    const l = tokens.length;
    for (i = 0; i < l; i++) {
      token = tokens[i];
      switch (token.type) {
        case 'bilibiliEmoji': {
          out += renderer.bilibiliEmoji(token.text);
          break;
        }
        case 'textEmoji': {
          out += renderer.textEmoji(token.text);
          break;
        }
        case 'codeEmoji': {
          out += renderer.codeEmoji(token.text);
          break;
        }
        ......
      }
    }
  }

生成HTML片段

最后根据Parser.js中的解析,调用Renderer.js中renderer对象的方法渲染html片段

// Renderer.js
......
  bilibiliEmoji(text) {
    let href = text + '.png';
    href = cleanUrl(this.options.sanitize, this.options.bilibiliEmojiUrl, href);
    return '<span class="emotion-inline emotion-item">'
      + '<img src="'
      + href
      + '" class="img"></span>';
  }

  textEmoji(text) {
    return text;
  }

  codeEmoji(text) {
    let href = 'icon_' + text + '.gif';
    href = cleanUrl(this.options.sanitize, this.options.codeEmojiEmojiUrl, href);
    return '<img src="'
     + href
     + '" alt=":'
     + text
     + ':" class="smilies">';
  }
.....

增加默认配置

在默认的配置文件(defaults.js)中,新增表情的地址,这样可以保证之后可以随意切换表情资源所在的地址

// defaults.js
function getDefaults() {
  return {
    baseUrl: null,
    breaks: false,
    gfm: true,
    headerIds: true,
    headerPrefix: '',
    highlight: null,
    langPrefix: 'language-',
    mangle: true,
    pedantic: false,
    renderer: null,
    sanitize: false,
    sanitizer: null,
    silent: false,
    smartLists: false,
    smartypants: false,
    tokenizer: null,
    walkTokens: null,
    xhtml: false,
    // 新增的表情地址
    bilibiliEmojiUrl: '****',
    codeEmojiEmojiUrl: '****'
  };
}

至此,解析已经完成。然后就可以对源码进行打包了。

打包

先修改原有的package.json 中的配置为自己的,主要是name,desc,author,version
也可以不修改,看个人。然后执行如下命令

// 安装依赖
npm install
// 执行代码规范性检查(可选)
npm run test:lint
// 打包
npm run build

执行之后,生成的marked.min.js就可以直接使用了。
当然了,我这里需要再发布到npm上,那就需要再执行下面的语句,将代码发布

npm publish

至此,扩展就已经全部完成!

Q.E.D.

知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议

You got to put the past behind you before you can move on.