diff --git a/Sources/MarkdownUI/Parser/HTMLTag.swift b/Sources/MarkdownUI/Parser/HTMLTag.swift
new file mode 100644
index 00000000..08bbfce1
--- /dev/null
+++ b/Sources/MarkdownUI/Parser/HTMLTag.swift
@@ -0,0 +1,25 @@
+import Foundation
+
+struct HTMLTag {
+ let name: String
+}
+
+extension HTMLTag {
+ private enum Constants {
+ static let tagExpression = try! NSRegularExpression(pattern: "<\\/?([a-zA-Z0-9]+)[^>]*>")
+ }
+
+ init?(_ description: String) {
+ guard
+ let match = Constants.tagExpression.firstMatch(
+ in: description,
+ range: NSRange(description.startIndex..., in: description)
+ ),
+ let nameRange = Range(match.range(at: 1), in: description)
+ else {
+ return nil
+ }
+
+ self.name = String(description[nameRange])
+ }
+}
diff --git a/Sources/MarkdownUI/Renderer/AttributedStringInlineRenderer.swift b/Sources/MarkdownUI/Renderer/AttributedStringInlineRenderer.swift
index aa5d7cdc..a6d2dced 100644
--- a/Sources/MarkdownUI/Renderer/AttributedStringInlineRenderer.swift
+++ b/Sources/MarkdownUI/Renderer/AttributedStringInlineRenderer.swift
@@ -22,6 +22,7 @@ private struct AttributedStringInlineRenderer {
private let baseURL: URL?
private let textStyles: InlineTextStyles
private var attributes: AttributeContainer
+ private var shouldSkipNextWhitespace = false
init(baseURL: URL?, textStyles: InlineTextStyles, attributes: AttributeContainer) {
self.baseURL = baseURL
@@ -55,26 +56,45 @@ private struct AttributedStringInlineRenderer {
}
private mutating func renderText(_ text: String) {
+ var text = text
+
+ if self.shouldSkipNextWhitespace {
+ self.shouldSkipNextWhitespace = false
+ text = text.replacingOccurrences(of: "^\\s+", with: "", options: .regularExpression)
+ }
+
self.result += .init(text, attributes: self.attributes)
}
private mutating func renderSoftBreak() {
- self.result += .init(" ", attributes: self.attributes)
+ if self.shouldSkipNextWhitespace {
+ self.shouldSkipNextWhitespace = false
+ } else {
+ self.result += .init(" ", attributes: self.attributes)
+ }
}
private mutating func renderLineBreak() {
self.result += .init("\n", attributes: self.attributes)
}
- mutating func renderCode(_ code: String) {
+ private 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)
+ private mutating func renderHTML(_ html: String) {
+ let tag = HTMLTag(html)
+
+ switch tag?.name.lowercased() {
+ case "br":
+ self.renderLineBreak()
+ self.shouldSkipNextWhitespace = true
+ default:
+ self.renderText(html)
+ }
}
- mutating func renderEmphasis(children: [InlineNode]) {
+ private mutating func renderEmphasis(children: [InlineNode]) {
let savedAttributes = self.attributes
self.attributes = self.textStyles.emphasis.mergingAttributes(self.attributes)
@@ -85,7 +105,7 @@ private struct AttributedStringInlineRenderer {
self.attributes = savedAttributes
}
- mutating func renderStrong(children: [InlineNode]) {
+ private mutating func renderStrong(children: [InlineNode]) {
let savedAttributes = self.attributes
self.attributes = self.textStyles.strong.mergingAttributes(self.attributes)
@@ -96,7 +116,7 @@ private struct AttributedStringInlineRenderer {
self.attributes = savedAttributes
}
- mutating func renderStrikethrough(children: [InlineNode]) {
+ private mutating func renderStrikethrough(children: [InlineNode]) {
let savedAttributes = self.attributes
self.attributes = self.textStyles.strikethrough.mergingAttributes(self.attributes)
@@ -107,7 +127,7 @@ private struct AttributedStringInlineRenderer {
self.attributes = savedAttributes
}
- mutating func renderLink(destination: String, children: [InlineNode]) {
+ private 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)
@@ -119,7 +139,7 @@ private struct AttributedStringInlineRenderer {
self.attributes = savedAttributes
}
- mutating func renderImage(source: String, children: [InlineNode]) {
+ private mutating func renderImage(source: String, children: [InlineNode]) {
// AttributedString does not support images
}
}
diff --git a/Sources/MarkdownUI/Renderer/TextInlineRenderer.swift b/Sources/MarkdownUI/Renderer/TextInlineRenderer.swift
index 79c6375d..50ae3657 100644
--- a/Sources/MarkdownUI/Renderer/TextInlineRenderer.swift
+++ b/Sources/MarkdownUI/Renderer/TextInlineRenderer.swift
@@ -25,6 +25,7 @@ private struct TextInlineRenderer {
private let textStyles: InlineTextStyles
private let images: [String: Image]
private let attributes: AttributeContainer
+ private var shouldSkipNextWhitespace = false
init(
baseURL: URL?,
@@ -46,20 +47,65 @@ private struct TextInlineRenderer {
private mutating func render(_ inline: InlineNode) {
switch inline {
+ case .text(let content):
+ self.renderText(content)
+ case .softBreak:
+ self.renderSoftBreak()
+ case .html(let content):
+ self.renderHTML(content)
case .image(let source, _):
- if let image = self.images[source] {
- self.result = self.result + Text(image)
- }
+ self.renderImage(source)
default:
- self.result =
- self.result
- + Text(
- inline.renderAttributedString(
- baseURL: self.baseURL,
- textStyles: self.textStyles,
- attributes: self.attributes
- )
- )
+ self.defaultRender(inline)
+ }
+ }
+
+ private mutating func renderText(_ text: String) {
+ var text = text
+
+ if self.shouldSkipNextWhitespace {
+ self.shouldSkipNextWhitespace = false
+ text = text.replacingOccurrences(of: "^\\s+", with: "", options: .regularExpression)
+ }
+
+ self.defaultRender(.text(text))
+ }
+
+ private mutating func renderSoftBreak() {
+ if self.shouldSkipNextWhitespace {
+ self.shouldSkipNextWhitespace = false
+ } else {
+ self.defaultRender(.softBreak)
+ }
+ }
+
+ private mutating func renderHTML(_ html: String) {
+ let tag = HTMLTag(html)
+
+ switch tag?.name.lowercased() {
+ case "br":
+ self.defaultRender(.lineBreak)
+ self.shouldSkipNextWhitespace = true
+ default:
+ self.defaultRender(.html(html))
+ }
+ }
+
+ private mutating func renderImage(_ source: String) {
+ if let image = self.images[source] {
+ self.result = self.result + Text(image)
}
}
+
+ private mutating func defaultRender(_ inline: InlineNode) {
+ self.result =
+ self.result
+ + Text(
+ inline.renderAttributedString(
+ baseURL: self.baseURL,
+ textStyles: self.textStyles,
+ attributes: self.attributes
+ )
+ )
+ }
}
diff --git a/Tests/MarkdownUITests/HTMLTagTests.swift b/Tests/MarkdownUITests/HTMLTagTests.swift
new file mode 100644
index 00000000..b4080921
--- /dev/null
+++ b/Tests/MarkdownUITests/HTMLTagTests.swift
@@ -0,0 +1,40 @@
+import Foundation
+import XCTest
+
+@testable import MarkdownUI
+
+final class HTMLTagTests: XCTestCase {
+ func testInvalidTag() {
+ XCTAssertNil(HTMLTag(""))
+ XCTAssertNil(HTMLTag("foo"))
+ XCTAssertNil(HTMLTag("<"))
+ XCTAssertNil(HTMLTag("<>"))
+ }
+
+ func testOpeningTag() {
+ // given
+ let tag = HTMLTag("")
+
+ // then
+ XCTAssertEqual("sub", tag?.name)
+ }
+
+ func testOpeningTagWithAttributes() {
+ // given
+ let tag = HTMLTag(
+ ""
+ )
+
+ // then
+ XCTAssertEqual("img", tag?.name)
+ }
+
+ func testClosingTag() {
+ let tag = HTMLTag("")
+ XCTAssertEqual(tag?.name, "sub")
+ }
+
+ func testSelfClosingTag() {
+ XCTAssertEqual("br", HTMLTag("
")?.name)
+ }
+}
diff --git a/Tests/MarkdownUITests/MarkdownTests.swift b/Tests/MarkdownUITests/MarkdownTests.swift
index e2e7fd9c..8a190f8d 100644
--- a/Tests/MarkdownUITests/MarkdownTests.swift
+++ b/Tests/MarkdownUITests/MarkdownTests.swift
@@ -241,6 +241,10 @@
Visit https://github.com.
Use `git status` to list all new or modified files that haven't yet been committed.
+
+ You can insert a line break
+ using the HTML `
`
+
tag.
"""#
}
.border(Color.accentColor)
diff --git a/Tests/MarkdownUITests/__Snapshots__/MarkdownTests/testInlines.1.png b/Tests/MarkdownUITests/__Snapshots__/MarkdownTests/testInlines.1.png
index 94332df9..a25c1074 100644
Binary files a/Tests/MarkdownUITests/__Snapshots__/MarkdownTests/testInlines.1.png and b/Tests/MarkdownUITests/__Snapshots__/MarkdownTests/testInlines.1.png differ