Highlight.js でシンタックスハイライト

プログラムのコードをキーワードや構文などにより色分けして表示するシンタックスハイライト機能で定評のある Highlight.js の使い方の解説のような覚書です。

基本的な使い方はとても簡単で、ファイルをダウンロードして(または CDN 経由で)読み込み、初期化の記述をするだけです。

Prism.js のように多くのプラグインは用意されていませんが、独自にプラグインを設定するための API も公開されていて、比較的簡単にカスタマイズが可能です。テーマが多数あるのも特徴です。

また、現在も開発が進められていて継続的に更新されています。

作成日:2023年12月28日

[追記]2024/02/26 行のハイライト表示のコードに問題があったので修正しました。

関連ページ:

Highlight.js

Highlight.js はシンタックスハイライト用のプラグイン(ライブラリ)で、この時点でのバージョンは 11.9.0(2023/10/9 リリース)です。

Highlight.js は、CDN 経由、ダウンロード、ES6 モジュール(npm インストール)、Vue プラグインなど、さまざまな方法で使用できます。

基本的な使い方はいずれかの方法で Highlight.js の JavaScript と CSS(テーマのスタイル)を読み込み、hljs オブジェクトの highlightAll() メソッドを呼び出して初期化するだけです。

<link rel="stylesheet" href="/path/to/styles/default.min.css">
<script src="/path/to/highlight.min.js"></script>
<script>hljs.highlightAll();</script><!-- Highlight.js の初期化 -->

これにより、ページの <pre><code></code></pre> タグ内のコードが検索されてハイライト表示されます。Highlight.js は言語を自動的に検出しようとします。

自動検出が正しく機能しない場合、または明示的に言語を指定したい場合は、class 属性に language-xxxx のようなクラス(xxxx は言語名)を設定して言語を指定することができます。

上記のコードのハイライト表示は atom-one-dark というテーマを使っています。

Examples ページ

どのような言語がどのようなスタイルで表示されるかは以下の Examples ページで確認できます。

https://highlightjs.org/examples

Examples ページでは左側のプルダウンの Language Category から言語を、Theme からテーマ(スタイル)を選択してどのように表示されるかを確認できます。

Language Category で Common (36) を選択すると、よく使われる36の言語の表示を確認できます。

テーマは多数あります。例えば、vs2015 というテーマは VSCode のようなスタイルで表示されます。

CDN を利用

よく使われる言語がバンドルされたバージョンが、cdnjs などの CDN によってホストされています。CDN 経由で Highlight.js を使用する場合、セキュリティのために SRI を使用できます。

Fetch via CDN

以下は cdnjs の CDN を利用する例です。

Highlight.js のリンクがリストされた cdnjs のページにアクセスします。

ページにアクセスすると以下が表示されるので、highlight.min.js を読み込む script タグとテーマの CSS を読み込む link タグをコピーします。

目的のファイルの右側の </> のアイコンをクリックすると SRI 用の integrity 属性やその他の必要な属性を含む読み込み用のタグのコードをコピーすることができます。

Asset Type で Styling を選択すると、テーマのみを表示できます。この例では atom-one-dark.min.css という CSS(テーマ)を利用します。

コピーした CSS の link タグを head 内に、JavaScript の script タグを body の閉じタグの直前など(head 内でも大丈夫です)に配置します。

そして JavaScript の script タグの読み込みの後に初期化の記述 hljs.highlightAll(); を追加します。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Hilight.js CDN Sample</title>
  <!-- テーマ CSS の読み込み(コピーしたタグを貼り付け) -->
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css" integrity="sha512-Jk4AqjWsdSzSWCSuQTfYRIF84Rq/eV0G2+tu07byYwHcbTGfdmLrHjUSwvzp5HvbiqK4ibmNwdcG49Y5RGYPTg==" crossorigin="anonymous" referrerpolicy="no-referrer" />
</head>
<body>

  <!-- JavaScript の読み込み(コピーしたタグを貼り付け) -->
  <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js" integrity="sha512-D9gUyxqja7hBtkWpPWGt9wfbfaMGVt9gnyCvYa+jojwwPHLCzUm5i8rpk7vD7wNee9bA35eYIjobYPaQuKS1MQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
  <!-- highlight.js の初期化 -->
  <script>hljs.highlightAll();</script>
</body>
</html>

バンドルされている言語

以下は CDN の highlight.min.js(v.11.9.0)にバンドルされている Common カテゴリーの言語です。

  • Bash
  • C
  • C++
  • C#
  • CSS
  • Diff
  • Go
  • GraphQL
  • TOML
  • INI
  • Java
  • JavaScript
  • JSON
  • Kotlin
  • Less
  • Lua
  • Makefile
  • HTML, XML
  • Markdown
  • Objective-C
  • Perl
  • PHP
  • PHP Template
  • Plain text
  • Python
  • Python REPL
  • R
  • Ruby
  • Rust
  • SCSS
  • Shell Session
  • SQL
  • Swift
  • TypeScript
  • Visual Basic .NET
  • WebAssembly
  • YAML

ダウンロード

Download ページで、必要な言語を選択してファイルをダウンロードすることができます。

https://highlightjs.org/download

使用する言語を選択して Donwload ボタンをクリックすると zip ファイルがダウンロードされるので解凍します。以下のようなフォルダやファイルが入っています。

highlight.min.js と styles フォルダの中のテーマの CSS ファイルを1つ選択して使用します。

ライブラリの読み込みと初期化

ダウンロードした highlight.min.js とテーマの CSS ファイルを任意の場所に保存してページで読み込みます。以下の例では highlight というフォルダを作成して、その中に style フォルダと highlight.min.js を配置しています(この例ではテーマの CSS は atom-one-dark.min.css を使用)。

CSS は head 内で読み込み、JavaScript(highlight.min.js)は body の閉じタグの直前などで読み込みます。そして highlight.min.js の読み込みの後で hljs.highlightAll() を呼び出して初期化します。

highlight.min.js の読み込みと初期化の記述は head 内に記述することもできます。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Hilight.js Sample</title>
  <!-- テーマ CSS の読み込み -->
  <link rel="stylesheet" href="highlight/styles/atom-one-dark.min.css">
</head>
<body>

  <!-- JavaScript の読み込み -->
  <script src="highlight/highlight.min.js"></script>
  <!-- highlight.js の初期化 -->
  <script>hljs.highlightAll();</script>
</body>
</html>

style フォルダの中にテーマを複数入れておけば、必要に応じてテーマを切り替えることもできます。

ハイライト表示(基本的な使い方)

デフォルトでは、<pre><code></code></pre> で囲まれたコードはハイライト表示されます。

例えば、以下のように記述すると、

<pre><code>function add(x,y) {
return x + y;
}
console.log(add(1,2));  // 3</code></pre>

以下のようにハイライト表示されます。

function add(x,y) {
  return x + y;
}
console.log(add(1,2));  // 3

language-xxxx クラスの指定

Highlight.js は言語を自動的に検出しようとしますが、その言語特有のキーワードが含まれていない場合などでは、自動検出が正しく機能しない(期待した言語と異なる)ことがあります。その場合は明示的に言語を指定できます。

自動的に検出された言語を確認するには、インスペクタでその code 要素の language-xxxx クラスの xxxx 部分を確認します。前述の例の場合、以下のように Highlight.js の自動検出により language-scss というクラスが付与されていて、言語は SCSS と判定されています。

自動検出の結果が期待した言語でない場合や明示的に言語を指定したい場合は、<code> タグに language-言語名 という形式のクラスを設定して言語を指定することができます。

例えば、コードの言語を JavaScript と認識させるには、code 要素に language-javascript クラスを指定します。

<pre><code class="language-javascript">function add(x,y) {
  return x + y;
}
console.log(add(1,2));  // 3</code></pre>

前述の例は、language-javascript クラスの指定により以下のように表示されます。

function add(x,y) {
  return x + y;
}
console.log(add(1,2));  // 3

HTML 特殊文字はエスケープ

ハイライト表示するコードの記述では、以下の HTML 特殊文字は文字参照を使ってエスケープします。

HTML 特殊文字 文字参照
< &lt;
> &gt;
& &amp;
' &#39;
" &quot;

例えば、以下のように記述すると、

<pre><code>const hello = (name) =&gt; {
  console.log(&#39;Hello, &#39; + name);
}
hello(&#39;foo&#39;);  // Hello, foo
</code></pre>

以下のようにハイライト表示されます。

const hello = (name) => {
  console.log('Hello, ' + name);
}
hello('foo');  // Hello, foo

手動で変換するのは大変なのでツールなどを使うと便利です(関連ページ:HTML特殊文字変換ツール)。

ハイライト表示しない

<pre><code></code></pre> 内に記述されたコードは、自動的にハイライト表示されます。

ハイライト表示したくない場合は、以下のいずれかの方法でハイライト表示させないようにできます。

プレインテキスト

ハイライト表示せずに、プレインテキストとして表示するには、code タグに language-plaintextクラスを指定します。

<pre><code class="language-plaintext">Plain Text</code></pre>

上記は以下のように表示されます。

Plain Text

※ language-plaintext クラスを指定するには、ダウンロードする際に言語の選択で plaintext を含める必要があります。plaintext が含まれていないとコンソールに警告が出力され、無効な言語名を指定したのと同じ表示(ハイライトなし)になります。

ハイライトを無効に

<pre><code></code></pre>のハイライトを無効にするには code タグに nohighlightクラスを指定します。

<pre><code class="nohighlight">No Highlight</code></pre>

上記は以下のように、ハイライトなしで表示されます。

No Highlight

特定のコードのみハイライト

特定の <pre><code></code></pre> のみをハイライト表示するには、初期化の際に hljs.highlightAll() の代わりに hljs.highlightElement() を使って、特定のクラスを指定した <pre> 要素や特定の親要素を持つ <pre><code></code></pre> などを対象にすることができます。

初期化のカスタマイズ 参照

無効な言語名

code タグや pre タグに、インストールしていない言語名や無効な言語名のクラス(例:language-foo や lang-foo)を指定すると、ハイライト表示されず、コンソールに警告が表示されます。

例えば、以下を記述すると、language-foo は無効な言語名のクラスなのでハイライト表示されず、コンソールを確認すると警告が表示されます。

<pre ><code class="language-foo">Not valid language name.</code></pre>

コンソールには以下のような警告が表示されます。

pre 要素と code 要素間での改行

pre 要素の直後に改行すると code 要素の前に空白があれば、code 要素内の記述の前に空白文字のスペースが表示されますが、Highlight.js では code 要素の前の空白は削除されるようになっているようです。

<pre><code>...</code></pre>

<pre>
  <code>...</code><!-- 通常、左のスペース(空白)が表示されるが Highlight.js では表示されない -->
</pre>

但し、<pre><code>の間で改行すればその分、1行分のスペースが改行により発生します。閉じタグ </code></pre>の間の改行も同様です。

また、pre 要素や code 要素を絶対配置の基準として表示している要素がある場合、pre 要素と code 要素の間で改行するのとしないのでは表示位置が異なってきます。

例えば、以下のように記述した場合、

<div class="hljs-wrap">
  <pre><code class="language-javascript">function add(x,y) {
    return x + y;
  }
  console.log(add(1,2));  // 3</code></pre>
</div>


<div class="hljs-wrap">
  <pre> <!-- 改行 -->
      <code class="language-javascript">function add(x,y) {
    return x + y;
  }
  console.log(add(1,2));  // 3</code></pre>
</div>

<div class="hljs-wrap">
  <pre> <!-- 改行 -->
      <code class="language-javascript">function add(x,y) {
    return x + y;
  }
  console.log(add(1,2));  // 3</code> <!-- 改行 -->
</pre>
</div>

pre 要素にピンク色の背景色を設定すると pre 要素や code 要素の改行のあるなしにより、以下のように表示されます。3つ目の例の右下のコピーボタンは親要素の div 要素を基準に絶対配置していますが、改行が入ることで位置がずれて表示されます。

カスタマイズしたりプラグインを設定する場合、スタイルの設定ではこれらのことを考慮すると良いかと思います。

pre 要素 code 要素間の改行を削除

必要に応じて JavaScript を使って pre 要素と code 要素間の改行や空白文字を削除することもできます。

以下はページの全ての pre 要素と code 要素の間の改行を削除してから、hljs.highlightAll() で初期化する例です。

// すべての pre 要素を取得
const preElements = document.querySelectorAll('pre');

// 各 pre 要素に対して処理を行う
preElements.forEach( (preElement) => {
  // pre 要素内の HTML を取得
  const preHTML = preElement.innerHTML;
  // <code> の前の改行や空白文字を削除
  let formattedHTML = preHTML.replace(/\n*\s*<code(.*)>/g, '<code$1>');
  // </code> の後の改行や空白文字を削除
  formattedHTML = formattedHTML.replace(/<\/code>\n*\s*/g, '</code>');
  // 更新された HTML を pre 要素に設定
  preElement.innerHTML = formattedHTML;
});

// Highlight.js の初期化
hljs.highlightAll();

上記では全ての pre 要素を取得していますが、必要に応じて querySelectorAll() に指定するセレクタを変更して、特定の pre 要素を取得して処理することができます。

また、replace() は2つに分けていますが、チェインして記述することもできるので、上記は以下のように短く記述することもできます。

document.querySelectorAll('pre').forEach( (elem) => {
  elem.innerHTML = elem.innerHTML.replace(/\n*\s*<code(.*)>/g, '<code$1>').replace(/<\/code>\n*\s*/g, '</code>');
});

hljs.highlightAll();
pre 要素 code 要素間のテキスト

Highlight.js の場合、pre 要素と code 要素の間にテキストを入れると、コードの外側に表示されます。

例えば、以下のように記述すると、

<pre>テキスト<code class="language-javascript">function add(x,y) {
  return x + y;
}
console.log(add(1,2));  // 3</code></pre>

以下のように表示されます。この例ではわかりやすいように pre 要素に黄色の背景色を設定しています。

テキストfunction add(x,y) {
  return x + y;
}
console.log(add(1,2));  // 3

必要であればテキストを span 要素で囲んでスタイルを指定するなども可能です。

但し、code 要素の前で改行すると、code 要素の前に空白があるかどうかでテキストの後に改行が入るかどうかが変わってきます。

注意する点としては、プラグインによっては、pre 要素と code 要素の間のテキストはコード部分(上記の場合黒色の背景色の部分)に表示されるものもあるため、将来プラグインを変更する可能性などを考えると、この方法は使わない方が良いかも知れません。

基本的には <pre><code> のように pre 要素と code 要素の間にはテキストや改行を入れずに記述するほうが安全だと思います。

カスタマイズ

必要に応じて初期化の処理やスタイル(テーマ)を変更することができます。また、プラグイン API も公開されているので独自にプラグイン(拡張機能)を設定することができます。

初期化のカスタマイズ

初期化の際に hljs オブジェクトのメソッド highlightAll() を呼び出すと、<pre><code></code></pre> で囲まれた全てのコードがハイライト表示されます。

以下は highlightAll() と同じことを DOMContentLoadedhighlightElement() を使って行う例です。

DOM ツリーの構築が完了した時点で querySelectorAll() を使ってページの全ての <pre><code> を取得して、それらを hljs オブジェクトの highlightElement() メソッドに渡してハイライト表示しています。

document.addEventListener('DOMContentLoaded', (event) => {
  document.querySelectorAll('pre code').forEach((el) => {
    // hljs オブジェクトの highlightElement()メソッドで全ての <pre><code> をハイライト
    hljs.highlightElement(el);
  });
});

ハイライト表示する要素を指定

上記の方法を使えば、例えば特定のクラスが指定された pre 要素の code 要素のみを対象にハイライト表示することができます。

以下は highlight クラスを指定した pre 要素の子孫の code 要素(<pre class="highlight"><code>)のみをハイライト表示します。

document.addEventListener('DOMContentLoaded', (event) => {
  // <pre class="highlight"><code> 内のコードのみをハイライト
  document.querySelectorAll('pre.highlight code').forEach((el) => {
    hljs.highlightElement(el);
  });
});

以下は、<div class="hljs-wrap"> の子孫の <pre><code> のコードのみをハイライト表示します。

document.addEventListener('DOMContentLoaded', (event) => {
  // .hljs-wrap の子孫のコード(pre code)のみをハイライト
  document.querySelectorAll('.hljs-wrap pre code').forEach((el) => {
    hljs.highlightElement(el);
  });
});

以下は pre 要素に no-highlight クラスが指定されていれば(<pre class="no-highlight"><code>)、そのコードをハイライト表示せず、それ以外の全ての <pre><code> のコードをハイライト表示します。

document.addEventListener('DOMContentLoaded', (event) => {
  // <pre class="no-highlight"><code> 以外の <pre><code> をハイライト
  document.querySelectorAll('pre:not(.no-highlight) code').forEach((el) => {
    hljs.highlightElement(el);
  });
});

div 要素にコードを記述

基本的にコードは <pre><code> でマークアップすることが推奨されますが、場合によっては div 要素などの他のタグを使用することもできます。

以下は code クラスを付与した div 要素をハイライトする場合の例です。但し、div 要素に CSS で追加の設定が必要になります。

document.addEventListener('DOMContentLoaded', (event) => {
  // code クラスを付与した div 要素をハイライト
  document.querySelectorAll('div.code').forEach((el) => {
    hljs.highlightElement(el);
  });
});

pre 要素はスペース、タブ、改行はそのまま表示される設定となっていますが、div 要素はそのような設定になっていないため、div 要素を使う場合は、CSS の white-space プロパティで、スペース、タブ、改行の表示方法を指定する必要があります。

例えば、以下のように設定します。但し、white-space に pre を指定すると自動的に改行されないので、自動的に改行する場合は white-space に pre-wrap を指定します。

div.code {
  white-space: pre; /* スペース、タブ、改行はそのまま表示(自動改行されない) */
}
インラインでハイライト

pre 要素内ではない code 要素をハイライト表示する例です。

以下は .hljs-wrap の子孫のコード(.hljs-wrap pre code)に加え、highlight クラスを指定した code 要素をハイライト表示します。

document.addEventListener('DOMContentLoaded', (event) => {
  // .hljs-wrap の子孫のコード(pre code)をハイライト
  document.querySelectorAll('.hljs-wrap pre code').forEach((el) => {
    hljs.highlightElement(el);
  });
  // pre 要素なしの code 要素(code.highlight)をハイライト
  document.querySelectorAll('code.highlight').forEach((el) => {
    hljs.highlightElement(el);
  });
});

<code class="highlight">document.addEventListener()</code> のように記述すると、 document.addEventListener() のように表示されます。language-xxxx クラスで言語を明示的に指定することもできます。

スタイルのカスタマイズ

テーマの CSS の読み込みの後に独自の CSS を読み込んで、code 要素に適用されるハイライトのスタイルを上書きすることができます。

code 要素内の記述が Highlight.js によって整形されてどのようにマークアップされるかを確認してみます。例えば、以下を記述すると、

<pre><code class="language-javascript">function add(x,y) {
  return x + y;
}
console.log(add(1,2));  // 3</code></pre>

以下のようにハイライトされて表示されます。

function add(x,y) {
  return x + y;
}
console.log(add(1,2));  // 3

上記をブラウザのインスペクタで調べると、Highlight.js の整形により以下のように文字列の種類(キーワードなど)により span 要素に分割され、それぞれに hljs- から始まるクラスが付与されています。

また、ルートコンテナー(root container)の code 要素には .hljs が付与されています(2行目)。

<pre>
  <code class="language-javascript hljs" data-highlighted="yes">
    <span class="hljs-keyword">function</span>
    <span class="hljs-title function_">add</span>
    (
    <span class="hljs-params">x,y</span>
    ) {
    <span class="hljs-keyword">return</span>
    x + y; }
    <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>
    ));
    <span class="hljs-comment">// 3</span>
  </code>
</pre>

カスタマイズしたい要素をインスペクタで調べて、指定されているクラスを利用してスタイルを上書きすることができます。

例えば、コメント部分(20行目)を見ると、hljs-comment クラスが付与されているので、コメント部分の文字色を変更するには、.hljs-comment に color を設定します。

.hljs-comment {
  color: #6b788f;
}

圧縮されていないテーマの CSS で確認すると、コメント部分に付与される .hljs-comment は以下のように指定されているのがわかります。

.hljs-comment,
.hljs-quote {
  color: #5c6370;
  font-style: italic
}

また、デフォルトではルートコンテナーの code 要素には以下のような設定がされています。

pre code.hljs {
  display: block;
  overflow-x: auto;
  padding: 1em
}

code.hljs {
  padding: 3px 5px /* テーマにより値は異なります */
}

.hljs {
  color: #abb2bf; /* テーマにより値は異なります */
  background: #282c34 /* テーマにより値は異なります */
}

Highlight.js テーマは言語に依存しません。 多くの言語で適切に機能する限られたクラスのセットが用意されているため、テーマの CSS はとてもシンプルでわかりやすくなっています(Theme Guide)。

行の折り返し

pre 要素はホワイトスペースをそのまま表示し、自動的に行を折り返さない設定となっています(pre 要素のデフォルトは white-space: pre)。

pre 要素内で自動的に行を折り返すようにするには、white-space に pre-wrap を指定します。

pre 要素のセレクタに直接指定することもできますが、その場合、全ての pre 要素に適用されるので、例えば、以下のようにクラスを指定した div 要素で pre 要素をラップします。

<div class="hljs-wrap">
  <pre><code>...</code></pre>
</div>

そして、例えば以下のように子孫セレクタで .hljs-wrap の子孫の pre 要素に設定を指定します。自動的に行を折り返す場合は、overflow-wrap: break-word も指定した方が良いかも知れません。

.hljs-wrap pre {
  white-space: pre-wrap;
  overflow-wrap: break-word;
}

上記の設定をして、特定の pre 要素内では自動的に行を折り返さないようにするには、例えば pre 要素に nowrap というクラスを指定して、以下の設定を追加すれば、pre.nowrap は自動的に行を折り返さないようになります。

.hljs-wrap pre.nowrap {
  white-space: pre;
}

場合によっては code 要素に white-space を設定して、折り返しを制御することもできます。

メディアクエリで折り返しを制御

画面幅が狭いと折り返すと見にくくなる可能性があるので、例えばある程度の幅以上では折り返し、それより狭い場合は折り返さないなども可能です。

@media screen and (max-width : 640px) {
  .hljs-wrap pre {
    white-space: pre;
  }
}

または、ボタンなどを表示してユーザーが折り返しを選択できるようにすることもできます(Highlight.js のカスタマイズ サンプル)。

ラベルを表示

pre 要素や code 要素にカスタムデータ属性(data-*)を設定してその値を表示することができます。

以下は pre 要素に data-label というカスタム属性を設定する例です。data-xxxx の xxxx の部分は任意の文字列を指定できます。

<pre data-label="ラベル"><code> ... </code></pre>

カスタムデータ属性に指定した値は CSS や JavaScript を使って取得・表示することができます。

CSS で表示

::before や ::after の content プロパティを使ってカスタムデータ属性に指定した値を表示できます。

但し、::before や ::after で content プロパティを使って表示したテキストは HTML 文として認識されないので、ブラウザ上で選択したり、コピーすることはできません。

この例では、pre 要素に data-label という名前のカスタム属性を設定します。

::before や ::after の疑似要素を任意の位置に表示するには絶対配置を使いますが、絶対配置の基準なる要素が必要です。そのため、以下のように絶対配置の基準にする div 要素で pre 要素をラップします。

<div class="hljs-wrap">
  <pre data-label="ラベル"><code>...</code></pre>
</div>

カスタムデータ属性 data-* に設定された値は、content プロパティで attr() 関数を使って取得できます。

例えば、以下を記述すれば、コードの左上に data-label に指定した文字列を表示します。

.hljs-wrap {
  position: relative; /* 絶対配置の基準 */
}

pre[data-label]::before {
  content: attr(data-label);  /* data-label の値を取得 */
  position: absolute;  /* 絶対配置 */
  top: -2rem;  /* 表示位置の調整 */
  left: 0;  /* 表示位置の調整 */
  color: #999;
  display: inline-block;
  padding: 0.5rem 0;
}

data-label 属性を設定した要素は属性セレクタを使って pre[data-label] で表せます。

JavaScript で表示

以下は JavaScript で、pre 要素に設定したカスタム属性の値を表示する例です。

CSS の場合と同様、表示する要素の絶対配置の基準となる div 要素で pre 要素をラップします。

<div class="hljs-wrap">
  <pre data-label="ラベル"><code>...</code></pre>
</div>

以下の JavaScript では、div.hljs-wrap の子要素(pre 要素)に data-label 属性が設定されていれば、その値をラベルとして span 要素で表示します。

DOMContentLoaded で hljs-wrap クラスを指定した div 要素(div.hljs-wrap)を全て取得し、div.hljs-wrap が1つ以上あれば、その子要素を調べ、pre 要素に data-label が設定されていれば、その値を span 要素で出力します。data-* 属性は dataset プロパティや setAttribute()、getAttribute() などを使って操作できます。

document.addEventListener('DOMContentLoaded', () => {
  // hljs-wrap クラスを指定した div 要素(div.hljs-wrap)を全て取得
  const hljsWrappers = document.querySelectorAll('div.hljs-wrap');
  // div.hljs-wrap が1つ以上あれば
  if (hljsWrappers.length > 0) {
    hljsWrappers.forEach((wrapper) => {
      // div.hljs-wrap の子要素の pre 要素を取得
      const pre = wrapper.firstElementChild;
      // pre 要素が存在しなければ終了
      if (!pre) { return; }
      // pre 要素に data-label が設定されていれば
      if (pre.hasAttribute('data-label')) {
        // span 要素を作成
        const labelSpan = document.createElement('span');
        // span 要素のテキストに pre 要素の data-label 属性の値を設定
        labelSpan.textContent = pre.dataset.label;
        // span 要素にクラスを設定
        labelSpan.setAttribute('class', 'hljs-label');
        // div.hljs-wrap に span 要素を追加
        wrapper.appendChild(labelSpan);
      }
    });
  }
});

この例では出力する span 要素に hljs-label というクラスを指定しているので、例えば以下のようなスタイルを設定するとコードの左上に data-label に指定した文字列を表示します。

.hljs-wrap {
  position: relative; /* 絶対配置の基準 */
}

.hljs-wrap .hljs-label {
  position: absolute; /* 絶対配置 */
  top: -2rem;  /* 表示位置の調整 */
  left: 0;  /* 表示位置の調整 */
  color: #999;
  display: inline-block;
  padding: 0.5rem 0;
}

リンクを表示

以下は pre 要素に data-label 属性に加えて data-label-url 属性も指定されていれば a 要素でリンクを表示し、data-label-url 属性が指定されていない場合は span 要素でラベル(テキスト)を表示する例です。

document.addEventListener('DOMContentLoaded', () => {
  // hljs-wrap クラスを指定した div 要素(div.hljs-wrap)を全て取得
  const hljsWrappers = document.querySelectorAll('div.hljs-wrap');
  // div.hljs-wrap が1つ以上あれば
  if (hljsWrappers.length > 0) {
    hljsWrappers.forEach((wrapper) => {
      // div.hljs-wrap の子要素の pre 要素を取得
      const pre = wrapper.firstElementChild;
      // pre 要素が存在しなければ終了
      if (!pre) { return; }
      // pre 要素に data-label 属性が設定されていなければ終了
      if (!pre.hasAttribute('data-label')) { return; }
      // data-label 属性の値を取得
      const label = pre.getAttribute('data-label');
      // 表示する要素の初期化
      let element;
      // data-label-url が設定されていればリンクを作成
      if (pre.hasAttribute('data-label-url')) {
        element = document.createElement('a');
        element.href = pre.getAttribute('data-label-url');
        element.classList.add('hljs-label-url')
      } else {
        // data-label-url がなければ span 要素を作成
        element = document.createElement('span');
        element.classList.add('hljs-label');
      }
      // 表示する要素のテキスト(ラベル)に data-label 属性の値を設定
      element.textContent = label;
      wrapper.appendChild(element);
    });
  }
});

リンクの場合、a 要素には hljs-label-url クラスが付与されるので、スタイルを別途定義します。

.hljs-wrap .hljs-label, .hljs-wrap .hljs-label-url {
  position: absolute;
  top: -2rem;
  left: 0;
  color: #999;
  display: inline-block;
  padding: 0.5rem 0;
}

.hljs-wrap .hljs-label-url {
  color:#3987C7;
  text-decoration: none;
}

.hljs-wrap .hljs-label-url:hover {
  color:#55924f;
}

例えば、以下のように pre 要素に data-label 属性と data-label-url 属性が指定されていれば、リンクを表示します。

<div class="hljs-wrap">
  <pre data-label="ラベル" data-label-url="#"><code>...</code></pre>
</div>

Plugin API

addPlugin() を使って独自のプラグインを定義することができます。

プラグインには Class based plugins(クラスベース)と Function Based Plugins(関数ベース)があり、単純なプラグインであれば関数ベースのプラグインを使って定義できます。

// クラスベース(Class based plugins)
addPlugin(new SimplePlugin());
addPlugin(new MoreComplexPlugin(options));

// 関数ベース(Function Based Plugins)
addPlugin({
  'after:highlightElement': ({ el, result, text }) => {
    // 何らかの処理
  }
});

after:highlightElement

after:highlightElement は、 Highlight.js によってハイライト表示の整形が完了後(出力する前)に実行されるコールバックで、以下の3つのオブジェクトが渡されます。

オブジェクト 説明
el Highlight.js によりハイライト表示された HTML 要素(整形後の code 要素)
result highlight(または highlightAuto)メソッドにより返されるハイライト表示された結果のオブジェクト。language(言語名)や value(ハイライト表示のマークアップ)などのプロパティを持っています
text ハイライト表示の文字列(raw text)

例えば以下のようなハイライト表示の記述がある場合、

<pre><code>function hello() {
  setTimeout(() =&gt; {
    console.log(&quot;Hello!&quot;);
  }, 1000);
}</code></pre>

以下を記述して、after:highlightElement に渡されるオブジェクトをコンソールに出力して確認すると、

hljs.addPlugin({
  'after:highlightElement': ({ el, result, text }) => {
    console.log(el.innerHTML);  // 整形後の code 要素の HTML を出力
    console.log(result);  // 結果のオブジェクト
    console.log(text);  // 結果のテキスト
  }
});

以下のように出力されます。

el.innerHTML は整形後の code 要素の HTML で、各行のマークアップが確認できます。

result は language プロパティ(検出または指定された言語名)や value プロパティなどが確認でき、text は表示されるテキストが入っているのが確認できます。

after:highlightElement を使えば、これらのオブジェクトを利用して何らかの処理をすることができます。

プラグインは初期化の前に定義

※ プラグインの定義は、Highlight.js の JavaScript(highlight.min.js)の読み込みの後で、且つ初期化の前に記述する必要があります。

言語名を表示

以下は関数ベースの言語名を表示するプラグインの例です。

この例では after:highlightElement を使って、検出されたコードの言語名(result.language)を取得して、その値が undefined でなければ code 要素(el)の data-language 属性に設定しています。

result.language には自動的に検出された言語名、または language-xxxx クラスで指定された言語名(xxxx)が入っています。

hljs.addPlugin({
  'after:highlightElement': ({ el, result }) => {
    // result の language プロパティが undefined でなければ
    if(result.language) {
      // language を result から取得して code 要素(el)の data-language 属性に設定
      el.dataset.language = result.language;
    }
  }
});

この例では、以下のように code 要素とその親要素の pre 要素を div 要素でラップします。

<div class="hljs-wrap"> <!-- div 要素でラップ -->
  <pre><code class="language-html">...</code></pre>
</div>

code 要素に追加された data-language 属性(カスタムデータ属性)の値は、CSS を使って ::before や ::after 疑似要素の content プロパティで attr() 関数を使って取得できます。

例えば、以下のような CSS を設定すれば、コードの右上に言語名が表示されます。

言語名(::before)を絶対配置するため、配置の基準となる親要素に position: relative を指定します。

必要に応じて data-language 属性が指定された code 要素にパディングを設定します(コードが言語名で隠れないように)。

.hljs-wrap {
  position: relative; /* 絶対配置の基準 */
}

.hljs-wrap code[data-language]::before {
  content: attr(data-language);
  position:absolute;
  top: 0;
  right: 0;
  color: #ccc;
  display: inline-block;
  padding: 0.5rem;
}

.hljs-wrap code[data-language] {
  padding-top: 2rem;
}

言語名

language-xxxx クラスを指定する場合、xxxx の部分の大文字・小文字は指定したように表示されます。

例えば、language-javascript と指定すれば言語名の表示は javascript になり、language-JavaScript と指定すれば JavaScript になります。

有効・無効のオプション

設定したプラグインをページ全体や個々の要素で有効にしたり、無効にする1つの方法はクラス属性やカスタムデータ属性を利用します。

以下はクラス属性を使って、ページ全体や個々の要素で言語表示するかどうかを設定する例です。

クラスが指定されている場合だけ言語を表示(デフォルトで無効)

以下は<code>show-lang クラスが指定されていれば言語名をコード上に表示する例です。

hljs.addPlugin({
  'after:highlightElement': ({ el, result }) => {
    if(result.language && el.classList.contains('show-lang')) {
      el.dataset.language = result.language;
    }
  }
});

pre 要素のクラスで判定する場合は、el.classList.contains() の代わりに el.parentElement.classList.contains() を使います。

クラスが指定されていれば言語表示を無効にする(デフォルトで有効)

以下は<body>show-no-lang クラスが指定されていればページ全体で言語表示を無効にし、<code>show-no-lang クラスが指定されていれば、その code 要素で言語表示を無効にする例です。

// body に show-no-lang クラスが指定されていなければ以下の addPlugin を実行
if (!document.body.classList.contains('show-no-lang')) {
  hljs.addPlugin({
    'after:highlightElement': ({ el, result }) => {
      // show-no-lang クラスが指定されていれば終了
      if (el.classList.contains('show-no-lang')) return;
      if (result.language) {
        el.dataset.language = result.language;
      }
    }
  });
}

複数のプラグインの定義がある場合は、例えば以下のようにまず body のクラスのリストを変数に入れておいて判定すると良いかと思います。

// body のクラスのリストを変数に代入
 const bodyClassList = document.body.classList;

// body に show-no-lang クラスが指定されていなければ以下を実行
if (!bodyClassList.contains('show-no-lang')) {
  hljs.addPlugin({
    'after:highlightElement': ({ el, result}) => {
      ...
    }
  });
}
// body に no-copy-btn クラスが指定されていなければ以下を実行
if (!bodyClassList.contains('no-copy-btn')) {
  hljs.addPlugin({
    'after:highlightElement': ({ el, result, text}) => {
      ...
    }
  });
}

[注意]Highlihgt.js の適用対象の pre 要素や code 要素に指定するクラスは language-lang- が含まれる名前は設定しないようにします。例えば、no-lang-name というクラスを code 要素や pre 要素に指定すると以下のような警告がコンソールに出力され、ハイライト表示されません(無効な言語名)。

WARN: Could not find the language 'name', did you forget to load/include a language module?

ファイル名を表示

以下は前述の言語名の表示に加え、code 要素に data-file 属性が設定されていれば、その値(ファイル名)をコードの上に表示する例です。

※ この機能は実際にはプラグイン addPlugin() を使ったものではなく、先述のラベルを表示と同じもので、単にカスタムデータ属性の値を CSS で表示するものです。

<div class="hljs-wrap"><!-- div 要素でラップ -->
  <!-- code 要素に data-file 属性を設定 -->
  <pre><code data-file="bar.css">...</code></pre>
</div>

この場合、前述の CSS に加えて、::after を使った以下のような記述の追加だけで実装できます。

但し、疑似要素で表示するので表示されるテキストは HTML として認識されず、テキストを選択したりコピーすることはできません。表示位置などはスタイルで調整します。

.hljs-wrap {
  position: relative; /* 絶対配置の基準 */
}

code[data-file]::after {
  content: attr(data-file);
  position:absolute;
  top: -2rem;
  left: 0;
  color: #999;
  display: inline-block;
  padding: 0.5rem 0;
}

この例ではファイル名を表示していますが、任意の data-* 属性を作成して、任意の文字列を表示できます。

また、code 要素に position: relative が設定されていると表示されません。

pre 要素に data-file 属性を設定

code 要素ではなく、pre 要素に data-file 属性を設定してその値を表示することもできます。

<div class="hljs-wrap"><!-- div 要素でラップ -->
  <!-- pre 要素に data-file 属性を設定 -->
  <pre data-file="bar.css"><code>...</code></pre>
</div>

以下はスタイルの設定例です。この場合も、親要素に position: relative が必要です。

