Yet Another SyntaxHighlighter

探してたわけではないのだけれど highlight.js とかいうのが軽いらしいのだ。
というわけで、SyntaxHighlighter から highlight.js への切り替えの顛末。


tl;dr

highlight.js

https://highlightjs.org/


気持ち ドキュメントがプア、かな、という気はしなくもない。

こんな感じで使う

<link rel="stylesheet" href="https://highlightjs.org/static/demo/styles/dracula.css">
<script src="//cdnjs.cloudflare.com/ajax/libs/highlight.js/8.4/highlight.min.js"></script>
<script>hljs.initHighlightingOnLoad();</script>

<pre><code class="hljs css">
.elem {
  font-size: 16px;
}
</code></pre>

HTML のエスケープが必要

SyntaxHighlighter は、pre で HTML のタグを囲っても、それなりに表示してくれたんだけど、highlight.js は text content を扱うだけ。

まあ、普通と言えば普通。

こうやれば良い、かな。


window.addEventListener("DOMContentLoaded", function() {
    Array.prototype.forEach.call(document.querySelectorAll("pre > code"), function(code) {
        var text = document.createTextNode(code.innerHTML);
        code.innerHTML = "";
        code.appendChild(text);
        hljs.highlightBlock(code);
    });
});

あー、でも pre > code の中で、開始・終了タグが一致していないと崩れちゃう。

SyntaxHighlighter は、どうやってるんだ?
  → 崩れるのは一緒だった :-)

予約語が Bold になるのがなあ

桁がずれちゃう。
あ、Bold でも幅が変わらないフォントを使うのか。


pre.hljs code {
    font-family: monospace;
}

行番号がつかない

作成者の信条だったら仕方ない。


別に用意した人がいるのな。
https://github.com/wcoder/highlightjs-line-numbers.js/


左側に float: left; な行番号用の要素を入れてる。

同じフォント、line-height を使うことで、コード部分とずれることが無い(はず)。

開始行番号は 1 に固定されてるのか。


このプラグインだと、行番号を文字で埋めてるけど、ol タグを使うという手もある。


こっちの方が長いな (´・ω・`)


これだと、start 属性で開始番号を変えられるとか思ったけど、どうせ javascript で要素を作るんだから変わんねえか。

行のハイライトもない

レンダリングされた code の中を見ると分かるけど、そもそも行単位のマーキングをしていない。
それが軽さにつながるんだろうし、行番号を導入しづらい理由でもある(いや、信条だった)。


こんな要素を行番号の前に入れると、行のハイライトもできる。




width: 100% だと、右にはみ出る。
body に margin-left が設定されていると、.marker の left も調整しなきゃいけない。

イマイチ (´・ω・`)

SyntaxHighlighter からの移行

  • pre の内側を code でくくる
  • brush: の指定を pre の class に移す(省略しても良さげ)

だけで移行できる。


とはいえ、いちいち手で直していくの面倒(たいして記事は書いてないけれど)。


これもスクリプトで対応だな。

  1. pre[class*="brush:"] で抽出
  2. html escape をするついでに、code 要素を間に挟む
  3. class の brush: から言語を取り出して、code のクラスに追加する

言語の指定は、変換しなくてもそのまま使えそう。
http://highlightjs.readthedocs.io/en/latest/css-classes-reference.html#language-names-and-aliases

タブの空白変換

