markdown-itは、サーバー/クライアント問わず使える、MarkdownをHTMLに変換してくれるライブラリである。プラグインを追加すれば、独自の記法も手軽にサポートできる。

ちょっと見た感じ、だいたいのことは既存のプラグインを工夫すればなんとかなりそうだ。おそらく、いま私がやりたいこともなんとかなる。

なので見ないふりをして、自分で手を動かしてみたいと思う。

まずアーキテクチャを知る

markdown-itでは、パーサーによってマークダウンをトークナイズし、トークンの配列を作る(このトークンは入れ子になっている)。そしてその配列をレンダラーに渡し、それぞれのトークンをHTMLへ変換する。

このトークナイズとレンダリングは、それぞれルールと呼ばれる関数(というか概念)で対応づけられている。

ルールは、大きくcoreblockinlineの3種類のチェーン(≒グループ)に分けられる。coreには、パースの下準備をしたり、blockトークンとinlineトークンを作ったりするチェーンが含まれる。blockinlineには、それぞれブロック要素、インライン要素の各トークンを作るチェーンが含まれる。

これら3つのチェーンは、ネストされて使われる。

それぞれのチェーンを適用してトークンの配列を作ったら、今度はそのトークンタイプに対応するレンダラーのルールを適用して、各トークンをHTMLに変換していく。

パース段階とレンダリング段階で、2種類のルールが適用される点が微妙にややこしい。

以降はパースで使うルールは無印の「ルール」とし、レンダラーで使うルールは「レンダールール」と呼んで区別する。

チェーンとルール

チェーンは、ルール関数のグループにつける名前である。例えばblockチェーン(というと余計ややこしいか)には、paragrahblockquoteというチェーンが連なっている。

ルール関数は、Markdown記法がサポートする要素と1対1で対応する。開始タグ、コンテンツ、終了タグのようにトークナイズされるので、トークンと1対1の関係ではない。

また、、HTMLは要素が入れ子になる。あるルール関数の中で別のルール関数を使うケースも当然出てくる。例えばblockquoteというトークンの中にはparagraphがあるかも知れず、別のblockquoteが入れ子になっているかも知れない。

そのためmarkdown-itでは、出力したいトークンに関連するルール関数を1つのチェーン名のもとにまとめ、ルール関数の中で適宜取得・適用できるような仕組みになっている。

例えばparagraphルール関数は以下のようになっている。

js
export default function paragraph(state, startLine, endLine) {
  //ここでparagraphチェーンを取得
  const terminatorRules = state.md.block.ruler.getRules("paragraph");
  const oldParentType = state.parentType;
  let nextLine = startLine + 1;
  state.parentType = "paragraph";

  for (; nextLine < endLine && !state.isEmpty(nextLine); nextLine++) {
    if (state.sCount[nextLine] - state.blkIndent > 3) {
      continue;
    }
    if (state.sCount[nextLine] < 0) {
      continue;
    }

    // ここで順番に適用
    let terminate = false;
    for (let i = 0, l = terminatorRules.length; i < l; i++) {
      if (terminatorRules[i](state, nextLine, endLine, true)) {
        terminate = true;
        break;
      }
    }
    if (terminate) {
      break;
    }
  }

  const content = state
    .getLines(startLine, nextLine, state.blkIndent, false)
    .trim();

  state.line = nextLine;

  const token_o = state.push("paragraph_open", "p", 1);
  token_o.map = [startLine, state.line];

  const token_i = state.push("inline", "", 0);
  token_i.content = content;
  token_i.map = [startLine, state.line];
  token_i.children = [];

  state.push("paragraph_close", "p", -1);

  state.parentType = oldParentType;

  return true;
}

なんだか再帰っぽく見えるかも知れないが、state.md.block.ruler.getRules('paragraph')で取得しているのはparagraphチェーンのルール関数群である。ここで定義しているparagraph()関数もそのチェーンには含まれているが、自身を取得しているわけではない。

デフォルトのチェーンに連なるルール関数は、それぞれのパーサーのソース(coreblockinline)でそれぞれ確認できる。

tokenオブジェクト

markdown-itのパース処理は、入力されたテキストをtokenオブジェクトのの配列(≒トークンストリーム)に置き換える。

tokenオブジェクトには、そのトークンの種類や変換するタグ、属性に関連するメソッドやプロパティが用意されている。

ts
// 開始タグは1、コンテンツあるいはセルフクロージングは0、終了タグは-1
export type Nesting = 1 | 0 | -1;

export default class Token {
  // 新しいトークンオブジェクトを作り、渡されたプロパティを設定する
  constructor(type: string, tag: string, nesting: Nesting);

