21時就寝、3時起床がデフォになりつつある、majirouです。
- marked.jsの字句解析器(lexer)と構文解析器(parser)を弄って、独自の記述方法を実装します。
- ザッカーバーグの「たぶん動くと思うからリリースしようぜ!」的精神で、結果が出ることを優先にしています。
- ソースコード中の
...
は前略、中略、以下略の意です。 - 実装内容は以下。
- textileのテーブル表記で
<table>
タグが生成される。 - rowspan,colspanに対応させる。
-
<th>
を縦のカラムに対応させる。
- textileのテーブル表記で
|_. 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なので、
block
とinline
に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
があり、この中で正規表現を使って、入力されたテキストを解析しています。-
exec
で判別 - その長さ分のテキストを切り出し
- 最終的に
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 ルール
- 字句解析のルールは以下のように、
block
とinline
が宣言されていて、これに独自のカスタマイズルールを追加して、字句解析時に引っ掛けます。 - 今回は、
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を直書きすべし。