pre[data-file]::after {
  content: attr(data-file);
  position:absolute;
  top: -2.25rrem;
  left: 0;
  color: #a9bbda;
  display: inline-block;
  padding: 0.5rem;
  background-color: #282c34;
}

/* ファイル名をコードの上に表示する場合(必要に応じて) */
pre[data-file] {
  margin-top: 4rem;
}

コードの外側に表示する場合、基準とする要素に overflow: hidden が設定されていると表示されません。

内側に表示するには pre 要素にある程度のパディングが必要になります。

pre[data-file] code {
  padding-top: 2.5rem;
}

pre[data-file]::after {
  content: attr(data-file);
  position: absolute;
  top: 0;
  left: 0;
  color: #eee;
  display: inline-block;
  padding: 0.25rem;
  background-color: #556a94;
}

ラッパー要素に data-file 属性を設定

ラッパー要素(pre 要素の親要素) に data-file 属性を設定してその値を表示することもできます。

<div class="hljs-wrap" data-file="bar.css"><!-- div 要素(ラッパー)に data-file 属性を設定 -->
  <pre><code>...</code></pre>
</div>

以下はスタイルの設定例です。

.hljs-wrap[data-file]::before {
  content: attr(data-file);
  position: absolute;
  top: -2.25rem;
  left: 0;
  color: #eee;
  display: inline-block;
  padding: 0.25rem;
  background-color: #556a94;
  width: 100%; /* 幅いっぱいに背景色を適用 */
}

.hljs-wrap[data-file] {
  margin-top: 4rem;
}
コピーボタンの追加

以下も after:highlightElement のコールバックを使ったプラグインの作成例です。

after:highlightElement のコールバックは text というキー(プロパティ名)で、ハイライト表示した文字列を受け取ることができます。

また、この例ではコピー(クリップボードへの書き込み)は Clipboard API のインターフェース Clipboard のメソッド Clipboard.writeText() を使用しています(IE など古いブラウザには対応していません)。

Clipboard.writeText() は Promise を返す非同期処理のメソッドなので、then() メソッドを使って成功時と失敗時のコールバックを登録しています。

コピーが成功した場合はコピーボタンのテキストを Copied に、失敗した場合はテキストを Failed に変更し、別途定義した resetCopyBtnText() を呼び出して1.5秒後に元のテキストに戻しています。

この例ではボタン(button 要素)を after() を使って pre 要素の後に追加していますが、before() を使ったり、appendChild() で親要素に追加するなども考えられます。

また、copyToClipboard と resetCopyBtnText は addPlugin の外で定義することもできます。

hljs.addPlugin({
  'after:highlightElement': ({ el, result, text }) => {
    // コピーボタンの処理
    const copyButton = document.createElement('button');
    // ボタンにスタイルを適用できるようにクラス属性を指定
    copyButton.setAttribute('class', 'hljs-copy-btn');
    // ボタンのラベル(テキスト)
    copyButton.textContent = 'Copy';
    // 作成したボタンを pre 要素(el.parentElement)の後に追加
    el.parentElement.after(copyButton);
    // 作成したボタンにクリックイベントのリスナーを設定
    copyButton.addEventListener('click', () => {
      // copyToClipboard を呼び出してコードのテキストをコピー
      copyToClipboard(copyButton, text)
    });

    // コピー処理の定義
    function copyToClipboard(btn, text) {
      // Clipboard API に対応していないブラウザ
      if (!navigator.clipboard) {
        alert('Sorry, can not copy');
      }
      // Clipboard API の writeText 関数でクリップボードに text をコピー
      navigator.clipboard.writeText(text).then(
        // 成功時の処理
        () => {
          btn.textContent = 'Copied';
          resetCopyBtnText(btn, 1500);
        },
        // 失敗時の処理
        (error) => {
          btn.textContent = 'Failed';
          resetCopyBtnText(btn, 1500);
          console.log(error.message);
        }
      );
    };

    // ボタンのテキストを指定されたミリ秒後にリセットする関数
    function resetCopyBtnText(btn, delay) {
      setTimeout(() => {
        btn.textContent = 'Copy'
      }, delay)
    }
  }
});

以下はコピーボタンのスタイルの例です。必要に応じて調整します。

このボタンも絶対配置にしているので、言語名の表示と同様、position: relative; を指定した親要素(div 要素)で pre 要素をラップする必要があります。

.hljs-copy-btn {
  position: absolute;
  bottom: 0;
  right: 0;
  background-color: rgba(201, 213, 245, 0.1);
  border: none;
  padding: 3px 10px;
  color: #999;
  cursor: pointer;
  transition: color .3s;
}
.hljs-copy-btn:hover {
  color: #eee
}

以下はサンプルです。実際の code 要素内のマークアップはエスケープされていますが、コピーされるテキストは表示通りのテキストになります。

<div class="hljs-wrap">
  <pre><code data-file="bar.css">...</code></pre>
</div>
有効・無効の切り替え

以下はコピーボタンの機能をデフォルトで有効にして、必要に応じて無効(非表示)にする例です。

以下の場合、<body>no-copy-btn クラスが指定されていればページ全体でコピーボタンを無効にし、<pre>no-copy-btn クラスが指定されていれば、その pre 要素でコピーボタンを無効にします。

// body に no-copy-btn クラスが指定されていなければ以下の addPlugin を実行
if (!document.body.classList.contains('no-copy-btn')) {
  hljs.addPlugin({
    'after:highlightElement': ({ el, result, text }) => {
      // pre 要素(el.parentElement)に no-copy-btn クラスが指定されていれば終了
      if (el.parentElement.classList.contains('no-copy-btn')) return;
      const copyButton = document.createElement('button');
      copyButton.setAttribute('class', 'hljs-copy-btn');
      copyButton.textContent = 'Copy';
      el.parentElement.after(copyButton);
      copyButton.addEventListener('click', () => {
        copyToClipboard(copyButton, text)
      });

      function copyToClipboard(btn, text) {
        if (!navigator.clipboard) {
          alert('Sorry, can not copy');
        }
        navigator.clipboard.writeText(text).then(
          () => {
            btn.textContent = 'Copied';
            resetCopyBtnText(btn, 1500);
          },
          (error) => {
            btn.textContent = 'Failed';
            resetCopyBtnText(btn, 1500);
            console.log(error.message);
          }
        );
      };

      function resetCopyBtnText(btn, delay) {
        setTimeout(() => {
          btn.textContent = 'Copy'
        }, delay)
      }
    }
  });
}

以下はコピーボタンの機能をデフォルトで無効(非表示)にして、pre 要素に copy クラスが指定されている場合に有効にする例です。5行目以降は前述の例と同じです。

hljs.addPlugin({
  'after:highlightElement': ({ el, result, text }) => {
    // pre 要素(el.parentElement)に copy クラスが指定されていなければ終了
    if (!el.parentElement.classList.contains('copy')) return;
    const copyButton = document.createElement('button');
    copyButton.setAttribute('class', 'hljs-copy-btn');
    copyButton.textContent = 'Copy';
    el.parentElement.after(copyButton);
    copyButton.addEventListener('click', () => {
      copyToClipboard(copyButton, text)
    });

    function copyToClipboard(btn, text) {
      if (!navigator.clipboard) {
        alert('Sorry, can not copy');
      }
      navigator.clipboard.writeText(text).then(
        () => {
          btn.textContent = 'Copied';
          resetCopyBtnText(btn, 1500);
        },
        (error) => {
          btn.textContent = 'Failed';
          resetCopyBtnText(btn, 1500);
          console.log(error.message);
        }
      );
    };

    function resetCopyBtnText(btn, delay) {
      setTimeout(() => {
        btn.textContent = 'Copy'
      }, delay)
    }
  }
});

                
行番号を表示

highlightjs-line-numbers.js というプラグインを使うと、簡単に行番号を表示することができますが、独自にプラグインを定義して行番号を表示することもできます。

以下は独自に行番号を表示するプラグインを定義する例です。行をハイライトする機能が必要な場合は、行や行番号をハイライトを使います。

addPlugin()after:highlightElement のコールバックは引数に elresult を受け取ります。

hljs.addPlugin({
  'after:highlightElement': ({ el, result }) => {
    // 何らかの処理
  }
});

el は Highlight.js によりハイライト表示される HTML 要素(整形後の code 要素)です。この innerHTML を書き換えることで、Highlight.js によって整形されハイライト表示される HTML を変更することができます。

また、result はハイライト表示された結果のオブジェクトで、このオブジェクトの value プロパティにはハイライト表示のマークアップ(HTML)が格納されています。

el.innerHTMLresult.value にはハイライト表示される HTML が、pre 要素内で改行されて表示されるように1行ずつ記述されています。

そのため、各行の先頭に行番号を表示するための span 要素を追加して、CSS カウンター(自動ナンバリング)を使えば行番号を表示することができます。

各行の先頭に span 要素を挿入するには、以下のように記述できます。

hljs.addPlugin({
  'after:highlightElement': ({ el, result }) => {
    el.innerHTML = result.value.replace(/^/gm, '<span class="line-num"></span>');
  }
});

replace(/^/gm, '<span class="line-num"></span>') は正規表現を使用してすべての行の先頭に line-num クラス(クラス名は任意)を指定した span 要素を挿入します。

正規表現の g フラグは、global を表し、マッチしたすべての箇所を対象に置換を行うオプションです。

m フラグは、multiline を表し、これを有効にすると、正規表現の^が文字列全体の先頭だけでなく、各行の先頭にもマッチするようになります。

つまり、/^/gm の正規表現が各行の先頭を対象にし、これにマッチした部分に span 要素を挿入します。

行番号を CSS で表示

行番号を CSS で表示するには、::before や ::after 疑似要素の content プロパティで counter() 関数を使って、要素に自動的にナンバリングします。

例えば、以下のように対象の pre 要素を .hljs-wrap でラップしている場合、

<div class="hljs-wrap">
  <pre><code>...</code></pre>
</div>

以下の CSS を記述すると行番号が表示されます。以下では行番号の右側に border-right で枠線を表示していますが、桁が増えてもずれないようにするにはある程度の幅が必要になります。

幅や余白は適宜調整します。

/* 起点となる要素でカウントする値を初期化 */
.hljs-wrap pre {
  /* カウンター名(lineNumber)と初期値を指定(初期値が0の場合は省略可能) */
  counter-reset: lineNumber;
  white-space: pre; /* 自動的に行を折り返さない( pre のデフォルトなので省略可能) */
}

/* カウント対象の要素に擬似要素で counter-increment と content プロパティを指定 */
.hljs-wrap pre span.line-num::before {
  /* カウンター名と増減する値を指定(増減値が1の場合は省略可能) */
  counter-increment: lineNumber;
  /* content プロパティに counter(カウンター名) を指定 */
  content: counter(lineNumber);
  width: 1.5rem;  /* 桁数が増えた場合も想定する必要あり */
  display: inline-block;  /* display: inline の方が良いかも */
  color: #5f9168; /* 行番号の色 */
  border-right: 1px solid #999;
  margin-right: 10px; /* 行番号とコードの余白 */
  padding-right: 5px; /* 必要に応じて */
  text-align: center;
}

起点となる要素(この例の場合は .hljs-wrap pre)の counter-reset プロパティでカウンターの名前(任意の文字列)を指定し、カウントする値を初期化します。

0 でカウントを初期化する場合は、値は省略可能なので上記では省略しています。

カウント対象の要素に擬似要素(::before や ::after)を指定し、counter-increment プロパティと content プロパティを設定します。

counter-increment プロパティでは、対象のカウンター名と増減する値(1 の場合は省略可能)を指定し、content プロパティでは counter(カウンター名) を指定します。

関連ページ:CSS カウンター(自動ナンバリング)

行を折り返す

上記の場合、pre 要素内で自動的に行を折り返す設定(white-space: pre-wrap)にすると、行番号部分にコードが入り込んでしまうため、長い行では以下のような表示になります。

自動的に折り返さずに、スクロールさせる場合は以下のように表示されるので問題ありません。

但し、横スクロールすると、行番号もスクロールされて見えなくなります。

横スクロールしても行番号固定して表示するには、行番号の擬似要素を絶対配置します。

この場合、行番号部分の幅を確保するので、pre 要素内で自動的に行を折り返す設定にした場合でも行番号部分にコードが入り込むことはありません。但し、ボーダー(枠線)は途切れた表示になります。

行番号を固定

横スクロールしても行番号を固定するには、code 要素に行番号を表示するための余白を確保し、行番号部分を絶対配置します。以下の幅や余白は調整する必要があるかも知れません。

.hljs-wrap {
  position: relative; /* 絶対配置の基準 */
}

.hljs-wrap pre {
  counter-reset: lineNumber;
  white-space: pre;
}

/* 行番号を表示するための余白を指定 */
.hljs-wrap pre code {
  padding-left: 3.25rem;
}

/* 絶対配置にして位置や背景色を設定 */
.hljs-wrap pre span.line-num::before {
  counter-increment: lineNumber;
  content: counter(lineNumber);
  min-width: 2.5rem;
  display: inline-block; /* または display: inline (この方が良いかも) */
  color: #777;
  border-right: 1px solid #999;
  margin-right: 10px;
  padding-right: 5px;
  text-align: center;
  position:absolute; /* 絶対配置 */
  left:0;  /* 左寄せ */
  background: #282c34; /* 背景色(テーマに合わせて設定) */
}

行を折り返しても枠線を途切れないようにする

上記の方法では、CSS で pre 要素内で自動的に行を折り返すように設定をしても、行番号部分にコードが入り込むことはありませんが、行番号右側のボーダー(枠線)は途切れてしまいます。

以下は pre 要素に white-space: pre-wrap を指定して自動的に行を折り返す設定にした場合に、行番号右側のボーダー(枠線)が途切れないようにする例です。

但し、この方法の場合、pre 要素に white-space: pre を指定して行を折り返さないようにすると、行番号は固定できません(固定するには一手間かかります)。

::before 疑似要素のボーダー(枠線)ではなく、枠線用の span 要素(border-right で枠線を指定)を作成して絶対配置にし、その要素の幅を行番号の幅に合わせ、高さをラッパー(.hljs-wrap)に合わせます。

以下がその記述です。

コードを表示する幅が変わると、枠線用の span 要素の高さも変わるように、ResizeObserver で .hljs-wrap(ラッパー)のサイズを監視して高さを更新します。

16行目の枠線用 span 要素の position: absolute の設定は CSS でも設定可能ですが、必須の設定なので JavaScript で設定しています。

hljs.addPlugin({
  'after:highlightElement': ({ el, result }) => {
    el.innerHTML = result.value.replace(/^/gm, '<span class="line-num"></span>');
    // 以下を追加
    // pre 要素の親要素 .hljs-wrap(ラッパー)を取得
    const wrapper = el.closest('.hljs-wrap');
    // .hljs-wrap の高さを取得
    const wrapperHeight = wrapper.offsetHeight;
    // 枠線用の span 要素を作成
    const borderSpan = document.createElement('span');
    // 枠線用の span 要素にクラスを設定
    borderSpan.classList.add('hljs-border-span');
    // 枠線用の span 要素を code 要素(el)に追加
    el.appendChild(borderSpan);
    // 枠線用の span 要素に position: absolute を設定
    borderSpan.style.setProperty('position', 'absolute');
    // 枠線用の span 要素に .hljs-wrap(pre 要素のラッパー)の高さを設定
    borderSpan.style.setProperty('height', wrapperHeight + 'px');
    // ResizeObserver で  .hljs-wrap(ラッパー)のサイズを監視して枠線用の span 要素の高さを更新
    const myObserver = new ResizeObserver((entries) => {
      if (entries.length > 0 && entries[0]) {
        const entry = entries[0];
        // contentBoxSize が使えればその値を、そうでなければ contentRect の値を使用
        const newHeight = entry.contentBoxSize ? entry.contentBoxSize[0].blockSize : entry.contentRect.height;
        // 枠線用の span 要素に .hljs-wrap(ラッパー)のサイズ変更後の高さを設定
        borderSpan.style.setProperty('height', newHeight + 'px');
      }
    });
    myObserver.observe(wrapper);
  }
});

以下が CSS です。

12行目の padding-bottom は、コードの最後に空の行があると、行番号部分が少し下側にはみ出してしまうので、その調整用です。コードの最後は空の行にしないようにすれば不要です(例えば、スペースなどの空文字が入っていればはみ出しません)。

13行目の position: relative は、code 要素を枠線用の span 要素(.hljs-border-span)の絶対配置の基準にするために必要な設定です(この設定がないと、pre 要素と code 要素の間に改行をいれると枠線が上部にはみ出します)。

折り返さない設定にも対応させる

code 要素を position: relative にすると、pre 要素が行を折り返さない設定(white-space: pre)の場合に、行番号を固定表示することができません。

行番号を固定表示にするには、前述の方法を使うか、または、13行目の position: relative を削除し、pre 要素と code 要素の間に改行を入れないようにします。または、pre 要素と code 要素の間に改行を入れる場合は、別途 pre 要素 code 要素間の改行を削除する方法もあります。

pre 要素が行を折り返す設定(white-space: pre-wrap)にしている場合は、行番号を固定する必要がないので問題ありません。

※ また、ファイル名の表示で、code[data-file]::after を絶対配置(position: absolute)にしている場合、その設定を pre 要素に移す(pre[data-file]::after に設定する)必要があります。

.hljs-wrap {
  position: relative;
}

.hljs-wrap pre {
  counter-reset: lineNumber;
  white-space: pre-wrap;  /* 行を自動的に折り返す場合 */
}

.hljs-wrap pre code {
  padding-left: 2.75rem;
  padding-bottom: 1.5em;  /* 最後に空行があると行番号部分はみ出すので調整(必要に応じて) */
  position: relative; /* 枠線用の span 要素(.hljs-border-span)の絶対配置の基準に */
}

.hljs-wrap pre span.line-num::before {
  counter-increment: lineNumber;
  content: counter(lineNumber);
  min-width: 2.5rem;
  display: inline-block;  /* または display: inline */
  color: #777;
  text-align: center;
  position: absolute;
  left: 0;
  background: #282c34;
}

/* 枠線用の span 要素 */
.hljs-border-span {
  background-color: transparent;
  width: 2.5rem; /* 行番号(span.line-num::before)の幅と合わせる */
  border-right: 1px solid #999;
  position: absolute;  /* JavaScript で設定しているので省略可能 */
  top: 0;
  left: 0;
}
行の開始番号を指定できるように

pre 要素に data-line-num-start 属性に行の開始番号を指定できるようにする例です。

以下の5〜11行目を追加します。

pre 要素のスタイルの counter-reset プロパティでカウンターの初期値を設定します。カウンターが表示される際には自動的に1増加するので、開始番号から1引いた値を指定します。

hljs.addPlugin({
  'after:highlightElement': ({ el, result }) => {
    el.innerHTML = result.value.replace(/^/gm, '<span class="line-num"></span>');
    // 行の開始番号を指定できるように以下を追加
    const pre = el.parentElement;
    if (pre.hasAttribute('data-line-num-start')) {
      const startNumber = parseInt(pre.getAttribute('data-line-num-start'));
      if(startNumber) {
        // counter-reset プロパティでカウンターの初期値を設定
        pre.style.setProperty('counter-reset', 'lineNumber ' + (startNumber -1));
      }
    }
    // 以降は同じ
    const wrapper = el.closest('.hljs-wrap');
    const wrapperHeight = wrapper.offsetHeight;
    const borderSpan = document.createElement('span');
    borderSpan.classList.add('hljs-border-span');
    el.appendChild(borderSpan);
    borderSpan.style.setProperty('position', 'absolute');
    borderSpan.style.setProperty('height', wrapperHeight + 'px');
    const myObserver = new ResizeObserver((entries) => {
      if (entries.length > 0 && entries[0]) {
        const entry = entries[0];
        const newHeight = entry.contentBoxSize ? entry.contentBoxSize[0].blockSize : entry.contentRect.height;
        borderSpan.style.setProperty('height', newHeight + 'px');
      }
    });
    myObserver.observe(wrapper);
  }
});
行や行番号をハイライト

以下は pre 要素の data-line-highlight 属性に行の番号を指定すると、指定した行を CSS で指定した背景色でハイライト表示するプラグインの例です(行番号を表示する機能も含まれています)。

行の番号は、カンマ区切りで指定することもハイフンでレンジ指定することもできます。

[追記] 2024/02/26 以下のコードと関連するコードを修正しました。(以前のコードの問題点)。

例えば、data-line-highlight="1,3-5" とすると、1行目と3〜5行目をハイライト表示します。

デフォルトでは行のコードと行番号の両方をハイライトしますが、pre 要素に no-highlight-code クラスを指定すれば行番号のみ、no-highlight-number クラスを指定すればコードのみをハイライトします。

また、以下の例ではデフォルトで行番号を表示するようにしています。但し、pre 要素に no-line-num クラスが指定されていれば行番号を表示しません。ページ全体で行番号を表示しないようにするには body 要素に no-line-num クラスを指定します。

data-line-highlight 属性のハイライト行の指定では、負の値のレンジ指定(例: -3-0)には対応していません(ハイライトしません)。

// body に no-line-num クラスが指定されていれば行番号を表示しない
if (!document.body.classList.contains('no-line-num')) {
  hljs.addPlugin({
    'after:highlightElement': ({ el, result }) => {
      // pre 要素(el.parentElement)に no-line-num クラスが指定されていれば行番号を表示しない
      if (el.parentElement.classList.contains('no-line-num')) return;
      // 各行の先頭に span.line-num を挿入
      el.innerHTML = result.value.replace( /^/gm, '<span class="line-num"></span>');
      const pre = el.parentElement;
      // 開始行番号のオフセットの初期値
      let startNumOffset = 0;
      // pre 要素の data-line-num-start 属性に開始行番号が指定されていれば
      if (pre.hasAttribute('data-line-num-start')) {
        // 開始行番号を取得
        const startNumber = parseInt(pre.getAttribute('data-line-num-start'));
        if (startNumber || startNumber === 0) {
          // CSS の counter-reset プロパティでカウンターの初期値を変更
          pre.style.setProperty('counter-reset', 'lineNumber ' + (startNumber - 1));
          // 開始行番号のオフセットに開始行番号を代入
          startNumOffset = startNumber - 1;
        }
      }
      // 行を折り返しても枠線を途切れないようにする処理
      const wrapper = el.closest('.hljs-wrap');
      if (wrapper) {
        const wrapperHeight = wrapper.offsetHeight;
        const borderSpan = document.createElement('span');
        borderSpan.classList.add('hljs-border-span');
        el.appendChild(borderSpan);
        borderSpan.style.setProperty('position', 'absolute');
        borderSpan.style.setProperty('height', wrapperHeight + 'px');
        const myObserver = new ResizeObserver((entries) => {
          if (entries.length > 0 && entries[0]) {
            const entry = entries[0];
            const newHeight = entry.contentBoxSize ? entry.contentBoxSize[0].blockSize : entry.contentRect.height;
            borderSpan.style.setProperty('height', newHeight + 'px');
          }
        });
        myObserver.observe(wrapper);
      }
      // 行のハイライト
      // pre 要素に data-line-highlight 属性が指定されていていれば
      if (pre.hasAttribute('data-line-highlight')) {
        // data-line-highlight 属性の値(行番号のリスト)を取得
        const targetLines = pre.getAttribute('data-line-highlight');
        // pre 要素に no-highlight-code クラスが指定されているかどうかのフラグ
        const highlightCode = pre.classList.contains('no-highlight-code') ? false : true;
        // pre 要素に no-highlight-number クラスが指定されているかどうかのフラグ
        const highlightNumber = pre.classList.contains('no-highlight-number') ? false : true;
        // 取得した値(行番号のリスト)をカンマで分割して前後の空白文字を除去
        const targets = targetLines.split(',').map((val) => val.trim());
        // targets(配列)の要素(行番号の指定)が1つ以上あれば
        if (targets.length > 0) {
          // el(code 要素)から行番号の要素 .line-num を全て取得
          const lineNumSpan = el.querySelectorAll('.line-num');
          // 行の総数
          const lineLength = lineNumSpan.length;
          // data-line-highlight 属性の値をカンマで分割したそれぞれの値にについて
          targets.forEach((target) => {
            // ハイフン(-)で分割
            const range = target.split('-');
            // ハイフンで指定されている場合
            if (range.length === 2) {
              // 負の値でない場合
              if (range[0] !== '') {
                // それぞれの値を数値に変換して開始番号が設定されていればオフセットの差分を適用
                const start = startNumOffset === 0 ? parseInt(range[0]) : parseInt(range[0]) - startNumOffset;
                const end = startNumOffset === 0 ? parseInt(range[1]) : parseInt(range[1]) - startNumOffset;
                // それぞれの値が数値として変換できれば
                if (start && end) {
                  if (end >= start) {
                    for (let i = start; i <= end; i++) {
                      // 各要素にクラスを追加
                      addClassToSpan(i);
                    }
                  } else {
                    for (let i = end; i <= start; i++) {
                      addClassToSpan(i);
                    }
                  }
                }
              } else { // 負の値の場合
                const negativeNum = (startNumOffset === 0 ? parseInt(range[1]) : parseInt(range[1])) * -1;
                addClassToSpan(negativeNum - startNumOffset);
              }
            } else if (range.length === 1) { // レンジ(ハイフン)指定でない場合
              // 開始番号が指定されていればその値の差分を適用
              addClassToSpan(startNumOffset === 0 ? parseInt(target) : parseInt(target) - startNumOffset);
            }
            // span 要素にクラスを追加する関数
            function addClassToSpan(number) {
              // 値が 0 より大きく且つ .line-num の総数(行数)より小さければ
              if (number > 0 && number <= lineLength) {
                // .no-highlight-code が pre 要素に指定されていなければ
                if (highlightCode) {
                  // コード部分をハイライトする span 要素を作成
                  const highlightSpan = document.createElement('span');
                  highlightSpan.className = 'line-highlight';
                  // 作成した span 要素を行番号の span 要素の後に追加
                  lineNumSpan.item(number - 1).after(highlightSpan);
                }
                // .no-highlight-number が pre 要素に指定されていなければ
                if (highlightNumber) {
                  // .line-num の(値- 1)番目の要素にクラスを追加
                  lineNumSpan.item(number - 1).classList.add('line-num-highlight');
                }
              }
            }
          })
        }
      }
    }
  });
}

以下が CSS です。

11〜14行目は no-line-num クラスを指定して行番号を非表示にした場合に、行番号部分の幅をリセットする設定です。

行のハイライト表示は行番号部分とコード部分でそれぞれ設定しています(43〜46と49〜59行目)。

.hljs-wrap {
  position: relative;
}

.hljs-wrap pre {
  counter-reset: lineNumber;
  white-space: pre-wrap;  /* 行を自動折返しする場合 */
}

/* no-line-num クラスを指定した場合のスタイルを追加 */
body.no-line-num .hljs-wrap pre code,
.hljs-wrap pre.no-line-num code {
  padding-left: 1em;
}

.hljs-wrap pre code {
  padding-left: 2.75rem;
  padding-bottom: 1.5em;
  position: relative; /* 枠線用の span 要素の絶対配置の基準とする */
}

.hljs-wrap pre span.line-num::before {
  counter-increment: lineNumber;
  content: counter(lineNumber);
  min-width: 2.5rem;
  display: inline-block;
  color: #777;
  text-align: center;
  position: absolute;
  left: 0;
  background: #282c34;
}

.hljs-border-span {
  background-color: transparent;
  width: 2.5rem;
  border-right: 1px solid #999;
  top: 0;
  left: 0;
}

/* 行番号部分のハイライト表示 */
.hljs-wrap pre span.line-num.line-num-highlight::before {
  color: #c2c21a;
  background: rgba(166, 168, 162, 0.225);
}

/* コード部分のハイライト表示(水平方向のグラデーションの例) */
.hljs-wrap .line-highlight {
  position:absolute;
  /* 行番号の幅を左側に確保 */
  left: 2.5rem;
  width: 100%;
  background: linear-gradient(to right, hsla(254, 15%, 51%, 0.15) 50%, hsla(254, 15%, 51%, 0.01));
  /* 高さは環境に応じて調整 */
  height: 1.2rem;
  /* 文字を選択できるように */
  pointer-events: none;
}

使用例

pre 要素に data-line-highlight 属性を指定して、ハイライト表示する行をカンマ区切りで指定します。その際、連続する行はハイフンを使って指定することができます。以下のように指定すると、

<div class="hljs-wrap">
  <pre data-line-highlight="2, 5-7"><code>...</code></pre>
</div>

例えば、以下のように指定された行の行番号とコードがハイライト表示されます。

<?php
$_POST['name'] = 'xxx';
$name = filter_input(INPUT_POST, "name");
?>
<form method="post">
  <input type="text" name="name" value="">
  <input type="submit" >
</form>
<p>name: <?php echo $name;

pre 要素に no-highlight-code クラスを指定すると行番号のみをハイライトします。

<div class="hljs-wrap">
  <pre data-line-highlight="2, 5-7" class="no-highlight-code"><code>
  ...
  </code></pre>
</div>
<?php
$_POST['name'] = 'xxx';
$name = filter_input(INPUT_POST, "name");
?>
<form method="post">
  <input type="text" name="name" value="">
  <input type="submit" >
</form>
<p>name: <?php echo $name;

pre 要素に no-highlight-number クラスを指定するとコード部分のみをハイライトします。

<div class="hljs-wrap">
  <pre data-line-highlight="2, 5-7" class="no-highlight-number"><code>
  ...
  </code></pre>
</div>
<?php
$_POST['name'] = 'xxx';
$name = filter_input(INPUT_POST, "name");
?>
<form method="post">
  <input type="text" name="name" value="">
  <input type="submit" >
</form>
<p>name: <?php echo $name;

開始行番号に負の値を指定して、data-line-highlight に負の値を指定することはできますが、負の値をハイフンでレンジ指定することはできません(ハイライトしません)。

<div class="hljs-wrap">
  <pre data-line-highlight="-2, 2-3" data-line-num-start="-3"><code>...</code></pre>
</div>
<?php
$_POST['name'] = 'xxx';
$name = filter_input(INPUT_POST, "name");
?>
<form method="post">
  <input type="text" name="name" value="">
  <input type="submit" >
</form>
<p>name: <?php echo $name;
以前のコードの問題点

以前のコードでは、行単位でスタイルを設定できるようにと考えて、各行の先頭に span.line-num を挿入し、各行を span.line-row で囲むように以下のような正規表現を使用していました。

el.innerHTML = result.value.replace(/^(.*)$/gm,
  '<span class="line-row"><span class="line-num"></span>$1</span>');

Highlight.js により出力されるコードを調べてみると、以下のように span 要素がネストされる場合があり、ネストされている出力に上記の正規表現での書き換えを行うと、マークアップ的にはエラーになりませんが、Highlight.js の span 要素の構造を壊していしまい、ネストがあると正しく表示されませんでした。

各行の span 要素が対になっていればよいのですが、例えば以下の2行目では class="language-xml" の span 要素を上記の書き換えにより閉じてしまうことになります。

