remark によるブログ記事の Markdown 処理

本ブログは2009年に開設したものですが、当時は今ほど Markdown がスタンダードな存在ではなく、本ブログの記事は PukiWiki(pukiwiki.osdn.jp)の構文を参考にパーサーを独自実装していました。

現在に至るまでの間、見出しを * から # へ、強調を '' から * へといったように少しずつ Markdown に寄せていったのですが、所詮は使用する記号を似せることがせいぜいであり、ようやく重い腰を上げて今どきのライブラリを使用するように変更した次第です。

ライブラリは markdown-it(GitHub)remark(remark.js.org)を試したのですが、最終的に remark を選定しました。

この記事で書くこと
出力される HTML をカスタマイズ
本ブログにおける Markdown 拡張構文
remark の Lint 機能を使用した Markdown 構文チェック
この記事で書かないこと
markdown-it と remark の比較
remark の導入方法解説

要素にクラスを付与

§

remark に限らずどの Markdown ライブラリもそうだと思いますが、出力される HTML の各要素は基本的に class 属性が付きません。しかし本ブログでは独自拡張として、例えばリストは通常の順不同リストのほかに「リンクリスト」や「注釈リスト」を追加しているため、 <ul> 要素が複数の使われ方をしており、クラスで区別する必要があります。

remark で Markdown を元に HTML を生成する場合、以下のようにいったん抽象構文木(AST)を経由して変換を行います。

  1. 【パーサー】 Markdown → Markdown 構文木 (mdast)
  2. 【トランスフォーマー】 Markdown 構文木 (mdast) → HTML 構文木 (hast)
  3. 【コンパイラー】 HTML 構文木 (hast) → HTML

これを図で表すとこうなります。(remark が内部で使用している unified(GitHub)のドキュメントより引用)

| ........................ process ........................... |
| .......... parse ... | ... run ... | ... stringify ..........|

          +--------+                     +----------+
Input ->- | Parser | ->- Syntax Tree ->- | Compiler | ->- Output
          +--------+          |          +----------+
                              X
                              |
                       +--------------+
                       | Transformers |
                       +--------------+

<ul> 要素にクラスを追加する場合、Markdown の - をリスト(list)と認識するところは remark のパーサーが行ってくれるため、トランスフォーマー(mdast → hast)の部分をカスタマイズすることになります。具体的には以下のように、remark-rehype(GitHub)handlers オプションにハンドラー関数(GitHub)を登録します。

import rehypeStringify from 'rehype-stringify';
import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import { unified } from 'unified';

const processor = unified();
processor.use(remarkParse); // Markdown → mdast
processor.use(remarkRehype, {
	handlers: {
		list: (state, node, parent) => {
			if (node.ordered) {
				/* <ol> の処理(省略) */
			} else {
				/* <ul> の処理 */
				return {
					type: 'element',
					tagName: 'ul',
					properties: {
						className: ['my-list'],
					},
					children: state.all(node),
				};
			}
		},
		foo: (state, node, parent) => {
			/* リスト以外の処理も行う場合はこのように追加してゆく */
		}
	},
}); // mdast → hast
processor.use(rehypeStringify); // hast → HTML

const html = await processor.process(`
- list
`);

console.debug(html.value); // <ul class="my-list"><li>list</li></ul>

上記コードはクラスを付与するだけの単純なものですが、先祖や子孫に要素を追加したり、親要素(parent)によって条件を分岐したりするといった複雑なことも可能です。

<section> 要素によるセクショニングと目次の自動生成

§

通常、Markdwon は #<h1>##<h2> といったように見出しのみを出力します。しかし HTML5 ではせっかく <section> 要素があるのですから、それを活用したいところです。

2022年にアウトラインアルゴリズムが廃止されたため <section> 要素を使用しても文書のアウトラインには影響を及ぼさず、また WAI-ARIA との関連からしても <section> 要素はデフォルトロール(暗黙のARIAセマンティックス)を持たない(W3C)ため、そこまで躍起になる必要はありませんが、セクションを要素で囲むことで CSS によるスタイル付けが容易になりますし、なによりセクションがセクションであることをユーザーに伝えるメリットは捨てがたいものです。

