diff --git a/.agents/skills/superdeck-presentations/references/runtime-customization.md b/.agents/skills/superdeck-presentations/references/runtime-customization.md index fe93fcd1..fef3aaf4 100644 --- a/.agents/skills/superdeck-presentations/references/runtime-customization.md +++ b/.agents/skills/superdeck-presentations/references/runtime-customization.md @@ -403,7 +403,10 @@ SuperDeckApp( ``` Mermaid diagrams render directly from fenced `mermaid` blocks. They do not -require a plugin, custom runner, browser, generated image, or cache: +require a plugin, custom runner, browser, generated image, or cache. The native +port includes flowchart, sequence, class, state, ER, journey, chart, timeline +and Git-graph families; syntax outside the port's supported subset can differ +from mermaid.js, and a rejected diagram shows the failing line on the slide: ````markdown ```mermaid diff --git a/demo/.superdeck/build_status.json b/demo/.superdeck/build_status.json index 5afce62f..df61e7ac 100644 --- a/demo/.superdeck/build_status.json +++ b/demo/.superdeck/build_status.json @@ -1,5 +1,5 @@ { "status": "success", - "timestamp": "2026-08-25T18:24:50.927825Z", - "slideCount": 34 + "timestamp": "2026-09-21T17:21:10.714812Z", + "slideCount": 35 } \ No newline at end of file diff --git a/demo/.superdeck/superdeck.json b/demo/.superdeck/superdeck.json index 3af9e3a1..880c9ba7 100644 --- a/demo/.superdeck/superdeck.json +++ b/demo/.superdeck/superdeck.json @@ -1088,6 +1088,47 @@ ], "comments": [] }, + { + "key": "7iMG66ab", + "options": { + "title": "Mermaid diagrams" + }, + "sections": [ + { + "blocks": [ + { + "type": "block", + "flex": 1, + "scrollable": false, + "content": "## Diagrams render on the slide\n" + } + ], + "flex": 1, + "spacing": 0.0, + "type": "section" + }, + { + "blocks": [ + { + "type": "block", + "flex": 1, + "scrollable": false, + "content": "\n\n```mermaid\nflowchart LR\n Draft[Draft slides] --> Review{Ready?}\n Review -->|Yes| Present[Present]\n Review -->|No| Draft\n```\n" + }, + { + "type": "block", + "flex": 1, + "scrollable": false, + "content": "```mermaid\nclassDiagram\n Deck \"1\" o-- \"many\" Slide\n Slide : +String key\n Slide : +render()\n```" + } + ], + "flex": 1, + "spacing": 0.0, + "type": "section" + } + ], + "comments": [] + }, { "key": "MKokn2KA", "options": {}, diff --git a/demo/.superdeck/superdeck_full.json b/demo/.superdeck/superdeck_full.json index 9f99e267..5f2a0a19 100644 --- a/demo/.superdeck/superdeck_full.json +++ b/demo/.superdeck/superdeck_full.json @@ -2947,6 +2947,117 @@ ], "comments": [] }, + { + "key": "7iMG66ab", + "options": { + "title": "Mermaid diagrams" + }, + "sections": [ + { + "blocks": [ + { + "type": "block", + "flex": 1, + "scrollable": false, + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h2", + "children": [ + { + "type": "text", + "text": "Diagrams render on the slide" + } + ], + "generatedId": "diagrams-render-on-the-slide" + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + } + } + ], + "flex": 1, + "spacing": 0.0, + "type": "section" + }, + { + "blocks": [ + { + "type": "block", + "flex": 1, + "scrollable": false, + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "pre", + "children": [ + { + "type": "element", + "tag": "code", + "attributes": { + "class": "language-mermaid" + }, + "children": [ + { + "type": "text", + "text": "flowchart LR\n Draft[Draft slides] --> Review{Ready?}\n Review -->|Yes| Present[Present]\n Review -->|No| Draft\n" + } + ] + } + ] + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + } + }, + { + "type": "block", + "flex": 1, + "scrollable": false, + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "pre", + "children": [ + { + "type": "element", + "tag": "code", + "attributes": { + "class": "language-mermaid" + }, + "children": [ + { + "type": "text", + "text": "classDiagram\n Deck "1" o-- "many" Slide\n Slide : +String key\n Slide : +render()\n" + } + ] + } + ] + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + } + } + ], + "flex": 1, + "spacing": 0.0, + "type": "section" + } + ], + "comments": [] + }, { "key": "MKokn2KA", "options": {}, diff --git a/demo/slides.md b/demo/slides.md index 678bca46..ace5d779 100644 --- a/demo/slides.md +++ b/demo/slides.md @@ -555,6 +555,32 @@ layout: fullscreen cacheKey: "fluttermix-fullscreen" } +--- +title: Mermaid diagrams +--- + +## Diagrams render on the slide + +@section + +@block + +```mermaid +flowchart LR + Draft[Draft slides] --> Review{Ready?} + Review -->|Yes| Present[Present] + Review -->|No| Draft +``` + +@block + +```mermaid +classDiagram + Deck "1" o-- "many" Slide + Slide : +String key + Slide : +render() +``` + --- @section{ diff --git a/docs/guides/mermaid-diagrams.mdx b/docs/guides/mermaid-diagrams.mdx index 032e26ad..8bc65199 100644 --- a/docs/guides/mermaid-diagrams.mdx +++ b/docs/guides/mermaid-diagrams.mdx @@ -24,19 +24,21 @@ of the Flutter widget tree for thumbnails, screenshots, and PDF export. ## Supported diagram types -SuperDeck supports: - -- flowcharts -- sequence diagrams -- pie charts -- Gantt charts -- timelines -- Kanban boards -- radar charts -- XY charts - -Class, state, journey, entity-relationship, mind map, Git graph, and other -Mermaid diagram families are not supported. +SuperDeck renders every diagram family the Dart Mermaid port supports, +including: + +- flowcharts and sequence diagrams +- class, state, entity-relationship and user-journey diagrams +- pie, Gantt, XY, radar and quadrant charts +- timelines, Kanban boards, mind maps and Git graphs +- C4, architecture, requirement, packet, sankey, block and treemap diagrams + +Diagram families are ported by hand from Mermaid's own grammars, so a diagram +that renders on mermaid.js can still be rejected here, and layout is not +pixel-identical. Rejected input says so on the slide with the failing line. +SuperDeck also guards the two known flowchart cases where version 0.3.0 would +otherwise accept invalid syntax and draw an incomplete diagram, but this is not +a claim of full mermaid.js conformance. ## Flowcharts @@ -128,26 +130,54 @@ xychart-beta line [70, 82, 95] ``` +## Class, state, ER and journey diagrams + +````markdown +```mermaid +classDiagram + Deck "1" o-- "many" Slide + Slide : +String key + Slide : +render() +``` +```` + +```mermaid +stateDiagram-v2 + [*] --> Draft + Draft --> Review: submit + Review --> Draft: changes + Review --> [*]: approve +``` + ## Layout and appearance -Diagrams adapt to the width assigned to their Markdown block and keep their -natural calculated height. SuperDeck makes the renderer background transparent -so the slide background remains visible. Diagram colors follow the app's light -or dark Flutter theme. +Diagrams scale down to the width assigned to their Markdown block and keep +their natural aspect ratio. SuperDeck makes the renderer background +transparent so the slide background remains visible, and derives diagram +colours from the app's Material colour scheme, so a diagram follows the deck's +light or dark theme. + +Diagram nodes are exposed to screen readers and browser automation, so a slide +with a diagram is not an unlabelled picture. + +`%%{init: ...}%%` directives and YAML frontmatter configuration are read, so +per-diagram theme and layout options work, including `look: handDrawn` and the +ELK layout engine. For dense diagrams, give the block more horizontal space or simplify the source. A diagram inside a narrow multi-column section has less room than the same diagram in a full-width section. -The previous JavaScript configuration map, browser launch options, Mermaid -`init` directives, custom CSS, and PNG viewport settings do not apply to the -runtime renderer. +The build plugin's JavaScript configuration map, browser launch options, +custom CSS, and PNG viewport settings have no runtime counterpart. ## Errors and troubleshooting -Mermaid parsing now happens in the Flutter app rather than during -`superdeck build`. Invalid syntax or an unsupported diagram type displays an -inline error card on the slide and in static exports. +Mermaid parsing happens in the Flutter app rather than during +`superdeck build`. Invalid syntax or an unknown diagram type displays an +inline error card on the slide naming the line that failed, and the same +failure stops a PDF export rather than writing a page with the diagram +missing. When a diagram shows an error: @@ -156,6 +186,8 @@ When a diagram shows an error: 3. Compare the syntax with the examples on this page. 4. Check the presentation at the same block size used by the final slide. -The official Mermaid live editor supports more syntax than SuperDeck's runtime -renderer, so success there does not guarantee that a diagram works in -SuperDeck. +The official Mermaid live editor is the reference implementation. A diagram +that works there usually works here, but the Dart port is a hand-written +re-implementation. Keep diagrams within the documented subset and treat a +renderer disagreement as a compatibility issue to report, even if neither +renderer returns an error. diff --git a/docs/maintainers/adr/0001-runtime-mermaid-rendering.md b/docs/maintainers/adr/0001-runtime-mermaid-rendering.md new file mode 100644 index 00000000..9ae3f230 --- /dev/null +++ b/docs/maintainers/adr/0001-runtime-mermaid-rendering.md @@ -0,0 +1,344 @@ +# ADR 0001: Runtime Mermaid rendering for SuperDeck + +- **Status:** Accepted, 2026-09-21. Implemented on `feat/superdeck-mermaid-core`. +- **Date:** 2026-09-21 +- **Deciders:** Leo (maintainer) +- **Supersedes:** `docs/maintainers/flutter_mermaid_replacement_plan.md` + (branch `origin/claude/mermaid-replacement-plan-56v2aw`, 2026-07-22), whose + Phase 0 spike was never run and whose gap analysis is now out of date. + +## 1. Context + +### What SuperDeck does today + +A fenced ` ```mermaid ` block is routed by one line in +`CodeElementBuilder` (`code_element_builder.dart:103`) to `MermaidCodeBlock` +(`mermaid_code_block.dart`), which renders `flutter_mermaid` 0.1.0's +`MermaidDiagram` with a transparent background, a light/dark `MermaidStyle`, +and an `errorBuilder` that shows `ErrorWidgets.detailed('Unable to render +Mermaid diagram', error)`. + +This replaced a build-time pipeline (`packages/plugins/mermaid`: puppeteer, +headless Chrome, vendored `mermaid.min.js` 11.4.1, PNG per diagram). That +package is gone; `packages/superdeck/CHANGELOG.md` 1.0.0 records the swap as a +breaking change, and the guide was rewritten to a shrunken support matrix. + +### The four concerns this ADR keeps separate + +| Concern | Owner | What this ADR changed | +| --- | --- | --- | +| **Runtime diagrams** — a fence painted inside a presented slide | `MermaidCodeBlock` | The renderer behind that one adapter | +| **Deck previews** — the Wizard rendering the deck it generated | The same widget through `DeckController` | `keepLastGoodSceneOnError: false`, so a preview never shows a stale drawing | +| **Derived presentation thumbnails** — regenerable raster of a slide | `ThumbnailService` + `SlideCaptureService`, memory-cached | Nothing. Thumbnails stay derived and stay in `MemoryAssetCacheStore` | +| **Deck-owned image assets** — artwork a saved deck references by bare key | `DeckLibrary` → `.assets/` | Nothing. A Mermaid fence is *source*, not an asset, and must not become one | + +The last row is the important boundary: a Mermaid diagram is authored text that +lives in the Markdown. Turning diagrams back into generated files would re-enter +the build-time model this project deliberately left, and would put derived +artefacts inside the asset directory reserved for artwork the user cannot +regenerate. + +The playground's file-backed editor was removed in the same delivery, so the +"live editing" affordance this ADR weighed — the package keeping the last good +scene while an author types — is no longer a SuperDeck use case. It is turned +off everywhere. + +### SuperDeck's real Mermaid corpus + +Extracted from the repository, not invented (34 unique diagrams): + +| Source | Count | Families | +| --- | --- | --- | +| `docs/guides/mermaid-diagrams.mdx` (current, user-facing contract) | 9 | flowchart ×2, sequence, pie, gantt, timeline, kanban, radar-beta, xychart-beta | +| `mermaid_code_block_test.dart` `_supportedDiagrams` | 8 | same eight families | +| `plugin_visual_test.dart`, `runtime-customization.md` | 2 | flowchart | +| `docs/guides/mermaid-diagrams.mdx@c2e4dfd2` (build-plugin era, **lost** in the swap) | 15 | + classDiagram, stateDiagram-v2, journey, erDiagram, `%%{init}%%` directive | + +Two facts about the corpus matter: + +- `demo/slides.md` contains **no** Mermaid diagram. The runtime path is exercised + only by a synthesized slide in `plugin_visual_test.dart`. The 2026-07 plan + asked for a demo slide; it was never added. +- The AI generation prompts never emit Mermaid (`grep -i mermaid|diagram` over + `packages/playground/assets/ai_prompts/` returns nothing), so generated decks + add no corpus pressure. + +## 2. Decision drivers + +1. **Content preservation** — a diagram the author wrote must render, or fail + visibly. No silent partial rendering (first-principles Principle 4). +2. **Coverage of the documented corpus**, and ideally of the four families the + project already documented before the swap. +3. **Preview behaviour** — render the generated deck without blocking the UI + thread, and never present a drawing that does not match its source. +4. **Capture fidelity** — the same widget tree must be capturable for + thumbnails and PDF export (both already go through `SlideCaptureReadiness`). +5. **No new toolchain** — no browser, no Node, no build step; `superdeck build` + must stay a pure Dart command. +6. **Maintenance and bus factor** — who fixes a parse bug found mid-talk. +7. **Web payload and startup**, because the demo ships to `superdeck-dev.web.app`. +8. **Reversibility** — how cheaply SuperDeck can change its mind. + +## 3. Options + +### Option 1 — adopt `mermaid_core` + `mermaid_flutter` behind a SuperDeck adapter + +`orestesgaolin/mermaid` (Dominik Roszkowski, verified publisher +`roszkowski.dev`, MIT). Pure Dart pipeline: source → per-diagram parser → +model → text measurement → backend-neutral `RenderScene` → SVG (`mermaid_core`) +or `CustomPainter` (`mermaid_flutter`). + +**Verified independently** (see §4). 28 diagram families, 34/34 of SuperDeck's +corpus rendered, line/column parse errors, built-in last-good-scene behaviour +for live editing, `MaterialMermaidTheme.fromTheme()` bridging Flutter +`ColorScheme`/`TextTheme` into the diagram theme, PNG and SVG renderers +available for future export needs. + +### Option 2 — keep, and contribute to or fork, `flutter_mermaid` + +The incumbent. Verified: 8 families render; class, state, journey, ER, mindmap +and gitGraph all fail with an untyped `Exception: Unable to parse diagram` (no +line, no column, no reason); three of four malformed inputs render silently. +One release (2026-03-05), last repository push the same day, 6 stars, 2 open +issues, unverified uploader, and the published archive is 14.4 MB because a +45 MB `build/` directory was shipped with it — never corrected in 6.5 months. + +Contributing upstream is plausible in principle, but the missing six families +are not small patches: they are six parsers and six layout engines. + +### Option 3 — official Mermaid JS through a WebView/JS adapter, or pre-rendered SVG + +The only path to true parity, because it *is* mermaid.js. SuperDeck already ran +a variant of this (puppeteer at build time) and removed it deliberately. + +- **WebView at runtime:** adds `webview_flutter` (no macOS/Linux desktop parity + for the presenter), breaks `RepaintBoundary` capture on several platforms + (platform views do not reliably appear in `toImage`), and ends thumbnail/PDF + capture as it works today. Fails drivers 4 and 5. +- **Pre-rendered SVG at build time:** re-introduces Node/Chrome into + `superdeck build`, ends live editing of diagrams (driver 3), and produces + derived files that would have to live somewhere — colliding with the + deck-owned asset boundary in §1. + +Retained as a **targeted fallback**, not a default: an opt-in export-time +renderer for authors who need a family the Dart port cannot do. + +### Option 4 — build and own the parser and renderer + +`mermaid_dart` 0.1.0 (flowchart/graph parser only, no layout, no renderer, one +release 2026-06-27, 11 downloads/30 days) is the only Dart seed available, and +it covers one family. Option 1 already is this work, done by someone else, with +~19,400 lines of tests. Rejected unless the project decides diagram rendering is +core intellectual property, which nothing in the repository suggests. + +### Parser nuance, verified + +`@mermaid-js/parser` 2.0.0 (installed and inspected) registers initializers for +**architecture, gitGraph, info, packet, pie, radar, treemap** only. Flowchart, +sequence, class, state, ER, journey, gantt, timeline, kanban, mindmap, xychart, +quadrant, C4, sankey and block still come from the legacy jison grammars inside +the `mermaid` package. There is no reusable upstream parser for the families +SuperDeck cares most about, in any language other than JavaScript. **Every Dart +option is a hand-port, so 28 types is type coverage, not compatibility.** The +upstream project says so itself in `parity/TRACKER.md`. + +## 4. Evidence + +Everything below was executed on this machine with the pinned SDK +(`.fvm/flutter_sdk`, Dart 3.12.2 / Flutter 3.44.6). Scratch projects lived in +`/tmp` and are removed. + +### 4.1 Corpus compatibility (`mermaid_core` 0.3.0, `ApproximateTextMeasurer`) + +**34 / 34 rendered**, including every family in the current guide *and* the four +lost families: + +``` +rendered flowchart docs-guide-1 … docs-guide-2 (5, 8 nodes) +rendered sequence docs-guide-3 (8 nodes) +rendered pie/gantt/timeline/kanban/radar/xychart (8–36 nodes) +rendered classDiagram legacy-doc-6 (11 nodes) ← lost family +rendered stateDiagram legacy-doc-7 (35 nodes) ← lost family +rendered journey legacy-doc-9 (18 nodes) ← lost family +rendered er legacy-doc-11 (9 nodes) ← lost family +rendered flowchart legacy-doc-12 (%%{init:…}%%) (3 nodes) ← lost feature +``` + +Feature probes that also render: subgraphs, emoji labels, markdown strings +(`"\`**bold**\`"`), `click`/`href`, `classDef`/`class`, `linkStyle`, all seven +node shapes, ELK layout via init directive, frontmatter `config:`, +`look: handDrawn`, `autonumber`/`loop`/`alt`/`Note over`, tabs, CRLF, comments. + +### 4.2 Failure behaviour against mermaid.js 12.0.0 (ground truth executed via jsdom) + +| Input | mermaid.js 12 | mermaid_core 0.3.0 | flutter_mermaid 0.1.0 | +| --- | --- | --- | --- | +| `A[Start --> B[Finish]` (unbalanced) | **error**, line 2 | **renders 1 node** `Start --> B[Finish` | **renders** | +| `A ~~> B` (invalid edge op) | **error**, lexical line 2 | **renders 2 nodes, edge dropped** | **renders** | +| prose line inside a flowchart | **error**, line 3 | **error**, line 3 ✓ | **renders** | +| `A[Start] -->` (missing target) | **error**, line 4 | **error**, line 2 ✓ | — | +| unknown diagram keyword | error | `UnsupportedError` + supported list ✓ | `Exception: Unable to parse diagram` | +| empty / `gr` (mid-typing) | error | `UnsupportedError` ✓ | error | + +**The "no silent partial rendering" requirement is not satisfied by any pure +Dart option today.** `mermaid_core` is strictly better than the incumbent +(2 silent cases vs 3, plus line/column diagnostics), and matches mermaid.js on +the other cases, but two narrow parser bugs remain. They are fixture-shaped and +worth reporting upstream; that repository closed 78 issues in three months. + +### 4.3 Performance (pinned SDK, `flutter test`, debug/JIT — pessimistic) + +| Measurement | flutter_mermaid 0.1.0 | mermaid_flutter 0.3.0 | +| --- | --- | --- | +| First frame, 8 shared diagrams (`pumpWidget`) | 17.0 ms avg (13–25) | 21.4 ms avg (13–34) | +| 20 rebuilds, unchanged source | 1 ms total | 4 ms total | +| One-character source edit | 34 ms | ~34 ms (same path) | +| Scene build only, all 34 corpus diagrams | n/a (no public API) | 5.35 ms avg, 22.7 ms worst | +| 20 diagrams in one list, first frame | not measured | 62 ms | + +Both memoize on `(source, theme)`, so an unrelated rebuild costs nothing. The +candidate is ~25 % slower on a cold render; neither is a live-preview problem at +SuperDeck's diagram sizes. **Performance is not a reason to move.** + +### 4.4 Maintenance, supply chain, packaging + +| | flutter_mermaid 0.1.0 | mermaid_core/_flutter 0.3.0 | +| --- | --- | --- | +| Publisher | unverified uploader | verified `roszkowski.dev` | +| Releases | 1 (2026-03-05) | 6 in 10 days (0.1.0 → 0.3.0) | +| Last repo push | 2026-03-05 | 2026-09-08 | +| Stars / forks / open issues | 6 / — / 2 | 24 / 2 / **0** (78 closed) | +| Contributors | 1 | 1 (+1 drive-by: `parlough`) | +| Tests | 64 KB `test/` | ~19,400 lines across core/flutter/elk; **200 `.mmd` fixtures**, incl. `upstream_*` sets | +| Parity documentation | none | `parity/` — per-diagram notes, `COMPATIBILITY.md` with explicit non-goals, evidence links | +| Published archive | 14.4 MB (ships a 45 MB `build/`) | 536 KB + 1.0 MB (+ `elk` 202 KB) | +| Transitive deps | none | `elk`, `katex_dart`, `path_parsing`, `meta` (first two same author) | +| Licence | MIT | MIT, + vendored dart_dagre (Apache-2.0) and embedded KaTeX fonts (OFL-1.1) | +| Pub points | 140/160 | 150/160 | +| SDK | Dart ≥3.0, Flutter ≥3.0 | Dart ^3.12.0, Flutter ≥3.35.0 (SuperDeck pins 3.12.2/3.44.6 ✓) | + +Bus factor is **1 in both cases**. The difference is that one of them is +actively maintained, tests against upstream fixtures, and documents where it +diverges. + +### 4.5 Adapter requirements discovered + +- `MermaidDiagram` paints at the scene's natural size + (`CustomPaint(size: scene.size)`); a narrower parent **crops** rather than + scales, with no overflow error. A `FittedBox` wrapper is required to keep the + behaviour the current guide promises ("diagrams adapt to the width assigned to + their Markdown block"). +- `keepLastGoodSceneOnError` defaults to **true**: on a parse error the previous + scene stays visible, dimmed, with a compact overlay. Correct for the editor; + **wrong for capture** — a thumbnail or a PDF page would show a stale diagram. + The adapter must set it `false` everywhere except the live editor preview. +- `semanticNodes` defaults to **false**; enabling it exposes one semantics node + per identified diagram node. Relevant to SuperDeck's accessibility story, and + free. +- The `Mermaid.render()` switch references all 28 layout engines, so Dart's tree + shaker cannot drop unused families. Web payload delta is unmeasured and is a + spike exit criterion. + +## 5. Decision + +**Adopt Option 1: `mermaid_core` + `mermaid_flutter` behind a thin SuperDeck +adapter, gated on the spike in §6.** Keep `MermaidCodeBlock` as the only +integration point so the renderer stays replaceable. + +Reasons, in order of weight: + +1. It restores four diagram families SuperDeck already documented and lost, plus + `%%{init}%%` directives, without re-introducing a browser toolchain. +2. Its failure behaviour is materially better than the incumbent's and closer to + mermaid.js: typed exceptions with line and column, an explicit supported-type + list, and one fewer silent-partial case. +3. Its live-editing model (memoized scenes, last-good scene with an error + overlay) is the behaviour SuperDeck's own first-principles review asked for. +4. The incumbent is unmaintained by every available signal, and its errors are a + single untyped string that cannot be shown usefully to an author. + +### Material downside accepted + +**SuperDeck would depend on a three-month-old 0.x package stack maintained by +one person, whose scene IR and theme semantics changed between 0.2.0 and 0.3.0** +(`SceneGroupRole` introduced, `MermaidTheme` equality widened, cluster title +geometry changed, frontmatter comment handling changed). Pin exact versions, +keep the adapter thin, and expect to read the changelog on every bump. Two +silent-partial parse cases (§4.2) ship with it. + +## 6. What was delivered + +Implemented on `feat/superdeck-mermaid-core`, not as a spike: + +1. **Dependency.** `flutter_mermaid: ^0.1.0` → `mermaid_core: 0.3.0` and + `mermaid_flutter: 0.3.0`, both pinned exactly. +2. **Adapter.** `MermaidCodeBlock` stays the only place the renderer is named. + It wraps `MermaidDiagram` in a `FittedBox` so a diagram scales down to the + width its Markdown block was given, derives the diagram theme from the app's + Material colour scheme over a transparent background, turns on + `semanticNodes`, and turns off `keepLastGoodSceneOnError`. +3. **Capture.** A diagram that cannot render reports + `SlideCaptureReadiness.fail`, so PDF export stops and names the slide + instead of writing a page without it. A rendered diagram completes the wait + after the frame that drew it. +4. **The two silent-partial cases** (§4.2) are rejected before rendering by + `checkMermaidSource`, which runs upstream's own flowchart parser, vendored + verbatim under `packages/superdeck/lib/src/markdown/mermaid/vendor/` with + its MIT licence and source commit, tightened in exactly two marked places. + Using their grammar rather than a SuperDeck pattern matcher is what keeps + quoted labels, `~~~`, subgraphs, shapes, `classDef`, `linkStyle`, `click`, + init directives and frontmatter working. Upstream was not contacted; the + two fixtures are ready to file when that is authorised, and the vendored + file is deleted the day upstream rejects both inputs. +5. **Corpus.** `mermaid_corpus_test.dart` renders every Mermaid fence in the + guide, the demo deck and the skills reference, so a documented diagram + cannot stop rendering unnoticed. The demo deck gained a Mermaid slide, so + the path is exercised by the integration and browser runs too. +6. **Docs.** The guide lists the restored families, states that the port is a + hand-written re-implementation, and says failures are reported with their + line rather than drawn half-finished. + +### What the delivery did not do + +- **No Node or mermaid.js in the product.** mermaid.js 12.0.0 was used once, + locally, to record the reference behaviour in §4.2. The export-time + JavaScript fallback (option 3) is closed for this delivery. +- **No web payload measurement yet.** The release web bundle delta is still + unmeasured; see the blockers list in the delivery report. + +## 7. Reconsideration triggers + +- `mermaid_core` publishes no release for **6 months**, or its issue tracker + stops being answered (the incumbent's failure mode). +- A 0.x bump breaks the adapter twice in a row, or changes rendered output + without a changelog entry. +- SuperDeck needs a family the Dart port does not implement, or needs pixel + parity with mermaid.js for a customer artefact. +- The silent-partial cases in §4.2 are declined upstream *and* a SuperDeck deck + is observed losing content because of them. +- Web payload or first-frame cost regresses past the exit criteria after an + upgrade. +- Mermaid upstream ships a Langium grammar for flowchart/sequence/class/state, + which would make a maintained Dart port materially cheaper — revisit who + should own the parser. + +## 8. Questions this decision answered + +1. **Is restoring class/state/journey/ER a product goal?** Yes. They were + documented before the build plugin was removed, and they render again. +2. **Silent partial rendering:** neither accepted nor deferred — both cases are + rejected locally by the vendored patch, with the upstream fixtures kept for + a report when contacting upstream is authorised. +3. **`demo/slides.md` had no Mermaid slide.** It has one now, with a flowchart + and a class diagram. +4. **Export-time SVG (option 3, narrow form):** closed for this delivery. It + stays available as the fallback named in §3 if a parity requirement ever + makes it necessary. + +## 9. Still open + +- The release web bundle delta from retaining 28 diagram implementations is + unmeasured. Measure before the next web release; the exit threshold agreed + above was 1.5 MB compressed. +- The vendored parser is a fork of one file. Re-check it on every + `mermaid_core` bump, and delete it when upstream rejects both inputs. diff --git a/packages/superdeck/lib/src/markdown/builders/mermaid_code_block.dart b/packages/superdeck/lib/src/markdown/builders/mermaid_code_block.dart index 672ef746..cf170019 100644 --- a/packages/superdeck/lib/src/markdown/builders/mermaid_code_block.dart +++ b/packages/superdeck/lib/src/markdown/builders/mermaid_code_block.dart @@ -1,30 +1,152 @@ import 'package:flutter/material.dart'; -import 'package:flutter_mermaid/flutter_mermaid.dart'; +import 'package:mermaid_core/mermaid_core.dart' as mermaid; +import 'package:mermaid_flutter/mermaid_flutter.dart'; +import '../../capture/slide_capture_readiness.dart'; +import '../mermaid/mermaid_source_check.dart'; import '../../ui/widgets/error_widgets.dart'; -final class MermaidCodeBlock extends StatelessWidget { +/// Renders a ```` ```mermaid ```` fence as a painted diagram. +/// +/// The diagram is drawn by `mermaid_flutter` on the slide's own canvas: no +/// image asset, no build step, and no browser. It scales to the width its +/// Markdown block was given and keeps its own aspect ratio, the way an image +/// on a slide does. +/// +/// A slide is a finished artefact, not a live editor, so a diagram that does +/// not render is reported instead of leaving the previous drawing on screen: +/// [MermaidDiagram.keepLastGoodSceneOnError] is off, and the failure is also +/// reported to [SlideCaptureReadiness] so an export that needs every visual +/// stops rather than writing a page with a missing diagram. +final class MermaidCodeBlock extends StatefulWidget { final String code; const MermaidCodeBlock({super.key, required this.code}); + @override + State createState() => _MermaidCodeBlockState(); +} + +class _MermaidCodeBlockState extends State { + SlideCaptureReadinessHandle? _readiness; + String? _trackedCode; + bool _reportedFailure = false; + + void _track() { + if (_trackedCode == widget.code && _readiness != null) return; + _readiness?.complete(); + _trackedCode = widget.code; + _reportedFailure = false; + _readiness = SlideCaptureReadiness.track( + context, + label: 'mermaid:${_diagramLabel(widget.code)}', + ); + } + + /// Ends the wait once the frame that drew the diagram has been laid out. + /// + /// The scene is built synchronously while this frame builds, so by the end + /// of it either the diagram painted or [_fail] has run. + void _completeAfterFrame() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _reportedFailure) return; + _readiness?.complete(); + _readiness = null; + }); + } + + void _fail(Object error) { + _reportedFailure = true; + _readiness?.fail('$error'); + _readiness = null; + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _track(); + } + + @override + void didUpdateWidget(MermaidCodeBlock oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.code != widget.code) _track(); + } + + @override + void dispose() { + _readiness?.complete(); + _readiness = null; + super.dispose(); + } + + /// The reason this source cannot be drawn completely, if there is one. + /// + /// Checked here rather than left to the renderer, because the renderer + /// accepts two inputs it cannot draw faithfully. + Object? _sourceRejection(String source) { + try { + checkMermaidSource(source); + + return null; + } on mermaid.MermaidParseException catch (error) { + return error; + } + } + @override Widget build(BuildContext context) { - final baseStyle = Theme.of(context).brightness == Brightness.dark - ? MermaidStyle.dark() - : const MermaidStyle(); - final style = baseStyle.copyWith( - backgroundColor: 0x00000000, - defaultEdgeStyle: baseStyle.defaultEdgeStyle.copyWith( - labelColor: baseStyle.defaultNodeStyle.textColor, + _track(); + _completeAfterFrame(); + + if (_sourceRejection(widget.code.trim()) case final rejection?) { + _fail(rejection); + + return ErrorWidgets.detailed( + 'Unable to render Mermaid diagram', + '$rejection', + ); + } + + return FittedBox( + fit: BoxFit.scaleDown, + child: MermaidDiagram( + source: widget.code.trim(), + theme: _slideDiagramTheme(context), + errorBuilder: (context, error) { + _fail(error); + + return ErrorWidgets.detailed( + 'Unable to render Mermaid diagram', + '$error', + ); + }, + // A slide shows the diagram its source describes, or says why it + // cannot. Keeping the last good scene would export a stale drawing. + keepLastGoodSceneOnError: false, + semanticNodes: true, ), ); + } +} - return MermaidDiagram( - code: code.trim(), - style: style, - errorBuilder: (_, error) => - ErrorWidgets.detailed('Unable to render Mermaid diagram', error), - ); +/// A Mermaid theme built from the app's own colours, with no background. +/// +/// The slide already painted its background; the diagram draws on top of it. +mermaid.MermaidTheme _slideDiagramTheme(BuildContext context) { + final theme = MaterialMermaidTheme.fromTheme(Theme.of(context)); + + return theme.copyWith(background: const mermaid.Color(0x00000000)); +} + +/// Names a diagram by its first meaningful line, for capture diagnostics. +String _diagramLabel(String code) { + for (final line in code.split('\n')) { + final trimmed = line.trim(); + if (trimmed.isNotEmpty && !trimmed.startsWith('%%')) { + return trimmed.length <= 40 ? trimmed : '${trimmed.substring(0, 39)}…'; + } } + + return 'diagram'; } diff --git a/packages/superdeck/lib/src/markdown/mermaid/mermaid_source_check.dart b/packages/superdeck/lib/src/markdown/mermaid/mermaid_source_check.dart new file mode 100644 index 00000000..61891eb2 --- /dev/null +++ b/packages/superdeck/lib/src/markdown/mermaid/mermaid_source_check.dart @@ -0,0 +1,21 @@ +import 'package:mermaid_core/mermaid_core.dart'; + +import 'vendor/flowchart_strict_parser.dart'; + +/// Rejects flowchart sources that the renderer would draw incompletely. +/// +/// The Dart Mermaid port accepts two inputs mermaid.js rejects, and renders +/// something plausible rather than reporting them: an unquoted `[` inside a +/// node label, and a tilde link carrying an arrow head. Both lose part of the +/// author's diagram without saying so, which a slide cannot afford. +/// +/// The check runs the vendored copy of the port's own flowchart parser, which +/// differs from it only in those two places, so it accepts exactly what the +/// renderer accepts otherwise. Other diagram families are left to the +/// renderer, which already reports what it cannot parse. +/// +/// Throws [MermaidParseException] with the offending line. +void checkMermaidSource(String source) { + if (detectDiagramType(source) != DiagramType.flowchart) return; + strictParseFlowchart(source); +} diff --git a/packages/superdeck/lib/src/markdown/mermaid/vendor/LICENSE b/packages/superdeck/lib/src/markdown/mermaid/vendor/LICENSE new file mode 100644 index 00000000..41fda242 --- /dev/null +++ b/packages/superdeck/lib/src/markdown/mermaid/vendor/LICENSE @@ -0,0 +1,35 @@ +MIT License + +Copyright (c) 2026 Dominik Roszkowski + +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. + +--- + +This project is a port of mermaid.js (https://github.com/mermaid-js/mermaid), +which is MIT licensed. + +It vendors a derivative of dart_dagre (Apache License 2.0) under +lib/src/vendor/dagre/; that directory retains its original LICENSE. + +It also embeds the KaTeX fonts (KaTeX_Main-Regular, KaTeX_Math-Italic) for +math rendering, base64-encoded in lib/src/math/katex_fonts.dart. The KaTeX +fonts are licensed under the SIL Open Font License 1.1 +(https://github.com/KaTeX/KaTeX/blob/main/src/fonts/OFL.txt); "KaTeX" is a +reserved font name. diff --git a/packages/superdeck/lib/src/markdown/mermaid/vendor/flowchart_strict_parser.dart b/packages/superdeck/lib/src/markdown/mermaid/vendor/flowchart_strict_parser.dart new file mode 100644 index 00000000..80e4994a --- /dev/null +++ b/packages/superdeck/lib/src/markdown/mermaid/vendor/flowchart_strict_parser.dart @@ -0,0 +1,1189 @@ +// Vendored from mermaid_core 0.3.0, file +// `lib/src/diagrams/flowchart/flow_parser.dart`, at +// https://github.com/orestesgaolin/mermaid commit 2828e1cb (2026-09-08). +// Copyright (c) 2026 Dominik Roszkowski. MIT licensed; see the licence text +// reproduced in `LICENSE` beside this file. +// +// WHY THIS COPY EXISTS +// -------------------- +// Upstream accepts two inputs that mermaid.js rejects, and renders something +// plausible instead of reporting them. SuperDeck cannot show a slide that +// silently lost a relationship, so this copy tightens exactly those two +// places, marked `SUPERDECK PATCH`, and nothing else: +// +// 1. `A[Start --> B[Finish]` — an unquoted `[` inside a square-bracket +// label swallowed the rest of the line into the node's text. +// 2. `A ~~> B` — a tilde link with an arrow head was accepted as an +// invisible edge, so the edge vanished from the drawing. +// +// It is used only as a validator: `strictParseFlowchart` runs before the +// package renders the same source, so every other behaviour, including layout +// and the other 27 diagram families, stays upstream's. +// +// TO RE-SYNC: diff this file against the same path in the pinned +// mermaid_core version, re-apply the two `SUPERDECK PATCH` hunks, and run +// `test/src/markdown/mermaid_strict_flowchart_test.dart`. Delete this file +// when upstream rejects both inputs. + +/// Hand-written recursive descent parser for mermaid flowchart syntax. +/// +/// Grammar reference: upstream `flowchart/parser/flow.jison` and the +/// semantics in `flowDb.ts` (notably `destructEndLink` for edge tokens). +/// Behavior is validated against test cases ported from the upstream +/// `flow-*.spec.js` suites. +library; + +import 'package:mermaid_core/mermaid_core.dart'; + +/// Parses flowchart source the way mermaid_core does, minus the two inputs +/// mermaid.js rejects (see the file header). +/// +/// Throws [MermaidParseException] on syntax errors. +FlowGraph strictParseFlowchart(String source) { + final title = frontmatterTitle(source); + final text = stripMetadata(source); + final inheritDir = + resolveDiagramConfig(source, 'flowchart')['inheritDir'] == true; + return _FlowParser(text, title, inheritDir).parse(); +} + +class _Statement { + _Statement(this.text, this.line); + final String text; + final int line; +} + +class _OpenSubgraph { + _OpenSubgraph(this.id, this.title, this.parentIndex); + final String id; + final String title; + final int? parentIndex; + final List nodeIds = []; + FlowDirection? direction; + int? listIndex; +} + +class _EdgeSpec { + _EdgeSpec({ + required this.stroke, + required this.headFrom, + required this.headTo, + required this.minLen, + this.label, + }); + + final EdgeStroke stroke; + final ArrowHead headFrom; + final ArrowHead headTo; + final int minLen; + final String? label; +} + +class _FlowParser { + _FlowParser(this.text, this.title, this.inheritDir); + + final String text; + final String? title; + final bool inheritDir; + + final nodes = {}; + final edges = []; + final subgraphs = []; + final classDefs = >{}; + final _defaultLinkStyles = {}; + var direction = FlowDirection.tb; + var _anonSubgraphCount = 0; + + final _openSubgraphs = <_OpenSubgraph>[]; + + /// Subgraph list under construction; entries are placed at their open-order + /// index and filled in when the block closes. + final _builtSubgraphs = []; + + FlowGraph parse() { + final statements = _splitStatements(text); + if (statements.isEmpty) { + throw const MermaidParseException('empty flowchart source'); + } + final header = statements.first; + final headerMatch = RegExp( + r'^(graph|flowchart-elk|flowchart)\b\s*(.*)$', + ).firstMatch(header.text.trim()); + if (headerMatch == null) { + throw MermaidParseException( + 'expected "graph" or "flowchart" header, got "${header.text.trim()}"', + line: header.line, + ); + } + final rest = headerMatch.group(2)!.trim(); + final remainder = _parseDirection(rest); + if (remainder.isNotEmpty) { + _parseStatement(_Statement(remainder, header.line)); + } + for (final st in statements.skip(1)) { + _parseStatement(st); + } + if (_openSubgraphs.isNotEmpty) { + throw MermaidParseException( + 'unclosed subgraph "${_openSubgraphs.last.title}"', + ); + } + final finalEdges = + (_defaultLinkStyles.isEmpty && _defaultInterpolate == null) + ? edges + : [ + for (final e in edges) + e.copyWith( + styles: {..._defaultLinkStyles, ...e.styles}, + interpolate: e.interpolate ?? _defaultInterpolate, + ), + ]; + return FlowGraph( + direction: direction, + nodes: nodes, + edges: finalEdges, + subgraphs: _builtSubgraphs.whereType().toList(), + classDefs: classDefs, + title: title, + ); + } + + /// Parses the direction token after the header keyword; returns whatever + /// trails it on the same statement. + String _parseDirection(String rest) { + if (rest.isEmpty) return ''; + const dirs = { + 'TB': FlowDirection.tb, + 'TD': FlowDirection.tb, + 'BT': FlowDirection.bt, + 'LR': FlowDirection.lr, + 'RL': FlowDirection.rl, + // Legacy single-char directions. + '>': FlowDirection.lr, + '<': FlowDirection.rl, + '^': FlowDirection.bt, + 'v': FlowDirection.tb, + }; + final m = RegExp(r'^(TB|TD|BT|LR|RL|>|<|\^|v)(?=\s|$)').firstMatch(rest); + if (m == null) return rest; + direction = dirs[m.group(1)]!; + return rest.substring(m.end).trim(); + } + + /// Splits the source into statements at newlines and top-level `;`, + /// respecting quotes and bracket nesting, dropping inline `%%` comments, + /// and keeping `accDescr { ... }` blocks together. + List<_Statement> _splitStatements(String text) { + final out = <_Statement>[]; + final buf = StringBuffer(); + var bufLine = 1; + var line = 1; + var depth = 0; + var inQuote = false; + var inAccBlock = false; + + void flush() { + final s = buf.toString().trim(); + if (s.isNotEmpty) out.add(_Statement(s, bufLine)); + buf.clear(); + bufLine = line; + } + + for (var i = 0; i < text.length; i++) { + final c = text[i]; + if (c == '\n') { + line++; + if (inAccBlock) { + buf.write(' '); + continue; + } + // Quoted strings (e.g. click tooltips) may span lines. + if (inQuote) { + buf.write('\n'); + continue; + } + flush(); + continue; + } + if (inQuote) { + buf.write(c); + if (c == '"') inQuote = false; + continue; + } + switch (c) { + case '"': + inQuote = true; + buf.write(c); + case '%' when i + 1 < text.length && text[i + 1] == '%': + // Inline comment: skip to end of line. + while (i + 1 < text.length && text[i + 1] != '\n') { + i++; + } + case '[' || '(': + depth++; + buf.write(c); + case '{': + if (RegExp(r'accDescr\s*$').hasMatch(buf.toString())) { + inAccBlock = true; + } else { + depth++; + } + buf.write(c); + case ']' || ')': + // Clamp: the asymmetric shape `A>text]` closes a bracket it never + // opened, which must not swallow the following `;`. + if (depth > 0) depth--; + buf.write(c); + case '}': + if (inAccBlock) { + inAccBlock = false; + } else if (depth > 0) { + depth--; + } + buf.write(c); + case ';' when depth == 0: + flush(); + default: + buf.write(c); + } + } + flush(); + return out; + } + + void _parseStatement(_Statement st) { + final s = st.text; + if (s.isEmpty) return; + if (RegExp(r'^subgraph\b').hasMatch(s)) { + _openSubgraph(s.substring('subgraph'.length).trim(), st.line); + return; + } + if (RegExp(r'^end$').hasMatch(s)) { + _closeSubgraph(st.line); + return; + } + final dir = RegExp(r'^direction\s+(TB|TD|BT|LR|RL)\s*$').firstMatch(s); + if (dir != null) { + const dirs = { + 'TB': FlowDirection.tb, + 'TD': FlowDirection.tb, + 'BT': FlowDirection.bt, + 'LR': FlowDirection.lr, + 'RL': FlowDirection.rl, + }; + if (_openSubgraphs.isNotEmpty) { + if (!inheritDir) { + _openSubgraphs.last.direction = dirs[dir.group(1)]!; + } + } else { + direction = dirs[dir.group(1)]!; + } + return; + } + if (RegExp(r'^classDef\b').hasMatch(s)) { + _parseClassDef(s, st.line); + return; + } + if (RegExp(r'^class\b').hasMatch(s)) { + _parseClassStatement(s, st.line); + return; + } + if (RegExp(r'^style\b').hasMatch(s)) { + _parseStyleStatement(s, st.line); + return; + } + if (RegExp(r'^linkStyle\b').hasMatch(s)) { + _parseLinkStyle(s, st.line); + return; + } + if (RegExp(r'^click\b').hasMatch(s)) { + _parseClick(s, st.line); + return; + } + // Accessibility statements are parsed and discarded for now. + if (RegExp(r'^acc(Title|Descr)\s*[:{]').hasMatch(s)) return; + _parseChain(st); + } + + // --- subgraphs ------------------------------------------------------------ + + void _openSubgraph(String header, int line) { + String id; + String titleText; + final bracket = RegExp(r'^([^\s\[\]"]+)\s*\[(.*)\]\s*$').firstMatch(header); + if (bracket != null) { + id = bracket.group(1)!; + titleText = _normalizeLabel(bracket.group(2)!); + } else if (header.isEmpty) { + id = 'subGraph${_anonSubgraphCount++}'; + titleText = ''; + } else if (RegExp(r'^"[^"]*"$').hasMatch(header)) { + titleText = _normalizeLabel(header); + id = 'subGraph${_anonSubgraphCount++}'; + } else if (!header.contains(' ')) { + id = header; + titleText = header; + } else { + id = 'subGraph${_anonSubgraphCount++}'; + titleText = _normalizeLabel(header); + } + final parentIndex = _openSubgraphs.isEmpty + ? null + : _openSubgraphs.last.listIndex; + final open = _OpenSubgraph(id, titleText, parentIndex) + ..listIndex = _builtSubgraphs.length; + _builtSubgraphs.add(null); + _openSubgraphs.add(open); + } + + void _closeSubgraph(int line) { + if (_openSubgraphs.isEmpty) { + throw MermaidParseException('"end" without open subgraph', line: line); + } + final open = _openSubgraphs.removeLast(); + _builtSubgraphs[open.listIndex!] = FlowSubgraph( + id: open.id, + title: open.title, + nodeIds: open.nodeIds, + direction: open.direction, + parentIndex: open.parentIndex, + ); + } + + // --- keyword statements ----------------------------------------------------- + + void _parseClassDef(String s, int line) { + final m = RegExp(r'^classDef\s+([^\s]+)\s+(.+)$').firstMatch(s); + if (m == null) { + throw MermaidParseException('malformed classDef', line: line); + } + final styles = _parseStyles(m.group(2)!); + for (final name in m.group(1)!.split(',')) { + classDefs[name.trim()] = styles; + } + } + + void _parseClassStatement(String s, int line) { + final m = RegExp(r'^class\s+([^\s]+)\s+([^\s]+)\s*$').firstMatch(s); + if (m == null) { + throw MermaidParseException('malformed class statement', line: line); + } + final className = m.group(2)!; + for (final id in m.group(1)!.split(',')) { + final nodeId = id.trim(); + // `class` may also target subgraph ids; subgraph classes are not + // modeled yet, so only known/auto-created node ids are updated. + if (_builtSubgraphs.any((sg) => sg?.id == nodeId) || + _openSubgraphs.any((sg) => sg.id == nodeId)) { + continue; + } + final node = _ensureNode(nodeId); + nodes[nodeId] = node.copyWith(classes: [...node.classes, className]); + } + } + + void _parseStyleStatement(String s, int line) { + final m = RegExp(r'^style\s+([^\s]+)\s+(.+)$').firstMatch(s); + if (m == null) { + throw MermaidParseException('malformed style statement', line: line); + } + final id = m.group(1)!; + final node = _ensureNode(id); + nodes[id] = node.copyWith( + styles: {...node.styles, ..._parseStyles(m.group(2)!)}, + ); + } + + void _parseLinkStyle(String s, int line) { + final m = RegExp(r'^linkStyle\s+([\d,\s]+|default)\s+(.+)$').firstMatch(s); + if (m == null) { + throw MermaidParseException('malformed linkStyle statement', line: line); + } + var styleText = m.group(2)!.trim(); + // `interpolate ` selects the edge curve (basis/linear/step/...). + String? curve; + final interp = RegExp(r'^interpolate\s+(\w+)\s*').firstMatch(styleText); + if (interp != null) { + curve = interp.group(1); + styleText = styleText.substring(interp.end).trim(); + } + final styles = styleText.isEmpty + ? {} + : _parseStyles(styleText); + final indexText = m.group(1)!.trim(); + // `linkStyle default` applies to every edge; per-index styles override + // it (merged at the end of parse()). + if (indexText == 'default') { + _defaultLinkStyles.addAll(styles); + if (curve != null) _defaultInterpolate = curve; + return; + } + for (final part in indexText.split(',')) { + final i = int.tryParse(part.trim()); + if (i == null || i < 0 || i >= edges.length) { + throw MermaidParseException( + 'linkStyle index $part out of range (have ${edges.length} links)', + line: line, + ); + } + edges[i] = edges[i].copyWith( + styles: {...edges[i].styles, ...styles}, + interpolate: curve, + ); + } + } + + String? _defaultInterpolate; + + void _parseClick(String s, int line) { + // click href "url" ["tooltip"] | click "url" ["tooltip"] + // click call fn() | click callback — parsed, ignored. + // [\s\S] because quoted tooltips may contain newlines. + final m = RegExp(r'^click\s+(\S+)\s+([\s\S]*)$').firstMatch(s); + if (m == null) { + throw MermaidParseException('malformed click statement', line: line); + } + final id = m.group(1)!; + var rest = m.group(2)!.trim(); + rest = rest.replaceFirst(RegExp(r'^href\s+'), ''); + final url = RegExp(r'^"([^"]*)"\s*(?:"([^"]*)")?').firstMatch(rest); + if (url != null) { + final node = _ensureNode(id); + nodes[id] = node.copyWith(link: url.group(1), tooltip: url.group(2)); + } + // Anything else (callback / call fn()) is intentionally discarded. + } + + Map _parseStyles(String text) { + final styles = {}; + for (final part in _splitTopLevel(text, ',')) { + final i = part.indexOf(':'); + if (i <= 0) continue; + styles[part.substring(0, i).trim()] = part.substring(i + 1).trim(); + } + return styles; + } + + /// Splits on [sep] outside of parentheses (e.g. `fill:rgb(1,2,3)`). + List _splitTopLevel(String text, String sep) { + final out = []; + var depth = 0; + final buf = StringBuffer(); + for (var i = 0; i < text.length; i++) { + final c = text[i]; + if (c == '(') depth++; + if (c == ')') depth--; + if (c == sep && depth == 0) { + out.add(buf.toString()); + buf.clear(); + } else { + buf.write(c); + } + } + out.add(buf.toString()); + return out; + } + + // --- node/edge chains ------------------------------------------------------- + + void _parseChain(_Statement st) { + final scanner = _Scanner(st.text, st.line); + var sources = _parseNodeGroup(scanner); + while (true) { + scanner.skipWs(); + if (scanner.atEnd) break; + final edge = _tryParseEdge(scanner); + if (edge == null) { + throw MermaidParseException( + 'expected link or end of statement near "${scanner.rest(12)}"', + line: st.line, + ); + } + final targets = _parseNodeGroup(scanner); + for (final from in sources) { + for (final to in targets) { + edges.add( + FlowEdge( + from: from, + to: to, + label: edge.label, + stroke: edge.stroke, + headFrom: edge.headFrom, + headTo: edge.headTo, + minLen: edge.minLen, + ), + ); + } + } + sources = targets; + } + } + + List _parseNodeGroup(_Scanner sc) { + final ids = [_parseNode(sc)]; + while (true) { + sc.skipWs(); + if (sc.tryConsume('&')) { + ids.add(_parseNode(sc)); + } else { + return ids; + } + } + } + + String _parseNode(_Scanner sc) { + sc.skipWs(); + final id = sc.readNodeId(); + if (id.isEmpty) { + throw MermaidParseException( + 'expected node id near "${sc.rest(12)}"', + line: sc.line, + ); + } + _ensureNode(id); + + // `:::class` may come before or after the shape declaration. + var shapeParsed = false; + while (true) { + if (sc.tryConsume(':::')) { + final cls = sc.readNodeId(); + if (cls.isEmpty) { + throw MermaidParseException( + 'expected class name after ":::"', + line: sc.line, + ); + } + final node = nodes[id]!; + nodes[id] = node.copyWith(classes: [...node.classes, cls]); + } else if (!shapeParsed && sc.tryConsume('@{')) { + _parseNodeAttributes(id, sc); + shapeParsed = true; + } else if (!shapeParsed) { + final shape = _tryParseShape(sc); + if (shape == null) break; + final (kind, rawLabel) = shape; + final label = _normalizeLabel(rawLabel); + nodes[id] = nodes[id]!.copyWith( + label: label.isEmpty ? id : label, + shape: kind, + markdown: _isMarkdownLabel(rawLabel), + ); + shapeParsed = true; + } else { + break; + } + } + + if (_openSubgraphs.isNotEmpty) { + final members = _openSubgraphs.last.nodeIds; + if (!members.contains(id)) members.add(id); + } + return id; + } + + /// Parses the v11 `@{ key: value, ... }` node attribute object (the `@{` + /// has already been consumed). Recognized keys: `shape`, `label`; others + /// (icon, form, w, h, ...) are parsed and ignored. + void _parseNodeAttributes(String id, _Scanner sc) { + String? shapeName; + String? label; + String? icon; + while (true) { + sc.skipWs(); + if (sc.tryConsume('}')) break; + if (sc.atEnd) { + throw MermaidParseException( + 'unterminated "@{" attributes on "$id"', + line: sc.line, + ); + } + final key = sc.readWhile(RegExp(r'[A-Za-z0-9_-]')).trim(); + sc.skipWs(); + if (!sc.tryConsume(':')) { + throw MermaidParseException( + 'expected ":" after "@{" attribute key "$key"', + line: sc.line, + ); + } + sc.skipWs(); + String value; + if (sc.tryConsume('"')) { + final (_, quoted) = sc.readUntil(['"']); + value = quoted; + } else { + value = sc.readWhile(RegExp(r'[^,}]')).trim(); + } + switch (key) { + case 'shape': + shapeName = value; + case 'label': + label = value; + case 'icon': + icon = value; + default: + break; // Recognized-but-unsupported attribute; ignored. + } + sc.skipWs(); + sc.tryConsume(','); + } + var node = nodes[id]!; + if (shapeName != null) { + final shape = _v11Shapes[shapeName]; + if (shape == null && !_knownUnsupportedV11Shapes.contains(shapeName)) { + throw MermaidParseException( + 'unknown shape "$shapeName"', + line: sc.line, + ); + } + // Unsupported-but-valid v11 shapes fall back to a plain rectangle. + node = node.copyWith(shape: shape ?? FlowNodeShape.rect); + } + if (label != null) { + final normalized = _normalizeLabel(label); + node = node.copyWith( + label: normalized.isEmpty ? id : normalized, + markdown: _isMarkdownLabel(label), + ); + } + if (icon != null) { + node = node.copyWith( + icon: icon, + shape: shapeName == null ? FlowNodeShape.rounded : null, + ); + } + nodes[id] = node; + } + + /// v11 shape names/aliases (rendering-elements/shapes.ts) mapped onto the + /// geometries we support. + static const _v11Shapes = { + 'rect': FlowNodeShape.rect, + 'rectangle': FlowNodeShape.rect, + 'proc': FlowNodeShape.rect, + 'process': FlowNodeShape.rect, + 'square': FlowNodeShape.rect, + 'rounded': FlowNodeShape.rounded, + 'event': FlowNodeShape.rounded, + 'stadium': FlowNodeShape.stadium, + 'terminal': FlowNodeShape.stadium, + 'pill': FlowNodeShape.stadium, + 'fr-rect': FlowNodeShape.subroutine, + 'subprocess': FlowNodeShape.subroutine, + 'subproc': FlowNodeShape.subroutine, + 'framed-rectangle': FlowNodeShape.subroutine, + 'subroutine': FlowNodeShape.subroutine, + 'cyl': FlowNodeShape.cylinder, + 'db': FlowNodeShape.cylinder, + 'database': FlowNodeShape.cylinder, + 'cylinder': FlowNodeShape.cylinder, + 'datastore': FlowNodeShape.cylinder, + 'data-store': FlowNodeShape.cylinder, + 'lin-cyl': FlowNodeShape.cylinder, + 'disk': FlowNodeShape.cylinder, + 'lined-cylinder': FlowNodeShape.cylinder, + 'h-cyl': FlowNodeShape.cylinder, + 'das': FlowNodeShape.cylinder, + 'horizontal-cylinder': FlowNodeShape.cylinder, + 'circle': FlowNodeShape.circle, + 'circ': FlowNodeShape.circle, + 'sm-circ': FlowNodeShape.circle, + 'start': FlowNodeShape.circle, + 'small-circle': FlowNodeShape.circle, + 'f-circ': FlowNodeShape.circle, + 'junction': FlowNodeShape.circle, + 'filled-circle': FlowNodeShape.circle, + 'fr-circ': FlowNodeShape.doubleCircle, + 'stop': FlowNodeShape.doubleCircle, + 'framed-circle': FlowNodeShape.doubleCircle, + 'dbl-circ': FlowNodeShape.doubleCircle, + 'double-circle': FlowNodeShape.doubleCircle, + 'diam': FlowNodeShape.diamond, + 'decision': FlowNodeShape.diamond, + 'diamond': FlowNodeShape.diamond, + 'question': FlowNodeShape.diamond, + 'hex': FlowNodeShape.hexagon, + 'hexagon': FlowNodeShape.hexagon, + 'prepare': FlowNodeShape.hexagon, + 'lean-r': FlowNodeShape.leanRight, + 'lean-right': FlowNodeShape.leanRight, + 'in-out': FlowNodeShape.leanRight, + 'lean-l': FlowNodeShape.leanLeft, + 'lean-left': FlowNodeShape.leanLeft, + 'out-in': FlowNodeShape.leanLeft, + 'trap-b': FlowNodeShape.trapezoid, + 'priority': FlowNodeShape.trapezoid, + 'trapezoid-bottom': FlowNodeShape.trapezoid, + 'trapezoid': FlowNodeShape.trapezoid, + 'trap-t': FlowNodeShape.invTrapezoid, + 'manual': FlowNodeShape.invTrapezoid, + 'trapezoid-top': FlowNodeShape.invTrapezoid, + 'inv-trapezoid': FlowNodeShape.invTrapezoid, + 'odd': FlowNodeShape.asymmetric, + // v11 geometries (rendering-elements/shapes.ts). + 'doc': FlowNodeShape.document, + 'document': FlowNodeShape.document, + 'lin-doc': FlowNodeShape.document, + 'lined-document': FlowNodeShape.document, + 'docs': FlowNodeShape.document, + 'documents': FlowNodeShape.document, + 'st-doc': FlowNodeShape.document, + 'stacked-document': FlowNodeShape.document, + 'tag-doc': FlowNodeShape.document, + 'tagged-document': FlowNodeShape.document, + 'tri': FlowNodeShape.triangle, + 'triangle': FlowNodeShape.triangle, + 'extract': FlowNodeShape.triangle, + 'flip-tri': FlowNodeShape.flippedTriangle, + 'flipped-triangle': FlowNodeShape.flippedTriangle, + 'manual-file': FlowNodeShape.flippedTriangle, + 'sl-rect': FlowNodeShape.slopedRect, + 'sloped-rectangle': FlowNodeShape.slopedRect, + 'manual-input': FlowNodeShape.slopedRect, + 'notch-rect': FlowNodeShape.card, + 'card': FlowNodeShape.card, + 'notched-rectangle': FlowNodeShape.card, + 'notch-pent': FlowNodeShape.notchedPentagon, + 'loop-limit': FlowNodeShape.notchedPentagon, + 'notched-pentagon': FlowNodeShape.notchedPentagon, + 'bolt': FlowNodeShape.bolt, + 'lightning-bolt': FlowNodeShape.bolt, + 'com-link': FlowNodeShape.bolt, + 'hourglass': FlowNodeShape.hourglass, + 'collate': FlowNodeShape.hourglass, + 'lin-rect': FlowNodeShape.linedRect, + 'lined-rectangle': FlowNodeShape.linedRect, + 'lined-process': FlowNodeShape.linedRect, + 'lin-proc': FlowNodeShape.linedRect, + 'shaded-process': FlowNodeShape.linedRect, + 'st-rect': FlowNodeShape.linedRect, + 'stacked-rectangle': FlowNodeShape.linedRect, + 'processes': FlowNodeShape.linedRect, + 'procs': FlowNodeShape.linedRect, + 'div-rect': FlowNodeShape.dividedRect, + 'div-proc': FlowNodeShape.dividedRect, + 'divided-rectangle': FlowNodeShape.dividedRect, + 'divided-process': FlowNodeShape.dividedRect, + 'win-pane': FlowNodeShape.windowPane, + 'internal-storage': FlowNodeShape.windowPane, + 'window-pane': FlowNodeShape.windowPane, + 'cross-circ': FlowNodeShape.crossedCircle, + 'crossed-circle': FlowNodeShape.crossedCircle, + 'summary': FlowNodeShape.crossedCircle, + 'fork': FlowNodeShape.forkJoin, + 'join': FlowNodeShape.forkJoin, + 'text': FlowNodeShape.textShape, + // Closest existing geometry for the remaining v11 names. + 'delay': FlowNodeShape.rounded, + 'half-rounded-rectangle': FlowNodeShape.rounded, + 'curv-trap': FlowNodeShape.trapezoid, + 'curved-trapezoid': FlowNodeShape.trapezoid, + 'display': FlowNodeShape.trapezoid, + 'brace': FlowNodeShape.rect, + 'brace-l': FlowNodeShape.rect, + 'brace-r': FlowNodeShape.rect, + 'braces': FlowNodeShape.rect, + 'comment': FlowNodeShape.rect, + 'tag-rect': FlowNodeShape.card, + 'tagged-rectangle': FlowNodeShape.card, + 'tag-proc': FlowNodeShape.card, + 'tagged-process': FlowNodeShape.card, + 'flag': FlowNodeShape.rect, + 'paper-tape': FlowNodeShape.document, + 'bow-rect': FlowNodeShape.rect, + 'bow-tie-rectangle': FlowNodeShape.rect, + 'stored-data': FlowNodeShape.cylinder, + }; + + /// Valid v11 shapes whose geometry we don't draw specially; they render as + /// rectangles rather than failing the parse. (`icon` is handled by the icon + /// feature; `image` has no image source support yet.) + static const _knownUnsupportedV11Shapes = {'icon', 'image'}; + + /// Bracket-delimited node shapes; openers are matched longest-first. + (FlowNodeShape, String)? _tryParseShape(_Scanner sc) { + // (opener, closers): the matched closer can refine the shape + // (lean vs trapezoid). + if (sc.tryConsume('(((')) { + return (FlowNodeShape.doubleCircle, sc.readUntil([')))']).$2); + } + if (sc.tryConsume('((')) { + return (FlowNodeShape.circle, sc.readUntil(['))']).$2); + } + if (sc.tryConsume('([')) { + return (FlowNodeShape.stadium, sc.readUntil(['])']).$2); + } + if (sc.tryConsume('(-')) { + return (FlowNodeShape.ellipse, sc.readUntil(['-)']).$2); + } + if (sc.tryConsume('(')) { + return (FlowNodeShape.rounded, sc.readUntil([')']).$2); + } + if (sc.tryConsume('[[')) { + return (FlowNodeShape.subroutine, sc.readUntil([']]']).$2); + } + if (sc.tryConsume('[(')) { + return (FlowNodeShape.cylinder, sc.readUntil([')]']).$2); + } + if (sc.tryConsume('[/')) { + final (closer, label) = sc.readUntil(['/]', r'\]']); + return ( + closer == '/]' ? FlowNodeShape.leanRight : FlowNodeShape.trapezoid, + label, + ); + } + if (sc.tryConsume(r'[\')) { + final (closer, label) = sc.readUntil([r'\]', '/]']); + return ( + closer == r'\]' ? FlowNodeShape.leanLeft : FlowNodeShape.invTrapezoid, + label, + ); + } + if (sc.tryConsume('[')) { + // SUPERDECK PATCH 1: reject an unquoted `[` inside the label, the way + // mermaid.js's lexer does. `A[Start --> B[Finish]` is a syntax error + // upstream; here it used to become one node labelled + // "Start --> B[Finish". + return (FlowNodeShape.rect, sc.readUntil([']'], rejectUnquoted: '[').$2); + } + if (sc.tryConsume('{{')) { + return (FlowNodeShape.hexagon, sc.readUntil(['}}']).$2); + } + if (sc.tryConsume('{')) { + return (FlowNodeShape.diamond, sc.readUntil(['}']).$2); + } + if (sc.tryConsume('>')) { + return (FlowNodeShape.asymmetric, sc.readUntil([']']).$2); + } + return null; + } + + // --- edges ------------------------------------------------------------------- + + _EdgeSpec? _tryParseEdge(_Scanner sc) { + sc.skipWs(); + // Text-form links: `-- label -->`, `-. label .->`, `== label ==>`. + final textForm = sc.tryMatch(RegExp(r'([ RegExp(r'(-{2,}[-xo>])'), + '-.' => RegExp(r'(\.+-[xo>]?)'), + _ => RegExp(r'(={2,}[=xo>]?)'), + }; + final (label, endToken) = sc.readEdgeText(endPattern); + final spec = _destructEdgeToken(endToken, sc.line); + return _withStartHead(spec, startHead, _normalizeLabel(label), sc.line); + } + + final m = sc.tryMatch(RegExp(r'([])?')); + if (m == null) return null; + final token = m.group(0)!; + final spec = _destructEdgeToken(token, sc.line); + + // Optional `|label|` after the link. + String? label; + sc.skipWs(); + if (sc.tryConsume('|')) { + final (_, raw) = sc.readUntil(['|']); + label = _normalizeLabel(raw); + } + return _EdgeSpec( + stroke: spec.stroke, + headFrom: spec.headFrom, + headTo: spec.headTo, + minLen: spec.minLen, + label: label, + ); + } + + /// Port of upstream flowDb.destructEndLink: classifies an edge token like + /// `-->`, `<-->`, `x-.-x`, `==>`, `~~~` into stroke/heads/length. + _EdgeSpec _destructEdgeToken(String token, int line) { + var str = token.trim(); + var line0 = str.substring(0, str.length - 1); + var headTo = ArrowHead.none; + var headFrom = ArrowHead.none; + + switch (str[str.length - 1]) { + case 'x': + headTo = ArrowHead.cross; + if (str.startsWith('x')) { + headFrom = ArrowHead.cross; + line0 = line0.substring(1); + } + case '>': + headTo = ArrowHead.point; + if (str.startsWith('<')) { + headFrom = ArrowHead.point; + line0 = line0.substring(1); + } + case 'o': + headTo = ArrowHead.circle; + if (str.startsWith('o')) { + headFrom = ArrowHead.circle; + line0 = line0.substring(1); + } + default: + // Open link: the unconditional last-char strip above already gives + // e.g. `---` -> `--` (length 1), matching upstream. + break; + } + + var stroke = EdgeStroke.normal; + var length = line0.length - 1; + if (line0.startsWith('=')) stroke = EdgeStroke.thick; + if (line0.startsWith('~')) stroke = EdgeStroke.invisible; + final dots = '.'.allMatches(line0).length; + if (dots > 0) { + stroke = EdgeStroke.dotted; + length = dots; + } + if (!_validEdgeCore(line0)) { + throw MermaidParseException('malformed link "$token"', line: line); + } + // SUPERDECK PATCH 2: `~~~` is mermaid's invisible link and carries no + // arrow head. `A ~~> B` is a lexical error upstream; here it used to draw + // an invisible edge, so the relationship silently disappeared. + if (line0.startsWith('~') && + (headTo != ArrowHead.none || headFrom != ArrowHead.none)) { + throw MermaidParseException( + 'invisible link "$token" cannot carry an arrow head; use "~~~"', + line: line, + ); + } + return _EdgeSpec( + stroke: stroke, + headFrom: headFrom, + headTo: headTo, + minLen: length, + ); + } + + bool _validEdgeCore(String core) { + // Dotted cores may be a single `.` (from `-. text .-`). + if (RegExp(r'^-?\.+-?$').hasMatch(core)) return true; + if (core.length < 2) return false; + if (RegExp(r'^-+$').hasMatch(core)) return true; + if (RegExp(r'^=+$').hasMatch(core)) return true; + return RegExp(r'^~+$').hasMatch(core); + } + + _EdgeSpec _withStartHead( + _EdgeSpec end, + String? startHead, + String label, + int line, + ) { + var headFrom = end.headFrom; + if (startHead != null) { + headFrom = switch (startHead) { + '<' => ArrowHead.point, + 'x' => ArrowHead.cross, + _ => ArrowHead.circle, + }; + } + return _EdgeSpec( + stroke: end.stroke, + headFrom: headFrom, + headTo: end.headTo, + minLen: end.minLen, + label: label.isEmpty ? null : label, + ); + } + + // --- helpers ------------------------------------------------------------- + + FlowNode _ensureNode(String id) => + nodes.putIfAbsent(id, () => FlowNode(id: id, label: id)); + + bool _isMarkdownLabel(String raw) { + final s = raw.trim(); + if (s.length < 4 || !s.startsWith('"') || !s.endsWith('"')) return false; + final inner = s.substring(1, s.length - 1).trim(); + return inner.length >= 2 && inner.startsWith('`') && inner.endsWith('`'); + } + + String _normalizeLabel(String raw) { + var s = raw.trim(); + if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) { + s = s.substring(1, s.length - 1); + } + // Markdown string syntax: "`text`" — keep the text, drop the backticks. + if (s.length >= 2 && s.startsWith('`') && s.endsWith('`')) { + s = s.substring(1, s.length - 1); + } + s = s.replaceAll(RegExp(r'', caseSensitive: false), '\n'); + final withoutLegacyIcons = s.replaceAll( + RegExp(r'fa[bklrs]?:fa-[\w-]+'), + '', + ); + if (withoutLegacyIcons != s) { + s = withoutLegacyIcons.replaceAll(RegExp(r'[ \t]+'), ' '); + } + return s.trim(); + } +} + +/// Character scanner over a single statement. +class _Scanner { + _Scanner(this.text, this.line); + + final String text; + final int line; + var pos = 0; + + bool get atEnd => pos >= text.length; + + String rest(int n) => text.substring(pos, (pos + n).clamp(0, text.length)); + + void skipWs() { + while (!atEnd && (text[pos] == ' ' || text[pos] == '\t')) { + pos++; + } + } + + bool tryConsume(String s) { + if (text.startsWith(s, pos)) { + pos += s.length; + return true; + } + return false; + } + + Match? tryMatch(RegExp re) { + final m = re.matchAsPrefix(text, pos); + if (m != null) pos = m.end; + return m; + } + + /// Consumes characters while they match [charClass]. + String readWhile(RegExp charClass) { + final start = pos; + while (!atEnd && charClass.hasMatch(text[pos])) { + pos++; + } + return text.substring(start, pos); + } + + static final _idChar = RegExp( + r'[\p{L}\p{N}_!#$%&*+.?\\/' + "'" + r']', + unicode: true, + ); + + /// Mirrors upstream NODE_STRING: `-` only when not followed by `>`, `-` or + /// `.` (so ids may contain dashes without eating links), `=` only when not + /// followed by `=`. + String readNodeId() { + final start = pos; + while (!atEnd) { + final c = text[pos]; + final next = pos + 1 < text.length ? text[pos + 1] : null; + if (c == '-') { + // Dash stays in the id only when it cannot start a link token. + if (next == null || next == '>' || next == '-' || next == '.') break; + pos++; + continue; + } + if (c == '=') { + if (next == '=') break; + pos++; + continue; + } + if (_idChar.hasMatch(c)) { + pos++; + continue; + } + break; + } + return text.substring(start, pos); + } + + /// Reads label content until one of [closers] (longest first), honoring + /// quoted segments. Returns (closer, content). + // SUPERDECK PATCH 1: [rejectUnquoted] names a character that may not appear + // outside quotes inside this label. + (String, String) readUntil(List closers, {String? rejectUnquoted}) { + final sorted = [...closers]..sort((a, b) => b.length.compareTo(a.length)); + final buf = StringBuffer(); + var inQuote = false; + while (!atEnd) { + final c = text[pos]; + if (inQuote) { + buf.write(c); + if (c == '"') inQuote = false; + pos++; + continue; + } + if (rejectUnquoted != null && c == rejectUnquoted) { + throw MermaidParseException( + 'unexpected "$c" inside a node label; quote the label to keep it', + line: line, + ); + } + // Closers win over quote-starts so `"` itself can act as a closer. + for (final closer in sorted) { + if (text.startsWith(closer, pos)) { + pos += closer.length; + return (closer, buf.toString()); + } + } + if (c == '"') { + inQuote = true; + buf.write(c); + pos++; + continue; + } + buf.write(c); + pos++; + } + throw MermaidParseException( + 'unterminated "${closers.first}" label in "$text"', + line: line, + ); + } + + /// For text-form links: reads the label until [endPattern] (which must be + /// preceded by whitespace), consuming the end token. Returns (label, token). + (String, String) readEdgeText(RegExp endPattern) { + final buf = StringBuffer(); + var inQuote = false; + while (!atEnd) { + final c = text[pos]; + if (inQuote) { + buf.write(c); + if (c == '"') inQuote = false; + pos++; + continue; + } + if (c == '"') { + inQuote = true; + buf.write(c); + pos++; + continue; + } + if (c == ' ' || c == '\t') { + final after = pos + 1; + final m = endPattern.matchAsPrefix(text, _skipWsFrom(after)); + if (m != null) { + pos = m.end; + return (buf.toString(), m.group(1)!); + } + } + buf.write(c); + pos++; + } + throw MermaidParseException( + 'unterminated link text in "$text"', + line: line, + ); + } + + int _skipWsFrom(int i) { + while (i < text.length && (text[i] == ' ' || text[i] == '\t')) { + i++; + } + return i; + } +} diff --git a/packages/superdeck/pubspec.yaml b/packages/superdeck/pubspec.yaml index eff23af0..5f29cc26 100644 --- a/packages/superdeck/pubspec.yaml +++ b/packages/superdeck/pubspec.yaml @@ -25,7 +25,8 @@ dependencies: mix_annotations: ^2.2.0-beta.0 remix: ^1.0.0-beta.1 flutter_markdown_plus: ^1.0.5 - flutter_mermaid: ^0.1.0 + mermaid_core: 0.3.0 + mermaid_flutter: 0.3.0 superdeck_core: ^1.0.0 markdown: ^7.3.0 web: ^1.1.0 diff --git a/packages/superdeck/test/src/markdown/mermaid_code_block_test.dart b/packages/superdeck/test/src/markdown/mermaid_code_block_test.dart index 5d121eb8..575af4d5 100644 --- a/packages/superdeck/test/src/markdown/mermaid_code_block_test.dart +++ b/packages/superdeck/test/src/markdown/mermaid_code_block_test.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; -import 'package:flutter_mermaid/flutter_mermaid.dart'; +import 'package:mermaid_flutter/mermaid_flutter.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:superdeck/superdeck.dart' show SlideParts; +import 'package:superdeck/superdeck.dart' + show SlideCaptureReadiness, SlideParts; import 'package:superdeck/src/markdown/builders/mermaid_code_block.dart'; import 'package:superdeck_core/superdeck_core.dart'; @@ -26,7 +27,13 @@ graph TD final diagram = tester.widget( find.byType(MermaidDiagram), ); - expect(diagram.style?.backgroundColor, 0x00000000); + expect(diagram.theme.background.value, 0x00000000); + expect( + diagram.keepLastGoodSceneOnError, + isFalse, + reason: 'a slide must not export a stale diagram', + ); + expect(diagram.semanticNodes, isTrue); }); testWidgets('uses legible Mermaid colors with a dark app theme', ( @@ -50,9 +57,14 @@ graph LR final diagram = tester.widget( find.byType(MermaidDiagram), ); - expect(diagram.style?.themeMode, MermaidThemeMode.dark); - expect(diagram.style?.backgroundColor, 0x00000000); - expect(diagram.style?.defaultEdgeStyle.labelColor, 0xFFE0E0E0); + final dark = ThemeData.dark().colorScheme; + expect(diagram.theme.background.value, 0x00000000); + expect( + diagram.theme.primaryColor.value, + dark.primaryContainer.toARGB32(), + reason: 'diagram fills follow the app colour scheme', + ); + expect(diagram.theme.primaryTextColor.value, dark.onPrimaryContainer.toARGB32()); }); testWidgets('keeps non-Mermaid fences on the code rendering path', ( @@ -140,6 +152,10 @@ graph TD of: mermaidFinder, matching: find.byType(CustomPaint), ); + final fittedFinder = find.ancestor( + of: mermaidFinder, + matching: find.byType(FittedBox), + ); expect(mermaidFinder, findsOneWidget); expect(paintFinder, findsOneWidget); @@ -148,31 +164,96 @@ graph TD findsNothing, ); - final diagramSize = tester.getSize(mermaidFinder); + // The diagram keeps its natural scene size and is scaled down to + // the width its Markdown block was given. Height flows like the + // rest of the block's content. + final shownSize = tester.getSize(fittedFinder.first); final paintSize = tester.getSize(paintFinder); - expect(diagramSize.width, greaterThan(0)); - expect(diagramSize.width, lessThanOrEqualTo(resolution.width)); + expect(shownSize.width, greaterThan(0)); + expect(shownSize.width, lessThanOrEqualTo(resolution.width)); expect(paintSize.width, greaterThan(0)); expect(paintSize.height, greaterThan(0)); - expect(paintSize.width, lessThanOrEqualTo(diagramSize.width)); expect(tester.takeException(), isNull); }); } } - testWidgets('shows an inline error for unsupported diagram syntax', ( + testWidgets('shows an inline error for a diagram it cannot parse', ( tester, ) async { await SlideTestHarness.pumpSlide( tester, _slideWithFence(''' -classDiagram - class Animal +graph TD + A[Start] --> B[End] + this line is prose, not a statement +'''), + resolution: const Size(800, 600), + ); + + // Rejected before the renderer sees it, so nothing is drawn at all. + expect(find.byType(MermaidDiagram), findsNothing); + expect( + find.textContaining('Unable to render Mermaid diagram'), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('a rendered diagram releases the capture wait', (tester) async { + final readiness = SlideCaptureReadiness(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: readiness.bind( + const MermaidCodeBlock(code: 'graph TD\n A --> B\n'), + ), + ), + ), + ); + await tester.pump(); + + expect(readiness.isReady, isTrue); + expect(readiness.failures, isEmpty); + }); + + testWidgets('a diagram that cannot render fails the capture wait', ( + tester, + ) async { + final readiness = SlideCaptureReadiness(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: readiness.bind( + const MermaidCodeBlock( + code: 'graph TD\n A --> B\n this is prose\n', + ), + ), + ), + ), + ); + await tester.pump(); + + // Export stops on this slide instead of writing a page without it. + expect(readiness.isReady, isTrue); + expect(readiness.failures, hasLength(1)); + expect(readiness.failures.single.label, startsWith('mermaid:')); + }); + + testWidgets('names an unknown diagram type instead of drawing nothing', ( + tester, + ) async { + await SlideTestHarness.pumpSlide( + tester, + _slideWithFence(''' +notADiagramType + A --> B '''), resolution: const Size(800, 600), ); - expect(find.byType(MermaidDiagram), findsOneWidget); expect( find.textContaining('Unable to render Mermaid diagram'), findsOneWidget, @@ -198,6 +279,35 @@ ${source.trim()} } const _supportedDiagrams = { + 'class diagram': ''' +classDiagram + Animal <|-- Duck + Animal : +int age + Animal : +isMammal() + class Duck { + +String beakColor + +swim() + } +''', + 'state diagram': ''' +stateDiagram-v2 + [*] --> Draft + Draft --> Review: submit + Review --> Draft: changes + Review --> [*]: approve +''', + 'entity relationship diagram': ''' +erDiagram + DECK ||--o{ SLIDE : contains + SLIDE ||--o{ BLOCK : holds +''', + 'user journey': ''' +journey + title Preparing a talk + section Draft + Outline the story: 4: Speaker + Write the slides: 3: Speaker +''', 'flowchart': ''' graph TD A[Start] --> B{Ready?} diff --git a/packages/superdeck/test/src/markdown/mermaid_corpus_test.dart b/packages/superdeck/test/src/markdown/mermaid_corpus_test.dart new file mode 100644 index 00000000..efebe17f --- /dev/null +++ b/packages/superdeck/test/src/markdown/mermaid_corpus_test.dart @@ -0,0 +1,69 @@ +@TestOn('vm') +library; + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mermaid_core/mermaid_core.dart'; +import 'package:superdeck/src/markdown/mermaid/mermaid_source_check.dart'; + +/// Every Mermaid diagram SuperDeck publishes, rendered for real. +/// +/// The corpus is read from the files that make the promise — the guide a +/// reader follows and the demo deck they can run — so a diagram cannot be +/// documented without being renderable, and the renderer cannot be changed +/// without noticing which documented diagram it broke. +void main() { + final sources = { + for (final path in const [ + '../../docs/guides/mermaid-diagrams.mdx', + '../../demo/slides.md', + '../../.agents/skills/superdeck-presentations/references/' + 'runtime-customization.md', + ]) + ...(_fencesIn(File(path))), + }; + + test('the corpus is the documented one', () { + // Guards against a silent emptying of the corpus if a path moves. + expect(sources, hasLength(greaterThanOrEqualTo(12))); + }); + + group('every documented diagram renders', () { + for (final entry in sources.entries) { + test(entry.key, () { + expect( + () => checkMermaidSource(entry.value), + returnsNormally, + reason: entry.value, + ); + + final scene = Mermaid( + measurer: const ApproximateTextMeasurer(), + ).render(entry.value); + + expect(scene.nodes, isNotEmpty, reason: entry.value); + expect(scene.size.width, greaterThan(0)); + expect(scene.size.height, greaterThan(0)); + }); + } + }); +} + +/// Extracts ```` ```mermaid ```` fences, keyed by file and first line. +Map _fencesIn(File file) { + if (!file.existsSync()) { + fail('Mermaid corpus file is missing: ${file.path}'); + } + final pattern = RegExp(r'```mermaid\n(.*?)```', dotAll: true); + final name = file.uri.pathSegments.last; + final found = {}; + var index = 0; + for (final match in pattern.allMatches(file.readAsStringSync())) { + final source = match.group(1)!; + final first = source.trim().split('\n').first.trim(); + found['$name #${++index} ($first)'] = source; + } + + return found; +} diff --git a/packages/superdeck/test/src/markdown/mermaid_strict_flowchart_test.dart b/packages/superdeck/test/src/markdown/mermaid_strict_flowchart_test.dart new file mode 100644 index 00000000..7a3480a0 --- /dev/null +++ b/packages/superdeck/test/src/markdown/mermaid_strict_flowchart_test.dart @@ -0,0 +1,136 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mermaid_core/mermaid_core.dart'; +import 'package:mermaid_flutter/mermaid_flutter.dart'; +import 'package:superdeck/src/markdown/builders/mermaid_code_block.dart'; +import 'package:superdeck/src/markdown/mermaid/mermaid_source_check.dart'; + +/// The two inputs mermaid.js rejects and mermaid_core 0.3.0 draws anyway. +/// +/// Ground truth recorded against mermaid.js 12.0.0 via `mermaid.parse`: +/// both are syntax errors there. Upstream Dart renders one node labelled +/// "Start --> B[Finish" for the first, and two nodes with an invisible edge +/// for the second. +const _unbalancedBracket = 'graph TD\n A[Start --> B[Finish]\n'; +const _tildeArrow = 'graph TD\n A ~~> B\n'; + +void main() { + group('strict flowchart check', () { + test('rejects an unquoted bracket inside a node label', () { + expect( + () => checkMermaidSource(_unbalancedBracket), + throwsA( + isA().having( + (error) => error.line, + 'line', + 2, + ), + ), + ); + }); + + test('rejects a tilde link carrying an arrow head', () { + expect( + () => checkMermaidSource(_tildeArrow), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('~~~'), + ), + ), + ); + }); + + test('keeps a quoted bracket, which mermaid.js accepts', () { + expect( + () => checkMermaidSource('graph TD\n A["array [0]"] --> B\n'), + returnsNormally, + ); + }); + + test('keeps the invisible link mermaid.js does define', () { + expect( + () => checkMermaidSource('graph TD\n A ~~~ B\n'), + returnsNormally, + ); + }); + + test('accepts representative flowchart shapes, styles and directives', () { + const sources = [ + 'flowchart TB\n subgraph one\n A --> B\n end\n B --> C\n', + 'flowchart TD\n A([round]) --> B[[sub]] --> C[(db)]\n' + ' C --> D((circle)) --> E{{hex}} --> F[/para/]\n', + 'graph LR\n A[🚀 Launch] --> B[✅ Done]\n', + 'graph LR\n A["`**bold** label`"] --> B\n', + 'graph LR\n A --> B\n classDef hot fill:#f96\n class A hot\n', + 'graph LR\n A --> B\n linkStyle 0 stroke:#f00\n', + 'graph LR\n A[Open] --> B\n click A "https://example.com" "Tip"\n', + '%%{init: {"flowchart": {"defaultRenderer": "elk"}}}%%\n' + 'flowchart TD\n A --> B\n', + '---\nconfig:\n look: handDrawn\n---\nflowchart LR\n A --> B\n', + 'graph LR\n %% a comment\n A -- label --> B\n', + 'graph LR\n A -.-> B ==> C\n', + ]; + + for (final source in sources) { + expect( + () => checkMermaidSource(source), + returnsNormally, + reason: source, + ); + } + }); + + test('leaves other diagram families to the renderer', () { + // A sequence diagram the strict flowchart parser would not understand. + expect( + () => checkMermaidSource('sequenceDiagram\n A->>B: hi\n'), + returnsNormally, + ); + }); + }); + + group('the slide reports what it cannot draw', () { + Future pumpDiagram(WidgetTester tester, String code) { + return tester.pumpWidget( + MaterialApp(home: Scaffold(body: MermaidCodeBlock(code: code))), + ); + } + + testWidgets('an unbalanced bracket is reported, not drawn', (tester) async { + await pumpDiagram(tester, _unbalancedBracket); + await tester.pump(); + + expect(find.byType(MermaidDiagram), findsNothing); + expect( + find.textContaining('Unable to render Mermaid diagram'), + findsOneWidget, + ); + }); + + testWidgets('a tilde arrow is reported, not silently invisible', ( + tester, + ) async { + await pumpDiagram(tester, _tildeArrow); + await tester.pump(); + + expect(find.byType(MermaidDiagram), findsNothing); + expect( + find.textContaining('Unable to render Mermaid diagram'), + findsOneWidget, + ); + }); + + testWidgets('a valid diagram still draws', (tester) async { + await pumpDiagram(tester, 'graph TD\n A[Start] --> B[End]\n'); + await tester.pump(); + + expect(find.byType(MermaidDiagram), findsOneWidget); + expect( + find.textContaining('Unable to render Mermaid diagram'), + findsNothing, + ); + }); + }); +} diff --git a/pubspec.lock b/pubspec.lock index 7a7261b0..a89ecd1f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -473,6 +473,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.0" + elk: + dependency: transitive + description: + name: elk + sha256: "4a3ad94128a1b9128fed5881ff7dcfe72054b5b92f5f9dcc070be16f8b8eaa32" + url: "https://pub.dev" + source: hosted + version: "0.2.0" email_validator: dependency: transitive description: @@ -571,14 +579,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.4" - flutter_mermaid: - dependency: transitive - description: - name: flutter_mermaid - sha256: "8830adaacf36559da5811fa08234a821b4199398507447529b975ca0c1dea6c7" - url: "https://pub.dev" - source: hosted - version: "0.1.0" flutter_shaders: dependency: transitive description: @@ -896,6 +896,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.14.1" + katex_dart: + dependency: transitive + description: + name: katex_dart + sha256: fe692a9ae1530e02eb358e0a4b5b3e5ad5f1618dedb9a252f6571eeb6227fb1e + url: "https://pub.dev" + source: hosted + version: "0.1.0" leak_tracker: dependency: transitive description: @@ -992,6 +1000,22 @@ packages: url: "https://pub.dev" source: hosted version: "7.8.1" + mermaid_core: + dependency: transitive + description: + name: mermaid_core + sha256: "8137f28d430f41de87247ac4f14af80a8b1b768cfbd1382d432615975bf03e2e" + url: "https://pub.dev" + source: hosted + version: "0.3.0" + mermaid_flutter: + dependency: transitive + description: + name: mermaid_flutter + sha256: "2ada823e866a83c2cd63936e9ffc59e4ef85bfb55e70a0fc856ab833051b1ce6" + url: "https://pub.dev" + source: hosted + version: "0.3.0" mesh: dependency: transitive description: