import type { ErrorHash, MarkerData, State, ValidatedState } from '$/types';
import { resolve } from '$app/paths';
import { debounce, get as lodashGet } from 'lodash-es';
import type { MermaidConfig } from 'mermaid';
import { untrack } from 'svelte';
import { env } from './env';
import {
extractErrorLineText,
findMostRelevantLineNumber,
replaceLineNumberInErrorMessage
} from './errorHandling';
import { defaultMermaidConfig, parse } from './mermaid';
import { readJSON, writeJSON } from './persist.svelte';
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
};
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.)
B --> C{Is the JSON correct?}
C -->|Yes| D(Please Click here to Raise an issue in github.
Including the broken link in the issue
will speed up the fix.)
C -->|No| E{Did someone
send you this link?}
E -->|Yes| F[Ask them to send
you the complete link]
E -->|No| G{Did you copy
the complete URL?}
G --> |Yes| D
G --> |"No :("| H(Try using the Timeline tab in History
from same browser you used to create the diagram.)
click D href "https://github.com/mermaid-js/mermaid-live-editor/issues/new?assignees=&labels=bug&template=bug_report.md&title=Broken%20link" "Raise issue"`;
const CODE_STORE_KEY = 'codeStore';
// The single mutable input state; only update() below may write to it.
// The fallback is cloned so mutations never write through to defaultState.
const input = $state(readJSON(CODE_STORE_KEY, { ...defaultState }));
// inputState is shared externally when exporting via URL, History, etc.
// It is reactive for reads; the read-only type keeps writes inside this
// module, where update() persists and re-validates every change.
export const inputState: Readonly = input;
const validatedStateOf = (state: State, serialized: string): ValidatedState => ({
...state,
editorMode: state.editorMode ?? 'code',
error: undefined,
errorMarkers: [],
serialized
});
const initialState = $state.snapshot(input) as State;
// Only ever replaced wholesale, so raw (shallow) reactivity is enough.
let validatedCurrent = $state.raw(
validatedStateOf(initialState, serializeState(initialState))
);
let lastDiagramType = '';
const processState = async (state: State) => {
const processed = validatedStateOf(state, '');
// No changes should be done to fields part of `state`.
try {
processed.serialized = serializeState(state);
const { diagramType } = await parse(state.code);
processed.diagramType = diagramType;
if (lastDiagramType === 'zenuml' && diagramType !== lastDiagramType) {
// Temp Hack to refresh page after displaying ZenUML.
setTimeout(() => window.location.reload(), 500);
}
lastDiagramType = diagramType;
JSON.parse(state.mermaid);
} catch (error) {
processed.error = error as Error;
errorDebug();
console.error(error);
if (error && typeof error === 'object' && 'hash' in error) {
try {
let errorString = processed.error.toString();
const errorLineText = extractErrorLineText(errorString);
const realLineNumber = findMostRelevantLineNumber(errorLineText, state.code);
let first_line: number, last_line: number, first_column: number, last_column: number;
try {
({ first_line, last_line, first_column, last_column } = (error.hash as ErrorHash).loc);
} catch {
const lineNo = findMostRelevantLineNumber(errorString, state.code);
first_line = lineNo;
last_line = lineNo + 1;
first_column = 0;
last_column = 0;
}
if (realLineNumber !== -1) {
errorString = replaceLineNumberInErrorMessage(errorString, realLineNumber);
}
processed.error = new Error(errorString);
const marker: MarkerData = {
endColumn: last_column + (first_column === last_column ? 0 : 5),
endLineNumber: last_line + (realLineNumber - first_line),
message: errorString || 'Syntax error',
severity: 8, // Error
startColumn: first_column,
startLineNumber: realLineNumber
};
processed.errorMarkers = [marker];
} catch (error) {
console.error('Error without line helper', error);
}
}
}
return processed;
};
// Replaces the old URL-hash store subscription; assigned by initURLSubscription.
let updateHash: ((serialized: string) => void) | undefined;
// Persist the current input state and asynchronously re-validate it,
// publishing the result to `validatedState` (and the URL hash, once
// initURLSubscription has run). Only called from update(), which suppresses
// dependency tracking.
const persistAndProcess = (): void => {
const snapshot = $state.snapshot(input) as State;
writeJSON(CODE_STORE_KEY, snapshot);
void processState(snapshot).then((processed) => {
validatedCurrent = processed;
updateHash?.(processed.serialized);
});
};
// The single mutation gateway: every update function funnels its writes
// through here. The mutator runs untracked so effects that call an update
// function never subscribe to the input state it reads, and the trailing
// persist + re-validate cannot be forgotten by a new update function.
const update = (mutate: (state: State) => void): void => {
untrack(() => {
mutate(input);
persistAndProcess();
});
};
// All internal reads should be done via validatedState, but it should not be
// persisted/shared externally.
export const validatedState = {
get current(): ValidatedState {
return validatedCurrent;
}
};
const urlsCurrent = $derived.by(() => {
const { code, serialized } = validatedCurrent;
const { krokiRendererUrl, rendererUrl } = env;
const png = rendererUrl ? `${rendererUrl}/img/${serialized}?type=png` : '';
return {
kroki: krokiRendererUrl ? `${krokiRendererUrl}/mermaid/svg/${pakoSerde.serialize(code)}` : '',
mdCode: png ? `[](${window.location.href})` : '',
mermaidChart: ({
medium,
campaign
}: {
medium:
| 'ai_edit'
| 'ai_repair'
| 'main_menu'
| 'save_diagram'
| 'share'
| 'vibe_diagramming'
| 'visual_edit'
| 'voice_edit';
campaign?: string;
}) => {
const utmSource = getUTMSource();
const params = new URLSearchParams({
utm_source: utmSource,
utm_medium: medium,
...(campaign ? { utm_campaign: campaign } : {})
}).toString();
return {
save: `${MCBaseURL}/app/plugin/save?state=${serialized}&${params}`,
playground: `${MCBaseURL}/play?${params}#${serialized}`,
plugins: `${MCBaseURL}/plugins?${params}`,
home: `${MCBaseURL}/?${params}`
};
},
new: `${resolve('/edit', {})}#${serializeState(defaultState)}`,
png,
svg: rendererUrl ? `${rendererUrl}/svg/${serialized}` : '',
view: `${resolve('/view', {})}#${serialized}`
};
});
export const urls = {
get current() {
return urlsCurrent;
}
};
/**
* 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();
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)[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`.
*
* @param config - The Mermaid configuration to sanitize.
* @returns The sanitized Mermaid configuration as a JSON string.
*/
export const sanitizeConfig = (config: string | MermaidConfig) => {
const mermaidConfig: MermaidConfig =
typeof config === 'string' ? (JSON.parse(config) as MermaidConfig) : config;
const secureKeys = defaultMermaidConfig.secure ?? [];
const unsafePaths = getUnsafePaths(mermaidConfig, secureKeys).filter((path) => {
return lodashGet(mermaidConfig, path) !== lodashGet(defaultMermaidConfig, path);
});
if (
unsafePaths.length > 0 &&
confirm(
`Removing ${unsafePaths
.map((unsafePath) => {
return `${JSON.stringify(unsafePath.join('.'))}: ${JSON.stringify(lodashGet(mermaidConfig, unsafePath))}`;
})
.join(
',\n'
)} from the config for safety.\nClick Cancel if you trust the source of this Diagram.`
)
) {
for (const unsafePath of unsafePaths) {
const pathToObject = [...unsafePath];
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- We know this exists since it was found in `getUnsafePaths`
const lastKey = pathToObject.pop()!;
const lastObject =
pathToObject.length === 0 ? mermaidConfig : lodashGet(mermaidConfig, pathToObject);
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- Copied from mermaid code
delete lastObject[lastKey];
}
}
return formatJSON(mermaidConfig);
};
export const loadState = (data: string): void => {
console.log(`Loading '${data}'`);
update((state) => {
let next: State;
try {
next = deserializeState(data);
next.mermaid = sanitizeConfig(next.mermaid || defaultState.mermaid);
} catch (error) {
next = $state.snapshot(state) as State;
if (data) {
console.error('Init error', error);
next.code = urlParseFailedState;
next.mermaid = defaultState.mermaid;
}
}
applyPartial(state, next);
});
};
let renderCount = 0;
const applyPartial = (state: State, newState: Partial): void => {
renderCount++;
Object.assign(state, newState, { renderCount });
};
export const updateCodeStore = (newState: Partial): void => {
update((state) => applyPartial(state, newState));
};
export const updateCode = (
code: string,
{
updateDiagram = false,
resetPanZoom = false
}: { updateDiagram?: boolean; resetPanZoom?: boolean } = {}
): void => {
errorDebug();
update((state) => {
if (resetPanZoom) {
state.pan = undefined;
state.zoom = undefined;
}
state.code = code;
state.updateDiagram = updateDiagram;
});
};
export const updateConfig = (config: string): void => {
updateCodeStore({ mermaid: config });
};
export const toggleDarkTheme = (dark: boolean): void => {
update((state) => {
const config = JSON.parse(state.mermaid) as MermaidConfig;
if (!config.theme || ['dark', 'default'].includes(config.theme)) {
config.theme = dark ? 'dark' : 'default';
}
state.mermaid = formatJSON(config);
});
};
// Replaces the whole input state (e.g. when restoring a history entry),
// dropping keys the next state does not define.
export const replaceInputState = (next: State): void => {
update((state) => {
for (const key of Object.keys(state)) {
if (!(key in next)) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- full-replace semantics
delete (state as unknown as Record)[key];
}
}
Object.assign(state, next);
});
};
export const initURLSubscription = (): void => {
updateHash = debounce((serialized: string) => {
history.replaceState(undefined, '', `#${serialized}`);
}, 250);
updateHash(validatedCurrent.serialized);
};
export const verifyState = (): void => {
update((state) => applyPartial(state, state.panZoom ? {} : { panZoom: true }));
};