From b410fd16b76146f4116538c5b82330818dd47da9 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Fri, 7 Aug 2026 02:45:31 -0500 Subject: Add multi-language syntax highlighting (#16) Splash (Swift-only, since removed) left code rendering unhighlighted everywhere. Add Highlightr (highlight.js) behind a small SyntaxHighlighter service and use it on both surfaces: - Native file viewer: highlight NSAttributedString by language inferred from the filename, picking a light/dark theme from the trait style and re-highlighting on style changes. Falls back to plain text for unknown languages or very large files. - Markdown/org code blocks in the WKWebView (READMEs, tickets, comments): the web view disables JavaScript, so highlight server-side into inline color-styled spans. The color scheme is now part of the render/cache key since colors are baked into the HTML. Covered by unit tests for language mapping and highlighted output. --- Hutch/Views/Repositories/FileTreeView.swift | 49 +++-- .../Views/Repositories/MarkdownHTMLRenderer.swift | 19 +- Hutch/Views/Repositories/ReadmeView.swift | 28 ++- Hutch/Views/Repositories/SyntaxHighlighter.swift | 222 +++++++++++++++++++++ 4 files changed, 292 insertions(+), 26 deletions(-) create mode 100644 Hutch/Views/Repositories/SyntaxHighlighter.swift (limited to 'Hutch/Views/Repositories') diff --git a/Hutch/Views/Repositories/FileTreeView.swift b/Hutch/Views/Repositories/FileTreeView.swift index 7bfce17..13f72b8 100644 --- a/Hutch/Views/Repositories/FileTreeView.swift +++ b/Hutch/Views/Repositories/FileTreeView.swift @@ -456,12 +456,19 @@ final class CodeFileUIView: UIView { private var wrapLines: Bool private var needsLineLayoutUpdate = true private var lastMeasuredCodeWidth: CGFloat = 0 + private var syntaxHighlighter: SyntaxHighlighter? + private var syntaxHighlighterTheme: SyntaxHighlightTheme? init(text: String, fileName: String, font: UIFont, wrapLines: Bool) { self.currentFont = font self.wrapLines = wrapLines super.init(frame: .zero) setupViews() + registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (view: CodeFileUIView, _: UITraitCollection) in + view.rebuildRows() + view.needsLineLayoutUpdate = true + view.setNeedsLayout() + } updateContent(text: text, fileName: fileName, font: font, wrapLines: wrapLines) } @@ -583,11 +590,7 @@ final class CodeFileUIView: UIView { } rows.removeAll() - let attributedText = CodeSyntaxHighlighter.attributedText( - for: currentText, - fileName: currentFileName, - font: currentFont - ) + let attributedText = highlightedAttributedText() let lines = makeLines(from: attributedText, font: currentFont) gutterWidthConstraint?.constant = Self.gutterWidth(lineCount: lines.count, font: currentFont) @@ -600,6 +603,30 @@ final class CodeFileUIView: UIView { } } + /// Highlighting cap: above this size, skip tokenizing to keep the main + /// thread responsive and render plain, label-colored text instead. + private static let maxHighlightableLength = 100_000 + + private func highlightedAttributedText() -> NSAttributedString { + let plain = NSAttributedString( + string: currentText, + attributes: [.font: currentFont, .foregroundColor: UIColor.label] + ) + + guard currentText.count <= Self.maxHighlightableLength, + let language = SyntaxHighlighter.language(forFileName: currentFileName) else { + return plain + } + + let theme = SyntaxHighlightTheme(userInterfaceStyle: traitCollection.userInterfaceStyle) + if syntaxHighlighterTheme != theme || syntaxHighlighter == nil { + syntaxHighlighter = SyntaxHighlighter(theme: theme) + syntaxHighlighterTheme = theme + } + + return syntaxHighlighter?.attributedText(for: currentText, language: language, font: currentFont) ?? plain + } + private func makeRow(for line: CodeFileLineData) -> LineRow { let row = LineRow() @@ -737,18 +764,6 @@ private struct CodeFileLineData { let text: NSAttributedString } -private enum CodeSyntaxHighlighter { - static func attributedText(for text: String, fileName _: String, font: UIFont) -> NSAttributedString { - NSAttributedString( - string: text, - attributes: [ - .font: font, - .foregroundColor: UIColor.label - ] - ) - } -} - // MARK: - Tree Entry Row private struct TreeEntryRow: View { diff --git a/Hutch/Views/Repositories/MarkdownHTMLRenderer.swift b/Hutch/Views/Repositories/MarkdownHTMLRenderer.swift index bc63f9d..91166ac 100644 --- a/Hutch/Views/Repositories/MarkdownHTMLRenderer.swift +++ b/Hutch/Views/Repositories/MarkdownHTMLRenderer.swift @@ -2,11 +2,16 @@ import Markdown nonisolated func markdownToHTML( _ text: String, + codeTheme: SyntaxHighlightTheme = .light, imageURLResolver: ((String) -> String?)? = nil, linkURLResolver: ((String) -> String?)? = nil ) -> String { let document = Document(parsing: text) - var renderer = MarkdownHTMLRenderer(imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) + var renderer = MarkdownHTMLRenderer( + imageURLResolver: imageURLResolver, + linkURLResolver: linkURLResolver, + highlighter: SyntaxHighlighter(theme: codeTheme) + ) return renderer.visit(document) } @@ -15,13 +20,19 @@ private struct MarkdownHTMLRenderer: MarkupVisitor { nonisolated(unsafe) let imageURLResolver: ((String) -> String?)? nonisolated(unsafe) let linkURLResolver: ((String) -> String?)? + nonisolated(unsafe) private let highlighter: SyntaxHighlighter nonisolated(unsafe) private var isRenderingTableHead = false nonisolated(unsafe) private var currentTableAlignments: [Markdown.Table.ColumnAlignment?] = [] nonisolated(unsafe) private var currentTableColumnIndex = 0 - nonisolated init(imageURLResolver: ((String) -> String?)?, linkURLResolver: ((String) -> String?)? = nil) { + nonisolated init( + imageURLResolver: ((String) -> String?)?, + linkURLResolver: ((String) -> String?)? = nil, + highlighter: SyntaxHighlighter + ) { self.imageURLResolver = imageURLResolver self.linkURLResolver = linkURLResolver + self.highlighter = highlighter } nonisolated mutating func visit(_ markup: Markup) -> String { @@ -78,7 +89,9 @@ private struct MarkdownHTMLRenderer: MarkupVisitor { } else { classAttribute = "" } - return "
\(escapeHTML(codeBlock.code))
\n" + let inner = highlighter.highlightedHTML(for: codeBlock.code, language: codeBlock.language) + ?? escapeHTML(codeBlock.code) + return "
\(inner)
\n" } nonisolated mutating func visitInlineCode(_ inlineCode: InlineCode) -> String { diff --git a/Hutch/Views/Repositories/ReadmeView.swift b/Hutch/Views/Repositories/ReadmeView.swift index b8b9622..722af8d 100644 --- a/Hutch/Views/Repositories/ReadmeView.swift +++ b/Hutch/Views/Repositories/ReadmeView.swift @@ -234,15 +234,18 @@ struct RenderedMarkupContentView: View { @State private var renderedHTML: String? private var cacheKey: String { + // Highlighted code colors are baked into the HTML, so the theme is part + // of the cache identity — light and dark must not share an entry. + let theme = colorScheme == .dark ? "dark" : "light" switch content { case .html(let html): - "html:\(readmePath ?? "custom"):\(html)" + return "html:\(readmePath ?? "custom"):\(html)" case .markdown(let text): - "markdown:\(readmePath ?? ""):\(text)" + return "markdown:\(theme):\(readmePath ?? ""):\(text)" case .org(let text): - "org:\(readmePath ?? ""):\(text)" + return "org:\(theme):\(readmePath ?? ""):\(text)" case .plainText(let text): - "plain:\(readmePath ?? ""):\(text)" + return "plain:\(readmePath ?? ""):\(text)" } } @@ -277,9 +280,11 @@ struct RenderedMarkupContentView: View { renderedHTML = cached return } + let theme = SyntaxHighlightTheme(colorScheme: colorScheme) let html = await Task.detached(priority: .userInitiated) { markdownToHTML( text, + codeTheme: theme, imageURLResolver: { source in resolveRepositoryAssetURL( source, @@ -308,9 +313,11 @@ struct RenderedMarkupContentView: View { renderedHTML = cached return } + let theme = SyntaxHighlightTheme(colorScheme: colorScheme) let html = await Task.detached(priority: .userInitiated) { orgToHTML( text, + codeTheme: theme, imageURLResolver: { source in resolveRepositoryAssetURL( source, @@ -462,9 +469,11 @@ nonisolated func processInline( nonisolated func orgToHTML( _ text: String, + codeTheme: SyntaxHighlightTheme = .light, imageURLResolver: ((String) -> String?)? = nil, linkURLResolver: ((String) -> String?)? = nil ) -> String { + let highlighter = SyntaxHighlighter(theme: codeTheme) let normalizedText = text .replacingOccurrences(of: "\r\n", with: "\n") .replacingOccurrences(of: "\r", with: "\n") @@ -496,6 +505,7 @@ nonisolated func orgToHTML( var inQuoteBlock = false var inPropertyDrawer = false var srcLanguage: String? + var srcLines: [String] = [] var inExampleBlock = false var inCenterBlock = false var inVerseBlock = false @@ -593,9 +603,14 @@ nonisolated func orgToHTML( } func closeSourceBlock() { - if srcLanguage != nil { + if let language = srcLanguage { + let code = srcLines.joined(separator: "\n") + if !code.isEmpty { + html += (highlighter.highlightedHTML(for: code, language: language) ?? escapeHTML(code)) + "\n" + } html += "\n" srcLanguage = nil + srcLines = [] closePendingBlockWrapper() } } @@ -659,7 +674,7 @@ nonisolated func orgToHTML( if trimmed.lowercased() == "#+end_src" { closeSourceBlock() } else { - html += escapeHTML(line) + "\n" + srcLines.append(line) } continue } @@ -730,6 +745,7 @@ nonisolated func orgToHTML( let classAttribute = language.map { " class=\"language-\(escapeHTMLAttribute($0))\"" } ?? "" html += "
"
             srcLanguage = language ?? ""
+            srcLines = []
             continue
         }
 
