Compare commits

..
Author SHA1 Message Date
renovate[bot]andGitHub 5385962c79 fix(deps): update all non-major dependencies 2026-06-24 08:49:09 +00:00
Sidharth VinodandGitHub 335f4bbfde Merge pull request #1991 from mermaid-js/renovate/patch-all-minor-patch
fix(deps): update all non-major dependencies (patch)
2026-06-19 19:23:27 +00:00
renovate[bot]andGitHub bc235e7498 fix(deps): update all non-major dependencies 2026-06-19 09:54:22 +00:00
Sidharth VinodandGitHub 7f08e5b970 Merge pull request #1987 from mermaid-js/sidv/examplesSelection
feat: add example picker dropdown to sample diagrams
2026-06-10 18:43:03 +00:00
Sidharth VinodandClaude Fable 5 a257f78d8e feat: add example picker dropdown to sample diagrams
Each diagram type in the Sample Diagrams panel is now a split button:
clicking the diagram name loads its default example, and a dropdown
arrow on the right (shown when a diagram has more than one example)
opens a popover listing all examples for that diagram.

- getSampleDiagrams now returns all examples per diagram with the
  default example first, instead of flattening to the default's code
- loadSampleDiagram analytics now include the chosen example title

https://claude.ai/code/session_015rUgHChBEkquLu77fMxmQ6

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 00:09:45 +05:30
Sidharth VinodandGitHub 9cd92e1fd6 Merge pull request #1988 from mermaid-js/sidv/runes-migration-fixes
fix: Address regressions and structural risks in the runes migration
2026-06-10 18:35:50 +00:00
Sidharth VinodandClaude Fable 5 c253054916 refactor: Make persisted() values raw state
The getter handed out a deep $state proxy, so in-place mutation of a
persisted array/record would update the UI while silently never being
written to localStorage. All consumers already replace .value wholesale;
$state.raw makes that the only semantics and drops the per-read proxy
overhead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 23:57:27 +05:30
Sidharth VinodandClaude Fable 5 d2f067a540 refactor: Route every input state mutation through one untracked gateway
The untrack()/persistAndProcess() pair was a per-function convention:
each update function had to remember both, a forgotten untrack would let
calling effects subscribe to input state (the bug fixed in 6a9a306e),
and a forgotten persistAndProcess would silently skip persistence and
re-validation. A single update(mutate) gateway now makes both structural,
and the scattered untrack calls (three of which were dead) are gone.

Also:
- Export inputState as Readonly<State> so writes outside the update
  functions are type errors instead of comment violations.
- Share the 'codeStore' key as a constant instead of two literals.
- Reuse validatedStateOf() in processState instead of duplicating the
  validated-state defaults.
- Use $state.raw for validatedCurrent: it is only ever replaced
  wholesale, so deep proxying every published state was pure overhead.

The new state.svelte.test.ts pins the invariants: update functions never
make a calling effect track input state, and every mutation persists.
Vitest needed resolve.conditions=['browser'] for those tests — it was
loading Svelte's server build, where $effect is a no-op.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 23:57:01 +05:30
Sidharth VinodandClaude Fable 5 59105628f4 fix: Treat a stored JSON null as a missing persisted value
readJSON returned any successfully parsed value, so a localStorage
entry holding the literal "null" came back as null instead of the
fallback — and a null inputState crashes module init on every load
until storage is cleared. The deleted MacFJA persist layer guarded
this with `null !== initialValue`; restore that behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 23:53:00 +05:30
Sidharth VinodandClaude Fable 5 144b4dc190 fix: Stop mobile editor from reverting keystrokes
currentText was $state, and the validated-state sync $effect both reads
and writes it. Every keystroke (which sets currentText in the CodeMirror
updateListener) therefore re-ran the effect while re-validation was
still in flight, dispatching a full-document replacement with the stale
validatedState text — visibly reverting the keystroke and resetting the
cursor until validation caught up.

The effect only needs to react to validatedState publishes, so make
currentText a plain variable, matching DesktopEditor.

No unit test: the repo has no component-test harness, and CodeMirror
cannot mount under jsdom without one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 23:51:57 +05:30
Sidharth VinodandClaude Fable 5 6a9a306ed7 fix: Untrack input state reads inside state update functions
toggleDarkTheme runs inside the layout $effect; without untrack the
effect starts depending on inputState.mermaid and re-adds the default
theme whenever a loaded config replaces it (caught by the unsafe-config
scrub e2e test).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 00:44:16 +05:30
Sidharth VinodandClaude Fable 5 337e9f86ac refactor: Use $app/state instead of $app/stores in error page
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 00:38:55 +05:30
Sidharth VinodandClaude Fable 5 c9cfaa10e7 refactor: Replace vendored svelte-persistent-store with shared persisted rune
History now uses the shared persist.svelte helpers, the MacFJA store
copy is deleted, and the esserializer dependency it required is dropped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 00:37:53 +05:30
Sidharth VinodandClaude Fable 5 ed06c07c06 refactor: Migrate hidden promotions store to the persisted rune
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 00:36:58 +05:30
Sidharth VinodandClaude Fable 5 a49503a1f0 refactor: Migrate the migrations store to the persisted rune
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 00:35:41 +05:30
Sidharth VinodandClaude Fable 5 81aaafc085 refactor: Migrate loading state from svelte/store to runes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 00:35:00 +05:30
Sidharth VinodandClaude Fable 5 b3c8bd6c28 refactor: Migrate editor state from svelte/store to runes
Replace the writable+derived store pipeline in state.ts with a
state.svelte.ts module: a deep $state inputState mutated only through
the exported update functions, each of which persists the snapshot and
re-validates it asynchronously into validatedState.current (with
urls.current derived from it). Store subscriptions in components become
$effect blocks, and the rough/grid toggles use function bindings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 00:34:07 +05:30
Sidharth VinodandClaude Fable 5 ca96ca8771 feat: Add shared runes-based localStorage persistence module
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 00:23:48 +05:30
Sidharth VinodandGitHub 219d8af74f Merge pull request #1984 from mermaid-js/sidv/fixViewURL
fix: base URL handling
2026-06-09 19:40:49 +05:30
Sidharth Vinod 49c5928202 fix: Resolve URLs in urlsStore 2026-06-09 19:32:08 +05:30
Sidharth VinodandGitHub 459c22cf09 Merge pull request #1982 from mermaid-js/sidv/rewrite-timeline-history
fix: Rewrite Timeline/Saved history state and UI handling
2026-06-08 15:01:44 +00:00
Sidharth VinodandClaude Opus 4.8 f25b3c82cd refactor: De-duplicate mode routing and drop dead getStateString
Code-review cleanup:
- Collapse the two mode switches (the historyState.entries getter and
  activeSlot) into a single slotFor(mode) helper; entries now reads
  `slotFor(mode)?.value ?? loader`.
- Remove the now-unused getStateString export from state.ts (its only
  consumer was rewritten off it in this PR).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:28:50 +05:30
Sidharth VinodandClaude Opus 4.8 2db9355b7b fix: Accept time:0 entries and precompute history entry URLs
Code-review follow-ups:
- validateEntry no longer rejects entries whose time is 0 (epoch); it now
  checks `typeof time === 'number'`, so restoring/uploading such entries
  works instead of silently dropping them.
- History.svelte serializes each entry's "open in new tab" URL once via a
  $derived list instead of calling serializeState (pako deflate) per row on
  every render.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:22:40 +05:30
