-
Notifications
You must be signed in to change notification settings - Fork 3.5k
Add mermaid support #13741
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Add mermaid support #13741
Changes from all commits
a5c7f55
54f30b0
196e378
9942358
ecd987c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; | ||
| } | ||
|
|
||
| // Edge lines & arrows | ||
| .edgePath .path { | ||
| stroke-width: 1.5px; | ||
| } | ||
| } | ||
| } | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
| } | ||
| } | ||
|
|
||
|
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)], | ||
| ), | ||
| ], | ||
| ); | ||
| } | ||
| } | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
| ]; | ||
| } | ||
| } |
| 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'; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Any particular reason you're relying on this branch over the If we're going to use it as a git dependency for now, pin it to a specific commit (like ddb951169592bf089518ba19dbf3bf7ffa8d3c35 from the current |
||
| path: packages/mermaid_core | ||
| meta: ^1.18.2 | ||
| nanoid2: ^2.0.1 | ||
| opal: ^0.2.4 | ||
|
|
||
There was a problem hiding this comment.
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.