krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v2.11.0: 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}