From 5c760b3410cf5f6b07cd507c65ecbdbc6174b634 Mon Sep 17 00:00:00 2001 From: Yanan Li <1474700628@qq.com> Date: Mon, 27 Oct 2025 17:11:43 +0800 Subject: [PATCH 01/27] Re-organizing source files --- .../MarkdownListConfiguration.swift | 148 ----------- .../MarkdownRendererConfiguration.Math.swift | 34 --- .../MarkdownRendererConfiguration.swift | 38 --- .../Block Quotes}/BlockQuoteStyle.swift | 0 .../DefaultBlockQuoteStyle.swift | 0 .../Block Quotes}/GithubBlockQuoteStyle.swift | 0 .../Code Blocks}/CodeBlockStyle.swift | 0 .../Code Blocks}/DefaultCodeBlockStyle.swift | 0 .../CodeHighlighterTheme.swift | 0 .../Font/AnyMarkdownFontGroup.swift | 0 .../Font/DefaultFontGroup.swift | 0 .../Font/MarkdownFontGroup.swift | 0 .../Font/MarkdownTextType.swift | 0 .../AnyForegroundStyleGroup.swift | 0 .../AutomaticForegroundStyleGroup.swift | 0 .../HeadingStyleGroup.swift | 0 .../MarkdownStyleTarget.swift | 0 .../Heading}/HeadingLevel.swift | 0 .../Heading}/HeadingPaddings.swift | 0 .../AnyOrderedListMarkerProtocol.swift | 23 ++ .../OrderedListIncreasingDigitsMarker.swift | 22 ++ .../OrderedListIncreasingLettersMarker.swift | 39 +++ .../OrderedListMarkerProtocol.swift | 23 ++ .../AnyUnorderedListMarkerProtocol.swift | 23 ++ .../UnorderedListBulletMarker.swift | 20 ++ .../UnorderedListDashMarker.swift | 20 ++ .../UnorderedListMarkerProtocol.swift | 23 ++ .../2.0}/DeprecatedAPIs.swift | 0 .../2.0}/RenderingModeModifier.swift | 0 .../Extensions/Swift/ActorIsolated.swift | 9 - .../AnyShape.swift | 0 .../View+OnValueChange.swift} | 2 +- .../Helpers/KeyPathModifiable.swift | 27 ++ .../Markdown/BlockQuote.swift | 0 .../Markdown/ListItemContainer.swift | 0 .../Markdown/Markdown+Sendable.swift | 0 .../Markdown/Markup.swift | 0 .../Markdown/SourceLocation.swift | 0 .../Markdown/Table++.swift | 0 .../AllowingModifyThroughKeyPath.swift | 18 -- .../{Layouts => SwiftUI}/FlowLayout.swift | 1 - .../SwiftUI/Image++.swift | 0 .../SwiftUI/View++.swift | 0 .../Text Manipulations/TextBuilder.swift | 50 ---- .../{Helpers => }/MarkdownContent.swift | 36 +-- Sources/MarkdownView/MarkdownView.swift | 14 +- .../Modifiers/BaseURLModifier.swift | 22 -- .../BlockDirectiveRendererModifier.swift | 24 -- .../MarkdownView/Modifiers/ListModifier.swift | 34 --- .../MarkdownImageRendererModifier.swift | 24 -- .../Modifiers/MathRenderingModifier.swift | 26 -- .../Renderers/Cmark/CmarkNodeVisitor.swift | 4 +- .../MarkdownRendererConfiguration.swift | 68 +++++ .../MathBlockDirectiveRenderer.swift | 2 +- ...arkdownHeading.swift => HeadingText.swift} | 4 +- .../Protocol/MarkdownImageRenderer.swift | 23 +- .../MarkdownBlockQuote.swift | 0 .../Node Representations/MarkdownList.swift | 6 +- .../MarkdownNodeView.swift | 2 +- .../MarkdownStyledCodeBlock.swift | 0 ..._MarkdownText.swift => MarkdownText.swift} | 4 +- .../Renderers/RawMarkdownContent.swift | 34 +++ .../MarkdownTableOfContent.swift | 11 +- .../Behaviors/BaseURLModifier.swift | 44 ++++ .../Behaviors/MathRenderingModifier.swift | 39 +++ .../BlockDirectiveRendererModifier.swift | 49 ++++ .../ImageRendererModifier.swift | 54 ++++ .../Heading/HeadingPaddingModifier.swift | 0 .../Heading/HeadingStyleModifier.swift | 0 .../MarkdownViewStyle.swift | 0 .../Stylings}/BlockQuoteStyleModifier.swift | 0 .../Stylings}/CodeBlockModifier.swift | 0 .../Stylings/ListModifier.swift | 56 ++++ .../Stylings}/TintColorModifier.swift | 2 +- .../MarkdownTableCellOverlayModifier.swift | 0 .../MarkdownTableCellPaddingModifier.swift | 0 ...kdownTableRowBackgroundStyleModifier.swift | 0 .../Table/TableStyleModifier.swift | 0 .../Text Formatting}/FontModifier.swift | 0 Sources/MarkdownView/WIP/MarkdownText.swift | 102 -------- .../MarkdownView/WIP/MarkdownTextKind.swift | 35 --- .../MarkdownView/WIP/MarkdownTextNode.swift | 152 ----------- .../WIP/MarkdownTextRenderer.Visit.swift | 247 ------------------ .../WIP/MarkdownTextRenderer.swift | 19 -- .../BreakTextRenderer.swift | 31 --- .../CodeBlockTextRenderer.swift | 132 ---------- .../FormattedTextRenderer.swift | 27 -- .../HeadingTextRenderer.swift | 54 ---- .../ImageTextRenderer.swift | 42 --- .../InlineCodeTextRenderer.swift | 20 -- .../LinkTextRenderer.swift | 16 -- .../ListItemTextRenderer.swift | 16 -- .../OrderedListTextRenderer.swift | 44 ---- .../ParagraphTextRenderer.swift | 18 -- .../Protocol/MarkdownNode2TextRenderer.swift | 29 -- .../UnorderedListTextRenderer.swift | 42 --- 96 files changed, 622 insertions(+), 1506 deletions(-) delete mode 100644 Sources/MarkdownView/Configurations/MarkdownListConfiguration.swift delete mode 100644 Sources/MarkdownView/Configurations/MarkdownRendererConfiguration.Math.swift delete mode 100644 Sources/MarkdownView/Configurations/MarkdownRendererConfiguration.swift rename Sources/MarkdownView/{Renderers/Node Representations/Block Quotes/Protocol => Customizations/Block Quotes}/BlockQuoteStyle.swift (100%) rename Sources/MarkdownView/{Renderers/Node Representations/Block Quotes/Styles => Customizations/Block Quotes}/DefaultBlockQuoteStyle.swift (100%) rename Sources/MarkdownView/{Renderers/Node Representations/Block Quotes/Styles => Customizations/Block Quotes}/GithubBlockQuoteStyle.swift (100%) rename Sources/MarkdownView/{Renderers/Node Representations/Code Blocks/Protocol => Customizations/Code Blocks}/CodeBlockStyle.swift (100%) rename Sources/MarkdownView/{Renderers/Node Representations/Code Blocks/Styles => Customizations/Code Blocks}/DefaultCodeBlockStyle.swift (100%) rename Sources/MarkdownView/{Configurations => Customizations}/CodeHighlighterTheme.swift (100%) rename Sources/MarkdownView/{Configurations => Customizations}/Font/AnyMarkdownFontGroup.swift (100%) rename Sources/MarkdownView/{Configurations => Customizations}/Font/DefaultFontGroup.swift (100%) rename Sources/MarkdownView/{Configurations => Customizations}/Font/MarkdownFontGroup.swift (100%) rename Sources/MarkdownView/{Configurations => Customizations}/Font/MarkdownTextType.swift (100%) rename Sources/MarkdownView/{Configurations/ForegroundStyle => Customizations/Foreground Styles}/AnyForegroundStyleGroup.swift (100%) rename Sources/MarkdownView/{Configurations/ForegroundStyle => Customizations/Foreground Styles}/AutomaticForegroundStyleGroup.swift (100%) rename Sources/MarkdownView/{Configurations/ForegroundStyle => Customizations/Foreground Styles}/HeadingStyleGroup.swift (100%) rename Sources/MarkdownView/{Configurations/ForegroundStyle => Customizations/Foreground Styles}/MarkdownStyleTarget.swift (100%) rename Sources/MarkdownView/{Renderers/Node Representations/Heading/Configurations => Customizations/Heading}/HeadingLevel.swift (100%) rename Sources/MarkdownView/{Renderers/Node Representations/Heading/Configurations => Customizations/Heading}/HeadingPaddings.swift (100%) create mode 100644 Sources/MarkdownView/Customizations/List/Ordered List Marker/AnyOrderedListMarkerProtocol.swift create mode 100644 Sources/MarkdownView/Customizations/List/Ordered List Marker/OrderedListIncreasingDigitsMarker.swift create mode 100644 Sources/MarkdownView/Customizations/List/Ordered List Marker/OrderedListIncreasingLettersMarker.swift create mode 100644 Sources/MarkdownView/Customizations/List/Ordered List Marker/OrderedListMarkerProtocol.swift create mode 100644 Sources/MarkdownView/Customizations/List/Unordered List Marker/AnyUnorderedListMarkerProtocol.swift create mode 100644 Sources/MarkdownView/Customizations/List/Unordered List Marker/UnorderedListBulletMarker.swift create mode 100644 Sources/MarkdownView/Customizations/List/Unordered List Marker/UnorderedListDashMarker.swift create mode 100644 Sources/MarkdownView/Customizations/List/Unordered List Marker/UnorderedListMarkerProtocol.swift rename Sources/MarkdownView/{ => Deprecated & Unavailable APIs/2.0}/DeprecatedAPIs.swift (100%) rename Sources/MarkdownView/{Modifiers => Deprecated & Unavailable APIs/2.0}/RenderingModeModifier.swift (100%) delete mode 100644 Sources/MarkdownView/Extensions/Swift/ActorIsolated.swift rename Sources/MarkdownView/Helpers/{SwiftUI => Backward Capabilities}/AnyShape.swift (100%) rename Sources/MarkdownView/Helpers/{View Modifiers/OnValueChange.swift => Backward Capabilities/View+OnValueChange.swift} (99%) create mode 100644 Sources/MarkdownView/Helpers/KeyPathModifiable.swift rename Sources/MarkdownView/{Extensions => Helpers}/Markdown/BlockQuote.swift (100%) rename Sources/MarkdownView/{Extensions => Helpers}/Markdown/ListItemContainer.swift (100%) rename Sources/MarkdownView/{Extensions => Helpers}/Markdown/Markdown+Sendable.swift (100%) rename Sources/MarkdownView/{Extensions => Helpers}/Markdown/Markup.swift (100%) rename Sources/MarkdownView/{Extensions => Helpers}/Markdown/SourceLocation.swift (100%) rename Sources/MarkdownView/{Extensions => Helpers}/Markdown/Table++.swift (100%) delete mode 100644 Sources/MarkdownView/Helpers/Protocols/AllowingModifyThroughKeyPath.swift rename Sources/MarkdownView/Helpers/{Layouts => SwiftUI}/FlowLayout.swift (99%) rename Sources/MarkdownView/{Extensions => Helpers}/SwiftUI/Image++.swift (100%) rename Sources/MarkdownView/{Extensions => Helpers}/SwiftUI/View++.swift (100%) delete mode 100644 Sources/MarkdownView/Helpers/Text Manipulations/TextBuilder.swift rename Sources/MarkdownView/{Helpers => }/MarkdownContent.swift (72%) delete mode 100644 Sources/MarkdownView/Modifiers/BaseURLModifier.swift delete mode 100644 Sources/MarkdownView/Modifiers/BlockDirectiveRendererModifier.swift delete mode 100644 Sources/MarkdownView/Modifiers/ListModifier.swift delete mode 100644 Sources/MarkdownView/Modifiers/MarkdownImageRendererModifier.swift delete mode 100644 Sources/MarkdownView/Modifiers/MathRenderingModifier.swift create mode 100644 Sources/MarkdownView/Renderers/MarkdownRendererConfiguration.swift rename Sources/MarkdownView/Renderers/Node Representations/{Heading/MarkdownHeading.swift => HeadingText.swift} (96%) rename Sources/MarkdownView/Renderers/Node Representations/{Block Quotes => }/MarkdownBlockQuote.swift (100%) rename Sources/MarkdownView/Renderers/Node Representations/{Code Blocks => }/MarkdownStyledCodeBlock.swift (100%) rename Sources/MarkdownView/Renderers/Node Representations/{_MarkdownText.swift => MarkdownText.swift} (96%) create mode 100644 Sources/MarkdownView/Renderers/RawMarkdownContent.swift rename Sources/MarkdownView/{ => Table of Content}/MarkdownTableOfContent.swift (79%) create mode 100644 Sources/MarkdownView/View Modifiers/Behaviors/BaseURLModifier.swift create mode 100644 Sources/MarkdownView/View Modifiers/Behaviors/MathRenderingModifier.swift create mode 100644 Sources/MarkdownView/View Modifiers/Custom Renderers/BlockDirectiveRendererModifier.swift create mode 100644 Sources/MarkdownView/View Modifiers/Custom Renderers/ImageRendererModifier.swift rename Sources/MarkdownView/{Modifiers => View Modifiers}/Heading/HeadingPaddingModifier.swift (100%) rename Sources/MarkdownView/{Modifiers => View Modifiers}/Heading/HeadingStyleModifier.swift (100%) rename Sources/MarkdownView/{Modifiers => View Modifiers}/MarkdownViewStyle.swift (100%) rename Sources/MarkdownView/{Modifiers => View Modifiers/Stylings}/BlockQuoteStyleModifier.swift (100%) rename Sources/MarkdownView/{Modifiers => View Modifiers/Stylings}/CodeBlockModifier.swift (100%) create mode 100644 Sources/MarkdownView/View Modifiers/Stylings/ListModifier.swift rename Sources/MarkdownView/{Modifiers => View Modifiers/Stylings}/TintColorModifier.swift (94%) rename Sources/MarkdownView/{Modifiers => View Modifiers}/Table/MarkdownTableCellOverlayModifier.swift (100%) rename Sources/MarkdownView/{Modifiers => View Modifiers}/Table/MarkdownTableCellPaddingModifier.swift (100%) rename Sources/MarkdownView/{Modifiers => View Modifiers}/Table/MarkdownTableRowBackgroundStyleModifier.swift (100%) rename Sources/MarkdownView/{Modifiers => View Modifiers}/Table/TableStyleModifier.swift (100%) rename Sources/MarkdownView/{Modifiers => View Modifiers/Text Formatting}/FontModifier.swift (100%) delete mode 100644 Sources/MarkdownView/WIP/MarkdownText.swift delete mode 100644 Sources/MarkdownView/WIP/MarkdownTextKind.swift delete mode 100644 Sources/MarkdownView/WIP/MarkdownTextNode.swift delete mode 100644 Sources/MarkdownView/WIP/MarkdownTextRenderer.Visit.swift delete mode 100644 Sources/MarkdownView/WIP/MarkdownTextRenderer.swift delete mode 100644 Sources/MarkdownView/WIP/Renderers/MarkdownText Components/BreakTextRenderer.swift delete mode 100644 Sources/MarkdownView/WIP/Renderers/MarkdownText Components/CodeBlockTextRenderer.swift delete mode 100644 Sources/MarkdownView/WIP/Renderers/MarkdownText Components/FormattedTextRenderer.swift delete mode 100644 Sources/MarkdownView/WIP/Renderers/MarkdownText Components/HeadingTextRenderer.swift delete mode 100644 Sources/MarkdownView/WIP/Renderers/MarkdownText Components/ImageTextRenderer.swift delete mode 100644 Sources/MarkdownView/WIP/Renderers/MarkdownText Components/InlineCodeTextRenderer.swift delete mode 100644 Sources/MarkdownView/WIP/Renderers/MarkdownText Components/LinkTextRenderer.swift delete mode 100644 Sources/MarkdownView/WIP/Renderers/MarkdownText Components/ListItemTextRenderer.swift delete mode 100644 Sources/MarkdownView/WIP/Renderers/MarkdownText Components/OrderedListTextRenderer.swift delete mode 100644 Sources/MarkdownView/WIP/Renderers/MarkdownText Components/ParagraphTextRenderer.swift delete mode 100644 Sources/MarkdownView/WIP/Renderers/MarkdownText Components/Protocol/MarkdownNode2TextRenderer.swift delete mode 100644 Sources/MarkdownView/WIP/Renderers/MarkdownText Components/UnorderedListTextRenderer.swift diff --git a/Sources/MarkdownView/Configurations/MarkdownListConfiguration.swift b/Sources/MarkdownView/Configurations/MarkdownListConfiguration.swift deleted file mode 100644 index 0867d0fb2..000000000 --- a/Sources/MarkdownView/Configurations/MarkdownListConfiguration.swift +++ /dev/null @@ -1,148 +0,0 @@ -// -// MarkdownListConfiguration.swift -// MarkdownView -// -// Created by LiYanan2004 on 2024/12/11. -// - -import Markdown -import Foundation - -struct MarkdownListConfiguration: Hashable, @unchecked Sendable { - var leadingIndentation: CGFloat = 12 - var unorderedListMarker = AnyUnorderedListMarkerProtocol(.bullet) - var orderedListMarker = AnyOrderedListMarkerProtocol(.increasingDigits) -} - -// MARK: - Ordered List Marker - -/// A type that represents the marker for ordered list items. -public protocol OrderedListMarkerProtocol: Hashable { - /// Returns a marker for a specific index of ordered list item. Index starting from 0. - func marker(at index: Int, listDepth: Int) -> String - - /// A boolean value indicates whether the marker should be monospaced, default value is `true`. - var monospaced: Bool { get } -} - -extension OrderedListMarkerProtocol { - public var monospaced: Bool { - true - } -} - -struct AnyOrderedListMarkerProtocol: OrderedListMarkerProtocol { - private var _marker: AnyHashable - var monospaced: Bool { - (_marker as! (any OrderedListMarkerProtocol)).monospaced - } - - init(_ marker: T) { - self._marker = AnyHashable(marker) - } - - public func marker(at index: Int, listDepth: Int) -> String { - (_marker as! (any OrderedListMarkerProtocol)).marker(at: index, listDepth: listDepth) - } -} - -/// An auto-increasing digits marker for ordered list items. -public struct OrderedListIncreasingDigitsMarker: OrderedListMarkerProtocol { - public func marker(at index: Int, listDepth: Int) -> String { - String(index + 1) + "." - } - - public var monospaced: Bool { false } -} - -extension OrderedListMarkerProtocol where Self == OrderedListIncreasingDigitsMarker { - /// An auto-increasing digits marker for ordered list items. - static public var increasingDigits: OrderedListIncreasingDigitsMarker { .init() } -} - -/// An auto-increasing letters marker for ordered list items. -public struct OrderedListIncreasingLettersMarker: OrderedListMarkerProtocol { - public func marker(at index: Int, listDepth: Int) -> String { - let base = 26 - var index = index - var result = "" - - // If index is smaller than 26, use single letter, otherwise, use double letters. - if index < base { - result = String(UnicodeScalar("a".unicodeScalars.first!.value + UInt32(index))!) - } else { - index -= base - let firstLetterIndex = index / base - let secondLetterIndex = index % base - let firstLetter = UnicodeScalar("a".unicodeScalars.first!.value + UInt32(firstLetterIndex))! - let secondLetter = UnicodeScalar("a".unicodeScalars.first!.value + UInt32(secondLetterIndex))! - result.append(Character(firstLetter)) - result.append(Character(secondLetter)) - } - - return result + "." - } - - public var monospaced: Bool { false } -} - -extension OrderedListMarkerProtocol where Self == OrderedListIncreasingLettersMarker { - /// An auto-increasing letters marker for ordered list items. - static public var increasingLetters: OrderedListIncreasingLettersMarker { .init() } -} - -// MARK: - Unordered List Marker - -/// A type that represents the marker for unordered list items. -public protocol UnorderedListMarkerProtocol: Hashable { - /// Returns a marker for a specific indentation level of unordered list item. indentationLevel starting from 0. - func marker(listDepth: Int) -> String - - /// A boolean value indicates whether the marker should be monospaced, default value is `true`. - var monospaced: Bool { get } -} - -extension UnorderedListMarkerProtocol { - public var monospaced: Bool { - true - } -} - -struct AnyUnorderedListMarkerProtocol: UnorderedListMarkerProtocol { - private var _marker: AnyHashable - var monospaced: Bool { - (_marker as! (any UnorderedListMarkerProtocol)).monospaced - } - - init(_ marker: T) { - self._marker = AnyHashable(marker) - } - - public func marker(listDepth: Int) -> String { - (_marker as! (any UnorderedListMarkerProtocol)).marker(listDepth: listDepth) - } -} - -/// A dash marker for unordered list items. -public struct UnorderedListDashMarker: UnorderedListMarkerProtocol { - public func marker(listDepth: Int) -> String { - "-" - } -} - -extension UnorderedListMarkerProtocol where Self == UnorderedListDashMarker { - /// A dash marker for unordered list items. - static public var dash: UnorderedListDashMarker { .init() } -} - -/// A bullet marker for unordered list items. -public struct UnorderedListBulletMarker: UnorderedListMarkerProtocol { - public func marker(listDepth: Int) -> String { - "•" - } -} - -extension UnorderedListMarkerProtocol where Self == UnorderedListBulletMarker { - /// A bullet marker for unordered list items. - static public var bullet: UnorderedListBulletMarker { .init() } -} diff --git a/Sources/MarkdownView/Configurations/MarkdownRendererConfiguration.Math.swift b/Sources/MarkdownView/Configurations/MarkdownRendererConfiguration.Math.swift deleted file mode 100644 index faac11ab7..000000000 --- a/Sources/MarkdownView/Configurations/MarkdownRendererConfiguration.Math.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// MarkdownRendererConfiguration.Math.swift -// MarkdownView -// -// Created by LiYanan2004 on 2025/4/16. -// - -import Foundation - -extension MarkdownRendererConfiguration { - struct Math: Sendable, Hashable { - var shouldRender: Bool { - get { displayMathStorage != nil } - set(enabled) { - if enabled { - displayMathStorage = [:] - } else { - displayMathStorage = nil - } - } - } - var displayMathStorage: [UUID : String]? = nil - - mutating func appendDisplayMath(_ displayMath: some StringProtocol) -> UUID { - if displayMathStorage == nil { - displayMathStorage = [:] - } - - let id = UUID() - displayMathStorage![id] = String(displayMath) - return id - } - } -} diff --git a/Sources/MarkdownView/Configurations/MarkdownRendererConfiguration.swift b/Sources/MarkdownView/Configurations/MarkdownRendererConfiguration.swift deleted file mode 100644 index fe99a7450..000000000 --- a/Sources/MarkdownView/Configurations/MarkdownRendererConfiguration.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// MarkdownRendererConfiguration.swift -// MarkdownView -// -// Created by LiYanan2004 on 2024/12/11. -// - -import Foundation -import SwiftUI - -struct MarkdownRendererConfiguration: Equatable, AllowingModifyThroughKeyPath, Sendable { - var preferredBaseURL: URL? - var componentSpacing: CGFloat = 8 - - var math: Math = Math() - - var linkTintColor: Color = .accentColor - var inlineCodeTintColor: Color = .accentColor - var blockQuoteTintColor: Color = .accentColor - - var listConfiguration: MarkdownListConfiguration = MarkdownListConfiguration() - - var allowedImageRenderers: Set = ["https", "http"] - var allowedBlockDirectiveRenderers: Set = [] -} - -// MARK: - SwiftUI Environment - -struct MarkdownRendererConfigurationKey: EnvironmentKey { - static let defaultValue: MarkdownRendererConfiguration = .init() -} - -extension EnvironmentValues { - var markdownRendererConfiguration: MarkdownRendererConfiguration { - get { self[MarkdownRendererConfigurationKey.self] } - set { self[MarkdownRendererConfigurationKey.self] = newValue } - } -} diff --git a/Sources/MarkdownView/Renderers/Node Representations/Block Quotes/Protocol/BlockQuoteStyle.swift b/Sources/MarkdownView/Customizations/Block Quotes/BlockQuoteStyle.swift similarity index 100% rename from Sources/MarkdownView/Renderers/Node Representations/Block Quotes/Protocol/BlockQuoteStyle.swift rename to Sources/MarkdownView/Customizations/Block Quotes/BlockQuoteStyle.swift diff --git a/Sources/MarkdownView/Renderers/Node Representations/Block Quotes/Styles/DefaultBlockQuoteStyle.swift b/Sources/MarkdownView/Customizations/Block Quotes/DefaultBlockQuoteStyle.swift similarity index 100% rename from Sources/MarkdownView/Renderers/Node Representations/Block Quotes/Styles/DefaultBlockQuoteStyle.swift rename to Sources/MarkdownView/Customizations/Block Quotes/DefaultBlockQuoteStyle.swift diff --git a/Sources/MarkdownView/Renderers/Node Representations/Block Quotes/Styles/GithubBlockQuoteStyle.swift b/Sources/MarkdownView/Customizations/Block Quotes/GithubBlockQuoteStyle.swift similarity index 100% rename from Sources/MarkdownView/Renderers/Node Representations/Block Quotes/Styles/GithubBlockQuoteStyle.swift rename to Sources/MarkdownView/Customizations/Block Quotes/GithubBlockQuoteStyle.swift diff --git a/Sources/MarkdownView/Renderers/Node Representations/Code Blocks/Protocol/CodeBlockStyle.swift b/Sources/MarkdownView/Customizations/Code Blocks/CodeBlockStyle.swift similarity index 100% rename from Sources/MarkdownView/Renderers/Node Representations/Code Blocks/Protocol/CodeBlockStyle.swift rename to Sources/MarkdownView/Customizations/Code Blocks/CodeBlockStyle.swift diff --git a/Sources/MarkdownView/Renderers/Node Representations/Code Blocks/Styles/DefaultCodeBlockStyle.swift b/Sources/MarkdownView/Customizations/Code Blocks/DefaultCodeBlockStyle.swift similarity index 100% rename from Sources/MarkdownView/Renderers/Node Representations/Code Blocks/Styles/DefaultCodeBlockStyle.swift rename to Sources/MarkdownView/Customizations/Code Blocks/DefaultCodeBlockStyle.swift diff --git a/Sources/MarkdownView/Configurations/CodeHighlighterTheme.swift b/Sources/MarkdownView/Customizations/CodeHighlighterTheme.swift similarity index 100% rename from Sources/MarkdownView/Configurations/CodeHighlighterTheme.swift rename to Sources/MarkdownView/Customizations/CodeHighlighterTheme.swift diff --git a/Sources/MarkdownView/Configurations/Font/AnyMarkdownFontGroup.swift b/Sources/MarkdownView/Customizations/Font/AnyMarkdownFontGroup.swift similarity index 100% rename from Sources/MarkdownView/Configurations/Font/AnyMarkdownFontGroup.swift rename to Sources/MarkdownView/Customizations/Font/AnyMarkdownFontGroup.swift diff --git a/Sources/MarkdownView/Configurations/Font/DefaultFontGroup.swift b/Sources/MarkdownView/Customizations/Font/DefaultFontGroup.swift similarity index 100% rename from Sources/MarkdownView/Configurations/Font/DefaultFontGroup.swift rename to Sources/MarkdownView/Customizations/Font/DefaultFontGroup.swift diff --git a/Sources/MarkdownView/Configurations/Font/MarkdownFontGroup.swift b/Sources/MarkdownView/Customizations/Font/MarkdownFontGroup.swift similarity index 100% rename from Sources/MarkdownView/Configurations/Font/MarkdownFontGroup.swift rename to Sources/MarkdownView/Customizations/Font/MarkdownFontGroup.swift diff --git a/Sources/MarkdownView/Configurations/Font/MarkdownTextType.swift b/Sources/MarkdownView/Customizations/Font/MarkdownTextType.swift similarity index 100% rename from Sources/MarkdownView/Configurations/Font/MarkdownTextType.swift rename to Sources/MarkdownView/Customizations/Font/MarkdownTextType.swift diff --git a/Sources/MarkdownView/Configurations/ForegroundStyle/AnyForegroundStyleGroup.swift b/Sources/MarkdownView/Customizations/Foreground Styles/AnyForegroundStyleGroup.swift similarity index 100% rename from Sources/MarkdownView/Configurations/ForegroundStyle/AnyForegroundStyleGroup.swift rename to Sources/MarkdownView/Customizations/Foreground Styles/AnyForegroundStyleGroup.swift diff --git a/Sources/MarkdownView/Configurations/ForegroundStyle/AutomaticForegroundStyleGroup.swift b/Sources/MarkdownView/Customizations/Foreground Styles/AutomaticForegroundStyleGroup.swift similarity index 100% rename from Sources/MarkdownView/Configurations/ForegroundStyle/AutomaticForegroundStyleGroup.swift rename to Sources/MarkdownView/Customizations/Foreground Styles/AutomaticForegroundStyleGroup.swift diff --git a/Sources/MarkdownView/Configurations/ForegroundStyle/HeadingStyleGroup.swift b/Sources/MarkdownView/Customizations/Foreground Styles/HeadingStyleGroup.swift similarity index 100% rename from Sources/MarkdownView/Configurations/ForegroundStyle/HeadingStyleGroup.swift rename to Sources/MarkdownView/Customizations/Foreground Styles/HeadingStyleGroup.swift diff --git a/Sources/MarkdownView/Configurations/ForegroundStyle/MarkdownStyleTarget.swift b/Sources/MarkdownView/Customizations/Foreground Styles/MarkdownStyleTarget.swift similarity index 100% rename from Sources/MarkdownView/Configurations/ForegroundStyle/MarkdownStyleTarget.swift rename to Sources/MarkdownView/Customizations/Foreground Styles/MarkdownStyleTarget.swift diff --git a/Sources/MarkdownView/Renderers/Node Representations/Heading/Configurations/HeadingLevel.swift b/Sources/MarkdownView/Customizations/Heading/HeadingLevel.swift similarity index 100% rename from Sources/MarkdownView/Renderers/Node Representations/Heading/Configurations/HeadingLevel.swift rename to Sources/MarkdownView/Customizations/Heading/HeadingLevel.swift diff --git a/Sources/MarkdownView/Renderers/Node Representations/Heading/Configurations/HeadingPaddings.swift b/Sources/MarkdownView/Customizations/Heading/HeadingPaddings.swift similarity index 100% rename from Sources/MarkdownView/Renderers/Node Representations/Heading/Configurations/HeadingPaddings.swift rename to Sources/MarkdownView/Customizations/Heading/HeadingPaddings.swift diff --git a/Sources/MarkdownView/Customizations/List/Ordered List Marker/AnyOrderedListMarkerProtocol.swift b/Sources/MarkdownView/Customizations/List/Ordered List Marker/AnyOrderedListMarkerProtocol.swift new file mode 100644 index 000000000..ea957c3cf --- /dev/null +++ b/Sources/MarkdownView/Customizations/List/Ordered List Marker/AnyOrderedListMarkerProtocol.swift @@ -0,0 +1,23 @@ +// +// AnyOrderedListMarkerProtocol.swift +// MarkdownView +// +// Created by Yanan Li on 2025/10/27. +// + +import Foundation + +struct AnyOrderedListMarkerProtocol: OrderedListMarkerProtocol { + private var _marker: AnyHashable + var monospaced: Bool { + (_marker as! (any OrderedListMarkerProtocol)).monospaced + } + + init(_ marker: T) { + self._marker = AnyHashable(marker) + } + + public func marker(at index: Int, listDepth: Int) -> String { + (_marker as! (any OrderedListMarkerProtocol)).marker(at: index, listDepth: listDepth) + } +} diff --git a/Sources/MarkdownView/Customizations/List/Ordered List Marker/OrderedListIncreasingDigitsMarker.swift b/Sources/MarkdownView/Customizations/List/Ordered List Marker/OrderedListIncreasingDigitsMarker.swift new file mode 100644 index 000000000..1fd3feb06 --- /dev/null +++ b/Sources/MarkdownView/Customizations/List/Ordered List Marker/OrderedListIncreasingDigitsMarker.swift @@ -0,0 +1,22 @@ +// +// OrderedListIncreasingDigitsMarker.swift +// MarkdownView +// +// Created by Yanan Li on 2025/10/27. +// + +import Foundation + +/// An auto-increasing digits marker for ordered list items. +public struct OrderedListIncreasingDigitsMarker: OrderedListMarkerProtocol { + public func marker(at index: Int, listDepth: Int) -> String { + String(index + 1) + "." + } + + public var monospaced: Bool { false } +} + +extension OrderedListMarkerProtocol where Self == OrderedListIncreasingDigitsMarker { + /// An auto-increasing digits marker for ordered list items. + static public var increasingDigits: OrderedListIncreasingDigitsMarker { .init() } +} diff --git a/Sources/MarkdownView/Customizations/List/Ordered List Marker/OrderedListIncreasingLettersMarker.swift b/Sources/MarkdownView/Customizations/List/Ordered List Marker/OrderedListIncreasingLettersMarker.swift new file mode 100644 index 000000000..29d453ddb --- /dev/null +++ b/Sources/MarkdownView/Customizations/List/Ordered List Marker/OrderedListIncreasingLettersMarker.swift @@ -0,0 +1,39 @@ +// +// OrderedListIncreasingLettersMarker.swift +// MarkdownView +// +// Created by Yanan Li on 2025/10/27. +// + +import Foundation + +/// An auto-increasing letters marker for ordered list items. +public struct OrderedListIncreasingLettersMarker: OrderedListMarkerProtocol { + public func marker(at index: Int, listDepth: Int) -> String { + let base = 26 + var index = index + var result = "" + + // If index is smaller than 26, use single letter, otherwise, use double letters. + if index < base { + result = String(UnicodeScalar("a".unicodeScalars.first!.value + UInt32(index))!) + } else { + index -= base + let firstLetterIndex = index / base + let secondLetterIndex = index % base + let firstLetter = UnicodeScalar("a".unicodeScalars.first!.value + UInt32(firstLetterIndex))! + let secondLetter = UnicodeScalar("a".unicodeScalars.first!.value + UInt32(secondLetterIndex))! + result.append(Character(firstLetter)) + result.append(Character(secondLetter)) + } + + return result + "." + } + + public var monospaced: Bool { false } +} + +extension OrderedListMarkerProtocol where Self == OrderedListIncreasingLettersMarker { + /// An auto-increasing letters marker for ordered list items. + static public var increasingLetters: OrderedListIncreasingLettersMarker { .init() } +} diff --git a/Sources/MarkdownView/Customizations/List/Ordered List Marker/OrderedListMarkerProtocol.swift b/Sources/MarkdownView/Customizations/List/Ordered List Marker/OrderedListMarkerProtocol.swift new file mode 100644 index 000000000..42102c64b --- /dev/null +++ b/Sources/MarkdownView/Customizations/List/Ordered List Marker/OrderedListMarkerProtocol.swift @@ -0,0 +1,23 @@ +// +// OrderedListMarkerProtocol.swift +// MarkdownView +// +// Created by Yanan Li on 2025/10/27. +// + +import Foundation + +/// A type that represents the marker for ordered list items. +public protocol OrderedListMarkerProtocol: Hashable { + /// Returns a marker for a specific index of ordered list item. Index starting from 0. + func marker(at index: Int, listDepth: Int) -> String + + /// A boolean value indicates whether the marker should be monospaced, default value is `true`. + var monospaced: Bool { get } +} + +extension OrderedListMarkerProtocol { + public var monospaced: Bool { + true + } +} diff --git a/Sources/MarkdownView/Customizations/List/Unordered List Marker/AnyUnorderedListMarkerProtocol.swift b/Sources/MarkdownView/Customizations/List/Unordered List Marker/AnyUnorderedListMarkerProtocol.swift new file mode 100644 index 000000000..5e99a7aa9 --- /dev/null +++ b/Sources/MarkdownView/Customizations/List/Unordered List Marker/AnyUnorderedListMarkerProtocol.swift @@ -0,0 +1,23 @@ +// +// AnyUnorderedListMarkerProtocol.swift +// MarkdownView +// +// Created by Yanan Li on 2025/10/27. +// + +import Foundation + +struct AnyUnorderedListMarkerProtocol: UnorderedListMarkerProtocol { + private var _marker: AnyHashable + var monospaced: Bool { + (_marker as! (any UnorderedListMarkerProtocol)).monospaced + } + + init(_ marker: T) { + self._marker = AnyHashable(marker) + } + + public func marker(listDepth: Int) -> String { + (_marker as! (any UnorderedListMarkerProtocol)).marker(listDepth: listDepth) + } +} diff --git a/Sources/MarkdownView/Customizations/List/Unordered List Marker/UnorderedListBulletMarker.swift b/Sources/MarkdownView/Customizations/List/Unordered List Marker/UnorderedListBulletMarker.swift new file mode 100644 index 000000000..6e1ed1c34 --- /dev/null +++ b/Sources/MarkdownView/Customizations/List/Unordered List Marker/UnorderedListBulletMarker.swift @@ -0,0 +1,20 @@ +// +// UnorderedListBulletMarker.swift +// MarkdownView +// +// Created by Yanan Li on 2025/10/27. +// + +import Foundation + +/// A bullet marker for unordered list items. +public struct UnorderedListBulletMarker: UnorderedListMarkerProtocol { + public func marker(listDepth: Int) -> String { + "•" + } +} + +extension UnorderedListMarkerProtocol where Self == UnorderedListBulletMarker { + /// A bullet marker for unordered list items. + static public var bullet: UnorderedListBulletMarker { .init() } +} diff --git a/Sources/MarkdownView/Customizations/List/Unordered List Marker/UnorderedListDashMarker.swift b/Sources/MarkdownView/Customizations/List/Unordered List Marker/UnorderedListDashMarker.swift new file mode 100644 index 000000000..08107f8eb --- /dev/null +++ b/Sources/MarkdownView/Customizations/List/Unordered List Marker/UnorderedListDashMarker.swift @@ -0,0 +1,20 @@ +// +// UnorderedListDashMarker.swift +// MarkdownView +// +// Created by Yanan Li on 2025/10/27. +// + +import Foundation + +/// A dash marker for unordered list items. +public struct UnorderedListDashMarker: UnorderedListMarkerProtocol { + public func marker(listDepth: Int) -> String { + "-" + } +} + +extension UnorderedListMarkerProtocol where Self == UnorderedListDashMarker { + /// A dash marker for unordered list items. + static public var dash: UnorderedListDashMarker { .init() } +} diff --git a/Sources/MarkdownView/Customizations/List/Unordered List Marker/UnorderedListMarkerProtocol.swift b/Sources/MarkdownView/Customizations/List/Unordered List Marker/UnorderedListMarkerProtocol.swift new file mode 100644 index 000000000..3e3d06a2d --- /dev/null +++ b/Sources/MarkdownView/Customizations/List/Unordered List Marker/UnorderedListMarkerProtocol.swift @@ -0,0 +1,23 @@ +// +// UnorderedListMarkerProtocol.swift +// MarkdownView +// +// Created by Yanan Li on 2025/10/27. +// + +import Foundation + +/// A type that represents the marker for unordered list items. +public protocol UnorderedListMarkerProtocol: Hashable { + /// Returns a marker for a specific indentation level of unordered list item. indentationLevel starting from 0. + func marker(listDepth: Int) -> String + + /// A boolean value indicates whether the marker should be monospaced, default value is `true`. + var monospaced: Bool { get } +} + +extension UnorderedListMarkerProtocol { + public var monospaced: Bool { + true + } +} diff --git a/Sources/MarkdownView/DeprecatedAPIs.swift b/Sources/MarkdownView/Deprecated & Unavailable APIs/2.0/DeprecatedAPIs.swift similarity index 100% rename from Sources/MarkdownView/DeprecatedAPIs.swift rename to Sources/MarkdownView/Deprecated & Unavailable APIs/2.0/DeprecatedAPIs.swift diff --git a/Sources/MarkdownView/Modifiers/RenderingModeModifier.swift b/Sources/MarkdownView/Deprecated & Unavailable APIs/2.0/RenderingModeModifier.swift similarity index 100% rename from Sources/MarkdownView/Modifiers/RenderingModeModifier.swift rename to Sources/MarkdownView/Deprecated & Unavailable APIs/2.0/RenderingModeModifier.swift diff --git a/Sources/MarkdownView/Extensions/Swift/ActorIsolated.swift b/Sources/MarkdownView/Extensions/Swift/ActorIsolated.swift deleted file mode 100644 index 8cdd59e6d..000000000 --- a/Sources/MarkdownView/Extensions/Swift/ActorIsolated.swift +++ /dev/null @@ -1,9 +0,0 @@ -import Foundation - -actor ActorIsolated { - var value: Value - - init(_ value: Value) { - self.value = value - } -} diff --git a/Sources/MarkdownView/Helpers/SwiftUI/AnyShape.swift b/Sources/MarkdownView/Helpers/Backward Capabilities/AnyShape.swift similarity index 100% rename from Sources/MarkdownView/Helpers/SwiftUI/AnyShape.swift rename to Sources/MarkdownView/Helpers/Backward Capabilities/AnyShape.swift diff --git a/Sources/MarkdownView/Helpers/View Modifiers/OnValueChange.swift b/Sources/MarkdownView/Helpers/Backward Capabilities/View+OnValueChange.swift similarity index 99% rename from Sources/MarkdownView/Helpers/View Modifiers/OnValueChange.swift rename to Sources/MarkdownView/Helpers/Backward Capabilities/View+OnValueChange.swift index a048629f5..5cec79efe 100644 --- a/Sources/MarkdownView/Helpers/View Modifiers/OnValueChange.swift +++ b/Sources/MarkdownView/Helpers/Backward Capabilities/View+OnValueChange.swift @@ -120,7 +120,7 @@ extension View { } } -// MARK: - Auxiliary +// MARK: - _BackDeployedOnChangeViewModifier fileprivate struct _BackDeployedOnChangeViewModifier: ViewModifier { nonisolated(unsafe) private var value: Value diff --git a/Sources/MarkdownView/Helpers/KeyPathModifiable.swift b/Sources/MarkdownView/Helpers/KeyPathModifiable.swift new file mode 100644 index 000000000..b908fa361 --- /dev/null +++ b/Sources/MarkdownView/Helpers/KeyPathModifiable.swift @@ -0,0 +1,27 @@ +// +// KeyPathModifiable.swift +// MarkdownView +// +// Created by Yanan Li on 2025/2/9. +// + +import Foundation + +protocol KeyPathModifiable { } + +extension KeyPathModifiable { + public func with(_ keyPath: WritableKeyPath, _ newValue: T) -> Self { + var copy = self + copy[keyPath: keyPath] = newValue + return copy + } + + public mutating func modify( + _ keyPath: WritableKeyPath, + _ modify: @escaping (inout T) -> Void + ) { + var value = self[keyPath: keyPath] + defer { self[keyPath: keyPath] = value } + modify(&value) + } +} diff --git a/Sources/MarkdownView/Extensions/Markdown/BlockQuote.swift b/Sources/MarkdownView/Helpers/Markdown/BlockQuote.swift similarity index 100% rename from Sources/MarkdownView/Extensions/Markdown/BlockQuote.swift rename to Sources/MarkdownView/Helpers/Markdown/BlockQuote.swift diff --git a/Sources/MarkdownView/Extensions/Markdown/ListItemContainer.swift b/Sources/MarkdownView/Helpers/Markdown/ListItemContainer.swift similarity index 100% rename from Sources/MarkdownView/Extensions/Markdown/ListItemContainer.swift rename to Sources/MarkdownView/Helpers/Markdown/ListItemContainer.swift diff --git a/Sources/MarkdownView/Extensions/Markdown/Markdown+Sendable.swift b/Sources/MarkdownView/Helpers/Markdown/Markdown+Sendable.swift similarity index 100% rename from Sources/MarkdownView/Extensions/Markdown/Markdown+Sendable.swift rename to Sources/MarkdownView/Helpers/Markdown/Markdown+Sendable.swift diff --git a/Sources/MarkdownView/Extensions/Markdown/Markup.swift b/Sources/MarkdownView/Helpers/Markdown/Markup.swift similarity index 100% rename from Sources/MarkdownView/Extensions/Markdown/Markup.swift rename to Sources/MarkdownView/Helpers/Markdown/Markup.swift diff --git a/Sources/MarkdownView/Extensions/Markdown/SourceLocation.swift b/Sources/MarkdownView/Helpers/Markdown/SourceLocation.swift similarity index 100% rename from Sources/MarkdownView/Extensions/Markdown/SourceLocation.swift rename to Sources/MarkdownView/Helpers/Markdown/SourceLocation.swift diff --git a/Sources/MarkdownView/Extensions/Markdown/Table++.swift b/Sources/MarkdownView/Helpers/Markdown/Table++.swift similarity index 100% rename from Sources/MarkdownView/Extensions/Markdown/Table++.swift rename to Sources/MarkdownView/Helpers/Markdown/Table++.swift diff --git a/Sources/MarkdownView/Helpers/Protocols/AllowingModifyThroughKeyPath.swift b/Sources/MarkdownView/Helpers/Protocols/AllowingModifyThroughKeyPath.swift deleted file mode 100644 index 6caf80f81..000000000 --- a/Sources/MarkdownView/Helpers/Protocols/AllowingModifyThroughKeyPath.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// AllowingModifyThroughKeyPath.swift -// MarkdownView -// -// Created by Yanan Li on 2025/2/9. -// - -import Foundation - -protocol AllowingModifyThroughKeyPath { } - -extension AllowingModifyThroughKeyPath { - public func with(_ keyPath: WritableKeyPath, _ newValue: T) -> Self { - var copy = self - copy[keyPath: keyPath] = newValue - return copy - } -} diff --git a/Sources/MarkdownView/Helpers/Layouts/FlowLayout.swift b/Sources/MarkdownView/Helpers/SwiftUI/FlowLayout.swift similarity index 99% rename from Sources/MarkdownView/Helpers/Layouts/FlowLayout.swift rename to Sources/MarkdownView/Helpers/SwiftUI/FlowLayout.swift index f9c294054..d71178e5c 100644 --- a/Sources/MarkdownView/Helpers/Layouts/FlowLayout.swift +++ b/Sources/MarkdownView/Helpers/SwiftUI/FlowLayout.swift @@ -106,4 +106,3 @@ extension FlowLayout { var size: CGSize } } - diff --git a/Sources/MarkdownView/Extensions/SwiftUI/Image++.swift b/Sources/MarkdownView/Helpers/SwiftUI/Image++.swift similarity index 100% rename from Sources/MarkdownView/Extensions/SwiftUI/Image++.swift rename to Sources/MarkdownView/Helpers/SwiftUI/Image++.swift diff --git a/Sources/MarkdownView/Extensions/SwiftUI/View++.swift b/Sources/MarkdownView/Helpers/SwiftUI/View++.swift similarity index 100% rename from Sources/MarkdownView/Extensions/SwiftUI/View++.swift rename to Sources/MarkdownView/Helpers/SwiftUI/View++.swift diff --git a/Sources/MarkdownView/Helpers/Text Manipulations/TextBuilder.swift b/Sources/MarkdownView/Helpers/Text Manipulations/TextBuilder.swift deleted file mode 100644 index f54d7f875..000000000 --- a/Sources/MarkdownView/Helpers/Text Manipulations/TextBuilder.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// TextBuilder.swift -// MarkdownView -// -// Created by Yanan Li on 2025/4/13. -// - -import SwiftUI - -@resultBuilder -struct TextBuilder { - static func buildBlock(_ components: Text...) -> Text { - components.reduce(Text(verbatim: ""), +) - } - - static func buildArray(_ components: [Text]) -> Text { - components.reduce(Text(verbatim: ""), +) - } - - static func buildOptional(_ component: Text?) -> Text { - if let component { - return component - } - return Text(verbatim: "") - } - - static func buildExpression(_ expression: Image) -> Text { - Text(expression) - } - - static func buildExpression(_ expression: Text) -> Text { - expression - } - - static func buildPartialBlock(accumulated: Text, next: Text) -> Text { - accumulated + next - } - - static func buildPartialBlock(first: Text) -> Text { - first - } - - static func buildEither(first component: Text) -> Text { - component - } - - static func buildEither(second component: Text) -> Text { - component - } -} diff --git a/Sources/MarkdownView/Helpers/MarkdownContent.swift b/Sources/MarkdownView/MarkdownContent.swift similarity index 72% rename from Sources/MarkdownView/Helpers/MarkdownContent.swift rename to Sources/MarkdownView/MarkdownContent.swift index 14ea079d7..0901624f0 100644 --- a/Sources/MarkdownView/Helpers/MarkdownContent.swift +++ b/Sources/MarkdownView/MarkdownContent.swift @@ -2,40 +2,18 @@ // MarkdownContent.swift // MarkdownView // -// Created by Yanan Li on 2025/2/9. +// Created by Yanan Li on 2025/10/27. // import Foundation @preconcurrency import Markdown -// MARK: - Raw - -enum RawMarkdownContent: Sendable, Hashable { - case plainText(String) - case url(URL) - - public var text: String { - switch self { - case .plainText(let text): - return text - case .url(let url): - return (try? String(contentsOf: url)) ?? "" - } - } - - public var source: URL? { - if case .url(let url) = self { - return url - } - return nil - } -} - -// MARK: - Parsed Content - -/// A Sendable markdown content that can be used to render content and supports on-demand parsing. +/// A value that stores the parsed representation of a Markdown document. +/// +/// If you're using ``MarkdownReader``, you will be able to get this within the view builder closure. public struct MarkdownContent: Sendable { - var raw: RawMarkdownContent + @_spi(RawMarkdown) + public var raw: RawMarkdownContent class ParsedDocumentStore: /* NSLock */ @unchecked Sendable { private var lock = NSLock() @@ -69,7 +47,7 @@ public struct MarkdownContent: Sendable { } } var store: ParsedDocumentStore - + internal init(raw: RawMarkdownContent) { self.raw = raw self.store = ParsedDocumentStore() diff --git a/Sources/MarkdownView/MarkdownView.swift b/Sources/MarkdownView/MarkdownView.swift index f111e4300..0a9ac969b 100644 --- a/Sources/MarkdownView/MarkdownView.swift +++ b/Sources/MarkdownView/MarkdownView.swift @@ -1,7 +1,7 @@ import SwiftUI import Markdown -/// A view that displays read-only Markdown content. +/// A view that renders markdown content. public struct MarkdownView: View { private var content: MarkdownContent @@ -13,6 +13,9 @@ public struct MarkdownView: View { @Environment(\.markdownFontGroup.body) private var bodyFont @Environment(\.markdownRendererConfiguration) private var configuration + /// Creates a view that renders given markdown string. + /// + /// - Parameter text: The Markdown source to render. public init(_ text: String) { self.content = MarkdownContent( raw: .plainText(text) @@ -26,6 +29,13 @@ public struct MarkdownView: View { ) } + /// Creates a view that renders from a ``MarkdownContent`` instance. + /// + /// Use this initializer when the content comes from ``MarkdownReader`` or a + /// cached value so that multiple Markdown views can reuse the same parsed + /// document and renderer cache. + /// + /// - Parameter content: The parsed Markdown to render. public init(_ content: MarkdownContent) { self.content = content } @@ -41,7 +51,7 @@ public struct MarkdownView: View { @ViewBuilder private var _renderedBody: some View { - if configuration.math.shouldRender { + if configuration.rendersMath { MathFirstMarkdownViewRenderer() .makeBody(content: content, configuration: configuration) } else { diff --git a/Sources/MarkdownView/Modifiers/BaseURLModifier.swift b/Sources/MarkdownView/Modifiers/BaseURLModifier.swift deleted file mode 100644 index 4c4657aaa..000000000 --- a/Sources/MarkdownView/Modifiers/BaseURLModifier.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// BaseURLModifier.swift -// MarkdownView -// -// Created by Yanan Li on 2025/2/9. -// - -import SwiftUI - -extension View { - nonisolated public func markdownBaseURL(_ url: URL) -> some View { - transformEnvironment(\.markdownRendererConfiguration) { configuration in - configuration.preferredBaseURL = url - } - } - - nonisolated public func markdownBaseURL(_ path: String) -> some View { - transformEnvironment(\.markdownRendererConfiguration) { configuration in - configuration.preferredBaseURL = URL(string: path) - } - } -} diff --git a/Sources/MarkdownView/Modifiers/BlockDirectiveRendererModifier.swift b/Sources/MarkdownView/Modifiers/BlockDirectiveRendererModifier.swift deleted file mode 100644 index a10030710..000000000 --- a/Sources/MarkdownView/Modifiers/BlockDirectiveRendererModifier.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// BlockDirectiveRendererModifier.swift -// MarkdownView -// -// Created by Yanan Li on 2025/2/9. -// - -import SwiftUI - -extension View { - /// Adds your custom block directive renderer. - /// - /// - parameter renderer: The renderer you have created to handle block directive rendering. - /// - parameter name: The name of the block directive. - nonisolated public func blockDirectiveRenderer( - _ renderer: some BlockDirectiveRenderer, - for name: String - ) -> some View { - transformEnvironment(\.markdownRendererConfiguration) { configuration in - BlockDirectiveRenderers.shared.addRenderer(renderer, for: name) - configuration.allowedBlockDirectiveRenderers.insert(name) - } - } -} diff --git a/Sources/MarkdownView/Modifiers/ListModifier.swift b/Sources/MarkdownView/Modifiers/ListModifier.swift deleted file mode 100644 index 00655e199..000000000 --- a/Sources/MarkdownView/Modifiers/ListModifier.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// ListModifier.swift -// MarkdownView -// -// Created by Yanan Li on 2025/2/9. -// - -import SwiftUI - -extension View { - nonisolated public func markdownListIndent(_ indent: CGFloat) -> some View { - transformEnvironment(\.markdownRendererConfiguration) { configuration in - configuration.listConfiguration.leadingIndentation = indent - } - } - - nonisolated public func markdownUnorderedListMarker(_ marker: some UnorderedListMarkerProtocol) -> some View { - transformEnvironment(\.markdownRendererConfiguration) { configuration in - configuration.listConfiguration.unorderedListMarker = AnyUnorderedListMarkerProtocol(marker) - } - } - - nonisolated public func markdownOrderedListMarker(_ marker: some OrderedListMarkerProtocol) -> some View { - transformEnvironment(\.markdownRendererConfiguration) { configuration in - configuration.listConfiguration.orderedListMarker = AnyOrderedListMarkerProtocol(marker) - } - } - - nonisolated public func markdownComponentSpacing(_ spacing: CGFloat) -> some View { - transformEnvironment(\.markdownRendererConfiguration) { configuration in - configuration.componentSpacing = spacing - } - } -} diff --git a/Sources/MarkdownView/Modifiers/MarkdownImageRendererModifier.swift b/Sources/MarkdownView/Modifiers/MarkdownImageRendererModifier.swift deleted file mode 100644 index 2adaafc32..000000000 --- a/Sources/MarkdownView/Modifiers/MarkdownImageRendererModifier.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// MarkdownImageRendererModifier.swift -// MarkdownView -// -// Created by Yanan Li on 2025/2/9. -// - -import SwiftUI - -extension View { - /// Use custom renderer to render images. - /// - /// - parameter renderer: The render you created to handle image loading and rendering. - /// - parameter urlScheme: A scheme for deciding which renderer to use. - nonisolated public func markdownImageRenderer( - _ renderer: some MarkdownImageRenderer, - forURLScheme urlScheme: String - ) -> some View { - transformEnvironment(\.markdownRendererConfiguration) { configuration in - MarkdownImageRenders.shared.addRenderer(renderer, forURLScheme: urlScheme) - configuration.allowedImageRenderers.insert(urlScheme) - } - } -} diff --git a/Sources/MarkdownView/Modifiers/MathRenderingModifier.swift b/Sources/MarkdownView/Modifiers/MathRenderingModifier.swift deleted file mode 100644 index f5c5fd969..000000000 --- a/Sources/MarkdownView/Modifiers/MathRenderingModifier.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// MathRenderingModifier.swift -// MarkdownView -// -// Created by LiYanan2004 on 2025/2/24. -// - -import SwiftUI - -extension View { - /// On macOS and iOS, parse and render math expression. - /// - /// - parameter enabled: A Boolean value that indicates whether to parse & render math expressions. The default value is true. - nonisolated public func markdownMathRenderingEnabled(_ enabled: Bool = true) -> some View { - transformEnvironment(\.markdownRendererConfiguration) { configuration in - configuration.math.shouldRender = enabled - if enabled { - configuration.allowedBlockDirectiveRenderers.insert("math") - BlockDirectiveRenderers.shared.addRenderer( - MathBlockDirectiveRenderer(), - for: "math" - ) - } - } - } -} diff --git a/Sources/MarkdownView/Renderers/Cmark/CmarkNodeVisitor.swift b/Sources/MarkdownView/Renderers/Cmark/CmarkNodeVisitor.swift index 5acb7eca7..39dfd77ee 100644 --- a/Sources/MarkdownView/Renderers/Cmark/CmarkNodeVisitor.swift +++ b/Sources/MarkdownView/Renderers/Cmark/CmarkNodeVisitor.swift @@ -47,7 +47,7 @@ struct CmarkNodeVisitor: @preconcurrency MarkupVisitor { } func visitText(_ text: Markdown.Text) -> MarkdownNodeView { - if configuration.math.shouldRender { + if configuration.rendersMath { InlineMathOrText(text: text.plainText) .makeBody(configuration: configuration) } else { @@ -187,7 +187,7 @@ struct CmarkNodeVisitor: @preconcurrency MarkupVisitor { func visitHeading(_ heading: Heading) -> MarkdownNodeView { MarkdownNodeView { - MarkdownHeading(heading: heading) + HeadingText(heading: heading) } } diff --git a/Sources/MarkdownView/Renderers/MarkdownRendererConfiguration.swift b/Sources/MarkdownView/Renderers/MarkdownRendererConfiguration.swift new file mode 100644 index 000000000..9d642088e --- /dev/null +++ b/Sources/MarkdownView/Renderers/MarkdownRendererConfiguration.swift @@ -0,0 +1,68 @@ +// +// MarkdownRendererConfiguration.swift +// MarkdownView +// +// Created by LiYanan2004 on 2024/12/11. +// + +import Foundation +import SwiftUI + +struct MarkdownRendererConfiguration: Equatable, KeyPathModifiable, Sendable { + var preferredBaseURL: URL? + var componentSpacing: CGFloat = 8 + + var math = MathRendering() + var rendersMath: Bool { math.isEnabled } + + var linkTintColor: Color = .accentColor + var inlineCodeTintColor: Color = .accentColor + var blockQuoteTintColor: Color = .accentColor + + var list = MarkdownListConfiguration() + + var allowedImageRenderers: Set = ["https", "http"] + var allowedBlockDirectiveRenderers: Set = [] +} + +// MARK: - List + +extension MarkdownRendererConfiguration { + struct MarkdownListConfiguration: Hashable, @unchecked Sendable { + var leadingIndentation: CGFloat = 12 + var unorderedListMarker = AnyUnorderedListMarkerProtocol(.bullet) + var orderedListMarker = AnyOrderedListMarkerProtocol(.increasingDigits) + } +} + +// MARK: - Math Rendering + +extension MarkdownRendererConfiguration { + struct MathRendering: Sendable, Hashable { + var isEnabled: Bool = false + var displayMathStorage: [UUID : String] = [:] + + mutating func setNeedsRendering(_ needRenderMath: Bool) { + isEnabled = needRenderMath + } + + mutating func appendDisplayMath(_ displayMath: some StringProtocol) -> UUID { + let id = UUID() + displayMathStorage[id] = String(displayMath) + return id + } + } +} + +// MARK: - SwiftUI Environment + +struct MarkdownRendererConfigurationKey: EnvironmentKey { + static let defaultValue: MarkdownRendererConfiguration = .init() +} + +extension EnvironmentValues { + var markdownRendererConfiguration: MarkdownRendererConfiguration { + get { self[MarkdownRendererConfigurationKey.self] } + set { self[MarkdownRendererConfigurationKey.self] = newValue } + } +} diff --git a/Sources/MarkdownView/Renderers/Node Representations/Block Directives/Renderers/MathBlockDirectiveRenderer.swift b/Sources/MarkdownView/Renderers/Node Representations/Block Directives/Renderers/MathBlockDirectiveRenderer.swift index 0c9576155..5c66d1e9b 100644 --- a/Sources/MarkdownView/Renderers/Node Representations/Block Directives/Renderers/MathBlockDirectiveRenderer.swift +++ b/Sources/MarkdownView/Renderers/Node Representations/Block Directives/Renderers/MathBlockDirectiveRenderer.swift @@ -26,7 +26,7 @@ fileprivate struct DisplayMath: View { @Environment(\.markdownFontGroup.displayMath) private var font @Environment(\.markdownRendererConfiguration.math) private var math private var latexMath: String? { - math.displayMathStorage?[mathIdentifier] + math.displayMathStorage[mathIdentifier] } var body: some View { diff --git a/Sources/MarkdownView/Renderers/Node Representations/Heading/MarkdownHeading.swift b/Sources/MarkdownView/Renderers/Node Representations/HeadingText.swift similarity index 96% rename from Sources/MarkdownView/Renderers/Node Representations/Heading/MarkdownHeading.swift rename to Sources/MarkdownView/Renderers/Node Representations/HeadingText.swift index e3a8a8dd4..4791b4b21 100644 --- a/Sources/MarkdownView/Renderers/Node Representations/Heading/MarkdownHeading.swift +++ b/Sources/MarkdownView/Renderers/Node Representations/HeadingText.swift @@ -1,5 +1,5 @@ // -// MarkdownHeading.swift +// HeadingText.swift // MarkdownView // // Created by Yanan Li on 2025/2/22. @@ -8,7 +8,7 @@ import SwiftUI import Markdown -struct MarkdownHeading: View { +struct HeadingText: View { let heading: Heading @Environment(\.markdownRendererConfiguration) private var configuration diff --git a/Sources/MarkdownView/Renderers/Node Representations/Images/Protocol/MarkdownImageRenderer.swift b/Sources/MarkdownView/Renderers/Node Representations/Images/Protocol/MarkdownImageRenderer.swift index 764e6d6ae..5a58237f6 100644 --- a/Sources/MarkdownView/Renderers/Node Representations/Images/Protocol/MarkdownImageRenderer.swift +++ b/Sources/MarkdownView/Renderers/Node Representations/Images/Protocol/MarkdownImageRenderer.swift @@ -7,11 +7,17 @@ import SwiftUI -/// A type that renders images. +/// A type that renders Markdown image nodes for a specific URL scheme. /// -/// Think of this type as a SwiftUI View wrapper. +/// The protocol mirrors SwiftUI’s `View` construction model: implement +/// ``MarkdownImageRenderer/makeBody(configuration:)`` and return a view hierarchy +/// that knows how to fetch and display the requested image. The method is +/// invoked on the main actor, so heavy work (networking, decoding, etc.) should +/// be delegated to another view or task. /// -/// Don't directly access view dependencies (e.g. `@Environment`), use a separate view instead. +/// > Tip: Because protocol witnesses cannot use property wrappers, keep the +/// > renderer itself lightweight and move any `@Environment` or `@State` +/// > dependencies into a nested SwiftUI view. @preconcurrency @MainActor public protocol MarkdownImageRenderer { @@ -29,17 +35,24 @@ public protocol MarkdownImageRenderer { typealias Configuration = MarkdownImageRendererConfiguration } -/// The properties of a markdown image. +/// The immutable properties of a Markdown image node. public struct MarkdownImageRendererConfiguration: Sendable { /// The source url of an image. + /// + /// When the original Markdown uses a relative path and a base URL was + /// provided via ``View/markdownBaseURL(_:)``, this value already contains the + /// resolved absolute URL. Otherwise it is the URL verbatim from the Markdown. public var url: URL /// The alternative text of an image. + /// + /// MarkdownView automatically suppresses the alternative text when the image + /// appears inside a link so you can decide how to expose descriptive text. public var alternativeText: String? } // MARK: - Type Erasure -/// A type-erasure for type conforms to `MarkdownImageRenderer`. +/// A type-erased wrapper for any ``MarkdownImageRenderer`` implementation. public struct AnyMarkdownImageRenderer: MarkdownImageRenderer { public typealias Body = AnyView diff --git a/Sources/MarkdownView/Renderers/Node Representations/Block Quotes/MarkdownBlockQuote.swift b/Sources/MarkdownView/Renderers/Node Representations/MarkdownBlockQuote.swift similarity index 100% rename from Sources/MarkdownView/Renderers/Node Representations/Block Quotes/MarkdownBlockQuote.swift rename to Sources/MarkdownView/Renderers/Node Representations/MarkdownBlockQuote.swift diff --git a/Sources/MarkdownView/Renderers/Node Representations/MarkdownList.swift b/Sources/MarkdownView/Renderers/Node Representations/MarkdownList.swift index fb3854a14..022b4527f 100644 --- a/Sources/MarkdownView/Renderers/Node Representations/MarkdownList.swift +++ b/Sources/MarkdownView/Renderers/Node Representations/MarkdownList.swift @@ -7,9 +7,9 @@ struct MarkdownList: View { @Environment(\.markdownRendererConfiguration) private var configuration private var marker: Either { if listItemsContainer is UnorderedList { - return .left(configuration.listConfiguration.unorderedListMarker) + return .left(configuration.list.unorderedListMarker) } else if listItemsContainer is OrderedList { - return .right(configuration.listConfiguration.orderedListMarker) + return .right(configuration.list.orderedListMarker) } else { fatalError("Marker Protocol not implemented for \(type(of: listItemsContainer)).") } @@ -26,7 +26,7 @@ struct MarkdownList: View { ) { (index, listItem) in HStack(alignment: .firstTextBaseline) { CheckboxOrMarker(list: self, listItem: listItem, index: index) - .padding(.leading, depth == 0 ? configuration.listConfiguration.leadingIndentation : 0) + .padding(.leading, depth == 0 ? configuration.list.leadingIndentation : 0) CmarkNodeVisitor(configuration: configuration) .makeBody(for: listItem) } diff --git a/Sources/MarkdownView/Renderers/Node Representations/MarkdownNodeView.swift b/Sources/MarkdownView/Renderers/Node Representations/MarkdownNodeView.swift index fd9c23539..9eb6b5744 100644 --- a/Sources/MarkdownView/Renderers/Node Representations/MarkdownNodeView.swift +++ b/Sources/MarkdownView/Renderers/Node Representations/MarkdownNodeView.swift @@ -37,7 +37,7 @@ struct MarkdownNodeView: View { var body: some View { Group { if case .left(let attributedString) = storage { - _MarkdownText(attributedString) + MarkdownText(attributedString) } else if case .right(let view) = storage { view } diff --git a/Sources/MarkdownView/Renderers/Node Representations/Code Blocks/MarkdownStyledCodeBlock.swift b/Sources/MarkdownView/Renderers/Node Representations/MarkdownStyledCodeBlock.swift similarity index 100% rename from Sources/MarkdownView/Renderers/Node Representations/Code Blocks/MarkdownStyledCodeBlock.swift rename to Sources/MarkdownView/Renderers/Node Representations/MarkdownStyledCodeBlock.swift diff --git a/Sources/MarkdownView/Renderers/Node Representations/_MarkdownText.swift b/Sources/MarkdownView/Renderers/Node Representations/MarkdownText.swift similarity index 96% rename from Sources/MarkdownView/Renderers/Node Representations/_MarkdownText.swift rename to Sources/MarkdownView/Renderers/Node Representations/MarkdownText.swift index 3f59e760f..f69bd362f 100644 --- a/Sources/MarkdownView/Renderers/Node Representations/_MarkdownText.swift +++ b/Sources/MarkdownView/Renderers/Node Representations/MarkdownText.swift @@ -1,5 +1,5 @@ // -// _MarkdownText.swift +// MarkdownText.swift // MarkdownView // // Created by Yanan Li on 2025/10/20. @@ -10,7 +10,7 @@ import SwiftUI /// A view that displays parsed HTML asynchronously. /// /// Convert HTML into `AttributedString` asynchronously to avoid `AttributeGraph` crash. -struct _MarkdownText: View { +struct MarkdownText: View { var text: AttributedString @State private var attributedString: AttributedString? diff --git a/Sources/MarkdownView/Renderers/RawMarkdownContent.swift b/Sources/MarkdownView/Renderers/RawMarkdownContent.swift new file mode 100644 index 000000000..8dd491c9b --- /dev/null +++ b/Sources/MarkdownView/Renderers/RawMarkdownContent.swift @@ -0,0 +1,34 @@ +// +// RawMarkdownContent.swift +// MarkdownView +// +// Created by Yanan Li on 2025/10/27. +// + +import Foundation +@preconcurrency import Markdown + +/// The raw input of a markdown content. +@_spi(RawMarkdown) +public enum RawMarkdownContent: Sendable, Hashable { + case plainText(String) + case url(URL) + + @_spi(RawMarkdown) + public var text: String { + switch self { + case .plainText(let text): + return text + case .url(let url): + return (try? String(contentsOf: url)) ?? "" + } + } + + @_spi(RawMarkdown) + public var source: URL? { + if case .url(let url) = self { + return url + } + return nil + } +} diff --git a/Sources/MarkdownView/MarkdownTableOfContent.swift b/Sources/MarkdownView/Table of Content/MarkdownTableOfContent.swift similarity index 79% rename from Sources/MarkdownView/MarkdownTableOfContent.swift rename to Sources/MarkdownView/Table of Content/MarkdownTableOfContent.swift index 592e8d554..47ec2d06d 100644 --- a/Sources/MarkdownView/MarkdownTableOfContent.swift +++ b/Sources/MarkdownView/Table of Content/MarkdownTableOfContent.swift @@ -1,9 +1,11 @@ import SwiftUI import Markdown -/// A customized view that defines its content as a function of a set of headings. +/// A view that produces content from the headings found in a Markdown document. /// -/// You should use ``MarkdownView/MarkdownReader`` to provide single source-of-truth for MarkdownView and table of content. +/// Pass the same ``MarkdownContent`` that drives your ``MarkdownView`` so the +/// table of contents stays in sync and no extra parsing is performed. The +/// easiest way to do this is to wrap both views in a ``MarkdownReader``. public struct MarkdownTableOfContent: View { private var markdownContent: MarkdownContent private var contents: (_ headings: [MarkdownHeading]) -> Content @@ -39,6 +41,10 @@ extension MarkdownTableOfContent { heading.level } /// The range of the heading in the raw Markdown. + /// + /// The range originates from `swift-markdown`’s parsing result. It is + /// present when the Markdown source carried location information (for + /// example when it was loaded from a file URL). public var range: SourceRange? { heading.range } @@ -71,4 +77,3 @@ extension MarkdownTableOfContent { } } } - diff --git a/Sources/MarkdownView/View Modifiers/Behaviors/BaseURLModifier.swift b/Sources/MarkdownView/View Modifiers/Behaviors/BaseURLModifier.swift new file mode 100644 index 000000000..9807a932b --- /dev/null +++ b/Sources/MarkdownView/View Modifiers/Behaviors/BaseURLModifier.swift @@ -0,0 +1,44 @@ +// +// BaseURLModifier.swift +// MarkdownView +// +// Created by Yanan Li on 2025/2/9. +// + +import SwiftUI + +extension View { + /// Sets the base URL used to resolve relative image paths inside Markdown. + /// + /// Markdown image elements that omit a scheme (for example `images/logo.png`) + /// are only displayable once they are resolved against a base URL. Use this + /// modifier whenever your Markdown references local documentation assets or + /// CDN paths. + /// + /// ```swift + /// MarkdownView(markdown) + /// .markdownBaseURL(Bundle.main.bundleURL) + /// // Markdown: ![Diagram](Resources/diagram.svg) + /// ``` + /// + /// - Parameter url: The base location that relative URLs are resolved + /// against. Only the scheme/host/path are used; query parameters are + /// preserved when the Markdown specifies them. + nonisolated public func markdownBaseURL(_ url: URL) -> some View { + transformEnvironment(\.markdownRendererConfiguration) { configuration in + configuration.preferredBaseURL = url + } + } + + /// Convenience overload that creates the base URL from a string. + /// + /// If the string cannot be converted into a valid `URL`, the modifier is a + /// no-op. + /// + /// - Parameter path: The raw string representation of the base URL. + nonisolated public func markdownBaseURL(_ path: String) -> some View { + transformEnvironment(\.markdownRendererConfiguration) { configuration in + configuration.preferredBaseURL = URL(string: path) + } + } +} diff --git a/Sources/MarkdownView/View Modifiers/Behaviors/MathRenderingModifier.swift b/Sources/MarkdownView/View Modifiers/Behaviors/MathRenderingModifier.swift new file mode 100644 index 000000000..5b6872141 --- /dev/null +++ b/Sources/MarkdownView/View Modifiers/Behaviors/MathRenderingModifier.swift @@ -0,0 +1,39 @@ +// +// MathRenderingModifier.swift +// MarkdownView +// +// Created by LiYanan2004 on 2025/2/24. +// + +import SwiftUI + +extension View { + /// Opts the surrounding Markdown views into parsing and rendering math expressions. + /// + /// Math support is disabled by default so plain Markdown renders quickly. + /// Calling this modifier rewrites display math blocks (`$$ ... $$`) into a + /// block-directive placeholder that is then rendered through LaTeXSwiftUI on + /// iOS and macOS. On other platforms the directive safely degrades to an + /// empty view. + /// + /// ```swift + /// MarkdownView(markdown) + /// .markdownMathRenderingEnabled() + /// ``` + /// + /// - Parameter enabled: Set to `false` to temporarily suppress math parsing + /// for a subtree. The default is `true`, which turns math rendering on for + /// the given view hierarchy. + nonisolated public func markdownMathRenderingEnabled(_ enabled: Bool = true) -> some View { + transformEnvironment(\.markdownRendererConfiguration) { configuration in + configuration.math.isEnabled = enabled + if enabled { + configuration.allowedBlockDirectiveRenderers.insert("math") + BlockDirectiveRenderers.shared.addRenderer( + MathBlockDirectiveRenderer(), + for: "math" + ) + } + } + } +} diff --git a/Sources/MarkdownView/View Modifiers/Custom Renderers/BlockDirectiveRendererModifier.swift b/Sources/MarkdownView/View Modifiers/Custom Renderers/BlockDirectiveRendererModifier.swift new file mode 100644 index 000000000..bac1b4e7b --- /dev/null +++ b/Sources/MarkdownView/View Modifiers/Custom Renderers/BlockDirectiveRendererModifier.swift @@ -0,0 +1,49 @@ +// +// BlockDirectiveRendererModifier.swift +// MarkdownView +// +// Created by Yanan Li on 2025/2/9. +// + +import SwiftUI + +extension View { + /// Registers a custom renderer for a block directive name. + /// + /// Block directives (`::name{}`) are only rendered when a matching renderer + /// exists and the name is present in the allow list. This modifier performs + /// both tasks for you: it stores the renderer in the shared registry (the + /// last registration wins for the same name, comparisons are + /// case-insensitive) and it inserts the name into the environment’s + /// allow list. Directives without a renderer fall back to rendering their + /// body with the default Markdown visitor. + /// + /// ```swift + /// struct CalloutDirective: BlockDirectiveRenderer { + /// func makeBody(configuration: Configuration) -> some View { + /// Label(configuration.arguments.first?.value ?? "Info", + /// systemImage: "info.circle") + /// .padding() + /// .background(RoundedRectangle(cornerRadius: 8).fill(.blue.opacity(0.1))) + /// } + /// } + /// + /// MarkdownView(markdown) + /// .blockDirectiveRenderer(CalloutDirective(), for: "callout") + /// // Markdown: ::callout[level:warning]{ ... } + /// ``` + /// + /// - Parameters: + /// - renderer: The renderer responsible for producing the SwiftUI view. + /// - name: The directive name to match. Use lowercase names to avoid + /// collisions with existing renderers. + nonisolated public func blockDirectiveRenderer( + _ renderer: some BlockDirectiveRenderer, + for name: String + ) -> some View { + transformEnvironment(\.markdownRendererConfiguration) { configuration in + BlockDirectiveRenderers.shared.addRenderer(renderer, for: name) + configuration.allowedBlockDirectiveRenderers.insert(name) + } + } +} diff --git a/Sources/MarkdownView/View Modifiers/Custom Renderers/ImageRendererModifier.swift b/Sources/MarkdownView/View Modifiers/Custom Renderers/ImageRendererModifier.swift new file mode 100644 index 000000000..35ab2e17d --- /dev/null +++ b/Sources/MarkdownView/View Modifiers/Custom Renderers/ImageRendererModifier.swift @@ -0,0 +1,54 @@ +// +// ImageRendererModifier.swift +// MarkdownView +// +// Created by Yanan Li on 2025/2/9. +// + +import SwiftUI + +extension View { + /// Registers a custom renderer for Markdown images that use the given URL scheme. + /// + /// Markdown image nodes choose their renderer by looking at the `scheme` + /// portion of the image URL. By default the built-in HTTP(S) renderer is + /// available. Call this modifier to support additional schemes (for example + /// `asset://` to load bundle resources or `ipfs://` to talk to a custom + /// client). + /// + /// The registration performs two actions: + /// 1. It stores the renderer in a shared registry (the most recently + /// registered renderer wins for a given scheme). + /// 2. It inserts the scheme into the environment’s allow list so the + /// renderer is considered during view construction. If an image uses a + /// scheme that is not on the allow list, MarkdownView intentionally falls + /// back to ``View/markdownBaseURL(_:)`` or to plain text for safety. + /// + /// ```swift + /// struct AssetImageRenderer: MarkdownImageRenderer { + /// func makeBody(configuration: Configuration) -> some View { + /// Image(configuration.url.lastPathComponent) + /// .resizable() + /// .scaledToFit() + /// } + /// } + /// + /// MarkdownView(markdown) + /// .markdownImageRenderer(AssetImageRenderer(), forURLScheme: "asset") + /// // Markdown: ![Logo](asset://logo.png) + /// ``` + /// + /// - Parameters: + /// - renderer: Your renderer type that knows how to load and display the image. + /// - urlScheme: The scheme to match (case-insensitive). Use unique schemes + /// to avoid clobbering system renderers. + nonisolated public func markdownImageRenderer( + _ renderer: some MarkdownImageRenderer, + forURLScheme urlScheme: String + ) -> some View { + transformEnvironment(\.markdownRendererConfiguration) { configuration in + MarkdownImageRenders.shared.addRenderer(renderer, forURLScheme: urlScheme) + configuration.allowedImageRenderers.insert(urlScheme) + } + } +} diff --git a/Sources/MarkdownView/Modifiers/Heading/HeadingPaddingModifier.swift b/Sources/MarkdownView/View Modifiers/Heading/HeadingPaddingModifier.swift similarity index 100% rename from Sources/MarkdownView/Modifiers/Heading/HeadingPaddingModifier.swift rename to Sources/MarkdownView/View Modifiers/Heading/HeadingPaddingModifier.swift diff --git a/Sources/MarkdownView/Modifiers/Heading/HeadingStyleModifier.swift b/Sources/MarkdownView/View Modifiers/Heading/HeadingStyleModifier.swift similarity index 100% rename from Sources/MarkdownView/Modifiers/Heading/HeadingStyleModifier.swift rename to Sources/MarkdownView/View Modifiers/Heading/HeadingStyleModifier.swift diff --git a/Sources/MarkdownView/Modifiers/MarkdownViewStyle.swift b/Sources/MarkdownView/View Modifiers/MarkdownViewStyle.swift similarity index 100% rename from Sources/MarkdownView/Modifiers/MarkdownViewStyle.swift rename to Sources/MarkdownView/View Modifiers/MarkdownViewStyle.swift diff --git a/Sources/MarkdownView/Modifiers/BlockQuoteStyleModifier.swift b/Sources/MarkdownView/View Modifiers/Stylings/BlockQuoteStyleModifier.swift similarity index 100% rename from Sources/MarkdownView/Modifiers/BlockQuoteStyleModifier.swift rename to Sources/MarkdownView/View Modifiers/Stylings/BlockQuoteStyleModifier.swift diff --git a/Sources/MarkdownView/Modifiers/CodeBlockModifier.swift b/Sources/MarkdownView/View Modifiers/Stylings/CodeBlockModifier.swift similarity index 100% rename from Sources/MarkdownView/Modifiers/CodeBlockModifier.swift rename to Sources/MarkdownView/View Modifiers/Stylings/CodeBlockModifier.swift diff --git a/Sources/MarkdownView/View Modifiers/Stylings/ListModifier.swift b/Sources/MarkdownView/View Modifiers/Stylings/ListModifier.swift new file mode 100644 index 000000000..ab189dc6a --- /dev/null +++ b/Sources/MarkdownView/View Modifiers/Stylings/ListModifier.swift @@ -0,0 +1,56 @@ +// +// ListModifier.swift +// MarkdownView +// +// Created by Yanan Li on 2025/2/9. +// + +import SwiftUI + +extension View { + /// Adjusts the leading indentation applied to list markers. + /// + /// The value applies to both ordered and unordered lists rendered by + /// `MarkdownView`. + /// + /// - Parameter indent: The padding, in points, to apply in front of list content. + nonisolated public func markdownListIndent(_ indent: CGFloat) -> some View { + transformEnvironment(\.markdownRendererConfiguration) { configuration in + configuration.list.leadingIndentation = indent + } + } + + /// Replaces the marker that unordered lists use for each item. + /// + /// Provide a type that conforms to ``UnorderedListMarkerProtocol`` to drive + /// the bullet’s appearance. + nonisolated public func markdownUnorderedListMarker( + _ marker: some UnorderedListMarkerProtocol + ) -> some View { + transformEnvironment(\.markdownRendererConfiguration) { configuration in + configuration.list.unorderedListMarker = AnyUnorderedListMarkerProtocol(marker) + } + } + + /// Replaces the marker that ordered lists use for each row. + /// + /// Provide a type that conforms to ``OrderedListMarkerProtocol`` to control + /// numbering, prefixes, and suffixes. + nonisolated public func markdownOrderedListMarker( + _ marker: some OrderedListMarkerProtocol + ) -> some View { + transformEnvironment(\.markdownRendererConfiguration) { configuration in + configuration.list.orderedListMarker = AnyOrderedListMarkerProtocol(marker) + } + } + + /// Sets the vertical spacing between block-level Markdown components such as + /// paragraphs, list items, and block quotes. + /// + /// - Parameter spacing: The spacing value passed to `VStack` containers inside MarkdownView. + nonisolated public func markdownComponentSpacing(_ spacing: CGFloat) -> some View { + transformEnvironment(\.markdownRendererConfiguration) { configuration in + configuration.componentSpacing = spacing + } + } +} diff --git a/Sources/MarkdownView/Modifiers/TintColorModifier.swift b/Sources/MarkdownView/View Modifiers/Stylings/TintColorModifier.swift similarity index 94% rename from Sources/MarkdownView/Modifiers/TintColorModifier.swift rename to Sources/MarkdownView/View Modifiers/Stylings/TintColorModifier.swift index ea9e09614..a95212d28 100644 --- a/Sources/MarkdownView/Modifiers/TintColorModifier.swift +++ b/Sources/MarkdownView/View Modifiers/Stylings/TintColorModifier.swift @@ -31,7 +31,7 @@ extension View { /// Components that can apply a tint color. @_documentation(visibility: internal) -public enum TintableComponent { +public enum TintableComponent: Hashable, Sendable { case blockQuote case inlineCodeBlock case link diff --git a/Sources/MarkdownView/Modifiers/Table/MarkdownTableCellOverlayModifier.swift b/Sources/MarkdownView/View Modifiers/Table/MarkdownTableCellOverlayModifier.swift similarity index 100% rename from Sources/MarkdownView/Modifiers/Table/MarkdownTableCellOverlayModifier.swift rename to Sources/MarkdownView/View Modifiers/Table/MarkdownTableCellOverlayModifier.swift diff --git a/Sources/MarkdownView/Modifiers/Table/MarkdownTableCellPaddingModifier.swift b/Sources/MarkdownView/View Modifiers/Table/MarkdownTableCellPaddingModifier.swift similarity index 100% rename from Sources/MarkdownView/Modifiers/Table/MarkdownTableCellPaddingModifier.swift rename to Sources/MarkdownView/View Modifiers/Table/MarkdownTableCellPaddingModifier.swift diff --git a/Sources/MarkdownView/Modifiers/Table/MarkdownTableRowBackgroundStyleModifier.swift b/Sources/MarkdownView/View Modifiers/Table/MarkdownTableRowBackgroundStyleModifier.swift similarity index 100% rename from Sources/MarkdownView/Modifiers/Table/MarkdownTableRowBackgroundStyleModifier.swift rename to Sources/MarkdownView/View Modifiers/Table/MarkdownTableRowBackgroundStyleModifier.swift diff --git a/Sources/MarkdownView/Modifiers/Table/TableStyleModifier.swift b/Sources/MarkdownView/View Modifiers/Table/TableStyleModifier.swift similarity index 100% rename from Sources/MarkdownView/Modifiers/Table/TableStyleModifier.swift rename to Sources/MarkdownView/View Modifiers/Table/TableStyleModifier.swift diff --git a/Sources/MarkdownView/Modifiers/FontModifier.swift b/Sources/MarkdownView/View Modifiers/Text Formatting/FontModifier.swift similarity index 100% rename from Sources/MarkdownView/Modifiers/FontModifier.swift rename to Sources/MarkdownView/View Modifiers/Text Formatting/FontModifier.swift diff --git a/Sources/MarkdownView/WIP/MarkdownText.swift b/Sources/MarkdownView/WIP/MarkdownText.swift deleted file mode 100644 index d30d85437..000000000 --- a/Sources/MarkdownView/WIP/MarkdownText.swift +++ /dev/null @@ -1,102 +0,0 @@ -// -// MarkdownText.swift -// MarkdownView -// -// Created by Yanan Li on 2025/2/12. -// - -import SwiftUI - -internal struct MarkdownText: View { - private var content: MarkdownContent - @Environment(\.colorScheme) private var colorScheme - @Environment(\.displayScale) private var displayScale - - @Environment(\.self) private var environment - - public init(_ text: String) { - content = MarkdownContent(raw: .plainText(text)) - } - - @_spi(WIP) - public init(_ url: URL) { - content = MarkdownContent(raw: .url(url)) - } - - public var body: some View { - MarkdownTextRenderer(environment: environment) - .renderMarkdownContent(content) - .render() - - // TODO: Loading Image async and replace placeholder node. - /* - .onChange(of: content, initial: true) { - Task.detached { - var documentNode = MarkdownTextRenderer - .walkDocument(content.document) - await documentNode.modifyOverIteration { node in - guard node.kind == .placeholder, - case let .task(task) = node.content else { - return - } - - if let result = try? await task.value, let image = result as? Image { - node.kind = .image - node.content = .image(image) - } - } - await MainActor.run { - self.documentNodes = documentNode - } - } - } - */ - } -} - -// MARK: - Preview - -#Preview { - let markdown = #""" - ## Apple - - Here is the [Apple](https://www.apple.com) *website*. - - ### SwiftUI - - `SwiftUI` is Apple's **declaritive**, **cross-platform** UI framework. - - Here is a basic example, it shows: - - how to create a simple view - - The body is an opaque type of `View` - - ```swift - import SwiftUI - - struct ContentView: View { - var body: some View { - EmptyView() - } - } - ``` - """# - MarkdownText(markdown) - .textSelectionEnabledIfPossible() - .padding() - .lineSpacing(8) -} - -fileprivate extension View { - @ViewBuilder - func textSelectionEnabledIfPossible(_ enabled: Bool = true) -> some View { - #if os(macOS) || os(iOS) - if #available(iOS 15.0, macOS 12.0, *), enabled { - textSelection(.enabled) - } else { - self - } - #else - self - #endif - } -} diff --git a/Sources/MarkdownView/WIP/MarkdownTextKind.swift b/Sources/MarkdownView/WIP/MarkdownTextKind.swift deleted file mode 100644 index 8eca810fd..000000000 --- a/Sources/MarkdownView/WIP/MarkdownTextKind.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// MarkdownTextKind.swift -// MarkdownView -// -// Created by LiYanan2004 on 2025/4/1. -// - -import Foundation - -enum MarkdownTextKind: Sendable, Hashable { - case document - - case paragraph - case heading - case plainText - case strikethrough - case boldText - case italicText - case link - - case softBreak - case hardBreak - - case code - case codeBlock - - case orderedList - case unorderedList - case listItem - - case placeholder - case image - - case unknown -} diff --git a/Sources/MarkdownView/WIP/MarkdownTextNode.swift b/Sources/MarkdownView/WIP/MarkdownTextNode.swift deleted file mode 100644 index 494b6f9df..000000000 --- a/Sources/MarkdownView/WIP/MarkdownTextNode.swift +++ /dev/null @@ -1,152 +0,0 @@ -// -// MarkdownTextNode.swift -// MarkdownView -// -// Created by Yanan Li on 2025/2/11. -// - -import Foundation -import SwiftUI - -@MainActor -@preconcurrency -struct MarkdownTextNode: Sendable, AllowingModifyThroughKeyPath { - var kind: MarkdownTextKind - var children: [MarkdownTextNode] - var content: Content? - var index: Int? - var depth: Int? - var environment: EnvironmentValues - - enum Content: Sendable { - case text(String) - case heading(Int) - case codeLanguage(String) - case link(String, URL) - case image(Image) - case task(Task) - } - - mutating func modifyOverIteration(_ body: (inout Self) async throws -> Void) async rethrows { - try await body(&self) - for index in children.indices { - try await children[index].modifyOverIteration(body) - } - } -} - -extension MarkdownTextNode { - func render() -> Text { - switch kind { - case .document: - return children - .map { $0.render() } - .reduce(Text(""), +) - case .plainText: - guard case let .text(text) = content! else { - fatalError("Unsupported content for .plainText") - } - return Text(text) - case .hardBreak: - return BreakTextRenderer() - .body( - context: BreakTextRenderer.Context( - node: self, - environment: environment - ) - ) - case .softBreak: - return BreakTextRenderer() - .body( - context: BreakTextRenderer.Context( - node: self, - environment: environment - ) - ) - case .placeholder: - return Text(" ") - case .paragraph: - return ParagraphTextRenderer() - .body( - context: HeadingTextRenderer.Context( - node: self, - environment: environment - ) - ) - case .heading: - return HeadingTextRenderer() - .body( - context: HeadingTextRenderer.Context( - node: self, - environment: environment - ) - ) - case .italicText, .boldText, .strikethrough: - return FormattedTextRenderer() - .body( - context: FormattedTextRenderer.Context( - node: self, - environment: environment - ) - ) - case .link: - return LinkTextRenderer() - .body( - context: LinkTextRenderer.Context( - node: self, - environment: environment - ) - ) - case .image: - return ImageTextRenderer() - .body( - context: ImageTextRenderer.Context( - node: self, - environment: environment - ) - ) - case .codeBlock: - return CodeBlockTextRenderer() - .body( - context: CodeBlockTextRenderer.Context( - node: self, - environment: environment - ) - ) - case .code: - return InlineCodeTextRenderer() - .body( - context: InlineCodeTextRenderer.Context( - node: self, - environment: environment - ) - ) - case .orderedList: - return OrderedListTextRenderer() - .body( - context: OrderedListTextRenderer.Context( - node: self, - environment: environment - ) - ) - case .unorderedList: - return UnorderedListTextRenderer() - .body( - context: UnorderedListTextRenderer.Context( - node: self, - environment: environment - ) - ) - case .listItem: - return ListItemTextRenderer() - .body( - context: ListItemTextRenderer.Context( - node: self, - environment: environment - ) - ) - default: - return Text("Unimplemented: \(kind)") - } - } -} diff --git a/Sources/MarkdownView/WIP/MarkdownTextRenderer.Visit.swift b/Sources/MarkdownView/WIP/MarkdownTextRenderer.Visit.swift deleted file mode 100644 index ed24e77d1..000000000 --- a/Sources/MarkdownView/WIP/MarkdownTextRenderer.Visit.swift +++ /dev/null @@ -1,247 +0,0 @@ -// -// MarkdownTextRenderer.Visit.swift -// MarkdownView -// -// Created by LiYanan2004 on 2025/4/1. -// - -import Markdown -import Foundation -import CoreGraphics - -extension MarkdownTextRenderer: @preconcurrency MarkupVisitor { - mutating func defaultVisit(_ markup: any Markdown.Markup) -> MarkdownTextNode { - MarkdownTextNode( - kind: .unknown, - children: markup.children.enumerated().map { - visit($0.element) - .with(\.index, $0.offset) - }, - environment: environment - ) - } - - mutating func visitDocument(_ document: Document) -> MarkdownTextNode { - MarkdownTextNode( - kind: .document, - children: document.children.enumerated().map { - visit($0.element) - .with(\.index, $0.offset) - }, - environment: environment - ) - } - - mutating func visitText(_ text: Text) -> MarkdownTextNode { - MarkdownTextNode( - kind: .plainText, - children: [], - content: .text(text.plainText), - environment: environment - ) - } - - mutating func visitHeading(_ heading: Heading) -> MarkdownTextNode { - MarkdownTextNode( - kind: .heading, - children: heading.children.enumerated().map { - visit($0.element) - .with(\.index, $0.offset) - }, - content: .heading(heading.level), - environment: environment - ) - } - - mutating func visitStrong(_ strong: Strong) -> MarkdownTextNode { - MarkdownTextNode( - kind: .boldText, - children: strong.children.enumerated().map { - visit($0.element) - .with(\.index, $0.offset) - }, - environment: environment - ) - } - - mutating func visitEmphasis(_ emphasis: Emphasis) -> MarkdownTextNode { - MarkdownTextNode( - kind: .italicText, - children: emphasis.children.enumerated().map { - visit($0.element) - .with(\.index, $0.offset) - }, - environment: environment - ) - } - - mutating func visitParagraph(_ paragraph: Paragraph) -> MarkdownTextNode { - MarkdownTextNode( - kind: .paragraph, - children: paragraph.children.enumerated().map { - visit($0.element) - .with(\.index, $0.offset) - }, - environment: environment - ) - } - - mutating func visitStrikethrough(_ strikethrough: Strikethrough) -> MarkdownTextNode { - MarkdownTextNode( - kind: .strikethrough, - children: strikethrough.children.enumerated().map { - visit($0.element) - .with(\.index, $0.offset) - }, - environment: environment - ) - } - - mutating func visitLink(_ link: Link) -> MarkdownTextNode { - if let destination = link.destination, let url = URL(string: destination) { - MarkdownTextNode( - kind: .link, - children: [], - content: .link(link.title ?? link.plainText, url), - environment: environment - ) - } else { - MarkdownTextNode( - kind: .plainText, - children: [], - content: .text(link.plainText), - environment: environment - ) - } - } - - mutating func visitThematicBreak(_ thematicBreak: ThematicBreak) -> MarkdownTextNode { - MarkdownTextNode(kind: .hardBreak, children: [], environment: environment) - } - - mutating func visitLineBreak(_ lineBreak: LineBreak) -> MarkdownTextNode { - MarkdownTextNode(kind: .hardBreak, children: [], environment: environment) - } - - mutating func visitSoftBreak(_ softBreak: SoftBreak) -> MarkdownTextNode { - MarkdownTextNode(kind: .softBreak, children: [], environment: environment) - } - - mutating func visitInlineCode(_ inlineCode: InlineCode) -> MarkdownTextNode { - MarkdownTextNode( - kind: .code, - children: [], - content: .text(inlineCode.code), - environment: environment - ) - } - - mutating func visitCodeBlock(_ codeBlock: CodeBlock) -> MarkdownTextNode { - if let language = codeBlock.language { - MarkdownTextNode( - kind: .codeBlock, - children: [ - MarkdownTextNode( - kind: .code, - children: [], - content: .text(codeBlock.code), - environment: environment - ) - ], - content: .codeLanguage(language), - environment: environment - ) - } else { - MarkdownTextNode( - kind: .paragraph, - children: [ - MarkdownTextNode( - kind: .plainText, - children: [], - content: .text(codeBlock.code), - environment: environment - ) - ], - environment: environment - ) - } - } - - mutating func visitUnorderedList(_ unorderedList: UnorderedList) -> MarkdownTextNode { - MarkdownTextNode( - kind: .unorderedList, - children: unorderedList.children.enumerated().map { - visit($0.element) - .with(\.index, $0.offset) - }, - content: nil, - depth: unorderedList.listDepth, - environment: environment - ) - } - - mutating func visitOrderedList(_ orderedList: OrderedList) -> MarkdownTextNode { - MarkdownTextNode( - kind: .orderedList, - children: orderedList.children.enumerated().map { - visit($0.element) - .with(\.index, $0.offset) - }, - content: nil, - depth: orderedList.listDepth, - environment: environment - ) - } - - mutating func visitListItem(_ listItem: ListItem) -> MarkdownTextNode { - if let _ = listItem.checkbox { - MarkdownTextNode( - kind: .listItem, - children: listItem.children.enumerated().map { - visit($0.element) - .with(\.index, $0.offset) - }, - content: nil, - environment: environment - ) - } else { - MarkdownTextNode( - kind: .listItem, - children: listItem.children.enumerated().map { - visit($0.element) - .with(\.index, $0.offset) - }, - content: nil, - environment: environment - ) - } - } - - mutating func visitImage(_ image: Image) -> MarkdownTextNode { - if let source = image.source, let sourceURL = URL(string: source) { - let task = Task.detached(priority: .background) { - (try await ImageLoader.load(sourceURL)) as (any Sendable) - } - return MarkdownTextNode( - kind: .placeholder, - children: [ - MarkdownTextNode( - kind: .plainText, - children: [], - content: .text(image.title ?? image.plainText), - environment: environment - ) - ], - content: .task(task), - environment: environment - ) - } else { - return MarkdownTextNode( - kind: .plainText, - children: [], - content: .text(image.plainText), - environment: environment - ) - } - } -} diff --git a/Sources/MarkdownView/WIP/MarkdownTextRenderer.swift b/Sources/MarkdownView/WIP/MarkdownTextRenderer.swift deleted file mode 100644 index d3e156de3..000000000 --- a/Sources/MarkdownView/WIP/MarkdownTextRenderer.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// MarkdownTextRenderer.swift -// MarkdownView -// -// Created by Yanan Li on 2025/2/11. -// - -import SwiftUI - -@MainActor -@preconcurrency -struct MarkdownTextRenderer { - var environment: EnvironmentValues - - func renderMarkdownContent(_ markdownContent: MarkdownContent) -> MarkdownTextNode { - var renderer = self - return renderer.visit(markdownContent.parse()) - } -} diff --git a/Sources/MarkdownView/WIP/Renderers/MarkdownText Components/BreakTextRenderer.swift b/Sources/MarkdownView/WIP/Renderers/MarkdownText Components/BreakTextRenderer.swift deleted file mode 100644 index f9fc89640..000000000 --- a/Sources/MarkdownView/WIP/Renderers/MarkdownText Components/BreakTextRenderer.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// BreakTextRenderer.swift -// MarkdownView -// -// Created by Yanan Li on 2025/2/12. -// - -import SwiftUI - -struct BreakTextRenderer: MarkdownNode2TextRenderer { - var breakType: BreakType? - enum BreakType { - case soft - case hard - } - - func body(context: Context) -> Text { - let breakType: BreakType? = switch context.node.kind { - case .hardBreak: .hard - case .softBreak: .soft - default: self.breakType - } - - if breakType == .soft { - Text(" ") - } else if breakType == .hard { - Text("\n") - .font(.system(size: 1)) - } - } -} diff --git a/Sources/MarkdownView/WIP/Renderers/MarkdownText Components/CodeBlockTextRenderer.swift b/Sources/MarkdownView/WIP/Renderers/MarkdownText Components/CodeBlockTextRenderer.swift deleted file mode 100644 index c85a44e5f..000000000 --- a/Sources/MarkdownView/WIP/Renderers/MarkdownText Components/CodeBlockTextRenderer.swift +++ /dev/null @@ -1,132 +0,0 @@ -// -// CodeBlockTextRenderer.swift -// MarkdownView -// -// Created by Yanan Li on 2025/2/12. -// - -import SwiftUI -#if canImport(Highlightr) -import Highlightr -#endif - -struct CodeBlockTextRenderer: MarkdownNode2TextRenderer { - func body(context: Context) -> Text { - if context.node.index != 0 { - BreakTextRenderer(breakType: .hard) - .body(context: context) - } - - let language = if case let .codeLanguage(language) = context.node.content! { - language - } else { - fatalError("Missing code language") - } - - if case let .text(code) = context.node.children[0].content! { - let processedCode: AttributedString = { - #if canImport(Highlightr) - let highlighter = Highlightr()! - let themeConfig = CodeHighlighterTheme( - lightModeThemeName: "xcode", - darkModeThemeName: "dark" - ) - highlighter.setTheme(to: themeConfig.themeName(for: context.environment.colorScheme)) - if highlighter.supportedLanguages().contains(language) == true, - let highlighted = highlighter.highlight(code, as: language) { - let attributedCode = NSMutableAttributedString( - attributedString: highlighted - ) - attributedCode.removeAttribute(.font, range: NSMakeRange(0, attributedCode.length)) - - return AttributedString(attributedCode) - } else { - return AttributedString(code) - } - #else - return AttributedString(code) - #endif - }() - - Text(processedCode) - .font(.callout.monospaced()) - } - } -} - -/* -@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) -struct CodeBlockTextRenderer: TextRenderer { - private func maxWidthOfTextLayout(_ layout: Text.Layout) -> CGFloat { - var maxWidth: CGFloat = 0 - for line in layout { - maxWidth = max(line.typographicBounds.width, maxWidth) - } - return maxWidth - } - - private func _drawCodeBlockBackground(rect: CGRect, padding: CGFloat, in context: inout GraphicsContext) { - context.fill( - RoundedRectangle(cornerRadius: abs(padding)) - .path(in: rect.insetBy(dx: padding, dy: padding)), - with: .color(.red.opacity(0.5)) - ) - } - - private func drawsBackgroundForCodeBlocks(layout: Text.Layout, in context: inout GraphicsContext) { - let maxWidth: CGFloat = maxWidthOfTextLayout(layout) - var codeBlockRect: CGRect? - - for line in layout { - let firstRun = line.first - guard let firstRun else { continue } - - guard firstRun.isWrappedInsideCodeBlock else { - if let codeBlockRect { - _drawCodeBlockBackground(rect: codeBlockRect, padding: -6, in: &context) - } - codeBlockRect = nil - continue - } - - if codeBlockRect == nil { - // Create a new background area - codeBlockRect = CGRect( - origin: line.typographicBounds.rect.origin, - size: CGSize( - width: maxWidth, - height: line.typographicBounds.rect.height - ) - ) - } else { - // Grows the height - codeBlockRect!.size.height = line.typographicBounds.rect.maxY - codeBlockRect!.minY - } - } - - if let codeBlockRect { - _drawCodeBlockBackground(rect: codeBlockRect, padding: -6, in: &context) - } - } - - func draw(layout: Text.Layout, in context: inout GraphicsContext) { - drawsBackgroundForCodeBlocks(layout: layout, in: &context) - - for line in layout { - context.draw(line) - } - } -} - -@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) -struct CodeBlockTextAttribute: TextAttribute {} - -// MARK: - Auxiliary - -@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) -fileprivate extension Text.Layout.Run { - var isWrappedInsideCodeBlock: Bool { - self[CodeBlockTextAttribute.self] != nil - } -} -*/ diff --git a/Sources/MarkdownView/WIP/Renderers/MarkdownText Components/FormattedTextRenderer.swift b/Sources/MarkdownView/WIP/Renderers/MarkdownText Components/FormattedTextRenderer.swift deleted file mode 100644 index e5cbb545f..000000000 --- a/Sources/MarkdownView/WIP/Renderers/MarkdownText Components/FormattedTextRenderer.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// File.swift -// MarkdownView -// -// Created by Yanan Li on 2025/2/12. -// - -import SwiftUI - -struct FormattedTextRenderer: MarkdownNode2TextRenderer { - func body(context: Context) -> Text { - let text = context.node.children - .map { $0.render() } - .reduce(Text(""), +) - - return switch context.node.kind { - case .boldText: - text.bold() - case .italicText: - text.italic() - case .strikethrough: - text.strikethrough() - default: - fatalError() - } - } -} diff --git a/Sources/MarkdownView/WIP/Renderers/MarkdownText Components/HeadingTextRenderer.swift b/Sources/MarkdownView/WIP/Renderers/MarkdownText Components/HeadingTextRenderer.swift deleted file mode 100644 index 32b2697ec..000000000 --- a/Sources/MarkdownView/WIP/Renderers/MarkdownText Components/HeadingTextRenderer.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -// HeadingTextRenderer.swift -// MarkdownView -// -// Created by Yanan Li on 2025/2/12. -// - -import SwiftUI - -struct HeadingTextRenderer: MarkdownNode2TextRenderer { - func body(context: Context) -> Text { - let level = if case let .heading(level) = context.node.content { - level - } else { - -1 - } - - let font = switch level { - case 1: context.environment.markdownFontGroup.h1 - case 2: context.environment.markdownFontGroup.h2 - case 3: context.environment.markdownFontGroup.h3 - case 4: context.environment.markdownFontGroup.h4 - case 5: context.environment.markdownFontGroup.h5 - case 6: context.environment.markdownFontGroup.h6 - default: context.environment.markdownFontGroup.body - } - - let foregroundStyle = switch level { - case 1: context.environment.headingStyleGroup.h1 - case 2: context.environment.headingStyleGroup.h2 - case 3: context.environment.headingStyleGroup.h3 - case 4: context.environment.headingStyleGroup.h4 - case 5: context.environment.headingStyleGroup.h5 - case 6: context.environment.headingStyleGroup.h6 - default: AnyShapeStyle(.foreground) - } - - if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { - context.node.children - .map { $0.render() } - .reduce(Text(""), +) - .foregroundStyle(foregroundStyle) - .font(font) - } else { - context.node.children - .map { $0.render() } - .reduce(Text(""), +) - .font(font) - } - - BreakTextRenderer(breakType: .hard) - .body(context: context) - } -} diff --git a/Sources/MarkdownView/WIP/Renderers/MarkdownText Components/ImageTextRenderer.swift b/Sources/MarkdownView/WIP/Renderers/MarkdownText Components/ImageTextRenderer.swift deleted file mode 100644 index 63247c299..000000000 --- a/Sources/MarkdownView/WIP/Renderers/MarkdownText Components/ImageTextRenderer.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// ImageTextRenderer.swift -// MarkdownView -// -// Created by Yanan Li on 2025/2/12. -// - -import SwiftUI - -struct ImageTextRenderer: MarkdownNode2TextRenderer { - func body(context: Context) -> Text { - if case let .image(image) = context.node.content { - image - } - } -} - -enum ImageLoader { - static func load(_ url: URL) async throws -> Image { - let (data, _) = try await URLSession.shared.data(from: url) - - #if os(macOS) - let nsImage = NSImage(data: data) - guard let nsImage else { - throw LoadError.invalidData - } - - return Image(platformImage: nsImage) - #else - let uiImage = UIImage(data: data) - guard let uiImage else { - throw LoadError.invalidData - } - - return Image(platformImage: uiImage) - #endif - } - - enum LoadError: Error { - case invalidData - } -} diff --git a/Sources/MarkdownView/WIP/Renderers/MarkdownText Components/InlineCodeTextRenderer.swift b/Sources/MarkdownView/WIP/Renderers/MarkdownText Components/InlineCodeTextRenderer.swift deleted file mode 100644 index a7c198b5a..000000000 --- a/Sources/MarkdownView/WIP/Renderers/MarkdownText Components/InlineCodeTextRenderer.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// InlineCodeTextRenderer.swift -// MarkdownView -// -// Created by Yanan Li on 2025/2/12. -// - -import SwiftUI - -struct InlineCodeTextRenderer: MarkdownNode2TextRenderer { - func body(context: Context) -> Text { - if case let .text(text) = context.node.content! { - if #available(iOS 16.4, macOS 13.3, tvOS 16.4, watchOS 9.4, *) { - Text(text).monospaced() - } else { - Text(text).font(.body.monospaced()) - } - } - } -} diff --git a/Sources/MarkdownView/WIP/Renderers/MarkdownText Components/LinkTextRenderer.swift b/Sources/MarkdownView/WIP/Renderers/MarkdownText Components/LinkTextRenderer.swift deleted file mode 100644 index 1e331f33f..000000000 --- a/Sources/MarkdownView/WIP/Renderers/MarkdownText Components/LinkTextRenderer.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// LinkTextRenderer.swift -// MarkdownView -// -// Created by Yanan Li on 2025/2/12. -// - -import SwiftUI - -struct LinkTextRenderer: MarkdownNode2TextRenderer { - func body(context: Context) -> Text { - if case let .link(title, url) = context.node.content! { - Text(.init("[\(title)](\(url))")) - } - } -} diff --git a/Sources/MarkdownView/WIP/Renderers/MarkdownText Components/ListItemTextRenderer.swift b/Sources/MarkdownView/WIP/Renderers/MarkdownText Components/ListItemTextRenderer.swift deleted file mode 100644 index 3c152f9e0..000000000 --- a/Sources/MarkdownView/WIP/Renderers/MarkdownText Components/ListItemTextRenderer.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// ListItemTextRenderer.swift -// MarkdownView -// -// Created by Yanan Li on 2025/2/13. -// - -import SwiftUI - -struct ListItemTextRenderer: MarkdownNode2TextRenderer { - func body(context: Context) -> Text { - context.node.children - .map { $0.render() } - .reduce(Text(""), +) - } -} diff --git a/Sources/MarkdownView/WIP/Renderers/MarkdownText Components/OrderedListTextRenderer.swift b/Sources/MarkdownView/WIP/Renderers/MarkdownText Components/OrderedListTextRenderer.swift deleted file mode 100644 index dd4fb1778..000000000 --- a/Sources/MarkdownView/WIP/Renderers/MarkdownText Components/OrderedListTextRenderer.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// OrderedListTextRenderer.swift -// MarkdownView -// -// Created by Yanan Li on 2025/2/13. -// - -import SwiftUI - -struct OrderedListTextRenderer: MarkdownNode2TextRenderer { - func body(context: Context) -> Text { - let indents = context.node.depth ?? 0 - let indentation = (0.. Text { - let marker = context.rendererConfiguration.listConfiguration - .orderedListMarker - .marker(at: index, listDepth: context.node.depth ?? 0) - if context.environment.markdownRendererConfiguration.listConfiguration.orderedListMarker.monospaced { - if #available(iOS 16.4, macOS 13.3, tvOS 16.4, watchOS 9.4, *) { - Text("\(marker) ") - .monospaced() - } else { - Text("\(marker) ") - .font(.body.monospaced()) - } - } else { - Text("\(marker) ") - } - } -} diff --git a/Sources/MarkdownView/WIP/Renderers/MarkdownText Components/ParagraphTextRenderer.swift b/Sources/MarkdownView/WIP/Renderers/MarkdownText Components/ParagraphTextRenderer.swift deleted file mode 100644 index e96090f3e..000000000 --- a/Sources/MarkdownView/WIP/Renderers/MarkdownText Components/ParagraphTextRenderer.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// ParagraphTextRenderer.swift -// MarkdownView -// -// Created by Yanan Li on 2025/2/12. -// - -import SwiftUI - -struct ParagraphTextRenderer: MarkdownNode2TextRenderer { - func body(context: Context) -> Text { - context.node.children - .map { $0.render() } - .reduce(Text(""), +) - BreakTextRenderer(breakType: .hard) - .body(context: context) - } -} diff --git a/Sources/MarkdownView/WIP/Renderers/MarkdownText Components/Protocol/MarkdownNode2TextRenderer.swift b/Sources/MarkdownView/WIP/Renderers/MarkdownText Components/Protocol/MarkdownNode2TextRenderer.swift deleted file mode 100644 index 0b4f89af6..000000000 --- a/Sources/MarkdownView/WIP/Renderers/MarkdownText Components/Protocol/MarkdownNode2TextRenderer.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// MarkdownNode2TextRenderer.swift -// MarkdownView -// -// Created by Yanan Li on 2025/2/12. -// - -import Foundation -import SwiftUI - -@MainActor -@preconcurrency -protocol MarkdownNode2TextRenderer { - typealias Context = MarkdownNode2TextRendererContext - - @MainActor - @TextBuilder - func body(context: Context) -> Text -} - -@MainActor -@preconcurrency -struct MarkdownNode2TextRendererContext: Sendable { - var node: MarkdownTextNode - var environment: EnvironmentValues - var rendererConfiguration: MarkdownRendererConfiguration { - environment.markdownRendererConfiguration - } -} diff --git a/Sources/MarkdownView/WIP/Renderers/MarkdownText Components/UnorderedListTextRenderer.swift b/Sources/MarkdownView/WIP/Renderers/MarkdownText Components/UnorderedListTextRenderer.swift deleted file mode 100644 index 61c73dc07..000000000 --- a/Sources/MarkdownView/WIP/Renderers/MarkdownText Components/UnorderedListTextRenderer.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// UnorderedListTextRenderer.swift -// MarkdownView -// -// Created by Yanan Li on 2025/2/13. -// - -import SwiftUI - -struct UnorderedListTextRenderer: MarkdownNode2TextRenderer { - func body(context: Context) -> Text { - let indents = context.node.depth ?? 0 - let indentation = (0.. Text { - let marker = context.rendererConfiguration.listConfiguration - .unorderedListMarker - .marker(listDepth: context.node.depth ?? 0) - if context.rendererConfiguration.listConfiguration.unorderedListMarker.monospaced { - if #available(iOS 16.4, macOS 13.3, tvOS 16.4, watchOS 9.4, *) { - Text("\(marker) ") - .monospaced() - } else { - Text("\(marker) ") - .font(.body.monospaced()) - } - } else { - Text("\(marker) ") - } - } -} From 23ef502a3ff0aada6236cb1a897771e28a084a92 Mon Sep 17 00:00:00 2001 From: Yanan Li <1474700628@qq.com> Date: Mon, 27 Oct 2025 17:31:27 +0800 Subject: [PATCH 02/27] Deprecated `MarkdownViewStyle` --- .../{2.0 => MarkdownView 2}/DeprecatedAPIs.swift | 0 .../MarkdownView 2}/MarkdownViewStyle.swift | 8 ++++++++ .../{2.0 => MarkdownView 2}/RenderingModeModifier.swift | 0 Sources/MarkdownView/MarkdownView.swift | 8 +------- 4 files changed, 9 insertions(+), 7 deletions(-) rename Sources/MarkdownView/Deprecated & Unavailable APIs/{2.0 => MarkdownView 2}/DeprecatedAPIs.swift (100%) rename Sources/MarkdownView/{View Modifiers => Deprecated & Unavailable APIs/MarkdownView 2}/MarkdownViewStyle.swift (82%) rename Sources/MarkdownView/Deprecated & Unavailable APIs/{2.0 => MarkdownView 2}/RenderingModeModifier.swift (100%) diff --git a/Sources/MarkdownView/Deprecated & Unavailable APIs/2.0/DeprecatedAPIs.swift b/Sources/MarkdownView/Deprecated & Unavailable APIs/MarkdownView 2/DeprecatedAPIs.swift similarity index 100% rename from Sources/MarkdownView/Deprecated & Unavailable APIs/2.0/DeprecatedAPIs.swift rename to Sources/MarkdownView/Deprecated & Unavailable APIs/MarkdownView 2/DeprecatedAPIs.swift diff --git a/Sources/MarkdownView/View Modifiers/MarkdownViewStyle.swift b/Sources/MarkdownView/Deprecated & Unavailable APIs/MarkdownView 2/MarkdownViewStyle.swift similarity index 82% rename from Sources/MarkdownView/View Modifiers/MarkdownViewStyle.swift rename to Sources/MarkdownView/Deprecated & Unavailable APIs/MarkdownView 2/MarkdownViewStyle.swift index 0f2e40ecb..7385090a1 100644 --- a/Sources/MarkdownView/View Modifiers/MarkdownViewStyle.swift +++ b/Sources/MarkdownView/Deprecated & Unavailable APIs/MarkdownView 2/MarkdownViewStyle.swift @@ -8,6 +8,7 @@ import SwiftUI extension View { + @available(*, deprecated, message: "Wrap `MarkdownView` or apply modifiers directly at here. If you have already created a `MarkdownViewStyle`, just copy the code from `makeBody(configuration:) -> Body` and copy to here.") nonisolated public func markdownViewStyle(_ style: some MarkdownViewStyle) -> some View { environment(\.markdownViewStyle, style) } @@ -18,6 +19,7 @@ extension View { /// The appearance and layout behavior of MarkdownView. @MainActor @preconcurrency +@available(*, deprecated, message: "Use `ViewModifier` protocol instead.") public protocol MarkdownViewStyle { /// The properties of a MarkdownView. typealias Configuration = MarkdownViewStyleConfiguration @@ -47,12 +49,14 @@ public struct MarkdownViewStyleConfiguration { // MARK: - DefaultMarkdownViewStyle /// A MarkdownViewStyle that uses default appearances. +@available(*, deprecated) public struct DefaultMarkdownViewStyle: MarkdownViewStyle { public func makeBody(configuration: Configuration) -> some View { configuration.body } } +@available(*, deprecated) extension MarkdownViewStyle where Self == DefaultMarkdownViewStyle { /// A MarkdownViewStyle that uses default appearances. static public var `default`: DefaultMarkdownViewStyle { .init() } @@ -61,6 +65,7 @@ extension MarkdownViewStyle where Self == DefaultMarkdownViewStyle { // MARK: - EditorMarkdownViewStyle /// A MarkdownViewStyle that takes up all available spaces and align its content to top-leading, just like an editor. +@available(*, deprecated, message: "Use `.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)` instead.") public struct EditorMarkdownViewStyle: MarkdownViewStyle { public func makeBody(configuration: Configuration) -> some View { configuration.body @@ -68,6 +73,7 @@ public struct EditorMarkdownViewStyle: MarkdownViewStyle { } } +@available(*, deprecated) extension MarkdownViewStyle where Self == EditorMarkdownViewStyle { /// A MarkdownViewStyle that takes up all available spaces and align its content to top-leading, just like an editor. static public var editor: EditorMarkdownViewStyle { .init() } @@ -75,11 +81,13 @@ extension MarkdownViewStyle where Self == EditorMarkdownViewStyle { // MARK: - MarkdownViewStyle + Environment +@available(*, deprecated) struct MarkdownViewStyleEnvironmentKey: @preconcurrency EnvironmentKey { @MainActor static var defaultValue: any MarkdownViewStyle = .default } extension EnvironmentValues { + @available(*, deprecated) var markdownViewStyle: any MarkdownViewStyle { get { self[MarkdownViewStyleEnvironmentKey.self] } set { self[MarkdownViewStyleEnvironmentKey.self] = newValue } diff --git a/Sources/MarkdownView/Deprecated & Unavailable APIs/2.0/RenderingModeModifier.swift b/Sources/MarkdownView/Deprecated & Unavailable APIs/MarkdownView 2/RenderingModeModifier.swift similarity index 100% rename from Sources/MarkdownView/Deprecated & Unavailable APIs/2.0/RenderingModeModifier.swift rename to Sources/MarkdownView/Deprecated & Unavailable APIs/MarkdownView 2/RenderingModeModifier.swift diff --git a/Sources/MarkdownView/MarkdownView.swift b/Sources/MarkdownView/MarkdownView.swift index 0a9ac969b..871937c54 100644 --- a/Sources/MarkdownView/MarkdownView.swift +++ b/Sources/MarkdownView/MarkdownView.swift @@ -9,7 +9,6 @@ public struct MarkdownView: View { @Environment(\.colorScheme) private var colorScheme @Environment(\.displayScale) private var displayScale - @Environment(\.markdownViewStyle) private var markdownViewStyle @Environment(\.markdownFontGroup.body) private var bodyFont @Environment(\.markdownRendererConfiguration) private var configuration @@ -41,12 +40,7 @@ public struct MarkdownView: View { } public var body: some View { - markdownViewStyle - .makeBody( - configuration: MarkdownViewStyleConfiguration(body: _renderedBody) - ) - .erasedToAnyView() - .font(bodyFont) + _renderedBody.font(bodyFont) } @ViewBuilder From 3009a6034bf26474205da2dfcfc1e4f6460ddae2 Mon Sep 17 00:00:00 2001 From: Yanan Li <1474700628@qq.com> Date: Mon, 27 Oct 2025 17:35:52 +0800 Subject: [PATCH 03/27] Update package --- Sources/MarkdownView/Caches/Cacheable.swift | 4 ++-- .../MarkdownTableStyleConfiguration.Table.Fallback.swift | 0 .../MarkdownTableStyleConfiguration.Table.Header.swift | 0 .../MarkdownTableStyleConfiguration.Table.Row.swift | 0 .../Configuration/MarkdownTableStyleConfiguration.Table.swift | 0 .../Configuration/MarkdownTableStyleConfiguration.swift | 0 .../Table}/DefaultMarkdownTableStyle.swift | 0 .../Table}/GithubMarkdownTableStyle.swift | 0 .../Table}/GridMarkdownTableStyle.swift | 0 .../Table}/MarkdownTableStyle.swift | 0 .../{MarkdownView 2 => v2}/DeprecatedAPIs.swift | 0 .../{MarkdownView 2 => v2}/MarkdownViewStyle.swift | 0 .../{MarkdownView 2 => v2}/RenderingModeModifier.swift | 0 13 files changed, 2 insertions(+), 2 deletions(-) rename Sources/MarkdownView/{Renderers/Node Representations/Tables/Protocol => Customizations/Table}/Configuration/MarkdownTableStyleConfiguration.Table.Fallback.swift (100%) rename Sources/MarkdownView/{Renderers/Node Representations/Tables/Protocol => Customizations/Table}/Configuration/MarkdownTableStyleConfiguration.Table.Header.swift (100%) rename Sources/MarkdownView/{Renderers/Node Representations/Tables/Protocol => Customizations/Table}/Configuration/MarkdownTableStyleConfiguration.Table.Row.swift (100%) rename Sources/MarkdownView/{Renderers/Node Representations/Tables/Protocol => Customizations/Table}/Configuration/MarkdownTableStyleConfiguration.Table.swift (100%) rename Sources/MarkdownView/{Renderers/Node Representations/Tables/Protocol => Customizations/Table}/Configuration/MarkdownTableStyleConfiguration.swift (100%) rename Sources/MarkdownView/{Renderers/Node Representations/Tables/Styles => Customizations/Table}/DefaultMarkdownTableStyle.swift (100%) rename Sources/MarkdownView/{Renderers/Node Representations/Tables/Styles => Customizations/Table}/GithubMarkdownTableStyle.swift (100%) rename Sources/MarkdownView/{Renderers/Node Representations/Tables/Styles => Customizations/Table}/GridMarkdownTableStyle.swift (100%) rename Sources/MarkdownView/{Renderers/Node Representations/Tables/Protocol => Customizations/Table}/MarkdownTableStyle.swift (100%) rename Sources/MarkdownView/Deprecated & Unavailable APIs/{MarkdownView 2 => v2}/DeprecatedAPIs.swift (100%) rename Sources/MarkdownView/Deprecated & Unavailable APIs/{MarkdownView 2 => v2}/MarkdownViewStyle.swift (100%) rename Sources/MarkdownView/Deprecated & Unavailable APIs/{MarkdownView 2 => v2}/RenderingModeModifier.swift (100%) diff --git a/Sources/MarkdownView/Caches/Cacheable.swift b/Sources/MarkdownView/Caches/Cacheable.swift index 1061343ae..3003ee8bd 100644 --- a/Sources/MarkdownView/Caches/Cacheable.swift +++ b/Sources/MarkdownView/Caches/Cacheable.swift @@ -8,8 +8,8 @@ import Foundation protocol Cacheable { - associatedtype CacheKey: Hashable - var cacheKey: CacheKey { get } + associatedtype Key: Hashable + var cacheKey: Key { get } init?(fromCache value: any Cacheable) } diff --git a/Sources/MarkdownView/Renderers/Node Representations/Tables/Protocol/Configuration/MarkdownTableStyleConfiguration.Table.Fallback.swift b/Sources/MarkdownView/Customizations/Table/Configuration/MarkdownTableStyleConfiguration.Table.Fallback.swift similarity index 100% rename from Sources/MarkdownView/Renderers/Node Representations/Tables/Protocol/Configuration/MarkdownTableStyleConfiguration.Table.Fallback.swift rename to Sources/MarkdownView/Customizations/Table/Configuration/MarkdownTableStyleConfiguration.Table.Fallback.swift diff --git a/Sources/MarkdownView/Renderers/Node Representations/Tables/Protocol/Configuration/MarkdownTableStyleConfiguration.Table.Header.swift b/Sources/MarkdownView/Customizations/Table/Configuration/MarkdownTableStyleConfiguration.Table.Header.swift similarity index 100% rename from Sources/MarkdownView/Renderers/Node Representations/Tables/Protocol/Configuration/MarkdownTableStyleConfiguration.Table.Header.swift rename to Sources/MarkdownView/Customizations/Table/Configuration/MarkdownTableStyleConfiguration.Table.Header.swift diff --git a/Sources/MarkdownView/Renderers/Node Representations/Tables/Protocol/Configuration/MarkdownTableStyleConfiguration.Table.Row.swift b/Sources/MarkdownView/Customizations/Table/Configuration/MarkdownTableStyleConfiguration.Table.Row.swift similarity index 100% rename from Sources/MarkdownView/Renderers/Node Representations/Tables/Protocol/Configuration/MarkdownTableStyleConfiguration.Table.Row.swift rename to Sources/MarkdownView/Customizations/Table/Configuration/MarkdownTableStyleConfiguration.Table.Row.swift diff --git a/Sources/MarkdownView/Renderers/Node Representations/Tables/Protocol/Configuration/MarkdownTableStyleConfiguration.Table.swift b/Sources/MarkdownView/Customizations/Table/Configuration/MarkdownTableStyleConfiguration.Table.swift similarity index 100% rename from Sources/MarkdownView/Renderers/Node Representations/Tables/Protocol/Configuration/MarkdownTableStyleConfiguration.Table.swift rename to Sources/MarkdownView/Customizations/Table/Configuration/MarkdownTableStyleConfiguration.Table.swift diff --git a/Sources/MarkdownView/Renderers/Node Representations/Tables/Protocol/Configuration/MarkdownTableStyleConfiguration.swift b/Sources/MarkdownView/Customizations/Table/Configuration/MarkdownTableStyleConfiguration.swift similarity index 100% rename from Sources/MarkdownView/Renderers/Node Representations/Tables/Protocol/Configuration/MarkdownTableStyleConfiguration.swift rename to Sources/MarkdownView/Customizations/Table/Configuration/MarkdownTableStyleConfiguration.swift diff --git a/Sources/MarkdownView/Renderers/Node Representations/Tables/Styles/DefaultMarkdownTableStyle.swift b/Sources/MarkdownView/Customizations/Table/DefaultMarkdownTableStyle.swift similarity index 100% rename from Sources/MarkdownView/Renderers/Node Representations/Tables/Styles/DefaultMarkdownTableStyle.swift rename to Sources/MarkdownView/Customizations/Table/DefaultMarkdownTableStyle.swift diff --git a/Sources/MarkdownView/Renderers/Node Representations/Tables/Styles/GithubMarkdownTableStyle.swift b/Sources/MarkdownView/Customizations/Table/GithubMarkdownTableStyle.swift similarity index 100% rename from Sources/MarkdownView/Renderers/Node Representations/Tables/Styles/GithubMarkdownTableStyle.swift rename to Sources/MarkdownView/Customizations/Table/GithubMarkdownTableStyle.swift diff --git a/Sources/MarkdownView/Renderers/Node Representations/Tables/Styles/GridMarkdownTableStyle.swift b/Sources/MarkdownView/Customizations/Table/GridMarkdownTableStyle.swift similarity index 100% rename from Sources/MarkdownView/Renderers/Node Representations/Tables/Styles/GridMarkdownTableStyle.swift rename to Sources/MarkdownView/Customizations/Table/GridMarkdownTableStyle.swift diff --git a/Sources/MarkdownView/Renderers/Node Representations/Tables/Protocol/MarkdownTableStyle.swift b/Sources/MarkdownView/Customizations/Table/MarkdownTableStyle.swift similarity index 100% rename from Sources/MarkdownView/Renderers/Node Representations/Tables/Protocol/MarkdownTableStyle.swift rename to Sources/MarkdownView/Customizations/Table/MarkdownTableStyle.swift diff --git a/Sources/MarkdownView/Deprecated & Unavailable APIs/MarkdownView 2/DeprecatedAPIs.swift b/Sources/MarkdownView/Deprecated & Unavailable APIs/v2/DeprecatedAPIs.swift similarity index 100% rename from Sources/MarkdownView/Deprecated & Unavailable APIs/MarkdownView 2/DeprecatedAPIs.swift rename to Sources/MarkdownView/Deprecated & Unavailable APIs/v2/DeprecatedAPIs.swift diff --git a/Sources/MarkdownView/Deprecated & Unavailable APIs/MarkdownView 2/MarkdownViewStyle.swift b/Sources/MarkdownView/Deprecated & Unavailable APIs/v2/MarkdownViewStyle.swift similarity index 100% rename from Sources/MarkdownView/Deprecated & Unavailable APIs/MarkdownView 2/MarkdownViewStyle.swift rename to Sources/MarkdownView/Deprecated & Unavailable APIs/v2/MarkdownViewStyle.swift diff --git a/Sources/MarkdownView/Deprecated & Unavailable APIs/MarkdownView 2/RenderingModeModifier.swift b/Sources/MarkdownView/Deprecated & Unavailable APIs/v2/RenderingModeModifier.swift similarity index 100% rename from Sources/MarkdownView/Deprecated & Unavailable APIs/MarkdownView 2/RenderingModeModifier.swift rename to Sources/MarkdownView/Deprecated & Unavailable APIs/v2/RenderingModeModifier.swift From 8770952d5775ec5f8322e840b10e305aef9e32c1 Mon Sep 17 00:00:00 2001 From: Yanan Li <1474700628@qq.com> Date: Mon, 27 Oct 2025 20:53:22 +0800 Subject: [PATCH 04/27] Remove cmark renderer result caching --- .../CmarkFirstMarkdownViewRenderer.swift | 40 +------------------ 1 file changed, 1 insertion(+), 39 deletions(-) diff --git a/Sources/MarkdownView/Renderers/Cmark/CmarkFirstMarkdownViewRenderer.swift b/Sources/MarkdownView/Renderers/Cmark/CmarkFirstMarkdownViewRenderer.swift index f31c83a47..d7515b4cb 100644 --- a/Sources/MarkdownView/Renderers/Cmark/CmarkFirstMarkdownViewRenderer.swift +++ b/Sources/MarkdownView/Renderers/Cmark/CmarkFirstMarkdownViewRenderer.swift @@ -13,50 +13,12 @@ struct CmarkFirstMarkdownViewRenderer: MarkdownViewRenderer { content: MarkdownContent, configuration: MarkdownRendererConfiguration ) -> some View { - _makeAndCacheBody( - content: content, - configuration: configuration - ) - } - - private func _makeAndCacheBody( - content: MarkdownContent, - configuration: MarkdownRendererConfiguration - ) -> some View { - if let cached = CacheStorage.shared.withCacheIfAvailable( - content, - type: Cache.self - ), cached.configuration == configuration { - return AnyView(cached.renderedView) - } - var parseOptions = ParseOptions() if !configuration.allowedBlockDirectiveRenderers.isEmpty { parseOptions.insert(.parseBlockDirectives) } - let renderedView = CmarkNodeVisitor(configuration: configuration) + return CmarkNodeVisitor(configuration: configuration) .makeBody(for: content.parse(options: parseOptions)) - .erasedToAnyView() - - CacheStorage.shared.addCache( - Cache( - markdownContent: content, - configuration: configuration, - renderedView: renderedView - ) - ) - - return renderedView - } -} - -extension CmarkFirstMarkdownViewRenderer { - struct Cache: Cacheable { - var markdownContent: MarkdownContent - var configuration: MarkdownRendererConfiguration - var renderedView: any View - - var cacheKey: some Hashable { markdownContent } } } From 5c2b3e175bee9b547e5279c3f4037f7a2b32b0ce Mon Sep 17 00:00:00 2001 From: Yanan Li <1474700628@qq.com> Date: Tue, 28 Oct 2025 00:27:57 +0800 Subject: [PATCH 05/27] Refactor `MarkdownContent` --- Sources/MarkdownView/MarkdownContent.swift | 132 ++++++++++++++---- Sources/MarkdownView/MarkdownReader.swift | 30 ++-- Sources/MarkdownView/MarkdownView.swift | 41 +++--- .../CmarkFirstMarkdownViewRenderer.swift | 2 +- .../Renderers/MarkdownViewRenderer.swift | 12 ++ ...nViewRenderer.ParsingRangesExtractor.swift | 64 --------- .../Math/MathFirstMarkdownViewRenderer.swift | 69 ++++++++- .../Renderers/Math/MathParser.swift | 4 +- .../Renderers/RawMarkdownContent.swift | 34 ----- .../Table of Content/MarkdownHeading.swift | 45 ++++++ .../MarkdownTableOfContent.swift | 80 ++++------- 11 files changed, 296 insertions(+), 217 deletions(-) delete mode 100644 Sources/MarkdownView/Renderers/Math/MathFirstMarkdownViewRenderer.ParsingRangesExtractor.swift delete mode 100644 Sources/MarkdownView/Renderers/RawMarkdownContent.swift create mode 100644 Sources/MarkdownView/Table of Content/MarkdownHeading.swift diff --git a/Sources/MarkdownView/MarkdownContent.swift b/Sources/MarkdownView/MarkdownContent.swift index 0901624f0..657ed31b6 100644 --- a/Sources/MarkdownView/MarkdownContent.swift +++ b/Sources/MarkdownView/MarkdownContent.swift @@ -6,20 +6,84 @@ // import Foundation +import Combine @preconcurrency import Markdown -/// A value that stores the parsed representation of a Markdown document. -/// -/// If you're using ``MarkdownReader``, you will be able to get this within the view builder closure. -public struct MarkdownContent: Sendable { - @_spi(RawMarkdown) - public var raw: RawMarkdownContent +/// An observable object that manages the content of the markdown and provides the parsed document. +public final class MarkdownContent: ObservableObject { + var store: ParsedDocumentStore! + @Published private var raw: Raw { + willSet { + if raw != newValue { + store.resetStorage() + } + } + } + + /// The markdown text. + public var markdown: String { + get throws { + try raw.markdownText + } + } + + internal init(_ source: Raw) { + self.raw = source + self.store = ParsedDocumentStore(self) + } + + /// Parsed document. + /// + /// - parameter parseOptions: The parse options to use for markdown parsing or `nil` if you want to either use any cached version or parse the markdown with default options. + /// + /// This API try to find parsed document with given `parseOptions` in the cache. + /// If there is no matches, then it tries to parse the content and cache it for future query. + /// + /// If the `parseOptions` is set to `nil` and there is any cached document available, the first cached result will be returned. + internal func document(options: ParseOptions = ParseOptions()) -> Document { + store.parse(raw, options: options) + } +} + +extension MarkdownContent { + /// Creates an instance from a plain string. + public convenience init(_ text: String) { + self.init(.plainText(text)) + } + + /// Creates an instance whose contents are loaded from a URL. + public convenience init(_ url: URL) { + self.init(.url(url)) + } + + /// Updates the source of the markdown content. + /// - parameter content: The markdown text. + public func updateContent(_ content: String) { + self.raw = .plainText(content) + } + + /// Updates the source of the markdown content. + /// - parameter content: The URL of the markdown file. + public func updateContent(_ content: URL) { + self.raw = .url(content) + } +} + +extension MarkdownContent { class ParsedDocumentStore: /* NSLock */ @unchecked Sendable { private var lock = NSLock() private var caches: [ParseOptions.RawValue : Document] = [:] + unowned var content: MarkdownContent - fileprivate func parse(_ rawContent: RawMarkdownContent, options: ParseOptions = ParseOptions()) -> Document { + init(_ content: MarkdownContent) { + self.content = content + } + + fileprivate func parse( + _ source: MarkdownContent.Raw, + options: ParseOptions = ParseOptions() + ) -> Document { lock.lock() defer { lock.unlock() } @@ -27,9 +91,17 @@ public struct MarkdownContent: Sendable { return cached } + let text: String + do { + text = try source.markdownText + } catch { + text = "" + logger.error("Unable to retrieve markdown content in string format: \(error). (fallback to empty string).") + } + let document = Document( - parsing: rawContent.text, - source: rawContent.source, + parsing: text, + source: nil, options: options ) caches[options.rawValue] = document @@ -45,25 +117,37 @@ public struct MarkdownContent: Sendable { var hasParsedDocument: Bool { !documents.isEmpty } + + func resetStorage() { + lock.lock() + defer { lock.unlock() } + + caches = [:] + } } - var store: ParsedDocumentStore +} - internal init(raw: RawMarkdownContent) { - self.raw = raw - self.store = ParsedDocumentStore() - } - - func parse(options: ParseOptions = ParseOptions()) -> Document { - store.parse(raw, options: options) +extension MarkdownContent: ExpressibleByStringLiteral { + public convenience init(stringLiteral value: String) { + self.init(value) } } -extension MarkdownContent: Hashable { - public static func == (lhs: MarkdownContent, rhs: MarkdownContent) -> Bool { - lhs.raw == rhs.raw - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(raw) +extension MarkdownContent { + /// A representation of where the Markdown originates. + public enum Raw: Hashable { + case plainText(String) + case url(URL) + + public var markdownText: String { + get throws { + switch self { + case .plainText(let string): + string + case .url(let url): + try String(contentsOf: url, encoding: .utf8) + } + } + } } } diff --git a/Sources/MarkdownView/MarkdownReader.swift b/Sources/MarkdownView/MarkdownReader.swift index 610865d5c..6f9350d28 100644 --- a/Sources/MarkdownView/MarkdownReader.swift +++ b/Sources/MarkdownView/MarkdownReader.swift @@ -7,9 +7,10 @@ import SwiftUI -/// A reader that provides a markdown content to use across multiple views. +/// A reader that provides markdown content to use across multiple views. /// -/// This reader offers a single source-of-truth for its child markdown views, and ensures the input is only parsed once. +/// This reader offers a single source of truth for its child markdown views so +/// the same Markdown source flows through the hierarchy. /// /// ```swift /// MarkdownReader("**Hello World**") { markdown in @@ -20,22 +21,27 @@ import SwiftUI /// } /// ``` public struct MarkdownReader: View { - private var markdownContent: MarkdownContent - private var contents: (_ markdownContent: MarkdownContent) -> Content + @ObservedObject private var content: MarkdownContent + private var _body: (_ markdownContent: MarkdownContent) -> Content - public init(_ text: String, @ViewBuilder contents: @escaping (MarkdownContent) -> Content) { - self.markdownContent = MarkdownContent(raw: .plainText(text)) - self.contents = contents + public init( + _ text: String, + @ViewBuilder contents: @escaping (MarkdownContent) -> Content + ) { + content = MarkdownContent(text) + self._body = contents } - @_spi(WIP) - public init(_ url: URL, @ViewBuilder contents: @escaping (MarkdownContent) -> Content) { - self.markdownContent = MarkdownContent(raw: .url(url)) - self.contents = contents + public init( + _ url: URL, + @ViewBuilder contents: @escaping (MarkdownContent) -> Content + ) { + content = MarkdownContent(url) + self._body = contents } public var body: some View { - contents(markdownContent) + _body(content) } } diff --git a/Sources/MarkdownView/MarkdownView.swift b/Sources/MarkdownView/MarkdownView.swift index 871937c54..bf13ea10d 100644 --- a/Sources/MarkdownView/MarkdownView.swift +++ b/Sources/MarkdownView/MarkdownView.swift @@ -3,38 +3,25 @@ import Markdown /// A view that renders markdown content. public struct MarkdownView: View { - private var content: MarkdownContent - - @State private var viewSize = CGSize.zero - @Environment(\.colorScheme) private var colorScheme - @Environment(\.displayScale) private var displayScale + @ObservedObject private var content: MarkdownContent @Environment(\.markdownFontGroup.body) private var bodyFont @Environment(\.markdownRendererConfiguration) private var configuration /// Creates a view that renders given markdown string. - /// - /// - Parameter text: The Markdown source to render. + /// - Parameter text: The markdown source to render. public init(_ text: String) { - self.content = MarkdownContent( - raw: .plainText(text) - ) + self.content = MarkdownContent(text) } - @_spi(WIP) + /// 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( - raw: .url(url) - ) + self.content = MarkdownContent(url) } - /// Creates a view that renders from a ``MarkdownContent`` instance. - /// - /// Use this initializer when the content comes from ``MarkdownReader`` or a - /// cached value so that multiple Markdown views can reuse the same parsed - /// document and renderer cache. - /// - /// - Parameter content: The parsed Markdown to render. + /// Creates an instance that renders from a ``MarkdownContent`` . + /// - Parameter content: The ``MarkdownContent`` to render. public init(_ content: MarkdownContent) { self.content = content } @@ -46,11 +33,15 @@ public struct MarkdownView: View { @ViewBuilder private var _renderedBody: some View { if configuration.rendersMath { - MathFirstMarkdownViewRenderer() - .makeBody(content: content, configuration: configuration) + MathFirstMarkdownViewRenderer().makeBody( + content: content, + configuration: configuration + ) } else { - CmarkFirstMarkdownViewRenderer() - .makeBody(content: content, configuration: configuration) + CmarkFirstMarkdownViewRenderer().makeBody( + content: content, + configuration: configuration + ) } } } diff --git a/Sources/MarkdownView/Renderers/Cmark/CmarkFirstMarkdownViewRenderer.swift b/Sources/MarkdownView/Renderers/Cmark/CmarkFirstMarkdownViewRenderer.swift index d7515b4cb..d053d54c8 100644 --- a/Sources/MarkdownView/Renderers/Cmark/CmarkFirstMarkdownViewRenderer.swift +++ b/Sources/MarkdownView/Renderers/Cmark/CmarkFirstMarkdownViewRenderer.swift @@ -19,6 +19,6 @@ struct CmarkFirstMarkdownViewRenderer: MarkdownViewRenderer { } return CmarkNodeVisitor(configuration: configuration) - .makeBody(for: content.parse(options: parseOptions)) + .makeBody(for: content.document(options: parseOptions)) } } diff --git a/Sources/MarkdownView/Renderers/MarkdownViewRenderer.swift b/Sources/MarkdownView/Renderers/MarkdownViewRenderer.swift index b8c508bf3..16a2a9e3c 100644 --- a/Sources/MarkdownView/Renderers/MarkdownViewRenderer.swift +++ b/Sources/MarkdownView/Renderers/MarkdownViewRenderer.swift @@ -21,3 +21,15 @@ protocol MarkdownViewRenderer { configuration: MarkdownRendererConfiguration ) -> Body } + +extension MarkdownViewRenderer { + internal func parseOptions(for configuration: MarkdownRendererConfiguration) -> ParseOptions { + var options = ParseOptions() + + if !configuration.allowedBlockDirectiveRenderers.isEmpty { + options.insert(.parseBlockDirectives) + } + + return options + } +} diff --git a/Sources/MarkdownView/Renderers/Math/MathFirstMarkdownViewRenderer.ParsingRangesExtractor.swift b/Sources/MarkdownView/Renderers/Math/MathFirstMarkdownViewRenderer.ParsingRangesExtractor.swift deleted file mode 100644 index 102faafec..000000000 --- a/Sources/MarkdownView/Renderers/Math/MathFirstMarkdownViewRenderer.ParsingRangesExtractor.swift +++ /dev/null @@ -1,64 +0,0 @@ -// -// MathFirstMarkdownViewRenderer.ParsingRangesExtractor.swift -// MarkdownView -// -// Created by Yanan Li on 2025/4/17. -// - -import Markdown - -extension MathFirstMarkdownViewRenderer { - struct ParsingRangesExtractor: MarkupWalker { - private var excludedRanges: [Range] = [] - - func parsableRanges(in text: String) -> [Range] { - var allowedRanges: [Range] = [] - let excludedRanges = self.excludedRanges.map { - ($0.lowerBound.index(in: text)..<$0.upperBound.index(in: text)) - } - - let fullRange = text.startIndex.. String.Index { - var idx = string.startIndex - var currentLine = 1 - while currentLine < self.line && idx < string.endIndex { - if string[idx] == "\n" { - currentLine += 1 - } - idx = string.index(after: idx) - } - guard let utf8LineStart = idx.samePosition(in: string.utf8) else { - return string.endIndex - } - let byteOffset = self.column - 1 - let targetUtf8Index = string.utf8.index(utf8LineStart, offsetBy: byteOffset, limitedBy: string.utf8.endIndex) ?? string.utf8.endIndex - return targetUtf8Index.samePosition(in: string) ?? string.endIndex - } -} diff --git a/Sources/MarkdownView/Renderers/Math/MathFirstMarkdownViewRenderer.swift b/Sources/MarkdownView/Renderers/Math/MathFirstMarkdownViewRenderer.swift index b79db83de..c4269174d 100644 --- a/Sources/MarkdownView/Renderers/Math/MathFirstMarkdownViewRenderer.swift +++ b/Sources/MarkdownView/Renderers/Math/MathFirstMarkdownViewRenderer.swift @@ -14,10 +14,10 @@ struct MathFirstMarkdownViewRenderer: MarkdownViewRenderer { configuration: MarkdownRendererConfiguration ) -> some View { var configuration = configuration - var rawText = content.raw.text + var rawText = (try? content.markdown) ?? "" var extractor = ParsingRangesExtractor() - extractor.visit(content.parse(options: ParseOptions().union(.parseBlockDirectives))) + extractor.visit(content.document()) for range in extractor.parsableRanges(in: rawText) { let segment = rawText[range] let segmentParser = MathParser(text: segment) @@ -32,8 +32,67 @@ struct MathFirstMarkdownViewRenderer: MarkdownViewRenderer { } } - let _content = MarkdownContent(raw: .plainText(rawText)) - return CmarkFirstMarkdownViewRenderer() - .makeBody(content: _content, configuration: configuration) + return CmarkFirstMarkdownViewRenderer().makeBody( + content: MarkdownContent(.plainText(rawText)), + configuration: configuration + ) + } +} + +// MARK: - Auxiliary + +fileprivate extension MathFirstMarkdownViewRenderer { + struct ParsingRangesExtractor: MarkupWalker { + private var excludedRanges: [Range] = [] + + func parsableRanges(in text: String) -> [Range] { + var allowedRanges: [Range] = [] + let excludedRanges = self.excludedRanges.map { + ($0.lowerBound.index(in: text)..<$0.upperBound.index(in: text)) + } + + let fullRange = text.startIndex.. String.Index { + var idx = string.startIndex + var currentLine = 1 + while currentLine < self.line && idx < string.endIndex { + if string[idx] == "\n" { + currentLine += 1 + } + idx = string.index(after: idx) + } + guard let utf8LineStart = idx.samePosition(in: string.utf8) else { + return string.endIndex + } + let byteOffset = self.column - 1 + let targetUtf8Index = string.utf8.index(utf8LineStart, offsetBy: byteOffset, limitedBy: string.utf8.endIndex) ?? string.utf8.endIndex + return targetUtf8Index.samePosition(in: string) ?? string.endIndex } } diff --git a/Sources/MarkdownView/Renderers/Math/MathParser.swift b/Sources/MarkdownView/Renderers/Math/MathParser.swift index 2d2afc9f8..687d5a081 100644 --- a/Sources/MarkdownView/Renderers/Math/MathParser.swift +++ b/Sources/MarkdownView/Renderers/Math/MathParser.swift @@ -3,6 +3,7 @@ // MarkdownView // // Created by LiYanan2004 on 2025/2/24. +// Credits to colinc86/LaTeXSwiftUI // import SwiftUI @@ -11,9 +12,6 @@ import LaTeXSwiftUI import MathJaxSwift #endif -/* - Credits to colinc86/LaTeXSwiftUI - */ @_spi(MarkdownMath) public struct MathParser { public var text: any StringProtocol diff --git a/Sources/MarkdownView/Renderers/RawMarkdownContent.swift b/Sources/MarkdownView/Renderers/RawMarkdownContent.swift deleted file mode 100644 index 8dd491c9b..000000000 --- a/Sources/MarkdownView/Renderers/RawMarkdownContent.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// RawMarkdownContent.swift -// MarkdownView -// -// Created by Yanan Li on 2025/10/27. -// - -import Foundation -@preconcurrency import Markdown - -/// The raw input of a markdown content. -@_spi(RawMarkdown) -public enum RawMarkdownContent: Sendable, Hashable { - case plainText(String) - case url(URL) - - @_spi(RawMarkdown) - public var text: String { - switch self { - case .plainText(let text): - return text - case .url(let url): - return (try? String(contentsOf: url)) ?? "" - } - } - - @_spi(RawMarkdown) - public var source: URL? { - if case .url(let url) = self { - return url - } - return nil - } -} diff --git a/Sources/MarkdownView/Table of Content/MarkdownHeading.swift b/Sources/MarkdownView/Table of Content/MarkdownHeading.swift new file mode 100644 index 000000000..9701e6140 --- /dev/null +++ b/Sources/MarkdownView/Table of Content/MarkdownHeading.swift @@ -0,0 +1,45 @@ +// +// MarkdownHeading.swift +// MarkdownView +// +// Created by Yanan Li on 2025/10/28. +// + +import Foundation +import Markdown + +/// A representation of a markdown heading. +public struct MarkdownHeading: Hashable, Sendable { + private var heading: Markdown.Heading + + /// Heading level, starting from 1. + public var level: Int { + heading.level + } + /// The range of the heading in the raw Markdown. + /// + /// The range originates from `swift-markdown`’s parsing result. It is + /// present when the Markdown source carried location information (for + /// example when it was loaded from a file URL). + public var range: SourceRange? { + heading.range + } + /// The content text of the heading. + public var plainText: String { + heading.plainText + } + + init(heading: Heading) { + self.heading = heading + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(level) + hasher.combine(range) + hasher.combine(plainText) + } + + public static func == (lhs: MarkdownHeading, rhs: MarkdownHeading) -> Bool { + lhs.heading.isIdentical(to: rhs.heading) + } +} diff --git a/Sources/MarkdownView/Table of Content/MarkdownTableOfContent.swift b/Sources/MarkdownView/Table of Content/MarkdownTableOfContent.swift index 47ec2d06d..751d7ce9c 100644 --- a/Sources/MarkdownView/Table of Content/MarkdownTableOfContent.swift +++ b/Sources/MarkdownView/Table of Content/MarkdownTableOfContent.swift @@ -4,24 +4,42 @@ import Markdown /// A view that produces content from the headings found in a Markdown document. /// /// Pass the same ``MarkdownContent`` that drives your ``MarkdownView`` so the -/// table of contents stays in sync and no extra parsing is performed. The -/// easiest way to do this is to wrap both views in a ``MarkdownReader``. +/// table of contents stays in sync. The easiest way to do this is to wrap both +/// views in a ``MarkdownReader``. public struct MarkdownTableOfContent: View { - private var markdownContent: MarkdownContent + @ObservedObject private var content: MarkdownContent private var contents: (_ headings: [MarkdownHeading]) -> Content public init( - _ markdownContent: MarkdownContent, + _ content: MarkdownContent, @ViewBuilder contents: @escaping ([MarkdownHeading]) -> Content ) { - self.markdownContent = markdownContent + self.content = content + self.contents = contents + } + + @_disfavoredOverload + public init( + _ content: URL, + @ViewBuilder contents: @escaping ([MarkdownHeading]) -> Content + ) { + self.content = .init(content) + self.contents = contents + } + + @_disfavoredOverload + public init( + _ content: String, + @ViewBuilder contents: @escaping ([MarkdownHeading]) -> Content + ) { + self.content = .init(content) self.contents = contents } private var headings: [MarkdownHeading] { var toc = TableOfContentVisitor() toc.visit( - markdownContent.store.documents.first ?? markdownContent.parse() + content.store.documents.first ?? content.document() ) return toc.headings } @@ -31,49 +49,13 @@ public struct MarkdownTableOfContent: View { } } -extension MarkdownTableOfContent { - /// A representation of a markdown heading. - public struct MarkdownHeading: Hashable, Sendable { - private var heading: Markdown.Heading - - /// Heading level, starting from 1. - public var level: Int { - heading.level - } - /// The range of the heading in the raw Markdown. - /// - /// The range originates from `swift-markdown`’s parsing result. It is - /// present when the Markdown source carried location information (for - /// example when it was loaded from a file URL). - public var range: SourceRange? { - heading.range - } - /// The content text of the heading. - public var plainText: String { - heading.plainText - } - - init(heading: Heading) { - self.heading = heading - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(level) - hasher.combine(range) - hasher.combine(plainText) - } - - public static func == (lhs: MarkdownHeading, rhs: MarkdownHeading) -> Bool { - lhs.heading.isIdentical(to: rhs.heading) - } - } +// MARK: - Auxiliary + +fileprivate struct TableOfContentVisitor: MarkupWalker { + private(set) var headings: [MarkdownHeading] = [] - struct TableOfContentVisitor: MarkupWalker { - private(set) var headings: [MarkdownHeading] = [] - - mutating func visitHeading(_ heading: Markdown.Heading) { - headings.append(MarkdownHeading(heading: heading)) - descendInto(heading) - } + mutating func visitHeading(_ heading: Markdown.Heading) { + headings.append(MarkdownHeading(heading: heading)) + descendInto(heading) } } From 8dd5a25fbb21542834d5a259bf84af302f19f8ce Mon Sep 17 00:00:00 2001 From: Yanan Li <1474700628@qq.com> Date: Mon, 24 Nov 2025 21:01:03 +0800 Subject: [PATCH 06/27] Update package --- .../View Modifiers/{Behaviors => }/BaseURLModifier.swift | 0 .../View Modifiers/{Behaviors => }/MathRenderingModifier.swift | 0 .../{Stylings => Styling}/BlockQuoteStyleModifier.swift | 0 .../{Stylings => Styling}/CodeBlockModifier.swift | 0 .../View Modifiers/{Stylings => Styling}/ListModifier.swift | 0 .../{Stylings => Styling}/TintColorModifier.swift | 0 .../View Modifiers/Text Formatting/FontModifier.swift | 2 -- 7 files changed, 2 deletions(-) rename Sources/MarkdownView/View Modifiers/{Behaviors => }/BaseURLModifier.swift (100%) rename Sources/MarkdownView/View Modifiers/{Behaviors => }/MathRenderingModifier.swift (100%) rename Sources/MarkdownView/View Modifiers/{Stylings => Styling}/BlockQuoteStyleModifier.swift (100%) rename Sources/MarkdownView/View Modifiers/{Stylings => Styling}/CodeBlockModifier.swift (100%) rename Sources/MarkdownView/View Modifiers/{Stylings => Styling}/ListModifier.swift (100%) rename Sources/MarkdownView/View Modifiers/{Stylings => Styling}/TintColorModifier.swift (100%) diff --git a/Sources/MarkdownView/View Modifiers/Behaviors/BaseURLModifier.swift b/Sources/MarkdownView/View Modifiers/BaseURLModifier.swift similarity index 100% rename from Sources/MarkdownView/View Modifiers/Behaviors/BaseURLModifier.swift rename to Sources/MarkdownView/View Modifiers/BaseURLModifier.swift diff --git a/Sources/MarkdownView/View Modifiers/Behaviors/MathRenderingModifier.swift b/Sources/MarkdownView/View Modifiers/MathRenderingModifier.swift similarity index 100% rename from Sources/MarkdownView/View Modifiers/Behaviors/MathRenderingModifier.swift rename to Sources/MarkdownView/View Modifiers/MathRenderingModifier.swift diff --git a/Sources/MarkdownView/View Modifiers/Stylings/BlockQuoteStyleModifier.swift b/Sources/MarkdownView/View Modifiers/Styling/BlockQuoteStyleModifier.swift similarity index 100% rename from Sources/MarkdownView/View Modifiers/Stylings/BlockQuoteStyleModifier.swift rename to Sources/MarkdownView/View Modifiers/Styling/BlockQuoteStyleModifier.swift diff --git a/Sources/MarkdownView/View Modifiers/Stylings/CodeBlockModifier.swift b/Sources/MarkdownView/View Modifiers/Styling/CodeBlockModifier.swift similarity index 100% rename from Sources/MarkdownView/View Modifiers/Stylings/CodeBlockModifier.swift rename to Sources/MarkdownView/View Modifiers/Styling/CodeBlockModifier.swift diff --git a/Sources/MarkdownView/View Modifiers/Stylings/ListModifier.swift b/Sources/MarkdownView/View Modifiers/Styling/ListModifier.swift similarity index 100% rename from Sources/MarkdownView/View Modifiers/Stylings/ListModifier.swift rename to Sources/MarkdownView/View Modifiers/Styling/ListModifier.swift diff --git a/Sources/MarkdownView/View Modifiers/Stylings/TintColorModifier.swift b/Sources/MarkdownView/View Modifiers/Styling/TintColorModifier.swift similarity index 100% rename from Sources/MarkdownView/View Modifiers/Stylings/TintColorModifier.swift rename to Sources/MarkdownView/View Modifiers/Styling/TintColorModifier.swift diff --git a/Sources/MarkdownView/View Modifiers/Text Formatting/FontModifier.swift b/Sources/MarkdownView/View Modifiers/Text Formatting/FontModifier.swift index ceff5af4b..6339ba1dd 100644 --- a/Sources/MarkdownView/View Modifiers/Text Formatting/FontModifier.swift +++ b/Sources/MarkdownView/View Modifiers/Text Formatting/FontModifier.swift @@ -8,7 +8,6 @@ import SwiftUI extension View { - /// Apply a font group to MarkdownView. /// /// Customize fonts for multiple types of text. @@ -41,5 +40,4 @@ extension View { } } } - } From c2f3f6888ba5b8d45bcf9edafb93fd3cddf6ad15 Mon Sep 17 00:00:00 2001 From: Yanan Li <1474700628@qq.com> Date: Wed, 26 Nov 2025 22:12:32 +0800 Subject: [PATCH 07/27] Update package --- Sources/MarkdownView/MarkdownContent.swift | 5 ++--- Sources/MarkdownView/MarkdownView.swift | 11 +++++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/Sources/MarkdownView/MarkdownContent.swift b/Sources/MarkdownView/MarkdownContent.swift index 657ed31b6..f72b8054d 100644 --- a/Sources/MarkdownView/MarkdownContent.swift +++ b/Sources/MarkdownView/MarkdownContent.swift @@ -42,7 +42,7 @@ public final class MarkdownContent: ObservableObject { /// /// If the `parseOptions` is set to `nil` and there is any cached document available, the first cached result will be returned. internal func document(options: ParseOptions = ParseOptions()) -> Document { - store.parse(raw, options: options) + store.parse(options: options) } } @@ -81,7 +81,6 @@ extension MarkdownContent { } fileprivate func parse( - _ source: MarkdownContent.Raw, options: ParseOptions = ParseOptions() ) -> Document { lock.lock() @@ -93,7 +92,7 @@ extension MarkdownContent { let text: String do { - text = try source.markdownText + text = try content.markdown } catch { text = "" logger.error("Unable to retrieve markdown content in string format: \(error). (fallback to empty string).") diff --git a/Sources/MarkdownView/MarkdownView.swift b/Sources/MarkdownView/MarkdownView.swift index bf13ea10d..8f870d54e 100644 --- a/Sources/MarkdownView/MarkdownView.swift +++ b/Sources/MarkdownView/MarkdownView.swift @@ -45,3 +45,14 @@ public struct MarkdownView: View { } } } + +@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) +#Preview(traits: .sizeThatFitsLayout) { + VStack { + MarkdownView("Hello **World**") + } + #if os(macOS) || os(iOS) + .textSelection(.enabled) + #endif + .padding() +} From 40e4ed8bd015761987af5eae91fde93acca7fdbb Mon Sep 17 00:00:00 2001 From: Yanan Li <1474700628@qq.com> Date: Sun, 18 Jan 2026 13:55:21 +0800 Subject: [PATCH 08/27] Update package --- .../Block Quotes/DefaultBlockQuoteStyle.swift | 3 +- ...TextType.swift => MarkdownComponent.swift} | 2 +- ...odifiable.swift => KeyPathModifying.swift} | 6 +- .../Helpers/Markdown/BlockMarkup++.swift | 77 +++++++++++++++++++ .../Helpers/Markdown/BlockQuote.swift | 27 ------- .../Helpers/Markdown/ListItemContainer.swift | 27 ------- .../Helpers/Markdown/Markdown+Sendable.swift | 16 ---- .../Markdown/{Markup.swift => Markup++.swift} | 10 +++ .../Helpers/Markdown/SourceLocation++.swift | 32 ++++++++ .../Helpers/Markdown/SourceLocation.swift | 14 ---- .../Helpers/Markdown/Table++.swift | 1 - .../Renderers/Cmark/CmarkNodeVisitor.swift | 10 ++- .../MarkdownRendererConfiguration.swift | 6 +- .../Node Representations/MarkdownList.swift | 6 +- .../Styling/TintColorModifier.swift | 13 +--- .../Text Formatting/FontModifier.swift | 6 +- 16 files changed, 143 insertions(+), 113 deletions(-) rename Sources/MarkdownView/Customizations/Font/{MarkdownTextType.swift => MarkdownComponent.swift} (77%) rename Sources/MarkdownView/Helpers/{KeyPathModifiable.swift => KeyPathModifying.swift} (85%) create mode 100644 Sources/MarkdownView/Helpers/Markdown/BlockMarkup++.swift delete mode 100644 Sources/MarkdownView/Helpers/Markdown/BlockQuote.swift delete mode 100644 Sources/MarkdownView/Helpers/Markdown/ListItemContainer.swift delete mode 100644 Sources/MarkdownView/Helpers/Markdown/Markdown+Sendable.swift rename Sources/MarkdownView/Helpers/Markdown/{Markup.swift => Markup++.swift} (53%) create mode 100644 Sources/MarkdownView/Helpers/Markdown/SourceLocation++.swift delete mode 100644 Sources/MarkdownView/Helpers/Markdown/SourceLocation.swift diff --git a/Sources/MarkdownView/Customizations/Block Quotes/DefaultBlockQuoteStyle.swift b/Sources/MarkdownView/Customizations/Block Quotes/DefaultBlockQuoteStyle.swift index 6ea7b825a..0ffd92ecc 100644 --- a/Sources/MarkdownView/Customizations/Block Quotes/DefaultBlockQuoteStyle.swift +++ b/Sources/MarkdownView/Customizations/Block Quotes/DefaultBlockQuoteStyle.swift @@ -22,8 +22,9 @@ extension BlockQuoteStyle where Self == DefaultBlockQuoteStyle { fileprivate struct DefaultBlockQuoteView: View { var configuration: BlockQuoteStyleConfiguration @Environment(\.markdownFontGroup.blockQuote) private var font - @Environment(\.markdownRendererConfiguration.blockQuoteTintColor) private var tint + @Environment(\.markdownRendererConfiguration) private var rendererConfiguration var body: some View { + let tint = rendererConfiguration.preferredTintColors[.blockQuote] ?? .accentColor configuration.content .padding(.vertical, 8) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/Sources/MarkdownView/Customizations/Font/MarkdownTextType.swift b/Sources/MarkdownView/Customizations/Font/MarkdownComponent.swift similarity index 77% rename from Sources/MarkdownView/Customizations/Font/MarkdownTextType.swift rename to Sources/MarkdownView/Customizations/Font/MarkdownComponent.swift index ca8f75058..5356e67bf 100644 --- a/Sources/MarkdownView/Customizations/Font/MarkdownTextType.swift +++ b/Sources/MarkdownView/Customizations/Font/MarkdownComponent.swift @@ -1,7 +1,7 @@ import Foundation @_documentation(visibility: internal) -public enum MarkdownTextType: Equatable, CaseIterable { +public enum MarkdownComponent: Equatable, CaseIterable { case h1, h2, h3, h4, h5, h6 case body case codeBlock, blockQuote diff --git a/Sources/MarkdownView/Helpers/KeyPathModifiable.swift b/Sources/MarkdownView/Helpers/KeyPathModifying.swift similarity index 85% rename from Sources/MarkdownView/Helpers/KeyPathModifiable.swift rename to Sources/MarkdownView/Helpers/KeyPathModifying.swift index b908fa361..0f130ae8f 100644 --- a/Sources/MarkdownView/Helpers/KeyPathModifiable.swift +++ b/Sources/MarkdownView/Helpers/KeyPathModifying.swift @@ -1,5 +1,5 @@ // -// KeyPathModifiable.swift +// KeyPathModifying.swift // MarkdownView // // Created by Yanan Li on 2025/2/9. @@ -7,9 +7,9 @@ import Foundation -protocol KeyPathModifiable { } +protocol KeyPathModifying { } -extension KeyPathModifiable { +extension KeyPathModifying { public func with(_ keyPath: WritableKeyPath, _ newValue: T) -> Self { var copy = self copy[keyPath: keyPath] = newValue diff --git a/Sources/MarkdownView/Helpers/Markdown/BlockMarkup++.swift b/Sources/MarkdownView/Helpers/Markdown/BlockMarkup++.swift new file mode 100644 index 000000000..4091945bd --- /dev/null +++ b/Sources/MarkdownView/Helpers/Markdown/BlockMarkup++.swift @@ -0,0 +1,77 @@ +// +// BlockMarkup++.swift +// MarkdownView +// +// Created by Yanan Li on 2026/1/18. +// + +import Markdown + +extension BlockMarkup { + /// The relative depth of a block markup (e.g. the nested list, the nested block quote, etc.). + /// + /// This value only considering the nested depth of the same block markup + /// + /// For example: + /// + /// ``` + /// - List Item 1 /* relativeDepth = 0 */ + /// - List Item 2 /* relativeDepth = 0 */ + /// - List Item 2.1 /* relativeDepth = 1 */ + /// - List Item 2.1.1 /* relativeDepth = 2 */ + /// + /// > "This is a block quote" /* relativeDepth = 0 */ + /// > - List Item 1 /* relativeDepth = 0 */ + /// > - List Item 1.1 /* relativeDepth = 1 */ + /// ``` + var relativeDepth: Int { + let parentDepth = (parent as? Self)?.relativeDepth + if let parentDepth { + return parentDepth + 1 + } else { + return 0 + } + } +} + +// MARK: - Deprecated + +extension BlockQuote { + /// Depth of the quote if nested within others. Index starts at 0. + @available(*, deprecated, renamed: "relativeDepth") + var quoteDepth: Int { + var index = 0 + + var currentElement = parent + + while currentElement != nil { + if currentElement is BlockQuote { + index += 1 + } + + currentElement = currentElement?.parent + } + + return index + } +} + + +extension ListItemContainer { + @available(*, deprecated, renamed: "relativeDepth") + var listDepth: Int { + var index = 0 + + var currentElement = parent + + while currentElement != nil { + if currentElement is ListItemContainer { + index += 1 + } + + currentElement = currentElement?.parent + } + + return index + } +} diff --git a/Sources/MarkdownView/Helpers/Markdown/BlockQuote.swift b/Sources/MarkdownView/Helpers/Markdown/BlockQuote.swift deleted file mode 100644 index e346ae09b..000000000 --- a/Sources/MarkdownView/Helpers/Markdown/BlockQuote.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// BlockQuote.swift -// MarkdownView -// -// Created by LiYanan2004 on 2024/12/11. -// - -import Markdown - -extension BlockQuote { - /// Depth of the quote if nested within others. Index starts at 0. - var quoteDepth: Int { - var index = 0 - - var currentElement = parent - - while currentElement != nil { - if currentElement is BlockQuote { - index += 1 - } - - currentElement = currentElement?.parent - } - - return index - } -} diff --git a/Sources/MarkdownView/Helpers/Markdown/ListItemContainer.swift b/Sources/MarkdownView/Helpers/Markdown/ListItemContainer.swift deleted file mode 100644 index 79e66b3dd..000000000 --- a/Sources/MarkdownView/Helpers/Markdown/ListItemContainer.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// ListItemContainer.swift -// MarkdownView -// -// Created by LiYanan2004 on 2024/12/11. -// - -import Markdown - -extension ListItemContainer { - /// Depth of the list if nested within others. Index starts at 0. - var listDepth: Int { - var index = 0 - - var currentElement = parent - - while currentElement != nil { - if currentElement is ListItemContainer { - index += 1 - } - - currentElement = currentElement?.parent - } - - return index - } -} diff --git a/Sources/MarkdownView/Helpers/Markdown/Markdown+Sendable.swift b/Sources/MarkdownView/Helpers/Markdown/Markdown+Sendable.swift deleted file mode 100644 index 1c2a28e87..000000000 --- a/Sources/MarkdownView/Helpers/Markdown/Markdown+Sendable.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// Markdown+Sendable.swift -// MarkdownView -// -// Created by Yanan Li on 2025/2/9. -// - -import Markdown - -// TODO: Remove these when swift-markdown adapted for swift 6.0 -extension Markdown.Table: @retroactive @unchecked Sendable { } -extension Markdown.Table.Row: @retroactive @unchecked Sendable { } -extension Markdown.OrderedList: @retroactive @unchecked Sendable { } -extension Markdown.UnorderedList: @retroactive @unchecked Sendable { } -extension Markdown.ParseOptions: @retroactive @unchecked Sendable { } -extension Markdown.Heading: @retroactive @unchecked Sendable { } diff --git a/Sources/MarkdownView/Helpers/Markdown/Markup.swift b/Sources/MarkdownView/Helpers/Markdown/Markup++.swift similarity index 53% rename from Sources/MarkdownView/Helpers/Markdown/Markup.swift rename to Sources/MarkdownView/Helpers/Markdown/Markup++.swift index 8b22db3de..13c8b6aad 100644 --- a/Sources/MarkdownView/Helpers/Markdown/Markup.swift +++ b/Sources/MarkdownView/Helpers/Markdown/Markup++.swift @@ -27,3 +27,13 @@ extension Markup { return false } } + +// MARK: - Sendable Assumptions + +// TODO: Remove these when swift-markdown adapted for swift 6.0 +extension Markdown.Table: @retroactive @unchecked Sendable { } +extension Markdown.Table.Row: @retroactive @unchecked Sendable { } +extension Markdown.OrderedList: @retroactive @unchecked Sendable { } +extension Markdown.UnorderedList: @retroactive @unchecked Sendable { } +extension Markdown.ParseOptions: @retroactive @unchecked Sendable { } +extension Markdown.Heading: @retroactive @unchecked Sendable { } diff --git a/Sources/MarkdownView/Helpers/Markdown/SourceLocation++.swift b/Sources/MarkdownView/Helpers/Markdown/SourceLocation++.swift new file mode 100644 index 000000000..755c55097 --- /dev/null +++ b/Sources/MarkdownView/Helpers/Markdown/SourceLocation++.swift @@ -0,0 +1,32 @@ +// +// SourceLocation++.swift +// MarkdownView +// +// Created by Yanan Li on 2026/1/18. +// + +import Markdown + +extension SourceLocation { + @available(*, deprecated, message: "Use `SourceLocation.offset(in:) -> String.Index` instead") + func offset(in text: String) -> Int { + let colIndex = column - 1 + let previousLinesTotalChar = text + .split(separator: "\n", maxSplits: line - 1, omittingEmptySubsequences: false) + .dropLast() + .map { String($0) } + .joined(separator: "\n") + .count + return previousLinesTotalChar + colIndex + 1 + } + + func offset(in text: String) -> String.Index { + let colIndex = column - 1 + let previousLinesTotalChar = text + .split(separator: "\n", maxSplits: line - 1, omittingEmptySubsequences: false) + .dropLast() + .joined(separator: "\n") + .count + return text.index(text.startIndex, offsetBy: previousLinesTotalChar + colIndex + 1) + } +} diff --git a/Sources/MarkdownView/Helpers/Markdown/SourceLocation.swift b/Sources/MarkdownView/Helpers/Markdown/SourceLocation.swift deleted file mode 100644 index 2da939f5b..000000000 --- a/Sources/MarkdownView/Helpers/Markdown/SourceLocation.swift +++ /dev/null @@ -1,14 +0,0 @@ -import Markdown - -extension SourceLocation { - func offset(in text: String) -> Int { - let colIndex = column - 1 - let previousLinesTotalChar = text - .split(separator: "\n", maxSplits: line - 1, omittingEmptySubsequences: false) - .dropLast() - .map { String($0) } - .joined(separator: "\n") - .count - return previousLinesTotalChar + colIndex + 1 - } -} diff --git a/Sources/MarkdownView/Helpers/Markdown/Table++.swift b/Sources/MarkdownView/Helpers/Markdown/Table++.swift index 6bda3b95f..e9cd83ce8 100644 --- a/Sources/MarkdownView/Helpers/Markdown/Table++.swift +++ b/Sources/MarkdownView/Helpers/Markdown/Table++.swift @@ -60,5 +60,4 @@ extension Markdown.Table.Cell { } } } - } diff --git a/Sources/MarkdownView/Renderers/Cmark/CmarkNodeVisitor.swift b/Sources/MarkdownView/Renderers/Cmark/CmarkNodeVisitor.swift index 39dfd77ee..201490157 100644 --- a/Sources/MarkdownView/Renderers/Cmark/CmarkNodeVisitor.swift +++ b/Sources/MarkdownView/Renderers/Cmark/CmarkNodeVisitor.swift @@ -82,9 +82,10 @@ struct CmarkNodeVisitor: @preconcurrency MarkupVisitor { } func visitInlineCode(_ inlineCode: InlineCode) -> MarkdownNodeView { + let tint = configuration.preferredTintColors[.inlineCodeBlock] ?? .accentColor var attributedString = AttributedString(stringLiteral: inlineCode.code) - attributedString.foregroundColor = configuration.inlineCodeTintColor - attributedString.backgroundColor = configuration.inlineCodeTintColor.opacity(0.1) + attributedString.foregroundColor = tint + attributedString.backgroundColor = tint.opacity(0.1) return MarkdownNodeView(attributedString) } @@ -239,12 +240,13 @@ struct CmarkNodeVisitor: @preconcurrency MarkupVisitor { else { return descendInto(link) } let nodeView = descendInto(link) + let tintColor = configuration.preferredTintColors[.link] ?? .accentColor return if let attributedString = nodeView.asAttributedString { MarkdownNodeView( attributedString.mergingAttributes( AttributeContainer() .link(url) - .foregroundColor(configuration.linkTintColor) + .foregroundColor(tintColor) ) ) } else { @@ -252,7 +254,7 @@ struct CmarkNodeVisitor: @preconcurrency MarkupVisitor { Link(destination: url) { nodeView } - .foregroundStyle(configuration.linkTintColor) + .foregroundStyle(tintColor) } } } diff --git a/Sources/MarkdownView/Renderers/MarkdownRendererConfiguration.swift b/Sources/MarkdownView/Renderers/MarkdownRendererConfiguration.swift index 9d642088e..770bea162 100644 --- a/Sources/MarkdownView/Renderers/MarkdownRendererConfiguration.swift +++ b/Sources/MarkdownView/Renderers/MarkdownRendererConfiguration.swift @@ -8,16 +8,14 @@ import Foundation import SwiftUI -struct MarkdownRendererConfiguration: Equatable, KeyPathModifiable, Sendable { +struct MarkdownRendererConfiguration: Equatable, KeyPathModifying, Sendable { var preferredBaseURL: URL? var componentSpacing: CGFloat = 8 var math = MathRendering() var rendersMath: Bool { math.isEnabled } - var linkTintColor: Color = .accentColor - var inlineCodeTintColor: Color = .accentColor - var blockQuoteTintColor: Color = .accentColor + var preferredTintColors: [MarkdownTintableComponent: Color] = [:] var list = MarkdownListConfiguration() diff --git a/Sources/MarkdownView/Renderers/Node Representations/MarkdownList.swift b/Sources/MarkdownView/Renderers/Node Representations/MarkdownList.swift index 022b4527f..bc94d92ac 100644 --- a/Sources/MarkdownView/Renderers/Node Representations/MarkdownList.swift +++ b/Sources/MarkdownView/Renderers/Node Representations/MarkdownList.swift @@ -3,6 +3,9 @@ import Markdown struct MarkdownList: View { var listItemsContainer: List + private var depth: Int { + listItemsContainer.relativeDepth + } @Environment(\.markdownRendererConfiguration) private var configuration private var marker: Either { @@ -14,9 +17,6 @@ struct MarkdownList: View { fatalError("Marker Protocol not implemented for \(type(of: listItemsContainer)).") } } - private var depth: Int { - listItemsContainer.listDepth - } var body: some View { VStack(alignment: .leading, spacing: configuration.componentSpacing) { diff --git a/Sources/MarkdownView/View Modifiers/Styling/TintColorModifier.swift b/Sources/MarkdownView/View Modifiers/Styling/TintColorModifier.swift index a95212d28..1a1bcb006 100644 --- a/Sources/MarkdownView/View Modifiers/Styling/TintColorModifier.swift +++ b/Sources/MarkdownView/View Modifiers/Styling/TintColorModifier.swift @@ -16,22 +16,17 @@ extension View { @ViewBuilder nonisolated public func tint( _ tint: Color, - for component: TintableComponent + for component: MarkdownTintableComponent ) -> some View { - switch component { - case .blockQuote: - environment(\.markdownRendererConfiguration.blockQuoteTintColor, tint) - case .inlineCodeBlock: - environment(\.markdownRendererConfiguration.inlineCodeTintColor, tint) - case .link: - environment(\.markdownRendererConfiguration.linkTintColor, tint) + transformEnvironment(\.markdownRendererConfiguration) { configuration in + configuration.preferredTintColors[component] = tint } } } /// Components that can apply a tint color. @_documentation(visibility: internal) -public enum TintableComponent: Hashable, Sendable { +public enum MarkdownTintableComponent: Hashable, Sendable { case blockQuote case inlineCodeBlock case link diff --git a/Sources/MarkdownView/View Modifiers/Text Formatting/FontModifier.swift b/Sources/MarkdownView/View Modifiers/Text Formatting/FontModifier.swift index 6339ba1dd..6b7fa60ab 100644 --- a/Sources/MarkdownView/View Modifiers/Text Formatting/FontModifier.swift +++ b/Sources/MarkdownView/View Modifiers/Text Formatting/FontModifier.swift @@ -20,10 +20,10 @@ extension View { /// Sets the font for the specific component in MarkdownView. /// - Parameters: /// - font: The font to apply to these components. - /// - type: The type of components to apply the font. - nonisolated public func font(_ font: Font, for type: MarkdownTextType) -> some View { + /// - component: The component to apply the font. + nonisolated public func font(_ font: Font, for component: MarkdownComponent) -> some View { transformEnvironment(\.markdownFontGroup) { fontGroup in - switch type { + switch component { case .h1: fontGroup._h1 = font case .h2: fontGroup._h2 = font case .h3: fontGroup._h3 = font From 810e503eeaf38adf19a948fe758ee32db51da66c Mon Sep 17 00:00:00 2001 From: Yanan Li <1474700628@qq.com> Date: Tue, 20 Jan 2026 22:22:40 +0800 Subject: [PATCH 09/27] Our first attempt on new renderer --- Package.swift | 14 +- .../Font/MarkdownComponent.swift | 18 +- .../AnyOrderedListMarkerProtocol.swift | 6 +- .../AnyUnorderedListMarkerProtocol.swift | 6 +- .../Helpers/Markdown/BlockMarkup++.swift | 31 -- .../Helpers/Swift/Sequence++.swift | 17 + Sources/MarkdownView/MarkdownView.swift | 25 +- .../CmarkFirstMarkdownViewRenderer.swift | 23 + .../Cmark/CmarkTextContentVisitor.swift | 427 ++++++++++++++++++ .../MarkdownRendererConfiguration.swift | 53 ++- .../Renderers/MarkdownViewRenderer.swift | 103 ++++- .../Math/MathFirstMarkdownViewRenderer.swift | 80 +++- .../Node Representations/MarkdownList.swift | 2 +- .../MarkdownTextSelectionModifier.swift | 35 ++ 14 files changed, 735 insertions(+), 105 deletions(-) create mode 100644 Sources/MarkdownView/Helpers/Swift/Sequence++.swift create mode 100644 Sources/MarkdownView/Renderers/Cmark/CmarkTextContentVisitor.swift create mode 100644 Sources/MarkdownView/View Modifiers/MarkdownTextSelectionModifier.swift diff --git a/Package.swift b/Package.swift index 251697d17..005f2d8b1 100644 --- a/Package.swift +++ b/Package.swift @@ -6,10 +6,10 @@ import PackageDescription let package = Package( name: "MarkdownView", platforms: [ - .macOS(.v12), - .iOS(.v15), - .tvOS(.v15), - .watchOS(.v8), + .macOS(.v13), + .iOS(.v16), + .tvOS(.v16), + .watchOS(.v9), .visionOS(.v1), ], products: [ @@ -19,6 +19,7 @@ let package = Package( .package(url: "https://github.com/swiftlang/swift-markdown.git", from: "0.5.0"), .package(url: "https://github.com/raspu/Highlightr.git", from: "2.2.1"), .package(url: "https://github.com/colinc86/LaTeXSwiftUI.git", from: "1.4.1"), + .package(url: "https://github.com/LiYanan2004/RichText.git", branch: "main"), ], targets: [ .target( @@ -38,6 +39,11 @@ let package = Package( package: "LaTeXSwiftUI", condition: .when(platforms: [.iOS, .macOS]) ), + .product( + name: "RichText", + package: "RichText", + condition: .when(platforms: [.iOS, .macOS]) + ), ], swiftSettings: [.swiftLanguageMode(.v6)] ), diff --git a/Sources/MarkdownView/Customizations/Font/MarkdownComponent.swift b/Sources/MarkdownView/Customizations/Font/MarkdownComponent.swift index 5356e67bf..786a49050 100644 --- a/Sources/MarkdownView/Customizations/Font/MarkdownComponent.swift +++ b/Sources/MarkdownView/Customizations/Font/MarkdownComponent.swift @@ -1,10 +1,18 @@ import Foundation @_documentation(visibility: internal) -public enum MarkdownComponent: Equatable, CaseIterable { - case h1, h2, h3, h4, h5, h6 +public enum MarkdownComponent: Hashable, Sendable, CaseIterable { + case h1 + case h2 + case h3 + case h4 + case h5 + case h6 case body - case codeBlock, blockQuote - case tableHeader, tableBody - case inlineMath, displayMath + case codeBlock + case blockQuote + case tableHeader + case tableBody + case inlineMath + case displayMath } diff --git a/Sources/MarkdownView/Customizations/List/Ordered List Marker/AnyOrderedListMarkerProtocol.swift b/Sources/MarkdownView/Customizations/List/Ordered List Marker/AnyOrderedListMarkerProtocol.swift index ea957c3cf..58ef42ca4 100644 --- a/Sources/MarkdownView/Customizations/List/Ordered List Marker/AnyOrderedListMarkerProtocol.swift +++ b/Sources/MarkdownView/Customizations/List/Ordered List Marker/AnyOrderedListMarkerProtocol.swift @@ -7,13 +7,13 @@ import Foundation -struct AnyOrderedListMarkerProtocol: OrderedListMarkerProtocol { +public struct AnyOrderedListMarkerProtocol: OrderedListMarkerProtocol { private var _marker: AnyHashable - var monospaced: Bool { + public var monospaced: Bool { (_marker as! (any OrderedListMarkerProtocol)).monospaced } - init(_ marker: T) { + public init(_ marker: T) { self._marker = AnyHashable(marker) } diff --git a/Sources/MarkdownView/Customizations/List/Unordered List Marker/AnyUnorderedListMarkerProtocol.swift b/Sources/MarkdownView/Customizations/List/Unordered List Marker/AnyUnorderedListMarkerProtocol.swift index 5e99a7aa9..5721aa8f4 100644 --- a/Sources/MarkdownView/Customizations/List/Unordered List Marker/AnyUnorderedListMarkerProtocol.swift +++ b/Sources/MarkdownView/Customizations/List/Unordered List Marker/AnyUnorderedListMarkerProtocol.swift @@ -7,13 +7,13 @@ import Foundation -struct AnyUnorderedListMarkerProtocol: UnorderedListMarkerProtocol { +public struct AnyUnorderedListMarkerProtocol: UnorderedListMarkerProtocol { private var _marker: AnyHashable - var monospaced: Bool { + public var monospaced: Bool { (_marker as! (any UnorderedListMarkerProtocol)).monospaced } - init(_ marker: T) { + public init(_ marker: T) { self._marker = AnyHashable(marker) } diff --git a/Sources/MarkdownView/Helpers/Markdown/BlockMarkup++.swift b/Sources/MarkdownView/Helpers/Markdown/BlockMarkup++.swift index 4091945bd..0ce58a1a3 100644 --- a/Sources/MarkdownView/Helpers/Markdown/BlockMarkup++.swift +++ b/Sources/MarkdownView/Helpers/Markdown/BlockMarkup++.swift @@ -7,38 +7,8 @@ import Markdown -extension BlockMarkup { - /// The relative depth of a block markup (e.g. the nested list, the nested block quote, etc.). - /// - /// This value only considering the nested depth of the same block markup - /// - /// For example: - /// - /// ``` - /// - List Item 1 /* relativeDepth = 0 */ - /// - List Item 2 /* relativeDepth = 0 */ - /// - List Item 2.1 /* relativeDepth = 1 */ - /// - List Item 2.1.1 /* relativeDepth = 2 */ - /// - /// > "This is a block quote" /* relativeDepth = 0 */ - /// > - List Item 1 /* relativeDepth = 0 */ - /// > - List Item 1.1 /* relativeDepth = 1 */ - /// ``` - var relativeDepth: Int { - let parentDepth = (parent as? Self)?.relativeDepth - if let parentDepth { - return parentDepth + 1 - } else { - return 0 - } - } -} - -// MARK: - Deprecated - extension BlockQuote { /// Depth of the quote if nested within others. Index starts at 0. - @available(*, deprecated, renamed: "relativeDepth") var quoteDepth: Int { var index = 0 @@ -58,7 +28,6 @@ extension BlockQuote { extension ListItemContainer { - @available(*, deprecated, renamed: "relativeDepth") var listDepth: Int { var index = 0 diff --git a/Sources/MarkdownView/Helpers/Swift/Sequence++.swift b/Sources/MarkdownView/Helpers/Swift/Sequence++.swift new file mode 100644 index 000000000..aa604541b --- /dev/null +++ b/Sources/MarkdownView/Helpers/Swift/Sequence++.swift @@ -0,0 +1,17 @@ +// +// Sequence++.swift +// MarkdownView +// +// Created by Yanan Li on 2026/1/19. +// + +import Foundation + +extension Sequence { + @_spi(Internal) + public func first( + byUnwrapping transform: @escaping (Element) throws -> T? + ) rethrows -> T? { + try self.lazy.compactMap(transform).first + } +} diff --git a/Sources/MarkdownView/MarkdownView.swift b/Sources/MarkdownView/MarkdownView.swift index 8f870d54e..94b5f1465 100644 --- a/Sources/MarkdownView/MarkdownView.swift +++ b/Sources/MarkdownView/MarkdownView.swift @@ -5,8 +5,8 @@ import Markdown public struct MarkdownView: View { @ObservedObject private var content: MarkdownContent - @Environment(\.markdownFontGroup.body) private var bodyFont @Environment(\.markdownRendererConfiguration) private var configuration + @Environment(\.markdownViewRenderer) private var renderer /// Creates a view that renders given markdown string. /// - Parameter text: The markdown source to render. @@ -27,29 +27,18 @@ public struct MarkdownView: View { } public var body: some View { - _renderedBody.font(bodyFont) - } - - @ViewBuilder - private var _renderedBody: some View { - if configuration.rendersMath { - MathFirstMarkdownViewRenderer().makeBody( - content: content, - configuration: configuration - ) - } else { - CmarkFirstMarkdownViewRenderer().makeBody( - content: content, - configuration: configuration - ) - } + renderer + .makeBody(content: content, configuration: configuration) + .erasedToAnyView() + .font(configuration.fonts[.body] ?? Font.body) } } -@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) +@available(iOS 26.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) #Preview(traits: .sizeThatFitsLayout) { VStack { MarkdownView("Hello **World**") + .markdownTextSelection(.enabled) } #if os(macOS) || os(iOS) .textSelection(.enabled) diff --git a/Sources/MarkdownView/Renderers/Cmark/CmarkFirstMarkdownViewRenderer.swift b/Sources/MarkdownView/Renderers/Cmark/CmarkFirstMarkdownViewRenderer.swift index d053d54c8..b19f04af3 100644 --- a/Sources/MarkdownView/Renderers/Cmark/CmarkFirstMarkdownViewRenderer.swift +++ b/Sources/MarkdownView/Renderers/Cmark/CmarkFirstMarkdownViewRenderer.swift @@ -22,3 +22,26 @@ struct CmarkFirstMarkdownViewRenderer: MarkdownViewRenderer { .makeBody(for: content.document(options: parseOptions)) } } + +#if canImport(RichText) +import RichText + +@available(iOS 26, macOS 26, *) +struct TextViewViewRenderer: MarkdownViewRenderer { + func makeBody( + content: MarkdownContent, + configuration: MarkdownRendererConfiguration + ) -> some View { + var parseOptions = ParseOptions() + if !configuration.allowedBlockDirectiveRenderers.isEmpty { + parseOptions.insert(.parseBlockDirectives) + } + + let textContent = CmarkTextContentVisitor(configuration: configuration) + .makeTextContent(for: content.document(options: parseOptions)) + return TextView { + textContent + } + } +} +#endif diff --git a/Sources/MarkdownView/Renderers/Cmark/CmarkTextContentVisitor.swift b/Sources/MarkdownView/Renderers/Cmark/CmarkTextContentVisitor.swift new file mode 100644 index 000000000..3f322d482 --- /dev/null +++ b/Sources/MarkdownView/Renderers/Cmark/CmarkTextContentVisitor.swift @@ -0,0 +1,427 @@ +// +// CmarkTextContentVisitor.swift +// MarkdownView +// +// Created by Yanan Li on 2026/1/20. +// + +#if canImport(RichText) +import SwiftUI +import Markdown +import RichText + +@MainActor +@preconcurrency +@available(iOS 26, macOS 26, *) +struct CmarkTextContentVisitor: @preconcurrency MarkupVisitor { + var configuration: MarkdownRendererConfiguration + + init(configuration: MarkdownRendererConfiguration) { + self.configuration = configuration + } + + func makeTextContent(for markup: any Markup) -> TextContent { + var visitor = self + return visitor.visit(markup) + } + + func visitDocument(_ document: Document) -> TextContent { + var renderer = self + let contents = document.children.map { + renderer.visit($0) + } + return combine(contents) + } + + func defaultVisit(_ markup: Markdown.Markup) -> TextContent { + descendInto(markup) + } + + func descendInto(_ markup: any Markup) -> TextContent { + var content = TextContent([]) + for child in markup.children { + var renderer = self + let fragments = renderer.visit(child) + .fragments + .flatMap({ frag -> [TextContent.Fragment] in + if case .view = frag { + return RichText.LineBreak().textContent.fragments + [frag] + } + return [frag] + }) + content += TextContent(fragments) + } + return content + } + + func visitText(_ text: Markdown.Text) -> TextContent { + let plainText = text.plainText + guard configuration.rendersMath else { + return TextContent(.string(plainText)) + } + #if canImport(LaTeXSwiftUI) + let mathParser = MathParser(text: plainText) + var content = TextContent([]) + var processingIndex = plainText.startIndex + + for math in mathParser.mathRepresentations { + let range = math.range + if processingIndex < range.lowerBound { + content += TextContent(.string(String(plainText[processingIndex.. TextContent { + inlineViewContent(for: blockDirective) { + MarkdownBlockDirective(blockDirective: blockDirective) + } + } + + func visitBlockQuote(_ blockQuote: BlockQuote) -> TextContent { + var visitor = self + let children = blockQuote.blockChildren.map({ child in + visitor.visit(child).attributedStringIgnoringViews + }) + + let replacementAttrString = children.reduce(AttributedString()) { attrString, row in + return attrString + row + "\n" + } + + return inlineViewContent( + for: blockQuote, + replacement: replacementAttrString + ) { + MarkdownBlockQuote(blockQuote: blockQuote) + } + } + + func visitSoftBreak(_ softBreak: SoftBreak) -> TextContent { + RichText.Space(1).textContent + } + + func visitThematicBreak(_ thematicBreak: ThematicBreak) -> TextContent { + inlineViewContent(for: thematicBreak) { + Divider() + } + } + + func visitLineBreak(_ lineBreak: Markdown.LineBreak) -> TextContent { + RichText.LineBreak(1).textContent + } + + func visitInlineCode(_ inlineCode: InlineCode) -> TextContent { + let tint = configuration.preferredTintColors[.inlineCodeBlock] ?? .accentColor + var attributedString = AttributedString(stringLiteral: inlineCode.code) + attributedString.foregroundColor = tint + attributedString.backgroundColor = tint.opacity(0.1) + return TextContent(.attributedString(attributedString)) + } + + func visitInlineHTML(_ inlineHTML: InlineHTML) -> TextContent { + TextContent( + .attributedString( + AttributedString( + inlineHTML.rawHTML, + attributes: AttributeContainer().isHTML(true) + ) + ) + ) + } + + func visitImage(_ image: Markdown.Image) -> TextContent { + inlineViewContent(for: image) { + MarkdownImage(image: image) + } + } + + func visitCodeBlock(_ codeBlock: CodeBlock) -> TextContent { + inlineViewContent( + for: codeBlock, + replacement: AttributedString(codeBlock.code) + ) { + MarkdownStyledCodeBlock( + configuration: CodeBlockStyleConfiguration( + language: codeBlock.language, + code: codeBlock.code + ) + ) + } + } + + func visitHTMLBlock(_ html: HTMLBlock) -> TextContent { + inlineViewContent( + for: html, + replacement: AttributedString( + html.rawHTML, + attributes: AttributeContainer().isHTML(true) + ) + ) { + HTMLBlockView(html: html.rawHTML) + } + } + + func visitListItem(_ listItem: ListItem) -> TextContent { + let depth = (listItem.parent as? ListItemContainer)?.listDepth ?? 0 + let indentation = CGFloat(depth) * configuration.list.leadingIndentation + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.headIndent = indentation + paragraphStyle.firstLineHeadIndent = indentation + + 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 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 { + AttributedString(markerString, attributes: attributes) + } + Space() + if let firstChildContent { + firstChildContent + } + LineBreak() + + for trailingBlock in trailingBlocks where !trailingBlock.fragments.isEmpty { + trailingBlock + } + } + } + + func visitOrderedList(_ orderedList: OrderedList) -> TextContent { + var renderer = self + let contents = orderedList.children.map { renderer.visit($0) } + return combine(contents) + } + + func visitUnorderedList(_ unorderedList: UnorderedList) -> TextContent { + var renderer = self + let contents = unorderedList.children.map { renderer.visit($0) } + return combine(contents) + } + + func visitTable(_ table: Markdown.Table) -> TextContent { + var visitor = self + let rows = ([table.head as (any TableCellContainer)] + Array(table.body.rows)).map({ row in + Array(row.cells).reduce(AttributedString()) { attrString, cell in + let cellAttrString = visitor.visit(cell).attributedStringIgnoringViews + return attrString + cellAttrString + "\t" + } + }) + + let replacementAttrString = rows.reduce(AttributedString()) { attrString, row in + return attrString + row + "\n" + } + return inlineViewContent( + for: table, + replacement: replacementAttrString + ) { + MarkdownTable(table: table) + } + } + + func visitHeading(_ heading: Heading) -> TextContent { + let component = switch heading.level { + case 1: MarkdownComponent.h1 + case 2: MarkdownComponent.h2 + case 3: MarkdownComponent.h3 + case 4: MarkdownComponent.h4 + case 5: MarkdownComponent.h5 + case 6: MarkdownComponent.h6 + default: MarkdownComponent.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) + + return TextContent { + AttributedString(heading.plainText, attributes: attributes) + LineBreak() + } + } + + func visitEmphasis(_ emphasis: Markdown.Emphasis) -> TextContent { + var attributedString = AttributedString() + for child in emphasis.children { + var renderer = self + let text = renderer.visit(child).attributedStringIgnoringViews + if text.characters.isEmpty { continue } + let intent = text.inlinePresentationIntent ?? [] + attributedString += text.mergingAttributes( + AttributeContainer() + .inlinePresentationIntent(intent.union(.emphasized)) + ) + } + return TextContent(.attributedString(attributedString)) + } + + func visitStrong(_ strong: Strong) -> TextContent { + var attributedString = AttributedString() + for child in strong.children { + var renderer = self + let text = renderer.visit(child).attributedStringIgnoringViews + if text.characters.isEmpty { continue } + let intent = text.inlinePresentationIntent ?? [] + attributedString += text.mergingAttributes( + AttributeContainer() + .inlinePresentationIntent(intent.union(.stronglyEmphasized)) + ) + } + return TextContent(.attributedString(attributedString)) + } + + func visitStrikethrough(_ strikethrough: Strikethrough) -> TextContent { + var attributedString = AttributedString() + for child in strikethrough.children { + var renderer = self + let text = renderer.visit(child).attributedStringIgnoringViews + if text.characters.isEmpty { continue } + let intent = text.inlinePresentationIntent ?? [] + attributedString += text.mergingAttributes( + AttributeContainer() + .inlinePresentationIntent(intent.union(.strikethrough)) + ) + } + return TextContent(.attributedString(attributedString)) + } + + mutating func visitLink(_ link: Markdown.Link) -> TextContent { + guard let destination = link.destination, + let url = URL(string: destination) else { + return descendInto(link) + } + + let linkContent = descendInto(link) + let tintColor = configuration.preferredTintColors[.link] ?? .accentColor + + let contentView = linkContent.fragments.first(byUnwrapping: { + if case let .view(attachment) = $0 { + return attachment.view + } + return nil + }) + + if let contentView { + return inlineViewContent( + for: link, + replacement: AttributedString( + link.plainText, + attributes: AttributeContainer().link(url) + ) + ) { + Link(destination: url) { + contentView + } + .foregroundStyle(tintColor) + } + } else { + let attributedString = linkContent.attributedStringIgnoringViews + return TextContent( + .attributedString( + attributedString.mergingAttributes( + AttributeContainer() + .link(url) + .foregroundColor(tintColor) + ) + ) + ) + } + } + + func visitParagraph(_ paragraph: Paragraph) -> TextContent { + TextContent { + descendInto(paragraph) + LineBreak() + } + } +} + +@available(iOS 26, macOS 26, *) +private extension CmarkTextContentVisitor { + func combine(_ contents: [TextContent]) -> TextContent { + var combined = TextContent([]) + for content in contents where !content.fragments.isEmpty { + combined += content + } + return combined + } + + func inlineViewContent( + for markup: any Markup, + replacement: AttributedString? = nil, + @ViewBuilder content: @escaping () -> some View + ) -> TextContent { + let view = content() + .environment(\.markdownRendererConfiguration, configuration) + let attachment = InlineHostingAttachment( + view, + id: markup.range, + replacement: replacement + ) + return TextContent { + TextContent(.view(attachment)) + LineBreak() + } + } + +} + +@available(iOS 26, macOS 26, *) +fileprivate extension TextContent { + var attributedStringIgnoringViews: AttributedString { + fragments.reduce(AttributedString()) { attrString, frag in + switch frag { + case .string(let string): + attrString + AttributedString(string) + case .attributedString(let value): + attrString + value + case .view: + attrString + } + } + } +} + +#endif diff --git a/Sources/MarkdownView/Renderers/MarkdownRendererConfiguration.swift b/Sources/MarkdownView/Renderers/MarkdownRendererConfiguration.swift index 770bea162..7f27d3292 100644 --- a/Sources/MarkdownView/Renderers/MarkdownRendererConfiguration.swift +++ b/Sources/MarkdownView/Renderers/MarkdownRendererConfiguration.swift @@ -8,37 +8,56 @@ import Foundation import SwiftUI -struct MarkdownRendererConfiguration: Equatable, KeyPathModifying, Sendable { - var preferredBaseURL: URL? - var componentSpacing: CGFloat = 8 +public struct MarkdownRendererConfiguration: Equatable, Sendable { + public internal(set) var preferredBaseURL: URL? + public internal(set) var componentSpacing: CGFloat = 8 + public internal(set) var fonts: [MarkdownComponent: Font] = [ + .h1: Font.largeTitle, + .h2: Font.title, + .h3: Font.title2, + .h4: Font.title3, + .h5: Font.headline, + .h6: Font.headline.weight(.regular), + .body: Font.body, + .codeBlock: Font.system(.callout, design: .monospaced), + .blockQuote: Font.system(.body, design: .serif), + .tableHeader: Font.headline, + .tableBody: Font.body, + .inlineMath: Font.body, + .displayMath: Font.body, + ] - var math = MathRendering() - var rendersMath: Bool { math.isEnabled } + public internal(set) var math = MathRendering() + public var rendersMath: Bool { math.isEnabled } - var preferredTintColors: [MarkdownTintableComponent: Color] = [:] + public internal(set) var preferredTintColors: [MarkdownTintableComponent: Color] = [:] - var list = MarkdownListConfiguration() + public internal(set) var list = MarkdownListConfiguration() - var allowedImageRenderers: Set = ["https", "http"] - var allowedBlockDirectiveRenderers: Set = [] + public internal(set) var allowedImageRenderers: Set = ["https", "http"] + public internal(set) var allowedBlockDirectiveRenderers: Set = [] + + public init() {} } // MARK: - List extension MarkdownRendererConfiguration { - struct MarkdownListConfiguration: Hashable, @unchecked Sendable { - var leadingIndentation: CGFloat = 12 - var unorderedListMarker = AnyUnorderedListMarkerProtocol(.bullet) - var orderedListMarker = AnyOrderedListMarkerProtocol(.increasingDigits) + public struct MarkdownListConfiguration: Hashable, @unchecked Sendable { + public internal(set) var leadingIndentation: CGFloat = 12 + public internal(set) var unorderedListMarker = AnyUnorderedListMarkerProtocol(.bullet) + public internal(set) var orderedListMarker = AnyOrderedListMarkerProtocol(.increasingDigits) + + public init() {} } } // MARK: - Math Rendering extension MarkdownRendererConfiguration { - struct MathRendering: Sendable, Hashable { - var isEnabled: Bool = false - var displayMathStorage: [UUID : String] = [:] + public struct MathRendering: Sendable, Hashable { + public internal(set) var isEnabled: Bool = false + public internal(set) var displayMathStorage: [UUID : String] = [:] mutating func setNeedsRendering(_ needRenderMath: Bool) { isEnabled = needRenderMath @@ -49,6 +68,8 @@ extension MarkdownRendererConfiguration { displayMathStorage[id] = String(displayMath) return id } + + public init() {} } } diff --git a/Sources/MarkdownView/Renderers/MarkdownViewRenderer.swift b/Sources/MarkdownView/Renderers/MarkdownViewRenderer.swift index 16a2a9e3c..dbfd4261c 100644 --- a/Sources/MarkdownView/Renderers/MarkdownViewRenderer.swift +++ b/Sources/MarkdownView/Renderers/MarkdownViewRenderer.swift @@ -8,9 +8,7 @@ import SwiftUI import Markdown -@preconcurrency -@MainActor -protocol MarkdownViewRenderer { +public protocol MarkdownViewRenderer { associatedtype Body: SwiftUI.View @preconcurrency @@ -22,6 +20,94 @@ protocol MarkdownViewRenderer { ) -> Body } +public struct AutomaticMarkdownViewRenderer: MarkdownViewRenderer { + public init() {} + + @ViewBuilder + public func makeBody( + content: MarkdownContent, + configuration: MarkdownRendererConfiguration + ) -> some View { + #if canImport(RichText) + if #available(iOS 26.0, macOS 26.0, *) { + TextContentMarkdownViewRenderer() + .makeBody(content: content, configuration: configuration) + } else { + ViewMarkdownViewRenderer() + .makeBody(content: content, configuration: configuration) + } + #else + ViewMarkdownViewRenderer() + .makeBody(content: content, configuration: configuration) + #endif + } +} + +public struct ViewMarkdownViewRenderer: MarkdownViewRenderer { + public init() {} + + @ViewBuilder + public func makeBody( + content: MarkdownContent, + configuration: MarkdownRendererConfiguration + ) -> some View { + if configuration.rendersMath { + MathFirstMarkdownViewRenderer().makeBody( + content: content, + configuration: configuration + ) + } else { + CmarkFirstMarkdownViewRenderer().makeBody( + content: content, + configuration: configuration + ) + } + } +} + +#if canImport(RichText) +@available(iOS 26, macOS 26, *) +@available(tvOS, unavailable) +@available(watchOS, unavailable) +@available(visionOS, unavailable) +public struct TextContentMarkdownViewRenderer: MarkdownViewRenderer { + public init() {} + + @ViewBuilder + public func makeBody( + content: MarkdownContent, + configuration: MarkdownRendererConfiguration + ) -> some View { + if configuration.rendersMath { + MathFirstTextViewRenderer().makeBody( + content: content, + configuration: configuration + ) + } else { + TextViewViewRenderer().makeBody( + content: content, + configuration: configuration + ) + } + } +} +#endif + +public extension MarkdownViewRenderer where Self == AutomaticMarkdownViewRenderer { + static var automatic: AutomaticMarkdownViewRenderer { .init() } +} + +public extension MarkdownViewRenderer where Self == ViewMarkdownViewRenderer { + static var view: ViewMarkdownViewRenderer { .init() } +} + +#if canImport(RichText) +@available(iOS 26, macOS 26, *) +public extension MarkdownViewRenderer where Self == TextContentMarkdownViewRenderer { + static var textContent: TextContentMarkdownViewRenderer { .init() } +} +#endif + extension MarkdownViewRenderer { internal func parseOptions(for configuration: MarkdownRendererConfiguration) -> ParseOptions { var options = ParseOptions() @@ -33,3 +119,14 @@ extension MarkdownViewRenderer { return options } } + +struct MarkdownViewRendererKey: EnvironmentKey { + nonisolated(unsafe) static let defaultValue: any MarkdownViewRenderer = .automatic +} + +extension EnvironmentValues { + var markdownViewRenderer: any MarkdownViewRenderer { + get { self[MarkdownViewRendererKey.self] } + set { self[MarkdownViewRendererKey.self] = newValue } + } +} diff --git a/Sources/MarkdownView/Renderers/Math/MathFirstMarkdownViewRenderer.swift b/Sources/MarkdownView/Renderers/Math/MathFirstMarkdownViewRenderer.swift index c4269174d..801c35d9f 100644 --- a/Sources/MarkdownView/Renderers/Math/MathFirstMarkdownViewRenderer.swift +++ b/Sources/MarkdownView/Renderers/Math/MathFirstMarkdownViewRenderer.swift @@ -13,32 +13,40 @@ struct MathFirstMarkdownViewRenderer: MarkdownViewRenderer { content: MarkdownContent, configuration: MarkdownRendererConfiguration ) -> some View { - var configuration = configuration - var rawText = (try? content.markdown) ?? "" - - var extractor = ParsingRangesExtractor() - extractor.visit(content.document()) - for range in extractor.parsableRanges(in: rawText) { - let segment = rawText[range] - let segmentParser = MathParser(text: segment) - for math in segmentParser.mathRepresentations.reversed() where !math.kind.inline { - let mathIdentifier = configuration.math.appendDisplayMath( - rawText[math.range] - ) - rawText.replaceSubrange( - math.range, - with: "@math(uuid:\(mathIdentifier))" - ) - } + makeMathFirstBody( + content: content, + configuration: configuration + ) { content, configuration in + CmarkFirstMarkdownViewRenderer().makeBody( + content: content, + configuration: configuration + ) } - - return CmarkFirstMarkdownViewRenderer().makeBody( - content: MarkdownContent(.plainText(rawText)), + } +} + +#if canImport(RichText) + +@available(iOS 26.0, macOS 26.0, *) +struct MathFirstTextViewRenderer: MarkdownViewRenderer { + func makeBody( + content: MarkdownContent, + configuration: MarkdownRendererConfiguration + ) -> some View { + makeMathFirstBody( + content: content, configuration: configuration - ) + ) { content, configuration in + TextViewViewRenderer().makeBody( + content: content, + configuration: configuration + ) + } } } +#endif + // MARK: - Auxiliary fileprivate extension MathFirstMarkdownViewRenderer { @@ -96,3 +104,33 @@ fileprivate extension SourceLocation { return targetUtf8Index.samePosition(in: string) ?? string.endIndex } } + +private func makeMathFirstBody( + content: MarkdownContent, + configuration: MarkdownRendererConfiguration, + @ViewBuilder render: (MarkdownContent, MarkdownRendererConfiguration) -> Body +) -> Body { + var configuration = configuration + var rawText = (try? content.markdown) ?? "" + + var extractor = MathFirstMarkdownViewRenderer.ParsingRangesExtractor() + extractor.visit(content.document()) + for range in extractor.parsableRanges(in: rawText) { + let segment = rawText[range] + let segmentParser = MathParser(text: segment) + for math in segmentParser.mathRepresentations.reversed() where !math.kind.inline { + let mathIdentifier = configuration.math.appendDisplayMath( + rawText[math.range] + ) + rawText.replaceSubrange( + math.range, + with: "@math(uuid:\(mathIdentifier))" + ) + } + } + + return render( + MarkdownContent(.plainText(rawText)), + configuration + ) +} diff --git a/Sources/MarkdownView/Renderers/Node Representations/MarkdownList.swift b/Sources/MarkdownView/Renderers/Node Representations/MarkdownList.swift index bc94d92ac..aa7fa2a3f 100644 --- a/Sources/MarkdownView/Renderers/Node Representations/MarkdownList.swift +++ b/Sources/MarkdownView/Renderers/Node Representations/MarkdownList.swift @@ -4,7 +4,7 @@ import Markdown struct MarkdownList: View { var listItemsContainer: List private var depth: Int { - listItemsContainer.relativeDepth + listItemsContainer.listDepth } @Environment(\.markdownRendererConfiguration) private var configuration diff --git a/Sources/MarkdownView/View Modifiers/MarkdownTextSelectionModifier.swift b/Sources/MarkdownView/View Modifiers/MarkdownTextSelectionModifier.swift new file mode 100644 index 000000000..9dae6e1af --- /dev/null +++ b/Sources/MarkdownView/View Modifiers/MarkdownTextSelectionModifier.swift @@ -0,0 +1,35 @@ +// +// MarkdownTextSelectionModifier.swift +// MarkdownView +// +// Created by Yanan Li on 2026/1/20. +// + +import SwiftUI + +extension SwiftUI.View { + nonisolated public func markdownTextSelection(_ selection: some TextSelectability) -> some View { + modifier(MarkdownTextSelectionViewModifier(selection: selection)) + } +} + +nonisolated struct MarkdownTextSelectionViewModifier: ViewModifier { + let selection: S + + func body(content: Content) -> some View { + Group { + if #available(iOS 26, macOS 26, *) { + if type(of: selection).allowsSelection { + content + .environment(\.markdownViewRenderer, .textContent) + } else { + content + .environment(\.markdownViewRenderer, .view) + } + } else { + content + } + } + .textSelection(selection) + } +} From aa1542eaa2bd2311b61e0f04f8b72ad9a9a8a7a2 Mon Sep 17 00:00:00 2001 From: Yanan Li <1474700628@qq.com> Date: Tue, 27 Jan 2026 14:24:11 +0800 Subject: [PATCH 10/27] Update package --- Sources/MarkdownView/MarkdownView.swift | 2 +- .../Cmark/CmarkTextContentVisitor.swift | 31 +++++++++---------- .../MarkdownTextSelectionModifier.swift | 8 +++++ 3 files changed, 24 insertions(+), 17 deletions(-) diff --git a/Sources/MarkdownView/MarkdownView.swift b/Sources/MarkdownView/MarkdownView.swift index 94b5f1465..f81f62ead 100644 --- a/Sources/MarkdownView/MarkdownView.swift +++ b/Sources/MarkdownView/MarkdownView.swift @@ -34,7 +34,7 @@ public struct MarkdownView: View { } } -@available(iOS 26.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) +@available(iOS 26.0, macOS 14.0, *) #Preview(traits: .sizeThatFitsLayout) { VStack { MarkdownView("Hello **World**") diff --git a/Sources/MarkdownView/Renderers/Cmark/CmarkTextContentVisitor.swift b/Sources/MarkdownView/Renderers/Cmark/CmarkTextContentVisitor.swift index 3f322d482..21b6d340f 100644 --- a/Sources/MarkdownView/Renderers/Cmark/CmarkTextContentVisitor.swift +++ b/Sources/MarkdownView/Renderers/Cmark/CmarkTextContentVisitor.swift @@ -41,15 +41,7 @@ struct CmarkTextContentVisitor: @preconcurrency MarkupVisitor { var content = TextContent([]) for child in markup.children { var renderer = self - let fragments = renderer.visit(child) - .fragments - .flatMap({ frag -> [TextContent.Fragment] in - if case .view = frag { - return RichText.LineBreak().textContent.fragments + [frag] - } - return [frag] - }) - content += TextContent(fragments) + content += renderer.visit(child) } return content } @@ -92,7 +84,7 @@ struct CmarkTextContentVisitor: @preconcurrency MarkupVisitor { } func visitBlockDirective(_ blockDirective: BlockDirective) -> TextContent { - inlineViewContent(for: blockDirective) { + inlineViewContent(for: blockDirective, appendsLineBreak: true) { MarkdownBlockDirective(blockDirective: blockDirective) } } @@ -109,7 +101,8 @@ struct CmarkTextContentVisitor: @preconcurrency MarkupVisitor { return inlineViewContent( for: blockQuote, - replacement: replacementAttrString + replacement: replacementAttrString, + appendsLineBreak: true ) { MarkdownBlockQuote(blockQuote: blockQuote) } @@ -120,7 +113,7 @@ struct CmarkTextContentVisitor: @preconcurrency MarkupVisitor { } func visitThematicBreak(_ thematicBreak: ThematicBreak) -> TextContent { - inlineViewContent(for: thematicBreak) { + inlineViewContent(for: thematicBreak, appendsLineBreak: true) { Divider() } } @@ -157,7 +150,8 @@ struct CmarkTextContentVisitor: @preconcurrency MarkupVisitor { func visitCodeBlock(_ codeBlock: CodeBlock) -> TextContent { inlineViewContent( for: codeBlock, - replacement: AttributedString(codeBlock.code) + replacement: AttributedString(codeBlock.code), + appendsLineBreak: true ) { MarkdownStyledCodeBlock( configuration: CodeBlockStyleConfiguration( @@ -174,7 +168,8 @@ struct CmarkTextContentVisitor: @preconcurrency MarkupVisitor { replacement: AttributedString( html.rawHTML, attributes: AttributeContainer().isHTML(true) - ) + ), + appendsLineBreak: true ) { HTMLBlockView(html: html.rawHTML) } @@ -252,7 +247,8 @@ struct CmarkTextContentVisitor: @preconcurrency MarkupVisitor { } return inlineViewContent( for: table, - replacement: replacementAttrString + replacement: replacementAttrString, + appendsLineBreak: true ) { MarkdownTable(table: table) } @@ -391,6 +387,7 @@ private extension CmarkTextContentVisitor { func inlineViewContent( for markup: any Markup, replacement: AttributedString? = nil, + appendsLineBreak: Bool = false, @ViewBuilder content: @escaping () -> some View ) -> TextContent { let view = content() @@ -402,7 +399,9 @@ private extension CmarkTextContentVisitor { ) return TextContent { TextContent(.view(attachment)) - LineBreak() + if appendsLineBreak { + LineBreak() + } } } diff --git a/Sources/MarkdownView/View Modifiers/MarkdownTextSelectionModifier.swift b/Sources/MarkdownView/View Modifiers/MarkdownTextSelectionModifier.swift index 9dae6e1af..363d609ee 100644 --- a/Sources/MarkdownView/View Modifiers/MarkdownTextSelectionModifier.swift +++ b/Sources/MarkdownView/View Modifiers/MarkdownTextSelectionModifier.swift @@ -8,16 +8,21 @@ import SwiftUI extension SwiftUI.View { + @available(tvOS, unavailable) + @available(watchOS, unavailable) nonisolated public func markdownTextSelection(_ selection: some TextSelectability) -> some View { modifier(MarkdownTextSelectionViewModifier(selection: selection)) } } +@available(tvOS, unavailable) +@available(watchOS, unavailable) nonisolated struct MarkdownTextSelectionViewModifier: ViewModifier { let selection: S func body(content: Content) -> some View { Group { + #if canImport(RichText) if #available(iOS 26, macOS 26, *) { if type(of: selection).allowsSelection { content @@ -29,6 +34,9 @@ nonisolated struct MarkdownTextSelectionViewModifier: View } else { content } + #else + content + #endif } .textSelection(selection) } From 4a0799b96e3f2ebb7d776ea8ec8fbb4177e44e0f Mon Sep 17 00:00:00 2001 From: Yanan Li <1474700628@qq.com> Date: Tue, 27 Jan 2026 14:24:58 +0800 Subject: [PATCH 11/27] Read font from renderer configuration --- .../Block Quotes/DefaultBlockQuoteStyle.swift | 2 +- .../Code Blocks/DefaultCodeBlockStyle.swift | 5 ++- .../Font/MarkdownFontGroup.swift | 1 + ...bleStyleConfiguration.Table.Fallback.swift | 4 +-- ...TableStyleConfiguration.Table.Header.swift | 3 +- ...ownTableStyleConfiguration.Table.Row.swift | 3 +- .../MathBlockDirectiveRenderer.swift | 5 ++- .../Node Representations/HeadingText.swift | 15 ++++---- .../InlineMathOrText.swift | 6 +++- .../Tables/MarkdownTable.swift | 2 +- .../Text Formatting/FontModifier.swift | 36 ++++++++++--------- 11 files changed, 46 insertions(+), 36 deletions(-) diff --git a/Sources/MarkdownView/Customizations/Block Quotes/DefaultBlockQuoteStyle.swift b/Sources/MarkdownView/Customizations/Block Quotes/DefaultBlockQuoteStyle.swift index 0ffd92ecc..09e576fa6 100644 --- a/Sources/MarkdownView/Customizations/Block Quotes/DefaultBlockQuoteStyle.swift +++ b/Sources/MarkdownView/Customizations/Block Quotes/DefaultBlockQuoteStyle.swift @@ -21,10 +21,10 @@ extension BlockQuoteStyle where Self == DefaultBlockQuoteStyle { fileprivate struct DefaultBlockQuoteView: View { var configuration: BlockQuoteStyleConfiguration - @Environment(\.markdownFontGroup.blockQuote) private var font @Environment(\.markdownRendererConfiguration) private var rendererConfiguration var body: some View { let tint = rendererConfiguration.preferredTintColors[.blockQuote] ?? .accentColor + let font = rendererConfiguration.fonts[.blockQuote] ?? .system(.body, design: .serif) configuration.content .padding(.vertical, 8) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/Sources/MarkdownView/Customizations/Code Blocks/DefaultCodeBlockStyle.swift b/Sources/MarkdownView/Customizations/Code Blocks/DefaultCodeBlockStyle.swift index 0312e2d4f..f429692ab 100644 --- a/Sources/MarkdownView/Customizations/Code Blocks/DefaultCodeBlockStyle.swift +++ b/Sources/MarkdownView/Customizations/Code Blocks/DefaultCodeBlockStyle.swift @@ -58,8 +58,7 @@ struct DefaultMarkdownCodeBlock: View { var theme: CodeHighlighterTheme @Environment(\.colorScheme) private var colorScheme - - @Environment(\.markdownFontGroup) private var fontGroup + @Environment(\.markdownRendererConfiguration) private var rendererConfiguration @State private var attributedCode: AttributedString? @State private var codeHighlightTask: Task? @@ -83,7 +82,7 @@ struct DefaultMarkdownCodeBlock: View { debouncedHighlight() } .lineSpacing(4) - .font(fontGroup.codeBlock) + .font(rendererConfiguration.fonts[.codeBlock] ?? .system(.callout, design: .monospaced)) .frame(maxWidth: .infinity, alignment: .leading) #if os(macOS) || os(iOS) .safeAreaInset(edge: .top, spacing: 0) { diff --git a/Sources/MarkdownView/Customizations/Font/MarkdownFontGroup.swift b/Sources/MarkdownView/Customizations/Font/MarkdownFontGroup.swift index 954986b78..aeacf0d14 100644 --- a/Sources/MarkdownView/Customizations/Font/MarkdownFontGroup.swift +++ b/Sources/MarkdownView/Customizations/Font/MarkdownFontGroup.swift @@ -59,6 +59,7 @@ struct MarkdownFontGroupEnvironmentKey: EnvironmentKey { } extension EnvironmentValues { + @available(*, deprecated, message: "Use markdownRendererConfiguration.fonts via font modifiers instead.") var markdownFontGroup: AnyMarkdownFontGroup { get { self[MarkdownFontGroupEnvironmentKey.self] } set { self[MarkdownFontGroupEnvironmentKey.self] = newValue } diff --git a/Sources/MarkdownView/Customizations/Table/Configuration/MarkdownTableStyleConfiguration.Table.Fallback.swift b/Sources/MarkdownView/Customizations/Table/Configuration/MarkdownTableStyleConfiguration.Table.Fallback.swift index 2f3e49901..b4d16c438 100644 --- a/Sources/MarkdownView/Customizations/Table/Configuration/MarkdownTableStyleConfiguration.Table.Fallback.swift +++ b/Sources/MarkdownView/Customizations/Table/Configuration/MarkdownTableStyleConfiguration.Table.Fallback.swift @@ -13,8 +13,6 @@ extension MarkdownTableStyleConfiguration.Table { public struct Fallback: View { private var table: Markdown.Table @Environment(\.markdownRendererConfiguration) private var configuration - @Environment(\.markdownFontGroup.tableHeader) private var headerFont - @Environment(\.markdownFontGroup.tableBody) private var bodyFont @Environment(\.markdownTableCellPadding) private var padding private var showsRowSeparators: Bool = false private var horizontalSpacing: CGFloat = 0 @@ -26,6 +24,8 @@ extension MarkdownTableStyleConfiguration.Table { @_documentation(visibility: internal) public var body: some View { + let headerFont = configuration.fonts[.tableHeader] ?? .headline + let bodyFont = configuration.fonts[.tableBody] ?? .body AdaptiveGrid( horizontalSpacing: horizontalSpacing, verticalSpacing: verticalSpacing, diff --git a/Sources/MarkdownView/Customizations/Table/Configuration/MarkdownTableStyleConfiguration.Table.Header.swift b/Sources/MarkdownView/Customizations/Table/Configuration/MarkdownTableStyleConfiguration.Table.Header.swift index 803266167..eccfe390c 100644 --- a/Sources/MarkdownView/Customizations/Table/Configuration/MarkdownTableStyleConfiguration.Table.Header.swift +++ b/Sources/MarkdownView/Customizations/Table/Configuration/MarkdownTableStyleConfiguration.Table.Header.swift @@ -14,7 +14,7 @@ extension MarkdownTableStyleConfiguration.Table { /// On platforms that does not supports `Grid`, it would be `EmptyView`. public struct Header: View { var head: Markdown.Table.Head - @Environment(\.markdownFontGroup.tableHeader) private var font + @Environment(\.markdownRendererConfiguration) private var configuration init(_ head: Markdown.Table.Head) { self.head = head @@ -22,6 +22,7 @@ extension MarkdownTableStyleConfiguration.Table { @_documentation(visibility: internal) public var body: some View { + let font = configuration.fonts[.tableHeader] ?? .headline MarkdownTableRow( rowIndex: 0, cells: Array(head.cells) diff --git a/Sources/MarkdownView/Customizations/Table/Configuration/MarkdownTableStyleConfiguration.Table.Row.swift b/Sources/MarkdownView/Customizations/Table/Configuration/MarkdownTableStyleConfiguration.Table.Row.swift index ad2fad067..f90bcf89f 100644 --- a/Sources/MarkdownView/Customizations/Table/Configuration/MarkdownTableStyleConfiguration.Table.Row.swift +++ b/Sources/MarkdownView/Customizations/Table/Configuration/MarkdownTableStyleConfiguration.Table.Row.swift @@ -14,7 +14,7 @@ extension MarkdownTableStyleConfiguration.Table { /// On platforms that does not supports `Grid`, it would be `EmptyView`. public struct Row: View { var row: Markdown.Table.Row - @Environment(\.markdownFontGroup.tableBody) private var font + @Environment(\.markdownRendererConfiguration) private var configuration init(_ row: Markdown.Table.Row) { self.row = row @@ -22,6 +22,7 @@ extension MarkdownTableStyleConfiguration.Table { @_documentation(visibility: internal) public var body: some View { + let font = configuration.fonts[.tableBody] ?? .body MarkdownTableRow( rowIndex: row.indexInParent + 1 /* header */, cells: Array(row.cells) diff --git a/Sources/MarkdownView/Renderers/Node Representations/Block Directives/Renderers/MathBlockDirectiveRenderer.swift b/Sources/MarkdownView/Renderers/Node Representations/Block Directives/Renderers/MathBlockDirectiveRenderer.swift index 5c66d1e9b..8d95bb313 100644 --- a/Sources/MarkdownView/Renderers/Node Representations/Block Directives/Renderers/MathBlockDirectiveRenderer.swift +++ b/Sources/MarkdownView/Renderers/Node Representations/Block Directives/Renderers/MathBlockDirectiveRenderer.swift @@ -23,11 +23,14 @@ struct MathBlockDirectiveRenderer: BlockDirectiveRenderer { fileprivate struct DisplayMath: View { var mathIdentifier: UUID - @Environment(\.markdownFontGroup.displayMath) private var font + @Environment(\.markdownRendererConfiguration) private var configuration @Environment(\.markdownRendererConfiguration.math) private var math private var latexMath: String? { math.displayMathStorage[mathIdentifier] } + private var font: Font { + configuration.fonts[.displayMath] ?? .body + } var body: some View { if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { diff --git a/Sources/MarkdownView/Renderers/Node Representations/HeadingText.swift b/Sources/MarkdownView/Renderers/Node Representations/HeadingText.swift index 4791b4b21..084d179da 100644 --- a/Sources/MarkdownView/Renderers/Node Representations/HeadingText.swift +++ b/Sources/MarkdownView/Renderers/Node Representations/HeadingText.swift @@ -12,19 +12,18 @@ struct HeadingText: View { let heading: Heading @Environment(\.markdownRendererConfiguration) private var configuration - @Environment(\.markdownFontGroup) private var fontGroup @Environment(\.headingStyleGroup) private var headingStyleGroup @Environment(\.headingPaddings) private var paddings private var font: Font { return switch heading.level { - case 1: fontGroup.h1 - case 2: fontGroup.h2 - case 3: fontGroup.h3 - case 4: fontGroup.h4 - case 5: fontGroup.h5 - case 6: fontGroup.h6 - default: fontGroup.body + case 1: configuration.fonts[.h1] ?? .largeTitle + case 2: configuration.fonts[.h2] ?? .title + case 3: configuration.fonts[.h3] ?? .title2 + case 4: configuration.fonts[.h4] ?? .title3 + case 5: configuration.fonts[.h5] ?? .headline + case 6: configuration.fonts[.h6] ?? .headline.weight(.regular) + default: configuration.fonts[.body] ?? .body } } private var foregroundStyle: AnyShapeStyle { diff --git a/Sources/MarkdownView/Renderers/Node Representations/InlineMathOrText.swift b/Sources/MarkdownView/Renderers/Node Representations/InlineMathOrText.swift index c11b26504..1c1d91bb7 100644 --- a/Sources/MarkdownView/Renderers/Node Representations/InlineMathOrText.swift +++ b/Sources/MarkdownView/Renderers/Node Representations/InlineMathOrText.swift @@ -61,7 +61,11 @@ struct InlineMathOrText { #if canImport(LaTeXSwiftUI) struct InlineMath: View { var latexText: String - @Environment(\.markdownFontGroup.inlineMath) private var font + @Environment(\.markdownRendererConfiguration) private var configuration + + private var font: Font { + configuration.fonts[.inlineMath] ?? .body + } var body: some View { if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { diff --git a/Sources/MarkdownView/Renderers/Node Representations/Tables/MarkdownTable.swift b/Sources/MarkdownView/Renderers/Node Representations/Tables/MarkdownTable.swift index aa50ed325..9fa97e851 100644 --- a/Sources/MarkdownView/Renderers/Node Representations/Tables/MarkdownTable.swift +++ b/Sources/MarkdownView/Renderers/Node Representations/Tables/MarkdownTable.swift @@ -25,9 +25,9 @@ struct MarkdownTableBody: View { var tableBody: Markdown.Table.Body @Environment(\.markdownRendererConfiguration) private var configuration - @Environment(\.markdownFontGroup.tableBody) private var font var body: some View { + let font = configuration.fonts[.tableBody] ?? .body ForEach(Array(tableBody.children.enumerated()), id: \.offset) { (_, row) in CmarkNodeVisitor(configuration: configuration) .makeBody(for: row) diff --git a/Sources/MarkdownView/View Modifiers/Text Formatting/FontModifier.swift b/Sources/MarkdownView/View Modifiers/Text Formatting/FontModifier.swift index 6b7fa60ab..9c555349f 100644 --- a/Sources/MarkdownView/View Modifiers/Text Formatting/FontModifier.swift +++ b/Sources/MarkdownView/View Modifiers/Text Formatting/FontModifier.swift @@ -14,7 +14,23 @@ extension View { /// /// - Parameter fontGroup: A font set to apply to the MarkdownView. nonisolated public func fontGroup(_ fontGroup: some MarkdownFontGroup) -> some View { - environment(\.markdownFontGroup, .init(fontGroup)) + transformEnvironment(\.markdownRendererConfiguration) { configuration in + configuration.fonts = [ + .h1: fontGroup.h1, + .h2: fontGroup.h2, + .h3: fontGroup.h3, + .h4: fontGroup.h4, + .h5: fontGroup.h5, + .h6: fontGroup.h6, + .body: fontGroup.body, + .codeBlock: fontGroup.codeBlock, + .blockQuote: fontGroup.blockQuote, + .tableHeader: fontGroup.tableHeader, + .tableBody: fontGroup.tableBody, + .inlineMath: fontGroup.inlineMath, + .displayMath: fontGroup.displayMath, + ] + } } /// Sets the font for the specific component in MarkdownView. @@ -22,22 +38,8 @@ extension View { /// - font: The font to apply to these components. /// - component: The component to apply the font. nonisolated public func font(_ font: Font, for component: MarkdownComponent) -> some View { - transformEnvironment(\.markdownFontGroup) { fontGroup in - switch component { - case .h1: fontGroup._h1 = font - case .h2: fontGroup._h2 = font - case .h3: fontGroup._h3 = font - case .h4: fontGroup._h4 = font - case .h5: fontGroup._h5 = font - case .h6: fontGroup._h6 = font - case .body: fontGroup._body = font - case .blockQuote: fontGroup._blockQuote = font - case .codeBlock: fontGroup._codeBlock = font - case .tableBody: fontGroup._tableBody = font - case .tableHeader: fontGroup._tableHeader = font - case .inlineMath: fontGroup._inlineMath = font - case .displayMath: fontGroup._displayMath = font - } + transformEnvironment(\.markdownRendererConfiguration) { configuration in + configuration.fonts[component] = font } } } From 1f8164c4f8a678f3cafe53080d5be9c2b824c7d7 Mon Sep 17 00:00:00 2001 From: Yanan Li <1474700628@qq.com> Date: Tue, 27 Jan 2026 17:26:57 +0800 Subject: [PATCH 12/27] Update package --- Sources/MarkdownView/MarkdownView.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/MarkdownView/MarkdownView.swift b/Sources/MarkdownView/MarkdownView.swift index f81f62ead..4a3c82916 100644 --- a/Sources/MarkdownView/MarkdownView.swift +++ b/Sources/MarkdownView/MarkdownView.swift @@ -34,7 +34,9 @@ public struct MarkdownView: View { } } -@available(iOS 26.0, macOS 14.0, *) +@available(iOS 17.0, macOS 14.0, *) +@available(watchOS, unavailable) +@available(tvOS, unavailable) #Preview(traits: .sizeThatFitsLayout) { VStack { MarkdownView("Hello **World**") From 2ebe004824072cd5d0ac0da3e5e575405bd645fa Mon Sep 17 00:00:00 2001 From: Henry Rausch Date: Fri, 6 Mar 2026 04:18:36 +0100 Subject: [PATCH 13/27] 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 801c35d9f..b7c8737c8 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 14/27] 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 4a3c82916..9e3650ffc 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 7f27d3292..e5ec657ea 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 15/27] 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 21b6d340f..dca1ca941 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 16/27] 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 b19f04af3..fdebb78de 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 17/27] 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 e5ec657ea..c394f4d66 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 000000000..6bc201ed5 --- /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 18/27] 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 201490157..d333e7498 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 dca1ca941..703a5f315 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 19/27] 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 f1430836d..32f8c08c9 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 ac504ba2d..311d70fea 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 1c1fe6155..012cfc7fe 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 6d981e3c8..bddbc5344 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 aa7fa2a3f..7b9c3a540 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 9fa97e851..bfc00ec5b 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 5eac6bfbd..9c6971c8f 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 20/27] 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 d333e7498..8a8fcc14e 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 9eb6b5744..684f80bc1 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 21/27] 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 6f9350d28..71a3ce532 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 9e3650ffc..771107d6e 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 751d7ce9c..e691af187 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 22/27] 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 f69bd362f..43c5d1cdf 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 23/27] 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 dbfd4261c..4b6026918 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 24/27] 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 771107d6e..4a3c82916 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 e691af187..751d7ce9c 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 25/27] 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 1a6fe99dc..fbe9c7455 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. /// From 83aa61bd5241934c7bb399d6b1a4a8d5a92670c5 Mon Sep 17 00:00:00 2001 From: Yanan Li <1474700628@qq.com> Date: Tue, 10 Mar 2026 13:53:33 +0800 Subject: [PATCH 26/27] Update examples --- DemoApp/DemoApp.xcodeproj/project.pbxproj | 367 -------------- .../contents.xcworkspacedata | 7 - .../AccentColor.colorset/Contents.json | 11 - .../AppIcon.appiconset/Contents.json | 85 ---- DemoApp/DemoApp/Assets.xcassets/Contents.json | 6 - DemoApp/DemoApp/ContentView.swift | 39 -- DemoApp/DemoApp/DemoApp.entitlements | 12 - DemoApp/DemoApp/DemoAppApp.swift | 17 - .../BlockDirectiveDestination.swift | 73 --- .../CustomizationDestination.swift | 88 ---- .../Destinations/ImageDestination.swift | 35 -- .../Destinations/InteractDestination.swift | 55 --- .../Destinations/ListDestination.swift | 60 --- .../Destinations/MathDestination.swift | 58 --- .../Destinations/OverviewDestination.swift | 41 -- .../Destinations/TableDestination.swift | 32 -- .../Destinations/TextDestination.swift | 31 -- .../Preview Assets.xcassets/Contents.json | 6 - DemoApp/DemoApp/Shared/Tab.swift | 77 --- DemoApp/DemoApp/Shared/TabGroup.swift | 28 -- Example/.gitignore | 8 + Example/Package.swift | 32 ++ Example/Sources/Example/BlockDirective.swift | 29 ++ Example/Sources/Example/Customization.swift | 30 ++ .../Example/Helpers/PreviewModifier.swift | 27 + Example/Sources/Example/Image.swift | 16 + Example/Sources/Example/Interact.swift | 18 + Example/Sources/Example/List.swift | 21 + Example/Sources/Example/Math.swift | 41 ++ Example/Sources/Example/Table.swift | 18 + Example/Sources/Example/Text.swift | 17 + .../MarkdownAttachmentReplacementPolicy.swift | 72 +++ .../Internal/MarkdownRenderPipeline.swift | 211 ++++++++ .../Internal/MarkdownSemanticDocument.swift | 86 ++++ .../Internal/MarkdownSemanticVisitor.swift | 222 +++++++++ .../Internal/MarkdownSubtreeRenderer.swift | 62 +++ .../Internal/MarkdownTextContentEmitter.swift | 460 ++++++++++++++++++ .../Internal/MarkdownViewEmitter.swift | 300 ++++++++++++ 38 files changed, 1670 insertions(+), 1128 deletions(-) delete mode 100644 DemoApp/DemoApp.xcodeproj/project.pbxproj delete mode 100644 DemoApp/DemoApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata delete mode 100644 DemoApp/DemoApp/Assets.xcassets/AccentColor.colorset/Contents.json delete mode 100644 DemoApp/DemoApp/Assets.xcassets/AppIcon.appiconset/Contents.json delete mode 100644 DemoApp/DemoApp/Assets.xcassets/Contents.json delete mode 100644 DemoApp/DemoApp/ContentView.swift delete mode 100644 DemoApp/DemoApp/DemoApp.entitlements delete mode 100644 DemoApp/DemoApp/DemoAppApp.swift delete mode 100644 DemoApp/DemoApp/Destinations/BlockDirectiveDestination.swift delete mode 100644 DemoApp/DemoApp/Destinations/CustomizationDestination.swift delete mode 100644 DemoApp/DemoApp/Destinations/ImageDestination.swift delete mode 100644 DemoApp/DemoApp/Destinations/InteractDestination.swift delete mode 100644 DemoApp/DemoApp/Destinations/ListDestination.swift delete mode 100644 DemoApp/DemoApp/Destinations/MathDestination.swift delete mode 100644 DemoApp/DemoApp/Destinations/OverviewDestination.swift delete mode 100644 DemoApp/DemoApp/Destinations/TableDestination.swift delete mode 100644 DemoApp/DemoApp/Destinations/TextDestination.swift delete mode 100644 DemoApp/DemoApp/Preview Content/Preview Assets.xcassets/Contents.json delete mode 100644 DemoApp/DemoApp/Shared/Tab.swift delete mode 100644 DemoApp/DemoApp/Shared/TabGroup.swift create mode 100644 Example/.gitignore create mode 100644 Example/Package.swift create mode 100644 Example/Sources/Example/BlockDirective.swift create mode 100644 Example/Sources/Example/Customization.swift create mode 100644 Example/Sources/Example/Helpers/PreviewModifier.swift create mode 100644 Example/Sources/Example/Image.swift create mode 100644 Example/Sources/Example/Interact.swift create mode 100644 Example/Sources/Example/List.swift create mode 100644 Example/Sources/Example/Math.swift create mode 100644 Example/Sources/Example/Table.swift create mode 100644 Example/Sources/Example/Text.swift create mode 100644 Sources/MarkdownView/Renderers/Internal/MarkdownAttachmentReplacementPolicy.swift create mode 100644 Sources/MarkdownView/Renderers/Internal/MarkdownRenderPipeline.swift create mode 100644 Sources/MarkdownView/Renderers/Internal/MarkdownSemanticDocument.swift create mode 100644 Sources/MarkdownView/Renderers/Internal/MarkdownSemanticVisitor.swift create mode 100644 Sources/MarkdownView/Renderers/Internal/MarkdownSubtreeRenderer.swift create mode 100644 Sources/MarkdownView/Renderers/Internal/MarkdownTextContentEmitter.swift create mode 100644 Sources/MarkdownView/Renderers/Internal/MarkdownViewEmitter.swift diff --git a/DemoApp/DemoApp.xcodeproj/project.pbxproj b/DemoApp/DemoApp.xcodeproj/project.pbxproj deleted file mode 100644 index 62ce640b7..000000000 --- a/DemoApp/DemoApp.xcodeproj/project.pbxproj +++ /dev/null @@ -1,367 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 77; - objects = { - -/* Begin PBXBuildFile section */ - 8C21E5B02D9E7FB20097554E /* MarkdownView in Frameworks */ = {isa = PBXBuildFile; productRef = 8C21E5AF2D9E7FB20097554E /* MarkdownView */; }; -/* End PBXBuildFile section */ - -/* Begin PBXFileReference section */ - 38A580AF2CC8D6D9001EDFCA /* DemoApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DemoApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; -/* End PBXFileReference section */ - -/* Begin PBXFileSystemSynchronizedRootGroup section */ - 38A580B12CC8D6D9001EDFCA /* DemoApp */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = DemoApp; - sourceTree = ""; - }; -/* End PBXFileSystemSynchronizedRootGroup section */ - -/* Begin PBXFrameworksBuildPhase section */ - 38A580AC2CC8D6D9001EDFCA /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 8C21E5B02D9E7FB20097554E /* MarkdownView in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 38A580A62CC8D6D9001EDFCA = { - isa = PBXGroup; - children = ( - 38A580B12CC8D6D9001EDFCA /* DemoApp */, - 38A580B02CC8D6D9001EDFCA /* Products */, - ); - sourceTree = ""; - }; - 38A580B02CC8D6D9001EDFCA /* Products */ = { - isa = PBXGroup; - children = ( - 38A580AF2CC8D6D9001EDFCA /* DemoApp.app */, - ); - name = Products; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 38A580AE2CC8D6D9001EDFCA /* DemoApp */ = { - isa = PBXNativeTarget; - buildConfigurationList = 38A580BE2CC8D6DB001EDFCA /* Build configuration list for PBXNativeTarget "DemoApp" */; - buildPhases = ( - 38A580AB2CC8D6D9001EDFCA /* Sources */, - 38A580AC2CC8D6D9001EDFCA /* Frameworks */, - 38A580AD2CC8D6D9001EDFCA /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - fileSystemSynchronizedGroups = ( - 38A580B12CC8D6D9001EDFCA /* DemoApp */, - ); - name = DemoApp; - packageProductDependencies = ( - 8C21E5AF2D9E7FB20097554E /* MarkdownView */, - ); - productName = DemoApp; - productReference = 38A580AF2CC8D6D9001EDFCA /* DemoApp.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 38A580A72CC8D6D9001EDFCA /* Project object */ = { - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1610; - LastUpgradeCheck = 1610; - TargetAttributes = { - 38A580AE2CC8D6D9001EDFCA = { - CreatedOnToolsVersion = 16.1; - }; - }; - }; - buildConfigurationList = 38A580AA2CC8D6D9001EDFCA /* Build configuration list for PBXProject "DemoApp" */; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 38A580A62CC8D6D9001EDFCA; - minimizedProjectReferenceProxies = 1; - packageReferences = ( - 8C21E5AE2D9E7FB20097554E /* XCLocalSwiftPackageReference "../../MarkdownView" */, - ); - preferredProjectObjectVersion = 77; - productRefGroup = 38A580B02CC8D6D9001EDFCA /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 38A580AE2CC8D6D9001EDFCA /* DemoApp */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 38A580AD2CC8D6D9001EDFCA /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 38A580AB2CC8D6D9001EDFCA /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin XCBuildConfiguration section */ - 38A580BC2CC8D6DB001EDFCA /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; - MTL_FAST_MATH = YES; - ONLY_ACTIVE_ARCH = YES; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - }; - name = Debug; - }; - 38A580BD2CC8D6DB001EDFCA /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MTL_ENABLE_DEBUG_INFO = NO; - MTL_FAST_MATH = YES; - SWIFT_COMPILATION_MODE = wholemodule; - }; - name = Release; - }; - 38A580BF2CC8D6DB001EDFCA /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = DemoApp/DemoApp.entitlements; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_ASSET_PATHS = "\"DemoApp/Preview Content\""; - DEVELOPMENT_TEAM = CJ9X49H2WL; - ENABLE_HARDENED_RUNTIME = YES; - ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; - "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; - "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; - "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; - "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; - "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; - "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; - "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; - LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; - "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; - MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.liyanan2004.DemoApp; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = auto; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 6.0; - TARGETED_DEVICE_FAMILY = "1,2,7"; - XROS_DEPLOYMENT_TARGET = 2.0; - }; - name = Debug; - }; - 38A580C02CC8D6DB001EDFCA /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = DemoApp/DemoApp.entitlements; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_ASSET_PATHS = "\"DemoApp/Preview Content\""; - DEVELOPMENT_TEAM = CJ9X49H2WL; - ENABLE_HARDENED_RUNTIME = YES; - ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; - "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; - "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; - "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; - "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; - "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; - "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; - "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; - LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; - "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; - MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.liyanan2004.DemoApp; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = auto; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 6.0; - TARGETED_DEVICE_FAMILY = "1,2,7"; - XROS_DEPLOYMENT_TARGET = 2.0; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 38A580AA2CC8D6D9001EDFCA /* Build configuration list for PBXProject "DemoApp" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 38A580BC2CC8D6DB001EDFCA /* Debug */, - 38A580BD2CC8D6DB001EDFCA /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 38A580BE2CC8D6DB001EDFCA /* Build configuration list for PBXNativeTarget "DemoApp" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 38A580BF2CC8D6DB001EDFCA /* Debug */, - 38A580C02CC8D6DB001EDFCA /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - -/* Begin XCLocalSwiftPackageReference section */ - 8C21E5AE2D9E7FB20097554E /* XCLocalSwiftPackageReference "../../MarkdownView" */ = { - isa = XCLocalSwiftPackageReference; - relativePath = ../../MarkdownView; - }; -/* End XCLocalSwiftPackageReference section */ - -/* Begin XCSwiftPackageProductDependency section */ - 8C21E5AF2D9E7FB20097554E /* MarkdownView */ = { - isa = XCSwiftPackageProductDependency; - productName = MarkdownView; - }; -/* End XCSwiftPackageProductDependency section */ - }; - rootObject = 38A580A72CC8D6D9001EDFCA /* Project object */; -} diff --git a/DemoApp/DemoApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/DemoApp/DemoApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 919434a62..000000000 --- a/DemoApp/DemoApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/DemoApp/DemoApp/Assets.xcassets/AccentColor.colorset/Contents.json b/DemoApp/DemoApp/Assets.xcassets/AccentColor.colorset/Contents.json deleted file mode 100644 index eb8789700..000000000 --- a/DemoApp/DemoApp/Assets.xcassets/AccentColor.colorset/Contents.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "colors" : [ - { - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/DemoApp/DemoApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/DemoApp/DemoApp/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index ffdfe150b..000000000 --- a/DemoApp/DemoApp/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,85 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "tinted" - } - ], - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - }, - { - "idiom" : "mac", - "scale" : "1x", - "size" : "16x16" - }, - { - "idiom" : "mac", - "scale" : "2x", - "size" : "16x16" - }, - { - "idiom" : "mac", - "scale" : "1x", - "size" : "32x32" - }, - { - "idiom" : "mac", - "scale" : "2x", - "size" : "32x32" - }, - { - "idiom" : "mac", - "scale" : "1x", - "size" : "128x128" - }, - { - "idiom" : "mac", - "scale" : "2x", - "size" : "128x128" - }, - { - "idiom" : "mac", - "scale" : "1x", - "size" : "256x256" - }, - { - "idiom" : "mac", - "scale" : "2x", - "size" : "256x256" - }, - { - "idiom" : "mac", - "scale" : "1x", - "size" : "512x512" - }, - { - "idiom" : "mac", - "scale" : "2x", - "size" : "512x512" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/DemoApp/DemoApp/Assets.xcassets/Contents.json b/DemoApp/DemoApp/Assets.xcassets/Contents.json deleted file mode 100644 index 73c00596a..000000000 --- a/DemoApp/DemoApp/Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/DemoApp/DemoApp/ContentView.swift b/DemoApp/DemoApp/ContentView.swift deleted file mode 100644 index 5d9dce7df..000000000 --- a/DemoApp/DemoApp/ContentView.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// ContentView.swift -// DemoApp -// -// Created by LiYanan2004 on 2024/10/23. -// - -import SwiftUI -import MarkdownView - -struct ContentView: View { - @State private var selection: Tab? = .overview - - var body: some View { - NavigationSplitView(columnVisibility: .constant(.doubleColumn)) { - List(selection: $selection) { - ForEach(TabGroup.allCases) { group in - Section { - ForEach(group.tabs) { tab in - tab.link - } - } header: { - Text(group.rawValue) - } - } - } - .listStyle(.sidebar) - } detail: { - if let selection { - selection.destination - } - } - .navigationSplitViewStyle(.balanced) - } -} - -#Preview { - ContentView() -} diff --git a/DemoApp/DemoApp/DemoApp.entitlements b/DemoApp/DemoApp/DemoApp.entitlements deleted file mode 100644 index 625af03d9..000000000 --- a/DemoApp/DemoApp/DemoApp.entitlements +++ /dev/null @@ -1,12 +0,0 @@ - - - - - com.apple.security.app-sandbox - - com.apple.security.files.user-selected.read-only - - com.apple.security.network.client - - - diff --git a/DemoApp/DemoApp/DemoAppApp.swift b/DemoApp/DemoApp/DemoAppApp.swift deleted file mode 100644 index a0b689eb2..000000000 --- a/DemoApp/DemoApp/DemoAppApp.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// DemoAppApp.swift -// DemoApp -// -// Created by LiYanan2004 on 2024/10/23. -// - -import SwiftUI - -@main -struct DemoAppApp: App { - var body: some Scene { - WindowGroup { - ContentView() - } - } -} diff --git a/DemoApp/DemoApp/Destinations/BlockDirectiveDestination.swift b/DemoApp/DemoApp/Destinations/BlockDirectiveDestination.swift deleted file mode 100644 index 0f7cb45b5..000000000 --- a/DemoApp/DemoApp/Destinations/BlockDirectiveDestination.swift +++ /dev/null @@ -1,73 +0,0 @@ -// -// BlockDirectiveDestination.swift -// DemoApp -// -// Created by LiYanan2004 on 2024/10/24. -// - -import SwiftUI -import MarkdownView - -struct BlockDirectiveDestination: View { - @State private var text = #""" - @note { - This is a note directive block. You can use it to highlight important information or provide additional context to users. - } - """# - - var body: some View { - VStack(alignment: .leading, spacing: 20) { - Section { - TextEditor(text: $text) - .scrollContentBackground(.hidden) - .font(.body) - .lineSpacing(6) - .padding(8) - .background( - .background.secondary, - in: .rect(cornerRadius: 12) - ) - } header: { - Text("Markdown Text") - .font(.headline) - } - - Divider() - - Section { - MarkdownView(text) - .blockDirectiveRenderer(.note, for: "note") - } header: { - Text("MarkdownView") - .font(.headline) - } - } - } -} - -#Preview { - ScrollView { - BlockDirectiveDestination() - .scenePadding() - .frame(width: 500) - } -} - -// MARK: - Custom Note Block Directive Renderer - -struct NoteBlockDirective: BlockDirectiveRenderer { - func makeBody(configuration: Configuration) -> some View { - Text(configuration.wrappedString) - .padding(20) - .background( - .yellow.secondary, - in: .rect(cornerRadius: 12) - ) - } -} - -// MARK: - Convenience - -extension BlockDirectiveRenderer where Self == NoteBlockDirective { - static var note: NoteBlockDirective { .init() } -} diff --git a/DemoApp/DemoApp/Destinations/CustomizationDestination.swift b/DemoApp/DemoApp/Destinations/CustomizationDestination.swift deleted file mode 100644 index e7fc42267..000000000 --- a/DemoApp/DemoApp/Destinations/CustomizationDestination.swift +++ /dev/null @@ -1,88 +0,0 @@ -// -// CustomizationDestination.swift -// DemoApp -// -// Created by LiYanan2004 on 2024/10/24. -// - -import SwiftUI -import MarkdownView - -struct CustomizationDestination: View { - @State private var quoteTint = Color.accentColor - @State private var inlineCodeTint = Color.accentColor - @State private var hirarchicalTint = false - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - HStack(spacing: 20) { - ColorPicker("Quote Block Tint", selection: $quoteTint) - ColorPicker("Inline Code Tint", selection: $inlineCodeTint) - Toggle("Hirarchical Tint", isOn: $hirarchicalTint) - } - - MarkdownView(""" - # Getting Started with **SwiftUI** - - ## SwiftUI Basics - - ### Why Choose SwiftUI? - SwiftUI is **Apple's declarative framework** for building user interfaces across all Apple platforms. It provides a clean and expressive syntax, making UI development more intuitive. - - #### Key Advantages of SwiftUI - - **Declarative Syntax**: Write what you want to achieve, and SwiftUI handles the rest. - - **Cross-Platform**: Build interfaces for iOS, macOS, watchOS, and tvOS with a single codebase. - - ## Example Code Block - - ```swift - // SwiftUI code snippet - import SwiftUI - - struct ContentView: View { - var body: some View { - VStack { - Text("Hello, SwiftUI!") - .font(.largeTitle) - .padding() - Button("Click Me") { - print("Button tapped!") - } - } - } - } - ``` - - The example above shows how **SwiftUI** leverages declarative code to create a simple interface with a `Text` view and a `Button`. - - ## Highlighting Quotes - - > "SwiftUI takes a lot of the complexity out of UI development, making it easier for developers to focus on building great apps." - > — Apple Developer - - ## Data Representation with Tables - - SwiftUI makes it easy to present structured data using lists and tables. Here's an example table explaining SwiftUI's components: - - | **Component** | **Description** | - |------------------|---------------------------------------------| - | Views | Basic building blocks like `Text` or `Image`.| - | Layout Containers| Structures like `VStack`, `HStack`, `ZStack`.| - | Modifiers | Chainable functions to style or configure views.| - """) - .foregroundStyle(hirarchicalTint ? .tertiary : .primary) - } - .tint(quoteTint, for: .blockQuote) - .tint(inlineCodeTint, for: .inlineCodeBlock) - .foregroundStyle(hirarchicalTint ? .secondary : .primary, for: .h2) - .foregroundStyle(hirarchicalTint ? .tertiary : .primary, for: .h3) - .foregroundStyle(hirarchicalTint ? .tertiary : .primary, for: .h4) - } -} - -#Preview { - ScrollView { - CustomizationDestination() - .scenePadding() - } -} diff --git a/DemoApp/DemoApp/Destinations/ImageDestination.swift b/DemoApp/DemoApp/Destinations/ImageDestination.swift deleted file mode 100644 index 18ac764f2..000000000 --- a/DemoApp/DemoApp/Destinations/ImageDestination.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// ImageDestination.swift -// DemoApp -// -// Created by LiYanan2004 on 2024/10/23. -// - -import SwiftUI -import MarkdownView - -struct ImageDestination: View { - var body: some View { - VStack(alignment: .leading) { - MarkdownView(""" - ### SVG Content - ![Swift Badge](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FLiYanan2004%2FMarkdownView%2Fbadge%3Ftype%3Dswift-versions) - ![Platform Badge](https://camo.githubusercontent.com/bf56cba1dd003eb35f7a5bbe930df25b30cec92d78f06d5c0e4cae285865ccb6/68747470733a2f2f696d672e736869656c64732e696f2f656e64706f696e743f75726c3d687474707325334125324625324673776966747061636b616765696e6465782e636f6d2532466170692532467061636b616765732532464c6959616e616e323030342532464d61726b646f776e56696577253246626164676525334674797065253344706c6174666f726d73) - """) - - MarkdownView(""" - ### Web Images - ![](https://avatars.githubusercontent.com/u/37542129?s=400&u=ad6b55151a424db26e94b3b9ba3c57e69f62b56b&v=4) - """) - } - } -} - -#Preview { - NavigationStack { - ScrollView { - ImageDestination() - .frame(width: 300) - } - } -} diff --git a/DemoApp/DemoApp/Destinations/InteractDestination.swift b/DemoApp/DemoApp/Destinations/InteractDestination.swift deleted file mode 100644 index 908407932..000000000 --- a/DemoApp/DemoApp/Destinations/InteractDestination.swift +++ /dev/null @@ -1,55 +0,0 @@ -// -// InteractDestination.swift -// DemoApp -// -// Created by LiYanan2004 on 2024/10/24. -// - -import SwiftUI -import MarkdownView - -struct InteractDestination: View { - @State private var text = "" - @State private var enableMathRendering = false - - var body: some View { - VStack(alignment: .leading, spacing: 20) { - Section { - TextEditor(text: $text) - .scrollContentBackground(.hidden) - .font(.body) - .lineSpacing(6) - .containerRelativeFrame(.vertical, count: 3, span: 1, spacing: 20) - .padding(8) - .background( - .background.secondary, - in: .rect(cornerRadius: 12) - ) - } header: { - HStack { - Text("Markdown Text") - .font(.headline) - Spacer(minLength: 20) - Toggle("Math Rendering", isOn: $enableMathRendering) - } - } - - Divider() - - Section { - MarkdownView(text) - .markdownMathRenderingEnabled(enableMathRendering) - } header: { - Text("MarkdownView") - .font(.headline) - } - } - } -} - -#Preview { - ScrollView { - InteractDestination() - .scenePadding() - } -} diff --git a/DemoApp/DemoApp/Destinations/ListDestination.swift b/DemoApp/DemoApp/Destinations/ListDestination.swift deleted file mode 100644 index 9dddb33ef..000000000 --- a/DemoApp/DemoApp/Destinations/ListDestination.swift +++ /dev/null @@ -1,60 +0,0 @@ -// -// ListDestination.swift -// DemoApp -// -// Created by LiYanan2004 on 2024/10/24. -// - -import SwiftUI -import MarkdownView - -struct ListDestination: View { - @State private var text = """ - - Solar System Exploration - - Planetary Missions - - Mars Rover Program - - Venus Atmospheric Studies - - Jupiter Moon Probes - - Asteroid Sampling - - Near-Earth Objects - - OSIRIS-REx Mission - - Hayabusa2 Project - - Main Belt Asteroids - - Dawn Mission to Ceres - - Psyche Metal World Study - """ - - var body: some View { - VStack(alignment: .leading, spacing: 20) { - Section { - TextEditor(text: $text) - .scrollContentBackground(.hidden) - .font(.body) - .lineSpacing(6) - .padding(8) - .background( - .background.secondary, - in: .rect(cornerRadius: 12) - ) - } header: { - Text("Markdown Text") - .font(.headline) - } - - Divider() - - Section { - MarkdownView(text) - } header: { - Text("MarkdownView") - .font(.headline) - } - } - } -} - -#Preview { - ScrollView { - ListDestination() - } -} diff --git a/DemoApp/DemoApp/Destinations/MathDestination.swift b/DemoApp/DemoApp/Destinations/MathDestination.swift deleted file mode 100644 index 454021354..000000000 --- a/DemoApp/DemoApp/Destinations/MathDestination.swift +++ /dev/null @@ -1,58 +0,0 @@ -// -// MathDestination.swift -// DemoApp -// -// Created by LiYanan2004 on 2024/10/23. -// - -import SwiftUI -import MarkdownView - -struct MathDestination: View { - var body: some View { - VStack(alignment: .leading) { - MarkdownView(#""" - Einstein's famous equation $E = mc^2$ relates energy and mass. - - --- - - The Pythagorean theorem states that \(a^2 + b^2 = c^2\). - - --- - - The Gaussian integral evaluates to: - - \[ - \int_{-\infty}^{\infty} e^{-x^2} dx = \sqrt{\pi} - \] - - --- - - A Taylor series expansion around a point \(a\) is given by: - - \begin{equation} - f(x) = \sum_{n=0}^{\infty} \frac{f^{(n)}(a)}{n!}(x - a)^n - \end{equation} - - --- - - The quadratic formula is written as: - - \begin{equation*} - x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a} - \end{equation*} - """#) - } - .markdownMathRenderingEnabled() - .lineSpacing(12) - } -} - -#Preview { - NavigationStack { - ScrollView { - ImageDestination() - .frame(width: 300) - } - } -} diff --git a/DemoApp/DemoApp/Destinations/OverviewDestination.swift b/DemoApp/DemoApp/Destinations/OverviewDestination.swift deleted file mode 100644 index 285ba1edf..000000000 --- a/DemoApp/DemoApp/Destinations/OverviewDestination.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// OverviewDestination.swift -// DemoApp -// -// Created by LiYanan2004 on 2024/10/23. -// - -import SwiftUI -import MarkdownView - -struct OverviewDestination: View { - var body: some View { - MarkdownView(""" - # MarkdownView - - Welcome to MarkdownView Demo App. - - MarkdownView is a dedicated package that provides a solution for Markdown text rendering. - - It renders content as native SwiftUI View and supports built-in accessibility features. - - ### MarkdownView supports - - Formatted Text, including: **bold**, _italic_, ~strike through~, [Link](https://apple.com), `inline code` - - Lists - - Unordered List - 1. Ordered List - - Quote - - Code Block - - Image, including SVG-based content - - Table - """) - } -} - -#Preview { - NavigationStack { - ScrollView { - OverviewDestination() - } - } -} diff --git a/DemoApp/DemoApp/Destinations/TableDestination.swift b/DemoApp/DemoApp/Destinations/TableDestination.swift deleted file mode 100644 index 981fb71f5..000000000 --- a/DemoApp/DemoApp/Destinations/TableDestination.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// TableDestination.swift -// DemoApp -// -// Created by LiYanan2004 on 2024/10/23. -// - -import SwiftUI -import MarkdownView - -struct TableDestination: View { - var body: some View { - MarkdownView(""" - | Programming Language | Year Created | Creator | Popular Frameworks/Libraries | - |:--------------------:|:------------:|-----------------|------------------------------| - | Python | 1991 | Guido van Rossum | Django, Flask, TensorFlow | - | JavaScript | 1995 | Brendan Eich | React, Angular, Vue.js | - | Java | 1995 | James Gosling | Spring, Hibernate, Android | - | Swift | 2014 | Apple Inc. | SwiftUI, Vapor | - | C# | 2000 | Microsoft | .NET, Unity | - | Ruby | 1995 | Yukihiro Matsumoto| Ruby on Rails, Sinatra | - | Go | 2009 | Robert Griesemer, Rob Pike, and Ken Thompson | Gin, Echo | - """) - } -} - -#Preview { - ScrollView { - TableDestination() - } - .frame(width: 500) -} diff --git a/DemoApp/DemoApp/Destinations/TextDestination.swift b/DemoApp/DemoApp/Destinations/TextDestination.swift deleted file mode 100644 index d2e1d3900..000000000 --- a/DemoApp/DemoApp/Destinations/TextDestination.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// TextDestination.swift -// DemoApp -// -// Created by LiYanan2004 on 2024/10/23. -// - -import SwiftUI -import MarkdownView - -struct TextDestination: View { - var body: some View { - MarkdownView(""" - # MarkdownView - ## Heading 2 - ### Heading 3 - #### Heading 4 - - __MarkdownView__ is built with `swift-markdown`. - - It supports _SVG Rendering_, which is pretty great. - """) - } -} - -#Preview { - ScrollView { - TextDestination() - } - .frame(width: 500) -} diff --git a/DemoApp/DemoApp/Preview Content/Preview Assets.xcassets/Contents.json b/DemoApp/DemoApp/Preview Content/Preview Assets.xcassets/Contents.json deleted file mode 100644 index 73c00596a..000000000 --- a/DemoApp/DemoApp/Preview Content/Preview Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/DemoApp/DemoApp/Shared/Tab.swift b/DemoApp/DemoApp/Shared/Tab.swift deleted file mode 100644 index 426d1a8f6..000000000 --- a/DemoApp/DemoApp/Shared/Tab.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// Tab.swift -// DemoApp -// -// Created by LiYanan2004 on 2024/10/23. -// - -import Foundation -import SwiftUI - -enum Tab: String, CaseIterable { - case overview, image, table, text, list, customization, interact, blockDirective, math - - var name: String { - switch self { - case .overview: "Overview" - case .image: "Images" - case .table: "Table" - case .text: "Text" - case .list: "List" - case .customization: "Customization" - case .interact: "Interact" - case .blockDirective: "Block Directive" - case .math: "Math Rendering" - } - } -} - -// MARK: - Link - -extension Tab { - struct TabLink: View { - let tab: Tab - var body: some View { - NavigationLink(tab.name, value: tab) - } - } - - /// A navigation link view to the destination view. - var link: TabLink { .init(tab: self) } -} - -// MARK: - Destination - -extension Tab { - struct TabDestination: View { - let tab: Tab - var body: some View { - ScrollView { - Group { - switch tab { - case .overview: OverviewDestination() - case .image: ImageDestination() - case .table: TableDestination() - case .text: TextDestination() - case .list: ListDestination() - case .customization: CustomizationDestination() - case .interact: InteractDestination() - case .blockDirective: BlockDirectiveDestination() - case .math: MathDestination() - } - } - .frame(maxWidth: .infinity) - .scenePadding() - } - } - } - - /// A navigation detail view of this tab. - var destination: TabDestination { .init(tab: self) } -} - -// MARK: - Conformance: Identifiable - -extension Tab: Identifiable { - var id: String { name } -} diff --git a/DemoApp/DemoApp/Shared/TabGroup.swift b/DemoApp/DemoApp/Shared/TabGroup.swift deleted file mode 100644 index ce19498c9..000000000 --- a/DemoApp/DemoApp/Shared/TabGroup.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// TabGroup.swift -// DemoApp -// -// Created by LiYanan2004 on 2024/10/24. -// - -import Foundation - -enum TabGroup: String, Codable, CaseIterable { - case intro = "Intro" - case interactive = "Try" - case usage = "Demo" - - var tabs: [Tab] { - switch self { - case .intro: [.overview] - case .interactive: [.interact] - case .usage: [.text, .image, .list, .table, .customization, .blockDirective, .math] - } - } -} - -// MARK: - Conformance: Identifiable - -extension TabGroup: Identifiable { - var id: Self { self } -} diff --git a/Example/.gitignore b/Example/.gitignore new file mode 100644 index 000000000..0023a5340 --- /dev/null +++ b/Example/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/Example/Package.swift b/Example/Package.swift new file mode 100644 index 000000000..f9146be2b --- /dev/null +++ b/Example/Package.swift @@ -0,0 +1,32 @@ +// swift-tools-version: 6.0 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "MarkdownViewExample", + platforms: [ + .macOS(.v15), + .iOS(.v16), + .tvOS(.v18), + .watchOS(.v11), + .visionOS(.v2), + ], + products: [ + .library( + name: "MarkdownViewExample", + targets: ["MarkdownViewExample"] + ), + ], + dependencies: [ + .package(path: "../"), + ], + targets: [ + .target( + name: "MarkdownViewExample", + dependencies: [ + "MarkdownView", + ] + ), + ] +) diff --git a/Example/Sources/Example/BlockDirective.swift b/Example/Sources/Example/BlockDirective.swift new file mode 100644 index 000000000..ea344658e --- /dev/null +++ b/Example/Sources/Example/BlockDirective.swift @@ -0,0 +1,29 @@ +import MarkdownView +import SwiftUI + +private struct NoteBlockDirectiveRenderer: BlockDirectiveRenderer { + func makeBody(configuration: Configuration) -> some View { + Text(configuration.wrappedString) + .padding(20) + .background( + Color.yellow.opacity(0.25), + in: RoundedRectangle(cornerRadius: 12) + ) + } +} + +private extension BlockDirectiveRenderer where Self == NoteBlockDirectiveRenderer { + static var previewNoteRenderer: NoteBlockDirectiveRenderer { .init() } +} + +#Preview(traits: .markdownViewExample) { + let markdownText = #""" + @note { + This is a note directive block. Use custom block directive renderers to style directive content. + } + """# + + MarkdownView(markdownText) + .blockDirectiveRenderer(.previewNoteRenderer, for: "note") + .frame(width: 500) +} diff --git a/Example/Sources/Example/Customization.swift b/Example/Sources/Example/Customization.swift new file mode 100644 index 000000000..beb19b5f9 --- /dev/null +++ b/Example/Sources/Example/Customization.swift @@ -0,0 +1,30 @@ +import MarkdownView +import SwiftUI + +#Preview(traits: .markdownViewExample) { + let blockQuoteTint = Color.orange + let inlineCodeTint = Color.indigo + let markdownText = """ + # Getting Started with **SwiftUI** + + ## SwiftUI Basics + + ### Why Choose SwiftUI? + SwiftUI is **Apple's declarative framework** for building user interfaces across Apple platforms. + + #### Key Advantages + - **Declarative syntax** with concise view code. + - **Cross-platform development** for iOS, macOS, watchOS, and tvOS. + + > SwiftUI takes a lot of the complexity out of UI development. + + Use `tint` and heading styles to customize rendering. + """ + + MarkdownView(markdownText) + .tint(blockQuoteTint, for: .blockQuote) + .tint(inlineCodeTint, for: .inlineCodeBlock) + .headingStyle(.secondary, for: .h2) + .headingStyle(.tertiary, for: .h3) + .headingStyle(.tertiary, for: .h4) +} diff --git a/Example/Sources/Example/Helpers/PreviewModifier.swift b/Example/Sources/Example/Helpers/PreviewModifier.swift new file mode 100644 index 000000000..4ddde4371 --- /dev/null +++ b/Example/Sources/Example/Helpers/PreviewModifier.swift @@ -0,0 +1,27 @@ +// +// File.swift +// MarkdownViewExample +// +// Created by Yanan Li on 2026/3/10. +// + +import DeveloperToolsSupport +import SwiftUI + +struct MarkdownViewPreviewModifier: PreviewModifier { + typealias Context = Void + + func body(content: Content, context: Context) -> some View { + ScrollView { + content + .scenePadding() + } + } +} + +@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) +extension PreviewTrait where T == Preview.ViewTraits { + static var markdownViewExample: Self { + .modifier(MarkdownViewPreviewModifier()) + } +} diff --git a/Example/Sources/Example/Image.swift b/Example/Sources/Example/Image.swift new file mode 100644 index 000000000..cb1f73e54 --- /dev/null +++ b/Example/Sources/Example/Image.swift @@ -0,0 +1,16 @@ +import MarkdownView +import SwiftUI + +#Preview(traits: .markdownViewExample) { + let markdownText = """ + ### SVG Content + ![Swift Badge](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FLiYanan2004%2FMarkdownView%2Fbadge%3Ftype%3Dswift-versions) + ![Platform Badge](https://camo.githubusercontent.com/bf56cba1dd003eb35f7a5bbe930df25b30cec92d78f06d5c0e4cae285865ccb6/68747470733a2f2f696d672e736869656c64732e696f2f656e64706f696e743f75726c3d687474707325334125324625324673776966747061636b616765696e6465782e636f6d2532466170692532467061636b616765732532464c6959616e616e323030342532464d61726b646f776e56696577253246626164676525334674797065253344706c6174666f726d73) + + ### Web Images + ![](https://avatars.githubusercontent.com/u/37542129?s=400&u=ad6b55151a424db26e94b3b9ba3c57e69f62b56b&v=4) + """ + + MarkdownView(markdownText) + .frame(width: 320) +} diff --git a/Example/Sources/Example/Interact.swift b/Example/Sources/Example/Interact.swift new file mode 100644 index 000000000..be7e953e2 --- /dev/null +++ b/Example/Sources/Example/Interact.swift @@ -0,0 +1,18 @@ +import MarkdownView +import SwiftUI + +#Preview(traits: .markdownViewExample) { + let markdownText = """ + # Interaction Example + + This preview enables text selection and link interaction. + + Visit [Swift.org](https://swift.org) for Swift language updates. + + Inline math can also be enabled: $E = mc^2$. + """ + + MarkdownView(markdownText) + .markdownTextSelection(.enabled) + .markdownMathRenderingEnabled() +} diff --git a/Example/Sources/Example/List.swift b/Example/Sources/Example/List.swift new file mode 100644 index 000000000..265a7ffca --- /dev/null +++ b/Example/Sources/Example/List.swift @@ -0,0 +1,21 @@ +import MarkdownView +import SwiftUI + +#Preview(traits: .markdownViewExample) { + let markdownText = """ + - Solar System Exploration + - Planetary Missions + - Mars Rover Program + - Venus Atmospheric Studies + - Jupiter Moon Probes + - Asteroid Sampling + - Near-Earth Objects + - OSIRIS-REx Mission + - Hayabusa2 Project + - Main Belt Asteroids + - Dawn Mission to Ceres + - Psyche Metal World Study + """ + + MarkdownView(markdownText) +} diff --git a/Example/Sources/Example/Math.swift b/Example/Sources/Example/Math.swift new file mode 100644 index 000000000..5c8e5892b --- /dev/null +++ b/Example/Sources/Example/Math.swift @@ -0,0 +1,41 @@ +import MarkdownView +import SwiftUI + +#Preview(traits: .markdownViewExample) { + let markdownText = #""" + Einstein's equation $E = mc^2$ relates energy and mass. + + --- + + The Pythagorean theorem states that \(a^2 + b^2 = c^2\). + + --- + + The Gaussian integral evaluates to: + + \[ + \int_{-\infty}^{\infty} e^{-x^2} dx = \sqrt{\pi} + \] + + --- + + A Taylor series expansion around \(a\) is: + + \begin{equation} + f(x) = \sum_{n=0}^{\infty} \frac{f^{(n)}(a)}{n!}(x - a)^n + \end{equation} + + --- + + The quadratic formula is: + + \begin{equation*} + x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a} + \end{equation*} + """# + + MarkdownView(markdownText) + .markdownMathRenderingEnabled() + .lineSpacing(12) + .frame(width: 500) +} diff --git a/Example/Sources/Example/Table.swift b/Example/Sources/Example/Table.swift new file mode 100644 index 000000000..8e8855e9f --- /dev/null +++ b/Example/Sources/Example/Table.swift @@ -0,0 +1,18 @@ +import MarkdownView +import SwiftUI + +#Preview(traits: .markdownViewExample) { + let markdownText = """ + | Programming Language | Year Created | Creator | Popular Frameworks/Libraries | + |:--------------------:|:------------:|-------------------|-------------------------------| + | Python | 1991 | Guido van Rossum | Django, Flask, TensorFlow | + | JavaScript | 1995 | Brendan Eich | React, Angular, Vue.js | + | Java | 1995 | James Gosling | Spring, Hibernate, Android | + | Swift | 2014 | Apple Inc. | SwiftUI, Vapor | + | C# | 2000 | Microsoft | .NET, Unity | + | Ruby | 1995 | Yukihiro Matsumoto| Ruby on Rails, Sinatra | + | Go | 2009 | Griesemer, Pike, Thompson | Gin, Echo | + """ + + MarkdownView(markdownText) +} diff --git a/Example/Sources/Example/Text.swift b/Example/Sources/Example/Text.swift new file mode 100644 index 000000000..db189cce0 --- /dev/null +++ b/Example/Sources/Example/Text.swift @@ -0,0 +1,17 @@ +import MarkdownView +import SwiftUI + +#Preview(traits: .markdownViewExample) { + let markdownText = """ + # MarkdownView + ## Heading 2 + ### Heading 3 + #### Heading 4 + + __MarkdownView__ is built with `swift-markdown`. + + It supports _SVG rendering_, which is great for badge-style assets. + """ + + MarkdownView(markdownText) +} diff --git a/Sources/MarkdownView/Renderers/Internal/MarkdownAttachmentReplacementPolicy.swift b/Sources/MarkdownView/Renderers/Internal/MarkdownAttachmentReplacementPolicy.swift new file mode 100644 index 000000000..99dcc7a8a --- /dev/null +++ b/Sources/MarkdownView/Renderers/Internal/MarkdownAttachmentReplacementPolicy.swift @@ -0,0 +1,72 @@ +// +// MarkdownAttachmentReplacementPolicy.swift +// MarkdownView +// +// Created by Codex on 2026/2/6. +// + +import Foundation +import Markdown + +@MainActor +struct MarkdownAttachmentReplacementPolicy { + func replacementForBlockDirective(_ blockDirective: BlockDirective) -> AttributedString? { + let wrappedString = blockDirective + .children + .compactMap { $0.format() } + .joined(separator: "\n") + guard !wrappedString.isEmpty else { + return nil + } + return AttributedString(wrappedString) + } + + func replacementForBlockQuote( + childAttributedStrings: [AttributedString] + ) -> AttributedString? { + guard !childAttributedStrings.isEmpty else { + return nil + } + + return childAttributedStrings.reduce(into: AttributedString()) { attributedString, row in + attributedString += row + attributedString += "\n" + } + } + + func replacementForImage(_ image: Markdown.Image) -> AttributedString? { + let plainText = image.plainText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !plainText.isEmpty else { + return nil + } + return AttributedString(plainText) + } + + func replacementForCodeBlock(_ codeBlock: CodeBlock) -> AttributedString? { + guard !codeBlock.code.isEmpty else { + return nil + } + return AttributedString(codeBlock.code) + } + + func replacementForHTMLBlock(_ htmlBlock: HTMLBlock) -> AttributedString? { + guard !htmlBlock.rawHTML.isEmpty else { + return nil + } + return AttributedString( + htmlBlock.rawHTML, + attributes: AttributeContainer().isHTML(true) + ) + } + + func replacementForTableRows(_ rows: [AttributedString]) -> AttributedString? { + guard !rows.isEmpty else { + return nil + } + + return rows.reduce(into: AttributedString()) { attributedString, row in + attributedString += row + attributedString += "\n" + } + } +} diff --git a/Sources/MarkdownView/Renderers/Internal/MarkdownRenderPipeline.swift b/Sources/MarkdownView/Renderers/Internal/MarkdownRenderPipeline.swift new file mode 100644 index 000000000..9fa646f72 --- /dev/null +++ b/Sources/MarkdownView/Renderers/Internal/MarkdownRenderPipeline.swift @@ -0,0 +1,211 @@ +// +// MarkdownRenderPipeline.swift +// MarkdownView +// +// Created by Codex on 2026/2/6. +// + +import SwiftUI +import Markdown +#if canImport(RichText) +import RichText +#endif + +@MainActor +struct MarkdownRenderPipeline { + var configuration: MarkdownRendererConfiguration + + func makeViewBody(content: MarkdownContent) -> AnyView { + let preparedInput = prepareInput(for: content) + let semanticVisitor = MarkdownSemanticVisitor(configuration: preparedInput.configuration) + let semanticDocument = semanticVisitor.makeDocument(for: preparedInput.document) + return makeViewBody( + for: semanticDocument, + configuration: preparedInput.configuration + ) + } + + func makeViewBody(for markup: any Markup) -> AnyView { + let semanticVisitor = MarkdownSemanticVisitor(configuration: configuration) + let semanticDocument = semanticVisitor.makeDocument(for: markup) + return makeViewBody( + for: semanticDocument, + configuration: configuration + ) + } + + func makeViewBody(descendingInto markup: any Markup) -> AnyView { + let semanticVisitor = MarkdownSemanticVisitor(configuration: configuration) + let semanticDocument = MarkdownSemanticDocument( + rootNodes: semanticVisitor.makeNodes(descendingInto: markup) + ) + return makeViewBody( + for: semanticDocument, + configuration: configuration + ) + } + + #if canImport(RichText) + @available(iOS 26, macOS 26, *) + func makeTextBody(content: MarkdownContent) -> AnyView { + let preparedInput = prepareInput(for: content) + let semanticVisitor = MarkdownSemanticVisitor(configuration: preparedInput.configuration) + let semanticDocument = semanticVisitor.makeDocument(for: preparedInput.document) + + let subtreeRenderer = MarkdownSubtreeRenderer.pipelineBacked + let textContentEmitter = MarkdownTextContentEmitter( + configuration: preparedInput.configuration, + subtreeRenderer: subtreeRenderer + ) + let textContent = textContentEmitter.makeTextContent(for: semanticDocument) + return AnyView( + TextView { + textContent + } + .environment(\.markdownRendererConfiguration, preparedInput.configuration) + .environment(\.markdownSubtreeRenderer, subtreeRenderer) + ) + } + #endif +} + +private extension MarkdownRenderPipeline { + func makeViewBody( + for semanticDocument: MarkdownSemanticDocument, + configuration: MarkdownRendererConfiguration + ) -> AnyView { + let subtreeRenderer = MarkdownSubtreeRenderer.pipelineBacked + let viewEmitter = MarkdownViewEmitter( + configuration: configuration, + subtreeRenderer: subtreeRenderer + ) + let body = viewEmitter.makeBody(for: semanticDocument) + return AnyView( + body + .environment(\.markdownRendererConfiguration, configuration) + .environment(\.markdownSubtreeRenderer, subtreeRenderer) + ) + } + + func prepareInput(for content: MarkdownContent) -> PreparedRenderingInput { + let preprocessedContent = preprocessMathIfNeeded(content: content) + let parseOptions = parseOptions(for: preprocessedContent.configuration) + let document = preprocessedContent.content.document(options: parseOptions) + return PreparedRenderingInput( + document: document, + configuration: preprocessedContent.configuration + ) + } + + func parseOptions( + for configuration: MarkdownRendererConfiguration + ) -> ParseOptions { + var parseOptions = ParseOptions() + if !configuration.allowedBlockDirectiveRenderers.isEmpty { + parseOptions.insert(.parseBlockDirectives) + } + return parseOptions + } + + func preprocessMathIfNeeded( + content: MarkdownContent + ) -> (content: MarkdownContent, configuration: MarkdownRendererConfiguration) { + guard configuration.rendersMath else { + return (content, configuration) + } + + var configuration = configuration + var rawMarkdown = (try? content.markdown) ?? "" + + var parsingRangesExtractor = ParsingRangesExtractor() + parsingRangesExtractor.visit(content.document()) + + for range in parsingRangesExtractor.parsableRanges(in: rawMarkdown).reversed() { + let segmentParser = MathParser(text: rawMarkdown[range]) + for representation in segmentParser.mathRepresentations.reversed() where !representation.kind.inline { + let identifier = configuration.math.appendDisplayMath(rawMarkdown[representation.range]) + rawMarkdown.replaceSubrange( + representation.range, + with: "@math(uuid:\(identifier))" + ) + } + } + + return ( + MarkdownContent(.plainText(rawMarkdown)), + configuration + ) + } +} + +private extension MarkdownRenderPipeline { + struct PreparedRenderingInput { + var document: Document + var configuration: MarkdownRendererConfiguration + } + + struct ParsingRangesExtractor: MarkupWalker { + private var excludedRanges: [Range] = [] + + func parsableRanges(in text: String) -> [Range] { + var parsableRanges: [Range] = [] + let excludedRanges = self.excludedRanges.map { + ($0.lowerBound.index(in: text)..<$0.upperBound.index(in: text)) + } + + let fullRange = text.startIndex.. String.Index { + var index = string.startIndex + var currentLine = 1 + while currentLine < line && index < string.endIndex { + if string[index] == "\n" { + currentLine += 1 + } + index = string.index(after: index) + } + + guard let utf8LineStart = index.samePosition(in: string.utf8) else { + return string.endIndex + } + + let byteOffset = column - 1 + let targetUTF8Index = string.utf8.index( + utf8LineStart, + offsetBy: byteOffset, + limitedBy: string.utf8.endIndex + ) ?? string.utf8.endIndex + + return targetUTF8Index.samePosition(in: string) ?? string.endIndex + } +} diff --git a/Sources/MarkdownView/Renderers/Internal/MarkdownSemanticDocument.swift b/Sources/MarkdownView/Renderers/Internal/MarkdownSemanticDocument.swift new file mode 100644 index 000000000..d3400f2ac --- /dev/null +++ b/Sources/MarkdownView/Renderers/Internal/MarkdownSemanticDocument.swift @@ -0,0 +1,86 @@ +// +// MarkdownSemanticDocument.swift +// MarkdownView +// +// Created by Codex on 2026/2/6. +// + +import Foundation +import SwiftUI +import Markdown + +@MainActor +struct MarkdownSemanticDocument { + var rootNodes: [MarkdownSemanticNode] + + init(rootNodes: [MarkdownSemanticNode]) { + self.rootNodes = rootNodes + } +} + +@MainActor +indirect enum MarkdownSemanticNode { + case document(children: [MarkdownSemanticNode]) + case container(children: [MarkdownSemanticNode]) + case paragraph(children: [MarkdownSemanticNode]) + + case text(String) + case softBreak + case lineBreak + case thematicBreak(sourceRange: Range?) + + case inlineCode(String) + case inlineHTML(String) + case emphasis(children: [MarkdownSemanticNode]) + case strong(children: [MarkdownSemanticNode]) + case strikethrough(children: [MarkdownSemanticNode]) + case link( + destination: String?, + plainText: String, + sourceRange: Range?, + children: [MarkdownSemanticNode] + ) + + case heading(Heading, children: [MarkdownSemanticNode]) + case blockDirective(BlockDirective) + case blockQuote(BlockQuote, children: [MarkdownSemanticNode]) + case image(Markdown.Image) + case codeBlock(CodeBlock) + case htmlBlock(HTMLBlock) + + case list(MarkdownSemanticList) + case listItem(MarkdownSemanticListItem) + + case table(Markdown.Table) + case tableHead(Markdown.Table.Head) + case tableBody(Markdown.Table.Body) + case tableRow(Markdown.Table.Row) + case tableCell(Markdown.Table.Cell, children: [MarkdownSemanticNode]) +} + +@MainActor +struct MarkdownSemanticList { + enum Kind { + case ordered + case unordered + } + + var kind: Kind + var depth: Int + var items: [MarkdownSemanticListItem] +} + +@MainActor +struct MarkdownSemanticListItem { + var source: ListItem + var marker: MarkdownSemanticListMarker? + var indentation: CGFloat + var leadingChildren: [MarkdownSemanticNode] + var trailingBlocks: [MarkdownSemanticNode] +} + +@MainActor +enum MarkdownSemanticListMarker { + case checkbox(Checkbox) + case text(value: String, monospaced: Bool) +} diff --git a/Sources/MarkdownView/Renderers/Internal/MarkdownSemanticVisitor.swift b/Sources/MarkdownView/Renderers/Internal/MarkdownSemanticVisitor.swift new file mode 100644 index 000000000..b761de64f --- /dev/null +++ b/Sources/MarkdownView/Renderers/Internal/MarkdownSemanticVisitor.swift @@ -0,0 +1,222 @@ +// +// MarkdownSemanticVisitor.swift +// MarkdownView +// +// Created by Codex on 2026/2/6. +// + +import Foundation +import SwiftUI +import Markdown + +@MainActor +@preconcurrency +struct MarkdownSemanticVisitor: @preconcurrency MarkupVisitor { + var configuration: MarkdownRendererConfiguration + + init(configuration: MarkdownRendererConfiguration) { + self.configuration = configuration + } + + func makeDocument(for markup: any Markup) -> MarkdownSemanticDocument { + let semanticNode = makeNode(for: markup) + switch semanticNode { + case .document(let children): + return MarkdownSemanticDocument(rootNodes: children) + default: + return MarkdownSemanticDocument(rootNodes: [semanticNode]) + } + } + + func makeNodes(descendingInto markup: any Markup) -> [MarkdownSemanticNode] { + makeNodes(for: Array(markup.children)) + } + + func visitDocument(_ document: Document) -> MarkdownSemanticNode { + .document(children: makeNodes(for: Array(document.children))) + } + + func defaultVisit(_ markup: Markdown.Markup) -> MarkdownSemanticNode { + .container(children: makeNodes(for: Array(markup.children))) + } + + func visitParagraph(_ paragraph: Paragraph) -> MarkdownSemanticNode { + .paragraph(children: makeNodes(for: Array(paragraph.children))) + } + + func visitText(_ text: Markdown.Text) -> MarkdownSemanticNode { + .text(text.plainText) + } + + func visitSoftBreak(_ softBreak: SoftBreak) -> MarkdownSemanticNode { + .softBreak + } + + func visitLineBreak(_ lineBreak: Markdown.LineBreak) -> MarkdownSemanticNode { + .lineBreak + } + + func visitThematicBreak(_ thematicBreak: ThematicBreak) -> MarkdownSemanticNode { + .thematicBreak(sourceRange: thematicBreak.range) + } + + func visitInlineCode(_ inlineCode: InlineCode) -> MarkdownSemanticNode { + .inlineCode(inlineCode.code) + } + + func visitInlineHTML(_ inlineHTML: InlineHTML) -> MarkdownSemanticNode { + .inlineHTML(inlineHTML.rawHTML) + } + + func visitEmphasis(_ emphasis: Markdown.Emphasis) -> MarkdownSemanticNode { + .emphasis(children: makeNodes(for: Array(emphasis.children))) + } + + func visitStrong(_ strong: Strong) -> MarkdownSemanticNode { + .strong(children: makeNodes(for: Array(strong.children))) + } + + func visitStrikethrough(_ strikethrough: Strikethrough) -> MarkdownSemanticNode { + .strikethrough(children: makeNodes(for: Array(strikethrough.children))) + } + + mutating func visitLink(_ link: Markdown.Link) -> MarkdownSemanticNode { + .link( + destination: link.destination, + plainText: link.plainText, + sourceRange: link.range, + children: makeNodes(for: Array(link.children)) + ) + } + + func visitHeading(_ heading: Heading) -> MarkdownSemanticNode { + .heading(heading, children: makeNodes(for: Array(heading.children))) + } + + func visitBlockDirective(_ blockDirective: BlockDirective) -> MarkdownSemanticNode { + .blockDirective(blockDirective) + } + + func visitBlockQuote(_ blockQuote: BlockQuote) -> MarkdownSemanticNode { + .blockQuote(blockQuote, children: makeNodes(for: Array(blockQuote.blockChildren))) + } + + func visitImage(_ image: Markdown.Image) -> MarkdownSemanticNode { + .image(image) + } + + func visitCodeBlock(_ codeBlock: CodeBlock) -> MarkdownSemanticNode { + .codeBlock(codeBlock) + } + + func visitHTMLBlock(_ html: HTMLBlock) -> MarkdownSemanticNode { + .htmlBlock(html) + } + + func visitOrderedList(_ orderedList: OrderedList) -> MarkdownSemanticNode { + .list( + MarkdownSemanticList( + kind: .ordered, + depth: orderedList.listDepth, + items: makeSemanticListItems(from: orderedList) + ) + ) + } + + func visitUnorderedList(_ unorderedList: UnorderedList) -> MarkdownSemanticNode { + .list( + MarkdownSemanticList( + kind: .unordered, + depth: unorderedList.listDepth, + items: makeSemanticListItems(from: unorderedList) + ) + ) + } + + func visitListItem(_ listItem: ListItem) -> MarkdownSemanticNode { + .listItem(makeSemanticListItem(from: listItem)) + } + + func visitTable(_ table: Markdown.Table) -> MarkdownSemanticNode { + .table(table) + } + + func visitTableHead(_ head: Markdown.Table.Head) -> MarkdownSemanticNode { + .tableHead(head) + } + + func visitTableBody(_ body: Markdown.Table.Body) -> MarkdownSemanticNode { + .tableBody(body) + } + + func visitTableRow(_ row: Markdown.Table.Row) -> MarkdownSemanticNode { + .tableRow(row) + } + + func visitTableCell(_ cell: Markdown.Table.Cell) -> MarkdownSemanticNode { + .tableCell(cell, children: makeNodes(for: Array(cell.children))) + } +} + +private extension MarkdownSemanticVisitor { + func makeNode(for markup: any Markup) -> MarkdownSemanticNode { + var semanticVisitor = self + return semanticVisitor.visit(markup) + } + + func makeNodes(for markups: [Markup]) -> [MarkdownSemanticNode] { + markups.map(makeNode(for:)) + } + + func makeSemanticListItems( + from listItemContainer: some ListItemContainer + ) -> [MarkdownSemanticListItem] { + listItemContainer.listItems.map(makeSemanticListItem(from:)) + } + + func makeSemanticListItem(from listItem: ListItem) -> MarkdownSemanticListItem { + let depth = (listItem.parent as? ListItemContainer)?.listDepth ?? 0 + let indentation = CGFloat(depth) * configuration.list.leadingIndentation + + let marker: MarkdownSemanticListMarker? + if let checkbox = listItem.checkbox { + marker = .checkbox(checkbox) + } else if let unorderedList = listItem.parent as? UnorderedList { + let unorderedMarker = configuration.list.unorderedListMarker + marker = .text( + value: unorderedMarker.marker(listDepth: unorderedList.listDepth), + monospaced: unorderedMarker.monospaced + ) + } else if let orderedList = listItem.parent as? OrderedList { + let orderedMarker = configuration.list.orderedListMarker + marker = .text( + value: orderedMarker.marker( + at: listItem.indexInParent, + listDepth: orderedList.listDepth + ), + monospaced: orderedMarker.monospaced + ) + } else { + marker = nil + } + + let children = Array(listItem.children) + let leadingChildren: [MarkdownSemanticNode] + let trailingBlocks: [MarkdownSemanticNode] + if let firstChild = children.first { + leadingChildren = makeNodes(for: Array(firstChild.children)) + trailingBlocks = children.dropFirst().map(makeNode(for:)) + } else { + leadingChildren = [] + trailingBlocks = [] + } + + return MarkdownSemanticListItem( + source: listItem, + marker: marker, + indentation: indentation, + leadingChildren: leadingChildren, + trailingBlocks: trailingBlocks + ) + } +} diff --git a/Sources/MarkdownView/Renderers/Internal/MarkdownSubtreeRenderer.swift b/Sources/MarkdownView/Renderers/Internal/MarkdownSubtreeRenderer.swift new file mode 100644 index 000000000..404e6c9f1 --- /dev/null +++ b/Sources/MarkdownView/Renderers/Internal/MarkdownSubtreeRenderer.swift @@ -0,0 +1,62 @@ +// +// MarkdownSubtreeRenderer.swift +// MarkdownView +// +// Created by Codex on 2026/2/6. +// + +import SwiftUI +import Markdown + +struct MarkdownSubtreeRenderer { + private let renderMarkup: @MainActor (any Markup, MarkdownRendererConfiguration) -> AnyView + private let renderChildren: @MainActor (any Markup, MarkdownRendererConfiguration) -> AnyView + + init( + renderMarkup: @escaping @MainActor (any Markup, MarkdownRendererConfiguration) -> AnyView, + renderChildren: @escaping @MainActor (any Markup, MarkdownRendererConfiguration) -> AnyView + ) { + self.renderMarkup = renderMarkup + self.renderChildren = renderChildren + } + + @MainActor + @ViewBuilder + func makeBody( + for markup: any Markup, + configuration: MarkdownRendererConfiguration + ) -> some View { + renderMarkup(markup, configuration) + } + + @MainActor + @ViewBuilder + func makeBody( + descendingInto markup: any Markup, + configuration: MarkdownRendererConfiguration + ) -> some View { + renderChildren(markup, configuration) + } + + static let pipelineBacked: MarkdownSubtreeRenderer = MarkdownSubtreeRenderer( + renderMarkup: { markup, configuration in + MarkdownRenderPipeline(configuration: configuration) + .makeViewBody(for: markup) + }, + renderChildren: { markup, configuration in + MarkdownRenderPipeline(configuration: configuration) + .makeViewBody(descendingInto: markup) + } + ) +} + +struct MarkdownSubtreeRendererKey: EnvironmentKey { + static let defaultValue: MarkdownSubtreeRenderer = .pipelineBacked +} + +extension EnvironmentValues { + var markdownSubtreeRenderer: MarkdownSubtreeRenderer { + get { self[MarkdownSubtreeRendererKey.self] } + set { self[MarkdownSubtreeRendererKey.self] = newValue } + } +} diff --git a/Sources/MarkdownView/Renderers/Internal/MarkdownTextContentEmitter.swift b/Sources/MarkdownView/Renderers/Internal/MarkdownTextContentEmitter.swift new file mode 100644 index 000000000..c33d87769 --- /dev/null +++ b/Sources/MarkdownView/Renderers/Internal/MarkdownTextContentEmitter.swift @@ -0,0 +1,460 @@ +// +// MarkdownTextContentEmitter.swift +// MarkdownView +// +// Created by Codex on 2026/2/6. +// + +#if canImport(RichText) +import SwiftUI +import Markdown +import RichText + +@available(iOS 26, macOS 26, *) +@MainActor +struct MarkdownTextContentEmitter { + var configuration: MarkdownRendererConfiguration + var subtreeRenderer: MarkdownSubtreeRenderer + var attachmentReplacementPolicy: MarkdownAttachmentReplacementPolicy + + init( + configuration: MarkdownRendererConfiguration, + subtreeRenderer: MarkdownSubtreeRenderer + ) { + self.configuration = configuration + self.subtreeRenderer = subtreeRenderer + self.attachmentReplacementPolicy = MarkdownAttachmentReplacementPolicy() + } + + func makeTextContent(for semanticDocument: MarkdownSemanticDocument) -> TextContent { + combine(semanticDocument.rootNodes.map(render)) + } + + func makeTextContent(for semanticNode: MarkdownSemanticNode) -> TextContent { + render(semanticNode) + } +} + +@available(iOS 26, macOS 26, *) +private extension MarkdownTextContentEmitter { + func render(_ semanticNode: MarkdownSemanticNode) -> TextContent { + switch semanticNode { + case .document(let children): + return combine(children.map(render)) + + case .container(let children): + return combine(children.map(render)) + + case .paragraph(let children): + return TextContent { + combine(children.map(render)) + LineBreak() + } + + case .text(let plainText): + guard configuration.rendersMath else { + return TextContent(.string(plainText)) + } + + #if canImport(LaTeXSwiftUI) + let mathParser = MathParser(text: plainText) + var textContent = TextContent([]) + var processingIndex = plainText.startIndex + + for representation in mathParser.mathRepresentations { + let range = representation.range + if processingIndex < range.lowerBound { + textContent += TextContent( + .string( + String(plainText[processingIndex.. TextContent { + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.headIndent = semanticListItem.indentation + paragraphStyle.firstLineHeadIndent = semanticListItem.indentation + + var markerAttributes = AttributeContainer([ + .paragraphStyle: paragraphStyle as NSParagraphStyle + ]) + + let markerString: String? + switch semanticListItem.marker { + case .checkbox(let checkbox): + markerString = switch checkbox { + case .checked: "☑︎" + case .unchecked: "☐" + } + case .text(let value, let monospaced): + markerString = value + markerAttributes = markerAttributes.font( + (configuration.fonts[.body] ?? .body) + .monospaced(monospaced) + ) + case .none: + markerString = nil + } + + let leadingContent = combine(semanticListItem.leadingChildren.map(render)) + let trailingBlocks = semanticListItem.trailingBlocks.map(render) + + return TextContent { + if let markerString { + AttributedString(markerString, attributes: markerAttributes) + } + Space() + if !leadingContent.fragments.isEmpty { + leadingContent + } + LineBreak() + + for trailingBlock in trailingBlocks where !trailingBlock.fragments.isEmpty { + trailingBlock + } + } + } + + func combine(_ contents: [TextContent]) -> TextContent { + var mergedContent = TextContent([]) + for content in contents where !content.fragments.isEmpty { + mergedContent += content + } + return mergedContent + } + + func attributedStringIgnoringAttachments(for markup: any Markup) -> AttributedString { + let semanticVisitor = MarkdownSemanticVisitor(configuration: configuration) + let semanticDocument = semanticVisitor.makeDocument(for: markup) + return makeTextContent(for: semanticDocument).attributedStringIgnoringViews + } + + func inlineViewContent( + sourceRange: Range?, + replacement: AttributedString?, + appendsLineBreak: Bool = false, + @ViewBuilder content: @escaping () -> some View + ) -> TextContent { + let view = content() + .environment(\.markdownRendererConfiguration, configuration) + .environment(\.markdownSubtreeRenderer, subtreeRenderer) + let attachment = InlineHostingAttachment( + view, + id: sourceRange, + replacement: replacement + ) + + return TextContent { + TextContent(.view(attachment)) + if appendsLineBreak { + LineBreak() + } + } + } +} + +@available(iOS 26, macOS 26, *) +fileprivate extension TextContent { + var attributedStringIgnoringViews: AttributedString { + fragments.reduce(into: AttributedString()) { attributedString, fragment in + switch fragment { + case .string(let string): + attributedString += AttributedString(string) + case .attributedString(let value): + attributedString += value + case .view: + break + } + } + } +} + +#endif diff --git a/Sources/MarkdownView/Renderers/Internal/MarkdownViewEmitter.swift b/Sources/MarkdownView/Renderers/Internal/MarkdownViewEmitter.swift new file mode 100644 index 000000000..aff75f9f8 --- /dev/null +++ b/Sources/MarkdownView/Renderers/Internal/MarkdownViewEmitter.swift @@ -0,0 +1,300 @@ +// +// MarkdownViewEmitter.swift +// MarkdownView +// +// Created by Codex on 2026/2/6. +// + +import SwiftUI +import Markdown + +@MainActor +struct MarkdownViewEmitter { + var configuration: MarkdownRendererConfiguration + var subtreeRenderer: MarkdownSubtreeRenderer + + init( + configuration: MarkdownRendererConfiguration, + subtreeRenderer: MarkdownSubtreeRenderer + ) { + self.configuration = configuration + self.subtreeRenderer = subtreeRenderer + } + + func makeBody(for semanticDocument: MarkdownSemanticDocument) -> MarkdownNodeView { + renderChildren( + semanticDocument.rootNodes, + layoutPolicy: .linebreak + ) + } + + func makeNodeView(for semanticNode: MarkdownSemanticNode) -> MarkdownNodeView { + render(semanticNode) + } +} + +private extension MarkdownViewEmitter { + func render(_ semanticNode: MarkdownSemanticNode) -> MarkdownNodeView { + switch semanticNode { + case .document(let children): + return renderChildren(children, layoutPolicy: .linebreak) + + case .container(let children): + return renderChildren(children) + + case .paragraph(let children): + return renderChildren(children) + + case .text(let plainText): + if configuration.rendersMath { + return InlineMathOrText(text: plainText) + .makeBody(configuration: configuration) + } + return MarkdownNodeView(plainText) + + case .blockDirective(let blockDirective): + return MarkdownNodeView { + MarkdownBlockDirective(blockDirective: blockDirective) + } + + case .blockQuote(let blockQuote, _): + return MarkdownNodeView { + MarkdownBlockQuote(blockQuote: blockQuote) + } + + case .softBreak: + return MarkdownNodeView(" ") + + case .thematicBreak: + return MarkdownNodeView { + Divider() + } + + case .lineBreak: + return MarkdownNodeView("\n") + + case .inlineCode(let code): + let tintColor = configuration.preferredTintColors[.inlineCodeBlock] ?? .accentColor + var attributedString = AttributedString(stringLiteral: code) + attributedString.foregroundColor = tintColor + attributedString.backgroundColor = tintColor.opacity(0.1) + return MarkdownNodeView(attributedString) + + case .inlineHTML(let rawHTML): + return MarkdownNodeView( + AttributedString( + rawHTML, + attributes: AttributeContainer().isHTML(true) + ) + ) + + case .image(let image): + return MarkdownNodeView { + MarkdownImage(image: image) + } + + case .codeBlock(let codeBlock): + return MarkdownNodeView { + MarkdownStyledCodeBlock( + configuration: CodeBlockStyleConfiguration( + language: codeBlock.language, + code: codeBlock.code + ) + ) + } + + case .htmlBlock(let htmlBlock): + return MarkdownNodeView { + HTMLBlockView(html: htmlBlock.rawHTML) + } + + case .list(let semanticList): + return renderList(semanticList) + + case .listItem(let semanticListItem): + return renderListItem(semanticListItem) + + case .table(let table): + return MarkdownNodeView { + MarkdownTable(table: table) + } + + case .tableHead(let tableHead): + return MarkdownNodeView { + MarkdownTableRow( + rowIndex: 0, + cells: Array(tableHead.cells) + ) + } + + case .tableBody(let tableBody): + return MarkdownNodeView { + MarkdownTableBody(tableBody: tableBody) + } + + case .tableRow(let tableRow): + return MarkdownNodeView { + MarkdownTableRow( + rowIndex: tableRow.indexInParent + 1, + cells: Array(tableRow.cells) + ) + } + + case .tableCell(let tableCell, let children): + return renderChildren( + children, + alignment: tableCell.horizontalAlignment + ) + + case .heading(let heading, _): + return MarkdownNodeView { + HeadingText(heading: heading) + } + + case .emphasis(let children): + return MarkdownNodeView( + mergeInlinePresentationIntent( + .emphasized, + children: children + ) + ) + + case .strong(let children): + return MarkdownNodeView( + mergeInlinePresentationIntent( + .stronglyEmphasized, + children: children + ) + ) + + case .strikethrough(let children): + return MarkdownNodeView( + mergeInlinePresentationIntent( + .strikethrough, + children: children + ) + ) + + case .link(let destination, _, _, let children): + guard let destination, let destinationURL = URL(string: destination) else { + return renderChildren(children) + } + + let linkedContent = renderChildren(children) + let tintColor = configuration.preferredTintColors[.link] ?? .accentColor + + if let attributedString = linkedContent.asAttributedString { + return MarkdownNodeView( + attributedString.mergingAttributes( + AttributeContainer() + .link(destinationURL) + .foregroundColor(tintColor) + ) + ) + } + + return MarkdownNodeView { + Link(destination: destinationURL) { + linkedContent + } + .foregroundStyle(tintColor) + } + } + } + + func renderChildren( + _ children: [MarkdownSemanticNode], + alignment: HorizontalAlignment = .leading, + layoutPolicy: MarkdownNodeView.LayoutPolicy = .adaptive + ) -> MarkdownNodeView { + let childViews = children.map(render) + return MarkdownNodeView( + childViews, + alignment: alignment, + layoutPolicy: layoutPolicy + ) + } + + func mergeInlinePresentationIntent( + _ inlinePresentationIntent: InlinePresentationIntent, + children: [MarkdownSemanticNode] + ) -> AttributedString { + var mergedAttributedString = AttributedString() + for child in children { + guard let attributedString = render(child).asAttributedString else { + continue + } + let existingIntent = attributedString.inlinePresentationIntent ?? [] + mergedAttributedString += attributedString.mergingAttributes( + AttributeContainer() + .inlinePresentationIntent(existingIntent.union(inlinePresentationIntent)) + ) + } + return mergedAttributedString + } + + func renderList(_ semanticList: MarkdownSemanticList) -> MarkdownNodeView { + MarkdownNodeView { + VStack(alignment: .leading, spacing: configuration.componentSpacing) { + ForEach(semanticList.items.indices, id: \.self) { index in + renderListItem(semanticList.items[index]) + } + } + } + } + + func renderListItem(_ semanticListItem: MarkdownSemanticListItem) -> MarkdownNodeView { + let leadingContent = renderChildren(semanticListItem.leadingChildren) + + return MarkdownNodeView { + VStack(alignment: .leading, spacing: configuration.componentSpacing) { + HStack(alignment: .firstTextBaseline) { + if let marker = semanticListItem.marker { + MarkdownSemanticListMarkerView( + marker: marker, + bodyFont: configuration.fonts[.body] ?? .body + ) + } + + if !semanticListItem.leadingChildren.isEmpty { + leadingContent + } + } + + ForEach(semanticListItem.trailingBlocks.indices, id: \.self) { index in + render(semanticListItem.trailingBlocks[index]) + } + } + .padding(.leading, semanticListItem.indentation) + } + } +} + +private struct MarkdownSemanticListMarkerView: View { + var marker: MarkdownSemanticListMarker + var bodyFont: Font + + var body: some View { + switch marker { + case .checkbox(let checkbox): + switch checkbox { + case .checked: + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.tint) + case .unchecked: + Image(systemName: "circle") + .foregroundStyle(.secondary) + } + + case .text(let value, let monospaced): + if #available(iOS 16.4, macOS 13.3, tvOS 16.4, watchOS 9.4, *) { + Text(value) + .monospaced(monospaced) + .font(bodyFont) + } else { + Text(value) + .font(monospaced ? bodyFont.monospaced() : bodyFont) + } + } + } +} From 647161359881cab680acd0aa0b3cf728854a2098 Mon Sep 17 00:00:00 2001 From: Yanan Li <1474700628@qq.com> Date: Tue, 10 Mar 2026 15:27:09 +0800 Subject: [PATCH 27/27] Update package --- .../Math/MathFirstMarkdownViewRenderer.swift | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/Sources/MarkdownView/Renderers/Math/MathFirstMarkdownViewRenderer.swift b/Sources/MarkdownView/Renderers/Math/MathFirstMarkdownViewRenderer.swift index 8a9bf70ed..b7c8737c8 100644 --- a/Sources/MarkdownView/Renderers/Math/MathFirstMarkdownViewRenderer.swift +++ b/Sources/MarkdownView/Renderers/Math/MathFirstMarkdownViewRenderer.swift @@ -53,19 +53,10 @@ fileprivate extension MathFirstMarkdownViewRenderer { struct ParsingRangesExtractor: MarkupWalker { private var excludedRanges: [Range] = [] - var extractor = ParsingRangesExtractor() - extractor.visit(content.parse(options: ParseOptions().union(.parseBlockDirectives))) - 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 { - let mathIdentifier = configuration.math.appendDisplayMath( - rawText[math.range] - ) - rawText.replaceSubrange( - math.range, - with: "@math(uuid:\(mathIdentifier))" - ) + func parsableRanges(in text: String) -> [Range] { + var allowedRanges: [Range] = [] + let excludedRanges = self.excludedRanges.map { + ($0.lowerBound.index(in: text)..<$0.upperBound.index(in: text)) } let fullRange = text.startIndex..