Render unsupported Mermaid types (gantt, pie, etc.)#31
Open
hazadus wants to merge 15 commits into
Open
Conversation
BeautifulMermaid only handles 6 diagram types (flowchart, state, sequence, class, ER, XY chart). Any other type — gantt, pie, timeline, mindmap, etc. — fell through to the flowchart parser, failed silently, and showed "Mermaid diagram could not be rendered". Fix: detect the diagram type from the first line before attempting BeautifulMermaid. For supported types the existing native pipeline (BeautifulMermaid → NSImage → zoom/pan/export) is unchanged. For everything else, render via a WKWebView backed by the full mermaid.js library bundled offline in Contents/Resources. - MermaidWebRenderer.swift: NSViewRepresentable wrapping WKWebView. Loads an HTML page with inlined mermaid.js, runs mermaid.run(), then reports the rendered SVG height back via a JS message handler so SwiftUI can size the frame correctly. - MermaidRenderer.swift: isBeautifulMermaidSupported() checks the first line; MDVMermaidDiagramView branches to MermaidWebViewContainer for unsupported types. - build.sh: copy mermaid.min.js into Contents/Resources. - .gitignore: exclude mdv/mermaid.min.js (downloaded asset). - test-docs/gantt.md: generic English gantt test document. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PR tqbf#29 added Mermaid rendering but none of the user-facing docs were updated. Our gantt follow-up made the gap more visible: gantt.md was listed nowhere, and Help.md had no mention of diagrams at all. - README.md: add Mermaid to the features bullet list, noting the native/WKWebView split. - Help.md: new "Mermaid diagrams" section covering the style picker, source toggle, export, zoom, and the native-vs-bundled rendering split. - test-docs/README.md: add gantt.md entry explaining what it tests. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
5485b94 to
71d9a1a
Compare
The file is excluded from git (.gitignore) but CI has no other way to obtain it, so the bundle step fails with 'cp: mdv/mermaid.min.js: No such file or directory'. Pins to mermaid 11.4.1 from jsDelivr so the build is reproducible; skips the download when the file already exists locally (developer workflow). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The unsupported-types path called `webView.loadHTMLString` from `updateNSView` unconditionally. Each reload measured the rendered SVG height in JS, posted it back through the script-message handler, wrote `@State height`, triggered another SwiftUI update, and reloaded the page again — a feedback loop that violates the CLAUDE.md guideline against writing `@State` from layout-derived events. Visible symptom: gantt diagrams flickering on theme change and the main thread doing a lot of needless mermaid.js work. Two guards on the load path: - Track `LoadKey(source, themeID, isDark)` in the coordinator and short-circuit `updateNSView` when the key is unchanged. Reloads now happen only when the diagram source or the theme actually changes. - In the height handler, suppress republishes that move the measured height by less than 0.5pt, so any residual layout noise can't bounce back through the view tree. The CSS / JS / HTML and the public surface of `MermaidWebView` are unchanged. Co-authored-by: Amp <amp@ampcode.com> Amp-Thread-ID: https://ampcode.com/threads/T-019e2d86-7146-73b8-8eb6-c24e338cbdfb
When the bundled mermaid.js threw — bad syntax, an unknown diagram
keyword that mermaid itself rejects, anything in the `.catch` arm —
the page used to post `-1` as a height and the Swift side silently
ignored it. The user got a blank 300pt box with no source, no error,
and no way to see what they had typed. That is strictly worse than
the native path, which falls back to `MermaidFallbackView` showing
the original code and a "could not be rendered" header.
Reuse the same fallback for the web path:
- `MermaidWebViewContainer` gains an `@State failed` flag bound
into the representable. When set, the container renders
`MermaidFallbackView(source:theme:)` instead of the WKWebView.
- The JS now posts a structured `{ ok, height | error }` message.
The coordinator routes `ok: false` (and any malformed payload) to
`failed = true`; success carries the measured height as before.
- `updateNSView` clears `failed` whenever it actually loads a new
page, so editing the source after a failure re-attempts the render.
- `MermaidFallbackView` was `private` in MermaidRenderer.swift;
promoted to internal so the web renderer can reach it.
Behaviour for valid diagrams is unchanged.
Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019e2d86-7146-73b8-8eb6-c24e338cbdfb
The previous build.sh fetched mermaid.min.js from jsDelivr on demand and trusted whatever bytes came back. That left three open risks: - a jsDelivr regression or compromise could swap in a different artifact at the same version tag; - a stale local file (`mdv/mermaid.min.js` is gitignored) could silently diverge from CI for arbitrarily long, since the script skipped the download whenever the file already existed; - nothing prevented a hand-edited copy from being bundled into the signed app and shipped. The minimum useful guarantee is "the JS we ship is the exact bytes we reviewed". Pin both `MERMAID_VERSION` and `MERMAID_SHA256` of the upstream artifact and run the check unconditionally: - Fresh download goes through a `.tmp` then `mv`, so a partial curl never lands as a valid-looking cached file. - `verify_mermaid` runs whether the file was just downloaded or was already on disk — local drift now hard-fails the build with a message pointing at the next step (delete and re-download, or bump SHA along with VERSION). Not vendoring the JS into git: the file is 2.5 MB and was deliberately excluded in the original gantt PR. The SHA pin gives us the integrity property without the repo-size cost. Co-authored-by: Amp <amp@ampcode.com> Amp-Thread-ID: https://ampcode.com/threads/T-019e2d86-7146-73b8-8eb6-c24e338cbdfb
`isBeautifulMermaidSupported` peeked at the first physical line of
the source, lowercased it, and checked a handful of `hasPrefix`
candidates. That misses every diagram that opens with one of
Mermaid's three legal preamble forms:
- `---\ntitle: …\n---` frontmatter blocks,
- `%%{ init: { … } }%%` configuration directives, and
- `%% line comments`.
Each of those routed otherwise-native diagrams (a `flowchart LR` with
a title, a `sequenceDiagram` with an init block) into the WKWebView
fallback — losing zoom, the style picker, PNG export, and the cache
hit, all for the sake of preserving a comment.
Also fixes a narrower bug in the keyword test: `"graph "" with a
trailing space failed to match `graph\nLR` and `graph\tLR`,
and `hasPrefix("flowchart")` was happy to match `flowchart-elk`
even though ELK is a different layout backend BeautifulMermaid
doesn't speak.
Two new helpers do the work:
- `firstMermaidDirectiveLine(in:)` walks the source, consuming
blank lines, comments, and the two block forms, and returns the
first lowercased line that actually carries a diagram keyword.
- A local `matches(_:)` predicate inside the dispatcher matches a
keyword only when it stands alone or is followed by whitespace,
so `flowchart` and `flowchart LR` are accepted and
`flowchart-elk` is not. `statediagram` / `xychart` keep an
explicit `hasPrefix` so the `-v2` / `-beta` variants still
reach BeautifulMermaid.
Behaviour for sources without a preamble is unchanged.
Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019e2d86-7146-73b8-8eb6-c24e338cbdfb
The five-option style menu (`Document`, `Light`, `Dark`, `Tokyo Night`, `Catppuccin`) is wired straight into the BeautifulMermaid `DiagramTheme` pipeline. The WKWebView fallback for gantt/pie/timeline/etc. doesn't read it — it derives a binary `default`/`dark` mermaid.js theme from the document theme's `isDark` and that's it. The picker therefore appeared on every diagram but did nothing on roughly half of them, which is the kind of UI lie that wastes a user's afternoon. Gate `styleMenu` (and the matching `Diagram Style` submenu in the right-click menu) on a single `nativeRenderer` predicate that asks `isBeautifulMermaidSupported(content)`. The floating toolbar on a gantt diagram now collapses to just the source-toggle, export, and copy buttons; on a flowchart everything is unchanged. Export-PNG has the same problem — it routes through the native pipeline and silently beeps for unsupported types — but is left for a separate change. Co-authored-by: Amp <amp@ampcode.com> Amp-Thread-ID: https://ampcode.com/threads/T-019e2d86-7146-73b8-8eb6-c24e338cbdfb
`MDVMermaidImage.exportPNG` re-enters the BeautifulMermaid pipeline; for diagram types BeautifulMermaid can't parse it returns `nil` and the export silently fails with an `NSSound.beep()`. A visible toolbar button that exists only to beep at the user is the worst possible UX — they assume something is broken and waste time chasing it. Wrap the floating-toolbar export icon and the matching context-menu `Export Diagram as PNG` entry in the same `nativeRenderer` gate introduced for the style picker. Native diagrams keep both affordances; gantt/pie/etc. lose them entirely until the WKWebView path can render its own PNG (probably via `takeSnapshot`, but that's another change). The diagram source is still reachable for those types via the curly-braces toggle and ⌘C from the source view, so users aren't stranded. Co-authored-by: Amp <amp@ampcode.com> Amp-Thread-ID: https://ampcode.com/threads/T-019e2d86-7146-73b8-8eb6-c24e338cbdfb
The WKWebView for the gantt/pie path was made transparent via `webView.setValue(false, forKey: "drawsBackground")` so the chrome's themed background showed through under the diagram. That key is a private-API toggle on the underlying `WebView` machinery — it works today, but Apple has changed unwritten WebKit behaviour without notice plenty of times, and shipping a KVC poke at a private property is the kind of thing that comes back to bite. Replace it with the obvious portable approach: leave the WebView opaque (the default) and paint the HTML body to the same color the chrome paints behind it. `MermaidCodeBlockChrome` uses `palette.background ?? theme.secondaryBackground`; the WebView path doesn't see the palette so it just uses `theme.secondaryBackground`, which is what every theme without a custom code-block palette already resolves to anyway. Mechanics: - New `MermaidWebView.cssColor(_:)` helper converts a SwiftUI `Color` to a `rgb(r,g,b)` literal in sRGB; falls back to `transparent` if the color can't be resolved (uglier, never crashes). - `buildHTML` now takes the full `MDVTheme` instead of a bare `isDark` and emits the body background. The `LoadKey` already includes `themeID`, so a theme switch still triggers a reload and the new background is picked up. - Imported `AppKit` for `NSColor`. Tested on the gantt test doc across light, dark, Tokyo Night, and Catppuccin themes — the diagram region is now flush with the surrounding chrome instead of bleeding the WebView's default background through the rounded corners. Co-authored-by: Amp <amp@ampcode.com> Amp-Thread-ID: https://ampcode.com/threads/T-019e2d86-7146-73b8-8eb6-c24e338cbdfb
The original gantt PR shipped one test document — `gantt.md` —
even though the dispatch change in `isBeautifulMermaidSupported`
sends pie, timeline, mindmap, journey, quadrant chart, and
requirement diagram down the same WKWebView path. Their PR text
said "and whatever else mermaid.js knows about" but nobody had
actually opened mdv against any of them, so any one of them
quietly failing in the bundled mermaid.js version we pin would
have gone unnoticed until a user filed a bug.
Add `test-docs/mermaid-web-fallback.md` with one of each:
- pie, timeline, mindmap, journey, quadrantChart, requirementDiagram
to exercise the web path's breadth;
- a flowchart preceded by a `%%{init}%%` directive and a `%%`
line comment to lock in the preamble-stripping in
`firstMermaidDirectiveLine` — if that regresses, the diagram
will silently bounce out of BeautifulMermaid and end up rendered
by the bigger, slower web pipeline.
Pair with `gantt.md` which still owns the depth-of-one-diagram
case (sections, milestones, critical bars, emoji in labels). Index
entry added to `test-docs/README.md` with a "if anything here
plates, the dispatcher is at fault" pointer so the next person
debugging this knows where to look.
Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019e2d86-7146-73b8-8eb6-c24e338cbdfb
The native path tags its rendered diagram with
`.accessibilityLabel("Mermaid diagram")` so VoiceOver announces
something useful instead of "image". The WKWebView path quietly
exposed whatever the embedded HTML/SVG happened to expose, which on
mermaid.js 11 is mostly opaque `<svg>` and `<g>` nodes — a
regression vs the native baseline for screen-reader users.
Wrap `MermaidWebViewContainer.body` in a `Group` so the new
modifiers can hang off a single subtree, and:
- collapse the AX subtree with `.accessibilityElement(children: .ignore)`
so the WebView's noisy internal tree stops bleeding through;
- match the native label with `.accessibilityLabel("Mermaid diagram")`;
- add a hint pointing at the curly-braces toggle, since unlike the
native renderer the WebView doesn't have a per-node accessibility
story to fall back on.
The container falls back to `MermaidFallbackView` when the WebView
render fails; `Group` covers both branches with the same labels.
Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019e2d86-7146-73b8-8eb6-c24e338cbdfb
The LoadKey guard added in "stop reloading the WKWebView on every SwiftUI update" was the right fix for the reload thrash, but it removed an accidental safety net: the old code reloaded the page dozens of times per diagram, and at least one of those reloads happened to measure the SVG after layout fully settled. With exactly one load per (source, theme), `mermaid.run().then(...)` sometimes resolved before the SVG's final dimensions were committed — mermaid finishes injecting the SVG into the DOM, the promise fires, but the browser hasn't run its first paint yet and `getBoundingClientRect()` reports a stale ~60pt height. Three diagram types in the test doc tripped this every time (journey, quadrantChart, requirementDiagram) and rendered as clipped 60-point strips with internal scrollbars. Two layered fixes inside the JS: - Schedule the initial measurement under a double `requestAnimationFrame`. That's the standard browser idiom for "after the next paint": the first rAF runs before paint, the second runs after, so the rect we read is the post-layout one. - Attach a `ResizeObserver` to the SVG element so diagrams that adjust their own dimensions after the first paint (mermaid does this for layouts with measured text) continue to update Swift's `height` binding as they settle. No new feedback-loop risk: the LoadKey guard in `updateNSView` short-circuits reloads, and the coordinator's existing 0.5pt epsilon filter drops the tail of sub-pixel ResizeObserver callbacks before they reach `@State`. The other six diagram types in `mermaid-web-fallback.md` and the long gantt example are unchanged. Co-authored-by: Amp <amp@ampcode.com> Amp-Thread-ID: https://ampcode.com/threads/T-019e2d86-7146-73b8-8eb6-c24e338cbdfb
…aceholder `MermaidWebViewContainer` opened with `@State height = 300` so the document made room for a diagram before mermaid had said anything about how tall it was actually going to be. When the JS finally posted the measurement back, the frame snapped from 300pt to whatever the diagram needed — the page jumped, and any scroll position established during the window was off by tens to hundreds of points. Drop the placeholder to 60pt (the existing `max(_, 60)` floor on the frame) and overlay a small `ProgressView` while we wait for the first measurement. The document now reflows exactly once, when we have a real number. - New `@State measured: Bool` in the container; the overlay shows while `!measured` and disappears the instant the first ok message lands. - `MermaidWebView` gets a matching `@Binding var measured: Bool`; the coordinator flips it inside the existing main-queue dispatch alongside the height write, so the two arrive together. - `measured` is intentionally one-way: subsequent reloads (theme switch, source edit) don't flip it back to false, so the prior render stays on screen until the new one settles instead of flashing the spinner on every theme change. The 0.5pt epsilon filter on the height write is unchanged, so the ResizeObserver tail from the previous commit still can't churn `@State`. Co-authored-by: Amp <amp@ampcode.com> Amp-Thread-ID: https://ampcode.com/threads/T-019e2d86-7146-73b8-8eb6-c24e338cbdfb
Two small lifecycle / safety nits, both in the WKWebView fallback, neither user-visible. Worth landing while we're already inside this file. - `WKUserContentController.add(_:name:)` retains the script-message handler. Without an explicit removal the coordinator (and its SwiftUI bindings) would stay alive until the WebView itself is collected. Add `static func dismantleNSView(_:coordinator:)` and call `removeScriptMessageHandler(forName: "mermaidHeight")`, which is the documented teardown for this exact pattern. - `mermaid.initialize` previously omitted `securityLevel`. The default in mermaid.js v11 is `'strict'`, which is what we want for user-supplied diagram source, but relying on "the default happens to do the right thing" leaves us exposed if a future bundled JS bump changes that default. Pin it explicitly. Co-authored-by: Amp <amp@ampcode.com> Amp-Thread-ID: https://ampcode.com/threads/T-019e2d86-7146-73b8-8eb6-c24e338cbdfb
|
@tqbf we need mermaid :) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
I use Gantt charts a lot in my projects, so I was excited to finally have a comfortable way to view them in mdv — only to find out they weren't supported! Hence this PR. Please consider the proposed approach. I am ready to address any feedback or take a different approach to the implementation if necessary. The considered alternatives are described at the end of the PR.
Summary
Mermaid fenced blocks whose diagram type isn't supported by BeautifulMermaid —
gantt,pie,timeline,mindmap, and anything else added in the future — silently failed with "Mermaid diagram could not be rendered". This PR renders those types using the fullmermaid.jslibrary in an offscreen WKWebView, so the fallback message effectively disappears for all valid Mermaid source.Root cause
BeautifulMermaid supports exactly 6 diagram types: flowchart, state, sequence, class, ER, and XY chart. Any other first-line keyword falls through to the flowchart parser, which chokes on the alien syntax (gantt tasks, date ranges, milestones), and
MDVMermaidImageCache.renderImagereturnsnilviatry?.MDVMermaidDiagramViewthen setsfailed = trueand showsMermaidFallbackView.There is no gantt support in BeautifulMermaid's git history, and the library's own README states "6 diagram types" with no roadmap entry for gantt.
Fix
Detect the diagram type from the first meaningful line before attempting BeautifulMermaid. The dispatcher (
firstMermaidDirectiveLine+isBeautifulMermaidSupported) skips Mermaid preamble — frontmatter (--- … ---),%%{ init: … }%%directives, and%% line comments— so a flowchart with a title block still reaches the native pipeline. Keyword matching requires whitespace or end-of-line after the keyword, soflowchart LRand bareflowchartare accepted butflowchart-elk(a different layout backend BeautifulMermaid doesn't speak) is not.Supported types go through the existing native pipeline unchanged. Everything else is handed to a new
MermaidWebViewContainerthat:mermaid.js(v11.4.1, bundled inContents/Resources/) inlined as a<script>.mermaid.run()asynchronously.window.webkit.messageHandlers.mermaidHeightas a structured{ ok, height | error }payload, so SwiftUI can size the frame correctly after render and route mermaid.js parse failures back into the existingMermaidFallbackView(showing the "could not be rendered" header and the raw source) instead of leaving a blank box.The WebView is opaque and the HTML body is painted with
theme.secondaryBackground(the same color the surrounding chrome uses), so the diagram region matches the chrome on every theme without resorting to private WebKit KVC.updateNSViewonly reloads the page when(source, themeID, isDark)actually changes, so SwiftUI re-renders driven by the height write don't bounce back into a load loop. Toolbar items that only make sense for the native pipeline — the diagram-style picker and PNG export — are hidden on web-rendered diagrams; the source toggle, copy button, and accessibility label (matching the native"Mermaid diagram") are kept.What changed
mdv/MermaidWebRenderer.swiftMermaidWebViewContainer(SwiftUI view with dynamic height, fallback-on-failure, AX label) +MermaidWebView: NSViewRepresentablewrapping WKWebView with diff-based reload, themed body background, structured success/error messagesmdv/MermaidRenderer.swiftfirstMermaidDirectiveLine()strips Mermaid preamble;isBeautifulMermaidSupported()matches keywords with whitespace/EOL boundaries;MDVMermaidDiagramView.bodybranches on it;MermaidCodeBlockChromegates the style picker and PNG export on the dispatcher predicate;MermaidFallbackViewpromoted to internal so the web path can reuse itbuild.shmermaid.min.js(pinnedMERMAID_VERSION="11.4.1"+MERMAID_SHA256) on demand and verifies the SHA-256 of the cached file on every build — local drift or a CDN regression hard-fails.gitignoremdv/mermaid.min.js(downloaded asset, not committed)test-docs/gantt.mdtest-docs/mermaid-web-fallback.md%%{init}%%+%%comment to lock in the preamble-stripping pathREADME.mdmdv/Help.mdtest-docs/README.mdgantt.mdandmermaid-web-fallback.mdentriesmermaid.min.jsis not committed; the first build will fetch it. To pre-fetch:Test plan
Open
test-docs/gantt.md:defaulttheme in light,darkin dark) and matches the surrounding chrome background on every theme (no light/system slab behind the SVG on dark themes)MermaidFallbackViewplate with the source code, not a blank boxOpen
test-docs/mermaid-web-fallback.md:%%{init}%%directive and a%%comment) renders via BeautifulMermaid — verifiable because its toolbar shows the style picker and PNG exportVoiceOver smoke check:
BEFORE (gantt block, any theme):
AFTER:
Screenshots of other diagram types (pie / timeline / mindmap / journey / quadrantChart / requirementDiagram)
Alternatives considered
1. Implement gantt natively in Swift (not chosen)
Would require writing a full gantt parser (date arithmetic, task/milestone/section grammar, axisFormat, dateFormat) plus a renderer that produces correct bar widths and axis labels. That is essentially re-implementing a significant chunk of mermaid.js — hundreds of lines of error-prone layout code with no benefit over the reference JS implementation.
2. Fork BeautifulMermaid and add gantt (not chosen)
BeautifulMermaid is built around the ELK graph-layout engine, which is node-and-edge oriented. Gantt diagrams have no graph topology — they are time-series bars. Adding gantt would require a parallel rendering subsystem that bypasses ELK entirely, making it a near-standalone contribution to a third-party library.
3. WKWebView snapshot → NSImage (considered seriously)
Render in a hidden WKWebView, call
takeSnapshot(with:), and feed the result into the existingMDVMermaidImageCachepipeline. This would give gantt diagrams the same zoom/pan/export-PNG toolbar behaviour as native types. Rejected because: (a) getting the correct initial frame size before mermaid renders requires a two-pass layout — first load, wait for height message, resize, snapshot — which adds complexity and latency; (b) snapshot scale / retina handling needs manual wiring; (c) the inline WKWebView already auto-sizes correctly via the height message, so the added complexity buys little.4. CDN-loaded mermaid.js (not chosen)
Drop the bundle, load from
cdn.jsdelivr.netat render time. Saves ~3 MB from the download step but breaks offline use — a markdown viewer that fails to render diagrams when there's no Wi-Fi is a bad trade-off.5. Vendoring
mermaid.min.jsinto git (not chosen)The simplest answer to "what bytes are we shipping?" is to commit them. Rejected because the file is 2.5 MB of minified third-party JS and would dominate the repo's churn whenever it's bumped. Pinning
MERMAID_VERSIONandMERMAID_SHA256inbuild.shand verifying on every build (including the cached local file) gives the same integrity guarantee — a CDN regression, a stale dev cache, or a hand-edited copy all hard-fail the build with a pointer at the next step.6.
webView.setValue(false, forKey: "drawsBackground")(initially used, then removed)The first cut made the WebView transparent so the chrome's themed background showed through. That's a private-API KVC poke; replaced by leaving the WebView opaque and painting the HTML body to
theme.secondaryBackgroundfrom inside the page, which gives the same visual result on every macOS version without relying on undocumented WebKit behaviour.Chosen: inline WKWebView + bundled mermaid.js
Minimal new code, works offline, correct rendering guaranteed by the reference implementation, self-sizing via JS message passing, and integrity-checked at build time. The existing native pipeline for the 6 supported types is untouched.