【JavaScript】marked.jsを無理矢理拡張してオレオレパーサーを作る

最近例のクソアプリのマニュアルを書いているんですが、なんだかHTMLを書くのが面倒になってきました。

GitHub Pagesですし、jekyllを使えば比較的簡単にMarkdown記法でガリガリ書けるらしいんですけど、使い方を覚えるのが面倒です。また、Rubyの環境がないと書けないのもちょっと嫌です。

どうしようかなぁと色々探してみたらmarked.jsなるものを使えばとても簡単にMarkdownをHTMLに変換してくれると言うことを知り、じゃあ使ってみようか、となったわけです。

が、そのまま使うとなると色々と面倒なことがあるので、無理矢理拡張してオレオレパーサーにしてしまいました。

marked.jsの使い方

非常に簡単です。コード例を読むだけでも十分でしょう。

<!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>
    document.getElementById('content').innerHTML =
      marked('# Marked in browser\n\nRendered by **marked**.');
  </script>
</body>
</html>

marked.jsを読み込んだ後、適当なStringをmarked関数に渡せば変換してくれます。

もちろん適当なStringは適当なmdファイルとして別途保存しておき、XHRで読み込んでもいいでしょう。と言うか、HTMLに埋め込む方が辛いので、普通はそうすると思います。

問題点

とりあえず適当なページのHTMLを紹介しておきましょう。

<!DOCTYPE html PUBLIC "">
<html lang="ja">
<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="chrome=1">
    <meta name="description" content="Tumbling-dice.github.io : ">
    <link href="../stylesheets/stylesheet.css" rel="stylesheet" type="text/css" media="screen">
    <script src="../javascripts/jquery-1.11.1.min.js" type="text/javascript"></script>
    <script src="../javascripts/create-index.min.js" type="text/javascript"></script>
    <title>包丁で刺されるはたてちゃんアラーム マニュアル - Twitter連携アカウント管理画面(AccountListActivity)</title>
</head>
<body>
    <!-- HEADER -->
    <div class="outer" id="header_wrap">
        <header class="inner">
          <a id="forkme_banner" href="https://github.com/tumbling-dice/Hatate">View on GitHub</a>
          <h1 id="project_title"><a href="../index.html">包丁で刺されるはたてちゃんアラーム</a></h1>
          <h2 id="project_tagline">マニュアル - Twitter連携アカウント管理画面(AccountListActivity)</h2>
        </header>
    </div>
    <!-- MAIN CONTENT -->
    <div class="outer" id="main_content_wrap">
        <nav class="inner" id="sidebar">
            <ol id="index">
                <li><a href="#layout">レイアウト</a></li>
                <li><a href="#guide">利用者向けガイド</a>
                    <ol>
                        <li><a href="#guide-select">アカウント選択</a></li>
                        <li><a href="#guide-add">アカウント追加</a></li>
                        <li><a href="#guide-delete">アカウント削除</a></li>
                    </ol>
                </li>
                <li><a href="#summary">(開発者向け)機能概要</a></li>
                <li><a href="#source">(開発者向け)ソース</a></li>
            </ol>
        </nav>
        
        <section class="inner" id="main_content">
            
            <div id="layout"><h3><a name="layout" href="#layout" class="anchor"><span class="octicon octicon-link"></span></a>
            1.レイアウト</h3>
                <p style="margin-left: 1em;">TODO:stub</p>
            </div>

            <div id="guide"><h3><a name="guide" href="#guide" class="anchor"><span class="octicon octicon-link"></span></a>
            2.利用者向けガイド</h3>
                <div id="guide-select" style="margin-left: 1em;"><h4>2-1.アカウント選択</h4>
                    <p style="margin-left: 1em;">チェックボックスにチェックが入っているアカウントはすべてTwitter連携対象アカウントになります。</p>
                    <p style="margin-left: 1em;">連携対象からはずしたい場合はチェックをはずせばOKです。</p>
                </div>

                <div id="guide-add" style="margin-left: 1em;"><h4><a name="guide-add" href="#guide-add" class="anchor"><span class="octicon octicon-link"></span></a>
                2-2.アカウント追加</h4>
                    <p style="margin-left: 1em;">オプションメニューから「追加」を選択することで連携対象にしたいアカウントを追加することが出来ます。</p>
                </div>

                <div id="guide-delete" style="margin-left: 1em;"><h4><a name="guide-delete" href="#guide-delete" class="anchor"><span class="octicon octicon-link"></span></a>
                2-3.アカウント削除</h4>
                    <p style="margin-left: 1em;">もう使用したくないアカウントをロングタップすると削除確認ダイアログが表示されます。</p>
                    <p style="margin-left: 1em;">問題なければOKをクリックして削除してください。</p>
                </div>
            </div>

            <div id="summary"><h3><a name="summary" href="#summary" class="anchor"><span class="octicon octicon-link"></span></a>
            3.(開発者向け)機能概要</h3>
                <p style="margin-left: 1em;">TODO:stub</p>
            </div>
        
            <div id="source"><h3><a name="source" href="#source" class="anchor"><span class="octicon octicon-link"></span></a>
            4.(開発者向け)ソース</h3>
                <ul>
                    <li><a href="https://github.com/tumbling-dice/Hatate/blob/master/src/inujini_/hatate/AccountListActivity.java">AccountListActivity.java</a></li>
                    <li><a href="https://github.com/tumbling-dice/Hatate/blob/master/res/layout/activity_list.xml">activity_list.xml</a></li>
                </ul>
            </div>
        
        </section>
    </div>
    <!-- FOOTER  -->
    <div class="outer" id="footer_wrap">
        <footer class="inner">
        <p>Published with <a href="http://pages.github.com">GitHub Pages</a></p>
        <p id="last_update">Last Update: 2014/10/28</p>
      </footer>
    </div>
