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 --- .../Views/Repositories/MarkdownHTMLRenderer.swift | 206 +++++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 Hutch/Views/Repositories/MarkdownHTMLRenderer.swift (limited to 'Hutch/Views/Repositories/MarkdownHTMLRenderer.swift') diff --git a/Hutch/Views/Repositories/MarkdownHTMLRenderer.swift b/Hutch/Views/Repositories/MarkdownHTMLRenderer.swift new file mode 100644 index 0000000..67966f1 --- /dev/null +++ b/Hutch/Views/Repositories/MarkdownHTMLRenderer.swift @@ -0,0 +1,206 @@ +import Markdown + +nonisolated func markdownToHTML(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String { + let document = Document(parsing: text) + var renderer = MarkdownHTMLRenderer(imageURLResolver: imageURLResolver) + return renderer.visit(document) +} + +private struct MarkdownHTMLRenderer: MarkupVisitor { + typealias Result = String + + nonisolated(unsafe) let imageURLResolver: ((String) -> String?)? + private var isRenderingTableHead = false + + nonisolated init(imageURLResolver: ((String) -> String?)?) { + self.imageURLResolver = imageURLResolver + } + + nonisolated mutating func visit(_ markup: Markup) -> String { + markup.accept(&self) + } + + nonisolated mutating func defaultVisit(_ markup: Markup) -> String { + visitChildren(of: markup) + } + + nonisolated mutating func visitDocument(_ document: Document) -> String { + visitChildren(of: document) + } + + nonisolated mutating func visitHeading(_ heading: Heading) -> String { + "\(visitChildren(of: heading))\n" + } + + nonisolated mutating func visitParagraph(_ paragraph: Paragraph) -> String { + "

\(visitChildren(of: paragraph))

\n" + } + + nonisolated mutating func visitBlockQuote(_ blockQuote: BlockQuote) -> String { + "
\n\(visitChildren(of: blockQuote))
\n" + } + + nonisolated mutating func visitUnorderedList(_ unorderedList: UnorderedList) -> String { + "\n" + } + + nonisolated mutating func visitOrderedList(_ orderedList: OrderedList) -> String { + "
    \n\(visitChildren(of: orderedList))
\n" + } + + nonisolated mutating func visitListItem(_ listItem: ListItem) -> String { + if let checkbox = listItem.checkbox, + listItem.childCount == 1, + let paragraph = listItem.child(at: 0) as? Paragraph { + let content = visitChildren(of: paragraph) + return "
  • \(checkboxHTML(for: checkbox)) \(content)
  • \n" + } + + var body = visitChildren(of: listItem) + if let checkbox = listItem.checkbox { + body = "\(checkboxHTML(for: checkbox))" + body + } + return "
  • \(body)
  • \n" + } + + nonisolated mutating func visitCodeBlock(_ codeBlock: CodeBlock) -> String { + let classAttribute: String + if let language = codeBlock.language, !language.isEmpty { + classAttribute = " class=\"language-\(escapeHTMLAttribute(language))\"" + } else { + classAttribute = "" + } + return "
    \(escapeHTML(codeBlock.code))
    \n" + } + + nonisolated mutating func visitInlineCode(_ inlineCode: InlineCode) -> String { + "\(escapeHTML(inlineCode.code))" + } + + nonisolated mutating func visitThematicBreak(_: ThematicBreak) -> String { + "
    \n" + } + + nonisolated mutating func visitHTMLBlock(_ html: HTMLBlock) -> String { + guard let sanitized = sanitizedMarkdownHTMLBlock(html.rawHTML) else { return "" } + return sanitized + "\n" + } + + nonisolated mutating func visitInlineHTML(_ inlineHTML: InlineHTML) -> String { + sanitizedMarkdownHTMLTag(inlineHTML.rawHTML) ?? "" + } + + nonisolated mutating func visitLink(_ link: Markdown.Link) -> String { + let content = visitChildren(of: link) + guard let destination = link.destination, + let sanitizedDestination = sanitizedReadmeLinkURLString(destination) else { + return content + } + let href = escapeHTMLAttribute(decodeHTMLEntities(sanitizedDestination)) + return "\(content)" + } + + nonisolated mutating func visitImage(_ image: Markdown.Image) -> String { + let altText = plainText(from: image) + guard let source = image.source, !source.isEmpty else { + return escapeHTML(altText) + } + + let resolvedSource = imageURLResolver?(source) ?? source + guard let sanitizedSource = sanitizedReadmeImageURLString(resolvedSource) else { + return escapeHTML(altText) + } + + let src = escapeHTMLAttribute(decodeHTMLEntities(sanitizedSource)) + return "\"\(escapeHTMLAttribute(altText))\"" + } + + nonisolated mutating func visitStrong(_ strong: Strong) -> String { + "\(visitChildren(of: strong))" + } + + nonisolated mutating func visitEmphasis(_ emphasis: Emphasis) -> String { + "\(visitChildren(of: emphasis))" + } + + nonisolated mutating func visitStrikethrough(_ strikethrough: Strikethrough) -> String { + "\(visitChildren(of: strikethrough))" + } + + nonisolated mutating func visitText(_ text: Markdown.Text) -> String { + escapeHTML(text.string) + } + + nonisolated mutating func visitSoftBreak(_: SoftBreak) -> String { + " " + } + + nonisolated mutating func visitLineBreak(_: LineBreak) -> String { + "
    " + } + + nonisolated mutating func visitTable(_ table: Markdown.Table) -> String { + "\n\(visitChildren(of: table))
    \n" + } + + nonisolated mutating func visitTableHead(_ tableHead: Markdown.Table.Head) -> String { + let previousValue = isRenderingTableHead + isRenderingTableHead = true + let content = visitChildren(of: tableHead) + isRenderingTableHead = previousValue + return "\(content)\n" + } + + nonisolated mutating func visitTableBody(_ tableBody: Markdown.Table.Body) -> String { + let previousValue = isRenderingTableHead + isRenderingTableHead = false + let content = visitChildren(of: tableBody) + isRenderingTableHead = previousValue + return "\n\(content)\n" + } + + nonisolated mutating func visitTableRow(_ tableRow: Markdown.Table.Row) -> String { + "\(visitChildren(of: tableRow))\n" + } + + nonisolated mutating func visitTableCell(_ tableCell: Markdown.Table.Cell) -> String { + let tagName = isRenderingTableHead ? "th" : "td" + return "<\(tagName)>\(visitChildren(of: tableCell))" + } + + nonisolated private mutating func visitChildren(of markup: Markup) -> String { + var html = "" + for child in markup.children { + html += visit(child) + } + return html + } + + nonisolated private func plainText(from markup: Markup) -> String { + switch markup { + case let text as Markdown.Text: + return text.string + case let inlineCode as InlineCode: + return inlineCode.code + case is SoftBreak: + return " " + case is LineBreak: + return "\n" + default: + var text = "" + for child in markup.children { + text += plainText(from: child) + } + return text + } + } + + nonisolated private func checkboxHTML(for checkbox: Checkbox) -> String { + switch checkbox { + case .checked: + return "" + case .unchecked: + return "" + } + } +} -- cgit v1.2.3