-
-
Notifications
You must be signed in to change notification settings - Fork 6.7k
Add elk layout support to mermaid
#36486
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 37 commits
52f2bc7
8bb2500
bfb614d
3cde6c5
6311159
2e85fd0
67de5a3
a6204c7
2712389
cbd2f87
723632f
1393f42
41f0028
2ec40ac
3cd3acf
aa5ee6f
1973e47
0d909d3
bf830fe
846ac7e
dfc36d3
23f84af
26a75e1
176fd16
69b6e3a
c4e699c
4c5a7e5
28d70cc
313c895
02a8093
2d5a66a
a7bfdb7
9bb7e28
f68d5a0
d74878c
5766550
ddcd933
9ed216b
4fc1094
bcfb75c
47424c4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| import {sourcesContainElk} from './mermaid.ts'; | ||
| import {dedent} from '../utils/testhelper.ts'; | ||
|
|
||
| test('sourcesContainElk', () => { | ||
| expect(sourcesContainElk([dedent(` | ||
| flowchart TB | ||
| elk --> B | ||
| `)])).toEqual(false); | ||
|
|
||
| expect(sourcesContainElk([dedent(` | ||
| --- | ||
| config: | ||
| layout : elk | ||
| --- | ||
| flowchart TB | ||
| A --> B | ||
| `)])).toEqual(true); | ||
|
|
||
| expect(sourcesContainElk([dedent(` | ||
| --- | ||
| config: | ||
| layout: elk.layered | ||
| --- | ||
| flowchart TB | ||
| A --> B | ||
| `)])).toEqual(true); | ||
|
|
||
| expect(sourcesContainElk([` | ||
| %%{ init : { "flowchart": { "defaultRenderer": "elk" } } }%% | ||
| flowchart TB | ||
| A --> B | ||
| `])).toEqual(true); | ||
|
|
||
| expect(sourcesContainElk([` | ||
| %%{init:{ | ||
| "layout" : "elk.layered" | ||
| }}%% | ||
| flowchart TB | ||
| A --> B | ||
| `])).toEqual(true); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,30 +3,114 @@ import {makeCodeCopyButton} from './codecopy.ts'; | |
| import {displayError} from './common.ts'; | ||
| import {queryElems} from '../utils/dom.ts'; | ||
| import {html, htmlRaw} from '../utils/html.ts'; | ||
| import {load as loadYaml} from 'js-yaml'; | ||
| import type {MermaidConfig} from 'mermaid'; | ||
|
|
||
| const {mermaidMaxSourceCharacters} = window.config; | ||
|
|
||
| const iframeCss = `:root {color-scheme: normal} | ||
| body {margin: 0; padding: 0; overflow: hidden} | ||
| #mermaid {display: block; margin: 0 auto}`; | ||
|
|
||
| function isSourceTooLarge(source: string) { | ||
| return mermaidMaxSourceCharacters >= 0 && source.length > mermaidMaxSourceCharacters; | ||
| } | ||
|
|
||
| function parseYamlInitConfig(source: string): MermaidConfig | null { | ||
| // ref: https://github.com/mermaid-js/mermaid/blob/develop/packages/mermaid/src/diagram-api/regexes.ts | ||
| const yamlFrontMatterRegex = /^---\s*[\n\r](.*?)[\n\r]---\s*[\n\r]+/s; | ||
| const frontmatter = (yamlFrontMatterRegex.exec(source) || [])[1]; | ||
| if (!frontmatter) return null; | ||
| try { | ||
| return (loadYaml(frontmatter) as {config: MermaidConfig})?.config; | ||
| } catch { | ||
| console.error('invalid or unsupported mermaid init YAML config', frontmatter); | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| function parseJsonInitConfig(source: string): MermaidConfig | null { | ||
| // https://mermaid.js.org/config/directives.html#declaring-directives | ||
| // TODO: mermaid is quite hacky for the "JSON" pattern, it can have other root keys | ||
| // and it can even accept invalid JSON string like: | ||
| // %%{initialize: { 'logLevel': 'fatal', "theme":'dark', 'startOnLoad': true } }%% | ||
| // To keep things simple, Gitea only supports "init" directive with valid JSON string at the moment. | ||
| const jsonInitConfigRegex = /%%\{\s*init\s*:\s*(.*?)\}%%/s; | ||
| const jsonInit = (jsonInitConfigRegex.exec(source) || [])[1]; | ||
| if (!jsonInit) return null; | ||
| try { | ||
| return JSON.parse(jsonInit); | ||
| } catch { | ||
| console.error('invalid or unsupported mermaid init JSON config', jsonInit); | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
|
silverwind marked this conversation as resolved.
|
||
| function isElk(layoutOrRenderer: string | undefined) { | ||
| return Boolean(layoutOrRenderer === 'elk' || layoutOrRenderer?.startsWith?.('elk.')); | ||
| } | ||
|
|
||
| /** checks if either `config.layout` or `config.*.defaultRender` contains a elk layout. */ | ||
| function configContainsElk(config: MermaidConfig | null) { | ||
|
silverwind marked this conversation as resolved.
|
||
| return isElk(config?.layout) || Object.values(config || {}).some((value) => isElk(value?.defaultRenderer)); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think anyone can really understand why the logic looks so strange.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's a perfectly readable function imho, but I will add a comment. |
||
| } | ||
|
|
||
| /** detect whether mermaid sources contain elk layout configuration */ | ||
| export function sourcesContainElk(sources: Array<string>) { | ||
| for (const source of sources) { | ||
| if (isSourceTooLarge(source)) continue; | ||
|
|
||
| const yamlConfig = parseYamlInitConfig(source); | ||
| if (configContainsElk(yamlConfig)) return true; | ||
|
|
||
| const jsonConfig = parseJsonInitConfig(source); | ||
| if (configContainsElk(jsonConfig)) return true; | ||
| } | ||
|
|
||
| return false; | ||
| } | ||
|
|
||
| async function loadMermaid(sources: Array<string>) { | ||
| const mermaidPromise = import(/* webpackChunkName: "mermaid" */'mermaid'); | ||
| const elkPromise = sourcesContainElk(sources) ? | ||
| import(/* webpackChunkName: "mermaid-layout-elk" */'@mermaid-js/layout-elk') : null; | ||
|
|
||
| const results = await Promise.all([mermaidPromise, elkPromise]); | ||
| return { | ||
| mermaid: results[0].default, | ||
| elkLayouts: results[1]?.default, | ||
| }; | ||
| } | ||
|
|
||
| let elkLayoutsRegistered = false; | ||
|
|
||
| export async function initMarkupCodeMermaid(elMarkup: HTMLElement): Promise<void> { | ||
| // .markup code.language-mermaid | ||
| queryElems(elMarkup, 'code.language-mermaid', async (el) => { | ||
| const {default: mermaid} = await import(/* webpackChunkName: "mermaid" */'mermaid'); | ||
| const els = Array.from(queryElems(elMarkup, 'code.language-mermaid')); | ||
|
silverwind marked this conversation as resolved.
|
||
| if (!els.length) return; | ||
| const sources = Array.from(els, (el) => el.textContent ?? ''); | ||
| const {mermaid, elkLayouts} = await loadMermaid(sources); | ||
|
|
||
|
silverwind marked this conversation as resolved.
|
||
| mermaid.initialize({ | ||
| startOnLoad: false, | ||
| theme: isDarkTheme() ? 'dark' : 'neutral', | ||
| securityLevel: 'strict', | ||
| suppressErrorRendering: true, | ||
| }); | ||
| if (elkLayouts && !elkLayoutsRegistered) { | ||
| mermaid.registerLayoutLoaders(elkLayouts); | ||
| elkLayoutsRegistered = true; | ||
| } | ||
| mermaid.initialize({ | ||
| startOnLoad: false, | ||
| theme: isDarkTheme() ? 'dark' : 'neutral', | ||
| securityLevel: 'strict', | ||
| suppressErrorRendering: true, | ||
| }); | ||
|
|
||
| await Promise.all(els.map(async (el, index) => { | ||
| const source = sources[index]; | ||
| const pre = el.closest('pre'); | ||
| if (!pre || pre.hasAttribute('data-render-done')) return; | ||
|
|
||
|
silverwind marked this conversation as resolved.
|
||
| const source = el.textContent; | ||
| if (mermaidMaxSourceCharacters >= 0 && source.length > mermaidMaxSourceCharacters) { | ||
| if (!pre || pre.hasAttribute('data-render-done')) { | ||
| return; | ||
|
silverwind marked this conversation as resolved.
|
||
| } | ||
|
|
||
| if (isSourceTooLarge(source)) { | ||
| displayError(pre, new Error(`Mermaid source of ${source.length} characters exceeds the maximum allowed length of ${mermaidMaxSourceCharacters}.`)); | ||
| return; | ||
| } | ||
|
|
@@ -83,5 +167,5 @@ export async function initMarkupCodeMermaid(elMarkup: HTMLElement): Promise<void | |
| } catch (err) { | ||
| displayError(pre, err); | ||
| } | ||
| }); | ||
| })); | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.