summaryrefslogtreecommitdiff
path: root/Hutch/Views/Repositories/MarkdownHTMLRenderer.swift
blob: 67966f10ab699abca126734d1e73fb48f12e2877 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
import Markdown

nonisolated func markdownToHTML(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String {
    let document = Document(parsing: text)
    var renderer = MarkdownHTMLRenderer(imageURLResolver: imageURLResolver)
    return renderer.visit(document)
}

private struct MarkdownHTMLRenderer: MarkupVisitor {
    typealias Result = String

    nonisolated(unsafe) let imageURLResolver: ((String) -> String?)?
    private var isRenderingTableHead = false

    nonisolated init(imageURLResolver: ((String) -> String?)?) {
        self.imageURLResolver = imageURLResolver
    }

    nonisolated mutating func visit(_ markup: Markup) -> String {
        markup.accept(&self)
    }

    nonisolated mutating func defaultVisit(_ markup: Markup) -> String {
        visitChildren(of: markup)
    }

    nonisolated mutating func visitDocument(_ document: Document) -> String {
        visitChildren(of: document)
    }

    nonisolated mutating func visitHeading(_ heading: Heading) -> String {
        "<h\(heading.level)>\(visitChildren(of: heading))</h\(heading.level)>\n"
    }

    nonisolated mutating func visitParagraph(_ paragraph: Paragraph) -> String {
        "<p>\(visitChildren(of: paragraph))</p>\n"
    }

    nonisolated mutating func visitBlockQuote(_ blockQuote: BlockQuote) -> String {
        "<blockquote>\n\(visitChildren(of: blockQuote))</blockquote>\n"
    }

    nonisolated mutating func visitUnorderedList(_ unorderedList: UnorderedList) -> String {
        "<ul>\n\(visitChildren(of: unorderedList))</ul>\n"
    }

    nonisolated mutating func visitOrderedList(_ orderedList: OrderedList) -> String {
        "<ol>\n\(visitChildren(of: orderedList))</ol>\n"
    }

    nonisolated mutating func visitListItem(_ listItem: ListItem) -> String {
        if let checkbox = listItem.checkbox,
           listItem.childCount == 1,
           let paragraph = listItem.child(at: 0) as? Paragraph {
            let content = visitChildren(of: paragraph)
            return "<li><span class=\"task-list-item\">\(checkboxHTML(for: checkbox)) \(content)</span></li>\n"
        }

        var body = visitChildren(of: listItem)
        if let checkbox = listItem.checkbox {
            body = "<span class=\"task-list-item\">\(checkboxHTML(for: checkbox))</span>" + body
        }
        return "<li>\(body)</li>\n"
    }

    nonisolated mutating func visitCodeBlock(_ codeBlock: CodeBlock) -> String {
        let classAttribute: String
        if let language = codeBlock.language, !language.isEmpty {
            classAttribute = " class=\"language-\(escapeHTMLAttribute(language))\""
        } else {
            classAttribute = ""
        }
        return "<pre><code\(classAttribute)>\(escapeHTML(codeBlock.code))</code></pre>\n"
    }

    nonisolated mutating func visitInlineCode(_ inlineCode: InlineCode) -> String {
        "<code>\(escapeHTML(inlineCode.code))</code>"
    }

    nonisolated mutating func visitThematicBreak(_: ThematicBreak) -> String {
        "<hr>\n"
    }

    nonisolated mutating func visitHTMLBlock(_ html: HTMLBlock) -> String {
        guard let sanitized = sanitizedMarkdownHTMLBlock(html.rawHTML) else { return "" }
        return sanitized + "\n"
    }

    nonisolated mutating func visitInlineHTML(_ inlineHTML: InlineHTML) -> String {
        sanitizedMarkdownHTMLTag(inlineHTML.rawHTML) ?? ""
    }

    nonisolated mutating func visitLink(_ link: Markdown.Link) -> String {
        let content = visitChildren(of: link)
        guard let destination = link.destination,
              let sanitizedDestination = sanitizedReadmeLinkURLString(destination) else {
            return content
        }
        let href = escapeHTMLAttribute(decodeHTMLEntities(sanitizedDestination))
        return "<a href=\"\(href)\">\(content)</a>"
    }

    nonisolated mutating func visitImage(_ image: Markdown.Image) -> String {
        let altText = plainText(from: image)
        guard let source = image.source, !source.isEmpty else {
            return escapeHTML(altText)
        }

        let resolvedSource = imageURLResolver?(source) ?? source
        guard let sanitizedSource = sanitizedReadmeImageURLString(resolvedSource) else {
            return escapeHTML(altText)
        }

        let src = escapeHTMLAttribute(decodeHTMLEntities(sanitizedSource))
        return "<img src=\"\(src)\" alt=\"\(escapeHTMLAttribute(altText))\">"
    }

    nonisolated mutating func visitStrong(_ strong: Strong) -> String {
        "<strong>\(visitChildren(of: strong))</strong>"
    }

    nonisolated mutating func visitEmphasis(_ emphasis: Emphasis) -> String {
        "<em>\(visitChildren(of: emphasis))</em>"
    }

    nonisolated mutating func visitStrikethrough(_ strikethrough: Strikethrough) -> String {
        "<del>\(visitChildren(of: strikethrough))</del>"
    }

    nonisolated mutating func visitText(_ text: Markdown.Text) -> String {
        escapeHTML(text.string)
    }

    nonisolated mutating func visitSoftBreak(_: SoftBreak) -> String {
        " "
    }

    nonisolated mutating func visitLineBreak(_: LineBreak) -> String {
        "<br>"
    }

    nonisolated mutating func visitTable(_ table: Markdown.Table) -> String {
        "<table>\n\(visitChildren(of: table))</table>\n"
    }

    nonisolated mutating func visitTableHead(_ tableHead: Markdown.Table.Head) -> String {
        let previousValue = isRenderingTableHead
        isRenderingTableHead = true
        let content = visitChildren(of: tableHead)
        isRenderingTableHead = previousValue
        return "<thead>\(content)</thead>\n"
    }

    nonisolated mutating func visitTableBody(_ tableBody: Markdown.Table.Body) -> String {
        let previousValue = isRenderingTableHead
        isRenderingTableHead = false
        let content = visitChildren(of: tableBody)
        isRenderingTableHead = previousValue
        return "<tbody>\n\(content)</tbody>\n"
    }

    nonisolated mutating func visitTableRow(_ tableRow: Markdown.Table.Row) -> String {
        "<tr>\(visitChildren(of: tableRow))</tr>\n"
    }

    nonisolated mutating func visitTableCell(_ tableCell: Markdown.Table.Cell) -> String {
        let tagName = isRenderingTableHead ? "th" : "td"
        return "<\(tagName)>\(visitChildren(of: tableCell))</\(tagName)>"
    }

    nonisolated private mutating func visitChildren(of markup: Markup) -> String {
        var html = ""
        for child in markup.children {
            html += visit(child)
        }
        return html
    }

    nonisolated private func plainText(from markup: Markup) -> String {
        switch markup {
        case let text as Markdown.Text:
            return text.string
        case let inlineCode as InlineCode:
            return inlineCode.code
        case is SoftBreak:
            return " "
        case is LineBreak:
            return "\n"
        default:
            var text = ""
            for child in markup.children {
                text += plainText(from: child)
            }
            return text
        }
    }

    nonisolated private func checkboxHTML(for checkbox: Checkbox) -> String {
        switch checkbox {
        case .checked:
            return "<input type=\"checkbox\" checked disabled>"
        case .unchecked:
            return "<input type=\"checkbox\" disabled>"
        }
    }
}