  // トークンタイプ。例えば"paragraph_open"など
  type: string;

  // HTMLのタグ名。例えば"p"など
  tag: string;

  // HTMLの属性値。フォーマットは`[ [ name1, value1 ], [ name2, value2 ] ]`
  attrs: Array<[string, string]> | null;

  // ソースマップ情報。フォーマットは `[ line_begin, line_end ]`
  map: [number, number] | null;

  // トークンが開始タグか、セルフクロージングタグあるいはコンテンツか、終了タグか
  nesting: Nesting;

  // ルートからの階層レベル。`state.level`と同じ
  level: number;

  // 子ノードの配列。inlineやimgトークン
  children: Token[] | null;

  // タグの持つコンテンツ
  content: string;

  // '*' や '_'など、マークダウンのマークアップに使われた記号
  markup: string;

  // "fence"トークンの場合は言語名
  // "link_open"あるいは"link_close"トークンの場合は、autolinkの場合に"auto"
  // 順序付きリストの"list_item_open"トークンの場合はマーカーの数字
  info: string;

  // プラグインの任意の値を保持
  meta: any;

  // block-levelトークンかどうか。line breakを計算するためにレンダラーに使われる
  block: boolean;

  // trueの場合、この要素はレンダリング時に無視される
  // タイトリストが段落を隠すために使用?
  hidden: boolean;

  // 属性名からそのインデックスを取得
  attrIndex(name: string): number;

  // 属性名と属性値を追加
  attrPush(attrData: [string, string]): void;

  // 既存の属性値の上書き
  attrSet(name: string, value: string): void;

  // 既存の属性値の値を取得。存在しない場合はnullが返る
  attrGet(name: string): string | null;

  // 既存の属性値に空白で値を繋げる。クラス名の指定に便利
  attrJoin(name: string, value: string): void;
}

ルール関数は、与えられたテキストをこのトークンに分ける処理を担う。

ルールをあれこれするには

ルールをあれこれするには、core,block,inlineという3つのパーサーが持つRulerインスタンスのメソッドを使う。

Rulerインスタンスは、以下の8つのメソッドを持っている。なお、xxxは、core, block,inlineのいずれかである。

また、簡便のためにオプションの引数は省略している。

js
// 既存のルールを新しいルールと置き換える
md.xxx.ruler.at("existed_rule", function rule_function(state) {
  //...
});

// 既存のルールの前に新しいルールを追加する
md.xxx.ruler.before("existed_rule", "my_rule", function rule_function(state) {
  //...
});

// 既存のルールの後ろに新しいルールを追加する
md.xxx.ruler.after("existed_rule", "my_rule", function rule_function(state) {
  //...
});

// 既存のルールを無効にする(トークンが生成されないようにする)
md.xxx.ruler.disable("existed_rule");

// 既存のルールを有効にする
md.xxx.ruler.enable("existed_rule");

// 既存のルール有効にし、それ以外を無効尾にする
md.xxx.ruler.enableOnly("existed_rule");

// 渡されたルール名に対応するルール関数を返す。
// 空文字の場合は、そのチェーンのすべてのルール関数の配列が返る。
md.xxx.ruler.getRules("");

//チェーンのルール関数の配列の末尾に新しいルール関数を追加する
md.xxx.ruler.push("existed_rule", function rule_function(state) {
  // 同じレベルでトークンになり得る文字列がない場合、
  // ルール関数は実行されない。
  // ルール関数が適用される順番に注意。
});

ドキュメントにはルールとあるが、上記のメソッドで指定するルール(’existed_rule’とか’my_rule’とか)はチェーン名と考えた方がわかりやすいかと思う。同名のチェーン名に対して、2つ以上のルール関数を渡すことができるからだ。

例えば、以下のようにすると、my_token1()関数とmy_token2()関数が両方実行される。

js
function demoPlugin(md) {
  md.block.ruler.before(
    "paragraph",
    "my_rule",
    function my_token1(state, start, end, slient) {
      console.log("1号!");
      return false;
    }
  );

  md.block.ruler.before(
    "paragraph",
    "my_rule",
    function my_token2(state, start, end, slient) {
      console.log("2号!");
      return false;
    }
  );
}

const md = markdownIt({
  html: true,
}).use(demoPlugin);

なお、markdown-itのプラグインは、markdown-itのインスタンスに、md.use(plugin, params)のような形で渡される。これはplugin(md, params)を呼び出すためのシンタクスシュガーである。

