diff options
| author | Christian Cleberg <[email protected]> | 2026-03-30 17:10:58 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-03-30 17:10:58 -0500 |
| commit | da81b67dc8b74fdef6691d79ba06b28712c8b046 (patch) | |
| tree | 7f614fe83e79bfa74ac1bf79bf7f676de42806c0 | |
| parent | 02fbc13ddb5b66ecf1d0dff3e7173a845984e006 (diff) | |
| download | hutch-2.5.1.tar.gz hutch-2.5.1.tar.bz2 hutch-2.5.1.zip | |
fix(org): improve org-mode rendering supportv2.5.1
| -rw-r--r-- | Hutch.xcodeproj/project.pbxproj | 16 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/ReadmeView.swift | 447 | ||||
| -rw-r--r-- | HutchTests/ReadmeViewTests.swift | 102 |
3 files changed, 503 insertions, 62 deletions
diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj index f4dffa0..087de10 100644 --- a/Hutch.xcodeproj/project.pbxproj +++ b/Hutch.xcodeproj/project.pbxproj @@ -465,7 +465,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 12; + CURRENT_PROJECT_VERSION = 13; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -482,7 +482,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.5.0; + MARKETING_VERSION = 2.5.1; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -502,7 +502,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 12; + CURRENT_PROJECT_VERSION = 13; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -519,7 +519,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.5.0; + MARKETING_VERSION = 2.5.1; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -582,7 +582,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 12; + CURRENT_PROJECT_VERSION = 13; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = HutchWidgetExtension/Info.plist; @@ -592,7 +592,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.4.2; + MARKETING_VERSION = 2.5.1; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -611,7 +611,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 12; + CURRENT_PROJECT_VERSION = 13; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = HutchWidgetExtension/Info.plist; @@ -621,7 +621,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.4.2; + MARKETING_VERSION = 2.5.1; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; diff --git a/Hutch/Views/Repositories/ReadmeView.swift b/Hutch/Views/Repositories/ReadmeView.swift index 10caeed..38c9d18 100644 --- a/Hutch/Views/Repositories/ReadmeView.swift +++ b/Hutch/Views/Repositories/ReadmeView.swift @@ -371,20 +371,18 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String var date: String? let lines = rawLines.filter { line in let trimmed = line.trimmingCharacters(in: .whitespaces) - guard let keywordMatch = trimmed.firstMatch(of: /^#\+([A-Za-z]+):\s*(.*)$/) else { + guard let directive = orgKeywordDirective(in: trimmed) else { return true } - let keyword = String(keywordMatch.1).lowercased() - let value = String(keywordMatch.2).trimmingCharacters(in: .whitespaces) - switch keyword { + switch directive.keyword { case "title": - title = value + title = directive.value return false case "author": - author = value + author = directive.value return false case "date": - date = value + date = directive.value return false default: return true @@ -397,10 +395,36 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String var srcLanguage: String? var inExampleBlock = false var inCenterBlock = false + var inVerseBlock = false var currentListItemLines: [String] = [] var paragraph: [String] = [] var tableRows: [[String]] = [] var propertyRows: [(String, String)] = [] + var verseLines: [String] = [] + var pendingBlockName: String? + var pendingBlockCaption: String? + var activeBlockCaption: String? + var isWrappingBlockFigure = false + + func beginPendingBlockWrapperIfNeeded() { + guard pendingBlockName != nil || pendingBlockCaption != nil else { return } + let idAttribute = pendingBlockName.map { #" id="\#(escapeHTMLAttribute($0))""# } ?? "" + html += #"<figure class="org-block"\#(idAttribute)>"# + "\n" + activeBlockCaption = pendingBlockCaption + isWrappingBlockFigure = true + pendingBlockName = nil + pendingBlockCaption = nil + } + + func closePendingBlockWrapper() { + guard isWrappingBlockFigure else { return } + if let activeBlockCaption { + html += "<figcaption>" + processOrgInline(activeBlockCaption, imageURLResolver: imageURLResolver) + "</figcaption>\n" + } + html += "</figure>\n" + activeBlockCaption = nil + isWrappingBlockFigure = false + } func flushParagraph() { if !paragraph.isEmpty { @@ -414,12 +438,9 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String func flushListItem() { guard !currentListItemLines.isEmpty else { return } - let content = currentListItemLines - .map { $0.trimmingCharacters(in: .whitespaces) } - .joined(separator: " ") - html += "<li>" + renderTaskListItem( - content, - inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) } + html += "<li>" + renderOrgListItemBody( + currentListItemLines, + imageURLResolver: imageURLResolver ) + "</li>\n" currentListItemLines = [] } @@ -439,10 +460,12 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String func flushTable() { guard !tableRows.isEmpty else { return } + beginPendingBlockWrapperIfNeeded() html += renderHTMLTable( rows: tableRows, inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) } ) + closePendingBlockWrapper() tableRows = [] } @@ -469,6 +492,7 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String if srcLanguage != nil { html += "</code></pre>\n" srcLanguage = nil + closePendingBlockWrapper() } } @@ -476,6 +500,7 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String if inExampleBlock { html += "</code></pre>\n" inExampleBlock = false + closePendingBlockWrapper() } } @@ -484,6 +509,21 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String flushParagraph() html += "</div>\n" inCenterBlock = false + closePendingBlockWrapper() + } + } + + func closeVerseBlock() { + if inVerseBlock { + let content = verseLines + .map { processOrgInline($0, imageURLResolver: imageURLResolver) } + .joined(separator: "\n") + html += #"<blockquote class="org-verse">"# + "\n" + html += content + "\n" + html += "</blockquote>\n" + verseLines = [] + inVerseBlock = false + closePendingBlockWrapper() } } @@ -529,6 +569,15 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String continue } + if inVerseBlock { + if trimmed.lowercased() == "#+end_verse" { + closeVerseBlock() + } else { + verseLines.append(line) + } + continue + } + if inQuoteBlock, trimmed.lowercased() == "#+end_quote" { closeQuoteBlock() continue @@ -549,9 +598,25 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String continue } + if let directive = orgKeywordDirective(in: trimmed) { + switch directive.keyword { + case "caption": + pendingBlockCaption = directive.value + continue + case "name": + pendingBlockName = directive.value + continue + case "options", "property": + continue + default: + break + } + } + if trimmed.lowercased().hasPrefix("#+begin_src") { closeQuoteBlock() flushBlockState() + beginPendingBlockWrapperIfNeeded() let language = trimmed .split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true) .dropFirst() @@ -567,6 +632,7 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String if trimmed.lowercased() == "#+begin_example" { closeQuoteBlock() flushBlockState() + beginPendingBlockWrapperIfNeeded() html += "<pre><code>" inExampleBlock = true continue @@ -574,6 +640,7 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String if trimmed.lowercased() == "#+begin_quote" { flushBlockState() + beginPendingBlockWrapperIfNeeded() html += "<blockquote>\n" inQuoteBlock = true continue @@ -582,11 +649,21 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String if trimmed.lowercased() == "#+begin_center" { closeQuoteBlock() flushBlockState() + beginPendingBlockWrapperIfNeeded() html += "<div style=\"text-align:center\">\n" inCenterBlock = true continue } + if trimmed.lowercased() == "#+begin_verse" { + closeQuoteBlock() + flushBlockState() + beginPendingBlockWrapperIfNeeded() + verseLines = [] + inVerseBlock = true + continue + } + if trimmed == ":PROPERTIES:" { closeQuoteBlock() flushBlockState() @@ -640,8 +717,13 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String continue } + if listType != nil && isIndentedContinuationLine(line) { + currentListItemLines.append(line) + continue + } + // List items: - item - if trimmed.hasPrefix("- ") { + if !isIndentedContinuationLine(line), trimmed.hasPrefix("- ") { flushParagraph() flushPropertyDrawer() if listType != .unordered { @@ -654,7 +736,7 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String continue } - if let orderedItem = orderedListItem(in: trimmed) { + if !isIndentedContinuationLine(line), let orderedItem = orderedListItem(in: trimmed) { flushParagraph() flushPropertyDrawer() if listType != .ordered { @@ -667,11 +749,6 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String continue } - if listType != nil && isIndentedContinuationLine(line) { - currentListItemLines.append(trimmed) - continue - } - // Blank line if trimmed.isEmpty { if inQuoteBlock { @@ -683,12 +760,17 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String } // Regular text + if pendingBlockName != nil || pendingBlockCaption != nil { + pendingBlockName = nil + pendingBlockCaption = nil + } paragraph.append(line) } closeSourceBlock() closeExampleBlock() closeCenterBlock() + closeVerseBlock() closeQuoteBlock() flushBlockState() @@ -701,41 +783,25 @@ nonisolated private func processOrgInline(_ text: String, imageURLResolver: ((St result = protectMatches( in: result, - pattern: #"\[\[([^\]]+)\]\[([^\]]+)\]\]"#, - protectedFragments: &protectedFragments - ) { match, nsText in - let url = nsText.substring(with: match.range(at: 1)) - let label = nsText.substring(with: match.range(at: 2)) - if let imageHTML = makeOrgImageHTML( - source: url, - alt: label, - imageURLResolver: imageURLResolver - ) { - return imageHTML - } - guard let sanitizedURL = sanitizedReadmeLinkURLString(url) else { - return label - } - return #"<a href="\#(sanitizedURL)">\#(label)</a>"# - } - result = protectMatches( - in: result, - pattern: #"\[\[([^\]]+)\]\]"#, + pattern: #"\[\[([^\]]+)\]\[\[([^\]]+)\]\]\]"#, protectedFragments: &protectedFragments ) { match, nsText in - let url = nsText.substring(with: match.range(at: 1)) - if let imageHTML = makeOrgImageHTML( - source: url, + let destination = nsText.substring(with: match.range(at: 1)) + let source = nsText.substring(with: match.range(at: 2)) + guard let imageHTML = makeOrgImageHTML( + source: source, alt: nil, imageURLResolver: imageURLResolver - ) { - return imageHTML + ) else { + return source } - guard let sanitizedURL = sanitizedReadmeLinkURLString(url) else { - return url + guard let sanitizedURL = sanitizedReadmeLinkURLString(destination) else { + return imageHTML } - return #"<a href="\#(sanitizedURL)">\#(url)</a>"# + return #"<a href="\#(sanitizedURL)">\#(imageHTML)</a>"# } + + result = protectOrgLinks(in: result, protectedFragments: &protectedFragments, imageURLResolver: imageURLResolver) result = protectMatches( in: result, pattern: #"(?<!\S)~(.+?)~(?=\s|$|[.,;:!?])|(?<!\S)=(.+?)=(?=\s|$|[.,;:!?])"#, @@ -875,9 +941,44 @@ nonisolated private func parseTableRow(_ line: String) -> [String] { .map { String($0).trimmingCharacters(in: .whitespaces) } } +nonisolated private func parseOrgTableSeparatorRow(_ line: String) -> [String] { + var content = line.trimmingCharacters(in: .whitespaces) + if content.hasPrefix("|") { + content.removeFirst() + } + if content.hasSuffix("|") { + content.removeLast() + } + return content + .split(separator: "+", omittingEmptySubsequences: false) + .map { String($0).trimmingCharacters(in: .whitespaces) } +} + nonisolated private func isTableSeparatorCell(_ cell: String) -> Bool { + tableAlignment(for: cell) != nil +} + +nonisolated private func tableAlignment(for cell: String) -> String? { let trimmed = cell.trimmingCharacters(in: .whitespaces) - return !trimmed.isEmpty && trimmed.allSatisfy { $0 == "-" || $0 == "+" } + guard !trimmed.isEmpty else { return nil } + + let core = trimmed.replacingOccurrences(of: ":", with: "") + guard !core.isEmpty, core.allSatisfy({ $0 == "-" || $0 == "+" }) else { + return nil + } + + let isLeftAligned = trimmed.hasPrefix(":") + let isRightAligned = trimmed.hasSuffix(":") + switch (isLeftAligned, isRightAligned) { + case (true, true): + return "center" + case (true, false): + return "left" + case (false, true): + return "right" + case (false, false): + return "" + } } nonisolated private func renderHTMLTable( @@ -885,15 +986,22 @@ nonisolated private func renderHTMLTable( inlineRenderer: (String) -> String ) -> String { guard !rows.isEmpty else { return "" } - let hasHeaderSeparator = rows.count > 1 && rows[1].allSatisfy(isTableSeparatorCell) + let separatorCells: [String] + if rows.count > 1, rows[1].count == 1 { + separatorCells = parseOrgTableSeparatorRow(rows[1][0]) + } else { + separatorCells = rows.count > 1 ? rows[1] : [] + } + let hasHeaderSeparator = rows.count > 1 && !separatorCells.isEmpty && separatorCells.allSatisfy(isTableSeparatorCell) let headerRow = rows.first ?? [] let bodyRows = hasHeaderSeparator ? Array(rows.dropFirst(2)) : rows + let columnAlignments = hasHeaderSeparator ? separatorCells.map(tableAlignment) : [] var html = "<table>\n" if hasHeaderSeparator { html += "<thead><tr>" - for cell in headerRow { - html += "<th>" + inlineRenderer(cell) + "</th>" + for (index, cell) in headerRow.enumerated() { + html += "<th" + tableAlignmentStyleAttribute(columnAlignments[safe: index] ?? nil) + ">" + inlineRenderer(cell) + "</th>" } html += "</tr></thead>\n" } @@ -901,8 +1009,8 @@ nonisolated private func renderHTMLTable( html += "<tbody>\n" for row in bodyRows { html += "<tr>" - for cell in row { - html += "<td>" + inlineRenderer(cell) + "</td>" + for (index, cell) in row.enumerated() { + html += "<td" + tableAlignmentStyleAttribute(columnAlignments[safe: index] ?? nil) + ">" + inlineRenderer(cell) + "</td>" } html += "</tr>\n" } @@ -911,6 +1019,11 @@ nonisolated private func renderHTMLTable( return html } +nonisolated private func tableAlignmentStyleAttribute(_ alignment: String?) -> String { + guard let alignment, !alignment.isEmpty else { return "" } + return #" style="text-align: \#(alignment);""# +} + private enum OrgListType: Equatable { case unordered case ordered @@ -921,6 +1034,200 @@ nonisolated private func orderedListItem(in line: String) -> String? { return String(match.2) } +nonisolated private func renderOrgListItemBody( + _ lines: [String], + imageURLResolver: ((String) -> String?)? = nil +) -> String { + guard let firstLine = lines.first else { return "" } + + var contentLines: [String] = [firstLine.trimmingCharacters(in: .whitespaces)] + var nestedLines: [String] = [] + + for line in lines.dropFirst() { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { + continue + } + + if isIndentedListItemLine(line) { + nestedLines.append(outdentOrgListLine(line)) + } else { + contentLines.append(trimmed) + } + } + + var html = renderTaskListItem( + contentLines.joined(separator: " "), + inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) } + ) + if !nestedLines.isEmpty { + html += "\n" + renderNestedOrgListHTML(nestedLines, imageURLResolver: imageURLResolver) + } + return html +} + +nonisolated private func renderNestedOrgListHTML( + _ lines: [String], + imageURLResolver: ((String) -> String?)? = nil +) -> String { + var html = "" + var listType: OrgListType? + var currentItemLines: [String] = [] + + func flushNestedItem() { + guard !currentItemLines.isEmpty else { return } + html += "<li>" + renderOrgListItemBody(currentItemLines, imageURLResolver: imageURLResolver) + "</li>\n" + currentItemLines = [] + } + + func closeNestedList() { + flushNestedItem() + switch listType { + case .unordered: + html += "</ul>\n" + case .ordered: + html += "</ol>\n" + case nil: + break + } + listType = nil + } + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("- ") { + if listType != .unordered { + closeNestedList() + html += "<ul>\n" + listType = .unordered + } + flushNestedItem() + currentItemLines = [String(trimmed.dropFirst(2))] + continue + } + + if let orderedItem = orderedListItem(in: trimmed) { + if listType != .ordered { + closeNestedList() + html += "<ol>\n" + listType = .ordered + } + flushNestedItem() + currentItemLines = [orderedItem] + continue + } + + if listType != nil { + currentItemLines.append(line) + } + } + + closeNestedList() + return html +} + +nonisolated private func protectOrgLinks( + in text: String, + protectedFragments: inout [String: String], + imageURLResolver: ((String) -> String?)? = nil +) -> String { + var result = text + + while let range = result.range(of: "[[") { + guard let parsed = parseOrgLink(in: result, from: range.lowerBound) else { + break + } + let token = "ZZPROTECTED\(protectedFragments.count)ZZ" + protectedFragments[token] = renderOrgLink( + destination: parsed.destination, + label: parsed.label, + imageURLResolver: imageURLResolver + ) + result.replaceSubrange(parsed.range, with: token) + } + + return result +} + +nonisolated private func parseOrgLink( + in text: String, + from start: String.Index +) -> (range: Range<String.Index>, destination: String, label: String?)? { + guard text[start...].hasPrefix("[[") else { return nil } + + var index = text.index(start, offsetBy: 2) + guard let destinationEnd = text[index...].range(of: "][" )?.lowerBound else { + guard let end = text[index...].range(of: "]]")?.lowerBound else { return nil } + return (start..<text.index(end, offsetBy: 2), String(text[index..<end]), nil) + } + + let destination = String(text[index..<destinationEnd]) + index = text.index(destinationEnd, offsetBy: 2) + let labelStart = index + var depth = 0 + + while index < text.endIndex { + if text[index...].hasPrefix("[[") { + depth += 1 + index = text.index(index, offsetBy: 2) + continue + } + if text[index...].hasPrefix("]]") { + if depth == 0 { + let end = text.index(index, offsetBy: 2) + return (start..<end, destination, String(text[labelStart..<index])) + } + depth -= 1 + index = text.index(index, offsetBy: 2) + continue + } + index = text.index(after: index) + } + + return nil +} + +nonisolated private func renderOrgLink( + destination: String, + label: String?, + imageURLResolver: ((String) -> String?)? = nil +) -> String { + if let label, label.hasPrefix("[["), label.hasSuffix("]]") { + let source = String(label.dropFirst(2).dropLast(2)) + if let imageHTML = makeOrgImageHTML(source: source, alt: nil, imageURLResolver: imageURLResolver) { + guard let sanitizedURL = sanitizedReadmeLinkURLString(destination) else { + return imageHTML + } + return #"<a href="\#(sanitizedURL)">\#(imageHTML)</a>"# + } + } + + if let imageHTML = makeOrgImageHTML( + source: destination, + alt: label, + imageURLResolver: imageURLResolver + ) { + return imageHTML + } + + guard let sanitizedURL = sanitizedReadmeLinkURLString(destination) else { + return label ?? destination + } + + let renderedLabel = label.map { processOrgInline($0, imageURLResolver: imageURLResolver) } ?? destination + return #"<a href="\#(sanitizedURL)">\#(renderedLabel)</a>"# +} + +nonisolated private func orgKeywordDirective(in line: String) -> (keyword: String, value: String)? { + guard let match = line.firstMatch(of: /^#\+([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/) else { + return nil + } + return ( + keyword: String(match.1).lowercased(), + value: String(match.2).trimmingCharacters(in: .whitespaces) + ) +} + nonisolated private func isOrgHorizontalRule(_ line: String) -> Bool { matchesRegex(line, pattern: #"^\s*-{5,}\s*$"#) @@ -946,6 +1253,27 @@ nonisolated private func isIndentedContinuationLine(_ line: String) -> Bool { return first == " " || first == "\t" } +nonisolated private func isIndentedListItemLine(_ line: String) -> Bool { + guard isIndentedContinuationLine(line) else { return false } + let trimmed = line.trimmingCharacters(in: .whitespaces) + return trimmed.hasPrefix("- ") || orderedListItem(in: trimmed) != nil +} + +nonisolated private func outdentOrgListLine(_ line: String) -> String { + var result = line + while result.first == " " || result.first == "\t" { + result.removeFirst() + } + return result +} + +private extension Array { + subscript(safe index: Int) -> Element? { + guard indices.contains(index) else { return nil } + return self[index] + } +} + nonisolated func decodeHTMLEntities(_ text: String) -> String { text @@ -1334,6 +1662,9 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable { color: inherit; opacity: 0.85; } + .org-verse { + white-space: pre-wrap; + } hr { border: none; border-top: 1px solid rgba(128, 128, 128, 0.35); @@ -1367,6 +1698,14 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable { } dd { margin: 0; } .org-metadata { margin-bottom: 1em; } + figure.org-block { + margin: 0.75em 0; + } + figure.org-block figcaption { + margin-top: 0.4em; + color: rgba(128, 128, 128, 0.85); + font-size: 0.9em; + } .org-title { margin: 0 0 0.25em; } .org-author, .org-date { margin: 0; diff --git a/HutchTests/ReadmeViewTests.swift b/HutchTests/ReadmeViewTests.swift index 8cced03..f338525 100644 --- a/HutchTests/ReadmeViewTests.swift +++ b/HutchTests/ReadmeViewTests.swift @@ -231,6 +231,108 @@ struct OrgRenderingTests { #expect(html.contains("<li>First line continues here</li>")) } + + @Test + func orgHeaderKeywordsIgnored() { + let html = orgToHTML(""" + #+OPTIONS: toc:nil + #+PROPERTY: header-args :results output + Body text + """) + + #expect(!html.contains("#+OPTIONS")) + #expect(!html.contains("#+PROPERTY")) + #expect(html.contains("<p>Body text</p>")) + } + + @Test + func orgVerseBlock() { + let html = orgToHTML(""" + #+begin_verse + There is a line. + And an indented line. + #+end_verse + """) + + #expect(html.contains(#"<blockquote class="org-verse">"#)) + #expect(html.contains("There is a line.")) + #expect(html.contains("And an indented line.")) + #expect(!html.contains("#+begin_verse")) + } + + @Test + func orgNamedBlockRendersCaption() { + let html = orgToHTML(""" + #+CAPTION: Build output + #+NAME: build-log + #+begin_example + hello world + #+end_example + """) + + #expect(html.contains(#"<figure class="org-block" id="build-log">"#)) + #expect(html.contains("<figcaption>Build output</figcaption>")) + #expect(!html.contains("#+CAPTION")) + #expect(!html.contains("#+NAME")) + } + + @Test + func orgLinkedImageRenders() { + let html = orgToHTML("[[https://example.com][[https://img.cleberg.net/apps/hutch/screenshots/ipad/01_patch.jpg]]]") + + #expect(html.contains(#"<a href="https://example.com">"#)) + #expect(html.contains(#"<img src="https://img.cleberg.net/apps/hutch/screenshots/ipad/01_patch.jpg" alt="">"#)) + } + + @Test + func orgLinkedRelativeImageRenders() { + let html = orgToHTML( + "[[https://example.com/docs][[./images/badge.svg]]]", + imageURLResolver: { source in + source == "./images/badge.svg" ? "https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/images/badge.svg" : nil + } + ) + + #expect(html.contains(#"<a href="https://example.com/docs">"#)) + #expect(html.contains(#"<img src="https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/images/badge.svg" alt="">"#)) + } + + @Test + func orgNestedBulletListRendersNestedMarkup() { + let html = orgToHTML(""" + * Lists + ** Unordered + - First bullet + - Second bullet + - Third bullet with /italic/ and *bold* + - Bullet with wrapped + continuation line + - Bullet with nested list + - Nested child one + - Nested child two + - Bullet with inline code ~let x = 1~ + """) + + #expect(html.contains("<ul>")) + #expect(html.contains("<li>Bullet with nested list\n<ul>")) + #expect(html.contains("<li>Nested child one</li>")) + #expect(html.contains("<li>Nested child two</li>")) + #expect(html.contains("<li>Bullet with wrapped continuation line</li>")) + } + + @Test + func orgTableAlignmentRendersStyles() { + let html = orgToHTML(""" + | Left | Center | Right | + |:-----+:-----:+------:| + | a | b | c | + | 1 | 2 | 3 | + """) + + #expect(html.contains("text-align: left;")) + #expect(html.contains("text-align: center;")) + #expect(html.contains("text-align: right;")) + } } struct RepositoryAssetURLTests { |
