Vivliostyle Flavored Markdownでコードに行番号を付ける

この記事の内容は[u1f992/rehype-spectroscope](https://github.com/u1f992/rehype-spectroscope)に引き継ぎました。今後はこちらのrehypeプラグインを使用することで、Vivliostyle組版でソースコードに行番号をつけることができます。

<details>
<summary>以前の記事</summary>

## はじめに

『[Web技術で「本」が作れるCSS組版Vivliostyle入門](https://www.c-r.com/book/detail/1493)』に次の記述があります[^1]。

> ソースコードに行番号を付けるのは、CSSだけではできません。（中略）本書のコードの行番号は、自作の拡張機能でつけています。

[^1]: 大津雄一郎（2023）Web技術で「本」が作れるCSS組版Vivliostyle入門　P.162

Vivliostyle側に寄せた筋のよい方法で行番号を付けることができないか考えてみました。

## 前提

Vivliostyle Flavored Markdownでは、コードブロックに言語を指定すると、HTMLへの変換時に各トークンに`span`が割り当てられます。この要素に対応したCSSを使用して、コードブロックをスタイリングできます。

<pre><code>$ cat sample.md
```js
function hello() {
    console.log("hello");
}
```
$ vfm sample.md
&lt;!doctype html&gt;
&lt;html&gt;
  &lt;head&gt;
    &lt;meta charset=&quot;utf-8&quot;&gt;
    &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1&quot;&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;pre class=&quot;language-js&quot;&gt;&lt;code class=&quot;language-js&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;hello&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;&#123;&lt;/span&gt;
    &lt;span class=&quot;token console class-name&quot;&gt;console&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token method function property-access&quot;&gt;log&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;hello&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;&#125;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
  &lt;/body&gt;
&lt;/html&gt;
</code></pre>

[VFMのドキュメント](https://vivliostyle.github.io/vfm/#/vfm#code)には「VFM uses [Prism](https://prismjs.com/) for syntax highlighting.」と記載されています。Prismには[line-numbersプラグイン](https://prismjs.com/plugins/line-numbers/)があり、これを適用すれば行番号を付けることができそうです。

ただし、Prismはブラウザで現在のページに対して使用することを前提とした古い仕組みのライブラリであり、Node.jsでは扱いづらいため、VFMで実際に使用しているのはPrismをラップした[refractor](https://github.com/wooorm/refractor)です。refractorは上述の都合により改変したPrismを用いており、オリジナルのPrismのプラグインを使用できません。

## 解決案

refractorの代わりに独自のPrismラッパーを作成して、VFMに手を加えます。

```
$ git clone https://github.com/vivliostyle/vfm.git --depth=1
$ cd vfm
$ npm install
$ git checkout -b feature/yet-another-prismjs-wrapper
$ npm remove refractor
$ npm install --save jsdom prismjs hast-util-from-html
$ npm install --save-dev @types/jsdom
```

とりあえず、すべてのコードを行番号付きにしてみます。JSDOM（およびその背後のnode:vm）でオリジナルのPrism一式を疑似的なブラウザ環境に閉じ込めて利用しています。参考：https://qiita.com/suin/items/b4975570fac1e524d01b

VFMが利用しているバージョンの`refractor.highlight`では`[{ type: 'element', tagName: 'span', properties: { className: [ ... ] }, children: [ ... ] }, ... ]`が返ってきていましたが、最新のバージョンでは形式が変わり`{ type: 'root', children: [ ... ] }`となっています。`children`を`hChildren`に収めればよいようです。

```diff
diff --git a/src/plugins/code.ts b/src/plugins/code.ts
index 81bdfe0..a461c9a 100644
--- a/src/plugins/code.ts
+++ b/src/plugins/code.ts
@@ -1,10 +1,87 @@
 import { Code } from 'mdast';
 import { Handler } from 'mdast-util-to-hast';
-import refractor from 'refractor';
 import { Node } from 'unist';
 import u from 'unist-builder';
 import visit from 'unist-util-visit';
 
+import fs from 'node:fs';
+import vm from 'node:vm';
+
+import jsdom from 'jsdom';
+
+import { fromHtml } from 'hast-util-from-html';
+
+type PrismContext = vm.Context & { PrismContext: never };
+type PrismPlugin = 'line-numbers';
+
+function createContext() {
+  const path = require.resolve('prismjs');
+  const src = fs.readFileSync(path, { encoding: 'utf-8' });
+
+  const dom = new jsdom.JSDOM('', { runScripts: 'dangerously' });
+  const ctx = dom.getInternalVMContext();
+
+  vm.runInContext(src, ctx);
+  return ctx as PrismContext;
+}
+
+function loadPlugin(ctx: PrismContext, plugin: PrismPlugin) {
+  const path = require.resolve(`prismjs/plugins/${plugin}/prism-${plugin}.js`);
+  const src = fs.readFileSync(path, { encoding: 'utf-8' });
+  vm.runInContext(src, ctx);
+}
+
+function highlight(
+  code: string,
+  lang: string,
+  plugin: {
+    lineNumbers?: boolean;
+  } = {},
+) {
+  const ctx = createContext();
+
+  const attr: { [key: string]: unknown } = {
+    class: `language-${lang}`,
+  };
+
+  if (plugin.lineNumbers) {
+    loadPlugin(ctx, 'line-numbers');
+    attr['class'] += ' line-numbers';
+  }
+
+  ctx['code'] = code;
+  ctx['attr'] = attr;
+
+  return vm.runInContext(
+    `{
+      let ret = "";
+
+      const pre = document.createElement("pre");
+      try {
+        const code_ = document.createElement("code");
+        try {
+          pre.appendChild(code_);
+          code_.textContent = code;
+
+          for (const key of Object.keys(attr)) {
+            pre.setAttribute(key, attr[key]);
+          }
+
+          Prism.highlightElement(code_);
+          ret = pre.outerHTML;
+        } finally {
+          code_.remove();
+        }
+      } finally {
+        pre.remove();
+      }
+
+      ret;
+    }`,
+    ctx,
+  );
+}
+
 export function mdast() {
   return (tree: Node) => {
     visit<Code>(tree, 'code', (node) => {
@@ -40,9 +117,13 @@ export function mdast() {
       }
 
       // syntax highlight
-      if (node.lang && refractor.registered(node.lang)) {
+      if (node.lang) {
         if (!node.data) node.data = {};
-        node.data.hChildren = refractor.highlight(node.value, node.lang);
+        node.data.hChildren = (
+          fromHtml(highlight(node.value, node.lang, { lineNumbers: true }), {
+            fragment: true,
+          }).children[/* pre */ 0] as any
+        ).children[/* code */ 0].children;
       }
     });
   };
@@ -51,7 +132,7 @@ export function mdast() {
 export function handler(h: any, node: any): Handler {
   const value = node.value || '';
   const lang = node.lang ? node.lang.match(/^[^ \t]+(?=[ \t]|$)/) : 'text';
-  const props = { className: ['language-' + lang] };
+  const props = { className: ['language-' + lang, 'line-numbers'] };
   return h(node.position, 'pre', props, [
     h(node, 'code', props, [u('text', value)]),
   ]);
```

`hast-util-from-html`がPure ESMなので、現在のVFMの構成で使用するには、最新のNode.jsを使用した上で`--experimental-require-module`フラグが必要です。

```
$ node --version
v22.0.0
$ npm run build
$ node --no-warnings --experimental-require-module lib/cli.js ../sample.md
<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
  </head>
  <body>
    <pre class="language-js line-numbers"><code class="language-js line-numbers"><span class="token keyword">function</span> <span class="token function">hello</span><span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
    console<span class="token punctuation">.</span><span class="token function">log</span><span class="token punctuation">(</span><span class="token string">"hello"</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span></span></code></pre>
  </body>
</html>
```

[PrismのDownloadページ](https://prismjs.com/download.html#themes=prism&languages=markup+css+clike+javascript&plugins=line-numbers)からline-numbersプラグインを有効にしたCSSをダウンロードして、Vivliostyleでプレビューします。

<pre><code>$ cat ../sample.md
---
link:
  - rel: "stylesheet"
    href: "prism.css"
---

```js
function hello() {
    console.log("hello");
}
```
$ node --no-warnings --experimental-require-module lib/cli.js ../sample.md > ../sample.html
$ vivliostyle preview ../sample.html
</code></pre>

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1550728/a5ecfa21-542c-5a36-4e1d-f916b33eb1fc.png)

Vivliostyleのツールチェインに寄せた方法で行番号をつけることができました。

</details>