最終更新日時:

Prism.jsによるSSRを試す

はじめに

 先日はMathJaxとKaTeXによるSSRを試してみたが、今回はPrism.jsのSSRを試してみる(最近はSSRに興味が出てきたので勉強中である)。普段、Prism.jsを利用するときは雑にPrism.highlightAllUnder(document, false)で済ませてきたが、もう少し一般的な利用方法も示していきたいところである。
 以下のドキュメントによれば、Prism v2を現在開発中とあるが、動いているのか不明である。現在のバージョンのPrism.jsが保守されなくなるのではという懸念点があるが、他のシンタックスハイライターに乗り換えるコストはあまり高くはないだろうし、乗り換えが必要になるような事件もそうそう起きないだろうから、利用を継続する(乗り換えは必要になってからで十分)。

 Prism.jsを利用する際は以下を実行して導入をする。

npm install prismjs
Bash

 今回利用したパッケージのバージョンは以下のとおりである。

{
  "dependencies": {
    "prismjs": "^1.29.0"
  }
}
Plain text

単純なソースコードのハイライト

 特に何も考えずにレンダリングをしたい場合、以下のように出力することができる。ほぼサンプルからの流用である。スタイルが絡む分、単純というよりは低レイヤといった方が正しいかもしれない。

const Prism = require('prismjs');
const loadLanguages = require('prismjs/components/');
loadLanguages(['cpp']);

// 雑にC++のコードをハイライトする
const code = `std::vector<int> a = {};`;
console.log(Prism.highlight(code, Prism.languages.cpp, 'c++'));
JavaScript

 ただ、現実的にCSSを自前で用意するのは面倒なため、用意されたテーマを利用するのが普通である。CSSはテーマやプラグインごとに存在しており、これらをすべてリンクして利用するのは酷なため、webpackおよびCSSのバンドルのためのプラグインを利用する。

npm install webpack
npm install webpack-cli
npm install style-loader
npm install css-loader
npm install mini-css-extract-plugin
npm install css-minimizer-webpack-plugin
Bash

 今回利用したパッケージのバージョンは以下のとおりである。

{
  "dependencies": {
    "css-loader": "^7.1.1",
    "css-minimizer-webpack-plugin": "^6.0.0",
    "mini-css-extract-plugin": "^2.9.0",
    "prismjs": "^1.29.0",
    "style-loader": "^4.0.0",
    "webpack": "^5.91.0",
    "webpack-cli": "^5.1.4"
  }
}
Plain text

 webpack.config.jsの設定については以下を参考にしてのように行った。

const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");

/** @type { import('webpack').Configuration } */
module.exports = {
    mode: 'development',
    target: 'node',
    entry: './highlight.js',
    output: {
        filename: 'bundle.js',
        path: __dirname
    },
    module: {
        rules: [{
            test: /\.js$/,
            exclude: /(node_modules|bower_components)/
        }, {
            test: /\.css$/,
            use: [
                MiniCssExtractPlugin.loader,//'style-loader',←インラインでやる場合はこっち
                 'css-loader'
            ]
        }]
    },
    optimization: {
        minimizer: [
            `...`,
            new CssMinimizerPlugin({
                minimizerOptions: {
                    preset: [
                    "default",
                        {
                            // 全てのコメントを消す
                            discardComments: { removeAll: true },
                        },
                    ],
                }
            })
        ],
        // mode: 'development'でもminifyする
        minimize: true
    },
    plugins: [
        new MiniCssExtractPlugin({
            filename: 'style.css',
        }),
    ]
};
JavaScript

 あとは、メインとなるJavaScriptのスクリプト中で依存関係にあるCSSをインポートすれば、style.cssがカレントに生成されるようになる。ただ、JavaScriptのバンドルについては、Prism.jsはその仕組み上、言語に関するモジュールが動的に相互依存しているという面倒な仕組みをしているため、以下のように手動でインポートをする。

global.Prism = require('prismjs/components/prism-core');
require('prismjs/components/prism-clike');
require('prismjs/components/prism-c');
require('prismjs/components/prism-cpp');

// 雑にC++のコードをハイライトする
const code = `std::vector<int> a = {};`;
console.log(Prism.highlight(code, Prism.languages.cpp, 'c++'));
JavaScript

 これらの言語モジュールの依存関係についてはnode_modules/prismjs/components.jsnode_modules/prismjs/components.jsonに記述されている。例えば、C++モジュールの場合であれば以下のような記述があるため、C言語のモジュールに依存していることがわかる。

"cpp":{"title":"C++","require":"c","owner":"zeitgeist87"}
JavaScript

 しかしながら、これは非常に面倒なため、JavaScriptをバンドルしないようにする(ハイライトはサーバで完結させる)、セルフホストする、以下からダウンロードしたものをバンドルするとした方がはるかに簡単だろう。そもそも本記事はSSRを行うことが目的なので、そんなことは気にする必要はないが。

 実際に生成したHTMLは以下のコードのコメント部に挿入することにより、いい感じにハイライトされた表示を確かめることができる。

<!DOCTYPE html>
<html lang="ja">
	<head>
		<meta charset="utf-8">
		<title>Prism.jsを使ってみる</title>
		<link rel="stylesheet" href="./style.css">
	</head>
	<body>
		<pre class="language-cpp"><code class="language-cpp"><!-- ここに生成したコードを挿入 --></code></pre>
	</body>
