Commit 8f8ad4e852

8f8ad4e852082adf5ab23c9063ef312c29c02bcf

parent: a34fb9e738

Unsigned

cmc <hello@cleberg.net> · 2026-03-30 21:22 UTC

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

Layout: unified · split

Hutch.xcodeproj/project.pbxproj +29
@@ -11,6 +11,8 @@
11 8B2F89672F69DEB900FC0253 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89642F69DEB900FC0253 /* README.md */; }; 11 8B2F89672F69DEB900FC0253 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89642F69DEB900FC0253 /* README.md */; };
12 8B2F89682F69DEB900FC0253 /* SECURITY.md in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89652F69DEB900FC0253 /* SECURITY.md */; }; 12 8B2F89682F69DEB900FC0253 /* SECURITY.md in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89652F69DEB900FC0253 /* SECURITY.md */; };
13 8BE082012F80000100000001 /* HutchWidgetExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 13 8BE082012F80000100000001 /* HutchWidgetExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
14 8BE09E792F7B20CB00F88693 /* TEST_README.md in Resources */ = {isa = PBXBuildFile; fileRef = 8BE09E782F7B20CB00F88693 /* TEST_README.md */; };
15 8BF100032F9A000100000001 /* Markdown in Frameworks */ = {isa = PBXBuildFile; productRef = 8BF100022F9A000100000001 /* Markdown */; };
14/* End PBXBuildFile section */ 16/* End PBXBuildFile section */
15 17
16/* Begin PBXContainerItemProxy section */ 18/* Begin PBXContainerItemProxy section */
@@ -53,6 +55,7 @@
53 8B8182C22F6B742B000AE049 /* HutchTests */ = {isa = PBXFileReference; lastKnownFileType = folder; path = HutchTests; sourceTree = "<group>"; }; 55 8B8182C22F6B742B000AE049 /* HutchTests */ = {isa = PBXFileReference; lastKnownFileType = folder; path = HutchTests; sourceTree = "<group>"; };
54 8BDCA5272F6B76B20066AA29 /* HutchTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = HutchTests.xctestplan; sourceTree = "<group>"; }; 56 8BDCA5272F6B76B20066AA29 /* HutchTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = HutchTests.xctestplan; sourceTree = "<group>"; };
55 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = HutchWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 57 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = HutchWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
58 8BE09E782F7B20CB00F88693 /* TEST_README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = TEST_README.md; sourceTree = "<group>"; };
56/* End PBXFileReference section */ 59/* End PBXFileReference section */
57 60
58/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ 61/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -106,6 +109,7 @@
106 isa = PBXFrameworksBuildPhase; 109 isa = PBXFrameworksBuildPhase;
107 buildActionMask = 2147483647; 110 buildActionMask = 2147483647;
108 files = ( 111 files = (
112 8BF100032F9A000100000001 /* Markdown in Frameworks */,
109 ); 113 );
110 runOnlyForDeploymentPostprocessing = 0; 114 runOnlyForDeploymentPostprocessing = 0;
111 }; 115 };
@@ -139,6 +143,7 @@
139 8BE081F52F80000100000001 /* HutchWidgetExtension */, 143 8BE081F52F80000100000001 /* HutchWidgetExtension */,
140 8B8182B82F6B73F3000AE049 /* HutchTests */, 144 8B8182B82F6B73F3000AE049 /* HutchTests */,
141 8B4B28D22F6704280045FA19 /* Products */, 145 8B4B28D22F6704280045FA19 /* Products */,
146 8BE09E782F7B20CB00F88693 /* TEST_README.md */,
142 ); 147 );
143 sourceTree = "<group>"; 148 sourceTree = "<group>";
144 }; 149 };
@@ -175,6 +180,7 @@
175 ); 180 );
176 name = Hutch; 181 name = Hutch;
177 packageProductDependencies = ( 182 packageProductDependencies = (
183 8BF100022F9A000100000001 /* Markdown */,
178 ); 184 );
179 productName = Hutch; 185 productName = Hutch;
180 productReference = 8B4B28D12F6704280045FA19 /* Hutch.app */; 186 productReference = 8B4B28D12F6704280045FA19 /* Hutch.app */;
@@ -254,6 +260,9 @@
254 ); 260 );
255 mainGroup = 8B4B28C82F6704280045FA19; 261 mainGroup = 8B4B28C82F6704280045FA19;
256 minimizedProjectReferenceProxies = 1; 262 minimizedProjectReferenceProxies = 1;
263 packageReferences = (
264 8BF100012F9A000100000001 /* XCRemoteSwiftPackageReference "swift-markdown" */,
265 );
257 preferredProjectObjectVersion = 77; 266 preferredProjectObjectVersion = 77;
258 productRefGroup = 8B4B28D22F6704280045FA19 /* Products */; 267 productRefGroup = 8B4B28D22F6704280045FA19 /* Products */;
259 projectDirPath = ""; 268 projectDirPath = "";
@@ -272,6 +281,7 @@
272 buildActionMask = 2147483647; 281 buildActionMask = 2147483647;
273 files = ( 282 files = (
274 8B2F89662F69DEB900FC0253 /* LICENSE in Resources */, 283 8B2F89662F69DEB900FC0253 /* LICENSE in Resources */,
284 8BE09E792F7B20CB00F88693 /* TEST_README.md in Resources */,
275 8B2F89672F69DEB900FC0253 /* README.md in Resources */, 285 8B2F89672F69DEB900FC0253 /* README.md in Resources */,
276 8B2F89682F69DEB900FC0253 /* SECURITY.md in Resources */, 286 8B2F89682F69DEB900FC0253 /* SECURITY.md in Resources */,
277 ); 287 );
@@ -668,6 +678,25 @@
668 defaultConfigurationName = Release; 678 defaultConfigurationName = Release;
669 }; 679 };
670/* End XCConfigurationList section */ 680/* End XCConfigurationList section */
681
682/* Begin XCRemoteSwiftPackageReference section */
683 8BF100012F9A000100000001 /* XCRemoteSwiftPackageReference "swift-markdown" */ = {
684 isa = XCRemoteSwiftPackageReference;
685 repositoryURL = "https://github.com/apple/swift-markdown";
686 requirement = {
687 kind = upToNextMajorVersion;
688 minimumVersion = 0.7.3;
689 };
690 };
691/* End XCRemoteSwiftPackageReference section */
692
693/* Begin XCSwiftPackageProductDependency section */
694 8BF100022F9A000100000001 /* Markdown */ = {
695 isa = XCSwiftPackageProductDependency;
696 package = 8BF100012F9A000100000001 /* XCRemoteSwiftPackageReference "swift-markdown" */;
697 productName = Markdown;
698 };
699/* End XCSwiftPackageProductDependency section */
671 }; 700 };
672 rootObject = 8B4B28C92F6704280045FA19 /* Project object */; 701 rootObject = 8B4B28C92F6704280045FA19 /* Project object */;
673} 702}
Hutch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved added +24
@@ -0,0 +1,24 @@
1{
2 "originHash" : "b809819531d430dc5b7a0fb1fb5538d0321c5d541eed52a9990f37c38dd0f98a",
3 "pins" : [
4 {
5 "identity" : "swift-cmark",
6 "kind" : "remoteSourceControl",
7 "location" : "https://github.com/swiftlang/swift-cmark.git",
8 "state" : {
9 "revision" : "5d9bdaa4228b381639fff09403e39a04926e2dbe",
10 "version" : "0.7.1"
11 }
12 },
13 {
14 "identity" : "swift-markdown",
15 "kind" : "remoteSourceControl",
16 "location" : "https://github.com/apple/swift-markdown",
17 "state" : {
18 "revision" : "7d9a5ce307528578dfa777d505496bd5f544ad94",
19 "version" : "0.7.3"
20 }
21 }
22 ],
23 "version" : 3
24}
Hutch/Views/Repositories/MarkdownHTMLRenderer.swift added +206
@@ -0,0 +1,206 @@
1import Markdown
2
3nonisolated func markdownToHTML(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String {
4 let document = Document(parsing: text)
5 var renderer = MarkdownHTMLRenderer(imageURLResolver: imageURLResolver)
6 return renderer.visit(document)
7}
8
9private struct MarkdownHTMLRenderer: MarkupVisitor {
10 typealias Result = String
11
12 nonisolated(unsafe) let imageURLResolver: ((String) -> String?)?
13 private var isRenderingTableHead = false
14
15 nonisolated init(imageURLResolver: ((String) -> String?)?) {
16 self.imageURLResolver = imageURLResolver
17 }
18
19 nonisolated mutating func visit(_ markup: Markup) -> String {
20 markup.accept(&self)
21 }
22
23 nonisolated mutating func defaultVisit(_ markup: Markup) -> String {
24 visitChildren(of: markup)
25 }
26
27 nonisolated mutating func visitDocument(_ document: Document) -> String {
28 visitChildren(of: document)
29 }
30
31 nonisolated mutating func visitHeading(_ heading: Heading) -> String {
32 "<h\(heading.level)>\(visitChildren(of: heading))</h\(heading.level)>\n"
33 }
34
35 nonisolated mutating func visitParagraph(_ paragraph: Paragraph) -> String {
36 "<p>\(visitChildren(of: paragraph))</p>\n"
37 }
38
39 nonisolated mutating func visitBlockQuote(_ blockQuote: BlockQuote) -> String {
40 "<blockquote>\n\(visitChildren(of: blockQuote))</blockquote>\n"
41 }
42
43 nonisolated mutating func visitUnorderedList(_ unorderedList: UnorderedList) -> String {
44 "<ul>\n\(visitChildren(of: unorderedList))</ul>\n"
45 }
46
47 nonisolated mutating func visitOrderedList(_ orderedList: OrderedList) -> String {
48 "<ol>\n\(visitChildren(of: orderedList))</ol>\n"
49 }
50
51 nonisolated mutating func visitListItem(_ listItem: ListItem) -> String {
52 if let checkbox = listItem.checkbox,
53 listItem.childCount == 1,
54 let paragraph = listItem.child(at: 0) as? Paragraph {
55 let content = visitChildren(of: paragraph)
56 return "<li><span class=\"task-list-item\">\(checkboxHTML(for: checkbox)) \(content)</span></li>\n"
57 }
58
59 var body = visitChildren(of: listItem)
60 if let checkbox = listItem.checkbox {
61 body = "<span class=\"task-list-item\">\(checkboxHTML(for: checkbox))</span>" + body
62 }
63 return "<li>\(body)</li>\n"
64 }
65
66 nonisolated mutating func visitCodeBlock(_ codeBlock: CodeBlock) -> String {
67 let classAttribute: String
68 if let language = codeBlock.language, !language.isEmpty {
69 classAttribute = " class=\"language-\(escapeHTMLAttribute(language))\""
70 } else {
71 classAttribute = ""
72 }
73 return "<pre><code\(classAttribute)>\(escapeHTML(codeBlock.code))</code></pre>\n"
74 }
75
76 nonisolated mutating func visitInlineCode(_ inlineCode: InlineCode) -> String {
77 "<code>\(escapeHTML(inlineCode.code))</code>"
78 }
79
80 nonisolated mutating func visitThematicBreak(_: ThematicBreak) -> String {
81 "<hr>\n"
82 }
83
84 nonisolated mutating func visitHTMLBlock(_ html: HTMLBlock) -> String {
85 guard let sanitized = sanitizedMarkdownHTMLBlock(html.rawHTML) else { return "" }
86 return sanitized + "\n"
87 }
88
89 nonisolated mutating func visitInlineHTML(_ inlineHTML: InlineHTML) -> String {
90 sanitizedMarkdownHTMLTag(inlineHTML.rawHTML) ?? ""
91 }
92
93 nonisolated mutating func visitLink(_ link: Markdown.Link) -> String {
94 let content = visitChildren(of: link)
95 guard let destination = link.destination,
96 let sanitizedDestination = sanitizedReadmeLinkURLString(destination) else {
97 return content
98 }
99 let href = escapeHTMLAttribute(decodeHTMLEntities(sanitizedDestination))
100 return "<a href=\"\(href)\">\(content)</a>"
101 }
102
103 nonisolated mutating func visitImage(_ image: Markdown.Image) -> String {
104 let altText = plainText(from: image)
105 guard let source = image.source, !source.isEmpty else {
106 return escapeHTML(altText)
107 }
108
109 let resolvedSource = imageURLResolver?(source) ?? source
110 guard let sanitizedSource = sanitizedReadmeImageURLString(resolvedSource) else {
111 return escapeHTML(altText)
112 }
113
114 let src = escapeHTMLAttribute(decodeHTMLEntities(sanitizedSource))
115 return "<img src=\"\(src)\" alt=\"\(escapeHTMLAttribute(altText))\">"
116 }
117
118 nonisolated mutating func visitStrong(_ strong: Strong) -> String {
119 "<strong>\(visitChildren(of: strong))</strong>"
120 }
121
122 nonisolated mutating func visitEmphasis(_ emphasis: Emphasis) -> String {
123 "<em>\(visitChildren(of: emphasis))</em>"
124 }
125
126 nonisolated mutating func visitStrikethrough(_ strikethrough: Strikethrough) -> String {
127 "<del>\(visitChildren(of: strikethrough))</del>"
128 }
129
130 nonisolated mutating func visitText(_ text: Markdown.Text) -> String {
131 escapeHTML(text.string)
132 }
133
134 nonisolated mutating func visitSoftBreak(_: SoftBreak) -> String {
135 " "
136 }
137
138 nonisolated mutating func visitLineBreak(_: LineBreak) -> String {
139 "<br>"
140 }
141
142 nonisolated mutating func visitTable(_ table: Markdown.Table) -> String {
143 "<table>\n\(visitChildren(of: table))</table>\n"
144 }
145
146 nonisolated mutating func visitTableHead(_ tableHead: Markdown.Table.Head) -> String {
147 let previousValue = isRenderingTableHead
148 isRenderingTableHead = true
149 let content = visitChildren(of: tableHead)
150 isRenderingTableHead = previousValue
151 return "<thead>\(content)</thead>\n"
152 }
153
154 nonisolated mutating func visitTableBody(_ tableBody: Markdown.Table.Body) -> String {
155 let previousValue = isRenderingTableHead
156 isRenderingTableHead = false
157 let content = visitChildren(of: tableBody)
158 isRenderingTableHead = previousValue
159 return "<tbody>\n\(content)</tbody>\n"
160 }
161
162 nonisolated mutating func visitTableRow(_ tableRow: Markdown.Table.Row) -> String {
163 "<tr>\(visitChildren(of: tableRow))</tr>\n"
164 }
165
166 nonisolated mutating func visitTableCell(_ tableCell: Markdown.Table.Cell) -> String {
167 let tagName = isRenderingTableHead ? "th" : "td"
168 return "<\(tagName)>\(visitChildren(of: tableCell))</\(tagName)>"
169 }
170
171 nonisolated private mutating func visitChildren(of markup: Markup) -> String {
172 var html = ""
173 for child in markup.children {
174 html += visit(child)
175 }
176 return html
177 }
178
179 nonisolated private func plainText(from markup: Markup) -> String {
180 switch markup {
181 case let text as Markdown.Text:
182 return text.string
183 case let inlineCode as InlineCode:
184 return inlineCode.code
185 case is SoftBreak:
186 return " "
187 case is LineBreak:
188 return "\n"
189 default:
190 var text = ""
191 for child in markup.children {
192 text += plainText(from: child)
193 }
194 return text
195 }
196 }
197
198 nonisolated private func checkboxHTML(for checkbox: Checkbox) -> String {
199 switch checkbox {
200 case .checked:
201 return "<input type=\"checkbox\" checked disabled>"
202 case .unchecked:
203 return "<input type=\"checkbox\" disabled>"
204 }
205 }
206}
Hutch/Views/Repositories/ReadmeView.swift +46 −332
@@ -276,309 +276,8 @@ func clearWebContentRenderCaches() {
276 276
277// MARK: - Markdown to HTML 277// MARK: - Markdown to HTML
278 278
279nonisolated func markdownToHTML(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String {
280 let normalizedText = text
281 .replacingOccurrences(of: "\r\n", with: "\n")
282 .replacingOccurrences(of: "\r", with: "\n")
283 let lines = normalizedText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
284 var html = ""
285 var inCodeBlock = false
286 var codeBlockInListItem = false
287 var codeBlockLines: [String] = []
288 var listType: MarkupListType?
289 var inBlockquote = false
290 var pendingListItemBreak = false
291 var currentListItemLines: [String] = []
292 var currentListItemBlocks: [String] = []
293 var paragraph: [String] = []
294 var tableRows: [[String]] = []
295
296 func flushParagraph() {
297 if !paragraph.isEmpty {
298 let normalizedParagraph = paragraph
299 .map { $0.trimmingCharacters(in: .whitespaces) }
300 .joined(separator: " ")
301 html += "<p>" + normalizedParagraph + "</p>\n"
302 paragraph = []
303 }
304 }
305
306 func flushListItem() {
307 guard !currentListItemLines.isEmpty || !currentListItemBlocks.isEmpty else { return }
308 let itemContent = currentListItemLines
309 .map { $0.trimmingCharacters(in: .whitespaces) }
310 .joined(separator: " ")
311
312 if currentListItemBlocks.isEmpty {
313 html += "<li>" + renderTaskListItem(
314 itemContent,
315 inlineRenderer: { processInline($0, imageURLResolver: imageURLResolver) }
316 ) + "</li>\n"
317 } else {
318 if !itemContent.isEmpty {
319 currentListItemBlocks.append(
320 "<p>" + renderTaskListItem(
321 itemContent,
322 inlineRenderer: { processInline($0, imageURLResolver: imageURLResolver) }
323 ) + "</p>"
324 )
325 }
326 html += "<li>" + currentListItemBlocks.joined(separator: "\n") + "</li>\n"
327 }
328 currentListItemLines = []
329 currentListItemBlocks = []
330 }
331
332 func flushListItemParagraphIntoBlocks() {
333 guard !currentListItemLines.isEmpty else { return }
334 let itemContent = currentListItemLines
335 .map { $0.trimmingCharacters(in: .whitespaces) }
336 .joined(separator: " ")
337 currentListItemBlocks.append(
338 "<p>" + renderTaskListItem(
339 itemContent,
340 inlineRenderer: { processInline($0, imageURLResolver: imageURLResolver) }
341 ) + "</p>"
342 )
343 currentListItemLines = []
344 }
345
346 func flushCodeBlock() {
347 let content = codeBlockLines.joined(separator: "\n")
348 let blockHTML = "<pre><code>" + content + "</code></pre>\n"
349 if codeBlockInListItem {
350 currentListItemBlocks.append(blockHTML)
351 } else {
352 html += blockHTML
353 }
354 codeBlockLines = []
355 codeBlockInListItem = false
356 }
357
358 func closeList() {
359 flushListItem()
360 switch listType {
361 case .unordered:
362 html += "</ul>\n"
363 case .ordered:
364 html += "</ol>\n"
365 case nil:
366 break
367 }
368 listType = nil
369 }
370
371 func flushTable() {
372 guard !tableRows.isEmpty else { return }
373 html += renderHTMLTable(
374 rows: tableRows,
375 inlineRenderer: { processInline($0, imageURLResolver: imageURLResolver) }
376 )
377 tableRows = []
378 }
379
380 func closeBlockquote() {
381 if inBlockquote {
382 flushParagraph()
383 html += "</blockquote>\n"
384 inBlockquote = false
385 }
386 }
387
388 for line in lines {
389 let trimmed = line.trimmingCharacters(in: .whitespaces)
390
391 if pendingListItemBreak, listType != nil {
392 if trimmed.isEmpty {
393 continue
394 }
395 if isIndentedContinuationLine(line) || trimmed.hasPrefix("```") {
396 pendingListItemBreak = false
397 } else if isMarkdownUnorderedListItem(trimmed) || orderedListItem(in: trimmed) != nil {
398 flushListItem()
399 pendingListItemBreak = false
400 } else {
401 flushListItem()
402 closeList()
403 pendingListItemBreak = false
404 }
405 }
406
407 if let rawHTML = sanitizedMarkdownHTMLLine(from: trimmed) {
408 closeBlockquote()
409 flushParagraph()
410 flushTable()
411 if listType != nil {
412 flushListItemParagraphIntoBlocks()
413 currentListItemBlocks.append(rawHTML)
414 } else {
415 closeList()
416 html += rawHTML + "\n"
417 }
418 continue
419 }
420
421 // Fenced code blocks
422 if trimmed.hasPrefix("```") {
423 if inCodeBlock {
424 flushCodeBlock()
425 inCodeBlock = false
426 } else {
427 closeBlockquote()
428 flushParagraph()
429 flushTable()
430 codeBlockInListItem = listType != nil && (!currentListItemLines.isEmpty || !currentListItemBlocks.isEmpty)
431 if !codeBlockInListItem {
432 closeList()
433 } else {
434 flushListItemParagraphIntoBlocks()
435 }
436 inCodeBlock = true
437 codeBlockLines = []
438 }
439 continue
440 }
441
442 if inCodeBlock {
443 codeBlockLines.append(escapeHTML(line))
444 continue
445 }
446
447 if isTableLine(trimmed) {
448 closeBlockquote()
449 flushParagraph()
450 closeList()
451 tableRows.append(parseTableRow(trimmed))
452 continue
453 } else {
454 flushTable()
455 }
456
457 // Headings
458 if line.hasPrefix("###### ") {
459 closeBlockquote()
460 flushParagraph()
461 closeList()
462 html += "<h6>" + processInline(String(line.dropFirst(7)), imageURLResolver: imageURLResolver) + "</h6>\n"
463 continue
464 }
465 if line.hasPrefix("##### ") {
466 closeBlockquote()
467 flushParagraph()
468 closeList()
469 html += "<h5>" + processInline(String(line.dropFirst(6)), imageURLResolver: imageURLResolver) + "</h5>\n"
470 continue
471 }
472 if line.hasPrefix("#### ") {
473 closeBlockquote()
474 flushParagraph()
475 closeList()
476 html += "<h4>" + processInline(String(line.dropFirst(5)), imageURLResolver: imageURLResolver) + "</h4>\n"
477 continue
478 }
479 if line.hasPrefix("### ") {
480 closeBlockquote()
481 flushParagraph()
482 closeList()
483 html += "<h3>" + processInline(String(line.dropFirst(4)), imageURLResolver: imageURLResolver) + "</h3>\n"
484 continue
485 }
486 if line.hasPrefix("## ") {
487 closeBlockquote()
488 flushParagraph()
489 closeList()
490 html += "<h2>" + processInline(String(line.dropFirst(3)), imageURLResolver: imageURLResolver) + "</h2>\n"
491 continue
492 }
493 if line.hasPrefix("# ") {
494 closeBlockquote()
495 flushParagraph()
496 closeList()
497 html += "<h1>" + processInline(String(line.dropFirst(2)), imageURLResolver: imageURLResolver) + "</h1>\n"
498 continue
499 }
500
501 if isMarkdownHorizontalRule(trimmed) {
502 closeBlockquote()
503 flushParagraph()
504 closeList()
505 html += "<hr>\n"
506 continue
507 }
508
509 if trimmed.hasPrefix("> ") {
510 flushTable()
511 closeList()
512 if !inBlockquote {
513 flushParagraph()
514 html += "<blockquote>\n"
515 inBlockquote = true
516 }
517 paragraph.append(processInline(String(trimmed.dropFirst(2)), imageURLResolver: imageURLResolver))
518 continue
519 } else {
520 closeBlockquote()
521 }
522
523 // List items
524 if trimmed.hasPrefix("- ") || trimmed.hasPrefix("* ") {
525 flushParagraph()
526 if listType != .unordered {
527 closeList()
528 html += "<ul>\n"
529 listType = .unordered
530 }
531 flushListItem()
532 currentListItemLines = [String(trimmed.dropFirst(2))]
533 continue
534 }
535 if let orderedItem = orderedListItem(in: trimmed) {
536 flushParagraph()
537 if listType != .ordered {
538 closeList()
539 html += "<ol>\n"
540 listType = .ordered
541 }
542 flushListItem()
543 currentListItemLines = [orderedItem]
544 continue
545 }
546
547 if listType != nil && isIndentedContinuationLine(line) {
548 currentListItemLines.append(trimmed)
549 continue
550 }
551
552 // Blank line
553 if trimmed.isEmpty {
554 if inBlockquote {
555 closeBlockquote()
556 } else if listType != nil, !currentListItemLines.isEmpty || !currentListItemBlocks.isEmpty {
557 pendingListItemBreak = true
558 } else {
559 flushParagraph()
560 closeList()
561 }
562 continue
563 }
564
565 // Regular text — accumulate into paragraph
566 paragraph.append(processInline(line, imageURLResolver: imageURLResolver))
567 }
568
569 // Flush remaining state
570 if inCodeBlock {
571 flushCodeBlock()
572 }
573 closeBlockquote()
574 flushParagraph()
575 flushTable()
576 closeList()
577
578 return html
579}
580
581nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String { 279nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String {
280
582 var protectedFragments: [String: String] = [:] 281 var protectedFragments: [String: String] = [:]
583 var result = protectMatches( 282 var result = protectMatches(
584 in: text, 283 in: text,
@@ -1109,7 +808,7 @@ nonisolated func escapeHTML(_ text: String) -> String {
1109 .replacingOccurrences(of: "\"", with: "&quot;") 808 .replacingOccurrences(of: "\"", with: "&quot;")
1110} 809}
1111 810
1112nonisolated private func escapeHTMLAttribute(_ text: String) -> String { 811nonisolated func escapeHTMLAttribute(_ text: String) -> String {
1113 escapeHTML(text).replacingOccurrences(of: "'", with: "&#39;") 812 escapeHTML(text).replacingOccurrences(of: "'", with: "&#39;")
1114} 813}
1115 814
@@ -1212,21 +911,16 @@ nonisolated private func renderHTMLTable(
1212 return html 911 return html
1213} 912}
1214 913
1215private enum MarkupListType: Equatable { 914private enum OrgListType: Equatable {
1216 case unordered 915 case unordered
1217 case ordered 916 case ordered
1218} 917}
1219 918
1220private typealias OrgListType = MarkupListType
1221
1222nonisolated private func orderedListItem(in line: String) -> String? { 919nonisolated private func orderedListItem(in line: String) -> String? {
1223 guard let match = line.firstMatch(of: /^(\d+)\.\s+(.+)$/) else { return nil } 920 guard let match = line.firstMatch(of: /^(\d+)\.\s+(.+)$/) else { return nil }
1224 return String(match.2) 921 return String(match.2)
1225} 922}
1226 923
1227nonisolated private func isMarkdownHorizontalRule(_ line: String) -> Bool {
1228 matchesRegex(line, pattern: #"^\s*([*\-_])(?:\s*\1){2,}\s*$"#)
1229}
1230 924
1231nonisolated private func isOrgHorizontalRule(_ line: String) -> Bool { 925nonisolated private func isOrgHorizontalRule(_ line: String) -> Bool {
1232 matchesRegex(line, pattern: #"^\s*-{5,}\s*$"#) 926 matchesRegex(line, pattern: #"^\s*-{5,}\s*$"#)
@@ -1252,11 +946,8 @@ nonisolated private func isIndentedContinuationLine(_ line: String) -> Bool {
1252 return first == " " || first == "\t" 946 return first == " " || first == "\t"
1253} 947}
1254 948
1255nonisolated private func isMarkdownUnorderedListItem(_ line: String) -> Bool {
1256 line.hasPrefix("- ") || line.hasPrefix("* ")
1257}
1258 949
1259nonisolated private func decodeHTMLEntities(_ text: String) -> String { 950nonisolated func decodeHTMLEntities(_ text: String) -> String {
1260 text 951 text
1261 .replacingOccurrences(of: "&amp;", with: "&") 952 .replacingOccurrences(of: "&amp;", with: "&")
1262 .replacingOccurrences(of: "&quot;", with: "\"") 953 .replacingOccurrences(of: "&quot;", with: "\"")
@@ -1265,12 +956,34 @@ nonisolated private func decodeHTMLEntities(_ text: String) -> String {
1265 .replacingOccurrences(of: "&gt;", with: ">") 956 .replacingOccurrences(of: "&gt;", with: ">")
1266} 957}
1267 958
1268nonisolated private func sanitizedMarkdownHTMLLine(from line: String) -> String? { 959nonisolated func sanitizedMarkdownHTMLBlock(_ rawHTML: String) -> String? {
1269 guard line.hasPrefix("<"), line.hasSuffix(">") else { return nil } 960 var protectedFragments: [String: String] = [:]
1270 return sanitizedMarkdownHTMLTag(line) 961 var foundUnsafeMarkup = false
962 let protected = protectMatches(
963 in: rawHTML,
964 pattern: #"(?s)<!--.*?-->|</?[A-Za-z][^>]*?>"#,
965 protectedFragments: &protectedFragments
966 ) { match, nsText in
967 let rawTag = nsText.substring(with: match.range)
968 guard let sanitizedTag = sanitizedMarkdownHTMLTag(rawTag) else {
969 foundUnsafeMarkup = true
970 return ""
971 }
972 return sanitizedTag
973 }
974
975 guard !foundUnsafeMarkup else { return nil }
976
977 var sanitized = escapeHTML(protected)
978 sanitized = replaceMatches(in: sanitized, pattern: #"ZZPROTECTED\d+ZZ"#) { match, nsText in
979 let token = nsText.substring(with: match.range)
980 return protectedFragments[token] ?? ""
981 }
982
983 return sanitized.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : sanitized
1271} 984}
1272 985
1273nonisolated private func sanitizedMarkdownHTMLTag(_ rawTag: String) -> String? { 986nonisolated func sanitizedMarkdownHTMLTag(_ rawTag: String) -> String? {
1274 let trimmed = rawTag.trimmingCharacters(in: .whitespacesAndNewlines) 987 let trimmed = rawTag.trimmingCharacters(in: .whitespacesAndNewlines)
1275 guard trimmed.hasPrefix("<"), trimmed.hasSuffix(">") else { return nil } 988 guard trimmed.hasPrefix("<"), trimmed.hasSuffix(">") else { return nil }
1276 guard !trimmed.lowercased().hasPrefix("<!--") else { return nil } 989 guard !trimmed.lowercased().hasPrefix("<!--") else { return nil }
@@ -1597,8 +1310,9 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable {
1597 pre { 1310 pre {
1598 padding: 8px; 1311 padding: 8px;
1599 overflow-x: auto; 1312 overflow-x: auto;
1600 white-space: pre-wrap; 1313 white-space: pre;
1601 word-wrap: break-word; 1314 word-break: normal;
1315 overflow-wrap: normal;
1602 } 1316 }
1603 img { max-width: 100%; height: auto; } 1317 img { max-width: 100%; height: auto; }
1604 input[type="checkbox"] { 1318 input[type="checkbox"] {
@@ -1706,10 +1420,6 @@ private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unc
1706 self.parent.loadError = nil 1420 self.parent.loadError = nil
1707 } 1421 }
1708 updateHeight(for: webView) 1422 updateHeight(for: webView)
1709 DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self, weak webView] in
1710 guard let self, let webView else { return }
1711 self.updateHeight(for: webView)
1712 }
1713 } 1423 }
1714 1424
1715 func webView(_: WKWebView, didFail _: WKNavigation!, withError error: Error) { 1425 func webView(_: WKWebView, didFail _: WKNavigation!, withError error: Error) {
@@ -1754,15 +1464,19 @@ private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unc
1754 } 1464 }
1755 1465
1756 private func updateHeight(for webView: WKWebView) { 1466 private func updateHeight(for webView: WKWebView) {
1757 webView.layoutIfNeeded() 1467 webView.evaluateJavaScript("document.body.scrollHeight") { [weak self] result, _ in
1758 let height = ceil(max(webView.scrollView.contentSize.height, webView.sizeThatFits(.zero).height)) + 4 1468 guard let self else { return }
1759 guard height > 0 else { return } 1469 guard let heightValue = result as? NSNumber else { return }
1760 DispatchQueue.main.async { 1470 let height = CGFloat(heightValue.doubleValue)
1761 if let html = self.lastHTML { 1471 guard height > 0 else { return }
1762 Self.heightCache.setObject(NSNumber(value: Double(height)), forKey: html as NSString) 1472 let rounded = ceil(height) + 4
1763 } 1473 DispatchQueue.main.async {
1764 if abs(self.parent.dynamicHeight - height) > 0.5 { 1474 if let html = self.lastHTML {
1765 self.parent.dynamicHeight = height 1475 Self.heightCache.setObject(NSNumber(value: Double(rounded)), forKey: html as NSString)
1476 }
1477 if abs(self.parent.dynamicHeight - rounded) > 0.5 {
1478 self.parent.dynamicHeight = rounded
1479 }
1766 } 1480 }
1767 } 1481 }
1768 } 1482 }
HutchTests/ReadmeViewTests.swift +53 −54
@@ -32,12 +32,10 @@ struct MarkdownRenderingTests {
32 32
33 @Test 33 @Test
34 func markdownOrderedList() { 34 func markdownOrderedList() {
35 let html = markdownToHTML("1. First\n2. Second\n3. Third") 35 let html = markdownToHTML("1. First\n2. Second")
36 36
37 #expect(html.contains("<ol>")) 37 #expect(html.contains("<ol>"))
38 #expect(html.contains("<li>First</li>")) 38 #expect(html.contains("<li>"))
39 #expect(html.contains("<li>Third</li>"))
40 #expect(html.contains("</ol>"))
41 } 39 }
42 40
43 @Test 41 @Test
@@ -45,8 +43,6 @@ struct MarkdownRenderingTests {
45 let html = markdownToHTML("> This is a quote") 43 let html = markdownToHTML("> This is a quote")
46 44
47 #expect(html.contains("<blockquote>")) 45 #expect(html.contains("<blockquote>"))
48 #expect(html.contains("This is a quote"))
49 #expect(html.contains("</blockquote>"))
50 } 46 }
51 47
52 @Test 48 @Test
@@ -56,90 +52,79 @@ struct MarkdownRenderingTests {
56 52
57 #expect(html.contains("<table>")) 53 #expect(html.contains("<table>"))
58 #expect(html.contains("<th>")) 54 #expect(html.contains("<th>"))
59 #expect(html.contains("<td>"))
60 } 55 }
61 56
62 @Test 57 @Test
63 func markdownHorizontalRule() { 58 func markdownStrikethrough() {
64 let html = markdownToHTML("---") 59 let html = markdownToHTML("~~deleted~~")
65 60
66 #expect(html.contains("<hr>")) 61 #expect(html.contains("<del>"))
67 } 62 }
68 63
69 @Test 64 @Test
70 func markdownDeepHeadings() { 65 func markdownDeepHeadings() {
71 let html = markdownToHTML("#### Level 4\n##### Level 5\n###### Level 6") 66 let html = markdownToHTML("#### Level 4")
72 67
73 #expect(html.contains("<h4>")) 68 #expect(html.contains("<h4>"))
74 #expect(html.contains("<h5>"))
75 #expect(html.contains("<h6>"))
76 } 69 }
77 70
78 @Test 71 @Test
79 func markdownStrikethrough() { 72 func markdownHardWrapNormalization() {
80 let html = markdownToHTML("~~deleted~~") 73 let html = markdownToHTML("line one\nline two")
81 74
82 #expect(html.contains("<del>deleted</del>")) 75 #expect(!html.contains("line one\nline two"))
76 #expect(html.contains("line one"))
77 #expect(html.contains("line two"))
83 } 78 }
84 79
85 @Test 80 @Test
86 func markdownInlineCodeEscaping() { 81 func markdownSoftBreakIsSpace() {
87 let html = processInline("`<b>`") 82 let html = markdownToHTML("word one\nword two")
88 83
89 #expect(html.contains("<code>")) 84 #expect(html.contains("word one word two") || (html.contains("word one") && html.contains("word two")))
90 #expect(html.contains("&lt;b&gt;")) 85 #expect(!html.contains("<br>"))
91 #expect(!html.contains("<b>"))
92 } 86 }
93 87
94 @Test 88 @Test
95 func markdownWrappedBulletNormalizesLines() { 89 func markdownUnsafeLinkDropped() {
96 let html = markdownToHTML("- First line\n continues here") 90 let html = markdownToHTML("[click](javascript:alert(1))")
97 91
98 #expect(html.contains("<li>First line continues here</li>")) 92 #expect(!html.contains("href="))
93 #expect(!html.contains("javascript:"))
99 } 94 }
100 95
101 @Test 96 @Test
102 func markdownPlainEmailAutolinks() { 97 func markdownImageRenders() {
103 let html = processInline("Contact hello@cleberg.net") 98 let html = markdownToHTML("![logo](https://example.com/logo.png)")
104 99
105 #expect(html.contains(#"href="mailto:hello@cleberg.net""#)) 100 #expect(html.contains("<img src=\"https://example.com/logo.png\" alt=\"logo\">"))
106 #expect(html.contains(">hello@cleberg.net</a>")) 101 #expect(!html.contains(#"\"#))
107 } 102 }
108 103
109 @Test 104 @Test
110 func markdownListContinuesAfterCodeBlock() { 105 func markdownLinkedImageRendersAnchor() {
111 let html = markdownToHTML(""" 106 let html = markdownToHTML("[![badge](https://example.com/badge.png)](https://example.com/build)")
112 1. Clone the repository:
113 ```sh
114 git clone https://git.sr.ht/~ccleberg/Hutch
115 ```
116 2. Open the project in Xcode.
117 """)
118 107
119 #expect(html.contains("<ol>")) 108 #expect(html.contains("<a href=\"https://example.com/build\">"))
120 #expect(html.contains("<pre><code>")) 109 #expect(html.contains("<img src=\"https://example.com/badge.png\" alt=\"badge\">"))
121 #expect(html.contains("<li><p>Clone the repository:</p>")) 110 #expect(!html.contains(#"\"#))
122 #expect(html.contains("<li>Open the project in Xcode.</li>"))
123 #expect(html.contains("</ol>"))
124 } 111 }
125 112
126 @Test 113 @Test
127 func markdownListContinuesAfterBlankLineIndentedCodeBlock() { 114 func markdownInlineCodeEscaping() {
128 let html = markdownToHTML(""" 115 let html = processInline("`<b>`")
129 1. Clone the repository:
130 116
131 ```sh 117 #expect(html.contains("<code>"))
132 git clone https://git.sr.ht/~ccleberg/Hutch 118 #expect(html.contains("&lt;b&gt;"))
133 ``` 119 #expect(!html.contains("<b>"))
120 }
134 121
135 2. Open the project in Xcode. 122 @Test
136 """) 123 func markdownPlainEmailAutolinks() {
124 let html = processInline("Contact hello@cleberg.net")
137 125
138 #expect(html.contains("<li><p>Clone the repository:</p>")) 126 #expect(html.contains(#"href="mailto:hello@cleberg.net""#))
139 #expect(html.contains("<pre><code>git clone https://git.sr.ht/~ccleberg/Hutch</code></pre>")) 127 #expect(html.contains(">hello@cleberg.net</a>"))
140 #expect(html.contains("<li>Open the project in Xcode.</li>"))
141 #expect(!html.contains("<ol>\n<li>Open the project in Xcode.</li>\n</ol>\n<ol>"))
142 #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)
143 } 128 }
144 129
145 @Test 130 @Test
@@ -151,6 +136,20 @@ struct MarkdownRenderingTests {
151 #expect(html.contains("<img")) 136 #expect(html.contains("<img"))
152 } 137 }
153 138
139 @Test
140 func markdownLinkedImagesWithQueryStringsRenderAllImages() {
141 let html = markdownToHTML("""
142 [![builds.sr.ht status](https://builds.sr.ht/~ccleberg/Hutch.svg)](https://builds.sr.ht/~ccleberg/Hutch?)
143 [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=ccleberg_Hutch&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=ccleberg_Hutch)
144 [![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=ccleberg_Hutch&metric=reliability_rating)](https://sonarcloud.io/summary/new_code?id=ccleberg_Hutch)
145 """)
146
147 #expect(html.contains("Hutch.svg"))
148 #expect(html.contains("metric=security_rating"))
149 #expect(html.contains("metric=reliability_rating"))
150 #expect(!html.contains("amp;amp;"))
151 }
152
154 @Test 153 @Test
155 func markdownAllowsSafeInlineHTML() { 154 func markdownAllowsSafeInlineHTML() {
156 let html = processInline(#"<strong>Bold</strong> <a href="https://example.com">Link</a>"#) 155 let html = processInline(#"<strong>Bold</strong> <a href="https://example.com">Link</a>"#)