krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

b410fd16b76146f4116538c5b82330818dd47da9

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-08-07T07:45:31Z

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.xcodeproj/project.pbxproj                    |  17 ++
 .../xcshareddata/swiftpm/Package.resolved          |  11 +-
 Hutch/Views/Repositories/FileTreeView.swift        |  49 +++--
 .../Views/Repositories/MarkdownHTMLRenderer.swift  |  19 +-
 Hutch/Views/Repositories/ReadmeView.swift          |  28 ++-
 Hutch/Views/Repositories/SyntaxHighlighter.swift   | 222 +++++++++++++++++++++
 Hutch/Views/Tickets/TicketDetailView.swift         |  12 +-
 HutchTests/SyntaxHighlighterTests.swift            |  48 +++++
 8 files changed, 377 insertions(+), 29 deletions(-)

diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index b7ddbe9..b6c7301 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -17,6 +17,7 @@
 		8B2FA9D12F7E211800E609E2 /* paste.json in Resources */ = {isa = PBXBuildFile; fileRef = 8B2FA9C92F7E211800E609E2 /* paste.json */; };
 		8B2FA9D22F7E211800E609E2 /* todo.json in Resources */ = {isa = PBXBuildFile; fileRef = 8B2FA9CA2F7E211800E609E2 /* todo.json */; };
 		8B2FA9D32F7E211800E609E2 /* lists.json in Resources */ = {isa = PBXBuildFile; fileRef = 8B2FA9C72F7E211800E609E2 /* lists.json */; };
+		8BAA000000000000000000B0 /* Highlightr in Frameworks */ = {isa = PBXBuildFile; productRef = 8BAA000000000000000000B2 /* Highlightr */; };
 		8BE082012F80000100000001 /* HutchWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
 		8BE083012F81000100000001 /* HutchSafariExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8BE083032F81000100000001 /* HutchSafariExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
 		8BF100032F9A000100000001 /* Markdown in Frameworks */ = {isa = PBXBuildFile; productRef = 8BF100022F9A000100000001 /* Markdown */; };
@@ -148,6 +149,7 @@
 			buildActionMask = 2147483647;
 			files = (
 				8BF100032F9A000100000001 /* Markdown in Frameworks */,
+				8BAA000000000000000000B0 /* Highlightr in Frameworks */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};
@@ -252,6 +254,7 @@
 			name = Hutch;
 			packageProductDependencies = (
 				8BF100022F9A000100000001 /* Markdown */,
+				8BAA000000000000000000B2 /* Highlightr */,
 			);
 			productName = Hutch;
 			productReference = 8B4B28D12F6704280045FA19 /* Hutch.app */;
@@ -358,6 +361,7 @@
 			minimizedProjectReferenceProxies = 1;
 			packageReferences = (
 				8BF100012F9A000100000001 /* XCRemoteSwiftPackageReference "swift-markdown" */,
+				8BAA000000000000000000B3 /* XCRemoteSwiftPackageReference "Highlightr" */,
 			);
 			preferredProjectObjectVersion = 77;
 			productRefGroup = 8B4B28D22F6704280045FA19 /* Products */;
@@ -871,6 +875,14 @@
 /* End XCConfigurationList section */
 
 /* Begin XCRemoteSwiftPackageReference section */
