Compare commits
59
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f0812496e | ||
|
|
c62ebf6d09 | ||
|
|
6438d0074d | ||
|
|
130711a9d0 | ||
|
|
7c6e28e163 | ||
|
|
1b432bcaaa | ||
|
|
da89994440 | ||
|
|
c25675d418 | ||
|
|
a58cde9516 | ||
|
|
ccf5a13018 | ||
|
|
4ab4e022a0 | ||
|
|
52abdccc3a | ||
|
|
d134306482 | ||
|
|
d040d8d84a | ||
|
|
c2cd0c0bfc | ||
|
|
2ff56f67bc | ||
|
|
55d9bd043f | ||
|
|
adf3ddf015 | ||
|
|
bfea7dd4db | ||
|
|
7cd24ddcb3 | ||
|
|
241f99bacf | ||
|
|
472a3283c8 | ||
|
|
7b43e33971 | ||
|
|
6af70cb44c | ||
|
|
1fabea49cf | ||
|
|
1bd6355ea8 | ||
|
|
29eb7b01fc | ||
|
|
b446c37315 | ||
|
|
031477cda9 | ||
|
|
a9becf5509 | ||
|
|
79c4b3bf55 | ||
|
|
a61a69e477 | ||
|
|
1daac45c87 | ||
|
|
e3562168ad | ||
|
|
7fa7a48325 | ||
|
|
4925de94a9 | ||
|
|
679e444bf1 | ||
|
|
46a7a533ab | ||
|
|
67aacdebe4 | ||
|
|
7e9cdfca73 | ||
|
|
48b9560e8e | ||
|
|
f9f1e27bc3 | ||
|
|
62699769d5 | ||
|
|
5608dc5661 | ||
|
|
29e92a9702 | ||
|
|
0e0da3b1e9 | ||
|
|
d273e1d722 | ||
|
|
c799dff042 | ||
|
|
ae7ad1b93e | ||
|
|
c675dc02e9 | ||
|
|
ab760f35fa | ||
|
|
8a436791de | ||
|
|
d16a1776e6 | ||
|
|
ab720e0267 | ||
|
|
d7db71e38a | ||
|
|
b466e8430e | ||
|
|
ea404df79b | ||
|
|
15cdb7b64a | ||
|
|
340911f271 |
@@ -1,4 +1,5 @@
|
||||
MERMAID_DOMAIN=''
|
||||
MERMAID_BASE_PATH=''
|
||||
MERMAID_DOCS_URL='https://mermaid.js.org'
|
||||
MERMAID_ANALYTICS_URL=''
|
||||
MERMAID_RENDERER_URL='https://mermaid.ink'
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
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'
|
||||
});
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
name: 'Playwright Tests'
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.52.0-jammy
|
||||
image: mcr.microsoft.com/playwright:v1.60.0-jammy
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
22.15.0
|
||||
24.16.0
|
||||
|
||||
Vendored
+3
@@ -12,11 +12,14 @@
|
||||
"fsegurai",
|
||||
"gantt",
|
||||
"gitgraph",
|
||||
"hugeicons",
|
||||
"KROKI",
|
||||
"localstorage",
|
||||
"mermaidchart",
|
||||
"mindmap",
|
||||
"NEWYEAR",
|
||||
"noopener",
|
||||
"noreferrer",
|
||||
"Pageview",
|
||||
"pako",
|
||||
"panmove",
|
||||
|
||||
+4
-1
@@ -1,4 +1,4 @@
|
||||
FROM docker.io/library/node:22.15.0-alpine3.21 AS mermaid-live-editor-dependencies
|
||||
FROM docker.io/library/node:24.16.0-alpine3.22 AS mermaid-live-editor-dependencies
|
||||
|
||||
RUN apk --no-cache add build-base git python3 && \
|
||||
rm -rf /var/cache/apk/*
|
||||
@@ -19,6 +19,9 @@ 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 . ./
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ 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
|
||||
|
||||
+74
-72
@@ -23,94 +23,96 @@
|
||||
"test:e2e:debug": "playwright test --debug"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.2.5",
|
||||
"@eslint/eslintrc": "^3.3.3",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||
"@iconify-json/material-symbols": "^1.2.20",
|
||||
"@eslint/compat": "^2.1.0",
|
||||
"@eslint/eslintrc": "^3.3.5",
|
||||
"@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/mdi": "^1.2.3",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@sveltejs/adapter-static": "^3.0.9",
|
||||
"@sveltejs/kit": "^2.37.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.1.3",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@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",
|
||||
"@types/hammerjs": "^2.0.46",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^22.15.10",
|
||||
"@types/pako": "2.0.3",
|
||||
"@types/uuid": "9.0.8",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"@vitest/ui": "^3.2.4",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"bits-ui": "^2.9.6",
|
||||
"c8": "7.14.0",
|
||||
"chai": "^4.5.0",
|
||||
"@types/node": "^24.12.4",
|
||||
"@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",
|
||||
"c8": "11.0.0",
|
||||
"chai": "^6.2.2",
|
||||
"clsx": "^2.1.1",
|
||||
"cssnano": "^6.1.2",
|
||||
"eslint": "^9.39.2",
|
||||
"cssnano": "^8.0.1",
|
||||
"dotenv": "^17.4.2",
|
||||
"eslint": "^10.4.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-es": "^4.1.0",
|
||||
"eslint-plugin-no-only-tests": "^3.3.0",
|
||||
"eslint-plugin-no-only-tests": "^3.4.0",
|
||||
"eslint-plugin-sort-keys": "^2.3.5",
|
||||
"eslint-plugin-svelte": "^3.0.0",
|
||||
"eslint-plugin-tailwindcss": "^3.18.2",
|
||||
"eslint-plugin-unicorn": "^60.0.0",
|
||||
"eslint-plugin-svelte": "^3.19.0",
|
||||
"eslint-plugin-tailwindcss": "^3.18.3",
|
||||
"eslint-plugin-unicorn": "^65.0.0",
|
||||
"esserializer": "^1.3.11",
|
||||
"globals": "^16.0.0",
|
||||
"husky": "^8.0.3",
|
||||
"jsdom": "^25.0.1",
|
||||
"lint-staged": "^15.5.1",
|
||||
"lucide-svelte": "^0.507.0",
|
||||
"node-html-parser": "^6.1.13",
|
||||
"paneforge": "1.0.0-next.6",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-svelte": "^3.4.1",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"svelte": "^5.38.6",
|
||||
"svelte-preprocess": "^6.0.3",
|
||||
"svelte-sonner": "^1.0.5",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwind-variants": "^3.1.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"globals": "^17.6.0",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^29.1.1",
|
||||
"lint-staged": "^17.0.7",
|
||||
"lucide-svelte": "^1.0.1",
|
||||
"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",
|
||||
"tailwind-variants": "^3.2.2",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"tslib": "^2.8.1",
|
||||
"tw-animate-css": "^1.3.8",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.42.0",
|
||||
"unplugin-icons": "^22.2.0",
|
||||
"vite": "^7.1.4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.60.1",
|
||||
"unplugin-icons": "^23.0.1",
|
||||
"vite": "^8.0.16",
|
||||
"vite-plugin-devtools-json": "^1.0.0",
|
||||
"vitest": "^3.2.4",
|
||||
"vitest": "^4.1.8",
|
||||
"vitest-dom": "^0.1.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/lang-markdown": "^6.3.2",
|
||||
"@codemirror/lang-yaml": "^6.1.2",
|
||||
"@codemirror/language": "^6.11.0",
|
||||
"@codemirror/state": "^6.5.2",
|
||||
"@codemirror/view": "^6.36.7",
|
||||
"@fontsource-variable/recursive": "^5.2.5",
|
||||
"@fsegurai/codemirror-theme-vscode-dark": "^6.1.4",
|
||||
"@fsegurai/codemirror-theme-vscode-light": "^6.1.4",
|
||||
"@mermaid-js/examples": "^1.0.0",
|
||||
"@mermaid-js/layout-elk": "^0.1.9",
|
||||
"@mermaid-js/layout-tidy-tree": "^0.2.1",
|
||||
"@mermaid-js/mermaid-zenuml": "^0.2.2",
|
||||
"codemirror": "^6.0.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"@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",
|
||||
"@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",
|
||||
"codemirror": "^6.0.2",
|
||||
"dayjs": "^1.11.21",
|
||||
"hammerjs": "^2.0.8",
|
||||
"js-base64": "3.7.7",
|
||||
"lodash-es": "^4.17.21",
|
||||
"mermaid": "^11.12.0",
|
||||
"mode-watcher": "^0.5.1",
|
||||
"monaco-editor": "0.52.2",
|
||||
"js-base64": "3.7.8",
|
||||
"lodash-es": "^4.18.1",
|
||||
"mermaid": "^11.15.0",
|
||||
"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.1",
|
||||
"uuid": "9.0.1"
|
||||
"svg2roughjs": "^3.2.3",
|
||||
"uuid": "14.0.0"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,svelte,js,css,md,json}": [
|
||||
@@ -119,9 +121,9 @@
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
"node": ">=24.16.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39",
|
||||
"packageManager": "pnpm@10.34.1+sha512.b58fbde6dca66a929538021581f648b4570b6ca19b18e7cbd7f2c07a7b24454155388dacdf08f2af3678e88a6d1fe04f9d609df24bf51735a060ea041b374ab7",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"deasync",
|
||||
|
||||
Generated
+2887
-3687
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -5,11 +5,11 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Online FlowChart & 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/png" href="%sveltekit.assets%/favicon.svg" />
|
||||
<link rel="icon" type="image/svg+xml" 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" />
|
||||
<meta name="theme-color" content="#ff3670" />
|
||||
<link rel="manifest" href="%sveltekit.assets%/manifest.json" />
|
||||
|
||||
Vendored
+2
@@ -7,6 +7,8 @@ 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...
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
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;
|
||||
@@ -34,6 +35,16 @@
|
||||
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',
|
||||
@@ -132,7 +143,7 @@
|
||||
|
||||
editor.onDidChangeModelContent(({ isFlush }) => {
|
||||
const newText = editor?.getValue();
|
||||
if (!newText || currentText === newText || isFlush) {
|
||||
if (!newText || currentText === newText || isFlush || isUpdatingFromState) {
|
||||
return;
|
||||
}
|
||||
currentText = newText;
|
||||
@@ -159,9 +170,21 @@
|
||||
// Update editor text if it's different
|
||||
const newText = editorMode === 'code' ? code : mermaid;
|
||||
if (newText !== currentText) {
|
||||
editor.setScrollTop(0);
|
||||
editor.setValue(newText);
|
||||
currentText = newText;
|
||||
isUpdatingFromState = true;
|
||||
try {
|
||||
editor.setScrollTop(0);
|
||||
editor.pushUndoStop();
|
||||
editor.executeEdits('updateCode', [
|
||||
{
|
||||
range: model.getFullModelRange(),
|
||||
text: newText
|
||||
}
|
||||
]);
|
||||
editor.pushUndoStop();
|
||||
currentText = newText;
|
||||
} finally {
|
||||
isUpdatingFromState = false;
|
||||
}
|
||||
renderAIPromptGutterGlyphIcon();
|
||||
}
|
||||
|
||||
@@ -183,12 +206,8 @@
|
||||
renderAIPromptGutterGlyphIcon();
|
||||
});
|
||||
|
||||
const unsubscribeMode = mode.subscribe((mode) => {
|
||||
if (editor) {
|
||||
monaco.editor.setTheme(`mermaid${mode === 'dark' ? '-dark' : ''}`);
|
||||
divElement?.classList.toggle('mermaid-dark', mode === 'dark');
|
||||
}
|
||||
});
|
||||
applyEditorTheme(mode.current);
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
editor?.layout({
|
||||
height: entries[0].contentRect.height,
|
||||
@@ -204,7 +223,6 @@
|
||||
|
||||
return () => {
|
||||
unsubscribeState();
|
||||
unsubscribeMode();
|
||||
resizeObserver.disconnect();
|
||||
jsonModel.dispose();
|
||||
mermaidModel.dispose();
|
||||
@@ -224,7 +242,11 @@
|
||||
onClose={closePopup}
|
||||
onTryFree={() => {
|
||||
logMermaidChartClick('vibeDiagramming');
|
||||
window.open($urlsStore.mermaidChart({ medium: 'vibe_diagramming' }).save, '_blank');
|
||||
window.open(
|
||||
$urlsStore.mermaidChart({ medium: 'vibe_diagramming' }).save,
|
||||
'_blank',
|
||||
'noopener'
|
||||
);
|
||||
closePopup();
|
||||
}} />
|
||||
</div>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
const showErrorDebounced = debounce(() => {
|
||||
showError = true;
|
||||
}, 5000);
|
||||
}, 3000);
|
||||
|
||||
$effect(() => {
|
||||
if ($stateStore.error) {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
<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}
|
||||
@@ -126,7 +126,7 @@
|
||||
Dark Mode
|
||||
</span>
|
||||
<Switch
|
||||
checked={$mode === 'dark'}
|
||||
checked={mode.current === 'dark'}
|
||||
onCheckedChange={(dark) => setMode(dark ? 'dark' : 'light')} />
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<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="/mermaidchart-logo.svg" alt="Mermaid Chart" />
|
||||
<img class={['size-4', className]} src={asset('/mermaidchart-logo.svg')} alt="Mermaid Chart" />
|
||||
|
||||
@@ -16,11 +16,17 @@
|
||||
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({
|
||||
@@ -56,12 +62,6 @@
|
||||
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,7 +89,6 @@
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeMode();
|
||||
unsubscribeState();
|
||||
editorView?.destroy();
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
</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';
|
||||
@@ -83,7 +84,7 @@
|
||||
<div class="flex flex-1 items-center gap-2">
|
||||
<MainMenu />
|
||||
<MermaidIcon class="size-6" />
|
||||
<a href="/" class="whitespace-nowrap text-accent">
|
||||
<a href={resolve('/')} class="whitespace-nowrap text-accent">
|
||||
{#if !mobileToggle}
|
||||
Mermaid
|
||||
{/if}
|
||||
|
||||
@@ -1,45 +1,66 @@
|
||||
<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>
|
||||
|
||||
<Dialog.Root>
|
||||
<Dialog.Trigger>
|
||||
{#if env.privacyPolicyUrl}
|
||||
<a href={env.privacyPolicyUrl} target="_blank">
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
{#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}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
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';
|
||||
@@ -22,7 +23,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="/favicon.svg" alt="Mermaid Live Editor" />
|
||||
<img class="size-5" src={asset('/favicon.svg')} alt="Mermaid Live Editor" />
|
||||
Mermaid Live Editor
|
||||
</h2>
|
||||
<CopyInput value={window.location.href} />
|
||||
|
||||
@@ -24,12 +24,12 @@
|
||||
</script>
|
||||
|
||||
<div class="inline-grid">
|
||||
{#key $mode}
|
||||
{#key mode.current}
|
||||
<div
|
||||
in:spin={{ clockWise: true }}
|
||||
out:spin={{ clockWise: false }}
|
||||
class="col-start-1 row-start-1">
|
||||
{#if $mode === 'dark'}
|
||||
{#if mode.current === 'dark'}
|
||||
<MoonIcon />
|
||||
{:else}
|
||||
<SunIcon />
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
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';
|
||||
@@ -11,18 +12,20 @@
|
||||
|
||||
<FloatingToolbar>
|
||||
<span class="text-sm font-semibold opacity-60">v{version}</span>
|
||||
<Button variant="ghost" size="icon" title="Privacy & Security">
|
||||
<Privacy />
|
||||
</Button>
|
||||
{#if !env.hidePrivacyPolicy}
|
||||
<Button variant="ghost" size="icon" title="Privacy & Security">
|
||||
<Privacy />
|
||||
</Button>
|
||||
|
||||
<Separator orientation="vertical" />
|
||||
<Separator orientation="vertical" />
|
||||
{/if}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-testid={TID.themeToggleButton}
|
||||
title="Switch to {$mode === 'dark' ? 'light' : 'dark'} theme"
|
||||
title="Switch to {mode.current === 'dark' ? 'light' : 'dark'} theme"
|
||||
class="[&_svg]:size-5"
|
||||
onclick={() => setMode($mode === 'dark' ? 'light' : 'dark')}>
|
||||
onclick={() => setMode(mode.current === 'dark' ? 'light' : 'dark')}>
|
||||
<ThemeIcon />
|
||||
</Button>
|
||||
</FloatingToolbar>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { render as renderDiagram } from '$/util/mermaid';
|
||||
import { PanZoomState } from '$/util/panZoom';
|
||||
import { inputStateStore, stateStore, updateCodeStore } from '$/util/state';
|
||||
import { logEvent, saveStatistics } from '$/util/stats';
|
||||
import { saveStatistics } from '$/util/stats';
|
||||
import FontAwesome, { mayContainFontAwesome } from '$lib/components/FontAwesome.svelte';
|
||||
import uniqueID from 'lodash-es/uniqueId';
|
||||
import type { MermaidConfig } from 'mermaid';
|
||||
@@ -30,7 +30,6 @@
|
||||
const setupPanZoomObserver = () => {
|
||||
panZoomState.onPanZoomChange = (pan, zoom) => {
|
||||
updateCodeStore({ pan, zoom });
|
||||
logEvent('panZoom');
|
||||
};
|
||||
};
|
||||
|
||||
@@ -154,7 +153,7 @@
|
||||
<div
|
||||
id="view"
|
||||
bind:this={view}
|
||||
class={['h-full w-full', shouldShowGrid && `grid-bg-${$mode}`, error && 'opacity-50']}>
|
||||
class={['h-full w-full', shouldShowGrid && `grid-bg-${mode.current}`, error && 'opacity-50']}>
|
||||
<div id="container" bind:this={container} class="h-full overflow-auto"></div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,135 +1,101 @@
|
||||
<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 { 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';
|
||||
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';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
let { open = $bindable() }: Props = $props();
|
||||
|
||||
const close = () => {
|
||||
dismissEditorChooser();
|
||||
// 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;
|
||||
open = false;
|
||||
};
|
||||
});
|
||||
|
||||
const handleStartTrial = () => {
|
||||
logEvent('chooseEditor', { choice: 'plus' });
|
||||
logMermaidChartClick('editorPicker');
|
||||
close();
|
||||
window.open(
|
||||
getCheckoutUrl({ utmCampaign: 'start_plus', utmMedium: '2_editor_selection' }),
|
||||
'_blank'
|
||||
);
|
||||
};
|
||||
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 handleStartFree = () => {
|
||||
logEvent('chooseEditor', { choice: 'openSource' });
|
||||
close();
|
||||
};
|
||||
const trustedLogos: { name: string; icon: Component }[] = [
|
||||
{ name: 'Google', icon: GoogleIcon },
|
||||
{ name: 'Microsoft', icon: MicrosoftIcon },
|
||||
{ name: 'Atlassian', icon: AtlassianIcon },
|
||||
{ name: 'Amazon', icon: AmazonIcon }
|
||||
];
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
bind:open
|
||||
onOpenChange={(v) => {
|
||||
if (!v) handleStartFree();
|
||||
if (v) return;
|
||||
if (!handled) actions.log('dismissed');
|
||||
handled = false;
|
||||
dismissEditorChooser();
|
||||
}}>
|
||||
<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.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.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<Button variant="accent" class="mb-6 w-full" onclick={handleStartTrial}>
|
||||
Start free trial
|
||||
</Button>
|
||||
<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>
|
||||
|
||||
<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 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}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PrivacyPolicyLink />
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<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>
|
||||
@@ -0,0 +1,38 @@
|
||||
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 };
|
||||
};
|
||||
@@ -6,7 +6,7 @@
|
||||
</script>
|
||||
|
||||
<Sonner
|
||||
theme={$mode}
|
||||
theme={mode.current}
|
||||
class="toaster group"
|
||||
toastOptions={{
|
||||
classes: {
|
||||
|
||||
@@ -2,7 +2,9 @@ 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,5 +1,5 @@
|
||||
import type { Loader, State } from '$lib/types';
|
||||
import { defaultState, updateCodeStore } from '$lib/util/state';
|
||||
import { defaultState, sanitizeConfig, updateCodeStore } from '$lib/util/state';
|
||||
import { fetchText } from '$lib/util/util';
|
||||
import { loadGistData } from './gist';
|
||||
|
||||
@@ -50,6 +50,7 @@ export const loadDataFromUrl = async (): Promise<void> => {
|
||||
}
|
||||
}
|
||||
if (loaded) {
|
||||
state.mermaid = sanitizeConfig(state.mermaid || defaultState.mermaid);
|
||||
updateCodeStore({
|
||||
...state,
|
||||
updateDiagram: true
|
||||
|
||||
@@ -24,6 +24,11 @@ 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':
|
||||
|
||||
@@ -2,6 +2,8 @@ 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
|
||||
@@ -11,25 +13,19 @@ export const isOnMermaidAI = (): boolean => {
|
||||
return domain === mermaidAiDomain || domain.endsWith(`.${mermaidAiDomain}`);
|
||||
};
|
||||
|
||||
// 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 mermaid.live
|
||||
*/
|
||||
export const isOnMermaidLive = (): boolean => {
|
||||
const domain = window.location.hostname;
|
||||
return domain === mermaidLiveDomain || domain.endsWith(`.${mermaidLiveDomain}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if user has any stored data in localStorage.
|
||||
* This includes saved diagrams, history entries, etc.
|
||||
* Check if we're on a Netlify preview/staging deploy (*.netlify.app).
|
||||
*/
|
||||
const hasStoredUserData = (): boolean => {
|
||||
for (const key of userDataStorageKeys) {
|
||||
const value = window.localStorage.getItem(key);
|
||||
if (value && value !== '[]' && value !== 'null' && value !== '{}') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
const isOnNetlifyPreview = (): boolean => {
|
||||
return window.location.hostname.endsWith(`.${netlifyPreviewDomain}`);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -54,7 +50,9 @@ const isReferredFromMermaid = (): boolean => {
|
||||
hostname === 'mermaid.ai' ||
|
||||
hostname.endsWith('.mermaid.ai') ||
|
||||
hostname === 'mermaid.js.org' ||
|
||||
hostname.endsWith('.mermaid.js.org')
|
||||
hostname.endsWith('.mermaid.js.org') ||
|
||||
hostname === 'mermaid.live' ||
|
||||
hostname.endsWith('.mermaid.live')
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
@@ -65,12 +63,16 @@ 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;
|
||||
|
||||
@@ -60,6 +60,13 @@
|
||||
onDestroy(() => {
|
||||
clearInterval(interval);
|
||||
});
|
||||
|
||||
const taglineHref = $derived(
|
||||
`${MCBaseURL}${currentTagline.url.path}?${new URLSearchParams({
|
||||
...commonParams,
|
||||
...currentTagline.url.params
|
||||
}).toString()}`
|
||||
);
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -70,11 +77,9 @@
|
||||
<div class="grid grow">
|
||||
{#key currentTagline}
|
||||
<a
|
||||
href="{MCBaseURL}{currentTagline.url.path}?{new URLSearchParams({
|
||||
...commonParams,
|
||||
...currentTagline.url.params
|
||||
}).toString()}"
|
||||
href={taglineHref}
|
||||
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 }}>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
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}`;
|
||||
};
|
||||
+97
-23
@@ -1,5 +1,5 @@
|
||||
import type { ErrorHash, MarkerData, State, ValidatedState } from '$/types';
|
||||
import { debounce } from 'lodash-es';
|
||||
import { debounce, get as lodashGet } 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 { parse } from './mermaid';
|
||||
import { defaultMermaidConfig, 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 ('hash' in error) {
|
||||
if (error && typeof error === 'object' && 'hash' in error) {
|
||||
try {
|
||||
let errorString = processed.error.toString();
|
||||
const errorLineText = extractErrorLineText(errorString);
|
||||
@@ -137,14 +137,25 @@ export const urlsStore = derived([stateStore], ([{ code, serialized }]) => {
|
||||
? `[](${window.location.protocol}//${window.location.host}${window.location.pathname}#${serialized})`
|
||||
: '',
|
||||
mermaidChart: ({
|
||||
medium
|
||||
medium,
|
||||
campaign
|
||||
}: {
|
||||
medium: 'ai_repair' | 'main_menu' | 'save_diagram' | 'share' | 'vibe_diagramming';
|
||||
medium:
|
||||
| 'ai_edit'
|
||||
| 'ai_repair'
|
||||
| 'main_menu'
|
||||
| 'save_diagram'
|
||||
| 'share'
|
||||
| 'vibe_diagramming'
|
||||
| 'visual_edit'
|
||||
| 'voice_edit';
|
||||
campaign?: string;
|
||||
}) => {
|
||||
const utmSource = getUTMSource();
|
||||
const params = new URLSearchParams({
|
||||
utm_source: utmSource,
|
||||
utm_medium: medium
|
||||
utm_medium: medium,
|
||||
...(campaign ? { utm_campaign: campaign } : {})
|
||||
}).toString();
|
||||
return {
|
||||
save: `${MCBaseURL}/app/plugin/save?state=${serialized}&${params}`,
|
||||
@@ -160,28 +171,91 @@ 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);
|
||||
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);
|
||||
state.mermaid = sanitizeConfig(state.mermaid || defaultState.mermaid);
|
||||
} catch (error) {
|
||||
state = get(inputStateStore);
|
||||
if (data) {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
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}/`);
|
||||
});
|
||||
});
|
||||
+13
-6
@@ -23,6 +23,15 @@ 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;
|
||||
};
|
||||
@@ -91,7 +100,6 @@ const delaysPerEvent = {
|
||||
mermaidChartClick: noDelay,
|
||||
migration: defaultDelay,
|
||||
mobileViewToggle: defaultDelay,
|
||||
panZoom: minutesToMilliSeconds(10),
|
||||
pwaInstalled: defaultDelay,
|
||||
render: minutesToMilliSeconds(5),
|
||||
renderDiagram: defaultDelay,
|
||||
@@ -105,6 +113,9 @@ 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;
|
||||
}
|
||||
@@ -112,11 +123,7 @@ export const logEvent = (
|
||||
if (timeouts.has(key)) {
|
||||
clearTimeout(timeouts.get(key));
|
||||
} else {
|
||||
plausible.trackEvent(
|
||||
name,
|
||||
{ props: data },
|
||||
{ url: window.location.origin + window.location.pathname }
|
||||
);
|
||||
plausible.trackEvent(name, { props: data }, { url: getAnalyticsSafeUrl() });
|
||||
}
|
||||
timeouts.set(
|
||||
key,
|
||||
|
||||
+16
-7
@@ -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 { initAnalytics, plausible } from './stats';
|
||||
import { getAnalyticsSafeUrl, initAnalytics, plausible } from './stats';
|
||||
|
||||
export const getDomain = (url?: string): string => {
|
||||
if (!url) return '';
|
||||
@@ -30,7 +30,9 @@ export const initHandler = async (): Promise<void> => {
|
||||
syncDiagram();
|
||||
initURLSubscription();
|
||||
await initAnalytics();
|
||||
plausible?.trackPageview({ url: window.location.origin + window.location.pathname });
|
||||
plausible?.trackPageview({
|
||||
url: getAnalyticsSafeUrl()
|
||||
});
|
||||
verifyState();
|
||||
};
|
||||
|
||||
@@ -40,23 +42,30 @@ export const MCBaseURL = env.isEnabledMermaidChartLinks
|
||||
? 'https://mermaid.ai' // 'http://localhost:5174'
|
||||
: 'https://example.com';
|
||||
|
||||
export const getCheckoutUrl = ({
|
||||
const buildUtmParams = ({
|
||||
utmCampaign,
|
||||
utmMedium
|
||||
}: {
|
||||
utmCampaign: string;
|
||||
utmMedium: string;
|
||||
}): string => {
|
||||
const params = new URLSearchParams({
|
||||
coupon: 'arDfyFT8',
|
||||
tier: 'plus',
|
||||
}): URLSearchParams =>
|
||||
new URLSearchParams({
|
||||
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;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<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('/');
|
||||
goto(resolve('/'));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -15,6 +16,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="/" class="text-blue-500 hover:underline">Return to Home</a>
|
||||
<a href={resolve('/')} class="text-blue-500 hover:underline">Return to Home</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
toggleDarkTheme($mode === 'dark');
|
||||
toggleDarkTheme(mode.current === 'dark');
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
<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 () => {
|
||||
// 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}`, {
|
||||
await goto(buildRedirectUrl(window.location), {
|
||||
replaceState: true
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
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';
|
||||
@@ -136,6 +137,7 @@
|
||||
<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>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
import 'dotenv/config';
|
||||
import { sveltePreprocess } from 'svelte-preprocess';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
@@ -10,6 +11,9 @@ const config = {
|
||||
alias: {
|
||||
'$/*': './src/lib/*'
|
||||
},
|
||||
paths: {
|
||||
base: process.env.MERMAID_BASE_PATH ?? ''
|
||||
},
|
||||
adapter: adapter({
|
||||
pages: 'docs',
|
||||
fallback: '404.html'
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { State } from '$/types';
|
||||
import assert from 'node:assert';
|
||||
import { expect, test } from './test';
|
||||
|
||||
test.describe('Site Loads', () => {
|
||||
@@ -67,6 +69,44 @@ 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' });
|
||||
|
||||
Reference in New Issue
Block a user