mirror of
https://github.com/mermaid-js/mermaid-live-editor.git
synced 2026-11-04 08:59:08 +00:00
Merge pull request #2019 from mermaid-js/release-promotion
Release live editor
This commit is contained in:
27 files changed
+1745
-221
No files matched your search
@@ -16,3 +16,6 @@
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
.playwright-mcp/
|
||||
|
||||
# Generated by `pnpm build:embed` from src/embed-loader.ts
|
||||
/static/embed.js
|
||||
+4
-3
@@ -4,10 +4,11 @@
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"dev": "pnpm build:embed && vite dev",
|
||||
"dev:force": "MERMAID_LOCAL=true pnpm dev --force",
|
||||
"dev:test": "pnpm dev",
|
||||
"build": "vite build",
|
||||
"build": "pnpm build:embed && vite build",
|
||||
"build:embed": "vite build --config vite.embed.config.js",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
@@ -106,7 +107,7 @@
|
||||
"hammerjs": "^2.0.8",
|
||||
"js-base64": "3.7.8",
|
||||
"lodash-es": "^4.18.1",
|
||||
"mermaid": "^11.16.0",
|
||||
"mermaid": "^11.16.1",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"monaco-editor": "0.55.1",
|
||||
"pako": "2.1.0",
|
||||
|
||||
Generated
+14
-14
@@ -40,13 +40,13 @@ importers:
|
||||
version: 1.3.0
|
||||
'@mermaid-js/layout-elk':
|
||||
specifier: ^0.2.2
|
||||
version: 0.2.2(mermaid@11.16.0)
|
||||
version: 0.2.2(mermaid@11.16.1)
|
||||
'@mermaid-js/layout-tidy-tree':
|
||||
specifier: ^0.2.2
|
||||
version: 0.2.2(mermaid@11.16.0)
|
||||
version: 0.2.2(mermaid@11.16.1)
|
||||
'@mermaid-js/mermaid-zenuml':
|
||||
specifier: ^0.2.3
|
||||
version: 0.2.3(mermaid@11.16.0)(yaml@2.9.0)
|
||||
version: 0.2.3(mermaid@11.16.1)(yaml@2.9.0)
|
||||
codemirror:
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.2
|
||||
@@ -63,8 +63,8 @@ importers:
|
||||
specifier: ^4.18.1
|
||||
version: 4.18.1
|
||||
mermaid:
|
||||
specifier: ^11.16.0
|
||||
version: 11.16.0
|
||||
specifier: ^11.16.1
|
||||
version: 11.16.1
|
||||
mode-watcher:
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.0(svelte@5.56.4(@typescript-eslint/types@8.60.1))
|
||||
@@ -2550,8 +2550,8 @@ packages:
|
||||
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
mermaid@11.16.0:
|
||||
resolution: {integrity: sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==}
|
||||
mermaid@11.16.1:
|
||||
resolution: {integrity: sha512-TQsq6u22fAn3rek5VOubrhKPo1g5hwC3FXUN9hiyupTckcYiGuuKGkNQrKYwGJkXUxZdojwRG46gsSCFZMDp4g==}
|
||||
|
||||
micromatch@4.0.8:
|
||||
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
|
||||
@@ -4267,21 +4267,21 @@ snapshots:
|
||||
|
||||
'@mermaid-js/examples@1.3.0': {}
|
||||
|
||||
'@mermaid-js/layout-elk@0.2.2(mermaid@11.16.0)':
|
||||
'@mermaid-js/layout-elk@0.2.2(mermaid@11.16.1)':
|
||||
dependencies:
|
||||
d3: 7.9.0
|
||||
elkjs: 0.9.3
|
||||
mermaid: 11.16.0
|
||||
mermaid: 11.16.1
|
||||
|
||||
'@mermaid-js/layout-tidy-tree@0.2.2(mermaid@11.16.0)':
|
||||
'@mermaid-js/layout-tidy-tree@0.2.2(mermaid@11.16.1)':
|
||||
dependencies:
|
||||
d3: 7.9.0
|
||||
mermaid: 11.16.0
|
||||
mermaid: 11.16.1
|
||||
|
||||
'@mermaid-js/mermaid-zenuml@0.2.3(mermaid@11.16.0)(yaml@2.9.0)':
|
||||
'@mermaid-js/mermaid-zenuml@0.2.3(mermaid@11.16.1)(yaml@2.9.0)':
|
||||
dependencies:
|
||||
'@zenuml/core': 3.49.2(yaml@2.9.0)
|
||||
mermaid: 11.16.0
|
||||
mermaid: 11.16.1
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- '@babel/template'
|
||||
@@ -6147,7 +6147,7 @@ snapshots:
|
||||
|
||||
merge2@1.4.1: {}
|
||||
|
||||
mermaid@11.16.0:
|
||||
mermaid@11.16.1:
|
||||
dependencies:
|
||||
'@braintree/sanitize-url': 7.1.2
|
||||
'@iconify/utils': 3.1.3
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* <mermaid-embed> — a tiny, dependency-free loader for the Mermaid embed widget.
|
||||
*
|
||||
* Usage:
|
||||
* <script src="https://mermaid.live/embed.js" async></script>
|
||||
* <mermaid-embed src="https://mermaid.live/embed#pako:...">
|
||||
* graph TD
|
||||
* A --> B
|
||||
* </mermaid-embed>
|
||||
*
|
||||
* The element renders an <iframe> pointing at the host's /embed page. Settings come from the
|
||||
* `src` URL and/or attributes (theme, look, mode, grid, pan, zoom, controls). If the element has
|
||||
* inline body text, that text is used as the diagram code and OVERRIDES any code in the `src` URL
|
||||
* (carried in the `#code:` hash — not the query string).
|
||||
*
|
||||
* Authored in TypeScript; `pnpm build:embed` compiles it into `static/embed.js` (IIFE, no
|
||||
* imports) so it can be dropped into any page with a single script tag.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Keep in sync with `EMBED_IFRAME_SANDBOX` in `$/util/embedCode`.
|
||||
* `allow-same-origin` is required for the SvelteKit `/embed` app to boot; see embedCode.ts.
|
||||
*/
|
||||
const IFRAME_SANDBOX =
|
||||
'allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox';
|
||||
|
||||
/**
|
||||
* Resolve the loader script URL. `document.currentScript` is null for async/defer scripts,
|
||||
* so fall back to scanning for a script whose path ends in `/embed.js`.
|
||||
*/
|
||||
const resolveScriptUrl = (): URL | null => {
|
||||
if (document.currentScript instanceof HTMLScriptElement && document.currentScript.src) {
|
||||
return new URL(document.currentScript.src);
|
||||
}
|
||||
for (const element of document.querySelectorAll('script[src]')) {
|
||||
if (!(element instanceof HTMLScriptElement)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const url = new URL(element.src, window.location.href);
|
||||
if (/\/embed\.js$/i.test(url.pathname)) {
|
||||
return url;
|
||||
}
|
||||
} catch {
|
||||
// ignore invalid script URLs
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const scriptUrl = resolveScriptUrl();
|
||||
const prefix = scriptUrl
|
||||
? scriptUrl.origin + scriptUrl.pathname.replace(/\/embed\.js$/i, '')
|
||||
: null;
|
||||
const defaultBase = prefix ? `${prefix}/embed` : null;
|
||||
const allowedOrigin = scriptUrl?.origin;
|
||||
|
||||
const passthroughAttributes = ['theme', 'look', 'mode', 'grid', 'pan', 'zoom', 'controls'];
|
||||
|
||||
const px = (value: string | null, fallback: string): string => {
|
||||
if (value === null) {
|
||||
return fallback;
|
||||
}
|
||||
return /^\d+$/.test(value) ? `${value}px` : value;
|
||||
};
|
||||
|
||||
// Strip the common leading indentation from inline HTML so the diagram code is clean.
|
||||
const dedent = (text: string): string => {
|
||||
const lines = text.replaceAll('\t', ' ').split('\n');
|
||||
while (lines.length > 0 && lines[0].trim() === '') {
|
||||
lines.shift();
|
||||
}
|
||||
while (lines.length > 0 && lines.at(-1)?.trim() === '') {
|
||||
lines.pop();
|
||||
}
|
||||
let indent = Number.POSITIVE_INFINITY;
|
||||
for (const line of lines) {
|
||||
if (line.trim() !== '') {
|
||||
indent = Math.min(indent, line.length - line.trimStart().length);
|
||||
}
|
||||
}
|
||||
if (!Number.isFinite(indent)) {
|
||||
indent = 0;
|
||||
}
|
||||
return lines.map((line) => line.slice(indent)).join('\n');
|
||||
};
|
||||
|
||||
const buildUrl = (element: HTMLElement): string | undefined => {
|
||||
const srcAttribute = element.getAttribute('src');
|
||||
if (!srcAttribute && !defaultBase) {
|
||||
console.error(
|
||||
'mermaid-embed: missing src and could not derive host from embed.js (async scripts need an absolute src)'
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const src = srcAttribute ?? defaultBase;
|
||||
if (!src) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(src, window.location.href);
|
||||
} catch {
|
||||
console.error('mermaid-embed: invalid src');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (allowedOrigin && url.origin !== allowedOrigin) {
|
||||
console.error(
|
||||
`mermaid-embed: src origin must match embed.js host (${allowedOrigin}), got ${url.origin}`
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const name of passthroughAttributes) {
|
||||
const value = element.getAttribute(name);
|
||||
if (value !== null) {
|
||||
url.searchParams.set(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
// Inline body code wins over whatever code the src URL carried. Put it in the
|
||||
// hash (`#code:…`) so it stays out of query strings / server logs / Referer.
|
||||
const code = dedent(element.textContent ?? '');
|
||||
if (code.trim() !== '') {
|
||||
url.hash = `code:${encodeURIComponent(code)}`;
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
const rendered = new WeakSet<HTMLElement>();
|
||||
|
||||
const render = (element: HTMLElement): void => {
|
||||
if (rendered.has(element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const src = buildUrl(element);
|
||||
if (!src) {
|
||||
return;
|
||||
}
|
||||
rendered.add(element);
|
||||
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.src = src;
|
||||
iframe.title = element.getAttribute('title') ?? 'Mermaid diagram';
|
||||
iframe.loading = 'lazy';
|
||||
iframe.setAttribute('frameborder', '0');
|
||||
iframe.setAttribute('sandbox', IFRAME_SANDBOX);
|
||||
iframe.style.border = '0';
|
||||
iframe.style.display = 'block';
|
||||
iframe.style.width = px(element.getAttribute('width'), '100%');
|
||||
iframe.style.height = px(element.getAttribute('height'), '480px');
|
||||
|
||||
element.style.display = 'block';
|
||||
element.textContent = '';
|
||||
element.append(iframe);
|
||||
};
|
||||
|
||||
if ('customElements' in window) {
|
||||
customElements.define(
|
||||
'mermaid-embed',
|
||||
class MermaidEmbed extends HTMLElement {
|
||||
connectedCallback(): void {
|
||||
// Defer a macrotask so any inline text children are fully parsed before we read
|
||||
// them (the start tag connects before its text content when scripted upgrade
|
||||
// happens mid-parse). setTimeout is more reliable than rAF in headless contexts.
|
||||
// render() ignores repeat calls, so re-connects are harmless.
|
||||
setTimeout(() => render(this), 0);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,119 @@
|
||||
<script lang="ts">
|
||||
import { TID } from '$/constants';
|
||||
import type { PanZoomState } from '$/util/panZoom';
|
||||
import { renderAndPlaceDiagram } from '$/util/renderView';
|
||||
import FontAwesome, { mayContainFontAwesome } from '$lib/components/FontAwesome.svelte';
|
||||
import uniqueID from 'lodash-es/uniqueId';
|
||||
import type { MermaidConfig } from 'mermaid';
|
||||
|
||||
// Standalone, props-driven diagram canvas for the /embed widget.
|
||||
// Must never import the editor's state singleton: no localStorage, no URL
|
||||
// rewriting, no analytics.
|
||||
let {
|
||||
code,
|
||||
config,
|
||||
dark = false,
|
||||
grid = true,
|
||||
pan,
|
||||
panZoomState,
|
||||
rough = false,
|
||||
zoom
|
||||
}: {
|
||||
code: string;
|
||||
config: MermaidConfig;
|
||||
dark?: boolean;
|
||||
grid?: boolean;
|
||||
pan?: { x: number; y: number };
|
||||
panZoomState: PanZoomState;
|
||||
rough?: boolean;
|
||||
zoom?: number;
|
||||
} = $props();
|
||||
|
||||
let container: HTMLDivElement | undefined = $state();
|
||||
let error = $state<string | undefined>();
|
||||
let waitForFontAwesomeToLoad: FontAwesome['waitForFontAwesomeToLoad'] | undefined = $state();
|
||||
|
||||
const renderDiagram = async (
|
||||
currentCode: string,
|
||||
currentConfig: MermaidConfig,
|
||||
currentRough: boolean,
|
||||
currentPan: { x: number; y: number } | undefined,
|
||||
currentZoom: number | undefined
|
||||
) => {
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (mayContainFontAwesome(currentCode)) {
|
||||
await waitForFontAwesomeToLoad?.();
|
||||
}
|
||||
const { graphDiv } = await renderAndPlaceDiagram({
|
||||
code: currentCode,
|
||||
config: currentConfig,
|
||||
container,
|
||||
rough: currentRough,
|
||||
viewId: uniqueID('embed-graph-')
|
||||
});
|
||||
if (graphDiv) {
|
||||
panZoomState.updateElement(graphDiv, { pan: currentPan, zoom: currentZoom });
|
||||
}
|
||||
error = undefined;
|
||||
} catch (error_) {
|
||||
console.error('embed render failed', error_);
|
||||
error = error_ instanceof Error ? error_.message : String(error_);
|
||||
}
|
||||
};
|
||||
|
||||
// Queue renders to avoid racing when code/config change quickly. Each queued
|
||||
// render is skipped if a newer one superseded it while it waited (latest wins).
|
||||
let pendingRender = Promise.resolve();
|
||||
let renderSeq = 0;
|
||||
$effect(() => {
|
||||
// Read every render input synchronously so prop updates re-trigger the effect.
|
||||
const currentCode = code;
|
||||
const currentConfig = config;
|
||||
const currentRough = rough;
|
||||
const currentPan = pan;
|
||||
const currentZoom = zoom;
|
||||
void container;
|
||||
const seq = ++renderSeq;
|
||||
pendingRender = pendingRender.then(() => {
|
||||
if (seq !== renderSeq) {
|
||||
return;
|
||||
}
|
||||
return renderDiagram(currentCode, currentConfig, currentRough, currentPan, currentZoom);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<FontAwesome bind:waitForFontAwesomeToLoad />
|
||||
|
||||
<div
|
||||
id="embed-view"
|
||||
class={[
|
||||
'h-full w-full',
|
||||
grid && (dark ? 'grid-bg-dark' : 'grid-bg-light'),
|
||||
error && 'opacity-50'
|
||||
]}>
|
||||
<div id="embed-container" bind:this={container} class="h-full overflow-auto"></div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div
|
||||
data-testid={TID.embedRenderError}
|
||||
class="pointer-events-none absolute bottom-2 left-2 max-w-[80%] rounded-md border border-red-400 bg-red-50 px-3 py-1.5 font-mono text-xs text-red-700 dark:bg-red-950 dark:text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.grid-bg-light {
|
||||
background-size: 30px 30px;
|
||||
background-image: radial-gradient(circle, #e4e4e48c 2px, #0000 2px);
|
||||
}
|
||||
|
||||
.grid-bg-dark {
|
||||
background-size: 30px 30px;
|
||||
background-image: radial-gradient(circle, #46464646 2px, #0000 2px);
|
||||
}
|
||||
</style>
|
||||
@@ -3,13 +3,24 @@
|
||||
import { Button } from '$/components/ui/button';
|
||||
import { Separator } from '$/components/ui/separator';
|
||||
import type { PanZoomState } from '$/util/panZoom';
|
||||
import { urls } from '$/util/state.svelte';
|
||||
import ExpandIcon from '~icons/material-symbols/open-in-full-rounded';
|
||||
import ArrowsToCircleIcon from '~icons/material-symbols/screenshot-frame-2';
|
||||
import MagnifyingGlassPlusIcon from '~icons/material-symbols/zoom-in';
|
||||
import MagnifyingGlassMinusIcon from '~icons/material-symbols/zoom-out';
|
||||
|
||||
let { panZoomState }: { panZoomState: PanZoomState } = $props();
|
||||
let {
|
||||
/** Embed/narrow frames: keep zoom buttons visible below the `sm` breakpoint. */
|
||||
compact = false,
|
||||
fullScreenHref,
|
||||
panZoomState
|
||||
}: {
|
||||
compact?: boolean;
|
||||
/** When set, shows a "Full Screen" button linking here. Omit for store-free embeds. */
|
||||
fullScreenHref?: string;
|
||||
panZoomState: PanZoomState;
|
||||
} = $props();
|
||||
|
||||
const zoomClass = $derived(compact ? undefined : 'hidden sm:block');
|
||||
</script>
|
||||
|
||||
<FloatingToolbar>
|
||||
@@ -20,15 +31,23 @@
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="hidden sm:block"
|
||||
class={zoomClass}
|
||||
title="Zoom out"
|
||||
onclick={() => panZoomState.zoomOut()}>
|
||||
<MagnifyingGlassMinusIcon />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="hidden sm:block" onclick={() => panZoomState.zoomIn()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class={zoomClass}
|
||||
title="Zoom in"
|
||||
onclick={() => panZoomState.zoomIn()}>
|
||||
<MagnifyingGlassPlusIcon />
|
||||
</Button>
|
||||
<Separator orientation="vertical" class="hidden sm:block" />
|
||||
<Button variant="ghost" size="icon" title="Full Screen" href={urls.current.view} target="_blank">
|
||||
<ExpandIcon />
|
||||
</Button>
|
||||
{#if fullScreenHref}
|
||||
<Separator orientation="vertical" class={zoomClass} />
|
||||
<Button variant="ghost" size="icon" title="Full Screen" href={fullScreenHref} target="_blank">
|
||||
<ExpandIcon />
|
||||
</Button>
|
||||
{/if}
|
||||
</FloatingToolbar>
|
||||
@@ -1,18 +1,61 @@
|
||||
<script lang="ts">
|
||||
import { buttonVariants } from '$/components/ui/button';
|
||||
import * as Dialog from '$/components/ui/dialog';
|
||||
import { Input } from '$/components/ui/input';
|
||||
import { Separator } from '$/components/ui/separator';
|
||||
import { Switch } from '$/components/ui/switch';
|
||||
import * as ToggleGroup from '$/components/ui/toggle-group';
|
||||
import { MERMAID_LOOKS, MERMAID_THEMES, TID } from '$/constants';
|
||||
import { isDarkTheme, type EmbedMode } from '$/util/embed';
|
||||
import { EMBED_IFRAME_SANDBOX, buildEmbedSnippets, buildEmbedUrls } from '$/util/embedCode';
|
||||
import { env } from '$/util/env';
|
||||
import { urls } from '$/util/state.svelte';
|
||||
import { asset } from '$app/paths';
|
||||
import { silentlySanitizeConfig } from '$/util/sanitize';
|
||||
import { urls, validatedState } from '$/util/state.svelte';
|
||||
import { copyToClipboard } from '$/util/util';
|
||||
import { asset, base } from '$app/paths';
|
||||
import CodeIcon from '~icons/material-symbols/code';
|
||||
import ShareIcon from '~icons/material-symbols/share';
|
||||
import CopyButton from './CopyButton.svelte';
|
||||
import CopyInput from './CopyInput.svelte';
|
||||
import MermaidChartIcon from './MermaidChartIcon.svelte';
|
||||
|
||||
const sanitizedConfig = $derived(silentlySanitizeConfig(validatedState.current.mermaid));
|
||||
// Deliberate initial-value capture: the embed form seeds from the config at
|
||||
// mount time and then owns its values.
|
||||
// svelte-ignore state_referenced_locally
|
||||
const initialConfig = sanitizedConfig;
|
||||
const initialTheme = (initialConfig.theme as string | undefined) ?? 'default';
|
||||
let theme = $state(initialTheme);
|
||||
let look = $state((initialConfig.look as string | undefined) ?? 'classic');
|
||||
let mode = $state<EmbedMode>(isDarkTheme(initialTheme) ? 'dark' : 'light');
|
||||
let controls = $state(true);
|
||||
let grid = $state(true);
|
||||
let width = $state('100%');
|
||||
let height = $state('480');
|
||||
let format = $state<'iframe' | 'webComponent'>('iframe');
|
||||
let showPreview = $state(false);
|
||||
|
||||
// The expensive half (config sanitize + pako serialize) is independent of
|
||||
// width/height, so typing in the size inputs only re-runs the snippet strings.
|
||||
const embedUrls = $derived(
|
||||
buildEmbedUrls({
|
||||
code: validatedState.current.code,
|
||||
config: sanitizedConfig,
|
||||
controls,
|
||||
grid,
|
||||
host: window.location.origin + base,
|
||||
look,
|
||||
mode,
|
||||
theme
|
||||
})
|
||||
);
|
||||
const snippets = $derived(buildEmbedSnippets(embedUrls, { height, width }));
|
||||
const snippet = $derived(format === 'webComponent' ? snippets.webComponent : snippets.iframe);
|
||||
</script>
|
||||
|
||||
<Dialog.Root>
|
||||
<Dialog.Trigger class={buttonVariants({ size: 'sm' })}>Share</Dialog.Trigger>
|
||||
<Dialog.Content>
|
||||
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="flex items-center gap-2 text-xl">
|
||||
<ShareIcon class="size-5" /> Shareable links
|
||||
@@ -44,6 +87,103 @@
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
{/if}
|
||||
<Separator />
|
||||
<div class="flex flex-col gap-3">
|
||||
<h2 class="flex items-center gap-2">
|
||||
<CodeIcon class="size-5" />
|
||||
Embed
|
||||
</h2>
|
||||
<Dialog.Description>
|
||||
Embed a live, interactive diagram in your own website or blog.
|
||||
</Dialog.Description>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
Theme
|
||||
<select
|
||||
bind:value={theme}
|
||||
class="h-9 rounded-md border border-input bg-background px-2 text-sm text-foreground">
|
||||
{#each MERMAID_THEMES as themeName (themeName)}
|
||||
<option value={themeName}>{themeName}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
Look
|
||||
<select
|
||||
bind:value={look}
|
||||
class="h-9 rounded-md border border-input bg-background px-2 text-sm text-foreground">
|
||||
{#each MERMAID_LOOKS as lookName (lookName)}
|
||||
<option value={lookName}>{lookName}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
Width
|
||||
<Input bind:value={width} />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
Height
|
||||
<Input bind:value={height} />
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<ToggleGroup.Root
|
||||
type="single"
|
||||
variant="outline"
|
||||
value={mode}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'light' || value === 'dark') {
|
||||
mode = value;
|
||||
}
|
||||
}}>
|
||||
<ToggleGroup.Item value="light">Light</ToggleGroup.Item>
|
||||
<ToggleGroup.Item value="dark">Dark</ToggleGroup.Item>
|
||||
</ToggleGroup.Root>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<Switch bind:checked={controls} />
|
||||
Controls
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<Switch bind:checked={grid} />
|
||||
Grid
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<Switch bind:checked={showPreview} />
|
||||
Preview
|
||||
</label>
|
||||
</div>
|
||||
{#if showPreview}
|
||||
<iframe
|
||||
data-testid={TID.embedPreview}
|
||||
src={embedUrls.url}
|
||||
title="Embed preview"
|
||||
class="h-52 w-full rounded-lg border"
|
||||
sandbox={EMBED_IFRAME_SANDBOX}></iframe>
|
||||
{/if}
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<ToggleGroup.Root
|
||||
type="single"
|
||||
variant="outline"
|
||||
value={format}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'iframe' || value === 'webComponent') {
|
||||
format = value;
|
||||
}
|
||||
}}>
|
||||
<ToggleGroup.Item value="iframe">iframe</ToggleGroup.Item>
|
||||
<ToggleGroup.Item value="webComponent">Web component</ToggleGroup.Item>
|
||||
</ToggleGroup.Root>
|
||||
<textarea
|
||||
data-testid={TID.embedSnippet}
|
||||
readonly
|
||||
rows="4"
|
||||
class="w-full rounded-md border border-input bg-background p-2 font-mono text-xs text-foreground"
|
||||
value={snippet}></textarea>
|
||||
</div>
|
||||
<CopyButton onclick={() => copyToClipboard(snippet)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import type { State, ValidatedState } from '$/types';
|
||||
import { recordRenderTime, shouldRefreshView } from '$/util/autoSync';
|
||||
import { render as renderDiagram } from '$/util/mermaid';
|
||||
import { PanZoomState } from '$/util/panZoom';
|
||||
import { renderAndPlaceDiagram } from '$/util/renderView';
|
||||
import { updateCodeStore, validatedState } from '$/util/state.svelte';
|
||||
import { saveStatistics } from '$/util/stats';
|
||||
import FontAwesome, { mayContainFontAwesome } from '$lib/components/FontAwesome.svelte';
|
||||
@@ -10,7 +10,6 @@
|
||||
import type { MermaidConfig } from 'mermaid';
|
||||
import { mode } from 'mode-watcher';
|
||||
import { onMount } from 'svelte';
|
||||
import { Svg2Roughjs } from 'svg2roughjs';
|
||||
|
||||
let {
|
||||
panZoomState = new PanZoomState(),
|
||||
@@ -76,48 +75,16 @@
|
||||
}
|
||||
|
||||
const scroll = view?.parentElement?.scrollTop;
|
||||
delete container.dataset.processed;
|
||||
const viewID = uniqueID('graph-');
|
||||
const {
|
||||
svg,
|
||||
bindFunctions,
|
||||
diagramType: detectedDiagramType
|
||||
} = await renderDiagram(JSON.parse(state.mermaid) as MermaidConfig, code, viewID);
|
||||
const { diagramType: detectedDiagramType, graphDiv } = await renderAndPlaceDiagram({
|
||||
code,
|
||||
config: JSON.parse(state.mermaid) as MermaidConfig,
|
||||
container,
|
||||
rough: state.rough,
|
||||
viewId: uniqueID('graph-')
|
||||
});
|
||||
diagramType = detectedDiagramType;
|
||||
if (svg.length > 0) {
|
||||
// eslint-disable-next-line svelte/no-dom-manipulating
|
||||
container.innerHTML = svg;
|
||||
let graphDiv = document.querySelector<SVGSVGElement>(`#${viewID}`);
|
||||
if (!graphDiv) {
|
||||
throw new Error('graph-div not found');
|
||||
}
|
||||
if (state.rough) {
|
||||
const svg2roughjs = new Svg2Roughjs('#container');
|
||||
svg2roughjs.svg = graphDiv;
|
||||
await svg2roughjs.sketch();
|
||||
graphDiv.remove();
|
||||
const sketch = document.querySelector<SVGSVGElement>('#container > svg');
|
||||
if (!sketch) {
|
||||
throw new Error('sketch not found');
|
||||
}
|
||||
const height = sketch.getAttribute('height');
|
||||
const width = sketch.getAttribute('width');
|
||||
sketch.setAttribute('id', 'graph-div');
|
||||
sketch.setAttribute('height', '100%');
|
||||
sketch.setAttribute('width', '100%');
|
||||
sketch.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
||||
sketch.style.maxWidth = '100%';
|
||||
graphDiv = sketch;
|
||||
} else {
|
||||
graphDiv.setAttribute('height', '100%');
|
||||
graphDiv.style.maxWidth = '100%';
|
||||
if (bindFunctions) {
|
||||
bindFunctions(graphDiv);
|
||||
}
|
||||
}
|
||||
if (state.panZoom) {
|
||||
handlePanZoom(state, graphDiv);
|
||||
}
|
||||
if (graphDiv && state.panZoom) {
|
||||
handlePanZoom(state, graphDiv);
|
||||
}
|
||||
if (view?.parentElement && scroll) {
|
||||
view.parentElement.scrollTop = scroll;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { State } from './types';
|
||||
|
||||
export const TID = {
|
||||
aiHelpText: 'ai-help-text',
|
||||
aiRepairButton: 'ai-repair-button',
|
||||
@@ -5,6 +7,15 @@ export const TID = {
|
||||
diagramDocumentationButton: 'diagram-documentation-button',
|
||||
downloadPNG: 'download-PNG',
|
||||
downloadSVG: 'download-SVG',
|
||||
embedEditLink: 'embed-edit-link',
|
||||
embedErrorCard: 'embed-error-card',
|
||||
embedFooter: 'embed-footer',
|
||||
embedModeToggle: 'embed-mode-toggle',
|
||||
embedPreview: 'embed-preview',
|
||||
embedRenderError: 'embed-render-error',
|
||||
embedSaveLink: 'embed-save-link',
|
||||
embedSnippet: 'embed-snippet',
|
||||
embedToolbar: 'embed-toolbar',
|
||||
errorContainer: 'error-container',
|
||||
themeToggleButton: 'theme-toggle-button'
|
||||
} as const;
|
||||
@@ -14,3 +25,33 @@ export const C = {
|
||||
editorChooserDismissedKey: 'mermaid-editor-chooser-dismissed',
|
||||
utmSource: 'mermaid_live_editor'
|
||||
} as const;
|
||||
|
||||
export const MERMAID_THEMES = [
|
||||
'default',
|
||||
'neutral',
|
||||
'forest',
|
||||
'dark',
|
||||
'neo',
|
||||
'neo-dark',
|
||||
'redux',
|
||||
'redux-dark',
|
||||
'redux-color',
|
||||
'redux-dark-color'
|
||||
] as const;
|
||||
|
||||
export const MERMAID_LOOKS = ['classic', 'handDrawn', 'neo'] as const;
|
||||
|
||||
export const defaultState: State = {
|
||||
code: `flowchart TD
|
||||
A[Christmas] -->|Get money| B(Go shopping)
|
||||
B --> C{Let me think}
|
||||
C -->|One| D[Laptop]
|
||||
C -->|Two| E[iPhone]
|
||||
C -->|Three| F[fa:fa-car Car]
|
||||
`,
|
||||
grid: true,
|
||||
mermaid: JSON.stringify({ theme: 'default' }, undefined, 2),
|
||||
panZoom: true,
|
||||
rough: false,
|
||||
updateDiagram: true
|
||||
};
|
||||
@@ -0,0 +1,183 @@
|
||||
import { defaultState } from '$/constants';
|
||||
import type { State } from '$/types';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildEditUrl, resolveEmbedSettings, serializeEmbedState, toggleMode } from './embed';
|
||||
import { deserializeState, serializeState } from './serde';
|
||||
|
||||
const embedUrl = (state?: Partial<State>, params?: Record<string, string>): URL => {
|
||||
const query = params ? `?${new URLSearchParams(params).toString()}` : '';
|
||||
const hash = state ? `#${serializeState({ ...defaultState, ...state })}` : '';
|
||||
return new URL(`https://mermaid.live/embed${query}${hash}`);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// The embed resolver must never fall back to the interactive confirm dialog.
|
||||
vi.stubGlobal('confirm', () => {
|
||||
throw new Error('resolveEmbedSettings must not call confirm()');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveEmbedSettings', () => {
|
||||
it('should return defaults for a bare /embed URL', () => {
|
||||
const { error, settings } = resolveEmbedSettings(embedUrl());
|
||||
expect(error).toBeUndefined();
|
||||
expect(settings).toBeDefined();
|
||||
expect(settings?.code).toBe(defaultState.code);
|
||||
expect(settings?.theme).toBe('default');
|
||||
expect(settings?.look).toBe('classic');
|
||||
expect(settings?.mode).toBe('light');
|
||||
expect(settings?.controls).toBe(true);
|
||||
expect(settings?.grid).toBe(true);
|
||||
expect(settings?.rough).toBe(false);
|
||||
expect(settings?.config.securityLevel).toBe('strict');
|
||||
});
|
||||
|
||||
it('should load code and config from the hash state', () => {
|
||||
const { settings } = resolveEmbedSettings(
|
||||
embedUrl({
|
||||
code: 'graph TD\n A-->B',
|
||||
grid: false,
|
||||
mermaid: JSON.stringify({ theme: 'dark' }),
|
||||
pan: { x: 5, y: 6 },
|
||||
rough: true,
|
||||
zoom: 1.5
|
||||
})
|
||||
);
|
||||
expect(settings?.code).toBe('graph TD\n A-->B');
|
||||
expect(settings?.theme).toBe('dark');
|
||||
expect(settings?.mode).toBe('dark');
|
||||
expect(settings?.grid).toBe(false);
|
||||
expect(settings?.rough).toBe(true);
|
||||
expect(settings?.pan).toEqual({ x: 5, y: 6 });
|
||||
expect(settings?.zoom).toBe(1.5);
|
||||
});
|
||||
|
||||
it('should let query params override the hash state', () => {
|
||||
const { settings } = resolveEmbedSettings(
|
||||
embedUrl(
|
||||
{ code: 'graph TD\n A-->B', mermaid: JSON.stringify({ theme: 'dark' }) },
|
||||
{
|
||||
look: 'handDrawn',
|
||||
pan: '1,2',
|
||||
theme: 'forest',
|
||||
zoom: '2'
|
||||
}
|
||||
)
|
||||
);
|
||||
expect(settings?.code).toBe('graph TD\n A-->B');
|
||||
expect(settings?.theme).toBe('forest');
|
||||
expect(settings?.look).toBe('handDrawn');
|
||||
expect(settings?.mode).toBe('light');
|
||||
expect(settings?.pan).toEqual({ x: 1, y: 2 });
|
||||
expect(settings?.zoom).toBe(2);
|
||||
expect(settings?.config.theme).toBe('forest');
|
||||
expect(settings?.config.look).toBe('handDrawn');
|
||||
});
|
||||
|
||||
it('should merge ?config= over the hash config', () => {
|
||||
const { settings } = resolveEmbedSettings(
|
||||
embedUrl(
|
||||
{ mermaid: JSON.stringify({ fontSize: 12, theme: 'dark' }) },
|
||||
{ config: JSON.stringify({ theme: 'neutral' }) }
|
||||
)
|
||||
);
|
||||
expect(settings?.config.fontSize).toBe(12);
|
||||
expect(settings?.theme).toBe('neutral');
|
||||
});
|
||||
|
||||
it('should let an explicit ?mode= override the theme inference', () => {
|
||||
const { settings } = resolveEmbedSettings(
|
||||
embedUrl(undefined, { mode: 'light', theme: 'redux-dark' })
|
||||
);
|
||||
expect(settings?.theme).toBe('redux-dark');
|
||||
expect(settings?.mode).toBe('light');
|
||||
});
|
||||
|
||||
it('should infer dark mode from a dark theme name', () => {
|
||||
const { settings } = resolveEmbedSettings(embedUrl(undefined, { theme: 'neo-dark' }));
|
||||
expect(settings?.mode).toBe('dark');
|
||||
});
|
||||
|
||||
it('should disable controls and grid with =0', () => {
|
||||
const { settings } = resolveEmbedSettings(embedUrl(undefined, { controls: '0', grid: '0' }));
|
||||
expect(settings?.controls).toBe(false);
|
||||
expect(settings?.grid).toBe(false);
|
||||
});
|
||||
|
||||
it('should ignore malformed pan values', () => {
|
||||
const { settings } = resolveEmbedSettings(embedUrl({ pan: { x: 3, y: 4 } }, { pan: 'a,b' }));
|
||||
expect(settings?.pan).toEqual({ x: 3, y: 4 });
|
||||
});
|
||||
|
||||
it('should report an error for a malformed hash', () => {
|
||||
const { error, settings } = resolveEmbedSettings(
|
||||
new URL('https://mermaid.live/embed#pako:garbage')
|
||||
);
|
||||
expect(error).toBeDefined();
|
||||
expect(settings).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should load diagram source from a #code: hash (web component body path)', () => {
|
||||
const { error, settings } = resolveEmbedSettings(
|
||||
new URL('https://mermaid.live/embed?theme=forest#code:graph%20TD%0A%20%20A--%3EB')
|
||||
);
|
||||
expect(error).toBeUndefined();
|
||||
expect(settings?.code).toBe('graph TD\n A-->B');
|
||||
expect(settings?.theme).toBe('forest');
|
||||
});
|
||||
|
||||
it('should ignore a leftover ?code= query param', () => {
|
||||
const { settings } = resolveEmbedSettings(
|
||||
new URL(
|
||||
'https://mermaid.live/embed?code=pie%0A%20%20%22a%22%3A%201#code:graph%20TD%0A%20%20A--%3EB'
|
||||
)
|
||||
);
|
||||
expect(settings?.code).toBe('graph TD\n A-->B');
|
||||
});
|
||||
|
||||
it('should silently strip unsafe config from the hash', () => {
|
||||
const { settings } = resolveEmbedSettings(
|
||||
embedUrl({
|
||||
mermaid: JSON.stringify({
|
||||
securityLevel: 'loose',
|
||||
theme: 'dark',
|
||||
themeVariables: { nodeBorder: '<script>alert(1)</script>' }
|
||||
})
|
||||
})
|
||||
);
|
||||
expect(settings?.config.securityLevel).toBe('strict');
|
||||
expect(settings?.config.themeVariables).toEqual({});
|
||||
expect(settings?.config.theme).toBe('dark');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleMode', () => {
|
||||
it('should flip only the chrome mode', () => {
|
||||
const { settings } = resolveEmbedSettings(embedUrl(undefined, { theme: 'dark' }));
|
||||
expect(settings).toBeDefined();
|
||||
if (!settings) {
|
||||
return;
|
||||
}
|
||||
const toggled = toggleMode(settings);
|
||||
expect(toggled.mode).toBe('light');
|
||||
expect(toggled.theme).toBe('dark');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildEditUrl', () => {
|
||||
it('should carry the resolved state to the editor', () => {
|
||||
const { settings } = resolveEmbedSettings(
|
||||
embedUrl({ code: 'graph TD\n A-->B' }, { theme: 'forest' })
|
||||
);
|
||||
expect(settings).toBeDefined();
|
||||
if (!settings) {
|
||||
return;
|
||||
}
|
||||
const url = buildEditUrl(serializeEmbedState(settings), 'https://mermaid.live/edit');
|
||||
expect(url).toMatch(/^https:\/\/mermaid\.live\/edit#pako:/);
|
||||
const state = deserializeState(url.split('#')[1]);
|
||||
expect(state.code).toBe('graph TD\n A-->B');
|
||||
expect(state.panZoom).toBe(true);
|
||||
expect(JSON.parse(state.mermaid)).toMatchObject({ securityLevel: 'strict', theme: 'forest' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import { C, defaultState } from '$/constants';
|
||||
import type { State } from '$/types';
|
||||
import type { MermaidConfig } from 'mermaid';
|
||||
import { MCBaseURL } from './env';
|
||||
import { isOnMermaidAI } from './migration/domainMigration';
|
||||
import { silentlySanitizeConfig } from './sanitize';
|
||||
import { deserializeState, serializeState } from './serde';
|
||||
|
||||
/** Widget chrome appearance — the independent axis from the diagram theme/look. */
|
||||
export type EmbedMode = 'dark' | 'light';
|
||||
|
||||
export interface EmbedSettings {
|
||||
code: string;
|
||||
/** Mermaid config actually rendered (includes `theme` + `look`, sanitized, securityLevel strict). */
|
||||
config: MermaidConfig;
|
||||
/** Whether interactive controls (pan/zoom buttons + mode switcher) are shown. */
|
||||
controls: boolean;
|
||||
/** Whether the dotted background is drawn. */
|
||||
grid: boolean;
|
||||
/** Mermaid `look`: classic | handDrawn | neo. */
|
||||
look: string;
|
||||
/** Widget chrome appearance (light/dark page), independent of the diagram theme. */
|
||||
mode: EmbedMode;
|
||||
pan?: { x: number; y: number };
|
||||
/** Hand-drawn (Svg2Roughjs) rendering, from the hash state. */
|
||||
rough: boolean;
|
||||
/** Mermaid diagram theme, e.g. default | dark | neo | redux-dark. */
|
||||
theme: string;
|
||||
zoom?: number;
|
||||
}
|
||||
|
||||
export interface ResolvedEmbed {
|
||||
error?: string;
|
||||
settings?: EmbedSettings;
|
||||
}
|
||||
|
||||
/** A diagram theme is "dark" if its name contains "dark" (dark, redux-dark, neo-dark, …). */
|
||||
export const isDarkTheme = (theme: string): boolean => /dark/i.test(theme);
|
||||
|
||||
/** Widget settings that travel in the query string. Decoded by {@link resolveEmbedSettings}. */
|
||||
export interface EmbedParams {
|
||||
controls?: boolean;
|
||||
grid?: boolean;
|
||||
look?: string;
|
||||
mode?: EmbedMode;
|
||||
theme?: string;
|
||||
}
|
||||
|
||||
/** Encode settings as query params (only non-defaults) — the inverse of {@link resolveEmbedSettings}. */
|
||||
export const encodeEmbedParams = ({ controls, grid, look, mode, theme }: EmbedParams): string => {
|
||||
const query = new URLSearchParams();
|
||||
if (theme) {
|
||||
query.set('theme', theme);
|
||||
}
|
||||
if (look) {
|
||||
query.set('look', look);
|
||||
}
|
||||
if (mode) {
|
||||
query.set('mode', mode);
|
||||
}
|
||||
if (controls === false) {
|
||||
query.set('controls', '0');
|
||||
}
|
||||
if (grid === false) {
|
||||
query.set('grid', '0');
|
||||
}
|
||||
return query.toString();
|
||||
};
|
||||
|
||||
const parsePan = (value: string | null): { x: number; y: number } | undefined => {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const [x, y] = value.split(',').map(Number);
|
||||
return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the widget settings from an embed URL.
|
||||
*
|
||||
* Precedence (highest first): `#code:` (web component body) >
|
||||
* hash `#pako:`/`#base64:` state > defaults.
|
||||
* Diagram source never travels in the query string.
|
||||
*
|
||||
* Unlike the editor's state loading this never asks the user anything: unsafe
|
||||
* config is silently stripped and `securityLevel` is forced to `strict`.
|
||||
*/
|
||||
export const resolveEmbedSettings = (url: URL): ResolvedEmbed => {
|
||||
const q = url.searchParams;
|
||||
|
||||
// Base state / raw code from the hash, if any.
|
||||
const hash = url.hash.replace(/^#/, '');
|
||||
let baseState: State | undefined;
|
||||
let hashCode: string | undefined;
|
||||
let hashFailed = false;
|
||||
if (hash.startsWith('code:')) {
|
||||
try {
|
||||
hashCode = decodeURIComponent(hash.slice('code:'.length));
|
||||
} catch {
|
||||
hashFailed = true;
|
||||
}
|
||||
} else if (hash) {
|
||||
try {
|
||||
baseState = deserializeState(hash);
|
||||
} catch {
|
||||
hashFailed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hashFailed) {
|
||||
return { error: 'Unable to load the diagram from this URL.' };
|
||||
}
|
||||
|
||||
const code = hashCode ?? (baseState?.code || defaultState.code);
|
||||
|
||||
const config: MermaidConfig = {
|
||||
...silentlySanitizeConfig(baseState?.mermaid),
|
||||
...silentlySanitizeConfig(q.get('config') ?? undefined)
|
||||
};
|
||||
|
||||
// Diagram theme + look (Mermaid config). Query overrides hash state; then defaults.
|
||||
const theme = q.get('theme') ?? (config.theme as string | undefined) ?? 'default';
|
||||
const look = q.get('look') ?? (config.look as string | undefined) ?? 'classic';
|
||||
config.theme = theme as MermaidConfig['theme'];
|
||||
config.look = look as MermaidConfig['look'];
|
||||
config.securityLevel = 'strict';
|
||||
|
||||
// Chrome appearance (independent). Explicit ?mode= wins; otherwise follow the theme's darkness.
|
||||
const modeParam = q.get('mode');
|
||||
const mode: EmbedMode =
|
||||
modeParam === 'dark' || modeParam === 'light'
|
||||
? modeParam
|
||||
: isDarkTheme(theme)
|
||||
? 'dark'
|
||||
: 'light';
|
||||
|
||||
const controls = q.get('controls') !== '0';
|
||||
const grid = q.get('grid') !== '0' && baseState?.grid !== false;
|
||||
const rough = baseState?.rough ?? false;
|
||||
|
||||
const pan = parsePan(q.get('pan')) ?? baseState?.pan;
|
||||
const zoomParam = q.get('zoom');
|
||||
const zoom =
|
||||
zoomParam !== null && Number.isFinite(Number(zoomParam)) ? Number(zoomParam) : baseState?.zoom;
|
||||
|
||||
return { settings: { code, config, controls, grid, look, mode, pan, rough, theme, zoom } };
|
||||
};
|
||||
|
||||
/** Toggle the widget chrome appearance (does not touch the diagram theme — independent axis). */
|
||||
export const toggleMode = (settings: EmbedSettings): EmbedSettings => {
|
||||
return { ...settings, mode: settings.mode === 'dark' ? 'light' : 'dark' };
|
||||
};
|
||||
|
||||
/** Build a shareable State from the current settings (for the footer links). */
|
||||
const buildEditState = (settings: EmbedSettings): State => {
|
||||
return {
|
||||
...defaultState,
|
||||
code: settings.code,
|
||||
grid: settings.grid,
|
||||
mermaid: JSON.stringify(settings.config, undefined, 2),
|
||||
pan: settings.pan,
|
||||
rough: settings.rough,
|
||||
zoom: settings.zoom
|
||||
};
|
||||
};
|
||||
|
||||
/** Serialize the resolved settings as editor-hash state, shared by the footer links. */
|
||||
export const serializeEmbedState = (settings: EmbedSettings): string => {
|
||||
return serializeState(buildEditState(settings));
|
||||
};
|
||||
|
||||
/** "Edit" link → this site's own editor, carrying the state in the hash. */
|
||||
export const buildEditUrl = (serialized: string, editBase: string): string => {
|
||||
return `${editBase}#${serialized}`;
|
||||
};
|
||||
|
||||
/** "Save in Mermaid Chart" link → mermaid.ai, carrying the serialized state. */
|
||||
export const buildSaveUrl = (serialized: string): string => {
|
||||
const utmSource = isOnMermaidAI() ? C.aiLiveEditor : C.utmSource;
|
||||
return `${MCBaseURL}/app/plugin/save?state=${encodeURIComponent(serialized)}&utm_source=${utmSource}&utm_medium=embed`;
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildEmbedCode } from './embedCode';
|
||||
import { deserializeState } from './serde';
|
||||
|
||||
const code = 'graph TD\n A-->B';
|
||||
const host = 'https://mermaid.live';
|
||||
|
||||
describe('buildEmbedCode', () => {
|
||||
it('should build a canonical embed URL with the state in the hash', () => {
|
||||
const { url } = buildEmbedCode({ code, host, theme: 'forest' });
|
||||
expect(url).toMatch(/^https:\/\/mermaid\.live\/embed\?theme=forest#pako:/);
|
||||
const state = deserializeState(url.split('#')[1]);
|
||||
expect(state.code).toBe(code);
|
||||
expect(JSON.parse(state.mermaid)).toEqual({ theme: 'forest' });
|
||||
});
|
||||
|
||||
it('should keep the caller-provided config in the hash state', () => {
|
||||
const { url } = buildEmbedCode({
|
||||
code,
|
||||
config: { themeVariables: { fontSize: '14px' } },
|
||||
host,
|
||||
theme: 'dark'
|
||||
});
|
||||
const state = deserializeState(url.split('#')[1]);
|
||||
expect(JSON.parse(state.mermaid)).toEqual({
|
||||
theme: 'dark',
|
||||
themeVariables: { fontSize: '14px' }
|
||||
});
|
||||
});
|
||||
|
||||
it('should omit query params for default settings', () => {
|
||||
const { url } = buildEmbedCode({ code, host });
|
||||
expect(url).toMatch(/^https:\/\/mermaid\.live\/embed#pako:/);
|
||||
});
|
||||
|
||||
it('should encode disabled controls and grid as =0 params', () => {
|
||||
const { url } = buildEmbedCode({ code, controls: false, grid: false, host });
|
||||
expect(url).toContain('controls=0');
|
||||
expect(url).toContain('grid=0');
|
||||
const state = deserializeState(url.split('#')[1]);
|
||||
expect(state.grid).toBe(false);
|
||||
});
|
||||
|
||||
it('should include mode when set', () => {
|
||||
const { url } = buildEmbedCode({ code, host, mode: 'dark' });
|
||||
expect(url).toContain('mode=dark');
|
||||
});
|
||||
|
||||
it('should build an iframe snippet with sensible defaults and a sandbox', () => {
|
||||
const { iframe, url } = buildEmbedCode({ code, host });
|
||||
expect(iframe).toBe(
|
||||
`<iframe src="${url}" width="100%" height="480" ` +
|
||||
`style="border:0" loading="lazy" title="Mermaid diagram" ` +
|
||||
`sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox"></iframe>`
|
||||
);
|
||||
});
|
||||
|
||||
it('should respect custom width and height', () => {
|
||||
const { iframe, webComponent } = buildEmbedCode({ code, height: '300', host, width: '600' });
|
||||
expect(iframe).toContain('width="600" height="300"');
|
||||
expect(webComponent).toContain('width="600" height="300"');
|
||||
});
|
||||
|
||||
it('should fall back to defaults for unsafe width/height', () => {
|
||||
const { iframe } = buildEmbedCode({
|
||||
code,
|
||||
height: '480" onload="alert(1)',
|
||||
host,
|
||||
width: '"><script>'
|
||||
});
|
||||
expect(iframe).toContain('width="100%" height="480"');
|
||||
expect(iframe).not.toContain('onload');
|
||||
expect(iframe).not.toContain('<script>');
|
||||
});
|
||||
|
||||
it('should build a web component snippet with the code inline', () => {
|
||||
const { webComponent } = buildEmbedCode({ code, host });
|
||||
expect(webComponent).toContain(`<script src="${host}/embed.js" async>`);
|
||||
expect(webComponent).toContain(`<mermaid-embed src="${host}/embed" height="480">`);
|
||||
expect(webComponent).toContain(code);
|
||||
});
|
||||
|
||||
it('should HTML-escape the inline web component body', () => {
|
||||
const { webComponent } = buildEmbedCode({ code: 'graph TD\n A["a < b & c"]-->B', host });
|
||||
expect(webComponent).toContain('A["a < b & c"]');
|
||||
expect(webComponent).not.toContain('a < b');
|
||||
});
|
||||
|
||||
it('should strip trailing slashes from the host', () => {
|
||||
const { url } = buildEmbedCode({ code, host: 'https://mermaid.live/' });
|
||||
expect(url).toMatch(/^https:\/\/mermaid\.live\/embed#/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { defaultState } from '$/constants';
|
||||
import type { MermaidConfig } from 'mermaid';
|
||||
import { encodeEmbedParams, type EmbedMode } from './embed';
|
||||
import { serializeState } from './serde';
|
||||
|
||||
/**
|
||||
* Sandbox for embed iframes.
|
||||
*
|
||||
* `allow-same-origin` is required: without it the iframe gets an opaque origin and the
|
||||
* SvelteKit `/embed` app fails to boot. Isolation from editor `localStorage` is enforced
|
||||
* in `/embed` by never importing the state singleton (covered by e2e), not by opaque origin.
|
||||
* The sandbox still blocks top-navigation and other powerful features.
|
||||
*
|
||||
* Keep in sync with `IFRAME_SANDBOX` in `src/embed-loader.ts`.
|
||||
*/
|
||||
export const EMBED_IFRAME_SANDBOX =
|
||||
'allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox';
|
||||
|
||||
/** CSS length values safe to interpolate into HTML width/height attributes. */
|
||||
const SAFE_DIMENSION = /^(?:\d+(?:\.\d+)?(?:%|px|rem|em|vh|vw)?)$/i;
|
||||
|
||||
/**
|
||||
* Options accepted by {@link buildEmbedUrls}.
|
||||
* Everything except `code` and `host` is optional and falls back to a sensible default.
|
||||
*/
|
||||
export interface EmbedUrlOptions {
|
||||
/** The Mermaid diagram source. Required. */
|
||||
code: string;
|
||||
/** Base mermaid config carried into the hash state (already sanitized by the caller). */
|
||||
config?: MermaidConfig;
|
||||
/** Show interactive controls (pan/zoom buttons + mode switcher). Default true. */
|
||||
controls?: boolean;
|
||||
/** Draw the dotted background. Default true. */
|
||||
grid?: boolean;
|
||||
/** Origin (plus base path) that hosts the widget, e.g. https://mermaid.live. Required. */
|
||||
host: string;
|
||||
/** Mermaid look: classic | handDrawn | neo. */
|
||||
look?: string;
|
||||
/** Widget chrome appearance: light | dark (independent of the diagram theme). */
|
||||
mode?: EmbedMode;
|
||||
/** Mermaid diagram theme, e.g. default | dark | neo | redux-dark. */
|
||||
theme?: string;
|
||||
}
|
||||
|
||||
/** Options accepted by {@link buildEmbedCode}: the URL options plus the snippet dimensions. */
|
||||
export interface EmbedCodeOptions extends EmbedUrlOptions {
|
||||
/** iframe / web-component height (CSS value or number). Default '480'. */
|
||||
height?: string;
|
||||
/** iframe width (CSS value or number). Default '100%'. */
|
||||
width?: string;
|
||||
}
|
||||
|
||||
export interface EmbedUrls {
|
||||
/** Diagram source, carried through for the web-component body. */
|
||||
code: string;
|
||||
/** Normalized host (no trailing slash). */
|
||||
host: string;
|
||||
/** Widget URL with settings only (no hash) — used by the web component. */
|
||||
settingsUrl: string;
|
||||
/** Canonical embed URL (settings as query params + state in the `#pako:` hash). */
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface EmbedCodeResult {
|
||||
/** Ready-to-paste raw <iframe> snippet (code travels in the URL). */
|
||||
iframe: string;
|
||||
/** Canonical embed URL (settings as query params + state in the `#pako:` hash). */
|
||||
url: string;
|
||||
/** Ready-to-paste <script> + <mermaid-embed> snippet (code in the element body). */
|
||||
webComponent: string;
|
||||
}
|
||||
|
||||
const dimension = (value: string | undefined, fallback: string): string => {
|
||||
const trimmed = value?.trim() ?? '';
|
||||
return trimmed && SAFE_DIMENSION.test(trimmed) ? trimmed : fallback;
|
||||
};
|
||||
|
||||
/** The inline body is parsed back via textContent, which decodes entities. */
|
||||
const escapeHtml = (text: string): string => {
|
||||
return text.replaceAll('&', '&').replaceAll('<', '<');
|
||||
};
|
||||
|
||||
/** Resolve the embed URLs — the expensive half (state serialization) of {@link buildEmbedCode}. */
|
||||
export const buildEmbedUrls = (options: EmbedUrlOptions): EmbedUrls => {
|
||||
const host = options.host.replace(/\/+$/, '');
|
||||
const grid = options.grid !== false;
|
||||
|
||||
// Settings live in the query string (so they override the hash state).
|
||||
const qs = encodeEmbedParams({
|
||||
controls: options.controls !== false,
|
||||
grid,
|
||||
look: options.look,
|
||||
mode: options.mode,
|
||||
theme: options.theme
|
||||
});
|
||||
|
||||
const base = `${host}/embed`;
|
||||
const settingsUrl = qs ? `${base}?${qs}` : base;
|
||||
|
||||
// Full state for the pako hash (code + config) — used by the raw iframe and
|
||||
// keeps the editor "Edit" link faithful.
|
||||
const config: MermaidConfig = { ...options.config };
|
||||
if (options.theme) {
|
||||
config.theme = options.theme as MermaidConfig['theme'];
|
||||
}
|
||||
if (options.look) {
|
||||
config.look = options.look as MermaidConfig['look'];
|
||||
}
|
||||
const state = {
|
||||
...defaultState,
|
||||
code: options.code,
|
||||
grid,
|
||||
mermaid: JSON.stringify(config)
|
||||
};
|
||||
const url = `${settingsUrl}#${serializeState(state)}`;
|
||||
|
||||
return { code: options.code, host, settingsUrl, url };
|
||||
};
|
||||
|
||||
/** Format the copyable snippets — the cheap half, safe to re-run per size keystroke. */
|
||||
export const buildEmbedSnippets = (
|
||||
urls: EmbedUrls,
|
||||
size: { height?: string; width?: string }
|
||||
): { iframe: string; webComponent: string } => {
|
||||
const width = dimension(size.width, '100%');
|
||||
const height = dimension(size.height, '480');
|
||||
|
||||
const iframe =
|
||||
`<iframe src="${urls.url}" width="${width}" height="${height}" ` +
|
||||
`style="border:0" loading="lazy" title="Mermaid diagram" ` +
|
||||
`sandbox="${EMBED_IFRAME_SANDBOX}"></iframe>`;
|
||||
|
||||
const widthAttribute = width === '100%' ? '' : ` width="${width}"`;
|
||||
const webComponent =
|
||||
`<script src="${urls.host}/embed.js" async></script>\n` +
|
||||
`<mermaid-embed src="${urls.settingsUrl}"${widthAttribute} height="${height}">\n` +
|
||||
`${escapeHtml(urls.code)}\n` +
|
||||
`</mermaid-embed>`;
|
||||
|
||||
return { iframe, webComponent };
|
||||
};
|
||||
|
||||
/** Build the embeddable widget code for a diagram. Pure + deterministic. */
|
||||
export const buildEmbedCode = (options: EmbedCodeOptions): EmbedCodeResult => {
|
||||
const urls = buildEmbedUrls(options);
|
||||
return { url: urls.url, ...buildEmbedSnippets(urls, options) };
|
||||
};
|
||||
@@ -8,3 +8,7 @@ export const env = {
|
||||
privacyPolicyUrl: import.meta.env.MERMAID_PRIVACY_POLICY_URL ?? '',
|
||||
rendererUrl: import.meta.env.MERMAID_RENDERER_URL ?? ''
|
||||
} as const;
|
||||
|
||||
export const MCBaseURL = env.isEnabledMermaidChartLinks
|
||||
? 'https://mermaid.ai' // 'http://localhost:5174'
|
||||
: 'https://example.com';
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { MermaidConfig } from 'mermaid';
|
||||
import { Svg2Roughjs } from 'svg2roughjs';
|
||||
import { render as renderDiagram } from './mermaid';
|
||||
|
||||
export interface PlacedDiagram {
|
||||
diagramType?: string;
|
||||
graphDiv?: SVGSVGElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the diagram and places the resulting SVG into the container,
|
||||
* applying the hand-drawn transform and sizing fixups. Shared by the
|
||||
* store-bound View and the standalone EmbedView.
|
||||
*
|
||||
* @returns The detected diagram type and the placed SVG element (absent when
|
||||
* mermaid returned an empty render).
|
||||
*/
|
||||
export const renderAndPlaceDiagram = async ({
|
||||
code,
|
||||
config,
|
||||
container,
|
||||
rough,
|
||||
viewId
|
||||
}: {
|
||||
code: string;
|
||||
config: MermaidConfig;
|
||||
/** Must have an `id` — Svg2Roughjs addresses the container by CSS selector. */
|
||||
container: HTMLDivElement;
|
||||
rough: boolean;
|
||||
viewId: string;
|
||||
}): Promise<PlacedDiagram> => {
|
||||
const containerSelector = `#${container.id}`;
|
||||
delete container.dataset.processed;
|
||||
const { svg, bindFunctions, diagramType } = await renderDiagram(config, code, viewId);
|
||||
if (svg.length === 0) {
|
||||
return { diagramType };
|
||||
}
|
||||
container.innerHTML = svg;
|
||||
let graphDiv = document.querySelector<SVGSVGElement>(`#${viewId}`);
|
||||
if (!graphDiv) {
|
||||
throw new Error('graph-div not found');
|
||||
}
|
||||
if (rough) {
|
||||
const svg2roughjs = new Svg2Roughjs(containerSelector);
|
||||
svg2roughjs.svg = graphDiv;
|
||||
await svg2roughjs.sketch();
|
||||
graphDiv.remove();
|
||||
const sketch = document.querySelector<SVGSVGElement>(`${containerSelector} > svg`);
|
||||
if (!sketch) {
|
||||
throw new Error('sketch not found');
|
||||
}
|
||||
const height = sketch.getAttribute('height');
|
||||
const width = sketch.getAttribute('width');
|
||||
sketch.setAttribute('id', 'graph-div');
|
||||
sketch.setAttribute('height', '100%');
|
||||
sketch.setAttribute('width', '100%');
|
||||
sketch.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
||||
sketch.style.maxWidth = '100%';
|
||||
graphDiv = sketch;
|
||||
} else {
|
||||
graphDiv.setAttribute('height', '100%');
|
||||
graphDiv.style.maxWidth = '100%';
|
||||
if (bindFunctions) {
|
||||
bindFunctions(graphDiv);
|
||||
}
|
||||
}
|
||||
return { diagramType, graphDiv };
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { MermaidConfig } from 'mermaid';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { findUnsafeConfigPaths, silentlySanitizeConfig, stripConfigPaths } from './sanitize';
|
||||
|
||||
describe('findUnsafeConfigPaths', () => {
|
||||
it('should flag secure keys that differ from the mermaid default', () => {
|
||||
expect(findUnsafeConfigPaths({ securityLevel: 'loose' })).toEqual([['securityLevel']]);
|
||||
});
|
||||
|
||||
it('should not flag secure keys equal to the mermaid default', () => {
|
||||
expect(findUnsafeConfigPaths({ securityLevel: 'strict' })).toEqual([]);
|
||||
});
|
||||
|
||||
it('should flag prototype pollution keys', () => {
|
||||
const config = JSON.parse('{"__proto__": {"polluted": true}}') as MermaidConfig;
|
||||
expect(findUnsafeConfigPaths(config)).toEqual([['__proto__']]);
|
||||
});
|
||||
|
||||
it('should flag nested string values containing XSS vectors', () => {
|
||||
const config = {
|
||||
themeVariables: { nodeBorder: '1px solid<script>alert(1)</script>' }
|
||||
} as MermaidConfig;
|
||||
expect(findUnsafeConfigPaths(config)).toEqual([['themeVariables', 'nodeBorder']]);
|
||||
});
|
||||
|
||||
it('should return no paths for a harmless config', () => {
|
||||
expect(findUnsafeConfigPaths({ theme: 'forest' })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripConfigPaths', () => {
|
||||
it('should delete the given paths in place', () => {
|
||||
const config = {
|
||||
securityLevel: 'loose',
|
||||
theme: 'dark',
|
||||
themeVariables: { nodeBorder: '<b>' }
|
||||
} as MermaidConfig;
|
||||
stripConfigPaths(config, [['securityLevel'], ['themeVariables', 'nodeBorder']]);
|
||||
expect(config).toEqual({ theme: 'dark', themeVariables: {} });
|
||||
});
|
||||
});
|
||||
|
||||
describe('silentlySanitizeConfig', () => {
|
||||
it('should parse a JSON string and strip unsafe settings', () => {
|
||||
const result = silentlySanitizeConfig(
|
||||
JSON.stringify({ securityLevel: 'loose', theme: 'dark' })
|
||||
);
|
||||
expect(result).toEqual({ theme: 'dark' });
|
||||
});
|
||||
|
||||
it('should accept a config object', () => {
|
||||
expect(silentlySanitizeConfig({ theme: 'forest' })).toEqual({ theme: 'forest' });
|
||||
});
|
||||
|
||||
it('should return an empty config for invalid JSON', () => {
|
||||
expect(silentlySanitizeConfig('not json')).toEqual({});
|
||||
});
|
||||
|
||||
it('should return an empty config for undefined', () => {
|
||||
expect(silentlySanitizeConfig(undefined)).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { get as lodashGet } from 'lodash-es';
|
||||
import type { MermaidConfig } from 'mermaid';
|
||||
import { defaultMermaidConfig } from './mermaid';
|
||||
|
||||
/**
|
||||
* 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 as Record<string, unknown>)[key];
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds unsafe paths in the config that differ from mermaid's own defaults.
|
||||
*
|
||||
* @param config - The Mermaid configuration to check.
|
||||
* @returns List of unsafe paths.
|
||||
*/
|
||||
export const findUnsafeConfigPaths = (config: MermaidConfig): string[][] => {
|
||||
const secureKeys = defaultMermaidConfig.secure ?? [];
|
||||
return getUnsafePaths(config, secureKeys).filter((path) => {
|
||||
return lodashGet(config, path) !== lodashGet(defaultMermaidConfig, path);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes the given paths from the config, mutating it in place.
|
||||
*
|
||||
* @param config - The Mermaid configuration to strip.
|
||||
* @param paths - Paths to delete, as returned by {@link findUnsafeConfigPaths}.
|
||||
*/
|
||||
export const stripConfigPaths = (config: MermaidConfig, paths: string[][]): void => {
|
||||
for (const unsafePath of paths) {
|
||||
const pathToObject = [...unsafePath];
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- We know this exists since it was found in `findUnsafeConfigPaths`
|
||||
const lastKey = pathToObject.pop()!;
|
||||
const lastObject = pathToObject.length === 0 ? config : lodashGet(config, pathToObject);
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- Copied from mermaid code
|
||||
delete lastObject[lastKey];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Non-interactive variant of `sanitizeConfig`: always strips unsafe settings
|
||||
* without asking the user. Used where a blocking confirm dialog is not an
|
||||
* option, such as the public /embed widget.
|
||||
*
|
||||
* @param config - The Mermaid configuration (JSON string or object).
|
||||
* @returns The sanitized Mermaid configuration object.
|
||||
*/
|
||||
export const silentlySanitizeConfig = (
|
||||
config: string | MermaidConfig | undefined
|
||||
): MermaidConfig => {
|
||||
let parsed: MermaidConfig = {};
|
||||
if (typeof config === 'string') {
|
||||
try {
|
||||
parsed = JSON.parse(config) as MermaidConfig;
|
||||
} catch {
|
||||
parsed = {};
|
||||
}
|
||||
} else if (config) {
|
||||
parsed = config;
|
||||
}
|
||||
stripConfigPaths(parsed, findUnsafeConfigPaths(parsed));
|
||||
return parsed;
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { defaultState } from '$/constants';
|
||||
import type { ErrorHash, MarkerData, State, ValidatedState } from '$/types';
|
||||
import { resolve } from '$app/paths';
|
||||
import { debounce, get as lodashGet } from 'lodash-es';
|
||||
@@ -9,27 +10,13 @@ import {
|
||||
findMostRelevantLineNumber,
|
||||
replaceLineNumberInErrorMessage
|
||||
} from './errorHandling';
|
||||
import { defaultMermaidConfig, parse } from './mermaid';
|
||||
import { parse } from './mermaid';
|
||||
import { readJSON, writeJSON } from './persist.svelte';
|
||||
import { findUnsafeConfigPaths, stripConfigPaths } from './sanitize';
|
||||
import { deserializeState, pakoSerde, serializeState } from './serde';
|
||||
import { errorDebug, formatJSON, getUTMSource, MCBaseURL } from './util';
|
||||
|
||||
export const defaultState: State = {
|
||||
code: `flowchart TD
|
||||
A[Christmas] -->|Get money| B(Go shopping)
|
||||
B --> C{Let me think}
|
||||
C -->|One| D[Laptop]
|
||||
C -->|Two| E[iPhone]
|
||||
C -->|Three| F[fa:fa-car Car]
|
||||
`,
|
||||
grid: true,
|
||||
mermaid: formatJSON({
|
||||
theme: 'default'
|
||||
}),
|
||||
panZoom: true,
|
||||
rough: false,
|
||||
updateDiagram: true
|
||||
};
|
||||
export { defaultState };
|
||||
|
||||
const urlParseFailedState = `flowchart TD
|
||||
A[Loading URL failed. We can try to figure out why.] -->|Decode JSON| B(Please check the console to see the JSON and error details.)
|
||||
@@ -208,44 +195,6 @@ export const urls = {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 as Record<string, unknown>)[key];
|
||||
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`.
|
||||
@@ -257,10 +206,7 @@ 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);
|
||||
});
|
||||
const unsafePaths = findUnsafeConfigPaths(mermaidConfig);
|
||||
|
||||
if (
|
||||
unsafePaths.length > 0 &&
|
||||
@@ -274,15 +220,7 @@ export const sanitizeConfig = (config: string | MermaidConfig) => {
|
||||
)} 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];
|
||||
}
|
||||
stripConfigPaths(mermaidConfig, unsafePaths);
|
||||
}
|
||||
return formatJSON(mermaidConfig);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { C } from '$/constants';
|
||||
import { env } from './env';
|
||||
import { MCBaseURL } from './env';
|
||||
import { loadDataFromUrl } from './fileLoaders/loader';
|
||||
import { initLoading } from './loading.svelte';
|
||||
import { isOnMermaidAI } from './migration/domainMigration';
|
||||
@@ -38,9 +38,7 @@ export const initHandler = async (): Promise<void> => {
|
||||
|
||||
export const isMac = navigator.platform.toUpperCase().includes('MAC');
|
||||
export const cmdKey = isMac ? 'Cmd' : 'Ctrl';
|
||||
export const MCBaseURL = env.isEnabledMermaidChartLinks
|
||||
? 'https://mermaid.ai' // 'http://localhost:5174'
|
||||
: 'https://example.com';
|
||||
export { MCBaseURL };
|
||||
|
||||
const buildUtmParams = ({
|
||||
utmCampaign,
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
import { Toaster } from '$/components/ui/sonner/index.js';
|
||||
import { loadingState } from '$/util/loading.svelte';
|
||||
import { toggleDarkTheme } from '$/util/state.svelte';
|
||||
import { initHandler } from '$/util/util';
|
||||
import { base } from '$app/paths';
|
||||
import { mode, ModeWatcher } from 'mode-watcher';
|
||||
import { onMount, type Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
|
||||
// This can be removed once https://github.com/sveltejs/kit/issues/1612 is fixed.
|
||||
// Then move it into src and vite will bundle it automatically.
|
||||
onMount(() => {
|
||||
window.addEventListener('hashchange', () => {
|
||||
void initHandler();
|
||||
});
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker
|
||||
.register(`${base}/service-worker.js`, { scope: `${base}/` })
|
||||
.then(function (registration) {
|
||||
console.log('Registration successful, scope is:', registration.scope);
|
||||
})
|
||||
.catch(function (error) {
|
||||
console.log('Service worker registration failed, error:', error);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
toggleDarkTheme(mode.current === 'dark');
|
||||
});
|
||||
</script>
|
||||
|
||||
<ModeWatcher />
|
||||
<Toaster />
|
||||
|
||||
<main class="h-dvh">
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
{#if loadingState.loading}
|
||||
<div
|
||||
class="absolute top-0 left-0 z-50 flex h-screen w-screen justify-center bg-gray-600 align-middle opacity-50">
|
||||
<div class="my-auto text-4xl font-bold text-indigo-100">
|
||||
<div class="loader mx-auto"></div>
|
||||
<div>{loadingState.message}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.loader {
|
||||
border: 0.45em solid #f3f3f3;
|
||||
border-radius: 50%;
|
||||
border-top: 0.45em solid #6365f1;
|
||||
width: 3em;
|
||||
height: 3em;
|
||||
-webkit-animation: spin 2s linear infinite; /* Safari */
|
||||
animation: spin 2s linear infinite;
|
||||
}
|
||||
|
||||
/* Safari */
|
||||
@-webkit-keyframes spin {
|
||||
0% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
-webkit-transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File renamed without changes.
@@ -142,7 +142,9 @@
|
||||
<Resizable.Pane minSize={15} class="relative flex h-full flex-1 flex-col overflow-hidden">
|
||||
<View {panZoomState} shouldShowGrid={validatedState.current.grid} />
|
||||
<div class="absolute top-0 left-5 hidden md:block"><EnhancedEditsButton /></div>
|
||||
<div class="absolute top-0 right-0"><PanZoomToolbar {panZoomState} /></div>
|
||||
<div class="absolute top-0 right-0">
|
||||
<PanZoomToolbar {panZoomState} fullScreenHref={urls.current.view} />
|
||||
</div>
|
||||
<div class="absolute right-0 bottom-0"><VersionSecurityToolbar /></div>
|
||||
<div class="absolute bottom-0 left-0 sm:left-5"><SyncRoughToolbar /></div>
|
||||
</Resizable.Pane>
|
||||
File renamed without changes.
@@ -1,11 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Toaster } from '$/components/ui/sonner/index.js';
|
||||
import { loadingState } from '$/util/loading.svelte';
|
||||
import { toggleDarkTheme } from '$/util/state.svelte';
|
||||
import { initHandler } from '$/util/util';
|
||||
import { base } from '$app/paths';
|
||||
import { mode, ModeWatcher } from 'mode-watcher';
|
||||
import { onMount, type Snippet } from 'svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
import '../app.css';
|
||||
|
||||
interface Props {
|
||||
@@ -13,75 +7,6 @@
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
|
||||
// This can be removed once https://github.com/sveltejs/kit/issues/1612 is fixed.
|
||||
// Then move it into src and vite will bundle it automatically.
|
||||
onMount(() => {
|
||||
window.addEventListener('hashchange', () => {
|
||||
void initHandler();
|
||||
});
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker
|
||||
.register(`${base}/service-worker.js`, { scope: `${base}/` })
|
||||
.then(function (registration) {
|
||||
console.log('Registration successful, scope is:', registration.scope);
|
||||
})
|
||||
.catch(function (error) {
|
||||
console.log('Service worker registration failed, error:', error);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
toggleDarkTheme(mode.current === 'dark');
|
||||
});
|
||||
</script>
|
||||
|
||||
<ModeWatcher />
|
||||
<Toaster />
|
||||
|
||||
<main class="h-dvh">
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
{#if loadingState.loading}
|
||||
<div
|
||||
class="absolute top-0 left-0 z-50 flex h-screen w-screen justify-center bg-gray-600 align-middle opacity-50">
|
||||
<div class="my-auto text-4xl font-bold text-indigo-100">
|
||||
<div class="loader mx-auto"></div>
|
||||
<div>{loadingState.message}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.loader {
|
||||
border: 0.45em solid #f3f3f3;
|
||||
border-radius: 50%;
|
||||
border-top: 0.45em solid #6365f1;
|
||||
width: 3em;
|
||||
height: 3em;
|
||||
-webkit-animation: spin 2s linear infinite; /* Safari */
|
||||
animation: spin 2s linear infinite;
|
||||
}
|
||||
|
||||
/* Safari */
|
||||
@-webkit-keyframes spin {
|
||||
0% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
-webkit-transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{@render children()}
|
||||
@@ -0,0 +1,143 @@
|
||||
<script lang="ts">
|
||||
import EmbedView from '$/components/EmbedView.svelte';
|
||||
import MermaidChartIcon from '$/components/MermaidChartIcon.svelte';
|
||||
import PanZoomToolbar from '$/components/PanZoomToolbar.svelte';
|
||||
import { Button } from '$/components/ui/button';
|
||||
import { TID } from '$/constants';
|
||||
import {
|
||||
buildEditUrl,
|
||||
buildSaveUrl,
|
||||
resolveEmbedSettings,
|
||||
serializeEmbedState,
|
||||
toggleMode,
|
||||
type EmbedMode,
|
||||
type ResolvedEmbed
|
||||
} from '$/util/embed';
|
||||
import { env } from '$/util/env';
|
||||
import { PanZoomState } from '$/util/panZoom';
|
||||
import { asset, resolve } from '$app/paths';
|
||||
import { onMount } from 'svelte';
|
||||
import DarkModeIcon from '~icons/material-symbols/dark-mode-outline';
|
||||
import LightModeIcon from '~icons/material-symbols/light-mode-outline';
|
||||
|
||||
let resolved = $state<ResolvedEmbed>();
|
||||
const panZoomState = new PanZoomState();
|
||||
|
||||
const applyMode = (embedMode: EmbedMode) => {
|
||||
// Local to the embed document — independent of the editor's theme store.
|
||||
document.documentElement.classList.toggle('dark', embedMode === 'dark');
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
const resolveFromLocation = () => {
|
||||
const next = resolveEmbedSettings(new URL(window.location.href));
|
||||
if (next.settings) {
|
||||
applyMode(next.settings.mode);
|
||||
}
|
||||
resolved = next;
|
||||
};
|
||||
resolveFromLocation();
|
||||
window.addEventListener('hashchange', resolveFromLocation);
|
||||
return () => window.removeEventListener('hashchange', resolveFromLocation);
|
||||
});
|
||||
|
||||
const flipMode = () => {
|
||||
if (!resolved?.settings) {
|
||||
return;
|
||||
}
|
||||
const settings = toggleMode(resolved.settings);
|
||||
applyMode(settings.mode);
|
||||
resolved = { settings };
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Mermaid diagram</title>
|
||||
<meta name="robots" content="noindex" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex h-dvh flex-col bg-background text-foreground">
|
||||
<div class="relative min-h-0 flex-1">
|
||||
{#if resolved?.error}
|
||||
<div class="flex h-full items-center justify-center p-4">
|
||||
<div
|
||||
data-testid={TID.embedErrorCard}
|
||||
class="max-w-md rounded-lg border bg-card p-4 text-center text-sm text-card-foreground shadow-sm">
|
||||
<p>{resolved.error}</p>
|
||||
<p class="mt-2 text-muted-foreground">
|
||||
Check that the link is complete, or
|
||||
<a class="underline" href={resolve('/edit', {})}>create a new diagram</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if resolved?.settings}
|
||||
{@const settings = resolved.settings}
|
||||
<EmbedView
|
||||
code={settings.code}
|
||||
config={settings.config}
|
||||
dark={settings.mode === 'dark'}
|
||||
grid={settings.grid}
|
||||
pan={settings.pan}
|
||||
{panZoomState}
|
||||
rough={settings.rough}
|
||||
zoom={settings.zoom} />
|
||||
|
||||
{#if settings.controls}
|
||||
<div class="absolute top-2 right-2" data-testid={TID.embedToolbar}>
|
||||
<PanZoomToolbar {panZoomState} compact />
|
||||
</div>
|
||||
<Button
|
||||
data-testid={TID.embedModeToggle}
|
||||
class="absolute top-2 left-2"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Toggle light / dark"
|
||||
onclick={flipMode}>
|
||||
{#if settings.mode === 'dark'}
|
||||
<LightModeIcon />
|
||||
{:else}
|
||||
<DarkModeIcon />
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if resolved?.settings}
|
||||
{@const serialized = serializeEmbedState(resolved.settings)}
|
||||
<footer
|
||||
data-testid={TID.embedFooter}
|
||||
class="flex items-center justify-between gap-2 border-t bg-card px-3 py-1.5 text-xs">
|
||||
<a
|
||||
href={env.docsUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
class="flex items-center gap-1.5 text-muted-foreground hover:text-foreground">
|
||||
<img src={asset('/favicon.svg')} alt="" class="h-4 w-4" />
|
||||
Powered by Mermaid
|
||||
</a>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<a
|
||||
data-testid={TID.embedEditLink}
|
||||
href={buildEditUrl(serialized, window.location.origin + resolve('/edit', {}))}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
class="text-muted-foreground hover:text-foreground">
|
||||
Edit
|
||||
</a>
|
||||
{#if env.isEnabledMermaidChartLinks}
|
||||
<a
|
||||
data-testid={TID.embedSaveLink}
|
||||
href={buildSaveUrl(serialized)}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
class="inline-flex items-center gap-1.5 font-medium text-muted-foreground hover:text-foreground hover:underline">
|
||||
<MermaidChartIcon class="h-3.5 w-3.5" />
|
||||
Save in Mermaid Chart
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</footer>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,112 @@
|
||||
import { defaultState, TID } from '$/constants';
|
||||
import type { State } from '$/types';
|
||||
import { serializeState } from '$/util/serde';
|
||||
import { expect, test as base, type Page } from '@playwright/test';
|
||||
import { test as editorTest } from './test';
|
||||
|
||||
const embedHash = (state: Partial<State>): string => serializeState({ ...defaultState, ...state });
|
||||
|
||||
const failOnDialog = (page: Page): void => {
|
||||
page.on('dialog', (dialog) => {
|
||||
throw new Error(`Unexpected dialog on /embed: ${dialog.message()}`);
|
||||
});
|
||||
};
|
||||
|
||||
base.describe('Embed page', () => {
|
||||
base('should render a diagram from the URL hash with footer links', async ({ page }) => {
|
||||
failOnDialog(page);
|
||||
await page.goto(`/embed#${embedHash({ code: 'graph TD\n A[Hello embed] --> B[World]' })}`);
|
||||
await expect(page.locator('#embed-container svg')).toBeVisible();
|
||||
await expect(page.locator('#embed-view')).toContainText('Hello embed');
|
||||
await expect(page.getByTestId(TID.embedFooter)).toBeVisible();
|
||||
await expect(page.getByTestId(TID.embedEditLink)).toHaveAttribute('href', /\/edit#pako:/);
|
||||
});
|
||||
|
||||
base('should hide the toolbar and mode toggle with ?controls=0', async ({ page }) => {
|
||||
failOnDialog(page);
|
||||
await page.goto('/embed?controls=0');
|
||||
await expect(page.locator('#embed-container svg')).toBeVisible();
|
||||
await expect(page.getByTestId(TID.embedToolbar)).toHaveCount(0);
|
||||
await expect(page.getByTestId(TID.embedModeToggle)).toHaveCount(0);
|
||||
});
|
||||
|
||||
base('should apply and toggle dark mode', async ({ page }) => {
|
||||
failOnDialog(page);
|
||||
await page.goto('/embed?mode=dark');
|
||||
await expect(page.locator('html')).toHaveClass(/dark/);
|
||||
await page.getByTestId(TID.embedModeToggle).click();
|
||||
await expect(page.locator('html')).not.toHaveClass(/dark/);
|
||||
});
|
||||
|
||||
base('should show an error card for a malformed hash', async ({ page }) => {
|
||||
failOnDialog(page);
|
||||
await page.goto('/embed#pako:garbage');
|
||||
await expect(page.getByTestId(TID.embedErrorCard)).toBeVisible();
|
||||
await expect(page.locator('#embed-container svg')).toHaveCount(0);
|
||||
});
|
||||
|
||||
base('should silently strip unsafe config and stay isolated', async ({ page }) => {
|
||||
failOnDialog(page);
|
||||
await page.goto(
|
||||
`/embed#${embedHash({
|
||||
code: 'graph TD\n S[Sanitized]',
|
||||
mermaid: JSON.stringify({
|
||||
securityLevel: 'loose',
|
||||
themeVariables: { nodeBorder: '<script>alert(1)</script>' }
|
||||
})
|
||||
})}`
|
||||
);
|
||||
await expect(page.locator('#embed-view')).toContainText('Sanitized');
|
||||
const codeStore = await page.evaluate(() => localStorage.getItem('codeStore'));
|
||||
expect(codeStore).toBeNull();
|
||||
});
|
||||
|
||||
base('should serve the web component loader at /embed.js', async ({ page }) => {
|
||||
const response = await page.request.get('/embed.js');
|
||||
expect(response.status()).toBe(200);
|
||||
const body = await response.text();
|
||||
expect(body).toContain('mermaid-embed');
|
||||
expect(body).toContain(
|
||||
'allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox'
|
||||
);
|
||||
});
|
||||
|
||||
base('should render inside the sandboxed iframe the snippets use', async ({ page }) => {
|
||||
failOnDialog(page);
|
||||
const embedSrc = `/embed#code:${encodeURIComponent('graph TD\n A[Sandboxed]-->B')}`;
|
||||
await page.setContent(
|
||||
`<!doctype html><iframe id="embed" title="embed"
|
||||
sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox"
|
||||
src="http://localhost:3000${embedSrc}"
|
||||
style="width:800px;height:500px;border:0"></iframe>`
|
||||
);
|
||||
const frame = page.frameLocator('#embed');
|
||||
await expect(frame.locator('#embed-container svg')).toBeVisible({ timeout: 20_000 });
|
||||
await expect(frame.locator('#embed-view')).toContainText('Sandboxed');
|
||||
// Code-level isolation: the embed document must not write the editor store.
|
||||
const codeStore = await frame.locator('html').evaluate(() => localStorage.getItem('codeStore'));
|
||||
expect(codeStore).toBeNull();
|
||||
});
|
||||
|
||||
base('should load #code: hashes from the web component path', async ({ page }) => {
|
||||
failOnDialog(page);
|
||||
await page.goto(
|
||||
`/embed?theme=forest#code:${encodeURIComponent('graph TD\n A[HashCode]-->B')}`
|
||||
);
|
||||
await expect(page.locator('#embed-view')).toContainText('HashCode');
|
||||
});
|
||||
});
|
||||
|
||||
editorTest.describe('Share dialog embed section', () => {
|
||||
editorTest('should offer iframe and web component snippets', async ({ editPage, page }) => {
|
||||
void editPage;
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
const snippet = page.getByTestId(TID.embedSnippet);
|
||||
await expect(snippet).toBeVisible();
|
||||
await expect(snippet).toHaveValue(/\/embed\?.*#pako:/);
|
||||
await expect(snippet).toHaveValue(/<iframe/);
|
||||
await expect(snippet).toHaveValue(/sandbox="allow-scripts allow-same-origin/);
|
||||
await page.getByRole('radio', { name: 'Web component' }).click();
|
||||
await expect(snippet).toHaveValue(/<mermaid-embed/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
// Builds the standalone <mermaid-embed> loader (src/embed-loader.ts) into static/embed.js,
|
||||
// which the SvelteKit build then ships verbatim as /embed.js. Run via `pnpm build:embed`
|
||||
// (chained into `pnpm dev` and `pnpm build`); the output file is gitignored.
|
||||
export default defineConfig({
|
||||
build: {
|
||||
emptyOutDir: false,
|
||||
lib: {
|
||||
entry: 'src/embed-loader.ts',
|
||||
fileName: () => 'embed.js',
|
||||
formats: ['iife'],
|
||||
name: 'mermaidEmbed'
|
||||
},
|
||||
minify: false,
|
||||
outDir: 'static'
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user