ricomintea’s diary

好きなものを好きでいる

Markdownでやる正規表現

MarkdownをHTMLに変換するものを自作したときに書いたものを振り返りがてら、この記事ではMarkdownの見出しを読み取りHTMLにするところを書こうと思います。

github.com

いくつかの方針

JavaScriptのランタイムのひとつであるDenoを使うことにしました。プログラミング言語の中でここ最近特に慣れがあるJavaScript / TypeScriptを使いつつ、LintとFormatツールを標準で備えているところに嬉しさがあります。

パーサー実装に慣れているわけではなかったので、テスト駆動のように簡単な例から実装していく進め方を取りました。

o

まずは受け取った文字列をそのまま返す状態を作ります。

const markdown = `Text`;

function convertMarkdownToHTML(markdown: string): string {
  return markdown;
}

if (import.meta.main) {
  const html = convertMarkdownToHTML(markdown);
}

見出しを処理する方法を考えます。

const markdown = `# Heading`;

function convertMarkdownToHTML(markdown: string): string {
  return markdown;
}

if (import.meta.main) {
  const html = convertMarkdownToHTML(markdown);
}

Markdownの見出しの構造は、見出しのレベルを表す # の1つ以上の繰り返しと、見出しとして表示する部分(コード中でいう Heading )に分かれています。

このパターンを読み取る方法として正規表現が使えます。

JavaScriptでは正規表現を扱うのに、正規表現リテラルを使う方法と RexExp オブジェクトを使う方法が存在します。

// 正規表現リテラルの定義はスラッシュ2つの間にパターンを書くことでできます。
const regexp1 = /ここに正規表現パターンを記述/;

// RegExpオブジェクトを使う場合はインスタンスを生成します。
const regexp2 = new RegExp('ここに正規表現パターンを記述');

developer.mozilla.org

正規表現のパターンを考えていきますが、エディタの拡張機能やWebアプリケーションで確認しながらやると間違っていないか気づきやすくいいと思います。

www-creators.com

見出しの定義は必ず行の一番最初に来るので正規表現アサーション ^ が使えます。

const heading_regexp = /^/;

見出しのレベルを表す # の1つ以上の繰り返しにマッチするパターンを作ります。

const heading_regexp = /^#+/;

GitHub Flavored Markdownでは見出しレベルとして扱うのは6までとされているので、そこに倣うのなら次のようになります。(今回は別にGitHub Flavored Markdownに従ったわけではなく、ふんわりとやっています。)

const heading_regexp = /^#{1,6}/;

github.github.com

次に見出しとして表示する文字列の部分のパターンを考えます。 # の繰り返しから空白を挟んで任意の文字列にマッチさせたいです。

空白に一致するには \s を使い、任意の文字には . が使えます。

const heading_reg = /^#{1,6}\s+.+/;

Stringオブジェクトの持つ match メソッドに定義した正規表現を渡して使うことができます。

'# heading'.match(heading_reg);
// => ['# heading', index: 0, input: '# heading', groups: undefined]

パターンにマッチして結果が返ってきます。ただこの状態ではせっかくマッチさせた情報が扱いづらいです。一部分の正規表現パターンに対して名前を付けて扱うことができる、名前付きキャプチャグループが使えます。

const heading_reg = /^(?<level>#{1,6})\s+(?<content>.+)/
'# heading'.match(heading_reg);
// => ['# heading', '#', 'heading', index: 0, input: '# heading', groups: {…}]
/*
    0: "# heading"
    1: "#"
    2: "heading"
    groups: {level: '#', content: 'heading'}
    index: 0
    input: "# heading"
*/

developer.mozilla.org

作った正規表現を使って、見出しを一回だけ評価してHTMLに変換する処理を書いてみます。

const markdown = `# Heading`;
const heading_reg = /^(?<level>#{1,6})\s+(?<content>.+)/

type Token = { level: number; content: string; };

function parse(markdown: string): { level: number, content: Token } {
  const heading = markdown.match(heading_reg);
  if (heading) {
    return {
      level: (heading.groups?.level || '#').length,
      content: heading.groups?.content || '',
    },
  }
}

function renderHTML(t: Token): string {
  return `<h${t.level}>${t.content}</h${t.level}>`;
}

function convertMarkdownToHTML(markdown: string): string {
  const parsed = parse(markdown);
  const html = renderHTML(parsed);
  return html;
}

if (import.meta.main) {
  const html = convertMarkdownToHTML(markdown);
  console.log(html);
  // => <h1>Heading</h1>
}

Denoで(または型情報を落とせばでブラウザのコンソール上でも)実行すると、 h1タグとなって見出しがログに出力されます。

ここから立派なMarkdown to HTMLの変換器を作るために、他の構文をサポートする、parse関数で1文字ずつ順繰り見る、render関数で気合いジェネレーターを書くなどの工程が必要です。いつか記事になることを夢にも見ています。

「マークダウンパーサー作ろう」という気持ちにさせてくれ、パース部分で参考にさせていただいた記事です。感謝 :pray:

www.m3tech.blog