<span class="hljs-keyword">return</span> (
  <span class="language-xml"><span class="hljs-tag">&lt;&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">InspectorControls</span>&gt;</span>
  ・・・以下省略・・・
  </span> <!-- class="language-xml"> の閉じタグが数行下に出現 -->

このため、各行をスタイル用の span 要素で囲むのではなく、ハイライト表示する行の、行番号の span 要素の後にハイライト用の span 要素を追加するように変更しました(上記コードの95〜101行目部分)。

正規表現を使用してマークアップを変更するのは一般的には避けるほうが無難です。

指定された行以降を表示・非表示

初期状態では指定された行までを表示し、ボタンをクリックすると全てのコード(行)を表示する例です。

このプラグインは行の位置を取得する必要があるため行や行番号をハイライトするプラグインが定義されて、有効になっている必要があります(依存しています)。

pre 要素の data-show-until 属性に行番号が指定されていれば、初期状態でその行までを表示し、ボタンをクリックするとその行以降の全てのコードを表示します。

指定された行の親要素(pre 要素)からの位置(offsetTop)を取得して、その値を pre 要素の高さに設定し、指定されて行以降を非表示にします。ボタンがクリックされたら Web Animation API のアニメーションで本来の(全ての行を表示した状態の)pre 要素の高さに変更します。

表示するボタンのラベルはデフォルトでは「Expand」ですが、pre 要素の data-show-until-label 属性で変更できます。全てのコードを表示すると、ボタンのラベルは「Reduce」に変わりますが、こちらは data-show-until-label-reduce 属性で変更できます(またはプラグインの定義で任意の値に変更します)。

pre 要素に data-show-until-offset 属性を指定すると、その分だけ表示範囲を下方向に広げます(10 を指定すると 10px 余分に表示)。

また、ボタンの位置をコードの下に配置し、且つ非表示部分が非常に長い場合、全ての行を表示した後に、再度ボタンをクリックして指定行以降を非表示にすると、コードの高さが縮小する分だけ表示位置がずれるので、そのような場合は、pre 要素に scroll クラスを指定すると、元の位置にスクロールします。

hljs.addPlugin({
  'after:highlightElement': ({ el, result }) => {
    // 垂直方向のスクロール量を代入する変数
    let windowScrollY;
    // pre 要素
    const pre = el.parentElement;
    // pre 要素に data-show-until 属性が指定されていなければ終了
    if (!pre.hasAttribute('data-show-until')) return;
    // el(code 要素)から各行を囲む要素 .line-num を全て取得
    const lineNumSpan = el.querySelectorAll('.line-num');
    // .line-num (行番号)が存在すれば
    if (lineNumSpan.length > 0) {
      // 何行目までを表示するか(整数)
      const dataShowUntil = parseInt(pre.getAttribute('data-show-until'));
      // dataShowUntil が NaN でなく且つコードの行数より小さければ
      if (dataShowUntil && lineNumSpan.length > dataShowUntil) {
        // pre 要素の data-show-until-offset 属性の値を取得して整数に変換
        const showUntilDataOffset = parseInt(pre.getAttribute('data-show-until-offset'));
        // showUntilDataOffset の値が数値であればその値を、そうでなければ 0(または任意のデフォルト値)を heightOffset に
        const heightOffset = showUntilDataOffset ? showUntilDataOffset : 0;
        // 開始行番号のオフセットの初期値
        let startNumOffset = 0;
        // data-line-num-start 属性(開始行番号)が指定されていれば
        if (pre.hasAttribute('data-line-num-start')) {
          const startNumber = parseInt(pre.getAttribute('data-line-num-start'));
          if (startNumber || startNumber === 0) {
            // 指定された値で開始行番号のオフセットを更新
            startNumOffset = startNumber - 1;
          }
        }
        // 何行目からを非表示にするか(整数)
        const hideFrom = dataShowUntil - startNumOffset;
        // 非表示にする行までの高さ(表示する高さ)
        const hideFromOffsetTop = lineNumSpan.item(hideFrom).offsetTop;
        // data-show-until-offset 属性に指定された値を表示する高さに追加
        const reducedHeight = hideFromOffsetTop + heightOffset;
        // pre 要素の本来の高さ
        const preHeight = pre.offsetHeight;
        // pre 要素の垂直方向のオーバーフローを非表示に
        pre.style.setProperty('overflow-y', 'hidden');
        // pre 要素の高さを非表示部分を除いた高さに変更
        pre.style.setProperty('height', reducedHeight + 'px');
        // ボタンを作成
        const expandButton = document.createElement('button');
        expandButton.setAttribute('class', 'hljs-expand-btn');
        // data-show-until-label 属性が指定されていればボタンのテキストに(指定がなければ Expand)
        const expandButtonLabel = pre.hasAttribute('data-show-until-label') ? pre.getAttribute('data-show-until-label') : 'Expand';
        expandButton.textContent = expandButtonLabel;
        // data-show-until-label-reduce 属性が指定されていれば縮小時のボタンのテキストに(指定がなければ Reduce)
        const expandButtonLabelReduce = pre.hasAttribute('data-show-until-label-reduce') ? pre.getAttribute('data-show-until-label-reduce') : 'Reduce';
        // ボタンを pre 要素の後ろに追加
        pre.after(expandButton);
        let isAnimating = false;
        let isExpanded = false;
        // ボタンのクリックイベントに pre 要素の高さを変更するアニメーションを設定
        expandButton.addEventListener('click', () => {
          if (isAnimating === true) {
            return;
          }
          if (!isExpanded) {
            // クリックされた時点での垂直方向のスクロール量を取得
            windowScrollY = window.scrollY
            expandButton.textContent = expandButtonLabelReduce;
            isAnimating = true;
            const expandPre = pre.animate(
              {
                height: [reducedHeight + 'px', preHeight + 'px'],
              },
              {
                duration: 300,
                easing: 'ease-in',
                fill: 'forwards',
              }
            );
            expandPre.onfinish = () => {
              isExpanded = true;
              isAnimating = false;
            }
          } else {
            expandButton.textContent = expandButtonLabel;
            isAnimating = true;
            const reducePre = pre.animate(
              {
                height: [preHeight + 'px', reducedHeight + 'px'],
              },
              {
                duration: 300,
                easing: 'ease-in',
                fill: 'forwards',
              }
            );
            reducePre.onfinish = () => {
              isExpanded = false;
              isAnimating = false;
              // scroll クラスが指定されていれば、非表示部分を表示した時点での位置へスクロール
              if (pre.classList.contains('scroll')) {
                if (pre.classList.contains('smooth')) {
                  // smooth クラスが指定されていれば、スムーススクロール
                  window.scroll({ top: windowScrollY, behavior: 'smooth' });
                }else{
                  window.scroll({ top: windowScrollY });
                }
              }
            }
          }
        });
      }
    }
  }
});

Expand ボタンをコードの外側に表示する場合は、必要に応じてマージンを設定します。

pre[data-show-until] {
  margin-bottom: 5rem;
}

例えば、以下のように指定すると初期状態では1〜4行目までを表示し、Expand ボタンをクリックすると全てのコードを表示します。

<div class="hljs-wrap">
  <pre data-show-until="4"><code>...</code></pre>
</div>

例えば、以下のように表示されます。

<?php
$_POST['name'] = 'xxx';
$name = filter_input(INPUT_POST, "name");
?>
<form method="post">
  <input type="text" name="name" value="">
  <input type="submit" >
</form>
<p>name: <?php echo $name;

以下は、data-show-until-offset="10" を指定して最後の行より 10px 下方向に余分に表示し、data-show-until-label と data-show-until-label-reduce でボタンのラベルを指定する例です。

<div class="hljs-wrap">
  <pre data-show-until="4" data-show-until-offset="10" data-show-until-label="全て表示" data-show-until-label-reduce="縮小"><code>...</code></pre>
</div>

例えば、以下のように表示されます。

data-show-until-offset の指定により、5行目の上半分ぐらいが表示されていて、data-show-until-label と data-show-until-label-reduce によりボタンのテキストが「全て表示」と「縮小」になっています。

<?php
$_POST['name'] = 'xxx';
$name = filter_input(INPUT_POST, "name");
?>
<form method="post">
  <input type="text" name="name" value="">
  <input type="submit" >
</form>
<p>name: <?php echo $name;

元の位置にスクロールするオプション

上記の例のように非表示の部分が少ない場合は問題ありませんが、非表示の部分が非常に長いと、全てを表示した後に縮小すると表示位置が大きくずれるため、ユーザーがどこに移動したのかがわからなくなる可能性があります。

Expand ボタンをコードの上に表示している場合は、あまり問題になりませんが、Expand ボタンをコードの下に表示していて、且つ表示部分が非常に長い場合には、pre 要素に scroll クラスを指定すれば元の位置にスクロールします。

また、追加で pre 要素に smooth クラスを指定するとスムーススクロールします。

<div class="hljs-wrap">
  <pre data-show-until="25" class="scroll smooth"><code>...
  非常に長いコード
  ...</code></pre>
</div>
表示する行数を指定

前項の指定された行以降を表示・非表示にするプラグインのアニメーションは、非表示部分が大きい場合などでは使いにくい可能性があります。

表示領域をコンパクトにする別の方法として、コードに max-height を設定して非表示部分をスクロールして見せる方法もあります。

以下は pre 要素の data-max-lines 属性に表示する行の数が指定されていれば、その行の位置までの高さを code 要素に max-height として設定するプラグインの例です。

※ data-max-lines 属性と data-show-until 属性は同時に指定することはできません。

このプラグインも行の位置を取得する必要があるため行や行番号をハイライトするプラグインが必要ですが、更に記述(コード)の追加が必要です。

max-height は code 要素に設定していますが、スタイルの設定によっては pre 要素に max-height を設定した方が良い場合もあるかもしれません。適宜調整してください。

また、data-scroll-to 属性に行の番号を指定すると、その行までスクロールした状態で表示します。

pre 要素に reset-btn クラスを指定すると、表示位置をリセットするボタンを表示します。

// 表示する行数を指定して code 要素に max-height を設定するプラグイン
hljs.addPlugin({
  'after:highlightElement': ({ el, result }) => {
    const pre = el.parentElement;
    if (!pre.hasAttribute('data-max-lines') || pre.hasAttribute('data-show-until')) return;
    const wrapper = pre.parentElement;
    if (!wrapper) return;
    // code 要素に max-height を設定する前のラッパーの高さ
    const wrapperHeight = wrapper.offsetHeight;
    const lineNumSpan = el.querySelectorAll('.line-num');
    if (lineNumSpan.length > 0) {
      // data-max-lines 属性の値(表示する行数)
      const dataMaxLine = parseInt(pre.getAttribute('data-max-lines'));
      if (dataMaxLine && lineNumSpan.length > dataMaxLine) {
        // data-max-lines-offset 属性に指定された値(余分に表示する高さ:ピクセル)
        const dataMaxLineOffset = parseInt(pre.getAttribute('data-max-lines-offset'));
        // 余分に表示する高さ(data-max-lines-offset 属性に指定されていればその値または0)
        const heightOffset = dataMaxLineOffset ? dataMaxLineOffset : 0;
        // data-max-lines に指定された行数分の高さ
        const targetOffsetTop = lineNumSpan.item(dataMaxLine).offsetTop;
        // code 要素のアクティブなスタイル
        const elComputedStyle = window.getComputedStyle(el);
        // code 要素の padding-top
        const elPaddingTop = parseFloat(elComputedStyle.paddingTop);
        // code 要素の padding-bottom
        const elPaddingBottom = parseFloat(elComputedStyle.paddingBottom);
        // 表示する領域の高さ
        const visibleHeight = targetOffsetTop + heightOffset - elPaddingTop - elPaddingBottom;
        // code 要素に max-height を設定
        el.style.setProperty('max-height', visibleHeight + 'px');
        // code 要素のカスタム属性に max-height 設定前のラッパー要素の高さを設定
        el.setAttribute('data-border-span-height', wrapperHeight);
        // 垂直方向のスクロールを有効に(必要に応じて)
        el.style.setProperty('overflow-y', 'scroll');
        // スクロール量
        let scrollAmount = 0;
        // data-scroll-to 属性が指定されている場合
        if (pre.hasAttribute('data-scroll-to')) {
          // data-scroll-to 属性に指定されている値を数値に変換
          const dataScrollTo = parseInt(pre.getAttribute('data-scroll-to'));
          if (dataScrollTo) {
            if(dataScrollTo + dataMaxLine > lineNumSpan.length + 1) {
              console.log('data-scroll-to or data-max-line is not valid')
              return;
            }
            // data-scroll-to 属性に指定されている行(表示する先頭行)のオフセットトップ
            const scrollToOffsetTop = lineNumSpan.item(dataScrollTo - 1).offsetTop;
            // 表示する最後の行のオフセットトップ
            const lastLineRow = lineNumSpan.item(dataScrollTo + dataMaxLine -2).offsetTop;
            // 表示領域の高さを調整する値(必要に応じて変更)
            const heightAdjustAmount = -8;
            el.style.setProperty('max-height', (lastLineRow - scrollToOffsetTop + heightOffset + heightAdjustAmount ) + 'px');
            // スクロール量を調整する値(必要に応じて変更)
            const scrollAdjustAmount = 3;
            // data-scroll-to 属性が指定されている場合のスクロール量
            scrollAmount = scrollToOffsetTop - scrollAdjustAmount;
            el.scroll(0, scrollAmount);
          }
        }
        if (pre.classList.contains('reset-btn')) {
          // スクロール状態をリセットする(最初の位置に戻す)ボタン
          const resetPositionButton = document.createElement('button');
          resetPositionButton.setAttribute('class', 'hljs-reset-position-btn');
          resetPositionButton.textContent = 'リセット';
          pre.after(resetPositionButton);
          resetPositionButton.addEventListener('click', () => {
            el.scroll(0, scrollAmount);
          });
        }
      }
    }
  }
});

※ 使用しているスタイルの設定などによっては、指定した行の範囲や位置がずれる可能性があります。

上記では code 要素に max-height を設定する際にパディングを差し引いていますが、スタイルの設定によってはパディング分を調整しない方が良いかもしれません。

例えば、このページの場合、表示する領域の高さ(上記28行目)を以下のように変更しています。

// 表示する領域の高さ
let visibleHeight = targetOffsetTop + heightOffset;
if (pre.hasAttribute('data-scroll-to')) {
  visibleHeight = targetOffsetTop + heightOffset - elPaddingTop - elPaddingBottom;
}

行や行番号をハイライトするプラグインに処理を追加

code 要素に max-height を設定すると、非表示部分の行番号の右側の枠線が表示されなくなります。

そのため、上記のプラグインでは、code 要素に max-height を設定する前のラッパーの高さを取得してその値を code 要素の data-border-span-height 属性に指定しているので、その値を使って行番号部分の高さを更新します(29〜31行目)。

// 行番号表示と行のハイライト表示のプラグイン
hljs.addPlugin({
  'after:highlightElement': ({ el, result }) => {
    if (el.parentElement.classList.contains('no-line-num')) return;
    el.innerHTML = result.value.replace( /^/gm, '<span class="line-num"></span>');
    const pre = el.parentElement;
    let startNumOffset = 0;
    if (pre.hasAttribute('data-line-num-start')) {
      const startNumber = parseInt(pre.getAttribute('data-line-num-start'));
      if (startNumber || startNumber === 0) {
        pre.style.setProperty('counter-reset', 'lineNumber ' + (startNumber - 1));
        startNumOffset = startNumber - 1;
      }
    }
    const wrapper = el.closest(wrapperSelector);
    if (wrapper) {
      const wrapperHeight = wrapper.offsetHeight;
      const borderSpan = document.createElement('span');
      borderSpan.classList.add('hljs-border-span');
      el.appendChild(borderSpan);
      borderSpan.style.setProperty('position', 'absolute');
      borderSpan.style.setProperty('height', wrapperHeight + 'px');
      const myObserver = new ResizeObserver((entries) => {
        if (entries.length > 0 && entries[0]) {
          const entry = entries[0];
          const newHeight = entry.contentBoxSize ? entry.contentBoxSize[0].blockSize : entry.contentRect.height;
          borderSpan.style.setProperty('height', newHeight + 'px');
          // 行番号用 span 要素の高さを更新
          if (el.hasAttribute('data-border-span-height')) {
            borderSpan.style.setProperty('height', el.getAttribute('data-border-span-height') + 'px');
          }
        }
      });
      myObserver.observe(wrapper);
    }

    if (pre.hasAttribute('data-line-highlight')) {
      const targetLines = pre.getAttribute('data-line-highlight');
      const highlightCode = pre.classList.contains('no-highlight-code') ? false : true;
      const highlightNumber = pre.classList.contains('no-highlight-number') ? false : true;
      const targets = targetLines.split(',').map((val) => val.trim());
      if (targets.length > 0) {
        const lineNumSpan = el.querySelectorAll('.line-num');
        const lineLength = lineNumSpan.length;
        targets.forEach((target) => {
          const range = target.split('-');
          if (range.length === 2) {
            if (range[0] !== '') {
              const start = startNumOffset === 0 ? parseInt(range[0]) : parseInt(range[0]) - startNumOffset;
              const end = startNumOffset === 0 ? parseInt(range[1]) : parseInt(range[1]) - startNumOffset;
              if (start && end) {
                if (end >= start) {
                  for (let i = start; i <= end; i++) {
                    addClassToSpan(i);
                  }
                } else {
                  for (let i = end; i <= start; i++) {
                    addClassToSpan(i);
                  }
                }
              }
            } else {
              const negativeNum = (startNumOffset === 0 ? parseInt(range[1]) : parseInt(range[1])) * -1;
              addClassToSpan(negativeNum - startNumOffset);
            }
          } else if (range.length === 1) {
            addClassToSpan(startNumOffset === 0 ? parseInt(target) : parseInt(target) - startNumOffset);
          }
          function addClassToSpan(number) {
            if (number > 0 && number <= lineLength) {
              if (highlightCode) {
                const highlightSpan = document.createElement('span');
                highlightSpan.className = 'line-highlight';
                lineNumSpan.item(number - 1).after(highlightSpan);
              }
              if (highlightNumber) {
                lineNumSpan.item(number - 1).classList.add('line-num-highlight');
              }
            }
          }
        })
      }
    }
  }
});

以下は data-max-lines="20" data-scroll-to="26" を指定して20行分の高さで先頭に26行目を表示し、data-max-lines-offset="5" で 5px 分長く表示領域の高さ調整しています。

また、class="reset-btn" を指定してリセットボタンを表示しているので、任意の位置にスクロール後、ボタンをクリックすると初期のスクロール位置に戻します。

// このページの場合の設定例
hljs.addPlugin({
  'after:highlightElement': ({ el, result }) => {
    const pre = el.parentElement;
    if (!pre.hasAttribute('data-max-lines') || pre.hasAttribute('data-show-until')) return;
    const wrapper = pre.parentElement;
    if (!wrapper) return;
    const wrapperHeight = wrapper.offsetHeight;
    const lineNumSpan = el.querySelectorAll('.line-num');
    if (lineNumSpan.length > 0) {
      const dataMaxLine = parseInt(pre.getAttribute('data-max-lines'));
      if (dataMaxLine && lineNumSpan.length > dataMaxLine) {
        const dataMaxLineOffset = parseInt(pre.getAttribute('data-max-lines-offset'));
        const heightOffset = dataMaxLineOffset ? dataMaxLineOffset : 0;
        const targetOffsetTop = lineNumSpan.item(dataMaxLine).offsetTop;
        const elComputedStyle = window.getComputedStyle(el);
        const elPaddingTop = parseFloat(elComputedStyle.paddingTop);
        const elPaddingBottom = parseFloat(elComputedStyle.paddingBottom);
        //const visibleHeight = targetOffsetTop + heightOffset - elPaddingTop - elPaddingBottom;
        let visibleHeight = targetOffsetTop + heightOffset;
            if (pre.hasAttribute('data-scroll-to')) {
              visibleHeight = targetOffsetTop + heightOffset - elPaddingTop - elPaddingBottom;
            }
        el.style.setProperty('max-height', visibleHeight + 'px');
        el.setAttribute('data-border-span-height', wrapperHeight);
        el.style.setProperty('overflow-y', 'scroll');
        let scrollAmount = 0;
        if (pre.hasAttribute('data-scroll-to')) {
          const dataScrollTo = parseInt(pre.getAttribute('data-scroll-to'));
          if (dataScrollTo) {
            if(dataScrollTo + dataMaxLine > lineNumSpan.length + 1) {
              console.log('data-scroll-to or data-max-line is not valid')
              return;
            }
            const scrollToOffsetTop = lineNumSpan.item(dataScrollTo - 1).offsetTop;
            // const lastLineRow = lineNumSpan.item(dataScrollTo + dataMaxLine -2).offsetTop;
            const lastLineRow = lineNumSpan.item(dataScrollTo + dataMaxLine -1).offsetTop;
            const heightAdjustAmount = 4;
            el.style.setProperty('max-height', (lastLineRow - scrollToOffsetTop + heightOffset + heightAdjustAmount ) + 'px');
            const scrollAdjustAmount = 3;
            scrollAmount = scrollToOffsetTop - scrollAdjustAmount;
            el.scroll(0, scrollAmount);
          }
        }
        if (pre.classList.contains('reset-btn')) {
          const resetPositionButton = document.createElement('button');
          resetPositionButton.setAttribute('class', 'hljs-reset-position-btn');
          resetPositionButton.textContent = 'リセット';
          pre.after(resetPositionButton);
          resetPositionButton.addEventListener('click', () => {
            el.scroll(0, scrollAmount);
          });
        }
      }
    }
  }
});

参考:Plugin Recipes

VS Code スニペットの登録

例えば、毎回以下のようなコードを記述するのは面倒ですが、VS Code を使っている場合、このような定形のテキスト(コード)をユーザースニペットとして登録しておくことができます。

<div class="hljs-wrap" data-file="...">
  <pre><code class="language-html"></code></pre>
</div>

VS Code でユーザースニペットを登録するには、ツールバーから[Code]→[基本設定]→[ユーザースニペットの構成]を選択します。

スニペットの登録が表示されるので、HTML のスニペットを登録する場合は、html と入力すると html(HTML) が表示されるので選択します。すでに HTML スニペットを登録してある場合は、 html.json(既存のスニペット)を選択します。

以下はスニペットの登録例です。

"Hightlight.js Syntax Highlihgt ": {
  "prefix": "hljs",
  "body": [
    "<div class=\"hljs-wrap\"${1: data-file=\"$2\"}>",
    "\t<pre><code${3: class=\"language-${4:html}\"}>$5</code></pre>",
    "</div>"
  ],
  "description": "Code Block for Hightlight.js"
},

上記を登録すると、prefix に指定した文字列 hljs の一部を入力するとスニペット名が候補として表示されるので選択すると以下の HTM が挿入されます。

<div class="hljs-wrap">
  <pre data-file=""><code class="language-html"></code></pre>
</div>

そしてタブキーを押せば $n(n は数値)で指定したタブストップにカーソルが移動します。${n:初期値} の書式でプレースホルダーも指定できるので、初期値を設定することができます。

関連ページ:VS Code で Web 制作

コードの表示・非表示(トグルボタン)

コードを最初は非表示にしておき、ボタンをクリックするとコードを表示する例です。

details 要素と summary 要素でアコーディオンを作成します。このプラグインに特化したものではありませんが、時々このような使い方をするので掲載しています。

関連ページ:details と summary 要素 + JavaScript で作るアコーディオン

以下のような構造でマークアップして、<div class="details-content">〜</div> 内に表示するコードを <pre><code>〜 </code></pre> で記述します。コードは複数記述することもできます。

details 要素には CSS でスタイルを設定のためのクラス code と JavaScript でアニメーションの対象とするためのクラス js-animation を指定します。

<details class="code js-animation">
  <summary data-close-text="コードを閉じる">コードを見る</summary>
  <div class="details-content-wrapper">
    <div class="details-content">
      ここにコードを記述
    </div>
  </div>
</details>
コードを見る
ここにコードを記述

CSS

アコーディオン自体はコード以外にも使用するため、この例では details 要素に code クラスを指定してスタイルを設定しています。

/* コード用のアコーディオン */
details.code {
  border: none;
  margin: 2rem 0;
}

details.code .details-content-wrapper {
  padding: 1rem 0;
}

details.code .details-content {
  overflow: hidden;
}

details.code summary {
  display: inline-block;
  cursor: pointer;
  position: relative;
  padding: 0.5rem 1rem 0.5rem 32px;
  border: 1px solid #aaa;
  font-size: 13px;
}

details.code summary::-webkit-details-marker {
  display: none;
}

/* 独自のアイコンを擬似要素で作成 */
details.code summary::before {
  content: "";
  position: absolute;
  top: 0;
  bottom: 0;
  left: 10px;
  margin: auto 0;
  width: 8px;
  height: 8px;
  border-top: 3px solid #097b27;
  border-right: 3px solid #097b27;
  transform: rotate(45deg);
}

JavaScript

summary 要素をクリックして details 要素が開閉する際のアニメーションは JavaScript(Web Animation API)を使って実装しています。

また、summary 要素に data-close-text 属性が設定されていれば、summary 要素がクリックされてコードが表示された際にその文字列をラベルとして表示します。設定されていない場合は「詳細を閉じる」と表示します。必要に応じてデフォルトの文字を14行目で変更できます。

// アコーディオン
document.addEventListener('DOMContentLoaded', () => {
  // DOM ツリーの構築が完了したら定義した以下の関数を呼び出す
  setupToggleDetailsAnimation();
});

// details と summary を使ったアコーディオンアニメーションの関数
function setupToggleDetailsAnimation() {
  const details = document.querySelectorAll('details.code');
  details.forEach(elem => {
    const summary = elem.querySelector('summary');
    const content = elem.querySelector('.details-content');
    const summaryText = summary.textContent;
    const summaryCloseText = summary.dataset.closeText ? summary.dataset.closeText : '詳細を閉じる';
    let isAnimating = false;
    summary.addEventListener('click', (e) => {
      e.preventDefault();
      if (isAnimating === true) {
        return;
      }
      if (elem.open) {
        summary.textContent = summaryText;
        isAnimating = true;
        const closeDetails = content.animate(
          {
            opacity: [1, 0],
            height: [content.offsetHeight + 'px', 0],
          },
          {
            duration: 300,
            easing: 'ease-in',
          }
        );
        const rotateIcon = summary.animate(
          { rotate: ["90deg", "0deg"] },
          {
            duration: 300,
            pseudoElement: "::before",
            easing: 'ease-in',
            fill: 'forwards',
          }
        );
        closeDetails.onfinish = () => {
          elem.removeAttribute('open');
          isAnimating = false;
        }
      } else {
        elem.setAttribute('open', 'true');
        summary.textContent = summaryCloseText;
        isAnimating = true;
        const openDetails = content.animate(
          {
            opacity: [0, 1],
            height: [0, content.offsetHeight + 'px'],
          },
          {
            duration: 300,
            easing: 'ease-in',
          }
        );
        const rotateIcon = summary.animate(
          { rotate: ["0deg", "90deg"] },
          {
            duration: 300,
            pseudoElement: "::before",
            easing: 'ease-in',
            fill: 'forwards',
          }
        );
        openDetails.onfinish = () => {
          isAnimating = false;
        }
      }
    });
  });
};

例えば、以下のように記述すると、

<details class="code js-animation">
  <summary data-close-text="JavaScript を閉じる">JavaScript を表示</summary>
  <div class="details-content-wrapper">
    <div class="details-content">
      <div class="hljs-wrap">
        <pre data-file="foo.js"><code class="language-javascript">function add(x,y) {
  return x + y;
}
console.log(add(1,2));  // 3</code></pre>
      </div>
    </div>
  </div>
</details>

以下のように表示されます。

JavaScript を表示
function add(x,y) {
  return x + y;
}
console.log(add(1,2));  // 3

VS Code スニペットの登録

毎回この HTML を書くのは大変なので、VS Code を使用している場合はスニペットに登録しておくと便利です。以下はアコーディオンのマークアップのスニペットの例です。

以下の場合、タブキーを3回押すとコードを記述する位置(最後のタブストップ:8行目の $0)に移動するので、そこで更にコードのスニペットを呼び出すことができます。

"Accordion Code Bloc ": {
  "prefix": "accordion code",
  "body": [
    "<details class=\"code js-animation\">",
    "\t<summary data-close-text=\"${1:コードを閉じる}\">${2:コードを見る}</summary>",
    "\t<div class=\"details-content-wrapper\">",
    "\t\t<div class=\"details-content\">",
    "\t\t\t$0",
    "\t\t</div>",
    "\t</div>",
    "</details>"
  ],
  "description": "Code Block for Hightlight.js"
},
開閉パネルを JavaScript で追加

details 要素と summary 要素の開閉パネル(アコーディオン)を JavaScript で追加することもできます。

以下の関数 addAccordionPanel は第1引数に対象の要素(コードのラッパー)のクラス名を、第2引数にアコーディオンパネルを表示する場合に pre 要素に指定するクラス名を受取、対象のコードに開閉パネルのマークアップを追加します。

但し、この方法の場合、後から

// ラッパー要素にアコーディオンアニメーションの開閉パネルを追加する関数の定義
function addAccordionPanel(targetSelector, accordionClassName) {
  // アコーディオンパネルを開くボタンのテキスト
  const summaryOpenText = "コードを表示";
  // アコーディオンパネルを閉じるボタンのテキスト
  const summaryCloseText = "コードを閉じる";
  // details 要素に付与するクラス
  const detailsClass='code';
  // details 要素内のコンテンツを格納する div 要素に付与するクラス
  const detailsContentClass='details-content';
  // details 要素内のコンテンツを格納する要素のラッパーに付与するクラス
  const detailsContentWrapperClass='details-content-wrapper';
  // 対象の要素を取得
  const targetElems = document.getElementsByClassName(targetSelector);
  for (const elem of targetElems)  {
    const pre = elem.querySelector('pre');
    // pre 要素に accordionClassName('accordion')が指定されていれば
    if(pre && pre.classList.contains(accordionClassName)) {
      // details 要素を作成
      const details = document.createElement('details');
      details.classList.add(detailsClass);
      // 作成した details 要素の HTML(summary 要素と div 要素)を設定
      details.innerHTML = `<summary data-close-text="${summaryCloseText}">${summaryOpenText}</summary>
      <div class="${detailsContentWrapperClass}">
        <div class="${detailsContentClass}"></div>
      </div>`;
      // コードブロックのラッパー要素を details 要素でラップする
      elem.insertAdjacentElement('beforebegin', details);
      details.querySelector('.' + detailsContentClass).appendChild(elem);
    }
  }
}

setupToggleDetailsAnimation() の前で呼び出します。

// アコーディオンアニメーションの開閉パネルを追加する関数の呼び出し
addAccordionPanel('hljs-wrap', 'accordion');

// コード全体を表示・非表示するアコーディオンアニメーションの呼び出し
setupToggleDetailsAnimation();
              

要素をラップする際の注意点

コードブロックのラッパー要素を details 要素でラップするには、insertAdjacentElementappendChild を使っています。

18〜30行目の部分を、outerHTML を使って、以下のように記述すると、アコーディオンのアニメーションは適用されますが、内部の outerHTML は書き換わってしまうので、ツールバーに設定した行番号や折り返しの切り替えのイベントリスナーが取り除かれてしまい、機能しなくなってしまいます。

if(pre && pre.classList.contains(accordionClassName)) {
  // コードブロックのラッパー要素を details 要素でラップ(この方法ではコードブロックのイベントリスナーが削除される)
  elem.outerHTML =  `<details class="${detailsClass}">
    <summary data-close-text="${summaryCloseText}">${summaryOpenText}</summary>
    <div class="${detailsContentWrapperClass}">
      <div class="${detailsContentClass}">${elem.outerHTML}</div>
    </div>
  </details>`;
}
制限事項(バグ)

アコーディオンのコンテンツ(div.details-content)に、指定された行以降を表示・非表示する data-show-until 属性や表示する行数を指定する data-max-lines 属性を指定すると、Google Chrome では期待通りに表示されますが FireFox や Safari、iOS(iPhone)ではコンテンツの高さが取得できず、正しく表示されません。

<details class="code js-animation">
  <summary data-close-text="コードを閉じる">コードを表示</summary>
  <div class="details-content-wrapper">
    <div class="details-content">
      <div class="hljs-wrap">
        <!-- data-show-until または data-max-lines 属性を指定すると正しく表示されない -->
        <pre data-label="foo.js" data-show-until-"5"><code>...</code></pre>
      </div>
    </div>
  </div>
</details>

以下は上記の例のようにコンテンツの pre 要素に data-show-until 属性を指定して5行分表示する例です。

Chrome では正しく表示されますが、 FireFox や Safari、iOS などではコード部分が表示されません。

コードを表示
<details class="code js-animation">
  <summary data-close-text="JavaScript を閉じる">JavaScript を表示</summary>
  <div class="details-content-wrapper">
    <div class="details-content">
      <div class="hljs-wrap">
        <pre data-file="foo.js"><code class="language-javascript">function add(x,y) {
  return x + y;
}
console.log(add(1,2));  // 3</code></pre>
      </div>
    </div>
  </div>
</details>

サンプルコード

以下はカスタマイズのサンプルコードです。但し、覚書的なもので実用レベルではありませんので参考程度に(おかしなところも多々あると思います)。

JavaScript は highlight.min.js の読み込みの後に、CSS はテーマの CSS の読み込みの後に記述すれば動作すると思います。必要に応じてページ全体でプラグインを有効・無効にするためのクラスなどを設定すると良いかも知れません(有効・無効のオプション)。

基本的には、以下のように pre code を任意のクラス名を指定した div 要素でラップします。

デフォルトでは行番号と言語名、コピーボタンが表示されます。必要に応じてクラスやカスタムデータ属性を指定します。

<div class="hljs-wrap">
  <pre><code>ハイライト表示するコード</code></pre>
</div>

ラッパーの div 要素のクラス名は hljs-wrap としていますが、異なるクラスにする場合は、以下の2行目で任意のクラスを指定できます。但し、CSS の全ての .hljs-wrap の部分を書き換える必要があります。

6行目の fixedLineNumbers を true にすると、pre 要素で行を折り返さない設定(white-space: pre)で、横スクロールの際に行番号を固定表示します。

プラグインの定義は Highlight.js の初期化の前に読み込まれる必要があります。以下では Highlight.js の初期化は DOMContentLoaded の中で行っていますが、プラグインの定義も DOMContentLoaded の中で行う場合は初期化の前に記述します。

// pre code のラッパーのセレクタを指定
const wrapperSelector = 'div.hljs-wrap';
// pre と code の間の改行を削除するかどうか
let removeLineBrake = true;
// pre 要素で自動で折り返さない設定(white-space: pre)で、行番号を固定表示するかどうか
const fixedLineNumbers = false;
// pre 要素なしの code 要素でもハイライトするかどうか
const useInlineHighlight = true;
// pre 要素なしの code 要素でハイライトする場合に code 要素に指定するクラス
const inlineHighlightClass = '.highlight';

document.addEventListener('DOMContentLoaded', () => {
  // 行番号を固定表示する場合
  if (fixedLineNumbers) {
    document.querySelectorAll(wrapperSelector + ' pre code').forEach((elem) => {
      elem.style.setProperty('position', 'static');
      elem.parentElement.style.setProperty('position', 'relative');
      elem.parentElement.style.setProperty('overflow-y', 'hidden');
      removeLineBrake = true;
    });
  }

  // pre と code の間の改行を削除する場合
  if (removeLineBrake) {
    document.querySelectorAll(wrapperSelector + ' pre').forEach((elem) => {
      elem.innerHTML = elem.innerHTML.replace(/\n*\s*<code(.*)>/g, '<code$1>').replace(/<\/code>\n*\s*/g, '</code>');
    });
  }

  // Highlight.js の初期化 <div class="hljs-wrap"> の子孫の <pre><code> のみをハイライト表示
  document.querySelectorAll(wrapperSelector + ' pre code').forEach((el) => {
    hljs.highlightElement(el);
  });

  if (useInlineHighlight) {
    // pre 要素なしの code 要素でハイライト
    document.querySelectorAll('code' + inlineHighlightClass).forEach((el) => {
      hljs.highlightElement(el);
    });
  }

  // ラベル(任意のテキスト)とリンクの表示
  const hljsWrappers = document.querySelectorAll(wrapperSelector);
  if (hljsWrappers.length > 0) {
    hljsWrappers.forEach((wrapper) => {
      const pre = wrapper.firstElementChild;
      if (!pre) {
        return;
      }
      if (!pre.hasAttribute('data-label')) {
        return;
      }
      const label = pre.getAttribute('data-label');
      let element;
      if (pre.hasAttribute('data-label-url')) {
        element = document.createElement('a');
        element.href = pre.getAttribute('data-label-url');
        element.classList.add('hljs-label-url')
      } else {
        element = document.createElement('span');
        element.classList.add('hljs-label');
      }
      element.textContent = label;
      wrapper.appendChild(element);
    });
  }
  // アコーディオンアニメーションの開閉パネルを追加する関数の呼び出し
  addAccordionPanel('hljs-wrap', 'accordion');
  // コード全体を表示・非表示するアコーディオンアニメーションの呼び出し
  setupToggleDetailsAnimation();
});


// カスタムプラグイン(Hightlight.js の addPlugin でプラグインを定義)
hljs.addPlugin({
  'after:highlightElement': ({ el, result, text }) => {
    const wrapper = el.closest(wrapperSelector);
    const pre = el.parentElement;
    showLanguage(el, result);
    copyCode(el, result, text);
    lineNumbers(el, result, wrapper, pre);
    toggleCode(el, result, wrapper, pre);
    setMaxHeight(el, result, wrapper, pre);
  }
});

// 言語名を表示するプラグイン用の関数
function showLanguage(el, result) {
  if (el.classList.contains('show-no-lang')) return;
  if (result.language) {
    el.dataset.language = result.language;
  }
}

// コードをコピーするプラグイン用の関数
function copyCode(el, result, text) {
  if (el.parentElement.classList.contains('no-copy-btn')) return;
  if (useInlineHighlight && el.parentElement.nodeName !== 'PRE') return;
  const copyButton = document.createElement('button');
  copyButton.setAttribute('class', 'hljs-copy-btn');
  copyButton.textContent = 'Copy';
  el.parentElement.after(copyButton);
  copyButton.addEventListener('click', () => {
    copyToClipboard(copyButton, text)
  });
  function copyToClipboard(btn, text) {
    if (!navigator.clipboard) {
      alert('Sorry, can not copy');
    }
    navigator.clipboard.writeText(text).then(
      () => {
        btn.textContent = 'Copied';
        resetCopyBtnText(btn, 1500);
      },
      (error) => {
        btn.textContent = 'Failed';
        resetCopyBtnText(btn, 1500);
        console.log(error.message);
      }
    );
  };
  function resetCopyBtnText(btn, delay) {
    setTimeout(() => {
      btn.textContent = 'Copy'
    }, delay)
  }
}

// 行番号表示と行のハイライト表示のプラグイン用の関数
function lineNumbers(el, result, wrapper, pre) {
  if (el.parentElement.classList.contains('no-line-num')) return;
  el.innerHTML = result.value.replace( /^/gm, '<span class="line-num"></span>');
  let startNumOffset = 0;
  if (pre.hasAttribute('data-line-num-start')) {
    const startNumber = parseInt(pre.getAttribute('data-line-num-start'));
    if (startNumber || startNumber === 0) {
      pre.style.setProperty('counter-reset', 'lineNumber ' + (startNumber - 1));
      startNumOffset = startNumber - 1;
    }
  }
  if (wrapper) {
    const wrapperHeight = wrapper.offsetHeight;
    const borderSpan = document.createElement('span');
    borderSpan.classList.add('hljs-border-span');
    el.appendChild(borderSpan);
    borderSpan.style.setProperty('position', 'absolute');
    borderSpan.style.setProperty('height', wrapperHeight + 'px');
    const myObserver = new ResizeObserver((entries) => {
      if (entries.length > 0 && entries[0]) {
        const entry = entries[0];
        const newHeight = entry.contentBoxSize ? entry.contentBoxSize[0].blockSize : entry.contentRect.height;
        borderSpan.style.setProperty('height', newHeight + 'px');
        if (el.hasAttribute('data-border-span-height')) {
          borderSpan.style.setProperty('height', el.getAttribute('data-border-span-height') + 'px');
        }
      }
    });
    myObserver.observe(wrapper);
  }

  if (pre.hasAttribute('data-line-highlight')) {
    const targetLines = pre.getAttribute('data-line-highlight');
    const highlightCode = pre.classList.contains('no-highlight-code') ? false : true;
    const highlightNumber = pre.classList.contains('no-highlight-number') ? false : true;
    const targets = targetLines.split(',').map((val) => val.trim());
    if (targets.length > 0) {
      const lineNumSpan = el.querySelectorAll('.line-num');
      const lineLength = lineNumSpan.length;
      targets.forEach((target) => {
        const range = target.split('-');
        if (range.length === 2) {
          if (range[0] !== '') {
            const start = startNumOffset === 0 ? parseInt(range[0]) : parseInt(range[0]) - startNumOffset;
            const end = startNumOffset === 0 ? parseInt(range[1]) : parseInt(range[1]) - startNumOffset;
            if (start && end) {
              if (end >= start) {
                for (let i = start; i <= end; i++) {
                  addClassToSpan(i);
                }
              } else {
                for (let i = end; i <= start; i++) {
                  addClassToSpan(i);
                }
              }
            }
          } else {
            const negativeNum = (startNumOffset === 0 ? parseInt(range[1]) : parseInt(range[1])) * -1;
            addClassToSpan(negativeNum - startNumOffset);
          }
        } else if (range.length === 1) {
          addClassToSpan(startNumOffset === 0 ? parseInt(target) : parseInt(target) - startNumOffset);
        }
        function addClassToSpan(number) {
          if (number > 0 && number <= lineLength) {
            if (highlightCode) {
              const highlightSpan = document.createElement('span');
              highlightSpan.className = 'line-highlight';
              lineNumSpan.item(number - 1).after(highlightSpan);
            }
            if (highlightNumber) {
              lineNumSpan.item(number - 1).classList.add('line-num-highlight');
            }
          }
        }
      })
    }
  }
}

// 指定された行以降をアニメーションで表示・非表示するプラグイン用の関数
function toggleCode(el, result, wrapper, pre) {
  let windowScrollY;
  if (!pre.hasAttribute('data-show-until')) return;
  if (pre.hasAttribute('data-max-lines')) {
    console.log('data-max-lines と data-show-until は併用はできません');
    return;
  }
  const lineNumSpan = el.querySelectorAll('.line-num');
  if (lineNumSpan.length > 0) {
    const dataShowUntil = parseInt(pre.getAttribute('data-show-until'));
    if (dataShowUntil && lineNumSpan.length > dataShowUntil) {
      const showUntilDataOffset = parseInt(pre.getAttribute('data-show-until-offset'));
      const heightOffset = showUntilDataOffset ? showUntilDataOffset : 0;
      let startNumOffset = 0;
      if (pre.hasAttribute('data-line-num-start')) {
        const startNumber = parseInt(pre.getAttribute('data-line-num-start'));
        if (startNumber || startNumber === 0) {
          startNumOffset = startNumber - 1;
        }
      }
      const hideFrom = dataShowUntil - startNumOffset;
      const hideFromOffsetTop = lineNumSpan.item(hideFrom).offsetTop;
      const reducedHeight = hideFromOffsetTop + heightOffset;
      const preHeight = pre.offsetHeight;
      pre.style.setProperty('overflow-y', 'hidden');
      pre.style.setProperty('height', reducedHeight + 'px');
      const expandButton = document.createElement('button');
      expandButton.setAttribute('class', 'hljs-expand-btn');
      const expandButtonLabel = pre.hasAttribute('data-show-until-label') ? pre.getAttribute('data-show-until-label') : 'Expand';
      expandButton.textContent = expandButtonLabel;
      const expandButtonLabelReduce = pre.hasAttribute('data-show-until-label-reduce') ? pre.getAttribute('data-show-until-label-reduce') : 'Reduce';
      pre.after(expandButton);
      let isAnimating = false;
      let isExpanded = false;
      expandButton.addEventListener('click', () => {
        if (isAnimating === true) {
          return;
        }
        if (!isExpanded) {
          windowScrollY = window.scrollY
          expandButton.textContent = expandButtonLabelReduce;
          isAnimating = true;
          const expandPre = pre.animate(
            {
              height: [reducedHeight + 'px', preHeight + 'px'],
            },
            {
              duration: 300,
              easing: 'ease-in',
              fill: 'forwards',
            }
          );
          expandPre.onfinish = () => {
            isExpanded = true;
            isAnimating = false;
          }
        } else {
          expandButton.textContent = expandButtonLabel;
          isAnimating = true;
          const reducePre = pre.animate(
            {
              height: [preHeight + 'px', reducedHeight + 'px'],
            },
            {
              duration: 300,
              easing: 'ease-in',
              fill: 'forwards',
            }
          );
          reducePre.onfinish = () => {
            isExpanded = false;
            isAnimating = false;
            if (pre.classList.contains('scroll')) {
              if (pre.classList.contains('smooth')) {
                window.scroll({ top: windowScrollY, behavior: 'smooth' });
              } else {
                window.scroll({ top: windowScrollY });
              }
            }
          }
        }
      });
    }
  }
}

// 表示する行数を指定して code 要素に max-height を設定するプラグイン用の関数
function setMaxHeight(el, result, wrapper, pre) {
  if (!pre.hasAttribute('data-max-lines') || pre.hasAttribute('data-show-until')) return;
  if (fixedLineNumbers) {
    console.log('data-max-lines は fixedLineNumbers : true では機能しません。');
    return;
  }
  if (!wrapper) return;
  const wrapperHeight = wrapper.offsetHeight;
  const lineNumSpan = el.querySelectorAll('.line-num');
  if (lineNumSpan.length > 0) {
    const dataMaxLine = parseInt(pre.getAttribute('data-max-lines'));
    if (dataMaxLine && lineNumSpan.length > dataMaxLine) {
      const dataMaxLineOffset = parseInt(pre.getAttribute('data-max-lines-offset'));
      const heightOffset = dataMaxLineOffset ? dataMaxLineOffset : 0;
      const targetOffsetTop = lineNumSpan.item(dataMaxLine).offsetTop;
      const elComputedStyle = window.getComputedStyle(el);
      const elPaddingTop = parseFloat(elComputedStyle.paddingTop);
      const elPaddingBottom = parseFloat(elComputedStyle.paddingBottom);
      const visibleHeight = targetOffsetTop + heightOffset - elPaddingTop - elPaddingBottom;
      el.style.setProperty('max-height', visibleHeight + 'px');
      el.setAttribute('data-border-span-height', wrapperHeight);
      el.style.setProperty('overflow-y', 'scroll');
      let scrollAmount = 0;
      if (pre.hasAttribute('data-scroll-to')) {
        const dataScrollTo = parseInt(pre.getAttribute('data-scroll-to'));
        if (dataScrollTo) {
          if(dataScrollTo + dataMaxLine > lineNumSpan.length + 1) {
            console.log('data-scroll-to or data-max-line is not valid')
            return;
          }
          const scrollToOffsetTop = lineNumSpan.item(dataScrollTo - 1).offsetTop;
          const lastLineRow = lineNumSpan.item(dataScrollTo + dataMaxLine - 2).offsetTop;
          const heightAdjustAmount = -8;
          el.style.setProperty('max-height', (lastLineRow - scrollToOffsetTop + heightOffset + heightAdjustAmount) + 'px');
          const scrollAdjustAmount = 3;
          scrollAmount = scrollToOffsetTop - scrollAdjustAmount;
          el.scroll(0, scrollAmount);
        }
      }
      if (pre.classList.contains('reset-btn')) {
        const resetPositionButton = document.createElement('button');
        resetPositionButton.setAttribute('class', 'hljs-reset-position-btn');
        resetPositionButton.textContent = 'リセット';
        pre.after(resetPositionButton);
        resetPositionButton.addEventListener('click', () => {
          el.scroll(0, scrollAmount);
        });
      }
    }
  }
}

// ラッパー要素にアコーディオンアニメーションの開閉パネルを追加する関数の定義
function addAccordionPanel(targetSelector, accordionClassName) {
  // アコーディオンパネルを開くボタンのテキスト
  const summaryOpenText = "コードを表示";
  // アコーディオンパネルを閉じるボタンのテキスト
  const summaryCloseText = "コードを閉じる";
  // details 要素に付与するクラス(385行目のクラス名と一致させる)
  const detailsClass='code';
  // details 要素内のコンテンツを格納する div 要素に付与するクラス
  const detailsContentClass='details-content';
  // details 要素内のコンテンツを格納する要素のラッパーに付与するクラス
  const detailsContentWrapperClass='details-content-wrapper';

  const targetElems = document.getElementsByClassName(targetSelector);
  for (const elem of targetElems)  {
    const pre = elem.querySelector('pre');
    // pre 要素に accordionClassName('accordion')が指定されていれば
    if(pre && pre.classList.contains(accordionClassName)) {
      // details 要素を作成
      const details = document.createElement('details');
      details.classList.add(detailsClass);
      // 作成した details 要素の HTML(summary 要素と div 要素)を設定
      details.innerHTML = `<summary data-close-text="${summaryCloseText}">${summaryOpenText}</summary>
      <div class="${detailsContentWrapperClass}">
        <div class="${detailsContentClass}"></div>
      </div>`;
      // コードブロックのラッパー要素を details 要素でラップする
      elem.insertAdjacentElement('beforebegin', details);
      details.querySelector('.' + detailsContentClass).appendChild(elem);
    }
  }
}

// アコーディオンアニメーションの関数の定義
function setupToggleDetailsAnimation() {
  const details = document.querySelectorAll('details.code');
  details.forEach(elem => {
    const summary = elem.querySelector('summary');
    const content = elem.querySelector('.details-content');
    const summaryText = summary.textContent;
    const summaryCloseText = summary.dataset.closeText ? summary.dataset.closeText : '詳細を閉じる';
    let isAnimating = false;
    summary.addEventListener('click', (e) => {
      e.preventDefault();
      if (isAnimating === true) {
        return;
      }
      if (elem.open) {
        summary.textContent = summaryText;
        isAnimating = true;
        const closeDetails = content.animate(
          {
            opacity: [1, 0],
            height: [content.offsetHeight + 'px', 0],
          },
          {
            duration: 300,
            easing: 'ease-in',
          }
        );
        const rotateIcon = summary.animate(
          { rotate: ["90deg", "0deg"] },
          {
            duration: 300,
            pseudoElement: "::before",
            easing: 'ease-in',
            fill: 'forwards',
          }
        );
        closeDetails.onfinish = () => {
          elem.removeAttribute('open');
          isAnimating = false;
        }
      } else {
        elem.setAttribute('open', 'true');
        summary.textContent = summaryCloseText;
        isAnimating = true;
        const openDetails = content.animate(
          {
            opacity: [0, 1],
            height: [0, content.offsetHeight + 'px'],
          },
          {
            duration: 300,
            easing: 'ease-in',
          }
        );
        const rotateIcon = summary.animate(
          { rotate: ["0deg", "90deg"] },
          {
            duration: 300,
            pseudoElement: "::before",
            easing: 'ease-in',
            fill: 'forwards',
          }
        );
        openDetails.onfinish = () => {
          isAnimating = false;
        }
      }
    });
  });
};

サンプル

指定できるカスタムデータ属性やクラス

以下は上記のサンプルコードを使用した場合に、pre 要素や code 要素に設定できるカスタムデータ属性とクラス属性の例です。

div.hljs-wrap (pre 要素の親要素)に指定できるカスタムデータ属性
カスタムデータ属性 説明
data-file 文字列 ファイル名(任意の文字列)を表示。スタイルは .hljs-wrap[data-file]::before で指定。
pre 要素に指定できるカスタムデータ属性
カスタムデータ属性 説明
data-line-highlight カンマ区切りの数値 指定した行を CSS で指定した背景色でハイライト表示。カンマ区切りで指定。ハイフンでレンジ指定も可能
data-label 文字列 ラベル(または任意の文字列)を表示。data-label-url に URL を指定するとリンクとして表示。
data-label-url 文字列 data-label のテキストのリンク(href)
data-line-num-start 数値 開始行番号
data-show-until 数値 指定された行以降を非表示にして、ボタンをクリックすると全ての行を表示
data-show-until-offset 数値 data-show-until で指定した行の範囲(表示部分)のオフセットを指定(単位はピクセル)
data-show-until-label 文字列 ボタンのテキスト。デフォルトは Expand
data-show-until-label-reduce 文字列 ボタンのテキスト。デフォルトは Reduce
data-max-lines 数値 表示する行数を指定(data-show-until と併用はできません)
data-max-lines-offset 数値 data-max-lines で指定した表示部分のオフセットを指定(単位はピクセル)
data-scroll-to 数値 data-max-lines を指定された際に、指定された行までスクロール
pre 要素に指定できるクラス属性
クラス 説明
no-copy-btn コピーボタンを表示しない
scroll data-show-until 属性を指定して、指定行以降を一度表示後、ボタンをクリックして非表示にする際に元の位置にスクロールさせる
smooth scroll クラスを指定した際にスムーススクロールさせる
no-highlight-code data-line-highlight を指定して行をハイライトする際にコード部分はハイライトしない
no-highlight-number data-line-highlight を指定して行をハイライトする際に行番号部分はハイライトしない
no-line-num 行番号を表示しない
pre-wrap 行を自動で折り返す(CSS で設定)
pre 行を自動で折り返さない(CSS で設定)
reset-btn data-max-lines を指定している場合に、表示位置をリセットするボタンを表示する
accordion 開閉パネルを表示して、クリックするとアコーディオンアニメーションで表示
code 要素に指定できるクラス属性
クラス 説明
language-言語名 言語名を明示的に指定(Hightlight.js の仕様)
show-no-lang 言語名を表示しない(デフォルトは表示する)

以下はスタイルの設定例です。必要に応じてマージンやパディング、幅や色などは変更します。

また、親要素のクラス名(.hljs-wrap)を JavaScript で変更した場合は、CSS(以下)も全て変更します。

/* pre 要素のラッパー */
.hljs-wrap {
  /* 最大幅(必要に応じて) */
  max-width: 780px;
  /* 上下のマージンはファイル名やボタンの表示を考慮する必要あり */
  margin: 3rem 0;
  /* 絶対配置の基準 */
  position: relative;
}

.hljs-wrap pre {
  /* 行の自動折返しのデフォルトを設定(以下の場合は行を自動で折り返す) */
  white-space: pre-wrap;
}

.hljs-wrap pre.pre-wrap {
  /* pre-wrap クラスを指定すると行を自動で折り返す */
  white-space: pre-wrap;
}

.hljs-wrap pre.pre {
  /* pre クラスを指定すると行を自動で折返さない */
  white-space: pre;
}

/* 言語名を表示 */
.hljs-wrap code[data-language]::before {
  content: attr(data-language);
  position: absolute;
  top: 0;
  right: 0;
  color: #ccc;
  display: inline-block;
  padding: 0.5rem;
}

/* ファイル名を表示(div.hljs-wrap に data-file 属性を指定) */
.hljs-wrap[data-file]::before {
  content: attr(data-file);
  position: absolute;
  top: -2rem;
  left: 0;
  color: #999;
  display: inline-block;
  padding: 0.5rem 0;
}

.hljs-wrap[data-file] {
  margin-top: 4rem;
}

/* コピーボタン */
.hljs-wrap .hljs-copy-btn {
  position: absolute;
  bottom: 0;
  right: 0;
  background-color: rgba(201, 213, 245, 0.1);
  border: none;
  padding: 3px 10px;
  color: #999;
  cursor: pointer;
  transition: color .3s;
}

.hljs-wrap .hljs-copy-btn:hover {
  color: #eee
}

/* ラベル&リンク */
.hljs-wrap .hljs-label,
.hljs-wrap .hljs-label-url {
  position: absolute;
  bottom: -2.5rem;
  left: 0;
  color: #999;
  display: inline-block;
  padding: 0.5rem 0;
}

.hljs-wrap .hljs-label-url {
  color: #3987C7;
  text-decoration: none;
}

.hljs-wrap .hljs-label-url:hover {
  color: #55924f;
}

pre[data-label] {
  margin-bottom: 5rem;
}

/* 行番号 */
.hljs-wrap pre {
  counter-reset: lineNumber;
}

.hljs-wrap pre.no-line-num code {
  padding-left: 1em;
}

.hljs-wrap pre code {
  padding-left: 2.75rem;
  padding-bottom: 1.5em;
  position: relative;
}

.hljs-wrap pre span.line-num::before {
  counter-increment: lineNumber;
  content: counter(lineNumber);
  min-width: 2.5rem;
  display: inline-block;
  color: #777;
  text-align: center;
  position: absolute;
  left: 0;
  background: #282c34;
}

.hljs-wrap .hljs-border-span {
  background-color: transparent;
  width: 2.5rem;
  border-right: 1px solid #595a60;
  top: 0;
  left: 0;
}

.hljs-wrap pre span.line-num.line-num-highlight::before {
  color: #c2c21a;
  /* 行番号を固定表示する場合は半透明にしない */
  background: #424638;
}

.hljs-wrap .line-highlight {
  position:absolute;
  /* 行番号の幅を左側に確保 */
  left: 2.5rem;
  width: 100%;
  background: linear-gradient(to right, hsla(254, 15%, 51%, 0.25) 50%, hsla(254, 15%, 51%, 0.01));
  /* 高さは環境に応じて調整 */
  height: 1.2rem;
  /* 文字を選択できるように */
  pointer-events: none;
}

/* 指定された行以降を表示・非表示にする場合に表示するボタンとリセットボタン */
.hljs-wrap .hljs-expand-btn,
.hljs-wrap .hljs-reset-position-btn {
  position: absolute;
  bottom: -2.5rem;
  right: 0;
  background-color: rgba(22, 40, 88, 1);
  border: none;
  padding: 5px 10px;
  color: #bbb;
  cursor: pointer;
  transition: color .3s;
}

.hljs-wrap .hljs-expand-btn:hover,
.hljs-wrap .hljs-reset-position-btn:hover {
  color: #eee
}

/* ボタンをコードの下(外側)に表示する場合 */
pre[data-show-until] {
  margin-bottom: 5.5rem;
}

/* コードの表示・非表示( details 要素と summary 要素によるアコーディオン)*/
details.code {
  border: none;
  margin: 2rem 0;
}

details.code .details-content-wrapper {
  padding: 1rem 0;
}

details.code .details-content {
  overflow: hidden;
}

details.code summary {
  display: inline-block;
  cursor: pointer;
  position: relative;
  padding: 0.5rem 0.5rem 0.5rem 36px;
  border: 1px solid #aaa;
  font-size: 13px;
}

details.code summary::-webkit-details-marker {
  display: none;
}

details.code summary::before {
  content: "";
  position: absolute;
  top: 0;
  bottom: 0;
  left: 10px;
  margin: auto 0;
  width: 8px;
  height: 8px;
  border-top: 3px solid #097b27;
  border-right: 3px solid #097b27;
  transform: rotate(45deg);
}

/* テーマ(atom-one-dark)のコメントの色を上書き */
.hljs-wrap .hljs-comment {
  color: #6b788f;
}

また、以下のような行番号表示や折り返しの切り替え、コピーボタンなどをツールバーに表示するカスタマイズ例を「Highlight.js のカスタマイズサンプル」に掲載していますのでよろしければ御覧ください。

以下はサンプルファイルを iframe で表示しています。

highlightjs-line-numbers.js で行番号を表示

以下は行番号を表示するために、別途 highlightjs-line-numbers.js というプラグインをダウンロードして利用する方法です。

このプラグインは、以下のページからダウンロードできます。現時点でのバージョンは v2.8.0 で最終更新は Jan 2, 2022 です。作者によると、highlight.js 11.3.1. で動作するとあります。

Github ページ:highlightjs-line-numbers.js

使い方は、Highlight.js の読み込みの後にダウンロードした(または CDN の)highlightjs-line-numbers.min.js を読み込みます。そして hljs.highlightAll() の後に、hljs.initLineNumbersOnLoad() を記述して読み込んだプラグインを初期化します。

<!-- Highlight.js の読み込み-->
<script src="xxxx/highlight.min.js"></script>
<!-- Highlight.js の読み込みの後に highlightjs-line-numbers.min.js を読み込む-->
<script src="xxxx/highlightjs-line-numbers.min.js"></script>
<!-- hljs.highlightAll() の後に hljs.initLineNumbersOnLoad() を実行-->
<script>hljs.highlightAll(); hljs.initLineNumbersOnLoad(); </script>

hljs.initLineNumbersOnLoad() は以下とほぼ同じです。

document.addEventListener('DOMContentLoaded', (event) => {
  hljs.highlightAll(); // Highlight.js の初期化
  document.querySelectorAll('code.hljs').forEach((el) => {
    hljs.lineNumbersBlock(el);
  });
});

例えば、ln クラスが指定された code 要素のみ行番号を表示するには、以下のように記述できます。

document.addEventListener('DOMContentLoaded', (event) => {
  hljs.highlightAll(); // Highlight.js の初期化
  document.querySelectorAll('code.hljs.ln').forEach((el) => {
    hljs.lineNumbersBlock(el);
  });
});

以下は行番号のスタイルの例です。

user-select: none を設定しておけば、コード全体を選択してコピーする場合などに、行番号を除外することができます。

.hljs-ln-numbers {
  font-family: inherit;
  -webkit-touch-callout: none;
  -webkit-user-select: none;
  -khtml-user-select: none;
  -moz-user-select: none;
  -ms-user-select: none;
  user-select: none; /* 行番号を選択できないように */
  text-align: center;
  color: #5f9168; /* 行番号の色 */
  vertical-align: top;
  min-width: 2.5rem;
  border-right: 1px solid #666; /* 行番号の右側に枠線を表示 */
}

/* コードの左側の余白 */
.hljs-ln-code.hljs-ln-line {
  padding-left: .75rem;
}

/* 行番号の左側の余白 */
.hljs-ln {
  margin-left: -.5em;
}

利用できる CSS セレクタ

以下の CSS セレクターを使用して行や行番号のスタイルを設定できます。

CSS セレクタ 説明
.hljs-ln-line 行番号を含むすべての行を選択
.hljs-ln-numbers コード行を除くすべての行番号を選択
.hljs-ln-code 行番号を除くコードのすべての行を選択
.hljs-ln-line[data-line-number="i"] 行番号を含む i 番目の行を選択
.hljs-ln-numbers[data-line-number="i"] コード行を除く i 番目の行番号を選択
.hljs-ln-code[data-line-number="i"] 行番号を除いたコードの i 行目を選択

行の開始番号を指定

行の開始番号を code 要素の data-ln-start-from 属性に指定することができます。

<pre><code data-ln-start-from="10">
    ...
</code></pre>

行番号を非表示

code 要素に nohljsln クラスを指定すると、行番号を表示しません。

<pre><code class="nohljsln">
    ...
</code></pre>

指定した行をハイライト

highlightjs-line-numbers.js は、コードを table 要素を使って整形し、各行は tr 要素でマークアップされて出力されます。

以下は pre 要素に data-line-highlight 属性を設定して、ハイライトする行を指定すると、その行(tr 要素)にクラス(line-highlight)を追加してハイライト表示する例です。

highlightjs-line-numbers.js により出力される table 要素には hljs-ln クラスが付与されています。

この場合、DOMContentLoaded イベントでは、highlightjs-line-numbers.js により出力される table 要素(.hljs-ln)を取得できないので、load イベントを使用します。

また、負の値のレンジ指定(例: -3-0)には対応していません(ハイライトしません)。

window.addEventListener('load', () => {
  // table 要素(.hljs-ln)を取得
  const tables = document.querySelectorAll('.hljs-ln');
  if (tables.length === 0) return; // .hljs-ln が存在しなければ何もしない
  // data-line-highlight 属性を指定した pre 要素を取得
  const lineHighlightElems = document.querySelectorAll('pre[data-line-highlight]');
  // data-line-highlight 属性を指定した pre 要素が存在すれば
  if (lineHighlightElems.length > 0 && lineHighlightElems) {
    lineHighlightElems.forEach((elem) => {
      // 子要素の code 要素を取得
      const code = elem.querySelector('code');
      // 開始行番号のオフセットの初期値
      let startNumOffset = 0;
      // code 要素の data-ln-start-from 属性に開始行番号が指定されていれば
      if (code.hasAttribute('data-ln-start-from')) {
        const startNumber = parseInt(code.getAttribute('data-ln-start-from'));
        if (startNumber || startNumber === 0) {
          // 開始行番号のオフセットに開始行番号を代入
          startNumOffset = startNumber - 1;
        }
      }
      // data-line-highlight 属性の値を取得
      const lineHigjlight = elem.getAttribute('data-line-highlight');
      // data-line-highlight 属性の値をカンマで分割して前後の空白文字を除去
      const lines = lineHigjlight.split(',').map((val) => val.trim());
      // lines(配列)の要素が1つ以上であれば
      if (lines.length > 0) {
        // pre code table から tr(行の要素)を全て取得
        const lineTrs = elem.querySelectorAll('tr');
        // 行の要素 tr の総数
        const lineTrsLength = lineTrs.length;
        // data-line-highlight 属性の値をカンマで分割したそれぞれの値にについて
        lines.forEach((num) => {
          // '-' で分割
          const range = num.split('-');
          // ハイフンで指定されている場合
          if (range.length === 2) {
            // 負の値でない場合
            if (range[0] !== '') {
              // 開始番号が設定されていればオフセットの差分を適用
              const start = startNumOffset === 0 ? parseInt(range[0]) : parseInt(range[0]) - startNumOffset;
              const end = startNumOffset === 0 ? parseInt(range[1]) : parseInt(range[1]) - startNumOffset;
              if (start && end) {
                if (end >= start) {
                  for (let i = start; i <= end; i++) {
                    addClassToLineTr(i);
                  }
                } else {
                  for (let i = end; i <= start; i++) {
                    addClassToLineTr(i);
                  }
                }
              }
            } else { // 負の値の場合
              const negativeNum = (startNumOffset === 0 ? parseInt(range[1]) : parseInt(range[1])) * -1;
              addClassToLineTr(negativeNum - startNumOffset);
            }
          } else if (range.length === 1) {
            // レンジで指定されていない場合
            addClassToLineTr(startNumOffset === 0 ? parseInt(num) : parseInt(num) - startNumOffset);
          }
          // 指定された要素にクラスを追加する関数
          function addClassToLineTr(number) {
            console.log(number)
            // 値が 0 より大きく且つ .line-num の総数より小さければ
            if (number > 0 && number <= lineTrsLength) {
              // (値- 1)番目の要素にクラスを追加
              lineTrs.item(number - 1).classList.add('line-highlight');
            }
          }
        })
      }
    });
  }
});

.line-highlight にハイライトのスタイルを設定します。

.hljs-ln tr.line-highlight {
  background-color: rgba(90, 105, 116, 0.252);
}

指定する行は、カンマ区切りで指定することもハイフンでレンジ指定することもできます。

例えば、data-line-highlight="1,3-5" とすると、1行目と3〜5行目のコードをハイライト表示します。

<div class="hljs-wrap">
  <pre  data-line-highlight="1, 3-5"><code>...</code></pre>
</div>

data-ln-start-from 属性で開始行番号に負の値を指定して、data-line-highlight 属性に負の値を指定することもできますが、レンジ指定で負の値を指定することはできません。

以下の場合は -2行目と1〜3行目をハイライトします。

<div class="hljs-wrap">
  <pre class="" data-line-highlight="-2, 1-3"><code  data-ln-start-from="-5"></code></pre>
</div>

ハイライトを幅いっぱいに表示

デフォルトではコードを整形するために使用されている table 要素(.hljs-ln)には幅などのスタイルが設定されていないので、コンテンツの長さにより幅が異なります。

そのため、ハイライトをコードの幅いっぱいに表示するには、table 要素(.hljs-ln)の幅を 100% にして、行番号部分(.hljs-ln-numbers)の幅を固定します。

.hljs-ln-numbers {
    font-family: inherit;
    -webkit-touch-callout: none;
    -webkit-user-select: none;
    -khtml-user-select: none;
    -moz-user-select: none;
    -ms-user-select: none;
    user-select: none;
    text-align: center;
    color: #5f9168;
    vertical-align: top;
    min-width : 2.5rem;
    /* 幅を固定(width を追加)*/
    width: 2.5rem;
    border-right: 1px solid #666;
  }

  .hljs-ln-code.hljs-ln-line {
    padding-left: .75rem;
  }

  .hljs-ln {
    margin-left: -.5em;
    /* 親要素いっぱいに広げる */
    width: 100%;
  }

.hljs-ln tr.line-highlight {
  /* 水平方向のグラデーションの例 */
  background: linear-gradient(to right, hsla(254, 15%, 51%, 0.2) 70%, hsla(254, 15%, 51%,0));
}

ハイライト表示した行番号のスタイル

ハイライト表示した行番号のスタイルは、例えば以下のように設定できます。

.hljs-ln tr.line-highlight .hljs-ln-line.hljs-ln-numbers {
  /* ハイライト表示した行番号の文字色を指定 */
  color: #c1c16e;
}

WordPress で Highlight.js を使う

functions.php で Highlight.js のテーマの CSS と JavaScript を読み込んで使用する例です。

CSS は wp_enqueue_style() を使って、JavaScript は wp_enqueue_script() を使って登録し、wp_enqueue_scripts アクションフックで読み込みます。

この例ではテーマフォルダの中に highlight-js というフォルダを作成してダウンロードした Highlight.js のファイル(atom-one-dark.min.css と highlight.min.js)を保存しています。

異なるフォルダ名を使用する場合や、異なるテーマの CSS を読み込む場合は、適宜以下のコードを読み替えてください。

highlight-js
├── atom-one-dark.min.css  // Highlight.js のテーマ CSS
└── highlight.min.js  // Highlight.js の JavaScript

以下を functions.php に記述します。

Highlight.js の初期化の記述は wp_add_inline_script() を使って highlight.min.js の読み込みの後に script タグで出力しています。

function add_my_hljs_styles_and_scripts() {
  // 管理画面では何もしない
  if (is_admin()) return;

  // Hightlight.js テーマ CSS の読み込み
  wp_enqueue_style(
    'atom-one-dark',
    get_theme_file_uri( '/highlight-js/atom-one-dark.min.css' ),
    array(),
    filemtime( get_theme_file_path( '/highlight-js/atom-one-dark.min.css' ) )
  );

  // Hightlight.js の本体(highlight.min.js)の読み込み
  wp_enqueue_script(
    'highlightJS', // ハンドル名
    get_theme_file_uri( '/highlight-js/highlight.min.js' ),
    array(),
    filemtime( get_theme_file_path( '/highlight-js/highlight.min.js' ) ),
    true
  );

  // Highlight.js の初期化の JavaScript を上記の highlight.min.js の読み込みの後に出力
  wp_add_inline_script(
    // 上記で登録したハンドル名を指定
    'highlightJS',
    // script タグに出力する JavaScript
    'hljs.highlightAll();'
  );
}
add_action( 'wp_enqueue_scripts', 'add_my_hljs_styles_and_scripts' );

編集画面でコードを挿入する位置でブロックの追加の + ボタンをクリックして「カスタム HTML」ブロックを追加します。

すでに「カスタム HTML ブロック」を使用したことがある場合は最近使用したブロックに表示されますが、表示されない場合は「すべて表示」をクリックします。

カスタム HTML ブロックに、例えば以下を記述すると、

<div class="hljs-wrap">
  <pre><code class="language-JavaScript">hljs.addPlugin({
  &#39;after:highlightElement&#39;: ({ el, result }) =&gt; {
    // result の language プロパティが undefined でなければ
    if(result.language) {
      // language を result から取得して code 要素(el)の data-language 属性に設定
      el.dataset.language = result.language;
    }
  }
});</code></pre>
  </div>
