コハム

Webクリエイターのコハムが提供する、Web制作に役立つ情報をまとめたブログです。

シンタックスハイライトをライブラリなしで簡単に!Custom Highlight APIの魅力

Syntax Highlighting code snippets with Prism and the Custom Highlight API

記事は上記記事を意訳したものです。 ※当ブログでの翻訳記事は元サイト様に許可を得て掲載しています。


ウェブ上の構文ハイライトの一般的な仕組みは、すべてのトークンを要素で囲み、適切なクラスを割り当て、CSSを使って色付けすることです。

CSS Custom Highlight APIのおかげで、DOMツリーにを散りばめてカラー情報を追加するステップを省略できます。

Custom Highlight APIの基礎

CSS Custom Highlight APIは、スクリプトで特定した任意の範囲をスタイリングするメカニズムを提供します。

カスタムハイライトはHighlightオブジェクトで表されます。これらのオブジェクトは1つ以上のRangeを保持しており、テキストのどの部分をハイライトするかを特定します。最後に、これらのHighlightをハイライトレジストリに登録する必要があり、そうするとCSSからスタイリングできるようになります。

コードで表すと以下のようになります。

// Highlightを作成
const h = new Highlight();

// HighlightにRangeを追加
const r1 = new Range();
r1.setStart(text.firstChild, 13); 
r1.setEnd(text.firstChild, 28);
h.add(r1);

const r2 = new Range();  
r2.setStart(text.firstChild, 38);
r2.setEnd(text.firstChild, 56); 
h.add(r2);

// ハイライトをレジストリに登録
// これでCSSの::highlight()が使えるようになる
CSS.highlights.set('example', h);

最後の行で::highlight(example)がCSSで使えるようになり、ハイライトをスタイリングできます。

::highlight(example) {
    color: hotpink; 
}

このコードの動作は、このテクニカルデモで確認できます。13-28文字目と38-56文字目がホットピンク色になるのは、それらの範囲がexampleというカスタムハイライトに追加されているからです。

See the Pen CSS Custom Highlight API Example by Bramus (@bramus)on CodePen.

CSS.highlights.setを使えば、名前が異なれば複数のハイライトを登録できます。1つのRangeを複数のハイライトに追加でき、Rangeも重複可能です。

カスタムハイライトAPIの典型的な使用例は、テキスト入力時の検索結果をハイライトすることです。Jen Simmonsによる以下のデモがこれを示しています。

See the Pen Custom Highlights demo by Jen Simmons (@jensimmons)on CodePen.

この APIの詳細は、「Getting started with the CSS Custom Highlight API」を読んでください。

ブラウザサポート

CSS Custom Highlight APIのブラウザサポート状況は以下の通りです。

Chrome (Blink) Chrome 105から対応

Firefox (Gecko) Firefox Nightlyで実験的サポート

Safari (WebKit) Safari 17.2から対応

以下に埋め込んだPenで、現在使用中のブラウザがCustom Highlight APIをサポートしているかどうかを確認できます。

See the Pen CSS Custom Highlight API Support Test by Bramus (@bramus) on CodePen.

ブラウザサポートの最新情報は、以下の追跡Issue を参照してください。

Chrome (Blink): Issue #40163546 — 修正済み

Firefox (Gecko): Issue #1703961 — 割り当て済み

Safari (WebKit): Issue #204903 — 解決済み・修正済み

静的コードスニペットの構文ハイライト

最近Mountain Viewで開催されたCSSワーキンググループF2F会議の会話の中で、Emilio(Firefox)とTab(Google)がカスタムハイライトAPIを使った構文ハイライトのアイデアを出しました。

その考えを試してみるため、このCodePenを作成しました。Penは自身がデモになっており、ページ上に表示されているCSSとJavaScriptが実行され、構文がハイライトされます。

See the Pen Syntax highlighting code blocks with Prism and the Custom Highlight API by Bramus (@bramus) on CodePen.

仕組み

この デモの中核には3つのステップがあります。

  1. すべての可能なトークンタイプのカスタムハイライトを登録する
  2. コードをトークン化する
  3. 抽出したトークンを関連するカスタムハイライトに関連付ける

ここでキーになるのが、トークン化のステップです。トークン化は、入力文字列を取り込み、言語が理解できる最小の個々の部分(トークン)を特定するプロセスです。

例えば、const name = "Bramus"; をJavaScriptとしてトークン化すると、以下のようになります(空白は無視)。

  • const
  • name
  • =
  • "Bramus"
  • ;

これらのトークンはそれぞれJavaScriptの字句構造の中の特定のタイプに属します。例えばconstトークンはキーワードと呼ばれます。

ステップ1:セットアップ

デモではJavaScriptとCSS、つまり構文ハイライトが必要な2つの言語について、すべてのトークンタイプに対して::highlightの疑似要素をセットアップしています。

/* From prism.css */
::highlight(parameter) {
    color: #1a1a1a;
}

::highlight(comment), ::highlight(prolog), ::highlight(doctype), ::highlight(cdata) {
    color: slategray;
}

::highlight(punctuation) {
    color: #999;
}

::highlight(property), ::highlight(tag), ::highlight(boolean), ::highlight(number), ::highlight(constant), ::highlight(symbol), ::highlight(deleted), ::highlight(class-name) {
    color: #905;
}

::highlight(selector), ::highlight(attr-name), ::highlight(string), ::highlight(char), ::highlight(builtin), ::highlight(inserted) {
    color: #690;
}

::highlight(operator), ::highlight(entity), ::highlight(url) {
    color: #a67f59;
    background: hsla(0, 0%, 100%, 0.5);
}

::highlight(atrule), ::highlight(attr-value), ::highlight(keyword) {
    color: #07a;
}

::highlight(function) {
    color: #dd4a68;
}

::highlight(regex), ::highlight(important), ::highlight(variable) {
    color: #e90;
}

::highlight(important), ::highlight(bold) {
    font-weight: bold;
}

::highlight(italic) {
    font-style: italic;
}

::highlight(entity) {
    cursor: help;
}

これらのスタイルはPrism.jsから借用したもので、ステップ2でトークン化に使用されます。

CSSだけではカスタムハイライトが使えないので、トークンタイプごとに1つのハイライトを登録する必要があります。

const tokenTypes = ['comment', 'prolog', 'doctype', 'cdata', 'punctuation', 'namespace', 
    'property', 'tag', 'boolean', 'number', 'constant', 'symbol', 'deleted',
    'selector', 'attr', 'string', 'char', 'builtin', 'inserted', 'operator',
    'entity', 'url', 'string', 'atrule', 'attr', 'keyword', 'function', 'class',
    'regex', 'important', 'variable', 'important', 'bold', 'italic', 'entity',
    'parameter', 'class-name'];

tokenTypes.forEach(tokenType => {
    CSS.highlights.set(tokenType, new Highlight());
});

ステップ2:コードのトークン化

トークン化にはすばらしいPrism.jsを使用しています。トークナイザーのみが必要なので、Prism.jsはロード時に独自の処理を行わないmanualモードでロードされています。

<script src="prism.js" data-manual></script>

manualモードでPrism.jsをロードすると、必要に応じてトークナイザーを実行できます。

// インラインのscriptとstyleブロックを全て取得
const codeBlocks = document.querySelectorAll('script[visible], style[visible]');

// 1つずつループ処理
for (const codeBlock of codeBlocks) {

    // トークン化
    // STYLEの場合はCSSとしてトークン化、それ以外はJavaScriptとみなす
    let tokens = Prism.tokenize(
        codeBlock.innerText,
        codeBlock.tagName == 'STYLE' ? Prism.languages.css : Prism.languages.javascript
    );

    // ...
}

Prism.tokenizeを呼ぶと、どのトークンがどこにあるかの情報を持つトークンのリストが返されます。

例:

[
    {
        "type": "keyword",
        "content": "const",
        "length": 5
    },
    " name ",
    {
        "type": "operator",
        "content": "=", 
        "length": 1
    },
    " ",
    {
        "type": "string",
        "content": "\"Bramus\"",
        "length": 8
    },
    {
        "type": "punctuation", 
        "content": ";",
        "length": 1
    }
]

ステップ3:トークンとハイライトの関連付け

