From 2ebe004824072cd5d0ac0da3e5e575405bd645fa Mon Sep 17 00:00:00 2001 From: Henry Rausch Date: Fri, 6 Mar 2026 04:18:36 +0100 Subject: [PATCH 01/13] Fix iteration order in MathFirstMarkdownViewRenderer --- .../Renderers/Math/MathFirstMarkdownViewRenderer.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/MarkdownView/Renderers/Math/MathFirstMarkdownViewRenderer.swift b/Sources/MarkdownView/Renderers/Math/MathFirstMarkdownViewRenderer.swift index 801c35d9..b7c8737c 100644 --- a/Sources/MarkdownView/Renderers/Math/MathFirstMarkdownViewRenderer.swift +++ b/Sources/MarkdownView/Renderers/Math/MathFirstMarkdownViewRenderer.swift @@ -115,7 +115,7 @@ private func makeMathFirstBody( var extractor = MathFirstMarkdownViewRenderer.ParsingRangesExtractor() extractor.visit(content.document()) - for range in extractor.parsableRanges(in: rawText) { + for range in extractor.parsableRanges(in: rawText).reversed() { let segment = rawText[range] let segmentParser = MathParser(text: segment) for math in segmentParser.mathRepresentations.reversed() where !math.kind.inline { From b84017a81b6edcf650cc0e610ec3a5fa711bef9e Mon Sep 17 00:00:00 2001 From: Henry Rausch Date: Fri, 6 Mar 2026 04:18:36 +0100 Subject: [PATCH 02/13] Add HeadingStyleGroup configuration support --- Sources/MarkdownView/MarkdownView.swift | 7 +++++-- .../Renderers/MarkdownRendererConfiguration.swift | 2 ++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/Sources/MarkdownView/MarkdownView.swift b/Sources/MarkdownView/MarkdownView.swift index 4a3c8291..9e3650ff 100644 --- a/Sources/MarkdownView/MarkdownView.swift +++ b/Sources/MarkdownView/MarkdownView.swift @@ -7,6 +7,7 @@ public struct MarkdownView: View { @Environment(\.markdownRendererConfiguration) private var configuration @Environment(\.markdownViewRenderer) private var renderer + @Environment(\.headingStyleGroup) private var headingStyleGroup /// Creates a view that renders given markdown string. /// - Parameter text: The markdown source to render. @@ -27,8 +28,10 @@ public struct MarkdownView: View { } public var body: some View { - renderer - .makeBody(content: content, configuration: configuration) + var config = configuration + config.headingStyleGroup = headingStyleGroup + return renderer + .makeBody(content: content, configuration: config) .erasedToAnyView() .font(configuration.fonts[.body] ?? Font.body) } diff --git a/Sources/MarkdownView/Renderers/MarkdownRendererConfiguration.swift b/Sources/MarkdownView/Renderers/MarkdownRendererConfiguration.swift index 7f27d329..e5ec657e 100644 --- a/Sources/MarkdownView/Renderers/MarkdownRendererConfiguration.swift +++ b/Sources/MarkdownView/Renderers/MarkdownRendererConfiguration.swift @@ -34,6 +34,8 @@ public struct MarkdownRendererConfiguration: Equatable, Sendable { public internal(set) var list = MarkdownListConfiguration() + public internal(set) var headingStyleGroup: AnyHeadingStyleGroup = .init(.automatic) + public internal(set) var allowedImageRenderers: Set = ["https", "http"] public internal(set) var allowedBlockDirectiveRenderers: Set = [] From eb9e35df127f4e601509dc1098af826820992343 Mon Sep 17 00:00:00 2001 From: Henry Rausch Date: Fri, 6 Mar 2026 04:18:36 +0100 Subject: [PATCH 03/13] Enhance CmarkVisitor for checklist and heading support --- .../Cmark/CmarkTextContentVisitor.swift | 88 +++++++++++++++---- 1 file changed, 70 insertions(+), 18 deletions(-) diff --git a/Sources/MarkdownView/Renderers/Cmark/CmarkTextContentVisitor.swift b/Sources/MarkdownView/Renderers/Cmark/CmarkTextContentVisitor.swift index 21b6d340..dca1ca94 100644 --- a/Sources/MarkdownView/Renderers/Cmark/CmarkTextContentVisitor.swift +++ b/Sources/MarkdownView/Renderers/Cmark/CmarkTextContentVisitor.swift @@ -15,7 +15,7 @@ import RichText @available(iOS 26, macOS 26, *) struct CmarkTextContentVisitor: @preconcurrency MarkupVisitor { var configuration: MarkdownRendererConfiguration - + init(configuration: MarkdownRendererConfiguration) { self.configuration = configuration } @@ -184,29 +184,51 @@ struct CmarkTextContentVisitor: @preconcurrency MarkupVisitor { var attributes = AttributeContainer([.paragraphStyle: paragraphStyle]) let markerString: String? - switch listItem.parent { - case let list as UnorderedList: - let marker = configuration.list.unorderedListMarker - markerString = marker.marker(listDepth: list.listDepth) - attributes = attributes.font((configuration.fonts[.body] ?? .body).monospaced(marker.monospaced)) - case let list as OrderedList: - let marker = configuration.list.orderedListMarker - markerString = marker.marker(at: listItem.indexInParent, listDepth: list.listDepth) - attributes = attributes.font((configuration.fonts[.body] ?? .body).monospaced(marker.monospaced)) - default: - markerString = nil + let hasCheckbox = listItem.checkbox != nil + if hasCheckbox { + markerString = nil + } else { + switch listItem.parent { + case let list as UnorderedList: + let marker = configuration.list.unorderedListMarker + markerString = marker.marker(listDepth: list.listDepth) + attributes = attributes.font((configuration.fonts[.body] ?? .body).monospaced(marker.monospaced)) + case let list as OrderedList: + let marker = configuration.list.orderedListMarker + markerString = marker.marker(at: listItem.indexInParent, listDepth: list.listDepth) + attributes = attributes.font((configuration.fonts[.body] ?? .body).monospaced(marker.monospaced)) + default: + markerString = nil + } } let children = Array(listItem.children) - + let firstChildContent = children.first.map(descendInto) let trailingBlocks = children.dropFirst().map { child in var nestedRenderer = self return nestedRenderer.visit(child) } - + return TextContent { - if let markerString { + if let checkbox = listItem.checkbox { + let checkboxView: some View = Group { + switch checkbox { + case .checked: + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.tint) + case .unchecked: + Image(systemName: "circle") + .foregroundStyle(.secondary) + } + } + let attachment = InlineHostingAttachment( + checkboxView, + id: listItem.range, + replacement: nil + ) + TextContent(.view(attachment)) + } else if let markerString { AttributedString(markerString, attributes: attributes) } Space() @@ -264,16 +286,46 @@ struct CmarkTextContentVisitor: @preconcurrency MarkupVisitor { case 6: MarkdownComponent.h6 default: MarkdownComponent.body } + let foregroundStyle: AnyShapeStyle = switch heading.level { + case 1: configuration.headingStyleGroup.h1 + case 2: configuration.headingStyleGroup.h2 + case 3: configuration.headingStyleGroup.h3 + case 4: configuration.headingStyleGroup.h4 + case 5: configuration.headingStyleGroup.h5 + case 6: configuration.headingStyleGroup.h6 + default: AnyShapeStyle(.foreground) + } + let font = configuration.fonts[component] ?? .body let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.paragraphSpacing = 12 paragraphStyle.paragraphSpacingBefore = 12 let attributes = AttributeContainer([.paragraphStyle : paragraphStyle as NSParagraphStyle]) .presentationIntent(.init(.header(level: heading.level), identity: heading.indexInParent)) .accessibilityHeadingLevel(AttributeScopes.AccessibilityAttributes.HeadingLevelAttribute.HeadingLevel(rawValue: heading.level) ?? .unspecified) - .font(configuration.fonts[component] ?? .body) - + .font(font) + + let replacement = AttributedString(heading.plainText, attributes: attributes) return TextContent { - AttributedString(heading.plainText, attributes: attributes) + inlineViewContent( + for: heading, + replacement: replacement + ) { + SwiftUI.Text(heading.plainText) + .font(font) + .foregroundStyle(foregroundStyle) + .accessibilityHeading({ + switch heading.level { + case 1: .h1 + case 2: .h2 + case 3: .h3 + case 4: .h4 + case 5: .h5 + case 6: .h6 + default: .unspecified + } + }() as AccessibilityHeadingLevel) + .accessibilityAddTraits(.isHeader) + } LineBreak() } } From 5e3ae15680d928ef49db62d71f6d68b9258f6c0d Mon Sep 17 00:00:00 2001 From: Henry Rausch Date: Fri, 6 Mar 2026 04:18:36 +0100 Subject: [PATCH 04/13] Minor code cleanup in text renderers --- .../Renderers/Cmark/CmarkFirstMarkdownViewRenderer.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/MarkdownView/Renderers/Cmark/CmarkFirstMarkdownViewRenderer.swift b/Sources/MarkdownView/Renderers/Cmark/CmarkFirstMarkdownViewRenderer.swift index b19f04af..fdebb78d 100644 --- a/Sources/MarkdownView/Renderers/Cmark/CmarkFirstMarkdownViewRenderer.swift +++ b/Sources/MarkdownView/Renderers/Cmark/CmarkFirstMarkdownViewRenderer.swift @@ -36,7 +36,7 @@ struct TextViewViewRenderer: MarkdownViewRenderer { if !configuration.allowedBlockDirectiveRenderers.isEmpty { parseOptions.insert(.parseBlockDirectives) } - + let textContent = CmarkTextContentVisitor(configuration: configuration) .makeTextContent(for: content.document(options: parseOptions)) return TextView { From 0702e44d9f001003d58da541debf4228b4df8370 Mon Sep 17 00:00:00 2001 From: Henry Rausch Date: Fri, 6 Mar 2026 12:17:38 +0100 Subject: [PATCH 05/13] Add underline configuration to MarkdownRendererConfiguration --- .../MarkdownRendererConfiguration.swift | 2 ++ .../Styling/UnderlineLinkModifier.swift | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 Sources/MarkdownView/View Modifiers/Styling/UnderlineLinkModifier.swift diff --git a/Sources/MarkdownView/Renderers/MarkdownRendererConfiguration.swift b/Sources/MarkdownView/Renderers/MarkdownRendererConfiguration.swift index e5ec657e..c394f4d6 100644 --- a/Sources/MarkdownView/Renderers/MarkdownRendererConfiguration.swift +++ b/Sources/MarkdownView/Renderers/MarkdownRendererConfiguration.swift @@ -31,6 +31,8 @@ public struct MarkdownRendererConfiguration: Equatable, Sendable { public var rendersMath: Bool { math.isEnabled } public internal(set) var preferredTintColors: [MarkdownTintableComponent: Color] = [:] + + public internal(set) var underlineLinks: Bool = false public internal(set) var list = MarkdownListConfiguration() diff --git a/Sources/MarkdownView/View Modifiers/Styling/UnderlineLinkModifier.swift b/Sources/MarkdownView/View Modifiers/Styling/UnderlineLinkModifier.swift new file mode 100644 index 00000000..6bc201ed --- /dev/null +++ b/Sources/MarkdownView/View Modifiers/Styling/UnderlineLinkModifier.swift @@ -0,0 +1,17 @@ +// +// UnderlineLinkModifier.swift +// MarkdownView +// + +import SwiftUI + +extension View { + /// Adds an underline decoration to links in the Markdown content. + /// + /// - Parameter isActive: Whether links should be underlined. Defaults to `true`. + nonisolated public func underlineLinks(_ isActive: Bool = true) -> some View { + transformEnvironment(\.markdownRendererConfiguration) { configuration in + configuration.underlineLinks = isActive + } + } +} From 87eea1b067a810fbd124547868a4aa6b909ad663 Mon Sep 17 00:00:00 2001 From: Henry Rausch Date: Fri, 6 Mar 2026 12:17:38 +0100 Subject: [PATCH 06/13] Apply underline configuration to link renderers --- .../Renderers/Cmark/CmarkNodeVisitor.swift | 12 +++++++++--- .../Cmark/CmarkTextContentVisitor.swift | 16 +++++++++++----- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/Sources/MarkdownView/Renderers/Cmark/CmarkNodeVisitor.swift b/Sources/MarkdownView/Renderers/Cmark/CmarkNodeVisitor.swift index 20149015..d333e749 100644 --- a/Sources/MarkdownView/Renderers/Cmark/CmarkNodeVisitor.swift +++ b/Sources/MarkdownView/Renderers/Cmark/CmarkNodeVisitor.swift @@ -241,13 +241,18 @@ struct CmarkNodeVisitor: @preconcurrency MarkupVisitor { let nodeView = descendInto(link) let tintColor = configuration.preferredTintColors[.link] ?? .accentColor + let underline = configuration.underlineLinks return if let attributedString = nodeView.asAttributedString { MarkdownNodeView( - attributedString.mergingAttributes( - AttributeContainer() + attributedString.mergingAttributes({ + var container = AttributeContainer() .link(url) .foregroundColor(tintColor) - ) + if underline { + container.underlineStyle = .single + } + return container + }()) ) } else { MarkdownNodeView { @@ -255,6 +260,7 @@ struct CmarkNodeVisitor: @preconcurrency MarkupVisitor { nodeView } .foregroundStyle(tintColor) + .underline(underline) } } } diff --git a/Sources/MarkdownView/Renderers/Cmark/CmarkTextContentVisitor.swift b/Sources/MarkdownView/Renderers/Cmark/CmarkTextContentVisitor.swift index dca1ca94..703a5f31 100644 --- a/Sources/MarkdownView/Renderers/Cmark/CmarkTextContentVisitor.swift +++ b/Sources/MarkdownView/Renderers/Cmark/CmarkTextContentVisitor.swift @@ -383,14 +383,15 @@ struct CmarkTextContentVisitor: @preconcurrency MarkupVisitor { let linkContent = descendInto(link) let tintColor = configuration.preferredTintColors[.link] ?? .accentColor - + let underline = configuration.underlineLinks + let contentView = linkContent.fragments.first(byUnwrapping: { if case let .view(attachment) = $0 { return attachment.view } return nil }) - + if let contentView { return inlineViewContent( for: link, @@ -403,16 +404,21 @@ struct CmarkTextContentVisitor: @preconcurrency MarkupVisitor { contentView } .foregroundStyle(tintColor) + .underline(underline) } } else { let attributedString = linkContent.attributedStringIgnoringViews return TextContent( .attributedString( - attributedString.mergingAttributes( - AttributeContainer() + attributedString.mergingAttributes({ + var container = AttributeContainer() .link(url) .foregroundColor(tintColor) - ) + if underline { + container.underlineStyle = .single + } + return container + }()) ) ) } From 60c38eebd825bec6424f45bf008f296d46ab42f5 Mon Sep 17 00:00:00 2001 From: Henry Rausch Date: Fri, 6 Mar 2026 12:35:48 +0100 Subject: [PATCH 07/13] Optimize list and table rendering with indices --- .../Block Quotes/BlockQuoteStyle.swift | 8 +++++-- ...arkdownTableStyleConfiguration.Table.swift | 4 ++-- .../Table/DefaultMarkdownTableStyle.swift | 4 ++-- .../Table/GithubMarkdownTableStyle.swift | 4 ++-- .../Node Representations/MarkdownList.swift | 23 +++++++++++-------- .../Tables/MarkdownTable.swift | 12 ++++++---- .../Tables/MarkdownTableRow.swift | 10 ++++---- 7 files changed, 39 insertions(+), 26 deletions(-) diff --git a/Sources/MarkdownView/Customizations/Block Quotes/BlockQuoteStyle.swift b/Sources/MarkdownView/Customizations/Block Quotes/BlockQuoteStyle.swift index f1430836..32f8c08c 100644 --- a/Sources/MarkdownView/Customizations/Block Quotes/BlockQuoteStyle.swift +++ b/Sources/MarkdownView/Customizations/Block Quotes/BlockQuoteStyle.swift @@ -41,12 +41,16 @@ public struct BlockQuoteStyleConfiguration { self.blockQuote = blockQuote } + private var children: [Markup] { + Array(blockQuote.children) + } + @_documentation(visibility: internal) public var body: some View { VStack(alignment: .leading, spacing: configuration.componentSpacing) { - ForEach(Array(blockQuote.children.enumerated()), id: \.offset) { _, child in + ForEach(children.indices, id: \.self) { index in CmarkNodeVisitor(configuration: configuration) - .makeBody(for: child) + .makeBody(for: children[index]) } } } diff --git a/Sources/MarkdownView/Customizations/Table/Configuration/MarkdownTableStyleConfiguration.Table.swift b/Sources/MarkdownView/Customizations/Table/Configuration/MarkdownTableStyleConfiguration.Table.swift index ac504ba2..311d70fe 100644 --- a/Sources/MarkdownView/Customizations/Table/Configuration/MarkdownTableStyleConfiguration.Table.swift +++ b/Sources/MarkdownView/Customizations/Table/Configuration/MarkdownTableStyleConfiguration.Table.swift @@ -38,8 +38,8 @@ extension MarkdownTableStyleConfiguration.Table: View { if #available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) { Grid(horizontalSpacing: 0, verticalSpacing: 0) { header - ForEach(Array(rows.enumerated()), id: \.offset) { (_, row) in - row + ForEach(rows.indices, id: \.self) { index in + rows[index] } } } else { diff --git a/Sources/MarkdownView/Customizations/Table/DefaultMarkdownTableStyle.swift b/Sources/MarkdownView/Customizations/Table/DefaultMarkdownTableStyle.swift index 1c1fe615..012cfc7f 100644 --- a/Sources/MarkdownView/Customizations/Table/DefaultMarkdownTableStyle.swift +++ b/Sources/MarkdownView/Customizations/Table/DefaultMarkdownTableStyle.swift @@ -42,11 +42,11 @@ fileprivate struct DefaultMarkdownTable: View { if #available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) { Grid(horizontalSpacing: 0, verticalSpacing: 0) { configuration.table.header - ForEach(Array(configuration.table.rows.enumerated()), id: \.offset) { (_, row) in + ForEach(configuration.table.rows.indices, id: \.self) { index in if showsRowSeparators { Divider() } - row + configuration.table.rows[index] } } } else { diff --git a/Sources/MarkdownView/Customizations/Table/GithubMarkdownTableStyle.swift b/Sources/MarkdownView/Customizations/Table/GithubMarkdownTableStyle.swift index 6d981e3c..bddbc534 100644 --- a/Sources/MarkdownView/Customizations/Table/GithubMarkdownTableStyle.swift +++ b/Sources/MarkdownView/Customizations/Table/GithubMarkdownTableStyle.swift @@ -63,9 +63,9 @@ fileprivate struct GithubMarkdownTable: View { Grid(horizontalSpacing: 0, verticalSpacing: 0) { configuration.table.header - ForEach(Array(configuration.table.rows.enumerated()), id: \.offset) { (index, row) in + ForEach(configuration.table.rows.indices, id: \.self) { index in let backgroundStyle = index % 2 == 0 ? AnyShapeStyle(backgroundColor) : AnyShapeStyle(alternativeRowColor) - row + configuration.table.rows[index] .markdownTableRowBackgroundStyle(backgroundStyle) } } diff --git a/Sources/MarkdownView/Renderers/Node Representations/MarkdownList.swift b/Sources/MarkdownView/Renderers/Node Representations/MarkdownList.swift index aa7fa2a3..7b9c3a54 100644 --- a/Sources/MarkdownView/Renderers/Node Representations/MarkdownList.swift +++ b/Sources/MarkdownView/Renderers/Node Representations/MarkdownList.swift @@ -18,17 +18,18 @@ struct MarkdownList: View { } } + private var listItems: [ListItem] { + Array(listItemsContainer.listItems) + } + var body: some View { VStack(alignment: .leading, spacing: configuration.componentSpacing) { - ForEach( - Array(listItemsContainer.listItems.enumerated()), - id: \.offset - ) { (index, listItem) in + ForEach(listItems.indices, id: \.self) { index in HStack(alignment: .firstTextBaseline) { - CheckboxOrMarker(list: self, listItem: listItem, index: index) + CheckboxOrMarker(list: self, listItem: listItems[index], index: index) .padding(.leading, depth == 0 ? configuration.list.leadingIndentation : 0) CmarkNodeVisitor(configuration: configuration) - .makeBody(for: listItem) + .makeBody(for: listItems[index]) } } } @@ -71,12 +72,16 @@ struct MarkdownList: View { struct MarkdownListItem: View { var listItem: ListItem @Environment(\.markdownRendererConfiguration) private var configuration - + + private var children: [Markup] { + Array(listItem.children) + } + var body: some View { VStack(alignment: .leading, spacing: configuration.componentSpacing) { - ForEach(Array(listItem.children.enumerated()), id: \.offset) { (_, child) in + ForEach(children.indices, id: \.self) { index in CmarkNodeVisitor(configuration: configuration) - .makeBody(for: child) + .makeBody(for: children[index]) } } } diff --git a/Sources/MarkdownView/Renderers/Node Representations/Tables/MarkdownTable.swift b/Sources/MarkdownView/Renderers/Node Representations/Tables/MarkdownTable.swift index 9fa97e85..bfc00ec5 100644 --- a/Sources/MarkdownView/Renderers/Node Representations/Tables/MarkdownTable.swift +++ b/Sources/MarkdownView/Renderers/Node Representations/Tables/MarkdownTable.swift @@ -23,14 +23,18 @@ extension MarkdownTable { struct MarkdownTableBody: View { var tableBody: Markdown.Table.Body - + @Environment(\.markdownRendererConfiguration) private var configuration - + + private var rows: [Markup] { + Array(tableBody.children) + } + var body: some View { let font = configuration.fonts[.tableBody] ?? .body - ForEach(Array(tableBody.children.enumerated()), id: \.offset) { (_, row) in + ForEach(rows.indices, id: \.self) { index in CmarkNodeVisitor(configuration: configuration) - .makeBody(for: row) + .makeBody(for: rows[index]) .font(font) } } diff --git a/Sources/MarkdownView/Renderers/Node Representations/Tables/MarkdownTableRow.swift b/Sources/MarkdownView/Renderers/Node Representations/Tables/MarkdownTableRow.swift index 5eac6bfb..9c6971c8 100644 --- a/Sources/MarkdownView/Renderers/Node Representations/Tables/MarkdownTableRow.swift +++ b/Sources/MarkdownView/Renderers/Node Representations/Tables/MarkdownTableRow.swift @@ -22,12 +22,12 @@ struct MarkdownTableRow: View { var body: some View { if #available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) { GridRow { - ForEach(Array(cells.enumerated()), id: \.offset) { (index, cell) in + ForEach(cells.indices, id: \.self) { index in CmarkNodeVisitor(configuration: configuration) - .makeBody(for: cell) - .multilineTextAlignment(cell.textAlignment) - .gridColumnAlignment(cell.horizontalAlignment) - .gridCellColumns(Int(cell.colspan)) + .makeBody(for: cells[index]) + .multilineTextAlignment(cells[index].textAlignment) + .gridColumnAlignment(cells[index].horizontalAlignment) + .gridCellColumns(Int(cells[index].colspan)) ._markdownCellPadding(padding) .modifier( MarkdownTableStylePreferenceSynchronizer( From c9afd418ae04591571ef96f3ab01d8048d9b43a8 Mon Sep 17 00:00:00 2001 From: Henry Rausch Date: Fri, 6 Mar 2026 12:35:48 +0100 Subject: [PATCH 08/13] Refactor visitor and node view structures --- .../Renderers/Cmark/CmarkNodeVisitor.swift | 40 +++++++++---------- .../MarkdownNodeView.swift | 27 ++++++------- 2 files changed, 30 insertions(+), 37 deletions(-) diff --git a/Sources/MarkdownView/Renderers/Cmark/CmarkNodeVisitor.swift b/Sources/MarkdownView/Renderers/Cmark/CmarkNodeVisitor.swift index d333e749..8a8fcc14 100644 --- a/Sources/MarkdownView/Renderers/Cmark/CmarkNodeVisitor.swift +++ b/Sources/MarkdownView/Renderers/Cmark/CmarkNodeVisitor.swift @@ -25,23 +25,21 @@ struct CmarkNodeVisitor: @preconcurrency MarkupVisitor { } func visitDocument(_ document: Document) -> MarkdownNodeView { - var renderer = self + var visitor = self let nodeViews = document.children.map { - renderer.visit($0) + visitor.visit($0) } return MarkdownNodeView(nodeViews, layoutPolicy: .linebreak) } - + func defaultVisit(_ markup: Markdown.Markup) -> MarkdownNodeView { descendInto(markup) } - + func descendInto(_ markup: any Markup) -> MarkdownNodeView { - var nodeViews = [MarkdownNodeView]() - for child in markup.children { - var renderer = self - let nodeView = renderer.visit(child) - nodeViews.append(nodeView) + var visitor = self + let nodeViews = markup.children.map { + visitor.visit($0) } return MarkdownNodeView(nodeViews) } @@ -170,11 +168,9 @@ struct CmarkNodeVisitor: @preconcurrency MarkupVisitor { } func visitTableCell(_ cell: Markdown.Table.Cell) -> MarkdownNodeView { - var cellViews = [MarkdownNodeView]() - for child in cell.children { - var renderer = CmarkNodeVisitor(configuration: configuration) - let cellView = renderer.visit(child) - cellViews.append(cellView) + var visitor = self + let cellViews = cell.children.map { + visitor.visit($0) } return MarkdownNodeView( cellViews, @@ -193,10 +189,10 @@ struct CmarkNodeVisitor: @preconcurrency MarkupVisitor { } func visitEmphasis(_ emphasis: Markdown.Emphasis) -> MarkdownNodeView { + var visitor = self var attributedString = AttributedString() for child in emphasis.children { - var renderer = self - guard let text = renderer.visit(child).asAttributedString else { continue } + guard let text = visitor.visit(child).asAttributedString else { continue } let intent = text.inlinePresentationIntent ?? [] attributedString += text.mergingAttributes( AttributeContainer() @@ -205,12 +201,12 @@ struct CmarkNodeVisitor: @preconcurrency MarkupVisitor { } return MarkdownNodeView(attributedString) } - + func visitStrong(_ strong: Strong) -> MarkdownNodeView { + var visitor = self var attributedString = AttributedString() for child in strong.children { - var renderer = self - guard let text = renderer.visit(child).asAttributedString else { continue } + guard let text = visitor.visit(child).asAttributedString else { continue } let intent = text.inlinePresentationIntent ?? [] attributedString += text.mergingAttributes( AttributeContainer() @@ -219,12 +215,12 @@ struct CmarkNodeVisitor: @preconcurrency MarkupVisitor { } return MarkdownNodeView(attributedString) } - + func visitStrikethrough(_ strikethrough: Strikethrough) -> MarkdownNodeView { + var visitor = self var attributedString = AttributedString() for child in strikethrough.children { - var renderer = self - guard let text = renderer.visit(child).asAttributedString else { continue } + guard let text = visitor.visit(child).asAttributedString else { continue } let intent = text.inlinePresentationIntent ?? [] attributedString += text.mergingAttributes( AttributeContainer() diff --git a/Sources/MarkdownView/Renderers/Node Representations/MarkdownNodeView.swift b/Sources/MarkdownView/Renderers/Node Representations/MarkdownNodeView.swift index 9eb6b574..684f80bc 100644 --- a/Sources/MarkdownView/Renderers/Node Representations/MarkdownNodeView.swift +++ b/Sources/MarkdownView/Renderers/Node Representations/MarkdownNodeView.swift @@ -35,15 +35,16 @@ struct MarkdownNodeView: View { } var body: some View { - Group { - if case .left(let attributedString) = storage { - MarkdownText(attributedString) - } else if case .right(let view) = storage { - view - } + switch storage { + case .left(let attributedString): + MarkdownText(attributedString) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + case .right(let view): + view + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) } - .lineLimit(nil) - .fixedSize(horizontal: false, vertical: true) } var asAttributedString: AttributedString? { @@ -89,24 +90,20 @@ extension MarkdownNodeView { } if composedContents.count == 1 { - if let attributedString = composedContents[0].asAttributedString { - storage = .left(attributedString) - } else { - storage = .right(AnyView(composedContents[0].body)) - } + storage = composedContents[0].storage } else { if layoutPolicy == .adaptive, #available(iOS 16.0, macOS 13.0, watchOS 9.0, tvOS 16.0, *) { let composedView = FlowLayout(verticleSpacing: 8) { ForEach(composedContents.indices, id: \.self) { - composedContents[$0].body + composedContents[$0] } } storage = .right(AnyView(composedView)) } else { let composedView = VStack(alignment: alignment, spacing: 8) { ForEach(composedContents.indices, id: \.self) { - composedContents[$0].body + composedContents[$0] } } storage = .right(AnyView(composedView)) From 23b469be82046c26b6a8da182b0121123a1c9cfe Mon Sep 17 00:00:00 2001 From: Henry Rausch Date: Fri, 6 Mar 2026 12:35:48 +0100 Subject: [PATCH 09/13] Improve Markdown rendering performance and state management --- Sources/MarkdownView/MarkdownReader.swift | 12 +-- Sources/MarkdownView/MarkdownView.swift | 80 ++++++++++++++++--- .../MarkdownTableOfContent.swift | 26 ++++-- 3 files changed, 93 insertions(+), 25 deletions(-) diff --git a/Sources/MarkdownView/MarkdownReader.swift b/Sources/MarkdownView/MarkdownReader.swift index 6f9350d2..71a3ce53 100644 --- a/Sources/MarkdownView/MarkdownReader.swift +++ b/Sources/MarkdownView/MarkdownReader.swift @@ -21,25 +21,25 @@ import SwiftUI /// } /// ``` public struct MarkdownReader: View { - @ObservedObject private var content: MarkdownContent + @StateObject private var content: MarkdownContent private var _body: (_ markdownContent: MarkdownContent) -> Content - + public init( _ text: String, @ViewBuilder contents: @escaping (MarkdownContent) -> Content ) { - content = MarkdownContent(text) + _content = StateObject(wrappedValue: MarkdownContent(text)) self._body = contents } - + public init( _ url: URL, @ViewBuilder contents: @escaping (MarkdownContent) -> Content ) { - content = MarkdownContent(url) + _content = StateObject(wrappedValue: MarkdownContent(url)) self._body = contents } - + public var body: some View { _body(content) } diff --git a/Sources/MarkdownView/MarkdownView.swift b/Sources/MarkdownView/MarkdownView.swift index 9e3650ff..771107d6 100644 --- a/Sources/MarkdownView/MarkdownView.swift +++ b/Sources/MarkdownView/MarkdownView.swift @@ -3,37 +3,91 @@ import Markdown /// A view that renders markdown content. public struct MarkdownView: View { - @ObservedObject private var content: MarkdownContent - + /// Owned content for the string/URL inits — created once, survives parent re‑renders. + @StateObject private var ownedContent: MarkdownContent + /// External content passed via ``init(_:)-MarkdownContent``. + @ObservedObject private var externalContent: MarkdownContent + /// Which source of truth to use. + private var usesExternalContent: Bool + + private var content: MarkdownContent { + usesExternalContent ? externalContent : ownedContent + } + @Environment(\.markdownRendererConfiguration) private var configuration @Environment(\.markdownViewRenderer) private var renderer @Environment(\.headingStyleGroup) private var headingStyleGroup - + /// Creates a view that renders given markdown string. /// - Parameter text: The markdown source to render. public init(_ text: String) { - self.content = MarkdownContent(text) + let content = MarkdownContent(text) + _ownedContent = StateObject(wrappedValue: content) + _externalContent = ObservedObject(wrappedValue: content) + usesExternalContent = false } - + /// Creates a view that renders the markdown from a local file at given url. /// - Parameter url: The url to the markdown file to render. public init(_ url: URL) { - self.content = MarkdownContent(url) + let content = MarkdownContent(url) + _ownedContent = StateObject(wrappedValue: content) + _externalContent = ObservedObject(wrappedValue: content) + usesExternalContent = false } - + /// Creates an instance that renders from a ``MarkdownContent`` . /// - Parameter content: The ``MarkdownContent`` to render. public init(_ content: MarkdownContent) { - self.content = content + _ownedContent = StateObject(wrappedValue: content) + _externalContent = ObservedObject(wrappedValue: content) + usesExternalContent = true } - + public var body: some View { var config = configuration config.headingStyleGroup = headingStyleGroup - return renderer - .makeBody(content: content, configuration: config) - .erasedToAnyView() - .font(configuration.fonts[.body] ?? Font.body) + return MarkdownRenderingView( + content: content, + configuration: config + ) + .font(configuration.fonts[.body] ?? Font.body) + } +} + +/// A cache that stores the last rendered view alongside the inputs that +/// produced it. Because this is a reference type stored in `@State`, it +/// persists across body evaluations without triggering additional renders. +@MainActor +private final class RenderCache { + var raw: MarkdownContent.Raw? + var configuration: MarkdownRendererConfiguration? + var rendered: AnyView = AnyView(EmptyView()) +} + +/// An inner view that caches the rendered output. +/// +/// Rendering is skipped when the markdown source and configuration +/// have not changed since the last evaluation. +private struct MarkdownRenderingView: View { + let content: MarkdownContent + let configuration: MarkdownRendererConfiguration + + @Environment(\.markdownViewRenderer) private var renderer + @State private var cache = RenderCache() + + var body: some View { + let currentRaw = (try? content.markdown).map { MarkdownContent.Raw.plainText($0) } + if currentRaw != cache.raw || configuration != cache.configuration { + let rendered = renderer + .makeBody(content: content, configuration: configuration) + .erasedToAnyView() + cache.raw = currentRaw + cache.configuration = configuration + cache.rendered = rendered + return rendered + } + return cache.rendered } } diff --git a/Sources/MarkdownView/Table of Content/MarkdownTableOfContent.swift b/Sources/MarkdownView/Table of Content/MarkdownTableOfContent.swift index 751d7ce9..e691af18 100644 --- a/Sources/MarkdownView/Table of Content/MarkdownTableOfContent.swift +++ b/Sources/MarkdownView/Table of Content/MarkdownTableOfContent.swift @@ -7,32 +7,46 @@ import Markdown /// table of contents stays in sync. The easiest way to do this is to wrap both /// views in a ``MarkdownReader``. public struct MarkdownTableOfContent: View { - @ObservedObject private var content: MarkdownContent + @StateObject private var ownedContent: MarkdownContent + @ObservedObject private var externalContent: MarkdownContent + private var usesExternalContent: Bool private var contents: (_ headings: [MarkdownHeading]) -> Content + private var content: MarkdownContent { + usesExternalContent ? externalContent : ownedContent + } + public init( _ content: MarkdownContent, @ViewBuilder contents: @escaping ([MarkdownHeading]) -> Content ) { - self.content = content + _ownedContent = StateObject(wrappedValue: content) + _externalContent = ObservedObject(wrappedValue: content) + usesExternalContent = true self.contents = contents } - + @_disfavoredOverload public init( _ content: URL, @ViewBuilder contents: @escaping ([MarkdownHeading]) -> Content ) { - self.content = .init(content) + let mc = MarkdownContent(content) + _ownedContent = StateObject(wrappedValue: mc) + _externalContent = ObservedObject(wrappedValue: mc) + usesExternalContent = false self.contents = contents } - + @_disfavoredOverload public init( _ content: String, @ViewBuilder contents: @escaping ([MarkdownHeading]) -> Content ) { - self.content = .init(content) + let mc = MarkdownContent(content) + _ownedContent = StateObject(wrappedValue: mc) + _externalContent = ObservedObject(wrappedValue: mc) + usesExternalContent = false self.contents = contents } From 73ae6afe9739338605491769550104361c931157 Mon Sep 17 00:00:00 2001 From: Henry Rausch Date: Fri, 6 Mar 2026 12:35:48 +0100 Subject: [PATCH 10/13] Enhance HTML-to-AttributedString processing in MarkdownText --- .../Node Representations/MarkdownText.swift | 60 ++++++++++--------- 1 file changed, 33 insertions(+), 27 deletions(-) diff --git a/Sources/MarkdownView/Renderers/Node Representations/MarkdownText.swift b/Sources/MarkdownView/Renderers/Node Representations/MarkdownText.swift index f69bd362..43c5d1cd 100644 --- a/Sources/MarkdownView/Renderers/Node Representations/MarkdownText.swift +++ b/Sources/MarkdownView/Renderers/Node Representations/MarkdownText.swift @@ -12,38 +12,44 @@ import SwiftUI /// Convert HTML into `AttributedString` asynchronously to avoid `AttributeGraph` crash. struct MarkdownText: View { var text: AttributedString - @State private var attributedString: AttributedString? - + @State private var resolvedString: AttributedString? + @State private var lastInput: AttributedString? + + private var containsHTML: Bool { + text.runs.contains { $0.isHTML ?? false } + } + init(_ text: AttributedString) { self.text = text } - + var body: some View { - Group { - if let attributedString { - Text(attributedString) - } else { - Text(text) - } - } - .task(id: text) { - var attributedString = text - for run in text.runs.reversed() where (run.isHTML ?? false) { - let range = run.range - - if let htmlAttrString = try? AttributedString( - NSAttributedString( - data: Data(String(text.characters[range]).utf8), - options: [ - .documentType: NSAttributedString.DocumentType.html - ], - documentAttributes: nil - ) - ) { - attributedString.replaceSubrange(range, with: htmlAttrString) + Text(resolvedString ?? text) + .task(id: text) { + guard containsHTML else { + resolvedString = nil + lastInput = text + return + } + guard text != lastInput else { return } + lastInput = text + + var result = text + for run in text.runs.reversed() where (run.isHTML ?? false) { + let range = run.range + if let htmlAttrString = try? AttributedString( + NSAttributedString( + data: Data(String(text.characters[range]).utf8), + options: [ + .documentType: NSAttributedString.DocumentType.html + ], + documentAttributes: nil + ) + ) { + result.replaceSubrange(range, with: htmlAttrString) + } } + resolvedString = result } - self.attributedString = attributedString - } } } From 995d873fc88d72eb9d3495a2eb45ecbdb370940a Mon Sep 17 00:00:00 2001 From: Henry Rausch Date: Mon, 9 Mar 2026 23:12:05 +0100 Subject: [PATCH 11/13] feat: make markdownViewRenderer environment key public Allows consumers to force a specific renderer (e.g. .view) via .environment(\.markdownViewRenderer, .view), which is needed for SwiftUI ImageRenderer compatibility on macOS 26+. --- Sources/MarkdownView/Renderers/MarkdownViewRenderer.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/MarkdownView/Renderers/MarkdownViewRenderer.swift b/Sources/MarkdownView/Renderers/MarkdownViewRenderer.swift index dbfd4261..4b602691 100644 --- a/Sources/MarkdownView/Renderers/MarkdownViewRenderer.swift +++ b/Sources/MarkdownView/Renderers/MarkdownViewRenderer.swift @@ -120,11 +120,11 @@ extension MarkdownViewRenderer { } } -struct MarkdownViewRendererKey: EnvironmentKey { - nonisolated(unsafe) static let defaultValue: any MarkdownViewRenderer = .automatic +public struct MarkdownViewRendererKey: EnvironmentKey { + nonisolated(unsafe) public static let defaultValue: any MarkdownViewRenderer = .automatic } -extension EnvironmentValues { +public extension EnvironmentValues { var markdownViewRenderer: any MarkdownViewRenderer { get { self[MarkdownViewRendererKey.self] } set { self[MarkdownViewRendererKey.self] = newValue } From d2909870b1d9e89f6ed3cf44f682e6bd7f178626 Mon Sep 17 00:00:00 2001 From: Yanan Li <1474700628@qq.com> Date: Tue, 10 Mar 2026 14:34:59 +0800 Subject: [PATCH 12/13] Revert some code --- Sources/MarkdownView/MarkdownView.swift | 83 +++---------------- .../MarkdownTableOfContent.swift | 26 ++---- 2 files changed, 19 insertions(+), 90 deletions(-) diff --git a/Sources/MarkdownView/MarkdownView.swift b/Sources/MarkdownView/MarkdownView.swift index 771107d6..4a3c8291 100644 --- a/Sources/MarkdownView/MarkdownView.swift +++ b/Sources/MarkdownView/MarkdownView.swift @@ -3,91 +3,34 @@ import Markdown /// A view that renders markdown content. public struct MarkdownView: View { - /// Owned content for the string/URL inits — created once, survives parent re‑renders. - @StateObject private var ownedContent: MarkdownContent - /// External content passed via ``init(_:)-MarkdownContent``. - @ObservedObject private var externalContent: MarkdownContent - /// Which source of truth to use. - private var usesExternalContent: Bool - - private var content: MarkdownContent { - usesExternalContent ? externalContent : ownedContent - } - + @ObservedObject private var content: MarkdownContent + @Environment(\.markdownRendererConfiguration) private var configuration @Environment(\.markdownViewRenderer) private var renderer - @Environment(\.headingStyleGroup) private var headingStyleGroup - + /// Creates a view that renders given markdown string. /// - Parameter text: The markdown source to render. public init(_ text: String) { - let content = MarkdownContent(text) - _ownedContent = StateObject(wrappedValue: content) - _externalContent = ObservedObject(wrappedValue: content) - usesExternalContent = false + self.content = MarkdownContent(text) } - + /// Creates a view that renders the markdown from a local file at given url. /// - Parameter url: The url to the markdown file to render. public init(_ url: URL) { - let content = MarkdownContent(url) - _ownedContent = StateObject(wrappedValue: content) - _externalContent = ObservedObject(wrappedValue: content) - usesExternalContent = false + self.content = MarkdownContent(url) } - + /// Creates an instance that renders from a ``MarkdownContent`` . /// - Parameter content: The ``MarkdownContent`` to render. public init(_ content: MarkdownContent) { - _ownedContent = StateObject(wrappedValue: content) - _externalContent = ObservedObject(wrappedValue: content) - usesExternalContent = true + self.content = content } - + public var body: some View { - var config = configuration - config.headingStyleGroup = headingStyleGroup - return MarkdownRenderingView( - content: content, - configuration: config - ) - .font(configuration.fonts[.body] ?? Font.body) - } -} - -/// A cache that stores the last rendered view alongside the inputs that -/// produced it. Because this is a reference type stored in `@State`, it -/// persists across body evaluations without triggering additional renders. -@MainActor -private final class RenderCache { - var raw: MarkdownContent.Raw? - var configuration: MarkdownRendererConfiguration? - var rendered: AnyView = AnyView(EmptyView()) -} - -/// An inner view that caches the rendered output. -/// -/// Rendering is skipped when the markdown source and configuration -/// have not changed since the last evaluation. -private struct MarkdownRenderingView: View { - let content: MarkdownContent - let configuration: MarkdownRendererConfiguration - - @Environment(\.markdownViewRenderer) private var renderer - @State private var cache = RenderCache() - - var body: some View { - let currentRaw = (try? content.markdown).map { MarkdownContent.Raw.plainText($0) } - if currentRaw != cache.raw || configuration != cache.configuration { - let rendered = renderer - .makeBody(content: content, configuration: configuration) - .erasedToAnyView() - cache.raw = currentRaw - cache.configuration = configuration - cache.rendered = rendered - return rendered - } - return cache.rendered + renderer + .makeBody(content: content, configuration: configuration) + .erasedToAnyView() + .font(configuration.fonts[.body] ?? Font.body) } } diff --git a/Sources/MarkdownView/Table of Content/MarkdownTableOfContent.swift b/Sources/MarkdownView/Table of Content/MarkdownTableOfContent.swift index e691af18..751d7ce9 100644 --- a/Sources/MarkdownView/Table of Content/MarkdownTableOfContent.swift +++ b/Sources/MarkdownView/Table of Content/MarkdownTableOfContent.swift @@ -7,46 +7,32 @@ import Markdown /// table of contents stays in sync. The easiest way to do this is to wrap both /// views in a ``MarkdownReader``. public struct MarkdownTableOfContent: View { - @StateObject private var ownedContent: MarkdownContent - @ObservedObject private var externalContent: MarkdownContent - private var usesExternalContent: Bool + @ObservedObject private var content: MarkdownContent private var contents: (_ headings: [MarkdownHeading]) -> Content - private var content: MarkdownContent { - usesExternalContent ? externalContent : ownedContent - } - public init( _ content: MarkdownContent, @ViewBuilder contents: @escaping ([MarkdownHeading]) -> Content ) { - _ownedContent = StateObject(wrappedValue: content) - _externalContent = ObservedObject(wrappedValue: content) - usesExternalContent = true + self.content = content self.contents = contents } - + @_disfavoredOverload public init( _ content: URL, @ViewBuilder contents: @escaping ([MarkdownHeading]) -> Content ) { - let mc = MarkdownContent(content) - _ownedContent = StateObject(wrappedValue: mc) - _externalContent = ObservedObject(wrappedValue: mc) - usesExternalContent = false + self.content = .init(content) self.contents = contents } - + @_disfavoredOverload public init( _ content: String, @ViewBuilder contents: @escaping ([MarkdownHeading]) -> Content ) { - let mc = MarkdownContent(content) - _ownedContent = StateObject(wrappedValue: mc) - _externalContent = ObservedObject(wrappedValue: mc) - usesExternalContent = false + self.content = .init(content) self.contents = contents } From 0c36ee7e0430b2219493b1553962516732af66c3 Mon Sep 17 00:00:00 2001 From: Yanan Li <1474700628@qq.com> Date: Tue, 10 Mar 2026 14:41:53 +0800 Subject: [PATCH 13/13] Update `headingStyleGroup` view modifier --- .../Heading/HeadingStyleModifier.swift | 35 +++++++++++-------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/Sources/MarkdownView/View Modifiers/Heading/HeadingStyleModifier.swift b/Sources/MarkdownView/View Modifiers/Heading/HeadingStyleModifier.swift index 1a6fe99d..fbe9c745 100644 --- a/Sources/MarkdownView/View Modifiers/Heading/HeadingStyleModifier.swift +++ b/Sources/MarkdownView/View Modifiers/Heading/HeadingStyleModifier.swift @@ -7,7 +7,7 @@ import SwiftUI -extension View { +extension SwiftUI.View { /// Apply a foreground style group to MarkdownView. /// /// This is useful when you want to completely customize foreground styles. @@ -16,20 +16,11 @@ extension View { nonisolated public func headingStyleGroup( _ group: some HeadingStyleGroup ) -> some View { - environment(\.headingStyleGroup, AnyHeadingStyleGroup(group)) - } - - /// Apply a foreground style group to MarkdownView. - /// - /// This is useful when you want to completely customize foreground styles. - /// - /// - Parameter group: A style set to apply to the MarkdownView. - @available(*, deprecated, renamed: "headingStyleGroup") - nonisolated public func foregroundStyleGroup( - _ group: some HeadingStyleGroup - ) -> some View { - headingStyleGroup(group) + transformEnvironment(\.markdownRendererConfiguration) { configuration in + configuration.headingStyleGroup = AnyHeadingStyleGroup(group) + } } + /// Sets foreground style for the specific component in MarkdownView. /// @@ -52,6 +43,22 @@ extension View { } } } +} + +// MARK: - Deprecated + +extension SwiftUI.View { + /// Apply a foreground style group to MarkdownView. + /// + /// This is useful when you want to completely customize foreground styles. + /// + /// - Parameter group: A style set to apply to the MarkdownView. + @available(*, deprecated, renamed: "headingStyleGroup") + nonisolated public func foregroundStyleGroup( + _ group: some HeadingStyleGroup + ) -> some View { + headingStyleGroup(group) + } /// Sets foreground style for the specific component in MarkdownView. ///