Compare commits

..
Author SHA1 Message Date
Claude 46fabde1b6 chore: update all dependencies to latest versions
- Updated all devDependencies and dependencies to their latest versions
- Added @internationalized/date to fix bits-ui peer dependency (3.7.0 → 3.11.0)
- Upgraded eslint-plugin-tailwindcss to 4.0.0-beta.0 to support tailwindcss v4
- Removed deprecated @types/uuid (uuid v13 provides its own type definitions)
- Notable upgrades: svelte 5.38→5.53, vite 7.1→7.3, vitest 3.2→4.0,
  monaco-editor 0.52→0.55, mermaid 11.12.0→11.12.3, eslint 9.39→10.0,
  @sveltejs/kit 2.37→2.53, typescript 5.9.2→5.9.3, husky 8→9

https://claude.ai/code/session_01X2VtT8Hz1NziUNhSzSQ8r3
2026-03-03 22:19:21 +00:00
44 changed files with 2558 additions and 2809 deletions
-1
View File
@@ -1,5 +1,4 @@
MERMAID_DOMAIN=''
MERMAID_BASE_PATH=''
MERMAID_DOCS_URL='https://mermaid.js.org'
MERMAID_ANALYTICS_URL=''
MERMAID_RENDERER_URL='https://mermaid.ink'
@@ -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'
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.60.0-jammy
image: mcr.microsoft.com/playwright:v1.52.0-jammy
steps:
- name: Checkout
+1 -1
View File
@@ -1 +1 @@
24.16.0
22.15.0
-3
View File
@@ -12,14 +12,11 @@
"fsegurai",
"gantt",
"gitgraph",
"hugeicons",
"KROKI",
"localstorage",
"mermaidchart",
"mindmap",
"NEWYEAR",
"noopener",
"noreferrer",
"Pageview",
"pako",
"panmove",
+1 -4
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 && \
rm -rf /var/cache/apk/*
@@ -19,9 +19,6 @@ ARG MERMAID_KROKI_RENDERER_URL
ARG MERMAID_ANALYTICS_URL
ARG MERMAID_DOMAIN
ARG MERMAID_IS_ENABLED_MERMAID_CHART_LINKS
ARG MERMAID_PRIVACY_POLICY_URL
ARG MERMAID_HIDE_PRIVACY_POLICY
ARG MERMAID_BASE_PATH
COPY . ./
-2
View File
@@ -28,8 +28,6 @@ If you want to speed up the progress for mermaid-live-editor, join the Discord c
docker run --platform linux/amd64 --publish 8000:8080 ghcr.io/mermaid-js/mermaid-live-editor
```
The published docker image is built using our default environment variables. You cannot override them when running the image. If you need to customize them, you will need to build the image yourself.
### To configure renderer URL
When building set the MERMAID_RENDERER_URL build argument to the rendering
+53 -55
View File
@@ -23,96 +23,94 @@
"test:e2e:debug": "playwright test --debug"
},
"devDependencies": {
"@eslint/compat": "^2.1.0",
"@eslint/eslintrc": "^3.3.5",
"@eslint/compat": "^2.0.2",
"@eslint/eslintrc": "^3.3.4",
"@eslint/js": "^10.0.1",
"@fortawesome/fontawesome-free": "^7.2.0",
"@iconify-json/hugeicons": "^1.2.29",
"@iconify-json/logos": "^1.2.11",
"@iconify-json/material-symbols": "^1.2.76",
"@iconify-json/material-symbols": "^1.2.59",
"@iconify-json/mdi": "^1.2.3",
"@playwright/test": "^1.60.0",
"@playwright/test": "^1.58.2",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.63.1",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tailwindcss/typography": "^0.5.20",
"@tailwindcss/vite": "^4.3.0",
"@sveltejs/kit": "^2.53.4",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.2.1",
"@types/hammerjs": "^2.0.46",
"@types/lodash-es": "^4.17.12",
"@types/node": "^24.12.4",
"@types/node": "^25.3.3",
"@types/pako": "2.0.4",
"@vitest/coverage-v8": "^4.1.8",
"@vitest/ui": "^4.1.8",
"autoprefixer": "^10.5.0",
"bits-ui": "^2.18.1",
"@vitest/coverage-v8": "^4.0.18",
"@vitest/ui": "^4.0.18",
"autoprefixer": "^10.4.27",
"bits-ui": "^2.16.2",
"c8": "11.0.0",
"chai": "^6.2.2",
"clsx": "^2.1.1",
"cssnano": "^8.0.1",
"dotenv": "^17.4.2",
"eslint": "^10.4.1",
"cssnano": "^7.1.2",
"eslint": "^10.0.2",
"eslint-config-prettier": "^10.1.8",
"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-svelte": "^3.19.0",
"eslint-plugin-tailwindcss": "^3.18.3",
"eslint-plugin-unicorn": "^65.0.0",
"eslint-plugin-svelte": "^3.15.0",
"eslint-plugin-tailwindcss": "4.0.0-beta.0",
"eslint-plugin-unicorn": "^63.0.0",
"esserializer": "^1.3.11",
"globals": "^17.6.0",
"globals": "^17.4.0",
"husky": "^9.1.7",
"jsdom": "^29.1.1",
"lint-staged": "^17.0.7",
"lucide-svelte": "^1.0.1",
"jsdom": "^28.1.0",
"lint-staged": "^16.3.2",
"lucide-svelte": "^0.576.0",
"node-html-parser": "^7.1.0",
"paneforge": "1.0.2",
"prettier": "^3.8.3",
"prettier-plugin-svelte": "^4.1.0",
"prettier-plugin-tailwindcss": "^0.8.0",
"svelte": "^5.56.3",
"svelte-preprocess": "^6.0.5",
"svelte-sonner": "^1.1.1",
"tailwind-merge": "^3.6.0",
"prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.5.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"svelte": "^5.53.7",
"svelte-preprocess": "^6.0.3",
"svelte-sonner": "^1.0.8",
"tailwind-merge": "^3.5.0",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.3.0",
"tailwindcss": "^4.2.1",
"tslib": "^2.8.1",
"tw-animate-css": "^1.4.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.60.1",
"typescript": "^5.9.3",
"typescript-eslint": "^8.56.1",
"unplugin-icons": "^23.0.1",
"vite": "^8.0.16",
"vite": "^7.3.1",
"vite-plugin-devtools-json": "^1.0.0",
"vitest": "^4.1.8",
"vitest": "^4.0.18",
"vitest-dom": "^0.1.1"
},
"dependencies": {
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-yaml": "^6.1.3",
"@codemirror/language": "^6.12.3",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.43.0",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.12.2",
"@codemirror/state": "^6.5.4",
"@codemirror/view": "^6.39.16",
"@fontsource-variable/recursive": "^5.2.8",
"@fsegurai/codemirror-theme-vscode-dark": "^6.2.6",
"@fsegurai/codemirror-theme-vscode-light": "^6.2.6",
"@mermaid-js/examples": "^1.2.0",
"@mermaid-js/layout-elk": "^0.2.1",
"@mermaid-js/layout-tidy-tree": "^0.2.2",
"@mermaid-js/mermaid-zenuml": "^0.2.3",
"@fsegurai/codemirror-theme-vscode-dark": "^6.2.4",
"@fsegurai/codemirror-theme-vscode-light": "^6.2.4",
"@internationalized/date": "^3.11.0",
"@mermaid-js/examples": "^1.0.0",
"@mermaid-js/layout-elk": "^0.2.0",
"@mermaid-js/layout-tidy-tree": "^0.2.1",
"@mermaid-js/mermaid-zenuml": "^0.2.2",
"codemirror": "^6.0.2",
"dayjs": "^1.11.21",
"dayjs": "^1.11.19",
"hammerjs": "^2.0.8",
"js-base64": "3.7.8",
"lodash-es": "^4.18.1",
"mermaid": "^11.15.0",
"lodash-es": "^4.17.23",
"mermaid": "^11.12.3",
"mode-watcher": "^1.1.0",
"monaco-editor": "0.55.1",
"pako": "2.1.0",
"plausible-tracker": "^0.3.9",
"random-word-slugs": "0.1.7",
"svg-pan-zoom": "3.6.2",
"svg2roughjs": "^3.2.3",
"uuid": "14.0.0"
"svg2roughjs": "^3.2.2",
"uuid": "13.0.0"
},
"lint-staged": {
"*.{ts,svelte,js,css,md,json}": [
@@ -121,9 +119,9 @@
]
},
"engines": {
"node": ">=24.16.0"
"node": ">=20.19.0"
},
"packageManager": "pnpm@10.34.1+sha512.b58fbde6dca66a929538021581f648b4570b6ca19b18e7cbd7f2c07a7b24454155388dacdf08f2af3678e88a6d1fe04f9d609df24bf51735a060ea041b374ab7",
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39",
"pnpm": {
"onlyBuiltDependencies": [
"deasync",
+2250 -1989
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -5,11 +5,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Online FlowChart &amp; Diagrams Editor - Mermaid Live Editor</title>
<meta name="og:image" content="%sveltekit.assets%/favicon.svg" />
<link rel="canonical" href="https://mermaid.ai/live" />
<meta
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." />
<link rel="icon" type="image/svg+xml" href="%sveltekit.assets%/favicon.svg" />
<link rel="apple-touch-icon" href="%sveltekit.assets%/favicon.png" />
<link rel="icon" type="image/png" href="%sveltekit.assets%/favicon.svg" />
<link rel="mask-icon" href="%sveltekit.assets%/favicon.svg" color="#000000" />
<meta name="theme-color" content="#ff3670" />
<link rel="manifest" href="%sveltekit.assets%/manifest.json" />
-2
View File
@@ -7,8 +7,6 @@ interface ImportMetaEnv {
readonly MERMAID_DOCS_URL?: string;
readonly MERMAID_DOMAIN?: string;
readonly MERMAID_IS_ENABLED_MERMAID_CHART_LINKS?: string;
readonly MERMAID_PRIVACY_POLICY_URL?: string;
readonly MERMAID_HIDE_PRIVACY_POLICY?: string;
// more env variables...
}
+12 -34
View File
@@ -27,7 +27,6 @@
lineNumbersMinChars: 4
} satisfies monaco.editor.IStandaloneEditorConstructionOptions;
let currentText = '';
let isUpdatingFromState = false;
let showPopup = $state(false);
let popupPosition = $state({ top: 0, lineNumber: 0 });
let decorationsCollection: monaco.editor.IEditorDecorationsCollection | undefined;
@@ -35,16 +34,6 @@
let lastMouseLine = 0;
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(
'',
'json',
@@ -143,7 +132,7 @@
editor.onDidChangeModelContent(({ isFlush }) => {
const newText = editor?.getValue();
if (!newText || currentText === newText || isFlush || isUpdatingFromState) {
if (!newText || currentText === newText || isFlush) {
return;
}
currentText = newText;
@@ -170,21 +159,9 @@
// Update editor text if it's different
const newText = editorMode === 'code' ? code : mermaid;
if (newText !== currentText) {
isUpdatingFromState = true;
try {
editor.setScrollTop(0);
editor.pushUndoStop();
editor.executeEdits('updateCode', [
{
range: model.getFullModelRange(),
text: newText
}
]);
editor.pushUndoStop();
currentText = newText;
} finally {
isUpdatingFromState = false;
}
editor.setScrollTop(0);
editor.setValue(newText);
currentText = newText;
renderAIPromptGutterGlyphIcon();
}
@@ -206,8 +183,12 @@
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) => {
editor?.layout({
height: entries[0].contentRect.height,
@@ -223,6 +204,7 @@
return () => {
unsubscribeState();
unsubscribeMode();
resizeObserver.disconnect();
jsonModel.dispose();
mermaidModel.dispose();
@@ -242,11 +224,7 @@
onClose={closePopup}
onTryFree={() => {
logMermaidChartClick('vibeDiagramming');
window.open(
$urlsStore.mermaidChart({ medium: 'vibe_diagramming' }).save,
'_blank',
'noopener'
);
window.open($urlsStore.mermaidChart({ medium: 'vibe_diagramming' }).save, '_blank');
closePopup();
}} />
</div>
+1 -1
View File
@@ -24,7 +24,7 @@
const showErrorDebounced = debounce(() => {
showError = true;
}, 3000);
}, 5000);
$effect(() => {
if ($stateStore.error) {
@@ -1,119 +0,0 @@
<script lang="ts">
import McWrapper from '$/components/McWrapper.svelte';
import MermaidChartIcon from '$/components/MermaidChartIcon.svelte';
import { Button } from '$/components/ui/button';
import { standardizeDiagramType } from '$/util/mermaid';
import { stateStore, urlsStore } from '$/util/state';
import { logMermaidChartClick } from '$/util/stats';
import { quintInOut } from 'svelte/easing';
import { slide } from 'svelte/transition';
const visualEditDiagramTypes = new Set([
'flowchart',
'stateDiagram',
'classDiagram',
'sequenceDiagram',
'er',
'requirement',
'mindmap'
]);
const diagramType = $derived.by(() => {
const dt = $stateStore.diagramType;
return dt ? standardizeDiagramType(dt) : undefined;
});
const showVisualEdit = $derived.by(() => {
return diagramType ? visualEditDiagramTypes.has(diagramType) : false;
});
interface EnhancedEditAction {
campaign: string;
label: string;
medium: 'ai_edit' | 'visual_edit' | 'voice_edit';
source: string;
}
const cycleIntervalMs = 30_000;
let currentActionIndex = $state(0);
const availableActions = $derived.by<EnhancedEditAction[]>(() => {
if (!$stateStore.diagramType) {
return [];
}
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' }
];
if (showVisualEdit) {
actions.unshift({
campaign: 'visual_1',
label: 'visually',
medium: 'visual_edit',
source: 'visualEdit'
});
}
return actions;
});
const currentAction = $derived.by(() => {
const actions = availableActions;
if (actions.length === 0) {
return undefined;
}
return actions[currentActionIndex % actions.length];
});
$effect(() => {
const actionCount = availableActions.length;
if (actionCount === 0) {
currentActionIndex = 0;
return;
}
if (currentActionIndex >= actionCount) {
currentActionIndex = 0;
}
if (actionCount <= 1) {
return;
}
const intervalID = setInterval(() => {
currentActionIndex = (currentActionIndex + 1) % actionCount;
}, cycleIntervalMs);
return () => clearInterval(intervalID);
});
</script>
{#if currentAction}
<McWrapper>
<Button
variant="secondary"
size="sm"
href={$urlsStore.mermaidChart({
medium: currentAction.medium,
campaign: currentAction.campaign
}).save}
target="_blank"
onclick={() => logMermaidChartClick(currentAction.source)}>
<MermaidChartIcon />
Edit
{#key currentAction.label}
<span
class="-ml-1"
in:slide={{ axis: 'x', easing: quintInOut, delay: 400 }}
out:slide={{ axis: 'x', easing: quintInOut }}>
{currentAction.label}
</span>
{/key}
</Button>
</McWrapper>
{/if}
+1 -1
View File
@@ -126,7 +126,7 @@
Dark Mode
</span>
<Switch
checked={mode.current === 'dark'}
checked={$mode === 'dark'}
onCheckedChange={(dark) => setMode(dark ? 'dark' : 'light')} />
</div>
{/snippet}
+1 -2
View File
@@ -1,8 +1,7 @@
<script lang="ts">
import { asset } from '$app/paths';
import type { ClassValue } from 'svelte/elements';
let { class: className }: { class?: ClassValue } = $props();
</script>
<img class={['size-4', className]} src={asset('/mermaidchart-logo.svg')} alt="Mermaid Chart" />
<img class={['size-4', className]} src="/mermaidchart-logo.svg" alt="Mermaid Chart" />
+8 -7
View File
@@ -16,17 +16,11 @@
let editorView: EditorView | undefined;
let editorContainer: HTMLDivElement;
let currentText = $state('');
const themeCompartment = new Compartment();
const { onUpdate }: EditorProps = $props();
$effect(() => {
editorView?.dispatch({
effects: themeCompartment.reconfigure(mode.current === 'dark' ? vsCodeDark : vsCodeLight)
});
});
onMount(() => {
const themeCompartment = new Compartment();
const languageCompartment = new Compartment();
editorView = new EditorView({
@@ -62,6 +56,12 @@
parent: editorContainer
});
const unsubscribeMode = mode.subscribe((mode) => {
editorView?.dispatch({
effects: themeCompartment.reconfigure(mode === 'dark' ? vsCodeDark : vsCodeLight)
});
});
const unsubscribeState = stateStore.subscribe(({ editorMode, code, mermaid }) => {
const text = editorMode === 'code' ? code : mermaid;
if (currentText === text || !editorView) {
@@ -89,6 +89,7 @@
});
return () => {
unsubscribeMode();
unsubscribeState();
editorView?.destroy();
};
+1 -2
View File
@@ -8,7 +8,6 @@
</script>
<script lang="ts">
import { resolve } from '$app/paths';
import MainMenu from '$/components/MainMenu.svelte';
import { Button } from '$/components/ui/button';
import { Separator } from '$/components/ui/separator';
@@ -84,7 +83,7 @@
<div class="flex flex-1 items-center gap-2">
<MainMenu />
<MermaidIcon class="size-6" />
<a href={resolve('/')} class="whitespace-nowrap text-accent">
<a href="/" class="whitespace-nowrap text-accent">
{#if !mobileToggle}
Mermaid
{/if}
+37 -58
View File
@@ -1,66 +1,45 @@
<script>
import ExternalLinkWrapper from '$/components/ExternalLinkWrapper.svelte';
import * as Dialog from '$/components/ui/dialog';
import { env } from '$/util/env';
import { isOnMermaidLive } from '$/util/migration/domainMigration';
import ShieldIcon from '~icons/material-symbols/shield-lock-outline-rounded';
</script>
{#if env.privacyPolicyUrl}
<a href={env.privacyPolicyUrl} target="_blank">
<Dialog.Root>
<Dialog.Trigger>
<ShieldIcon />
</a>
{:else}
<Dialog.Root>
<Dialog.Trigger>
<ShieldIcon />
</Dialog.Trigger>
<Dialog.Content class="max-h-full overflow-hidden overflow-y-auto p-12">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2 text-xl">
<ShieldIcon class="size-8 text-green-700" />
Data security
</Dialog.Title>
</Dialog.Header>
</Dialog.Trigger>
<Dialog.Content class="max-h-full overflow-hidden overflow-y-auto p-12">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2 text-xl">
<ShieldIcon class="size-8 text-green-700" />
Data security
</Dialog.Title>
</Dialog.Header>
{#if isOnMermaidLive()}
<p class="text-xl font-semibold">Your diagrams never leave your browser.</p>
<p>They're only stored in the URL and your browser's local storage.</p>
<p>
This is a fully open source, client-side app deployed on <a
href="https://github.com/mermaid-js/mermaid-live-editor/deployments"
class="underline"
target="_blank">GitHub Pages</a>
that works offline as a
<a href="https://web.dev/explore/progressive-web-apps" target="_blank"
>Progressive Web App</a
>.
</p>
<p>
We use self hosted, privacy-friendly Plausible Analytics to collect anonymous usage
metadata (diagram types, feature usage, etc.). All data is <a
href="https://p.mermaid.live/mermaid.live"
class="underline"
target="_blank">publicly available</a
>.
</p>
<ExternalLinkWrapper domain="example.com" isVisible>
<p class="text-left">
External services (PNG/SVG/Kroki exports, "Save to Mermaid Chart", "Repair with AI",
etc) will share your diagram with those 3rd parties, and are highlighted in the UI on
hover.
</p>
</ExternalLinkWrapper>
{:else}
<p>No privacy policy has been configured for this deployment.</p>
<p>
If you are self-hosting the Mermaid Live Editor, set the
<code class="rounded bg-muted px-1.5 py-0.5 text-sm">MERMAID_PRIVACY_POLICY_URL</code>
environment variable at build time to link to your privacy policy, or set
<code class="rounded bg-muted px-1.5 py-0.5 text-sm">MERMAID_HIDE_PRIVACY_POLICY</code>
to <code class="rounded bg-muted px-1.5 py-0.5 text-sm">true</code> to hide this button.
</p>
{/if}
</Dialog.Content>
</Dialog.Root>
{/if}
<p class="text-xl font-semibold">Your diagrams never leave your browser.</p>
<p>They're only stored in the URL and your browser's local storage.</p>
<p>
This is a fully open source, client-side app deployed on <a
href="https://github.com/mermaid-js/mermaid-live-editor/deployments"
class="underline"
target="_blank">GitHub Pages</a>
that works offline as a
<a href="https://web.dev/explore/progressive-web-apps" target="_blank">Progressive Web App</a
>.
</p>
<p>
We use self hosted, privacy-friendly Plausible Analytics to collect anonymous usage metadata
(diagram types, feature usage, etc.). All data is <a
href="https://p.mermaid.live/mermaid.live"
class="underline"
target="_blank">publicly available</a
>.
</p>
<ExternalLinkWrapper domain="example.com" isVisible>
<p class="text-left">
External services (PNG/SVG/Kroki exports, "Save to Mermaid Chart", "Repair with AI", etc)
will share your diagram with those 3rd parties, and are highlighted in the UI on hover.
</p>
</ExternalLinkWrapper>
</Dialog.Content>
</Dialog.Root>
+1 -2
View File
@@ -4,7 +4,6 @@
import { Separator } from '$/components/ui/separator';
import { env } from '$/util/env';
import { urlsStore } from '$/util/state';
import { asset } from '$app/paths';
import ShareIcon from '~icons/material-symbols/share';
import CopyInput from './CopyInput.svelte';
import MermaidChartIcon from './MermaidChartIcon.svelte';
@@ -23,7 +22,7 @@
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<h2 class="flex items-center gap-2">
<img class="size-5" src={asset('/favicon.svg')} alt="Mermaid Live Editor" />
<img class="size-5" src="/favicon.svg" alt="Mermaid Live Editor" />
Mermaid Live Editor
</h2>
<CopyInput value={window.location.href} />
+2 -2
View File
@@ -24,12 +24,12 @@
</script>
<div class="inline-grid">
{#key mode.current}
{#key $mode}
<div
in:spin={{ clockWise: true }}
out:spin={{ clockWise: false }}
class="col-start-1 row-start-1">
{#if mode.current === 'dark'}
{#if $mode === 'dark'}
<MoonIcon />
{:else}
<SunIcon />
@@ -4,7 +4,6 @@
import { Button } from '$/components/ui/button';
import { Separator } from '$/components/ui/separator';
import { TID } from '$/constants';
import { env } from '$/util/env';
import { version } from 'mermaid/package.json';
import { mode, setMode } from 'mode-watcher';
import ThemeIcon from './ThemeIcon.svelte';
@@ -12,20 +11,18 @@
<FloatingToolbar>
<span class="text-sm font-semibold opacity-60">v{version}</span>
{#if !env.hidePrivacyPolicy}
<Button variant="ghost" size="icon" title="Privacy & Security">
<Privacy />
</Button>
<Button variant="ghost" size="icon" title="Privacy & Security">
<Privacy />
</Button>
<Separator orientation="vertical" />
{/if}
<Separator orientation="vertical" />
<Button
variant="ghost"
size="icon"
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"
onclick={() => setMode(mode.current === 'dark' ? 'light' : 'dark')}>
onclick={() => setMode($mode === 'dark' ? 'light' : 'dark')}>
<ThemeIcon />
</Button>
</FloatingToolbar>
+3 -2
View File
@@ -4,7 +4,7 @@
import { render as renderDiagram } from '$/util/mermaid';
import { PanZoomState } from '$/util/panZoom';
import { inputStateStore, stateStore, updateCodeStore } from '$/util/state';
import { saveStatistics } from '$/util/stats';
import { logEvent, saveStatistics } from '$/util/stats';
import FontAwesome, { mayContainFontAwesome } from '$lib/components/FontAwesome.svelte';
import uniqueID from 'lodash-es/uniqueId';
import type { MermaidConfig } from 'mermaid';
@@ -30,6 +30,7 @@
const setupPanZoomObserver = () => {
panZoomState.onPanZoomChange = (pan, zoom) => {
updateCodeStore({ pan, zoom });
logEvent('panZoom');
};
};
@@ -153,7 +154,7 @@
<div
id="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>
@@ -1,101 +1,135 @@
<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 * as Dialog from '$/components/ui/dialog';
import { dismissEditorChooser } from '$/util/migration/domainMigration';
import type { Component } from 'svelte';
import AtlassianIcon from '~icons/logos/atlassian';
import AmazonIcon from '~icons/logos/aws';
import GoogleIcon from '~icons/logos/google';
import MicrosoftIcon from '~icons/logos/microsoft';
import { createEditorChooserActions } from './editorChooserActions';
import { logEvent, logMermaidChartClick } from '$/util/stats';
import { getCheckoutUrl } from '$/util/util';
import CodeIcon from '~icons/custom/code';
import OpenSourceIcon from '~icons/material-symbols/book-2-outline-rounded';
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 {
open: boolean;
}
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).
let handled = false;
const actions = createEditorChooserActions(() => {
handled = true;
const close = () => {
dismissEditorChooser();
open = false;
});
};
const features = [
{ title: 'AI diagram generation', description: 'Describe what you need, AI builds it' },
{
title: 'Visual drag-and-drop editor',
description: 'Edit diagrams without writing code'
},
{
title: 'Unlimited diagram storage',
description: 'Save and organize all your diagrams'
},
{
title: 'Team collaboration',
description: 'Share, comment, and edit together in real-time'
}
];
const handleStartTrial = () => {
logEvent('chooseEditor', { choice: 'plus' });
logMermaidChartClick('editorPicker');
close();
window.open(
getCheckoutUrl({ utmCampaign: 'start_plus', utmMedium: '2_editor_selection' }),
'_blank'
);
};
const trustedLogos: { name: string; icon: Component }[] = [
{ name: 'Google', icon: GoogleIcon },
{ name: 'Microsoft', icon: MicrosoftIcon },
{ name: 'Atlassian', icon: AtlassianIcon },
{ name: 'Amazon', icon: AmazonIcon }
];
const handleStartFree = () => {
logEvent('chooseEditor', { choice: 'openSource' });
close();
};
</script>
<Dialog.Root
bind:open
onOpenChange={(v) => {
if (v) return;
if (!handled) actions.log('dismissed');
handled = false;
dismissEditorChooser();
if (!v) handleStartFree();
}}>
<Dialog.Content class="flex max-w-lg flex-col gap-3 bg-background p-8">
<Dialog.Header class="flex-col items-start gap-2 space-y-0 text-left sm:text-left">
<MermaidChartIcon class="size-10" />
<Dialog.Title class="pt-2 text-2xl font-bold">Try the full Mermaid experience</Dialog.Title>
<Dialog.Description class="text-xs font-light text-muted-foreground">
Free forever, with Plus features free for 7 days.
<Dialog.Content class="max-w-2xl bg-pink-50 p-0 dark:bg-background">
<Dialog.Header class="px-8 pt-8 pb-0">
<Dialog.Title class="text-center text-2xl font-semibold">Choose your editor</Dialog.Title>
<Dialog.Description class="-mt-2 text-center text-sm font-light">
You'll never see this again
</Dialog.Description>
</Dialog.Header>
<ul class="mt-2 flex flex-col gap-3">
{#each features as feature (feature.title)}
<li class="flex items-center gap-3 rounded-lg border border-border p-3">
<span class="size-2 shrink-0 rounded-full bg-accent"></span>
<div class="flex flex-col gap-0.5">
<p class="text-xs">{feature.title}</p>
<p class="text-xs font-light text-muted-foreground">{feature.description}</p>
<div class="grid gap-4 px-6 pt-4 pb-8 sm:grid-cols-2">
<!-- Mermaid Plus Card -->
<div
class="relative flex flex-col overflow-hidden rounded-xl border-2 border-accent bg-white shadow dark:bg-card">
<div class="bg-accent px-6 py-2 text-center text-sm font-semibold text-accent-foreground">
Recommended
</div>
<div class="flex flex-col p-6">
<h3 class="text-xl font-bold">Mermaid Plus</h3>
<p class="mb-4 text-sm text-muted-foreground">Unlock AI, storage and collaboration</p>
<div class="mb-2 flex justify-center">
<span
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">
10% off with code JS26
</span>
</div>
</li>
{/each}
</ul>
<div class="mt-2 flex items-center gap-3">
<McWrapper labelPrefix="Opens ">
<Button variant="accent" onclick={() => actions.startTrial()}>Start free trial</Button>
</McWrapper>
<Button variant="outline" onclick={() => actions.dismiss('stayOnLive')}>
Stay on mermaid.live
</Button>
</div>
<Button variant="accent" class="mb-6 w-full" onclick={handleStartTrial}>
Start free trial
</Button>
<div class="mt-3 flex flex-col items-start gap-4">
<p class="text-xs">Trusted by 5M people and over 200k companies</p>
<div class="flex w-full items-center justify-between gap-2">
{#each trustedLogos as logo (logo.name)}
<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 rounded-xl border bg-white p-6 shadow dark:bg-card">
<h3 class="mt-10 text-xl font-bold">Open Source</h3>
<p class="mb-4 text-sm text-muted-foreground">Code only, no login, always free</p>
<Button variant="outline" class="mb-6 w-full border-accent" onclick={handleStartFree}>
Start free
</Button>
<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>
<PrivacyPolicyLink />
</Dialog.Content>
</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>
<Sonner
theme={mode.current}
theme={$mode}
class="toaster group"
toastOptions={{
classes: {
-2
View File
@@ -2,9 +2,7 @@ export const env = {
analyticsUrl: import.meta.env.MERMAID_ANALYTICS_URL ?? '',
docsUrl: import.meta.env.MERMAID_DOCS_URL ?? 'https://mermaid.js.org',
domain: import.meta.env.MERMAID_DOMAIN ?? '',
hidePrivacyPolicy: import.meta.env.MERMAID_HIDE_PRIVACY_POLICY === 'true',
isEnabledMermaidChartLinks: import.meta.env.MERMAID_IS_ENABLED_MERMAID_CHART_LINKS === 'true',
krokiRendererUrl: import.meta.env.MERMAID_KROKI_RENDERER_URL ?? '',
privacyPolicyUrl: import.meta.env.MERMAID_PRIVACY_POLICY_URL ?? '',
rendererUrl: import.meta.env.MERMAID_RENDERER_URL ?? ''
} as const;
+1 -2
View File
@@ -1,5 +1,5 @@
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 { loadGistData } from './gist';
@@ -50,7 +50,6 @@ export const loadDataFromUrl = async (): Promise<void> => {
}
}
if (loaded) {
state.mermaid = sanitizeConfig(state.mermaid || defaultState.mermaid);
updateCodeStore({
...state,
updateDiagram: true
-5
View File
@@ -24,11 +24,6 @@ export const parse = async (code: string) => {
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) => {
switch (diagramType) {
case 'class':
+18 -20
View File
@@ -2,8 +2,6 @@ import { C } from '$/constants';
import { env } from '$/util/env';
const mermaidAiDomain = 'mermaid.ai';
const mermaidLiveDomain = 'mermaid.live';
const netlifyPreviewDomain = 'netlify.app';
/**
* Check if we're on mermaid.ai
@@ -13,19 +11,25 @@ export const isOnMermaidAI = (): boolean => {
return domain === mermaidAiDomain || domain.endsWith(`.${mermaidAiDomain}`);
};
/**
* Check if we're on mermaid.live
*/
export const isOnMermaidLive = (): boolean => {
const domain = window.location.hostname;
return domain === mermaidLiveDomain || domain.endsWith(`.${mermaidLiveDomain}`);
};
// localStorage keys that indicate a returning user.
// Note: codeStore is excluded because it's always populated with the default state on first load.
const userDataStorageKeys = [
'manualHistoryStore', // Manual history entries
'autoHistoryStore' // Auto history entries
];
/**
* Check if we're on a Netlify preview/staging deploy (*.netlify.app).
* Check if user has any stored data in localStorage.
* This includes saved diagrams, history entries, etc.
*/
const isOnNetlifyPreview = (): boolean => {
return window.location.hostname.endsWith(`.${netlifyPreviewDomain}`);
const hasStoredUserData = (): boolean => {
for (const key of userDataStorageKeys) {
const value = window.localStorage.getItem(key);
if (value && value !== '[]' && value !== 'null' && value !== '{}') {
return true;
}
}
return false;
};
/**
@@ -50,9 +54,7 @@ const isReferredFromMermaid = (): boolean => {
hostname === 'mermaid.ai' ||
hostname.endsWith('.mermaid.ai') ||
hostname === 'mermaid.js.org' ||
hostname.endsWith('.mermaid.js.org') ||
hostname === 'mermaid.live' ||
hostname.endsWith('.mermaid.live')
hostname.endsWith('.mermaid.js.org')
);
} catch {
return false;
@@ -63,16 +65,12 @@ const isReferredFromMermaid = (): boolean => {
* 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.
* 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 => {
if (!env.isEnabledMermaidChartLinks) 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 (hasStoredUserData()) return false;
if (hasPakoData()) return false;
if (isReferredFromMermaid()) return false;
return true;
+4 -9
View File
@@ -60,13 +60,6 @@
onDestroy(() => {
clearInterval(interval);
});
const taglineHref = $derived(
`${MCBaseURL}${currentTagline.url.path}?${new URLSearchParams({
...commonParams,
...currentTagline.url.params
}).toString()}`
);
</script>
<div
@@ -77,9 +70,11 @@
<div class="grid grow">
{#key currentTagline}
<a
href={taglineHref}
href="{MCBaseURL}{currentTagline.url.path}?{new URLSearchParams({
...commonParams,
...currentTagline.url.params
}).toString()}"
target="_blank"
rel="noopener noreferrer"
class="col-start-1 row-start-1 flex items-center justify-center gap-4 no-underline"
in:fade={{ delay: 800 }}
out:fade={{ duration: 1000 }}>
-51
View File
@@ -1,51 +0,0 @@
import { describe, expect, it } from 'vitest';
import { buildRedirectUrl } from './redirect';
const mockLocation = (url: string): Location => {
const parsed = new URL(url);
return { hash: parsed.hash, search: parsed.search } as Location;
};
describe('buildRedirectUrl', () => {
it('should redirect to /edit by default when hash is empty', () => {
expect(buildRedirectUrl(mockLocation('https://mermaid.live/'))).toBe('/edit');
});
it('should preserve search params', () => {
expect(buildRedirectUrl(mockLocation('https://mermaid.live/?utm_source=github'))).toBe(
'/edit?utm_source=github'
);
});
it('should extract route and fragment from old hash format', () => {
expect(buildRedirectUrl(mockLocation('https://mermaid.live/#/edit/pako:abc123'))).toBe(
'/edit#pako:abc123'
);
});
it('should place search params before the hash fragment', () => {
expect(
buildRedirectUrl(mockLocation('https://mermaid.live/?utm_source=twitter#/edit/pako:abc123'))
).toBe('/edit?utm_source=twitter#pako:abc123');
});
it('should handle hash with view route', () => {
expect(buildRedirectUrl(mockLocation('https://mermaid.live/#/view/pako:xyz'))).toBe(
'/view#pako:xyz'
);
});
it('should default to edit when hash has no route', () => {
expect(buildRedirectUrl(mockLocation('https://mermaid.live/#somethingelse'))).toBe('/edit');
});
it('should handle multiple search params with hash', () => {
expect(
buildRedirectUrl(
mockLocation(
'https://mermaid.live/?utm_source=gh&utm_medium=link&utm_campaign=test#/edit/pako:data'
)
)
).toBe('/edit?utm_source=gh&utm_medium=link&utm_campaign=test#pako:data');
});
});
-17
View File
@@ -1,17 +0,0 @@
import { resolve } from '$app/paths';
/**
* Build the redirect URL for legacy root-path links.
* Extracts the route and fragment from the old hash-based URL format,
* and ensures search params (e.g. UTM) come before the hash fragment.
*/
export const buildRedirectUrl = (location: Location): string => {
const parts = location.hash.split('/');
let path = 'edit';
let fragment = '';
if (parts.length > 2) {
path = parts[1];
fragment = `#${parts[2]}`;
}
return `${resolve(`/${path}`, {})}${location.search}${fragment}`;
};
+23 -97
View File
@@ -1,5 +1,5 @@
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 { derived, get, writable, type Readable } from 'svelte/store';
import { env } from './env';
@@ -8,7 +8,7 @@ import {
findMostRelevantLineNumber,
replaceLineNumberInErrorMessage
} from './errorHandling';
import { defaultMermaidConfig, parse } from './mermaid';
import { parse } from './mermaid';
import { localStorage, persist } from './persist';
import { deserializeState, pakoSerde, serializeState } from './serde';
import { errorDebug, formatJSON, getUTMSource, MCBaseURL } from './util';
@@ -80,7 +80,7 @@ const processState = async (state: State) => {
processed.error = error as Error;
errorDebug();
console.error(error);
if (error && typeof error === 'object' && 'hash' in error) {
if ('hash' in error) {
try {
let errorString = processed.error.toString();
const errorLineText = extractErrorLineText(errorString);
@@ -137,25 +137,14 @@ export const urlsStore = derived([stateStore], ([{ code, serialized }]) => {
? `[![](${png})](${window.location.protocol}//${window.location.host}${window.location.pathname}#${serialized})`
: '',
mermaidChart: ({
medium,
campaign
medium
}: {
medium:
| 'ai_edit'
| 'ai_repair'
| 'main_menu'
| 'save_diagram'
| 'share'
| 'vibe_diagramming'
| 'visual_edit'
| 'voice_edit';
campaign?: string;
medium: 'ai_repair' | 'main_menu' | 'save_diagram' | 'share' | 'vibe_diagramming';
}) => {
const utmSource = getUTMSource();
const params = new URLSearchParams({
utm_source: utmSource,
utm_medium: medium,
...(campaign ? { utm_campaign: campaign } : {})
utm_medium: medium
}).toString();
return {
save: `${MCBaseURL}/app/plugin/save?state=${serialized}&${params}`,
@@ -171,91 +160,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 => {
let state: State;
console.log(`Loading '${data}'`);
try {
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) {
state = get(inputStateStore);
if (data) {
-47
View File
@@ -1,47 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { getAnalyticsSafeUrl } from './stats';
describe('getAnalyticsUrl', () => {
beforeEach(() => {
// Reset location to a clean state before each test
window.history.replaceState(null, '', '/');
});
it('should return origin and pathname for a simple URL', () => {
window.history.replaceState(null, '', '/edit');
const url = getAnalyticsSafeUrl();
expect(url).toBe(`${window.location.origin}/edit`);
});
it('should include search/query params (UTM parameters)', () => {
window.history.replaceState(null, '', '/edit?utm_source=github&utm_medium=docs');
const url = getAnalyticsSafeUrl();
expect(url).toBe(`${window.location.origin}/edit?utm_source=github&utm_medium=docs`);
});
it('should never include the hash', () => {
window.history.replaceState(null, '', '/edit#pako:someDiagramData');
// replaceState doesn't set hash, so set it via location
window.location.hash = '#pako:someDiagramData';
const url = getAnalyticsSafeUrl();
expect(url).not.toContain('#');
expect(url).not.toContain('pako:');
expect(url).toBe(`${window.location.origin}/edit`);
});
it('should include search params but exclude hash when both are present', () => {
window.history.replaceState(null, '', '/edit?utm_campaign=launch');
window.location.hash = '#pako:diagramDataHere';
const url = getAnalyticsSafeUrl();
expect(url).not.toContain('#');
expect(url).not.toContain('pako:');
expect(url).toContain('utm_campaign=launch');
expect(url).toBe(`${window.location.origin}/edit?utm_campaign=launch`);
});
it('should return just origin for root path with no params', () => {
window.history.replaceState(null, '', '/');
const url = getAnalyticsSafeUrl();
expect(url).toBe(`${window.location.origin}/`);
});
});
+6 -13
View File
@@ -23,15 +23,6 @@ export const initAnalytics = async (): Promise<void> => {
}
};
/**
* Build the current page URL for analytics tracking.
* Includes origin, pathname, and search (for UTM params),
* but never the hash (which contains diagram data).
*/
export const getAnalyticsSafeUrl = (): string => {
return window.location.origin + window.location.pathname + window.location.search;
};
export const countLines = (code: string): number => {
return (code.match(/\n/g)?.length ?? 0) + 1;
};
@@ -100,6 +91,7 @@ const delaysPerEvent = {
mermaidChartClick: noDelay,
migration: defaultDelay,
mobileViewToggle: defaultDelay,
panZoom: minutesToMilliSeconds(10),
pwaInstalled: defaultDelay,
render: minutesToMilliSeconds(5),
renderDiagram: defaultDelay,
@@ -113,9 +105,6 @@ export const logEvent = (
name: AnalyticsEvent,
data?: Record<string, string | number | boolean>
): void => {
if (browser && window.location.hostname === 'localhost') {
console.log('[plausible]', name, data);
}
if (!plausible) {
return;
}
@@ -123,7 +112,11 @@ export const logEvent = (
if (timeouts.has(key)) {
clearTimeout(timeouts.get(key));
} else {
plausible.trackEvent(name, { props: data }, { url: getAnalyticsSafeUrl() });
plausible.trackEvent(
name,
{ props: data },
{ url: window.location.origin + window.location.pathname }
);
}
timeouts.set(
key,
+7 -16
View File
@@ -5,7 +5,7 @@ import { initLoading } from './loading';
import { isOnMermaidAI } from './migration/domainMigration';
import { applyMigrations } from './migrations';
import { initURLSubscription, loadState, updateCodeStore, verifyState } from './state';
import { getAnalyticsSafeUrl, initAnalytics, plausible } from './stats';
import { initAnalytics, plausible } from './stats';
export const getDomain = (url?: string): string => {
if (!url) return '';
@@ -30,9 +30,7 @@ export const initHandler = async (): Promise<void> => {
syncDiagram();
initURLSubscription();
await initAnalytics();
plausible?.trackPageview({
url: getAnalyticsSafeUrl()
});
plausible?.trackPageview({ url: window.location.origin + window.location.pathname });
verifyState();
};
@@ -42,30 +40,23 @@ export const MCBaseURL = env.isEnabledMermaidChartLinks
? 'https://mermaid.ai' // 'http://localhost:5174'
: 'https://example.com';
const buildUtmParams = ({
export const getCheckoutUrl = ({
utmCampaign,
utmMedium
}: {
utmCampaign: string;
utmMedium: string;
}): URLSearchParams =>
new URLSearchParams({
}): string => {
const params = new URLSearchParams({
coupon: 'arDfyFT8',
tier: 'plus',
utm_campaign: utmCampaign,
utm_medium: utmMedium,
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()}`;
};
export const getMermaidAiLiveUrl = (utm: { utmCampaign: string; utmMedium: string }): string => {
return `${MCBaseURL}/live?${buildUtmParams(utm).toString()}`;
};
let count = 0;
export const errorDebug = (limit = 1000) => {
count += 1;
+2 -3
View File
@@ -1,13 +1,12 @@
<script>
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/stores';
import { onMount } from 'svelte';
// Only redirect if it's a 404 error
onMount(() => {
if ($page.status === 404) {
goto(resolve('/'));
goto('/');
}
});
</script>
@@ -16,6 +15,6 @@
<div class="container mx-auto p-8">
<h1 class="mb-4 text-2xl font-bold">Error {$page.status}</h1>
<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>
{/if}
+1 -1
View File
@@ -34,7 +34,7 @@
});
$effect(() => {
toggleDarkTheme(mode.current === 'dark');
toggleDarkTheme($mode === 'dark');
});
</script>
+8 -2
View File
@@ -1,10 +1,16 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { buildRedirectUrl } from '$lib/util/redirect';
import { onMount } from 'svelte';
import { base } from '$app/paths';
onMount(async () => {
await goto(buildRedirectUrl(window.location), {
// Handle old live editor links and redirect to new version
const hash = window.location.hash.split('/');
let newURL = 'edit';
if (hash.length > 2) {
newURL = `${hash[1]}#${hash[2]}`;
}
await goto(`${base}/${newURL}`, {
replaceState: true
});
});
-2
View File
@@ -3,7 +3,6 @@
import Card from '$/components/Card/Card.svelte';
import DiagramDocButton from '$/components/DiagramDocumentationButton.svelte';
import Editor from '$/components/Editor.svelte';
import EnhancedEditsButton from '$/components/EnhancedEditsButton.svelte';
import History from '$/components/History/History.svelte';
import McWrapper from '$/components/McWrapper.svelte';
import MermaidChartIcon from '$/components/MermaidChartIcon.svelte';
@@ -137,7 +136,6 @@
<Resizable.Handle class="mr-1 hidden opacity-0 sm:block" />
<Resizable.Pane minSize={15} class="relative flex h-full flex-1 flex-col overflow-hidden">
<View {panZoomState} shouldShowGrid={$stateStore.grid} />
<div class="absolute top-0 left-5 hidden md:block"><EnhancedEditsButton /></div>
<div class="absolute top-0 right-0"><PanZoomToolbar {panZoomState} /></div>
<div class="absolute right-0 bottom-0"><VersionSecurityToolbar /></div>
<div class="absolute bottom-0 left-0 sm:left-5"><SyncRoughToolbar /></div>
-4
View File
@@ -1,5 +1,4 @@
import adapter from '@sveltejs/adapter-static';
import 'dotenv/config';
import { sveltePreprocess } from 'svelte-preprocess';
/** @type {import('@sveltejs/kit').Config} */
@@ -11,9 +10,6 @@ const config = {
alias: {
'$/*': './src/lib/*'
},
paths: {
base: process.env.MERMAID_BASE_PATH ?? ''
},
adapter: adapter({
pages: 'docs',
fallback: '404.html'
-40
View File
@@ -1,5 +1,3 @@
import type { State } from '$/types';
import assert from 'node:assert';
import { expect, test } from './test';
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 }) => {
await editPage.start('/#/edit/eyJjb2RlIjoiZ3JhcGggVERcbiAg');
await page.reload({ waitUntil: 'networkidle' });