From f56cfe171c9a85471541f5000ead7770f25b1de1 Mon Sep 17 00:00:00 2001 From: 28pins Date: Tue, 11 Aug 2026 20:06:11 -0500 Subject: [PATCH 1/2] Add mermaid support using beautiful-mermaid-swift --- Package.resolved | 18 +++++ Package.swift | 4 +- Sources/MarkdownText/Block/CodeBlock+.swift | 2 + .../MarkdownRenderConfig+Builders.swift | 20 +++++ .../Models/MarkdownRenderConfig.swift | 4 + .../Models/MarkdownRenderable.swift | 4 + .../MarkdownText/Models/MermaidConfig.swift | 73 +++++++++++++++++++ .../Models/RenderableDocument.swift | 2 + Sources/MarkdownText/UI/BlockView.swift | 2 + .../MarkdownText/UI/MermaidBlockView.swift | 23 ++++++ 10 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 Sources/MarkdownText/Models/MermaidConfig.swift create mode 100644 Sources/MarkdownText/UI/MermaidBlockView.swift diff --git a/Package.resolved b/Package.resolved index e02a15b..a6fcc3b 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,23 @@ { "pins" : [ + { + "identity" : "beautiful-mermaid-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/lukilabs/beautiful-mermaid-swift", + "state" : { + "revision" : "6a23a29e91af8f5b3e9fc09945332ca193bd69ec", + "version" : "1.0.4" + } + }, + { + "identity" : "elk-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/lukilabs/elk-swift", + "state" : { + "revision" : "32f8042e3509a4819f00ff9cd46e829ec2b26da0", + "version" : "1.0.2" + } + }, { "identity" : "equatable", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 1f3ddab..14aab1e 100644 --- a/Package.swift +++ b/Package.swift @@ -13,6 +13,7 @@ let package = Package( targets: ["SwiftStreamingMarkdown"]) ], dependencies: [ + .package(url: "https://github.com/lukilabs/beautiful-mermaid-swift", exact: "1.0.4"), .package(url: "https://github.com/ordo-one/equatable", exact: "1.4.1"), .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", exact: "1.19.4"), .package(url: "https://github.com/swiftlang/swift-markdown.git", exact: "0.7.3"), @@ -28,7 +29,8 @@ let package = Package( .product(name: "Markdown", package: "swift-markdown"), .product(name: "HighlightSwift", package: "highlightswift"), .product(name: "iosMath", package: "iosMath"), - .product(name: "Shimmer", package: "SwiftUI-Shimmer") + .product(name: "Shimmer", package: "SwiftUI-Shimmer"), + .product(name: "BeautifulMermaid", package: "beautiful-mermaid-swift") ], path: "Sources/MarkdownText", resources: [ diff --git a/Sources/MarkdownText/Block/CodeBlock+.swift b/Sources/MarkdownText/Block/CodeBlock+.swift index a65c07c..c9be38d 100644 --- a/Sources/MarkdownText/Block/CodeBlock+.swift +++ b/Sources/MarkdownText/Block/CodeBlock+.swift @@ -11,6 +11,8 @@ extension CodeBlock: BlockConvertible { func convert(attributeContainer: NSAttributeContainer, config: MarkdownRenderConfig) -> MarkdownRenderable { if self.language == LaTexPreProcessorImpl.customCodeType { return .latex(id: self.id, content: self.code) + } else if self.language?.lowercased() == "mermaid" { + return .mermaidView(id: self.id, code: self.code) } else { return .codeBlock(id: self.id, language: self.language, code: self.code) } diff --git a/Sources/MarkdownText/Models/MarkdownRenderConfig+Builders.swift b/Sources/MarkdownText/Models/MarkdownRenderConfig+Builders.swift index 019cc3f..a2ce5be 100644 --- a/Sources/MarkdownText/Models/MarkdownRenderConfig+Builders.swift +++ b/Sources/MarkdownText/Models/MarkdownRenderConfig+Builders.swift @@ -198,6 +198,26 @@ extension MarkdownRenderConfig { ) } + /// Returns a copy with `mermaidConfig` replaced. + public func withMermaidConfig(_ value: MermaidConfig) -> MarkdownRenderConfig { + MarkdownRenderConfig( + shouldAnimateText: shouldAnimateText, + blockQuoteStyle: blockQuoteStyle, + headingStyle: headingStyle, + orderedListStyle: orderedListStyle, + paragraphStyle: paragraphStyle, + tableStyle: tableStyle, + inlineStyle: inlineStyle, + textContextMenu: textContextMenu, + citationConfig: citationConfig, + codeBlockConfig: codeBlockConfig, + mermaidConfig: value, + blockSpacing: blockSpacing, + textSelectionConfig: textSelectionConfig, + thematicBreakColor: thematicBreakColor + ) + } + /// Returns a copy with `textSelectionConfig` replaced. Pass a config with /// `isEnabled: false` to hide the built-in "Select more text" edit-menu action. public func withTextSelectionConfig(value: TextSelectionConfig) -> MarkdownRenderConfig { diff --git a/Sources/MarkdownText/Models/MarkdownRenderConfig.swift b/Sources/MarkdownText/Models/MarkdownRenderConfig.swift index 71feecc..18ad27c 100644 --- a/Sources/MarkdownText/Models/MarkdownRenderConfig.swift +++ b/Sources/MarkdownText/Models/MarkdownRenderConfig.swift @@ -34,6 +34,8 @@ public struct MarkdownRenderConfig: Hashable, Sendable { public let citationConfig: CitationConfig /// Configuration that controls code-block syntax-highlighting styling. public let codeBlockConfig: CodeBlockConfig + /// Configuration that controls Mermaid diagram styling. + public let mermaidConfig: MermaidConfig /// Vertical spacing between adjacent blocks (paragraphs, headings, /// code blocks, lists, etc.). Defaults to 30. public let blockSpacing: CGFloat @@ -280,6 +282,7 @@ public struct MarkdownRenderConfig: Hashable, Sendable { textContextMenu: TextContextMenu? = nil, citationConfig: CitationConfig = .default, codeBlockConfig: CodeBlockConfig = .default, + mermaidConfig: MermaidConfig = .default, blockSpacing: CGFloat = MarkdownRenderConfig.defaultBlockSpacing, textSelectionConfig: TextSelectionConfig = .default, thematicBreakColor: Color = MarkdownRenderConfig.defaultThematicBreakColor, @@ -295,6 +298,7 @@ public struct MarkdownRenderConfig: Hashable, Sendable { self.textContextMenu = textContextMenu self.citationConfig = citationConfig self.codeBlockConfig = codeBlockConfig + self.mermaidConfig = mermaidConfig self.blockSpacing = blockSpacing self.textSelectionConfig = textSelectionConfig self.thematicBreakColor = thematicBreakColor diff --git a/Sources/MarkdownText/Models/MarkdownRenderable.swift b/Sources/MarkdownText/Models/MarkdownRenderable.swift index 397f3c9..f66a750 100644 --- a/Sources/MarkdownText/Models/MarkdownRenderable.swift +++ b/Sources/MarkdownText/Models/MarkdownRenderable.swift @@ -33,6 +33,9 @@ indirect enum MarkdownRenderable: Identifiable, Equatable, @unchecked Sendable { /// To be rendered as a code block case codeBlock(id: String, language: String?, code: String) + /// To be rendered as a Mermaid diagram (a code block tagged with `mermaid`) + case mermaidView(id: String, code: String) + /// To be rendered as a table case table(id: String, headers: [NSMutableAttributedString], rows: [[NSMutableAttributedString]], rawMarkdown: String) @@ -54,6 +57,7 @@ indirect enum MarkdownRenderable: Identifiable, Equatable, @unchecked Sendable { case .orderedList(let id, _): return id case .unorderedList(let id, _, _): return id case .codeBlock(let id, _, _): return id + case .mermaidView(let id, _): return id case .table(let id, _, _, _): return id case .thematicBreak(let id): return id case .blockQuote(let id, _): return id diff --git a/Sources/MarkdownText/Models/MermaidConfig.swift b/Sources/MarkdownText/Models/MermaidConfig.swift new file mode 100644 index 0000000..70e05d7 --- /dev/null +++ b/Sources/MarkdownText/Models/MermaidConfig.swift @@ -0,0 +1,73 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +// + +import BeautifulMermaid +import SwiftUI + +/// Styling configuration for Mermaid diagrams. +public struct MermaidConfig: Hashable, Sendable { + + /// A named BeautifulMermaid diagram theme. + public enum Theme: String, CaseIterable, Hashable, Sendable { + /// Resolves to the light or dark variant of the bundled default theme based + /// on the active `ColorScheme`. + case auto + case zincLight + case zincDark + case tokyoNight + case tokyoNightStorm + case tokyoNightLight + case catppuccinMocha + case catppuccinLatte + case nord + case nordLight + case dracula + case githubLight + case githubDark + case solarizedLight + case solarizedDark + case oneDark + case gruvboxDark + case gruvboxLight + + /// Resolve the `DiagramTheme` to render with for the given color scheme. + func diagramTheme(for colorScheme: ColorScheme) -> DiagramTheme { + switch self { + case .auto: + return colorScheme == .dark ? .zincDark : .zincLight + case .zincLight: return .zincLight + case .zincDark: return .zincDark + case .tokyoNight: return .tokyoNight + case .tokyoNightStorm: return .tokyoNightStorm + case .tokyoNightLight: return .tokyoNightLight + case .catppuccinMocha: return .catppuccinMocha + case .catppuccinLatte: return .catppuccinLatte + case .nord: return .nord + case .nordLight: return .nordLight + case .dracula: return .dracula + case .githubLight: return .githubLight + case .githubDark: return .githubDark + case .solarizedLight: return .solarizedLight + case .solarizedDark: return .solarizedDark + case .oneDark: return .oneDark + case .gruvboxDark: return .gruvboxDark + case .gruvboxLight: return .gruvboxLight + } + } + } + + /// The theme applied to rendered diagrams. Defaults to `.auto`, which follows + /// the active `ColorScheme`. + public let theme: Theme + + /// Create a mermaid configuration. + /// - Parameter theme: See `theme`. Defaults to `.auto`. + public init(theme: Theme = .auto) { + self.theme = theme + } + + /// The default mermaid configuration, following the active color scheme. + public static let `default` = MermaidConfig() +} diff --git a/Sources/MarkdownText/Models/RenderableDocument.swift b/Sources/MarkdownText/Models/RenderableDocument.swift index 2ef23d7..993cd81 100644 --- a/Sources/MarkdownText/Models/RenderableDocument.swift +++ b/Sources/MarkdownText/Models/RenderableDocument.swift @@ -92,6 +92,8 @@ extension MarkdownRenderable { return items.plainText(separator: "\n") case .codeBlock(_, _, let code): return code + case .mermaidView(_, let code): + return code case .table(_, let headers, let rows, _): let headerLine = headers.map { $0.string }.joined(separator: "\t") let rowLines = rows.map { row in row.map { $0.string }.joined(separator: "\t") } diff --git a/Sources/MarkdownText/UI/BlockView.swift b/Sources/MarkdownText/UI/BlockView.swift index b7fa300..633fe53 100644 --- a/Sources/MarkdownText/UI/BlockView.swift +++ b/Sources/MarkdownText/UI/BlockView.swift @@ -60,6 +60,8 @@ struct SingleBlockView: View { case .codeBlock(_, let language, let code): CodeBlockView(language: language ?? "", code: code) + case .mermaidView(_, let code): + MermaidBlockView(code: code) case .thematicBreak: ThematicBreakView() case .table(_, let headers, let rows, let rawMarkdown): diff --git a/Sources/MarkdownText/UI/MermaidBlockView.swift b/Sources/MarkdownText/UI/MermaidBlockView.swift new file mode 100644 index 0000000..a1717fb --- /dev/null +++ b/Sources/MarkdownText/UI/MermaidBlockView.swift @@ -0,0 +1,23 @@ +// +// MermaidBlockView.swift +// SwiftStreamingMarkdown +// +// Created by Sam Clark on 8/11/26. +// + +import SwiftUI +import BeautifulMermaid + +struct MermaidBlockView: View { + @Environment(\.markdownConfig) var config: MarkdownRenderConfig + @Environment(\.colorScheme) var colorScheme + @State var code: String + + var body: some View { + MermaidDiagramView( + source: code, + theme: config.mermaidConfig.theme.diagramTheme(for: colorScheme) + ) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + } +} From c34013a73da88aa17b10b62333bde6086a8daf69 Mon Sep 17 00:00:00 2001 From: 28pins Date: Tue, 11 Aug 2026 20:42:27 -0500 Subject: [PATCH 2/2] Update README, demo, and tests for commit 1b622e1 --- .../Resources/Fixtures/kitchen-sink.md | 4 +- README.md | 3 +- Sources/MarkdownText/Block/CodeBlock+.swift | 2 +- .../MarkdownRenderConfig+Builders.swift | 4 +- .../MarkdownText/Models/MermaidConfig.swift | 13 ++- .../MarkdownText/UI/MermaidBlockView.swift | 97 ++++++++++++++-- .../UI/MermaidStreamDebouncer.swift | 44 ++++++++ .../MermaidBlockRenderingTests.swift | 104 ++++++++++++++++++ .../MermaidConfigTests.swift | 60 ++++++++++ .../MermaidStreamDebouncerTests.swift | 49 +++++++++ 10 files changed, 361 insertions(+), 19 deletions(-) create mode 100644 Sources/MarkdownText/UI/MermaidStreamDebouncer.swift create mode 100644 Tests/MarkdownTextTests/MermaidBlockRenderingTests.swift create mode 100644 Tests/MarkdownTextTests/MermaidConfigTests.swift create mode 100644 Tests/MarkdownTextTests/MermaidStreamDebouncerTests.swift diff --git a/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/Resources/Fixtures/kitchen-sink.md b/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/Resources/Fixtures/kitchen-sink.md index cac5589..001e2a1 100644 --- a/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/Resources/Fixtures/kitchen-sink.md +++ b/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/Resources/Fixtures/kitchen-sink.md @@ -219,9 +219,9 @@ HTML blocks may render as plain text or be ignored depending on parser support. -## Mermaid diagram fallback +## Mermaid diagram -Mermaid is intentionally included as an unimplemented markdown feature. Until a diagram renderer exists, this should remain readable as a fenced code block. +Mermaid fences render as interactive diagrams. Configure them via `MermaidConfig`; setting `.disabled` falls back to a readable fenced code block. ```mermaid flowchart TD diff --git a/README.md b/README.md index efae63a..2d3fc7b 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,7 @@ The renderer targets the subset of CommonMark + GitHub-flavored Markdown that LL - [x] `Inline code` - [x] Inline links - [x] Fenced code blocks with language tag +- [x] Mermaid diagrams — rendered as interactive diagrams for `mermaid`-tagged fenced code blocks; theme and opt-out via `MermaidConfig` (`withMermaidConfig`, `.disabled`) - [x] Block quotes (with nested inlines, lists, and citations) - [x] Ordered lists - [x] Unordered lists (with nesting) @@ -121,7 +122,7 @@ The renderer targets the subset of CommonMark + GitHub-flavored Markdown that LL - [ ] Raw HTML (`
`, ``, `