Skip to content

Markdown フィールドの拡張(Mermaid・独自記法) ​

第1版作成 最終更新 (日本時間)
確認バージョン1.5.1.01.5.8.1

Markdown フィールドで ```mermaid のコードブロックを図として描画したり、数式・アラートなどの記法を足したりする方法の設計メモです。本体の標準機能ではありません。 現行の Markdown の描画の仕組みは Markdown の機能差異とリッチテキストへの移行 を参照してください。

方法本体の改修できること主な制約
A. 拡張スクリプトでビューアの DOM を後処理する不要コードブロックを図などに置き換える描画のたびに後処理が必要。生 HTML は使えないまま
B. フロントエンドのビルドを改修する(marked.use() と DOMPurify の設定)必要新しい記法の追加、許可するタグ・属性の追加本体の更新のたびに差分を当て直してビルドする
C. html レンダラーのエスケープを緩める必要Markdown に書いた HTML の一部を通すXSS の危険が大きい。DOMPurify 側の許可も要る

調査は 1.5.1.0 のソースで行い、前提にした部分を 1.5.8.1 のソースで確かめています。

前提にした現行実装 ​

  • Markdown の変換は markdownField.ts の MarkdownFieldElement(markdown-field カスタム要素)が行います。marked は new Marked({ gfm: true, breaks: true, renderer: { html, link, image, code } }) のインスタンスを 1 つ持つだけで、use()・extensions・tokenizer・walkTokens・hooks は使っていません(markdownField.ts#L163-L172)。package.json にも marked-* の拡張パッケージはありません。
  • html レンダラーは生の HTML をすべてエスケープします。Markdown に <div class="mermaid"> のようなタグを書いても文字列になります。
  • 変換結果は DOMPurify.sanitize(md, { ADD_ATTR: ['title'] }) を通してから .md-viewer に innerHTML で入れます(markdownField.ts#L414-L442)。DOMPurify の設定はソースに直接書かれていて、外から変える手段はありません。DOMPurify 3 は既定で data-* 属性を通しますが、知らないタグは ADD_TAGS で許可しない限り取り除きます。
  • ビューアを表示するたびに .md-viewer の中身は作り直されます。表示し終えると markdown-field から data-editable 属性が外れます(markdownField.ts#L434)。ビューアの切り替えが「無効」の項目はビューアが作られず、変換もされません。
  • markdown-field は Shadow DOM を使いません。rt-editor(リッチテキストエディタ)と ui-carousel も同じで、date-field と code-editor は Shadow DOM を使います。
  • Markdown の描画に割り込むプラグインやフックの仕組みは、サーバー側にもブラウザ側にもありません。
  • Mermaid は wwwroot/Extensions/mermaid-11.9.0.min.js(Mermaid 11.9.0)が同梱されていますが、読み込むのはサイト設定の可視化のページだけです(smt-json-to-table.html#L306)。そのページでは mermaid.initialize({ startOnLoad: false, securityLevel: 'strict' }) のあと mermaid.render() で SVG を作っています(smt-json-to-table-er.js#L64-L65)。wwwroot は静的ファイルとして配信されるので、通常の画面からも {ApplicationPath}Extensions/mermaid-11.9.0.min.js で読み込めます。

A. 拡張スクリプトでビューアを後処理する ​

ビューアの表示が終わった後に、pre code.language-mermaid を探して図に置き換えます。サニタイズの後に DOM を触るので、DOMPurify の制約は受けません。図の元になる文字列はサニタイズ済みの DOM から textContent で取り出します。

図を読み込み中…

拡張スクリプトの例(ExtendedScripts/MermaidMarkdown.js)
js
(function () {
  'use strict';

  // mermaid.js を読み込む(読み込み済みなら何もしない)
  function loadMermaid(callback) {
    if (window.mermaid) {
      callback();
      return;
    }
    var script = document.createElement('script');
    script.src = $('#ApplicationPath').val() + 'Extensions/mermaid-11.9.0.min.js';
    script.onload = callback;
    document.head.appendChild(script);
  }

  // ビューア内の mermaid のコードブロックを図に置き換える
  function renderMermaidInViewer(viewerElem) {
    viewerElem.querySelectorAll('pre code.language-mermaid').forEach(function (codeEl) {
      var text = codeEl.textContent;
      var codeBlock = codeEl.closest('.md-code-block');
      if (!codeBlock || !text.trim()) return;
      var id = 'mermaid-' + Math.random().toString(36).slice(2, 11);
      window.mermaid
        .render(id, text)
        .then(function (result) {
          var div = document.createElement('div');
          div.className = 'mermaid-diagram';
          div.innerHTML = result.svg;
          codeBlock.replaceWith(div);
        })
        .catch(function (e) {
          // 構文エラーのときはコードブロックのまま残す
          console.warn('[mermaid] render error:', e);
        });
    });
  }

  // markdown-field の data-editable の変化を見る
  function observeMarkdownField(fieldEl, attrObserver) {
    attrObserver.observe(fieldEl, {
      attributes: true,
      attributeFilter: ['data-editable']
    });
    // すでにビューア表示中なら、すぐ描画する
    if (!fieldEl.hasAttribute('data-editable')) {
      var viewer = fieldEl.querySelector('.md-viewer');
      if (viewer) renderMermaidInViewer(viewer);
    }
  }

  $(function () {
    loadMermaid(function () {
      window.mermaid.initialize({ startOnLoad: false, securityLevel: 'strict' });

      // data-editable が外れた = ビューアの表示が終わった
      var attrObserver = new MutationObserver(function (mutations) {
        mutations.forEach(function (mutation) {
          var field = mutation.target;
          if (!field.hasAttribute('data-editable')) {
            var viewer = field.querySelector('.md-viewer');
            if (viewer) renderMermaidInViewer(viewer);
          }
        });
      });

      document.querySelectorAll('markdown-field').forEach(function (field) {
        observeMarkdownField(field, attrObserver);
      });

      // モーダルやコメントの差し替えなど、後から追加される markdown-field にも対応する
      var bodyObserver = new MutationObserver(function (mutations) {
        mutations.forEach(function (mutation) {
          mutation.addedNodes.forEach(function (node) {
            if (node.nodeType !== 1) return;
            var fields = node.tagName === 'MARKDOWN-FIELD'
              ? [node]
              : Array.from(node.querySelectorAll('markdown-field'));
            fields.forEach(function (field) {
              observeMarkdownField(field, attrObserver);
            });
          });
        });
      });
      bodyObserver.observe(document.body, { childList: true, subtree: true });
    });
  });
})();
注意点内容
ファイルのパス$p.applicationPath() という関数はありません(1.5.8.1 のソースで確認)。アプリケーションのパスは Hidden 要素 #ApplicationPath から取ります(スクリプトで使えるシステム変数)
securityLevelサイト設定の可視化と同じ strict にしています。loose にすると図のクリックイベントなども使えますが、図のラベルに書いた HTML が通るようになります
再描画ビューアを表示するたびに .md-viewer が作り直されるので、そのたびに描画します。mermaid のコードブロックが無ければ何もしません
ビューアの切り替えが「無効」の項目ビューアが無いので描画されません
置く場所全サイトなら拡張スクリプト(拡張スクリプトの仕組み)、特定のテーブルならサイト設定のスクリプト(「全ての画面」)

数式(KaTeX)なども、コードブロックに言語名を付けて書く決まりにすれば同じ方法で置き換えられます。

B. フロントエンドのビルドを改修する ​

Implem.PleasanterFrontend/wwwroot のソースを直して Vite でビルドし直す方法です。ビルドの成果物は Implem.Pleasanter/wwwroot/assets に出力され(vite.config.shared.ts#L4-L5)、サーバーは assets/manifest.json を読んで <script> を出力します(HtmlScripts.cs#L107-L115)。ファイル名にはハッシュが付くので、置き換えても manifest 経由で新しいファイルが読まれます。

独自記法を足す(marked.use) ​

marked のインスタンスに拡張を足し、拡張が出すタグや属性を DOMPurify で許可します。

ts
// markdownField.ts の変更例(marked-alert を足す場合)
import markedAlert from 'marked-alert';

constructor() {
    super();
    this.viewerMarked?.use(markedAlert());
    // ...
}

// サニタイズの設定に、拡張が出すタグを足す
md = DOMPurify.sanitize(md, {
    ADD_ATTR: ['title'],
    ADD_TAGS: ['...'] // 拡張が出すタグ
});

Mermaid を本体に組み込む ​

  1. npm install mermaid でパッケージを足します(同梱の mermaid-11.9.0.min.js はモジュールとして import できないため)。
  2. mdRenderCode の先頭で言語が mermaid のときだけ、元の文字列を data-mermaid-text 属性に入れたプレースホルダーを返します。DOMPurify 3 は既定で data-* 属性を通すので、設定を変える必要はありません。
  3. showViewer() の最後で、プレースホルダーを探して mermaid.render() の SVG に置き換えます。
ts
import mermaid from 'mermaid';

private static isMermaidInitialized = false;

private initMermaid() {
    if (MarkdownFieldElement.isMermaidInitialized) return;
    mermaid.initialize({ startOnLoad: false, securityLevel: 'strict' });
    MarkdownFieldElement.isMermaidInitialized = true;
}

private mdRenderCode = (token: Tokens.Code) => {
    const lang = (token.lang || '').trim();
    if (lang === 'mermaid') {
        return `<div class="mermaid-placeholder" data-mermaid-text="${this.escapeHtml(token.text)}"></div>`;
    }
    // 以降は既存の処理
};

private renderMermaidPlaceholders() {
    this.viewerElem!.querySelectorAll<HTMLElement>('.mermaid-placeholder').forEach(el => {
        const text = el.dataset.mermaidText; // HTML エンティティはブラウザが戻す
        if (!text) return;
        const id = `mermaid-${Math.random().toString(36).slice(2, 11)}`;
        mermaid
            .render(id, text)
            .then(({ svg }) => {
                const div = document.createElement('div');
                div.className = 'mermaid-diagram';
                div.innerHTML = svg;
                el.replaceWith(div);
            })
            .catch(e => console.warn('[mermaid] render error:', e));
    });
}

initMermaid() は connectedCallback() の最後で、renderMermaidPlaceholders() は showViewer() の finalizeViewerDom() の後で呼びます。変更するのは package.json と markdownField.ts の 2 ファイルです。

項目A(拡張スクリプト)B(本体改修)
mermaid の読み込み同梱ファイルを <script> で動的に読むnpm パッケージを Vite でバンドルする
描画のタイミングdata-editable が外れたのを MutationObserver で検知showViewer() の中で直接呼ぶ
DOMPurifyサニタイズ後の DOM に SVG を入れるので影響なしdata-* 属性のプレースホルダーで通す
本体の更新スクリプトを置いたままでよい(markdown-field の構造が変わらない限り)差分を当て直してビルドする
向いている場面検証、すぐ使いたいとき正式に機能として持つとき

ほかの配置先 ​

サーバーは assets/manifest.json の前に components/manifest.json も読みます(HtmlScripts.cs#L107-L109)。別の Vite プロジェクトでビルドした後処理のモジュールを wwwroot/components に置く手もありますが、markdown-field が定義される前に読まれるため、できることは A と同じ DOM の後処理に限られます。また、components は Svelte のコンポーネントの置き場所でもあるので、本体の manifest を上書きしないようにする必要があります。

C. html レンダラーのエスケープを緩める ​

html レンダラーで、許可するタグ(details・summary・kbd・mark など)だけをそのまま通す方法です。

ts
renderer: {
    html: token => {
        const allowed = /<(\/)?(details|summary|mark|kbd|abbr)(\s[^>]*)?>/i;
        return allowed.test(token.text) ? token.text : this.escapeHtml(token.text);
    },
    // ...
}

生の HTML を通すと XSS の危険が大きくなります。DOMPurify で最終的に取り除かれるタグは ADD_TAGS でも許可する必要があり、エスケープと DOMPurify の二重の防御のうち 1 つを外すことになります。採用するなら、許可するタグと属性を最小にしてください。

関連ページ ​

変更履歴

第1版Markdown の描画の仕組み・ショートカットキー・アイコン・公式マニュアルに無い $p 関数の解説と、Markdown 拡張・画像形式・ファビコン・テーマ・和暦などの改修・設計メモを追加