Compare commits

..
Author SHA1 Message Date
Sidharth Vinod d1fd4dc244 feat: More edit modes in CTA 2026-04-02 17:33:52 +05:30
Sidharth Vinod 9436f8ddda chore: Remove canonical 2026-04-01 22:43:58 +05:30
30 changed files with 3956 additions and 3331 deletions
@@ -1,60 +0,0 @@
name: Close broken link issues
on:
issues:
types: [opened, edited]
permissions:
issues: write
jobs:
close-broken-link-issues:
if: github.event.issue.state == 'open'
runs-on: ubuntu-latest
steps:
- name: Close issues with only "Broken link" in the title
uses: actions/github-script@v7
with:
script: |
const title = context.payload.issue.title.trim();
const isBrokenLinkOnlyTitle = /^broken link\.?$/i.test(title);
if (!isBrokenLinkOnlyTitle) {
return;
}
const { owner, repo } = context.repo;
const issueNumber = context.payload.issue.number;
const newIssueUrl = `https://github.com/${owner}/${repo}/issues/new?assignees=&labels=bug&template=bug_report.md&title=Broken%20link`;
const mermaidIssuesUrl = 'https://github.com/mermaid-js/mermaid/issues/new';
const commentBody = [
`@${context.payload.issue.user.login} This issue was automatically closed because the title only contains "Broken link" without additional details.`,
'',
'**Please only open issues here if something is broken in the live editor itself** (e.g. a link fails to load, the editor UI misbehaves, or a feature of mermaid.live does not work).',
'',
'**Do not open issues here for syntax errors or invalid Mermaid diagram code.** Those belong in the main Mermaid repository: [mermaid-js/mermaid](https://github.com/mermaid-js/mermaid/issues/new).',
'',
'If you are reporting a live editor bug, please include:',
'- The full URL that failed to load',
'- What you expected to happen',
'- Any error messages you saw',
'',
`You can [open a new issue in this repo](${newIssueUrl}) with this information, or report diagram syntax issues in the [Mermaid repo](${mermaidIssuesUrl}).`
].join('\n');
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: commentBody
});
await github.rest.issues.update({
owner,
repo,
issue_number: issueNumber,
state: 'closed',
state_reason: 'not_planned'
});
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
name: 'Playwright Tests' name: 'Playwright Tests'
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: mcr.microsoft.com/playwright:v1.60.0-jammy image: mcr.microsoft.com/playwright:v1.52.0-jammy
steps: steps:
- name: Checkout - name: Checkout
+1 -1
View File
@@ -1 +1 @@
24.16.0 22.15.0
-3
View File
@@ -12,14 +12,11 @@
"fsegurai", "fsegurai",
"gantt", "gantt",
"gitgraph", "gitgraph",
"hugeicons",
"KROKI", "KROKI",
"localstorage", "localstorage",
"mermaidchart", "mermaidchart",
"mindmap", "mindmap",
"NEWYEAR", "NEWYEAR",
"noopener",
"noreferrer",
"Pageview", "Pageview",
"pako", "pako",
"panmove", "panmove",
+1 -1
View File
@@ -1,4 +1,4 @@
FROM docker.io/library/node:24.16.0-alpine3.22 AS mermaid-live-editor-dependencies FROM docker.io/library/node:22.15.0-alpine3.21 AS mermaid-live-editor-dependencies
RUN apk --no-cache add build-base git python3 && \ RUN apk --no-cache add build-base git python3 && \
rm -rf /var/cache/apk/* rm -rf /var/cache/apk/*
+73 -74
View File
@@ -23,96 +23,95 @@
"test:e2e:debug": "playwright test --debug" "test:e2e:debug": "playwright test --debug"
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^2.1.0", "@eslint/compat": "^1.2.5",
"@eslint/eslintrc": "^3.3.5", "@eslint/eslintrc": "^3.3.3",
"@eslint/js": "^10.0.1", "@eslint/js": "^9.39.2",
"@fortawesome/fontawesome-free": "^7.2.0", "@fortawesome/fontawesome-free": "^6.7.2",
"@iconify-json/hugeicons": "^1.2.29", "@iconify-json/material-symbols": "^1.2.20",
"@iconify-json/logos": "^1.2.11",
"@iconify-json/material-symbols": "^1.2.76",
"@iconify-json/mdi": "^1.2.3", "@iconify-json/mdi": "^1.2.3",
"@playwright/test": "^1.60.0", "@playwright/test": "^1.52.0",
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.9",
"@sveltejs/kit": "^2.63.1", "@sveltejs/kit": "^2.37.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2", "@sveltejs/vite-plugin-svelte": "^6.1.3",
"@tailwindcss/typography": "^0.5.20", "@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.1.18",
"@types/hammerjs": "^2.0.46", "@types/hammerjs": "^2.0.46",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/node": "^24.12.4", "@types/node": "^22.15.10",
"@types/pako": "2.0.4", "@types/pako": "2.0.3",
"@vitest/coverage-v8": "^4.1.8", "@types/uuid": "9.0.8",
"@vitest/ui": "^4.1.8", "@vitest/coverage-v8": "^3.2.4",
"autoprefixer": "^10.5.0", "@vitest/ui": "^3.2.4",
"bits-ui": "^2.18.1", "autoprefixer": "^10.4.21",
"c8": "11.0.0", "bits-ui": "^2.9.6",
"chai": "^6.2.2", "c8": "7.14.0",
"chai": "^4.5.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cssnano": "^8.0.1", "cssnano": "^6.1.2",
"dotenv": "^17.4.2", "dotenv": "^17.3.1",
"eslint": "^10.4.1", "eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-es": "^4.1.0", "eslint-plugin-es": "^4.1.0",
"eslint-plugin-no-only-tests": "^3.4.0", "eslint-plugin-no-only-tests": "^3.3.0",
"eslint-plugin-sort-keys": "^2.3.5", "eslint-plugin-sort-keys": "^2.3.5",
"eslint-plugin-svelte": "^3.19.0", "eslint-plugin-svelte": "^3.0.0",
"eslint-plugin-tailwindcss": "^3.18.3", "eslint-plugin-tailwindcss": "^3.18.2",
"eslint-plugin-unicorn": "^65.0.0", "eslint-plugin-unicorn": "^60.0.0",
"esserializer": "^1.3.11", "esserializer": "^1.3.11",
"globals": "^17.6.0", "globals": "^16.0.0",
"husky": "^9.1.7", "husky": "^8.0.3",
"jsdom": "^29.1.1", "jsdom": "^25.0.1",
"lint-staged": "^17.0.7", "lint-staged": "^15.5.1",
"lucide-svelte": "^1.0.1", "lucide-svelte": "^0.507.0",
"node-html-parser": "^7.1.0", "node-html-parser": "^6.1.13",
"paneforge": "1.0.2", "paneforge": "1.0.0-next.6",
"prettier": "^3.8.3", "prettier": "^3.8.1",
"prettier-plugin-svelte": "^4.1.0", "prettier-plugin-svelte": "^3.4.1",
"prettier-plugin-tailwindcss": "^0.8.0", "prettier-plugin-tailwindcss": "^0.7.2",
"svelte": "^5.56.3", "svelte": "^5.38.6",
"svelte-preprocess": "^6.0.5", "svelte-preprocess": "^6.0.3",
"svelte-sonner": "^1.1.1", "svelte-sonner": "^1.0.5",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.3.1",
"tailwind-variants": "^3.2.2", "tailwind-variants": "^3.1.0",
"tailwindcss": "^4.3.0", "tailwindcss": "^4.1.18",
"tslib": "^2.8.1", "tslib": "^2.8.1",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.3.8",
"typescript": "^6.0.3", "typescript": "^5.9.2",
"typescript-eslint": "^8.60.1", "typescript-eslint": "^8.42.0",
"unplugin-icons": "^23.0.1", "unplugin-icons": "^22.2.0",
"vite": "^8.0.16", "vite": "^7.1.4",
"vite-plugin-devtools-json": "^1.0.0", "vite-plugin-devtools-json": "^1.0.0",
"vitest": "^4.1.8", "vitest": "^3.2.4",
"vitest-dom": "^0.1.1" "vitest-dom": "^0.1.1"
}, },
"dependencies": { "dependencies": {
"@codemirror/lang-json": "^6.0.2", "@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-markdown": "^6.5.0", "@codemirror/lang-markdown": "^6.3.2",
"@codemirror/lang-yaml": "^6.1.3", "@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.12.3", "@codemirror/language": "^6.11.0",
"@codemirror/state": "^6.6.0", "@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.43.0", "@codemirror/view": "^6.36.7",
"@fontsource-variable/recursive": "^5.2.8", "@fontsource-variable/recursive": "^5.2.5",
"@fsegurai/codemirror-theme-vscode-dark": "^6.2.6", "@fsegurai/codemirror-theme-vscode-dark": "^6.1.4",
"@fsegurai/codemirror-theme-vscode-light": "^6.2.6", "@fsegurai/codemirror-theme-vscode-light": "^6.1.4",
"@mermaid-js/examples": "^1.2.0", "@mermaid-js/examples": "^1.0.0",
"@mermaid-js/layout-elk": "^0.2.1", "@mermaid-js/layout-elk": "^0.1.9",
"@mermaid-js/layout-tidy-tree": "^0.2.2", "@mermaid-js/layout-tidy-tree": "^0.2.1",
"@mermaid-js/mermaid-zenuml": "^0.2.3", "@mermaid-js/mermaid-zenuml": "^0.2.2",
"codemirror": "^6.0.2", "codemirror": "^6.0.1",
"dayjs": "^1.11.21", "dayjs": "^1.11.13",
"hammerjs": "^2.0.8", "hammerjs": "^2.0.8",
"js-base64": "3.7.8", "js-base64": "3.7.7",
"lodash-es": "^4.18.1", "lodash-es": "^4.17.21",
"mermaid": "^11.15.0", "mermaid": "^11.13.0",
"mode-watcher": "^1.1.0", "mode-watcher": "^0.5.1",
"monaco-editor": "0.55.1", "monaco-editor": "0.52.2",
"pako": "2.1.0", "pako": "2.1.0",
"plausible-tracker": "^0.3.9", "plausible-tracker": "^0.3.9",
"random-word-slugs": "0.1.7", "random-word-slugs": "0.1.7",
"svg-pan-zoom": "3.6.2", "svg-pan-zoom": "3.6.2",
"svg2roughjs": "^3.2.3", "svg2roughjs": "^3.2.1",
"uuid": "14.0.0" "uuid": "9.0.1"
}, },
"lint-staged": { "lint-staged": {
"*.{ts,svelte,js,css,md,json}": [ "*.{ts,svelte,js,css,md,json}": [
@@ -121,9 +120,9 @@
] ]
}, },
"engines": { "engines": {
"node": ">=24.16.0" "node": ">=20.19.0"
}, },
"packageManager": "pnpm@10.34.1+sha512.b58fbde6dca66a929538021581f648b4570b6ca19b18e7cbd7f2c07a7b24454155388dacdf08f2af3678e88a6d1fe04f9d609df24bf51735a060ea041b374ab7", "packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39",
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"deasync", "deasync",
+3665 -2832
View File
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -8,8 +8,7 @@
<meta <meta
name="description" name="description"
content="Simplify documentation and avoid heavy tools. Open source Visio Alternative. Commonly used for explaining your code! Mermaid is a simple markdown-like script language for generating charts from text via javascript." /> content="Simplify documentation and avoid heavy tools. Open source Visio Alternative. Commonly used for explaining your code! Mermaid is a simple markdown-like script language for generating charts from text via javascript." />
<link rel="icon" type="image/svg+xml" href="%sveltekit.assets%/favicon.svg" /> <link rel="icon" type="image/png" href="%sveltekit.assets%/favicon.svg" />
<link rel="apple-touch-icon" href="%sveltekit.assets%/favicon.png" />
<link rel="mask-icon" href="%sveltekit.assets%/favicon.svg" color="#000000" /> <link rel="mask-icon" href="%sveltekit.assets%/favicon.svg" color="#000000" />
<meta name="theme-color" content="#ff3670" /> <meta name="theme-color" content="#ff3670" />
<link rel="manifest" href="%sveltekit.assets%/manifest.json" /> <link rel="manifest" href="%sveltekit.assets%/manifest.json" />
+12 -34
View File
@@ -27,7 +27,6 @@
lineNumbersMinChars: 4 lineNumbersMinChars: 4
} satisfies monaco.editor.IStandaloneEditorConstructionOptions; } satisfies monaco.editor.IStandaloneEditorConstructionOptions;
let currentText = ''; let currentText = '';
let isUpdatingFromState = false;
let showPopup = $state(false); let showPopup = $state(false);
let popupPosition = $state({ top: 0, lineNumber: 0 }); let popupPosition = $state({ top: 0, lineNumber: 0 });
let decorationsCollection: monaco.editor.IEditorDecorationsCollection | undefined; let decorationsCollection: monaco.editor.IEditorDecorationsCollection | undefined;
@@ -35,16 +34,6 @@
let lastMouseLine = 0; let lastMouseLine = 0;
const aiPromptManager = new AIPromptViewZoneManager(); const aiPromptManager = new AIPromptViewZoneManager();
const applyEditorTheme = (currentMode: typeof mode.current) => {
if (!editor) return;
monaco.editor.setTheme(`mermaid${currentMode === 'dark' ? '-dark' : ''}`);
divElement?.classList.toggle('mermaid-dark', currentMode === 'dark');
};
$effect(() => {
applyEditorTheme(mode.current);
});
const jsonModel = monaco.editor.createModel( const jsonModel = monaco.editor.createModel(
'', '',
'json', 'json',
@@ -143,7 +132,7 @@
editor.onDidChangeModelContent(({ isFlush }) => { editor.onDidChangeModelContent(({ isFlush }) => {
const newText = editor?.getValue(); const newText = editor?.getValue();
if (!newText || currentText === newText || isFlush || isUpdatingFromState) { if (!newText || currentText === newText || isFlush) {
return; return;
} }
currentText = newText; currentText = newText;
@@ -170,21 +159,9 @@
// Update editor text if it's different // Update editor text if it's different
const newText = editorMode === 'code' ? code : mermaid; const newText = editorMode === 'code' ? code : mermaid;
if (newText !== currentText) { if (newText !== currentText) {
isUpdatingFromState = true; editor.setScrollTop(0);
try { editor.setValue(newText);
editor.setScrollTop(0); currentText = newText;
editor.pushUndoStop();
editor.executeEdits('updateCode', [
{
range: model.getFullModelRange(),
text: newText
}
]);
editor.pushUndoStop();
currentText = newText;
} finally {
isUpdatingFromState = false;
}
renderAIPromptGutterGlyphIcon(); renderAIPromptGutterGlyphIcon();
} }
@@ -206,8 +183,12 @@
renderAIPromptGutterGlyphIcon(); renderAIPromptGutterGlyphIcon();
}); });
applyEditorTheme(mode.current); const unsubscribeMode = mode.subscribe((mode) => {
if (editor) {
monaco.editor.setTheme(`mermaid${mode === 'dark' ? '-dark' : ''}`);
divElement?.classList.toggle('mermaid-dark', mode === 'dark');
}
});
const resizeObserver = new ResizeObserver((entries) => { const resizeObserver = new ResizeObserver((entries) => {
editor?.layout({ editor?.layout({
height: entries[0].contentRect.height, height: entries[0].contentRect.height,
@@ -223,6 +204,7 @@
return () => { return () => {
unsubscribeState(); unsubscribeState();
unsubscribeMode();
resizeObserver.disconnect(); resizeObserver.disconnect();
jsonModel.dispose(); jsonModel.dispose();
mermaidModel.dispose(); mermaidModel.dispose();
@@ -242,11 +224,7 @@
onClose={closePopup} onClose={closePopup}
onTryFree={() => { onTryFree={() => {
logMermaidChartClick('vibeDiagramming'); logMermaidChartClick('vibeDiagramming');
window.open( window.open($urlsStore.mermaidChart({ medium: 'vibe_diagramming' }).save, '_blank');
$urlsStore.mermaidChart({ medium: 'vibe_diagramming' }).save,
'_blank',
'noopener'
);
closePopup(); closePopup();
}} /> }} />
</div> </div>
+18 -19
View File
@@ -34,9 +34,8 @@
source: string; source: string;
} }
const cycleIntervalMs = 30_000; let currentActionMedium = $state<EnhancedEditAction['medium'] | undefined>(undefined);
let previousDiagramType = $state<string | undefined>(undefined);
let currentActionIndex = $state(0);
const availableActions = $derived.by<EnhancedEditAction[]>(() => { const availableActions = $derived.by<EnhancedEditAction[]>(() => {
if (!$stateStore.diagramType) { if (!$stateStore.diagramType) {
@@ -44,14 +43,14 @@
} }
const actions: EnhancedEditAction[] = [ const actions: EnhancedEditAction[] = [
{ campaign: 'voice_1', label: 'with voice', medium: 'voice_edit', source: 'voiceEdit' }, { campaign: 'ai_1', label: 'with AI', medium: 'ai_edit', source: 'aiEdit' },
{ campaign: 'ai_1', label: 'with AI', medium: 'ai_edit', source: 'aiEdit' } { campaign: 'voice_1', label: 'with Voice', medium: 'voice_edit', source: 'voiceEdit' }
]; ];
if (showVisualEdit) { if (showVisualEdit) {
actions.unshift({ actions.unshift({
campaign: 'visual_1', campaign: 'visual_1',
label: 'visually', label: 'Visually',
medium: 'visual_edit', medium: 'visual_edit',
source: 'visualEdit' source: 'visualEdit'
}); });
@@ -66,30 +65,30 @@
return undefined; return undefined;
} }
return actions[currentActionIndex % actions.length]; return actions.find(({ medium }) => medium === currentActionMedium) ?? actions[0];
}); });
$effect(() => { $effect(() => {
const actionCount = availableActions.length; const actions = availableActions;
if (actionCount === 0) { if (!diagramType || actions.length === 0) {
currentActionIndex = 0;
return; return;
} }
if (currentActionIndex >= actionCount) { const hasCurrentAction = actions.some(({ medium }) => medium === currentActionMedium);
currentActionIndex = 0;
}
if (actionCount <= 1) { if (diagramType !== previousDiagramType || !hasCurrentAction) {
currentActionMedium = actions[Math.floor(Math.random() * actions.length)].medium;
previousDiagramType = diagramType;
}
});
$effect(() => {
if (diagramType) {
return; return;
} }
const intervalID = setInterval(() => { previousDiagramType = undefined;
currentActionIndex = (currentActionIndex + 1) % actionCount;
}, cycleIntervalMs);
return () => clearInterval(intervalID);
}); });
</script> </script>
+1 -1
View File
@@ -126,7 +126,7 @@
Dark Mode Dark Mode
</span> </span>
<Switch <Switch
checked={mode.current === 'dark'} checked={$mode === 'dark'}
onCheckedChange={(dark) => setMode(dark ? 'dark' : 'light')} /> onCheckedChange={(dark) => setMode(dark ? 'dark' : 'light')} />
</div> </div>
{/snippet} {/snippet}
+8 -7
View File
@@ -16,17 +16,11 @@
let editorView: EditorView | undefined; let editorView: EditorView | undefined;
let editorContainer: HTMLDivElement; let editorContainer: HTMLDivElement;
let currentText = $state(''); let currentText = $state('');
const themeCompartment = new Compartment();
const { onUpdate }: EditorProps = $props(); const { onUpdate }: EditorProps = $props();
$effect(() => {
editorView?.dispatch({
effects: themeCompartment.reconfigure(mode.current === 'dark' ? vsCodeDark : vsCodeLight)
});
});
onMount(() => { onMount(() => {
const themeCompartment = new Compartment();
const languageCompartment = new Compartment(); const languageCompartment = new Compartment();
editorView = new EditorView({ editorView = new EditorView({
@@ -62,6 +56,12 @@
parent: editorContainer parent: editorContainer
}); });
const unsubscribeMode = mode.subscribe((mode) => {
editorView?.dispatch({
effects: themeCompartment.reconfigure(mode === 'dark' ? vsCodeDark : vsCodeLight)
});
});
const unsubscribeState = stateStore.subscribe(({ editorMode, code, mermaid }) => { const unsubscribeState = stateStore.subscribe(({ editorMode, code, mermaid }) => {
const text = editorMode === 'code' ? code : mermaid; const text = editorMode === 'code' ? code : mermaid;
if (currentText === text || !editorView) { if (currentText === text || !editorView) {
@@ -89,6 +89,7 @@
}); });
return () => { return () => {
unsubscribeMode();
unsubscribeState(); unsubscribeState();
editorView?.destroy(); editorView?.destroy();
}; };
+1 -2
View File
@@ -8,7 +8,6 @@
</script> </script>
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths';
import MainMenu from '$/components/MainMenu.svelte'; import MainMenu from '$/components/MainMenu.svelte';
import { Button } from '$/components/ui/button'; import { Button } from '$/components/ui/button';
import { Separator } from '$/components/ui/separator'; import { Separator } from '$/components/ui/separator';
@@ -84,7 +83,7 @@
<div class="flex flex-1 items-center gap-2"> <div class="flex flex-1 items-center gap-2">
<MainMenu /> <MainMenu />
<MermaidIcon class="size-6" /> <MermaidIcon class="size-6" />
<a href={resolve('/')} class="whitespace-nowrap text-accent"> <a href="/" class="whitespace-nowrap text-accent">
{#if !mobileToggle} {#if !mobileToggle}
Mermaid Mermaid
{/if} {/if}
+2 -2
View File
@@ -24,12 +24,12 @@
</script> </script>
<div class="inline-grid"> <div class="inline-grid">
{#key mode.current} {#key $mode}
<div <div
in:spin={{ clockWise: true }} in:spin={{ clockWise: true }}
out:spin={{ clockWise: false }} out:spin={{ clockWise: false }}
class="col-start-1 row-start-1"> class="col-start-1 row-start-1">
{#if mode.current === 'dark'} {#if $mode === 'dark'}
<MoonIcon /> <MoonIcon />
{:else} {:else}
<SunIcon /> <SunIcon />
@@ -23,9 +23,9 @@
variant="ghost" variant="ghost"
size="icon" size="icon"
data-testid={TID.themeToggleButton} data-testid={TID.themeToggleButton}
title="Switch to {mode.current === 'dark' ? 'light' : 'dark'} theme" title="Switch to {$mode === 'dark' ? 'light' : 'dark'} theme"
class="[&_svg]:size-5" class="[&_svg]:size-5"
onclick={() => setMode(mode.current === 'dark' ? 'light' : 'dark')}> onclick={() => setMode($mode === 'dark' ? 'light' : 'dark')}>
<ThemeIcon /> <ThemeIcon />
</Button> </Button>
</FloatingToolbar> </FloatingToolbar>
+1 -1
View File
@@ -153,7 +153,7 @@
<div <div
id="view" id="view"
bind:this={view} bind:this={view}
class={['h-full w-full', shouldShowGrid && `grid-bg-${mode.current}`, error && 'opacity-50']}> class={['h-full w-full', shouldShowGrid && `grid-bg-${$mode}`, error && 'opacity-50']}>
<div id="container" bind:this={container} class="h-full overflow-auto"></div> <div id="container" bind:this={container} class="h-full overflow-auto"></div>
</div> </div>
@@ -1,101 +1,164 @@
<script lang="ts"> <script lang="ts">
import McWrapper from '$/components/McWrapper.svelte';
import MermaidChartIcon from '$/components/MermaidChartIcon.svelte';
import PrivacyPolicyLink from '$/components/migration/PrivacyPolicyLink.svelte';
import { Button } from '$/components/ui/button'; import { Button } from '$/components/ui/button';
import * as Dialog from '$/components/ui/dialog'; import * as Dialog from '$/components/ui/dialog';
import { dismissEditorChooser } from '$/util/migration/domainMigration'; import { dismissEditorChooser } from '$/util/migration/domainMigration';
import type { Component } from 'svelte'; import { logEvent, logMermaidChartClick } from '$/util/stats';
import AtlassianIcon from '~icons/logos/atlassian'; import { getCheckoutUrl } from '$/util/util';
import AmazonIcon from '~icons/logos/aws'; import CodeIcon from '~icons/custom/code';
import GoogleIcon from '~icons/logos/google'; import ArrowIcon from '~icons/material-symbols/arrow-forward-rounded';
import MicrosoftIcon from '~icons/logos/microsoft'; import OpenSourceIcon from '~icons/material-symbols/book-2-outline-rounded';
import { createEditorChooserActions } from './editorChooserActions'; import ChatIcon from '~icons/material-symbols/chat-outline-rounded';
import EditIcon from '~icons/material-symbols/edit-outline-rounded';
import HistoryIcon from '~icons/material-symbols/history';
import HomeIcon from '~icons/material-symbols/home-storage-outline-rounded';
import SparklesIcon from '~icons/material-symbols/kid-star-outline';
import LanguageIcon from '~icons/material-symbols/language';
import WidthIcon from '~icons/material-symbols/width-rounded';
interface Props { interface Props {
open: boolean; open: boolean;
} }
let { open = $bindable() }: Props = $props(); let { open = $bindable() }: Props = $props();
// Tracks whether the current close was triggered by an explicit action
// (button click logs its own event) vs. implicit dismissal (ESC/click-outside). const close = () => {
let handled = false; dismissEditorChooser();
const actions = createEditorChooserActions(() => {
handled = true;
open = false; open = false;
}); };
const features = [ const handleStartTrial = () => {
{ title: 'AI diagram generation', description: 'Describe what you need, AI builds it' }, logEvent('chooseEditor', { choice: 'plus' });
{ logMermaidChartClick('editorPicker');
title: 'Visual drag-and-drop editor', close();
description: 'Edit diagrams without writing code' window.open(
}, getCheckoutUrl({ utmCampaign: 'start_plus', utmMedium: '2_editor_selection' }),
{ '_blank'
title: 'Unlimited diagram storage', );
description: 'Save and organize all your diagrams' };
},
{
title: 'Team collaboration',
description: 'Share, comment, and edit together in real-time'
}
];
const trustedLogos: { name: string; icon: Component }[] = [ const handleStartFree = () => {
{ name: 'Google', icon: GoogleIcon }, logEvent('chooseEditor', { choice: 'openSource' });
{ name: 'Microsoft', icon: MicrosoftIcon }, close();
{ name: 'Atlassian', icon: AtlassianIcon }, };
{ name: 'Amazon', icon: AmazonIcon }
]; const handleContinueToNewHome = () => {
logEvent('chooseEditor', { choice: 'newHome' });
close();
window.open('https://mermaid.ai/live', '_blank');
};
</script> </script>
<Dialog.Root <Dialog.Root
bind:open bind:open
onOpenChange={(v) => { onOpenChange={(v) => {
if (v) return; if (!v) handleStartFree();
if (!handled) actions.log('dismissed');
handled = false;
dismissEditorChooser();
}}> }}>
<Dialog.Content class="flex max-w-lg flex-col gap-3 bg-background p-8"> <Dialog.Content class="flex max-w-2xl flex-col gap-6 bg-pink-50 p-6 dark:bg-background">
<Dialog.Header class="flex-col items-start gap-2 space-y-0 text-left sm:text-left"> <Dialog.Header>
<MermaidChartIcon class="size-10" /> <Dialog.Title class="text-center text-2xl font-semibold">Choose your editor</Dialog.Title>
<Dialog.Title class="pt-2 text-2xl font-bold">Try the full Mermaid experience</Dialog.Title> <Dialog.Description class="text-center text-sm font-light">
<Dialog.Description class="text-xs font-light text-muted-foreground"> You'll never see this again
Free forever, with Plus features free for 7 days.
</Dialog.Description> </Dialog.Description>
</Dialog.Header> </Dialog.Header>
<ul class="mt-2 flex flex-col gap-3"> <div class="grid gap-4 sm:grid-cols-2">
{#each features as feature (feature.title)} <!-- Mermaid Plus Card -->
<li class="flex items-center gap-3 rounded-lg border border-border p-3"> <div
<span class="size-2 shrink-0 rounded-full bg-accent"></span> class="relative flex flex-col overflow-hidden rounded-xl border-2 border-accent bg-white shadow dark:bg-card">
<div class="flex flex-col gap-0.5"> <div class="bg-accent px-6 py-2 text-center text-sm font-semibold text-accent-foreground">
<p class="text-xs">{feature.title}</p> Recommended
<p class="text-xs font-light text-muted-foreground">{feature.description}</p> </div>
<div class="flex flex-col gap-4 p-6">
<div>
<h3 class="text-xl font-bold">Mermaid Plus</h3>
<p class="text-sm text-muted-foreground">Unlock AI, storage and collaboration</p>
</div> </div>
</li>
{/each}
</ul>
<div class="mt-2 flex items-center gap-3"> <div class="flex flex-col gap-2">
<McWrapper labelPrefix="Opens "> <div class="flex justify-center">
<Button variant="accent" onclick={() => actions.startTrial()}>Start free trial</Button> <span
</McWrapper> class="rounded-full bg-pink-100 px-3 py-0.5 text-xs font-semibold text-pink-700 dark:bg-pink-950 dark:text-pink-300">
<Button variant="outline" onclick={() => actions.dismiss('stayOnLive')}> 10% off with code JS26
Stay on mermaid.live </span>
</Button> </div>
</div>
<div class="mt-3 flex flex-col items-start gap-4"> <Button variant="accent" class="w-full" onclick={handleStartTrial}>
<p class="text-xs">Trusted by 5M people and over 200k companies</p> Start free trial
<div class="flex w-full items-center justify-between gap-2"> </Button>
{#each trustedLogos as logo (logo.name)} </div>
<logo.icon class="h-6 w-auto grayscale" aria-label={logo.name} />
{/each} <ul class="space-y-3 text-sm">
<li class="flex items-center gap-2">
<EditIcon class="size-4 shrink-0 text-muted-foreground" />
Visual editor
</li>
<li class="flex items-center gap-2">
<SparklesIcon class="size-4 shrink-0 text-muted-foreground" />
300 AI credits
</li>
<li class="flex items-center gap-2">
<HomeIcon class="size-4 shrink-0 text-muted-foreground" />
Unlimited diagram storage
</li>
<li class="flex items-center gap-2">
<WidthIcon class="size-4 shrink-0 text-muted-foreground" />
Limitless diagram size
</li>
<li class="flex items-center gap-2">
<ChatIcon class="size-4 shrink-0 text-muted-foreground" />
View & comment collaboration
</li>
</ul>
</div>
</div>
<!-- Open Source Card -->
<div class="flex flex-col gap-4 rounded-xl border bg-white p-6 shadow dark:bg-card">
<div class="flex flex-col justify-end pt-10">
<h3 class="text-xl font-bold">Open Source</h3>
<p class="text-sm text-muted-foreground">Code only, no login, always free</p>
</div>
<div class="flex flex-col gap-2">
<p class="mt-2 text-sm text-muted-foreground">Mermaid has a new home</p>
<Button variant="outline" class="w-full border-accent" onclick={handleContinueToNewHome}>
Continue to mermaid.ai/live
<ArrowIcon class="ml-1 size-4" />
</Button>
<Button variant="outline" class="w-full" onclick={handleStartFree}>
Stay on mermaid.live
</Button>
</div>
<ul class="space-y-3 text-sm">
<li class="flex items-center gap-2">
<LanguageIcon class="size-4 shrink-0 text-muted-foreground" />
Diagram stored in URL
</li>
<li class="flex items-center gap-2">
<CodeIcon class="size-4 shrink-0 text-muted-foreground" />
Code editor
</li>
<li class="flex items-center gap-2">
<OpenSourceIcon class="size-4 shrink-0 text-muted-foreground" />
Open source
</li>
<li class="flex items-center gap-2">
<HistoryIcon class="size-4 shrink-0 text-muted-foreground" />
Version history
</li>
</ul>
</div> </div>
</div> </div>
<PrivacyPolicyLink /> <div class="text-center">
<a
href="https://mermaid.ai/privacy-policy"
target="_blank"
class="text-sm text-foreground underline hover:text-accent">
mermaid.ai Privacy Policy
</a>
</div>
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>
@@ -1,8 +0,0 @@
<div class="text-center">
<a
href="https://mermaid.ai/privacy-policy"
target="_blank"
class="text-sm text-foreground underline hover:text-accent">
mermaid.ai Privacy Policy
</a>
</div>
@@ -1,38 +0,0 @@
import { logEvent, logMermaidChartClick } from '$/util/stats';
import { getCheckoutUrl, getMermaidAiLiveUrl } from '$/util/util';
const utmMedium = 'editorSelection';
const utmCampaign = 'live_2026';
export interface EditorChooserActions {
log: (buttonClick: string) => void;
startTrial: (buttonClick?: string) => void;
dismiss: (buttonClick: string) => void;
openMermaidAiLive: (buttonClick: string) => void;
}
export const createEditorChooserActions = (close: () => void): EditorChooserActions => {
const log = (buttonClick: string) => {
logEvent('chooseEditor', { buttonClick });
};
const startTrial = (buttonClick = 'startTrial') => {
log(buttonClick);
logMermaidChartClick('editorPicker');
close();
window.open(getCheckoutUrl({ utmCampaign, utmMedium }), '_blank', 'noopener');
};
const dismiss = (buttonClick: string) => {
log(buttonClick);
close();
};
const openMermaidAiLive = (buttonClick: string) => {
log(buttonClick);
close();
window.open(getMermaidAiLiveUrl({ utmCampaign, utmMedium }), '_blank', 'noopener');
};
return { log, startTrial, dismiss, openMermaidAiLive };
};
+1 -1
View File
@@ -6,7 +6,7 @@
</script> </script>
<Sonner <Sonner
theme={mode.current} theme={$mode}
class="toaster group" class="toaster group"
toastOptions={{ toastOptions={{
classes: { classes: {
+1 -2
View File
@@ -1,5 +1,5 @@
import type { Loader, State } from '$lib/types'; import type { Loader, State } from '$lib/types';
import { defaultState, sanitizeConfig, updateCodeStore } from '$lib/util/state'; import { defaultState, updateCodeStore } from '$lib/util/state';
import { fetchText } from '$lib/util/util'; import { fetchText } from '$lib/util/util';
import { loadGistData } from './gist'; import { loadGistData } from './gist';
@@ -50,7 +50,6 @@ export const loadDataFromUrl = async (): Promise<void> => {
} }
} }
if (loaded) { if (loaded) {
state.mermaid = sanitizeConfig(state.mermaid || defaultState.mermaid);
updateCodeStore({ updateCodeStore({
...state, ...state,
updateDiagram: true updateDiagram: true
-5
View File
@@ -24,11 +24,6 @@ export const parse = async (code: string) => {
return await mermaid.parse(code); return await mermaid.parse(code);
}; };
/**
* @see https://mermaid.js.org/config/schema-docs/config.html
*/
export const defaultMermaidConfig = mermaid.mermaidAPI.defaultConfig ?? {};
export const standardizeDiagramType = (diagramType: string) => { export const standardizeDiagramType = (diagramType: string) => {
switch (diagramType) { switch (diagramType) {
case 'class': case 'class':
+1 -13
View File
@@ -3,7 +3,6 @@ import { env } from '$/util/env';
const mermaidAiDomain = 'mermaid.ai'; const mermaidAiDomain = 'mermaid.ai';
const mermaidLiveDomain = 'mermaid.live'; const mermaidLiveDomain = 'mermaid.live';
const netlifyPreviewDomain = 'netlify.app';
/** /**
* Check if we're on mermaid.ai * Check if we're on mermaid.ai
@@ -21,13 +20,6 @@ export const isOnMermaidLive = (): boolean => {
return domain === mermaidLiveDomain || domain.endsWith(`.${mermaidLiveDomain}`); return domain === mermaidLiveDomain || domain.endsWith(`.${mermaidLiveDomain}`);
}; };
/**
* Check if we're on a Netlify preview/staging deploy (*.netlify.app).
*/
const isOnNetlifyPreview = (): boolean => {
return window.location.hostname.endsWith(`.${netlifyPreviewDomain}`);
};
/** /**
* Check if the current URL has pako data (diagram content from a shared link). * Check if the current URL has pako data (diagram content from a shared link).
*/ */
@@ -63,15 +55,11 @@ const isReferredFromMermaid = (): boolean => {
* Check if the editor chooser modal should be shown. * Check if the editor chooser modal should be shown.
* Shows for new users who haven't dismissed it and aren't viewing a shared link. * Shows for new users who haven't dismissed it and aren't viewing a shared link.
* Not shown on mobile (viewport width < 640px). * Not shown on mobile (viewport width < 640px).
* Can be forced open for QA via the `?editorChooser=1` query flag, which bypasses
* the hostname and dismissed checks.
*/ */
export const shouldShowEditorChooser = (): boolean => { export const shouldShowEditorChooser = (): boolean => {
if (!env.isEnabledMermaidChartLinks) return false; if (!env.isEnabledMermaidChartLinks) return false;
if (!isOnMermaidAI() && !isOnMermaidLive()) return false;
if (window.innerWidth < 640) return false; if (window.innerWidth < 640) return false;
const forced = new URLSearchParams(window.location.search).get('editorChooser') === '1';
if (forced) return true;
if (!isOnMermaidAI() && !isOnMermaidLive() && !isOnNetlifyPreview()) return false;
if (window.localStorage.getItem(C.editorChooserDismissedKey) === 'true') return false; if (window.localStorage.getItem(C.editorChooserDismissedKey) === 'true') return false;
if (hasPakoData()) return false; if (hasPakoData()) return false;
if (isReferredFromMermaid()) return false; if (isReferredFromMermaid()) return false;
+4 -9
View File
@@ -60,13 +60,6 @@
onDestroy(() => { onDestroy(() => {
clearInterval(interval); clearInterval(interval);
}); });
const taglineHref = $derived(
`${MCBaseURL}${currentTagline.url.path}?${new URLSearchParams({
...commonParams,
...currentTagline.url.params
}).toString()}`
);
</script> </script>
<div <div
@@ -77,9 +70,11 @@
<div class="grid grow"> <div class="grid grow">
{#key currentTagline} {#key currentTagline}
<a <a
href={taglineHref} href="{MCBaseURL}{currentTagline.url.path}?{new URLSearchParams({
...commonParams,
...currentTagline.url.params
}).toString()}"
target="_blank" target="_blank"
rel="noopener noreferrer"
class="col-start-1 row-start-1 flex items-center justify-center gap-4 no-underline" class="col-start-1 row-start-1 flex items-center justify-center gap-4 no-underline"
in:fade={{ delay: 800 }} in:fade={{ delay: 800 }}
out:fade={{ duration: 1000 }}> out:fade={{ duration: 1000 }}>
+20 -83
View File
@@ -1,5 +1,5 @@
import type { ErrorHash, MarkerData, State, ValidatedState } from '$/types'; import type { ErrorHash, MarkerData, State, ValidatedState } from '$/types';
import { debounce, get as lodashGet } from 'lodash-es'; import { debounce } from 'lodash-es';
import type { MermaidConfig } from 'mermaid'; import type { MermaidConfig } from 'mermaid';
import { derived, get, writable, type Readable } from 'svelte/store'; import { derived, get, writable, type Readable } from 'svelte/store';
import { env } from './env'; import { env } from './env';
@@ -8,7 +8,7 @@ import {
findMostRelevantLineNumber, findMostRelevantLineNumber,
replaceLineNumberInErrorMessage replaceLineNumberInErrorMessage
} from './errorHandling'; } from './errorHandling';
import { defaultMermaidConfig, parse } from './mermaid'; import { parse } from './mermaid';
import { localStorage, persist } from './persist'; import { localStorage, persist } from './persist';
import { deserializeState, pakoSerde, serializeState } from './serde'; import { deserializeState, pakoSerde, serializeState } from './serde';
import { errorDebug, formatJSON, getUTMSource, MCBaseURL } from './util'; import { errorDebug, formatJSON, getUTMSource, MCBaseURL } from './util';
@@ -141,8 +141,8 @@ export const urlsStore = derived([stateStore], ([{ code, serialized }]) => {
campaign campaign
}: { }: {
medium: medium:
| 'ai_edit'
| 'ai_repair' | 'ai_repair'
| 'ai_edit'
| 'main_menu' | 'main_menu'
| 'save_diagram' | 'save_diagram'
| 'share' | 'share'
@@ -171,91 +171,28 @@ export const urlsStore = derived([stateStore], ([{ code, serialized }]) => {
}; };
}); });
/**
* Gets a list of paths that contain unsafe keys which might pose security risks.
*
* @param object - The object to check for unsafe keys.
* @param unsafeKeys - List of unsafe keys.
* @param path - The current path being checked (used for recursion).
* @returns List of unsafe paths.
*/
function getUnsafePaths(object: object, unsafeKeys: string[], path: string[] = []) {
const unsafePaths = new Array<string[]>();
for (const key of unsafeKeys) {
// Copied from mermaid's sanitize function in case there's non-enumerable keys
if (Object.hasOwn(object, key)) {
unsafePaths.push([...path, key]);
continue;
}
}
Object.keys(object).forEach((key) => {
const value = object[key] as unknown;
const currentPath = [...path, key];
// Prototype pollution check.
if (key.startsWith('__')) {
unsafePaths.push(currentPath);
return;
}
if (typeof value === 'object' && value !== null) {
unsafePaths.push(...getUnsafePaths(value as object, unsafeKeys, currentPath));
} else if (
typeof value === 'string' &&
// XSS prevention checks -- See mermaid `sanitize` function for reference.
(value.includes('<') || value.includes('>') || value.includes('url(data:'))
) {
unsafePaths.push(currentPath);
}
});
return unsafePaths;
}
/**
* Asks the user for confirmation if the config contains settings that might
* pose security risks, such as a relaxed `securityLevel`.
*
* @param config - The Mermaid configuration to sanitize.
* @returns The sanitized Mermaid configuration as a JSON string.
*/
export const sanitizeConfig = (config: string | MermaidConfig) => {
const mermaidConfig: MermaidConfig =
typeof config === 'string' ? (JSON.parse(config) as MermaidConfig) : config;
const secureKeys = defaultMermaidConfig.secure ?? [];
const unsafePaths = getUnsafePaths(mermaidConfig, secureKeys).filter((path) => {
return lodashGet(mermaidConfig, path) !== lodashGet(defaultMermaidConfig, path);
});
if (
unsafePaths.length > 0 &&
confirm(
`Removing ${unsafePaths
.map((unsafePath) => {
return `${JSON.stringify(unsafePath.join('.'))}: ${JSON.stringify(lodashGet(mermaidConfig, unsafePath))}`;
})
.join(
',\n'
)} from the config for safety.\nClick Cancel if you trust the source of this Diagram.`
)
) {
for (const unsafePath of unsafePaths) {
const pathToObject = [...unsafePath];
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- We know this exists since it was found in `getUnsafePaths`
const lastKey = pathToObject.pop()!;
const lastObject =
pathToObject.length === 0 ? mermaidConfig : lodashGet(mermaidConfig, pathToObject);
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- Copied from mermaid code
delete lastObject[lastKey];
}
}
return formatJSON(mermaidConfig);
};
export const loadState = (data: string): void => { export const loadState = (data: string): void => {
let state: State; let state: State;
console.log(`Loading '${data}'`); console.log(`Loading '${data}'`);
try { try {
state = deserializeState(data); state = deserializeState(data);
state.mermaid = sanitizeConfig(state.mermaid || defaultState.mermaid); if (!state.mermaid) {
state.mermaid = defaultState.mermaid;
}
const mermaidConfig: MermaidConfig =
typeof state.mermaid === 'string'
? (JSON.parse(state.mermaid) as MermaidConfig)
: state.mermaid;
if (
mermaidConfig.securityLevel &&
mermaidConfig.securityLevel !== 'strict' &&
confirm(
`Removing "securityLevel":"${mermaidConfig.securityLevel}" from the config for safety.\nClick Cancel if you trust the source of this Diagram.`
)
) {
delete mermaidConfig.securityLevel; // Prevent setting overriding securityLevel when loading state to mitigate possible XSS attack
}
state.mermaid = formatJSON(mermaidConfig);
} catch (error) { } catch (error) {
state = get(inputStateStore); state = get(inputStateStore);
if (data) { if (data) {
-3
View File
@@ -113,9 +113,6 @@ export const logEvent = (
name: AnalyticsEvent, name: AnalyticsEvent,
data?: Record<string, string | number | boolean> data?: Record<string, string | number | boolean>
): void => { ): void => {
if (browser && window.location.hostname === 'localhost') {
console.log('[plausible]', name, data);
}
if (!plausible) { if (!plausible) {
return; return;
} }
+5 -12
View File
@@ -42,30 +42,23 @@ export const MCBaseURL = env.isEnabledMermaidChartLinks
? 'https://mermaid.ai' // 'http://localhost:5174' ? 'https://mermaid.ai' // 'http://localhost:5174'
: 'https://example.com'; : 'https://example.com';
const buildUtmParams = ({ export const getCheckoutUrl = ({
utmCampaign, utmCampaign,
utmMedium utmMedium
}: { }: {
utmCampaign: string; utmCampaign: string;
utmMedium: string; utmMedium: string;
}): URLSearchParams => }): string => {
new URLSearchParams({ const params = new URLSearchParams({
coupon: 'arDfyFT8',
tier: 'plus',
utm_campaign: utmCampaign, utm_campaign: utmCampaign,
utm_medium: utmMedium, utm_medium: utmMedium,
utm_source: getUTMSource() utm_source: getUTMSource()
}); });
export const getCheckoutUrl = (utm: { utmCampaign: string; utmMedium: string }): string => {
const params = buildUtmParams(utm);
params.set('coupon', 'arDfyFT8');
params.set('tier', 'plus');
return `${MCBaseURL}/app/user/billing/checkout?${params.toString()}`; return `${MCBaseURL}/app/user/billing/checkout?${params.toString()}`;
}; };
export const getMermaidAiLiveUrl = (utm: { utmCampaign: string; utmMedium: string }): string => {
return `${MCBaseURL}/live?${buildUtmParams(utm).toString()}`;
};
let count = 0; let count = 0;
export const errorDebug = (limit = 1000) => { export const errorDebug = (limit = 1000) => {
count += 1; count += 1;
+2 -3
View File
@@ -1,13 +1,12 @@
<script> <script>
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
// Only redirect if it's a 404 error // Only redirect if it's a 404 error
onMount(() => { onMount(() => {
if ($page.status === 404) { if ($page.status === 404) {
goto(resolve('/')); goto('/');
} }
}); });
</script> </script>
@@ -16,6 +15,6 @@
<div class="container mx-auto p-8"> <div class="container mx-auto p-8">
<h1 class="mb-4 text-2xl font-bold">Error {$page.status}</h1> <h1 class="mb-4 text-2xl font-bold">Error {$page.status}</h1>
<p class="mb-4">{$page.error?.message || 'An unexpected error occurred'}</p> <p class="mb-4">{$page.error?.message || 'An unexpected error occurred'}</p>
<a href={resolve('/')} class="text-blue-500 hover:underline">Return to Home</a> <a href="/" class="text-blue-500 hover:underline">Return to Home</a>
</div> </div>
{/if} {/if}
+1 -1
View File
@@ -34,7 +34,7 @@
}); });
$effect(() => { $effect(() => {
toggleDarkTheme(mode.current === 'dark'); toggleDarkTheme($mode === 'dark');
}); });
</script> </script>
-40
View File
@@ -1,5 +1,3 @@
import type { State } from '$/types';
import assert from 'node:assert';
import { expect, test } from './test'; import { expect, test } from './test';
test.describe('Site Loads', () => { test.describe('Site Loads', () => {
@@ -69,44 +67,6 @@ test.describe('Site Loads', () => {
}); });
}); });
test('should prompt user to scrub unsafe config', async ({ editPage, page }) => {
let dialogAccepted = false;
page.on('dialog', async (dialog) => {
expect(dialog.type()).toBe('confirm');
expect(dialog.message()).toContain('from the config for safety');
await dialog.accept();
dialogAccepted = true;
});
await editPage.start(
`/edit?${new URLSearchParams({
code: `data:application/vnd.mermaid,${encodeURIComponent('flowchart TD\nHello-->World')}`,
config: `data:application/json,${encodeURIComponent(
JSON.stringify({
someOtherSetting: 'Test value',
securityLevel: 'loose',
secure: [],
themeVariables: {
nodeBorder: '</style></svg><script>alert("XSS")</script>'
}
})
)}`
}).toString()}`
);
await editPage.checkTextInView('Hello');
await expect.poll(() => dialogAccepted).toBeTruthy();
const codeStore = await page.evaluate(() => localStorage.getItem('codeStore'));
assert(codeStore);
const parsedStore = JSON.parse(codeStore) as State;
const parsedConfig = JSON.parse(parsedStore.mermaid) as Record<string, unknown>;
expect(parsedConfig).toEqual({
someOtherSetting: 'Test value',
themeVariables: {}
});
// should scrub unsafe securityLevel but keep other settings
expect(parsedConfig.securityLevel).toBeUndefined();
expect(parsedConfig.secure).toBeUndefined();
});
test('should show troubleshooting steps if loading fails', async ({ editPage, page }) => { test('should show troubleshooting steps if loading fails', async ({ editPage, page }) => {
await editPage.start('/#/edit/eyJjb2RlIjoiZ3JhcGggVERcbiAg'); await editPage.start('/#/edit/eyJjb2RlIjoiZ3JhcGggVERcbiAg');
await page.reload({ waitUntil: 'networkidle' }); await page.reload({ waitUntil: 'networkidle' });