highlight.jsでdiffと言語を同時にシンタックスハイライトする
はじめに
本ブログはhighlight.jsでコードブロックにハイライトをつけていますが、highlight.jsにはdiffとプログラミング言語の両方のシンタックスハイライトを重ねがけすることができません。この重ねがけの機能はZennやQiitaには実装されていますが、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つのステップからなります。
diffMarker
を削除したコード文字列に対して任意のプログラミング言語のハイライトを行う- ハイライト済みの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>) => a - b
<span class="hljs-keyword">const</span> <span class="hljs-title function_">add</span> = (<span class="hljs-params">a, b</span>) => 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-highlight
classの<span>
で囲む-
の場合はdeletion-line-highlight
diffMarker
自体をaddition-marker
classの<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: '言語名');