+		8BAA000000000000000000B3 /* XCRemoteSwiftPackageReference "Highlightr" */ = {
+			isa = XCRemoteSwiftPackageReference;
+			repositoryURL = "https://github.com/raspu/Highlightr";
+			requirement = {
+				kind = upToNextMajorVersion;
+				minimumVersion = 2.1.0;
+			};
+		};
 		8BF100012F9A000100000001 /* XCRemoteSwiftPackageReference "swift-markdown" */ = {
 			isa = XCRemoteSwiftPackageReference;
 			repositoryURL = "https://github.com/apple/swift-markdown";
@@ -882,6 +894,11 @@
 /* End XCRemoteSwiftPackageReference section */
 
 /* Begin XCSwiftPackageProductDependency section */
+		8BAA000000000000000000B2 /* Highlightr */ = {
+			isa = XCSwiftPackageProductDependency;
+			package = 8BAA000000000000000000B3 /* XCRemoteSwiftPackageReference "Highlightr" */;
+			productName = Highlightr;
+		};
 		8BF100022F9A000100000001 /* Markdown */ = {
 			isa = XCSwiftPackageProductDependency;
 			package = 8BF100012F9A000100000001 /* XCRemoteSwiftPackageReference "swift-markdown" */;
diff --git a/Hutch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Hutch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
index 417e4d7..a268436 100644
--- a/Hutch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/Hutch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -1,6 +1,15 @@
 {
-  "originHash" : "65542d8a5fbbad8dd7d07c8901c1cc48b3aba12cef8da1f062bd28d9b3a6ba50",
+  "originHash" : "4554417ca2693020116bce7cbda415a5e51ada6b4cc4a8b39f7f43a9644bb5e5",
   "pins" : [
+    {
+      "identity" : "highlightr",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/raspu/Highlightr",
+      "state" : {
+        "revision" : "05e7fcc63b33925cd0c1faaa205cdd5681e7bbef",
+        "version" : "2.3.0"
+      }
+    },
     {
       "identity" : "swift-cmark",
       "kind" : "remoteSourceControl",
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 "<pre><code\(classAttribute)>\(escapeHTML(codeBlock.code))</code></pre>\n"
+        let inner = highlighter.highlightedHTML(for: codeBlock.code, language: codeBlock.language)
+            ?? escapeHTML(codeBlock.code)
+        return "<pre><code\(classAttribute)>\(inner)</code></pre>\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 += "</code></pre>\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 += "<pre><code\(classAttribute)>"
             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<String>
+
+    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 `<code>` element — color-styled `<span>`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 += "<span style=\"color:\(hex)\">\(escaped)</span>"
+            } 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))
+    }
+}
diff --git a/Hutch/Views/Tickets/TicketDetailView.swift b/Hutch/Views/Tickets/TicketDetailView.swift
index 6339776..1ace6b6 100644
--- a/Hutch/Views/Tickets/TicketDetailView.swift
+++ b/Hutch/Views/Tickets/TicketDetailView.swift
@@ -553,14 +553,22 @@ private struct MarkdownContentView: View {
                     .frame(minHeight: 80)
             }
         }
-        .task(id: markdown) {
+        .task(id: MarkdownRenderKey(markdown: markdown, isDark: colorScheme == .dark)) {
+            let theme = SyntaxHighlightTheme(colorScheme: colorScheme)
             let html = await Task.detached(priority: .userInitiated) {
-                markdownToHTML(markdown)
+                markdownToHTML(markdown, codeTheme: theme)
             }.value
             guard !Task.isCancelled else { return }
             renderedHTML = html
         }
     }
+
+    /// Re-renders when the markdown or the color scheme changes; highlighted
+    /// code colors are baked into the HTML, so both must key the render.
+    private struct MarkdownRenderKey: Equatable {
+        let markdown: String
+        let isDark: Bool
+    }
 }
 
 // MARK: - Event Row
diff --git a/HutchTests/SyntaxHighlighterTests.swift b/HutchTests/SyntaxHighlighterTests.swift
new file mode 100644
index 0000000..258e81e
--- /dev/null
+++ b/HutchTests/SyntaxHighlighterTests.swift
@@ -0,0 +1,48 @@
+import Foundation
+import Testing
+import UIKit
+@testable import Hutch
+
+struct SyntaxHighlighterTests {
+    @Test
+    func mapsFileNamesToLanguages() {
+        #expect(SyntaxHighlighter.language(forFileName: "Sources/App/Main.swift") == "swift")
+        #expect(SyntaxHighlighter.language(forFileName: "server.py") == "python")
+        #expect(SyntaxHighlighter.language(forFileName: "index.tsx") == "typescript")
+        #expect(SyntaxHighlighter.language(forFileName: "Dockerfile") == "dockerfile")
+        #expect(SyntaxHighlighter.language(forFileName: "Makefile") == "makefile")
+    }
+
+    @Test
+    func returnsNilForUnknownExtensions() {
+        #expect(SyntaxHighlighter.language(forFileName: "notes.unknownext") == nil)
+        #expect(SyntaxHighlighter.language(forFileName: "LICENSE") == nil)
+    }
+
+    @Test
+    func highlightsKnownLanguageAsColoredSpans() {
+        let highlighter = SyntaxHighlighter(theme: .light)
+        let html = highlighter.highlightedHTML(for: "let answer = 42", language: "swift")
+
+        let unwrapped = try? #require(html)
+        #expect(unwrapped?.contains("<span style=\"color:") == true)
+        #expect(unwrapped?.contains("answer") == true)
+    }
+
+    @Test
+    func fallsBackToNilForUnsupportedLanguage() {
+        let highlighter = SyntaxHighlighter(theme: .dark)
+        #expect(highlighter.highlightedHTML(for: "plain text", language: nil) == nil)
+        #expect(highlighter.highlightedHTML(for: "plain text", language: "totally-not-a-language") == nil)
+    }
+
+    @Test
+    func producesAttributedTextForNativeViewer() {
+        let highlighter = SyntaxHighlighter(theme: .light)
+        let font = UIFont.monospacedSystemFont(ofSize: 12, weight: .regular)
+        let attributed = highlighter.attributedText(for: "print(\"hi\")", language: "python", font: font)
+
+        let unwrapped = try? #require(attributed)
+        #expect(unwrapped?.string.contains("print") == true)
+    }
+}