Commit 45599c2892

45599c2892a2d788e3876d8372c82e2d69db40ba

parent: 2440b74a29

Unsigned

cmc <hello@cleberg.net> · 2026-08-07 07:45 UTC

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.

Layout: unified · split

Hutch.xcodeproj/project.pbxproj +17
@@ -17,6 +17,7 @@
17 8B2FA9D12F7E211800E609E2 /* paste.json in Resources */ = {isa = PBXBuildFile; fileRef = 8B2FA9C92F7E211800E609E2 /* paste.json */; }; 17 8B2FA9D12F7E211800E609E2 /* paste.json in Resources */ = {isa = PBXBuildFile; fileRef = 8B2FA9C92F7E211800E609E2 /* paste.json */; };
18 8B2FA9D22F7E211800E609E2 /* todo.json in Resources */ = {isa = PBXBuildFile; fileRef = 8B2FA9CA2F7E211800E609E2 /* todo.json */; }; 18 8B2FA9D22F7E211800E609E2 /* todo.json in Resources */ = {isa = PBXBuildFile; fileRef = 8B2FA9CA2F7E211800E609E2 /* todo.json */; };
19 8B2FA9D32F7E211800E609E2 /* lists.json in Resources */ = {isa = PBXBuildFile; fileRef = 8B2FA9C72F7E211800E609E2 /* lists.json */; }; 19 8B2FA9D32F7E211800E609E2 /* lists.json in Resources */ = {isa = PBXBuildFile; fileRef = 8B2FA9C72F7E211800E609E2 /* lists.json */; };
20 8BAA000000000000000000B0 /* Highlightr in Frameworks */ = {isa = PBXBuildFile; productRef = 8BAA000000000000000000B2 /* Highlightr */; };
20 8BE082012F80000100000001 /* HutchWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 21 8BE082012F80000100000001 /* HutchWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
21 8BE083012F81000100000001 /* HutchSafariExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8BE083032F81000100000001 /* HutchSafariExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 22 8BE083012F81000100000001 /* HutchSafariExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8BE083032F81000100000001 /* HutchSafariExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
22 8BF100032F9A000100000001 /* Markdown in Frameworks */ = {isa = PBXBuildFile; productRef = 8BF100022F9A000100000001 /* Markdown */; }; 23 8BF100032F9A000100000001 /* Markdown in Frameworks */ = {isa = PBXBuildFile; productRef = 8BF100022F9A000100000001 /* Markdown */; };
@@ -148,6 +149,7 @@
148 buildActionMask = 2147483647; 149 buildActionMask = 2147483647;
149 files = ( 150 files = (
150 8BF100032F9A000100000001 /* Markdown in Frameworks */, 151 8BF100032F9A000100000001 /* Markdown in Frameworks */,
152 8BAA000000000000000000B0 /* Highlightr in Frameworks */,
151 ); 153 );
152 runOnlyForDeploymentPostprocessing = 0; 154 runOnlyForDeploymentPostprocessing = 0;
153 }; 155 };
@@ -252,6 +254,7 @@
252 name = Hutch; 254 name = Hutch;
253 packageProductDependencies = ( 255 packageProductDependencies = (
254 8BF100022F9A000100000001 /* Markdown */, 256 8BF100022F9A000100000001 /* Markdown */,
257 8BAA000000000000000000B2 /* Highlightr */,
255 ); 258 );
256 productName = Hutch; 259 productName = Hutch;
257 productReference = 8B4B28D12F6704280045FA19 /* Hutch.app */; 260 productReference = 8B4B28D12F6704280045FA19 /* Hutch.app */;
@@ -358,6 +361,7 @@
358 minimizedProjectReferenceProxies = 1; 361 minimizedProjectReferenceProxies = 1;
359 packageReferences = ( 362 packageReferences = (
360 8BF100012F9A000100000001 /* XCRemoteSwiftPackageReference "swift-markdown" */, 363 8BF100012F9A000100000001 /* XCRemoteSwiftPackageReference "swift-markdown" */,
364 8BAA000000000000000000B3 /* XCRemoteSwiftPackageReference "Highlightr" */,
361 ); 365 );
362 preferredProjectObjectVersion = 77; 366 preferredProjectObjectVersion = 77;
363 productRefGroup = 8B4B28D22F6704280045FA19 /* Products */; 367 productRefGroup = 8B4B28D22F6704280045FA19 /* Products */;
@@ -871,6 +875,14 @@
871/* End XCConfigurationList section */ 875/* End XCConfigurationList section */
872 876
873/* Begin XCRemoteSwiftPackageReference section */ 877/* Begin XCRemoteSwiftPackageReference section */
878 8BAA000000000000000000B3 /* XCRemoteSwiftPackageReference "Highlightr" */ = {
879 isa = XCRemoteSwiftPackageReference;
880 repositoryURL = "https://github.com/raspu/Highlightr";
881 requirement = {
882 kind = upToNextMajorVersion;
883 minimumVersion = 2.1.0;
884 };
885 };
874 8BF100012F9A000100000001 /* XCRemoteSwiftPackageReference "swift-markdown" */ = { 886 8BF100012F9A000100000001 /* XCRemoteSwiftPackageReference "swift-markdown" */ = {
875 isa = XCRemoteSwiftPackageReference; 887 isa = XCRemoteSwiftPackageReference;
876 repositoryURL = "https://github.com/apple/swift-markdown"; 888 repositoryURL = "https://github.com/apple/swift-markdown";
@@ -882,6 +894,11 @@
882/* End XCRemoteSwiftPackageReference section */ 894/* End XCRemoteSwiftPackageReference section */
883 895
884/* Begin XCSwiftPackageProductDependency section */ 896/* Begin XCSwiftPackageProductDependency section */
897 8BAA000000000000000000B2 /* Highlightr */ = {
898 isa = XCSwiftPackageProductDependency;
899 package = 8BAA000000000000000000B3 /* XCRemoteSwiftPackageReference "Highlightr" */;
900 productName = Highlightr;
901 };
885 8BF100022F9A000100000001 /* Markdown */ = { 902 8BF100022F9A000100000001 /* Markdown */ = {
886 isa = XCSwiftPackageProductDependency; 903 isa = XCSwiftPackageProductDependency;
887 package = 8BF100012F9A000100000001 /* XCRemoteSwiftPackageReference "swift-markdown" */; 904 package = 8BF100012F9A000100000001 /* XCRemoteSwiftPackageReference "swift-markdown" */;
Hutch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +10 −1
@@ -1,6 +1,15 @@
1{ 1{
2 "originHash" : "65542d8a5fbbad8dd7d07c8901c1cc48b3aba12cef8da1f062bd28d9b3a6ba50", 2 "originHash" : "4554417ca2693020116bce7cbda415a5e51ada6b4cc4a8b39f7f43a9644bb5e5",
3 "pins" : [ 3 "pins" : [
4 {
5 "identity" : "highlightr",
6 "kind" : "remoteSourceControl",
7 "location" : "https://github.com/raspu/Highlightr",
8 "state" : {
9 "revision" : "05e7fcc63b33925cd0c1faaa205cdd5681e7bbef",
10 "version" : "2.3.0"
11 }
12 },
4 { 13 {
5 "identity" : "swift-cmark", 14 "identity" : "swift-cmark",
6 "kind" : "remoteSourceControl", 15 "kind" : "remoteSourceControl",
Hutch/Views/Repositories/FileTreeView.swift +32 −17
@@ -456,12 +456,19 @@ final class CodeFileUIView: UIView {
456 private var wrapLines: Bool 456 private var wrapLines: Bool
457 private var needsLineLayoutUpdate = true 457 private var needsLineLayoutUpdate = true
458 private var lastMeasuredCodeWidth: CGFloat = 0 458 private var lastMeasuredCodeWidth: CGFloat = 0
459 private var syntaxHighlighter: SyntaxHighlighter?
460 private var syntaxHighlighterTheme: SyntaxHighlightTheme?
459 461
460 init(text: String, fileName: String, font: UIFont, wrapLines: Bool) { 462 init(text: String, fileName: String, font: UIFont, wrapLines: Bool) {
461 self.currentFont = font 463 self.currentFont = font
462 self.wrapLines = wrapLines 464 self.wrapLines = wrapLines
463 super.init(frame: .zero) 465 super.init(frame: .zero)
464 setupViews() 466 setupViews()
467 registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (view: CodeFileUIView, _: UITraitCollection) in
468 view.rebuildRows()
469 view.needsLineLayoutUpdate = true
470 view.setNeedsLayout()
471 }
465 updateContent(text: text, fileName: fileName, font: font, wrapLines: wrapLines) 472 updateContent(text: text, fileName: fileName, font: font, wrapLines: wrapLines)
466 } 473 }
467 474
@@ -583,11 +590,7 @@ final class CodeFileUIView: UIView {
583 } 590 }
584 rows.removeAll() 591 rows.removeAll()
585 592
586 let attributedText = CodeSyntaxHighlighter.attributedText( 593 let attributedText = highlightedAttributedText()
587 for: currentText,
588 fileName: currentFileName,
589 font: currentFont
590 )
591 let lines = makeLines(from: attributedText, font: currentFont) 594 let lines = makeLines(from: attributedText, font: currentFont)
592 595
593 gutterWidthConstraint?.constant = Self.gutterWidth(lineCount: lines.count, font: currentFont) 596 gutterWidthConstraint?.constant = Self.gutterWidth(lineCount: lines.count, font: currentFont)
@@ -600,6 +603,30 @@ final class CodeFileUIView: UIView {
600 } 603 }
601 } 604 }
602 605
606 /// Highlighting cap: above this size, skip tokenizing to keep the main
607 /// thread responsive and render plain, label-colored text instead.
608 private static let maxHighlightableLength = 100_000
609
610 private func highlightedAttributedText() -> NSAttributedString {
611 let plain = NSAttributedString(
612 string: currentText,
613 attributes: [.font: currentFont, .foregroundColor: UIColor.label]
614 )
615
616 guard currentText.count <= Self.maxHighlightableLength,
617 let language = SyntaxHighlighter.language(forFileName: currentFileName) else {
618 return plain
619 }
620
621 let theme = SyntaxHighlightTheme(userInterfaceStyle: traitCollection.userInterfaceStyle)
622 if syntaxHighlighterTheme != theme || syntaxHighlighter == nil {
623 syntaxHighlighter = SyntaxHighlighter(theme: theme)
624 syntaxHighlighterTheme = theme
625 }
626
627 return syntaxHighlighter?.attributedText(for: currentText, language: language, font: currentFont) ?? plain
628 }
629
603 private func makeRow(for line: CodeFileLineData) -> LineRow { 630 private func makeRow(for line: CodeFileLineData) -> LineRow {
604 let row = LineRow() 631 let row = LineRow()
605 632
@@ -737,18 +764,6 @@ private struct CodeFileLineData {
737 let text: NSAttributedString 764 let text: NSAttributedString
738} 765}
739 766
740private enum CodeSyntaxHighlighter {
741 static func attributedText(for text: String, fileName _: String, font: UIFont) -> NSAttributedString {
742 NSAttributedString(
743 string: text,
744 attributes: [
745 .font: font,
746 .foregroundColor: UIColor.label
747 ]
748 )
749 }
750}
751
752// MARK: - Tree Entry Row 767// MARK: - Tree Entry Row
753 768
754private struct TreeEntryRow: View { 769private struct TreeEntryRow: View {
Hutch/Views/Repositories/MarkdownHTMLRenderer.swift +16 −3
@@ -2,11 +2,16 @@ import Markdown
2 2
3nonisolated func markdownToHTML( 3nonisolated func markdownToHTML(
4 _ text: String, 4 _ text: String,
5 codeTheme: SyntaxHighlightTheme = .light,
5 imageURLResolver: ((String) -> String?)? = nil, 6 imageURLResolver: ((String) -> String?)? = nil,
6 linkURLResolver: ((String) -> String?)? = nil 7 linkURLResolver: ((String) -> String?)? = nil
7) -> String { 8) -> String {
8 let document = Document(parsing: text) 9 let document = Document(parsing: text)
9 var renderer = MarkdownHTMLRenderer(imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) 10 var renderer = MarkdownHTMLRenderer(
11 imageURLResolver: imageURLResolver,
12 linkURLResolver: linkURLResolver,
13 highlighter: SyntaxHighlighter(theme: codeTheme)
14 )
10 return renderer.visit(document) 15 return renderer.visit(document)
11} 16}
12 17
@@ -15,13 +20,19 @@ private struct MarkdownHTMLRenderer: MarkupVisitor {
15 20
16 nonisolated(unsafe) let imageURLResolver: ((String) -> String?)? 21 nonisolated(unsafe) let imageURLResolver: ((String) -> String?)?
17 nonisolated(unsafe) let linkURLResolver: ((String) -> String?)? 22 nonisolated(unsafe) let linkURLResolver: ((String) -> String?)?
23 nonisolated(unsafe) private let highlighter: SyntaxHighlighter
18 nonisolated(unsafe) private var isRenderingTableHead = false 24 nonisolated(unsafe) private var isRenderingTableHead = false
19 nonisolated(unsafe) private var currentTableAlignments: [Markdown.Table.ColumnAlignment?] = [] 25 nonisolated(unsafe) private var currentTableAlignments: [Markdown.Table.ColumnAlignment?] = []
20 nonisolated(unsafe) private var currentTableColumnIndex = 0 26 nonisolated(unsafe) private var currentTableColumnIndex = 0
21 27
22 nonisolated init(imageURLResolver: ((String) -> String?)?, linkURLResolver: ((String) -> String?)? = nil) { 28 nonisolated init(
29 imageURLResolver: ((String) -> String?)?,
30 linkURLResolver: ((String) -> String?)? = nil,
31 highlighter: SyntaxHighlighter
32 ) {
23 self.imageURLResolver = imageURLResolver 33 self.imageURLResolver = imageURLResolver
24 self.linkURLResolver = linkURLResolver 34 self.linkURLResolver = linkURLResolver
35 self.highlighter = highlighter
25 } 36 }
26 37
27 nonisolated mutating func visit(_ markup: Markup) -> String { 38 nonisolated mutating func visit(_ markup: Markup) -> String {
@@ -78,7 +89,9 @@ private struct MarkdownHTMLRenderer: MarkupVisitor {
78 } else { 89 } else {
79 classAttribute = "" 90 classAttribute = ""
80 } 91 }
81 return "<pre><code\(classAttribute)>\(escapeHTML(codeBlock.code))</code></pre>\n" 92 let inner = highlighter.highlightedHTML(for: codeBlock.code, language: codeBlock.language)
93 ?? escapeHTML(codeBlock.code)
94 return "<pre><code\(classAttribute)>\(inner)</code></pre>\n"
82 } 95 }
83 96
84 nonisolated mutating func visitInlineCode(_ inlineCode: InlineCode) -> String { 97 nonisolated mutating func visitInlineCode(_ inlineCode: InlineCode) -> String {
Hutch/Views/Repositories/ReadmeView.swift +22 −6
@@ -234,15 +234,18 @@ struct RenderedMarkupContentView: View {
234 @State private var renderedHTML: String? 234 @State private var renderedHTML: String?
235 235
236 private var cacheKey: String { 236 private var cacheKey: String {
237 // Highlighted code colors are baked into the HTML, so the theme is part
238 // of the cache identity — light and dark must not share an entry.
239 let theme = colorScheme == .dark ? "dark" : "light"
237 switch content { 240 switch content {
238 case .html(let html): 241 case .html(let html):
239 "html:\(readmePath ?? "custom"):\(html)" 242 return "html:\(readmePath ?? "custom"):\(html)"
240 case .markdown(let text): 243 case .markdown(let text):
241 "markdown:\(readmePath ?? ""):\(text)" 244 return "markdown:\(theme):\(readmePath ?? ""):\(text)"
242 case .org(let text): 245 case .org(let text):
243 "org:\(readmePath ?? ""):\(text)" 246 return "org:\(theme):\(readmePath ?? ""):\(text)"
244 case .plainText(let text): 247 case .plainText(let text):
245 "plain:\(readmePath ?? ""):\(text)" 248 return "plain:\(readmePath ?? ""):\(text)"
246 } 249 }
247 } 250 }
248 251
@@ -277,9 +280,11 @@ struct RenderedMarkupContentView: View {
277 renderedHTML = cached 280 renderedHTML = cached
278 return 281 return
279 } 282 }
283 let theme = SyntaxHighlightTheme(colorScheme: colorScheme)
280 let html = await Task.detached(priority: .userInitiated) { 284 let html = await Task.detached(priority: .userInitiated) {
281 markdownToHTML( 285 markdownToHTML(
282 text, 286 text,
287 codeTheme: theme,
283 imageURLResolver: { source in 288 imageURLResolver: { source in
284 resolveRepositoryAssetURL( 289 resolveRepositoryAssetURL(
285 source, 290 source,
@@ -308,9 +313,11 @@ struct RenderedMarkupContentView: View {
308 renderedHTML = cached 313 renderedHTML = cached
309 return 314 return
310 } 315 }
316 let theme = SyntaxHighlightTheme(colorScheme: colorScheme)
311 let html = await Task.detached(priority: .userInitiated) { 317 let html = await Task.detached(priority: .userInitiated) {
312 orgToHTML( 318 orgToHTML(
313 text, 319 text,
320 codeTheme: theme,
314 imageURLResolver: { source in 321 imageURLResolver: { source in
315 resolveRepositoryAssetURL( 322 resolveRepositoryAssetURL(
316 source, 323 source,
@@ -462,9 +469,11 @@ nonisolated func processInline(
462 469
463nonisolated func orgToHTML( 470nonisolated func orgToHTML(
464 _ text: String, 471 _ text: String,
472 codeTheme: SyntaxHighlightTheme = .light,
465 imageURLResolver: ((String) -> String?)? = nil, 473 imageURLResolver: ((String) -> String?)? = nil,
466 linkURLResolver: ((String) -> String?)? = nil 474 linkURLResolver: ((String) -> String?)? = nil
467) -> String { 475) -> String {
476 let highlighter = SyntaxHighlighter(theme: codeTheme)
468 let normalizedText = text 477 let normalizedText = text
469 .replacingOccurrences(of: "\r\n", with: "\n") 478 .replacingOccurrences(of: "\r\n", with: "\n")
470 .replacingOccurrences(of: "\r", with: "\n") 479 .replacingOccurrences(of: "\r", with: "\n")
@@ -496,6 +505,7 @@ nonisolated func orgToHTML(
496 var inQuoteBlock = false 505 var inQuoteBlock = false
497 var inPropertyDrawer = false 506 var inPropertyDrawer = false
498 var srcLanguage: String? 507 var srcLanguage: String?
508 var srcLines: [String] = []
499 var inExampleBlock = false 509 var inExampleBlock = false
500 var inCenterBlock = false 510 var inCenterBlock = false
501 var inVerseBlock = false 511 var inVerseBlock = false
@@ -593,9 +603,14 @@ nonisolated func orgToHTML(
593 } 603 }
594 604
595 func closeSourceBlock() { 605 func closeSourceBlock() {
596 if srcLanguage != nil { 606 if let language = srcLanguage {
607 let code = srcLines.joined(separator: "\n")
608 if !code.isEmpty {
609 html += (highlighter.highlightedHTML(for: code, language: language) ?? escapeHTML(code)) + "\n"
610 }
597 html += "</code></pre>\n" 611 html += "</code></pre>\n"
598 srcLanguage = nil 612 srcLanguage = nil
613 srcLines = []
599 closePendingBlockWrapper() 614 closePendingBlockWrapper()
600 } 615 }
601 } 616 }
@@ -659,7 +674,7 @@ nonisolated func orgToHTML(
659 if trimmed.lowercased() == "#+end_src" { 674 if trimmed.lowercased() == "#+end_src" {
660 closeSourceBlock() 675 closeSourceBlock()
661 } else { 676 } else {
662 html += escapeHTML(line) + "\n" 677 srcLines.append(line)
663 } 678 }
664 continue 679 continue
665 } 680 }
@@ -730,6 +745,7 @@ nonisolated func orgToHTML(
730 let classAttribute = language.map { " class=\"language-\(escapeHTMLAttribute($0))\"" } ?? "" 745 let classAttribute = language.map { " class=\"language-\(escapeHTMLAttribute($0))\"" } ?? ""
731 html += "<pre><code\(classAttribute)>" 746 html += "<pre><code\(classAttribute)>"
732 srcLanguage = language ?? "" 747 srcLanguage = language ?? ""
748 srcLines = []
733 continue 749 continue
734 } 750 }
735 751
Hutch/Views/Repositories/SyntaxHighlighter.swift added +222
@@ -0,0 +1,222 @@
1import Foundation
2import Highlightr
3import SwiftUI
4import UIKit
5
6enum SyntaxHighlightTheme {
7 case light
8 case dark
9
10 init(userInterfaceStyle: UIUserInterfaceStyle) {
11 self = userInterfaceStyle == .dark ? .dark : .light
12 }
13
14 init(colorScheme: ColorScheme) {
15 self = colorScheme == .dark ? .dark : .light
16 }
17
18 /// highlight.js theme names bundled with Highlightr. Xcode-like in light,
19 /// a muted dark palette in dark, so highlighting reads as native on both.
20 var highlightrName: String {
21 switch self {
22 case .light: "xcode"
23 case .dark: "atom-one-dark"
24 }
25 }
26}
27
28/// Multi-language syntax highlighting backed by Highlightr (highlight.js).
29///
30/// Highlightr wraps a JavaScriptCore context and is not thread-safe, so each
31/// instance must stay on the thread/task that created it. Callers that fail to
32/// resolve a language — or hit an unavailable engine — get `nil` and should
33/// fall back to plain, escaped text.
34final class SyntaxHighlighter {
35 private let highlightr: Highlightr?
36 private let supportedLanguages: Set<String>
37
38 init(theme: SyntaxHighlightTheme) {
39 let engine = Highlightr()
40 engine?.setTheme(to: theme.highlightrName)
41 highlightr = engine
42 supportedLanguages = Set(engine?.supportedLanguages() ?? [])
43 }
44
45 /// Full-document highlighted string, or `nil` when the language is unknown
46 /// or the engine is unavailable.
47 func attributedText(for code: String, language: String?, font: UIFont) -> NSAttributedString? {
48 guard let highlightr, let language = resolvedLanguage(language) else { return nil }
49 highlightr.theme.setCodeFont(font)
50 return highlightr.highlight(code, as: language, fastRender: true)
51 }
52
53 /// Inner HTML for a `<code>` element — color-styled `<span>`s — or `nil` to
54 /// fall back. Colors are inlined, so the caller must regenerate when the
55 /// theme changes.
56 func highlightedHTML(for code: String, language: String?) -> String? {
57 guard let attributed = attributedText(for: code, language: language, font: Self.htmlMeasurementFont) else {
58 return nil
59 }
60 return Self.html(from: attributed)
61 }
62
63 private static let htmlMeasurementFont = UIFont.monospacedSystemFont(ofSize: 12, weight: .regular)
64
65 private func resolvedLanguage(_ raw: String?) -> String? {
66 guard let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(),
67 !trimmed.isEmpty else {
68 return nil
69 }
70 let mapped = Self.languageAliases[trimmed] ?? trimmed
71 return supportedLanguages.contains(mapped) ? mapped : nil
72 }
73
74 /// Resolves a filename to a highlight.js language identifier, or `nil` when
75 /// there is no confident match (so we render plain rather than mis-highlight).
76 static func language(forFileName fileName: String) -> String? {
77 let base = (fileName as NSString).lastPathComponent.lowercased()
78 if let byName = fileNameLanguages[base] {
79 return byName
80 }
81 let ext = (fileName as NSString).pathExtension.lowercased()
82 guard !ext.isEmpty else { return nil }
83 return extensionLanguages[ext]
84 }
85
86 private static func html(from attributed: NSAttributedString) -> String {
87 var html = ""
88 let range = NSRange(location: 0, length: attributed.length)
89 attributed.enumerateAttribute(.foregroundColor, in: range, options: []) { value, subrange, _ in
90 let fragment = (attributed.string as NSString).substring(with: subrange)
91 let escaped = escapeHTML(fragment)
92 if let color = value as? UIColor, let hex = color.hexRGBString {
93 html += "<span style=\"color:\(hex)\">\(escaped)</span>"
94 } else {
95 html += escaped
96 }
97 }
98 return html
99 }
100
101 /// Fence tags / short names that differ from highlight.js identifiers.
102 private static let languageAliases: [String: String] = [
103 "js": "javascript",
104 "jsx": "javascript",
105 "ts": "typescript",
106 "tsx": "typescript",
107 "py": "python",
108 "rb": "ruby",
109 "sh": "bash",
110 "shell": "bash",
111 "zsh": "bash",
112 "yml": "yaml",
113 "c++": "cpp",
114 "cc": "cpp",
115 "h": "cpp",
116 "hpp": "cpp",
117 "cs": "csharp",
118 "objc": "objectivec",
119 "objective-c": "objectivec",
120 "obj-c": "objectivec",
121 "html": "xml",
122 "htm": "xml",
123 "kt": "kotlin",
124 "rs": "rust",
125 "golang": "go",
126 "md": "markdown",
127 "ps1": "powershell",
128 "yaml": "yaml"
129 ]
130
131 /// Filenames without a useful extension.
132 private static let fileNameLanguages: [String: String] = [
133 "dockerfile": "dockerfile",
134 "makefile": "makefile",
135 "gnumakefile": "makefile",
136 "cmakelists.txt": "cmake",
137 "gemfile": "ruby",
138 "rakefile": "ruby",
139 "podfile": "ruby",
140 "package.swift": "swift"
141 ]
142
143 private static let extensionLanguages: [String: String] = [
144 "swift": "swift",
145 "js": "javascript",
146 "mjs": "javascript",
147 "cjs": "javascript",
148 "jsx": "javascript",
149 "ts": "typescript",
150 "tsx": "typescript",
151 "py": "python",
152 "rb": "ruby",
153 "go": "go",
154 "rs": "rust",
155 "c": "c",
156 "h": "c",
157 "cpp": "cpp",
158 "cxx": "cpp",
159 "cc": "cpp",
160 "hpp": "cpp",
161 "hxx": "cpp",
162 "m": "objectivec",
163 "mm": "objectivec",
164 "cs": "csharp",
165 "java": "java",
166 "kt": "kotlin",
167 "kts": "kotlin",
168 "scala": "scala",
169 "php": "php",
170 "pl": "perl",
171 "pm": "perl",
172 "lua": "lua",
173 "r": "r",
174 "dart": "dart",
175 "groovy": "groovy",
176 "hs": "haskell",
177 "ex": "elixir",
178 "exs": "elixir",
179 "erl": "erlang",
180 "clj": "clojure",
181 "sh": "bash",
182 "bash": "bash",
183 "zsh": "bash",
184 "fish": "bash",
185 "ps1": "powershell",
186 "json": "json",
187 "yaml": "yaml",
188 "yml": "yaml",
189 "toml": "ini",
190 "ini": "ini",
191 "cfg": "ini",
192 "conf": "ini",
193 "xml": "xml",
194 "html": "xml",
195 "htm": "xml",
196 "css": "css",
197 "scss": "scss",
198 "sass": "scss",
199 "less": "less",
200 "sql": "sql",
201 "md": "markdown",
202 "markdown": "markdown",
203 "diff": "diff",
204 "patch": "diff",
205 "cmake": "cmake",
206 "gradle": "groovy",
207 "vim": "vim"
208 ]
209}
210
211private extension UIColor {
212 /// `#rrggbb` for HTML inline styles, or `nil` if the color isn't RGB-convertible.
213 var hexRGBString: String? {
214 var red: CGFloat = 0
215 var green: CGFloat = 0
216 var blue: CGFloat = 0
217 var alpha: CGFloat = 0
218 guard getRed(&red, green: &green, blue: &blue, alpha: &alpha) else { return nil }
219 let clamp: (CGFloat) -> Int = { Int((max(0, min(1, $0)) * 255).rounded()) }
220 return String(format: "#%02x%02x%02x", clamp(red), clamp(green), clamp(blue))
221 }
222}
Hutch/Views/Tickets/TicketDetailView.swift +10 −2
@@ -553,14 +553,22 @@ private struct MarkdownContentView: View {
553 .frame(minHeight: 80) 553 .frame(minHeight: 80)
554 } 554 }
555 } 555 }
556 .task(id: markdown) { 556 .task(id: MarkdownRenderKey(markdown: markdown, isDark: colorScheme == .dark)) {
557 let theme = SyntaxHighlightTheme(colorScheme: colorScheme)
557 let html = await Task.detached(priority: .userInitiated) { 558 let html = await Task.detached(priority: .userInitiated) {
558 markdownToHTML(markdown) 559 markdownToHTML(markdown, codeTheme: theme)
559 }.value 560 }.value
560 guard !Task.isCancelled else { return } 561 guard !Task.isCancelled else { return }
561 renderedHTML = html 562 renderedHTML = html
562 } 563 }
563 } 564 }
565
566 /// Re-renders when the markdown or the color scheme changes; highlighted
567 /// code colors are baked into the HTML, so both must key the render.
568 private struct MarkdownRenderKey: Equatable {
569 let markdown: String
570 let isDark: Bool
571 }
564} 572}
565 573
566// MARK: - Event Row 574// MARK: - Event Row
HutchTests/SyntaxHighlighterTests.swift added +48
@@ -0,0 +1,48 @@
1import Foundation
2import Testing
3import UIKit
4@testable import Hutch
5
6struct SyntaxHighlighterTests {
7 @Test
8 func mapsFileNamesToLanguages() {
9 #expect(SyntaxHighlighter.language(forFileName: "Sources/App/Main.swift") == "swift")
10 #expect(SyntaxHighlighter.language(forFileName: "server.py") == "python")
11 #expect(SyntaxHighlighter.language(forFileName: "index.tsx") == "typescript")
12 #expect(SyntaxHighlighter.language(forFileName: "Dockerfile") == "dockerfile")
13 #expect(SyntaxHighlighter.language(forFileName: "Makefile") == "makefile")
14 }
15
16 @Test
17 func returnsNilForUnknownExtensions() {
18 #expect(SyntaxHighlighter.language(forFileName: "notes.unknownext") == nil)
19 #expect(SyntaxHighlighter.language(forFileName: "LICENSE") == nil)
20 }
21
22 @Test
23 func highlightsKnownLanguageAsColoredSpans() {
24 let highlighter = SyntaxHighlighter(theme: .light)
25 let html = highlighter.highlightedHTML(for: "let answer = 42", language: "swift")
26
27 let unwrapped = try? #require(html)
28 #expect(unwrapped?.contains("<span style=\"color:") == true)
29 #expect(unwrapped?.contains("answer") == true)
30 }
31
32 @Test
33 func fallsBackToNilForUnsupportedLanguage() {
34 let highlighter = SyntaxHighlighter(theme: .dark)
35 #expect(highlighter.highlightedHTML(for: "plain text", language: nil) == nil)
36 #expect(highlighter.highlightedHTML(for: "plain text", language: "totally-not-a-language") == nil)
37 }
38
39 @Test
40 func producesAttributedTextForNativeViewer() {
41 let highlighter = SyntaxHighlighter(theme: .light)
42 let font = UIFont.monospacedSystemFont(ofSize: 12, weight: .regular)
43 let attributed = highlighter.attributedText(for: "print(\"hi\")", language: "python", font: font)
44
45 let unwrapped = try? #require(attributed)
46 #expect(unwrapped?.string.contains("print") == true)
47 }
48}