mdmark

差分を表示する

mount の diffSource に取り出し元を渡し、文書の 2 つのリビジョンの差分を出す

読む人が文書の変更を、消した行は赤・追加した行は緑・行の中で変わった文字は濃い色で読めるようにするときに実行する。 仕組みは 差分を表示する仕組み。

前提

mdmark は Git や YAML の読み方を持たない。 リビジョンの一覧と本文を返す取り出し元(DiffSource)を、使う側が書く。

手順

取り出し元を渡す

取り出し元を書く

revisions と content の 2 つを持つオブジェクトを書く。

const diffSource = {
  async revisions(docId) {
    // 新しい順の { id, at, label?, author? } の配列を返す
  },
  async content({ docId, revisionId }) {
    // そのリビジョンの Markdown の文字列を返す
  },
};
関数 返す値
revisions(docId) リビジョンの配列を、新しい順に返す Promise。mdmark は並べ替えない
content({ docId, revisionId }) そのリビジョンの Markdown を返す Promise

リビジョンは次の項目を持つ。

項目 必須 / 任意 説明
id 必須 リビジョンの識別子。空にできず、一覧の中で重ならない
at 必須 ISO 8601 の日時の文字列
label 任意 一言の説明。リビジョンを選ぶ欄に出る
author 任意 書いた人。リビジョンを選ぶ欄に出る

取り出せないときは reject する。 形は API の opts.diffSource。

mount に渡す

mount(document.querySelector("#doc"), { markdown, docId: "guide", diffSource });

docId は取り出し元の関数に渡る文書の識別子で、diffSource と一緒に渡す。 docId を渡さないと、mount は何も描かずに TypeError を投げる。 diffSource を渡さない mount は、差分の UI を付けずに今までどおり描く。

文書の題(先頭の h1)の後ろに「変更を見る」のボタンが付く。 押すと、題を残して本文を隠し、一番新しいリビジョンとその 1 つ前の差分を出す。 前と後のリビジョンは、差分の上の 2 つの欄で選び直せる。 押し直すと、差分を片付けて本文に戻る。

Git の取り出し元を書く

コミットごとの文書のファイルを、リビジョンにする例。 ブラウザは Git を読めないため、使う側のサーバーが Git を呼んで返す。

サーバーを書く

import { createServer } from "node:http";
import { execFile } from "node:child_process";
import { promisify } from "node:util";

const run = promisify(execFile);
const repo = "/path/to/repo";
// 文書の識別子から、リポジトリの中のファイルを引く。表に無い識別子は受け付けない
const files = { guide: "docs/guide.md" };

createServer(async (req, res) => {
  const url = new URL(req.url, "http://localhost");
  const file = files[url.searchParams.get("doc")];
  if (!file) return res.writeHead(404).end();
  try {
    if (url.pathname === "/api/history") {
      const { stdout } = await run("git", ["log", "--format=%H%x1f%aI%x1f%an%x1f%s", "--", file], { cwd: repo });
      const revisions = stdout.split("\n").filter(Boolean).map((line) => {
        const [id, at, author, label] = line.split("\x1f");
        return { id, at, author, label };
      });
      return res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(revisions));
    }
    if (url.pathname === "/api/file") {
      const rev = url.searchParams.get("rev");
      if (!/^[0-9a-f]{40,64}$/.test(rev)) return res.writeHead(400).end();
      const { stdout } = await run("git", ["show", `${rev}:${file}`], { cwd: repo, maxBuffer: 16 * 1024 * 1024 });
      return res.writeHead(200, { "content-type": "text/markdown; charset=utf-8" }).end(stdout);
    }
    res.writeHead(404).end();
  } catch (error) {
    console.error(error);
    res.writeHead(500).end();
  }
}).listen(8080);

git log は新しいコミットから返すため、並べ替えずにそのまま渡せる。 コミットの ID を id、日時を at、件名を label、書いた人を author にする。

取り出し元を書く

const gitSource = {
  async revisions(docId) {
    const res = await fetch(`/api/history?doc=${encodeURIComponent(docId)}`);
    if (!res.ok) throw new Error(`履歴を取り出せません (${res.status})`);
    return res.json();
  },
  async content({ docId, revisionId }) {
    const res = await fetch(`/api/file?doc=${encodeURIComponent(docId)}&rev=${revisionId}`);
    if (!res.ok) throw new Error(`本文を取り出せません (${res.status})`);
    return res.text();
  },
};

YAML の取り出し元を書く

Git の管理外の、項目ごとの変更履歴を YAML に持つ例。 文書の識別子ごとに、リビジョンを content(その時点の Markdown)つきで並べる。

変更履歴を書く

guide:
  - id: r2
    at: "2026-10-20T11:05:00+09:00"
    author: 田中
    label: 見出しを書き換える
    content: |
      # ガイド

      変更を色分けして読む。
  - id: r1
    at: "2026-10-18T09:12:00+09:00"
    author: 佐藤
    label: 初版
    content: |
      # ガイド

      差分を色分けして読む。

取り出し元を書く

import { parse } from "https://cdn.jsdelivr.net/npm/yaml@2.9.1/+esm";

async function history(docId) {
  const res = await fetch("/history.yaml");
  if (!res.ok) throw new Error(`変更履歴を取り出せません (${res.status})`);
  const items = parse(await res.text())[docId];
  if (!items) throw new Error(`${docId} の変更履歴がありません`);
  return items;
}

const yamlSource = {
  async revisions(docId) {
    return (await history(docId))
      .map(({ id, at, label, author }) => ({ id, at, label, author }))
      .sort((a, b) => Date.parse(b.at) - Date.parse(a.at));
  },
  async content({ docId, revisionId }) {
    const item = (await history(docId)).find((r) => r.id === revisionId);
    if (!item) throw new Error(`${revisionId} がありません`);
    return item.content;
  },
};

mdmark は一覧を並べ替えないため、日時の新しい順に並べて返す。

反映

即時。mount を呼ぶと「変更を見る」のボタンが付き、押すと取り出し元が呼ばれる。 update は開いている差分を閉じて本文を描き直す。

確認

差分が開く

document.querySelector("#doc button[aria-expanded]").click()

差分の上に、前と後のリビジョンを選ぶ 2 つの欄が並び、一番新しいリビジョンとその 1 つ前の差分が出る。 消した行は赤の背景と行頭の -、追加した行は緑の背景と行頭の + で出る。 書き換えた行の対では、変わった語だけが濃い色になる。

取り出せないときの表示

revisions を reject させて開くと、差分の場所に「変更の履歴を表示できません」の知らせの枠が出て、文書の本文は残る。 content が reject したときは、選ぶ欄を残して「このリビジョンの本文を表示できません」の知らせの枠が出る。 別のリビジョンを選び直すと、差分が出る。

元に戻す

mount の呼び出しから docId と diffSource を外す。 「変更を見る」のボタンが付かず、文書は今までどおり描かれる。 destroy を呼ぶと、差分と取り出し元の待ちも片付く。