diff options
| author | Christian Cleberg <[email protected]> | 2026-08-07 02:45:31 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-08-07 02:45:31 -0500 |
| commit | b410fd16b76146f4116538c5b82330818dd47da9 (patch) | |
| tree | 649488b2e5d3d901db8c792c0dc215c835fa9d38 /Hutch/Views/Repositories | |
| parent | 0a3e55c15cfb9ce1bd3df0c7b352760bc2ee33c3 (diff) | |
| download | hutch-b410fd16b76146f4116538c5b82330818dd47da9.tar.gz hutch-b410fd16b76146f4116538c5b82330818dd47da9.tar.bz2 hutch-b410fd16b76146f4116538c5b82330818dd47da9.zip | |
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.
Diffstat (limited to 'Hutch/Views/Repositories')
| -rw-r--r-- | Hutch/Views/Repositories/FileTreeView.swift | 49 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/MarkdownHTMLRenderer.swift | 19 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/ReadmeView.swift | 28 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/SyntaxHighlighter.swift | 222 |
4 files changed, 292 insertions, 26 deletions
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)) + } +} |