抽出したトークンを関連するハイライトに関連付けるには、元のソースコード内でそのトークンの開始位置と終了位置を特定する必要があります。

// コード内の位置 
let pos = 0;

// すべてのトークンをループ
for (const token of tokens) {
    if (token.type) {
        // 現在のトークンの新しいRangeを作成
        const range = new Range();
        range.setStart(codeBlock.firstChild, pos);
        range.setEnd(codeBlock.firstChild, pos + token.length);

        // 登録済みのハイライトにRangeを追加
        CSS.highlights.get(token.alias ?? token.type)?.add(range);
    }

    // 位置を更新
    pos += token.length;
}

以上で、CSS Custom Highlight APIを使ってコードスニペットの構文がハイライトされました🙂

Custom Highlight APIの欠点

残念ながら、Custom Highlight APIにはいくつかの制限があり、実装上の問題もあります。

~

制限されたスタイリングオプション

通常のハイライトと同様に、ハイライトのスタイリングでは、レイアウトに影響を与えない一部のCSSプロパティしか使えません。許可されているプロパティはcolor、background-color、text-decoration、text-shadow、stroke-color/fill-color/stroke-widthなどです。

つまり、コードスニペットのハイライトで一般的な太字やイタリック体には対応していません。CSS WGでは、w3c/csswg-drafts#8355が上げられ、より多くのスタイルを許可するかどうかが議論されています。

textareaに対応していない

Custom Highlightsはtextareaでは動作しません。これが仕様の制限なのか実装上のバグなのかは分かりませんでした。この件については、CSS WGにw3c/csswg-drafts#9971を上げて議論する予定です。

☝️ この問題を回避する方法としては、[contenteditable]をハイライトすることです。後ほど「[contenteditable]コードスニペットのオンザフライ構文ハイライト」で詳しく説明します。

ポインターイベントに対応していない(まだ)

Custom Highlightsにはポインターイベントがありません。つまり、例えばカスタムハイライトの上にマウスオーバーした時に、そのハイライト部分にツールチップを表示することができません。

CSS Working Groupでは、CSS.highlights.highlightsFromPoint(x, y)の追加が決定され、将来的にこの機能が使えるようになる予定です。使い方は次のようになります。

document.addEventListener("click", function(e) {
    if (e.defaultPrevented) {
        return;
    }

    for (let highlight of CSS.highlights.highlightsFromPoint(e.clientX, e.clientY)) {
        highlight.dispatchEvent(e);
        if (e.defaultPrevented) return;
    }
});

現時点での問題点は、この機能がまだ初期段階にあり、仕様やテスト、そして何より実装がないことです。

(Chrome/Firefoxのバグ) テキスト選択時にカラー情報が失われる

ChromeとFirefoxの両方で、カスタムハイライトされたテキストを選択するとカラー情報が失われるバグがあります。Safariには影響がありません。

選択時のChrome 122の動作

選択時のSafari 17.4の動作

Chromeに関しては、CrBug 325442893が報告されています。

(Chrome/Safariのバグ) 多数のハイライトでパフォーマンス低下

jQuery ソースコードなど大量のコードをカスタムハイライトでハイライトすると、ブラウザのパフォーマンスが低下します。jQuery 3.7.1のミニファイされていないソースコード(63,338トークン)をハイライトすると、スクロール時のパフォーマンスが大幅に低下します。

この問題はChromeとSafariで発生しますが、Firefoxはうまく対処できているようです。Chrome 122のパフォーマンスが最も悪く、現在の開発版Chrome 124ではかなり改善されています。

Chromeに関してはCrBug 325589486を報告済みです。

一般的には、カスタムハイライトAPIはDOM木の肥大化に比べてパフォーマンスが優れています。ただし、一定数のトークンを超えると従来の手法の方が優れる場合があります。その境界値の正確な数値は分かりません。自由にテストして教えてください。

[contenteditable]コードスニペットのオンザフライ構文ハイライト

textareaではカスタムハイライトAPIが使えないため、[contenteditable]の<pre>要素を使ってオンザフライの構文ハイライト エディターを作れないか検討しました。

その答えは以下のデモが示す通りイエスです。入力するたびに現在のハイライトがクリアされ、コードが再トークン化され、新しいハイライトが適用されます。

長い答えとなりますが、このエディターの作成には[contenteditable]自体が引き起こすいくつかの問題がありました。

まず最初にしたことは、[contenteditable]がデフォルトで備える、CMD+Bで太字にするなどのリッチテキスト編集機能を無効化することでした。これは[contenteditable]の値をplaintext-onlyに設定すれば簡単に無効化できます。Firefoxはこの値に対応していないため、[contenteditable=true]でも対応しています。

// 編集可能なpre要素
const codeBlock = document.querySelectorAll('pre[contenteditable]');

// プレーンテキスト編集のみ許可
// Firefoxは'plaintext-only'に対応していないが'true'には対応
codeBlock.setAttribute('contenteditable', 'plaintext-only');

if (codeBlock.contentEditable != 'plaintext-only') {
codeBlock.setAttribute('contenteditable', 'true');
}

最大の課題は、[contenteditable="plaintext-only"]でも時折HTMLの改行が入ったり、リターンキーを押すと<pre>要素内に新しいテキストノードが作成されてしまうことでした。

これに対処するため、トークン化のステップ前にテキストノードを1つにまとめる処理を追加する必要がありました。これには、カーソル位置を維持するための追加ロジックも必要でした。

// 要素内のキャレット位置を取得するヘルパー関数
// 子要素が複数のテキストノードの場合でも対応
const getCaretPosition = (el) => {
    const selectionInfo = window.getSelection(el);
    let node = selectionInfo.anchorNode;
    let pos = selectionInfo.anchorOffset;
    
    
    
    // 現在のテキストノードの位置のみなので、前のノードの長さを足す必要がある 
    // TODO: テキストノードのみをループするよう修正が必要かも
    while (node.previousSibling) {
        pos += node.previousSibling.length;
        node = node.previousSibling;
    }
    
    return pos;
    }
    
    // 要素内のテキストノードを1つにまとめるヘルパー関数
    
    const flattenTextNodes = (codeBlock) => {
    if (codeBlock.childNodes.length > 1) {
    // 現在のキャレット位置を記録
    const caretPosition = getCaretPosition(codeBlock);
    
        // 最初の子要素にテキスト全体を設定
        codeBlock.firstChild.textContent = codeBlock.firstChild.wholeText;
    
        // 他のテキストノードを全て削除
        let node = codeBlock.firstChild;
        while (node.nextSibling) {
            codeBlock.removeChild(node.nextSibling);
        }
    
        // キャレット位置を復元
        // TODO: 最終行の全てを選択し、バックスペースを押すと
        // 位置0に戻ってしまうバグがある
        window.getSelection(codeBlock).setPosition(codeBlock.firstChild, caretPosition);
    }
}
   

この flattenTextNodes ヘルパー関数は、コードのトークン化直前に呼ばれます。目的は達成できましたが、行を追加する際にフラッシュが発生するという副作用があります。

ハイライトするコードは以下のようになります。

const highlight = (codeBlock, lang = Prism.languages.javascript) => {
    // [contenteditable]には1つの子テキストノードのみを持つ必要がある

    // そうしないとハイライトの範囲が外れてしまう可能性がある
    flattenTextNodes(codeBlock);



    // コードをトークン化
    let tokens = Prism.tokenize(
        codeBlock.innerText,
        lang
    );

    // 現在のハイライトをクリア
    tokenTypes.forEach(tokenType => {
        CSS.highlights.get(tokenType).clear();
    });

    // すべてのトークンハイライトを描画
    paintTokenHighlights(codeBlock, tokens);
}

//補足として、TABキーを実際にTABキャラクタを挿入するよう少しJSを追加しました。

codeBlock.addEventListener('keydown', e => {
    // Tabキーでタブキャラクタを挿入
    if (e.keyCode == 9) {
        document.execCommand('insertHTML', false, '   ');
        e.preventDefault();
    }
});

終わりに

私はカスタムハイライトAPIに非常に興奮しており、可能になることに期待を持っています。ただし、現在の課題が解決され、あらゆる状況で使えるようになることを願っています。[contenteditable]自体にも手を加える必要がありそうです。

©コハム