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