つまりプラグインはmd(=markdown-itのインスタンス)とparams(任意の引数)を1つ受け取る関数である。

だんだん込み入ってきた。

ルール関数の型

ルール関数は、大元となるcoreblockinlineのいずれかによって、異なる引数を受け取る。具体的には、チェーンごとに以下のように型づけされている。

ts
type RuleCore = (state: StateCore) => void;

type RuleBlock = (
  state: StateBlock,
  startLine: number,
  endLine: number,
  silent: boolean /* 構文チェックだけを行うか、トークンを生成するか */
) => boolean;

export type RuleInline = (state: StateInline, silent: boolean) => boolean;

export type RuleInline2 = (state: StateInline) => boolean;

それぞれの第一引数は、stateという名前は同じだが、実は違うインスタンスであることに留意したい。

例えばblockチェーンのルール関数は、第一引数にStateBlock、第二引数、第三引数に開始行のインデックスと最終行のインデックス、第四引数にトークンを生成しないか(バリデーションだけを行うか)を判断する真偽値を渡す。

ルール関数の中では、渡された情報をもとにトークンを探し、行数(state.line)を進めてトークンを生成、プッシュする。順番はどうでもよさそうだが、とにかくそんなことをする。

ルール関数は、トークンを生成したらtrue、マッチするパターンがなければfalseを返す。

この値に応じて、パーサーは次の行へ処理を進めるか、次のルールを適用する。

inlineチェーンの場合も、行単位が文字単位になるだけで、似たり寄ったりである。

ルール関数を作る際は、このstateオブジェクトをあれこれして、任意のトークンをトークンストリームにプッシュすることになる。

ので、それぞれのstateオブジェクトの構造を知ることが不可欠だ。

StateCoreオブジェクト

StateCoreオブジェクトは以下のように定義されている。

coreチェーンのルールは、トークンを作るのではなく、入力テキストの下準備をしたり、blockinlineのそれぞれのパーサーを呼び出したりするために使われる。

coreのデフォルトのルールはここで参照できる。

ts
import MarkdownIt from "../index.mjs";
import Token from "../token.mjs";

export default class StateCore {
  constructor(src: string, md: MarkdownIt, env: any);

  src: string;
  env: any;
  tokens: Token[];
  inlineMode: boolean;

  md: MarkdownIt;

  Token: typeof Token;
}

参考:DefinitelyTyped/types/markdown-it/lib/rules_core/state_core.d.mts at master · DefinitelyTyped/DefinitelyTyped

StateBlockオブジェクト

StateBlockオブジェクトは以下のように定義されている。blockチェーンに連なるルールはここで参照できる。

ts
export type ParentType =
  | "blockquote"
  | "list"
  | "root"
  | "paragraph"
  | "reference";

export default class StateBlock {
  constructor(src: string, md: MarkdownIt, env: any, tokens: Token[]);

  src: string;

  // markdown-itのインスタンス
  md: MarkdownIt;

  // プラグインが使う任意のプロパティ?
  env: any;

  // トークンストリーム
  tokens: Token[];

  // 行頭のインデックスの配列
  bMarks: number[];

  // 行終わりのインデックスの配列
  eMarks: number[];

  // 各行のスペース以外の文字が始まるインデックスの配列
  tShift: number[];

  // 各行の行頭からのスペースの数の配列
  sCount: number[];

  // よくわからん
  bsCount: number[];

  // 処理中のブロックのインデント
  blkIndent: number;

  // 処理中の行番号
  line: number;

  // 行数
  lineMax: number;

  // タイトモードかルーズモードか
  tight: boolean;

  // 現在のddブロックのインデント(存在しない場合は-1)
  ddIndent: number;

  // 現在のlistブロックのインデント(存在しない場合は-1)
  listIndent: number;

  parentType: ParentType;

  level: number;

  // 新しいトークンをトークンストリームにプッシュ
  push(type: string, tag: string, nesting: Nesting): Token;

  // 空行かどうかを返す。
  isEmpty(line: number): boolean;

  // 次の空白文字以外の文字のインデックスを返す。
  skipEmptyLines(from: number): number;

  // 与えられたインデックスから続く空白文字の直後にある文字のインデックスを返す
  skipSpaces(pos: number): number;

  // 上の逆
  skipSpacesBack(pos: number, min: number): number;

  // 渡されたインデックスから続く文字コードをスキップし、直後の文字のインデックスを返す
  skipChars(pos: number, code: number): number;

  // 上の逆
  skipCharsBack(pos: number, code: number, min: number): number;

  // 大元の入力テキストから任意の範囲のテキストを取得
  getLines(
    begin: number,
    end: number,
    indent: number,
    keepLastLF: boolean
  ): string;

  Token: typeof Token;
}

参考:DefinitelyTyped/types/markdown-it/lib/rules_block/state_block.d.mts at master · DefinitelyTyped/DefinitelyTyped

StateInlineオブジェクト

StateInlineオブジェクトは以下のように定義されている。inlineチェーンに連なるルールはここで参照できる。

ts
import MarkdownIt from "../index.mjs";
import Token, { Nesting } from "../token.mjs";

export interface Scanned {
  can_open: boolean;
  can_close: boolean;
  length: number;
}

export interface Delimiter {
  marker: number;
  length: number;
  token: number;
  end: number;
  open: boolean;
  close: boolean;
}

export interface TokenMeta {
  delimiters: Delimiter[];
}

export default class StateInline {
  constructor(src: string, md: MarkdownIt, env: any, outTokens: Token[]);

  src: string;
  env: any;
  md: MarkdownIt;
  tokens: Token[];
  tokens_meta: Array<TokenMeta | null>;

  pos: number;
  posMax: number;
  level: number;
  pending: string;
  pendingLevel: number;

  /**
   * Stores { start: end } pairs. Useful for backtrack
   * optimization of pairs parse (emphasis, strikes).
   */
  cache: any;

  /**
   * List of emphasis-like delimiters for current tag
   */
  delimiters: Delimiter[];

  // Stack of delimiter lists for upper level tags
  // _prev_delimiters: StateInline.Delimiter[][];

  /**
   * Flush pending text
   */
  pushPending(): Token;

  /**
   * Push new token to "stream".
   * If pending text exists - flush it as text token
   */
  push(type: string, tag: string, nesting: Nesting): Token;

  /**
   * Scan a sequence of emphasis-like markers, and determine whether
   * it can start an emphasis sequence or end an emphasis sequence.
   *
   * @param start position to scan from (it should point at a valid marker)
   * @param canSplitWord determine if these markers can be found inside a word
   */
  scanDelims(start: number, canSplitWord: boolean): Scanned;

  Token: typeof Token;
}

参考:DefinitelyTyped/types/markdown-it/lib/rules_inline/state_inline.d.mts at master · DefinitelyTyped/DefinitelyTyped

レンダールール

パーサーによってテキストからトークンストリームが生成されると、それはレンダラーに渡される。レンダラーはすべてのトークンをイテレートして、トークンタイプと同名のレンダールールを適用してHTMLに変換する。

レンダールールをあれこれするには

レンダールールをあれこれするには、レンダールール関数の中でtokensRendererインスタンスのプロパティあるいはメソッドを使ってアレコレする。

レンダールール関数の型

まずレンダールール関数は以下のように型づけされている。

js
type RenderRule = (
  tokens: Token[],
  idx: number,
  options: Options,
  env: any,
  self: Renderer
) => string;

レンダールールを追加したり、変更したり際は、以下のようにmd.renderer.rulesにトークンタイプを直接指定し、関数を代入する。

js
md.renderer.rules.fence = function (tokens, idx, options, env, slf) {
  const token = tokens[idx];
  return (
    "<my-element><code>" +
    md.utils.escapeHtml(token.content) +
    "</code></my-element>\n"
  );
};

レンダールールはトークンと1対1で対応づけられているため、パーサーのルールと違ってチェーンにできない。

そのためデフォルトのレンダールールの前段で何らかの処理をしたいときは、以下のように変数に保持した上でルール関数を定義し、その関数内でデフォルトのルールを呼び出す形を取る。

js
const originalFenceRenderRule = md.renderer.rules["token_name"];
md.renderer.rules["token_name"] = function (tokens, idx, options, env, slf) {
  // なんらかの処理
  return originalFenceRenderRule(tokens, idx, options, env, slf);
};

デフォルトのレンダールールは1つのファイルにまとまっているので、自作する際はこれを参考にすると良い。

自作プラグインの例

