summaryrefslogtreecommitdiff
path: root/Hutch/Views/Repositories/MarkdownHTMLRenderer.swift
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-03-30 16:22:59 -0500
committerChristian Cleberg <[email protected]>2026-03-30 16:22:59 -0500
commitddb310ed15fcd693a5487e5f38b5ba74cdf27843 (patch)
tree9b6fa7fbe60066e84b867415f4177fb5a605af07 /Hutch/Views/Repositories/MarkdownHTMLRenderer.swift
parent09d93e6e6f2111f569d82da40dac8c499ba9a575 (diff)
downloadhutch-ddb310ed15fcd693a5487e5f38b5ba74cdf27843.tar.gz
hutch-ddb310ed15fcd693a5487e5f38b5ba74cdf27843.tar.bz2
hutch-ddb310ed15fcd693a5487e5f38b5ba74cdf27843.zip
Migrate README markdown rendering to swift-markdown and fix badge images
- replace the hand-rolled markdown parser with a MarkupVisitor renderer - keep the org-mode rendering path and shared sanitization helpers intact - update WKWebView styling and height measurement for README content - fix linked image and query-string badge rendering in markdown output - expand README rendering tests and add markdown syntax coverage Implements: https://todo.sr.ht/~ccleberg/Hutch/2
Diffstat (limited to 'Hutch/Views/Repositories/MarkdownHTMLRenderer.swift')
-rw-r--r--Hutch/Views/Repositories/MarkdownHTMLRenderer.swift206
1 files changed, 206 insertions, 0 deletions
diff --git a/Hutch/Views/Repositories/MarkdownHTMLRenderer.swift b/Hutch/Views/Repositories/MarkdownHTMLRenderer.swift
new file mode 100644
index 0000000..67966f1
--- /dev/null
+++ b/Hutch/Views/Repositories/MarkdownHTMLRenderer.swift
@@ -0,0 +1,206 @@
+import Markdown
+
+nonisolated func markdownToHTML(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String {
+ let document = Document(parsing: text)
+ var renderer = MarkdownHTMLRenderer(imageURLResolver: imageURLResolver)
+ return renderer.visit(document)
+}
+
+private struct MarkdownHTMLRenderer: MarkupVisitor {
+ typealias Result = String
+
+ nonisolated(unsafe) let imageURLResolver: ((String) -> String?)?
+ private var isRenderingTableHead = false
+
+ nonisolated init(imageURLResolver: ((String) -> String?)?) {
+ self.imageURLResolver = imageURLResolver
+ }
+
+ nonisolated mutating func visit(_ markup: Markup) -> String {
+ markup.accept(&self)
+ }
+
+ nonisolated mutating func defaultVisit(_ markup: Markup) -> String {
+ visitChildren(of: markup)
+ }
+
+ nonisolated mutating func visitDocument(_ document: Document) -> String {
+ visitChildren(of: document)
+ }
+
+ nonisolated mutating func visitHeading(_ heading: Heading) -> String {
+ "<h\(heading.level)>\(visitChildren(of: heading))</h\(heading.level)>\n"
+ }
+
+ nonisolated mutating func visitParagraph(_ paragraph: Paragraph) -> String {
+ "<p>\(visitChildren(of: paragraph))</p>\n"
+ }
+
+ nonisolated mutating func visitBlockQuote(_ blockQuote: BlockQuote) -> String {
+ "<blockquote>\n\(visitChildren(of: blockQuote))</blockquote>\n"
+ }
+
+ nonisolated mutating func visitUnorderedList(_ unorderedList: UnorderedList) -> String {
+ "<ul>\n\(visitChildren(of: unorderedList))</ul>\n"
+ }
+
+ nonisolated mutating func visitOrderedList(_ orderedList: OrderedList) -> String {
+ "<ol>\n\(visitChildren(of: orderedList))</ol>\n"
+ }
+
+ nonisolated mutating func visitListItem(_ listItem: ListItem) -> String {
+ if let checkbox = listItem.checkbox,
+ listItem.childCount == 1,
+ let paragraph = listItem.child(at: 0) as? Paragraph {
+ let content = visitChildren(of: paragraph)
+ return "<li><span class=\"task-list-item\">\(checkboxHTML(for: checkbox)) \(content)</span></li>\n"
+ }
+
+ var body = visitChildren(of: listItem)
+ if let checkbox = listItem.checkbox {
+ body = "<span class=\"task-list-item\">\(checkboxHTML(for: checkbox))</span>" + body
+ }
+ return "<li>\(body)</li>\n"
+ }
+
+ nonisolated mutating func visitCodeBlock(_ codeBlock: CodeBlock) -> String {
+ let classAttribute: String
+ if let language = codeBlock.language, !language.isEmpty {
+ classAttribute = " class=\"language-\(escapeHTMLAttribute(language))\""
+ } else {
+ classAttribute = ""
+ }
+ return "<pre><code\(classAttribute)>\(escapeHTML(codeBlock.code))</code></pre>\n"
+ }
+
+ nonisolated mutating func visitInlineCode(_ inlineCode: InlineCode) -> String {
+ "<code>\(escapeHTML(inlineCode.code))</code>"
+ }
+
+ nonisolated mutating func visitThematicBreak(_: ThematicBreak) -> String {
+ "<hr>\n"
+ }
+
+ nonisolated mutating func visitHTMLBlock(_ html: HTMLBlock) -> String {
+ guard let sanitized = sanitizedMarkdownHTMLBlock(html.rawHTML) else { return "" }
+ return sanitized + "\n"
+ }
+
+ nonisolated mutating func visitInlineHTML(_ inlineHTML: InlineHTML) -> String {
+ sanitizedMarkdownHTMLTag(inlineHTML.rawHTML) ?? ""
+ }
+
+ nonisolated mutating func visitLink(_ link: Markdown.Link) -> String {
+ let content = visitChildren(of: link)
+ guard let destination = link.destination,
+ let sanitizedDestination = sanitizedReadmeLinkURLString(destination) else {
+ return content
+ }
+ let href = escapeHTMLAttribute(decodeHTMLEntities(sanitizedDestination))
+ return "<a href=\"\(href)\">\(content)</a>"
+ }
+
+ nonisolated mutating func visitImage(_ image: Markdown.Image) -> String {
+ let altText = plainText(from: image)
+ guard let source = image.source, !source.isEmpty else {
+ return escapeHTML(altText)
+ }
+
+ let resolvedSource = imageURLResolver?(source) ?? source
+ guard let sanitizedSource = sanitizedReadmeImageURLString(resolvedSource) else {
+ return escapeHTML(altText)
+ }
+
+ let src = escapeHTMLAttribute(decodeHTMLEntities(sanitizedSource))
+ return "<img src=\"\(src)\" alt=\"\(escapeHTMLAttribute(altText))\">"
+ }
+
+ nonisolated mutating func visitStrong(_ strong: Strong) -> String {
+ "<strong>\(visitChildren(of: strong))</strong>"
+ }
+
+ nonisolated mutating func visitEmphasis(_ emphasis: Emphasis) -> String {
+ "<em>\(visitChildren(of: emphasis))</em>"
+ }
+
+ nonisolated mutating func visitStrikethrough(_ strikethrough: Strikethrough) -> String {
+ "<del>\(visitChildren(of: strikethrough))</del>"
+ }
+
+ nonisolated mutating func visitText(_ text: Markdown.Text) -> String {
+ escapeHTML(text.string)
+ }
+
+ nonisolated mutating func visitSoftBreak(_: SoftBreak) -> String {
+ " "
+ }
+
+ nonisolated mutating func visitLineBreak(_: LineBreak) -> String {
+ "<br>"
+ }
+
+ nonisolated mutating func visitTable(_ table: Markdown.Table) -> String {
+ "<table>\n\(visitChildren(of: table))</table>\n"
+ }
+
+ nonisolated mutating func visitTableHead(_ tableHead: Markdown.Table.Head) -> String {
+ let previousValue = isRenderingTableHead
+ isRenderingTableHead = true
+ let content = visitChildren(of: tableHead)
+ isRenderingTableHead = previousValue
+ return "<thead>\(content)</thead>\n"
+ }
+
+ nonisolated mutating func visitTableBody(_ tableBody: Markdown.Table.Body) -> String {
+ let previousValue = isRenderingTableHead
+ isRenderingTableHead = false
+ let content = visitChildren(of: tableBody)
+ isRenderingTableHead = previousValue
+ return "<tbody>\n\(content)</tbody>\n"
+ }
+
+ nonisolated mutating func visitTableRow(_ tableRow: Markdown.Table.Row) -> String {
+ "<tr>\(visitChildren(of: tableRow))</tr>\n"
+ }
+
+ nonisolated mutating func visitTableCell(_ tableCell: Markdown.Table.Cell) -> String {
+ let tagName = isRenderingTableHead ? "th" : "td"
+ return "<\(tagName)>\(visitChildren(of: tableCell))</\(tagName)>"
+ }
+
+ nonisolated private mutating func visitChildren(of markup: Markup) -> String {
+ var html = ""
+ for child in markup.children {
+ html += visit(child)
+ }
+ return html
+ }
+
+ nonisolated private func plainText(from markup: Markup) -> String {
+ switch markup {
+ case let text as Markdown.Text:
+ return text.string
+ case let inlineCode as InlineCode:
+ return inlineCode.code
+ case is SoftBreak:
+ return " "
+ case is LineBreak:
+ return "\n"
+ default:
+ var text = ""
+ for child in markup.children {
+ text += plainText(from: child)
+ }
+ return text
+ }
+ }
+
+ nonisolated private func checkboxHTML(for checkbox: Checkbox) -> String {
+ switch checkbox {
+ case .checked:
+ return "<input type=\"checkbox\" checked disabled>"
+ case .unchecked:
+ return "<input type=\"checkbox\" disabled>"
+ }
+ }
+}