Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
52f2bc7
Add `layout: elk` support to mermaid
silverwind Jan 30, 2026
8bb2500
rename var
silverwind Jan 30, 2026
bfb614d
use same webpackChunkName
silverwind Jan 30, 2026
3cde6c5
add lazy-loading elk
silverwind Jan 30, 2026
6311159
add unit tests
silverwind Jan 30, 2026
2e85fd0
fix review comments
silverwind Jan 30, 2026
67de5a3
simplify loadMermaid
silverwind Jan 30, 2026
a6204c7
fix lint
silverwind Jan 30, 2026
2712389
add test
silverwind Jan 30, 2026
cbd2f87
add return when no els
silverwind Jan 30, 2026
723632f
move check
silverwind Jan 30, 2026
1393f42
refine regex and add tests
silverwind Jan 30, 2026
41f0028
support yaml quotes
silverwind Jan 30, 2026
2ec40ac
use continue to attempt render other diagrams
silverwind Jan 30, 2026
3cd3acf
init once per markup element
silverwind Jan 30, 2026
aa5ee6f
parallel render
silverwind Jan 30, 2026
1973e47
use index from map
silverwind Jan 30, 2026
0d909d3
Merge remote-tracking branch 'origin/main' into mermaidelk
silverwind Feb 1, 2026
bf830fe
Merge branch 'main' into mermaidelk
silverwind Feb 1, 2026
846ac7e
use json and yaml parsers
silverwind Feb 4, 2026
dfc36d3
value.defaultRenderer can only be elk as per mermaid types
silverwind Feb 4, 2026
23f84af
use valid json
silverwind Feb 4, 2026
26a75e1
use for-of
silverwind Feb 4, 2026
176fd16
add test
silverwind Feb 4, 2026
69b6e3a
add test
silverwind Feb 4, 2026
c4e699c
check source limit in sourcesContainElk
silverwind Feb 4, 2026
4c5a7e5
Merge branch 'main' into mermaidelk
wxiaoguang Feb 5, 2026
28d70cc
fix
wxiaoguang Feb 5, 2026
313c895
fix
wxiaoguang Feb 5, 2026
02a8093
fix
wxiaoguang Feb 5, 2026
2d5a66a
fix
wxiaoguang Feb 5, 2026
a7bfdb7
restore Object.values method
silverwind Feb 5, 2026
9bb7e28
rename variable
silverwind Feb 5, 2026
f68d5a0
add dedent test helper
silverwind Feb 5, 2026
d74878c
Apply suggestion from @silverwind
silverwind Feb 6, 2026
5766550
Apply suggestion from @silverwind
silverwind Feb 6, 2026
ddcd933
Apply suggestion from @silverwind
silverwind Feb 6, 2026
9ed216b
fix
wxiaoguang Feb 6, 2026
4fc1094
Merge branch 'main' into mermaidelk
wxiaoguang Feb 6, 2026
bcfb75c
fix
wxiaoguang Feb 7, 2026
47424c4
Merge branch 'main' into mermaidelk
wxiaoguang Feb 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"@github/relative-time-element": "5.0.0",
"@github/text-expander-element": "2.9.4",
"@mcaptcha/vanilla-glue": "0.1.0-alpha-3",
"@mermaid-js/layout-elk": "0.2.0",
"@primer/octicons": "19.21.2",
"@resvg/resvg-wasm": "2.6.2",
"@silverwind/vue3-calendar-heatmap": "2.1.1",
Expand Down
19 changes: 19 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

59 changes: 59 additions & 0 deletions web_src/js/markup/mermaid.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
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([`
---
config:
layout: 123
---
%%{ init : { "class": { "defaultRenderer": "elk.any" } } }%%
flowchart TB
A --> B
`])).toEqual(true);

expect(sourcesContainElk([`
%%{init:{
"layout" : "elk.layered"
}}%%
flowchart TB
A --> B
`])).toEqual(true);

expect(sourcesContainElk([`
%%{ initialize: {
'layout' : 'elk.layered'
}}%%
flowchart TB
A --> B
`])).toEqual(true);
});
114 changes: 102 additions & 12 deletions web_src/js/markup/mermaid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,120 @@ 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
// Do as dirty as mermaid does: https://github.com/mermaid-js/mermaid/blob/develop/packages/mermaid/src/utils.ts
// It can even accept invalid JSON string like:
// %%{initialize: { 'logLevel': 'fatal', "theme":'dark', 'startOnLoad': true } }%%
const jsonInitConfigRegex = /%%\{\s*(init|initialize)\s*:\s*(.*?)\}%%/s;
const jsonInitText = (jsonInitConfigRegex.exec(source) || [])[2];
if (!jsonInitText) return null;
try {
const processed = jsonInitText.trim().replace(/'/g, '"');
return JSON.parse(processed);
} catch {
console.error('invalid or unsupported mermaid init JSON config', jsonInitText);
}
return null;
}

Comment thread
silverwind marked this conversation as resolved.
function configValueIsElk(layoutOrRenderer: string | undefined) {
if (typeof layoutOrRenderer !== 'string') return false;
return layoutOrRenderer === 'elk' || layoutOrRenderer.startsWith('elk.');
}

function configContainsElk(config: MermaidConfig | null) {
Comment thread
silverwind marked this conversation as resolved.
if (!config) return false;
// Check the layout from the following properties:
// * config.layout
// * config.{any-diagram-config}.defaultRenderer
// Although only a few diagram types like "flowchart" support "defaultRenderer",
// as long as there is no side effect, here do a general check for all properties of "config", for ease of maintenance
return configValueIsElk(config.layout) || Object.values(config).some((value) => configValueIsElk(value?.defaultRenderer));
}

/** 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'));
Comment thread
silverwind marked this conversation as resolved.
if (!els.length) return;
const sources = Array.from(els, (el) => el.textContent ?? '');
const {mermaid, elkLayouts} = await loadMermaid(sources);

Comment thread
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;

Comment thread
silverwind marked this conversation as resolved.
const source = el.textContent;
if (mermaidMaxSourceCharacters >= 0 && source.length > mermaidMaxSourceCharacters) {
if (!pre || pre.hasAttribute('data-render-done')) {
return;
}

if (isSourceTooLarge(source)) {
displayError(pre, new Error(`Mermaid source of ${source.length} characters exceeds the maximum allowed length of ${mermaidMaxSourceCharacters}.`));
return;
}
Expand Down Expand Up @@ -83,5 +173,5 @@ export async function initMarkupCodeMermaid(elMarkup: HTMLElement): Promise<void
} catch (err) {
displayError(pre, err);
}
});
}));
}
16 changes: 16 additions & 0 deletions web_src/js/utils/testhelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,19 @@
export function isInFrontendUnitTest() {
return import.meta.env.TEST === 'true';
}

/** strip common indentation from a string and trim it */
export function dedent(str: string) {
Comment thread
wxiaoguang marked this conversation as resolved.
const match = str.match(/^[ \t]*(?=\S)/gm);
if (!match) return str;

let minIndent = Number.POSITIVE_INFINITY;
for (const indent of match) {
minIndent = Math.min(minIndent, indent.length);
}
if (minIndent === 0 || minIndent === Number.POSITIVE_INFINITY) {
return str;
}

return str.replace(new RegExp(`^[ \\t]{${minIndent}}`, 'gm'), '').trim();
}