Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions packages/site_shared/lib/_sass/components/_mermaid.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Copyright 2026 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

.mermaid-container {
display: flex;
justify-content: center;
margin: 1.75rem 0;
padding: 1.5rem 1rem;
overflow-x: auto;
background-color: var(--site-raised-bgColor-translucent);
border: 1px solid var(--site-outline-variant);
border-radius: var(--site-radius);

// Fallback while loading or during SSR
pre.mermaid {
margin: 0;
padding: 0;
background: transparent;
border: none;
font-family: var(--site-code-fontFamily);
}

// Rendered SVG styling
// You must use !important to override styles for specific elements within
// the rendered SVG. For styling individual graphs, use Mermaid's built-in
// classRef system
svg {
max-width: 100%;
height: auto;

// Use Google Sans Flex for diagram labels
text,
.label,
.nodeLabel,
.edgeLabel {
font-family: var(--site-ui-fontFamily), sans-serif !important;
}

// Sharper node outlines matching site borders
.node rect,
.node circle,
.node ellipse,
.node polygon,
.node path {
stroke-width: 1.5px;
fill: var(--site-secondaryContainer-bgColor) !important;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll update these base styles to match our themes once I know whether this entire approach is reasonable.


// Edge lines & arrows
.edgePath .path {
stroke-width: 1.5px;
}
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than using this as a client dependency that listens for theme changes, can this instead be a server component that renders both versions up front and switches between them with css? That way we can drop the complicated theming logic, avoid shipping the renderer to the client, and improve page load time.

I imagine the component implementation could look something like this:

/// Renders a Mermaid diagram as server-generated SVG.
///
/// Both light and dark variants are included in the page
/// so that CSS can show the variant matching the site's current theme.
final class MermaidDiagram extends StatelessComponent {
  const MermaidDiagram({
    required this.diagram,
    super.key,
  });

  /// The Mermaid diagram definition to render.
  final String diagram;

  @override
  Component build(BuildContext context) {
    // Render both theme variants on the server.
    final lightSvg = _renderDiagram(theme: .defaultTheme);
    final darkSvg = _renderDiagram(theme: .darkTheme);

    return div(
      classes: 'mermaid-container',
      [
        if (lightSvg != null && darkSvg != null) ...[
          div(classes: 'mermaid-theme mermaid-theme-light', [
            RawText(lightSvg),
          ]),
          div(classes: 'mermaid-theme mermaid-theme-dark', [RawText(darkSvg)]),
        ] else
          // If rendering fails,
          // preserve the source in a fallback `<pre>` element.
          pre(
            classes: 'mermaid',
            attributes: {'data-source': diagram},
            [.text(diagram)],
          ),
      ],
    );
  }

  /// Renders [diagram] as SVG using the specified [theme].
  String? _renderDiagram({required MermaidTheme theme}) {
    try {
      // Server rendering doesn't have browser text metrics available, so use
      // the library's deterministic approximation when laying out labels.
      final mermaid = Mermaid(
        measurer: const ApproximateTextMeasurer(),
        theme: theme,
      );
      final scene = mermaid.render(diagram);
      return renderSceneToSvg(scene);
    } catch (error) {
      if (kDebugMode) {
        print('Failed to render Mermaid diagram: $error');
      }
      return null;
    }
  }
}

And the styles could then hide the other depending on the theme. Something like this:

.mermaid-container {
  // ...

  .mermaid-theme-dark {
    display: none;
  }

  @at-root body.dark-mode & {
    .mermaid-theme-light {
      display: none;
    }

    .mermaid-theme-dark {
      display: block;
    }
  }

  // ...
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import 'package:jaspr/dom.dart';
import 'package:jaspr/jaspr.dart';
import 'package:mermaid_core/mermaid_core.dart';
import 'package:universal_web/js_interop.dart';
import 'package:universal_web/web.dart' as web;

@client
final class MermaidViewer extends StatefulComponent {
const MermaidViewer({required this.diagram, super.key});

final String diagram;

@override
State<MermaidViewer> createState() => _MermaidViewerState();
}

final class _MermaidViewerState extends State<MermaidViewer> {
String? _svg;
web.MutationObserver? _themeObserver;

@override
void initState() {
super.initState();
final isDark =
kIsWeb && (web.document.body?.classList.contains('dark-mode') ?? false);
_svg = _renderDiagram(isDark: isDark);

if (kIsWeb) {
_observeTheme();
}
}

@override
void didUpdateComponent(MermaidViewer oldComponent) {
super.didUpdateComponent(oldComponent);
if (oldComponent.diagram != component.diagram) {
final isDark =
kIsWeb &&
(web.document.body?.classList.contains('dark-mode') ?? false);
_svg = _renderDiagram(isDark: isDark);
}
}

Comment thread
ericwindmill marked this conversation as resolved.
@override
void dispose() {
_themeObserver?.disconnect();
super.dispose();
}

void _observeTheme() {
final body = web.document.body;
if (body == null) return;

var isDark = body.classList.contains('dark-mode');
_themeObserver = web.MutationObserver(
((JSArray<web.MutationRecord> _, web.MutationObserver _) {
final newIsDark = body.classList.contains('dark-mode');
if (newIsDark != isDark) {
isDark = newIsDark;
setState(() {
_svg = _renderDiagram(isDark: isDark);
});
}
}).toJS,
);

_themeObserver?.observe(
body,
web.MutationObserverInit(
attributes: true,
attributeFilter: ['class'.toJS].toJS,
),
);
}

String? _renderDiagram({required bool isDark}) {
try {
final theme =
isDark ? MermaidTheme.darkTheme : MermaidTheme.defaultTheme;
final mermaid = Mermaid(
measurer: const ApproximateTextMeasurer(),
theme: theme,
);
final scene = mermaid.render(component.diagram);
return renderSceneToSvg(scene);
} catch (e) {
if (kDebugMode) {
print('Failed to render Mermaid diagram: $e');
}
return null;
}
}

@override
Component build(BuildContext context) {
return div(
classes: 'mermaid-container',
[
if (_svg case final svg?)
RawText(svg)
else
// Fallback during SSR or while loading
pre(
classes: 'mermaid',
attributes: {'data-source': component.diagram},
[.text(component.diagram)],
),
],
);
}
}
1 change: 1 addition & 0 deletions packages/site_shared/lib/page_extensions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ export 'src/extensions/attribute_processor.dart';
export 'src/extensions/code_block_processor.dart';
export 'src/extensions/header_extractor.dart';
export 'src/extensions/header_processor.dart';
export 'src/extensions/mermaid_processor.dart';
export 'src/extensions/table_processor.dart';
34 changes: 34 additions & 0 deletions packages/site_shared/lib/src/extensions/mermaid_processor.dart

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add license headers to these Dart files.

Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import 'package:jaspr_content/jaspr_content.dart';

import '../../components/common/client/mermaid_diagram.dart';

final class MermaidProcessor implements PageExtension {
const MermaidProcessor();

@override
Future<List<Node>> apply(Page page, List<Node> nodes) async =>
_processNodes(nodes);

List<Node> _processNodes(List<Node> nodes) {
return [
for (final node in nodes)
if (node case ElementNode(
tag: 'div',
attributes: {'class': 'mermaid-container'},
children: [
ElementNode(attributes: {'data-source': final diagram}),
...,
],
))
ComponentNode(MermaidViewer(diagram: diagram))
else if (node is ElementNode)
ElementNode(
node.tag,
node.attributes,
node.children != null ? _processNodes(node.children!) : null,
)
else
node,
];
}
}
2 changes: 2 additions & 0 deletions packages/site_shared/lib/src/markdown/markdown_parser.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@ import 'alert_syntax.dart';
import 'attribute_syntax.dart';
import 'fenced_code_block_syntax.dart';
import 'header_syntax.dart';
import 'mermaid_syntax.dart';

/// The `package:markdown` block syntaxes to apply when parsing Markdown.
const List<md.BlockSyntax> _blockSyntaxes = [
JasprHtmlBlockSyntax(),
MermaidBlockSyntax(),
CustomFencedCodeBlockSyntax(),
HeaderWithAttributesSyntax(),
AttributeBlockSyntax(),
Expand Down
59 changes: 59 additions & 0 deletions packages/site_shared/lib/src/markdown/mermaid_syntax.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import 'package:markdown/markdown.dart' as md;

/// A custom Markdown block syntax for diagrams authored
/// between ```mermaid code fences.
///
/// Example:
///
/// ````markdown
/// ```mermaid
/// flowchart TD
/// A --> B
/// ```
/// ````
///
/// This renders as a `<div class="mermaid-container">` containing
/// a `<pre class="mermaid">` element that hydrates on the client
/// via [MermaidViewer].
final class MermaidBlockSyntax extends md.BlockSyntax {
const MermaidBlockSyntax();

// Matches opening fence: ```mermaid (with optional trailing whitespace/config)
@override
RegExp get pattern => RegExp(r'^\s{0,3}`{3,}mermaid(?:\s.*)?$');

static final _closingFencePattern = RegExp(r'^\s{0,3}`{3,}\s*$');

@override
bool canParse(md.BlockParser parser) {
return pattern.hasMatch(parser.current.content);
}

@override
md.Node? parse(md.BlockParser parser) {
// Advance past the opening ```mermaid line
parser.advance();

final lines = <String>[];

// Collect diagram definition until the closing ```
while (!parser.isDone) {
final line = parser.current.content;
if (_closingFencePattern.hasMatch(line)) {
parser.advance(); // Consume closing fence
break;
}
lines.add(line);
parser.advance();
}

final rawContent = lines.join('\n');

// Return HTML AST node for the diagram container
final pre = md.Element.text('pre', rawContent)
..attributes['class'] = 'mermaid'
..attributes['data-source'] = rawContent;

return md.Element('div', [pre])..attributes['class'] = 'mermaid-container';
}
}
5 changes: 5 additions & 0 deletions packages/site_shared/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ dependencies:
jaspr_content: ^0.5.3
markdown: ^7.3.1
markdown_description_list: ^0.2.0
mermaid_core:
git:
url: https://github.com/orestesgaolin/mermaid.git
ref: round3-fixes-and-packaging

@parlough parlough Aug 20, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any particular reason you're relying on this branch over the main branch? Seems like main contains additional updates.

If we're going to use it as a git dependency for now, pin it to a specific commit (like ddb951169592bf089518ba19dbf3bf7ffa8d3c35 from the current main branch).

path: packages/mermaid_core
meta: ^1.18.2
nanoid2: ^2.0.1
opal: ^0.2.4
Expand Down
1 change: 1 addition & 0 deletions sites/docs/lib/_sass/_site.scss
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
@use 'package:site_shared/_sass/components/cookie-notice';
@use 'package:site_shared/_sass/components/dropdown';
@use 'package:site_shared/_sass/components/menu-toggle';
@use 'package:site_shared/_sass/components/mermaid';
@use 'package:site_shared/_sass/components/progress-ring';
@use 'package:site_shared/_sass/components/quiz';
@use 'package:site_shared/_sass/components/site-switcher';
Expand Down
6 changes: 6 additions & 0 deletions sites/docs/lib/main.client.options.dart
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import 'package:site_shared/components/common/client/download_button.dart'
deferred as _download_button;
import 'package:site_shared/components/common/client/feedback.dart'
deferred as _feedback;
import 'package:site_shared/components/common/client/mermaid_diagram.dart'
deferred as _mermaid_diagram;
import 'package:site_shared/components/common/client/on_this_page_button.dart'
deferred as _on_this_page_button;
import 'package:site_shared/components/common/client/page_header_options.dart'
Expand Down Expand Up @@ -140,6 +142,10 @@ ClientOptions get defaultClientOptions => ClientOptions(
(p) => _feedback.FeedbackComponent(issueUrl: p['issueUrl'] as String),
loader: _feedback.loadLibrary,
),
'site_shared:mermaid_diagram': ClientLoader(
(p) => _mermaid_diagram.MermaidViewer(diagram: p['diagram'] as String),
loader: _mermaid_diagram.loadLibrary,
),
'site_shared:on_this_page_button': ClientLoader(
(p) => _on_this_page_button.OnThisPageButton(),
loader: _on_this_page_button.loadLibrary,
Expand Down
10 changes: 10 additions & 0 deletions sites/docs/lib/main.server.options.dart
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import 'package:site_shared/components/common/client/download_button.dart'
as _download_button;
import 'package:site_shared/components/common/client/feedback.dart'
as _feedback;
import 'package:site_shared/components/common/client/mermaid_diagram.dart'
as _mermaid_diagram;
import 'package:site_shared/components/common/client/on_this_page_button.dart'
as _on_this_page_button;
import 'package:site_shared/components/common/client/page_header_options.dart'
Expand Down Expand Up @@ -114,6 +116,11 @@ ServerOptions get defaultServerOptions => ServerOptions(
'site_shared:feedback',
params: __feedbackFeedbackComponent,
),
_mermaid_diagram.MermaidViewer:
ClientTarget<_mermaid_diagram.MermaidViewer>(
'site_shared:mermaid_diagram',
params: __mermaid_diagramMermaidViewer,
),
_on_this_page_button.OnThisPageButton:
ClientTarget<_on_this_page_button.OnThisPageButton>(
'site_shared:on_this_page_button',
Expand Down Expand Up @@ -180,6 +187,9 @@ Map<String, Object?> __download_buttonDownloadButton(
Map<String, Object?> __feedbackFeedbackComponent(
_feedback.FeedbackComponent c,
) => {'issueUrl': c.issueUrl};
Map<String, Object?> __mermaid_diagramMermaidViewer(
_mermaid_diagram.MermaidViewer c,
) => {'diagram': c.diagram};
Map<String, Object?> __page_header_optionsPageHeaderOptions(
_page_header_options.PageHeaderOptions c,
) => {
Expand Down
1 change: 1 addition & 0 deletions sites/docs/lib/src/extensions/registry.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const List<PageExtension> allNodeProcessingExtensions = [
HeaderExtractorExtension(),
HeaderWrapperExtension(),
TableWrapperExtension(),
MermaidProcessor(),
CodeBlockProcessor(defaultTitle: 'Runnable Flutter example'),
GlossaryLinkProcessor(),
TutorialNavigationExtension(),
Expand Down
Loading
Loading