summaryrefslogtreecommitdiff
path: root/Hutch/Views/Repositories/MarkdownHTMLRenderer.swift
blob: 5c69659df71c63bbc58c56e0bc2d045368df960d (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
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
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?)?
    nonisolated(unsafe) private var isRenderingTableHead = false
    nonisolated(unsafe) private var currentTableAlignments: [Markdown.Table.ColumnAlignment?] = []
    nonisolated(unsafe) private var currentTableColumnIndex = 0

    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 {
        let previousAlignments = currentTableAlignments
        let previousColumnIndex = currentTableColumnIndex
        currentTableAlignments = table.columnAlignments
        currentTableColumnIndex = 0
        let content = visitChildren(of: table)
        currentTableAlignments = previousAlignments
        currentTableColumnIndex = previousColumnIndex
        return "<table>\n\(content)</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 {
        let previousColumnIndex = currentTableColumnIndex
        currentTableColumnIndex = 0
        let content = visitChildren(of: tableRow)
        currentTableColumnIndex = previousColumnIndex
        return "<tr>\(content)</tr>\n"
    }

    nonisolated mutating func visitTableCell(_ tableCell: Markdown.Table.Cell) -> String {
        let tagName = isRenderingTableHead ? "th" : "td"
        let styleAttribute = alignmentStyleAttribute(forColumn: currentTableColumnIndex)
        currentTableColumnIndex += 1
        return "<\(tagName)\(styleAttribute)>\(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 alignmentStyleAttribute(forColumn column: Int) -> String {
        guard column < currentTableAlignments.count,
              let alignment = currentTableAlignments[column] else {
            return ""
        }

        let textAlignment: String
        switch alignment {
        case .left:
            textAlignment = "left"
        case .center:
            textAlignment = "center"
        case .right:
            textAlignment = "right"
        }

        return " style=\"text-align: \(textAlignment);\""
    }

    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>"
        }
    }
}