Reheat
Link to my portfolio.
Link to my portfolio.

highlight.jsでdiffと言語を同時にシンタックスハイライトする

はじめに

本ブログはhighlight.jsでコードブロックにハイライトをつけていますが、highlight.jsにはdiffとプログラミング言語の両方のシンタックスハイライトを重ねがけすることができません。この重ねがけの機能はZennQiitaには実装されていますが、highlight.jsにはデフォルトでその機能がついていないようだったので自力で実装してみました。

参考

highlight.jsを使った実装の仕組みにおいてhighlightjs-code-diffを参考にさせていただきました。最初からこのライブラリ使えばいいんじゃない?と思いましたが実際使ってみると使っているテーマ的に差分がある部分の文字色が変わるだけだったため、+/-の記号だけdiffのハイライトになり、ほとんど言語のハイライトと同じになってしまったので独自実装する形にしました。

diffのハイライト

前提としてdiffのハイライトって何?という人用に軽く説明しときますと、Gitの差分のアレです。

変更前の行に-をつけて、変更後の行に+をつけます。

- git difff
+ git diff

highlight.jsを拡張する

highlight.jsのHLJSApiオブジェクトのラッパーを作る形でdiffと言語のハイライトができるように拡張します。 仕様としては変更前の行は+, 変更後の行は-, 変更のない行は空白が付いているものとし、この3種類をdiffMarkerと呼ぶこととします。また、重ねがけをする際には言語名をdiff-言語名とします。例えばdiff+typescriptなコードブロックをシンタックスハイライトする場合はdiff-typescriptを言語名としてHLJSApiオブジェクトに渡します。

処理の流れは大まかには2つのステップからなります。

  1. diffMarkerを削除したコード文字列に対して任意のプログラミング言語のハイライトを行う
  2. ハイライト済みのHTML文字列の各行を<span>で囲い、classにはdiffMarkerに応じたものを付ける

diffの要素を除外して言語のハイライトを行い、後付けでdiffのハイライトをする感じですね。テーマによらず行ごとハイライトするために、diffのハイライトを行うcssは自分で用意します。

diffMarkerを削除したコード文字列に対して任意のプログラミング言語のハイライトを行う

コードブロックで表示される文字列(コード文字列)が変数codeに入っているとします。

const code = `
-const add = (a, b) => a - b
+const add = (a, b) => a + b
 console.log(add(1, 2))
`

codeからdiffMarkerを取り除き、highlight.jsのhljs.highlight()を用いてシンタックスハイライトします。typescriptとしてハイライトして、それ以外の設定は変数optionsに入っているものとします。

// const options = ハイライト時の設定

// diffMarkerを取り除く
const diffLines = code.split('\n');
const codeWithoutDiffMarkers = diffLines.map((line) => line.slice(1)).join('\n'); 

// シンタックスハイライト
const result = hljs.highlight(codeWithoutDiffMarkers, { ...options, language });

result.valueにはハイライトされるよう処理が施されたHTML文字列が入っています。

<span class="hljs-keyword">const</span> <span class="hljs-title function_">add</span> = (<span class="hljs-params">a, b</span>) =&gt; a - b
<span class="hljs-keyword">const</span> <span class="hljs-title function_">add</span> = (<span class="hljs-params">a, b</span>) =&gt; a + b
<span class="hljs-variable language_">console</span>.<span class="hljs-title function_">log</span>(<span class="hljs-title function_">add</span>(<span class="hljs-number">1</span>, <span class="hljs-number">2</span>))

ハイライト済みのHTML文字列の各行を<span>で囲い、classにはdiffMarkerに応じたものを付ける

result.valueをdiffのハイライトがされるように以下の加工をします。

  • 取り除いたdiffMarkerを元に戻す
  • diffMarker+の行をaddition-line-highlightclassの<span>で囲む
    • -の場合はdeletion-line-highlight
  • diffMarker自体をaddition-markerclassの<span>で囲む
    • -の場合はdeletion-marker
const highlightedLines = result.value.split('\n');
const highlightedLinesWithDiffMarkers = highlightedLines.map((line, i) => {
  const diffMarker = diffLines[i].slice(0, 1);

  switch (diffMarker) {
    case '+':
      return `<span class="addition-line-highlight"><span class="addition-marker">${diffMarker}</span>${line}</span>`;
    case '-':
      return `<span class="deletion-line-highlight"><span class="deletion-marker">${diffMarker}</span>${line}</span>`;
    default:
      return `${diffMarker}${line}`;
  }
});

これでdiff用のハイライトが付くようになります。

HLJSApiオブジェクトのラッパーとして実装する

HLJSApiを拡張する形で実装します。ついでにちょっとしたバリデーションや、言語がdiff-から始まらない時は通常通りにハイライトするようにしたりします。

import { HighlightResult, HighlightOptions, HLJSApi } from 'highlight.js';
import './highlight-diff.css';

export function hljsDiffPlus(hljs: HLJSApi): HLJSApi {
  function isValidDiffCode(codeLines: string[]): boolean {
    const isValidDiffCode = codeLines
      .filter((line) => line.length >= 1)
      .every((line) => {
        const headChar = line[0];
        return headChar == ' ' || headChar == '+' || headChar == '-';
      });

    return isValidDiffCode;
  }

  function highlight(code: string, options: HighlightOptions): HighlightResult {
    const diffLangPrefix = 'diff-';

    if (!options.language.startsWith(diffLangPrefix)) {
      return hljs.highlight(code, options);
    }

    const language = options.language.slice(diffLangPrefix.length);
    const diffLines = code.split('\n');

    if (!isValidDiffCode(diffLines)) {
      console.warn('改行以外の文字を含む行は空白/+/-のいずれかで始まる必要があります。');
    }

    const codeWithoutDiffMarkers = diffLines.map((line) => line.slice(1)).join('\n');

    const result = hljs.highlight(codeWithoutDiffMarkers, { ...options, language });
    const highlightedLines = result.value.split('\n');

    if (highlightedLines.length !== diffLines.length) {
      console.warn(
        `正常にhighlightできませんでした。diffのみのhighlightを行います。input_lang: ${options.language}`,
      );
      return hljs.highlight(code, { ...options, language: 'diff' });
    }

    const highlightedLinesWithDiffMarkers = highlightedLines.map((line, i) => {
      const diffMarker = diffLines[i].slice(0, 1);

      switch (diffMarker) {
        case '+':
          return `<span class="addition-line-highlight"><span class="addition-marker">${diffMarker}</span>${line}</span>`;
        case '-':
          return `<span class="deletion-line-highlight"><span class="deletion-marker">${diffMarker}</span>${line}</span>`;
        default:
          return `${diffMarker}${line}`;
      }
    });

    result.code = code;
    result.value = highlightedLinesWithDiffMarkers.join('\n');
    result.language = options.language;

    return result;
  }

  return {
    ...hljs,
    highlight(
      codeOrLanguageName: string,
      optionsOrCode: string | HighlightOptions,
      ignoreIllegals?: boolean,
    ): HighlightResult {
      return typeof optionsOrCode === 'string'
        ? highlight(optionsOrCode, { language: codeOrLanguageName, ignoreIllegals })
        : highlight(codeOrLanguageName, optionsOrCode);
    },
  };
}

diff用のCSSを作成する

diff部分が行ごとハイライトされるようなCSSを作成します。色はテーマやコードブロックの背景色に合わせて適宜調整します。このCSSはhighlightDiffPlus.tsで読み込んでいます。

.addition-line-highlight {
  background-color: #2c4334;
  display: inline-block;
}
.addition-marker {
  color: #afa;
}

.deletion-line-highlight {
  background-color: #553335;
  display: inline-block;
}
.deletion-marker {
  color: #faa;
}

既存のHLJSApiオブジェクトと置き換えて使う

通常のhighlight.jsは以下のようにして使います。

import hljs from 'highlight.js';

hljs.highlight(コード文字列, { language: '言語名');

自作したhljsDiffPlus()を使って置き換えます。

import plainHljs from 'highlight.js';
import { hljsDiffPlus } from './highlightDiffPlus';

const hljs = hljsDiffPlus(plainHljs);
hljs.highlight(コード文字列, { language: '言語名');

これでdiffと言語の両方のシンタックスハイライトが効くようになりました!
実際に使ってみるとこんな感じです。

-import hljs from 'highlight.js';
+import plainHljs from 'highlight.js';
+import { hljsDiffPlus } from './highlightDiffPlus';

+const hljs = hljsDiffPlus(plainHljs);
 hljs.highlight(コード文字列, { language: '言語名');