From 09d93e6e6f2111f569d82da40dac8c499ba9a575 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Mon, 30 Mar 2026 13:10:54 -0500 Subject: partial extended support for markdown and org-mode --- Hutch.xcodeproj/project.pbxproj | 16 +- Hutch/Views/Repositories/ReadmeView.swift | 699 +++++++++++++++++++++++++++--- HutchTests/ReadmeViewTests.swift | 211 +++++++++ 3 files changed, 849 insertions(+), 77 deletions(-) diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj index 347d6fc..a52d824 100644 --- a/Hutch.xcodeproj/project.pbxproj +++ b/Hutch.xcodeproj/project.pbxproj @@ -459,7 +459,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 11; + CURRENT_PROJECT_VERSION = 12; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -476,7 +476,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.4.1; + MARKETING_VERSION = 2.4.2; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -496,7 +496,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 11; + CURRENT_PROJECT_VERSION = 12; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -513,7 +513,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.4.1; + MARKETING_VERSION = 2.4.2; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -576,7 +576,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 11; + CURRENT_PROJECT_VERSION = 12; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = HutchWidgetExtension/Info.plist; @@ -586,7 +586,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.4.1; + MARKETING_VERSION = 2.4.2; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -605,7 +605,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 11; + CURRENT_PROJECT_VERSION = 12; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = HutchWidgetExtension/Info.plist; @@ -615,7 +615,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.4.1; + MARKETING_VERSION = 2.4.2; 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 dea41f3..27cec55 100644 --- a/Hutch/Views/Repositories/ReadmeView.swift +++ b/Hutch/Views/Repositories/ReadmeView.swift @@ -283,8 +283,15 @@ nonisolated func markdownToHTML(_ text: String, imageURLResolver: ((String) -> S let lines = normalizedText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) var html = "" var inCodeBlock = false - var inList = 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 { @@ -296,72 +303,262 @@ nonisolated func markdownToHTML(_ text: String, imageURLResolver: ((String) -> S } } + func flushListItem() { + guard !currentListItemLines.isEmpty || !currentListItemBlocks.isEmpty else { return } + let itemContent = currentListItemLines + .map { $0.trimmingCharacters(in: .whitespaces) } + .joined(separator: " ") + + if currentListItemBlocks.isEmpty { + html += "
  • " + renderTaskListItem( + itemContent, + inlineRenderer: { processInline($0, imageURLResolver: imageURLResolver) } + ) + "
  • \n" + } else { + if !itemContent.isEmpty { + currentListItemBlocks.append( + "

    " + renderTaskListItem( + itemContent, + inlineRenderer: { processInline($0, imageURLResolver: imageURLResolver) } + ) + "

    " + ) + } + html += "
  • " + currentListItemBlocks.joined(separator: "\n") + "
  • \n" + } + currentListItemLines = [] + currentListItemBlocks = [] + } + + func flushListItemParagraphIntoBlocks() { + guard !currentListItemLines.isEmpty else { return } + let itemContent = currentListItemLines + .map { $0.trimmingCharacters(in: .whitespaces) } + .joined(separator: " ") + currentListItemBlocks.append( + "

    " + renderTaskListItem( + itemContent, + inlineRenderer: { processInline($0, imageURLResolver: imageURLResolver) } + ) + "

    " + ) + currentListItemLines = [] + } + + func flushCodeBlock() { + let content = codeBlockLines.joined(separator: "\n") + let blockHTML = "
    " + content + "
    \n" + if codeBlockInListItem { + currentListItemBlocks.append(blockHTML) + } else { + html += blockHTML + } + codeBlockLines = [] + codeBlockInListItem = false + } + func closeList() { - if inList { + flushListItem() + switch listType { + case .unordered: html += "\n" - inList = false + case .ordered: + html += "\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 += "\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 line.hasPrefix("```") { + if trimmed.hasPrefix("```") { if inCodeBlock { - html += "\n" + flushCodeBlock() inCodeBlock = false } else { + closeBlockquote() flushParagraph() - closeList() - html += "
    "
    +                flushTable()
    +                codeBlockInListItem = listType != nil && (!currentListItemLines.isEmpty || !currentListItemBlocks.isEmpty)
    +                if !codeBlockInListItem {
    +                    closeList()
    +                } else {
    +                    flushListItemParagraphIntoBlocks()
    +                }
                     inCodeBlock = true
    +                codeBlockLines = []
                 }
                 continue
             }
     
             if inCodeBlock {
    -            html += escapeHTML(line) + "\n"
    +            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 += "
    " + processInline(String(line.dropFirst(7)), imageURLResolver: imageURLResolver) + "
    \n" + continue + } + if line.hasPrefix("##### ") { + closeBlockquote() + flushParagraph() + closeList() + html += "
    " + processInline(String(line.dropFirst(6)), imageURLResolver: imageURLResolver) + "
    \n" + continue + } + if line.hasPrefix("#### ") { + closeBlockquote() + flushParagraph() + closeList() + html += "

    " + processInline(String(line.dropFirst(5)), imageURLResolver: imageURLResolver) + "

    \n" + continue + } if line.hasPrefix("### ") { + closeBlockquote() flushParagraph() closeList() html += "

    " + processInline(String(line.dropFirst(4)), imageURLResolver: imageURLResolver) + "

    \n" continue } if line.hasPrefix("## ") { + closeBlockquote() flushParagraph() closeList() html += "

    " + processInline(String(line.dropFirst(3)), imageURLResolver: imageURLResolver) + "

    \n" continue } if line.hasPrefix("# ") { + closeBlockquote() flushParagraph() closeList() html += "

    " + processInline(String(line.dropFirst(2)), imageURLResolver: imageURLResolver) + "

    \n" continue } + if isMarkdownHorizontalRule(trimmed) { + closeBlockquote() + flushParagraph() + closeList() + html += "
    \n" + continue + } + + if trimmed.hasPrefix("> ") { + flushTable() + closeList() + if !inBlockquote { + flushParagraph() + html += "
    \n" + inBlockquote = true + } + paragraph.append(processInline(String(trimmed.dropFirst(2)), imageURLResolver: imageURLResolver)) + continue + } else { + closeBlockquote() + } + // List items - let trimmed = line.trimmingCharacters(in: .whitespaces) if trimmed.hasPrefix("- ") || trimmed.hasPrefix("* ") { flushParagraph() - if !inList { + if listType != .unordered { + closeList() html += "
      \n" - inList = true + listType = .unordered } - html += "
    • " + renderTaskListItem( - String(trimmed.dropFirst(2)), - inlineRenderer: { processInline($0, imageURLResolver: imageURLResolver) } - ) + "
    • \n" + flushListItem() + currentListItemLines = [String(trimmed.dropFirst(2))] + continue + } + if let orderedItem = orderedListItem(in: trimmed) { + flushParagraph() + if listType != .ordered { + closeList() + html += "
        \n" + listType = .ordered + } + flushListItem() + currentListItemLines = [orderedItem] + continue + } + + if listType != nil && isIndentedContinuationLine(line) { + currentListItemLines.append(trimmed) continue } // Blank line if trimmed.isEmpty { - flushParagraph() - closeList() + if inBlockquote { + closeBlockquote() + } else if listType != nil, !currentListItemLines.isEmpty || !currentListItemBlocks.isEmpty { + pendingListItemBreak = true + } else { + flushParagraph() + closeList() + } continue } @@ -371,21 +568,33 @@ nonisolated func markdownToHTML(_ text: String, imageURLResolver: ((String) -> S // Flush remaining state if inCodeBlock { - html += "
    \n" + flushCodeBlock() } + closeBlockquote() flushParagraph() + flushTable() closeList() return html } nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String { - var result = escapeHTML(text) + var protectedFragments: [String: String] = [:] + var result = protectMatches( + in: text, + pattern: #"]*?>"#, + protectedFragments: &protectedFragments + ) { match, nsText in + let rawTag = nsText.substring(with: match.range) + return sanitizedMarkdownHTMLTag(rawTag) ?? escapeHTML(rawTag) + } + + result = escapeHTML(result) // Images: ![alt](url) result = replaceMatches(in: result, pattern: #"!\[([^\]]*)\]\(([^)]+)\)"#) { match, nsText in let alt = nsText.substring(with: match.range(at: 1)) - let source = nsText.substring(with: match.range(at: 2)) + let source = decodeHTMLEntities(nsText.substring(with: match.range(at: 2))) let resolvedSource = imageURLResolver?(source) ?? source guard let sanitizedSource = sanitizedReadmeImageURLString(resolvedSource) else { return escapeHTML(alt) @@ -395,12 +604,30 @@ nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> St // Links: [text](url) result = replaceMatches(in: result, pattern: #"\[([^\]]+)\]\(([^)]+)\)"#) { match, nsText in let label = nsText.substring(with: match.range(at: 1)) - let rawURL = nsText.substring(with: match.range(at: 2)) + let rawURL = decodeHTMLEntities(nsText.substring(with: match.range(at: 2))) guard let sanitizedURL = sanitizedReadmeLinkURLString(rawURL) else { return label } return #"\#(label)"# } + // Plain email autolinks + result = replaceMatches( + in: result, + pattern: #"(?i)(?\#(email)"# + } + // Strikethrough: ~~text~~ + result = result.replacingOccurrences( + of: #"~~(.+?)~~"#, + with: "$1", + options: .regularExpression + ) // Bold: **text** result = result.replacingOccurrences( of: #"\*\*(.+?)\*\*"#, @@ -409,7 +636,13 @@ nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> St ) // Italic: *text* result = result.replacingOccurrences( - of: #"\*(.+?)\*"#, + of: #"(?$1", + options: .regularExpression + ) + // Italic: _text_ + result = result.replacingOccurrences( + of: #"(?$1", options: .regularExpression ) @@ -420,6 +653,10 @@ nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> St options: .regularExpression ) + for (token, fragment) in protectedFragments { + result = result.replacingOccurrences(of: token, with: fragment) + } + return result } @@ -429,12 +666,39 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> 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) + let rawLines = normalizedText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + var title: String? + var author: 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 { + return true + } + let keyword = String(keywordMatch.1).lowercased() + let value = String(keywordMatch.2).trimmingCharacters(in: .whitespaces) + switch keyword { + case "title": + title = value + return false + case "author": + author = value + return false + case "date": + date = value + return false + default: + return true + } + } var html = "" var listType: OrgListType? var inQuoteBlock = false var inPropertyDrawer = false var srcLanguage: String? + var inExampleBlock = false + var inCenterBlock = false + var currentListItemLines: [String] = [] var paragraph: [String] = [] var tableRows: [[String]] = [] var propertyRows: [(String, String)] = [] @@ -449,7 +713,20 @@ 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 += "
  • " + renderTaskListItem( + content, + inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) } + ) + "
  • \n" + currentListItemLines = [] + } + func closeList() { + flushListItem() switch listType { case .unordered: html += "\n" @@ -463,34 +740,10 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String func flushTable() { guard !tableRows.isEmpty else { return } - let hasHeaderSeparator = tableRows.count > 1 && tableRows[1].allSatisfy(isOrgTableSeparatorCell) - let headerRow = tableRows.first ?? [] - let bodyRows: [[String]] - - html += "\n" - if hasHeaderSeparator { - html += "" - for cell in headerRow { - html += "" - } - html += "\n\n" - bodyRows = Array(tableRows.dropFirst(2)) - } else { - bodyRows = tableRows - } - - for row in bodyRows { - html += "" - for cell in row { - html += "" - } - html += "\n" - } - - if hasHeaderSeparator { - html += "\n" - } - html += "
    " + processOrgInline(cell, imageURLResolver: imageURLResolver) + "
    " + processOrgInline(cell, imageURLResolver: imageURLResolver) + "
    \n" + html += renderHTMLTable( + rows: tableRows, + inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) } + ) tableRows = [] } @@ -520,6 +773,21 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String } } + func closeExampleBlock() { + if inExampleBlock { + html += "\n" + inExampleBlock = false + } + } + + func closeCenterBlock() { + if inCenterBlock { + flushParagraph() + html += "\n" + inCenterBlock = false + } + } + func flushBlockState() { flushParagraph() closeList() @@ -527,6 +795,20 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String flushPropertyDrawer() } + if title != nil || author != nil || date != nil { + html += "
    \n" + if let title { + html += "

    " + escapeHTML(title) + "

    \n" + } + if let author { + html += "

    " + escapeHTML(author) + "

    \n" + } + if let date { + html += "

    " + escapeHTML(date) + "

    \n" + } + html += "
    \n" + } + for line in lines { let trimmed = line.trimmingCharacters(in: .whitespaces) @@ -539,11 +821,35 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String continue } + if inExampleBlock { + if trimmed.lowercased() == "#+end_example" { + closeExampleBlock() + } else { + html += escapeHTML(line) + "\n" + } + continue + } + if inQuoteBlock, trimmed.lowercased() == "#+end_quote" { closeQuoteBlock() continue } + if inCenterBlock { + if trimmed.lowercased() == "#+end_center" { + closeCenterBlock() + } else if trimmed.isEmpty { + flushParagraph() + } else { + paragraph.append(line) + } + continue + } + + if trimmed == "#" || trimmed.hasPrefix("# ") { + continue + } + if trimmed.lowercased().hasPrefix("#+begin_src") { closeQuoteBlock() flushBlockState() @@ -559,6 +865,14 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String continue } + if trimmed.lowercased() == "#+begin_example" { + closeQuoteBlock() + flushBlockState() + html += "
    "
    +            inExampleBlock = true
    +            continue
    +        }
    +
             if trimmed.lowercased() == "#+begin_quote" {
                 flushBlockState()
                 html += "
    \n" @@ -566,6 +880,14 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String continue } + if trimmed.lowercased() == "#+begin_center" { + closeQuoteBlock() + flushBlockState() + html += "
    \n" + inCenterBlock = true + continue + } + if trimmed == ":PROPERTIES:" { closeQuoteBlock() flushBlockState() @@ -592,18 +914,25 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String } } - if isOrgTableLine(trimmed) { + if isTableLine(trimmed) { closeQuoteBlock() flushParagraph() closeList() - tableRows.append(parseOrgTableRow(trimmed)) + tableRows.append(parseTableRow(trimmed)) continue } else { flushTable() } + if isOrgHorizontalRule(trimmed) { + closeQuoteBlock() + flushBlockState() + html += "
    \n" + continue + } + // Org headings: * heading, ** heading, *** heading - if let match = trimmed.firstMatch(of: /^(\*{1,3})\s+(.+)$/) { + if let match = trimmed.firstMatch(of: /^(\*{1,6})\s+(.+)$/) { closeQuoteBlock() flushBlockState() let level = match.1.count @@ -621,10 +950,8 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String html += "
      \n" listType = .unordered } - html += "
    • " + renderTaskListItem( - String(trimmed.dropFirst(2)), - inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) } - ) + "
    • \n" + flushListItem() + currentListItemLines = [String(trimmed.dropFirst(2))] continue } @@ -636,10 +963,13 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String html += "
        \n" listType = .ordered } - html += "
      1. " + renderTaskListItem( - orderedItem, - inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) } - ) + "
      2. \n" + flushListItem() + currentListItemLines = [orderedItem] + continue + } + + if listType != nil && isIndentedContinuationLine(line) { + currentListItemLines.append(trimmed) continue } @@ -658,6 +988,8 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String } closeSourceBlock() + closeExampleBlock() + closeCenterBlock() closeQuoteBlock() flushBlockState() @@ -720,6 +1052,22 @@ nonisolated private func processOrgInline(_ text: String, imageURLResolver: ((St } return "\(codeText)" } + result = protectMatches( + in: result, + pattern: #"(?\(value)" + } + result = protectMatches( + in: result, + pattern: #"(?\(value)" + } // Bold: *text* result = result.replacingOccurrences( @@ -733,6 +1081,17 @@ nonisolated private func processOrgInline(_ text: String, imageURLResolver: ((St with: "$1", options: .regularExpression ) + result = replaceMatches( + in: result, + pattern: #"(?i)(?\#(email)"# + } for (token, fragment) in protectedFragments { result = result.replacingOccurrences(of: token, with: fragment) @@ -805,11 +1164,11 @@ nonisolated private func sanitizeReadmeURLString( return escapeHTMLAttribute(sanitizedURL) } -nonisolated private func isOrgTableLine(_ line: String) -> Bool { +nonisolated private func isTableLine(_ line: String) -> Bool { line.hasPrefix("|") && line.hasSuffix("|") } -nonisolated private func parseOrgTableRow(_ line: String) -> [String] { +nonisolated private func parseTableRow(_ line: String) -> [String] { line .split(separator: "|", omittingEmptySubsequences: false) .dropFirst() @@ -817,21 +1176,163 @@ nonisolated private func parseOrgTableRow(_ line: String) -> [String] { .map { String($0).trimmingCharacters(in: .whitespaces) } } -nonisolated private func isOrgTableSeparatorCell(_ cell: String) -> Bool { +nonisolated private func isTableSeparatorCell(_ cell: String) -> Bool { let trimmed = cell.trimmingCharacters(in: .whitespaces) return !trimmed.isEmpty && trimmed.allSatisfy { $0 == "-" || $0 == "+" } } -private enum OrgListType { +nonisolated private func renderHTMLTable( + rows: [[String]], + inlineRenderer: (String) -> String +) -> String { + guard !rows.isEmpty else { return "" } + let hasHeaderSeparator = rows.count > 1 && rows[1].allSatisfy(isTableSeparatorCell) + let headerRow = rows.first ?? [] + let bodyRows = hasHeaderSeparator ? Array(rows.dropFirst(2)) : rows + var html = "\n" + + if hasHeaderSeparator { + html += "" + for cell in headerRow { + html += "" + } + html += "\n" + } + + html += "\n" + for row in bodyRows { + html += "" + for cell in row { + html += "" + } + html += "\n" + } + html += "\n" + html += "
        " + inlineRenderer(cell) + "
        " + inlineRenderer(cell) + "
        \n" + return html +} + +private enum MarkupListType: 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*$"#) +} + +nonisolated private func matchesRegex(_ text: String, pattern: String) -> Bool { + guard let regex = try? NSRegularExpression(pattern: pattern) else { return false } + let range = NSRange(location: 0, length: (text as NSString).length) + return regex.firstMatch(in: text, range: range) != nil +} + +nonisolated private func isInsideHTMLTag(_ text: NSString, range: NSRange) -> Bool { + guard range.location != NSNotFound else { return false } + let prefix = text.substring(to: range.location) + guard let lastOpen = prefix.lastIndex(of: "<") else { return false } + guard let lastClose = prefix.lastIndex(of: ">") else { return true } + return lastOpen > lastClose +} + +nonisolated private func isIndentedContinuationLine(_ line: String) -> Bool { + guard !line.trimmingCharacters(in: .whitespaces).isEmpty else { return false } + guard let first = line.first else { return false } + return first == " " || first == "\t" +} + +nonisolated private func isMarkdownUnorderedListItem(_ line: String) -> Bool { + line.hasPrefix("- ") || line.hasPrefix("* ") +} + +nonisolated private func decodeHTMLEntities(_ text: String) -> String { + text + .replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: """, with: "\"") + .replacingOccurrences(of: "'", with: "'") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") +} + +nonisolated private func sanitizedMarkdownHTMLLine(from line: String) -> String? { + guard line.hasPrefix("<"), line.hasSuffix(">") else { return nil } + return sanitizedMarkdownHTMLTag(line) +} + +nonisolated private func sanitizedMarkdownHTMLTag(_ rawTag: String) -> String? { + let trimmed = rawTag.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("<"), trimmed.hasSuffix(">") else { return nil } + guard !trimmed.lowercased().hasPrefix("