</html>
Markup

 ちなみに、全ての言語をバンドルするためのJavaScriptファイルをインポートをするスクリプトは以下のように簡単に実装をすることができる。

// prismjs/components/index.jsを基に実装

const fs = require('fs');
const components = require('prismjs/components.js')
const getLoader = require('prismjs/dependencies');


// 言語名の全部
const languages = Object.keys(components.languages).filter(l => l != 'meta');

/** @type { Set<string> } 読み込み済みの言語に関するデータセット */
const loadedLanguages = new Set();

// 読み込み対象のパッケージ関する文字列
let loadLanguagesStr = '';

// 言語の走査
getLoader(components, languages, []).load(lang => {
    if (!(lang in components.languages)) {
        console.warn('Language does not exist: ' + lang);
        return;
    }

    const pathToLanguage = 'prismjs/components/prism-' + lang;

    loadLanguagesStr += `require('${pathToLanguage}');`;

    loadedLanguages.add(lang);
});

// ファイルの出力
fs.writeFileSync('load-languages.js', loadLanguagesStr);
JavaScript

プラグインの利用

 Prism.jsにおけるプラグイン、例えばnode_modules/prismjs/plugins/line-numbersにあるものは行番号を付与するものであるが、明らかにdocumentに依存しているためそのままではSSRを行うことができない。というわけで、JSDOMを利用してそれを回避する。

npm install jsdom
Bash

 今回利用したパッケージのバージョンは以下のとおりである。

{
  "dependencies": {
    "css-loader": "^7.1.1",
    "css-minimizer-webpack-plugin": "^6.0.0",
    "jsdom": "^24.0.0",
    "mini-css-extract-plugin": "^2.9.0",
    "prismjs": "^1.29.0",
    "style-loader": "^4.0.0",
    "webpack": "^5.91.0",
    "webpack-cli": "^5.1.4"
  }
}
Plain text

 また、Prism.jsのプラグインは基本的にDOMの構造に依存して利用されるものであり、Prism.highlightでは機能しない。そのため、HTMLファイルを用意しておく。ハイライトを行うコードはC++による「Hello, Wirld.」である。

<!DOCTYPE html>
<html lang="ja">
	<head>
		<meta charset="utf-8">
		<title>Prism.jsを使ってみる</title>
		<link rel="stylesheet" href="./style.css">
	</head>
	<body>
		<pre class="line-numbers" data-start="2" data-label="main.cpp"><code class="language-cpp">#include &lt;iostream&gt;

int main(){
	std::cout &lt;&lt; "Hello world." &lt;&lt; std::endl;
	return 0;
}</code></pre>
	</body>
</html>
Markup

 具体的なdocumentなどの参照を回避するコードは以下である。globalに色々オブジェクトを設置した作為のあるコードである(特にgetComputedStyleあたり)。ファイルへの出力については一通りのイベントの発火後ということで、マイクロタスクにファイルの出力のコードを積んでいる(多分必要はない)。

const { JSDOM } = require('jsdom');
const fs = require('fs');

// 入力するファイル
const inFile = 'in.html';
// 出力するファイル
const outFile = 'out.html';

// 入力されたファイルについてのDOMツリーの構築
const dom = new JSDOM(fs.readFileSync(inFile).toString(), { contentType: 'text/html'});
global.document = dom.window.document;
global.window = dom.window;
global.getComputedStyle = window.getComputedStyle;

// Prism.jsからWindowオブジェクトを参照できるかつグローバルにPrismを参照できるように設置
global.Prism = require('prismjs/components/prism-core');
require('prismjs/components/prism-clike');
require('prismjs/components/prism-c');
require('prismjs/components/prism-cpp');

// テーマの定義
require('prismjs/themes/prism-okaidia.css');

// 行番号の表示
require('prismjs/plugins/line-numbers/prism-line-numbers');
require('prismjs/plugins/line-numbers/prism-line-numbers.css');

// ツールバーの利用
require('prismjs/plugins/toolbar/prism-toolbar');
require('prismjs/plugins/toolbar/prism-toolbar.css');

// 言語名の表示
require('prismjs/plugins/show-language/prism-show-language');

// ハイライトを付ける
dom.window.document.querySelectorAll('pre > code').forEach(e => Prism.highlightElement(e, false));

queueMicrotask(() => {
    // グローバルの関連付けの削除(必要はないがこのタイミングで削除できるという意味で記述)
    delete global.document;
    delete global.window;
    delete global.getComputedStyle;

    // ファイルの出力
    fs.writeFileSync(outFile, dom.serialize());
});
JavaScript

おわりに

 MathJaxやKaTeXでのSSRの時も思ったが、全然素直に動いてくれなくて辛いものを感じる。それは多分私がフロントエンド素人であることが原因の1つだろう。ブログ運営のためにJavaScriptは勉強しているが、それ以外の場面で使ったことはないので、ノウハウはない。
 とりあえず、MathJaxとKaTeX、Prism.jsのSSRは調べたから、次はmermaid.jsのSSRを調べると思う。ただ、今はGWなので明けてから考えることにする(ブログのシステム部分で利用したいとは思っているが、利用は未定である)。