configure() の tabReplace って、行頭のタブしか変換してくれないのな (´・ω・`)

fc2 のテンプレートエディタの問題

スクリプト中の正規表現にある \r\n が、テンプレートとしてそのままアップロードできない。
改行に変えられちゃう。


多分、テンプレートエディタだけじゃなくテキスト更新の全般の問題。

この記事をアップしたときも、記事中のスクリプトの "<" が "&lt;" に変換されて困った。
<script> </script> の中なのに :-|
しかも、全ての "<" が駄目なわけじゃなくて、あるひとつだけが。
意味わからん。


悩んでても仕方ないので、外部スクリプトにする。

fc2 のファイルアップロードは操作が面倒なので、置き場所は、とりあえず Gist 。

と思ったら、 Gist の RAW は、Content-Type を text/plain で返すので、スクリプトとして読み込めない。


http://qiita.com/cognitom/items/041b48d8cf746ab54f06
というわけで、githack を利用。


Gist の URL は、リビジョン(ハッシュ)がつくので、更新する度に URL が変わる。
こちらも、面倒っちゃあ面倒だったのだ。

というわけで

こんな感じに。

HTML
<link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/highlight.js/9.9.0/styles/agate.min.css">
<script src="//cdnjs.cloudflare.com/ajax/libs/highlight.js/9.9.0/highlight.min.js"></script>
<script src="https://gist.githack.com/a-kuma3/114172cfc6a78b66c42fe7e6379182b0/raw/811043179755d3929627c44edf735772c9a3fa1c/hljs_akuma3.js"></script>
javascript

window.addEventListener("DOMContentLoaded", function() {
    hljs.configure({
        tabReplace: '    ', // 4 spaces
    });
    
    function create_escaped_textnode(ele) {
        return document.createTextNode(
            ele.innerHTML
                .replace(/^[\r\n]+|[\r\n]$/g, "")
                .replace(/</g, "<")      // single "<" already escaped in innerHTML
                .replace(/>/g, ">")
            );
    }
    function hijs_escape_html(e) {
        if (e.dataset.notEscapeHtml === undefined) {
            var text = create_escaped_textnode(e);
            e.innerHTML = "";
            e.appendChild(text);
        }
        return e;
    }
    function hijs_migrate_syntaxhighlighter(e) {    // pre[class*="brush:"]
        var text = create_escaped_textnode(e);
        e.innerHTML = "";
        var code = document.createElement("code");
        code.appendChild(text);
        e.appendChild(code);
        var m = /brush:\s*(\w+)/.exec(e.className);
        if (m) {
            code.classList.add(m[1]);
        }
        m = /highlight:\s*\[([^\]]+)\]/.exec(e.className);
        if (m) {
            code.dataset.markerLine = m[1];
        }
        /* TODO:
            gutter
            first-line
        */
        return code;
    }
    function hijs_modify_line_number(code, start, marker) {
        var lines = code.textContent.split(/\r\n|\r|\n/).length;
        if (lines > 1) {
            var need_linenumber = start > 0;
            var need_marker = marker && marker.length > 0;
            var line_content = need_linenumber ?
                function(n) { return n + "."; } :
                function(n) { return ""; };
            var marker_map = {};
            if (need_marker) {
                marker.forEach(function(item) {
                    marker_map[item] = true;
                });
            }
            var r = code.parentNode.getBoundingClientRect();
            var marker_style = "width: " + (r.width - 6*2) + "px;";
            var i = need_linenumber ? start : 1;
            var nums = [];
            while (lines--) {
                var n = i++;
                var line = line_content(n);
                if (marker_map[n]) {
                    line = ' ' + line;
                }
                nums.push(line);
            }
            var e = document.createElement("code");
            e.className = "hljs hljs-line-numbers" + (need_linenumber ? "" : " only-marker");
            e.style.float = "left";
            e.style.textAlign = "right";
            e.innerHTML = nums.join("\n");
            code.parentNode.insertBefore(e, code);
        }
    }
    // line number
    function extract_attr(code, attr) {
        var lineNumber = code.dataset.lineNumber;
        if (lineNumber !== undefined) {
            attr.start_line = lineNumber === "" ? 1 : code.dataset.lineNumber;
        }
        var markerLine = code.dataset.markerLine;
        if (markerLine !== undefined) {
            attr.marker_line = markerLine.split(/[,\s]+/).reduce(function(a, i) {
                if (/^\d+$/.test(i)) {
                    a.push(parseInt(i,10));
                }
                return a;
            }, []);
            console.log(attr.marker_line);
        }
        return attr;
        /* TODO:
            check parent node (PRE) ?
        */
    }

    Array.prototype.forEach.call(document.querySelectorAll("pre > code"), function(code) {
        code = hijs_escape_html(code);
        code.parentNode.classList.add("hljs-container");
        hljs.highlightBlock(code);
    });

    // migration for SyntaxHighlighter
    Array.prototype.forEach.call(document.querySelectorAll('pre[class*="brush:"]'), function(code) {
        code = hijs_migrate_syntaxhighlighter(code);
        code.parentNode.classList.add("hljs-container");
        hljs.highlightBlock(code);
    });

    Array.prototype.forEach.call(document.querySelectorAll("pre > code"), function(code) {
        var attr = extract_attr(code, {});
        hijs_modify_line_number(code, attr.start_line, attr.marker_line);
    });
    /* TODO:
        copy code to clipboard
    */
});
CSS

pre.hljs-container {
    white-space: pre;
}
pre > code.hljs {
    font-family: Menlo, Consolas, 'DejaVu Sans Mono', monospace;
}
.hljs-line-numbers {
    border-right: 1px solid gray;
    padding-left: 1em;
}
.hljs-line-numbers.only-marker {
    padding-left: 0;
    padding-right: 0;
    border-right: none;
}
.hljs-line-numbers .marker {
    width: 100%;
    position: absolute;
    background-color: rgba(255,255,255,0.4);
    margin: 0 6px;
}

Gist にもアップしてある。
https://gist.github.com/a-kuma3/114172cfc6a78b66c42fe7e6379182b0

code タグで使える カスタム data 属性

data-line-number

行番号を表示する。
属性だけを指定するか、数値を指定。
数値を指定した場合には、その数値から行番号が始まる。

data-marker-line

マーカー行の指定。
カンマ区切りで行番号を指定する。

data-not-escape-html

HTML のエスケープをしない。
script や style を書くと そのまま動いちゃうので、手で HTML のエスケープをしなきゃいけないこともある。
その場合は、data-not-escape-html を指定して、code の中の記述をそのまま使う。

escape html

タグの一部と見なされない "<" や ">" があると、innerHTML で取得したときには実体参照の "&lt;" や "&gt;" になっている。
なので、実体参照を文字に直さなくちゃいけない。


    ele.innerHTML
        .replace(/^[\r\n]+|[\r\n]$/g, "")
        .replace(/</g, "<")      // single "<" already escaped in innerHTML
        .replace(/>/g, ">")
    );

ちなみに、最初の置き換えは pre に続く code の後にある改行が空行扱いされちゃうことの対処。

マーカー行

サイズの指定が無理やり感。
pre の幅が欲しいんだけど、間に code が挟まっているから width: inherit; は使えず。
width: 100%; はさらに上位の幅が撮れちゃう。

body に margin-left が指定してあると、left も指定しないと左位置がずれちゃう (´・ω・`)


