Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ In [settings.json](https://github.com/MarkEdit-app/MarkEdit/wiki/Customization#a
"breaks": true,
"linkify": true
}
},
"mermaid": {
"lightTheme": "default",
"darkTheme": "dark"
}
}
}
Expand All @@ -81,6 +85,7 @@ In [settings.json](https://github.com/MarkEdit-app/MarkEdit/wiki/Customization#a
- `changeMode.hotKey`: Assign keyboard shortcuts for mode switching. See the specification [here](https://github.com/MarkEdit-app/MarkEdit/wiki/Customization#generalmainwindowhotkey).
- `markdownIt.preset`: Override the default [markdown-it preset](https://markdown-it.github.io/markdown-it/#MarkdownIt.new).
- `markdownIt.options`: Customize [markdown-it options](https://markdown-it.github.io/markdown-it/interfaces/MarkdownItOptions.html).
- `mermaid.lightTheme` / `mermaid.darkTheme`: Set Mermaid themes for previews and exports (full build only). Keeps the legacy colors by default: `"default"` in light mode and `"dark"` in dark mode. To opt into Mermaid 12's colorful palettes, use `"redux-color"` and `"redux-dark-color"` respectively.

> [!TIP]
>
Expand Down
82 changes: 41 additions & 41 deletions dist/lite/markedit-preview.js

Large diffs are not rendered by default.

1,761 changes: 1,214 additions & 547 deletions dist/markedit-preview.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import parserTs from '@typescript-eslint/parser';

export default [
{
ignores: ['vitest.config.ts'],
ignores: ['dist/**', 'vitest.config.ts'],
},
{
files: ['**/*.ts'],
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
"markdown-it-link-attributes": "^4.0.1",
"markdown-it-task-lists": "^2.1.1",
"markedit-katex": "https://github.com/MarkEdit-app/MarkEdit-katex#v1.1.8",
"mermaid": "^11.16.0",
"mermaid": "^12.2.0",
"split-grid": "^1.0.11"
}
}
4 changes: 3 additions & 1 deletion src/features/mermaid.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { mermaidLightTheme, mermaidDarkTheme } from '../support/settings';

export async function loadMermaid() {
const mermaid = await (mermaidAPI ??= importMermaid());
const isDarkMode = matchMedia('(prefers-color-scheme: dark)').matches;
if (isDarkMode !== mermaidDarkMode) {
mermaid.initialize({ theme: isDarkMode ? 'dark' : undefined });
mermaid.initialize({ theme: isDarkMode ? mermaidDarkTheme : mermaidLightTheme });
mermaidDarkMode = isDarkMode;
}

Expand Down
6 changes: 3 additions & 3 deletions src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { loadMermaid, renderMermaidDiagrams } from './features/mermaid';
import { coreCss, previewThemeCss, alertsCss, hljsCss, codeCopyCss } from './styling';
import { localized } from './shared/strings';
import { hasFullHost } from './support/host';
import { syntaxAutoDetect, styledHtmlColorScheme, mathDelimiters, markdownItPreset, markdownItOptions } from './support/settings';
import { syntaxAutoDetect, styledHtmlColorScheme, mathDelimiters, markdownItPreset, markdownItOptions, mermaidLightTheme, mermaidDarkTheme } from './support/settings';

/**
* @param lineInfo Whether to include line info like `data-line-from` and `data-line-to`.
Expand Down Expand Up @@ -156,15 +156,15 @@ export async function applyStyles(html: string) {
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@${__MERMAID_VERSION__}/dist/mermaid.esm.min.mjs";
if (${styledHtmlColorScheme === 'auto' ? 'true' : 'false'}) {
const darkMode = matchMedia("(prefers-color-scheme: dark)");
mermaid.initialize({ theme: darkMode.matches ? "dark" : undefined });
mermaid.initialize({ theme: darkMode.matches ? ${JSON.stringify(mermaidDarkTheme)} : ${JSON.stringify(mermaidLightTheme)} });
darkMode.addEventListener("change", () => {
if (document.querySelector(".mermaid") !== null) {
location.reload();
}
});
} else {
const isDark = ${styledHtmlColorScheme === 'dark' ? 'true' : 'false'};
mermaid.initialize({ theme: isDark ? "dark" : undefined });
mermaid.initialize({ theme: isDark ? ${JSON.stringify(mermaidDarkTheme)} : ${JSON.stringify(mermaidLightTheme)} });
}
</script>`;
components.push(mermaid);
Expand Down
22 changes: 22 additions & 0 deletions src/support/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { MarkEdit } from 'markedit-api';
import type { JSONObject, JSONValue } from 'markedit-api';
import type { MarkdownIt } from 'markdown-it';
import type { MathDelimiter } from 'markedit-katex';
import type { MermaidConfig } from 'mermaid';
import type { ColorScheme } from '../shared/types';

export type InlineRenderingType = 'image' | 'table' | 'math' | 'mermaid' | 'html';
Expand All @@ -17,6 +18,7 @@ const userSettings = toObject(MarkEdit.userSettings);
const rootValue = toObject(userSettings[Constants.rootValueKey]);
const changeMode = toObject(rootValue.changeMode);
const markdownIt = toObject(rootValue.markdownIt);
const mermaid = toObject(rootValue.mermaid);

export const syncScroll = toBoolean(rootValue.syncScroll);
export const hidePreviewButtons = toBoolean(rootValue.hidePreviewButtons);
Expand All @@ -40,6 +42,8 @@ export const viewModes = (changeMode.modes ?? Constants.defaultModes) as string[
export const keyboardShortcut = toObject(changeMode.hotKey);
export const markdownItPreset = (markdownIt.preset ?? Constants.defaultPreset) as Extract<Parameters<MarkdownIt['configure']>[0], string>;
export const markdownItOptions = toObject(markdownIt.options);
export const mermaidLightTheme = toMermaidTheme(mermaid.lightTheme, 'lightTheme', 'default');
export const mermaidDarkTheme = toMermaidTheme(mermaid.darkTheme, 'darkTheme', 'dark');

function toObject(jsonValue: JSONValue, defaultValue = {}): JSONObject {
return jsonValue ?? defaultValue;
Expand All @@ -66,3 +70,21 @@ function toMathDelimiters(value: unknown): MathDelimiter[] | undefined {

return value;
}

function toMermaidTheme(value: unknown, key: string, defaultValue: NonNullable<MermaidConfig['theme']>): NonNullable<MermaidConfig['theme']> {
if (value === undefined || value === null) {
return defaultValue;
}

const themes: readonly NonNullable<MermaidConfig['theme']>[] = [
'default', 'base', 'dark', 'forest', 'neutral', 'neo', 'neo-dark',
'redux', 'redux-dark', 'redux-color', 'redux-dark-color', 'null',
];

const theme = themes.find(name => name === value);
if (theme === undefined) {
throw new TypeError(`Invalid mermaid.${key} setting: expected a supported Mermaid theme name`);
}

return theme;
}
1 change: 1 addition & 0 deletions tests/build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ describe('build variants', () => {

const modules = Object.keys(chunk.modules);
expect(modules.some(id => id.includes('/node_modules/mermaid/'))).toBe(!lite);
expect(modules.some(id => id.includes('/node_modules/elkjs/'))).toBe(!lite);

const katexRoot = dirname(require.resolve('katex/package.json'));
expect(modules.some(id => id.startsWith(`${katexRoot}/`))).toBe(!lite);
Expand Down
2 changes: 1 addition & 1 deletion tests/hiddenSyntax/block.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,7 @@ describe('Mermaid blocks', () => {

darkMode = false;
await renderMermaidSVG('graph TD');
expect(mermaidMocks.initialize).toHaveBeenLastCalledWith({ theme: undefined });
expect(mermaidMocks.initialize).toHaveBeenLastCalledWith({ theme: 'default' });
});

test('rerenders mounted diagrams when the color scheme changes', async () => {
Expand Down
127 changes: 127 additions & 0 deletions tests/mermaid.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// @vitest-environment jsdom
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
import mermaid from 'mermaid';
import type * as Settings from '../src/support/settings';
import { loadMermaid } from '../src/features/mermaid';
import { renderMermaidSVG } from '../src/render';

const themes = vi.hoisted<{ light: typeof Settings.mermaidLightTheme; dark: typeof Settings.mermaidDarkTheme }>(() => ({
light: 'default',
dark: 'dark',
}));

vi.mock('../src/support/settings', async importOriginal => ({
...await importOriginal<typeof Settings>(),
get mermaidLightTheme() { return themes.light; },
get mermaidDarkTheme() { return themes.dark; },
}));

beforeAll(() => {
// jsdom has no SVG text measurement; exercise the real renderer with fixed geometry
Object.defineProperties(SVGElement.prototype, {
getBBox: { configurable: true, value: () => ({ x: 0, y: 0, width: 100, height: 20 }) },
getComputedTextLength: { configurable: true, value: () => 100 },
});
});

beforeEach(() => {
vi.stubGlobal('matchMedia', () => ({ matches: false }));
});

afterEach(() => {
document.body.innerHTML = '';
vi.restoreAllMocks();
vi.unstubAllGlobals();
});

afterAll(() => {
Reflect.deleteProperty(SVGElement.prototype, 'getBBox');
Reflect.deleteProperty(SVGElement.prototype, 'getComputedTextLength');
});

function diagramStyles(svg: string) {
const container = document.createElement('div');
container.innerHTML = svg;

const styles = container.querySelector('svg style')?.textContent;
if (!styles) {
throw new Error('Expected rendered SVG styles');
}

return styles.replace(/markedit-mermaid-\d+/g, 'diagram');
}

describe('Mermaid 12 renderer', () => {
test.each([
['flowchart', 'flowchart TD\nA[Start] --> B[End]'],
['class', 'classDiagram\nAnimal <|-- Duck'],
['state', 'stateDiagram-v2\n[*] --> Ready\nReady --> [*]'],
])('renders a %s diagram with the bundled default layout', async (_type, source) => {
const svg = await renderMermaidSVG(source);
const container = document.createElement('div');
container.innerHTML = svg;
expect(container.querySelector('svg')).not.toBeNull();
expect(container.querySelector('.error-icon')).toBeNull();
expect(svg).not.toContain('Syntax error');
});

test('uses legacy themes and restores the light theme after dark mode', async () => {
let darkMode = false;
vi.stubGlobal('matchMedia', () => ({ matches: darkMode }));
const initialize = vi.spyOn(mermaid, 'initialize');
expect(await loadMermaid()).toBe(mermaid);

const source = 'flowchart TD\nA --> B';
const lightStyles = diagramStyles(await renderMermaidSVG(source));
initialize.mockClear();

darkMode = true;
const darkStyles = diagramStyles(await renderMermaidSVG(source));
expect(initialize).toHaveBeenLastCalledWith({ theme: 'dark' });
expect(darkStyles).not.toBe(lightStyles);

darkMode = false;
expect(diagramStyles(await renderMermaidSVG(source))).toBe(lightStyles);
expect(initialize).toHaveBeenLastCalledWith({ theme: 'default' });
expect(initialize).toHaveBeenCalledTimes(2);
});

test('supports per-diagram opt-in to the previous layout and appearance', async () => {
const svg = await renderMermaidSVG('---\nconfig:\n layout: dagre\n theme: default\n look: classic\n---\nflowchart TD\nA --> B');
expect(svg).toContain('<svg');
expect(svg).not.toContain('Syntax error');
});

test('renders with opt-in Redux themes when switching color schemes', async () => {
themes.light = 'redux-color';
themes.dark = 'redux-dark-color';

let darkMode = true;
vi.stubGlobal('matchMedia', () => ({ matches: darkMode }));
const initialize = vi.spyOn(mermaid, 'initialize');

try {
const styles = new Map<boolean, string>();
for (const isDark of [true, false, true]) {
darkMode = isDark;
const svg = await renderMermaidSVG('flowchart TD\nA --> B');
const theme = isDark ? themes.dark : themes.light;
expect(initialize).toHaveBeenLastCalledWith({ theme });

const expected = await renderMermaidSVG(`---\nconfig:\n theme: ${theme}\n---\nflowchart TD\nA --> B`);
expect(diagramStyles(svg)).toBe(diagramStyles(expected));
styles.set(isDark, diagramStyles(svg));
}

expect(styles.get(true)).not.toBe(styles.get(false));
const svg = await renderMermaidSVG('---\nconfig:\n theme: neutral\n---\nflowchart TD\nA --> B');
expect(diagramStyles(svg)).not.toBe(styles.get(true));
expect(diagramStyles(await renderMermaidSVG('flowchart TD\nA --> B'))).toBe(styles.get(true));
} finally {
themes.light = 'default';
themes.dark = 'dark';
darkMode = false;
await loadMermaid();
}
});
});
70 changes: 59 additions & 11 deletions tests/render.styles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,24 @@ import type * as Settings from '../src/support/settings';
import { applyStyles, renderMarkdown } from '../src/render';
import { generateStaticHtml, renderStaticHtml, saveStyledHtml } from '../src/view';

const mocks = vi.hoisted(() => ({
getText: vi.fn(() => '# Hello'),
getFileInfo: vi.fn(async () => ({ filePath: '/example.md' })),
showSavePanel: vi.fn(),
settings: { styledHtmlColorScheme: 'auto' },
}));
const mocks = vi.hoisted(() => {
const settings: {
styledHtmlColorScheme: string;
mermaidLightTheme: typeof Settings.mermaidLightTheme;
mermaidDarkTheme: typeof Settings.mermaidDarkTheme;
} = {
styledHtmlColorScheme: 'auto',
mermaidLightTheme: 'default',
mermaidDarkTheme: 'dark',
};

return {
getText: vi.fn(() => '# Hello'),
getFileInfo: vi.fn(async () => ({ filePath: '/example.md' })),
showSavePanel: vi.fn(),
settings,
};
});

vi.mock('markedit-api', () => ({
MarkEdit: {
Expand All @@ -23,6 +35,8 @@ vi.mock('markedit-api', () => ({
vi.mock('../src/support/settings', async importOriginal => ({
...await importOriginal<typeof Settings>(),
get styledHtmlColorScheme() { return mocks.settings.styledHtmlColorScheme; },
get mermaidLightTheme() { return mocks.settings.mermaidLightTheme; },
get mermaidDarkTheme() { return mocks.settings.mermaidDarkTheme; },
}));

const mermaidMarkdown = '```mermaid\ngraph TD\n A --> B\n```';
Expand All @@ -39,6 +53,8 @@ beforeEach(() => {
mocks.getFileInfo.mockResolvedValue({ filePath: '/example.md' });
mocks.showSavePanel.mockClear();
mocks.settings.styledHtmlColorScheme = 'auto';
mocks.settings.mermaidLightTheme = 'default';
mocks.settings.mermaidDarkTheme = 'dark';
});

describe('styled HTML remote scripts', () => {
Expand Down Expand Up @@ -69,14 +85,46 @@ describe('styled HTML remote scripts', () => {
expect(html).not.toContain('mermaid@11/');
});

it.each(['auto', 'light', 'dark'])('preserves %s color scheme initialization', async scheme => {
it.each([
['auto', false, 'default'],
['auto', true, 'dark'],
['light', true, 'default'],
['dark', false, 'dark'],
])('initializes %s exports with system dark mode %s', async (scheme, systemDark, theme) => {
mocks.settings.styledHtmlColorScheme = scheme;
const html = await applyStyles(await renderMarkdown(mermaidMarkdown));
const script = scripts(html)[0].textContent;
expect(script).toContain(`if (${scheme === 'auto'})`);
expect(script).toContain(`const isDark = ${scheme === 'dark'}`);
expect(script).toContain('darkMode.addEventListener("change"');
expect(script).toContain('mermaid.initialize');
const initialize = vi.fn();
const addEventListener = vi.fn();
const matchMedia = vi.fn(() => ({ matches: systemDark, addEventListener }));
const execute = new Function('mermaid', 'matchMedia', script.replace(`import mermaid from "${mermaidURL}";`, ''));
execute({ initialize }, matchMedia);

expect(initialize).toHaveBeenCalledExactlyOnceWith({ theme });
expect(addEventListener).toHaveBeenCalledTimes(scheme === 'auto' ? 1 : 0);
if (scheme === 'auto') {
expect(addEventListener).toHaveBeenCalledWith('change', expect.any(Function));
}
});

it.each([
['auto', false, 'forest'],
['auto', true, 'neo-dark'],
['light', true, 'forest'],
['dark', false, 'neo-dark'],
])('uses configured themes in %s exports with system dark mode %s', async (scheme, systemDark, theme) => {
mocks.settings.styledHtmlColorScheme = scheme;
mocks.settings.mermaidLightTheme = 'forest';
mocks.settings.mermaidDarkTheme = 'neo-dark';

const html = await applyStyles(await renderMarkdown(mermaidMarkdown));
const script = scripts(html)[0].textContent;
const initialize = vi.fn();
const matchMedia = () => ({ matches: systemDark, addEventListener: vi.fn() });
const execute = new Function('mermaid', 'matchMedia', script.replace(`import mermaid from "${mermaidURL}";`, ''));

execute({ initialize }, matchMedia);
expect(initialize).toHaveBeenCalledExactlyOnceWith({ theme });
});
});

Expand Down
Loading
Loading