</div>

code 要素内に記述するコードの HTML 特殊文字はエスケープする必要があります(コードブロックを使えばエスケープする必要はありません)。

HTML 特殊文字を手動でエスケープするのは大変なのでオンラインツールを利用するか、自分でツールを作成すると便利です。

関連ページ:HTML特殊文字変換ツール

例えば、以下のように表示されます。

上記はテーマ Twenty Twenty-Four での表示例です。テーマで使用しているスタイルによっては追加でスタイルを調整する必要があります。

例えば、以下の場合、テーマのスタイルで pre 要素に padding が設定されているので code 要素の周りにパディングが表示されています。必要に応じて .hljs-wrap pre に padding: 0 を指定するなど調整します。

カスタマイズ用のファイルを読み込む

カスタマイズ用のファイルを作成して読み込む例です。ファイルをまとめれば、読み込みを減らせますが、以下ではそれぞれ別々のファイルとして保存しています。

例えば、カスタマイズ用のファイルを custom.css と custom.js という名前で以下のように保存した場合、

highlight-js
├── atom-one-dark.min.css  // Highlight.js のテーマ CSS
├── custom.css  // カスタマイズ用スタイル
├── custom.js  // カスタマイズ用 JavaScript
└── highlight.min.js  // Highlight.js の JavaScript

