krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
remove-splash-highlighter: Hutch/Views/Repositories/MarkdownHTMLRenderer.swift · raw
1import Markdown
2
3nonisolated func markdownToHTML(
4 _ text: String,
5 imageURLResolver: ((String) -> String?)? = nil,
6 linkURLResolver: ((String) -> String?)? = nil
7) -> String {
8 let document = Document(parsing: text)
9 var renderer = MarkdownHTMLRenderer(imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver)
10 return renderer.visit(document)
11}
12
13private struct MarkdownHTMLRenderer: MarkupVisitor {
14 typealias Result = String
15
16 nonisolated(unsafe) let imageURLResolver: ((String) -> String?)?
17 nonisolated(unsafe) let linkURLResolver: ((String) -> String?)?
18 nonisolated(unsafe) private var isRenderingTableHead = false
19 nonisolated(unsafe) private var currentTableAlignments: [Markdown.Table.ColumnAlignment?] = []
20 nonisolated(unsafe) private var currentTableColumnIndex = 0
21
22 nonisolated init(imageURLResolver: ((String) -> String?)?, linkURLResolver: ((String) -> String?)? = nil) {
23 self.imageURLResolver = imageURLResolver
24 self.linkURLResolver = linkURLResolver
25 }
26
27 nonisolated mutating func visit(_ markup: Markup) -> String {
28 markup.accept(&self)
29 }
30
31 nonisolated mutating func defaultVisit(_ markup: Markup) -> String {
32 visitChildren(of: markup)
33 }
34
35 nonisolated mutating func visitDocument(_ document: Document) -> String {
36 visitChildren(of: document)
37 }
38
39 nonisolated mutating func visitHeading(_ heading: Heading) -> String {
40 "<h\(heading.level)>\(visitChildren(of: heading))</h\(heading.level)>\n"
41 }
42
43 nonisolated mutating func visitParagraph(_ paragraph: Paragraph) -> String {
44 "<p>\(visitChildren(of: paragraph))</p>\n"
45 }
46
47 nonisolated mutating func visitBlockQuote(_ blockQuote: BlockQuote) -> String {
48 "<blockquote>\n\(visitChildren(of: blockQuote))</blockquote>\n"
49 }
50
51 nonisolated mutating func visitUnorderedList(_ unorderedList: UnorderedList) -> String {
52 "<ul>\n\(visitChildren(of: unorderedList))</ul>\n"
53 }
54
55 nonisolated mutating func visitOrderedList(_ orderedList: OrderedList) -> String {
56 "<ol>\n\(visitChildren(of: orderedList))</ol>\n"
57 }
58
59 nonisolated mutating func visitListItem(_ listItem: ListItem) -> String {
60 if let checkbox = listItem.checkbox,
61 listItem.childCount == 1,
62 let paragraph = listItem.child(at: 0) as? Paragraph {
63 let content = visitChildren(of: paragraph)
64 return "<li><span class=\"task-list-item\">\(checkboxHTML(for: checkbox)) \(content)</span></li>\n"
65 }
66
67 var body = visitChildren(of: listItem)
68 if let checkbox = listItem.checkbox {
69 body = "<span class=\"task-list-item\">\(checkboxHTML(for: checkbox))</span>" + body
70 }
71 return "<li>\(body)</li>\n"
72 }
73
74 nonisolated mutating func visitCodeBlock(_ codeBlock: CodeBlock) -> String {
75 let classAttribute: String
76 if let language = codeBlock.language, !language.isEmpty {
77 classAttribute = " class=\"language-\(escapeHTMLAttribute(language))\""
78 } else {
79 classAttribute = ""
80 }
81 return "<pre><code\(classAttribute)>\(escapeHTML(codeBlock.code))</code></pre>\n"
82 }
83
84 nonisolated mutating func visitInlineCode(_ inlineCode: InlineCode) -> String {
85 "<code>\(escapeHTML(inlineCode.code))</code>"
86 }
87
88 nonisolated mutating func visitThematicBreak(_: ThematicBreak) -> String {
89 "<hr>\n"
90 }
91
92 nonisolated mutating func visitHTMLBlock(_ html: HTMLBlock) -> String {
93 guard let sanitized = sanitizedMarkdownHTMLBlock(html.rawHTML) else { return "" }
94 return sanitized + "\n"
95 }
96
97 nonisolated mutating func visitInlineHTML(_ inlineHTML: InlineHTML) -> String {
98 sanitizedMarkdownHTMLTag(inlineHTML.rawHTML) ?? ""
99 }
100
101 nonisolated mutating func visitLink(_ link: Markdown.Link) -> String {
102 let content = visitChildren(of: link)
103 guard let destination = link.destination else {
104 return content
105 }
106 let resolvedDestination = linkURLResolver?(destination) ?? destination
107 guard let sanitizedDestination = sanitizedReadmeLinkURLString(resolvedDestination) else {
108 return content
109 }
110 let href = escapeHTMLAttribute(decodeHTMLEntities(sanitizedDestination))
111 return "<a href=\"\(href)\">\(content)</a>"
112 }
113
114 nonisolated mutating func visitImage(_ image: Markdown.Image) -> String {
115 let altText = plainText(from: image)
116 guard let source = image.source, !source.isEmpty else {
117 return escapeHTML(altText)
118 }
119
120 let resolvedSource = imageURLResolver?(source) ?? source
121 guard let sanitizedSource = sanitizedReadmeImageURLString(resolvedSource) else {
122 return escapeHTML(altText)
123 }
124
125 let src = escapeHTMLAttribute(decodeHTMLEntities(sanitizedSource))
126 return "<img src=\"\(src)\" alt=\"\(escapeHTMLAttribute(altText))\">"
127 }
128
129 nonisolated mutating func visitStrong(_ strong: Strong) -> String {
130 "<strong>\(visitChildren(of: strong))</strong>"
131 }
132
133 nonisolated mutating func visitEmphasis(_ emphasis: Emphasis) -> String {
134 "<em>\(visitChildren(of: emphasis))</em>"
135 }
136
137 nonisolated mutating func visitStrikethrough(_ strikethrough: Strikethrough) -> String {
138 "<del>\(visitChildren(of: strikethrough))</del>"
139 }
140
141 nonisolated mutating func visitText(_ text: Markdown.Text) -> String {
142 escapeHTML(text.string)
143 }
144
145 nonisolated mutating func visitSoftBreak(_: SoftBreak) -> String {
146 " "
147 }
148
149 nonisolated mutating func visitLineBreak(_: LineBreak) -> String {
150 "<br>"
151 }
152
153 nonisolated mutating func visitTable(_ table: Markdown.Table) -> String {
154 let previousAlignments = currentTableAlignments
155 let previousColumnIndex = currentTableColumnIndex
156 currentTableAlignments = table.columnAlignments
157 currentTableColumnIndex = 0
158 let content = visitChildren(of: table)
159 currentTableAlignments = previousAlignments
160 currentTableColumnIndex = previousColumnIndex
161 return "<table>\n\(content)</table>\n"
162 }
163
164 nonisolated mutating func visitTableHead(_ tableHead: Markdown.Table.Head) -> String {
165 let previousValue = isRenderingTableHead
166 isRenderingTableHead = true
167 let content = visitChildren(of: tableHead)
168 isRenderingTableHead = previousValue
169 return "<thead>\(content)</thead>\n"
170 }
171
172 nonisolated mutating func visitTableBody(_ tableBody: Markdown.Table.Body) -> String {
173 let previousValue = isRenderingTableHead
174 isRenderingTableHead = false
175 let content = visitChildren(of: tableBody)
176 isRenderingTableHead = previousValue
177 return "<tbody>\n\(content)</tbody>\n"
178 }
179
180 nonisolated mutating func visitTableRow(_ tableRow: Markdown.Table.Row) -> String {
181 let previousColumnIndex = currentTableColumnIndex
182 currentTableColumnIndex = 0
183 let content = visitChildren(of: tableRow)
184 currentTableColumnIndex = previousColumnIndex
185 return "<tr>\(content)</tr>\n"
186 }
187
188 nonisolated mutating func visitTableCell(_ tableCell: Markdown.Table.Cell) -> String {
189 let tagName = isRenderingTableHead ? "th" : "td"
190 let styleAttribute = alignmentStyleAttribute(forColumn: currentTableColumnIndex)
191 currentTableColumnIndex += 1
192 return "<\(tagName)\(styleAttribute)>\(visitChildren(of: tableCell))</\(tagName)>"
193 }
194
195 nonisolated private mutating func visitChildren(of markup: Markup) -> String {
196 var html = ""
197 for child in markup.children {
198 html += visit(child)
199 }
200 return html
201 }
202
203 nonisolated private func plainText(from markup: Markup) -> String {
204 switch markup {
205 case let text as Markdown.Text:
206 return text.string
207 case let inlineCode as InlineCode:
208 return inlineCode.code
209 case is SoftBreak:
210 return " "
211 case is LineBreak:
212 return "\n"
213 default:
214 var text = ""
215 for child in markup.children {
216 text += plainText(from: child)
217 }
218 return text
219 }
220 }
221
222 nonisolated private func alignmentStyleAttribute(forColumn column: Int) -> String {
223 guard column < currentTableAlignments.count,
224 let alignment = currentTableAlignments[column] else {
225 return ""
226 }
227
228 let textAlignment: String
229 switch alignment {
230 case .left:
231 textAlignment = "left"
232 case .center:
233 textAlignment = "center"
234 case .right:
235 textAlignment = "right"
236 }
237
238 return " style=\"text-align: \(textAlignment);\""
239 }
240
241 nonisolated private func checkboxHTML(for checkbox: Checkbox) -> String {
242 switch checkbox {
243 case .checked:
244 return "<input type=\"checkbox\" checked disabled>"
245 case .unchecked:
246 return "<input type=\"checkbox\" disabled>"
247 }
248 }
249}