diff --git a/Hutch/Views/Repositories/SyntaxHighlighter.swift b/Hutch/Views/Repositories/SyntaxHighlighter.swift
new file mode 100644
index 0000000..5ae8f58
--- /dev/null
+++ b/Hutch/Views/Repositories/SyntaxHighlighter.swift
@@ -0,0 +1,222 @@
+import Foundation
+import Highlightr
+import SwiftUI
+import UIKit
+
+enum SyntaxHighlightTheme {
+    case light
+    case dark
+
+    init(userInterfaceStyle: UIUserInterfaceStyle) {
+        self = userInterfaceStyle == .dark ? .dark : .light
+    }
+
+    init(colorScheme: ColorScheme) {
+        self = colorScheme == .dark ? .dark : .light
+    }
+
+    /// highlight.js theme names bundled with Highlightr. Xcode-like in light,
+    /// a muted dark palette in dark, so highlighting reads as native on both.
+    var highlightrName: String {
+        switch self {
+        case .light: "xcode"
+        case .dark: "atom-one-dark"
+        }
+    }
+}
+
+/// Multi-language syntax highlighting backed by Highlightr (highlight.js).
+///
+/// Highlightr wraps a JavaScriptCore context and is not thread-safe, so each
+/// instance must stay on the thread/task that created it. Callers that fail to
+/// resolve a language — or hit an unavailable engine — get `nil` and should
+/// fall back to plain, escaped text.
+final class SyntaxHighlighter {
+    private let highlightr: Highlightr?
+    private let supportedLanguages: Set
+
+    init(theme: SyntaxHighlightTheme) {
+        let engine = Highlightr()
+        engine?.setTheme(to: theme.highlightrName)
+        highlightr = engine
+        supportedLanguages = Set(engine?.supportedLanguages() ?? [])
+    }
+
+    /// Full-document highlighted string, or `nil` when the language is unknown
+    /// or the engine is unavailable.
+    func attributedText(for code: String, language: String?, font: UIFont) -> NSAttributedString? {
+        guard let highlightr, let language = resolvedLanguage(language) else { return nil }
+        highlightr.theme.setCodeFont(font)
+        return highlightr.highlight(code, as: language, fastRender: true)
+    }
+
+    /// Inner HTML for a `` element — color-styled ``s — or `nil` to
+    /// fall back. Colors are inlined, so the caller must regenerate when the
+    /// theme changes.
+    func highlightedHTML(for code: String, language: String?) -> String? {
+        guard let attributed = attributedText(for: code, language: language, font: Self.htmlMeasurementFont) else {
+            return nil
+        }
+        return Self.html(from: attributed)
+    }
+
+    private static let htmlMeasurementFont = UIFont.monospacedSystemFont(ofSize: 12, weight: .regular)
+
+    private func resolvedLanguage(_ raw: String?) -> String? {
+        guard let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(),
+              !trimmed.isEmpty else {
+            return nil
+        }
+        let mapped = Self.languageAliases[trimmed] ?? trimmed
+        return supportedLanguages.contains(mapped) ? mapped : nil
+    }
+
+    /// Resolves a filename to a highlight.js language identifier, or `nil` when
+    /// there is no confident match (so we render plain rather than mis-highlight).
+    static func language(forFileName fileName: String) -> String? {
+        let base = (fileName as NSString).lastPathComponent.lowercased()
+        if let byName = fileNameLanguages[base] {
+            return byName
+        }
+        let ext = (fileName as NSString).pathExtension.lowercased()
+        guard !ext.isEmpty else { return nil }
+        return extensionLanguages[ext]
+    }
+
+    private static func html(from attributed: NSAttributedString) -> String {
+        var html = ""
+        let range = NSRange(location: 0, length: attributed.length)
+        attributed.enumerateAttribute(.foregroundColor, in: range, options: []) { value, subrange, _ in
+            let fragment = (attributed.string as NSString).substring(with: subrange)
+            let escaped = escapeHTML(fragment)
+            if let color = value as? UIColor, let hex = color.hexRGBString {
+                html += "\(escaped)"
+            } else {
+                html += escaped
+            }
+        }
+        return html
+    }
+
+    /// Fence tags / short names that differ from highlight.js identifiers.
+    private static let languageAliases: [String: String] = [
+        "js": "javascript",
+        "jsx": "javascript",
+        "ts": "typescript",
+        "tsx": "typescript",
+        "py": "python",
+        "rb": "ruby",
+        "sh": "bash",
+        "shell": "bash",
+        "zsh": "bash",
+        "yml": "yaml",
+        "c++": "cpp",
+        "cc": "cpp",
+        "h": "cpp",
+        "hpp": "cpp",
+        "cs": "csharp",
+        "objc": "objectivec",
+        "objective-c": "objectivec",
+        "obj-c": "objectivec",
+        "html": "xml",
+        "htm": "xml",
+        "kt": "kotlin",
+        "rs": "rust",
+        "golang": "go",
+        "md": "markdown",
+        "ps1": "powershell",
+        "yaml": "yaml"
+    ]
+
+    /// Filenames without a useful extension.
+    private static let fileNameLanguages: [String: String] = [
+        "dockerfile": "dockerfile",
+        "makefile": "makefile",
+        "gnumakefile": "makefile",
+        "cmakelists.txt": "cmake",
+        "gemfile": "ruby",
+        "rakefile": "ruby",
+        "podfile": "ruby",
+        "package.swift": "swift"
+    ]
+
+    private static let extensionLanguages: [String: String] = [
+        "swift": "swift",
+        "js": "javascript",
+        "mjs": "javascript",
+        "cjs": "javascript",
+        "jsx": "javascript",
+        "ts": "typescript",
+        "tsx": "typescript",
+        "py": "python",
+        "rb": "ruby",
+        "go": "go",
+        "rs": "rust",
+        "c": "c",
+        "h": "c",
+        "cpp": "cpp",
+        "cxx": "cpp",
+        "cc": "cpp",
+        "hpp": "cpp",
+        "hxx": "cpp",
+        "m": "objectivec",
+        "mm": "objectivec",
+        "cs": "csharp",
+        "java": "java",
+        "kt": "kotlin",
+        "kts": "kotlin",
+        "scala": "scala",
+        "php": "php",
+        "pl": "perl",
+        "pm": "perl",
+        "lua": "lua",
+        "r": "r",
+        "dart": "dart",
+        "groovy": "groovy",
+        "hs": "haskell",
+        "ex": "elixir",
+        "exs": "elixir",
+        "erl": "erlang",
+        "clj": "clojure",
+        "sh": "bash",
+        "bash": "bash",
+        "zsh": "bash",
+        "fish": "bash",
+        "ps1": "powershell",
+        "json": "json",
+        "yaml": "yaml",
+        "yml": "yaml",
+        "toml": "ini",
+        "ini": "ini",
+        "cfg": "ini",
+        "conf": "ini",
+        "xml": "xml",
+        "html": "xml",
+        "htm": "xml",
+        "css": "css",
+        "scss": "scss",
+        "sass": "scss",
+        "less": "less",
+        "sql": "sql",
+        "md": "markdown",
+        "markdown": "markdown",
+        "diff": "diff",
+        "patch": "diff",
+        "cmake": "cmake",
+        "gradle": "groovy",
+        "vim": "vim"
+    ]
+}
+
+private extension UIColor {
+    /// `#rrggbb` for HTML inline styles, or `nil` if the color isn't RGB-convertible.
+    var hexRGBString: String? {
+        var red: CGFloat = 0
+        var green: CGFloat = 0
+        var blue: CGFloat = 0
+        var alpha: CGFloat = 0
+        guard getRed(&red, green: &green, blue: &blue, alpha: &alpha) else { return nil }
+        let clamp: (CGFloat) -> Int = { Int((max(0, min(1, $0)) * 255).rounded()) }
+        return String(format: "#%02x%02x%02x", clamp(red), clamp(green), clamp(blue))
+    }
+}
-- 
cgit v1.2.3