functions.php に以下を記述して CSS と JavaScript を読み込みます。

※ Highlight.js の初期化は custom.js に記述する必要があります。

 function add_my_hljs_styles_and_scripts() {
  // 管理画面では何もしない
  if (is_admin()) return;

  // Hightlight.js テーマ CSS(atom-one-dark.min.css)の読み込み
  wp_enqueue_style(
    'atom-one-dark', // ハンドル名
    get_theme_file_uri( '/highlight-js/atom-one-dark.min.css' ),
    array(),
    filemtime( get_theme_file_path( '/highlight-js/atom-one-dark.min.css' ) )
  );
  // カスタマイズ用スタイルシートの読み込み
  wp_enqueue_style(
    'highlight-js-custom-style',
    get_theme_file_uri( '/highlight-js/custom.css'),
    array('atom-one-dark'), //依存ファイルにテーマファイルのハンドル名を指定
    filemtime( get_theme_file_path( '/highlight-js/custom.css' ) )
  );

  // highlight.min.js の読み込み
  wp_enqueue_script(
    'highlightJS',  // ハンドル名
    get_theme_file_uri( '/highlight-js/highlight.min.js' ),
    array(),
    filemtime( get_theme_file_path( '/highlight-js/highlight.min.js' ) ),
    true
  );

  // カスタマイズ用 JavaScript の読み込み
  wp_enqueue_script(
    'highlight-custom-js',
    get_theme_file_uri( '/highlight-js/custom.js' ),
    array( 'highlightJS' ),  //依存ファイルに highlight.min.js のハンドル名を指定
    filemtime( get_theme_file_path( '/highlight-js/custom.js' ) ),
    true
  );
}
add_action( 'wp_enqueue_scripts', 'add_my_hljs_styles_and_scripts' );   

