diff --git a/Package.resolved b/Package.resolved index 1874936d..4120ccab 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,23 @@ { "pins" : [ + { + "identity" : "latexswiftui", + "kind" : "remoteSourceControl", + "location" : "https://github.com/colinc86/LaTeXSwiftUI", + "state" : { + "revision" : "c45e0fd45f64923c49c5904a9f9626bc8939f05f", + "version" : "1.5.0" + } + }, + { + "identity" : "mathjaxswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/colinc86/MathJaxSwift", + "state" : { + "revision" : "e23d6eab941da699ac4a60fb0e60f3ba5c937459", + "version" : "3.4.0" + } + }, { "identity" : "networkimage", "kind" : "remoteSourceControl", @@ -10,12 +28,12 @@ } }, { - "identity" : "swift-cmark", + "identity" : "swift-html-entities", "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-cmark", + "location" : "https://github.com/Kitura/swift-html-entities", "state" : { - "revision" : "3ccff77b2dc5b96b77db3da0d68d28068593fa53", - "version" : "0.5.0" + "revision" : "d8ca73197f59ce260c71bd6d7f6eb8bbdccf508b", + "version" : "4.0.1" } }, { @@ -26,6 +44,15 @@ "revision" : "26ed3a2b4a2df47917ca9b790a57f91285b923fb", "version" : "1.12.0" } + }, + { + "identity" : "swiftdraw", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swhitty/SwiftDraw", + "state" : { + "revision" : "a19594794cdcdee5135caad3bc119096c50c92c2", + "version" : "0.22.0" + } } ], "version" : 2 diff --git a/Package.swift b/Package.swift index 5fb61bc9..49bb8188 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.6 +// swift-tools-version:5.7 import PackageDescription @@ -6,7 +6,7 @@ let package = Package( name: "swift-markdown-ui", platforms: [ .macOS(.v12), - .iOS(.v15), + .iOS(.v16), .tvOS(.v15), .macCatalyst(.v15), .watchOS(.v8), @@ -15,19 +15,19 @@ let package = Package( .library( name: "MarkdownUI", targets: ["MarkdownUI"] - ) + ), ], dependencies: [ .package(url: "https://github.com/gonzalezreal/NetworkImage", from: "6.0.0"), .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.10.0"), - .package(url: "https://github.com/swiftlang/swift-cmark", from: "0.4.0"), + .package(id: "spm-external.cmark-gfm", exact: "0.5.0+3ccff77b2dc5b96b77db3da0d68d28068593fa53"), ], targets: [ .target( name: "MarkdownUI", dependencies: [ - .product(name: "cmark-gfm", package: "swift-cmark"), - .product(name: "cmark-gfm-extensions", package: "swift-cmark"), + .product(name: "cmark-gfm", package: "spm-external.cmark-gfm"), + .product(name: "cmark-gfm-extensions", package: "spm-external.cmark-gfm"), .product(name: "NetworkImage", package: "NetworkImage"), ] ), diff --git a/Sources/MarkdownUI/DSL/Blocks/MarkdownContent.swift b/Sources/MarkdownUI/DSL/Blocks/MarkdownContent.swift index 7bcf9fa2..faef9fae 100644 --- a/Sources/MarkdownUI/DSL/Blocks/MarkdownContent.swift +++ b/Sources/MarkdownUI/DSL/Blocks/MarkdownContent.swift @@ -59,7 +59,7 @@ public protocol MarkdownContentProtocol { /// } /// } /// ``` -public struct MarkdownContent: Equatable, MarkdownContentProtocol { +public struct MarkdownContent: Hashable, MarkdownContentProtocol, Sendable { /// Returns a Markdown content value with the sum of the contents of all the container blocks /// present in this content. /// @@ -87,8 +87,8 @@ public struct MarkdownContent: Equatable, MarkdownContentProtocol { /// Creates a Markdown content value from a Markdown-formatted string. /// - Parameter markdown: A Markdown-formatted string. - public init(_ markdown: String) { - self.init(blocks: .init(markdown: markdown)) + public init(_ markdown: String, extensions: [CmarkExtension] = []) { + self.init(blocks: .init(markdown: markdown, extensions: extensions)) } /// Creates a Markdown content value composed of any number of blocks. diff --git a/Sources/MarkdownUI/Parser/CmarkExtension.swift b/Sources/MarkdownUI/Parser/CmarkExtension.swift new file mode 100644 index 00000000..9a1bfc31 --- /dev/null +++ b/Sources/MarkdownUI/Parser/CmarkExtension.swift @@ -0,0 +1,26 @@ +/// Describes extension to parse custom inline nodes in cmark. +public struct CmarkExtension: Sendable { + /// Extension name to pass to cmark + public var name: String + + /// Registers extension in cmark + public var register: @Sendable () -> Void + + /// Name to identify cmark node + public var nodeName: String + + /// This is pointer to `cmark_node` + public var makeNode: @Sendable (UnsafeMutableRawPointer) -> CustomInline? + + public init( + name: String, + register: @escaping @Sendable () -> Void, + nodeName: String, + makeNode: @escaping @Sendable (UnsafeMutableRawPointer) -> CustomInline?, + ) { + self.name = name + self.register = register + self.nodeName = nodeName + self.makeNode = makeNode + } +} diff --git a/Sources/MarkdownUI/Parser/CustomInline.swift b/Sources/MarkdownUI/Parser/CustomInline.swift new file mode 100644 index 00000000..eff5291b --- /dev/null +++ b/Sources/MarkdownUI/Parser/CustomInline.swift @@ -0,0 +1,34 @@ +import SwiftUI + +/// Describes custom inline node. +/// As this is inline node, it is type erased to SwiftUI's `Text` +/// to properly render inside surrounding text. +public struct CustomInline: Hashable { + /// Must uniquely identify renderers provided. + /// Failure to do so will result in undefined behavior. + public var id: String + + /// Sync render must be as lightweight as possible. + public var renderSync: @Sendable () -> Text + + /// Optional async renderer for heavy operations like image render/load. + public var renderAsync: (@Sendable () async -> Text)? + + public init( + id: String, + renderSync: @escaping @Sendable () -> Text, + renderAsync: (@Sendable () async -> Text)? = nil, + ) { + self.id = id + self.renderSync = renderSync + self.renderAsync = renderAsync + } + + public static func == (lhs: CustomInline, rhs: CustomInline) -> Bool { + lhs.id == rhs.id + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(self.id) + } +} diff --git a/Sources/MarkdownUI/Parser/InlineNode.swift b/Sources/MarkdownUI/Parser/InlineNode.swift index 390a8f0d..5975a0fa 100644 --- a/Sources/MarkdownUI/Parser/InlineNode.swift +++ b/Sources/MarkdownUI/Parser/InlineNode.swift @@ -11,6 +11,7 @@ enum InlineNode: Hashable, Sendable { case strikethrough(children: [InlineNode]) case link(destination: String, children: [InlineNode]) case image(source: String, children: [InlineNode]) + case custom(CustomInline) } extension InlineNode { diff --git a/Sources/MarkdownUI/Parser/MarkdownParser.swift b/Sources/MarkdownUI/Parser/MarkdownParser.swift index 76bbd193..908b4ba5 100644 --- a/Sources/MarkdownUI/Parser/MarkdownParser.swift +++ b/Sources/MarkdownUI/Parser/MarkdownParser.swift @@ -3,9 +3,11 @@ import Foundation @_implementationOnly import cmark_gfm_extensions extension Array where Element == BlockNode { - init(markdown: String) { - let blocks = UnsafeNode.parseMarkdown(markdown) { document in - document.children.compactMap(BlockNode.init(unsafeNode:)) + init(markdown: String, extensions: [CmarkExtension]) { + let blocks = UnsafeNode.parseMarkdown(markdown, extensions: extensions) { document in + document.children.compactMap { + BlockNode(unsafeNode: $0, extensions: extensions) + } } self.init(blocks ?? .init()) } @@ -30,28 +32,36 @@ extension Array where Element == BlockNode { } extension BlockNode { - fileprivate init?(unsafeNode: UnsafeNode) { + fileprivate init?(unsafeNode: UnsafeNode, extensions: [CmarkExtension]) { switch unsafeNode.nodeType { case .blockquote: - self = .blockquote(children: unsafeNode.children.compactMap(BlockNode.init(unsafeNode:))) + self = .blockquote(children: unsafeNode.children.compactMap { + BlockNode(unsafeNode: $0, extensions: extensions) + }) case .list: if unsafeNode.children.contains(where: \.isTaskListItem) { self = .taskList( isTight: unsafeNode.isTightList, - items: unsafeNode.children.map(RawTaskListItem.init(unsafeNode:)) + items: unsafeNode.children.map { + RawTaskListItem(unsafeNode: $0, extensions: extensions) + } ) } else { switch unsafeNode.listType { case CMARK_BULLET_LIST: self = .bulletedList( isTight: unsafeNode.isTightList, - items: unsafeNode.children.map(RawListItem.init(unsafeNode:)) + items: unsafeNode.children.map { + RawListItem(unsafeNode: $0, extensions: extensions) + } ) case CMARK_ORDERED_LIST: self = .numberedList( isTight: unsafeNode.isTightList, start: unsafeNode.listStart, - items: unsafeNode.children.map(RawListItem.init(unsafeNode:)) + items: unsafeNode.children.map { + RawListItem(unsafeNode: $0, extensions: extensions) + } ) default: fatalError("cmark reported a list node without a list type.") @@ -62,16 +72,22 @@ extension BlockNode { case .htmlBlock: self = .htmlBlock(content: unsafeNode.literal ?? "") case .paragraph: - self = .paragraph(content: unsafeNode.children.compactMap(InlineNode.init(unsafeNode:))) + self = .paragraph(content: unsafeNode.children.compactMap { + InlineNode(unsafeNode: $0, extensions: extensions) + }) case .heading: self = .heading( level: unsafeNode.headingLevel, - content: unsafeNode.children.compactMap(InlineNode.init(unsafeNode:)) + content: unsafeNode.children.compactMap { + InlineNode(unsafeNode: $0, extensions: extensions) + } ) case .table: self = .table( columnAlignments: unsafeNode.tableAlignments, - rows: unsafeNode.children.map(RawTableRow.init(unsafeNode:)) + rows: unsafeNode.children.map { + RawTableRow(unsafeNode: $0, extensions: extensions) + } ) case .thematicBreak: self = .thematicBreak @@ -83,46 +99,54 @@ extension BlockNode { } extension RawListItem { - fileprivate init(unsafeNode: UnsafeNode) { + fileprivate init(unsafeNode: UnsafeNode, extensions: [CmarkExtension]) { guard unsafeNode.nodeType == .item else { fatalError("Expected a list item but got a '\(unsafeNode.nodeType)' instead.") } - self.init(children: unsafeNode.children.compactMap(BlockNode.init(unsafeNode:))) + self.init(children: unsafeNode.children.compactMap { + BlockNode(unsafeNode: $0, extensions: extensions) + }) } } extension RawTaskListItem { - fileprivate init(unsafeNode: UnsafeNode) { + fileprivate init(unsafeNode: UnsafeNode, extensions: [CmarkExtension]) { guard unsafeNode.nodeType == .taskListItem || unsafeNode.nodeType == .item else { fatalError("Expected a list item but got a '\(unsafeNode.nodeType)' instead.") } self.init( isCompleted: unsafeNode.isTaskListItemChecked, - children: unsafeNode.children.compactMap(BlockNode.init(unsafeNode:)) + children: unsafeNode.children.compactMap { + BlockNode(unsafeNode: $0, extensions: extensions) + } ) } } extension RawTableRow { - fileprivate init(unsafeNode: UnsafeNode) { + fileprivate init(unsafeNode: UnsafeNode, extensions: [CmarkExtension]) { guard unsafeNode.nodeType == .tableRow || unsafeNode.nodeType == .tableHead else { fatalError("Expected a table row but got a '\(unsafeNode.nodeType)' instead.") } - self.init(cells: unsafeNode.children.map(RawTableCell.init(unsafeNode:))) + self.init(cells: unsafeNode.children.map { + RawTableCell(unsafeNode: $0, extensions: extensions) + }) } } extension RawTableCell { - fileprivate init(unsafeNode: UnsafeNode) { + fileprivate init(unsafeNode: UnsafeNode, extensions: [CmarkExtension]) { guard unsafeNode.nodeType == .tableCell else { fatalError("Expected a table cell but got a '\(unsafeNode.nodeType)' instead.") } - self.init(content: unsafeNode.children.compactMap(InlineNode.init(unsafeNode:))) + self.init(content: unsafeNode.children.compactMap { + InlineNode(unsafeNode: $0, extensions: extensions) + }) } } extension InlineNode { - fileprivate init?(unsafeNode: UnsafeNode) { + fileprivate init?(unsafeNode: UnsafeNode, extensions: [CmarkExtension]) { switch unsafeNode.nodeType { case .text: self = .text(unsafeNode.literal ?? "") @@ -135,21 +159,42 @@ extension InlineNode { case .html: self = .html(unsafeNode.literal ?? "") case .emphasis: - self = .emphasis(children: unsafeNode.children.compactMap(InlineNode.init(unsafeNode:))) + self = .emphasis(children: unsafeNode.children.compactMap { + InlineNode(unsafeNode: $0, extensions: extensions) + }) case .strong: - self = .strong(children: unsafeNode.children.compactMap(InlineNode.init(unsafeNode:))) + self = .strong(children: unsafeNode.children.compactMap { + InlineNode(unsafeNode: $0, extensions: extensions) + }) case .strikethrough: - self = .strikethrough(children: unsafeNode.children.compactMap(InlineNode.init(unsafeNode:))) + self = .strikethrough(children: unsafeNode.children.compactMap { + InlineNode(unsafeNode: $0, extensions: extensions) + }) case .link: self = .link( destination: unsafeNode.url ?? "", - children: unsafeNode.children.compactMap(InlineNode.init(unsafeNode:)) + children: unsafeNode.children.compactMap { + InlineNode(unsafeNode: $0, extensions: extensions) + } ) case .image: self = .image( source: unsafeNode.url ?? "", - children: unsafeNode.children.compactMap(InlineNode.init(unsafeNode:)) + children: unsafeNode.children.compactMap { + InlineNode(unsafeNode: $0, extensions: extensions) + } ) + case nil: + let rawName = String(cString: cmark_node_get_type_string(unsafeNode)) + if let custom = extensions.lazy.filter({ + $0.nodeName == rawName + }).compactMap({ + $0.makeNode(UnsafeMutableRawPointer(unsafeNode)) + }).first { + self = .custom(custom) + } else { + return nil + } default: assertionFailure("Unhandled node type '\(unsafeNode.nodeType)' in InlineNode.") return nil @@ -160,12 +205,9 @@ extension InlineNode { private typealias UnsafeNode = UnsafeMutablePointer extension UnsafeNode { - fileprivate var nodeType: NodeType { + fileprivate var nodeType: NodeType? { let typeString = String(cString: cmark_node_get_type_string(self)) - guard let nodeType = NodeType(rawValue: typeString) else { - fatalError("Unknown node type '\(typeString)' found.") - } - return nodeType + return NodeType(rawValue: typeString) } fileprivate var children: UnsafeNodeSequence { @@ -176,6 +218,11 @@ extension UnsafeNode { cmark_node_get_literal(self).map(String.init(cString:)) } + fileprivate var stringContent: String? { + get { cmark_node_get_string_content(self).flatMap(String.init(cString:)) } + nonmutating set { cmark_node_set_string_content(self, newValue) } + } + fileprivate var url: String? { cmark_node_get_url(self).map(String.init(cString:)) } @@ -223,9 +270,11 @@ extension UnsafeNode { fileprivate static func parseMarkdown( _ markdown: String, + extensions: [CmarkExtension], body: (UnsafeNode) throws -> ResultType ) rethrows -> ResultType? { cmark_gfm_core_extensions_ensure_registered() + extensions.forEach { $0.register() } // Create a Markdown parser and attach the GitHub syntax extensions @@ -240,7 +289,7 @@ extension UnsafeNode { extensionNames = ["autolink", "strikethrough", "tagfilter", "tasklist"] } - for extensionName in extensionNames { + for extensionName in extensionNames.union(Set(extensions.map(\.name))) { guard let syntaxExtension = cmark_find_syntax_extension(extensionName) else { continue } @@ -418,6 +467,9 @@ extension UnsafeNode { cmark_node_set_url(node, source) children.compactMap(UnsafeNode.make).forEach { cmark_node_append_child(node, $0) } return node + case .custom: + // No support for custom inlines yet + return nil } } } diff --git a/Sources/MarkdownUI/Renderer/AttributedStringInlineRenderer.swift b/Sources/MarkdownUI/Renderer/AttributedStringInlineRenderer.swift index b7a1bcde..b55bc1dc 100644 --- a/Sources/MarkdownUI/Renderer/AttributedStringInlineRenderer.swift +++ b/Sources/MarkdownUI/Renderer/AttributedStringInlineRenderer.swift @@ -61,6 +61,8 @@ private struct AttributedStringInlineRenderer { self.renderLink(destination: destination, children: children) case .image(let source, let children): self.renderImage(source: source, children: children) + case .custom: + assertionFailure() } } diff --git a/Sources/MarkdownUI/Renderer/TextInlineRenderer.swift b/Sources/MarkdownUI/Renderer/TextInlineRenderer.swift index e3f86673..76bf65ad 100644 --- a/Sources/MarkdownUI/Renderer/TextInlineRenderer.swift +++ b/Sources/MarkdownUI/Renderer/TextInlineRenderer.swift @@ -5,6 +5,7 @@ extension Sequence where Element == InlineNode { baseURL: URL?, textStyles: InlineTextStyles, images: [String: Image], + customInlines: [String: Text], softBreakMode: SoftBreak.Mode, attributes: AttributeContainer ) -> Text { @@ -12,6 +13,7 @@ extension Sequence where Element == InlineNode { baseURL: baseURL, textStyles: textStyles, images: images, + customInlines: customInlines, softBreakMode: softBreakMode, attributes: attributes ) @@ -26,6 +28,7 @@ private struct TextInlineRenderer { private let baseURL: URL? private let textStyles: InlineTextStyles private let images: [String: Image] + private let customInlines: [String: Text] private let softBreakMode: SoftBreak.Mode private let attributes: AttributeContainer private var shouldSkipNextWhitespace = false @@ -34,12 +37,14 @@ private struct TextInlineRenderer { baseURL: URL?, textStyles: InlineTextStyles, images: [String: Image], + customInlines: [String: Text], softBreakMode: SoftBreak.Mode, attributes: AttributeContainer ) { self.baseURL = baseURL self.textStyles = textStyles self.images = images + self.customInlines = customInlines self.softBreakMode = softBreakMode self.attributes = attributes } @@ -60,6 +65,8 @@ private struct TextInlineRenderer { self.renderHTML(content) case .image(let source, _): self.renderImage(source) + case .custom(let value): + self.result = self.result + (self.customInlines[value.id] ?? value.renderSync()) default: self.defaultRender(inline) } diff --git a/Sources/MarkdownUI/Views/Inlines/InlineText.swift b/Sources/MarkdownUI/Views/Inlines/InlineText.swift index 9da39404..98a6f4d4 100644 --- a/Sources/MarkdownUI/Views/Inlines/InlineText.swift +++ b/Sources/MarkdownUI/Views/Inlines/InlineText.swift @@ -8,6 +8,7 @@ struct InlineText: View { @Environment(\.theme) private var theme @State private var inlineImages: [String: Image] = [:] + @State private var customInlines: [String: Text] = [:] private let inlines: [InlineNode] @@ -26,38 +27,60 @@ struct InlineText: View { strikethrough: self.theme.strikethrough, link: self.theme.link ), - images: self.inlineImages, + images: inlineImages, + customInlines: customInlines, softBreakMode: self.softBreakMode, attributes: attributes ) } - .task(id: self.inlines) { - self.inlineImages = (try? await self.loadInlineImages()) ?? [:] + .task(id: self.inlines) { @MainActor in + try? await withThrowingTaskGroup { @MainActor in + $0.addTask { @MainActor in + inlineImages = (try? await self.loadInlineImages()) ?? [:] + } + $0.addTask { @MainActor in + let customNodes = self.inlines.compactMap { + if case let .custom(custom) = $0 { custom } else { nil } + } + for customNode in customNodes { + if let renderAsync = customNode.renderAsync, customInlines[customNode.id] == nil { + let result = await renderAsync() + try Task.checkCancellation() + customInlines[customNode.id] = result + } + } + } + } } } + @MainActor private func loadInlineImages() async throws -> [String: Image] { let images = Set(self.inlines.compactMap(\.imageData)) guard !images.isEmpty else { return [:] } - return try await withThrowingTaskGroup(of: (String, Image).self) { taskGroup in + return try await withThrowingTaskGroup(of: (String, Image).self) { @MainActor taskGroup in for image in images { guard let url = URL(string: image.source, relativeTo: self.imageBaseURL) else { continue } - taskGroup.addTask { - (image.source, try await self.inlineImageProvider.image(with: url, label: image.alt)) + taskGroup.addTask { @MainActor in + if let existing = inlineImages[image.source] { + return (image.source, existing) + } else { + return (image.source, try await self.inlineImageProvider.image(with: url, label: image.alt)) + } } } - var inlineImages: [String: Image] = [:] + var result: [String: Image] = [:] - for try await result in taskGroup { - inlineImages[result.0] = result.1 + for try await pair in taskGroup { + result[pair.0] = pair.1 } - return inlineImages + return result } } } diff --git a/Sources/MarkdownUI/Views/Markdown.swift b/Sources/MarkdownUI/Views/Markdown.swift index 7e182f9e..7c719dc6 100644 --- a/Sources/MarkdownUI/Views/Markdown.swift +++ b/Sources/MarkdownUI/Views/Markdown.swift @@ -234,8 +234,8 @@ extension Markdown { /// URLs absolute. The default is `nil`. /// - imageBaseURL: The base URL to use when resolving Markdown image URLs. If this value is `nil`, the initializer will /// determine image URLs using the `baseURL` parameter. The default is `nil`. - public init(_ markdown: String, baseURL: URL? = nil, imageBaseURL: URL? = nil) { - self.init(MarkdownContent(markdown), baseURL: baseURL, imageBaseURL: imageBaseURL) + public init(_ markdown: String, baseURL: URL? = nil, imageBaseURL: URL? = nil, extensions: [CmarkExtension] = []) { + self.init(MarkdownContent(markdown, extensions: extensions), baseURL: baseURL, imageBaseURL: imageBaseURL) } /// Creates a Markdown view composed of any number of blocks.