krz/hutch

an ios client for sourcehut

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

v3.1.11: 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}