From 71c424069f6694f35f012972f6a72772d3e4968f Mon Sep 17 00:00:00 2001 From: Guille Gonzalez Date: Wed, 5 Apr 2023 21:14:41 +0200 Subject: [PATCH] Refactor Markdown parsing and implement the `renderMarkdown()` method. (#210) --- .../Common/MarkdownContent+ColorScheme.swift | 112 ----- Sources/MarkdownUI/Content/Blocks/Block.swift | 118 ----- .../Content/CommonMarkExtension.swift | 31 -- .../MarkdownUI/Content/CommonMarkNode.swift | 155 ------ .../MarkdownUI/Content/Inlines/Inline.swift | 104 ---- .../{Content => DSL}/Blocks/Blockquote.swift | 2 +- .../Blocks/BulletedList.swift | 8 +- .../{Content => DSL}/Blocks/CodeBlock.swift | 2 +- .../{Content => DSL}/Blocks/Heading.swift | 2 +- .../Blocks/ListContentBuilder.swift | 0 .../{Content => DSL}/Blocks/ListItem.swift | 10 +- .../Blocks/MarkdownContent.swift | 10 +- .../Blocks/MarkdownContentBuilder.swift | 0 .../Blocks/NumberedList.swift | 8 +- .../{Content => DSL}/Blocks/Paragraph.swift | 2 +- .../{Content => DSL}/Blocks/TaskList.swift | 10 +- .../Blocks/TaskListContentBuilder.swift | 0 .../Blocks/TaskListItem.swift | 10 +- .../{Content => DSL}/Blocks/TextTable.swift | 46 +- .../Blocks/TextTableColumn.swift | 0 .../Blocks/TextTableColumnAlignment.swift | 0 .../Blocks/TextTableColumnBuilder.swift | 0 .../Blocks/TextTableRow.swift | 0 .../Blocks/TextTableRowBuilder.swift | 0 .../Blocks/ThematicBreak.swift | 0 .../{Content => DSL}/Inlines/Code.swift | 0 .../{Content => DSL}/Inlines/Emphasis.swift | 2 +- .../Inlines/InlineContent.swift | 4 +- .../Inlines/InlineContentBuilder.swift | 0 .../Inlines/InlineImage.swift | 0 .../{Content => DSL}/Inlines/InlineLink.swift | 0 .../{Content => DSL}/Inlines/LineBreak.swift | 0 .../{Content => DSL}/Inlines/SoftBreak.swift | 0 .../Inlines/Strikethrough.swift | 2 +- .../{Content => DSL}/Inlines/Strong.swift | 2 +- .../AssetImageProvider.swift | 0 .../AssetInlineImageProvider.swift | 0 .../CodeSyntaxHighlighter.swift | 0 .../DefaultImageProvider.swift | 0 .../DefaultImageView/DefaultImageLoader.swift | 0 .../DefaultImageView/DefaultImageView.swift | 0 .../DefaultImageViewModel.swift | 0 .../DefaultInlineImageProvider.swift | 0 .../Image+PlatformImage.swift | 0 .../ImageProvider.swift | 0 .../InlineImageProvider.swift | 0 .../MarkdownUI/Parser/BlockNode+Rewrite.swift | 104 ++++ Sources/MarkdownUI/Parser/BlockNode.swift | 45 ++ .../Parser/InlineNode+Collect.swift | 13 + .../Parser/InlineNode+Rewrite.swift | 15 + Sources/MarkdownUI/Parser/InlineNode.swift | 52 ++ .../MarkdownUI/Parser/MarkdownParser.swift | 470 ++++++++++++++++++ .../AttributedStringInlineRenderer.swift | 133 +++++ .../Renderer/InlineTextStyles.swift | 9 + .../Renderer/TextInlineRenderer.swift | 65 +++ .../Utility/BlockNode+ColorSchemeImage.swift | 34 ++ .../{Common => Utility}/Color+RGBA.swift | 0 .../{Common => Utility}/Deprecations.swift | 0 .../{Common => Utility}/FlowLayout.swift | 0 .../{Common => Utility}/Indexed.swift | 0 .../Utility/InlineNode+PlainText.swift | 23 + .../Utility/InlineNode+RawImageData.swift | 22 + .../{Common => Utility}/Int+Roman.swift | 0 .../{Common => Utility}/RelativeSize.swift | 0 .../{Common => Utility}/ResizeToFit.swift | 0 .../String+KebabCase.swift | 0 .../MarkdownUI/Views/Blocks/Block+View.swift | 41 -- .../Views/Blocks/BlockNode+View.swift | 39 ++ .../Views/Blocks/BlockSequence.swift | 4 +- .../Views/Blocks/BulletedListView.swift | 10 +- .../Views/Blocks/CodeBlockView.swift | 8 +- .../MarkdownUI/Views/Blocks/ImageFlow.swift | 15 +- .../Views/Blocks/ListItemSequence.swift | 4 +- .../Views/Blocks/ListItemView.swift | 6 +- .../Views/Blocks/NumberedListView.swift | 10 +- .../MarkdownUI/Views/Blocks/TableCell.swift | 10 +- .../MarkdownUI/Views/Blocks/TableView.swift | 14 +- .../Views/Blocks/TaskListItemView.swift | 6 +- .../Views/Blocks/TaskListView.swift | 10 +- .../Inlines/AttributedString+Inline.swift | 67 --- .../MarkdownUI/Views/Inlines/ImageView.swift | 33 +- .../MarkdownUI/Views/Inlines/InlineText.swift | 21 +- .../Views/Inlines/Text+Inline.swift | 36 -- Sources/MarkdownUI/Views/Markdown.swift | 4 +- .../InlineContentBuilderTests.swift | 10 +- .../ListContentBuilderTests.swift | 26 +- .../MarkdownContentBuilderTests.swift | 28 +- .../MarkdownContentTests.swift | 175 ++++--- .../TaskListContentBuilderTests.swift | 26 +- 89 files changed, 1334 insertions(+), 894 deletions(-) delete mode 100644 Sources/MarkdownUI/Common/MarkdownContent+ColorScheme.swift delete mode 100644 Sources/MarkdownUI/Content/Blocks/Block.swift delete mode 100644 Sources/MarkdownUI/Content/CommonMarkExtension.swift delete mode 100644 Sources/MarkdownUI/Content/CommonMarkNode.swift delete mode 100644 Sources/MarkdownUI/Content/Inlines/Inline.swift rename Sources/MarkdownUI/{Content => DSL}/Blocks/Blockquote.swift (93%) rename Sources/MarkdownUI/{Content => DSL}/Blocks/BulletedList.swift (93%) rename Sources/MarkdownUI/{Content => DSL}/Blocks/CodeBlock.swift (93%) rename Sources/MarkdownUI/{Content => DSL}/Blocks/Heading.swift (92%) rename Sources/MarkdownUI/{Content => DSL}/Blocks/ListContentBuilder.swift (100%) rename Sources/MarkdownUI/{Content => DSL}/Blocks/ListItem.swift (75%) rename Sources/MarkdownUI/{Content => DSL}/Blocks/MarkdownContent.swift (90%) rename Sources/MarkdownUI/{Content => DSL}/Blocks/MarkdownContentBuilder.swift (100%) rename Sources/MarkdownUI/{Content => DSL}/Blocks/NumberedList.swift (93%) rename Sources/MarkdownUI/{Content => DSL}/Blocks/Paragraph.swift (93%) rename Sources/MarkdownUI/{Content => DSL}/Blocks/TaskList.swift (91%) rename Sources/MarkdownUI/{Content => DSL}/Blocks/TaskListContentBuilder.swift (100%) rename Sources/MarkdownUI/{Content => DSL}/Blocks/TaskListItem.swift (73%) rename Sources/MarkdownUI/{Content => DSL}/Blocks/TextTable.swift (74%) rename Sources/MarkdownUI/{Content => DSL}/Blocks/TextTableColumn.swift (100%) rename Sources/MarkdownUI/{Content => DSL}/Blocks/TextTableColumnAlignment.swift (100%) rename Sources/MarkdownUI/{Content => DSL}/Blocks/TextTableColumnBuilder.swift (100%) rename Sources/MarkdownUI/{Content => DSL}/Blocks/TextTableRow.swift (100%) rename Sources/MarkdownUI/{Content => DSL}/Blocks/TextTableRowBuilder.swift (100%) rename Sources/MarkdownUI/{Content => DSL}/Blocks/ThematicBreak.swift (100%) rename Sources/MarkdownUI/{Content => DSL}/Inlines/Code.swift (100%) rename Sources/MarkdownUI/{Content => DSL}/Inlines/Emphasis.swift (92%) rename Sources/MarkdownUI/{Content => DSL}/Inlines/InlineContent.swift (95%) rename Sources/MarkdownUI/{Content => DSL}/Inlines/InlineContentBuilder.swift (100%) rename Sources/MarkdownUI/{Content => DSL}/Inlines/InlineImage.swift (100%) rename Sources/MarkdownUI/{Content => DSL}/Inlines/InlineLink.swift (100%) rename Sources/MarkdownUI/{Content => DSL}/Inlines/LineBreak.swift (100%) rename Sources/MarkdownUI/{Content => DSL}/Inlines/SoftBreak.swift (100%) rename Sources/MarkdownUI/{Content => DSL}/Inlines/Strikethrough.swift (91%) rename Sources/MarkdownUI/{Content => DSL}/Inlines/Strong.swift (92%) rename Sources/MarkdownUI/{Extensions => Extensibility}/AssetImageProvider.swift (100%) rename Sources/MarkdownUI/{Extensions => Extensibility}/AssetInlineImageProvider.swift (100%) rename Sources/MarkdownUI/{Extensions => Extensibility}/CodeSyntaxHighlighter.swift (100%) rename Sources/MarkdownUI/{Extensions => Extensibility}/DefaultImageProvider.swift (100%) rename Sources/MarkdownUI/{Extensions => Extensibility}/DefaultImageView/DefaultImageLoader.swift (100%) rename Sources/MarkdownUI/{Extensions => Extensibility}/DefaultImageView/DefaultImageView.swift (100%) rename Sources/MarkdownUI/{Extensions => Extensibility}/DefaultImageView/DefaultImageViewModel.swift (100%) rename Sources/MarkdownUI/{Extensions => Extensibility}/DefaultInlineImageProvider.swift (100%) rename Sources/MarkdownUI/{Extensions => Extensibility}/Image+PlatformImage.swift (100%) rename Sources/MarkdownUI/{Extensions => Extensibility}/ImageProvider.swift (100%) rename Sources/MarkdownUI/{Extensions => Extensibility}/InlineImageProvider.swift (100%) create mode 100644 Sources/MarkdownUI/Parser/BlockNode+Rewrite.swift create mode 100644 Sources/MarkdownUI/Parser/BlockNode.swift create mode 100644 Sources/MarkdownUI/Parser/InlineNode+Collect.swift create mode 100644 Sources/MarkdownUI/Parser/InlineNode+Rewrite.swift create mode 100644 Sources/MarkdownUI/Parser/InlineNode.swift create mode 100644 Sources/MarkdownUI/Parser/MarkdownParser.swift create mode 100644 Sources/MarkdownUI/Renderer/AttributedStringInlineRenderer.swift create mode 100644 Sources/MarkdownUI/Renderer/InlineTextStyles.swift create mode 100644 Sources/MarkdownUI/Renderer/TextInlineRenderer.swift create mode 100644 Sources/MarkdownUI/Utility/BlockNode+ColorSchemeImage.swift rename Sources/MarkdownUI/{Common => Utility}/Color+RGBA.swift (100%) rename Sources/MarkdownUI/{Common => Utility}/Deprecations.swift (100%) rename Sources/MarkdownUI/{Common => Utility}/FlowLayout.swift (100%) rename Sources/MarkdownUI/{Common => Utility}/Indexed.swift (100%) create mode 100644 Sources/MarkdownUI/Utility/InlineNode+PlainText.swift create mode 100644 Sources/MarkdownUI/Utility/InlineNode+RawImageData.swift rename Sources/MarkdownUI/{Common => Utility}/Int+Roman.swift (100%) rename Sources/MarkdownUI/{Common => Utility}/RelativeSize.swift (100%) rename Sources/MarkdownUI/{Common => Utility}/ResizeToFit.swift (100%) rename Sources/MarkdownUI/{Common => Utility}/String+KebabCase.swift (100%) delete mode 100644 Sources/MarkdownUI/Views/Blocks/Block+View.swift create mode 100644 Sources/MarkdownUI/Views/Blocks/BlockNode+View.swift delete mode 100644 Sources/MarkdownUI/Views/Inlines/AttributedString+Inline.swift delete mode 100644 Sources/MarkdownUI/Views/Inlines/Text+Inline.swift diff --git a/Sources/MarkdownUI/Common/MarkdownContent+ColorScheme.swift b/Sources/MarkdownUI/Common/MarkdownContent+ColorScheme.swift deleted file mode 100644 index e3820c02..00000000 --- a/Sources/MarkdownUI/Common/MarkdownContent+ColorScheme.swift +++ /dev/null @@ -1,112 +0,0 @@ -import SwiftUI - -extension MarkdownContent { - func colorScheme(_ colorScheme: ColorScheme) -> Self { - .init(blocks: self.blocks.colorScheme(colorScheme)) - } -} - -extension Block { - fileprivate func colorScheme(_ colorScheme: ColorScheme) -> Self { - switch self { - case .blockquote(let array): - return .blockquote(array.colorScheme(colorScheme)) - case .taskList(let tight, let items): - return .taskList(tight: tight, items: items.colorScheme(colorScheme)) - case .bulletedList(let tight, let items): - return .bulletedList(tight: tight, items: items.colorScheme(colorScheme)) - case .numberedList(let tight, let start, let items): - return .numberedList(tight: tight, start: start, items: items.colorScheme(colorScheme)) - case .codeBlock, .htmlBlock, .thematicBreak: - return self - case .paragraph(let array): - return .paragraph(array.colorScheme(colorScheme)) - case .heading(let level, let text): - return .heading(level: level, text: text.colorScheme(colorScheme)) - case .table(let columnAlignments, let rows): - return .table( - columnAlignments: columnAlignments, - rows: rows.map { columns in - columns.map { cell in - cell.colorScheme(colorScheme) - } - } - ) - } - } -} - -extension Array where Element == Block { - fileprivate func colorScheme(_ colorScheme: ColorScheme) -> Self { - self.map { $0.colorScheme(colorScheme) } - } -} - -extension TaskListItem { - fileprivate func colorScheme(_ colorScheme: ColorScheme) -> Self { - .init(isCompleted: self.isCompleted, blocks: self.blocks.colorScheme(colorScheme)) - } -} - -extension Array where Element == TaskListItem { - fileprivate func colorScheme(_ colorScheme: ColorScheme) -> Self { - self.map { $0.colorScheme(colorScheme) } - } -} - -extension ListItem { - fileprivate func colorScheme(_ colorScheme: ColorScheme) -> Self { - .init(blocks: self.blocks.colorScheme(colorScheme)) - } -} - -extension Array where Element == ListItem { - fileprivate func colorScheme(_ colorScheme: ColorScheme) -> Self { - self.map { $0.colorScheme(colorScheme) } - } -} - -extension Inline { - fileprivate func colorScheme(_ colorScheme: ColorScheme) -> Inline? { - switch self { - case .text, .softBreak, .lineBreak, .code, .html: - return self - case .emphasis(let children): - return .emphasis(children.colorScheme(colorScheme)) - case .strong(let children): - return .strong(children.colorScheme(colorScheme)) - case .strikethrough(let children): - return .strikethrough(children.colorScheme(colorScheme)) - case .link(let destination, let children): - return .link(destination: destination, children: children.colorScheme(colorScheme)) - case .image(let source, _): - guard let url = URL(string: source) else { - return self - } - return url.matchesColorScheme(colorScheme) ? self : nil - } - } -} - -extension Array where Element == Inline { - fileprivate func colorScheme(_ colorScheme: ColorScheme) -> Self { - self.compactMap { $0.colorScheme(colorScheme) } - } -} - -extension URL { - fileprivate func matchesColorScheme(_ colorScheme: ColorScheme) -> Bool { - guard let fragment = self.fragment?.lowercased() else { - return true - } - - switch colorScheme { - case .light: - return fragment != "gh-dark-mode-only" - case .dark: - return fragment != "gh-light-mode-only" - @unknown default: - return true - } - } -} diff --git a/Sources/MarkdownUI/Content/Blocks/Block.swift b/Sources/MarkdownUI/Content/Blocks/Block.swift deleted file mode 100644 index 14d60095..00000000 --- a/Sources/MarkdownUI/Content/Blocks/Block.swift +++ /dev/null @@ -1,118 +0,0 @@ -import Foundation -@_implementationOnly import cmark_gfm - -enum Block: Hashable { - case blockquote([Block]) - case taskList(tight: Bool, items: [TaskListItem]) - case bulletedList(tight: Bool, items: [ListItem]) - case numberedList(tight: Bool, start: Int, items: [ListItem]) - case codeBlock(info: String?, content: String) - case htmlBlock(String) - case paragraph([Inline]) - case heading(level: Int, text: [Inline]) - case table(columnAlignments: [TextTableColumnAlignment?], rows: [[[Inline]]]) - case thematicBreak -} - -extension Block { - init?(node: CommonMarkNode) { - switch node.type { - case CMARK_NODE_BLOCK_QUOTE: - self = .blockquote(node.children.compactMap(Block.init(node:))) - case CMARK_NODE_LIST where node.hasTaskItems: - self = .taskList( - tight: node.listTight, - items: node.children.compactMap(TaskListItem.init(node:)) - ) - case CMARK_NODE_LIST where node.listType == CMARK_BULLET_LIST: - self = .bulletedList( - tight: node.listTight, - items: node.children.compactMap(ListItem.init(node:)) - ) - case CMARK_NODE_LIST where node.listType == CMARK_ORDERED_LIST: - self = .numberedList( - tight: node.listTight, - start: node.listStart, - items: node.children.compactMap(ListItem.init(node:)) - ) - case CMARK_NODE_CODE_BLOCK: - self = .codeBlock(info: node.fenceInfo, content: node.literal!) - case CMARK_NODE_HTML_BLOCK: - self = .htmlBlock(node.literal!) - case CMARK_NODE_PARAGRAPH: - self = .paragraph(node.children.compactMap(Inline.init(node:))) - case CMARK_NODE_HEADING: - self = .heading(level: node.headingLevel, text: node.children.compactMap(Inline.init(node:))) - case CMARK_NODE_TABLE: - self = .table( - columnAlignments: node.tableAlignments.map(TextTableColumnAlignment.init), - rows: node.children.compactMap { rowNode in - guard rowNode.type == CMARK_NODE_TABLE_ROW else { - return nil - } - return rowNode.children.compactMap { cellNode in - guard cellNode.type == CMARK_NODE_TABLE_CELL else { - return nil - } - return cellNode.children.compactMap(Inline.init(node:)) - } - } - ) - case CMARK_NODE_THEMATIC_BREAK: - self = .thematicBreak - default: - assertionFailure("Unknown block type '\(node.typeString)'") - return nil - } - } - - var isParagraph: Bool { - guard case .paragraph = self else { return false } - return true - } -} - -extension Array where Element == Block { - init(markdown: String) { - let node = CommonMarkNode(markdown: markdown, extensions: .all, options: CMARK_OPT_DEFAULT) - let blocks = node?.children.compactMap(Block.init(node:)) ?? [] - - self.init(blocks) - } -} - -extension ListItem { - fileprivate init?(node: CommonMarkNode) { - guard node.type == CMARK_NODE_ITEM else { - return nil - } - self.init(blocks: .init(node.children.compactMap(Block.init(node:)))) - } -} - -extension TaskListItem { - fileprivate init?(node: CommonMarkNode) { - guard node.type == CMARK_NODE_ITEM else { - return nil - } - self.init( - isCompleted: node.isTaskListItemChecked, - blocks: .init(node.children.compactMap(Block.init(node:))) - ) - } -} - -extension TextTableColumnAlignment { - fileprivate init?(_ character: Character) { - switch character { - case "l": - self = .leading - case "c": - self = .center - case "r": - self = .trailing - default: - return nil - } - } -} diff --git a/Sources/MarkdownUI/Content/CommonMarkExtension.swift b/Sources/MarkdownUI/Content/CommonMarkExtension.swift deleted file mode 100644 index 7197ed43..00000000 --- a/Sources/MarkdownUI/Content/CommonMarkExtension.swift +++ /dev/null @@ -1,31 +0,0 @@ -import Foundation - -struct CommonMarkExtension: Hashable, RawRepresentable { - let rawValue: String - - init(rawValue: String) { - self.rawValue = rawValue - } -} - -extension CommonMarkExtension { - static let autolink = Self(rawValue: "autolink") - static let strikethrough = Self(rawValue: "strikethrough") - static let tagfilter = Self(rawValue: "tagfilter") - static let tasklist = Self(rawValue: "tasklist") -} - -@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) -extension CommonMarkExtension { - static let table = Self(rawValue: "table") -} - -extension Set where Element == CommonMarkExtension { - static let all: Self = { - if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { - return [.autolink, .strikethrough, .table, .tagfilter, .tasklist] - } else { - return [.autolink, .strikethrough, .tagfilter, .tasklist] - } - }() -} diff --git a/Sources/MarkdownUI/Content/CommonMarkNode.swift b/Sources/MarkdownUI/Content/CommonMarkNode.swift deleted file mode 100644 index 3fb790ff..00000000 --- a/Sources/MarkdownUI/Content/CommonMarkNode.swift +++ /dev/null @@ -1,155 +0,0 @@ -import Foundation -@_implementationOnly import cmark_gfm - -class CommonMarkNode { - private let pointer: UnsafeMutablePointer - - init(pointer: UnsafeMutablePointer) { - self.pointer = pointer - } - - convenience init?(markdown: String, extensions: Set, options: Int32) { - cmark_gfm_core_extensions_ensure_registered() - - let parser = cmark_parser_new(options) - defer { - cmark_parser_free(parser) - } - - for `extension` in extensions { - guard let syntaxExtension = cmark_find_syntax_extension(`extension`.rawValue) else { - continue - } - cmark_parser_attach_syntax_extension(parser, syntaxExtension) - } - - cmark_parser_feed(parser, markdown, markdown.utf8.count) - - guard let pointer = cmark_parser_finish(parser) else { - return nil - } - - self.init(pointer: pointer) - } - - deinit { - guard type == CMARK_NODE_DOCUMENT else { - return - } - cmark_node_free(pointer) - } -} - -extension CommonMarkNode { - struct Sequence: Swift.Sequence { - struct Iterator: IteratorProtocol { - private var pointer: UnsafeMutablePointer? - - init(pointer: UnsafeMutablePointer?) { - self.pointer = pointer - } - - mutating func next() -> CommonMarkNode? { - guard let pointer = pointer else { - return nil - } - - defer { - self.pointer = cmark_node_next(pointer) - } - - return CommonMarkNode(pointer: pointer) - } - } - - private let pointer: UnsafeMutablePointer? - - init(pointer: UnsafeMutablePointer?) { - self.pointer = pointer - } - - func makeIterator() -> Iterator { - Iterator(pointer: pointer) - } - } - - var children: CommonMarkNode.Sequence { - .init(pointer: cmark_node_first_child(pointer)) - } -} - -extension CommonMarkNode { - var type: cmark_node_type { - cmark_node_get_type(pointer) - } - - var typeString: String { - String(cString: cmark_node_get_type_string(pointer)) - } - - var literal: String? { - guard let literal = cmark_node_get_literal(pointer) else { return nil } - return String(cString: literal) - } - - var url: String? { - guard let url = cmark_node_get_url(pointer) else { return nil } - return String(cString: url) - } - - var title: String? { - guard let title = cmark_node_get_title(pointer) else { return nil } - return String(cString: title) - } - - var fenceInfo: String? { - guard let fenceInfo = cmark_node_get_fence_info(pointer) else { return nil } - return String(cString: fenceInfo) - } - - var listType: cmark_list_type { - cmark_node_get_list_type(pointer) - } - - var hasTaskItems: Bool { - children.contains { node in - node.isTaskListItem - } - } - - var isTaskListItem: Bool { - type == CMARK_NODE_ITEM && typeString == "tasklist" - } - - var isTaskListItemChecked: Bool { - cmark_gfm_extensions_get_tasklist_item_checked(pointer) - } - - var listStart: Int { - Int(cmark_node_get_list_start(pointer)) - } - - var listTight: Bool { - cmark_node_get_list_tight(pointer) != 0 - } - - var headingLevel: Int { - Int(cmark_node_get_heading_level(pointer)) - } - - var tableColumns: Int { - Int(cmark_gfm_extensions_get_table_columns(pointer)) - } - - var tableAlignments: [Character] { - UnsafeBufferPointer( - start: cmark_gfm_extensions_get_table_alignments(pointer), - count: tableColumns - ) - .map { Character(.init($0)) } - } - - var isTableHeader: Bool { - (cmark_gfm_extensions_get_table_row_is_header(pointer) != 0) - } -} diff --git a/Sources/MarkdownUI/Content/Inlines/Inline.swift b/Sources/MarkdownUI/Content/Inlines/Inline.swift deleted file mode 100644 index 40efc46a..00000000 --- a/Sources/MarkdownUI/Content/Inlines/Inline.swift +++ /dev/null @@ -1,104 +0,0 @@ -import Foundation -@_implementationOnly import cmark_gfm - -enum Inline: Hashable { - case text(String) - case softBreak - case lineBreak - case code(String) - case html(String) - case emphasis([Inline]) - case strong([Inline]) - case strikethrough([Inline]) - case link(destination: String, children: [Inline]) - case image(source: String, children: [Inline]) -} - -extension Inline { - init?(node: CommonMarkNode) { - switch node.type { - case CMARK_NODE_TEXT: - self = .text(node.literal!) - case CMARK_NODE_SOFTBREAK: - self = .softBreak - case CMARK_NODE_LINEBREAK: - self = .lineBreak - case CMARK_NODE_CODE: - self = .code(node.literal!) - case CMARK_NODE_HTML_INLINE: - self = .html(node.literal!) - case CMARK_NODE_EMPH: - self = .emphasis(node.children.compactMap(Inline.init(node:))) - case CMARK_NODE_STRONG: - self = .strong(node.children.compactMap(Inline.init(node:))) - case CMARK_NODE_STRIKETHROUGH: - self = .strikethrough(node.children.compactMap(Inline.init(node:))) - case CMARK_NODE_LINK: - self = .link( - destination: node.url ?? "", - children: node.children.compactMap(Inline.init(node:)) - ) - case CMARK_NODE_IMAGE: - self = .image( - source: node.url ?? "", - children: node.children.compactMap(Inline.init(node:)) - ) - default: - assertionFailure("Unknown inline type '\(node.typeString)'") - return nil - } - } - - var text: String { - switch self { - case .text(let content): - return content - case .softBreak: - return " " - case .lineBreak: - return "\n" - case .code(let content): - return content - case .html(let content): - return content - case .emphasis(let children): - return children.text - case .strong(let children): - return children.text - case .strikethrough(let children): - return children.text - case .link(_, let children): - return children.text - case .image(_, let children): - return children.text - } - } -} - -extension Array where Element == Inline { - var text: String { - map(\.text).joined() - } -} - -extension Inline { - struct Image: Hashable { - var source: String? - var alt: String - var destination: String? - } - - var image: Image? { - switch self { - case let .image(source, children): - return .init(source: source, alt: children.text) - case let .link(destination, children) where children.count == 1: - guard case let .some(.image(source, children)) = children.first else { - return nil - } - return .init(source: source, alt: children.text, destination: destination) - default: - return nil - } - } -} diff --git a/Sources/MarkdownUI/Content/Blocks/Blockquote.swift b/Sources/MarkdownUI/DSL/Blocks/Blockquote.swift similarity index 93% rename from Sources/MarkdownUI/Content/Blocks/Blockquote.swift rename to Sources/MarkdownUI/DSL/Blocks/Blockquote.swift index a5decd6a..b9a33bd9 100644 --- a/Sources/MarkdownUI/Content/Blocks/Blockquote.swift +++ b/Sources/MarkdownUI/DSL/Blocks/Blockquote.swift @@ -21,7 +21,7 @@ import Foundation /// ![](BlockquoteContent) public struct Blockquote: MarkdownContentProtocol { public var _markdownContent: MarkdownContent { - .init(blocks: [.blockquote(content.blocks)]) + .init(blocks: [.blockquote(children: content.blocks)]) } private let content: MarkdownContent diff --git a/Sources/MarkdownUI/Content/Blocks/BulletedList.swift b/Sources/MarkdownUI/DSL/Blocks/BulletedList.swift similarity index 93% rename from Sources/MarkdownUI/Content/Blocks/BulletedList.swift rename to Sources/MarkdownUI/DSL/Blocks/BulletedList.swift index 2a74a49f..43fce0e1 100644 --- a/Sources/MarkdownUI/Content/Blocks/BulletedList.swift +++ b/Sources/MarkdownUI/DSL/Blocks/BulletedList.swift @@ -63,20 +63,20 @@ import Foundation /// ![](NestedBulletedList) public struct BulletedList: MarkdownContentProtocol { public var _markdownContent: MarkdownContent { - .init(blocks: [.bulletedList(tight: self.tight, items: self.items)]) + .init(blocks: [.bulletedList(isTight: self.tight, items: self.items)]) } private let tight: Bool - private let items: [ListItem] + private let items: [RawListItem] init(tight: Bool, items: [ListItem]) { // Force loose spacing if any of the items contains more than one paragraph let hasItemsWithMultipleParagraphs = items.contains { item in - item.blocks.filter(\.isParagraph).count > 1 + item.children.filter(\.isParagraph).count > 1 } self.tight = hasItemsWithMultipleParagraphs ? false : tight - self.items = items + self.items = items.map(\.children).map(RawListItem.init) } /// Creates a bulleted list with the specified items. diff --git a/Sources/MarkdownUI/Content/Blocks/CodeBlock.swift b/Sources/MarkdownUI/DSL/Blocks/CodeBlock.swift similarity index 93% rename from Sources/MarkdownUI/Content/Blocks/CodeBlock.swift rename to Sources/MarkdownUI/DSL/Blocks/CodeBlock.swift index d1673c7e..28679da9 100644 --- a/Sources/MarkdownUI/Content/Blocks/CodeBlock.swift +++ b/Sources/MarkdownUI/DSL/Blocks/CodeBlock.swift @@ -27,7 +27,7 @@ import Foundation /// ![](CodeBlock) public struct CodeBlock: MarkdownContentProtocol { public var _markdownContent: MarkdownContent { - .init(blocks: [.codeBlock(info: self.language, content: self.content)]) + .init(blocks: [.codeBlock(fenceInfo: self.language, content: self.content)]) } private let language: String? diff --git a/Sources/MarkdownUI/Content/Blocks/Heading.swift b/Sources/MarkdownUI/DSL/Blocks/Heading.swift similarity index 92% rename from Sources/MarkdownUI/Content/Blocks/Heading.swift rename to Sources/MarkdownUI/DSL/Blocks/Heading.swift index b5f925dc..78c0286d 100644 --- a/Sources/MarkdownUI/Content/Blocks/Heading.swift +++ b/Sources/MarkdownUI/DSL/Blocks/Heading.swift @@ -30,7 +30,7 @@ public struct Heading: MarkdownContentProtocol { } public var _markdownContent: MarkdownContent { - .init(blocks: [.heading(level: self.level.rawValue, text: self.content.inlines)]) + .init(blocks: [.heading(level: self.level.rawValue, content: self.content.inlines)]) } private let level: Level diff --git a/Sources/MarkdownUI/Content/Blocks/ListContentBuilder.swift b/Sources/MarkdownUI/DSL/Blocks/ListContentBuilder.swift similarity index 100% rename from Sources/MarkdownUI/Content/Blocks/ListContentBuilder.swift rename to Sources/MarkdownUI/DSL/Blocks/ListContentBuilder.swift diff --git a/Sources/MarkdownUI/Content/Blocks/ListItem.swift b/Sources/MarkdownUI/DSL/Blocks/ListItem.swift similarity index 75% rename from Sources/MarkdownUI/Content/Blocks/ListItem.swift rename to Sources/MarkdownUI/DSL/Blocks/ListItem.swift index e2029449..0d4cbd92 100644 --- a/Sources/MarkdownUI/Content/Blocks/ListItem.swift +++ b/Sources/MarkdownUI/DSL/Blocks/ListItem.swift @@ -24,17 +24,17 @@ import Foundation /// /// ![](ListItem) public struct ListItem: Hashable { - let blocks: [Block] + let children: [BlockNode] - init(blocks: [Block]) { - self.blocks = blocks + init(children: [BlockNode]) { + self.children = children } init(_ text: String) { - self.init(blocks: [.paragraph([.text(text)])]) + self.init(children: [.paragraph(content: [.text(text)])]) } public init(@MarkdownContentBuilder content: () -> MarkdownContent) { - self.init(blocks: content().blocks) + self.init(children: content().blocks) } } diff --git a/Sources/MarkdownUI/Content/Blocks/MarkdownContent.swift b/Sources/MarkdownUI/DSL/Blocks/MarkdownContent.swift similarity index 90% rename from Sources/MarkdownUI/Content/Blocks/MarkdownContent.swift rename to Sources/MarkdownUI/DSL/Blocks/MarkdownContent.swift index 089e0de7..07da9cc9 100644 --- a/Sources/MarkdownUI/Content/Blocks/MarkdownContent.swift +++ b/Sources/MarkdownUI/DSL/Blocks/MarkdownContent.swift @@ -61,9 +61,9 @@ public protocol MarkdownContentProtocol { /// ``` public struct MarkdownContent: Equatable, MarkdownContentProtocol { public var _markdownContent: MarkdownContent { self } - let blocks: [Block] + let blocks: [BlockNode] - init(blocks: [Block] = []) { + init(blocks: [BlockNode] = []) { self.blocks = blocks } @@ -82,4 +82,10 @@ public struct MarkdownContent: Equatable, MarkdownContentProtocol { public init(@MarkdownContentBuilder content: () -> MarkdownContent) { self.init(blocks: content().blocks) } + + /// Renders this Markdown content value as a Markdown-formatted text. + public func renderMarkdown() -> String { + let result = self.blocks.renderMarkdown() + return result.hasSuffix("\n") ? String(result.dropLast()) : result + } } diff --git a/Sources/MarkdownUI/Content/Blocks/MarkdownContentBuilder.swift b/Sources/MarkdownUI/DSL/Blocks/MarkdownContentBuilder.swift similarity index 100% rename from Sources/MarkdownUI/Content/Blocks/MarkdownContentBuilder.swift rename to Sources/MarkdownUI/DSL/Blocks/MarkdownContentBuilder.swift diff --git a/Sources/MarkdownUI/Content/Blocks/NumberedList.swift b/Sources/MarkdownUI/DSL/Blocks/NumberedList.swift similarity index 93% rename from Sources/MarkdownUI/Content/Blocks/NumberedList.swift rename to Sources/MarkdownUI/DSL/Blocks/NumberedList.swift index a66641d0..4887975c 100644 --- a/Sources/MarkdownUI/Content/Blocks/NumberedList.swift +++ b/Sources/MarkdownUI/DSL/Blocks/NumberedList.swift @@ -57,21 +57,21 @@ import Foundation /// ![](ListItem) public struct NumberedList: MarkdownContentProtocol { public var _markdownContent: MarkdownContent { - .init(blocks: [.numberedList(tight: self.tight, start: self.start, items: self.items)]) + .init(blocks: [.numberedList(isTight: self.tight, start: self.start, items: self.items)]) } private let tight: Bool private let start: Int - private let items: [ListItem] + private let items: [RawListItem] init(tight: Bool, start: Int, items: [ListItem]) { // Force loose spacing if any of the items contains more than one paragraph let hasItemsWithMultipleParagraphs = items.contains { item in - item.blocks.filter(\.isParagraph).count > 1 + item.children.filter(\.isParagraph).count > 1 } self.tight = hasItemsWithMultipleParagraphs ? false : tight self.start = start - self.items = items + self.items = items.map(\.children).map(RawListItem.init) } /// Creates a numbered list with the specified items. diff --git a/Sources/MarkdownUI/Content/Blocks/Paragraph.swift b/Sources/MarkdownUI/DSL/Blocks/Paragraph.swift similarity index 93% rename from Sources/MarkdownUI/Content/Blocks/Paragraph.swift rename to Sources/MarkdownUI/DSL/Blocks/Paragraph.swift index 8087db5e..d2cb4b69 100644 --- a/Sources/MarkdownUI/Content/Blocks/Paragraph.swift +++ b/Sources/MarkdownUI/DSL/Blocks/Paragraph.swift @@ -25,7 +25,7 @@ import Foundation /// ![](Paragraph) public struct Paragraph: MarkdownContentProtocol { public var _markdownContent: MarkdownContent { - .init(blocks: [.paragraph(self.content.inlines)]) + .init(blocks: [.paragraph(content: self.content.inlines)]) } private let content: InlineContent diff --git a/Sources/MarkdownUI/Content/Blocks/TaskList.swift b/Sources/MarkdownUI/DSL/Blocks/TaskList.swift similarity index 91% rename from Sources/MarkdownUI/Content/Blocks/TaskList.swift rename to Sources/MarkdownUI/DSL/Blocks/TaskList.swift index 80a05339..e5e87358 100644 --- a/Sources/MarkdownUI/Content/Blocks/TaskList.swift +++ b/Sources/MarkdownUI/DSL/Blocks/TaskList.swift @@ -62,20 +62,22 @@ import Foundation /// ``` public struct TaskList: MarkdownContentProtocol { public var _markdownContent: MarkdownContent { - .init(blocks: [.taskList(tight: self.tight, items: self.items)]) + .init(blocks: [.taskList(isTight: self.tight, items: self.items)]) } private let tight: Bool - private let items: [TaskListItem] + private let items: [RawTaskListItem] init(tight: Bool, items: [TaskListItem]) { // Force loose spacing if any of the items contains more than one paragraph let hasItemsWithMultipleParagraphs = items.contains { item in - item.blocks.filter(\.isParagraph).count > 1 + item.children.filter(\.isParagraph).count > 1 } self.tight = hasItemsWithMultipleParagraphs ? false : tight - self.items = items + self.items = items.map { + RawTaskListItem(isCompleted: $0.isCompleted, children: $0.children) + } } /// Creates a task list with the given items. diff --git a/Sources/MarkdownUI/Content/Blocks/TaskListContentBuilder.swift b/Sources/MarkdownUI/DSL/Blocks/TaskListContentBuilder.swift similarity index 100% rename from Sources/MarkdownUI/Content/Blocks/TaskListContentBuilder.swift rename to Sources/MarkdownUI/DSL/Blocks/TaskListContentBuilder.swift diff --git a/Sources/MarkdownUI/Content/Blocks/TaskListItem.swift b/Sources/MarkdownUI/DSL/Blocks/TaskListItem.swift similarity index 73% rename from Sources/MarkdownUI/Content/Blocks/TaskListItem.swift rename to Sources/MarkdownUI/DSL/Blocks/TaskListItem.swift index e427cb14..e768b2b2 100644 --- a/Sources/MarkdownUI/Content/Blocks/TaskListItem.swift +++ b/Sources/MarkdownUI/DSL/Blocks/TaskListItem.swift @@ -24,18 +24,18 @@ import Foundation /// ``` public struct TaskListItem: Hashable { let isCompleted: Bool - let blocks: [Block] + let children: [BlockNode] - init(isCompleted: Bool, blocks: [Block]) { + init(isCompleted: Bool, children: [BlockNode]) { self.isCompleted = isCompleted - self.blocks = blocks + self.children = children } init(_ text: String) { - self.init(isCompleted: false, blocks: [.paragraph([.text(text)])]) + self.init(isCompleted: false, children: [.paragraph(content: [.text(text)])]) } public init(isCompleted: Bool = false, @MarkdownContentBuilder content: () -> MarkdownContent) { - self.init(isCompleted: isCompleted, blocks: content().blocks) + self.init(isCompleted: isCompleted, children: content().blocks) } } diff --git a/Sources/MarkdownUI/Content/Blocks/TextTable.swift b/Sources/MarkdownUI/DSL/Blocks/TextTable.swift similarity index 74% rename from Sources/MarkdownUI/Content/Blocks/TextTable.swift rename to Sources/MarkdownUI/DSL/Blocks/TextTable.swift index 87627a38..6338d8dd 100644 --- a/Sources/MarkdownUI/Content/Blocks/TextTable.swift +++ b/Sources/MarkdownUI/DSL/Blocks/TextTable.swift @@ -62,10 +62,10 @@ public struct TextTable: MarkdownContentProtocol { .init(blocks: [.table(columnAlignments: self.columnAlignments, rows: self.rows)]) } - private let columnAlignments: [TextTableColumnAlignment?] - private let rows: [[[Inline]]] + private let columnAlignments: [RawTableColumnAlignment] + private let rows: [RawTableRow] - init(columnAlignments: [TextTableColumnAlignment?], rows: [[[Inline]]]) { + init(columnAlignments: [RawTableColumnAlignment], rows: [RawTableRow]) { self.columnAlignments = columnAlignments self.rows = rows } @@ -90,16 +90,38 @@ public struct TextTable: MarkdownContentProtocol { @TextTableColumnBuilder columns: () -> [TextTableColumn] ) where Data: RandomAccessCollection { let tableColumns = columns() - let headerRow = tableColumns.map(\.title.inlines) - self.init( - columnAlignments: tableColumns.map(\.alignment), - rows: CollectionOfOne(headerRow) - + data.map { value in - tableColumns.map { column in - column.content(value).inlines - } - } + let columnAlignments = tableColumns.map(\.alignment) + .map(RawTableColumnAlignment.init) + let header = RawTableRow( + cells: + tableColumns + .map(\.title.inlines) + .map(RawTableCell.init) ) + let body = data.map { value in + RawTableRow( + cells: tableColumns.map { column in + RawTableCell(content: column.content(value).inlines) + } + ) + } + + self.init(columnAlignments: columnAlignments, rows: CollectionOfOne(header) + body) + } +} + +extension RawTableColumnAlignment { + init(_ alignment: TextTableColumnAlignment?) { + switch alignment { + case .none: + self = .none + case .leading: + self = .left + case .center: + self = .center + case .trailing: + self = .right + } } } diff --git a/Sources/MarkdownUI/Content/Blocks/TextTableColumn.swift b/Sources/MarkdownUI/DSL/Blocks/TextTableColumn.swift similarity index 100% rename from Sources/MarkdownUI/Content/Blocks/TextTableColumn.swift rename to Sources/MarkdownUI/DSL/Blocks/TextTableColumn.swift diff --git a/Sources/MarkdownUI/Content/Blocks/TextTableColumnAlignment.swift b/Sources/MarkdownUI/DSL/Blocks/TextTableColumnAlignment.swift similarity index 100% rename from Sources/MarkdownUI/Content/Blocks/TextTableColumnAlignment.swift rename to Sources/MarkdownUI/DSL/Blocks/TextTableColumnAlignment.swift diff --git a/Sources/MarkdownUI/Content/Blocks/TextTableColumnBuilder.swift b/Sources/MarkdownUI/DSL/Blocks/TextTableColumnBuilder.swift similarity index 100% rename from Sources/MarkdownUI/Content/Blocks/TextTableColumnBuilder.swift rename to Sources/MarkdownUI/DSL/Blocks/TextTableColumnBuilder.swift diff --git a/Sources/MarkdownUI/Content/Blocks/TextTableRow.swift b/Sources/MarkdownUI/DSL/Blocks/TextTableRow.swift similarity index 100% rename from Sources/MarkdownUI/Content/Blocks/TextTableRow.swift rename to Sources/MarkdownUI/DSL/Blocks/TextTableRow.swift diff --git a/Sources/MarkdownUI/Content/Blocks/TextTableRowBuilder.swift b/Sources/MarkdownUI/DSL/Blocks/TextTableRowBuilder.swift similarity index 100% rename from Sources/MarkdownUI/Content/Blocks/TextTableRowBuilder.swift rename to Sources/MarkdownUI/DSL/Blocks/TextTableRowBuilder.swift diff --git a/Sources/MarkdownUI/Content/Blocks/ThematicBreak.swift b/Sources/MarkdownUI/DSL/Blocks/ThematicBreak.swift similarity index 100% rename from Sources/MarkdownUI/Content/Blocks/ThematicBreak.swift rename to Sources/MarkdownUI/DSL/Blocks/ThematicBreak.swift diff --git a/Sources/MarkdownUI/Content/Inlines/Code.swift b/Sources/MarkdownUI/DSL/Inlines/Code.swift similarity index 100% rename from Sources/MarkdownUI/Content/Inlines/Code.swift rename to Sources/MarkdownUI/DSL/Inlines/Code.swift diff --git a/Sources/MarkdownUI/Content/Inlines/Emphasis.swift b/Sources/MarkdownUI/DSL/Inlines/Emphasis.swift similarity index 92% rename from Sources/MarkdownUI/Content/Inlines/Emphasis.swift rename to Sources/MarkdownUI/DSL/Inlines/Emphasis.swift index ef3db4b8..9eb17462 100644 --- a/Sources/MarkdownUI/Content/Inlines/Emphasis.swift +++ b/Sources/MarkdownUI/DSL/Inlines/Emphasis.swift @@ -3,7 +3,7 @@ import Foundation /// An emphasized text in a Markdown content block. public struct Emphasis: InlineContentProtocol { public var _inlineContent: InlineContent { - .init(inlines: [.emphasis(self.content.inlines)]) + .init(inlines: [.emphasis(children: self.content.inlines)]) } private let content: InlineContent diff --git a/Sources/MarkdownUI/Content/Inlines/InlineContent.swift b/Sources/MarkdownUI/DSL/Inlines/InlineContent.swift similarity index 95% rename from Sources/MarkdownUI/Content/Inlines/InlineContent.swift rename to Sources/MarkdownUI/DSL/Inlines/InlineContent.swift index f6a807a0..1fab04af 100644 --- a/Sources/MarkdownUI/Content/Inlines/InlineContent.swift +++ b/Sources/MarkdownUI/DSL/Inlines/InlineContent.swift @@ -34,9 +34,9 @@ public protocol InlineContentProtocol { /// ``` public struct InlineContent: Equatable, InlineContentProtocol { public var _inlineContent: InlineContent { self } - let inlines: [Inline] + let inlines: [InlineNode] - init(inlines: [Inline] = []) { + init(inlines: [InlineNode] = []) { self.inlines = inlines } diff --git a/Sources/MarkdownUI/Content/Inlines/InlineContentBuilder.swift b/Sources/MarkdownUI/DSL/Inlines/InlineContentBuilder.swift similarity index 100% rename from Sources/MarkdownUI/Content/Inlines/InlineContentBuilder.swift rename to Sources/MarkdownUI/DSL/Inlines/InlineContentBuilder.swift diff --git a/Sources/MarkdownUI/Content/Inlines/InlineImage.swift b/Sources/MarkdownUI/DSL/Inlines/InlineImage.swift similarity index 100% rename from Sources/MarkdownUI/Content/Inlines/InlineImage.swift rename to Sources/MarkdownUI/DSL/Inlines/InlineImage.swift diff --git a/Sources/MarkdownUI/Content/Inlines/InlineLink.swift b/Sources/MarkdownUI/DSL/Inlines/InlineLink.swift similarity index 100% rename from Sources/MarkdownUI/Content/Inlines/InlineLink.swift rename to Sources/MarkdownUI/DSL/Inlines/InlineLink.swift diff --git a/Sources/MarkdownUI/Content/Inlines/LineBreak.swift b/Sources/MarkdownUI/DSL/Inlines/LineBreak.swift similarity index 100% rename from Sources/MarkdownUI/Content/Inlines/LineBreak.swift rename to Sources/MarkdownUI/DSL/Inlines/LineBreak.swift diff --git a/Sources/MarkdownUI/Content/Inlines/SoftBreak.swift b/Sources/MarkdownUI/DSL/Inlines/SoftBreak.swift similarity index 100% rename from Sources/MarkdownUI/Content/Inlines/SoftBreak.swift rename to Sources/MarkdownUI/DSL/Inlines/SoftBreak.swift diff --git a/Sources/MarkdownUI/Content/Inlines/Strikethrough.swift b/Sources/MarkdownUI/DSL/Inlines/Strikethrough.swift similarity index 91% rename from Sources/MarkdownUI/Content/Inlines/Strikethrough.swift rename to Sources/MarkdownUI/DSL/Inlines/Strikethrough.swift index 91da556e..8f728f0e 100644 --- a/Sources/MarkdownUI/Content/Inlines/Strikethrough.swift +++ b/Sources/MarkdownUI/DSL/Inlines/Strikethrough.swift @@ -3,7 +3,7 @@ import Foundation /// A deleted or redacted text in a Markdown content block. public struct Strikethrough: InlineContentProtocol { public var _inlineContent: InlineContent { - .init(inlines: [.strikethrough(self.content.inlines)]) + .init(inlines: [.strikethrough(children: self.content.inlines)]) } private let content: InlineContent diff --git a/Sources/MarkdownUI/Content/Inlines/Strong.swift b/Sources/MarkdownUI/DSL/Inlines/Strong.swift similarity index 92% rename from Sources/MarkdownUI/Content/Inlines/Strong.swift rename to Sources/MarkdownUI/DSL/Inlines/Strong.swift index 8a8a5e6a..93f39867 100644 --- a/Sources/MarkdownUI/Content/Inlines/Strong.swift +++ b/Sources/MarkdownUI/DSL/Inlines/Strong.swift @@ -3,7 +3,7 @@ import Foundation /// A strong text in a Markdown content block. public struct Strong: InlineContentProtocol { public var _inlineContent: InlineContent { - .init(inlines: [.strong(self.content.inlines)]) + .init(inlines: [.strong(children: self.content.inlines)]) } private let content: InlineContent diff --git a/Sources/MarkdownUI/Extensions/AssetImageProvider.swift b/Sources/MarkdownUI/Extensibility/AssetImageProvider.swift similarity index 100% rename from Sources/MarkdownUI/Extensions/AssetImageProvider.swift rename to Sources/MarkdownUI/Extensibility/AssetImageProvider.swift diff --git a/Sources/MarkdownUI/Extensions/AssetInlineImageProvider.swift b/Sources/MarkdownUI/Extensibility/AssetInlineImageProvider.swift similarity index 100% rename from Sources/MarkdownUI/Extensions/AssetInlineImageProvider.swift rename to Sources/MarkdownUI/Extensibility/AssetInlineImageProvider.swift diff --git a/Sources/MarkdownUI/Extensions/CodeSyntaxHighlighter.swift b/Sources/MarkdownUI/Extensibility/CodeSyntaxHighlighter.swift similarity index 100% rename from Sources/MarkdownUI/Extensions/CodeSyntaxHighlighter.swift rename to Sources/MarkdownUI/Extensibility/CodeSyntaxHighlighter.swift diff --git a/Sources/MarkdownUI/Extensions/DefaultImageProvider.swift b/Sources/MarkdownUI/Extensibility/DefaultImageProvider.swift similarity index 100% rename from Sources/MarkdownUI/Extensions/DefaultImageProvider.swift rename to Sources/MarkdownUI/Extensibility/DefaultImageProvider.swift diff --git a/Sources/MarkdownUI/Extensions/DefaultImageView/DefaultImageLoader.swift b/Sources/MarkdownUI/Extensibility/DefaultImageView/DefaultImageLoader.swift similarity index 100% rename from Sources/MarkdownUI/Extensions/DefaultImageView/DefaultImageLoader.swift rename to Sources/MarkdownUI/Extensibility/DefaultImageView/DefaultImageLoader.swift diff --git a/Sources/MarkdownUI/Extensions/DefaultImageView/DefaultImageView.swift b/Sources/MarkdownUI/Extensibility/DefaultImageView/DefaultImageView.swift similarity index 100% rename from Sources/MarkdownUI/Extensions/DefaultImageView/DefaultImageView.swift rename to Sources/MarkdownUI/Extensibility/DefaultImageView/DefaultImageView.swift diff --git a/Sources/MarkdownUI/Extensions/DefaultImageView/DefaultImageViewModel.swift b/Sources/MarkdownUI/Extensibility/DefaultImageView/DefaultImageViewModel.swift similarity index 100% rename from Sources/MarkdownUI/Extensions/DefaultImageView/DefaultImageViewModel.swift rename to Sources/MarkdownUI/Extensibility/DefaultImageView/DefaultImageViewModel.swift diff --git a/Sources/MarkdownUI/Extensions/DefaultInlineImageProvider.swift b/Sources/MarkdownUI/Extensibility/DefaultInlineImageProvider.swift similarity index 100% rename from Sources/MarkdownUI/Extensions/DefaultInlineImageProvider.swift rename to Sources/MarkdownUI/Extensibility/DefaultInlineImageProvider.swift diff --git a/Sources/MarkdownUI/Extensions/Image+PlatformImage.swift b/Sources/MarkdownUI/Extensibility/Image+PlatformImage.swift similarity index 100% rename from Sources/MarkdownUI/Extensions/Image+PlatformImage.swift rename to Sources/MarkdownUI/Extensibility/Image+PlatformImage.swift diff --git a/Sources/MarkdownUI/Extensions/ImageProvider.swift b/Sources/MarkdownUI/Extensibility/ImageProvider.swift similarity index 100% rename from Sources/MarkdownUI/Extensions/ImageProvider.swift rename to Sources/MarkdownUI/Extensibility/ImageProvider.swift diff --git a/Sources/MarkdownUI/Extensions/InlineImageProvider.swift b/Sources/MarkdownUI/Extensibility/InlineImageProvider.swift similarity index 100% rename from Sources/MarkdownUI/Extensions/InlineImageProvider.swift rename to Sources/MarkdownUI/Extensibility/InlineImageProvider.swift diff --git a/Sources/MarkdownUI/Parser/BlockNode+Rewrite.swift b/Sources/MarkdownUI/Parser/BlockNode+Rewrite.swift new file mode 100644 index 00000000..be326175 --- /dev/null +++ b/Sources/MarkdownUI/Parser/BlockNode+Rewrite.swift @@ -0,0 +1,104 @@ +import Foundation + +extension Sequence where Element == BlockNode { + func rewrite(_ r: (BlockNode) throws -> [BlockNode]) rethrows -> [BlockNode] { + try self.flatMap { try $0.rewrite(r) } + } + + func rewrite(_ r: (InlineNode) throws -> [InlineNode]) rethrows -> [BlockNode] { + try self.flatMap { try $0.rewrite(r) } + } +} + +extension BlockNode { + func rewrite(_ r: (BlockNode) throws -> [BlockNode]) rethrows -> [BlockNode] { + switch self { + case .blockquote(let children): + return try r(.blockquote(children: children.rewrite(r))) + case .bulletedList(let isTight, let items): + return try r( + .bulletedList( + isTight: isTight, + items: try items.map { + RawListItem(children: try $0.children.rewrite(r)) + } + ) + ) + case .numberedList(let isTight, let start, let items): + return try r( + .numberedList( + isTight: isTight, + start: start, + items: try items.map { + RawListItem(children: try $0.children.rewrite(r)) + } + ) + ) + case .taskList(let isTight, let items): + return try r( + .taskList( + isTight: isTight, + items: try items.map { + RawTaskListItem(isCompleted: $0.isCompleted, children: try $0.children.rewrite(r)) + } + ) + ) + default: + return try r(self) + } + } + + func rewrite(_ r: (InlineNode) throws -> [InlineNode]) rethrows -> [BlockNode] { + switch self { + case .blockquote(let children): + return [.blockquote(children: try children.rewrite(r))] + case .bulletedList(let isTight, let items): + return [ + .bulletedList( + isTight: isTight, + items: try items.map { + RawListItem(children: try $0.children.rewrite(r)) + } + ) + ] + case .numberedList(let isTight, let start, let items): + return [ + .numberedList( + isTight: isTight, + start: start, + items: try items.map { + RawListItem(children: try $0.children.rewrite(r)) + } + ) + ] + case .taskList(let isTight, let items): + return [ + .taskList( + isTight: isTight, + items: try items.map { + RawTaskListItem(isCompleted: $0.isCompleted, children: try $0.children.rewrite(r)) + } + ) + ] + case .paragraph(let content): + return [.paragraph(content: try content.rewrite(r))] + case .heading(let level, let content): + return [.heading(level: level, content: try content.rewrite(r))] + case .table(let columnAlignments, let rows): + return [ + .table( + columnAlignments: columnAlignments, + rows: try rows.map { + RawTableRow( + cells: try $0.cells.map { + RawTableCell(content: try $0.content.rewrite(r)) + } + ) + } + ) + ] + default: + return [self] + } + } +} diff --git a/Sources/MarkdownUI/Parser/BlockNode.swift b/Sources/MarkdownUI/Parser/BlockNode.swift new file mode 100644 index 00000000..9fb93aa7 --- /dev/null +++ b/Sources/MarkdownUI/Parser/BlockNode.swift @@ -0,0 +1,45 @@ +import Foundation + +enum BlockNode: Hashable { + case blockquote(children: [BlockNode]) + case bulletedList(isTight: Bool, items: [RawListItem]) + case numberedList(isTight: Bool, start: Int, items: [RawListItem]) + case taskList(isTight: Bool, items: [RawTaskListItem]) + case codeBlock(fenceInfo: String?, content: String) + case htmlBlock(content: String) + case paragraph(content: [InlineNode]) + case heading(level: Int, content: [InlineNode]) + case table(columnAlignments: [RawTableColumnAlignment], rows: [RawTableRow]) + case thematicBreak +} + +extension BlockNode { + var isParagraph: Bool { + guard case .paragraph = self else { return false } + return true + } +} + +struct RawListItem: Hashable { + let children: [BlockNode] +} + +struct RawTaskListItem: Hashable { + let isCompleted: Bool + let children: [BlockNode] +} + +enum RawTableColumnAlignment: Character { + case none = "\0" + case left = "l" + case center = "c" + case right = "r" +} + +struct RawTableRow: Hashable { + let cells: [RawTableCell] +} + +struct RawTableCell: Hashable { + let content: [InlineNode] +} diff --git a/Sources/MarkdownUI/Parser/InlineNode+Collect.swift b/Sources/MarkdownUI/Parser/InlineNode+Collect.swift new file mode 100644 index 00000000..ccdaef29 --- /dev/null +++ b/Sources/MarkdownUI/Parser/InlineNode+Collect.swift @@ -0,0 +1,13 @@ +import Foundation + +extension Sequence where Element == InlineNode { + func collect(_ c: (InlineNode) throws -> [Result]) rethrows -> [Result] { + try self.flatMap { try $0.collect(c) } + } +} + +extension InlineNode { + func collect(_ c: (InlineNode) throws -> [Result]) rethrows -> [Result] { + try self.children.collect(c) + c(self) + } +} diff --git a/Sources/MarkdownUI/Parser/InlineNode+Rewrite.swift b/Sources/MarkdownUI/Parser/InlineNode+Rewrite.swift new file mode 100644 index 00000000..dc164784 --- /dev/null +++ b/Sources/MarkdownUI/Parser/InlineNode+Rewrite.swift @@ -0,0 +1,15 @@ +import Foundation + +extension Sequence where Element == InlineNode { + func rewrite(_ r: (InlineNode) throws -> [InlineNode]) rethrows -> [InlineNode] { + try self.flatMap { try $0.rewrite(r) } + } +} + +extension InlineNode { + func rewrite(_ r: (InlineNode) throws -> [InlineNode]) rethrows -> [InlineNode] { + var inline = self + inline.children = try self.children.rewrite(r) + return try r(inline) + } +} diff --git a/Sources/MarkdownUI/Parser/InlineNode.swift b/Sources/MarkdownUI/Parser/InlineNode.swift new file mode 100644 index 00000000..3d01a17f --- /dev/null +++ b/Sources/MarkdownUI/Parser/InlineNode.swift @@ -0,0 +1,52 @@ +import Foundation + +enum InlineNode: Hashable { + case text(String) + case softBreak + case lineBreak + case code(String) + case html(String) + case emphasis(children: [InlineNode]) + case strong(children: [InlineNode]) + case strikethrough(children: [InlineNode]) + case link(destination: String, children: [InlineNode]) + case image(source: String, children: [InlineNode]) +} + +extension InlineNode { + var children: [InlineNode] { + get { + switch self { + case .emphasis(let children): + return children + case .strong(let children): + return children + case .strikethrough(let children): + return children + case .link(_, let children): + return children + case .image(_, let children): + return children + default: + return [] + } + } + + set { + switch self { + case .emphasis: + self = .emphasis(children: newValue) + case .strong: + self = .strong(children: newValue) + case .strikethrough: + self = .strikethrough(children: newValue) + case .link(let destination, _): + self = .link(destination: destination, children: newValue) + case .image(let source, _): + self = .image(source: source, children: newValue) + default: + break + } + } + } +} diff --git a/Sources/MarkdownUI/Parser/MarkdownParser.swift b/Sources/MarkdownUI/Parser/MarkdownParser.swift new file mode 100644 index 00000000..059d0a38 --- /dev/null +++ b/Sources/MarkdownUI/Parser/MarkdownParser.swift @@ -0,0 +1,470 @@ +import Foundation +@_implementationOnly import cmark_gfm + +extension Array where Element == BlockNode { + init(markdown: String) { + let blocks = UnsafeNode.parseMarkdown(markdown) { document in + document.children.compactMap(BlockNode.init(unsafeNode:)) + } + self.init(blocks ?? .init()) + } + + func renderMarkdown() -> String { + UnsafeNode.makeDocument(self) { document in + String(cString: cmark_render_commonmark(document, CMARK_OPT_DEFAULT, 0)) + } ?? "" + } +} + +extension BlockNode { + fileprivate init?(unsafeNode: UnsafeNode) { + switch unsafeNode.nodeType { + case .blockquote: + self = .blockquote(children: unsafeNode.children.compactMap(BlockNode.init(unsafeNode:))) + case .list: + if unsafeNode.children.contains(where: \.isTaskListItem) { + self = .taskList( + isTight: unsafeNode.isTightList, + items: unsafeNode.children.map(RawTaskListItem.init(unsafeNode:)) + ) + } else { + switch unsafeNode.listType { + case CMARK_BULLET_LIST: + self = .bulletedList( + isTight: unsafeNode.isTightList, + items: unsafeNode.children.map(RawListItem.init(unsafeNode:)) + ) + case CMARK_ORDERED_LIST: + self = .numberedList( + isTight: unsafeNode.isTightList, + start: unsafeNode.listStart, + items: unsafeNode.children.map(RawListItem.init(unsafeNode:)) + ) + default: + fatalError("cmark reported a list node without a list type.") + } + } + case .codeBlock: + self = .codeBlock(fenceInfo: unsafeNode.fenceInfo, content: unsafeNode.literal ?? "") + case .htmlBlock: + self = .htmlBlock(content: unsafeNode.literal ?? "") + case .paragraph: + self = .paragraph(content: unsafeNode.children.compactMap(InlineNode.init(unsafeNode:))) + case .heading: + self = .heading( + level: unsafeNode.headingLevel, + content: unsafeNode.children.compactMap(InlineNode.init(unsafeNode:)) + ) + case .table: + self = .table( + columnAlignments: unsafeNode.tableAlignments, + rows: unsafeNode.children.map(RawTableRow.init(unsafeNode:)) + ) + case .thematicBreak: + self = .thematicBreak + default: + assertionFailure("Unhandled node type '\(unsafeNode.nodeType)' in BlockNode.") + return nil + } + } +} + +extension RawListItem { + fileprivate init(unsafeNode: UnsafeNode) { + 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:))) + } +} + +extension RawTaskListItem { + fileprivate init(unsafeNode: UnsafeNode) { + 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:)) + ) + } +} + +extension RawTableRow { + fileprivate init(unsafeNode: UnsafeNode) { + 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:))) + } +} + +extension RawTableCell { + fileprivate init(unsafeNode: UnsafeNode) { + 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:))) + } +} + +extension InlineNode { + fileprivate init?(unsafeNode: UnsafeNode) { + switch unsafeNode.nodeType { + case .text: + self = .text(unsafeNode.literal ?? "") + case .softBreak: + self = .softBreak + case .lineBreak: + self = .lineBreak + case .code: + self = .code(unsafeNode.literal ?? "") + case .html: + self = .html(unsafeNode.literal ?? "") + case .emphasis: + self = .emphasis(children: unsafeNode.children.compactMap(InlineNode.init(unsafeNode:))) + case .strong: + self = .strong(children: unsafeNode.children.compactMap(InlineNode.init(unsafeNode:))) + case .strikethrough: + self = .strikethrough(children: unsafeNode.children.compactMap(InlineNode.init(unsafeNode:))) + case .link: + self = .link( + destination: unsafeNode.url ?? "", + children: unsafeNode.children.compactMap(InlineNode.init(unsafeNode:)) + ) + case .image: + self = .image( + source: unsafeNode.url ?? "", + children: unsafeNode.children.compactMap(InlineNode.init(unsafeNode:)) + ) + default: + assertionFailure("Unhandled node type '\(unsafeNode.nodeType)' in InlineNode.") + return nil + } + } +} + +private typealias UnsafeNode = UnsafeMutablePointer + +extension UnsafeNode { + 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 + } + + fileprivate var children: UnsafeNodeSequence { + .init(cmark_node_first_child(self)) + } + + fileprivate var literal: String? { + cmark_node_get_literal(self).map(String.init(cString:)) + } + + fileprivate var url: String? { + cmark_node_get_url(self).map(String.init(cString:)) + } + + fileprivate var isTaskListItem: Bool { + self.nodeType == .taskListItem + } + + fileprivate var listType: cmark_list_type { + cmark_node_get_list_type(self) + } + + fileprivate var listStart: Int { + Int(cmark_node_get_list_start(self)) + } + + fileprivate var isTaskListItemChecked: Bool { + cmark_gfm_extensions_get_tasklist_item_checked(self) + } + + fileprivate var isTightList: Bool { + cmark_node_get_list_tight(self) != 0 + } + + fileprivate var fenceInfo: String? { + cmark_node_get_fence_info(self).map(String.init(cString:)) + } + + fileprivate var headingLevel: Int { + Int(cmark_node_get_heading_level(self)) + } + + fileprivate var tableColumns: Int { + Int(cmark_gfm_extensions_get_table_columns(self)) + } + + fileprivate var tableAlignments: [RawTableColumnAlignment] { + (0..( + _ markdown: String, + body: (UnsafeNode) throws -> ResultType + ) rethrows -> ResultType? { + cmark_gfm_core_extensions_ensure_registered() + + // Create a Markdown parser and attach the GitHub syntax extensions + + let parser = cmark_parser_new(CMARK_OPT_DEFAULT) + defer { cmark_parser_free(parser) } + + let extensionNames: Set + + if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { + extensionNames = ["autolink", "strikethrough", "tagfilter", "tasklist", "table"] + } else { + extensionNames = ["autolink", "strikethrough", "tagfilter", "tasklist"] + } + + for extensionName in extensionNames { + guard let syntaxExtension = cmark_find_syntax_extension(extensionName) else { + continue + } + cmark_parser_attach_syntax_extension(parser, syntaxExtension) + } + + // Parse the Markdown document + + cmark_parser_feed(parser, markdown, markdown.utf8.count) + + guard let document = cmark_parser_finish(parser) else { + return nil + } + + defer { cmark_node_free(document) } + return try body(document) + } + + fileprivate static func makeDocument( + _ blocks: [BlockNode], + body: (UnsafeNode) throws -> ResultType + ) rethrows -> ResultType? { + cmark_gfm_core_extensions_ensure_registered() + guard let document = cmark_node_new(CMARK_NODE_DOCUMENT) else { return nil } + blocks.compactMap(UnsafeNode.make).forEach { cmark_node_append_child(document, $0) } + + defer { cmark_node_free(document) } + return try body(document) + } + + fileprivate static func make(_ block: BlockNode) -> UnsafeNode? { + switch block { + case .blockquote(let children): + guard let node = cmark_node_new(CMARK_NODE_BLOCK_QUOTE) else { return nil } + children.compactMap(UnsafeNode.make).forEach { cmark_node_append_child(node, $0) } + return node + case .bulletedList(let isTight, let items): + guard let node = cmark_node_new(CMARK_NODE_LIST) else { return nil } + cmark_node_set_list_type(node, CMARK_BULLET_LIST) + cmark_node_set_list_tight(node, isTight ? 1 : 0) + items.compactMap(UnsafeNode.make).forEach { cmark_node_append_child(node, $0) } + return node + case .numberedList(let isTight, let start, let items): + guard let node = cmark_node_new(CMARK_NODE_LIST) else { return nil } + cmark_node_set_list_type(node, CMARK_ORDERED_LIST) + cmark_node_set_list_tight(node, isTight ? 1 : 0) + cmark_node_set_list_start(node, Int32(start)) + items.compactMap(UnsafeNode.make).forEach { cmark_node_append_child(node, $0) } + return node + case .taskList(let isTight, let items): + guard let node = cmark_node_new(CMARK_NODE_LIST) else { return nil } + cmark_node_set_list_type(node, CMARK_BULLET_LIST) + cmark_node_set_list_tight(node, isTight ? 1 : 0) + items.compactMap(UnsafeNode.make).forEach { cmark_node_append_child(node, $0) } + return node + case .codeBlock(let fenceInfo, let content): + guard let node = cmark_node_new(CMARK_NODE_CODE_BLOCK) else { return nil } + if let fenceInfo { + cmark_node_set_fence_info(node, fenceInfo) + } + cmark_node_set_literal(node, content) + return node + case .htmlBlock(let content): + guard let node = cmark_node_new(CMARK_NODE_HTML_BLOCK) else { return nil } + cmark_node_set_literal(node, content) + return node + case .paragraph(let content): + guard let node = cmark_node_new(CMARK_NODE_PARAGRAPH) else { return nil } + content.compactMap(UnsafeNode.make).forEach { cmark_node_append_child(node, $0) } + return node + case .heading(let level, let content): + guard let node = cmark_node_new(CMARK_NODE_HEADING) else { return nil } + cmark_node_set_heading_level(node, Int32(level)) + content.compactMap(UnsafeNode.make).forEach { cmark_node_append_child(node, $0) } + return node + case .table(let columnAlignments, let rows): + guard let table = cmark_find_syntax_extension("table"), + let node = cmark_node_new_with_ext(CMARK_NODE_TABLE, table) + else { + return nil + } + cmark_gfm_extensions_set_table_columns(node, UInt16(columnAlignments.count)) + var alignments = columnAlignments.map { $0.rawValue.asciiValue! } + cmark_gfm_extensions_set_table_alignments(node, UInt16(columnAlignments.count), &alignments) + rows.compactMap(UnsafeNode.make).forEach { cmark_node_append_child(node, $0) } + if let header = cmark_node_first_child(node) { + cmark_gfm_extensions_set_table_row_is_header(header, 1) + } + return node + case .thematicBreak: + guard let node = cmark_node_new(CMARK_NODE_THEMATIC_BREAK) else { return nil } + return node + } + } + + fileprivate static func make(_ item: RawListItem) -> UnsafeNode? { + guard let node = cmark_node_new(CMARK_NODE_ITEM) else { return nil } + item.children.compactMap(UnsafeNode.make).forEach { cmark_node_append_child(node, $0) } + return node + } + + fileprivate static func make(_ item: RawTaskListItem) -> UnsafeNode? { + guard let tasklist = cmark_find_syntax_extension("tasklist"), + let node = cmark_node_new_with_ext(CMARK_NODE_ITEM, tasklist) + else { + return nil + } + cmark_gfm_extensions_set_tasklist_item_checked(node, item.isCompleted) + item.children.compactMap(UnsafeNode.make).forEach { cmark_node_append_child(node, $0) } + return node + } + + fileprivate static func make(_ tableRow: RawTableRow) -> UnsafeNode? { + guard let table = cmark_find_syntax_extension("table"), + let node = cmark_node_new_with_ext(CMARK_NODE_TABLE_ROW, table) + else { + return nil + } + tableRow.cells.compactMap(UnsafeNode.make).forEach { cmark_node_append_child(node, $0) } + return node + } + + fileprivate static func make(_ tableCell: RawTableCell) -> UnsafeNode? { + guard let table = cmark_find_syntax_extension("table"), + let node = cmark_node_new_with_ext(CMARK_NODE_TABLE_CELL, table) + else { + return nil + } + tableCell.content.compactMap(UnsafeNode.make).forEach { cmark_node_append_child(node, $0) } + return node + } + + fileprivate static func make(_ inline: InlineNode) -> UnsafeNode? { + switch inline { + case .text(let content): + guard let node = cmark_node_new(CMARK_NODE_TEXT) else { return nil } + cmark_node_set_literal(node, content) + return node + case .softBreak: + return cmark_node_new(CMARK_NODE_SOFTBREAK) + case .lineBreak: + return cmark_node_new(CMARK_NODE_LINEBREAK) + case .code(let content): + guard let node = cmark_node_new(CMARK_NODE_CODE) else { return nil } + cmark_node_set_literal(node, content) + return node + case .html(let content): + guard let node = cmark_node_new(CMARK_NODE_HTML_INLINE) else { return nil } + cmark_node_set_literal(node, content) + return node + case .emphasis(let children): + guard let node = cmark_node_new(CMARK_NODE_EMPH) else { return nil } + children.compactMap(UnsafeNode.make).forEach { cmark_node_append_child(node, $0) } + return node + case .strong(let children): + guard let node = cmark_node_new(CMARK_NODE_STRONG) else { return nil } + children.compactMap(UnsafeNode.make).forEach { cmark_node_append_child(node, $0) } + return node + case .strikethrough(let children): + guard let strikethrough = cmark_find_syntax_extension("strikethrough"), + let node = cmark_node_new_with_ext(CMARK_NODE_STRIKETHROUGH, strikethrough) + else { + return nil + } + children.compactMap(UnsafeNode.make).forEach { cmark_node_append_child(node, $0) } + return node + case .link(let destination, let children): + guard let node = cmark_node_new(CMARK_NODE_LINK) else { return nil } + cmark_node_set_url(node, destination) + children.compactMap(UnsafeNode.make).forEach { cmark_node_append_child(node, $0) } + return node + case .image(let source, let children): + guard let node = cmark_node_new(CMARK_NODE_IMAGE) else { return nil } + cmark_node_set_url(node, source) + children.compactMap(UnsafeNode.make).forEach { cmark_node_append_child(node, $0) } + return node + } + } +} + +private enum NodeType: String { + case document + case blockquote = "block_quote" + case list + case item + case codeBlock = "code_block" + case htmlBlock = "html_block" + case customBlock = "custom_block" + case paragraph + case heading + case thematicBreak = "thematic_break" + case text + case softBreak = "softbreak" + case lineBreak = "linebreak" + case code + case html = "html_inline" + case customInline = "custom_inline" + case emphasis = "emph" + case strong + case link + case image + case inlineAttributes = "attribute" + case none = "NONE" + case unknown = "" + + // Extensions + + case strikethrough + case table + case tableHead = "table_header" + case tableRow = "table_row" + case tableCell = "table_cell" + case taskListItem = "tasklist" +} + +private struct UnsafeNodeSequence: Sequence { + struct Iterator: IteratorProtocol { + private var node: UnsafeNode? + + init(_ node: UnsafeNode?) { + self.node = node + } + + mutating func next() -> UnsafeNode? { + guard let node else { return nil } + defer { self.node = cmark_node_next(node) } + return node + } + } + + private let node: UnsafeNode? + + init(_ node: UnsafeNode?) { + self.node = node + } + + func makeIterator() -> Iterator { + .init(self.node) + } +} diff --git a/Sources/MarkdownUI/Renderer/AttributedStringInlineRenderer.swift b/Sources/MarkdownUI/Renderer/AttributedStringInlineRenderer.swift new file mode 100644 index 00000000..aa5d7cdc --- /dev/null +++ b/Sources/MarkdownUI/Renderer/AttributedStringInlineRenderer.swift @@ -0,0 +1,133 @@ +import Foundation + +extension InlineNode { + func renderAttributedString( + baseURL: URL?, + textStyles: InlineTextStyles, + attributes: AttributeContainer + ) -> AttributedString { + var renderer = AttributedStringInlineRenderer( + baseURL: baseURL, + textStyles: textStyles, + attributes: attributes + ) + renderer.render(self) + return renderer.result.resolvingFonts() + } +} + +private struct AttributedStringInlineRenderer { + var result = AttributedString() + + private let baseURL: URL? + private let textStyles: InlineTextStyles + private var attributes: AttributeContainer + + init(baseURL: URL?, textStyles: InlineTextStyles, attributes: AttributeContainer) { + self.baseURL = baseURL + self.textStyles = textStyles + self.attributes = attributes + } + + mutating func render(_ inline: InlineNode) { + switch inline { + case .text(let content): + self.renderText(content) + case .softBreak: + self.renderSoftBreak() + case .lineBreak: + self.renderLineBreak() + case .code(let content): + self.renderCode(content) + case .html(let content): + self.renderHTML(content) + case .emphasis(let children): + self.renderEmphasis(children: children) + case .strong(let children): + self.renderStrong(children: children) + case .strikethrough(let children): + self.renderStrikethrough(children: children) + case .link(let destination, let children): + self.renderLink(destination: destination, children: children) + case .image(let source, let children): + self.renderImage(source: source, children: children) + } + } + + private mutating func renderText(_ text: String) { + self.result += .init(text, attributes: self.attributes) + } + + private mutating func renderSoftBreak() { + self.result += .init(" ", attributes: self.attributes) + } + + private mutating func renderLineBreak() { + self.result += .init("\n", attributes: self.attributes) + } + + mutating func renderCode(_ code: String) { + self.result += .init(code, attributes: self.textStyles.code.mergingAttributes(self.attributes)) + } + + mutating func renderHTML(_ html: String) { + self.result += .init(html, attributes: self.attributes) + } + + mutating func renderEmphasis(children: [InlineNode]) { + let savedAttributes = self.attributes + self.attributes = self.textStyles.emphasis.mergingAttributes(self.attributes) + + for child in children { + self.render(child) + } + + self.attributes = savedAttributes + } + + mutating func renderStrong(children: [InlineNode]) { + let savedAttributes = self.attributes + self.attributes = self.textStyles.strong.mergingAttributes(self.attributes) + + for child in children { + self.render(child) + } + + self.attributes = savedAttributes + } + + mutating func renderStrikethrough(children: [InlineNode]) { + let savedAttributes = self.attributes + self.attributes = self.textStyles.strikethrough.mergingAttributes(self.attributes) + + for child in children { + self.render(child) + } + + self.attributes = savedAttributes + } + + mutating func renderLink(destination: String, children: [InlineNode]) { + let savedAttributes = self.attributes + self.attributes = self.textStyles.link.mergingAttributes(self.attributes) + self.attributes.link = URL(string: destination, relativeTo: self.baseURL) + + for child in children { + self.render(child) + } + + self.attributes = savedAttributes + } + + mutating func renderImage(source: String, children: [InlineNode]) { + // AttributedString does not support images + } +} + +extension TextStyle { + fileprivate func mergingAttributes(_ attributes: AttributeContainer) -> AttributeContainer { + var newAttributes = attributes + self._collectAttributes(in: &newAttributes) + return newAttributes + } +} diff --git a/Sources/MarkdownUI/Renderer/InlineTextStyles.swift b/Sources/MarkdownUI/Renderer/InlineTextStyles.swift new file mode 100644 index 00000000..134daf8b --- /dev/null +++ b/Sources/MarkdownUI/Renderer/InlineTextStyles.swift @@ -0,0 +1,9 @@ +import Foundation + +struct InlineTextStyles { + let code: TextStyle + let emphasis: TextStyle + let strong: TextStyle + let strikethrough: TextStyle + let link: TextStyle +} diff --git a/Sources/MarkdownUI/Renderer/TextInlineRenderer.swift b/Sources/MarkdownUI/Renderer/TextInlineRenderer.swift new file mode 100644 index 00000000..79c6375d --- /dev/null +++ b/Sources/MarkdownUI/Renderer/TextInlineRenderer.swift @@ -0,0 +1,65 @@ +import SwiftUI + +extension Sequence where Element == InlineNode { + func renderText( + baseURL: URL?, + textStyles: InlineTextStyles, + images: [String: Image], + attributes: AttributeContainer + ) -> Text { + var renderer = TextInlineRenderer( + baseURL: baseURL, + textStyles: textStyles, + images: images, + attributes: attributes + ) + renderer.render(self) + return renderer.result + } +} + +private struct TextInlineRenderer { + var result = Text("") + + private let baseURL: URL? + private let textStyles: InlineTextStyles + private let images: [String: Image] + private let attributes: AttributeContainer + + init( + baseURL: URL?, + textStyles: InlineTextStyles, + images: [String: Image], + attributes: AttributeContainer + ) { + self.baseURL = baseURL + self.textStyles = textStyles + self.images = images + self.attributes = attributes + } + + mutating func render(_ inlines: S) where S.Element == InlineNode { + for inline in inlines { + self.render(inline) + } + } + + private mutating func render(_ inline: InlineNode) { + switch inline { + case .image(let source, _): + if let image = self.images[source] { + self.result = self.result + Text(image) + } + default: + self.result = + self.result + + Text( + inline.renderAttributedString( + baseURL: self.baseURL, + textStyles: self.textStyles, + attributes: self.attributes + ) + ) + } + } +} diff --git a/Sources/MarkdownUI/Utility/BlockNode+ColorSchemeImage.swift b/Sources/MarkdownUI/Utility/BlockNode+ColorSchemeImage.swift new file mode 100644 index 00000000..cd9bb27c --- /dev/null +++ b/Sources/MarkdownUI/Utility/BlockNode+ColorSchemeImage.swift @@ -0,0 +1,34 @@ +import SwiftUI + +extension Sequence where Element == BlockNode { + func filterImagesMatching(colorScheme: ColorScheme) -> [BlockNode] { + self.rewrite { inline in + switch inline { + case .image(let source, _): + guard let url = URL(string: source), url.matchesColorScheme(colorScheme) else { + return [] + } + return [inline] + default: + return [inline] + } + } + } +} + +extension URL { + fileprivate func matchesColorScheme(_ colorScheme: ColorScheme) -> Bool { + guard let fragment = self.fragment?.lowercased() else { + return true + } + + switch colorScheme { + case .light: + return fragment != "gh-dark-mode-only" + case .dark: + return fragment != "gh-light-mode-only" + @unknown default: + return true + } + } +} diff --git a/Sources/MarkdownUI/Common/Color+RGBA.swift b/Sources/MarkdownUI/Utility/Color+RGBA.swift similarity index 100% rename from Sources/MarkdownUI/Common/Color+RGBA.swift rename to Sources/MarkdownUI/Utility/Color+RGBA.swift diff --git a/Sources/MarkdownUI/Common/Deprecations.swift b/Sources/MarkdownUI/Utility/Deprecations.swift similarity index 100% rename from Sources/MarkdownUI/Common/Deprecations.swift rename to Sources/MarkdownUI/Utility/Deprecations.swift diff --git a/Sources/MarkdownUI/Common/FlowLayout.swift b/Sources/MarkdownUI/Utility/FlowLayout.swift similarity index 100% rename from Sources/MarkdownUI/Common/FlowLayout.swift rename to Sources/MarkdownUI/Utility/FlowLayout.swift diff --git a/Sources/MarkdownUI/Common/Indexed.swift b/Sources/MarkdownUI/Utility/Indexed.swift similarity index 100% rename from Sources/MarkdownUI/Common/Indexed.swift rename to Sources/MarkdownUI/Utility/Indexed.swift diff --git a/Sources/MarkdownUI/Utility/InlineNode+PlainText.swift b/Sources/MarkdownUI/Utility/InlineNode+PlainText.swift new file mode 100644 index 00000000..aa9709c1 --- /dev/null +++ b/Sources/MarkdownUI/Utility/InlineNode+PlainText.swift @@ -0,0 +1,23 @@ +import Foundation + +extension Sequence where Element == InlineNode { + func renderPlainText() -> String { + self.collect { inline in + switch inline { + case .text(let content): + return [content] + case .softBreak: + return [" "] + case .lineBreak: + return ["\n"] + case .code(let content): + return [content] + case .html(let content): + return [content] + default: + return [] + } + } + .joined() + } +} diff --git a/Sources/MarkdownUI/Utility/InlineNode+RawImageData.swift b/Sources/MarkdownUI/Utility/InlineNode+RawImageData.swift new file mode 100644 index 00000000..11f77d3f --- /dev/null +++ b/Sources/MarkdownUI/Utility/InlineNode+RawImageData.swift @@ -0,0 +1,22 @@ +import Foundation + +struct RawImageData: Hashable { + var source: String + var alt: String + var destination: String? +} + +extension InlineNode { + var imageData: RawImageData? { + switch self { + case .image(let source, let children): + return .init(source: source, alt: children.renderPlainText()) + case .link(let destination, let children) where children.count == 1: + guard var imageData = children.first?.imageData else { return nil } + imageData.destination = destination + return imageData + default: + return nil + } + } +} diff --git a/Sources/MarkdownUI/Common/Int+Roman.swift b/Sources/MarkdownUI/Utility/Int+Roman.swift similarity index 100% rename from Sources/MarkdownUI/Common/Int+Roman.swift rename to Sources/MarkdownUI/Utility/Int+Roman.swift diff --git a/Sources/MarkdownUI/Common/RelativeSize.swift b/Sources/MarkdownUI/Utility/RelativeSize.swift similarity index 100% rename from Sources/MarkdownUI/Common/RelativeSize.swift rename to Sources/MarkdownUI/Utility/RelativeSize.swift diff --git a/Sources/MarkdownUI/Common/ResizeToFit.swift b/Sources/MarkdownUI/Utility/ResizeToFit.swift similarity index 100% rename from Sources/MarkdownUI/Common/ResizeToFit.swift rename to Sources/MarkdownUI/Utility/ResizeToFit.swift diff --git a/Sources/MarkdownUI/Common/String+KebabCase.swift b/Sources/MarkdownUI/Utility/String+KebabCase.swift similarity index 100% rename from Sources/MarkdownUI/Common/String+KebabCase.swift rename to Sources/MarkdownUI/Utility/String+KebabCase.swift diff --git a/Sources/MarkdownUI/Views/Blocks/Block+View.swift b/Sources/MarkdownUI/Views/Blocks/Block+View.swift deleted file mode 100644 index 76cbae3a..00000000 --- a/Sources/MarkdownUI/Views/Blocks/Block+View.swift +++ /dev/null @@ -1,41 +0,0 @@ -import SwiftUI - -extension Block: View { - var body: some View { - switch self { - case .blockquote(let blocks): - ApplyBlockStyle(\.blockquote, to: BlockSequence(blocks)) - case .taskList(let tight, let items): - ApplyBlockStyle(\.list, to: TaskListView(tight: tight, items: items)) - case .bulletedList(let tight, let items): - ApplyBlockStyle(\.list, to: BulletedListView(tight: tight, items: items)) - case .numberedList(let tight, let start, let items): - ApplyBlockStyle(\.list, to: NumberedListView(tight: tight, start: start, items: items)) - case .codeBlock(let info, let content): - ApplyBlockStyle(\.codeBlock, to: CodeBlockView(info: info, content: content)) - case .htmlBlock(let content): - ApplyBlockStyle(\.paragraph, to: HTMLBlockView(content: content)) - case .paragraph(let inlines): - if let imageView = ImageView(inlines) { - ApplyBlockStyle(\.paragraph, to: imageView) - } else if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *), - let imageFlow = ImageFlow(inlines) - { - ApplyBlockStyle(\.paragraph, to: imageFlow) - } else { - ApplyBlockStyle(\.paragraph, to: InlineText(inlines)) - } - case .heading(let level, let inlines): - ApplyBlockStyle(\.headings[level - 1], to: InlineText(inlines)) - .id(inlines.text.kebabCased()) - case .table(let columnAlignments, let rows): - if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { - ApplyBlockStyle(\.table, to: TableView(columnAlignments: columnAlignments, rows: rows)) - } else { - EmptyView() - } - case .thematicBreak: - ApplyBlockStyle(\.thematicBreak) - } - } -} diff --git a/Sources/MarkdownUI/Views/Blocks/BlockNode+View.swift b/Sources/MarkdownUI/Views/Blocks/BlockNode+View.swift new file mode 100644 index 00000000..abb24140 --- /dev/null +++ b/Sources/MarkdownUI/Views/Blocks/BlockNode+View.swift @@ -0,0 +1,39 @@ +import SwiftUI + +extension BlockNode: View { + var body: some View { + switch self { + case .blockquote(let children): + ApplyBlockStyle(\.blockquote, to: BlockSequence(children)) + case .bulletedList(let isTight, let items): + ApplyBlockStyle(\.list, to: BulletedListView(isTight: isTight, items: items)) + case .numberedList(let isTight, let start, let items): + ApplyBlockStyle(\.list, to: NumberedListView(isTight: isTight, start: start, items: items)) + case .taskList(let isTight, let items): + ApplyBlockStyle(\.list, to: TaskListView(isTight: isTight, items: items)) + case .codeBlock(let fenceInfo, let content): + ApplyBlockStyle(\.codeBlock, to: CodeBlockView(fenceInfo: fenceInfo, content: content)) + case .htmlBlock(let content): + ApplyBlockStyle(\.paragraph, to: HTMLBlockView(content: content)) + case .paragraph(let content): + if let imageView = ImageView(content) { + ApplyBlockStyle(\.paragraph, to: imageView) + } else if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *), + let imageFlow = ImageFlow(content) + { + ApplyBlockStyle(\.paragraph, to: imageFlow) + } else { + ApplyBlockStyle(\.paragraph, to: InlineText(content)) + } + case .heading(let level, let content): + ApplyBlockStyle(\.headings[level - 1], to: InlineText(content)) + .id(content.renderPlainText().kebabCased()) + case .table(let columnAlignments, let rows): + if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { + ApplyBlockStyle(\.table, to: TableView(columnAlignments: columnAlignments, rows: rows)) + } + case .thematicBreak: + ApplyBlockStyle(\.thematicBreak) + } + } +} diff --git a/Sources/MarkdownUI/Views/Blocks/BlockSequence.swift b/Sources/MarkdownUI/Views/Blocks/BlockSequence.swift index daf1a5c6..fd56e638 100644 --- a/Sources/MarkdownUI/Views/Blocks/BlockSequence.swift +++ b/Sources/MarkdownUI/Views/Blocks/BlockSequence.swift @@ -50,8 +50,8 @@ where } } -extension BlockSequence where Data == [Block], Content == Block { - init(_ blocks: [Block]) { +extension BlockSequence where Data == [BlockNode], Content == BlockNode { + init(_ blocks: [BlockNode]) { self.init(blocks) { $1 } } } diff --git a/Sources/MarkdownUI/Views/Blocks/BulletedListView.swift b/Sources/MarkdownUI/Views/Blocks/BulletedListView.swift index b362e101..e231356d 100644 --- a/Sources/MarkdownUI/Views/Blocks/BulletedListView.swift +++ b/Sources/MarkdownUI/Views/Blocks/BulletedListView.swift @@ -4,17 +4,17 @@ struct BulletedListView: View { @Environment(\.theme.bulletedListMarker) private var bulletedListMarker @Environment(\.listLevel) private var listLevel - private let tight: Bool - private let items: [ListItem] + private let isTight: Bool + private let items: [RawListItem] - init(tight: Bool, items: [ListItem]) { - self.tight = tight + init(isTight: Bool, items: [RawListItem]) { + self.isTight = isTight self.items = items } var body: some View { ListItemSequence(items: self.items, markerStyle: self.bulletedListMarker) .environment(\.listLevel, self.listLevel + 1) - .environment(\.tightSpacingEnabled, self.tight) + .environment(\.tightSpacingEnabled, self.isTight) } } diff --git a/Sources/MarkdownUI/Views/Blocks/CodeBlockView.swift b/Sources/MarkdownUI/Views/Blocks/CodeBlockView.swift index 28f8389e..26d78c7f 100644 --- a/Sources/MarkdownUI/Views/Blocks/CodeBlockView.swift +++ b/Sources/MarkdownUI/Views/Blocks/CodeBlockView.swift @@ -3,16 +3,16 @@ import SwiftUI struct CodeBlockView: View { @Environment(\.codeSyntaxHighlighter) private var codeSyntaxHighlighter - private let info: String? + private let fenceInfo: String? private let content: String - init(info: String?, content: String) { - self.info = info + init(fenceInfo: String?, content: String) { + self.fenceInfo = fenceInfo self.content = content.hasSuffix("\n") ? String(content.dropLast()) : content } var body: some View { - self.codeSyntaxHighlighter.highlightCode(self.content, language: self.info) + self.codeSyntaxHighlighter.highlightCode(self.content, language: self.fenceInfo) .textStyleFont() } } diff --git a/Sources/MarkdownUI/Views/Blocks/ImageFlow.swift b/Sources/MarkdownUI/Views/Blocks/ImageFlow.swift index 88542f68..0a5ae244 100644 --- a/Sources/MarkdownUI/Views/Blocks/ImageFlow.swift +++ b/Sources/MarkdownUI/Views/Blocks/ImageFlow.swift @@ -3,7 +3,7 @@ import SwiftUI @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) struct ImageFlow: View { private enum Item: Hashable { - case image(source: String?, alt: String, destination: String? = nil) + case image(RawImageData) case lineBreak } @@ -16,8 +16,8 @@ struct ImageFlow: View { FlowLayout(horizontalSpacing: spacing, verticalSpacing: spacing) { ForEach(self.items, id: \.self) { item in switch item.value { - case let .image(source, alt, destination): - ImageView(source: source, alt: alt, destination: destination) + case .image(let data): + ImageView(data: data) case .lineBreak: Spacer() } @@ -29,7 +29,7 @@ struct ImageFlow: View { @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) extension ImageFlow { - init?(_ inlines: [Inline]) { + init?(_ inlines: [InlineNode]) { var items: [Item] = [] for inline in inlines { @@ -41,12 +41,13 @@ extension ImageFlow { case .lineBreak: items.append(.lineBreak) case let .image(source, children): - items.append(.image(source: source, alt: children.text)) + items.append(.image(.init(source: source, alt: children.renderPlainText()))) case let .link(destination, children) where children.count == 1: - guard let image = children.first?.image else { + guard var data = children.first?.imageData else { return nil } - items.append(.image(source: image.source, alt: image.alt, destination: destination)) + data.destination = destination + items.append(.image(data)) default: return nil } diff --git a/Sources/MarkdownUI/Views/Blocks/ListItemSequence.swift b/Sources/MarkdownUI/Views/Blocks/ListItemSequence.swift index 6f636cec..142b39eb 100644 --- a/Sources/MarkdownUI/Views/Blocks/ListItemSequence.swift +++ b/Sources/MarkdownUI/Views/Blocks/ListItemSequence.swift @@ -1,13 +1,13 @@ import SwiftUI struct ListItemSequence: View { - private let items: [ListItem] + private let items: [RawListItem] private let start: Int private let markerStyle: BlockStyle private let markerWidth: CGFloat? init( - items: [ListItem], + items: [RawListItem], start: Int = 1, markerStyle: BlockStyle, markerWidth: CGFloat? = nil diff --git a/Sources/MarkdownUI/Views/Blocks/ListItemView.swift b/Sources/MarkdownUI/Views/Blocks/ListItemView.swift index cb64c090..fe9a9c4d 100644 --- a/Sources/MarkdownUI/Views/Blocks/ListItemView.swift +++ b/Sources/MarkdownUI/Views/Blocks/ListItemView.swift @@ -3,13 +3,13 @@ import SwiftUI struct ListItemView: View { @Environment(\.listLevel) private var listLevel - private let item: ListItem + private let item: RawListItem private let number: Int private let markerStyle: BlockStyle private let markerWidth: CGFloat? init( - item: ListItem, + item: RawListItem, number: Int, markerStyle: BlockStyle, markerWidth: CGFloat? @@ -22,7 +22,7 @@ struct ListItemView: View { var body: some View { Label { - BlockSequence(self.item.blocks) + BlockSequence(self.item.children) } icon: { self.markerStyle .makeBody(configuration: .init(listLevel: self.listLevel, itemNumber: self.number)) diff --git a/Sources/MarkdownUI/Views/Blocks/NumberedListView.swift b/Sources/MarkdownUI/Views/Blocks/NumberedListView.swift index 749c4720..99a07f16 100644 --- a/Sources/MarkdownUI/Views/Blocks/NumberedListView.swift +++ b/Sources/MarkdownUI/Views/Blocks/NumberedListView.swift @@ -6,12 +6,12 @@ struct NumberedListView: View { @State private var markerWidth: CGFloat? - private let tight: Bool + private let isTight: Bool private let start: Int - private let items: [ListItem] + private let items: [RawListItem] - init(tight: Bool, start: Int, items: [ListItem]) { - self.tight = tight + init(isTight: Bool, start: Int, items: [RawListItem]) { + self.isTight = isTight self.start = start self.items = items } @@ -24,7 +24,7 @@ struct NumberedListView: View { markerWidth: self.markerWidth ) .environment(\.listLevel, self.listLevel + 1) - .environment(\.tightSpacingEnabled, self.tight) + .environment(\.tightSpacingEnabled, self.isTight) .onColumnWidthChange { columnWidths in self.markerWidth = columnWidths[0] } diff --git a/Sources/MarkdownUI/Views/Blocks/TableCell.swift b/Sources/MarkdownUI/Views/Blocks/TableCell.swift index fb613485..a75634cd 100644 --- a/Sources/MarkdownUI/Views/Blocks/TableCell.swift +++ b/Sources/MarkdownUI/Views/Blocks/TableCell.swift @@ -4,12 +4,12 @@ import SwiftUI struct TableCell: View { private let row: Int private let column: Int - private let inlines: [Inline] + private let cell: RawTableCell - init(row: Int, column: Int, inlines: [Inline]) { + init(row: Int, column: Int, cell: RawTableCell) { self.row = row self.column = column - self.inlines = inlines + self.cell = cell } var body: some View { @@ -25,10 +25,10 @@ struct TableCell: View { } @ViewBuilder private var content: some View { - if let imageFlow = ImageFlow(self.inlines) { + if let imageFlow = ImageFlow(self.cell.content) { imageFlow } else { - InlineText(self.inlines) + InlineText(self.cell.content) } } } diff --git a/Sources/MarkdownUI/Views/Blocks/TableView.swift b/Sources/MarkdownUI/Views/Blocks/TableView.swift index 7dac7521..140655ee 100644 --- a/Sources/MarkdownUI/Views/Blocks/TableView.swift +++ b/Sources/MarkdownUI/Views/Blocks/TableView.swift @@ -5,9 +5,9 @@ struct TableView: View { @Environment(\.tableBorderStyle.strokeStyle.lineWidth) private var borderWidth private let columnAlignments: [HorizontalAlignment] - private let rows: [[[Inline]]] + private let rows: [RawTableRow] - init(columnAlignments: [TextTableColumnAlignment?], rows: [[[Inline]]]) { + init(columnAlignments: [RawTableColumnAlignment], rows: [RawTableRow]) { self.columnAlignments = columnAlignments.map(HorizontalAlignment.init) self.rows = rows } @@ -17,7 +17,7 @@ struct TableView: View { ForEach(0.. AttributeContainer { - var newAttributes = attributes - self._collectAttributes(in: &newAttributes) - return newAttributes - } -} diff --git a/Sources/MarkdownUI/Views/Inlines/ImageView.swift b/Sources/MarkdownUI/Views/Inlines/ImageView.swift index 1771ecd1..4a8f6e4a 100644 --- a/Sources/MarkdownUI/Views/Inlines/ImageView.swift +++ b/Sources/MarkdownUI/Views/Inlines/ImageView.swift @@ -4,39 +4,32 @@ struct ImageView: View { @Environment(\.imageProvider) private var imageProvider @Environment(\.imageBaseURL) private var baseURL - private let source: String? - private let alt: String - private let destination: String? + private let data: RawImageData - init(source: String?, alt: String, destination: String? = nil) { - self.source = source - self.alt = alt - self.destination = destination + init(data: RawImageData) { + self.data = data } var body: some View { ApplyBlockStyle( \.image, to: self.imageProvider.makeImage(url: self.url) - .link(destination: self.destination) + .link(destination: self.data.destination) ) - .accessibilityLabel(self.alt) + .accessibilityLabel(self.data.alt) } private var url: URL? { - self.source.flatMap { - URL(string: $0, relativeTo: self.baseURL) - } + URL(string: self.data.source, relativeTo: self.baseURL) } } extension ImageView { - init?(_ inlines: [Inline]) { - guard inlines.count == 1, let inline = inlines.first, let image = inline.image else { + init?(_ inlines: [InlineNode]) { + guard inlines.count == 1, let data = inlines.first?.imageData else { return nil } - - self.init(source: image.source, alt: image.alt, destination: image.destination) + self.init(data: data) } } @@ -52,8 +45,14 @@ private struct LinkModifier: ViewModifier { let destination: String? + var url: URL? { + self.destination.flatMap { + URL(string: $0, relativeTo: self.baseURL) + } + } + func body(content: Content) -> some View { - if let url = self.destination.flatMap({ URL(string: $0, relativeTo: self.baseURL) }) { + if let url { Button { self.openURL(url) } label: { diff --git a/Sources/MarkdownUI/Views/Inlines/InlineText.swift b/Sources/MarkdownUI/Views/Inlines/InlineText.swift index 5c55d62b..6d4ac621 100644 --- a/Sources/MarkdownUI/Views/Inlines/InlineText.swift +++ b/Sources/MarkdownUI/Views/Inlines/InlineText.swift @@ -8,25 +8,24 @@ struct InlineText: View { @State private var inlineImages: [String: Image] = [:] - private let inlines: [Inline] + private let inlines: [InlineNode] - init(_ inlines: [Inline]) { + init(_ inlines: [InlineNode]) { self.inlines = inlines } var body: some View { TextStyleAttributesReader { attributes in - Text( - inlines: self.inlines, - images: self.inlineImages, - environment: .init( - baseURL: self.baseURL, + self.inlines.renderText( + baseURL: self.baseURL, + textStyles: .init( code: self.theme.code, emphasis: self.theme.emphasis, strong: self.theme.strong, strikethrough: self.theme.strikethrough, link: self.theme.link ), + images: self.inlineImages, attributes: attributes ) } @@ -37,19 +36,17 @@ struct InlineText: View { } private func loadInlineImages() async throws -> [String: Image] { - let images = Set(self.inlines.compactMap(\.image)) + let images = Set(self.inlines.compactMap(\.imageData)) guard !images.isEmpty else { return [:] } return try await withThrowingTaskGroup(of: (String, Image).self) { taskGroup in for image in images { - guard let source = image.source, - let url = URL(string: source, relativeTo: self.imageBaseURL) - else { + guard let url = URL(string: image.source, relativeTo: self.imageBaseURL) else { continue } taskGroup.addTask { - (source, try await self.inlineImageProvider.image(with: url, label: image.alt)) + (image.source, try await self.inlineImageProvider.image(with: url, label: image.alt)) } } diff --git a/Sources/MarkdownUI/Views/Inlines/Text+Inline.swift b/Sources/MarkdownUI/Views/Inlines/Text+Inline.swift deleted file mode 100644 index 935bdd9b..00000000 --- a/Sources/MarkdownUI/Views/Inlines/Text+Inline.swift +++ /dev/null @@ -1,36 +0,0 @@ -import SwiftUI - -extension Text { - init( - inlines: [Inline], - images: [String: Image], - environment: InlineEnvironment, - attributes: AttributeContainer - ) { - self = inlines.map { inline in - Text(inline: inline, images: images, environment: environment, attributes: attributes) - } - .reduce(.init(""), +) - } - - init( - inline: Inline, - images: [String: Image], - environment: InlineEnvironment, - attributes: AttributeContainer - ) { - switch inline { - case .image(let source, _): - if let image = images[source] { - self.init(image) - } else { - self.init("") - } - default: - self.init( - AttributedString(inline: inline, environment: environment, attributes: attributes) - .resolvingFonts() - ) - } - } -} diff --git a/Sources/MarkdownUI/Views/Markdown.swift b/Sources/MarkdownUI/Views/Markdown.swift index bb3dfa62..7e182f9e 100644 --- a/Sources/MarkdownUI/Views/Markdown.swift +++ b/Sources/MarkdownUI/Views/Markdown.swift @@ -221,8 +221,8 @@ public struct Markdown: View { .environment(\.imageBaseURL, self.imageBaseURL) } - private var blocks: [Block] { - self.content.colorScheme(self.colorScheme).blocks + private var blocks: [BlockNode] { + self.content.blocks.filterImagesMatching(colorScheme: self.colorScheme) } } diff --git a/Tests/MarkdownUITests/InlineContentBuilderTests.swift b/Tests/MarkdownUITests/InlineContentBuilderTests.swift index f70057f2..b1079d0f 100644 --- a/Tests/MarkdownUITests/InlineContentBuilderTests.swift +++ b/Tests/MarkdownUITests/InlineContentBuilderTests.swift @@ -45,10 +45,10 @@ final class InlineContentBuilderTests: XCTestCase { .lineBreak, .code("let a = b"), .strikethrough( - [ + children: [ .text("This is a "), - .strong([.text("mistake, ")]), - .emphasis([.text("right?")]), + .strong(children: [.text("mistake, ")]), + .emphasis(children: [.text("right?")]), ] ), .link(destination: "https://w.wiki/qYn", children: [.text("Hurricane")]), @@ -102,7 +102,7 @@ final class InlineContentBuilderTests: XCTestCase { InlineContent( inlines: [ .text("Something is "), - .emphasis([.text("true")]), + .emphasis(children: [.text("true")]), ] ), result @@ -130,7 +130,7 @@ final class InlineContentBuilderTests: XCTestCase { InlineContent( inlines: [ .text("Something is "), - .emphasis([.text("true")]), + .emphasis(children: [.text("true")]), ] ), result1 diff --git a/Tests/MarkdownUITests/ListContentBuilderTests.swift b/Tests/MarkdownUITests/ListContentBuilderTests.swift index 007546aa..13a198ac 100644 --- a/Tests/MarkdownUITests/ListContentBuilderTests.swift +++ b/Tests/MarkdownUITests/ListContentBuilderTests.swift @@ -35,9 +35,9 @@ final class ListContentBuilderTests: XCTestCase { // then XCTAssertEqual( [ - .init(blocks: [.paragraph([.text("Flour")])]), - .init(blocks: [.paragraph([.text("Cheese")])]), - .init(blocks: [.paragraph([.text("Tomatoes")])]), + .init(children: [.paragraph(content: [.text("Flour")])]), + .init(children: [.paragraph(content: [.text("Cheese")])]), + .init(children: [.paragraph(content: [.text("Tomatoes")])]), ], result ) @@ -57,10 +57,10 @@ final class ListContentBuilderTests: XCTestCase { // then XCTAssertEqual( [ - .init(blocks: [.paragraph([.text("0")])]), - .init(blocks: [.paragraph([.text("1")])]), - .init(blocks: [.paragraph([.text("2")])]), - .init(blocks: [.paragraph([.text("3")])]), + .init(children: [.paragraph(content: [.text("0")])]), + .init(children: [.paragraph(content: [.text("1")])]), + .init(children: [.paragraph(content: [.text("2")])]), + .init(children: [.paragraph(content: [.text("3")])]), ], result ) @@ -82,8 +82,8 @@ final class ListContentBuilderTests: XCTestCase { // then XCTAssertEqual( [ - .init(blocks: [.paragraph([.text("Something is:")])]), - .init(blocks: [.paragraph([.text("true")])]), + .init(children: [.paragraph(content: [.text("Something is:")])]), + .init(children: [.paragraph(content: [.text("true")])]), ], result ) @@ -108,15 +108,15 @@ final class ListContentBuilderTests: XCTestCase { // then XCTAssertEqual( [ - .init(blocks: [.paragraph([.text("Something is:")])]), - .init(blocks: [.paragraph([.text("true")])]), + .init(children: [.paragraph(content: [.text("Something is:")])]), + .init(children: [.paragraph(content: [.text("true")])]), ], result1 ) XCTAssertEqual( [ - .init(blocks: [.paragraph([.text("Something is:")])]), - .init(blocks: [.paragraph([.text("false")])]), + .init(children: [.paragraph(content: [.text("Something is:")])]), + .init(children: [.paragraph(content: [.text("false")])]), ], result2 ) diff --git a/Tests/MarkdownUITests/MarkdownContentBuilderTests.swift b/Tests/MarkdownUITests/MarkdownContentBuilderTests.swift index 267fd7c3..696896f9 100644 --- a/Tests/MarkdownUITests/MarkdownContentBuilderTests.swift +++ b/Tests/MarkdownUITests/MarkdownContentBuilderTests.swift @@ -33,14 +33,14 @@ final class MarkdownContentBuilderTests: XCTestCase { MarkdownContent( blocks: [ .paragraph( - [ - .strong([.text("First")]), + content: [ + .strong(children: [.text("First")]), .text(" paragraph."), ] ), .paragraph( - [ - .strong([.text("Second")]), + content: [ + .strong(children: [.text("Second")]), .text(" paragraph."), ] ), @@ -65,10 +65,10 @@ final class MarkdownContentBuilderTests: XCTestCase { XCTAssertEqual( MarkdownContent( blocks: [ - .paragraph([.text("0")]), - .paragraph([.text("1")]), - .paragraph([.text("2")]), - .paragraph([.text("3")]), + .paragraph(content: [.text("0")]), + .paragraph(content: [.text("1")]), + .paragraph(content: [.text("2")]), + .paragraph(content: [.text("3")]), ] ), result @@ -90,8 +90,8 @@ final class MarkdownContentBuilderTests: XCTestCase { XCTAssertEqual( MarkdownContent( blocks: [ - .paragraph([.text("Something is:")]), - .paragraph([.text("true")]), + .paragraph(content: [.text("Something is:")]), + .paragraph(content: [.text("true")]), ] ), result @@ -116,8 +116,8 @@ final class MarkdownContentBuilderTests: XCTestCase { XCTAssertEqual( MarkdownContent( blocks: [ - .paragraph([.text("Something is:")]), - .paragraph([.text("true")]), + .paragraph(content: [.text("Something is:")]), + .paragraph(content: [.text("true")]), ] ), result1 @@ -125,8 +125,8 @@ final class MarkdownContentBuilderTests: XCTestCase { XCTAssertEqual( MarkdownContent( blocks: [ - .paragraph([.text("Something is:")]), - .paragraph([.text("false")]), + .paragraph(content: [.text("Something is:")]), + .paragraph(content: [.text("false")]), ] ), result2 diff --git a/Tests/MarkdownUITests/MarkdownContentTests.swift b/Tests/MarkdownUITests/MarkdownContentTests.swift index 0a42d156..1d674675 100644 --- a/Tests/MarkdownUITests/MarkdownContentTests.swift +++ b/Tests/MarkdownUITests/MarkdownContentTests.swift @@ -8,16 +8,19 @@ final class MarkdownContentTests: XCTestCase { // then XCTAssertEqual(MarkdownContent {}, content) + XCTAssertEqual("", content.renderMarkdown()) } func testBlockquote() { - // when - let content = MarkdownContent { - """ - >Hello - >>World + // given + let markdown = """ + > Hello + >\u{20} + > > World """ - } + + // when + let content = MarkdownContent(markdown) // then XCTAssertEqual( @@ -31,18 +34,20 @@ final class MarkdownContentTests: XCTestCase { }, content ) + XCTAssertEqual(markdown, content.renderMarkdown()) } func testList() { - // when - let content = MarkdownContent { - """ - 1. one - 1. two + // given + let markdown = """ + 1. one + 2. two - nested 1 - nested 2 """ - } + + // when + let content = MarkdownContent(markdown) // then XCTAssertEqual( @@ -60,19 +65,22 @@ final class MarkdownContentTests: XCTestCase { }, content ) + XCTAssertEqual(markdown, content.renderMarkdown()) } func testLooseList() { - // when - let content = MarkdownContent { - """ - 9. one + // given + let markdown = """ + 9. one - 1. two + 10. two + \u{20}\u{20}\u{20}\u{20} - nested 1 - nested 2 """ - } + + // when + let content = MarkdownContent(markdown) // then XCTAssertEqual( @@ -90,16 +98,18 @@ final class MarkdownContentTests: XCTestCase { }, content ) + XCTAssertEqual(markdown, content.renderMarkdown()) } func testTaskList() { - // when - let content = MarkdownContent { + // given + let markdown = """ + - [ ] one + - [x] two """ - - [ ] one - - [x] two - """ - } + + // when + let content = MarkdownContent(markdown) // then XCTAssertEqual( @@ -113,18 +123,20 @@ final class MarkdownContentTests: XCTestCase { }, content ) + XCTAssertEqual(markdown, content.renderMarkdown()) } func testCodeBlock() { - // when - let content = MarkdownContent { - """ - ```swift + // given + let markdown = """ + ``` swift let a = 5 let b = 42 ``` """ - } + + // when + let content = MarkdownContent(markdown) // then XCTAssertEqual( @@ -139,11 +151,15 @@ final class MarkdownContentTests: XCTestCase { }, content ) + XCTAssertEqual(markdown, content.renderMarkdown()) } func testParagraph() { + // given + let markdown = "Hello world\\!" + // when - let content = MarkdownContent("Hello world!") + let content = MarkdownContent(markdown) // then XCTAssertEqual( @@ -154,16 +170,19 @@ final class MarkdownContentTests: XCTestCase { }, content ) + XCTAssertEqual(markdown, content.renderMarkdown()) } func testHeading() { - // when - let content = MarkdownContent { - """ - # Hello - ## World + // given + let markdown = """ + # Hello + + ## World """ - } + + // when + let content = MarkdownContent(markdown) // then XCTAssertEqual( @@ -177,6 +196,7 @@ final class MarkdownContentTests: XCTestCase { }, content ) + XCTAssertEqual(markdown, content.renderMarkdown()) } func testTable() throws { @@ -184,15 +204,16 @@ final class MarkdownContentTests: XCTestCase { throw XCTSkip("Required API is not available for this test") } - // when - let content = MarkdownContent { - """ - | Default | Leading | Center | Trailing | - | --- | :--- | :---: | ---: | + // given + let markdown = """ + | Default | Leading | Center | Trailing | + | --- | :-- | :-: | --: | | git status | git status | git status | git status | - | git diff | git diff | git diff | git diff | + | git diff | git diff | git diff | git diff | """ - } + + // when + let content = MarkdownContent(markdown) // then XCTAssertEqual( @@ -209,17 +230,21 @@ final class MarkdownContentTests: XCTestCase { }, content ) + XCTAssertEqual(markdown, content.renderMarkdown()) } func testThematicBreak() { - // when - let content = MarkdownContent { - """ + // given + let markdown = """ Foo - *** + + ----- + Bar """ - } + + // when + let content = MarkdownContent(markdown) // then XCTAssertEqual( @@ -230,16 +255,15 @@ final class MarkdownContentTests: XCTestCase { }, content ) + XCTAssertEqual(markdown, content.renderMarkdown()) } func testSoftBreak() { + // given + let markdown = "Hello\nWorld" + // when - let content = MarkdownContent { - """ - Hello - World - """ - } + let content = MarkdownContent(markdown) // then XCTAssertEqual( @@ -252,11 +276,15 @@ final class MarkdownContentTests: XCTestCase { }, content ) + XCTAssertEqual(markdown, content.renderMarkdown()) } func testLineBreak() { + // given + let markdown = "Hello \nWorld" + // when - let content = MarkdownContent("Hello \n World") + let content = MarkdownContent(markdown) // then XCTAssertEqual( @@ -269,11 +297,15 @@ final class MarkdownContentTests: XCTestCase { }, content ) + XCTAssertEqual(markdown, content.renderMarkdown()) } func testCode() { + // given + let markdown = "Returns `nil`." + // when - let content = MarkdownContent("Returns `nil`.") + let content = MarkdownContent(markdown) // then XCTAssertEqual( @@ -286,11 +318,15 @@ final class MarkdownContentTests: XCTestCase { }, content ) + XCTAssertEqual(markdown, content.renderMarkdown()) } func testEmphasis() { + // given + let markdown = "Hello *world*." + // when - let content = MarkdownContent("Hello _world_.") + let content = MarkdownContent(markdown) // then XCTAssertEqual( @@ -303,11 +339,15 @@ final class MarkdownContentTests: XCTestCase { }, content ) + XCTAssertEqual(markdown, content.renderMarkdown()) } func testStrong() { + // given + let markdown = "Hello **world**." + // when - let content = MarkdownContent("Hello __world__.") + let content = MarkdownContent(markdown) // then XCTAssertEqual( @@ -320,11 +360,15 @@ final class MarkdownContentTests: XCTestCase { }, content ) + XCTAssertEqual(markdown, content.renderMarkdown()) } func testStrikethrough() { + // given + let markdown = "Hello ~~world~~." + // when - let content = MarkdownContent("Hello ~world~.") + let content = MarkdownContent(markdown) // then XCTAssertEqual( @@ -337,11 +381,15 @@ final class MarkdownContentTests: XCTestCase { }, content ) + XCTAssertEqual(markdown, content.renderMarkdown()) } func testLink() { + // given + let markdown = "Hello [world](https://example.com)." + // when - let content = MarkdownContent("Hello [world](https://example.com).") + let content = MarkdownContent(markdown) // then XCTAssertEqual( @@ -354,11 +402,15 @@ final class MarkdownContentTests: XCTestCase { }, content ) + XCTAssertEqual(markdown, content.renderMarkdown()) } func testImage() { + // given + let markdown = "![Puppy](https://picsum.photos/id/237/200/300)" + // when - let content = MarkdownContent("![Puppy](https://picsum.photos/id/237/200/300)") + let content = MarkdownContent(markdown) // then XCTAssertEqual( @@ -369,5 +421,6 @@ final class MarkdownContentTests: XCTestCase { }, content ) + XCTAssertEqual(markdown, content.renderMarkdown()) } } diff --git a/Tests/MarkdownUITests/TaskListContentBuilderTests.swift b/Tests/MarkdownUITests/TaskListContentBuilderTests.swift index cb9b991b..48622eaf 100644 --- a/Tests/MarkdownUITests/TaskListContentBuilderTests.swift +++ b/Tests/MarkdownUITests/TaskListContentBuilderTests.swift @@ -35,9 +35,9 @@ final class TaskListContentBuilderTests: XCTestCase { // then XCTAssertEqual( [ - .init(isCompleted: false, blocks: [.paragraph([.text("Flour")])]), - .init(isCompleted: true, blocks: [.paragraph([.text("Cheese")])]), - .init(isCompleted: false, blocks: [.paragraph([.text("Tomatoes")])]), + .init(isCompleted: false, children: [.paragraph(content: [.text("Flour")])]), + .init(isCompleted: true, children: [.paragraph(content: [.text("Cheese")])]), + .init(isCompleted: false, children: [.paragraph(content: [.text("Tomatoes")])]), ], result ) @@ -57,10 +57,10 @@ final class TaskListContentBuilderTests: XCTestCase { // then XCTAssertEqual( [ - .init(isCompleted: false, blocks: [.paragraph([.text("0")])]), - .init(isCompleted: false, blocks: [.paragraph([.text("1")])]), - .init(isCompleted: false, blocks: [.paragraph([.text("2")])]), - .init(isCompleted: false, blocks: [.paragraph([.text("3")])]), + .init(isCompleted: false, children: [.paragraph(content: [.text("0")])]), + .init(isCompleted: false, children: [.paragraph(content: [.text("1")])]), + .init(isCompleted: false, children: [.paragraph(content: [.text("2")])]), + .init(isCompleted: false, children: [.paragraph(content: [.text("3")])]), ], result ) @@ -82,8 +82,8 @@ final class TaskListContentBuilderTests: XCTestCase { // then XCTAssertEqual( [ - .init(isCompleted: false, blocks: [.paragraph([.text("Something is:")])]), - .init(isCompleted: true, blocks: [.paragraph([.text("true")])]), + .init(isCompleted: false, children: [.paragraph(content: [.text("Something is:")])]), + .init(isCompleted: true, children: [.paragraph(content: [.text("true")])]), ], result ) @@ -108,15 +108,15 @@ final class TaskListContentBuilderTests: XCTestCase { // then XCTAssertEqual( [ - .init(isCompleted: false, blocks: [.paragraph([.text("Something is:")])]), - .init(isCompleted: true, blocks: [.paragraph([.text("true")])]), + .init(isCompleted: false, children: [.paragraph(content: [.text("Something is:")])]), + .init(isCompleted: true, children: [.paragraph(content: [.text("true")])]), ], result1 ) XCTAssertEqual( [ - .init(isCompleted: false, blocks: [.paragraph([.text("Something is:")])]), - .init(isCompleted: false, blocks: [.paragraph([.text("false")])]), + .init(isCompleted: false, children: [.paragraph(content: [.text("Something is:")])]), + .init(isCompleted: false, children: [.paragraph(content: [.text("false")])]), ], result2 )