From a0889289438b8629639a3a951199431b236937c5 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 21 Sep 2026 13:22:24 -0400 Subject: [PATCH 1/4] feat(superdeck): render Mermaid with mermaid_core and mermaid_flutter Replaces `flutter_mermaid` 0.1.0 with `mermaid_flutter` 0.3.0 and its pure-Dart `mermaid_core`, keeping `MermaidCodeBlock` as the only place the renderer is named. What changes for a deck: - Class, state, entity-relationship and user-journey diagrams render again. They were documented before the build plugin was removed and have been unsupported since. Mind maps, Git graphs, C4, quadrant and the rest of the port's 28 families come with them. - A diagram that cannot be parsed reports the line it failed on, instead of "Unable to parse diagram" with no location. - `%%{init: ...}%%` directives and frontmatter configuration are honoured. - Diagram colours are derived from the app's Material colour scheme rather than a fixed light/dark preset, over a transparent background. - Diagram nodes carry semantics, so a diagram is not an unlabelled picture to a screen reader or to browser automation. Capture and export: - `keepLastGoodSceneOnError` is off. The package keeps the previous drawing on a parse error, which suits a live editor; a slide is a finished artefact, so it shows the error instead of a stale diagram. - A failed diagram reports `SlideCaptureReadiness.fail`, so a PDF export stops and names the slide rather than writing a page without it. - The diagram scales down to the width its Markdown block was given and keeps its natural aspect ratio. The demo deck gains a Mermaid slide, so the path is exercised by the demo, the integration test and the browser smoke run rather than only by unit tests. --- .../references/runtime-customization.md | 4 +- demo/.superdeck/build_status.json | 4 +- demo/.superdeck/superdeck.json | 41 ++++++ demo/.superdeck/superdeck_full.json | 111 ++++++++++++++ demo/slides.md | 26 ++++ docs/guides/mermaid-diagrams.mdx | 77 ++++++---- .../markdown/builders/mermaid_code_block.dart | 128 +++++++++++++++-- packages/superdeck/pubspec.yaml | 3 +- .../src/markdown/mermaid_code_block_test.dart | 135 ++++++++++++++++-- pubspec.lock | 40 ++++-- 10 files changed, 505 insertions(+), 64 deletions(-) diff --git a/.agents/skills/superdeck-presentations/references/runtime-customization.md b/.agents/skills/superdeck-presentations/references/runtime-customization.md index fe93fcd1..7e7fe53e 100644 --- a/.agents/skills/superdeck-presentations/references/runtime-customization.md +++ b/.agents/skills/superdeck-presentations/references/runtime-customization.md @@ -403,7 +403,9 @@ 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. Flowchart, +sequence, class, state, ER, journey, chart, timeline and Git-graph families all +render; a diagram that cannot be parsed 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..966eab83 100644 --- a/docs/guides/mermaid-diagrams.mdx +++ b/docs/guides/mermaid-diagrams.mdx @@ -24,19 +24,19 @@ of the Flutter widget tree for thumbnails, screenshots, and PDF export. ## Supported diagram types -SuperDeck supports: +SuperDeck renders every diagram family the Dart Mermaid port supports, +including: -- flowcharts -- sequence diagrams -- pie charts -- Gantt charts -- timelines -- Kanban boards -- radar charts -- XY charts +- 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 -Class, state, journey, entity-relationship, mind map, Git graph, and other -Mermaid diagram families are not supported. +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. Anything that is rejected says so on the slide, with the line +it failed on — nothing is dropped silently. ## Flowcharts @@ -128,26 +128,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 +184,7 @@ 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: when the two disagree, the slide shows the error rather +than a half-drawn diagram. 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..4482bd83 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,128 @@ 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 '../../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(); + } + @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(); + + 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/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..a6c01189 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,26 +164,29 @@ 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), ); @@ -179,6 +198,67 @@ classDiagram ); 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.textContaining('Unable to render Mermaid diagram'), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + }); }); } @@ -198,6 +278,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/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: From 56917b17f83260a5b020a6d5a84214c4f752cd0d Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 21 Sep 2026 13:26:53 -0400 Subject: [PATCH 2/4] fix(superdeck): reject the two Mermaid inputs the port draws incompletely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mermaid_core 0.3.0 accepts two flowchart inputs that mermaid.js 12 rejects, and renders something plausible instead of reporting them: graph TD A[Start --> B[Finish] one node labelled "Start --> B[Finish" A ~~> B two nodes, the edge drawn invisible Both lose part of the author's diagram without saying so. A slide cannot afford that, so `checkMermaidSource` runs before rendering and reports them with the failing line. The check is upstream's own flowchart parser, vendored verbatim under `markdown/mermaid/vendor/` with its MIT licence and the source commit, and tightened in exactly the two marked places. Using their grammar rather than a SuperDeck pattern matcher is what keeps quoted labels (`A["array [0]"]`), the real invisible link (`~~~`), subgraphs, shapes, `classDef`, `linkStyle`, `click`, init directives and frontmatter working — all asserted. Other diagram families are left to the renderer, which already reports what it cannot parse. Adds a corpus test that renders every Mermaid fence in the guide, the demo deck and the skills reference, so a documented diagram cannot stop rendering unnoticed. --- .../markdown/builders/mermaid_code_block.dart | 24 + .../mermaid/mermaid_source_check.dart | 21 + .../lib/src/markdown/mermaid/vendor/LICENSE | 35 + .../vendor/flowchart_strict_parser.dart | 1189 +++++++++++++++++ .../src/markdown/mermaid_code_block_test.dart | 3 +- .../src/markdown/mermaid_corpus_test.dart | 69 + .../mermaid_strict_flowchart_test.dart | 136 ++ 7 files changed, 1476 insertions(+), 1 deletion(-) create mode 100644 packages/superdeck/lib/src/markdown/mermaid/mermaid_source_check.dart create mode 100644 packages/superdeck/lib/src/markdown/mermaid/vendor/LICENSE create mode 100644 packages/superdeck/lib/src/markdown/mermaid/vendor/flowchart_strict_parser.dart create mode 100644 packages/superdeck/test/src/markdown/mermaid_corpus_test.dart create mode 100644 packages/superdeck/test/src/markdown/mermaid_strict_flowchart_test.dart 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 4482bd83..cf170019 100644 --- a/packages/superdeck/lib/src/markdown/builders/mermaid_code_block.dart +++ b/packages/superdeck/lib/src/markdown/builders/mermaid_code_block.dart @@ -3,6 +3,7 @@ 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'; /// Renders a ```` ```mermaid ```` fence as a painted diagram. @@ -79,11 +80,34 @@ class _MermaidCodeBlockState extends State { 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) { _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( 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/test/src/markdown/mermaid_code_block_test.dart b/packages/superdeck/test/src/markdown/mermaid_code_block_test.dart index a6c01189..575af4d5 100644 --- a/packages/superdeck/test/src/markdown/mermaid_code_block_test.dart +++ b/packages/superdeck/test/src/markdown/mermaid_code_block_test.dart @@ -191,7 +191,8 @@ graph TD resolution: const Size(800, 600), ); - expect(find.byType(MermaidDiagram), findsOneWidget); + // 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, 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..e409905f --- /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 every flowchart shape, style and directive form', () { + 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, + ); + }); + }); +} From b32aa49f66a6411694c460f675728e6f755d20ab Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 21 Sep 2026 13:27:48 -0400 Subject: [PATCH 3/4] docs(superdeck): accept ADR 0001 on runtime Mermaid rendering Moves the decision record into `docs/maintainers/adr/`, marks it Accepted, and replaces the pre-decision spike plan with what was actually delivered, including the vendored two-case patch and what the delivery deliberately did not do. Drops the assumptions about a live editor: the playground's file-backed editor is gone, so keeping the last good scene while an author types is no longer a SuperDeck use case, and the four concerns are restated as runtime diagrams, deck previews, derived thumbnails, and the deck-owned artwork of a saved deck. --- .../adr/0001-runtime-mermaid-rendering.md | 344 ++++++++++++++++++ 1 file changed, 344 insertions(+) create mode 100644 docs/maintainers/adr/0001-runtime-mermaid-rendering.md 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. From 6c56e800c24a36e5b1ef3ac81414daa180210420 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 21 Sep 2026 16:37:39 -0400 Subject: [PATCH 4/4] docs(superdeck): clarify Mermaid compatibility --- .../references/runtime-customization.md | 7 ++++--- docs/guides/mermaid-diagrams.mdx | 11 +++++++---- .../src/markdown/mermaid_strict_flowchart_test.dart | 2 +- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/.agents/skills/superdeck-presentations/references/runtime-customization.md b/.agents/skills/superdeck-presentations/references/runtime-customization.md index 7e7fe53e..fef3aaf4 100644 --- a/.agents/skills/superdeck-presentations/references/runtime-customization.md +++ b/.agents/skills/superdeck-presentations/references/runtime-customization.md @@ -403,9 +403,10 @@ SuperDeckApp( ``` Mermaid diagrams render directly from fenced `mermaid` blocks. They do not -require a plugin, custom runner, browser, generated image, or cache. Flowchart, -sequence, class, state, ER, journey, chart, timeline and Git-graph families all -render; a diagram that cannot be parsed shows the failing line on the slide: +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/docs/guides/mermaid-diagrams.mdx b/docs/guides/mermaid-diagrams.mdx index 966eab83..8bc65199 100644 --- a/docs/guides/mermaid-diagrams.mdx +++ b/docs/guides/mermaid-diagrams.mdx @@ -35,8 +35,10 @@ including: 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. Anything that is rejected says so on the slide, with the line -it failed on — nothing is dropped silently. +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 @@ -186,5 +188,6 @@ When a diagram shows an error: 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: when the two disagree, the slide shows the error rather -than a half-drawn diagram. +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/packages/superdeck/test/src/markdown/mermaid_strict_flowchart_test.dart b/packages/superdeck/test/src/markdown/mermaid_strict_flowchart_test.dart index e409905f..7a3480a0 100644 --- a/packages/superdeck/test/src/markdown/mermaid_strict_flowchart_test.dart +++ b/packages/superdeck/test/src/markdown/mermaid_strict_flowchart_test.dart @@ -56,7 +56,7 @@ void main() { ); }); - test('accepts every flowchart shape, style and directive form', () { + 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'