5
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

marked.js本体をカスタマイズして、オレオレ変換ルールを追加する(今回はtextileのテーブル風)

Last updated at Posted at 2019-06-06

21時就寝、3時起床がデフォになりつつある、majirouです。

  • marked.jsの字句解析器(lexer)と構文解析器(parser)を弄って、独自の記述方法を実装します。
  • ザッカーバーグの「たぶん動くと思うからリリースしようぜ!」的精神で、結果が出ることを優先にしています。
  • ソースコード中の...は前略、中略、以下略の意です。
  • 実装内容は以下。
    • textileのテーブル表記で<table>タグが生成される。
    • rowspan,colspanに対応させる。
    • <th>を縦のカラムに対応させる。
|_. ABC|DEF|
|/2 GHI|JKL|
|\3 MNO|PQR|

と書くと、以下のように変換させたい。

<table>
 <tr>
   <th>ABC</th>
   <td>DEF</td>
 </tr>
 <tr>
   <td rowspan="2">GHI</td>
   <td>JKL</td>
 </tr>
 <tr>
   <td colspan="3">MNO</td>
   <td>PQR</td>
 </tr>
</table>

カスタマイズ結果

いつもの通り、まず結果から。

  • marked.jsに追記、改造した箇所の差分です
  • 5箇所ほどなので、各変更箇所にそれぞれ(1),...(5)として、記事内の参照印とします。
カスタマイズしたmarked.jsの差分
// (1)
var block = {
  ...
- text: /^[^\n]+/
+ text: /^[^\n]+/,
+ textileTable: noop
}

...

// (2)
block.tables = merge({}, block.gfm, {
  ...
  nptable: /^ *([^|\n ].*\|.*)\n *([-:]+ *\|[-| :]*)(?:\n((?:.*[^>\n ].*(?:\n|$))*)\n*|$)/,
- table: /^ *\|(.+)\n *\|?( *[-:]+[-| :]*)(?:\n((?: *[^>\n ].*(?:\n|$))*)\n*|$)/
+ table: /^ *\|(.+)\n *\|?( *[-:]+[-| :]*)(?:\n((?: *[^>\n ].*(?:\n|$))*)\n*|$)/,
+ textileTable: /^\|(((((_\.|\/[0-9]+|\\[0-9]+)?\s+).*|.+)\|)+\n)+/
});

...

