From ddb310ed15fcd693a5487e5f38b5ba74cdf27843 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Mon, 30 Mar 2026 16:22:59 -0500 Subject: Migrate README markdown rendering to swift-markdown and fix badge images - replace the hand-rolled markdown parser with a MarkupVisitor renderer - keep the org-mode rendering path and shared sanitization helpers intact - update WKWebView styling and height measurement for README content - fix linked image and query-string badge rendering in markdown output - expand README rendering tests and add markdown syntax coverage Implements: https://todo.sr.ht/~ccleberg/Hutch/2 --- Hutch/Views/Repositories/ReadmeView.swift | 378 ++++-------------------------- 1 file changed, 46 insertions(+), 332 deletions(-) (limited to 'Hutch/Views/Repositories/ReadmeView.swift') diff --git a/Hutch/Views/Repositories/ReadmeView.swift b/Hutch/Views/Repositories/ReadmeView.swift index 27cec55..10caeed 100644 --- a/Hutch/Views/Repositories/ReadmeView.swift +++ b/Hutch/Views/Repositories/ReadmeView.swift @@ -276,309 +276,8 @@ func clearWebContentRenderCaches() { // MARK: - Markdown to HTML -nonisolated func markdownToHTML(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String { - let normalizedText = text - .replacingOccurrences(of: "\r\n", with: "\n") - .replacingOccurrences(of: "\r", with: "\n") - let lines = normalizedText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) - var html = "" - var inCodeBlock = false - var codeBlockInListItem = false - var codeBlockLines: [String] = [] - var listType: MarkupListType? - var inBlockquote = false - var pendingListItemBreak = false - var currentListItemLines: [String] = [] - var currentListItemBlocks: [String] = [] - var paragraph: [String] = [] - var tableRows: [[String]] = [] - - func flushParagraph() { - if !paragraph.isEmpty { - let normalizedParagraph = paragraph - .map { $0.trimmingCharacters(in: .whitespaces) } - .joined(separator: " ") - html += "

" + normalizedParagraph + "

\n" - paragraph = [] - } - } - - func flushListItem() { - guard !currentListItemLines.isEmpty || !currentListItemBlocks.isEmpty else { return } - let itemContent = currentListItemLines - .map { $0.trimmingCharacters(in: .whitespaces) } - .joined(separator: " ") - - if currentListItemBlocks.isEmpty { - html += "
  • " + 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() { - flushListItem() - switch listType { - case .unordered: - html += "\n" - 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 trimmed.hasPrefix("```") { - if inCodeBlock { - flushCodeBlock() - inCodeBlock = false - } else { - closeBlockquote() - flushParagraph() - flushTable() - codeBlockInListItem = listType != nil && (!currentListItemLines.isEmpty || !currentListItemBlocks.isEmpty) - if !codeBlockInListItem { - closeList() - } else { - flushListItemParagraphIntoBlocks() - } - inCodeBlock = true - codeBlockLines = [] - } - continue - } - - if inCodeBlock { - codeBlockLines.append(escapeHTML(line)) - continue - } - - if isTableLine(trimmed) { - closeBlockquote() - flushParagraph() - closeList() - tableRows.append(parseTableRow(trimmed)) - continue - } else { - flushTable() - } - - // Headings - if line.hasPrefix("###### ") { - closeBlockquote() - flushParagraph() - closeList() - html += "
    " + 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 - if trimmed.hasPrefix("- ") || trimmed.hasPrefix("* ") { - flushParagraph() - if listType != .unordered { - closeList() - html += "