</body>
</html>

このHTMLをvue.jsを使って一種のテンプレート化してしまいます。

mdファイルの名前をクエリで渡すだけで使い回せるようにするためです。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset='utf-8'>
    <meta http-equiv="X-UA-Compatible" content="chrome=1">
    <meta name="description" content="Tumbling-dice.github.io : ">
    <link rel="stylesheet" type="text/css" media="screen" href="../stylesheets/stylesheet.css" />
    <script type="text/javascript" src="../javascripts/jquery-1.11.1.min.js"></script>
    <script type="text/javascript" src="../javascripts/vue.js"></script>
    <script type="text/javascript" src="../javascripts/marked.min.js"></script>
    <title>包丁で刺されるはたてちゃんアラーム マニュアル - {{title}}</title>
</head>
<body>
    <!-- HEADER -->
    <div id="header_wrap" class="outer">
        <header class="inner">
          <a id="forkme_banner" href="https://github.com/tumbling-dice/Hatate">View on GitHub</a>
          <h1 id="project_title"><a href="../index.html">包丁で刺されるはたてちゃんアラーム</a></h1>
          <h2 id="project_tagline">マニュアル - {{title}}</h2>
        </header>
    </div>
    <!-- MAIN CONTENT -->
    <div id="main_content_wrap" class="outer">
        <nav id="sidebar" class="inner" v-html="index"></nav>
        <section id="main_content" class="inner" v-html="content"></section>
    </div>
    <!-- FOOTER  -->
    <div id="footer_wrap" class="outer">
        <footer class="inner">
        <p>Published with <a href="http://pages.github.com">GitHub Pages</a></p>
        <p id="last_update">Last Update: {{lastUpdate}}</p>
      </footer>
    </div>
</body>
</html>

後はハイライトした部分の情報をMarkdown形式で記述すれば…と思ったんですが、ある部分はサイドバーに、またある部分は本文に、またある部分はタイトル、最終更新日として…と考えていくと、1ファイルに収めるのが非常に難しいことに気づきます。

かと言ってバカ正直に別ファイルに分けてしまうと管理が非常に面倒です。ここをどうにかしていきましょう。

Rendererの動作をオーバーライドする

marked.jsのオプションとして「Overriding renderer methods」なんてものが紹介されています。

Markdown記法をパースし、HTMLに変換する瞬間のメソッドを上書きできるようです。

// サーバサイドだとこれが必要
// var marked = require('marked');
var renderer = new marked.Renderer();

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 + '>';
},

console.log(marked('# heading+', { renderer: renderer }));
<h1>
  <a name="heading-" class="anchor" href="#heading-">
    <span class="header-link"></span>
  </a>
  heading+
</h1>

具体的にどのようなイベントをフックできるかは「Block level renderer methods」と「Inline level renderer methods」を見ておきましょう。

あくまでレンダリングのイベントなのでHTMLタグを自前で作らなきゃいけないんですが、デフォルトではどうやってるのかはソースを見ないとわかりません。

2014/10/30現在だと749行目あたりからRendererのprototypeを設定しています。その辺を掘っていけばいいでしょう。

これを上手く活用するとちゃんとしたMarkdown記法でありながら全然関係ないHTMLを返せるばかりか、特定のパターンの場合は一切レンダリングしないでテキストだけ受け取るなんてことも可能です。

タイトルでは「拡張」とか「パーサー」とか言ってますが、全くの嘘偽りであり、「marked.jsのRendererのメソッドをオーバーライドしてオレオレレンダリングを行う」が正しいです。

実際にやってみる