カスタマイズが適用されて、例えば、以下のように表示されます。

CDN を利用

以下は CDN を利用する場合の例です(カスタマイズなし)。

function add_my_hljs_styles_and_scripts() {
  // 管理画面では何もしない
  if (is_admin()) return;

  // Hightlight.js テーマ CSS(atom-one-dark.min.css)の読み込み
  wp_enqueue_style(
    // ハンドル名(任意の名前)
    'atom-one-dark',
    '//cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css',
    array(),
    NULL
  );

  // highlight.min.js の読み込み
  wp_enqueue_script(
    'highlightJS', // ハンドル名(任意の名前)
    '//cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js',
    array(),
    NULL,
    true
  );

  // Highlight.js の初期化
  wp_add_inline_script(
    // 上記で登録したハンドル名を指定
    'highlightJS',
    // script タグに出力する JavaScript
    'hljs.highlightAll();'
  );
}
add_action('wp_enqueue_scripts', 'add_my_hljs_styles_and_scripts');

SRI を使用する場合

以下は SRI 用の integrity 属性などを指定する場合の例です。

style_loader_tag と script_loader_tag を使って integrity 属性を追加しています。script_loader_tag を使用すると wp_add_inline_script のインラインスクリプトは削除されるので、以下では wp_footer で初期化のインラインスクリプトを追加しています(もっと良い方法があるかも知れません)

function add_my_hljs_styles_and_scripts() {
  // 管理画面では何もしない
  if (is_admin()) return;

  // Hightlight.js テーマ CSS(atom-one-dark.min.css)の読み込み
  wp_enqueue_style(
    // ハンドル名(任意の名前)
    'atom-one-dark',
    '//cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css',
    array(),
    NULL
  );

  // highlight.min.js の読み込み
  wp_enqueue_script(
    'highlightJS', // ハンドル名(任意の名前)
    '//cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js',
    array(),
    NULL,
    true
  );
}
add_action('wp_enqueue_scripts', 'add_my_hljs_styles_and_scripts');

// integrity 属性を追加
function change_stylesheet_link($html, $handle, $href) {
  if (is_admin()) {
    return $html;
  }

  // ハンドル名が atom-one-dark の場合
  if ($handle === 'atom-one-dark') {
    $html = '<link rel="stylesheet" href="' . $href . '" integrity="sha512-Jk4AqjWsdSzSWCSuQTfYRIF84Rq/eV0G2+tu07byYwHcbTGfdmLrHjUSwvzp5HvbiqK4ibmNwdcG49Y5RGYPTg=="  crossorigin="anonymous" referrerpolicy="no-referrer">' . "\n";
  }
  return $html;
}
add_filter('style_loader_tag', 'change_stylesheet_link', 10, 3);

function change_script_tag($tag, $handle, $src) {
  if (is_admin()) {
    return $tag;
  }

  // ハンドル名が highlightJS の場合
  if ($handle === 'highlightJS') {
    $tag = '<script src="' . $src . '" integrity="sha512-D9gUyxqja7hBtkWpPWGt9wfbfaMGVt9gnyCvYa+jojwwPHLCzUm5i8rpk7vD7wNee9bA35eYIjobYPaQuKS1MQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>' . "\n";
  }
  return $tag;
}
add_filter('script_loader_tag', 'change_script_tag', 10, 3);

// 初期化のインラインスクリプトを追加
function add_my_hljs_init() {
  if (is_single()) {
?>
    <script>
      hljs.highlightAll();
    </script>
<?php
  }
}
add_action('wp_footer', 'add_my_hljs_init', 99);

関連ページ:WordPress CSSやJavaScriptファイルの読み込み

コードブロックを利用

エディタでコードブロックを使用すると、入力するコードをエスケープする必要がありません。

また、単純な使い方であれば、Highlight.js のファイルを読み込むだけで手軽に利用することができます。

詳細は以下のページを御覧ください。

Highlight.js を WordPress で使う

カスタムブロックを作成

カスタムブロックを作成すれば、入力したコードをエスケープ処理なしでハイライト表示することができます。詳細は以下のページを御覧ください。