Unified.js が ESM 対応していたのでシングル HTML でパース表示できるようにしてみた

このブログの記事は Markdown で書いていて、Unified.js ファミリーの Remark および Rehype を使って、Markdown から HTML に変換している。

最近、ふと Unified.js の各ページを見ていたら、CDN の esm.sh 経由で各種ライブラリがインポートできるという記述を発見。つまりは Node.js 上だけでなく、ブラウザ上でも Unified.js が動作するワケだ。言われてみれば、Node.js に依存しそうな場所というと「ファイルの読み込み」ぐらいなモノで、文字列データを渡して文字列として返してもらうだけならブラウザで動作してもおかしくないか。


というワケで、こんなモノを作ってみた。

出来上がりのページはこんな感じ。ソースコードを直接見てもらえば分かるが、HTML 内には Markdown しか書いておらず、サーバサイドでのパースはしていない。ページ読み込み後にクライアントサイドでパースしている。


./src/ 配下を見てもらうと、HTML・JS・CSS がある。HTML 内に、Markdown を書くための領域を用意してある。

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <title>Unified Page</title>
    <link rel="stylesheet" href="index.css">
    <script type="module" src="index.js"></script>
  </head>
  <body>

<template id="markdown" data-title="Unified Page">

# Unified Page ← ココに Markdown を書いていく

</template>

  </body>
</html>

type="module"、つまり ES Module として読み込んでいる JS 部分の冒頭は、こうなっている。

import { unified } from 'https://esm.sh/unified@11?bundle';
import remarkParse from 'https://esm.sh/remark-parse@11?bundle';
import remarkGfm from 'https://esm.sh/remark-gfm@4?bundle';
import remarkToc from 'https://esm.sh/remark-toc@9?bundle';
import remarkRehype from 'https://esm.sh/remark-rehype@11?bundle';
import rehypeSlug from 'https://esm.sh/rehype-slug@6?bundle';
import rehypeAutolinkHeadings from 'https://esm.sh/rehype-autolink-headings@7?bundle';
import rehypePrism from 'https://esm.sh/rehype-prism@2?bundle';
import rehypeStringify from 'https://esm.sh/rehype-stringify@10?bundle';

Markdown から HTML へと変換するために使用する各種ライブラリを、全て esm.sh からインポートしている。

あとは Unified Processor として組み立てていって、HTML 側に #markdown な要素があればその中のテキスト利用して processSync() で Markdown から HTML へと変換している。

const processor = unified()
  .use(remarkParse)
  .use(remarkGfm)
  .use(remarkToc, { heading: '目次', tight: true })
  .use(remarkRehype, { fragment: true, allowDangerousHtml: true })
  .use(rehypeSlug)
  .use(rehypeAutolinkHeadings, { /* 中略 */ })
  .use(rehypePrism)
  .use(rehypeStringify, { allowDangerousHtml: true });

const markdownElement = document.querySelector('template#markdown');
if(markdownElement) {
  document.body.insertAdjacentHTML('afterbegin', `<div id="unified-container">${processor.processSync(markdownElement.innerHTML).value}</div>`);
}

一応、最後に window.processor = processor; とグローバル変数に Unified Processor をエクスポートしておくことで、追加で JS を書けば任意のタイミングで window.processor.processSync() を呼び出して再度 Markdown パースができるようにしてある。

あと、僕は pre 要素にプログラミング言語別のシンタックスハイライトを付けてくれる rehype-prism が好きなのだが、コレはプログラミング言語別のコンポーネントを予めインポートしておかないと、Unified.js でパースした時にうまくシンタックスハイライトがつかないようであった。例えば Bash や Powershell なんかは、デフォルトの Prism ではうまくシンタックスハイライトされないので、別途コンポーネントをインポートしてやる必要がある。

// よく使う言語は予め静的にインポートしておく
import 'https://esm.sh/prismjs@1/components/prism-bash';
import 'https://esm.sh/prismjs@1/components/prism-markdown';
import 'https://esm.sh/prismjs@1/components/prism-powershell';

言語別のコンポーネントファイルを探して import 文を列挙するのが面倒だったのと、ファイル数が多くてインポートに時間がかかるため、HTML ファイルにアクセスする際に ?all とクエリパラメータを付けた時のみ、ES Module を動的インポートして追加のプログラミング言語に対応させるという荒業を実装しておいた。

import prismComponents from 'https://esm.sh/prismjs@1/components/index';

// `?all` とクエリパラメータを付けてアクセスした場合、全ての言語を動的インポートする
if(location.search.includes('all')) {
  await Promise.all(
    Object.keys(prismComponents.languages)
      .filter(languageName => !['meta', 'django'].includes(languageName))  // 読み込むとエラーになった言語を弾いておく
      .map(languageName => import(`https://esm.sh/prismjs@1/components/prism-${languageName}`).catch(() => null))  // 何かあった時の気休めの `catch`
  ).catch(() => null);
}

あとは個人的にダークテーマに対応させたかったのでその辺のコンポーネントを JS からブチ込んでいたりする。

これらは type="module" (ES Module) として読み込んでいるため、DOMContentLoaded のタイミングで必ず実行される。よって DOM の読み込み完了を待って存在チェックをしたりする必要はなく、また動的インポートの部分で使っているように Top-Level await も問題なく動く。書き味はかなり今風で、Node.js の CommonJS 仕様に慣れている一昔前の JSer はちょっとビビるかもしれない。コレがブラウザでトランスパイルなしに動くの?って感じ。w


さて、あと CSS だが、CSS は単なるページデザインなのでお好みで用意してもらえば良い。今回は Neo's Normalize をベースに必要な分だけ作り直し、ダークテーマ対応させたスタイルにした。個人的には Prism.js に Monokai テーマを当てたいので、そのためだけに CSS を用意している。


コーディング時は HTML・JS・CSS を別ファイルに別けて作ったが、完全にシングル HTML にしても問題ない。すなわち、JS ファイルの中身は <script type="module"> 内に、CSS ファイルの中身は <style> 内に書いてしまえば良い。

ということでお手製のビルドスクリプトをちょっと書いて、シングル HTML ファイルを吐き出せるようにした。ミニファイには html-minifierclean-cssuglify-js (v3 系の ES Module 対応版) も使っている。

ビルドしたシングルファイルは以下。

1行目に CSS と JS をギュギュッとまとめてインラインで詰め込み、2行目に <template id="markdown"> を置くことで Markdown を書き始める行を分かりやすくしておいた。

esm.sh CDN さえアクセスできれば、ローカルで直接 HTML ファイルを開いても動作するし、開発用サーバを立てたりする必要もない。シングル HTML ファイルにまとまったので可搬性も良い。


Markdown で書きたいけど、人に見せる時に HTML 形式でデザインを整えたい、という人にはこういう作り方もできるのかな、ということで作ってみた次第でした。