このブログのために、!demo-start!demo-endの間にあるコードブロック(“`)の中身)を、タブ形式で表示できるような簡単なコンポーネントを出力できるようにしたい。

以下はそのために書いたお試しコードである。

js
function demoPlugin(md) {
  md.block.ruler.before(
    "fence",
    "demo_viewer",
    function (state, startLine, endLine, silent) {
      // 行の先頭が'!demo-open'じゃなかったら何もしない
      const startLineContent = state
        .getLines(startLine, startLine + 1, 0, false)
        .trim();
      if (startLineContent !== "!demo-open") return false;

      let nextLine = startLine;
      while (nextLine < endLine) {
        nextLine++;
        const line = state.getLines(nextLine, nextLine + 1, 0, false).trim();
        if (line === "!demo-close") {
          // '!demo-close'の存在を確認したら'!demo-open'トークンをプッシュ
          const startToken = state.push("demo-open", "div", 1);
          // demo-openトークンに必要な情報を設定
          startToken.markup = "!demo-open";
          startToken.block = true;
          startToken.map = [startLine, nextLine];
          // blockパーサーのメソッドを利用して'!demo-open'と'!demo-close'の間のトークンを生成&プッシュ
          state.md.block.tokenize(state, startLine + 1, nextLine);
          // 最後に'!demo-open'トークンをプッシュ
          state.push("demo-close", "div", -1);
          // state.lineを次に進める
          const closeLine = nextLine + 1;
          state.line = closeLine + 1;
          return true;
        }
      }

      // demo-open/demo-closeが対応していない場合は何もしない
      return false;
    }
  );

  md.renderer.rules["demo-open"] = function (tokens, idx, options, env, slf) {
    const codeBlocks = [];
    let diff = idx + 1;
    while (tokens[diff].type !== "demo-close") {
      if (tokens[diff].type !== "fence") {
        console.log(tokens[diff].type);
        console.warn("Demo blocks can contain only fences.");
        return "";
      }
      // demo-open/demo-closeの間の、
      // 非表示にしたい`fence`を判定するためにhiddenを使う(この使い方が正しいかは知らない)
      tokens[diff].hidden = true;

      codeBlocks.push({
        langName: tokens[diff].info,
        content: tokens[diff].content,
      });

      diff++;
    }

    const tabButtons = codeBlocks
      .map((c_blk, blk_idx) => {
        return `<button class="${
          blk_idx === 0 ? "active" : ""
        }" data-tab-id="${blk_idx}">${c_blk.langName}</button>`;
      })
      .join("");

    const tabContents = codeBlocks
      .map((c_blk, blk_idx) => {
        return `<div class="${
          blk_idx === 0 ? " active" : ""
        }" data-tab-id="${blk_idx}"><pre><code class="language-${
          c_blk.langName
        }">${md.utils.escapeHtml(c_blk.content)}</code></pre></div>`;
      })
      .join("");

    return `<div class="demo-viewer"><div class="code-container"><div class="tab-buttons">${tabButtons}</div><div class="tab-contents">${tabContents}</div><div class="preview-container"><iframe></iframe></div></div>`;
  };

  // tokens[diff].hiddenでfenceの表示/非表示を振り分けるために、
  // fenceのデフォルトのレンダールールを上書き
  // ※レンダールールはトークン単位で適用されるため、これをしないと
  // demo-open/demo-closeの間のfenceも処理されてしまう
  const originalFenceRenderRule = md.renderer.rules["fence"];
  md.renderer.rules["fence"] = function (tokens, idx, options, env, slf) {
    if (tokens[idx].hidden) {
      return "";
    }
    return originalFenceRenderRule(tokens, idx, options, env, slf);
  };
}

const md = markdownIt({
  html: true,
}).use(demoPlugin);

const testMd = `
!demo-open
\`\`\`html
<p>こんにちは</p>
\`\`\`

\`\`\`css
p{color:red;}
\`\`\`

\`\`\`js
console.log('ごきげんよう');
\`\`\`

!demo-close
`;
console.log(md.render(testMd));
/* 出力:
 <div class="demo-viewer">
    <div class="code-container">
      <div class="tab-buttons">
        <button class="active" data-tab-id="0">js</button>
        <button class="" data-tab-id="1">html</button>
        <button class="" data-tab-id="2">css</button></div>
      <div class="tab-contents">
        <div class=" active" data-tab-id="0">
          <pre><code class="language-js">console.log(&quot;hello&quot;);</code></pre>
        </div>
        <div class="" data-tab-id="1">
          <pre><code class="language-html">&lt;p&gt;Hello, guys.&lt;/p&gt;</code></pre>
        </div>
        <div class="" data-tab-id="2">
          <pre><code class="language-css">p{color:red;}</code></pre>
        </div>
      </div>
      <div class="preview-container"><iframe></iframe></div>
    </div>
  </div>
*/

これがmarkdown-itの設計意図に適うものかは謎だ。例えばtoken.hiddenの使い方とか、使用目的にかなっているのか謎だ(たぶんmetaを使うべきだろう)。

あんまり既存プラグインのコードとかも読んでいないので、これから勉強して詰めていきたいと思う。

参考資料