Merge pull request #2019 from mermaid-js/release-promotion

Release live editor
This commit is contained in:
Sidharth Vinod authored and GitHub committed 2026-08-14 15:01:59 +02:00
commit f2a7887f10
27 files changed
+1745 -221

No files matched your search

+3
View File
@@ -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
View File
@@ -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",
+14 -14
View File
@@ -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
+178
View File
@@ -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 {};
+119
View File
@@ -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>
+27 -8
View File
@@ -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>
+143 -3
View File
@@ -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>
+10 -43
View File
@@ -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;
+41
View File
@@ -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
};
+183
View File
@@ -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' });
});
});
+181
View File
@@ -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`;
};
+93
View File
@@ -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 &lt; b &amp; 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#/);
});
});
+147
View File
@@ -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('&', '&amp;').replaceAll('<', '&lt;');
};
/** 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) };
};
+4
View File
@@ -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';
+68
View File
@@ -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 };
};
+62
View File
@@ -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({});
});
});
+96
View File
@@ -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;
};
+6 -68
View File
@@ -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);
};
+2 -4
View File
@@ -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,
+86
View File
@@ -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.
+2 -77
View File
@@ -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()}
+143
View File
@@ -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>
+112
View File
@@ -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/);
});
});
+18
View File
@@ -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'
}
});