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