LoginSignup
4
1

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

Posted at

はじめに

Web技術で「本」が作れるCSS組版Vivliostyle入門』に次の記述があります1

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

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

前提

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

$ cat sample.md
```js
function hello() {
    console.log("hello");
}
```
$ vfm 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"><code class="language-js"><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>
    <span class="token console class-name">console</span><span class="token punctuation">.</span><span class="token method function property-access">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></code></pre>
  </body>
</html>

VFMのドキュメントには「VFM uses Prism for syntax highlighting.」と記載されています。Prismにはline-numbersプラグインがあり、これを適用すれば行番号を付けることができそうです。

ただし、Prismはブラウザで現在のページに対して使用することを前提とした古い仕組みのライブラリであり、Node.jsでは扱いづらいため、VFMで実際に使用しているのはPrismをラップした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: [ ... ] }となっています。childrenhChildrenに収めればよいようです。

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ページからline-numbersプラグインを有効にしたCSSをダウンロードして、Vivliostyleでプレビューします。

$ 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

image.png

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

  1. 大津雄一郎(2023)Web技術で「本」が作れるCSS組版Vivliostyle入門 P.162

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1