diff --git a/packages/example/articles/example.md b/packages/example/articles/example.md index de9b4bc1..7722231d 100644 --- a/packages/example/articles/example.md +++ b/packages/example/articles/example.md @@ -135,4 +135,193 @@ $a^*b$ with $a^*$ $\sum_{i=1}^n$ fafa -a \ No newline at end of file +a + + + +## mermaid.js + +```mermaid +%%{init: { 'theme': 'forest' } }%% +graph LR; + A-->B & C-->D & E-->F & Z-->X; + F-->G + G-->H + H-->I + I-->J + J-->K + K-->L + L-->M + M-->N + N-->O + O-->P + P-->ID1[ノード1
ノード2] +``` + +### flowchart + +```mermaid +flowchart TB + c1-->a2 + subgraph one + a1-->a2 + end + subgraph two + b1-->b2 + end + subgraph three + c1-->c2 + end + one --> two + three --> two + two --> c2 +``` + +### sequence diagram + +```mermaid +sequenceDiagram + autonumber + アリス->>光輝: Hello John, how are you? + loop Healthcheck + 光輝->>光輝: Fight against hypochondria + end + Note right of 光輝: Rational thoughts! + 光輝-->>アリス: Great! + 光輝->>Bob: How about you? + Bob-->>光輝: Jolly good! +``` + +### class diagram + +```mermaid + classDiagram + Animal <|-- Duck + Animal <|-- Fish + Animal <|-- Zebra + Animal : +int age + Animal : +String gender + Animal: +isMammal() + Animal: +mate() + class Duck{ + +String beakColor + +swim() + +quack() + } + class Fish{ + -int sizeInFeet + -canEat() + } + class Zebra{ + +bool is_wild + +run() + } +``` + + +### state diagram + +```mermaid +stateDiagram-v2 + [*] --> Active + + state Active { + [*] --> NumLockOff + NumLockOff --> NumLockOn : EvNumLockPressed + NumLockOn --> NumLockOff : EvNumLockPressed + -- + [*] --> CapsLockOff + CapsLockOff --> CapsLockOn : EvCapsLockPressed + CapsLockOn --> CapsLockOff : EvCapsLockPressed + -- + [*] --> ScrollLockOff + ScrollLockOff --> ScrollLockOn : EvScrollLockPressed + ScrollLockOn --> ScrollLockOff : EvScrollLockPressed + + } +``` + + + +```mermaid +graph LR +A:::someclass B +classDef someclass fill:#f96; +classDef someclass fill:#f96; +classDef someclass fill:#f96; +classDef someclass fill:#f96; +classDef someclass fill:#f96; +classDef someclass fill:#f96; +classDef someclass fill:#f96; +classDef someclass fill:#f96; +classDef someclass fill:#f96; +classDef someclass fill:#f96; +classDef someclass fill:#f96; +classDef someclass fill:#f96; +classDef someclass fill:#f96; +classDef someclass fill:#f96; +classDef someclass fill:#f96; +classDef someclass fill:#f96; +classDef someclass fill:#f96; +classDef someclass fill:#f96; +classDef someclass fill:#f96; +classDef someclass fill:#f96; +classDef someclass fill:#f96; +classDef someclass fill:#f96; +classDef someclass fill:#f96; +classDef someclass fill:#f96; +classDef someclass fill:#f96; +classDef someclass fill:#f96; + A-->B & C-->D & E-->F & Z-->X; + A-->B & C-->D & E-->F & Z-->X; + A-->B & C-->D & E-->F & Z-->X; + A-->B & C-->D & E-->F & Z-->X; + A-->B & C-->D & E-->F & Z-->X; + A-->B & C-->D & E-->F & Z-->X; + A-->B & C-->D & E-->F & Z-->X; + A-->B & C-->D & E-->F & Z-->X; + A-->B & C-->D & E-->F & Z-->X; + A-->B & C-->D & E-->F & Z-->X; + A-->B & C-->D & E-->F & Z-->X; + A-->B & C-->D & E-->F & Z-->X; + A-->B & C-->D & E-->F & Z-->X; + A-->B & C-->D & E-->F & Z-->X; + A-->B & C-->D & E-->F & Z-->X; + A-->B & C-->D & E-->F & Z-->X; + A-->B & C-->D & E-->F & Z-->X; + A-->B & C-->D & E-->F & Z-->X; + A-->B & C-->D & E-->F & Z-->X; + A-->B & C-->D & E-->F & Z-->X; + A-->B & C-->D & E-->F & Z-->X; + A-->B & C-->D & E-->F & Z-->X; + A-->B & C-->D & E-->F & Z-->X; + A-->B & C-->D & E-->F & Z-->X; + A-->B & C-->D & E-->F & Z-->X; + A-->B & C-->D & E-->F & Z-->X; + A-->B & C-->D & E-->F & Z-->X; + A-->B & C-->D & E-->F & Z-->X; + A-->B & C-->D & E-->F & Z-->X; + A-->B & C-->D & E-->F & Z-->X; + A-->B & C-->D & E-->F & Z-->X; + A-->B & C-->D & E-->F & Z-->X; + A-->B & C-->D & E-->F & Z-->X; + A-->B & C-->D & E-->F & Z-->X; + A-->B & C-->D & E-->F & Z-->X; +``` + + +```mermaid +graph LR; + A[""] --> B; + alert`md5_salt`-->B; + click alert`md5_salt` eval "Tooltip for a callback" + click B "javascript:alert('XSS')" "This is a tooltip for a link" +``` + +```mermaid +graph LR; + alert`md5_salt`-->B; + click alert`md5_salt` eval "Tooltip for a callback" + click B "javascript:alert('XSS')" "This is a tooltip for a link" + link Zebra "http://www.github.com" "This is a link" +``` diff --git a/packages/zenn-embed-elements/src/classes/mermaid.ts b/packages/zenn-embed-elements/src/classes/mermaid.ts new file mode 100644 index 00000000..3703252b --- /dev/null +++ b/packages/zenn-embed-elements/src/classes/mermaid.ts @@ -0,0 +1,164 @@ +/** + * original: https://github.com/gitlabhq/gitlabhq/blob/master/app/assets/javascripts/behaviors/markdown/render_mermaid.js + */ + +import { loadScript } from '../utils/load-script'; + +// レンダリングする図ごとの最大文字数 +const MAX_CHAR_LIMIT = 2000; + +// https://mermaid-js.github.io/mermaid/#/flowchart?id=chaining-of-links +// 新しい仕様で +// graph LR +// a --> b & c--> d +// に対応するが、少ない記述でノード接続が爆発する可能性があるため最大数を制限する +const MAX_CHAINING_OF_LINKS_LIMIT = 10; + +// Page values +declare let mermaid: any; +const containerId = 'mermaid-container'; + +async function initMermaid(): Promise { + if (typeof mermaid === 'undefined') { + await loadScript({ + src: 'https://cdn.jsdelivr.net/npm/mermaid@8.10/dist/mermaid.min.js', + id: 'mermaid-js', + }); + const theme = 'default'; + + // mermaid 本体がロード時に走らないように設定 + // mermaid 本体は使わないのでほかは設定しない + mermaid!.initialize({ + mermaid: { + startOnLoad: false, + }, + }); + + // mermaidAPI の設定 + mermaid!.mermaidAPI.initialize({ + startOnLoad: false, // レンダリングはこちらでやるので false + securityLevel: 'strict', // tags in text are encoded, click functionality is disabled + theme, + er: { + useMaxWidth: true, + }, + flowchart: { + useMaxWidth: true, // 表示の都合上見切れるのもスクロールするのも嫌なので最大幅を有効にする + htmlLabels: false, // セキュリティのため、HTMLラベルは許可しない + }, + sequence: { + useMaxWidth: true, + }, + }); + } +} + +type ErrorContainer = { + yes: boolean; + message: string; +}; + +type PotentialRisk = { + syntaxError: ErrorContainer; + charLimitOver: ErrorContainer; + chainingOfLinksOver: ErrorContainer; +}; + +function getPotentialPerformanceRisk(source: string): PotentialRisk { + const cool = (() => { + try { + mermaid!.mermaidAPI.parse(source); + return true; + } catch (e) { + console.log( + 'mermaid.js のレンダリングでシンタックスエラーが発生しました', + e + ); + return false; + } + })(); + return { + syntaxError: { + yes: !cool, + message: `
  • シンタックスエラーです
  • `, + }, + charLimitOver: { + yes: source.length > MAX_CHAR_LIMIT, + message: `
  • ブロックあたりの文字数上限は${MAX_CHAR_LIMIT}です
  • `, + }, + chainingOfLinksOver: { + yes: (source.match(/&/g) || []).length > MAX_CHAINING_OF_LINKS_LIMIT, + message: `
  • ブロックあたりの&によるチェイン上限は${MAX_CHAINING_OF_LINKS_LIMIT}です
  • `, + }, + }; +} + +export class EmbedMermaid extends HTMLElement { + // mermaid のソース記述が格納されているpreタグ + private readonly _sourceContainer: HTMLPreElement; + + // 描画後の svg を格納するdivタグ ここで作る + private readonly _svgContainer: HTMLDivElement; + + constructor() { + super(); + + // コード記述が格納されている pre タグを取得 + this._sourceContainer = this.childNodes[0] as HTMLPreElement; + + // 描画後のSVGを格納する div タグを作成 + const container = document.createElement('div'); + this.appendChild(container); + this._svgContainer = container; + } + + async connectedCallback() { + this.render(); + } + + async render() { + await initMermaid(); + const source = this._sourceContainer.innerText || ''; + + // Mermaid モジュールの読み込みに失敗したり、レンダリング対象のコンテンツが空の場合は何もせずに終了 + if (!source) { + return; + } + + // 文法エラーやパフォーマンスリスクが検出された場合、注意書きをレンダリングして終了 + const risk = getPotentialPerformanceRisk(source); + if ( + Object.values(risk) + .map((r) => r.yes) + .includes(true) + ) { + this.innerHTML = ` +

    + mermaidをレンダリングできません。 +

    +

    + `; + return; + } + + // すべて通過した場合はレンダリングする + // セキュリティリスクを考慮して bindFunctions は実行しない方針にする + // 今回は `securityLevel='strict'` にしているのでどのみち実行されない + // securityLevel='loose'にし、かつ `Interaction` を有効にする場合は + // https://github.com/mermaidjs/mermaid-gitbook/blob/master/content/usage.md#binding-events + // ここを参考に追加する + const insert = (svgCode: string, bindFunctions: any) => { + this._svgContainer.innerHTML = svgCode; + }; + mermaid?.mermaidAPI.render( + `${containerId}-${Date.now().valueOf()}-render`, + source, + insert, + this._sourceContainer + ); + } +} diff --git a/packages/zenn-embed-elements/src/index.ts b/packages/zenn-embed-elements/src/index.ts index 12373376..8de07abe 100644 --- a/packages/zenn-embed-elements/src/index.ts +++ b/packages/zenn-embed-elements/src/index.ts @@ -1,7 +1,9 @@ import { EmbedGist } from './classes/gist'; import { EmbedTweet } from './classes/tweet'; import { EmbedKatex } from './classes/katex'; +import { EmbedMermaid } from './classes/mermaid'; customElements.define('embed-gist', EmbedGist); customElements.define('embed-tweet', EmbedTweet); customElements.define('embed-katex', EmbedKatex); +customElements.define('embed-mermaid', EmbedMermaid); diff --git a/packages/zenn-markdown-html/__tests__/mermaid.test.ts b/packages/zenn-markdown-html/__tests__/mermaid.test.ts new file mode 100644 index 00000000..f9af4f02 --- /dev/null +++ b/packages/zenn-markdown-html/__tests__/mermaid.test.ts @@ -0,0 +1,24 @@ +import markdownToHtml from '../src/index'; + +describe('Detect mermaid property', () => { + test('should generate TD valid code format html', () => { + const html = markdownToHtml(`\`\`\`mermaid\ngraph TD\nA --> B\n\`\`\``); + expect(html).toContain( + '
    graph TD\nA --> B
    ' + ); + }); + test('should keep directive', () => { + const html = markdownToHtml(`\`\`\`mermaid\n%%{init: { 'theme': 'forest' } }%%\ngraph TD\nA --> B\n\`\`\``); + expect(html).toContain( + '
    %%{init: { \'theme\': \'forest\' } }%%\ngraph TD\nA --> B
    ' + ); + }); + test('should escape html tag', () => { + const html = markdownToHtml( + `\`\`\`mermaid\ngraph TD\nA --> B\n
    \`\`\`` + ); + expect(html).toContain( + '
    graph TD\nA --> B\n<br><script>alert("XSS")</script>```
    ' + ); + }); +}); diff --git a/packages/zenn-markdown-html/__tests__/xss.test.ts b/packages/zenn-markdown-html/__tests__/xss.test.ts index 5178d02b..286d2cbf 100644 --- a/packages/zenn-markdown-html/__tests__/xss.test.ts +++ b/packages/zenn-markdown-html/__tests__/xss.test.ts @@ -44,4 +44,12 @@ describe('No XSS Vulnerability', () => { '
    '
         );
       });
    +  test('should escape img tag around mermaid syntax', () => {
    +    const html = markdownToHtml(
    +      `\`\`\`mermaid\ngraph TD\nA[""] --> B\`\`\``
    +    );
    +    expect(html).toContain(
    +      '
    graph TD\nA["<img src="invalid" onerror=alert(\'XSS\')/>"] --> B```
    ' + ); + }); }); diff --git a/packages/zenn-markdown-html/jest.config.js b/packages/zenn-markdown-html/jest.config.js index 4805cba6..c42bb78c 100644 --- a/packages/zenn-markdown-html/jest.config.js +++ b/packages/zenn-markdown-html/jest.config.js @@ -2,7 +2,7 @@ module.exports = { globals: { 'ts-jest': { // avoid "jsx" treated as "preserved" - tsConfig: 'tsconfig.json', + tsconfig: 'tsconfig.json', }, }, moduleFileExtensions: ['js', 'json', 'ts'], diff --git a/packages/zenn-markdown-html/src/index.ts b/packages/zenn-markdown-html/src/index.ts index 4c91eb16..ffd1ac13 100644 --- a/packages/zenn-markdown-html/src/index.ts +++ b/packages/zenn-markdown-html/src/index.ts @@ -14,6 +14,7 @@ import { mdBr } from './utils/md-br'; import { mdCustomBlock } from './utils/md-custom-block'; import markdownItImSize from '@steelydylan/markdown-it-imsize'; import markdownItAnchor from 'markdown-it-anchor'; +import { mdMermaid } from './utils/md-mermaid'; const mdContainer = require('markdown-it-container'); const mdFootnote = require('markdown-it-footnote'); @@ -52,7 +53,8 @@ md.use(mdBr) }, }) .use(mdKatex) - .use(mdLinkifyToCard); + .use(mdLinkifyToCard) + .use(mdMermaid); // custom footnote => TODO: ファイルを分ける md.renderer.rules.footnote_block_open = () => diff --git a/packages/zenn-markdown-html/src/utils/md-mermaid.ts b/packages/zenn-markdown-html/src/utils/md-mermaid.ts new file mode 100644 index 00000000..242d0d0e --- /dev/null +++ b/packages/zenn-markdown-html/src/utils/md-mermaid.ts @@ -0,0 +1,19 @@ +import MarkdownIt from 'markdown-it'; + +export function mdMermaid(md: MarkdownIt) { + const defaultRender = + md.renderer.rules.fence || + function (tokens, idx, options, env, self) { + return self.renderToken(tokens, idx, options); + }; + md.renderer.rules.fence = (tokens, idx, options, env, slf) => { + const langInfo = tokens[idx]; + if (langInfo.info === 'mermaid') { + const code = langInfo.content.trim(); + return `
    ${md.utils.escapeHtml(
    +        code
    +      )}
    `; + } + return defaultRender(tokens, idx, options, env, slf); + }; +}