diff --git a/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/Demonstrations.swift b/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/Demonstrations.swift index c79027d..53c5130 100644 --- a/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/Demonstrations.swift +++ b/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/Demonstrations.swift @@ -58,6 +58,26 @@ enum Demonstration: String, CaseIterable, Identifiable, Hashable { } } + var customViewBuilder: MarkdownCustomViewBuilder? { + switch self { + case .kitchenSink: + return MarkdownCustomViewBuilder(id: "kitchen-sink-custom-views") { customView in + switch customView { + case .image(let image) where image.source == "StreamingMarkdownSample": + // swiftlint:disable:next no_anyview + return AnyView(SampleMarkdownImageView(image: image)) + case .block(let block) where block.name == "Callout": + // swiftlint:disable:next no_anyview + return AnyView(SampleCalloutView(block: block)) + default: + return nil + } + } + default: + return nil + } + } + func renderConfig(theme: SampleMarkdownTheme, isStreaming: Bool) -> MarkdownRenderConfig { theme.renderConfig(for: self, isStreaming: isStreaming) } @@ -69,3 +89,45 @@ enum Demonstration: String, CaseIterable, Identifiable, Hashable { } } } + +private struct SampleMarkdownImageView: View { + let image: MarkdownImage + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Image(image.source) + .resizable() + .scaledToFit() + .clipShape(RoundedRectangle(cornerRadius: 8)) + + if !image.alternativeText.isEmpty { + Text(image.alternativeText) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } +} + +private struct SampleCalloutView: View { + @Environment(\.markdownConfig) private var config + + let block: MarkdownCustomBlock + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 8) { + Image(systemName: block.arguments["icon"] ?? "sparkles") + .font(.system(size: 14, weight: .semibold)) + Text(block.arguments["title"] ?? block.name) + .font(.headline) + } + + DocumentView(renderableDocument: block.content, config: config) + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(uiColor: .secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } +} diff --git a/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/Resources/Assets.xcassets/StreamingMarkdownSample.imageset/Contents.json b/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/Resources/Assets.xcassets/StreamingMarkdownSample.imageset/Contents.json new file mode 100644 index 0000000..65e6b1a --- /dev/null +++ b/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/Resources/Assets.xcassets/StreamingMarkdownSample.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "streaming-markdown.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/Resources/Assets.xcassets/StreamingMarkdownSample.imageset/streaming-markdown.svg b/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/Resources/Assets.xcassets/StreamingMarkdownSample.imageset/streaming-markdown.svg new file mode 100644 index 0000000..b16bcc5 --- /dev/null +++ b/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/Resources/Assets.xcassets/StreamingMarkdownSample.imageset/streaming-markdown.svg @@ -0,0 +1,22 @@ + + Streaming Markdown sample + A rounded card with markdown tokens flowing left to right. + + + + + + + + + + + + + + + + + # Markdown + **streaming** `tokens` + diff --git a/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/Resources/Fixtures/kitchen-sink.md b/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/Resources/Fixtures/kitchen-sink.md index e8ef055..4c29542 100644 --- a/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/Resources/Fixtures/kitchen-sink.md +++ b/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/Resources/Fixtures/kitchen-sink.md @@ -198,7 +198,14 @@ $$ Bundled SVG asset: -![Streaming Markdown SVG sample](Images/streaming-markdown.svg) +![Streaming Markdown SVG sample](StreamingMarkdownSample) + +@Callout(title: "Custom view builder", icon: "wand.and.stars") { +This block is written as markdown, parsed as a swift-markdown directive, and rendered by the sample app's custom view builder. + +- It can contain regular markdown content. +- The fallback renderer still has access to this content. +} Remote unsupported image fallback: diff --git a/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/SampleMarkdownTheme.swift b/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/SampleMarkdownTheme.swift index 3919ab8..9edd57b 100644 --- a/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/SampleMarkdownTheme.swift +++ b/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/SampleMarkdownTheme.swift @@ -48,6 +48,7 @@ enum SampleMarkdownTheme: String, CaseIterable, Identifiable { func renderConfig(for demonstration: Demonstration, isStreaming: Bool) -> MarkdownRenderConfig { resolvedConfig(for: demonstration) .withTextContextMenu(value: demonstration.customContextMenu) + .withCustomViewBuilder(value: demonstration.customViewBuilder) .withShouldAnimateText(value: isStreaming) } diff --git a/Sources/MarkdownText/Block/BlockConvertible.swift b/Sources/MarkdownText/Block/BlockConvertible.swift index 2c971c3..6591e50 100644 --- a/Sources/MarkdownText/Block/BlockConvertible.swift +++ b/Sources/MarkdownText/Block/BlockConvertible.swift @@ -22,4 +22,20 @@ extension Markup { var blockConvertibleChildren: [BlockConvertible] { return self.children.compactMap { $0 as? BlockConvertible } } + + func blockRenderables(attributeContainer: NSAttributeContainer, config: MarkdownRenderConfig) -> [MarkdownRenderable] { + if let paragraph = self as? Paragraph { + return paragraph.convertRenderables(attributeContainer: attributeContainer, config: config) + } + + if let blockDirective = self as? BlockDirective { + return blockDirective.convertRenderables(attributeContainer: attributeContainer, config: config) + } + + guard let convertible = self as? BlockConvertible else { + return [] + } + + return [convertible.convert(attributeContainer: attributeContainer, config: config)] + } } diff --git a/Sources/MarkdownText/Block/BlockDirective+.swift b/Sources/MarkdownText/Block/BlockDirective+.swift new file mode 100644 index 0000000..71f7853 --- /dev/null +++ b/Sources/MarkdownText/Block/BlockDirective+.swift @@ -0,0 +1,37 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +// + +import Foundation +import Markdown +import SwiftUI + +extension BlockDirective { + + func convertRenderables(attributeContainer: NSAttributeContainer, config: MarkdownRenderConfig) -> [MarkdownRenderable] { + let content = children.flatMap { + $0.blockRenderables(attributeContainer: attributeContainer, config: config) + } + let block = MarkdownCustomBlock( + id: id, + name: name, + arguments: parsedArguments, + rawArguments: rawArgumentText, + content: RenderableDocument(renderables: content) + ) + return [.customView(id: id, block: block)] + } + + private var parsedArguments: [String: String] { + argumentText + .parseNameValueArguments() + .reduce(into: [:]) { result, argument in + result[argument.name] = argument.value + } + } + + private var rawArgumentText: String { + argumentText.segments.map(\.trimmedText).joined(separator: "\n") + } +} diff --git a/Sources/MarkdownText/Block/Document+.swift b/Sources/MarkdownText/Block/Document+.swift index c3b4a77..9bac2c9 100644 --- a/Sources/MarkdownText/Block/Document+.swift +++ b/Sources/MarkdownText/Block/Document+.swift @@ -11,7 +11,7 @@ extension Markdown.Document { func convert(with config: MarkdownRenderConfig) -> [MarkdownRenderable] { return self - .blockConvertibleChildren - .map { $0.convert(attributeContainer: NSAttributeContainer(), config: config) } + .children + .flatMap { $0.blockRenderables(attributeContainer: NSAttributeContainer(), config: config) } } } diff --git a/Sources/MarkdownText/Block/OrderedList+.swift b/Sources/MarkdownText/Block/OrderedList+.swift index 5cbf369..f5231a5 100644 --- a/Sources/MarkdownText/Block/OrderedList+.swift +++ b/Sources/MarkdownText/Block/OrderedList+.swift @@ -12,7 +12,7 @@ extension OrderedList: BlockConvertible { func convert(attributeContainer: NSAttributeContainer, config: MarkdownRenderConfig) -> MarkdownRenderable { let nodes: [ListItem] = self.children.compactMap { $0 as? ListItem } let items: [MarkdownListItem] = nodes.map { listItem in - MarkdownListItem(children: listItem.blockConvertibleChildren.map { $0.convert(attributeContainer: attributeContainer, config: config)}, + MarkdownListItem(children: listItem.children.flatMap { $0.blockRenderables(attributeContainer: attributeContainer, config: config) }, startsWithBold: listItem.startsWithBold) } return .orderedList(id: self.id, items: items) diff --git a/Sources/MarkdownText/Block/Paragraph+.swift b/Sources/MarkdownText/Block/Paragraph+.swift index 80412f9..68e358f 100644 --- a/Sources/MarkdownText/Block/Paragraph+.swift +++ b/Sources/MarkdownText/Block/Paragraph+.swift @@ -10,6 +10,41 @@ import SwiftUI extension Paragraph: BlockConvertible { func convert(attributeContainer: NSAttributeContainer, config: MarkdownRenderConfig) -> MarkdownRenderable { + let container = paragraphAttributeContainer(inheriting: attributeContainer, config: config) + let paragraphContent: NSMutableAttributedString = self.buildParagraphContent(container: container, config: config) + return MarkdownRenderable.paragraph(id: self.id, content: paragraphContent) + } + + func convertRenderables(attributeContainer: NSAttributeContainer, config: MarkdownRenderConfig) -> [MarkdownRenderable] { + guard self.children.contains(where: { $0 is Markdown.Image }) else { + return [convert(attributeContainer: attributeContainer, config: config)] + } + + let container = paragraphAttributeContainer(inheriting: attributeContainer, config: config) + var renderables: [MarkdownRenderable] = [] + var currentText = NSMutableAttributedString() + + func flushText() { + guard currentText.length > 0 else { return } + let textID = renderables.isEmpty ? id : "\(id)-paragraph-\(renderables.count)" + renderables.append(.paragraph(id: textID, content: currentText)) + currentText = NSMutableAttributedString() + } + + for child in self.children { + if let image = child as? Markdown.Image { + flushText() + renderables.append(.image(id: image.id, image: image.markdownImage)) + } else { + appendInlineChild(child, to: currentText, container: container, config: config) + } + } + + flushText() + return renderables + } + + private func paragraphAttributeContainer(inheriting attributeContainer: NSAttributeContainer, config: MarkdownRenderConfig) -> NSAttributeContainer { var container = attributeContainer container[.font] = config.paragraphStyle.textFonts.normal container[.typography] = config.paragraphStyle.textFonts @@ -17,8 +52,7 @@ extension Paragraph: BlockConvertible { container[.kern] = kern } container[.foregroundColor] = config.paragraphStyle.textColor - let paragraphContent: NSMutableAttributedString = self.buildParagraphContent(container: container, config: config) - return MarkdownRenderable.paragraph(id: self.id, content: paragraphContent) + return container } } @@ -28,46 +62,45 @@ extension BlockMarkup { let result = NSMutableAttributedString() for child in self.children { - guard let convertible = child as? InlineConvertible else { - continue - } - - let coder = config.citationConfig.coder - if config.citationConfig.isEnabled, - let link = child as? Markdown.Link, - let destination = link.destination, - link.isInlineCitation(coder: coder) { - - // Create citation attachment directly during parsing (as suggested by @hanzhouli_microsoft) - let attachmentData = coder.decode(linkDestination: destination) - if let attachmentData = attachmentData, - let attachment = InlineCitationAttachment(citationData: attachmentData, citationConfig: config.citationConfig) { - let attachmentString = NSMutableAttributedString(attachment: attachment) - - // Add link attribute for accessibility activation (space key) - let url = attachmentData.url - attachmentString.addAttribute( - .link, - value: url, - range: NSRange(location: 0, length: attachmentString.length) - ) - - // Apply baseline offset to the attachment using the font from config - attachmentString.addAttribute( - .baselineOffset, - value: config.paragraphStyle.textFonts.normal.descender, - range: NSRange(location: 0, length: attachmentString.length) - ) - - // Add the citation directly to result - result.append(attachmentString) - } - } else { - let stringPart = convertible.convert(attributeContainer: container, config: config) - result.append(stringPart) - } + appendInlineChild(child, to: result, container: container, config: config) } return result } + + func appendInlineChild(_ child: Markup, to result: NSMutableAttributedString, container: NSAttributeContainer, config: MarkdownRenderConfig) { + guard let convertible = child as? InlineConvertible else { + return + } + + let coder = config.citationConfig.coder + if config.citationConfig.isEnabled, + let link = child as? Markdown.Link, + let destination = link.destination, + link.isInlineCitation(coder: coder) { + + let attachmentData = coder.decode(linkDestination: destination) + if let attachmentData = attachmentData, + let attachment = InlineCitationAttachment(citationData: attachmentData, citationConfig: config.citationConfig) { + let attachmentString = NSMutableAttributedString(attachment: attachment) + + attachmentString.addAttribute( + .link, + value: attachmentData.url, + range: NSRange(location: 0, length: attachmentString.length) + ) + + attachmentString.addAttribute( + .baselineOffset, + value: config.paragraphStyle.textFonts.normal.descender, + range: NSRange(location: 0, length: attachmentString.length) + ) + + result.append(attachmentString) + } + } else { + let stringPart = convertible.convert(attributeContainer: container, config: config) + result.append(stringPart) + } + } } diff --git a/Sources/MarkdownText/Block/UnorderedList+.swift b/Sources/MarkdownText/Block/UnorderedList+.swift index 83f2493..55ab06e 100644 --- a/Sources/MarkdownText/Block/UnorderedList+.swift +++ b/Sources/MarkdownText/Block/UnorderedList+.swift @@ -13,7 +13,7 @@ extension UnorderedList: BlockConvertible { let nodes: [ListItem] = self.children.compactMap { $0 as? ListItem } var items: [MarkdownListItem] = [] for listItem in nodes { - items.append(MarkdownListItem(children: listItem.blockConvertibleChildren.map { $0.convert(attributeContainer: attributeContainer, config: config) }, + items.append(MarkdownListItem(children: listItem.children.flatMap { $0.blockRenderables(attributeContainer: attributeContainer, config: config) }, startsWithBold: listItem.startsWithBold )) } return .unorderedList(id: self.id, items: items, nestedLevel: self.nestedLevel) diff --git a/Sources/MarkdownText/Inline/Markdown+InlineConvertible.swift b/Sources/MarkdownText/Inline/Markdown+InlineConvertible.swift index a83465a..a6a6548 100644 --- a/Sources/MarkdownText/Inline/Markdown+InlineConvertible.swift +++ b/Sources/MarkdownText/Inline/Markdown+InlineConvertible.swift @@ -129,6 +129,31 @@ extension Markdown.Link: InlineConvertible { } } +extension Markdown.Image: InlineConvertible { + + var markdownImage: MarkdownImage { + MarkdownImage( + id: id, + source: source ?? "", + title: title, + alternativeText: plainText + ) + } + + func convert(attributeContainer: NSAttributeContainer, config: MarkdownRenderConfig) -> NSMutableAttributedString { + let fallbackText = alternativeTextFallback + return NSMutableAttributedString(string: fallbackText).mergingAttributes(attributeContainer) + } + + private var alternativeTextFallback: String { + if !plainText.isEmpty { + return plainText + } + + return source ?? "" + } +} + extension Markdown.SoftBreak: InlineConvertible { func convert(attributeContainer: NSAttributeContainer, config: MarkdownRenderConfig) -> NSMutableAttributedString { diff --git a/Sources/MarkdownText/Models/MarkdownCustomView.swift b/Sources/MarkdownText/Models/MarkdownCustomView.swift new file mode 100644 index 0000000..7bc70aa --- /dev/null +++ b/Sources/MarkdownText/Models/MarkdownCustomView.swift @@ -0,0 +1,80 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +// + +import Foundation +import SwiftUI + +/// A parsed custom Markdown block directive ready for app-provided rendering. +public struct MarkdownCustomBlock: Equatable, Sendable { + /// Stable identifier derived from the parsed Markdown node. + public let id: String + /// Directive name, e.g. `Callout` for `@Callout(...)`. + public let name: String + /// Parsed directive arguments keyed by argument name. The first unnamed + /// argument, if present, uses an empty string key. + public let arguments: [String: String] + /// Raw argument text exactly as exposed by swift-markdown. + public let rawArguments: String + /// Fallback Markdown content contained by the directive. + public let content: RenderableDocument + + /// Create a custom block payload. + public init( + id: String, + name: String, + arguments: [String: String] = [:], + rawArguments: String = "", + content: RenderableDocument = .empty + ) { + self.id = id + self.name = name + self.arguments = arguments + self.rawArguments = rawArguments + self.content = content + } +} + +/// Content that can be handled by a custom view builder. +public enum MarkdownCustomView: Equatable, Sendable { + /// A standard Markdown image. + case image(MarkdownImage) + /// A swift-markdown block directive. + case block(MarkdownCustomBlock) +} + +/// Type-erased app hook for replacing parsed Markdown payloads with SwiftUI views. +public struct MarkdownCustomViewBuilder: Hashable, @unchecked Sendable { + private let id: String + // swiftlint:disable:next no_anyview + private let buildView: @MainActor @Sendable (MarkdownCustomView) -> AnyView? + + /// Create a custom view builder. + /// - Parameters: + /// - id: Stable identity used for `MarkdownRenderConfig` equality. + /// - build: Returns a custom view for supported payloads, or `nil` to use + /// the renderer's default/fallback view. + public init( + id: String, + // swiftlint:disable:next no_anyview + build: @escaping @MainActor @Sendable (MarkdownCustomView) -> AnyView? + ) { + self.id = id + self.buildView = build + } + + @MainActor + // swiftlint:disable:next no_anyview + func view(for customView: MarkdownCustomView) -> AnyView? { + buildView(customView) + } + + public static func == (lhs: MarkdownCustomViewBuilder, rhs: MarkdownCustomViewBuilder) -> Bool { + lhs.id == rhs.id + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} diff --git a/Sources/MarkdownText/Models/MarkdownImage.swift b/Sources/MarkdownText/Models/MarkdownImage.swift new file mode 100644 index 0000000..f1be9c3 --- /dev/null +++ b/Sources/MarkdownText/Models/MarkdownImage.swift @@ -0,0 +1,26 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +// + +import Foundation + +/// A parsed Markdown image ready for rendering. +public struct MarkdownImage: Equatable, Sendable { + /// Stable identifier derived from the parsed Markdown node. + public let id: String + /// The image source from the Markdown destination. + public let source: String + /// Optional title from the Markdown image. + public let title: String? + /// Plain-text alternative text from the Markdown image label. + public let alternativeText: String + + /// Create a parsed Markdown image payload. + public init(id: String, source: String, title: String? = nil, alternativeText: String = "") { + self.id = id + self.source = source + self.title = title + self.alternativeText = alternativeText + } +} diff --git a/Sources/MarkdownText/Models/MarkdownRenderConfig+Builders.swift b/Sources/MarkdownText/Models/MarkdownRenderConfig+Builders.swift index 548ccbc..b163ba6 100644 --- a/Sources/MarkdownText/Models/MarkdownRenderConfig+Builders.swift +++ b/Sources/MarkdownText/Models/MarkdownRenderConfig+Builders.swift @@ -16,7 +16,9 @@ extension MarkdownRenderConfig { paragraphStyle: paragraphStyle, tableStyle: tableStyle, inlineStyle: inlineStyle, - textContextMenu: textContextMenu + textContextMenu: textContextMenu, + citationConfig: citationConfig, + customViewBuilder: customViewBuilder ) } @@ -30,7 +32,9 @@ extension MarkdownRenderConfig { paragraphStyle: paragraphStyle, tableStyle: tableStyle, inlineStyle: inlineStyle, - textContextMenu: textContextMenu + textContextMenu: textContextMenu, + citationConfig: citationConfig, + customViewBuilder: customViewBuilder ) } @@ -44,7 +48,9 @@ extension MarkdownRenderConfig { paragraphStyle: paragraphStyle, tableStyle: tableStyle, inlineStyle: inlineStyle, - textContextMenu: textContextMenu + textContextMenu: textContextMenu, + citationConfig: citationConfig, + customViewBuilder: customViewBuilder ) } @@ -58,7 +64,9 @@ extension MarkdownRenderConfig { paragraphStyle: paragraphStyle, tableStyle: tableStyle, inlineStyle: inlineStyle, - textContextMenu: textContextMenu + textContextMenu: textContextMenu, + citationConfig: citationConfig, + customViewBuilder: customViewBuilder ) } @@ -72,7 +80,9 @@ extension MarkdownRenderConfig { paragraphStyle: value, tableStyle: tableStyle, inlineStyle: inlineStyle, - textContextMenu: textContextMenu + textContextMenu: textContextMenu, + citationConfig: citationConfig, + customViewBuilder: customViewBuilder ) } @@ -86,7 +96,9 @@ extension MarkdownRenderConfig { paragraphStyle: paragraphStyle, tableStyle: value, inlineStyle: inlineStyle, - textContextMenu: textContextMenu + textContextMenu: textContextMenu, + citationConfig: citationConfig, + customViewBuilder: customViewBuilder ) } @@ -100,7 +112,9 @@ extension MarkdownRenderConfig { paragraphStyle: paragraphStyle, tableStyle: tableStyle, inlineStyle: value, - textContextMenu: textContextMenu + textContextMenu: textContextMenu, + citationConfig: citationConfig, + customViewBuilder: customViewBuilder ) } @@ -115,7 +129,42 @@ extension MarkdownRenderConfig { paragraphStyle: paragraphStyle, tableStyle: tableStyle, inlineStyle: inlineStyle, - textContextMenu: value + textContextMenu: value, + citationConfig: citationConfig, + customViewBuilder: customViewBuilder + ) + } + + /// Returns a copy with `citationConfig` replaced. + public func withCitationConfig(value: CitationConfig) -> MarkdownRenderConfig { + MarkdownRenderConfig( + shouldAnimateText: shouldAnimateText, + blockQuoteStyle: blockQuoteStyle, + headingStyle: headingStyle, + orderedListStyle: orderedListStyle, + paragraphStyle: paragraphStyle, + tableStyle: tableStyle, + inlineStyle: inlineStyle, + textContextMenu: textContextMenu, + citationConfig: value, + customViewBuilder: customViewBuilder + ) + } + + /// Returns a copy with `customViewBuilder` replaced. Pass `nil` to use only + /// built-in rendering and directive fallback content. + public func withCustomViewBuilder(value: MarkdownCustomViewBuilder?) -> MarkdownRenderConfig { + MarkdownRenderConfig( + shouldAnimateText: shouldAnimateText, + blockQuoteStyle: blockQuoteStyle, + headingStyle: headingStyle, + orderedListStyle: orderedListStyle, + paragraphStyle: paragraphStyle, + tableStyle: tableStyle, + inlineStyle: inlineStyle, + textContextMenu: textContextMenu, + citationConfig: citationConfig, + customViewBuilder: value ) } } diff --git a/Sources/MarkdownText/Models/MarkdownRenderConfig.swift b/Sources/MarkdownText/Models/MarkdownRenderConfig.swift index 70f2fd9..f64112e 100644 --- a/Sources/MarkdownText/Models/MarkdownRenderConfig.swift +++ b/Sources/MarkdownText/Models/MarkdownRenderConfig.swift @@ -33,6 +33,8 @@ public struct MarkdownRenderConfig: Hashable, Sendable { public let textContextMenu: TextContextMenu? /// Configuration that controls inline citation parsing and rendering. public let citationConfig: CitationConfig + /// Optional app-provided view builder for images and custom block directives. + public let customViewBuilder: MarkdownCustomViewBuilder? /// Font and color style for a uniformly-styled run of markdown text. public struct MarkdownTextStyle: Hashable, Sendable { @@ -237,7 +239,8 @@ public struct MarkdownRenderConfig: Hashable, Sendable { tableStyle: MarkdownTableTextStyle = MarkdownRenderConfig.defaultTableStyle, inlineStyle: MarkdownInlineTextStyle = MarkdownRenderConfig.defaultInlineStyle, textContextMenu: TextContextMenu? = nil, - citationConfig: CitationConfig = .default + citationConfig: CitationConfig = .default, + customViewBuilder: MarkdownCustomViewBuilder? = nil ) { self.shouldAnimateText = shouldAnimateText self.blockQuoteStyle = blockQuoteStyle @@ -248,6 +251,7 @@ public struct MarkdownRenderConfig: Hashable, Sendable { self.inlineStyle = inlineStyle self.textContextMenu = textContextMenu self.citationConfig = citationConfig + self.customViewBuilder = customViewBuilder } /// The default render config, equivalent to calling `init()` with no diff --git a/Sources/MarkdownText/Models/MarkdownRenderable.swift b/Sources/MarkdownText/Models/MarkdownRenderable.swift index c0edbeb..ccd032a 100644 --- a/Sources/MarkdownText/Models/MarkdownRenderable.swift +++ b/Sources/MarkdownText/Models/MarkdownRenderable.swift @@ -38,6 +38,12 @@ indirect enum MarkdownRenderable: Identifiable, Equatable, @unchecked Sendable { /// To be rendered as a block quote case blockQuote(id: String, item: BlockQuoteRenderable) + /// To be rendered as an image + case image(id: String, image: MarkdownImage) + + /// To be rendered by an app-provided custom view builder + case customView(id: String, block: MarkdownCustomBlock) + var id: String { switch self { case .paragraph(let id, _): return id @@ -49,6 +55,8 @@ indirect enum MarkdownRenderable: Identifiable, Equatable, @unchecked Sendable { case .table(let id, _, _, _): return id case .thematicBreak(let id): return id case .blockQuote(let id, _): return id + case .image(let id, _): return id + case .customView(let id, _): return id } } diff --git a/Sources/MarkdownText/Models/RenderableDocument.swift b/Sources/MarkdownText/Models/RenderableDocument.swift index e59ca8f..d4e40e1 100644 --- a/Sources/MarkdownText/Models/RenderableDocument.swift +++ b/Sources/MarkdownText/Models/RenderableDocument.swift @@ -74,6 +74,8 @@ extension MarkdownRenderable { return items.flatMap { $0.attributedStrings() } case .table(_, let headers, let rows, _): return headers + rows.flatMap { $0 } + case .customView(_, let block): + return block.content.attributedStrings default: return [] } diff --git a/Sources/MarkdownText/Parser/MarkdownParseOption.swift b/Sources/MarkdownText/Parser/MarkdownParseOption.swift index 9b8b8fc..5e9c06c 100644 --- a/Sources/MarkdownText/Parser/MarkdownParseOption.swift +++ b/Sources/MarkdownText/Parser/MarkdownParseOption.swift @@ -12,13 +12,22 @@ public struct MarkdownParseOption { /// Specify how to parse latex public let latexMatchingRules: [LatexMatching] + /// Whether swift-markdown block directive syntax should be parsed. + public let parseBlockDirectives: Bool + /// Create a new parse option. /// - Parameters: /// - speculativeRewrite: See `speculativeRewrite`. /// - latexMatchingRules: See `latexMatchingRules`. Defaults to every supported rule. - public init(speculativeRewrite: Bool, latexMatchingRules: [LatexMatching] = LatexMatching.allCases) { + /// - parseBlockDirectives: See `parseBlockDirectives`. Defaults to `false`. + public init( + speculativeRewrite: Bool, + latexMatchingRules: [LatexMatching] = LatexMatching.allCases, + parseBlockDirectives: Bool = false + ) { self.speculativeRewrite = speculativeRewrite self.latexMatchingRules = latexMatchingRules + self.parseBlockDirectives = parseBlockDirectives } /// The set of delimiter forms the LaTeX preprocessor will recognize. Omitting diff --git a/Sources/MarkdownText/Parser/MarkdownParser.swift b/Sources/MarkdownText/Parser/MarkdownParser.swift index c4d6b1c..efef7d8 100644 --- a/Sources/MarkdownText/Parser/MarkdownParser.swift +++ b/Sources/MarkdownText/Parser/MarkdownParser.swift @@ -32,7 +32,12 @@ extension MarkdownParser { /// - config: Render configuration applied when building the renderable. /// - Returns: A `RenderableDocument` built from the parsed `Document`. public func parse(text: String, config: MarkdownRenderConfig) async -> RenderableDocument { - let document = await parse(text: text) + let parseOption = MarkdownParseOption( + speculativeRewrite: false, + parseBlockDirectives: config.customViewBuilder != nil + ) + let result = await parse(text: text, option: parseOption) + let document = result.document return await RenderableDocument(document: document, config: config) } } diff --git a/Sources/MarkdownText/Parser/MarkdownParserImpl.swift b/Sources/MarkdownText/Parser/MarkdownParserImpl.swift index 777c87e..3c5cf1b 100644 --- a/Sources/MarkdownText/Parser/MarkdownParserImpl.swift +++ b/Sources/MarkdownText/Parser/MarkdownParserImpl.swift @@ -23,9 +23,10 @@ public final class MarkdownParserImpl: MarkdownParser { /// Parse `text` into a `MarkdownParseResult`. See `MarkdownParser.parse(text:option:)`. public func parse(text: String, option: MarkdownParseOption) async -> MarkdownParseResult { let targetString = latexPreprocessor.process(input: text, matchingRules: option.latexMatchingRules) + let parseOptions: ParseOptions = option.parseBlockDirectives ? [.parseBlockDirectives] : [] var result: MarkdownParseResult = MarkdownParseResult( - document: Document(parsing: targetString), + document: Document(parsing: targetString, options: parseOptions), speculativeRewritten: false ) diff --git a/Sources/MarkdownText/UI/BlockView.swift b/Sources/MarkdownText/UI/BlockView.swift index d9b79aa..7ed1c1c 100644 --- a/Sources/MarkdownText/UI/BlockView.swift +++ b/Sources/MarkdownText/UI/BlockView.swift @@ -72,6 +72,10 @@ struct SingleBlockView: View { rawMarkdown: rawMarkdown) case .blockQuote(_, let item): BlockQuoteView(item: item) + case .image(_, let image): + MarkdownImageView(image: image) + case .customView(_, let block): + MarkdownCustomBlockView(block: block) } } } diff --git a/Sources/MarkdownText/UI/MarkdownCustomBlockView.swift b/Sources/MarkdownText/UI/MarkdownCustomBlockView.swift new file mode 100644 index 0000000..9469141 --- /dev/null +++ b/Sources/MarkdownText/UI/MarkdownCustomBlockView.swift @@ -0,0 +1,20 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +// + +import SwiftUI + +struct MarkdownCustomBlockView: View { + @Environment(\.markdownConfig) private var config: MarkdownRenderConfig + + let block: MarkdownCustomBlock + + var body: some View { + if let customView = config.customViewBuilder?.view(for: .block(block)) { + customView + } else { + BlockView(renderables: block.content.renderables) + } + } +} diff --git a/Sources/MarkdownText/UI/MarkdownImageView.swift b/Sources/MarkdownText/UI/MarkdownImageView.swift new file mode 100644 index 0000000..430f444 --- /dev/null +++ b/Sources/MarkdownText/UI/MarkdownImageView.swift @@ -0,0 +1,118 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +// + +import SwiftUI +import UIKit + +struct MarkdownImageView: View { + @Environment(\.markdownConfig) private var config: MarkdownRenderConfig + + let image: MarkdownImage + + var body: some View { + if let customView = config.customViewBuilder?.view(for: .image(image)) { + customView + } else { + defaultImageView + } + } + + @ViewBuilder + private var defaultImageView: some View { + if let uiImage { + renderedImage(Image(uiImage: uiImage)) + } else if let remoteURL { + AsyncImage(url: remoteURL) { phase in + switch phase { + case .empty: + placeholder(systemImage: "photo", text: image.alternativeText) + case .success(let loadedImage): + renderedImage(loadedImage) + case .failure: + placeholder(systemImage: "exclamationmark.triangle", text: fallbackText) + @unknown default: + placeholder(systemImage: "photo", text: fallbackText) + } + } + } else { + placeholder(systemImage: "photo", text: fallbackText) + } + } + + private func renderedImage(_ image: Image) -> some View { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityLabel(accessibilityLabel) + } + + private func placeholder(systemImage: String, text: String) -> some View { + HStack(spacing: 10) { + Image(systemName: systemImage) + .font(.system(size: 18, weight: .semibold)) + Text(text.isEmpty ? "Image" : text) + .font(.caption) + .lineLimit(2) + .multilineTextAlignment(.leading) + Spacer(minLength: 0) + } + .foregroundStyle(Color(config.paragraphStyle.textColor).opacity(0.72)) + .padding(12) + .frame(maxWidth: .infinity, minHeight: 72, alignment: .leading) + .background(Color(config.paragraphStyle.textColor).opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .accessibilityLabel(accessibilityLabel) + } + + private var uiImage: UIImage? { + if let image = UIImage(named: image.source) { + return image + } + + if let fileURL, let image = UIImage(contentsOfFile: fileURL.path) { + return image + } + + return nil + } + + private var remoteURL: URL? { + guard let url = URL.fromMixedEncodingString(image.source), + let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https" + else { + return nil + } + + return url + } + + private var fileURL: URL? { + guard let url = URL.fromMixedEncodingString(image.source), + url.isFileURL + else { + return nil + } + + return url + } + + private var fallbackText: String { + if !image.alternativeText.isEmpty { + return image.alternativeText + } + + return image.source + } + + private var accessibilityLabel: String { + if !image.alternativeText.isEmpty { + return image.alternativeText + } + + return image.title ?? image.source + } +} diff --git a/Tests/MarkdownTextTests/ImageAndCustomViewSnapshotTests.swift b/Tests/MarkdownTextTests/ImageAndCustomViewSnapshotTests.swift new file mode 100644 index 0000000..2755a34 --- /dev/null +++ b/Tests/MarkdownTextTests/ImageAndCustomViewSnapshotTests.swift @@ -0,0 +1,116 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +// + +@testable import SwiftStreamingMarkdown +import SwiftUI +import XCTest + +@MainActor +final class ImageAndCustomViewSnapshotTests: SnapshotTestCase { + + private let parser: MarkdownParser = MarkdownParserImpl() + + func testImageWithCustomViewBuilder() async throws { + let config = MarkdownRenderConfig( + customViewBuilder: MarkdownCustomViewBuilder(id: "snapshot-image-builder") { customView in + guard case .image(let image) = customView else { return nil } + return AnyView(SnapshotMarkdownImageView(image: image)) + } + ) + let renderable = await parser.parse( + text: "![Streaming Markdown sample](snapshot-image)", + config: config + ) + + let view = CanvasView { + DocumentView(renderableDocument: renderable, config: config) + .padding(.horizontal, 24) + } + + assert(view) + } + + func testCustomBlockViewBuilder() async throws { + let config = MarkdownRenderConfig( + customViewBuilder: MarkdownCustomViewBuilder(id: "snapshot-callout-builder") { customView in + guard case .block(let block) = customView, block.name == "Callout" else { return nil } + return AnyView(SnapshotCalloutView(block: block)) + } + ) + let renderable = await parser.parse( + text: """ + @Callout(title: "Custom rendering", icon: "wand.and.stars") { + This directive is rendered by a custom SwiftUI view. + } + """, + config: config + ) + + let view = CanvasView { + DocumentView(renderableDocument: renderable, config: config) + .padding(.horizontal, 24) + } + + assert(view) + } +} + +private struct SnapshotMarkdownImageView: View { + let image: MarkdownImage + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + RoundedRectangle(cornerRadius: 8) + .fill( + LinearGradient( + colors: [ + Color(red: 0.08, green: 0.24, blue: 0.36), + Color(red: 0.44, green: 0.80, blue: 0.72) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(height: 156) + .overlay(alignment: .leading) { + VStack(alignment: .leading, spacing: 8) { + Text("# Markdown") + .font(.system(size: 28, weight: .bold, design: .monospaced)) + Text("**streaming** `tokens`") + .font(.system(size: 16, weight: .semibold, design: .monospaced)) + } + .foregroundStyle(.white) + .padding(20) + } + + Text(image.alternativeText) + .font(.caption) + .foregroundStyle(.secondary) + } + } +} + +private struct SnapshotCalloutView: View { + let block: MarkdownCustomBlock + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Image(systemName: block.arguments["icon"] ?? "sparkles") + .font(.system(size: 14, weight: .semibold)) + Text(block.arguments["title"] ?? block.name) + .font(.headline) + } + + Text(block.content.attributedStrings.map(\.string).joined(separator: " ")) + .font(.body) + } + .foregroundStyle(Color.Theme.Foreground.Primary.Primary750) + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.Theme.Overlay.Black.Black5) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } +} diff --git a/Tests/MarkdownTextTests/ImageAndCustomViewTests.swift b/Tests/MarkdownTextTests/ImageAndCustomViewTests.swift new file mode 100644 index 0000000..060c2d8 --- /dev/null +++ b/Tests/MarkdownTextTests/ImageAndCustomViewTests.swift @@ -0,0 +1,91 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +// + +import Markdown +@testable import SwiftStreamingMarkdown +import UIKit +import XCTest + +final class ImageAndCustomViewTests: XCTestCase { + + private let parser: MarkdownParser = MarkdownParserImpl() + + func testStandaloneImageConvertsToImageRenderable() async { + let document = await parser.parse(text: "![Streaming Markdown](StreamingMarkdownSample)") + let renderable = await RenderableDocument(document: document, config: .default) + + XCTAssertEqual(renderable.renderables.count, 1) + guard case .image(_, let image) = renderable.renderables.first else { + XCTFail("Expected image renderable") + return + } + + XCTAssertEqual(image.source, "StreamingMarkdownSample") + XCTAssertEqual(image.alternativeText, "Streaming Markdown") + } + + func testMixedImageParagraphSplitsIntoRenderableBlocks() async { + let document = await parser.parse(text: "Before ![Diagram](diagram) after") + let renderable = await RenderableDocument(document: document, config: .default) + + XCTAssertEqual(renderable.renderables.count, 3) + + guard case .paragraph(_, let leadingText) = renderable.renderables[0], + case .image(_, let image) = renderable.renderables[1], + case .paragraph(_, let trailingText) = renderable.renderables[2] else { + XCTFail("Expected paragraph, image, paragraph renderables") + return + } + + XCTAssertEqual(leadingText.string, "Before ") + XCTAssertEqual(image.source, "diagram") + XCTAssertEqual(image.alternativeText, "Diagram") + XCTAssertEqual(trailingText.string, " after") + } + + func testBlockDirectiveConvertsToCustomBlockRenderable() async { + let text = """ + @Callout(title: "Heads up", icon: lightbulb) { + This is **custom** content. + } + """ + + let result = await parser.parse( + text: text, + option: .init(speculativeRewrite: false, parseBlockDirectives: true) + ) + let renderable = await RenderableDocument(document: result.document, config: .default) + + XCTAssertEqual(renderable.renderables.count, 1) + guard case .customView(_, let block) = renderable.renderables.first else { + XCTFail("Expected custom view renderable") + return + } + + XCTAssertEqual(block.name, "Callout") + XCTAssertEqual(block.arguments["title"], "Heads up") + XCTAssertEqual(block.arguments["icon"], "lightbulb") + XCTAssertEqual(block.content.attributedStrings.map(\.string).joined(), "This is custom content.") + } + + func testConfigBuildersPreserveCitationAndCustomViewBuilder() { + let citationConfig = MarkdownRenderConfig.CitationConfig( + isEnabled: false, + font: .systemFont(ofSize: 12), + textColor: .red, + backgroundColor: .blue + ) + let customViewBuilder = MarkdownCustomViewBuilder(id: "test-builder") { _ in nil } + let config = MarkdownRenderConfig( + citationConfig: citationConfig, + customViewBuilder: customViewBuilder + ) + .withShouldAnimateText(value: true) + .withParagraphStyle(value: .init(textFonts: Typography.baseTextFonts, textColor: .green)) + + XCTAssertFalse(config.citationConfig.isEnabled) + XCTAssertEqual(config.customViewBuilder, customViewBuilder) + } +} diff --git a/Tests/MarkdownTextTests/__Snapshots__/ImageAndCustomViewSnapshotTests/testCustomBlockViewBuilder.iPadPro11-light-US-en.png b/Tests/MarkdownTextTests/__Snapshots__/ImageAndCustomViewSnapshotTests/testCustomBlockViewBuilder.iPadPro11-light-US-en.png new file mode 100644 index 0000000..deb1388 Binary files /dev/null and b/Tests/MarkdownTextTests/__Snapshots__/ImageAndCustomViewSnapshotTests/testCustomBlockViewBuilder.iPadPro11-light-US-en.png differ diff --git a/Tests/MarkdownTextTests/__Snapshots__/ImageAndCustomViewSnapshotTests/testCustomBlockViewBuilder.iPadPro11Landscape-dark-US-en.png b/Tests/MarkdownTextTests/__Snapshots__/ImageAndCustomViewSnapshotTests/testCustomBlockViewBuilder.iPadPro11Landscape-dark-US-en.png new file mode 100644 index 0000000..8f380e8 Binary files /dev/null and b/Tests/MarkdownTextTests/__Snapshots__/ImageAndCustomViewSnapshotTests/testCustomBlockViewBuilder.iPadPro11Landscape-dark-US-en.png differ diff --git a/Tests/MarkdownTextTests/__Snapshots__/ImageAndCustomViewSnapshotTests/testCustomBlockViewBuilder.iPhone16-dark-US-en.png b/Tests/MarkdownTextTests/__Snapshots__/ImageAndCustomViewSnapshotTests/testCustomBlockViewBuilder.iPhone16-dark-US-en.png new file mode 100644 index 0000000..1e29334 Binary files /dev/null and b/Tests/MarkdownTextTests/__Snapshots__/ImageAndCustomViewSnapshotTests/testCustomBlockViewBuilder.iPhone16-dark-US-en.png differ diff --git a/Tests/MarkdownTextTests/__Snapshots__/ImageAndCustomViewSnapshotTests/testCustomBlockViewBuilder.iPhone16-light-US-en.png b/Tests/MarkdownTextTests/__Snapshots__/ImageAndCustomViewSnapshotTests/testCustomBlockViewBuilder.iPhone16-light-US-en.png new file mode 100644 index 0000000..917f2b3 Binary files /dev/null and b/Tests/MarkdownTextTests/__Snapshots__/ImageAndCustomViewSnapshotTests/testCustomBlockViewBuilder.iPhone16-light-US-en.png differ diff --git a/Tests/MarkdownTextTests/__Snapshots__/ImageAndCustomViewSnapshotTests/testImageWithCustomViewBuilder.iPadPro11-light-US-en.png b/Tests/MarkdownTextTests/__Snapshots__/ImageAndCustomViewSnapshotTests/testImageWithCustomViewBuilder.iPadPro11-light-US-en.png new file mode 100644 index 0000000..8b46bcb Binary files /dev/null and b/Tests/MarkdownTextTests/__Snapshots__/ImageAndCustomViewSnapshotTests/testImageWithCustomViewBuilder.iPadPro11-light-US-en.png differ diff --git a/Tests/MarkdownTextTests/__Snapshots__/ImageAndCustomViewSnapshotTests/testImageWithCustomViewBuilder.iPadPro11Landscape-dark-US-en.png b/Tests/MarkdownTextTests/__Snapshots__/ImageAndCustomViewSnapshotTests/testImageWithCustomViewBuilder.iPadPro11Landscape-dark-US-en.png new file mode 100644 index 0000000..66b89a0 Binary files /dev/null and b/Tests/MarkdownTextTests/__Snapshots__/ImageAndCustomViewSnapshotTests/testImageWithCustomViewBuilder.iPadPro11Landscape-dark-US-en.png differ diff --git a/Tests/MarkdownTextTests/__Snapshots__/ImageAndCustomViewSnapshotTests/testImageWithCustomViewBuilder.iPhone16-dark-US-en.png b/Tests/MarkdownTextTests/__Snapshots__/ImageAndCustomViewSnapshotTests/testImageWithCustomViewBuilder.iPhone16-dark-US-en.png new file mode 100644 index 0000000..e989f0b Binary files /dev/null and b/Tests/MarkdownTextTests/__Snapshots__/ImageAndCustomViewSnapshotTests/testImageWithCustomViewBuilder.iPhone16-dark-US-en.png differ diff --git a/Tests/MarkdownTextTests/__Snapshots__/ImageAndCustomViewSnapshotTests/testImageWithCustomViewBuilder.iPhone16-light-US-en.png b/Tests/MarkdownTextTests/__Snapshots__/ImageAndCustomViewSnapshotTests/testImageWithCustomViewBuilder.iPhone16-light-US-en.png new file mode 100644 index 0000000..55f010c Binary files /dev/null and b/Tests/MarkdownTextTests/__Snapshots__/ImageAndCustomViewSnapshotTests/testImageWithCustomViewBuilder.iPhone16-light-US-en.png differ