krz/hutch

an ios client for sourcehut

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

v3.10.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}