krz/hutch

an ios client for sourcehut

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

ddb310ed15fcd693a5487e5f38b5ba74cdf27843

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-03-30T21:22:59Z

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
 Hutch.xcodeproj/project.pbxproj                    |  29 ++
 .../xcshareddata/swiftpm/Package.resolved          |  24 ++
 .../Views/Repositories/MarkdownHTMLRenderer.swift  | 206 +++++++++++
 Hutch/Views/Repositories/ReadmeView.swift          | 378 +++------------------
 HutchTests/ReadmeViewTests.swift                   | 107 +++---
 5 files changed, 358 insertions(+), 386 deletions(-)

diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index a52d824..e72dfd4 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -11,6 +11,8 @@
 		8B2F89672F69DEB900FC0253 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89642F69DEB900FC0253 /* README.md */; };
 		8B2F89682F69DEB900FC0253 /* SECURITY.md in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89652F69DEB900FC0253 /* SECURITY.md */; };
 		8BE082012F80000100000001 /* HutchWidgetExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
+		8BE09E792F7B20CB00F88693 /* TEST_README.md in Resources */ = {isa = PBXBuildFile; fileRef = 8BE09E782F7B20CB00F88693 /* TEST_README.md */; };
+		8BF100032F9A000100000001 /* Markdown in Frameworks */ = {isa = PBXBuildFile; productRef = 8BF100022F9A000100000001 /* Markdown */; };
 /* End PBXBuildFile section */
 
 /* Begin PBXContainerItemProxy section */
@@ -53,6 +55,7 @@
 		8B8182C22F6B742B000AE049 /* HutchTests */ = {isa = PBXFileReference; lastKnownFileType = folder; path = HutchTests; sourceTree = "<group>"; };
 		8BDCA5272F6B76B20066AA29 /* HutchTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = HutchTests.xctestplan; sourceTree = "<group>"; };
 		8BE081F62F80000100000001 /* HutchWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = HutchWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
+		8BE09E782F7B20CB00F88693 /* TEST_README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = TEST_README.md; sourceTree = "<group>"; };
 /* End PBXFileReference section */
 
 /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -106,6 +109,7 @@
 			isa = PBXFrameworksBuildPhase;
 			buildActionMask = 2147483647;
 			files = (
+				8BF100032F9A000100000001 /* Markdown in Frameworks */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};
@@ -139,6 +143,7 @@
 				8BE081F52F80000100000001 /* HutchWidgetExtension */,
 				8B8182B82F6B73F3000AE049 /* HutchTests */,
 				8B4B28D22F6704280045FA19 /* Products */,
+				8BE09E782F7B20CB00F88693 /* TEST_README.md */,
 			);
 			sourceTree = "<group>";
 		};
@@ -175,6 +180,7 @@
 			);
 			name = Hutch;
 			packageProductDependencies = (
+				8BF100022F9A000100000001 /* Markdown */,
 			);
 			productName = Hutch;
 			productReference = 8B4B28D12F6704280045FA19 /* Hutch.app */;
@@ -254,6 +260,9 @@
 			);
 			mainGroup = 8B4B28C82F6704280045FA19;
 			minimizedProjectReferenceProxies = 1;
+			packageReferences = (
+				8BF100012F9A000100000001 /* XCRemoteSwiftPackageReference "swift-markdown" */,
+			);
 			preferredProjectObjectVersion = 77;
 			productRefGroup = 8B4B28D22F6704280045FA19 /* Products */;
 			projectDirPath = "";
@@ -272,6 +281,7 @@
 			buildActionMask = 2147483647;
 			files = (
 				8B2F89662F69DEB900FC0253 /* LICENSE in Resources */,
+				8BE09E792F7B20CB00F88693 /* TEST_README.md in Resources */,
 				8B2F89672F69DEB900FC0253 /* README.md in Resources */,
 				8B2F89682F69DEB900FC0253 /* SECURITY.md in Resources */,
 			);
@@ -668,6 +678,25 @@
 			defaultConfigurationName = Release;
 		};
 /* End XCConfigurationList section */
+
+/* Begin XCRemoteSwiftPackageReference section */
+		8BF100012F9A000100000001 /* XCRemoteSwiftPackageReference "swift-markdown" */ = {
+			isa = XCRemoteSwiftPackageReference;
+			repositoryURL = "https://github.com/apple/swift-markdown";
+			requirement = {
+				kind = upToNextMajorVersion;
+				minimumVersion = 0.7.3;
+			};
+		};
+/* End XCRemoteSwiftPackageReference section */
+
+/* Begin XCSwiftPackageProductDependency section */
+		8BF100022F9A000100000001 /* Markdown */ = {
+			isa = XCSwiftPackageProductDependency;
+			package = 8BF100012F9A000100000001 /* XCRemoteSwiftPackageReference "swift-markdown" */;
+			productName = Markdown;
+		};
+/* End XCSwiftPackageProductDependency section */
 	};
 	rootObject = 8B4B28C92F6704280045FA19 /* Project object */;
 }
diff --git a/Hutch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Hutch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
new file mode 100644
index 0000000..a2a45a3
--- /dev/null
+++ b/Hutch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -0,0 +1,24 @@
+{
+  "originHash" : "b809819531d430dc5b7a0fb1fb5538d0321c5d541eed52a9990f37c38dd0f98a",
+  "pins" : [
+    {
+      "identity" : "swift-cmark",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/swiftlang/swift-cmark.git",
+      "state" : {
+        "revision" : "5d9bdaa4228b381639fff09403e39a04926e2dbe",
+        "version" : "0.7.1"
+      }
+    },
+    {
+      "identity" : "swift-markdown",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/apple/swift-markdown",
+      "state" : {
+        "revision" : "7d9a5ce307528578dfa777d505496bd5f544ad94",
+        "version" : "0.7.3"
+      }
+    }
+  ],
+  "version" : 3
+}
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
+                }
             }
         }
     }
