summaryrefslogtreecommitdiff
path: root/Hutch/Views
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
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')
-rw-r--r--Hutch/Views/Repositories/MarkdownHTMLRenderer.swift206
-rw-r--r--Hutch/Views/Repositories/ReadmeView.swift378
2 files changed, 252 insertions, 332 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>"
+ }
+ }
+}
diff --git a/Hutch/Views/Repositories/ReadmeView.swift b/Hutch/Views/Repositories/ReadmeView.swift
index 27cec55..10caeed 100644
--- a/Hutch/Views/Repositories/ReadmeView.swift
+++ b/Hutch/Views/Repositories/ReadmeView.swift
@@ -276,309 +276,8 @@ func clearWebContentRenderCaches() {
// MARK: - Markdown to HTML
-nonisolated func markdownToHTML(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String {
- let normalizedText = text
- .replacingOccurrences(of: "\r\n", with: "\n")
- .replacingOccurrences(of: "\r", with: "\n")
- let lines = normalizedText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
- var html = ""
- var inCodeBlock = false
- var codeBlockInListItem = false
- var codeBlockLines: [String] = []
- var listType: MarkupListType?
- var inBlockquote = false
- var pendingListItemBreak = false
- var currentListItemLines: [String] = []
- var currentListItemBlocks: [String] = []
- var paragraph: [String] = []
- var tableRows: [[String]] = []
-
- func flushParagraph() {
- if !paragraph.isEmpty {
- let normalizedParagraph = paragraph
- .map { $0.trimmingCharacters(in: .whitespaces) }
- .joined(separator: " ")
- html += "<p>" + normalizedParagraph + "</p>\n"
- paragraph = []
- }
- }
-
- func flushListItem() {
- guard !currentListItemLines.isEmpty || !currentListItemBlocks.isEmpty else { return }
- let itemContent = currentListItemLines
- .map { $0.trimmingCharacters(in: .whitespaces) }
- .joined(separator: " ")
-
- if currentListItemBlocks.isEmpty {
- html += "<li>" + renderTaskListItem(
- itemContent,
- inlineRenderer: { processInline($0, imageURLResolver: imageURLResolver) }
- ) + "</li>\n"
- } else {
- if !itemContent.isEmpty {
- currentListItemBlocks.append(
- "<p>" + renderTaskListItem(
- itemContent,
- inlineRenderer: { processInline($0, imageURLResolver: imageURLResolver) }
- ) + "</p>"
- )
- }
- html += "<li>" + currentListItemBlocks.joined(separator: "\n") + "</li>\n"
- }
- currentListItemLines = []
- currentListItemBlocks = []
- }
-
- func flushListItemParagraphIntoBlocks() {
- guard !currentListItemLines.isEmpty else { return }
- let itemContent = currentListItemLines
- .map { $0.trimmingCharacters(in: .whitespaces) }
- .joined(separator: " ")
- currentListItemBlocks.append(
- "<p>" + renderTaskListItem(
- itemContent,
- inlineRenderer: { processInline($0, imageURLResolver: imageURLResolver) }
- ) + "</p>"
- )
- currentListItemLines = []
- }
-
- func flushCodeBlock() {
- let content = codeBlockLines.joined(separator: "\n")
- let blockHTML = "<pre><code>" + content + "</code></pre>\n"
- if codeBlockInListItem {
- currentListItemBlocks.append(blockHTML)
- } else {
- html += blockHTML
- }
- codeBlockLines = []
- codeBlockInListItem = false
- }
-
- func closeList() {
- flushListItem()
- switch listType {
- case .unordered:
- html += "</ul>\n"
- case .ordered:
- html += "</ol>\n"
- case nil:
- break
- }
- listType = nil
- }
-
- func flushTable() {
- guard !tableRows.isEmpty else { return }
- html += renderHTMLTable(
- rows: tableRows,
- inlineRenderer: { processInline($0, imageURLResolver: imageURLResolver) }
- )
- tableRows = []
- }
-
- func closeBlockquote() {
- if inBlockquote {
- flushParagraph()
- html += "</blockquote>\n"
- inBlockquote = false
- }
- }
-
- for line in lines {
- let trimmed = line.trimmingCharacters(in: .whitespaces)
-
- if pendingListItemBreak, listType != nil {
- if trimmed.isEmpty {
- continue
- }
- if isIndentedContinuationLine(line) || trimmed.hasPrefix("```") {
- pendingListItemBreak = false
- } else if isMarkdownUnorderedListItem(trimmed) || orderedListItem(in: trimmed) != nil {
- flushListItem()
- pendingListItemBreak = false
- } else {
- flushListItem()
- closeList()
- pendingListItemBreak = false
- }
- }
-
- if let rawHTML = sanitizedMarkdownHTMLLine(from: trimmed) {
- closeBlockquote()
- flushParagraph()
- flushTable()
- if listType != nil {
- flushListItemParagraphIntoBlocks()
- currentListItemBlocks.append(rawHTML)
- } else {
- closeList()
- html += rawHTML + "\n"
- }
- continue
- }
-
- // Fenced code blocks
- if trimmed.hasPrefix("```") {
- if inCodeBlock {
- flushCodeBlock()
- inCodeBlock = false
- } else {
- closeBlockquote()
- flushParagraph()
- flushTable()
- codeBlockInListItem = listType != nil && (!currentListItemLines.isEmpty || !currentListItemBlocks.isEmpty)
- if !codeBlockInListItem {
- closeList()
- } else {
- flushListItemParagraphIntoBlocks()
- }
- inCodeBlock = true
- codeBlockLines = []
- }
- continue
- }
-
- if inCodeBlock {
- codeBlockLines.append(escapeHTML(line))
- continue
- }
-
- if isTableLine(trimmed) {
- closeBlockquote()
- flushParagraph()
- closeList()
- tableRows.append(parseTableRow(trimmed))
- continue
- } else {
- flushTable()
- }
-
- // Headings
- if line.hasPrefix("###### ") {
- closeBlockquote()
- flushParagraph()
- closeList()
- html += "<h6>" + processInline(String(line.dropFirst(7)), imageURLResolver: imageURLResolver) + "</h6>\n"
- continue
- }
- if line.hasPrefix("##### ") {
- closeBlockquote()
- flushParagraph()
- closeList()
- html += "<h5>" + processInline(String(line.dropFirst(6)), imageURLResolver: imageURLResolver) + "</h5>\n"
- continue
- }
- if line.hasPrefix("#### ") {
- closeBlockquote()
- flushParagraph()
- closeList()
- html += "<h4>" + processInline(String(line.dropFirst(5)), imageURLResolver: imageURLResolver) + "</h4>\n"
- continue
- }
- if line.hasPrefix("### ") {
- closeBlockquote()
- flushParagraph()
- closeList()
- html += "<h3>" + processInline(String(line.dropFirst(4)), imageURLResolver: imageURLResolver) + "</h3>\n"
- continue
- }
- if line.hasPrefix("## ") {
- closeBlockquote()
- flushParagraph()
- closeList()
- html += "<h2>" + processInline(String(line.dropFirst(3)), imageURLResolver: imageURLResolver) + "</h2>\n"
- continue
- }
- if line.hasPrefix("# ") {
- closeBlockquote()
- flushParagraph()
- closeList()
- html += "<h1>" + processInline(String(line.dropFirst(2)), imageURLResolver: imageURLResolver) + "</h1>\n"
- continue
- }
-
- if isMarkdownHorizontalRule(trimmed) {
- closeBlockquote()
- flushParagraph()
- closeList()
- html += "<hr>\n"
- continue
- }
-
- if trimmed.hasPrefix("> ") {
- flushTable()
- closeList()
- if !inBlockquote {
- flushParagraph()
- html += "<blockquote>\n"
- inBlockquote = true
- }
- paragraph.append(processInline(String(trimmed.dropFirst(2)), imageURLResolver: imageURLResolver))
- continue
- } else {
- closeBlockquote()
- }
-
- // List items
- if trimmed.hasPrefix("- ") || trimmed.hasPrefix("* ") {
- flushParagraph()
- if listType != .unordered {
- closeList()
- html += "<ul>\n"
- listType = .unordered
- }
- flushListItem()
- currentListItemLines = [String(trimmed.dropFirst(2))]
- continue
- }
- if let orderedItem = orderedListItem(in: trimmed) {
- flushParagraph()
- if listType != .ordered {
- closeList()
- html += "<ol>\n"
- listType = .ordered
- }
- flushListItem()
- currentListItemLines = [orderedItem]
- continue
- }
-
- if listType != nil && isIndentedContinuationLine(line) {
- currentListItemLines.append(trimmed)
- continue
- }
-
- // Blank line
- if trimmed.isEmpty {
- if inBlockquote {
- closeBlockquote()
- } else if listType != nil, !currentListItemLines.isEmpty || !currentListItemBlocks.isEmpty {
- pendingListItemBreak = true
- } else {
- flushParagraph()
- closeList()
- }
- continue
- }
-
- // Regular text — accumulate into paragraph
- paragraph.append(processInline(line, imageURLResolver: imageURLResolver))
- }
-
- // Flush remaining state
- if inCodeBlock {
- flushCodeBlock()
- }
- closeBlockquote()
- flushParagraph()
- flushTable()
- closeList()
-
- return html
-}
-
nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String {
+
var protectedFragments: [String: String] = [:]
var result = protectMatches(
in: text,
@@ -1109,7 +808,7 @@ nonisolated func escapeHTML(_ text: String) -> String {
.replacingOccurrences(of: "\"", with: "&quot;")
}
-nonisolated private func escapeHTMLAttribute(_ text: String) -> String {
+nonisolated func escapeHTMLAttribute(_ text: String) -> String {
escapeHTML(text).replacingOccurrences(of: "'", with: "&#39;")
}
@@ -1212,21 +911,16 @@ nonisolated private func renderHTMLTable(
return html
}
-private enum MarkupListType: Equatable {
+private enum OrgListType: Equatable {
case unordered
case ordered
}
-private typealias OrgListType = MarkupListType
-
nonisolated private func orderedListItem(in line: String) -> String? {
guard let match = line.firstMatch(of: /^(\d+)\.\s+(.+)$/) else { return nil }
return String(match.2)
}
-nonisolated private func isMarkdownHorizontalRule(_ line: String) -> Bool {
- matchesRegex(line, pattern: #"^\s*([*\-_])(?:\s*\1){2,}\s*$"#)
-}
nonisolated private func isOrgHorizontalRule(_ line: String) -> Bool {
matchesRegex(line, pattern: #"^\s*-{5,}\s*$"#)
@@ -1252,11 +946,8 @@ nonisolated private func isIndentedContinuationLine(_ line: String) -> Bool {
return first == " " || first == "\t"
}
-nonisolated private func isMarkdownUnorderedListItem(_ line: String) -> Bool {
- line.hasPrefix("- ") || line.hasPrefix("* ")
-}
-nonisolated private func decodeHTMLEntities(_ text: String) -> String {
+nonisolated func decodeHTMLEntities(_ text: String) -> String {
text
.replacingOccurrences(of: "&amp;", with: "&")
.replacingOccurrences(of: "&quot;", with: "\"")
@@ -1265,12 +956,34 @@ nonisolated private func decodeHTMLEntities(_ text: String) -> String {
.replacingOccurrences(of: "&gt;", with: ">")
}
-nonisolated private func sanitizedMarkdownHTMLLine(from line: String) -> String? {
- guard line.hasPrefix("<"), line.hasSuffix(">") else { return nil }
- return sanitizedMarkdownHTMLTag(line)
+nonisolated func sanitizedMarkdownHTMLBlock(_ rawHTML: String) -> String? {
+ var protectedFragments: [String: String] = [:]
+ var foundUnsafeMarkup = false
+ let protected = protectMatches(
+ in: rawHTML,
+ pattern: #"(?s)<!--.*?-->|</?[A-Za-z][^>]*?>"#,
+ protectedFragments: &protectedFragments
+ ) { match, nsText in
+ let rawTag = nsText.substring(with: match.range)
+ guard let sanitizedTag = sanitizedMarkdownHTMLTag(rawTag) else {
+ foundUnsafeMarkup = true
+ return ""
+ }
+ return sanitizedTag
+ }
+
+ guard !foundUnsafeMarkup else { return nil }
+
+ var sanitized = escapeHTML(protected)
+ sanitized = replaceMatches(in: sanitized, pattern: #"ZZPROTECTED\d+ZZ"#) { match, nsText in
+ let token = nsText.substring(with: match.range)
+ return protectedFragments[token] ?? ""
+ }
+
+ return sanitized.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : sanitized
}
-nonisolated private func sanitizedMarkdownHTMLTag(_ rawTag: String) -> String? {
+nonisolated func sanitizedMarkdownHTMLTag(_ rawTag: String) -> String? {
let trimmed = rawTag.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.hasPrefix("<"), trimmed.hasSuffix(">") else { return nil }
guard !trimmed.lowercased().hasPrefix("<!--") else { return nil }
@@ -1597,8 +1310,9 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable {
pre {
padding: 8px;
overflow-x: auto;
- white-space: pre-wrap;
- word-wrap: break-word;
+ white-space: pre;
+ word-break: normal;
+ overflow-wrap: normal;
}
img { max-width: 100%; height: auto; }
input[type="checkbox"] {
@@ -1706,10 +1420,6 @@ private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unc
self.parent.loadError = nil
}
updateHeight(for: webView)
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self, weak webView] in
- guard let self, let webView else { return }
- self.updateHeight(for: webView)
- }
}
func webView(_: WKWebView, didFail _: WKNavigation!, withError error: Error) {
@@ -1754,15 +1464,19 @@ private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unc
}
private func updateHeight(for webView: WKWebView) {
- webView.layoutIfNeeded()
- let height = ceil(max(webView.scrollView.contentSize.height, webView.sizeThatFits(.zero).height)) + 4
- guard height > 0 else { return }
- DispatchQueue.main.async {
- if let html = self.lastHTML {
- Self.heightCache.setObject(NSNumber(value: Double(height)), forKey: html as NSString)
- }
- if abs(self.parent.dynamicHeight - height) > 0.5 {
- self.parent.dynamicHeight = height
+ webView.evaluateJavaScript("document.body.scrollHeight") { [weak self] result, _ in
+ guard let self else { return }
+ guard let heightValue = result as? NSNumber else { return }
+ let height = CGFloat(heightValue.doubleValue)
+ guard height > 0 else { return }
+ let rounded = ceil(height) + 4
+ DispatchQueue.main.async {
+ if let html = self.lastHTML {
+ Self.heightCache.setObject(NSNumber(value: Double(rounded)), forKey: html as NSString)
+ }
+ if abs(self.parent.dynamicHeight - rounded) > 0.5 {
+ self.parent.dynamicHeight = rounded
+ }
}
}
}