Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "streaming-markdown.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
16 changes: 16 additions & 0 deletions Sources/MarkdownText/Block/BlockConvertible.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
}
}
37 changes: 37 additions & 0 deletions Sources/MarkdownText/Block/BlockDirective+.swift
Original file line number Diff line number Diff line change
@@ -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

Comment on lines +6 to +9
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")
}
}
4 changes: 2 additions & 2 deletions Sources/MarkdownText/Block/Document+.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
}
}
2 changes: 1 addition & 1 deletion Sources/MarkdownText/Block/OrderedList+.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
113 changes: 73 additions & 40 deletions Sources/MarkdownText/Block/Paragraph+.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,49 @@ 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
if let kern = config.paragraphStyle.textFonts.preferredLetterSpacing {
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
}
}

Expand All @@ -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)
}
}
}
2 changes: 1 addition & 1 deletion Sources/MarkdownText/Block/UnorderedList+.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
25 changes: 25 additions & 0 deletions Sources/MarkdownText/Inline/Markdown+InlineConvertible.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading