DOMやwindowに依存しているPrismのプラグインをNode.js上で動かす方法

Prism.jsのline-numbersプラグインなどは、ブラウザ上での動作を前提とした設計でDOMやwindowに依存しているため、Node.jsのようなサーバーサイドで`require`しても動作しません。

## 解決策

ではどうやったら動くか？解決策は次の通りです。

### DOMに依存している問題の解決策

DOMに依存しているコードが動くようにするために、`jsdom`を使います。

### グローバルオブジェクト`window`に依存している問題の解決策

line-numbersプラグインなどは、`window.Prism`が`undefined`だとプラグインのロードをやめてしまいます。この解決策としては、`vm`モジュールを使い、擬似的にグローバルオブジェクトとして`window`があるサンドボックス環境を作り、その中で、プラグインコードを`eval`してやるようにします。要するにPrismにブラウザ環境だと思わせるようにするということです。

### 上の解決策を講じたコード

次が上の解決策を施したコードです。

```loadPlugin.ts
import fs from "fs";
import { JSDOM } from "jsdom";
import Prism from "prismjs";
import vm from "vm";

export function createLoadPlugin() {
  const { window } = new JSDOM("");
  window.Prism = Prism;
  const ctx = vm.createContext(window);

  return function load(plugin: string): void {
    const filename = require.resolve(
      `prismjs/plugins/${plugin}/prism-${plugin}`
    );
    const src = fs.readFileSync(filename, "utf-8");
    vm.runInContext(
      // language=JavaScript
      `
        try {
          const self = window;
          ${src};
        } catch (err) {
          console.error(err);
        }
      `,
      ctx
    );
  };
}
```

使い方は次の通り。

```usage.ts
import { createLoadPlugin } from "./loadPlugin";

const loadPlugin = createLoadPlugin();
loadPlugin("line-numbers");
loadPlugin("diff-highlight");
loadPlugin("autolinker");
loadPlugin("inline-color");
```

これで様々なプラグインがロードできるようになります。

### ハイライトは`Prism.highlightElement`を用いる

Node.jsでハイライトするには`Prism.highlight`を用いるのが一般的ですが、DOM依存のプラグインはそのメソッドでは動かないので、`Prism.highlightElement`を使います。

実際に`highlightElement`を使う場合は、DOMが必要なので次のようなユーティリティ関数を作っておくとよいです。

```highlight.ts
import { JSDOM } from "jsdom";
import Prism from "prismjs";

export function highlight(
  code: string,
  {
    language = "none",
    lineNumbers = false,
  }: { language?: string; lineNumbers?: boolean } = {}
): string {
  const { window } = new JSDOM("");
  const pre = window.document.createElement("pre");
  const codeElm = window.document.createElement("code");
  pre.appendChild(codeElm);
  codeElm.textContent = code;
  codeElm.setAttribute(
    "class",
    [`language-${language}`]
      .concat(lineNumbers ? ["line-numbers"] : [])
      .join(" ")
  );
  Prism.highlightElement(codeElm);
  return pre.outerHTML;
}
```

### 最終的なコード

上で作った、`loadPlugin`と`highlight`を使った最終的なコードです。

```main.ts
import loadLanguages from "prismjs/components/index";
import { highlight } from "./highlight";
import { createLoadPlugin } from "./loadPlugin";

loadLanguages();
const loadPlugin = createLoadPlugin();
loadPlugin("line-numbers");
loadPlugin("diff-highlight");
loadPlugin("autolinker");
loadPlugin("inline-color");

const code = `span.foo {
  background-color: navy;
  color: #BFD;
}`;

const html = highlight(code, { language: "css", lineNumbers: true });

console.log(html);
```

上の`html`内容は次のようになります。

<p class="codepen" data-height="265" data-theme-id="light" data-default-tab="html,result" data-user="suin" data-slug-hash="GRWpmXd" style="height: 265px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;" data-pen-title="Prism line-numbers inline-colors">
  <span>See the Pen <a href="https://codepen.io/suin/pen/GRWpmXd">
  Prism line-numbers inline-colors</a> by suin (<a href="https://codepen.io/suin">@suin</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>