Skip to content

Instantly share code, notes, and snippets.

@fxm90
Created June 28, 2021 14:43
Show Gist options
  • Save fxm90/abd949e4258050f2f3cd80118024e5bd to your computer and use it in GitHub Desktop.
Save fxm90/abd949e4258050f2f3cd80118024e5bd to your computer and use it in GitHub Desktop.
Extension that converts Strings with basic HTML tags to SwiftUI's Text (Supports SwiftUI 3.0 / iOS 15.0).
//
// SwiftUI+HTML.swift
//
// Created by Felix Mau on 28.05.21.
// Copyright © 2021 Felix Mau. All rights reserved.
//
import SwiftUI
@available(iOS 15.0, *)
enum HTMLToMarkdownConverter {
// MARK: - Public methods
/// Converts the HTML-tags in the given string to their corresponding markdown tags.
///
/// - SeeAlso: See type `HTMLToMarkdownConverter.Tags` for a list of supported HTML-tags.
static func convert(_ htmlAsString: String) -> String {
// Convert "basic" HTML-tags that don't use an attribute.
let markdownAsString = Tags.allCases.reduce(htmlAsString) { result, textFormattingTag in
result
.replacingOccurrences(of: textFormattingTag.openingHtmlTag, with: textFormattingTag.markdownTag)
.replacingOccurrences(of: textFormattingTag.closingHtmlTag, with: textFormattingTag.markdownTag)
}
// Hyperlinks use an attribute and therefore need to be handled differently.
return convertHtmlLinksToMarkdown(markdownAsString)
}
// MARK: - Private methods
/// Converts hyperlinks in HTML-format to their corresponding markdown representations.
///
/// - Note: Currently we only support a basic HTML syntax without any attributed other than `href`.
/// E.g. `<a href="URL">TEXT</a>` will be converted to `[TEXT](URL)`
///
/// - Parameter htmlAsString: The string containing hyperlinks in HTML-format.
///
/// - Returns: A string with hyperlinks converted to their corresponding markdown representations.
private static func convertHtmlLinksToMarkdown(_ htmlAsString: String) -> String {
htmlAsString.replacingOccurrences(of: "<a href=\"(.+)\">(.+)</a>",
with: "[$2]($1)",
options: .regularExpression,
range: nil)
}
}
extension HTMLToMarkdownConverter {
/// The supported tags inside a string we can format.
enum Tags: String, CaseIterable {
case strong
case em
case s
case code
// Hyperlinks need to be handled differently, as they not only have simple opening and closing tag, but also use the attribute `href`.
// See private method `Text.convertHtmlLinksToMarkdown(:)` for further details.
// case a
// MARK: - Public properties
var openingHtmlTag: String {
"<\(rawValue)>"
}
var closingHtmlTag: String {
"</\(rawValue)>"
}
var markdownTag: String {
switch self {
case .strong:
return "**"
case .em:
return "*"
case .s:
return "~~"
case .code:
return "`"
}
}
}
}
@available(iOS 15.0, *)
extension Text {
// MARK: - Initializer
/// Renders the given string containing HTML-tags with the related formatting.
///
/// - SeeAlso: See type `HTMLToMarkdownConverter.Tags` for a list of supported HTML-tags.
init(html htmlAsString: String) {
let markdownAsString = HTMLToMarkdownConverter.convert(htmlAsString)
do {
let markdownAsAttributedString = try AttributedString(markdown: markdownAsString)
self = .init(markdownAsAttributedString)
} catch {
print("⚠️ – Couldn't parse markdown: \(error)")
// Show the "plain" markdown string as a fallback.
self = .init(markdownAsString)
}
}
}
@fxm90
Copy link
Author

fxm90 commented Jun 28, 2021

Example

Code

struct ContentView: View {

    var body: some View {
        VStack(spacing: 4) {
            Text(html: "Regular")
            Text(html: "<em>Italics</em>")
            Text(html: "<strong>Bold</strong>")
            Text(html: "<s>Strikethrough</s>")
            Text(html: "<code>Code (Monospaced)</code>")
            Text(html: "<a href=\"https://apple.com\">Visit Apple</a>")
            Text(html: "<strong><em><a href=\"https://apple.com\">They</a></em> <s>are</s> <code>combinable</code>.</strong>")
        }.padding()
    }
}

Preview

image

@fxm90
Copy link
Author

fxm90 commented Jun 28, 2021

Tests

Unit-Tests for HTMLToMarkdownConverter.convert(:)

class HTMLToMarkdownConverterTestCase: XCTestCase {

    func testConvertShouldIgnoreStringWithoutHTMLTags() {
        // Given
        let html = "Lorem ipsum dolor sit amet"

        // When
        let markdown = HTMLToMarkdownConverter.convert(html)

        // Then
        XCTAssertEqual(html, markdown)
    }

    func testConvertShouldIgnoreUnsupportedHTMLTags() {
        // Given
        let html = "<p>Lorem ipsum dolor sit amet</p>"

        // When
        let markdown = HTMLToMarkdownConverter.convert(html)

        // Then
        XCTAssertEqual(html, markdown)
    }

    func testConvertShouldReplaceStrongTag() {
        // Given
        let html = "<strong>Lorem</strong> ipsum <strong>dolor</strong> sit <strong>amet</strong>"

        // When
        let markdown = HTMLToMarkdownConverter.convert(html)

        // Then
        XCTAssertEqual(markdown, "**Lorem** ipsum **dolor** sit **amet**")
    }

    func testConvertShouldReplaceEmTag() {
        // Given
        let html = "<em>Lorem</em> ipsum <em>dolor</em> sit <em>amet</em>"

        // When
        let markdown = HTMLToMarkdownConverter.convert(html)

        // Then
        XCTAssertEqual(markdown, "*Lorem* ipsum *dolor* sit *amet*")
    }

    func testConvertShouldReplaceStrikeTag() {
        // Given
        let html = "<s>Lorem</s> ipsum <s>dolor</s> sit <s>amet</s>"

        // When
        let markdown = HTMLToMarkdownConverter.convert(html)

        // Then
        XCTAssertEqual(markdown, "~~Lorem~~ ipsum ~~dolor~~ sit ~~amet~~")
    }

    func testConvertShouldReplaceCodeTag() {
        // Given
        let html = "<code>Lorem</code> ipsum <code>dolor</code> sit <code>amet</code>"

        // When
        let markdown = HTMLToMarkdownConverter.convert(html)

        // Then
        XCTAssertEqual(markdown, "`Lorem` ipsum `dolor` sit `amet`")
    }

    func testConvertShouldReplaceLinkTag() {
        // Given
        let html = "Visit <a href=\"https://apple.com\">Apple</a>"

        // When
        let markdown = HTMLToMarkdownConverter.convert(html)

        // Then
        XCTAssertEqual(markdown, "Visit [Apple](https://apple.com)")
    }

    func testConvertShouldHandleMultipleTags() {
        // Given
        let html = "Visit <a href=\"https://apple.com\">Apple</a>. <strong>Lorem</strong> <em>ipsum</em>"

        // When
        let markdown = HTMLToMarkdownConverter.convert(html)

        // Then
        XCTAssertEqual(markdown, "Visit [Apple](https://apple.com). **Lorem** *ipsum*")
    }

    func testConvertShouldHandleNestedTags() {
        // Given
        let html = "Visit <strong><em><a href=\"https://apple.com\">Apple</a></em></strong>"

        // When
        let markdown = HTMLToMarkdownConverter.convert(html)

        // Then
        XCTAssertEqual(markdown, "Visit ***[Apple](https://apple.com)***")
    }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment