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