Sidharth VinodandClaude Opus 4.8 8dd9cb49a0 feat: Add "open in new tab" link to history entries
Each history entry now has a third action: a real anchor (rendered via
the Button's href, target="_blank") linking to the editor with that
entry's serialized state. Being a normal link, users can also copy it or
open it in a new tab via the context menu.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:03:31 +05:30
Sidharth VinodandClaude Opus 4.8 64e8822e32 fix: Make svelte-check pass and resolve TS6/Svelte 5.56 type errors
Adding the `svelte-check` CI gate surfaced pre-existing type errors and
warnings under develop's TypeScript 6 / Svelte 5.56 bump. Fixes them so
`pnpm check` reports 0 errors / 0 warnings:

- Add `lang="ts"` to Share/Privacy (and a script to PrivacyPolicyLink) so
  importers get real declarations instead of implicit `any`.
- Replace the deprecated `monaco.languages.json` with the new top-level
  `monaco.json` namespace (proper API, no casts).
- Navbar: use `resolve('/', {})` instead of the deprecated `base`.
- Type the promo `component` as `Component<{ closeBanner: Snippet }>` and
  MainMenu's `renderer` as `Snippet<[Omit<MenuItem, 'renderer'>]>`.
- Index-by-string casts in state.ts / Preset / DiagramDocumentationButton.
- Make Actions' clipboard handler accept an optional event.
- toggle-group: expose variant/size via getters to fix the
  state_referenced_locally warning.
- Make the History e2e save/delete cases change state via a sample
  diagram (deterministic) instead of editor typing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:53:25 +05:30
Sidharth Vinod a3a3f8ffbe Update .gitignore 2026-06-08 19:28:39 +05:30
Sidharth VinodandClaude Opus 4.8 e6333e9d7f refactor: Convert history state to Svelte 5 runes + add svelte-check
Migrate the Timeline/Saved history state module to idiomatic Svelte 5
runes and wire up type-checking.

- Rename history.ts -> historyState.svelte.ts and replace the Svelte
  stores with $state-backed reactive values, removing the get() calls
  (the only remaining one reads the external inputStateStore). A small
  localStorage-backed `persisted()` helper keeps the same keys, so user
  data is preserved. Consumers read via the reactive `historyState`
  getter object instead of store auto-subscriptions.
- The gist loader now replaces the in-memory revisions in one call
  (setLoaderEntries) instead of appending, so loading a new gist no
  longer accumulates stale revisions.
- Add svelte-check: new `check` script, a "Type check" step in the
  unit-tests CI workflow, and skipLibCheck so the check passes on the
  dependency .d.ts files. svelte-check reports 0 errors / 0 warnings.
- Add accessible labels to the History toggle and the per-item
  Restore/Delete buttons (a11y + testability).
- Unskip tests/history.spec.ts and rewrite it against the new UI:
  load-from-localStorage + restore, tab-highlight follows the mode,
  save/dedupe, auto-vs-manual isolation, and delete/clear. All pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:28:38 +05:30
Sidharth VinodandClaude Opus 4.8 3628acec02 fix: Rewrite Timeline/Saved history state and UI handling
The auto (Timeline) and manual (Saved) history handling had several bugs.
This rewrites the state module into a small, tested source-of-truth API and
turns History.svelte into a thin view.

Bugs fixed:
- Auto-saves no longer leak into the Saved list. addEntry now writes only to
  the store for its own type (previously every entry was also pushed to the
  manual store).
- Dedup is keyed on code + config via stateKey() instead of the whole input
  state, so volatile/view-only fields (renderCount, updateDiagram, pan/zoom)
  no longer cause spurious saves or hide real edits.
- The active-tab highlight tracks the history mode. History binds
  activeTabID={$historyModeStore} and Tabs derives the highlight reactively
  instead of mutating its prop once, which had frozen it on the first tab.
- Auto-save runs for the whole edit session via startAutoSave() in +page,
  with proper cleanup. Previously the interval lived in History.svelte, only
  ran while the panel was open, and was never cleared (leaking a new interval
  on every open).
- restoreEntries() routes each uploaded entry to the store matching its own
  type instead of dumping everything into one store by the first entry's type.

The persisted localStorage keys (autoHistoryStore, manualHistoryStore,
autoHistoryMode) are unchanged, so existing user data is preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:27:23 +05:30
Sidharth VinodandGitHub ebced635c9 Merge pull request #1774 from mermaid-js/sidv/fixUndo
fix: #1753 Undo in Monaco
2026-06-08 13:16:56 +00:00
Sidharth VinodandCursor 1f0812496e fix: prevent state feedback loop when updating Monaco via executeEdits
Guard programmatic editor updates so sample diagrams and URL loads do not re-trigger onUpdate, which caused infinite state/URL churn and failing e2e tests.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Reported-by: Chai Cheng Xun @QiaoNPC
2026-04-02 18:59:09 +09:00
Sidharth VinodandGitHub f9f1e27bc3 Merge pull request #1923 from mermaid-js/sidv/visualEditor
feat: Add visual editor button
2026-04-01 22:20:22 +05:30
Alois Klink ae7ad1b93e refactor: move config sanitization into function
Moves the code that prompts the user to remove unsafe MermaidConfig
settings into it's own function, so that it can be reused.
2026-03-30 17:06:32 +09:00
65 changed files with 4936 additions and 5134 deletions
@@ -0,0 +1,60 @@
name: Close broken link issues
on:
issues:
types: [opened, edited]
permissions:
issues: write
jobs:
close-broken-link-issues:
if: github.event.issue.state == 'open'
runs-on: ubuntu-latest
steps:
- name: Close issues with only "Broken link" in the title
uses: actions/github-script@v7
with:
script: |
const title = context.payload.issue.title.trim();
const isBrokenLinkOnlyTitle = /^broken link\.?$/i.test(title);
if (!isBrokenLinkOnlyTitle) {
return;
}
const { owner, repo } = context.repo;
const issueNumber = context.payload.issue.number;
const newIssueUrl = `https://github.com/${owner}/${repo}/issues/new?assignees=&labels=bug&template=bug_report.md&title=Broken%20link`;
const mermaidIssuesUrl = 'https://github.com/mermaid-js/mermaid/issues/new';
const commentBody = [
`@${context.payload.issue.user.login} This issue was automatically closed because the title only contains "Broken link" without additional details.`,
'',
'**Please only open issues here if something is broken in the live editor itself** (e.g. a link fails to load, the editor UI misbehaves, or a feature of mermaid.live does not work).',
'',
'**Do not open issues here for syntax errors or invalid Mermaid diagram code.** Those belong in the main Mermaid repository: [mermaid-js/mermaid](https://github.com/mermaid-js/mermaid/issues/new).',
'',
'If you are reporting a live editor bug, please include:',
'- The full URL that failed to load',
'- What you expected to happen',
'- Any error messages you saw',
'',
`You can [open a new issue in this repo](${newIssueUrl}) with this information, or report diagram syntax issues in the [Mermaid repo](${mermaidIssuesUrl}).`
].join('\n');
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: commentBody
});
await github.rest.issues.update({
owner,
repo,
issue_number: issueNumber,
state: 'closed',
state_reason: 'not_planned'
});
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
name: 'Playwright Tests'
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.52.0-jammy
image: mcr.microsoft.com/playwright:v1.61.1-jammy
steps:
- name: Checkout
+3
View File
@@ -39,5 +39,8 @@ jobs:
- name: Lint
run: pnpm lint
- name: Type check
run: pnpm check
- name: Run unit tests
run: pnpm test:unit
+1
View File
@@ -15,3 +15,4 @@
/playwright-report/
/blob-report/
/playwright/.cache/
.playwright-mcp/
+1 -1
View File
@@ -1 +1 @@
22.15.0
24.18.0
+3
View File
@@ -12,11 +12,14 @@
"fsegurai",
"gantt",
"gitgraph",
"hugeicons",
"KROKI",
"localstorage",
"mermaidchart",
"mindmap",
"NEWYEAR",
"noopener",
"noreferrer",
"Pageview",
"pako",
"panmove",
+1 -1
View File
@@ -1,4 +1,4 @@
FROM docker.io/library/node:22.15.0-alpine3.21 AS mermaid-live-editor-dependencies
FROM docker.io/library/node:24.16.0-alpine3.22 AS mermaid-live-editor-dependencies
RUN apk --no-cache add build-base git python3 && \
rm -rf /var/cache/apk/*
+78 -75
View File
@@ -9,6 +9,8 @@
"dev:test": "pnpm dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check --cache . && eslint .",
"lint:fix": "prettier --write --cache . && eslint --fix .",
"format": "prettier --write --cache .",
@@ -23,95 +25,96 @@
"test:e2e:debug": "playwright test --debug"
},
"devDependencies": {
"@eslint/compat": "^1.2.5",
"@eslint/eslintrc": "^3.3.3",
"@eslint/js": "^9.39.2",
"@fortawesome/fontawesome-free": "^6.7.2",
"@iconify-json/material-symbols": "^1.2.20",
"@eslint/compat": "^2.1.0",
"@eslint/eslintrc": "^3.3.5",
"@eslint/js": "^10.0.1",
"@fortawesome/fontawesome-free": "^7.2.0",
"@iconify-json/hugeicons": "^1.2.29",
"@iconify-json/logos": "^1.2.11",
"@iconify-json/material-symbols": "^1.2.79",
"@iconify-json/mdi": "^1.2.3",
"@playwright/test": "^1.52.0",
"@sveltejs/adapter-static": "^3.0.9",
"@sveltejs/kit": "^2.37.0",
"@sveltejs/vite-plugin-svelte": "^6.1.3",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.18",
"@playwright/test": "^1.61.1",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.67.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tailwindcss/typography": "^0.5.20",
"@tailwindcss/vite": "^4.3.1",
"@types/hammerjs": "^2.0.46",
"@types/lodash-es": "^4.17.12",
"@types/node": "^22.15.10",
"@types/pako": "2.0.3",
"@types/uuid": "9.0.8",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.2.4",
"autoprefixer": "^10.4.21",
"bits-ui": "^2.9.6",
"c8": "7.14.0",
"chai": "^4.5.0",
"@types/node": "^24.13.2",
"@types/pako": "2.0.4",
"@vitest/coverage-v8": "^4.1.9",
"@vitest/ui": "^4.1.9",
"autoprefixer": "^10.5.0",
"bits-ui": "^2.18.1",
"c8": "11.0.0",
"chai": "^6.2.2",
"clsx": "^2.1.1",
"cssnano": "^6.1.2",
"dotenv": "^17.3.1",
"eslint": "^9.39.2",
"cssnano": "^8.0.2",
"dotenv": "^17.4.2",
"eslint": "^10.5.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-es": "^4.1.0",
"eslint-plugin-no-only-tests": "^3.3.0",
"eslint-plugin-no-only-tests": "^3.4.0",
"eslint-plugin-sort-keys": "^2.3.5",
"eslint-plugin-svelte": "^3.0.0",
"eslint-plugin-tailwindcss": "^3.18.2",
"eslint-plugin-unicorn": "^60.0.0",
"esserializer": "^1.3.11",
"globals": "^16.0.0",
"husky": "^8.0.3",
"jsdom": "^25.0.1",
"lint-staged": "^15.5.1",
"lucide-svelte": "^0.507.0",
"node-html-parser": "^6.1.13",
"paneforge": "1.0.0-next.6",
"prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.4.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"svelte": "^5.38.6",
"svelte-preprocess": "^6.0.3",
"svelte-sonner": "^1.0.5",
"tailwind-merge": "^3.3.1",
"tailwind-variants": "^3.1.0",
"tailwindcss": "^4.1.18",
"eslint-plugin-svelte": "^3.19.0",
"eslint-plugin-tailwindcss": "^3.18.3",
"eslint-plugin-unicorn": "^65.0.1",
"globals": "^17.7.0",
"husky": "^9.1.7",
"jsdom": "^29.1.1",
"lint-staged": "^17.0.7",
"lucide-svelte": "^1.0.1",
"node-html-parser": "^7.1.0",
"paneforge": "1.0.2",
"prettier": "^3.8.4",
"prettier-plugin-svelte": "^4.1.1",
"prettier-plugin-tailwindcss": "^0.8.0",
"svelte": "^5.56.3",
"svelte-check": "^4.7.1",
"svelte-preprocess": "^6.0.5",
"svelte-sonner": "^1.1.1",
"tailwind-merge": "^3.6.0",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.3.1",
"tslib": "^2.8.1",
"tw-animate-css": "^1.3.8",
"typescript": "^5.9.2",
"typescript-eslint": "^8.42.0",
"unplugin-icons": "^22.2.0",
"vite": "^7.1.4",
"tw-animate-css": "^1.4.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.62.0",
"unplugin-icons": "^23.0.1",
"vite": "^8.1.0",
"vite-plugin-devtools-json": "^1.0.0",
"vitest": "^3.2.4",
"vitest": "^4.1.9",
"vitest-dom": "^0.1.1"
},
"dependencies": {
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-markdown": "^6.3.2",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.11.0",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.36.7",
"@fontsource-variable/recursive": "^5.2.5",
"@fsegurai/codemirror-theme-vscode-dark": "^6.1.4",
"@fsegurai/codemirror-theme-vscode-light": "^6.1.4",
"@mermaid-js/examples": "^1.0.0",
"@mermaid-js/layout-elk": "^0.1.9",
"@mermaid-js/layout-tidy-tree": "^0.2.1",
"@mermaid-js/mermaid-zenuml": "^0.2.2",
"codemirror": "^6.0.1",
"dayjs": "^1.11.13",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-yaml": "^6.1.3",
"@codemirror/language": "^6.12.3",
"@codemirror/state": "^6.7.0",
"@codemirror/view": "^6.43.1",
"@fontsource-variable/recursive": "^5.2.8",
"@fsegurai/codemirror-theme-vscode-dark": "^6.2.6",
"@fsegurai/codemirror-theme-vscode-light": "^6.2.6",
"@mermaid-js/examples": "^1.2.0",
"@mermaid-js/layout-elk": "^0.2.1",
"@mermaid-js/layout-tidy-tree": "^0.2.2",
"@mermaid-js/mermaid-zenuml": "^0.2.3",
"codemirror": "^6.0.2",
"dayjs": "^1.11.21",
"hammerjs": "^2.0.8",
"js-base64": "3.7.7",
"lodash-es": "^4.17.21",
"mermaid": "^11.13.0",
"mode-watcher": "^0.5.1",
"monaco-editor": "0.52.2",
"pako": "2.1.0",
"js-base64": "3.7.8",
"lodash-es": "^4.18.1",
"mermaid": "^11.15.0",
"mode-watcher": "^1.1.0",
"monaco-editor": "0.55.1",
"pako": "2.2.0",
"plausible-tracker": "^0.3.9",
"random-word-slugs": "0.1.7",
"svg-pan-zoom": "3.6.2",
"svg2roughjs": "^3.2.1",
"uuid": "9.0.1"
"svg2roughjs": "^3.2.3",
"uuid": "14.0.0"
},
"lint-staged": {
"*.{ts,svelte,js,css,md,json}": [
@@ -120,9 +123,9 @@
]
},
"engines": {
"node": ">=20.19.0"
"node": ">=24.18.0"
},
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39",
"packageManager": "pnpm@10.34.4+sha512.8768be55200ae3f2226b6527fcca2687e14bc4e5f12d7721a0f25da3df47915177058648db4177baf348120fa0ba2752d8d8d93f6beaf1fe64ae18da8de961af",
"pnpm": {
"onlyBuiltDependencies": [
"deasync",
+2937 -3695
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -8,7 +8,8 @@
<meta
name="description"
content="Simplify documentation and avoid heavy tools. Open source Visio Alternative. Commonly used for explaining your code! Mermaid is a simple markdown-like script language for generating charts from text via javascript." />
<link rel="icon" type="image/png" href="%sveltekit.assets%/favicon.svg" />
<link rel="icon" type="image/svg+xml" href="%sveltekit.assets%/favicon.svg" />
<link rel="apple-touch-icon" href="%sveltekit.assets%/favicon.png" />
<link rel="mask-icon" href="%sveltekit.assets%/favicon.svg" color="#000000" />
<meta name="theme-color" content="#ff3670" />
<link rel="manifest" href="%sveltekit.assets%/manifest.json" />
+19 -15
View File
@@ -11,7 +11,7 @@
import { getDomain } from '$/util/util';
import { browser } from '$app/environment';
import { waitForRender } from '$lib/util/autoSync';
import { inputStateStore, stateStore, urlsStore } from '$lib/util/state';
import { inputState, updateCodeStore, urls, validatedState } from '$lib/util/state.svelte';
import { logEvent } from '$lib/util/stats';
import { version as FAVersion } from '@fortawesome/fontawesome-free/package.json';
import dayjs from 'dayjs';
@@ -80,7 +80,7 @@
svg = getSvgElement();
}
if ($stateStore.rough) {
if (validatedState.current.rough) {
fixForeignObjectClipping(svg);
}
@@ -106,7 +106,7 @@ ${svgString}`);
};
const exportImage = async (event: Event, exporter: Exporter) => {
$inputStateStore.panZoom = false;
updateCodeStore({ panZoom: false });
await new Promise((resolve) => setTimeout(resolve, 1000));
await waitForRender();
const canvas = document.createElement('canvas');
@@ -149,14 +149,14 @@ ${svgString}`);
const image = new Image();
image.addEventListener('load', () => {
exporter(context, image)();
$inputStateStore.panZoom = true;
updateCodeStore({ panZoom: true });
});
image.src = `data:image/svg+xml;base64,${getBase64SVG(svg, canvas.width, canvas.height)}`;
// Fallback to set panZoom to true after 2 seconds
// This is a workaround for the case when the image is not loaded
setTimeout(() => {
if (!$inputStateStore.panZoom) {
$inputStateStore.panZoom = true;
if (!inputState.panZoom) {
updateCodeStore({ panZoom: true });
}
}, 2000);
event.stopPropagation();
@@ -199,7 +199,10 @@ ${svgString}`);
};
};
const onCopyClipboard = async (event: Event) => {
const onCopyClipboard = async (event?: Event) => {
if (!event) {
return;
}
await exportImage(event, clipboardCopy);
logEvent('copyClipboard');
};
@@ -219,7 +222,8 @@ ${svgString}`);
};
let gistURL = $state('');
stateStore.subscribe(({ loader }) => {
$effect(() => {
const { loader } = validatedState.current;
if (loader?.type === 'gist') {
gistURL = loader.config.url;
}
@@ -284,10 +288,10 @@ ${svgString}`);
bind:value={imageSize} />
</div>
<div class="flex gap-2">
{@render dualActionButton('PNG', onDownloadPNG, $urlsStore.png)}
{@render dualActionButton('SVG', onDownloadSVG, $urlsStore.svg)}
<ExternalLinkWrapper domain={getDomain($urlsStore.kroki)} isVisible={!!$urlsStore.kroki}>
<a target="_blank" rel="noreferrer" class="flex-grow" href={$urlsStore.kroki}>
{@render dualActionButton('PNG', onDownloadPNG, urls.current.png)}
{@render dualActionButton('SVG', onDownloadSVG, urls.current.svg)}
<ExternalLinkWrapper domain={getDomain(urls.current.kroki)} isVisible={!!urls.current.kroki}>
<a target="_blank" rel="noreferrer" class="flex-grow" href={urls.current.kroki}>
<Button class="action-btn flex w-full items-center gap-2">
<ExternalLinkIcon /> Kroki
</Button>
@@ -300,9 +304,9 @@ ${svgString}`);
{/if}
<ExternalLinkWrapper
labelPrefix="Thumbnail generated by"
domain={getDomain($urlsStore.png)}
isVisible={!!$urlsStore.mdCode}>
<CopyInput value={$urlsStore.mdCode} label="Copy Markdown" testID={TID.copyMarkdown} />
domain={getDomain(urls.current.png)}
isVisible={!!urls.current.mdCode}>
<CopyInput value={urls.current.mdCode} label="Copy Markdown" testID={TID.copyMarkdown} />
</ExternalLinkWrapper>
<div class="flex w-full items-center gap-2">
<Input type="url" bind:value={gistURL} placeholder="Enter Gist URL" />
+3 -4
View File
@@ -14,9 +14,8 @@
onselect?: (tab: Tab) => void;
} = $props();
if (!activeTabID && tabs.length > 0) {
activeTabID = tabs[0].id;
}
// Derive (don't mutate the prop) so the highlight tracks a bound activeTabID.
const effectiveTabID = $derived(activeTabID || tabs[0]?.id);
const toggleTabs = (tab: Tab) => {
return (event: Event) => {
@@ -34,7 +33,7 @@
variant="ghost"
class={[
'px-2',
activeTabID === tab.id && 'rounded-b-none border-b-2 border-b-primary-foreground/50'
effectiveTabID === tab.id && 'rounded-b-none border-b-2 border-b-primary-foreground/50'
]}
onclick={toggleTabs(tab)}
onkeypress={toggleTabs(tab)}>
+64 -42
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import type { EditorProps } from '$/types';
import { env } from '$/util/env';
import { stateStore, urlsStore } from '$/util/state';
import { urls, validatedState } from '$/util/state.svelte';
import { logMermaidChartClick } from '$/util/stats';
import { AIPromptViewZoneManager } from '$lib/util/AIPromptViewZoneManager';
import { initEditor } from '$lib/util/monacoExtra';
@@ -27,6 +27,7 @@
lineNumbersMinChars: 4
} satisfies monaco.editor.IStandaloneEditorConstructionOptions;
let currentText = '';
let isUpdatingFromState = false;
let showPopup = $state(false);
let popupPosition = $state({ top: 0, lineNumber: 0 });
let decorationsCollection: monaco.editor.IEditorDecorationsCollection | undefined;
@@ -34,6 +35,16 @@
let lastMouseLine = 0;
const aiPromptManager = new AIPromptViewZoneManager();
const applyEditorTheme = (currentMode: typeof mode.current) => {
if (!editor) return;
monaco.editor.setTheme(`mermaid${currentMode === 'dark' ? '-dark' : ''}`);
divElement?.classList.toggle('mermaid-dark', currentMode === 'dark');
};
$effect(() => {
applyEditorTheme(mode.current);
});
const jsonModel = monaco.editor.createModel(
'',
'json',
@@ -104,7 +115,7 @@
throw new Error('divEl is undefined');
}
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
monaco.json.jsonDefaults.setDiagnosticsOptions({
validate: true,
enableSchemaRequest: true,
schemas: [
@@ -132,43 +143,13 @@
editor.onDidChangeModelContent(({ isFlush }) => {
const newText = editor?.getValue();
if (!newText || currentText === newText || isFlush) {
if (!newText || currentText === newText || isFlush || isUpdatingFromState) {
return;
}
currentText = newText;
onUpdate(currentText);
});
const unsubscribeState = stateStore.subscribe(({ errorMarkers, editorMode, code, mermaid }) => {
if (!editor) {
return;
}
const model = editorMode === 'code' ? mermaidModel : jsonModel;
if (editor.getModel()?.id !== model.id) {
editor.setModel(model);
renderAIPromptGutterGlyphIcon();
}
// Clear decorations if not in 'code' mode, or if the model changes
if (editorMode !== 'code' || editor.getModel()?.id !== mermaidModel.id) {
decorationsCollection?.clear();
}
// Update editor text if it's different
const newText = editorMode === 'code' ? code : mermaid;
if (newText !== currentText) {
editor.setScrollTop(0);
editor.setValue(newText);
currentText = newText;
renderAIPromptGutterGlyphIcon();
}
// Display/clear errors
monaco.editor.setModelMarkers(model, 'mermaid', errorMarkers);
});
editor.onMouseMove((e) => {
if (!editor) return;
if (showPopup) return;
@@ -183,12 +164,8 @@
renderAIPromptGutterGlyphIcon();
});
const unsubscribeMode = mode.subscribe((mode) => {
if (editor) {
monaco.editor.setTheme(`mermaid${mode === 'dark' ? '-dark' : ''}`);
divElement?.classList.toggle('mermaid-dark', mode === 'dark');
}
});
applyEditorTheme(mode.current);
const resizeObserver = new ResizeObserver((entries) => {
editor?.layout({
height: entries[0].contentRect.height,
@@ -203,8 +180,6 @@
renderAIPromptGutterGlyphIcon();
return () => {
unsubscribeState();
unsubscribeMode();
resizeObserver.disconnect();
jsonModel.dispose();
mermaidModel.dispose();
@@ -212,6 +187,49 @@
editor?.dispose();
};
});
$effect(() => {
const { errorMarkers, editorMode, code, mermaid } = validatedState.current;
if (!editor) {
return;
}
const model = editorMode === 'code' ? mermaidModel : jsonModel;
if (editor.getModel()?.id !== model.id) {
editor.setModel(model);
renderAIPromptGutterGlyphIcon();
}
// Clear decorations if not in 'code' mode, or if the model changes
if (editorMode !== 'code' || editor.getModel()?.id !== mermaidModel.id) {
decorationsCollection?.clear();
}
// Update editor text if it's different
const newText = editorMode === 'code' ? code : mermaid;
if (newText !== currentText) {
isUpdatingFromState = true;
try {
editor.setScrollTop(0);
editor.pushUndoStop();
editor.executeEdits('updateCode', [
{
range: model.getFullModelRange(),
text: newText
}
]);
editor.pushUndoStop();
currentText = newText;
} finally {
isUpdatingFromState = false;
}
renderAIPromptGutterGlyphIcon();
}
// Display/clear errors
monaco.editor.setModelMarkers(model, 'mermaid', errorMarkers);
});
</script>
<div class="relative h-full grow overflow-hidden">
@@ -224,7 +242,11 @@
onClose={closePopup}
onTryFree={() => {
logMermaidChartClick('vibeDiagramming');
window.open($urlsStore.mermaidChart({ medium: 'vibe_diagramming' }).save, '_blank');
window.open(
urls.current.mermaidChart({ medium: 'vibe_diagramming' }).save,
'_blank',
'noopener'
);
closePopup();
}} />
</div>
@@ -4,7 +4,7 @@
import type { DocumentationConfig } from '$/types';
import { env } from '$/util/env';
import { standardizeDiagramType } from '$/util/mermaid';
import { stateStore } from '$/util/state';
import { validatedState } from '$/util/state.svelte';
import BookIcon from '~icons/material-symbols/book-2-outline-rounded';
const docURLBase = env.docsUrl;
@@ -92,12 +92,14 @@
} as const satisfies DocumentationConfig;
const doc = $derived.by(() => {
const { editorMode, diagramType } = $stateStore;
const { editorMode, diagramType } = validatedState.current;
if (!diagramType) {
return { key: '', url: docURLBase };
}
const key = standardizeDiagramType(diagramType);
const docConfig = docMap[key] ?? { code: '' };
const docConfig: { code: string; config?: string } = docMap[key as keyof typeof docMap] ?? {
code: ''
};
const url = docURLBase + (docConfig[editorMode] ?? docConfig.code ?? '');
return { key, url };
});
+8 -8
View File
@@ -6,14 +6,14 @@
import { Button } from '$/components/ui/button';
import { TID } from '$/constants';
import { env } from '$/util/env';
import { stateStore, updateCode, updateConfig, urlsStore } from '$lib/util/state';
import { updateCode, updateConfig, urls, validatedState } from '$lib/util/state.svelte';
import { logMermaidChartClick } from '$lib/util/stats';
import { debounce } from 'lodash-es';
import ExclamationCircleIcon from '~icons/material-symbols/error-outline-rounded';
const { isMobile } = $props<{ isMobile: boolean }>();
const onUpdate = (text: string) => {
if ($stateStore.editorMode === 'code') {
if (validatedState.current.editorMode === 'code') {
updateCode(text);
} else {
updateConfig(text);
@@ -27,7 +27,7 @@
}, 3000);
$effect(() => {
if ($stateStore.error) {
if (validatedState.current.error) {
showErrorDebounced();
} else {
showErrorDebounced.cancel();
@@ -46,27 +46,27 @@
{:else}
<DesktopEditor {onUpdate} />
{/if}
{#if showError && $stateStore.error instanceof Error}
{#if showError && validatedState.current.error instanceof Error}
<div class="flex flex-col text-sm" data-testid={TID.errorContainer}>
<div class="flex items-center justify-between gap-2 bg-slate-900 p-2 text-white">
<div class="flex w-fit items-center gap-2">
<ExclamationCircleIcon class="size-6 text-destructive" aria-hidden="true" />
<div class="flex flex-col">
<p>Syntax error</p>
{#if env.isEnabledMermaidChartLinks && $stateStore.editorMode === 'code'}
{#if env.isEnabledMermaidChartLinks && validatedState.current.editorMode === 'code'}
<p class="text-xs text-white/60" data-testid={TID.aiHelpText}>
Create a free account to repair with AI
</p>
{/if}
</div>
</div>
{#if $stateStore.editorMode === 'code'}
{#if validatedState.current.editorMode === 'code'}
<McWrapper>
<Button
variant="accent"
size="sm"
data-testid={TID.aiRepairButton}
href={$urlsStore.mermaidChart({ medium: 'ai_repair' }).save}
href={urls.current.mermaidChart({ medium: 'ai_repair' }).save}
target="_blank"
onclick={() => logMermaidChartClick('aiRepair')}>
<MermaidChartIcon />
@@ -76,7 +76,7 @@
{/if}
</div>
<output class="max-h-32 overflow-auto bg-muted p-2" name="mermaid-error" for="editor">
<pre>{$stateStore.error?.toString()}</pre>
<pre>{validatedState.current.error?.toString()}</pre>
</output>
</div>
{/if}
+22 -21
View File
@@ -3,7 +3,7 @@
import MermaidChartIcon from '$/components/MermaidChartIcon.svelte';
import { Button } from '$/components/ui/button';
import { standardizeDiagramType } from '$/util/mermaid';
import { stateStore, urlsStore } from '$/util/state';
import { validatedState, urls } from '$/util/state.svelte';
import { logMermaidChartClick } from '$/util/stats';
import { quintInOut } from 'svelte/easing';
import { slide } from 'svelte/transition';
@@ -19,7 +19,7 @@
]);
const diagramType = $derived.by(() => {
const dt = $stateStore.diagramType;
const dt = validatedState.current.diagramType;
return dt ? standardizeDiagramType(dt) : undefined;
});
@@ -34,23 +34,24 @@
source: string;
}
let currentActionMedium = $state<EnhancedEditAction['medium'] | undefined>(undefined);
let previousDiagramType = $state<string | undefined>(undefined);
const cycleIntervalMs = 30_000;
let currentActionIndex = $state(0);
const availableActions = $derived.by<EnhancedEditAction[]>(() => {
if (!$stateStore.diagramType) {
if (!validatedState.current.diagramType) {
return [];
}
const actions: EnhancedEditAction[] = [
{ campaign: 'ai_1', label: 'with AI', medium: 'ai_edit', source: 'aiEdit' },
{ campaign: 'voice_1', label: 'with Voice', medium: 'voice_edit', source: 'voiceEdit' }
{ campaign: 'voice_1', label: 'with voice', medium: 'voice_edit', source: 'voiceEdit' },
{ campaign: 'ai_1', label: 'with AI', medium: 'ai_edit', source: 'aiEdit' }
];
if (showVisualEdit) {
actions.unshift({
campaign: 'visual_1',
label: 'Visually',
label: 'visually',
medium: 'visual_edit',
source: 'visualEdit'
});
@@ -65,30 +66,30 @@
return undefined;
}
return actions.find(({ medium }) => medium === currentActionMedium) ?? actions[0];
return actions[currentActionIndex % actions.length];
});
$effect(() => {
const actions = availableActions;
const actionCount = availableActions.length;
if (!diagramType || actions.length === 0) {
if (actionCount === 0) {
currentActionIndex = 0;
return;
}
const hasCurrentAction = actions.some(({ medium }) => medium === currentActionMedium);
if (diagramType !== previousDiagramType || !hasCurrentAction) {
currentActionMedium = actions[Math.floor(Math.random() * actions.length)].medium;
previousDiagramType = diagramType;
if (currentActionIndex >= actionCount) {
currentActionIndex = 0;
}
});
$effect(() => {
if (diagramType) {
if (actionCount <= 1) {
return;
}
previousDiagramType = undefined;
const intervalID = setInterval(() => {
currentActionIndex = (currentActionIndex + 1) % actionCount;
}, cycleIntervalMs);
return () => clearInterval(intervalID);
});
</script>
@@ -97,7 +98,7 @@
<Button
variant="secondary"
size="sm"
href={$urlsStore.mermaidChart({
href={urls.current.mermaidChart({
medium: currentAction.medium,
campaign: currentAction.campaign
}).save}
@@ -1,5 +1,5 @@
<script lang="ts">
import { stateStore } from '$/util/state';
import { validatedState } from '$/util/state.svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import type { ComponentProps, Snippet } from 'svelte';
import ExternalLinkIcon from '~icons/material-symbols/open-in-new-rounded';
@@ -25,7 +25,7 @@
} = $props();
let shouldDisableComponent = $derived(
shouldCheckDiagramType && $stateStore.diagramType === 'zenuml'
shouldCheckDiagramType && validatedState.current.diagramType === 'zenuml'
);
</script>
+76 -79
View File
@@ -2,12 +2,11 @@
import Card from '$lib/components/Card/Card.svelte';
import type { HistoryEntry, HistoryType, State, Tab } from '$lib/types';
import { notify, prompt } from '$lib/util/notify';
import { getStateString, inputStateStore } from '$lib/util/state';
import { serializeState } from '$lib/util/serde';
import { inputState, replaceInputState } from '$lib/util/state.svelte';
import { logEvent } from '$lib/util/stats';
import dayjs from 'dayjs';
import dayjsRelativeTime from 'dayjs/plugin/relativeTime';
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import BookmarkIcon from '~icons/material-symbols/bookmark-outline-rounded';
import TrashAltIcon from '~icons/material-symbols/delete-outline-rounded';
import DownloadIcon from '~icons/material-symbols/download-rounded';
@@ -16,41 +15,51 @@
import UploadIcon from '~icons/material-symbols/upload-rounded';
import HistoryIcon from '~icons/mdi/clock-outline';
import GitAltIcon from '~icons/mdi/git';
import OpenInNewIcon from '~icons/material-symbols/open-in-new-rounded';
import { Button } from '../ui/button';
import { Separator } from '../ui/separator';
import {
addHistoryEntry,
clearHistoryData,
getPreviousState,
historyModeStore,
historyStore,
loaderHistoryStore,
restoreHistory
} from './history';
addManualEntry,
clearActive,
historyState,
removeEntry,
restoreEntries,
setMode
} from './historyState.svelte';
dayjs.extend(dayjsRelativeTime);
const HISTORY_SAVE_INTERVAL = 60_000;
const baseTabs: Tab[] = [
{ id: 'manual', title: 'Saved', icon: BookmarkIcon },
{ id: 'auto', title: 'Timeline', icon: HistoryIcon }
];
const loaderTab: Tab = { id: 'loader', title: 'Revisions', icon: GitAltIcon };
const tabs = $derived(
historyState.loaderEntries.length > 0 ? [loaderTab, ...baseTabs] : baseTabs
);
// Surface revisions once when they first appear; the user can switch away after.
let revisionsShown = false;
$effect(() => {
if (historyState.loaderEntries.length > 0 && !revisionsShown) {
revisionsShown = true;
setMode('loader');
}
});
const emptyMessage = $derived(
historyState.mode === 'auto'
? 'No timeline snapshots yet.\nThe Timeline is saved automatically every minute.'
: 'No saved states yet.\nClick the Save button to bookmark the current diagram and restore it later.'
);
const tabSelectHandler = (tab: Tab) => {
historyModeStore.set(tab.id as HistoryType);
setMode(tab.id as HistoryType);
};
let tabs: Tab[] = $state([
{
id: 'manual',
title: 'Saved',
icon: BookmarkIcon
},
{
id: 'auto',
title: 'Timeline',
icon: HistoryIcon
}
]);
const downloadHistory = () => {
const data = get(historyStore);
const data = historyState.entries;
const blob = new Blob([JSON.stringify(data)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -58,9 +67,7 @@
a.download = `mermaid-history-${dayjs().format('YYYY-MM-DD-HHmmss')}.json`;
a.click();
URL.revokeObjectURL(url);
logEvent('history', {
action: 'download'
});
logEvent('history', { action: 'download' });
};
const uploadHistory = () => {
@@ -73,59 +80,39 @@
return;
}
const data: HistoryEntry[] = JSON.parse(await file.text());
restoreHistory(data);
const { restored, invalid, duplicates } = restoreEntries(data);
notify(`${restored} restored, ${duplicates} duplicate, ${invalid} invalid.`);
});
input.click();
};
const saveHistory = (auto = false) => {
const currentState: string = getStateString();
const previousState: string = getPreviousState(auto);
if (previousState !== currentState) {
addHistoryEntry({
state: $inputStateStore,
time: Date.now(),
type: auto ? 'auto' : 'manual'
});
} else if (!auto) {
const saveHistory = () => {
if (!addManualEntry($state.snapshot(inputState))) {
notify('State already saved.');
}
};
const clearHistory = (id?: string): void => {
if (!id && !prompt('Clear all saved items?')) {
return;
const clearAll = () => {
if (prompt('Clear all saved items?')) {
clearActive();
}
clearHistoryData(id);
};
const restoreHistoryItem = (state: State): void => {
inputStateStore.set({ ...state, updateDiagram: true });
replaceInputState({ ...state, updateDiagram: true });
};
onMount(() => {
historyModeStore.set('manual');
setInterval(() => {
saveHistory(true);
}, HISTORY_SAVE_INTERVAL);
});
// Absolute editor URL for an entry, so the link can be opened in a new tab or copied.
const entryUrl = (state: State): string =>
`${window.location.origin}${window.location.pathname}#${serializeState(state)}`;
loaderHistoryStore.subscribe((entries) => {
if (entries.length > 0 && tabs.length === 2) {
tabs = [
{
id: 'loader',
title: 'Revisions',
icon: GitAltIcon
},
...tabs
];
historyModeStore.set('loader');
}
});
// Serialize each entry's URL once per change rather than per row on every render.
const entriesWithUrl = $derived(
historyState.entries.map((entry) => ({ ...entry, openUrl: entryUrl(entry.state) }))
);
</script>
<Card onselect={tabSelectHandler} isOpen isClosable={false} {tabs}>
<Card onselect={tabSelectHandler} isOpen isClosable={false} {tabs} activeTabID={historyState.mode}>
{#snippet actions()}
<div class="flex items-center gap-2">
<Button
@@ -134,7 +121,7 @@
id="uploadHistory"
onclick={uploadHistory}
title="Upload history"><UploadIcon /></Button>
{#if $historyStore.length > 0}
{#if historyState.entries.length > 0}
<Button
id="downloadHistory"
size="icon"
@@ -147,22 +134,22 @@
id="saveHistory"
size="icon"
variant="ghost"
onclick={() => saveHistory()}
onclick={saveHistory}
title="Save current state"><SaveIcon /></Button>
{#if $historyModeStore !== 'loader'}
{#if historyState.mode !== 'loader'}
<Button
id="clearHistory"
size="icon"
variant="ghost"
class="hover:text-destructive"
onclick={() => clearHistory()}
onclick={clearAll}
title="Delete all saved states"><TrashAltIcon /></Button>
{/if}
</div>
{/snippet}
<ul class="flex h-full min-w-fit flex-col gap-2 overflow-auto p-2" id="historyList">
{#if $historyStore.length > 0}
{#each $historyStore as { id, state, time, name, url, type } (id)}
{#if entriesWithUrl.length > 0}
{#each entriesWithUrl as { id, state, time, name, url, type, openUrl } (id)}
<li class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<div class="flex flex-col">
@@ -184,7 +171,20 @@
<span class="text-sm whitespace-nowrap text-primary-foreground/50">
{dayjs(time).fromNow()}
</span>
<Button size="icon" variant="ghost" onclick={() => restoreHistoryItem(state)}>
<Button
href={openUrl}
target="_blank"
rel="noopener"
size="icon"
variant="ghost"
title="Open in new tab">
<OpenInNewIcon />
</Button>
<Button
size="icon"
variant="ghost"
title="Restore this version"
onclick={() => restoreHistoryItem(state)}>
<UndoIcon />
</Button>
{#if type !== 'loader'}
@@ -192,7 +192,8 @@
size="icon"
variant="ghost"
class="hover:text-destructive"
onclick={() => clearHistory(id)}>
title="Delete this version"
onclick={() => removeEntry(id)}>
<TrashAltIcon />
</Button>
{/if}
@@ -202,11 +203,7 @@
</li>
{/each}
{:else}
<div class="m-2 text-center">
No items in History<br />
Click the Save button to save current state and restore it later.<br />
Timeline will automatically be saved every minute.
</div>
<div class="m-2 text-center whitespace-pre-line">{emptyMessage}</div>
{/if}
</ul>
</Card>
-126
View File
@@ -1,126 +0,0 @@
import type { HistoryEntry } from '$lib/types';
import { defaultState } from '$lib/util/state';
import { get } from 'svelte/store';
import { describe, expect, it } from 'vitest';
import {
addHistoryEntry,
clearHistoryData,
historyModeStore,
historyStore,
injectHistoryIDs
} from './history';
describe('history', () => {
it('should handle saving individual history entry', () => {
expect(window.localStorage.getItem('manualHistoryStore')).toBe('[]');
expect(window.localStorage.getItem('autoHistoryStore')).toBe('[]');
addHistoryEntry({
state: defaultState,
time: 12_345,
type: 'manual'
});
const [manualEntry] = JSON.parse(
window.localStorage.getItem('manualHistoryStore') ?? '[]'
) as HistoryEntry[];
expect(manualEntry.time).toBe(12_345);
expect(manualEntry.type).toBe('manual');
expect(manualEntry.name).not.toBeNull();
expect(manualEntry.state).not.toBeNull();
addHistoryEntry({
state: defaultState,
time: 54_321,
type: 'auto'
});
const [autoEntry] = JSON.parse(
window.localStorage.getItem('autoHistoryStore') ?? '[]'
) as HistoryEntry[];
expect(autoEntry.time).toBe(54_321);
expect(autoEntry.type).toBe('auto');
expect(autoEntry.name).not.toBeNull();
expect(autoEntry.state).not.toBeNull();
historyModeStore.set('manual');
clearHistoryData();
historyModeStore.set('auto');
clearHistoryData();
expect(window.localStorage.getItem('manualHistoryStore')).toBe('[]');
expect(window.localStorage.getItem('autoHistoryStore')).toBe('[]');
});
it('should clear history entries', () => {
addHistoryEntry({
state: defaultState,
time: 12_345,
type: 'manual'
});
addHistoryEntry({
state: { ...defaultState, code: 'graph TD\\n A[Christmas] -->|Get money| B(Go shopping)' },
time: 123_456,
type: 'manual'
});
historyModeStore.set('manual');
const store: HistoryEntry[] = get(historyStore);
expect(store.length).toBe(2);
clearHistoryData(store[1].id);
expect(get(historyStore).length).toBe(1);
clearHistoryData();
expect(get(historyStore).length).toBe(0);
historyModeStore.set('auto');
addHistoryEntry({
state: defaultState,
time: 54_321,
type: 'auto'
});
addHistoryEntry({
state: { ...defaultState, code: 'graph TD\\n A[Christmas] -->|Get money| B(Go shopping)' },
time: 654_321,
type: 'auto'
});
expect(get(historyStore).length).toBe(2);
clearHistoryData();
expect(get(historyStore).length).toBe(0);
// Test calling when history is empty
clearHistoryData();
expect(get(historyStore).length).toBe(0);
});
});
describe('history migration', () => {
it('should inject history IDs as migration', () => {
window.localStorage.setItem(
'manualHistoryStore',
'[{"state":{"code":"graph TD\\n A[Halloween] -->|Get money| B(Go shopping)","mermaid":"{\\n \\"theme\\": \\"dark\\"\\n}","autoSync":true,"updateDiagram":false},"time":0,"type":"manual","name":"hollow-art"},{"state":{"code":"graph TD\\n A[Christmas] -->|Get money| B(Go shopping)","mermaid":"{\\n \\"theme\\": \\"dark\\"\\n}","autoSync":true,"updateDiagram":true},"time":0,"type":"manual","name":"helpful-ocean"}]'
);
window.localStorage.setItem(
'autoHistoryStore',
'[{"state":{"code":"graph TD\\n A[New Year] -->|Get money| B(Go shopping)","mermaid":"{\\n \\"theme\\": \\"dark\\"\\n}","autoSync":true,"updateDiagram":false},"time":0,"type":"auto","name":"barking-dog"},{"state":{"code":"graph TD\\n A[Christmas] -->|Get money| B(Go shopping)","mermaid":"{\\n \\"theme\\": \\"dark\\"\\n}","autoSync":true,"updateDiagram":true},"time":0,"type":"manual","name":"needy-mosquito"}]'
);
let manualHistoryStore = JSON.parse(
window.localStorage.getItem('manualHistoryStore') ?? '[]'
) as HistoryEntry[],
autoHistoryStore = JSON.parse(
window.localStorage.getItem('autoHistoryStore') ?? '[]'
) as HistoryEntry[];
expect(manualHistoryStore.every(({ id }) => id !== undefined)).toBe(false);
expect(autoHistoryStore.every(({ id }) => id !== undefined)).toBe(false);
injectHistoryIDs();
manualHistoryStore = JSON.parse(
window.localStorage.getItem('manualHistoryStore') ?? '[]'
) as HistoryEntry[];
autoHistoryStore = JSON.parse(
window.localStorage.getItem('autoHistoryStore') ?? '[]'
) as HistoryEntry[];
expect(manualHistoryStore.every(({ id }) => id !== undefined)).toBe(true);
expect(autoHistoryStore.every(({ id }) => id !== undefined)).toBe(true);
});
});
-151
View File
@@ -1,151 +0,0 @@
import type { HistoryEntry, HistoryType, Optional } from '$lib/types';
import { localStorage, persist } from '$lib/util/persist';
import { logEvent } from '$lib/util/stats';
import { generateSlug } from 'random-word-slugs';
import type { Readable, Writable } from 'svelte/store';
import { derived, get, writable } from 'svelte/store';
import { v4 as uuidV4 } from 'uuid';
const MAX_AUTO_HISTORY_LENGTH = 30;
export const historyModeStore: Writable<HistoryType> = persist(
writable('manual'),
localStorage(),
'autoHistoryMode'
);
const autoHistoryStore: Writable<HistoryEntry[]> = persist(
writable([]),
localStorage(),
'autoHistoryStore'
);
const manualHistoryStore: Writable<HistoryEntry[]> = persist(
writable([]),
localStorage(),
'manualHistoryStore'
);
export const loaderHistoryStore: Writable<HistoryEntry[]> = writable([]);
export const historyStore: Readable<HistoryEntry[]> = derived(
[historyModeStore, autoHistoryStore, manualHistoryStore, loaderHistoryStore],
([historyMode, autoHistories, manualHistories, loadedHistories], set) => {
switch (historyMode) {
case 'auto': {
set(autoHistories);
break;
}
case 'manual': {
set(manualHistories);
break;
}
case 'loader': {
set(loadedHistories);
break;
}
default: {
set(autoHistories);
}
}
}
);
export const addHistoryEntry = (entryToAdd: Optional<HistoryEntry, 'id'>): void => {
const entry: HistoryEntry = {
...entryToAdd,
id: uuidV4()
};
if (entry.type === 'loader') {
loaderHistoryStore.update((entries) => [entry, ...entries]);
return;
}
if (!entry.name) {
entry.name = generateSlug(2);
}
if (entry.type === 'auto') {
autoHistoryStore.update((entries) => {
if (entries.length >= MAX_AUTO_HISTORY_LENGTH) {
entries = entries.slice(0, MAX_AUTO_HISTORY_LENGTH - 1);
}
return [entry, ...entries];
});
}
manualHistoryStore.update((entries) => [entry, ...entries]);
logEvent('history', { action: 'save' });
};
export const clearHistoryData = (idToClear?: string): void => {
(get(historyModeStore) === 'auto' ? autoHistoryStore : manualHistoryStore).update((entries) => {
if (get(historyModeStore) !== 'loader') {
entries = entries.filter(({ id }) => idToClear && id != idToClear);
logEvent('history', { action: 'clear', type: idToClear ? 'single' : 'all' });
}
return entries;
});
};
export const getPreviousState = (auto: boolean): string => {
const entries = get(auto ? autoHistoryStore : manualHistoryStore);
if (entries.length > 0) {
return JSON.stringify(entries[0].state);
}
return '';
};
export const restoreHistory = (data: HistoryEntry[]) => {
const entries = data.filter((element) => validateEntry(element));
const invalidEntryCount = data.length - entries.length;
if (invalidEntryCount > 0) {
console.error(`${invalidEntryCount} invalid history entries were removed.`);
console.error(data);
}
if (entries.length > 0) {
let entryCount = 0;
(entries[0].type === 'auto' ? autoHistoryStore : manualHistoryStore).update((existing) => {
const existingIDs = new Set(existing.map(({ id }) => id));
const newEntries = entries.filter(({ id }) => !existingIDs.has(id));
entryCount = newEntries.length;
const combined = [...existing, ...newEntries];
combined.sort((a, b) => b.time - a.time);
return combined;
});
alert(
`${entryCount} entries restored. ${invalidEntryCount} invalid, ${
entries.length - entryCount
} duplicates.`
);
logEvent('history', {
action: 'restore',
success: entryCount,
invalid: invalidEntryCount,
duplicates: entries.length - entryCount
});
} else {
alert('No valid entries found.');
}
};
const setIDs = (entries: HistoryEntry[]) => {
for (const entry of entries) {
if (!entry.id) {
entry.id = uuidV4();
}
}
return entries;
};
export const injectHistoryIDs = (): void => {
autoHistoryStore.update(setIDs);
manualHistoryStore.update(setIDs);
};
const validateEntry = (entry: HistoryEntry): boolean => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
return entry.type && entry.state && entry.time && true;
};
@@ -0,0 +1,177 @@
import type { HistoryEntry, HistoryType, Optional, State } from '$lib/types';
import { persisted, readJSON, type Persisted } from '$lib/util/persist.svelte';
import { inputState } from '$lib/util/state.svelte';
import { logEvent } from '$lib/util/stats';
import { generateSlug } from 'random-word-slugs';
import { v4 as uuidV4 } from 'uuid';
const MAX_AUTO_HISTORY_LENGTH = 30;
const AUTO_SAVE_INTERVAL = 60_000;
const auto = persisted<HistoryEntry[]>('autoHistoryStore', []);
const manual = persisted<HistoryEntry[]>('manualHistoryStore', []);
const mode = persisted<HistoryType>('autoHistoryMode', 'manual');
let loader = $state<HistoryEntry[]>([]);
// Loader entries are in-memory, so a persisted 'loader' mode is empty after reload.
if (mode.value === 'loader') {
mode.value = 'manual';
}
// The persisted slot backing a mode; loader is in-memory and has no slot.
const slotFor = (m: HistoryType): Persisted<HistoryEntry[]> | null => {
switch (m) {
case 'auto': {
return auto;
}
case 'manual': {
return manual;
}
default: {
return null;
}
}
};
export const historyState = {
get entries(): HistoryEntry[] {
return slotFor(mode.value)?.value ?? loader;
},
get loaderEntries(): HistoryEntry[] {
return loader;
},
get mode(): HistoryType {
return mode.value;
}
};
export const setMode = (next: HistoryType): void => {
mode.value = next;
};
// Dedup key: only the fields that define the diagram, so volatile/view-only
// fields (renderCount, pan/zoom, …) don't count as a change.
export const stateKey = (state: State): string =>
JSON.stringify({ code: state.code, mermaid: state.mermaid });
const createEntry = (state: State, type: 'auto' | 'manual'): HistoryEntry => ({
id: uuidV4(),
name: generateSlug(2),
state,
time: Date.now(),
type
});
// Returns true if added, false if it duplicated the most recent entry.
const addEntry = (
slot: Persisted<HistoryEntry[]>,
state: State,
type: 'auto' | 'manual',
maxLength?: number
): boolean => {
const entries = slot.value;
if (entries.length > 0 && stateKey(entries[0].state) === stateKey(state)) {
return false;
}
const trimmed =
maxLength && entries.length >= maxLength ? entries.slice(0, maxLength - 1) : entries;
slot.value = [createEntry(state, type), ...trimmed];
logEvent('history', { action: 'save', type });
return true;
};
export const addManualEntry = (state: State): boolean => addEntry(manual, state, 'manual');
export const addAutoEntry = (state: State): boolean =>
addEntry(auto, state, 'auto', MAX_AUTO_HISTORY_LENGTH);
// Replaces the in-memory revisions (e.g. when a gist is loaded), assigning ids.
export const setLoaderEntries = (entries: Optional<HistoryEntry, 'id'>[]): void => {
loader = entries.map((entry) =>
entry.id ? (entry as HistoryEntry) : { ...entry, id: uuidV4() }
);
};
export const removeEntry = (id: string): void => {
const slot = slotFor(mode.value);
if (!slot) {
return;
}
slot.value = slot.value.filter((entry) => entry.id !== id);
logEvent('history', { action: 'clear', type: 'single' });
};
export const clearActive = (): void => {
const slot = slotFor(mode.value);
if (!slot) {
return;
}
slot.value = [];
logEvent('history', { action: 'clear', type: 'all' });
};
const validateEntry = (entry: HistoryEntry): boolean =>
Boolean(entry && entry.type && entry.state) && typeof entry.time === 'number';
export interface RestoreResult {
restored: number;
invalid: number;
duplicates: number;
}
// Routes each uploaded entry to the store matching its own type, skipping ids
// that already exist.
export const restoreEntries = (data: HistoryEntry[]): RestoreResult => {
const valid = data.filter((entry) => validateEntry(entry));
const invalid = data.length - valid.length;
let restored = 0;
const slots: [HistoryType, Persisted<HistoryEntry[]>][] = [
['auto', auto],
['manual', manual]
];
for (const [type, slot] of slots) {
const incoming = valid.filter((entry) => entry.type === type);
if (incoming.length === 0) {
continue;
}
const existingIDs = slot.value.map(({ id }) => id);
const fresh = incoming.filter(({ id }) => !existingIDs.includes(id));
restored += fresh.length;
slot.value = [...slot.value, ...fresh].sort((a, b) => b.time - a.time);
}
const duplicates = valid.length - restored;
logEvent('history', { action: 'restore', duplicates, invalid, success: restored });
return { restored, invalid, duplicates };
};
const setIDs = (entries: HistoryEntry[]): HistoryEntry[] =>
entries.map((entry) => (entry.id ? entry : { ...entry, id: uuidV4() }));
// One-time migration: re-reads localStorage so entries written by an older
// version get ids, then persists and updates the reactive state.
export const injectHistoryIDs = (): void => {
auto.value = setIDs(readJSON<HistoryEntry[]>('autoHistoryStore', []));
manual.value = setIDs(readJSON<HistoryEntry[]>('manualHistoryStore', []));
};
let autoSaveTimer: ReturnType<typeof setInterval> | undefined;
// Idempotent; returns the stop function for use as a lifecycle cleanup.
export const startAutoSave = (): (() => void) => {
if (autoSaveTimer === undefined) {
autoSaveTimer = setInterval(
() => addAutoEntry($state.snapshot(inputState)),
AUTO_SAVE_INTERVAL
);
}
return stopAutoSave;
};
export const stopAutoSave = (): void => {
if (autoSaveTimer !== undefined) {
clearInterval(autoSaveTimer);
autoSaveTimer = undefined;
}
};
@@ -0,0 +1,314 @@
import type { HistoryEntry } from '$lib/types';
import { defaultState, replaceInputState } from '$lib/util/state.svelte';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
addAutoEntry,
addManualEntry,
clearActive,
historyState,
injectHistoryIDs,
removeEntry,
restoreEntries,
setLoaderEntries,
setMode,
startAutoSave,
stateKey,
stopAutoSave
} from './historyState.svelte';
const codeState = (code: string) => ({ ...defaultState, code });
/** Read the entries currently shown for a given mode. */
const entriesFor = (mode: 'auto' | 'manual' | 'loader'): HistoryEntry[] => {
setMode(mode);
return historyState.entries;
};
beforeEach(() => {
// Reset every store through the public API so tests don't leak into each other.
setMode('manual');
clearActive();
setMode('auto');
clearActive();
setLoaderEntries([]);
setMode('manual');
});
describe('stateKey', () => {
it('ignores volatile and view-only fields, keying only on code + config', () => {
const a = {
...defaultState,
code: 'graph TD\n A-->B',
panZoom: true,
renderCount: 1,
updateDiagram: true
};
const b = {
...defaultState,
code: 'graph TD\n A-->B',
pan: { x: 5, y: 5 },
panZoom: false,
renderCount: 99,
updateDiagram: false
};
expect(stateKey(a)).toBe(stateKey(b));
});
it('differs when code differs', () => {
expect(stateKey(codeState('graph TD\n A-->B'))).not.toBe(
stateKey(codeState('graph TD\n A-->C'))
);
});
it('differs when config differs', () => {
const a = { ...defaultState, mermaid: '{"theme":"dark"}' };
const b = { ...defaultState, mermaid: '{"theme":"forest"}' };
expect(stateKey(a)).not.toBe(stateKey(b));
});
});
describe('addManualEntry', () => {
it('adds to the manual store only, never the auto store', () => {
expect(addManualEntry(codeState('graph TD\n A-->B'))).toBe(true);
expect(entriesFor('manual')).toHaveLength(1);
expect(entriesFor('auto')).toHaveLength(0);
});
it('returns false and does not add a duplicate of the latest entry', () => {
const state = codeState('graph TD\n A-->B');
expect(addManualEntry(state)).toBe(true);
expect(addManualEntry(state)).toBe(false);
expect(entriesFor('manual')).toHaveLength(1);
});
it('treats states differing only in volatile/view fields as duplicates', () => {
expect(addManualEntry({ ...defaultState, code: 'graph TD\n A-->B', renderCount: 1 })).toBe(
true
);
expect(
addManualEntry({
...defaultState,
code: 'graph TD\n A-->B',
panZoom: false,
renderCount: 2,
updateDiagram: true
})
).toBe(false);
expect(entriesFor('manual')).toHaveLength(1);
});
it('adds a new entry when the code changes', () => {
expect(addManualEntry(codeState('graph TD\n A-->B'))).toBe(true);
expect(addManualEntry(codeState('graph TD\n A-->C'))).toBe(true);
expect(entriesFor('manual')).toHaveLength(2);
});
it('generates an id and a name for each entry', () => {
addManualEntry(codeState('graph TD\n A-->B'));
const [entry] = entriesFor('manual');
expect(entry.id).toBeTruthy();
expect(entry.name).toBeTruthy();
expect(entry.type).toBe('manual');
});
});
describe('addAutoEntry', () => {
it('adds to the auto store only, never the manual store', () => {
expect(addAutoEntry(codeState('graph TD\n A-->B'))).toBe(true);
expect(entriesFor('auto')).toHaveLength(1);
expect(entriesFor('manual')).toHaveLength(0);
});
it('returns false and does not add a duplicate of the latest entry', () => {
const state = codeState('graph TD\n A-->B');
expect(addAutoEntry(state)).toBe(true);
expect(addAutoEntry(state)).toBe(false);
expect(entriesFor('auto')).toHaveLength(1);
});
it('caps the auto store at 30 entries, dropping the oldest', () => {
for (let i = 0; i < 35; i++) {
addAutoEntry(codeState(`graph TD\n A-->B${i}`));
}
const entries = entriesFor('auto');
expect(entries).toHaveLength(30);
expect(entries[0].state.code).toBe('graph TD\n A-->B34');
});
});
describe('historyState.entries', () => {
it('reflects the active mode', () => {
addManualEntry(codeState('manual-code'));
addAutoEntry(codeState('auto-code'));
setMode('manual');
expect(historyState.entries).toHaveLength(1);
expect(historyState.entries[0].state.code).toBe('manual-code');
setMode('auto');
expect(historyState.entries).toHaveLength(1);
expect(historyState.entries[0].state.code).toBe('auto-code');
setMode('loader');
expect(historyState.entries).toHaveLength(0);
});
});
describe('removeEntry / clearActive', () => {
it('removes a single entry from the active store by id', () => {
addManualEntry(codeState('graph TD\n A-->B'));
addManualEntry(codeState('graph TD\n A-->C'));
setMode('manual');
const target = historyState.entries[1].id;
removeEntry(target);
expect(historyState.entries).toHaveLength(1);
expect(historyState.entries.some((e) => e.id === target)).toBe(false);
});
it('clears all entries in the active store only', () => {
addManualEntry(codeState('graph TD\n A-->B'));
addAutoEntry(codeState('graph TD\n A-->C'));
setMode('manual');
clearActive();
expect(entriesFor('manual')).toHaveLength(0);
expect(entriesFor('auto')).toHaveLength(1);
});
it('does nothing in loader mode', () => {
setLoaderEntries([
{ name: 'rev', state: defaultState, time: 1, type: 'loader', url: 'http://x' }
]);
setMode('loader');
clearActive();
expect(historyState.entries).toHaveLength(1);
});
});
describe('setLoaderEntries', () => {
it('replaces the in-memory revisions and assigns ids', () => {
setLoaderEntries([
{ name: 'v1', state: defaultState, time: 1, type: 'loader', url: 'http://x/1' },
{ name: 'v2', state: defaultState, time: 2, type: 'loader', url: 'http://x/2' }
]);
setMode('loader');
expect(historyState.entries).toHaveLength(2);
expect(historyState.entries.every((e) => e.id)).toBe(true);
setLoaderEntries([
{ name: 'only', state: defaultState, time: 3, type: 'loader', url: 'http://x/3' }
]);
expect(historyState.entries).toHaveLength(1);
expect(historyState.entries[0].name).toBe('only');
});
});
describe('restoreEntries', () => {
it('routes each entry to the store matching its own type', () => {
const result = restoreEntries([
{ id: 'a1', name: 'a', state: defaultState, time: 10, type: 'auto' },
{ id: 'm1', name: 'm', state: defaultState, time: 20, type: 'manual' }
]);
expect(result.restored).toBe(2);
expect(entriesFor('auto').map((e) => e.id)).toEqual(['a1']);
expect(entriesFor('manual').map((e) => e.id)).toEqual(['m1']);
});
it('skips duplicates by id and reports them', () => {
restoreEntries([{ id: 'm1', name: 'm', state: defaultState, time: 20, type: 'manual' }]);
const result = restoreEntries([
{ id: 'm1', name: 'm', state: defaultState, time: 20, type: 'manual' },
{ id: 'm2', name: 'm2', state: defaultState, time: 30, type: 'manual' }
]);
expect(result.restored).toBe(1);
expect(result.duplicates).toBe(1);
expect(entriesFor('manual')).toHaveLength(2);
});
it('reports invalid entries and does not restore them', () => {
const result = restoreEntries([
{ id: 'm1', name: 'm', state: defaultState, time: 20, type: 'manual' },
{ foo: 'bar' } as unknown as HistoryEntry
]);
expect(result.restored).toBe(1);
expect(result.invalid).toBe(1);
});
it('sorts restored entries newest first', () => {
restoreEntries([
{ id: 'm1', name: 'old', state: defaultState, time: 10, type: 'manual' },
{ id: 'm2', name: 'new', state: defaultState, time: 30, type: 'manual' },
{ id: 'm3', name: 'mid', state: defaultState, time: 20, type: 'manual' }
]);
expect(entriesFor('manual').map((e) => e.time)).toEqual([30, 20, 10]);
});
it('restores entries whose time is 0 (epoch) instead of treating them as invalid', () => {
const result = restoreEntries([
{ id: 'm0', name: 'epoch', state: defaultState, time: 0, type: 'manual' }
]);
expect(result.restored).toBe(1);
expect(result.invalid).toBe(0);
expect(entriesFor('manual')).toHaveLength(1);
});
});
describe('injectHistoryIDs migration', () => {
it('adds ids to persisted entries that lack them', () => {
window.localStorage.setItem(
'manualHistoryStore',
'[{"state":{"code":"a"},"time":1,"type":"manual","name":"x"}]'
);
window.localStorage.setItem(
'autoHistoryStore',
'[{"state":{"code":"b"},"time":2,"type":"auto","name":"y"}]'
);
injectHistoryIDs();
const manual = JSON.parse(
window.localStorage.getItem('manualHistoryStore') ?? '[]'
) as HistoryEntry[];
const auto = JSON.parse(
window.localStorage.getItem('autoHistoryStore') ?? '[]'
) as HistoryEntry[];
expect(manual).toHaveLength(1);
expect(auto).toHaveLength(1);
expect(manual.every(({ id }) => id !== undefined)).toBe(true);
expect(auto.every(({ id }) => id !== undefined)).toBe(true);
});
});
describe('auto-save lifecycle', () => {
afterEach(() => {
stopAutoSave();
vi.useRealTimers();
});
it('records an auto entry on each interval from the current editor state', () => {
vi.useFakeTimers();
replaceInputState(codeState('graph TD\n auto-saved'));
startAutoSave();
vi.advanceTimersByTime(60_000);
const entries = entriesFor('auto');
expect(entries).toHaveLength(1);
expect(entries[0].state.code).toBe('graph TD\n auto-saved');
});
it('is idempotent: calling startAutoSave twice does not double-record', () => {
vi.useFakeTimers();
replaceInputState(codeState('graph TD\n once'));
startAutoSave();
startAutoSave();
vi.advanceTimersByTime(60_000);
expect(entriesFor('auto')).toHaveLength(1);
});
it('stops recording after stopAutoSave', () => {
vi.useFakeTimers();
replaceInputState(codeState('graph TD\n stoppable'));
startAutoSave();
vi.advanceTimersByTime(60_000);
stopAutoSave();
replaceInputState(codeState('graph TD\n after-stop'));
vi.advanceTimersByTime(60_000);
expect(entriesFor('auto')).toHaveLength(1);
});
});
+10 -10
View File
@@ -3,7 +3,7 @@
import * as Popover from '$/components/ui/popover';
import { Switch } from '$/components/ui/switch';
import { env } from '$/util/env';
import { urlsStore } from '$/util/state';
import { urls } from '$/util/state.svelte';
import { logMermaidChartClick } from '$/util/stats';
import { cn } from '$/utils';
import { mode, setMode } from 'mode-watcher';
@@ -28,14 +28,14 @@
sharesData?: boolean;
checkDiagramType?: boolean;
isSectionEnd?: boolean;
renderer: (item: Omit<MenuItem, 'renderer'>) => ReturnType<Snippet>;
renderer: Snippet<[Omit<MenuItem, 'renderer'>]>;
}
const menuItems: MenuItem[] = $derived([
{ label: 'New', icon: AddIcon, href: $urlsStore.new, renderer: menuItem },
{ label: 'New', icon: AddIcon, href: urls.current.new, renderer: menuItem },
{ label: 'Duplicate', icon: DuplicateIcon, href: window.location.href, renderer: menuItem },
{
href: $urlsStore.mermaidChart({ medium: 'main_menu' }).playground,
href: urls.current.mermaidChart({ medium: 'main_menu' }).playground,
icon: PlaygroundIcon,
isSectionEnd: true,
label: 'Edit in Playground',
@@ -62,7 +62,7 @@
},
{
checkDiagramType: false,
href: $urlsStore.mermaidChart({ medium: 'main_menu' }).plugins,
href: urls.current.mermaidChart({ medium: 'main_menu' }).plugins,
icon: PluginIcon,
label: 'Plugins',
onclick: () => logMermaidChartClick('plugins'),
@@ -79,7 +79,7 @@
{
checkDiagramType: false,
class: 'text-accent border-b-0',
href: $urlsStore.mermaidChart({ medium: 'main_menu' }).home,
href: urls.current.mermaidChart({ medium: 'main_menu' }).home,
icon: MermaidChartIcon,
label: 'Mermaid',
onclick: () => logMermaidChartClick('mermaidHome'),
@@ -89,7 +89,7 @@
]);
</script>
{#snippet menuItem(options: MenuItem)}
{#snippet menuItem(options: Omit<MenuItem, 'renderer'>)}
<a
href={options.href}
target="_blank"
@@ -104,7 +104,7 @@
</a>
{/snippet}
{#snippet mcMenuItem(item: MenuItem)}
{#snippet mcMenuItem(item: Omit<MenuItem, 'renderer'>)}
<McWrapper
side="right"
labelPrefix={item.sharesData === false ? 'Opens a new tab in' : undefined}
@@ -114,7 +114,7 @@
</McWrapper>
{/snippet}
{#snippet darkModeMenuItem(options: MenuItem)}
{#snippet darkModeMenuItem(options: Omit<MenuItem, 'renderer'>)}
<div
class={cn(
'flex cursor-pointer items-center justify-between border-b-2 px-3 py-2 hover:bg-muted',
@@ -126,7 +126,7 @@
Dark Mode
</span>
<Switch
checked={$mode === 'dark'}
checked={mode.current === 'dark'}
onCheckedChange={(dark) => setMode(dark ? 'dark' : 'light')} />
</div>
{/snippet}
+40 -39
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import type { EditorProps } from '$/types';
import { stateStore } from '$/util/state';
import { validatedState } from '$/util/state.svelte';
import { json, jsonLanguage } from '@codemirror/lang-json';
import { markdown } from '@codemirror/lang-markdown';
import { yamlFrontmatter } from '@codemirror/lang-yaml';
@@ -15,14 +15,22 @@
let editorView: EditorView | undefined;
let editorContainer: HTMLDivElement;
let currentText = $state('');
// Deliberately not $state: the sync effect below both reads and writes it,
// so a reactive currentText would make every keystroke re-run the effect
// against the not-yet-revalidated state and revert the user's input.
let currentText = '';
const themeCompartment = new Compartment();
const languageCompartment = new Compartment();
const { onUpdate }: EditorProps = $props();
onMount(() => {
const themeCompartment = new Compartment();
const languageCompartment = new Compartment();
$effect(() => {
editorView?.dispatch({
effects: themeCompartment.reconfigure(mode.current === 'dark' ? vsCodeDark : vsCodeLight)
});
});
onMount(() => {
editorView = new EditorView({
state: EditorState.create({
doc: currentText,
@@ -56,44 +64,37 @@
parent: editorContainer
});
const unsubscribeMode = mode.subscribe((mode) => {
editorView?.dispatch({
effects: themeCompartment.reconfigure(mode === 'dark' ? vsCodeDark : vsCodeLight)
});
});
const unsubscribeState = stateStore.subscribe(({ editorMode, code, mermaid }) => {
const text = editorMode === 'code' ? code : mermaid;
if (currentText === text || !editorView) {
return;
}
currentText = text;
editorView.dispatch({
changes: {
from: 0,
to: editorView.state.doc.length,
insert: text
}
});
const stateLanguage = editorView.state.facet(language);
const isStateJson = stateLanguage === jsonLanguage;
const isCodeJson = editorMode === 'config';
if (stateLanguage && isStateJson === isCodeJson) {
return;
}
editorView.dispatch({
effects: languageCompartment.reconfigure(
isCodeJson ? json() : yamlFrontmatter({ content: markdown() })
)
});
});
return () => {
unsubscribeMode();
unsubscribeState();
editorView?.destroy();
};
});
$effect(() => {
const { editorMode, code, mermaid } = validatedState.current;
const text = editorMode === 'code' ? code : mermaid;
if (currentText === text || !editorView) {
return;
}
currentText = text;
editorView.dispatch({
changes: {
from: 0,
to: editorView.state.doc.length,
insert: text
}
});
const stateLanguage = editorView.state.facet(language);
const isStateJson = stateLanguage === jsonLanguage;
const isCodeJson = editorMode === 'config';
if (stateLanguage && isStateJson === isCodeJson) {
return;
}
editorView.dispatch({
effects: languageCompartment.reconfigure(
isCodeJson ? json() : yamlFrontmatter({ content: markdown() })
)
});
});
</script>
<div bind:this={editorContainer} class="size-full"></div>
+5 -4
View File
@@ -8,11 +8,12 @@
</script>
<script lang="ts">
import { resolve } from '$app/paths';
import MainMenu from '$/components/MainMenu.svelte';
import { Button } from '$/components/ui/button';
import { Separator } from '$/components/ui/separator';
import { dismissPromotion, getActivePromotion } from '$lib/util/promos/promo';
import type { ComponentProps, Snippet } from 'svelte';
import { dismissPromotion, getActivePromotion } from '$lib/util/promos/promo.svelte';
import { untrack, type ComponentProps, type Snippet } from 'svelte';
import MermaidIcon from '~icons/custom/mermaid';
import CloseIcon from '~icons/material-symbols/close-rounded';
import GithubIcon from '~icons/mdi/github';
@@ -40,7 +41,7 @@
}
];
let activePromotion = $state(hidePromotion ? undefined : getActivePromotion());
let activePromotion = $state(untrack(() => (hidePromotion ? undefined : getActivePromotion())));
const trackBannerClick = () => {
if (!activePromotion) {
@@ -83,7 +84,7 @@
<div class="flex flex-1 items-center gap-2">
<MainMenu />
<MermaidIcon class="size-6" />
<a href="/" class="whitespace-nowrap text-accent">
<a href={resolve('/', {})} class="whitespace-nowrap text-accent">
{#if !mobileToggle}
Mermaid
{/if}
+2 -2
View File
@@ -3,7 +3,7 @@
import { Button } from '$/components/ui/button';
import { Separator } from '$/components/ui/separator';
import type { PanZoomState } from '$/util/panZoom';
import { urlsStore } from '$/util/state';
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';
@@ -28,7 +28,7 @@
<MagnifyingGlassPlusIcon />
</Button>
<Separator orientation="vertical" class="hidden sm:block" />
<Button variant="ghost" size="icon" title="Full Screen" href={$urlsStore.view} target="_blank">
<Button variant="ghost" size="icon" title="Full Screen" href={urls.current.view} target="_blank">
<ExpandIcon />
</Button>
</FloatingToolbar>
+54 -17
View File
@@ -1,13 +1,20 @@
<script lang="ts">
import Card from '$/components/Card/Card.svelte';
import { Button } from '$/components/ui/button';
import { getSampleDiagrams } from '$/util/mermaid';
import { updateCode } from '$lib/util/state';
import { Button, buttonVariants } from '$/components/ui/button';
import * as Popover from '$/components/ui/popover';
import { getSampleDiagrams, type SampleExample } from '$/util/mermaid';
import { updateCode } from '$lib/util/state.svelte';
import { logEvent } from '$lib/util/stats';
import { cn } from '$lib/utils';
import ShapesIcon from '~icons/material-symbols/account-tree-outline-rounded';
import ChevronDownIcon from '~icons/material-symbols/keyboard-arrow-down-rounded';
const extras = {
ZenUML: `zenuml
const extras: Record<string, SampleExample[]> = {
ZenUML: [
{
title: 'Order Service',
isDefault: true,
code: `zenuml
title Order Service
@Actor Client #FFEBE6
@Boundary OrderController #0747A6
@@ -25,21 +32,24 @@
if(order != null) {
par {
PurchaseService.createPO(order)
InvoiceService.createInvoice(order)
}
InvoiceService.createInvoice(order)
}
}
}
}
`
}
]
};
const samples = { ...getSampleDiagrams(), ...extras } as const;
const loadSampleDiagram = (diagramType: string): void => {
updateCode(samples[diagramType], {
const samples = { ...getSampleDiagrams(), ...extras };
const loadSampleDiagram = (diagramType: string, example: SampleExample): void => {
updateCode(example.code, {
resetPanZoom: true,
updateDiagram: true
});
logEvent('loadSampleDiagram', { diagramType });
logEvent('loadSampleDiagram', { diagramType, exampleTitle: example.title });
};
const mainDiagrams = [
@@ -62,12 +72,39 @@
<Card title="Sample Diagrams" isOpen isStackable icon={{ component: ShapesIcon }}>
<div class="flex h-fit max-h-52 flex-wrap gap-2 overflow-y-auto p-2">
{#each diagramOrder as sample (sample)}
<Button
size="sm"
class="w-fit min-w-20 flex-grow normal-case"
onclick={() => loadSampleDiagram(sample)}>
{sample}
</Button>
{@const examples = samples[sample]}
<div class="flex min-w-20 flex-grow">
<Button
size="sm"
class={cn('flex-grow normal-case', examples.length > 1 && 'rounded-r-none')}
onclick={() => loadSampleDiagram(sample, examples[0])}>
{sample}
</Button>
{#if examples.length > 1}
<Popover.Root>
<Popover.Trigger
aria-label="Choose a {sample} example"
class={cn(
buttonVariants({ size: 'sm' }),
'rounded-l-none border-l border-primary-foreground/30 px-0.5 [&_svg]:size-5'
)}>
<ChevronDownIcon />
</Popover.Trigger>
<Popover.Content align="start" class="flex w-fit flex-col gap-1 p-1">
{#each examples as example (example.title)}
<Popover.Close
class={cn(
buttonVariants({ variant: 'ghost', size: 'sm' }),
'justify-start normal-case'
)}
onclick={() => loadSampleDiagram(sample, example)}>
{example.title}
</Popover.Close>
{/each}
</Popover.Content>
</Popover.Root>
{/if}
</div>
{/each}
</div>
</Card>
+1 -1
View File
@@ -1,4 +1,4 @@
<script>
<script lang="ts">
import ExternalLinkWrapper from '$/components/ExternalLinkWrapper.svelte';
import * as Dialog from '$/components/ui/dialog';
import { env } from '$/util/env';
+3 -3
View File
@@ -1,9 +1,9 @@
<script>
<script lang="ts">
import { buttonVariants } from '$/components/ui/button';
import * as Dialog from '$/components/ui/dialog';
import { Separator } from '$/components/ui/separator';
import { env } from '$/util/env';
import { urlsStore } from '$/util/state';
import { urls } from '$/util/state.svelte';
import { asset } from '$app/paths';
import ShareIcon from '~icons/material-symbols/share';
import CopyInput from './CopyInput.svelte';
@@ -38,7 +38,7 @@
<MermaidChartIcon class="size-5" />
Mermaid Chart Playground
</h2>
<CopyInput value={$urlsStore.mermaidChart({ medium: 'share' }).playground} />
<CopyInput value={urls.current.mermaidChart({ medium: 'share' }).playground} />
<Dialog.Description>
Opens the Mermaid Chart Playground with Mermaid AI, Visual Editor, and more.
</Dialog.Description>
+11 -5
View File
@@ -1,21 +1,27 @@
<script lang="ts">
import FloatingToolbar from '$/components/FloatingToolbar.svelte';
import { Toggle } from '$/components/ui/toggle';
import { defaultState, inputStateStore } from '$/util/state';
import { defaultState, inputState, updateCodeStore } from '$/util/state.svelte';
import RoughIcon from '~icons/material-symbols/draw-outline-rounded';
import BackgroundIcon from '~icons/material-symbols/grid-4x4-rounded';
if ($inputStateStore.grid === undefined) {
if (inputState.grid === undefined) {
// Handle cases where old states were saved without grid option
$inputStateStore.grid = defaultState.grid;
updateCodeStore({ grid: defaultState.grid });
}
</script>
<FloatingToolbar>
<Toggle bind:pressed={$inputStateStore.rough} size="sm" title="Hand-Drawn">
<Toggle
bind:pressed={() => inputState.rough, (rough) => updateCodeStore({ rough })}
size="sm"
title="Hand-Drawn">
<RoughIcon />
</Toggle>
<Toggle bind:pressed={$inputStateStore.grid} size="sm" title="Background Grid">
<Toggle
bind:pressed={() => inputState.grid ?? defaultState.grid, (grid) => updateCodeStore({ grid })}
size="sm"
title="Background Grid">
<BackgroundIcon />
</Toggle>
</FloatingToolbar>
+2 -2
View File
@@ -24,12 +24,12 @@
</script>
<div class="inline-grid">
{#key $mode}
{#key mode.current}
<div
in:spin={{ clockWise: true }}
out:spin={{ clockWise: false }}
class="col-start-1 row-start-1">
{#if $mode === 'dark'}
{#if mode.current === 'dark'}
<MoonIcon />
{:else}
<SunIcon />
@@ -23,9 +23,9 @@
variant="ghost"
size="icon"
data-testid={TID.themeToggleButton}
title="Switch to {$mode === 'dark' ? 'light' : 'dark'} theme"
title="Switch to {mode.current === 'dark' ? 'light' : 'dark'} theme"
class="[&_svg]:size-5"
onclick={() => setMode($mode === 'dark' ? 'light' : 'dark')}>
onclick={() => setMode(mode.current === 'dark' ? 'light' : 'dark')}>
<ThemeIcon />
</Button>
</FloatingToolbar>
+11 -9
View File
@@ -3,7 +3,7 @@
import { recordRenderTime, shouldRefreshView } from '$/util/autoSync';
import { render as renderDiagram } from '$/util/mermaid';
import { PanZoomState } from '$/util/panZoom';
import { inputStateStore, stateStore, updateCodeStore } from '$/util/state';
import { updateCodeStore, validatedState } from '$/util/state.svelte';
import { saveStatistics } from '$/util/stats';
import FontAwesome, { mayContainFontAwesome } from '$lib/components/FontAwesome.svelte';
import uniqueID from 'lodash-es/uniqueId';
@@ -133,18 +133,20 @@
const renderTime = Date.now() - startTime;
saveStatistics({ code, diagramType, isRough: state.rough, renderTime });
recordRenderTime(renderTime, () => {
$inputStateStore.updateDiagram = true;
updateCodeStore({ updateDiagram: true });
});
};
onMount(() => {
setupPanZoomObserver();
// Queue state changes to avoid race condition
let pendingStateChange = Promise.resolve();
stateStore.subscribe((state) => {
// eslint-disable-next-line @typescript-eslint/no-empty-function
pendingStateChange = pendingStateChange.then(() => handleStateChange(state).catch(() => {}));
});
});
// Queue state changes to avoid race condition
let pendingStateChange = Promise.resolve();
$effect(() => {
const state = validatedState.current;
// eslint-disable-next-line @typescript-eslint/no-empty-function
pendingStateChange = pendingStateChange.then(() => handleStateChange(state).catch(() => {}));
});
</script>
@@ -153,7 +155,7 @@
<div
id="view"
bind:this={view}
class={['h-full w-full', shouldShowGrid && `grid-bg-${$mode}`, error && 'opacity-50']}>
class={['h-full w-full', shouldShowGrid && `grid-bg-${mode.current}`, error && 'opacity-50']}>
<div id="container" bind:this={container} class="h-full overflow-auto"></div>
</div>
@@ -1,164 +1,101 @@
<script lang="ts">
import McWrapper from '$/components/McWrapper.svelte';
import MermaidChartIcon from '$/components/MermaidChartIcon.svelte';
import PrivacyPolicyLink from '$/components/migration/PrivacyPolicyLink.svelte';
import { Button } from '$/components/ui/button';
import * as Dialog from '$/components/ui/dialog';
import { dismissEditorChooser } from '$/util/migration/domainMigration';
import { logEvent, logMermaidChartClick } from '$/util/stats';
import { getCheckoutUrl } from '$/util/util';
import CodeIcon from '~icons/custom/code';
import ArrowIcon from '~icons/material-symbols/arrow-forward-rounded';
import OpenSourceIcon from '~icons/material-symbols/book-2-outline-rounded';
import ChatIcon from '~icons/material-symbols/chat-outline-rounded';
import EditIcon from '~icons/material-symbols/edit-outline-rounded';
import HistoryIcon from '~icons/material-symbols/history';
import HomeIcon from '~icons/material-symbols/home-storage-outline-rounded';
import SparklesIcon from '~icons/material-symbols/kid-star-outline';
import LanguageIcon from '~icons/material-symbols/language';
import WidthIcon from '~icons/material-symbols/width-rounded';
import type { Component } from 'svelte';
import AtlassianIcon from '~icons/logos/atlassian';
import AmazonIcon from '~icons/logos/aws';
import GoogleIcon from '~icons/logos/google';
import MicrosoftIcon from '~icons/logos/microsoft';
import { createEditorChooserActions } from './editorChooserActions';
interface Props {
open: boolean;
}
let { open = $bindable() }: Props = $props();
const close = () => {
dismissEditorChooser();
// Tracks whether the current close was triggered by an explicit action
// (button click logs its own event) vs. implicit dismissal (ESC/click-outside).
let handled = false;
const actions = createEditorChooserActions(() => {
handled = true;
open = false;
};
});
const handleStartTrial = () => {
logEvent('chooseEditor', { choice: 'plus' });
logMermaidChartClick('editorPicker');
close();
window.open(
getCheckoutUrl({ utmCampaign: 'start_plus', utmMedium: '2_editor_selection' }),
'_blank'
);
};
const features = [
{ title: 'AI diagram generation', description: 'Describe what you need, AI builds it' },
{
title: 'Visual drag-and-drop editor',
description: 'Edit diagrams without writing code'
},
{
title: 'Unlimited diagram storage',
description: 'Save and organize all your diagrams'
},
{
title: 'Team collaboration',
description: 'Share, comment, and edit together in real-time'
}
];
const handleStartFree = () => {
logEvent('chooseEditor', { choice: 'openSource' });
close();
};
const handleContinueToNewHome = () => {
logEvent('chooseEditor', { choice: 'newHome' });
close();
window.open('https://mermaid.ai/live', '_blank');
};
const trustedLogos: { name: string; icon: Component }[] = [
{ name: 'Google', icon: GoogleIcon },
{ name: 'Microsoft', icon: MicrosoftIcon },
{ name: 'Atlassian', icon: AtlassianIcon },
{ name: 'Amazon', icon: AmazonIcon }
];
</script>
<Dialog.Root
bind:open
onOpenChange={(v) => {
if (!v) handleStartFree();
if (v) return;
if (!handled) actions.log('dismissed');
handled = false;
dismissEditorChooser();
}}>
<Dialog.Content class="flex max-w-2xl flex-col gap-6 bg-pink-50 p-6 dark:bg-background">
<Dialog.Header>
<Dialog.Title class="text-center text-2xl font-semibold">Choose your editor</Dialog.Title>
<Dialog.Description class="text-center text-sm font-light">
You'll never see this again
<Dialog.Content class="flex max-w-lg flex-col gap-3 bg-background p-8">
<Dialog.Header class="flex-col items-start gap-2 space-y-0 text-left sm:text-left">
<MermaidChartIcon class="size-10" />
<Dialog.Title class="pt-2 text-2xl font-bold">Try the full Mermaid experience</Dialog.Title>
<Dialog.Description class="text-xs font-light text-muted-foreground">
Free forever, with Plus features free for 7 days.
</Dialog.Description>
</Dialog.Header>
<div class="grid gap-4 sm:grid-cols-2">
<!-- Mermaid Plus Card -->
<div
class="relative flex flex-col overflow-hidden rounded-xl border-2 border-accent bg-white shadow dark:bg-card">
<div class="bg-accent px-6 py-2 text-center text-sm font-semibold text-accent-foreground">
Recommended
</div>
<div class="flex flex-col gap-4 p-6">
<div>
<h3 class="text-xl font-bold">Mermaid Plus</h3>
<p class="text-sm text-muted-foreground">Unlock AI, storage and collaboration</p>
<ul class="mt-2 flex flex-col gap-3">
{#each features as feature (feature.title)}
<li class="flex items-center gap-3 rounded-lg border border-border p-3">
<span class="size-2 shrink-0 rounded-full bg-accent"></span>
<div class="flex flex-col gap-0.5">
<p class="text-xs">{feature.title}</p>
<p class="text-xs font-light text-muted-foreground">{feature.description}</p>
</div>
</li>
{/each}
</ul>
<div class="flex flex-col gap-2">
<div class="flex justify-center">
<span
class="rounded-full bg-pink-100 px-3 py-0.5 text-xs font-semibold text-pink-700 dark:bg-pink-950 dark:text-pink-300">
10% off with code JS26
</span>
</div>
<div class="mt-2 flex items-center gap-3">
<McWrapper labelPrefix="Opens ">
<Button variant="accent" onclick={() => actions.startTrial()}>Start free trial</Button>
</McWrapper>
<Button variant="outline" onclick={() => actions.dismiss('stayOnLive')}>
Stay on mermaid.live
</Button>
</div>
<Button variant="accent" class="w-full" onclick={handleStartTrial}>
Start free trial
</Button>
</div>
<ul class="space-y-3 text-sm">
<li class="flex items-center gap-2">
<EditIcon class="size-4 shrink-0 text-muted-foreground" />
Visual editor
</li>
<li class="flex items-center gap-2">
<SparklesIcon class="size-4 shrink-0 text-muted-foreground" />
300 AI credits
</li>
<li class="flex items-center gap-2">
<HomeIcon class="size-4 shrink-0 text-muted-foreground" />
Unlimited diagram storage
</li>
<li class="flex items-center gap-2">
<WidthIcon class="size-4 shrink-0 text-muted-foreground" />
Limitless diagram size
</li>
<li class="flex items-center gap-2">
<ChatIcon class="size-4 shrink-0 text-muted-foreground" />
View & comment collaboration
</li>
</ul>
</div>
</div>
<!-- Open Source Card -->
<div class="flex flex-col gap-4 rounded-xl border bg-white p-6 shadow dark:bg-card">
<div class="flex flex-col justify-end pt-10">
<h3 class="text-xl font-bold">Open Source</h3>
<p class="text-sm text-muted-foreground">Code only, no login, always free</p>
</div>
<div class="flex flex-col gap-2">
<p class="mt-2 text-sm text-muted-foreground">Mermaid has a new home</p>
<Button variant="outline" class="w-full border-accent" onclick={handleContinueToNewHome}>
Continue to mermaid.ai/live
<ArrowIcon class="ml-1 size-4" />
</Button>
<Button variant="outline" class="w-full" onclick={handleStartFree}>
Stay on mermaid.live
</Button>
</div>
<ul class="space-y-3 text-sm">
<li class="flex items-center gap-2">
<LanguageIcon class="size-4 shrink-0 text-muted-foreground" />
Diagram stored in URL
</li>
<li class="flex items-center gap-2">
<CodeIcon class="size-4 shrink-0 text-muted-foreground" />
Code editor
</li>
<li class="flex items-center gap-2">
<OpenSourceIcon class="size-4 shrink-0 text-muted-foreground" />
Open source
</li>
<li class="flex items-center gap-2">
<HistoryIcon class="size-4 shrink-0 text-muted-foreground" />
Version history
</li>
</ul>
<div class="mt-3 flex flex-col items-start gap-4">
<p class="text-xs">Trusted by 5M people and over 200k companies</p>
<div class="flex w-full items-center justify-between gap-2">
{#each trustedLogos as logo (logo.name)}
<logo.icon class="h-6 w-auto grayscale" aria-label={logo.name} />
{/each}
</div>
</div>
<div class="text-center">
<a
href="https://mermaid.ai/privacy-policy"
target="_blank"
class="text-sm text-foreground underline hover:text-accent">
mermaid.ai Privacy Policy
</a>
</div>
<PrivacyPolicyLink />
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,10 @@
<script lang="ts"></script>
<div class="text-center">
<a
href="https://mermaid.ai/privacy-policy"
target="_blank"
class="text-sm text-foreground underline hover:text-accent">
mermaid.ai Privacy Policy
</a>
</div>
@@ -0,0 +1,38 @@
import { logEvent, logMermaidChartClick } from '$/util/stats';
import { getCheckoutUrl, getMermaidAiLiveUrl } from '$/util/util';
const utmMedium = 'editorSelection';
const utmCampaign = 'live_2026';
export interface EditorChooserActions {
log: (buttonClick: string) => void;
startTrial: (buttonClick?: string) => void;
dismiss: (buttonClick: string) => void;
openMermaidAiLive: (buttonClick: string) => void;
}
export const createEditorChooserActions = (close: () => void): EditorChooserActions => {
const log = (buttonClick: string) => {
logEvent('chooseEditor', { buttonClick });
};
const startTrial = (buttonClick = 'startTrial') => {
log(buttonClick);
logMermaidChartClick('editorPicker');
close();
window.open(getCheckoutUrl({ utmCampaign, utmMedium }), '_blank', 'noopener');
};
const dismiss = (buttonClick: string) => {
log(buttonClick);
close();
};
const openMermaidAiLive = (buttonClick: string) => {
log(buttonClick);
close();
window.open(getMermaidAiLiveUrl({ utmCampaign, utmMedium }), '_blank', 'noopener');
};
return { log, startTrial, dismiss, openMermaidAiLive };
};
+1 -1
View File
@@ -6,7 +6,7 @@
</script>
<Sonner
theme={$mode}
theme={mode.current}
class="toaster group"
toastOptions={{
classes: {
@@ -24,8 +24,12 @@
}: ToggleGroupPrimitive.RootProps & ToggleVariants = $props();
setToggleGroupCtx({
variant,
size
get variant() {
return variant;
},
get size() {
return size;
}
});
</script>
+13 -11
View File
@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { addHistoryEntry } from '$lib/components/History/history';
import { setLoaderEntries } from '$lib/components/History/historyState.svelte';
import type { State } from '$lib/types';
import { defaultState } from '$lib/util/state';
import { defaultState } from '$lib/util/state.svelte';
import { fetchJSON, fetchText } from '$lib/util/util';
const codeFileName = 'code.mmd';
@@ -117,14 +117,16 @@ export const loadGistData = async (gistURL: string): Promise<State> => {
throw new Error('Invalid gist provided');
}
const state = getStateFromGist(entry, gistURL);
for (const gist of gistHistory) {
addHistoryEntry({
name: `${gist.author} v${gist.version}`,
state: getStateFromGist(gist),
time: gist.time,
type: 'loader',
url: gist.url
});
}
setLoaderEntries(
gistHistory
.map((gist) => ({
name: `${gist.author} v${gist.version}`,
state: getStateFromGist(gist),
time: gist.time,
type: 'loader' as const,
url: gist.url
}))
.reverse()
);
return state;
};
+2 -1
View File
@@ -1,5 +1,5 @@
import type { Loader, State } from '$lib/types';
import { defaultState, updateCodeStore } from '$lib/util/state';
import { defaultState, sanitizeConfig, updateCodeStore } from '$lib/util/state.svelte';
import { fetchText } from '$lib/util/util';
import { loadGistData } from './gist';
@@ -50,6 +50,7 @@ export const loadDataFromUrl = async (): Promise<void> => {
}
}
if (loaded) {
state.mermaid = sanitizeConfig(state.mermaid || defaultState.mermaid);
updateCodeStore({
...state,
updateDiagram: true
+14
View File
@@ -0,0 +1,14 @@
import type { LoadingState } from '$lib/types';
export const loadingState = $state<LoadingState>({ loading: false });
export const initLoading = async <T>(message: string, task: Promise<T>): Promise<T> => {
loadingState.loading = true;
loadingState.message = message;
try {
return await task;
} finally {
loadingState.loading = false;
loadingState.message = undefined;
}
};
-20
View File
@@ -1,20 +0,0 @@
import { writable } from 'svelte/store';
import type { Writable } from 'svelte/store';
import type { LoadingState } from '$lib/types';
const defaultLoading: LoadingState = {
loading: false
};
export const loadingStateStore: Writable<LoadingState> = writable(defaultLoading);
export const initLoading = async <T>(message: string, task: Promise<T>): Promise<T> => {
loadingStateStore.set({
loading: true,
message
});
const result: T = await task;
loadingStateStore.set({
loading: false
});
return result;
};
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { getSampleDiagrams } from './mermaid';
describe('getSampleDiagrams', () => {
const samples = getSampleDiagrams();
it('should return at least one example per diagram', () => {
expect(Object.keys(samples).length).toBeGreaterThan(0);
for (const [name, examples] of Object.entries(samples)) {
expect(examples.length, `${name} should have at least one example`).toBeGreaterThan(0);
for (const example of examples) {
expect(example.title, `${name} has an example without a title`).toBeTruthy();
expect(example.code, `${name} example "${example.title}" has no code`).toBeTruthy();
}
}
});
it('should list the default example first', () => {
for (const [name, examples] of Object.entries(samples)) {
expect(examples[0].isDefault, `${name} should have its default example first`).toBe(true);
}
});
});
+16 -11
View File
@@ -24,6 +24,11 @@ export const parse = async (code: string) => {
return await mermaid.parse(code);
};
/**
* @see https://mermaid.js.org/config/schema-docs/config.html
*/
export const defaultMermaidConfig = mermaid.mermaidAPI.defaultConfig ?? {};
export const standardizeDiagramType = (diagramType: string) => {
switch (diagramType) {
case 'class':
@@ -44,20 +49,20 @@ export const standardizeDiagramType = (diagramType: string) => {
type DiagramDefinition = (typeof diagramData)[number];
export type SampleExample = DiagramDefinition['examples'][number];
const isValidDiagram = (diagram: DiagramDefinition): diagram is Required<DiagramDefinition> => {
return Boolean(diagram.name && diagram.examples && diagram.examples.length > 0);
};
export const getSampleDiagrams = () => {
const diagrams = diagramData
.filter((d) => isValidDiagram(d))
.map(({ examples, ...rest }) => ({
...rest,
example: examples?.filter(({ isDefault }) => isDefault)[0]
}));
const examples: Record<string, string> = {};
for (const diagram of diagrams) {
examples[diagram.name.replace(/ (Diagram|Chart|Graph)/, '')] = diagram.example.code;
export const getSampleDiagrams = (): Record<string, SampleExample[]> => {
const samples: Record<string, SampleExample[]> = {};
for (const diagram of diagramData.filter((d) => isValidDiagram(d))) {
// The default example comes first, so it is loaded when clicking the
// diagram name and shown at the top of the example dropdown.
samples[diagram.name.replace(/ (Diagram|Chart|Graph)/, '')] = [...diagram.examples].sort(
(a, b) => Number(b.isDefault ?? false) - Number(a.isDefault ?? false)
);
}
return examples;
return samples;
};
+13 -1
View File
@@ -3,6 +3,7 @@ import { env } from '$/util/env';
const mermaidAiDomain = 'mermaid.ai';
const mermaidLiveDomain = 'mermaid.live';
const netlifyPreviewDomain = 'netlify.app';
/**
* Check if we're on mermaid.ai
@@ -20,6 +21,13 @@ export const isOnMermaidLive = (): boolean => {
return domain === mermaidLiveDomain || domain.endsWith(`.${mermaidLiveDomain}`);
};
/**
* Check if we're on a Netlify preview/staging deploy (*.netlify.app).
*/
const isOnNetlifyPreview = (): boolean => {
return window.location.hostname.endsWith(`.${netlifyPreviewDomain}`);
};
/**
* Check if the current URL has pako data (diagram content from a shared link).
*/
@@ -55,11 +63,15 @@ const isReferredFromMermaid = (): boolean => {
* Check if the editor chooser modal should be shown.
* Shows for new users who haven't dismissed it and aren't viewing a shared link.
* Not shown on mobile (viewport width < 640px).
* Can be forced open for QA via the `?editorChooser=1` query flag, which bypasses
* the hostname and dismissed checks.
*/
export const shouldShowEditorChooser = (): boolean => {
if (!env.isEnabledMermaidChartLinks) return false;
if (!isOnMermaidAI() && !isOnMermaidLive()) return false;
if (window.innerWidth < 640) return false;
const forced = new URLSearchParams(window.location.search).get('editorChooser') === '1';
if (forced) return true;
if (!isOnMermaidAI() && !isOnMermaidLive() && !isOnNetlifyPreview()) return false;
if (window.localStorage.getItem(C.editorChooserDismissedKey) === 'true') return false;
if (hasPakoData()) return false;
if (isReferredFromMermaid()) return false;
@@ -1,6 +1,5 @@
import { writable, get, type Writable } from 'svelte/store';
import { persist, localStorage } from '$lib/util/persist';
import { injectHistoryIDs } from '$lib/components/History/history';
import { injectHistoryIDs } from '$lib/components/History/historyState.svelte';
import { persisted } from '$lib/util/persist.svelte';
import { logEvent } from './stats';
interface MigrationState {
@@ -11,14 +10,10 @@ const migrations: Record<string, () => void> = {
injectHistoryIDs
};
const migrationStore: Writable<MigrationState> = persist(
writable({ version: -1 }),
localStorage(),
'migrations'
);
const migrationState = persisted<MigrationState>('migrations', { version: -1 });
export const applyMigrations = (): void => {
const { version }: MigrationState = get(migrationStore);
const { version } = migrationState.value;
const allMigrations = Object.entries(migrations);
if (version === allMigrations.length - 1) {
return;
@@ -29,7 +24,7 @@ export const applyMigrations = (): void => {
console.log(`Applying migration ${i}: ${key}.`);
fn();
logEvent('migration', { key });
migrationStore.set({ version: i });
migrationState.value = { version: i };
}
logEvent('migration', { status: 'complete', from: version, to: allMigrations.length - 1 });
};
+1 -1
View File
@@ -14,7 +14,7 @@ describe('migrations', () => {
});
it('should migrate from v0 to v1', async () => {
const { applyMigrations } = await import('./migrations');
const { applyMigrations } = await import('./migrations.svelte');
let manualHistoryStore: HistoryEntry[] = JSON.parse(
window.localStorage.getItem('manualHistoryStore') ?? '[]'
) as HistoryEntry[];
+66
View File
@@ -0,0 +1,66 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { persisted, readJSON, writeJSON } from './persist.svelte';
beforeEach(() => {
window.localStorage.clear();
});
describe('readJSON', () => {
it('returns the fallback when the key is missing', () => {
expect(readJSON('missing', 'fallback')).toBe('fallback');
});
it('returns the parsed value when present', () => {
window.localStorage.setItem('key', '{"a":1}');
expect(readJSON<{ a: number }>('key', { a: 0 })).toEqual({ a: 1 });
});
it('returns the fallback when the stored value is corrupt', () => {
window.localStorage.setItem('corrupt', '{oops');
expect(readJSON('corrupt', 'fallback')).toBe('fallback');
// The previous persistence layer could write the literal string "undefined".
window.localStorage.setItem('legacy', 'undefined');
expect(readJSON('legacy', 'fallback')).toBe('fallback');
});
it('returns the fallback when the stored value parses to null', () => {
// The pre-runes persistence layer treated a stored null as absent.
window.localStorage.setItem('legacy-null', 'null');
expect(readJSON('legacy-null', 'fallback')).toBe('fallback');
});
});
describe('writeJSON', () => {
it('round-trips values through localStorage as JSON', () => {
writeJSON('key', { nested: { value: 2 } });
expect(window.localStorage.getItem('key')).toBe('{"nested":{"value":2}}');
expect(readJSON('key', {})).toEqual({ nested: { value: 2 } });
});
});
describe('persisted', () => {
it('initialises from storage when a value exists', () => {
window.localStorage.setItem('counter', '5');
const counter = persisted('counter', 0);
expect(counter.value).toBe(5);
});
it('uses the initial value when storage is empty, without writing it', () => {
const counter = persisted('counter', 7);
expect(counter.value).toBe(7);
expect(window.localStorage.getItem('counter')).toBeNull();
});
it('persists on assignment and exposes the new value', () => {
const counter = persisted('counter', 0);
counter.value = 42;
expect(counter.value).toBe(42);
expect(window.localStorage.getItem('counter')).toBe('42');
});
it('uses the initial value when storage holds a literal null', () => {
window.localStorage.setItem('settings', 'null');
const settings = persisted('settings', { theme: 'default' });
expect(settings.value).toEqual({ theme: 'default' });
});
});
+53
View File
@@ -0,0 +1,53 @@
/**
* Runes-based localStorage persistence, shared by the persisted state in
* `state.svelte.ts`, `migrations.svelte.ts`, `promo.svelte.ts` and History.
*
* Values are stored as plain JSON. Reads of missing or corrupt values fall
* back to the provided default, so values written by older versions of the
* editor (which serialized plain objects to the same JSON shape) stay loadable.
*/
const hasStorage = (): boolean => typeof window !== 'undefined' && !!window.localStorage;
export const readJSON = <T>(key: string, fallback: T): T => {
if (!hasStorage()) {
return fallback;
}
try {
const raw = window.localStorage.getItem(key);
if (raw === null) {
return fallback;
}
// A stored literal "null" means the value is absent: the pre-runes
// persistence layer never wrote null and treated it as missing.
return (JSON.parse(raw) as T) ?? fallback;
} catch {
return fallback;
}
};
export const writeJSON = (key: string, value: unknown): void => {
if (hasStorage()) {
window.localStorage.setItem(key, JSON.stringify(value));
}
};
export interface Persisted<T> {
value: T;
}
// A localStorage-backed reactive value. Reads on init, writes on every set.
// Raw state: replace `value` wholesale to change it. With a deep proxy,
// in-place mutation would update the UI without ever being persisted.
export const persisted = <T>(key: string, initial: T): Persisted<T> => {
let value = $state.raw<T>(readJSON(key, initial));
return {
get value() {
return value;
},
set value(next: T) {
value = next;
writeJSON(key, next);
}
};
};
-254
View File
@@ -1,254 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// Copied from https://github.com/MacFJA/svelte-persistent-store
// # The MIT License (MIT)
// Copyright (c) 2021 [MacFJA](https://github.com/MacFJA)
// > Permission is hereby granted, free of charge, to any person obtaining a copy
// > of this software and associated documentation files (the "Software"), to deal
// > in the Software without restriction, including without limitation the rights
// > to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// > copies of the Software, and to permit persons to whom the Software is
// > furnished to do so, subject to the following conditions:
// >
// > The above copyright notice and this permission notice shall be included in
// > all copies or substantial portions of the Software.
// >
// > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// > IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// > FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// > AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// > THE SOFTWARE.
import ESSerializer from 'esserializer';
import type { Writable } from 'svelte/store';
/**
* Disabled warnings about missing/unavailable storages
*/
export function disableWarnings(): void {
noWarnings = true;
}
/**
* If set to true, no warning will be emitted if the requested Storage is not found.
* This option can be useful when the lib is used on a server.
*/
let noWarnings = false;
/**
* List of storages where the warning have already been displayed.
*/
const alreadyWarnFor: string[] = [];
/**
* Add a log to indicate that the requested Storage have not been found.
* @param {string} storageName
*/
const warnStorageNotFound = (storageName: string) => {
const isProduction = typeof process !== 'undefined' && process.env.NODE_ENV === 'production';
if (!noWarnings && !alreadyWarnFor.includes(storageName) && !isProduction) {
let message = `Unable to find the ${storageName}. No data will be persisted.`;
if (typeof window === 'undefined') {
message +=
'\n' +
'Are you running on a server? Most of storages are not available while running on a server.';
}
console.warn(message);
alreadyWarnFor.push(storageName);
}
};
const serialize = (value: unknown): string => ESSerializer.serialize(value);
const deserialize = (value?: string | null): unknown => {
// @TODO: to remove in the next major
if (value === 'undefined') {
return undefined;
}
if (value !== null && value !== undefined) {
try {
return ESSerializer.deserialize(value);
} catch {
// Do nothing
// use the value "as is"
}
try {
return JSON.parse(value);
} catch {
// Do nothing
// use the value "as is"
}
}
return value;
};
/**
* A store that keep it's value in time.
*/
export interface PersistentStore<T> extends Writable<T> {
/**
* Delete the store value from the persistent storage
*/
delete(): void;
}
/**
* Storage interface
*/
export interface StorageInterface<T> {
/**
* Get a value from the storage.
*
* If the value doesn't exists in the storage, `null` should be returned.
* This method MUST be synchronous.
* @param key The key/name of the value to retrieve
*/
getValue(key: string): T | null;
/**
* Save a value in the storage.
* @param key The key/name of the value to save
* @param value The value to save
*/
setValue(key: string, value: T): void;
/**
* Remove a value from the storage
* @param key The key/name of the value to remove
*/
deleteValue(key: string): void;
}
export interface SelfUpdateStorageInterface<T> extends StorageInterface<T> {
/**
* Add a listener to the storage values changes
* @param {string} key The key to listen
* @param {(newValue: T) => void} listener The listener callback function
*/
addListener(key: string, listener: (newValue: T) => void): void;
/**
* Remove a listener from the storage values changes
* @param {string} key The key that was listened
* @param {(newValue: T) => void} listener The listener callback function to remove
*/
removeListener(key: string, listener: (newValue: T) => void): void;
}
/**
* Make a store persistent
* @param {Writable<*>} store The store to enhance
* @param {StorageInterface} storage The storage to use
* @param {string} key The name of the data key
*/
export function persist<T>(
store: Writable<T>,
storage: StorageInterface<T>,
key: string
): PersistentStore<T> {
const initialValue = storage.getValue(key);
if (null !== initialValue) {
store.set(initialValue);
}
if ('addListener' in storage) {
(storage as SelfUpdateStorageInterface<T>).addListener(key, (newValue) => {
store.set(newValue);
});
}
store.subscribe((value) => {
storage.setValue(key, value);
});
return {
...store,
delete() {
storage.deleteValue(key);
}
};
}
function getBrowserStorage(
browserStorage: Storage,
listenExternalChanges = false
): SelfUpdateStorageInterface<any> {
const listeners: { key: string; listener: (newValue: any) => void }[] = [];
const listenerFunction = (event: StorageEvent) => {
const eventKey = event.key;
if (event.storageArea === browserStorage) {
for (const { listener } of listeners.filter(({ key }) => key === eventKey)) {
listener(deserialize(event.newValue));
}
}
};
const connect = () => {
if (listenExternalChanges && typeof window !== 'undefined' && window.addEventListener) {
window.addEventListener('storage', listenerFunction);
}
};
const disconnect = () => {
if (listenExternalChanges && typeof window !== 'undefined' && window.removeEventListener) {
window.removeEventListener('storage', listenerFunction);
}
};
return {
addListener(key: string, listener: (newValue: any) => void) {
listeners.push({ key, listener });
if (listeners.length === 1) {
connect();
}
},
deleteValue(key: string) {
browserStorage.removeItem(key);
},
getValue(key: string): any {
const value = browserStorage.getItem(key);
return deserialize(value);
},
removeListener(key: string, listener: (newValue: any) => void) {
const index = listeners.indexOf({ key, listener });
if (index !== -1) {
listeners.splice(index, 1);
}
if (listeners.length === 0) {
disconnect();
}
},
setValue(key: string, value: any) {
browserStorage.setItem(key, serialize(value));
}
};
}
/**
* Storage implementation that use the browser local storage
* @param listenExternalChanges - Update the store if the localStorage is updated from another page
*/
export function localStorage<T>(listenExternalChanges = false): StorageInterface<T> {
if (typeof window !== 'undefined' && window.localStorage) {
return getBrowserStorage(window.localStorage, listenExternalChanges);
}
warnStorageNotFound('window.localStorage');
return noopStorage();
}
/**
* Storage implementation that do nothing
*/
export function noopStorage<T>(): StorageInterface<T> {
return {
getValue(): null {
return null;
},
deleteValue() {
// Do nothing
},
setValue() {
// Do nothing
}
};
}
+9 -4
View File
@@ -60,6 +60,13 @@
onDestroy(() => {
clearInterval(interval);
});
const taglineHref = $derived(
`${MCBaseURL}${currentTagline.url.path}?${new URLSearchParams({
...commonParams,
...currentTagline.url.params
}).toString()}`
);
</script>
<div
@@ -70,11 +77,9 @@
<div class="grid grow">
{#key currentTagline}
<a
href="{MCBaseURL}{currentTagline.url.path}?{new URLSearchParams({
...commonParams,
...currentTagline.url.params
}).toString()}"
href={taglineHref}
target="_blank"
rel="noopener noreferrer"
class="col-start-1 row-start-1 flex items-center justify-center gap-4 no-underline"
in:fade={{ delay: 800 }}
out:fade={{ duration: 1000 }}>
@@ -1,9 +1,8 @@
import { env } from '$lib/util/env';
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import type { Component } from 'svelte';
import { get, writable, type Writable } from 'svelte/store';
import { localStorage, persist } from '../persist';
import type { Component, Snippet } from 'svelte';
import { persisted } from '../persist.svelte';
import April2025 from './April2025.svelte';
import JS2026 from './JS2026.svelte';
@@ -12,7 +11,7 @@ dayjs.extend(duration);
interface Promotion {
startDate: Date;
endDate: Date;
component: Component;
component: Component<{ closeBanner: Snippet }>;
hideDurationMs: number;
}
@@ -35,25 +34,21 @@ export const dismissPromotion = (id?: string): void => {
if (!id || !promotions[id]) {
return;
}
hiddenPromotionsStore.update((dismissedIDs) => {
dismissedIDs[id] = dayjs().add(promotions[id].hideDurationMs).valueOf();
return dismissedIDs;
});
hiddenPromotions.value = {
...hiddenPromotions.value,
[id]: dayjs().add(promotions[id].hideDurationMs).valueOf()
};
};
const hiddenPromotionsStore: Writable<Record<string, number>> = persist(
writable({}),
localStorage(),
'hiddenPromotions'
);
const hiddenPromotions = persisted<Record<string, number>>('hiddenPromotions', {});
export const getActivePromotion = (): (Promotion & { id: string }) | undefined => {
if (!env.isEnabledMermaidChartLinks) {
return;
}
const hidePromotionsUntil = get(hiddenPromotionsStore);
const now = new Date();
const hidePromotionsUntil = hiddenPromotions.value;
const now = dayjs();
const promotionWithID = Object.entries(promotions)
.filter(
([id, p]) =>
+1 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { serializeState, deserializeState, type SerdeType } from './serde';
import { defaultState } from './state';
import { defaultState } from './state.svelte';
import type { State } from '$lib/types';
const verifySerde = (state: State, serde?: SerdeType): string => {
+88
View File
@@ -0,0 +1,88 @@
import type { State } from '$lib/types';
import { flushSync } from 'svelte';
import { describe, expect, it } from 'vitest';
import {
defaultState,
inputState,
loadState,
replaceInputState,
toggleDarkTheme,
updateCode,
updateCodeStore,
updateConfig,
verifyState
} from './state.svelte';
// Runs `body` inside an effect and reports how often the effect (re-)runs.
const countEffectRuns = (body: () => void): { runs: () => number; stop: () => void } => {
let runs = 0;
const stop = $effect.root(() => {
$effect(() => {
runs++;
body();
});
});
flushSync();
return { runs: () => runs, stop };
};
const readStoredState = (): State =>
JSON.parse(window.localStorage.getItem('codeStore') ?? '{}') as State;
describe('update functions called from effects', () => {
// Effects that call an update function must not subscribe to the input
// state the function reads, or unrelated state changes re-fire the effect
// (and self-reads loop, e.g. the dark-theme effect in +layout.svelte).
const cases: [string, () => void][] = [
['updateCodeStore', () => updateCodeStore({})],
['updateCode', () => updateCode('graph TD\n inside-effect')],
['updateConfig', () => updateConfig('{"theme":"default"}')],
['toggleDarkTheme', () => toggleDarkTheme(false)],
['replaceInputState', () => replaceInputState({ ...defaultState })],
['verifyState', () => verifyState()],
['loadState', () => loadState('')]
];
it.each(cases)('%s does not make the calling effect track input state', (_name, call) => {
const counter = countEffectRuns(call);
try {
expect(counter.runs()).toBe(1);
updateCode('graph TD\n external-change');
updateConfig('{"theme":"forest"}');
updateCodeStore({ pan: { x: 1, y: 2 } });
flushSync();
expect(counter.runs()).toBe(1);
} finally {
counter.stop();
}
});
});
describe('update functions persist input state', () => {
it('updateCode writes the new code to localStorage', () => {
updateCode('graph TD\n persisted-by-test');
expect(readStoredState().code).toBe('graph TD\n persisted-by-test');
});
it('updateCodeStore merges partial state and persists it', () => {
updateCodeStore({ rough: true });
expect(inputState.rough).toBe(true);
expect(readStoredState().rough).toBe(true);
});
it('replaceInputState drops keys absent from the next state and persists', () => {
updateCodeStore({ pan: { x: 1, y: 2 } });
expect(inputState.pan).toEqual({ x: 1, y: 2 });
replaceInputState({ ...defaultState });
expect(inputState.pan).toBeUndefined();
expect(readStoredState().pan).toBeUndefined();
expect(readStoredState().code).toBe(defaultState.code);
});
it('verifyState forces panZoom back on', () => {
updateCodeStore({ panZoom: false });
verifyState();
expect(inputState.panZoom).toBe(true);
expect(readStoredState().panZoom).toBe(true);
});
});
+375
View File
@@ -0,0 +1,375 @@
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.<br/>Including the broken link in the issue <br/> will speed up the fix.)
C -->|No| E{Did someone <br/>send you this link?}
E -->|Yes| F[Ask them to send <br/>you the complete link]
E -->|No| G{Did you copy <br/> the complete URL?}
G --> |Yes| D
G --> |"No :("| H(Try using the Timeline tab in History <br/>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<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<State> = 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<ValidatedState>(
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 ? `[![](${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<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`.
*
* @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<State>): void => {
renderCount++;
Object.assign(state, newState, { renderCount });
};
export const updateCodeStore = (newState: Partial<State>): 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<string, unknown>)[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 }));
};
-267
View File
@@ -1,267 +0,0 @@
import type { ErrorHash, MarkerData, State, ValidatedState } from '$/types';
import { debounce } from 'lodash-es';
import type { MermaidConfig } from 'mermaid';
import { derived, get, writable, type Readable } from 'svelte/store';
import { env } from './env';
import {
extractErrorLineText,
findMostRelevantLineNumber,
replaceLineNumberInErrorMessage
} from './errorHandling';
import { parse } from './mermaid';
import { localStorage, persist } from './persist';
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.<br/>Including the broken link in the issue <br/> will speed up the fix.)
C -->|No| E{Did someone <br/>send you this link?}
E -->|Yes| F[Ask them to send <br/>you the complete link]
E -->|No| G{Did you copy <br/> the complete URL?}
G --> |Yes| D
G --> |"No :("| H(Try using the Timeline tab in History <br/>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"`;
// inputStateStore handles all updates and is shared externally when exporting via URL, History, etc.
export const inputStateStore = persist(writable(defaultState), localStorage(), 'codeStore');
export const currentState: ValidatedState = (() => {
const state = get(inputStateStore);
return {
...state,
editorMode: state.editorMode ?? 'code',
error: undefined,
errorMarkers: [],
serialized: serializeState(state)
};
})();
let lastDiagramType = '';
const processState = async (state: State) => {
const processed: ValidatedState = {
...state,
editorMode: state.editorMode ?? 'code',
error: undefined,
errorMarkers: [],
serialized: ''
};
// 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;
};
// All internal reads should be done via stateStore, but it should not be persisted/shared externally.
export const stateStore: Readable<ValidatedState> = derived(
[inputStateStore],
([state], set) => {
void processState(state).then(set);
},
currentState
);
export const urlsStore = derived([stateStore], ([{ code, serialized }]) => {
const { krokiRendererUrl, rendererUrl } = env;
const png = rendererUrl ? `${rendererUrl}/img/${serialized}?type=png` : '';
return {
kroki: krokiRendererUrl ? `${krokiRendererUrl}/mermaid/svg/${pakoSerde.serialize(code)}` : '',
mdCode: png
? `[![](${png})](${window.location.protocol}//${window.location.host}${window.location.pathname}#${serialized})`
: '',
mermaidChart: ({
medium,
campaign
}: {
medium:
| 'ai_repair'
| 'ai_edit'
| '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: `${window.location.protocol}//${window.location.host}${window.location.pathname}#${serializeState(defaultState)}`,
png,
svg: rendererUrl ? `${rendererUrl}/svg/${serialized}` : '',
view: `/view#${serialized}`
};
});
export const loadState = (data: string): void => {
let state: State;
console.log(`Loading '${data}'`);
try {
state = deserializeState(data);
if (!state.mermaid) {
state.mermaid = defaultState.mermaid;
}
const mermaidConfig: MermaidConfig =
typeof state.mermaid === 'string'
? (JSON.parse(state.mermaid) as MermaidConfig)
: state.mermaid;
if (
mermaidConfig.securityLevel &&
mermaidConfig.securityLevel !== 'strict' &&
confirm(
`Removing "securityLevel":"${mermaidConfig.securityLevel}" from the config for safety.\nClick Cancel if you trust the source of this Diagram.`
)
) {
delete mermaidConfig.securityLevel; // Prevent setting overriding securityLevel when loading state to mitigate possible XSS attack
}
state.mermaid = formatJSON(mermaidConfig);
} catch (error) {
state = get(inputStateStore);
if (data) {
console.error('Init error', error);
state.code = urlParseFailedState;
state.mermaid = defaultState.mermaid;
}
}
updateCodeStore(state);
};
let renderCount = 0;
export const updateCodeStore = (newState: Partial<State>): void => {
inputStateStore.update((state) => {
renderCount++;
return { ...state, ...newState, renderCount };
});
};
export const updateCode = (
code: string,
{
updateDiagram = false,
resetPanZoom = false
}: { updateDiagram?: boolean; resetPanZoom?: boolean } = {}
): void => {
errorDebug();
inputStateStore.update((state) => {
if (resetPanZoom) {
state.pan = undefined;
state.zoom = undefined;
}
return { ...state, code, updateDiagram };
});
};
export const updateConfig = (config: string): void => {
updateCodeStore({ mermaid: config });
};
export const toggleDarkTheme = (dark: boolean): void => {
inputStateStore.update((state) => {
const config = JSON.parse(state.mermaid) as MermaidConfig;
if (!config.theme || ['dark', 'default'].includes(config.theme)) {
config.theme = dark ? 'dark' : 'default';
}
return { ...state, mermaid: formatJSON(config) };
});
};
export const initURLSubscription = (): void => {
const updateHash = debounce((hash) => {
history.replaceState(undefined, '', `#${hash}`);
}, 250);
stateStore.subscribe(({ serialized }) => {
updateHash(serialized);
});
};
export const getStateString = (): string => {
return JSON.stringify(get(inputStateStore));
};
export const verifyState = (): void => {
const state = get(inputStateStore);
if (!state.panZoom) {
state.panZoom = true;
}
updateCodeStore(state);
};
+3
View File
@@ -113,6 +113,9 @@ export const logEvent = (
name: AnalyticsEvent,
data?: Record<string, string | number | boolean>
): void => {
if (browser && window.location.hostname === 'localhost') {
console.log('[plausible]', name, data);
}
if (!plausible) {
return;
}
+15 -8
View File
@@ -1,10 +1,10 @@
import { C } from '$/constants';
import { env } from './env';
import { loadDataFromUrl } from './fileLoaders/loader';
import { initLoading } from './loading';
import { initLoading } from './loading.svelte';
import { isOnMermaidAI } from './migration/domainMigration';
import { applyMigrations } from './migrations';
import { initURLSubscription, loadState, updateCodeStore, verifyState } from './state';
import { applyMigrations } from './migrations.svelte';
import { initURLSubscription, loadState, updateCodeStore, verifyState } from './state.svelte';
import { getAnalyticsSafeUrl, initAnalytics, plausible } from './stats';
export const getDomain = (url?: string): string => {
@@ -42,23 +42,30 @@ export const MCBaseURL = env.isEnabledMermaidChartLinks
? 'https://mermaid.ai' // 'http://localhost:5174'
: 'https://example.com';
export const getCheckoutUrl = ({
const buildUtmParams = ({
utmCampaign,
utmMedium
}: {
utmCampaign: string;
utmMedium: string;
}): string => {
const params = new URLSearchParams({
coupon: 'arDfyFT8',
tier: 'plus',
}): URLSearchParams =>
new URLSearchParams({
utm_campaign: utmCampaign,
utm_medium: utmMedium,
utm_source: getUTMSource()
});
export const getCheckoutUrl = (utm: { utmCampaign: string; utmMedium: string }): string => {
const params = buildUtmParams(utm);
params.set('coupon', 'arDfyFT8');
params.set('tier', 'plus');
return `${MCBaseURL}/app/user/billing/checkout?${params.toString()}`;
};
export const getMermaidAiLiveUrl = (utm: { utmCampaign: string; utmMedium: string }): string => {
return `${MCBaseURL}/live?${buildUtmParams(utm).toString()}`;
};
let count = 0;
export const errorDebug = (limit = 1000) => {
count += 1;
+8 -7
View File
@@ -1,20 +1,21 @@
<script>
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { onMount } from 'svelte';
// Only redirect if it's a 404 error
onMount(() => {
if ($page.status === 404) {
goto('/');
if (page.status === 404) {
goto(resolve('/'));
}
});
</script>
{#if $page.status !== 404}
{#if page.status !== 404}
<div class="container mx-auto p-8">
<h1 class="mb-4 text-2xl font-bold">Error {$page.status}</h1>
<p class="mb-4">{$page.error?.message || 'An unexpected error occurred'}</p>
<a href="/" class="text-blue-500 hover:underline">Return to Home</a>
<h1 class="mb-4 text-2xl font-bold">Error {page.status}</h1>
<p class="mb-4">{page.error?.message || 'An unexpected error occurred'}</p>
<a href={resolve('/')} class="text-blue-500 hover:underline">Return to Home</a>
</div>
{/if}
+5 -5
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { Toaster } from '$/components/ui/sonner/index.js';
import { loadingStateStore } from '$/util/loading';
import { toggleDarkTheme } from '$/util/state';
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';
@@ -34,7 +34,7 @@
});
$effect(() => {
toggleDarkTheme($mode === 'dark');
toggleDarkTheme(mode.current === 'dark');
});
</script>
@@ -45,12 +45,12 @@
{@render children()}
</main>
{#if $loadingStateStore.loading}
{#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>{$loadingStateStore.message}</div>
<div>{loadingState.message}</div>
</div>
</div>
{/if}
+9 -5
View File
@@ -5,6 +5,7 @@
import Editor from '$/components/Editor.svelte';
import EnhancedEditsButton from '$/components/EnhancedEditsButton.svelte';
import History from '$/components/History/History.svelte';
import { startAutoSave } from '$/components/History/historyState.svelte';
import McWrapper from '$/components/McWrapper.svelte';
import MermaidChartIcon from '$/components/MermaidChartIcon.svelte';
import EditorChooserModal from '$/components/migration/EditorChooserModal.svelte';
@@ -22,7 +23,7 @@
import type { EditorMode, Tab } from '$/types';
import { shouldShowEditorChooser } from '$/util/migration/domainMigration';
import { PanZoomState } from '$/util/panZoom';
import { stateStore, updateCodeStore, urlsStore } from '$/util/state';
import { validatedState, updateCodeStore, urls } from '$/util/state.svelte';
import { logEvent, logMermaidChartClick } from '$/util/stats';
import { initHandler } from '$/util/util';
import { onMount } from 'svelte';
@@ -63,6 +64,9 @@
});
});
// Record the Timeline for the whole session, not just while the panel is open.
onMount(() => startAutoSave());
let isHistoryOpen = $state(false);
let editorPane: Resizable.Pane | undefined;
@@ -87,7 +91,7 @@
{/snippet}
<Navbar mobileToggle={isMobile ? mobileToggle : undefined}>
<Toggle bind:pressed={isHistoryOpen} size="sm">
<Toggle bind:pressed={isHistoryOpen} size="sm" title="History" aria-label="History">
<HistoryIcon />
</Toggle>
<Share />
@@ -95,7 +99,7 @@
<Button
variant="accent"
size="sm"
href={$urlsStore.mermaidChart({ medium: 'save_diagram' }).save}
href={urls.current.mermaidChart({ medium: 'save_diagram' }).save}
target="_blank"
onclick={() => logMermaidChartClick('saveDiagram')}>
<MermaidChartIcon />
@@ -120,7 +124,7 @@
onselect={tabSelectHandler}
isOpen
tabs={editorTabs}
activeTabID={$stateStore.editorMode}
activeTabID={validatedState.current.editorMode}
isClosable={false}>
{#snippet actions()}
<DiagramDocButton />
@@ -136,7 +140,7 @@
</Resizable.Pane>
<Resizable.Handle class="mr-1 hidden opacity-0 sm:block" />
<Resizable.Pane minSize={15} class="relative flex h-full flex-1 flex-col overflow-hidden">
<View {panZoomState} shouldShowGrid={$stateStore.grid} />
<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 right-0 bottom-0"><VersionSecurityToolbar /></div>
+120 -57
View File
@@ -1,84 +1,147 @@
import { expect, test } from '@playwright/test';
import { typeInEditor } from './utils';
import { expect, test, type Page } from '@playwright/test';
test.describe.skip('Save History', () => {
const config = '{\n "theme": "default"\n}';
const entry = (id: string, name: string, type: 'manual' | 'auto', label: string) => ({
id,
name,
type,
time: Number(id.slice(2)),
state: {
code: `flowchart TD\n A[${label}]`,
mermaid: config,
autoSync: true,
updateDiagram: false
}
});
const manualHistory = [
entry('m-2', 'hollow-art', 'manual', 'Halloween'),
entry('m-1', 'helpful-ocean', 'manual', 'Pumpkin')
];
const autoHistory = [
entry('a-2', 'barking-dog', 'auto', 'NewYear'),
entry('a-1', 'needy-mosquito', 'auto', 'Fireworks')
];
const openHistory = (page: Page) => page.getByRole('button', { name: 'History' }).click();
test.describe('History', () => {
test.beforeEach(async ({ page }) => {
// Freeze time so auto-save snapshots are deterministic.
await page.addInitScript(() => {
Object.defineProperty(Date, 'now', {
value: () => new Date(2022, 0, 1).getTime()
});
Object.defineProperty(Date, 'now', { value: () => new Date(2022, 0, 1).getTime() });
});
await page.goto('/edit');
await page.getByText('History').click();
});
test('should load history from localstorage', async ({ page }) => {
await page.evaluate(() => {
localStorage.setItem(
'manualHistoryStore',
'[{"state":{"code":"graph TD\\n A[Halloween] -->|Get money| B(Go shopping)","mermaid":"{\\n \\"theme\\": \\"dark\\"\\n}","autoSync":true,"updateDiagram":false},"time":0,"type":"manual","id":"d7ea820e-21dd-418a-b984-fd58acde09df","name":"hollow-art"},{"state":{"code":"graph TD\\n A[Christmas] -->|Get money| B(Go shopping)","mermaid":"{\\n \\"theme\\": \\"dark\\"\\n}","autoSync":true,"updateDiagram":true},"time":0,"type":"manual","id":"b749ffc6-522b-4a44-86cf-7c1ffc3146b3","name":"helpful-ocean"}]'
);
localStorage.setItem(
'autoHistoryStore',
'[{"state":{"code":"graph TD\\n A[New Year] -->|Get money| B(Go shopping)","mermaid":"{\\n \\"theme\\": \\"dark\\"\\n}","autoSync":true,"updateDiagram":false},"time":0,"type":"auto","id":"69ea820e-522b-4a44-86cf-fd58acde09df","name":"barking-dog"},{"state":{"code":"graph TD\\n A[Christmas] -->|Get money| B(Go shopping)","mermaid":"{\\n \\"theme\\": \\"dark\\"\\n}","autoSync":true,"updateDiagram":true},"time":0,"type":"manual","id":"x749ffc6-21dd-418a-b984-7c1ffc3146b3","name":"needy-mosquito"}]'
);
});
test('loads Saved and Timeline history from localStorage and restores entries', async ({
page
}) => {
await page.evaluate(
([manual, auto]) => {
localStorage.setItem('manualHistoryStore', manual);
localStorage.setItem('autoHistoryStore', auto);
},
[JSON.stringify(manualHistory), JSON.stringify(autoHistory)]
);
await page.reload();
await page.getByText('History').click();
await openHistory(page);
// Saved tab is active by default.
await expect(page.locator('#historyList li')).toHaveCount(2);
await expect(page.locator('#historyList').getByText('No items in History')).not.toBeVisible();
await expect(page.locator('#historyList')).toContainText('helpful-ocean');
await expect(page.locator('#historyList')).toContainText('hollow-art');
await page.getByText('Restore').first().click();
await expect(page.locator('#view').getByText('Halloween')).toBeVisible();
await page.getByText('Timeline').click();
await expect(page.locator('#historyList')).toContainText('helpful-ocean');
await page.getByRole('button', { name: 'Restore this version' }).first().click();
await expect(page.locator('#view')).toContainText('Halloween');
// Switching to the Timeline tab shows the auto entries only.
await page.getByRole('tab', { name: 'Timeline' }).click();
await expect(page.locator('#historyList li')).toHaveCount(2);
await expect(page.locator('#historyList').getByText('No items in History')).not.toBeVisible();
await expect(page.locator('#historyList')).toContainText('needy-mosquito');
await expect(page.locator('#historyList')).toContainText('barking-dog');
await page.getByText('Restore').first().click();
await expect(page.locator('#view').getByText('New Year')).toBeVisible();
await expect(page.locator('#historyList')).toContainText('needy-mosquito');
await expect(page.locator('#historyList')).not.toContainText('hollow-art');
await page.getByRole('button', { name: 'Restore this version' }).first().click();
await expect(page.locator('#view')).toContainText('NewYear');
});
test.skip('should save when clicked', async ({ page }) => {
await expect(page.locator('#historyList li')).toHaveCount(0);
await expect(page.locator('#historyList')).toContainText('No items in History');
await page.locator('#saveHistory').click();
await expect(page.locator('#historyList').getByText('No items in History')).not.toBeVisible();
await expect(page.locator('#historyList li')).toHaveCount(1);
const dialogPromise = page.waitForEvent('dialog');
await page.locator('#saveHistory').click();
const dialog = await dialogPromise;
expect(dialog.message()).toBe('State already saved.');
await dialog.accept();
test('each entry has a copyable link that opens it in a new tab', async ({ page }) => {
await page.evaluate(
(manual) => localStorage.setItem('manualHistoryStore', manual),
JSON.stringify(manualHistory)
);
await page.reload();
await openHistory(page);
await typeInEditor(page, ' C --> HistoryTest');
// It is a real link (so it can be copied / opened in a new tab), not a button.
const link = page.getByRole('link', { name: 'Open in new tab' }).first();
await expect(link).toHaveAttribute('target', '_blank');
const href = await link.getAttribute('href');
expect(href).toContain('/edit#pako:');
// Following it loads that entry's diagram.
await page.goto(href ?? '');
await expect(page.locator('#view')).toContainText('Halloween');
});
test('keeps the active tab highlighted when switching modes', async ({ page }) => {
await openHistory(page);
const saved = page.getByRole('tab', { name: 'Saved' });
const timeline = page.getByRole('tab', { name: 'Timeline' });
await expect(saved).toHaveClass(/border-b-2/);
await expect(timeline).not.toHaveClass(/border-b-2/);
await timeline.click();
await expect(timeline).toHaveClass(/border-b-2/);
await expect(saved).not.toHaveClass(/border-b-2/);
});
test('saves the current state and reports duplicates', async ({ page }) => {
await openHistory(page);
await expect(page.locator('#historyList li')).toHaveCount(0);
await page.locator('#saveHistory').click();
await expect(page.locator('#historyList li')).toHaveCount(1);
// Saving again without changes does not add a duplicate and notifies the user.
await page.locator('#saveHistory').click();
await expect(page.getByText('State already saved.')).toBeVisible();
await expect(page.locator('#historyList li')).toHaveCount(1);
// Loading a different sample changes the state, so it saves as a new entry.
await page.getByRole('button', { name: 'Sequence', exact: true }).click();
await expect(page.locator('#view')).not.toContainText('Christmas');
await page.locator('#saveHistory').click();
await expect(page.locator('#historyList li')).toHaveCount(2);
});
test.skip('should be able to restore and delete', async ({ page }) => {
test('auto-saves to the Timeline only, never the Saved list', async ({ page }) => {
await openHistory(page);
await page.locator('#saveHistory').click();
await typeInEditor(page, ' C --> HistoryTest');
await expect(page.locator('#historyList').getByText('No items in History')).not.toBeVisible();
await expect(page.locator('#historyList li')).toHaveCount(1);
await expect(page.locator('#view').getByText('HistoryTest')).toBeVisible();
await page.getByText('Restore').click();
await expect(page.locator('#view').getByText('HistoryTest')).not.toBeVisible();
await page.getByText('Delete').click();
await expect(page.locator('#historyList li')).toHaveCount(0);
await expect(page.locator('#historyList')).toContainText('No items in History');
await page.getByRole('tab', { name: 'Timeline' }).click();
// A manual save must not appear under Timeline.
await expect(page.locator('#historyList')).toContainText('No timeline snapshots yet.');
});
test('deletes a single entry and clears all after confirmation', async ({ page }) => {
await openHistory(page);
await page.locator('#saveHistory').click();
await typeInEditor(page, ' C --> HistoryTest');
await page.getByRole('button', { name: 'Sequence', exact: true }).click();
await expect(page.locator('#view')).not.toContainText('Christmas');
await page.locator('#saveHistory').click();
await page.locator('#editor').type('ing');
await expect(page.locator('#historyList li')).toHaveCount(2);
await page.getByRole('button', { name: 'Delete this version' }).first().click();
await expect(page.locator('#historyList li')).toHaveCount(1);
page.on('dialog', (dialog) => dialog.accept());
await page.locator('#clearHistory').click();
const dialog = await page.waitForEvent('dialog');
expect(dialog.message()).toBe('Clear all saved items?');
await dialog.accept();
await expect(page.locator('#historyList')).toContainText('No items in History');
await expect(page.locator('#historyList li')).toHaveCount(0);
await expect(page.locator('#historyList')).toContainText('No saved states yet.');
});
});
+40
View File
@@ -1,3 +1,5 @@
import type { State } from '$/types';
import assert from 'node:assert';
import { expect, test } from './test';
test.describe('Site Loads', () => {
@@ -67,6 +69,44 @@ test.describe('Site Loads', () => {
});
});
test('should prompt user to scrub unsafe config', async ({ editPage, page }) => {
let dialogAccepted = false;
page.on('dialog', async (dialog) => {
expect(dialog.type()).toBe('confirm');
expect(dialog.message()).toContain('from the config for safety');
await dialog.accept();
dialogAccepted = true;
});
await editPage.start(
`/edit?${new URLSearchParams({
code: `data:application/vnd.mermaid,${encodeURIComponent('flowchart TD\nHello-->World')}`,
config: `data:application/json,${encodeURIComponent(
JSON.stringify({
someOtherSetting: 'Test value',
securityLevel: 'loose',
secure: [],
themeVariables: {
nodeBorder: '</style></svg><script>alert("XSS")</script>'
}
})
)}`
}).toString()}`
);
await editPage.checkTextInView('Hello');
await expect.poll(() => dialogAccepted).toBeTruthy();
const codeStore = await page.evaluate(() => localStorage.getItem('codeStore'));
assert(codeStore);
const parsedStore = JSON.parse(codeStore) as State;
const parsedConfig = JSON.parse(parsedStore.mermaid) as Record<string, unknown>;
expect(parsedConfig).toEqual({
someOtherSetting: 'Test value',
themeVariables: {}
});
// should scrub unsafe securityLevel but keep other settings
expect(parsedConfig.securityLevel).toBeUndefined();
expect(parsedConfig.secure).toBeUndefined();
});
test('should show troubleshooting steps if loading fails', async ({ editPage, page }) => {
await editPage.start('/#/edit/eyJjb2RlIjoiZ3JhcGggVERcbiAg');
await page.reload({ waitUntil: 'networkidle' });
+1
View File
@@ -12,6 +12,7 @@
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"strictNullChecks": true,
"skipLibCheck": true,
"types": ["vitest/importMeta", "@playwright/test"]
},
"extends": "./.svelte-kit/tsconfig.json"
+2
View File
@@ -33,6 +33,8 @@ export default defineConfig({
envPrefix: 'MERMAID_',
server: { port: 3000, host: true },
preview: { port: 3000, host: true },
// Vitest otherwise resolves Svelte's server build, where $effect is a no-op.
resolve: process.env.VITEST ? { conditions: ['browser'] } : undefined,
test: {
environment: 'jsdom',
// in-source testing