// (3)
Lexer.prototype.token = function(src, top) {
  ...
  while (src) {
    ...
+    if (cap = this.rules.textileTable.exec(src)) {
+      src = src.substring(cap[0].length);
+      this.tokens.push({
+        type: 'textileTable',
+        text: cap[0]
+      });
+      continue;
+    }

...

// (4)
Renderer.prototype.tablecell = function(content, flags) {
  var type = flags.header ? 'th' : 'td';
-  var tag = flags.align
-    ? '<' + type + ' align="' + flags.align + '">'
-    : '<' + type + '>';
-  return tag + content + '</' + type + '>\n';
+  // rowspan, colspanを追記するため
+  var attr = [];
+  if (flags.align != null) {
+    attr.push(` align="${flags.align}"`)
+  }
+  if (flags.rowspan != null) {
+    attr.push(` rowspan="${flags.rowspan}"`)
+  }
+  if (flags.colspan != null) {
+    attr.push(` colspan="${flags.colspan}"`)
+  }
+  return '<' + type + attr.join('') + '>'+ content + '</' + type + '>\n';
};

...

// (5)
Parser.prototype.tok = function() {
  switch (this.token.type) {
    ...
+   case 'textileTable': {
+     var body = '',
+         i,
+         ii,
+         rows = this.token.text.split('\n'),
+         cols,
+         tempCols = '',
+         max,
+         regexpTh = /^_\. +.*/ ,
+         regexpRowspan = /^\/[0-9]+ +.*/ ,
+         regexpColspan = /^\\[0-9]+ +.*/ ,
+         temp
+         ;
+     for (i = 0; i < rows.length; i++) {
+       cols = rows[i].split('|')
+       max = cols.length-1
+       for (ii = 1; ii < max; ii++) {
+         if (regexpTh.test(cols[ii])) {
+           tempCols += this.renderer.tablecell(
+             cols[ii].replace(/^_\. /,''),
+             { header: true }
+           )
+         } else if(regexpRowspan.exec(cols[ii])) {
+           temp = cols[ii].split(' ')
+           const rowspan = temp.shift().substr(1)
+           tempCols += this.renderer.tablecell(
+             temp.join(''),
+             { header: false , rowspan }
+           )
+         } else if(regexpColspan.exec(cols[ii])) {
+           temp = cols[ii].split(' ')
+           const colspan = temp.shift().substr(1)
+           tempCols += this.renderer.tablecell(
+             temp.join(''),
+             { header: false , colspan }
+           )
+         } else {
+           // 普通のtd
+           tempCols += this.renderer.tablecell(cols[ii], { header: false })
+         }
+       }
+       body += this.renderer.tablerow(tempCols)
+       tempCols = ''
+     }
+     return '<table>' + body + '</table>'
+   }

...

経緯

  • 記事管理をするために、Markdownで編集するウェブアプリを実装してほしい
  • でも、テーブルは、セル結合したいから『textile』風に書きたい

という要望を受けました。

marked.js customizeなどで検索すると、該当記事がちらほら出てきますが、marked.jsに用意されているルールをオーバーライドする方法が紹介されています。

公式 でも「独自ルール追加なら、オーバーライドで!」って書いてありますが、今回やりたいのはそれではありません。以下は公式のオーバーライドの例。

Example: Overriding default heading token by adding an embedded anchor tag like on GitHub.

// Create reference instance
var myMarked = require('marked');

// Get reference
var renderer = new myMarked.Renderer();

// Override function
renderer.heading = function (text, level) {
  var escapedText = text.toLowerCase().replace(/[^\w]+/g, '-');

  return `
          <h${level}>
            <a name="${escapedText}" class="anchor" href="#${escapedText}">
              <span class="header-link"></span>
            </a>
            ${text}
          </h${level}>`;
};

// Run marked
console.log(myMarked('# heading+', { renderer: renderer }));

カスタマイズ説明

まずは、ざっくり何をしているかを掴むため全体像を把握します。

  • メイン処理marked関数がメイン処理と思われます。
  • 解析処理: 大きく分けて、字句解析Lexerと構文解析Parserがあり、入力テキストを解析(分類)していきます。
  • レベルとルール: htmlなので、blockinlineに2レベルに分けられ、さらにその中に、「この構文なら、こういうHTMLを書き出す」といったルールを定義をし、処理を行うようです。
  • HTML変換Rendererにて、上記レベル配下の定義に沿って、変換していくようです。

marked関数

  • lexerで字句解析のルールになるトークンを取得。
  • それを元にparse処理をするdone関数を定義し、実行。
  • コードをハイライト化するためにいろいろ分岐していますが、今回は割愛。
  • ソースを見る限り、lexer,parserそれぞれを見様見真似でカスタマイズできそうとあたりをつける。
function marked(src, opt, callback) {
    ...
    try {
      tokens = Lexer.lex(src, opt);
    } catch (e) {
      return callback(e);
    }
    ...
    var done = function(err) {
      ...
      try {
        out = Parser.parse(tokens, opt);
      } catch (e) {
        err = e;
      }

      opt.highlight = highlight;

      return err
        ? callback(err)
        : callback(null, out);
    };
...

Lexer

  • Lexer関数にて、字句解析処理のLexer.prototype.tokenがあり、この中で正規表現を使って、入力されたテキストを解析しています。
    1. execで判別
    2. その長さ分のテキストを切り出し
    3. 最終的にtokens配列に追加

      この連想配列の要素は、MD→HTMLのレンダリング時に使用します。typeはその際の分岐で使うので必須
  • カスタマイズ箇所は(3)が該当し、textileのtable表記のテキストをtype: 'textileTable'として、tokensに追加します。
Lexer.prototype.token = function(src, top) {
  ...
    // heading
    if (cap = this.rules.heading.exec(src)) {
      src = src.substring(cap[0].length);
      this.tokens.push({
        type: 'heading',
        depth: cap[1].length,
        text: cap[2]
      });
      continue;
    }
    ...
    // (3)
    // textile table
    if (cap = this.rules.textileTable.exec(src)) {
      src = src.substring(cap[0].length);
      this.tokens.push({
        type: 'textileTable',
        text: cap[0]
      });
      continue;
    }

Lexer ルール

  • 字句解析のルールは以下のように、blockinlineが宣言されていて、これに独自のカスタマイズルールを追加して、字句解析時に引っ掛けます。
  • 今回は、tableを真似るので、blockレベルに対して追加します。
  • カスタマイズ箇所は(1)で、textileTableとし、table同様のnoopを指定しました。
    • noop自体はfunction noop() {}と定義されており、その名の通り何もしないことになります。
/**
 * Block-Level Grammar
 */
var block { 
  ...
  heading: /^ *(#{1,6}) *([^\n]+?) *(?:#+ *)?(?:\n+|$)/,
  ...
  table: noop,
  ...
  // (1)
  textileTable: noop
};

...

/**
 * Expose Block Rules
 */

Lexer.rules = block;

...

Parser

  • 構文解析を行い、それに対応した処理を行わせるようで、switch文で分岐しています。
  • カスタマイズ箇所は(5)で、case 'textileTable'時に、tableタブを作成するようにしています。
    • _.始まりなら<th>にする
    • /始まりならそのあとの数値分rowspanを指定する。
      など力技で処理しています。あとは、割愛。
  • (あとで気づいたこと) 本来、Renderサイドでやるタグ生成処理を、このパーサー部で力技として、やってました。
Parser.prototype.tok = function() {
  switch (this.token.type) {
    case 'space': {
      return '';
    }
    ...
    // (5)
    case 'textileTable': {
      ...
    }

Renderer

  • tablecellレンダーが、colspan,rowspanに対応していいので改造。
  • カスタマイズ箇所は(4)になります。
Renderer.prototype.tablecell = function(content, flags) {
  var type = flags.header ? 'th' : 'td';
  // var tag = flags.align
  //   ? '<' + type + ' align="' + flags.align + '">'
  //   : '<' + type + '>';
  // return tag + content + '</' + type + '>\n';

  var attr = [];
  if (flags.align != null) {
    attr.push(` align="${flags.align}"`)
  }
  if (flags.rowspan != null) {
    attr.push(` rowspan="${flags.rowspan}"`)
  }
  if (flags.colspan != null) {
    attr.push(` colspan="${flags.colspan}"`)
  }
  return '<' + type + attr.join('') + '>'+ content + '</' + type + '>\n';
};

感想

  • markdown楽なんですが、今回のようにテーブルを結合したいとか、ヘッダーを縦一列にしたいという場合、textileの方が融通が効くなぁ〜という印象。
  • 大学の授業で、コンパイラとか作った時以来で久しぶりにlexerという単語に出くわし、「(詳細は覚えてないけど)過去の勉強した」という経験は大事だなと再認識。
  • 正規表現さえあれば、力技で基本どうにかなる。
  • できなかったら、htmlを直書きすべし。
5
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?