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
Original file line number Diff line number Diff line change
Expand Up @@ -219,9 +219,9 @@ HTML blocks may render as plain text or be ignored depending on parser support.

</details>

## 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
Expand Down
18 changes: 18 additions & 0 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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: [
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -121,7 +122,7 @@ The renderer targets the subset of CommonMark + GitHub-flavored Markdown that LL
- [ ] Raw HTML (`<details>`, `<kbd>`, `<aside>`, …) — kept inline as text
- [ ] GitHub alerts (`> [!NOTE]`) — rendered as plain block quotes
- [ ] Container directives (`::: warning … :::`) and admonitions (`!!! note`)
- [ ] Mermaid / PlantUML diagrams — rendered as fenced code
- [ ] PlantUML diagrams — rendered as fenced code

The bundled `Kitchen Sink` demonstration in the sample app exercises every item above so you can verify the fallback behavior on-device.

Expand Down
2 changes: 2 additions & 0 deletions Sources/MarkdownText/Block/CodeBlock+.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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" && config.mermaidConfig.isEnabled {
return .mermaidView(id: self.id, code: self.code)
} else {
return .codeBlock(id: self.id, language: self.language, code: self.code)
}
Expand Down
22 changes: 22 additions & 0 deletions Sources/MarkdownText/Models/MarkdownRenderConfig+Builders.swift
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,28 @@ 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,
imageConfig: imageConfig,
blockQuoteAlertStyle: blockQuoteAlertStyle
)
}

/// 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 {
Expand Down
4 changes: 4 additions & 0 deletions Sources/MarkdownText/Models/MarkdownRenderConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions Sources/MarkdownText/Models/MarkdownRenderable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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
Expand Down
82 changes: 82 additions & 0 deletions Sources/MarkdownText/Models/MermaidConfig.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
//
// 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

/// Whether Mermaid diagram rendering is enabled.
public let isEnabled: Bool

/// Create a mermaid configuration.
/// - Parameters:
/// - theme: See `theme`. Defaults to `.auto`.
/// - isEnabled: See `isEnabled`. Defaults to `true`.
public init(theme: Theme = .auto, isEnabled: Bool = true) {
self.theme = theme
self.isEnabled = isEnabled
}

/// The default mermaid configuration, following the active color scheme.
public static let `default` = MermaidConfig()

/// Mermaid diagram rendering disabled.
public static let disabled = MermaidConfig(isEnabled: false)
}
2 changes: 2 additions & 0 deletions Sources/MarkdownText/Models/RenderableDocument.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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") }
Expand Down
2 changes: 2 additions & 0 deletions Sources/MarkdownText/UI/BlockView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
96 changes: 96 additions & 0 deletions Sources/MarkdownText/UI/MermaidBlockView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
//
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License. See LICENSE in the project root for license information.
//

//
// MermaidBlockView.swift
// SwiftStreamingMarkdown
//
// Created by Sam Clark on 8/11/26.
//

import BeautifulMermaid
import SwiftUI

/// Renders a fenced ` ```mermaid ` code block as a diagram.
///
/// While the source is still streaming in, the block shows the raw code so the
/// diagram renderer never receives partial source. Once the source has been
/// stable for `MermaidStreamDebouncer`'s settle window the diagram is rendered,
/// and if the diagram fails to parse the raw code is shown again.
struct MermaidBlockView: View {
@Environment(\.markdownConfig) var config: MarkdownRenderConfig
@Environment(\.colorScheme) var colorScheme

/// The latest streamed diagram source.
let code: String

/// The source committed to the diagram renderer after streaming settled.
@State private var settledSource = ""
/// The last parse error reported by the diagram renderer, if any.
@State private var parseError: Error?
@State private var debouncer = MermaidStreamDebouncer()

init(code: String) {
self.code = code

let settledSource = State(initialValue: "")
let parseError = State<Error?>(initialValue: nil)
let debouncer = MermaidStreamDebouncer()
debouncer.onCommit = { source in
settledSource.wrappedValue = source
parseError.wrappedValue = nil
}
_settledSource = settledSource
_parseError = parseError
_debouncer = State(initialValue: debouncer)
}

var body: some View {
content
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
.onAppear {
debouncer.schedule(code)
}
.onChange(of: code) { newValue in
debouncer.schedule(newValue)
}
.onDisappear {
debouncer.cancel()
}
}

@ViewBuilder
private var content: some View {
if !code.isEmpty, settledSource == code, parseError == nil {
MermaidDiagramView(
source: settledSource,
theme: config.mermaidConfig.theme.diagramTheme(for: colorScheme),
parseError: $parseError
)
} else {
MermaidCodeFallbackView(code: code)
}
}
}

/// Shows the raw Mermaid source while it is streaming in or when the diagram
/// failed to parse, matching the readable-fallback behavior used for code.
private struct MermaidCodeFallbackView: View {
@Environment(\.markdownConfig) var config: MarkdownRenderConfig

let code: String

var body: some View {
ScrollView(.horizontal) {
Text(code)
.font(config.codeBlockConfig.codeTextFonts)
.foregroundStyle(config.codeBlockConfig.foregroundColor ?? Color.Static.Stone.Stone350)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
}
.scrollIndicators(.hidden)
.background(config.codeBlockConfig.backgroundColor ?? Color.clear)
}
}
44 changes: 44 additions & 0 deletions Sources/MarkdownText/UI/MermaidStreamDebouncer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
//
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License. See LICENSE in the project root for license information.
//

import Foundation

/// Coalesces rapidly-repeated source updates from a streaming Mermaid code
/// fence so the diagram renderer only re-runs after the source has stopped
/// changing, avoiding per-token layout jitter.
final class MermaidStreamDebouncer {

/// Invoked on the main actor with the latest source once it has been stable
/// for `delayMs`. Called at most once per `schedule(_:)` burst.
var onCommit: ((String) -> Void)?

/// How long a source must stop changing before it is committed.
private let delayMs: Int

private var task: Task<Void, Never>?

/// - Parameter delayMs: See `delayMs`. Defaults to `300`.
init(delayMs: Int = 300) {
self.delayMs = delayMs
}

/// Schedule `source` for commit, cancelling any pending commit.
func schedule(_ source: String) {
task?.cancel()
task = Task { @MainActor [weak self] in
try? await Task.sleep(ms: self?.delayMs ?? 0)
guard !Task.isCancelled else { return }
guard let self else { return }
self.task = nil
self.onCommit?(source)
}
}

/// Cancel any pending commit.
func cancel() {
task?.cancel()
task = nil
}
}
Loading