diff options
| author | Christian Cleberg <[email protected]> | 2026-03-30 16:22:59 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-03-30 16:22:59 -0500 |
| commit | ddb310ed15fcd693a5487e5f38b5ba74cdf27843 (patch) | |
| tree | 9b6fa7fbe60066e84b867415f4177fb5a605af07 | |
| parent | 09d93e6e6f2111f569d82da40dac8c499ba9a575 (diff) | |
| download | hutch-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
| -rw-r--r-- | Hutch.xcodeproj/project.pbxproj | 29 | ||||
| -rw-r--r-- | Hutch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 24 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/MarkdownHTMLRenderer.swift | 206 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/ReadmeView.swift | 378 | ||||
| -rw-r--r-- | 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: """) } -nonisolated private func escapeHTMLAttribute(_ text: String) -> String { +nonisolated func escapeHTMLAttribute(_ text: String) -> String { escapeHTML(text).replacingOccurrences(of: "'", with: "'") } @@ -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: "&", with: "&") .replacingOccurrences(of: """, with: "\"") @@ -1265,12 +956,34 @@ nonisolated private func decodeHTMLEntities(_ text: String) -> String { .replacingOccurrences(of: ">", 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("<b>")) - #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 [email protected]") + func markdownImageRenders() { + let html = markdownToHTML("") - #expect(html.contains(#"href="mailto:[email protected]""#)) - #expect(html.contains(">[email protected]</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("[](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("<b>")) + #expect(!html.contains("<b>")) + } - 2. Open the project in Xcode. - """) + @Test + func markdownPlainEmailAutolinks() { + let html = processInline("Contact [email protected]") - #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:[email protected]""#)) + #expect(html.contains(">[email protected]</a>")) } @Test @@ -152,6 +137,20 @@ struct MarkdownRenderingTests { } @Test + func markdownLinkedImagesWithQueryStringsRenderAllImages() { + let html = markdownToHTML(""" + [](https://builds.sr.ht/~ccleberg/Hutch?) + [](https://sonarcloud.io/summary/new_code?id=ccleberg_Hutch) + [](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>"#) |
