Commit 8f8ad4e852
Unsigned
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 @@ | |||
| 1 | import Markdown | ||
| 2 | |||
| 3 | nonisolated 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 | |||
| 9 | private 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 | ||
| 279 | nonisolated 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 | |||
| 581 | nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String { | 279 | nonisolated 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: """) | 808 | .replacingOccurrences(of: "\"", with: """) |
| 1110 | } | 809 | } |
| 1111 | 810 | ||
| 1112 | nonisolated private func escapeHTMLAttribute(_ text: String) -> String { | 811 | nonisolated func escapeHTMLAttribute(_ text: String) -> String { |
| 1113 | escapeHTML(text).replacingOccurrences(of: "'", with: "'") | 812 | escapeHTML(text).replacingOccurrences(of: "'", with: "'") |
| 1114 | } | 813 | } |
| 1115 | 814 | ||
| @@ -1212,21 +911,16 @@ nonisolated private func renderHTMLTable( | |||
| 1212 | return html | 911 | return html |
| 1213 | } | 912 | } |
| 1214 | 913 | ||
| 1215 | private enum MarkupListType: Equatable { | 914 | private enum OrgListType: Equatable { |
| 1216 | case unordered | 915 | case unordered |
| 1217 | case ordered | 916 | case ordered |
| 1218 | } | 917 | } |
| 1219 | 918 | ||
| 1220 | private typealias OrgListType = MarkupListType | ||
| 1221 | |||
| 1222 | nonisolated private func orderedListItem(in line: String) -> String? { | 919 | nonisolated 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 | ||
| 1227 | nonisolated private func isMarkdownHorizontalRule(_ line: String) -> Bool { | ||
| 1228 | matchesRegex(line, pattern: #"^\s*([*\-_])(?:\s*\1){2,}\s*$"#) | ||
| 1229 | } | ||
| 1230 | 924 | ||
| 1231 | nonisolated private func isOrgHorizontalRule(_ line: String) -> Bool { | 925 | nonisolated 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 | ||
| 1255 | nonisolated private func isMarkdownUnorderedListItem(_ line: String) -> Bool { | ||
| 1256 | line.hasPrefix("- ") || line.hasPrefix("* ") | ||
| 1257 | } | ||
| 1258 | 949 | ||
| 1259 | nonisolated private func decodeHTMLEntities(_ text: String) -> String { | 950 | nonisolated func decodeHTMLEntities(_ text: String) -> String { |
| 1260 | text | 951 | text |
| 1261 | .replacingOccurrences(of: "&", with: "&") | 952 | .replacingOccurrences(of: "&", with: "&") |
| 1262 | .replacingOccurrences(of: """, with: "\"") | 953 | .replacingOccurrences(of: """, with: "\"") |
| @@ -1265,12 +956,34 @@ nonisolated private func decodeHTMLEntities(_ text: String) -> String { | |||
| 1265 | .replacingOccurrences(of: ">", with: ">") | 956 | .replacingOccurrences(of: ">", with: ">") |
| 1266 | } | 957 | } |
| 1267 | 958 | ||
| 1268 | nonisolated private func sanitizedMarkdownHTMLLine(from line: String) -> String? { | 959 | nonisolated 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 | ||
| 1273 | nonisolated private func sanitizedMarkdownHTMLTag(_ rawTag: String) -> String? { | 986 | nonisolated 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("<b>")) | 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("") |
| 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("[](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("<b>")) |
| 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 | [](https://builds.sr.ht/~ccleberg/Hutch?) | ||
| 143 | [](https://sonarcloud.io/summary/new_code?id=ccleberg_Hutch) | ||
| 144 | [](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>"#) |