ESM 対応の Unified.js で Markdown を書きつつ Cloudflare Pages + D1 Database でデータ永続化してやる!

昨日紹介した Unified Page。Unified.js (Remark・Rehype) を ES Modules として読み込み、クライアントサイドで Markdown から HTML へとパースするシングル HTML ファイルを作ったというお話でした。

CSS と JS をバンドルしたシングル HTML ファイルの中に Markdown も一緒に書いてしまえばローカルでも持ち回せて動作するため、可搬性は良くなったものの、なんだろう、やっぱデータ永続化層が欲しい


ということで、この発展形となるウェブサービスを Cloudflare Pages、Pages Workes、D1 Database を用いて作成してみた。その名も Neo's Unified Page。「Neo's」が付きました (謎)。動作している Cloudflare Pages サイトは以下。

左カラムに登録した Makdown 文書のリンクが並んでおり、リンク先に遷移するとクライアントサイドで Markdown をパースし、HTML として描画している。コンテンツ部分のデザインに関しては先日の Unified Page と同じ。

ソースコードは以下。


昔、ngx-markdown-wiki という Angular 製の似たようなツールを作ったことがある。

やっていることはコレに近いのだが、今回は SPA 風の挙動を Vanilla JS で自分で実装した。クエリパラメータの制御ってしんどくてバグ取りがキツかった。w

SPA としての JS の実装のところはゴリゴリと書いただけなのであまり紹介することはない。


type="module" で読み込んだ JS は DOMContentLoaded まで待機してから実行される仕様上、画面の初期表示時に真っ白なページが表示される瞬間が生まれてしまう。今回は特に esm.sh からのインポートが多いので、動的にダークテーマに変えられるよう対応したものの、画面初期表示時に白い背景が一瞬映るチラつきが気になった。

type="module" での回避はちょっと難しく、ページ読み込み時の瞬間からダークテーマを割り当てるためには、head 要素内で type="module" を付与しない JS ファイルを読み込み、DOMContentLoaded イベントを待たずに即時実行してダークテーマを当てる、という処理を実装した。

つまり、HTML 側はこうなっていて、

<!DOCTYPE html>
<html lang="ja">
  <head>
    <!-- 中略 -->
    <link rel="stylesheet" href="./styles.css">
    
    <!-- 即時実行したい JS -->
    <script src="./scripts-init.js"></script>
    
    <!-- Unified.js などが入ったメインの JS -->
    <script type="module" src="./scripts-module.js"></script>
  </head>

./scripts-init.js の中身は以下のようになっている。

// グローバル変数化を避けるための無名関数、気にならないなら無名関数でラップせずベタで書いても良い
(() => {
  const setTheme = condition => {
    const nextTheme = condition ? 'dark' : 'light';
    document.documentElement.dataset.theme = nextTheme;  // 3.
    localStorage.setItem('theme', nextTheme);  // 3.
  };
  
  const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme:dark)');  // 1.
  darkModeMediaQuery.onchange = event => setTheme(event.matches);  // 2.
  
  const lastTheme = localStorage.getItem('theme');  // 4.
  if(lastTheme) {
    document.documentElement.dataset.theme = lastTheme;  // 4.
  }
  else {
    setTheme(darkModeMediaQuery.matches);  // 1.
  }
  
  document.addEventListener('DOMContentLoaded', () => {  // 5.
    document.getElementById('unified-toggle-theme').addEventListener('click', () => {  // 5.
      setTheme(document.documentElement.dataset.theme === 'light');  // 5.
    });
  });
})();
  1. 初回訪問時は window.matchMedia() で OS・ブラウザ設定を参照して、それに応じてダークテーマ or ライトテーマを割り当てる
  2. OS・ブラウザでのテーマ切替時は自動的にそれに追従するよう onchange イベントを設定している
  3. ダークテーマ or ライトテーマの割り当ては html 要素に対する data-theme 属性値で分かるようにしており、同時に LocalStorage に「選択中のテーマ名」を保存している
  4. 2回目以降の訪問時は LocalStorage を参照して、最後に選択していたテーマを割り当てる
    • ココまでの動作が、DOMContentLoaded を待たずに head 要素内で実行されるため、「ダークテーマにしたいのに一瞬だけ白い背景の画面が映る」という現象を回避できる
  5. あとは画面内に埋め込んだテーマ切り替え用のボタンに対して、DOMContentLoaded を待って Click イベントを設定している

…という形。

やりたいこと (白い背景がチラつくことの回避) に対して、実装で意図を伝えるのが難しい形になっているので、しばらくして忘れたら自分でも分からなくなりそうだ…。


さて、Markdown テキストは Cloudflare D1 Database (SQLite) に保存している。特筆するようなことはない、専用のテーブルを1つ作っておき、管理画面で CRUD できるようにしているだけ。一応、編集にはパスワードが必要で、そのパスワードは Cloudflare の管理画面から環境変数で割り当てているので、僕しか Markdown を置いたりすることはできない。.dev.vars は「こんな環境変数を設定してるよ」の定義のみで、値はダミー。

SPA 的な挙動を実現するのが地獄のように辛かったので、依存関係を増やしたくないからといって Vanilla で書くのはやめよう…。Angular じゃちょっと重たいかなーと思ったので、こういう時にサラッと Vue とか使えればいいのかなー。Vite での環境構築とかに慣れておきたい。