krz/hutch

an ios client for sourcehut

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

02fbc13ddb5b66ecf1d0dff3e7173a845984e006

verified · cmc

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

feat(markdown): finalize migration to apple/swift-markdown renderer
 Hutch.xcodeproj/project.pbxproj                    |  8 ++---
 .../Views/Repositories/MarkdownHTMLRenderer.swift  | 42 +++++++++++++++++++---
 HutchTests/ReadmeViewTests.swift                   | 10 ++++++
 3 files changed, 50 insertions(+), 10 deletions(-)

diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index e72dfd4..f4dffa0 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -11,7 +11,6 @@
 		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 */
 
@@ -55,7 +54,6 @@
 		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 */
@@ -143,7 +141,6 @@
 				8BE081F52F80000100000001 /* HutchWidgetExtension */,
 				8B8182B82F6B73F3000AE049 /* HutchTests */,
 				8B4B28D22F6704280045FA19 /* Products */,
-				8BE09E782F7B20CB00F88693 /* TEST_README.md */,
 			);
 			sourceTree = "<group>";
 		};
@@ -281,7 +278,6 @@
 			buildActionMask = 2147483647;
 			files = (
 				8B2F89662F69DEB900FC0253 /* LICENSE in Resources */,
-				8BE09E792F7B20CB00F88693 /* TEST_README.md in Resources */,
 				8B2F89672F69DEB900FC0253 /* README.md in Resources */,
 				8B2F89682F69DEB900FC0253 /* SECURITY.md in Resources */,
 			);
@@ -486,7 +482,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.4.2;
+				MARKETING_VERSION = 2.5.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -523,7 +519,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.4.2;
+				MARKETING_VERSION = 2.5.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
diff --git a/Hutch/Views/Repositories/MarkdownHTMLRenderer.swift b/Hutch/Views/Repositories/MarkdownHTMLRenderer.swift
index 67966f1..5c69659 100644
--- a/Hutch/Views/Repositories/MarkdownHTMLRenderer.swift
+++ b/Hutch/Views/Repositories/MarkdownHTMLRenderer.swift
@@ -10,7 +10,9 @@ private struct MarkdownHTMLRenderer: MarkupVisitor {
     typealias Result = String
 
     nonisolated(unsafe) let imageURLResolver: ((String) -> String?)?
-    private var isRenderingTableHead = false
+    nonisolated(unsafe) private var isRenderingTableHead = false
+    nonisolated(unsafe) private var currentTableAlignments: [Markdown.Table.ColumnAlignment?] = []
+    nonisolated(unsafe) private var currentTableColumnIndex = 0
 
     nonisolated init(imageURLResolver: ((String) -> String?)?) {
         self.imageURLResolver = imageURLResolver
@@ -140,7 +142,14 @@ private struct MarkdownHTMLRenderer: MarkupVisitor {
     }
 
     nonisolated mutating func visitTable(_ table: Markdown.Table) -> String {
-        "<table>\n\(visitChildren(of: table))</table>\n"
+        let previousAlignments = currentTableAlignments
+        let previousColumnIndex = currentTableColumnIndex
+        currentTableAlignments = table.columnAlignments
+        currentTableColumnIndex = 0
+        let content = visitChildren(of: table)
+        currentTableAlignments = previousAlignments
+        currentTableColumnIndex = previousColumnIndex
+        return "<table>\n\(content)</table>\n"
     }
 
     nonisolated mutating func visitTableHead(_ tableHead: Markdown.Table.Head) -> String {
@@ -160,12 +169,18 @@ private struct MarkdownHTMLRenderer: MarkupVisitor {
     }
 
     nonisolated mutating func visitTableRow(_ tableRow: Markdown.Table.Row) -> String {
-        "<tr>\(visitChildren(of: tableRow))</tr>\n"
+        let previousColumnIndex = currentTableColumnIndex
+        currentTableColumnIndex = 0
+        let content = visitChildren(of: tableRow)
+        currentTableColumnIndex = previousColumnIndex
+        return "<tr>\(content)</tr>\n"
     }
 
     nonisolated mutating func visitTableCell(_ tableCell: Markdown.Table.Cell) -> String {
         let tagName = isRenderingTableHead ? "th" : "td"
-        return "<\(tagName)>\(visitChildren(of: tableCell))</\(tagName)>"
+        let styleAttribute = alignmentStyleAttribute(forColumn: currentTableColumnIndex)
+        currentTableColumnIndex += 1
+        return "<\(tagName)\(styleAttribute)>\(visitChildren(of: tableCell))</\(tagName)>"
     }
 
     nonisolated private mutating func visitChildren(of markup: Markup) -> String {
@@ -195,6 +210,25 @@ private struct MarkdownHTMLRenderer: MarkupVisitor {
         }
     }
 
+    nonisolated private func alignmentStyleAttribute(forColumn column: Int) -> String {
+        guard column < currentTableAlignments.count,
+              let alignment = currentTableAlignments[column] else {
+            return ""
+        }
+
+        let textAlignment: String
+        switch alignment {
+        case .left:
+            textAlignment = "left"
+        case .center:
+            textAlignment = "center"
+        case .right:
+            textAlignment = "right"
+        }
+
+        return " style=\"text-align: \(textAlignment);\""
+    }
+
     nonisolated private func checkboxHTML(for checkbox: Checkbox) -> String {
         switch checkbox {
         case .checked:
diff --git a/HutchTests/ReadmeViewTests.swift b/HutchTests/ReadmeViewTests.swift
index e9bab0b..8cced03 100644
--- a/HutchTests/ReadmeViewTests.swift
+++ b/HutchTests/ReadmeViewTests.swift
@@ -54,6 +54,16 @@ struct MarkdownRenderingTests {
         #expect(html.contains("<th>"))
     }
 
+    @Test
+    func markdownTableAlignment() {
+        let input = "| Left | Center | Right |\n|:-----|:------:|------:|\n| a | b | c |"
+        let html = markdownToHTML(input)
+
+        #expect(html.contains("text-align: left;"))
+        #expect(html.contains("text-align: center;"))
+        #expect(html.contains("text-align: right;"))
+    }
+
     @Test
     func markdownStrikethrough() {
         let html = markdownToHTML("~~deleted~~")