Markdown フィールドの拡張(Mermaid・独自記法)
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)
(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 で許可します。
// 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 を本体に組み込む
npm install mermaidでパッケージを足します(同梱のmermaid-11.9.0.min.jsはモジュールとして import できないため)。mdRenderCodeの先頭で言語がmermaidのときだけ、元の文字列をdata-mermaid-text属性に入れたプレースホルダーを返します。DOMPurify 3 は既定でdata-*属性を通すので、設定を変える必要はありません。showViewer()の最後で、プレースホルダーを探してmermaid.render()の SVG に置き換えます。
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 など)だけをそのまま通す方法です。
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 つを外すことになります。採用するなら、許可するタグと属性を最小にしてください。