Skip to content
Closed
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 @@ -142,7 +142,7 @@ Container directives from Markdown-it and GitHub-style alerts are extension synt
:::

> [!NOTE]
> GitHub alert syntax is included as another block quote extension. Unsupported renderers should display it as quoted text.
> GitHub alert syntax is included as another block quote extension. This should display with a colored bar and icon to the left.

> [!WARNING]
> Alerts with multiple paragraphs should still wrap and stream without corrupting following content.
Expand Down 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
5 changes: 3 additions & 2 deletions 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 @@ -113,15 +114,15 @@ The renderer targets the subset of CommonMark + GitHub-flavored Markdown that LL
- [x] Inline LaTeX math via `\( … \)`
- [x] Display LaTeX math via `$$ … $$`
- [x] Inline citation pills
- [x] GitHub alerts (`> [!NOTE]`) — rendered as block quotes with icon and color

### Not yet supported

- [ ] Footnotes (`[^1]`)
- [ ] Highlight (`==text==`), superscript (`^x^`), subscript (`~x~`)
- [ ] 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
31 changes: 27 additions & 4 deletions Sources/MarkdownText/Block/BlockQuote+.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,41 @@ import SwiftUI
extension BlockQuote: BlockConvertible {
var quoteTypes: BlockQuoteType {
var finalQuoteTypes = [BlockQuoteType]()
var alertKind: BlockQuoteAlertKind?

for child in children {
if let inlineContainer = child as? InlineContainer {
// Use our custom extractPlainText method instead of the built-in plainText property
// to properly handle attachment citations
finalQuoteTypes.append(.text(inlineContainer.extractPlainText(removeHeading: false)))
let text = inlineContainer.extractPlainText(removeHeading: false)
if alertKind == nil, let (kind, rest) = Self.parseAlertTag(text) {
alertKind = kind
finalQuoteTypes.append(.text(rest, alertKind))
} else {
finalQuoteTypes.append(.text(text, alertKind))
}
} else if let blockQuoteContainer = child as? BlockQuote {
finalQuoteTypes.append(blockQuoteContainer.quoteTypes)
}
}

return .nested(finalQuoteTypes)
return .nested(finalQuoteTypes, alertKind)
}

/// Recognizes a leading GitHub-style `[!KIND]` alert marker, returning the
/// parsed kind and the remaining text with the marker stripped.
static func parseAlertTag(_ text: String) -> (kind: BlockQuoteAlertKind, rest: String)? {
guard text.hasPrefix("[!"),
let close = text.firstIndex(of: "]"),
close > text.index(text.startIndex, offsetBy: 2),
let kind = BlockQuoteAlertKind(rawValue: String(text[text.index(text.startIndex, offsetBy: 2)..<close]).lowercased())
else {
return nil
}

var rest = String(text[text.index(after: close)...])
if rest.hasPrefix(" ") {
rest.removeFirst()
}
return (kind, rest)
}

func convert(attributeContainer: NSAttributeContainer, config: MarkdownRenderConfig) -> MarkdownRenderable {
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
44 changes: 44 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 Expand Up @@ -256,4 +278,26 @@ extension MarkdownRenderConfig {
imageConfig: value
)
}

/// Returns a copy with `blockQuoteAlertStyle` replaced.
public func withBlockQuoteAlertStyle(_ value: [BlockQuoteAlertKind: MarkdownBlockQuoteAlertStyle]) -> MarkdownRenderConfig {
MarkdownRenderConfig(
shouldAnimateText: shouldAnimateText,
blockQuoteStyle: blockQuoteStyle,
headingStyle: headingStyle,
orderedListStyle: orderedListStyle,
paragraphStyle: paragraphStyle,
tableStyle: tableStyle,
inlineStyle: inlineStyle,
textContextMenu: textContextMenu,
citationConfig: citationConfig,
codeBlockConfig: codeBlockConfig,
mermaidConfig: mermaidConfig,
blockSpacing: blockSpacing,
textSelectionConfig: textSelectionConfig,
thematicBreakColor: thematicBreakColor,
imageConfig: imageConfig,
blockQuoteAlertStyle: value
)
}
}
43 changes: 42 additions & 1 deletion 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 All @@ -53,6 +55,8 @@ public struct MarkdownRenderConfig: Hashable, Sendable {
/// - Important: Image support is **experimental**. The behavior, API, and
/// rendering output may change in future releases. Defaults to `.disabled`.
public let imageConfig: ImageConfig
/// Per-kind styling applied to block-quote alerts (`> [!NOTE]`).
public let blockQuoteAlertStyle: [BlockQuoteAlertKind: MarkdownBlockQuoteAlertStyle]

/// Font and color style for a uniformly-styled run of markdown text.
public struct MarkdownTextStyle: Hashable, Sendable {
Expand Down Expand Up @@ -95,6 +99,30 @@ public struct MarkdownRenderConfig: Hashable, Sendable {
}
}

/// Styling applied to a block-quote alert of a given `BlockQuoteAlertKind`.
public struct MarkdownBlockQuoteAlertStyle: Hashable, Sendable {
/// Color of the leading accent bar.
public let accentColor: Color
/// Fill color behind the alert content.
public let backgroundColor: Color
/// SF Symbols icon name
public let imageName: String

/// Create an alert style with the supplied accent and background colors.
public init(accentColor: Color, backgroundColor: Color, imageName: String) {
self.accentColor = accentColor
self.backgroundColor = backgroundColor
self.imageName = imageName
}

/// A neutral fallback used when a kind is missing from the style map.
public static let `default` = MarkdownBlockQuoteAlertStyle(
accentColor: .blue,
backgroundColor: .blue.opacity(0.08),
imageName: "info.circle"
)
}

/// Per-level fonts and shared foreground color for markdown headings.
public struct MarkdownHeadingTextStyle: Hashable, Sendable {
/// Font set for level-1 headings.
Expand Down Expand Up @@ -222,6 +250,15 @@ public struct MarkdownRenderConfig: Hashable, Sendable {
textColor: Color.Theme.Foreground.Primary.Primary750
)

/// Default per-kind styling for block-quote alerts.
public static let defaultBlockQuoteAlertStyle: [BlockQuoteAlertKind: MarkdownBlockQuoteAlertStyle] = [
.note: MarkdownBlockQuoteAlertStyle(accentColor: .blue, backgroundColor: .blue.opacity(0.08), imageName: "note.text"),
.tip: MarkdownBlockQuoteAlertStyle(accentColor: .green, backgroundColor: .green.opacity(0.08), imageName: "lightbulb.circle"),
.important: MarkdownBlockQuoteAlertStyle(accentColor: .purple, backgroundColor: .purple.opacity(0.08), imageName: "info.circle"),
.warning: MarkdownBlockQuoteAlertStyle(accentColor: .yellow, backgroundColor: .yellow.opacity(0.08), imageName: "exclamationmark.triangle"),
.caution: MarkdownBlockQuoteAlertStyle(accentColor: .red, backgroundColor: .red.opacity(0.08), imageName: "exclamationmark.octagon")
]

/// Default styling for `headingStyle`.
public static let defaultHeadingStyle = MarkdownHeadingTextStyle(
h1Font: Typography.extraLargeTextFonts,
Expand Down Expand Up @@ -280,10 +317,12 @@ 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,
imageConfig: ImageConfig = .disabled
imageConfig: ImageConfig = .disabled,
blockQuoteAlertStyle: [BlockQuoteAlertKind: MarkdownBlockQuoteAlertStyle] = MarkdownRenderConfig.defaultBlockQuoteAlertStyle
) {
self.shouldAnimateText = shouldAnimateText
self.blockQuoteStyle = blockQuoteStyle
Expand All @@ -295,10 +334,12 @@ 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
self.imageConfig = imageConfig
self.blockQuoteAlertStyle = blockQuoteAlertStyle
}

/// The default render config, equivalent to calling `init()` with no
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)
}
Loading