markedで自動セクショニング

このブログではサブセクションを作る場合、直接<section>タグを書き、その内容を再帰的にmarkedで処理してやっていた。あまり困ってはいないが、普通に書かれたMarkdownをうまく処理して、好みのHTMLコードで出力してやる作業を少しずつ重ねてきたので、ついでに自動セクショニングらしきものを実装した。

自動セクショニングは以下の条件で発動させたい:

  1. 見出しレベルが増加
  2. 同じ見出しレベルが出現
  3. 見出しレベルが減少
  4. テーマの区切り(hr要素)が出現

1ではsection開始タグが、2ではsectionタグの終了と開始が、3と4ではsection終了タグが、それぞれ追加される。見出しは常にh1要素でマークアップする。またMarkdownの性質上、何かしらのテンプレートに流し込まれることが多く、そのテンプレートがセクションかセクショニング・ルートを持っている(bodyarticle、またはblockquote要素が直近に存在する)ので、トップ・レベルは例外としてセクショニングしない。

# Section 1

This is a level 1 section.

## Section 1-1

This is a level 2 section.

## Section 1-2

This is a level 2 section.

### Section 1-2-1

This is a level 3 section.

---

## Section 1-3

This is a level 2 section.

* * *

This is a level 1 section.

こういうMarkdownを以下のようなHTMLにしたい。

<h1>Section 1</h1>
<p>This is a level 1 section.</p>
<section>
  <h1>Section 1-1</h1>
  <p>This is a level 2 section.</p>
</section>
<section>
  <h1>Section 1-2</h1>
  <p>This is a level 2 section.</p>
  <section>
    <h1>Section 1-2-1</h1>
    <p>This is a level 3 section.</p>
  </section>
  <hr>
</section>
<section>
  <h1>Section 1-3</h1>
  <p>This is a level 2 section.</p>
</section>
<hr>
<p>This is a level 1 section.</p>

実装は、グローバルで現在の見出しレベルを管理し、それを増減しつつ比較することで、出力すべきタグを決めていく。markedのブロック・レンダラーのうち、headinghrを使う。

const marked = require("marked");

let currentLevel = 1;

const markupHeading = (text, level) => {
  if (currentLevel === 1 && level === 1) {
    return `<h1>${text}</h1>
`;
  }

  if (currentLevel > 1 && level === currentLevel) {
    return `</section>
<section>
<h1>${text}</h1>
`;
  }

  if (currentLevel > 1 && level < currentLevel) {
    currentLevel = level;
    return `</section>
<h1>${text}</h1>
`;
  }

  currentLevel = level;
  return `<section>
<h1>${text}</h1>
`;
};

const markupThematicBreak = () => {
  if (currentLevel === 1) {
    return `<hr>
`;
  }

  currentLevel = currentLevel - 1;
  return `</section>
<hr>
`;
};

const renderer = new marked.Renderer();
renderer.heading = markupHeading;
renderer.hr = markupThematicBreak;

module.exports = t =>
  marked(t, {
    renderer: renderer
  });

既に判明している問題は、サブセクションでテーマの区切りが使えないこと、引用やリスト内で見出しが使えないこと、の2つだ。両者ともに使う機会は少ないと思うので目をつぶっている。トップ・レベルでレベル1見出しを複数使えないという制限もあるが、Markdownの使われ方を考慮すると、この点は問題ないだろう。

副作用として生HTMLをmarkedに任せられるようになった。そのため野良Markdownを食わせても、HTMLをそのまま食わせても、うまく扱える。どこかに書いたMarkdown (らしきテキスト)をそのまま処理できるようになったので、相互運用性が高まった……かもしれない。