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/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/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/Configurations/Font/MarkdownTextType.swift b/Sources/MarkdownView/Configurations/Font/MarkdownTextType.swift deleted file mode 100644 index ca8f75058..000000000 --- a/Sources/MarkdownView/Configurations/Font/MarkdownTextType.swift +++ /dev/null @@ -1,10 +0,0 @@ -import Foundation - -@_documentation(visibility: internal) -public enum MarkdownTextType: Equatable, CaseIterable { - case h1, h2, h3, h4, h5, h6 - case body - case codeBlock, blockQuote - case tableHeader, tableBody - case inlineMath, displayMath -} 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 90% rename from Sources/MarkdownView/Renderers/Node Representations/Block Quotes/Protocol/BlockQuoteStyle.swift rename to Sources/MarkdownView/Customizations/Block Quotes/BlockQuoteStyle.swift index f1430836d..32f8c08c9 100644 --- a/Sources/MarkdownView/Renderers/Node Representations/Block Quotes/Protocol/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/Renderers/Node Representations/Block Quotes/Styles/DefaultBlockQuoteStyle.swift b/Sources/MarkdownView/Customizations/Block Quotes/DefaultBlockQuoteStyle.swift similarity index 80% rename from Sources/MarkdownView/Renderers/Node Representations/Block Quotes/Styles/DefaultBlockQuoteStyle.swift rename to Sources/MarkdownView/Customizations/Block Quotes/DefaultBlockQuoteStyle.swift index 6ea7b825a..09e576fa6 100644 --- a/Sources/MarkdownView/Renderers/Node Representations/Block Quotes/Styles/DefaultBlockQuoteStyle.swift +++ b/Sources/MarkdownView/Customizations/Block Quotes/DefaultBlockQuoteStyle.swift @@ -21,9 +21,10 @@ 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 + let font = rendererConfiguration.fonts[.blockQuote] ?? .system(.body, design: .serif) configuration.content .padding(.vertical, 8) .frame(maxWidth: .infinity, alignment: .leading) 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 98% rename from Sources/MarkdownView/Renderers/Node Representations/Code Blocks/Styles/DefaultCodeBlockStyle.swift rename to Sources/MarkdownView/Customizations/Code Blocks/DefaultCodeBlockStyle.swift index 0312e2d4f..f429692ab 100644 --- a/Sources/MarkdownView/Renderers/Node Representations/Code Blocks/Styles/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/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/Customizations/Font/MarkdownComponent.swift b/Sources/MarkdownView/Customizations/Font/MarkdownComponent.swift new file mode 100644 index 000000000..786a49050 --- /dev/null +++ b/Sources/MarkdownView/Customizations/Font/MarkdownComponent.swift @@ -0,0 +1,18 @@ +import Foundation + +@_documentation(visibility: internal) +public enum MarkdownComponent: Hashable, Sendable, CaseIterable { + case h1 + case h2 + case h3 + case h4 + case h5 + case h6 + case body + case codeBlock + case blockQuote + case tableHeader + case tableBody + case inlineMath + case displayMath +} diff --git a/Sources/MarkdownView/Configurations/Font/MarkdownFontGroup.swift b/Sources/MarkdownView/Customizations/Font/MarkdownFontGroup.swift similarity index 94% rename from Sources/MarkdownView/Configurations/Font/MarkdownFontGroup.swift rename to Sources/MarkdownView/Customizations/Font/MarkdownFontGroup.swift index 954986b78..aeacf0d14 100644 --- a/Sources/MarkdownView/Configurations/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/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..58ef42ca4 --- /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 + +public struct AnyOrderedListMarkerProtocol: OrderedListMarkerProtocol { + private var _marker: AnyHashable + public var monospaced: Bool { + (_marker as! (any OrderedListMarkerProtocol)).monospaced + } + + public 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..5721aa8f4 --- /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 + +public struct AnyUnorderedListMarkerProtocol: UnorderedListMarkerProtocol { + private var _marker: AnyHashable + public var monospaced: Bool { + (_marker as! (any UnorderedListMarkerProtocol)).monospaced + } + + public 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/Renderers/Node Representations/Tables/Protocol/Configuration/MarkdownTableStyleConfiguration.Table.Fallback.swift b/Sources/MarkdownView/Customizations/Table/Configuration/MarkdownTableStyleConfiguration.Table.Fallback.swift similarity index 95% 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 index 2f3e49901..b4d16c438 100644 --- a/Sources/MarkdownView/Renderers/Node Representations/Tables/Protocol/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/Renderers/Node Representations/Tables/Protocol/Configuration/MarkdownTableStyleConfiguration.Table.Header.swift b/Sources/MarkdownView/Customizations/Table/Configuration/MarkdownTableStyleConfiguration.Table.Header.swift similarity index 83% 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 index 803266167..eccfe390c 100644 --- a/Sources/MarkdownView/Renderers/Node Representations/Tables/Protocol/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/Renderers/Node Representations/Tables/Protocol/Configuration/MarkdownTableStyleConfiguration.Table.Row.swift b/Sources/MarkdownView/Customizations/Table/Configuration/MarkdownTableStyleConfiguration.Table.Row.swift similarity index 84% 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 index ad2fad067..f90bcf89f 100644 --- a/Sources/MarkdownView/Renderers/Node Representations/Tables/Protocol/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/Tables/Protocol/Configuration/MarkdownTableStyleConfiguration.Table.swift b/Sources/MarkdownView/Customizations/Table/Configuration/MarkdownTableStyleConfiguration.Table.swift similarity index 93% rename from Sources/MarkdownView/Renderers/Node Representations/Tables/Protocol/Configuration/MarkdownTableStyleConfiguration.Table.swift rename to Sources/MarkdownView/Customizations/Table/Configuration/MarkdownTableStyleConfiguration.Table.swift index ac504ba2d..311d70fea 100644 --- a/Sources/MarkdownView/Renderers/Node Representations/Tables/Protocol/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/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 93% rename from Sources/MarkdownView/Renderers/Node Representations/Tables/Styles/DefaultMarkdownTableStyle.swift rename to Sources/MarkdownView/Customizations/Table/DefaultMarkdownTableStyle.swift index 1c1fe6155..012cfc7fe 100644 --- a/Sources/MarkdownView/Renderers/Node Representations/Tables/Styles/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/Renderers/Node Representations/Tables/Styles/GithubMarkdownTableStyle.swift b/Sources/MarkdownView/Customizations/Table/GithubMarkdownTableStyle.swift similarity index 95% rename from Sources/MarkdownView/Renderers/Node Representations/Tables/Styles/GithubMarkdownTableStyle.swift rename to Sources/MarkdownView/Customizations/Table/GithubMarkdownTableStyle.swift index 6d981e3c8..bddbc5344 100644 --- a/Sources/MarkdownView/Renderers/Node Representations/Tables/Styles/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/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/DeprecatedAPIs.swift b/Sources/MarkdownView/Deprecated & Unavailable APIs/v2/DeprecatedAPIs.swift similarity index 100% rename from Sources/MarkdownView/DeprecatedAPIs.swift rename to Sources/MarkdownView/Deprecated & Unavailable APIs/v2/DeprecatedAPIs.swift diff --git a/Sources/MarkdownView/Modifiers/MarkdownViewStyle.swift b/Sources/MarkdownView/Deprecated & Unavailable APIs/v2/MarkdownViewStyle.swift similarity index 82% rename from Sources/MarkdownView/Modifiers/MarkdownViewStyle.swift rename to Sources/MarkdownView/Deprecated & Unavailable APIs/v2/MarkdownViewStyle.swift index 0f2e40ecb..7385090a1 100644 --- a/Sources/MarkdownView/Modifiers/MarkdownViewStyle.swift +++ b/Sources/MarkdownView/Deprecated & Unavailable APIs/v2/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/Modifiers/RenderingModeModifier.swift b/Sources/MarkdownView/Deprecated & Unavailable APIs/v2/RenderingModeModifier.swift similarity index 100% rename from Sources/MarkdownView/Modifiers/RenderingModeModifier.swift rename to Sources/MarkdownView/Deprecated & Unavailable APIs/v2/RenderingModeModifier.swift diff --git a/Sources/MarkdownView/Extensions/Markdown/ListItemContainer.swift b/Sources/MarkdownView/Extensions/Markdown/ListItemContainer.swift deleted file mode 100644 index 79e66b3dd..000000000 --- a/Sources/MarkdownView/Extensions/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/Extensions/Markdown/Markdown+Sendable.swift b/Sources/MarkdownView/Extensions/Markdown/Markdown+Sendable.swift deleted file mode 100644 index 1c2a28e87..000000000 --- a/Sources/MarkdownView/Extensions/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/Extensions/Markdown/SourceLocation.swift b/Sources/MarkdownView/Extensions/Markdown/SourceLocation.swift deleted file mode 100644 index 2da939f5b..000000000 --- a/Sources/MarkdownView/Extensions/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/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/KeyPathModifying.swift b/Sources/MarkdownView/Helpers/KeyPathModifying.swift new file mode 100644 index 000000000..0f130ae8f --- /dev/null +++ b/Sources/MarkdownView/Helpers/KeyPathModifying.swift @@ -0,0 +1,27 @@ +// +// KeyPathModifying.swift +// MarkdownView +// +// Created by Yanan Li on 2025/2/9. +// + +import Foundation + +protocol KeyPathModifying { } + +extension KeyPathModifying { + 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/BlockMarkup++.swift similarity index 52% rename from Sources/MarkdownView/Extensions/Markdown/BlockQuote.swift rename to Sources/MarkdownView/Helpers/Markdown/BlockMarkup++.swift index e346ae09b..0ce58a1a3 100644 --- a/Sources/MarkdownView/Extensions/Markdown/BlockQuote.swift +++ b/Sources/MarkdownView/Helpers/Markdown/BlockMarkup++.swift @@ -1,8 +1,8 @@ // -// BlockQuote.swift +// BlockMarkup++.swift // MarkdownView // -// Created by LiYanan2004 on 2024/12/11. +// Created by Yanan Li on 2026/1/18. // import Markdown @@ -25,3 +25,22 @@ extension BlockQuote { return index } } + + +extension ListItemContainer { + 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/Extensions/Markdown/Markup.swift b/Sources/MarkdownView/Helpers/Markdown/Markup++.swift similarity index 53% rename from Sources/MarkdownView/Extensions/Markdown/Markup.swift rename to Sources/MarkdownView/Helpers/Markdown/Markup++.swift index 8b22db3de..13c8b6aad 100644 --- a/Sources/MarkdownView/Extensions/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/Extensions/Markdown/Table++.swift b/Sources/MarkdownView/Helpers/Markdown/Table++.swift similarity index 99% rename from Sources/MarkdownView/Extensions/Markdown/Table++.swift rename to Sources/MarkdownView/Helpers/Markdown/Table++.swift index 6bda3b95f..e9cd83ce8 100644 --- a/Sources/MarkdownView/Extensions/Markdown/Table++.swift +++ b/Sources/MarkdownView/Helpers/Markdown/Table++.swift @@ -60,5 +60,4 @@ extension Markdown.Table.Cell { } } } - } diff --git a/Sources/MarkdownView/Helpers/MarkdownContent.swift b/Sources/MarkdownView/Helpers/MarkdownContent.swift deleted file mode 100644 index 14ea079d7..000000000 --- a/Sources/MarkdownView/Helpers/MarkdownContent.swift +++ /dev/null @@ -1,91 +0,0 @@ -// -// MarkdownContent.swift -// MarkdownView -// -// Created by Yanan Li on 2025/2/9. -// - -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. -public struct MarkdownContent: Sendable { - var raw: RawMarkdownContent - - class ParsedDocumentStore: /* NSLock */ @unchecked Sendable { - private var lock = NSLock() - private var caches: [ParseOptions.RawValue : Document] = [:] - - fileprivate func parse(_ rawContent: RawMarkdownContent, options: ParseOptions = ParseOptions()) -> Document { - lock.lock() - defer { lock.unlock() } - - if let cached = caches[options.rawValue] { - return cached - } - - let document = Document( - parsing: rawContent.text, - source: rawContent.source, - options: options - ) - caches[options.rawValue] = document - return document - } - - var documents: LazySequence.Values> { - lock.withLock { - caches.values.lazy - } - } - - var hasParsedDocument: Bool { - !documents.isEmpty - } - } - 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: Hashable { - public static func == (lhs: MarkdownContent, rhs: MarkdownContent) -> Bool { - lhs.raw == rhs.raw - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(raw) - } -} 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/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/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/MarkdownContent.swift b/Sources/MarkdownView/MarkdownContent.swift new file mode 100644 index 000000000..f72b8054d --- /dev/null +++ b/Sources/MarkdownView/MarkdownContent.swift @@ -0,0 +1,152 @@ +// +// MarkdownContent.swift +// MarkdownView +// +// Created by Yanan Li on 2025/10/27. +// + +import Foundation +import Combine +@preconcurrency import Markdown + +/// 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(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 + + init(_ content: MarkdownContent) { + self.content = content + } + + fileprivate func parse( + options: ParseOptions = ParseOptions() + ) -> Document { + lock.lock() + defer { lock.unlock() } + + if let cached = caches[options.rawValue] { + return cached + } + + let text: String + do { + text = try content.markdown + } catch { + text = "" + logger.error("Unable to retrieve markdown content in string format: \(error). (fallback to empty string).") + } + + let document = Document( + parsing: text, + source: nil, + options: options + ) + caches[options.rawValue] = document + return document + } + + var documents: LazySequence.Values> { + lock.withLock { + caches.values.lazy + } + } + + var hasParsedDocument: Bool { + !documents.isEmpty + } + + func resetStorage() { + lock.lock() + defer { lock.unlock() } + + caches = [:] + } + } +} + +extension MarkdownContent: ExpressibleByStringLiteral { + public convenience init(stringLiteral value: String) { + self.init(value) + } +} + +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..71a3ce532 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 - - public init(_ text: String, @ViewBuilder contents: @escaping (MarkdownContent) -> Content) { - self.markdownContent = MarkdownContent(raw: .plainText(text)) - self.contents = contents + @StateObject private var content: MarkdownContent + private var _body: (_ markdownContent: MarkdownContent) -> Content + + public init( + _ text: String, + @ViewBuilder contents: @escaping (MarkdownContent) -> Content + ) { + _content = StateObject(wrappedValue: 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 = StateObject(wrappedValue: MarkdownContent(url)) + self._body = contents } - + public var body: some View { - contents(markdownContent) + _body(content) } } diff --git a/Sources/MarkdownView/MarkdownTableOfContent.swift b/Sources/MarkdownView/MarkdownTableOfContent.swift deleted file mode 100644 index 592e8d554..000000000 --- a/Sources/MarkdownView/MarkdownTableOfContent.swift +++ /dev/null @@ -1,74 +0,0 @@ -import SwiftUI -import Markdown - -/// A customized view that defines its content as a function of a set of headings. -/// -/// You should use ``MarkdownView/MarkdownReader`` to provide single source-of-truth for MarkdownView and table of content. -public struct MarkdownTableOfContent: View { - private var markdownContent: MarkdownContent - private var contents: (_ headings: [MarkdownHeading]) -> Content - - public init( - _ markdownContent: MarkdownContent, - @ViewBuilder contents: @escaping ([MarkdownHeading]) -> Content - ) { - self.markdownContent = markdownContent - self.contents = contents - } - - private var headings: [MarkdownHeading] { - var toc = TableOfContentVisitor() - toc.visit( - markdownContent.store.documents.first ?? markdownContent.parse() - ) - return toc.headings - } - - public var body: some View { - contents(headings) - } -} - -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. - 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) - } - } - - struct TableOfContentVisitor: MarkupWalker { - private(set) var headings: [MarkdownHeading] = [] - - mutating func visitHeading(_ heading: Markdown.Heading) { - headings.append(MarkdownHeading(heading: heading)) - descendInto(heading) - } - } -} - diff --git a/Sources/MarkdownView/MarkdownView.swift b/Sources/MarkdownView/MarkdownView.swift index f111e4300..4a3c82916 100644 --- a/Sources/MarkdownView/MarkdownView.swift +++ b/Sources/MarkdownView/MarkdownView.swift @@ -1,52 +1,49 @@ 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 + @ObservedObject private var content: MarkdownContent - @State private var viewSize = CGSize.zero - @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 + @Environment(\.markdownViewRenderer) private var renderer + /// 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) - ) + 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 an instance that renders from a ``MarkdownContent`` . + /// - Parameter content: The ``MarkdownContent`` to render. public init(_ content: MarkdownContent) { self.content = content } public var body: some View { - markdownViewStyle - .makeBody( - configuration: MarkdownViewStyleConfiguration(body: _renderedBody) - ) + renderer + .makeBody(content: content, configuration: configuration) .erasedToAnyView() - .font(bodyFont) + .font(configuration.fonts[.body] ?? Font.body) } - - @ViewBuilder - private var _renderedBody: some View { - if configuration.math.shouldRender { - MathFirstMarkdownViewRenderer() - .makeBody(content: content, configuration: configuration) - } else { - CmarkFirstMarkdownViewRenderer() - .makeBody(content: content, configuration: configuration) - } +} + +@available(iOS 17.0, macOS 14.0, *) +@available(watchOS, unavailable) +@available(tvOS, unavailable) +#Preview(traits: .sizeThatFitsLayout) { + VStack { + MarkdownView("Hello **World**") + .markdownTextSelection(.enabled) } + #if os(macOS) || os(iOS) + .textSelection(.enabled) + #endif + .padding() } 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/FontModifier.swift b/Sources/MarkdownView/Modifiers/FontModifier.swift deleted file mode 100644 index ceff5af4b..000000000 --- a/Sources/MarkdownView/Modifiers/FontModifier.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// FontModifier.swift -// MarkdownView -// -// Created by Yanan Li on 2025/2/9. -// - -import SwiftUI - -extension View { - - /// Apply a font group to MarkdownView. - /// - /// Customize fonts for multiple types of text. - /// - /// - Parameter fontGroup: A font set to apply to the MarkdownView. - nonisolated public func fontGroup(_ fontGroup: some MarkdownFontGroup) -> some View { - environment(\.markdownFontGroup, .init(fontGroup)) - } - - /// 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 { - transformEnvironment(\.markdownFontGroup) { fontGroup in - switch type { - 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 - } - } - } - -} 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/CmarkFirstMarkdownViewRenderer.swift b/Sources/MarkdownView/Renderers/Cmark/CmarkFirstMarkdownViewRenderer.swift index f31c83a47..fdebb78de 100644 --- a/Sources/MarkdownView/Renderers/Cmark/CmarkFirstMarkdownViewRenderer.swift +++ b/Sources/MarkdownView/Renderers/Cmark/CmarkFirstMarkdownViewRenderer.swift @@ -13,50 +13,35 @@ struct CmarkFirstMarkdownViewRenderer: MarkdownViewRenderer { content: MarkdownContent, configuration: MarkdownRendererConfiguration ) -> some View { - _makeAndCacheBody( - content: content, - configuration: configuration - ) + var parseOptions = ParseOptions() + if !configuration.allowedBlockDirectiveRenderers.isEmpty { + parseOptions.insert(.parseBlockDirectives) + } + + return CmarkNodeVisitor(configuration: configuration) + .makeBody(for: content.document(options: parseOptions)) } - - private func _makeAndCacheBody( +} + +#if canImport(RichText) +import RichText + +@available(iOS 26, macOS 26, *) +struct TextViewViewRenderer: MarkdownViewRenderer { + func makeBody( 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) - .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 } + let textContent = CmarkTextContentVisitor(configuration: configuration) + .makeTextContent(for: content.document(options: parseOptions)) + return TextView { + textContent + } } } +#endif diff --git a/Sources/MarkdownView/Renderers/Cmark/CmarkNodeVisitor.swift b/Sources/MarkdownView/Renderers/Cmark/CmarkNodeVisitor.swift index 5acb7eca7..8a8fcc14e 100644 --- a/Sources/MarkdownView/Renderers/Cmark/CmarkNodeVisitor.swift +++ b/Sources/MarkdownView/Renderers/Cmark/CmarkNodeVisitor.swift @@ -25,29 +25,27 @@ 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) } func visitText(_ text: Markdown.Text) -> MarkdownNodeView { - if configuration.math.shouldRender { + if configuration.rendersMath { InlineMathOrText(text: text.plainText) .makeBody(configuration: configuration) } else { @@ -82,9 +80,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) } @@ -169,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, @@ -187,15 +184,15 @@ struct CmarkNodeVisitor: @preconcurrency MarkupVisitor { func visitHeading(_ heading: Heading) -> MarkdownNodeView { MarkdownNodeView { - MarkdownHeading(heading: heading) + HeadingText(heading: heading) } } 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() @@ -204,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() @@ -218,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() @@ -239,20 +236,27 @@ struct CmarkNodeVisitor: @preconcurrency MarkupVisitor { else { return descendInto(link) } 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(configuration.linkTintColor) - ) + .foregroundColor(tintColor) + if underline { + container.underlineStyle = .single + } + return container + }()) ) } else { MarkdownNodeView { Link(destination: url) { nodeView } - .foregroundStyle(configuration.linkTintColor) + .foregroundStyle(tintColor) + .underline(underline) } } } diff --git a/Sources/MarkdownView/Renderers/Cmark/CmarkTextContentVisitor.swift b/Sources/MarkdownView/Renderers/Cmark/CmarkTextContentVisitor.swift new file mode 100644 index 000000000..703a5f315 --- /dev/null +++ b/Sources/MarkdownView/Renderers/Cmark/CmarkTextContentVisitor.swift @@ -0,0 +1,484 @@ +// +// 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 + content += renderer.visit(child) + } + 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, appendsLineBreak: true) { + 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, + appendsLineBreak: true + ) { + MarkdownBlockQuote(blockQuote: blockQuote) + } + } + + func visitSoftBreak(_ softBreak: SoftBreak) -> TextContent { + RichText.Space(1).textContent + } + + func visitThematicBreak(_ thematicBreak: ThematicBreak) -> TextContent { + inlineViewContent(for: thematicBreak, appendsLineBreak: true) { + 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), + appendsLineBreak: true + ) { + 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) + ), + appendsLineBreak: 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? + 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 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() + 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, + appendsLineBreak: true + ) { + 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 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(font) + + let replacement = AttributedString(heading.plainText, attributes: attributes) + return TextContent { + 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() + } + } + + 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 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, + replacement: AttributedString( + link.plainText, + attributes: AttributeContainer().link(url) + ) + ) { + Link(destination: url) { + contentView + } + .foregroundStyle(tintColor) + .underline(underline) + } + } else { + let attributedString = linkContent.attributedStringIgnoringViews + return TextContent( + .attributedString( + attributedString.mergingAttributes({ + var container = AttributeContainer() + .link(url) + .foregroundColor(tintColor) + if underline { + container.underlineStyle = .single + } + return container + }()) + ) + ) + } + } + + 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, + appendsLineBreak: Bool = false, + @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)) + if appendsLineBreak { + 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/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) + } + } + } +} diff --git a/Sources/MarkdownView/Renderers/MarkdownRendererConfiguration.swift b/Sources/MarkdownView/Renderers/MarkdownRendererConfiguration.swift new file mode 100644 index 000000000..c394f4d66 --- /dev/null +++ b/Sources/MarkdownView/Renderers/MarkdownRendererConfiguration.swift @@ -0,0 +1,91 @@ +// +// MarkdownRendererConfiguration.swift +// MarkdownView +// +// Created by LiYanan2004 on 2024/12/11. +// + +import Foundation +import SwiftUI + +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, + ] + + public internal(set) var math = MathRendering() + 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() + + public internal(set) var headingStyleGroup: AnyHeadingStyleGroup = .init(.automatic) + + public internal(set) var allowedImageRenderers: Set = ["https", "http"] + public internal(set) var allowedBlockDirectiveRenderers: Set = [] + + public init() {} +} + +// MARK: - List + +extension MarkdownRendererConfiguration { + 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 { + 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 + } + + mutating func appendDisplayMath(_ displayMath: some StringProtocol) -> UUID { + let id = UUID() + displayMathStorage[id] = String(displayMath) + return id + } + + public init() {} + } +} + +// 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/MarkdownViewRenderer.swift b/Sources/MarkdownView/Renderers/MarkdownViewRenderer.swift index b8c508bf3..4b6026918 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 @@ -21,3 +19,114 @@ protocol MarkdownViewRenderer { configuration: MarkdownRendererConfiguration ) -> 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() + + if !configuration.allowedBlockDirectiveRenderers.isEmpty { + options.insert(.parseBlockDirectives) + } + + return options + } +} + +public struct MarkdownViewRendererKey: EnvironmentKey { + nonisolated(unsafe) public static let defaultValue: any MarkdownViewRenderer = .automatic +} + +public extension EnvironmentValues { + var markdownViewRenderer: any MarkdownViewRenderer { + get { self[MarkdownViewRendererKey.self] } + set { self[MarkdownViewRendererKey.self] = newValue } + } +} 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 7f0d323b9..b7c8737c8 100644 --- a/Sources/MarkdownView/Renderers/Math/MathFirstMarkdownViewRenderer.swift +++ b/Sources/MarkdownView/Renderers/Math/MathFirstMarkdownViewRenderer.swift @@ -13,27 +13,124 @@ struct MathFirstMarkdownViewRenderer: MarkdownViewRenderer { content: MarkdownContent, configuration: MarkdownRendererConfiguration ) -> some View { - var configuration = configuration - var rawText = content.raw.text + makeMathFirstBody( + content: content, + configuration: configuration + ) { content, configuration in + CmarkFirstMarkdownViewRenderer().makeBody( + content: content, + configuration: configuration + ) + } + } +} + +#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 { + 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.. 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 + } +} + +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).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))" + ) + } } + + return render( + MarkdownContent(.plainText(rawText)), + configuration + ) } 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/Node Representations/Block Directives/Renderers/MathBlockDirectiveRenderer.swift b/Sources/MarkdownView/Renderers/Node Representations/Block Directives/Renderers/MathBlockDirectiveRenderer.swift index 0c9576155..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,10 +23,13 @@ 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] + math.displayMathStorage[mathIdentifier] + } + private var font: Font { + configuration.fonts[.displayMath] ?? .body } 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 76% rename from Sources/MarkdownView/Renderers/Node Representations/Heading/MarkdownHeading.swift rename to Sources/MarkdownView/Renderers/Node Representations/HeadingText.swift index e3a8a8dd4..084d179da 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,23 +8,22 @@ import SwiftUI import Markdown -struct MarkdownHeading: View { +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/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/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/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..7b9c3a540 100644 --- a/Sources/MarkdownView/Renderers/Node Representations/MarkdownList.swift +++ b/Sources/MarkdownView/Renderers/Node Representations/MarkdownList.swift @@ -3,32 +3,33 @@ import Markdown struct MarkdownList: View { var listItemsContainer: List + private var depth: Int { + listItemsContainer.listDepth + } @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)).") } } - private var depth: Int { - listItemsContainer.listDepth - } + 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) - .padding(.leading, depth == 0 ? configuration.listConfiguration.leadingIndentation : 0) + 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/MarkdownNodeView.swift b/Sources/MarkdownView/Renderers/Node Representations/MarkdownNodeView.swift index fd9c23539..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)) 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 new file mode 100644 index 000000000..43c5d1cdf --- /dev/null +++ b/Sources/MarkdownView/Renderers/Node Representations/MarkdownText.swift @@ -0,0 +1,55 @@ +// +// MarkdownText.swift +// MarkdownView +// +// Created by Yanan Li on 2025/10/20. +// + +import SwiftUI + +/// A view that displays parsed HTML asynchronously. +/// +/// Convert HTML into `AttributedString` asynchronously to avoid `AttributeGraph` crash. +struct MarkdownText: View { + var text: 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 { + 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 + } + } +} diff --git a/Sources/MarkdownView/Renderers/Node Representations/Tables/MarkdownTable.swift b/Sources/MarkdownView/Renderers/Node Representations/Tables/MarkdownTable.swift index aa50ed325..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 - @Environment(\.markdownFontGroup.tableBody) private var font - + + private var rows: [Markup] { + Array(tableBody.children) + } + var body: some View { - ForEach(Array(tableBody.children.enumerated()), id: \.offset) { (_, row) in + let font = configuration.fonts[.tableBody] ?? .body + 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( diff --git a/Sources/MarkdownView/Renderers/Node Representations/_MarkdownText.swift b/Sources/MarkdownView/Renderers/Node Representations/_MarkdownText.swift deleted file mode 100644 index 3f59e760f..000000000 --- a/Sources/MarkdownView/Renderers/Node Representations/_MarkdownText.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// _MarkdownText.swift -// MarkdownView -// -// Created by Yanan Li on 2025/10/20. -// - -import SwiftUI - -/// A view that displays parsed HTML asynchronously. -/// -/// Convert HTML into `AttributedString` asynchronously to avoid `AttributeGraph` crash. -struct _MarkdownText: View { - var text: AttributedString - @State private var attributedString: AttributedString? - - 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) - } - } - self.attributedString = attributedString - } - } -} 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 new file mode 100644 index 000000000..751d7ce9c --- /dev/null +++ b/Sources/MarkdownView/Table of Content/MarkdownTableOfContent.swift @@ -0,0 +1,61 @@ +import SwiftUI +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. The easiest way to do this is to wrap both +/// views in a ``MarkdownReader``. +public struct MarkdownTableOfContent: View { + @ObservedObject private var content: MarkdownContent + private var contents: (_ headings: [MarkdownHeading]) -> Content + + public init( + _ content: MarkdownContent, + @ViewBuilder contents: @escaping ([MarkdownHeading]) -> Content + ) { + 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( + content.store.documents.first ?? content.document() + ) + return toc.headings + } + + public var body: some View { + contents(headings) + } +} + +// MARK: - Auxiliary + +fileprivate struct TableOfContentVisitor: MarkupWalker { + private(set) var headings: [MarkdownHeading] = [] + + mutating func visitHeading(_ heading: Markdown.Heading) { + headings.append(MarkdownHeading(heading: heading)) + descendInto(heading) + } +} diff --git a/Sources/MarkdownView/View Modifiers/BaseURLModifier.swift b/Sources/MarkdownView/View Modifiers/BaseURLModifier.swift new file mode 100644 index 000000000..9807a932b --- /dev/null +++ b/Sources/MarkdownView/View Modifiers/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/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 90% rename from Sources/MarkdownView/Modifiers/Heading/HeadingStyleModifier.swift rename to Sources/MarkdownView/View Modifiers/Heading/HeadingStyleModifier.swift index 1a6fe99dc..fbe9c7455 100644 --- a/Sources/MarkdownView/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. /// diff --git a/Sources/MarkdownView/View Modifiers/MarkdownTextSelectionModifier.swift b/Sources/MarkdownView/View Modifiers/MarkdownTextSelectionModifier.swift new file mode 100644 index 000000000..363d609ee --- /dev/null +++ b/Sources/MarkdownView/View Modifiers/MarkdownTextSelectionModifier.swift @@ -0,0 +1,43 @@ +// +// MarkdownTextSelectionModifier.swift +// MarkdownView +// +// Created by Yanan Li on 2026/1/20. +// + +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 + .environment(\.markdownViewRenderer, .textContent) + } else { + content + .environment(\.markdownViewRenderer, .view) + } + } else { + content + } + #else + content + #endif + } + .textSelection(selection) + } +} diff --git a/Sources/MarkdownView/View Modifiers/MathRenderingModifier.swift b/Sources/MarkdownView/View Modifiers/MathRenderingModifier.swift new file mode 100644 index 000000000..5b6872141 --- /dev/null +++ b/Sources/MarkdownView/View Modifiers/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/Modifiers/BlockQuoteStyleModifier.swift b/Sources/MarkdownView/View Modifiers/Styling/BlockQuoteStyleModifier.swift similarity index 100% rename from Sources/MarkdownView/Modifiers/BlockQuoteStyleModifier.swift rename to Sources/MarkdownView/View Modifiers/Styling/BlockQuoteStyleModifier.swift diff --git a/Sources/MarkdownView/Modifiers/CodeBlockModifier.swift b/Sources/MarkdownView/View Modifiers/Styling/CodeBlockModifier.swift similarity index 100% rename from Sources/MarkdownView/Modifiers/CodeBlockModifier.swift rename to Sources/MarkdownView/View Modifiers/Styling/CodeBlockModifier.swift diff --git a/Sources/MarkdownView/View Modifiers/Styling/ListModifier.swift b/Sources/MarkdownView/View Modifiers/Styling/ListModifier.swift new file mode 100644 index 000000000..ab189dc6a --- /dev/null +++ b/Sources/MarkdownView/View Modifiers/Styling/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/Styling/TintColorModifier.swift similarity index 58% rename from Sources/MarkdownView/Modifiers/TintColorModifier.swift rename to Sources/MarkdownView/View Modifiers/Styling/TintColorModifier.swift index ea9e09614..1a1bcb006 100644 --- a/Sources/MarkdownView/Modifiers/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 { +public enum MarkdownTintableComponent: Hashable, Sendable { case blockQuote case inlineCodeBlock case link 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 + } + } +} 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/View Modifiers/Text Formatting/FontModifier.swift b/Sources/MarkdownView/View Modifiers/Text Formatting/FontModifier.swift new file mode 100644 index 000000000..9c555349f --- /dev/null +++ b/Sources/MarkdownView/View Modifiers/Text Formatting/FontModifier.swift @@ -0,0 +1,45 @@ +// +// FontModifier.swift +// MarkdownView +// +// Created by Yanan Li on 2025/2/9. +// + +import SwiftUI + +extension View { + /// Apply a font group to MarkdownView. + /// + /// Customize fonts for multiple types of text. + /// + /// - Parameter fontGroup: A font set to apply to the MarkdownView. + nonisolated public func fontGroup(_ fontGroup: some MarkdownFontGroup) -> some View { + 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. + /// - Parameters: + /// - 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(\.markdownRendererConfiguration) { configuration in + configuration.fonts[component] = font + } + } +} 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) ") - } - } -}