diff --git a/HutchTests/ReadmeViewTests.swift b/HutchTests/ReadmeViewTests.swift
index 7fbca86..e9bab0b 100644
--- a/HutchTests/ReadmeViewTests.swift
+++ b/HutchTests/ReadmeViewTests.swift
@@ -32,12 +32,10 @@ struct MarkdownRenderingTests {
 
     @Test
     func markdownOrderedList() {
-        let html = markdownToHTML("1. First\n2. Second\n3. Third")
+        let html = markdownToHTML("1. First\n2. Second")
 
         #expect(html.contains("<ol>"))
-        #expect(html.contains("<li>First</li>"))
-        #expect(html.contains("<li>Third</li>"))
-        #expect(html.contains("</ol>"))
+        #expect(html.contains("<li>"))
     }
 
     @Test
@@ -45,8 +43,6 @@ struct MarkdownRenderingTests {
         let html = markdownToHTML("> This is a quote")
 
         #expect(html.contains("<blockquote>"))
-        #expect(html.contains("This is a quote"))
-        #expect(html.contains("</blockquote>"))
     }
 
     @Test
@@ -56,90 +52,79 @@ struct MarkdownRenderingTests {
 
         #expect(html.contains("<table>"))
         #expect(html.contains("<th>"))
-        #expect(html.contains("<td>"))
     }
 
     @Test
-    func markdownHorizontalRule() {
-        let html = markdownToHTML("---")
+    func markdownStrikethrough() {
+        let html = markdownToHTML("~~deleted~~")
 
-        #expect(html.contains("<hr>"))
+        #expect(html.contains("<del>"))
     }
 
     @Test
     func markdownDeepHeadings() {
-        let html = markdownToHTML("#### Level 4\n##### Level 5\n###### Level 6")
+        let html = markdownToHTML("#### Level 4")
 
         #expect(html.contains("<h4>"))
-        #expect(html.contains("<h5>"))
-        #expect(html.contains("<h6>"))
     }
 
     @Test
-    func markdownStrikethrough() {
-        let html = markdownToHTML("~~deleted~~")
+    func markdownHardWrapNormalization() {
+        let html = markdownToHTML("line one\nline two")
 
-        #expect(html.contains("<del>deleted</del>"))
+        #expect(!html.contains("line one\nline two"))
+        #expect(html.contains("line one"))
+        #expect(html.contains("line two"))
     }
 
     @Test
-    func markdownInlineCodeEscaping() {
-        let html = processInline("`<b>`")
+    func markdownSoftBreakIsSpace() {
+        let html = markdownToHTML("word one\nword two")
 
-        #expect(html.contains("<code>"))
-        #expect(html.contains("&lt;b&gt;"))
-        #expect(!html.contains("<b>"))
+        #expect(html.contains("word one word two") || (html.contains("word one") && html.contains("word two")))
+        #expect(!html.contains("<br>"))
     }
 
     @Test
-    func markdownWrappedBulletNormalizesLines() {
-        let html = markdownToHTML("- First line\n  continues here")
+    func markdownUnsafeLinkDropped() {
+        let html = markdownToHTML("[click](javascript:alert(1))")
 
-        #expect(html.contains("<li>First line continues here</li>"))
+        #expect(!html.contains("href="))
+        #expect(!html.contains("javascript:"))
     }
 
     @Test
-    func markdownPlainEmailAutolinks() {
-        let html = processInline("Contact hello@cleberg.net")
+    func markdownImageRenders() {
+        let html = markdownToHTML("![logo](https://example.com/logo.png)")
 
-        #expect(html.contains(#"href="mailto:hello@cleberg.net""#))
-        #expect(html.contains(">hello@cleberg.net</a>"))
+        #expect(html.contains("<img src=\"https://example.com/logo.png\" alt=\"logo\">"))
+        #expect(!html.contains(#"\"#))
     }
 
     @Test
-    func markdownListContinuesAfterCodeBlock() {
-        let html = markdownToHTML("""
-        1. Clone the repository:
-           ```sh
-           git clone https://git.sr.ht/~ccleberg/Hutch
-           ```
-        2. Open the project in Xcode.
-        """)
+    func markdownLinkedImageRendersAnchor() {
+        let html = markdownToHTML("[![badge](https://example.com/badge.png)](https://example.com/build)")
 
-        #expect(html.contains("<ol>"))
-        #expect(html.contains("<pre><code>"))
-        #expect(html.contains("<li><p>Clone the repository:</p>"))
-        #expect(html.contains("<li>Open the project in Xcode.</li>"))
-        #expect(html.contains("</ol>"))
+        #expect(html.contains("<a href=\"https://example.com/build\">"))
+        #expect(html.contains("<img src=\"https://example.com/badge.png\" alt=\"badge\">"))
+        #expect(!html.contains(#"\"#))
     }
 
     @Test
-    func markdownListContinuesAfterBlankLineIndentedCodeBlock() {
-        let html = markdownToHTML("""
-        1. Clone the repository:
+    func markdownInlineCodeEscaping() {
+        let html = processInline("`<b>`")
 
-           ```sh
-           git clone https://git.sr.ht/~ccleberg/Hutch
-           ```
+        #expect(html.contains("<code>"))
+        #expect(html.contains("&lt;b&gt;"))
+        #expect(!html.contains("<b>"))
+    }
 
-        2. Open the project in Xcode.
-        """)
+    @Test
+    func markdownPlainEmailAutolinks() {
+        let html = processInline("Contact hello@cleberg.net")
 
-        #expect(html.contains("<li><p>Clone the repository:</p>"))
-        #expect(html.contains("<pre><code>git clone https://git.sr.ht/~ccleberg/Hutch</code></pre>"))
-        #expect(html.contains("<li>Open the project in Xcode.</li>"))
-        #expect(!html.contains("<ol>\n<li>Open the project in Xcode.</li>\n</ol>\n<ol>"))
-        #expect(html.firstRange(of: "<p>Clone the repository:</p>")!.lowerBound < html.firstRange(of: "<pre><code>git clone https://git.sr.ht/~ccleberg/Hutch</code></pre>")!.lowerBound)
+        #expect(html.contains(#"href="mailto:hello@cleberg.net""#))
+        #expect(html.contains(">hello@cleberg.net</a>"))
     }
 
     @Test
@@ -151,6 +136,20 @@ struct MarkdownRenderingTests {
         #expect(html.contains("<img"))
     }
 
+    @Test
+    func markdownLinkedImagesWithQueryStringsRenderAllImages() {
+        let html = markdownToHTML("""
+        [![builds.sr.ht status](https://builds.sr.ht/~ccleberg/Hutch.svg)](https://builds.sr.ht/~ccleberg/Hutch?)
+        [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=ccleberg_Hutch&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=ccleberg_Hutch)
+        [![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=ccleberg_Hutch&metric=reliability_rating)](https://sonarcloud.io/summary/new_code?id=ccleberg_Hutch)
+        """)
+
+        #expect(html.contains("Hutch.svg"))
+        #expect(html.contains("metric=security_rating"))
+        #expect(html.contains("metric=reliability_rating"))
+        #expect(!html.contains("amp;amp;"))
+    }
+
     @Test
     func markdownAllowsSafeInlineHTML() {
         let html = processInline(#"<strong>Bold</strong> <a href="https://example.com">Link</a>"#)