HTML と CSS だけでどうにかならんものかな……

常に行番号が表示される : Android

カスタム data 属性がサポートされているものの、属性を指定していないときに undefined ではなくて空文字列が返される。

undefined との比較ではなく、hasAttribute メソッドを使って判定する。


//  if (code.dataset.lineNumber !== undefined) {
    if (code.hasAttribute("data-line-number")) {
        attr.start_line = code.dataset.lineNumber === "" ? 1 : code.dataset.lineNumber;
    }

コードのエリアが右端で折り返しになっている : Android

テンプレートの css で pre に指定されている word-wrap も打ち消さなくちゃいけない。


pre.hljs-container {
	white-space: pre;
	word-wrap: normal;	/* ← break-word */
}

マーカー行の改善

getBoundingClientRect() メソッドで幅を求めて、style に絶対値を直接指定してたのを、要素の配置とかを変えてスタイルシートだけでどうにかしてみた。


<pre>
    <code class="hljs-markers">     <-- 行番号とは別のエリアにする -->
        ...
        <span class="marker">&nbsp;</span>
        ...
    </code>
    <code class="hljs-line-numbers">
    </code>
    <code>
    </code>
</pre>


pre {
    position: relative;
}
.hljs-markers {
    display: block;
    position: absolute;
    width: 100%;
}
.marker {
    position: absolute;
    background-color: rgba(255, 180, 180, 0.20);
    left: 6px;
    right: 6px;
}
  • 親を static か relative にして
  • 子供を absolute にし
  • left と right を両方指定する
ことで、親の幅を基準にして幅を調整できる。 http://appstars.jp/archive/14

code 直後の改行の削除

html の escape とは別に、pre の内側の code タグの直後、直前にある改行を取り除く処理を関数化。 innerHTMLString.replace よりは、DOM で操作した方が速いような気がするのだ。

    function chomp(e) {
        var c = e.firstChild;
        if (c.nodeType == 3 && c.data.length > 0 && c.data[0] == "\n") {
            c.deleteData(0, 1);
        }
        c = e.lastChild;
        if (c.nodeType == 3 && c.data.length > 0 && c.data[c.data.length - 1] == "\n") {
            c.deleteData(c.data.length - 1, 1);
        }
    }
処理が重複しているように見えるので、改善するとこんな感じか。

    function chomp(e) {
        function remove_newline(node, ofs) {
            if (node.nodeType == 3 && node.data.length > 0) {   // 3 : Text
                if (ofs < 0) {
                    ofs = node.data.length - 1;
                }
                if (node.data[ofs] == "\n") {
                    node.deleteData(ofs, 1);
                }
            }
        }
        remove_newline(e.firstChild, 0);
        remove_newline(e.lastChild, -1);
    }
んー、ベタに書いた方が分かりやすい気がするし、多分、速くなるわけでもない。 却下。

後でやるかも

  • SyntaxHighlighter の gutter と first-line の migrate(使ってなかった気がする)
  • カスタム data 属性は、code だけじゃなくて、上位の pre についてても読み取る
  • コードをクリップボードにコピー
  • マーカー行のスタイル
  • not-escape-html で code 直後の改行が残る
  • Android の標準ブラウザで崩れる
    • 常に行番号が表示される
    • コードのエリアが右端で折り返しになっている

試してないやつ

Google Code Prettify

https://github.com/google/code-prettify
http://mae.chab.in/archives/2963
http://blog.s-giken.net/230.html
最初に試したのがこれ。

ちょろっと試してみたんだけど、なんか、うまく動かんかった。
highlight.js の方は素直に動いたので、こちらは見限った。

Prism.js

http://prismjs.com/
https://webtatan.com/blog/wordpress/prismjs
言語をあらかじめ選んでおかなくちゃいけない、というところにひっかかって、試してない。
プラグインは良いよね。


HTML エスケープが必要なら、別にこれじゃなくても、って感じ。 Unescaped Markup というプラグインで HTMLエスケープをしなくてもハイライトの対象にできる。

関連記事
スポンサーサイト

html javascript css

0 Comments

Leave a comment