remark には多数のプラグイン(GitHub)があり、remark-sectionize(GitHub)のようにこれを実現できるものもすでに存在します。しかし本ブログでは見出しに関して以下のような処理を行っています。

  • 見出しと後続のコンテンツを <section> 要素で囲う
  • セクションに ID を設定する(id 属性は <hn> 要素でなく <section> 要素に書き出す)
  • 記事タイトル(<h1> 要素)は本文には含めないため、Markdown の見出し階層と HTML の見出しレベルは一つずらす(#<h2>##<h3> とする)
  • 目次を自動生成する(挿入位置は文書冒頭ではなく最初の # の直上とする)

個々の機能はプラグインが存在するものの、目次の挿入位置など細かな調整を含めてこれらをすべて実現するプラグインは見つからなかったので自作することにしました。

脚注のカスタマイズ

§

<ul> 要素にクラスを設定した事例で紹介したように、出力される HTML は簡単にカスタマイズ可能です。しかし唯一(?)の例外が脚注で、これは決め打ちで出力されてしまいます。

脚注は Markdown の標準では存在しないため、remark-gfm(GitHub)を追加で読み込み、以下の Markdown を変換します。

HTML[^1]とは……

[^1]: [HTML Standard](https://html.spec.whatwg.org/multipage/)

すると、出力される HTML はこうなります。(改行やインデントは実際とは多少異なります)

<p>HTML<sup><a href="#user-content-fn-1" id="user-content-fnref-1" data-footnote-ref aria-describedby="footnote-label">1</a></sup>とは……</p>

<section data-footnotes class="footnotes">
	<h2 class="sr-only" id="footnote-label">Footnotes</h2>

	<ol>
		<li id="user-content-fn-1">
			<p>
				<a href="https://html.spec.whatwg.org/multipage/">HTML Standard</a>
				<a href="#user-content-fnref-1" data-footnote-backref class="data-footnote-backref" aria-label="Back to content"></a>
			</p>
		</li>
	</ol>
</section>

このうち、参照リンク: sup > a[data-footnote-ref] の部分は footnoteReference の名前で構文木に存在するため、前記の <ul> と同じ要領でカスタマイズ可能です。

一方、脚注そのもの: section[data-footnotes]mdast-util-to-hast/lib/footer.js(GitHub)での管理となっており、remark-rehype のオプション(GitHub)でカスタマイズする形になります。

processor.use(remarkRehype, {
	clobberPrefix: 'my-footnote',
	footnoteLabel: '脚注',
	footnoteLabelTagName: 'h2',
	footnoteLabelProperties: {
		className: ['footnote-heading'],
	},
	footnoteBackLabel: 'コンテンツへ戻る',
	handlers: {
		// 省略
	},
}); // mdast → hast

これで見出し周りはだいぶカスタマイズできるのですが、<section> のクラスや <ol> の中身はほとんど変えることができません。

とくに気になるのがリストに <ol> 要素を採用していることです。 <ol> 要素とは

The ol element represents a list of items, where the items have been intentionally ordered, such that changing the order would change the meaning of the document.

4.4.5 The ol element(WHATWG)

とあるように、順序に意味があるリストであり、機械の操作手順や料理のレシピのようなケースに使うものです。

脚注のリストは本文中に登場した参照リンクを末尾にまとめているだけで、このリスト自体は順序に意味を持ちません。仮に順序を変更したとして、HTML 仕様に書かれているような「順序を変更することで文書の意味が変わる」ことはありません。そのため、ここに <ol> を採用するのは適切ではなく、<ul> が妥当でしょう。あるいは <dl> でも良いと思います。

前述のとおり通常のやり方で <ol> 要素の変更ができないため、hast の構築後にむりやり構造を書き換える形となります。以下に挙げるコードは <ol> の要素名を <ul> に書き換えるだけの単純な処理ですが、完成された DOM を自分好みに変更するユーザースクリプトのような感覚で任意の構造に組み直すことも可能です。

import { select } from 'hast-util-select';

// 中略

processor.use(remarkRehype, {
	// 省略
}); // mdast → hast

processor.use(() => {
	return (tree) => {
		/* 脚注のカスタマイズ */
		const footnoteList = select('[data-footnotes] > ol', tree);
		if (footnoteList === null) {
			return;
		}
		footnoteList.tagName = 'ul';
	};
});

本ブログにおける Markdown 拡張構文

§

<table><dl> は既存の拡張構文を import して登録すればよいのですが、本ブログ独自に採用したものも多くあります。

完全に独自の概念としては、以下のようなものがあります。とくに <blockquote> にするほどでもないワンフレーズの引用として <q> 要素を使用する頻度は多く、なぜこれが Markdown に存在しないのか不思議なくらいです。

機能 Markdown HTML
文中の短い引用 {引用文}(メタ情報) <q cite="" lang="">引用文</q>
なにも出力しない行[1] 行内を文字列 のみとする
独立画像[2] @ファイル名: キャプション figure > a > picture > source + img および figcaption
YouTube 動画の埋め込み @youtube: メタ情報 割愛
Amazon 商品の画像付きリンク @amazon: メタ情報 割愛

また既存の構文を拡張したものの一例を挙げます。

機能 Markdown HTML
リンクリスト 順不同リストの中身がすべてリンク形式 [text](URL) <ul> 開始タグの class 属性値を通常リストと分けることで見た目を変える
注釈リスト 順不同リストの中身がすべて note: で始まる <ul> 開始タグの class 属性値を通常リストと分けることで見た目を変える
追記 順不同リストの中身がすべて YYYY-MM-DD: 形式で始まる 記事公開後の追記情報として <ins datetime=""> で出力
引用ブロックの出典 >- ? に続けて出典のタイトルや URL を書く[3] figure > blockquote + figcaption に出典を表示
引用ブロックのメタ情報 同じく >- ? に続けて言語コードや ISBN を書く <blockquote> 開始タグに lang 属性や cite 属性を設定
引用ブロック内の中略 >~ <blockquote> 内で前後の文と区別した形で「中略」と表示[4]
表本体の一列目が見出しセル 一行目の最初のセルを | ~cell | のように ~ で始める[5] <tbody> 内の各一列目を <th scope="row"> にする

Lint 機能を使用した Markdown 構文チェック

§

remark は Lint 機能も充実しており、Markdown の構文チェックを行うことができます。

例えば <em><strong> はそれぞれアスタリスク構文とアンダースコア構文がありますが、Markdown Guide(www.markdownguide.org)ではアスタリスク構文をベストプラクティスとしているため、アンダースコア構文が使用されていた場合は警告を出すようにします。

import remarkLintEmphasisMarker from 'remark-lint-emphasis-marker';
import remarkLintStrongMarker from 'remark-lint-strong-marker';

// 中略

const processor = unified();

processor.use(remarkLintEmphasisMarker, '*'); // <em> の構文チェック
processor.use(remarkLintStrongMarker, '*'); // <strong> の構文チェック

processor.use(remarkParse); // Markdown → mdast
processor.use(remarkRehype); // mdast → hast
processor.use(rehypeStringify); // hast → HTML

const emAsterisk = await processor.process('I like *kinoko*.');
console.debug(emAsterisk.value); // <p>I like <em>kinoko</em>.</p>
console.debug(emAsterisk.messages); // []

const emUnderscore = await processor.process('I like _takenoko_.');
console.debug(emUnderscore.value); // <p>I like <em>takenoko</em>.</p>
console.debug(emUnderscore.messages); // [ { reason: 'Emphasis should use `*` as a marker', ... } ]

公式からリンクが張られているものだけでも 100近いルール(GitHub)が存在しますが、足りないものは自作することもできます。

本ブログでは以下のような独自のチェックを行っています。

  • 見出し階層のリミットを設定(3階層目の ### 以深は警告する)
  • セクション内に見出し以外のコンテンツが存在すること(# heading1\n# heading2 のようなケースを警告する)
  • <br><hr> などいくつかの構文は使用しない
  • Loose list は使用しない[6]
  • [Duck Duck Go](https://duckduckgo.com "The best search engine for privacy") のようなリンクのタイトルは使用しない[7]
  • HTML タグ表記は `<p>` のように極力コード構文内に書く(ただし引用時は引用元の表現を尊重し、意図的に素のまま書くこともある)

脚注

  • 1.

    リストの分割に使用する。リストは - list\n\n- list のように空行を設けても一つのリストとされてしまうため、 - list\n␣\n- listとすることで分割可能にしたいケースがある。 ↩ 戻る

  • 2.

    Markdown 標準の ![Alt](URL)形式は使用しない。文中でフレージングコンテンツとしての画像を使用するケースは本ブログでは皆無なため。 ↩ 戻る

  • 3.

    >- ? と長くなったのは、「引用記号としての >」「順不同リストであることを示す - 」「独自拡張として出典を表す ?」の3種類の意味合いがあるため。プログラムの処理上の都合を優先した結果であり、気持ちとしては ?のみに短くしたいところ。 ↩ 戻る

  • 4.

    <blockquote> 要素の仕様(WHATWG)では引用の出典は <blockquote> の外側に配置しなければならないなど、引用文とそれ以外の区別が厳密に定めていますが、一方で引用文を元の文章から改変することは認められています。そのため <blockquote> <p>引用文前半</p> <p><b>(中略)</b></p> <p>引用文後半</p> </blockquote> のように <blockquote>内に中略表記を含めることは問題ないと考えられます。 ↩ 戻る

  • 5.

    記号文字を ~ にしたのは PukiWiki 構文(pukiwiki.osdn.jp)が由来。 ↩ 戻る

  • 6.

    リスト内に他の要素を含める(www.markdownguide.org)こと。<li> の子要素に <p> が挟まれるなどマークアップも変わってしまう。詳細は remark-lint-list-item-indent のドキュメント(GitHub)が詳しい。 ↩ 戻る

  • 7.

    HTML 仕様において、補足情報を title 属性(WHATWG)でのみ提供することはいくつかの例外を除き推奨されていない。 ↩ 戻る