Compare commits

..
Author SHA1 Message Date
Sidharth VinodandCursor 1f0812496e fix: prevent state feedback loop when updating Monaco via executeEdits
Guard programmatic editor updates so sample diagrams and URL loads do not re-trigger onUpdate, which caused infinite state/URL churn and failing e2e tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 18:40:07 +05:30
Sidharth Vinod c62ebf6d09 fix: #1753 Undo in Monaco 2026-06-08 18:14:56 +05:30
Sidharth VinodandGitHub 6438d0074d Merge pull request #1719 from mermaid-js/renovate/all-minor-patch
chore(deps): update all non-major dependencies to v10.34.1
2026-06-08 18:02:54 +05:30
Sidharth VinodandGitHub 130711a9d0 Merge pull request #1718 from mermaid-js/renovate/patch-all-minor-patch
chore(deps): update all non-major dependencies to ^24.12.4
2026-06-08 18:02:07 +05:30
renovate[bot]andGitHub 7c6e28e163 chore(deps): update all non-major dependencies to v10.34.1 2026-06-08 12:27:57 +00:00
renovate[bot]andGitHub 1b432bcaaa chore(deps): update all non-major dependencies to ^24.12.4 2026-06-08 12:27:15 +00:00
Sidharth VinodandGitHub da89994440 Merge pull request #1980 from mermaid-js/chore/update-all-deps
chore: update all dependencies to latest versions
2026-06-08 12:21:54 +00:00
Sidharth VinodandCursor c25675d418 chore: update all dependencies to latest versions
Bump the full dependency tree (Vite 8, Vitest 4, ESLint 10, Svelte 5.56,
mode-watcher 1.x, etc.) and adapt code for breaking API changes. Upgrade
Playwright CI image to v1.60 for Node 24 compatibility.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 17:47:38 +05:30
Sidharth VinodandGitHub a58cde9516 Merge pull request #1971 from mermaid-js/chore/node-24-lts
chore: Node.js 24 LTS and latest mermaid packages
2026-06-08 11:41:55 +00:00
Sidharth VinodandCursor ccf5a13018 fix: upgrade Playwright to 1.60 for Node 24.16 compatibility
Playwright <1.60 hangs on browser extraction under Node 24.16.0; bump
the test dependency and CI container image to unblock e2e.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 17:08:27 +05:30
Sidharth VinodandGitHub 4ab4e022a0 Merge branch 'develop' into chore/node-24-lts 2026-06-08 16:19:46 +05:30
Sidharth VinodandGitHub 52abdccc3a Merge pull request #1963 from IvanTheGeek/bug/1961_favicon-link-tags
fix(#1961): Correct favicon link tags
2026-06-08 08:22:51 +00:00
Ivan The Geek d134306482 fix: drop ico link tag — favicon.ico has opaque white corners
The .ico file is a single 32×32 frame with white (255,255,255,255)
pixels filling the rounded-corner regions instead of transparent
ones, so declaring it as an `<link rel="icon">` made Chrome render
the favicon with visible white corners on dark backgrounds. The
svg+xml type fix and apple-touch-icon link remain — those don't
involve the .ico.

Legacy browsers that need the .ico will still find it via the root
`/favicon.ico` auto-probe; regenerating the .ico from favicon.svg
so its content matches the rest is a separate asset change.
2026-06-07 13:34:40 -04:00
Sidharth VinodandCursor d040d8d84a chore: bump mermaid and related packages to latest
Upgrade mermaid 11.15.0, layout-elk 0.2.1, and layout-tidy-tree 0.2.2.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 01:07:52 +05:30
Sidharth VinodandCursor c2cd0c0bfc chore: upgrade Node.js to 24.16.0 LTS
Pin CI, Docker, and engines to the current Active LTS and bump @types/node.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 20:44:47 +05:30
Sidharth VinodandGitHub 2ff56f67bc Merge pull request #1965 from mermaid-js/sidv/editor-chooser-live-2026
Ship editor chooser testB with live_2026 UTM campaign
2026-05-29 16:02:47 +05:30
Sidharth VinodandCursor 55d9bd043f refactor: Inline editor chooser modal content
Remove the separate testB component now that the A/B test is over.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 15:49:44 +05:30
Sidharth VinodandCursor adf3ddf015 feat: Ship editor chooser testB and use live_2026 UTM campaign
End the A/B test by always showing the testB modal, remove unused variant
components and assignment logic, and route CTA links with utm_campaign=live_2026.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 15:45:04 +05:30
Sidharth VinodandGitHub bfea7dd4db Merge pull request #1964 from mermaid-js/sidv/close-broken-link-issues
chore: Auto-close issues with only "Broken link" in title
2026-05-28 00:42:19 +05:30
Sidharth VinodandCursor 7cd24ddcb3 chore: Clarify live editor vs Mermaid syntax issues in auto-close comment
Direct syntax error reports to mermaid-js/mermaid and limit this repo to live editor bugs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 00:41:27 +05:30
Sidharth VinodandCursor 241f99bacf chore: Auto-close issues with only "Broken link" in title
Add a GitHub Action that closes low-effort issues using the default pre-filled title and prompts reporters to include the failing URL and details.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 00:38:52 +05:30
Ivan The GeekandIvan The Geek 472a3283c8 fix(#1961): Correct favicon link tags
Three small markup fixes in src/app.html:

- Update the icon link's `type` from `image/png` to `image/svg+xml` so
  it matches its `favicon.svg` href. The MIME mismatch was introduced
  in #1065 when the href was switched from `.png` to `.svg` without
  updating `type`; browsers ignore the attribute and trust the
  Content-Type so it rendered fine, but the markup was incorrect.
- Add an explicit `image/x-icon` link for `favicon.ico`. The file
  already ships in `static/`, but it was previously discoverable only
  via the root `/favicon.ico` auto-probe.
- Add an `apple-touch-icon` link pointing at `favicon.png` (512×512)
  so iOS "Add to Home Screen" uses the Mermaid logo instead of a
  low-quality page screenshot.

No behavior change for existing desktop users.
2026-05-25 13:38:32 -04:00
Sidharth VinodandGitHub 7b43e33971 Merge pull request #1951 from ZenUml/chore/mermaid-zenuml-0.2.3
[codex] bump mermaid-zenuml to 0.2.3
2026-05-18 20:17:21 +05:30
MrCoder 6af70cb44c chore: bump mermaid-zenuml to 0.2.3 2026-05-14 16:56:18 +10:00
Sidharth Vinod 1fabea49cf chore: Update mermaid-examples 2026-04-23 14:40:49 +05:30
Sidharth VinodandGitHub 1bd6355ea8 Merge pull request #1938 from mermaid-js/sidv/editorChooserExperiments
feat(MC-4486): Add variations to editor picker
2026-04-23 14:32:44 +05:30
Sidharth VinodandGitHub 29eb7b01fc Merge pull request #1939 from mermaid-js/sidv/EnhancedEditButton
chore(MC-4489): Add enhanced edit button
2026-04-23 14:16:34 +05:30
Sidharth Vinod b446c37315 chore: Add enhanced edit button 2026-04-23 13:31:19 +05:30
Sidharth Vinod 031477cda9 fix: Update tagline and layout 2026-04-23 13:05:45 +05:30
Sidharth Vinod a9becf5509 fix: copy 2026-04-22 22:37:01 +05:30
Sidharth Vinod 79c4b3bf55 chore: Add noopener 2026-04-22 21:04:00 +05:30
Sidharth Vinod a61a69e477 fix: Show picker on netlify 2026-04-22 18:58:31 +05:30
Sidharth Vinod 1daac45c87 feat(MC-4486): Add variations to editor picker 2026-04-22 18:52:02 +05:30
Alois KlinkandGitHub e3562168ad Merge pull request #1929 from mermaid-js/renovate/mermaid-packages
fix(deps): update dependency mermaid to ^11.14.0
2026-04-10 12:42:42 +00:00
renovate[bot]andGitHub 7fa7a48325 fix(deps): update dependency mermaid to ^11.14.0 2026-04-10 12:39:46 +00:00
Alois KlinkandGitHub 4925de94a9 Merge commit from fork
fix: improve sanitization of mermaid config
2026-04-10 21:35:24 +09:00
Sidharth Vinod 679e444bf1 chore: Remove canonical 2026-04-02 17:34:24 +05:30
Alois Klink 46a7a533ab fix: copy mermaid config sanitization check
Copy the XSS prevention check from the `sanitize` function in mermaid
upstream's `config.ts` file.

See: https://github.com/mermaid-js/mermaid/blob/9745f325cb9e1967640f0e85da193a2f820634f1/packages/mermaid/src/config.ts#L178-L183
2026-04-02 19:00:54 +09:00
Alois Klink 67aacdebe4 fix: sanitize config for __ keys
According to upstream, this is supposed to be fore prototype pollution
prevention.

See: https://github.com/mermaid-js/mermaid/blob/9745f325cb9e1967640f0e85da193a2f820634f1/packages/mermaid/src/config.ts#L169-L174
2026-04-02 19:00:54 +09:00
Alois Klink 7e9cdfca73 fix: sanitize all secure keys in config
Check and remove all the secure keys in the site Mermaid Config,
not just `securityLevel`. Unfortunately, the logic of Mermaid's
`sanitize` function in `config.ts` is a bit convoluted. For instance,
the `secure` config restricts keys deeply in the object.

See: https://github.com/mermaid-js/mermaid/blob/9745f325cb9e1967640f0e85da193a2f820634f1/packages/mermaid/src/config.ts#L155-L190
2026-04-02 19:00:54 +09:00
Alois Klink 48b9560e8e fix: sanitize config loaded from gist/config URL
Currently, the `config` in the codeState in the hash is sanitized for
unsafe values, however the `?config` URL parameter or configs loaded
from a GitHub Gist are not.

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