先ほどのHTMLの内容をMarkdown記法に書き換えました。

#Twitter連携アカウント管理画面(AccountListActivity)

<index>
1. [レイアウト](#layout)
2. [利用者向けガイド](#guide)
    1. [アカウント選択](#guide-select)
    2. [アカウント追加](#guide-add)
    3. [アカウント削除](#guide-delete)
3. [(開発者向け)機能概要](#summary)
4. [(開発者向け)ソース](#source)
</index>

##layout
TODO:stub

##guide

###guide-select
チェックボックスにチェックが入っているアカウントはすべてTwitter連携対象アカウントになります。

連携対象からはずしたい場合はチェックをはずせばOKです。

###guide-add
オプションメニューから「追加」を選択することで連携対象にしたいアカウントを追加することが出来ます。

###guide-delete
もう使用したくないアカウントをロングタップすると削除確認ダイアログが表示されます。

問題なければOKをクリックして削除してください。

##summary
TODO:stub

##source
* [AccountListActivity.java](https://github.com/tumbling-dice/Hatate/blob/master/src/inujini_/hatate/AccountListActivity.java)
* [activity_list.xml](https://github.com/tumbling-dice/Hatate/blob/master/res/layout/activity_list.xml)

[LastUpdate](2014/10/30)

実際にこれをパースしてViewModelを作ってみましょう。

$(document).ready(function () {
    // クエリからmdファイルの名前を取得する
    var query = window.location.search;
    query = query.substr(1, query.length);

    var queryItems = query.split("&");

    var fileName;

    for (var i = 0, length = queryItems.length; i < length; i++) {
        var item = queryItems[i].split("=");
        var key = item[0];

        if (key == "q") {
            fileName = item[1] ? window.decodeURIComponent(item[1]) : undefined;
            break;
        }
    }

    $.ajax({
        url: "./" + fileName + ".md"
    }).success(function (data) {

        var renderer = new marked.Renderer();

        var idx;
        renderer.html = function (html) {
            // indexタグがなかったらそのまま
            if (html.indexOf("<index>") == -1) {
                return html;
            }

            // indexタグで囲まれた部分をちゃんとパースする
            idx = marked(html.replace("<index>", "").replace("</index>", ""));
            
            // レンダリングする必要がなければ空文字を返せばOK
            return "";
        };

        var lastUpdate;
        var indexes = {};

        renderer.link = function (href, title, text) {
            // textが「LastUpdate」だったら最終更新日を取得する
            if (text == "LastUpdate") {
                lastUpdate = href;
                return "";
            }

            // hrefが#から始まる場合はナビゲーションなので、
            // 見出しを保存しておく
            if (href.substr(0, 1) == "#") {
                indexes[href] = text;
            }

            return "<a href=\"" + href + "\" title=\"" + title + "\">" + text + "</a>";
        };

        var projectTagline;

        renderer.heading = function (text, level) {
            switch (level) {
                case 1:
                    // 見出しレベルが1だったらタイトル
                    projectTagline = text;
                    return "";
                case 2:
                case 3:
                    // 見出しレベルが2〜3
                    // かつ
                    // ナビゲーションのhrefと同じtextだったら
                    // ナビゲーション用のアンカーを作成する
                    var hash = "#" + text;
                    if (indexes[hash]) {
                        // ナビゲーションのタイトルはハッシュから取得する
                        var idxTitle = indexes[hash];

                        var $header = "<h" + (level + 1) + ">";
                        $header = $header + "<a name=\"" + text + "\" href=\"" + hash + "\" class=\"anchor\">"
                                          + "<span class=\"octicon octicon-link\"></span></a>"
                                          + idxTitle
                                          + "</h" + (length + 1) + ">";

                        return $header;
                    }

                    return "<h" + level + ">" + text + "</h" + level + ">";

                default:
                    return "<h" + level + ">" + text + "</h" + level + ">";

            }
        };

        renderer.paragraph = function (text) {
            return "<p style=\"margin-left:1em;\">" + text + "</p>";
        };

        // markedの第三引数にcallbackを設定できる
        marked(data, { renderer: renderer }, function (err, content) {
            new Vue({
                el: "html",
                data: {
                    title: projectTagline,
                    index: idx,
                    content: content,
                    lastUpdate: lastUpdate
                }
            });
        });
    });
});

まとめ

とまぁ、こんな感じにRendererのメソッドを書き換えることでやりたい放題できます。

なるべくMarkdown記法内で完結できるようにすると楽ですが、どうにもならなかったら独自のタグを作ってRenderer.htmlで無理矢理変換してしまうのがオススメです。何も考えなくて済むので。

かと言って独自タグだらけにするとただのHTMLになるんですが…。

参考