krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
main: Hutch/Views/Repositories/SyntaxHighlighter.swift · raw
1import Foundation
2import Highlightr
3import SwiftUI
4import UIKit
5
6nonisolated enum 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.
34nonisolated final 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
211nonisolated private 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}