Commit 45599c2892
Unsigned
Layout: unified · split
Hutch.xcodeproj/project.pbxproj +17
| @@ -17,6 +17,7 @@ | ||
| 17 | 17 | 8B2FA9D12F7E211800E609E2 /* paste.json in Resources */ = {isa = PBXBuildFile; fileRef = 8B2FA9C92F7E211800E609E2 /* paste.json */; }; |
| 18 | 18 | 8B2FA9D22F7E211800E609E2 /* todo.json in Resources */ = {isa = PBXBuildFile; fileRef = 8B2FA9CA2F7E211800E609E2 /* todo.json */; }; |
| 19 | 19 | 8B2FA9D32F7E211800E609E2 /* lists.json in Resources */ = {isa = PBXBuildFile; fileRef = 8B2FA9C72F7E211800E609E2 /* lists.json */; }; |
| 20 | 8BAA000000000000000000B0 /* Highlightr in Frameworks */ = {isa = PBXBuildFile; productRef = 8BAA000000000000000000B2 /* Highlightr */; }; | |
| 20 | 21 | 8BE082012F80000100000001 /* HutchWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; |
| 21 | 22 | 8BE083012F81000100000001 /* HutchSafariExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8BE083032F81000100000001 /* HutchSafariExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; |
| 22 | 23 | 8BF100032F9A000100000001 /* Markdown in Frameworks */ = {isa = PBXBuildFile; productRef = 8BF100022F9A000100000001 /* Markdown */; }; |
| @@ -148,6 +149,7 @@ | ||
| 148 | 149 | buildActionMask = 2147483647; |
| 149 | 150 | files = ( |
| 150 | 151 | 8BF100032F9A000100000001 /* Markdown in Frameworks */, |
| 152 | 8BAA000000000000000000B0 /* Highlightr in Frameworks */, | |
| 151 | 153 | ); |
| 152 | 154 | runOnlyForDeploymentPostprocessing = 0; |
| 153 | 155 | }; |
| @@ -252,6 +254,7 @@ | ||
| 252 | 254 | name = Hutch; |
| 253 | 255 | packageProductDependencies = ( |
| 254 | 256 | 8BF100022F9A000100000001 /* Markdown */, |
| 257 | 8BAA000000000000000000B2 /* Highlightr */, | |
| 255 | 258 | ); |
| 256 | 259 | productName = Hutch; |
| 257 | 260 | productReference = 8B4B28D12F6704280045FA19 /* Hutch.app */; |
| @@ -358,6 +361,7 @@ | ||
| 358 | 361 | minimizedProjectReferenceProxies = 1; |
| 359 | 362 | packageReferences = ( |
| 360 | 363 | 8BF100012F9A000100000001 /* XCRemoteSwiftPackageReference "swift-markdown" */, |
| 364 | 8BAA000000000000000000B3 /* XCRemoteSwiftPackageReference "Highlightr" */, | |
| 361 | 365 | ); |
| 362 | 366 | preferredProjectObjectVersion = 77; |
| 363 | 367 | productRefGroup = 8B4B28D22F6704280045FA19 /* Products */; |
| @@ -871,6 +875,14 @@ | ||
| 871 | 875 | /* End XCConfigurationList section */ |
| 872 | 876 | |
| 873 | 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 | 886 | 8BF100012F9A000100000001 /* XCRemoteSwiftPackageReference "swift-markdown" */ = { |
| 875 | 887 | isa = XCRemoteSwiftPackageReference; |
| 876 | 888 | repositoryURL = "https://github.com/apple/swift-markdown"; |
| @@ -882,6 +894,11 @@ | ||
| 882 | 894 | /* End XCRemoteSwiftPackageReference section */ |
| 883 | 895 | |
| 884 | 896 | /* Begin XCSwiftPackageProductDependency section */ |
| 897 | 8BAA000000000000000000B2 /* Highlightr */ = { | |
| 898 | isa = XCSwiftPackageProductDependency; | |
| 899 | package = 8BAA000000000000000000B3 /* XCRemoteSwiftPackageReference "Highlightr" */; | |
| 900 | productName = Highlightr; | |
| 901 | }; | |
| 885 | 902 | 8BF100022F9A000100000001 /* Markdown */ = { |
| 886 | 903 | isa = XCSwiftPackageProductDependency; |
| 887 | 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 | 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 | 14 | "identity" : "swift-cmark", |
| 6 | 15 | "kind" : "remoteSourceControl", |
Hutch/Views/Repositories/FileTreeView.swift +32 −17
| @@ -456,12 +456,19 @@ final class CodeFileUIView: UIView { | ||
| 456 | 456 | private var wrapLines: Bool |
| 457 | 457 | private var needsLineLayoutUpdate = true |
| 458 | 458 | private var lastMeasuredCodeWidth: CGFloat = 0 |
| 459 | private var syntaxHighlighter: SyntaxHighlighter? | |
| 460 | private var syntaxHighlighterTheme: SyntaxHighlightTheme? | |
| 459 | 461 | |
| 460 | 462 | init(text: String, fileName: String, font: UIFont, wrapLines: Bool) { |
| 461 | 463 | self.currentFont = font |
| 462 | 464 | self.wrapLines = wrapLines |
| 463 | 465 | super.init(frame: .zero) |
| 464 | 466 | setupViews() |
| 467 | registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (view: CodeFileUIView, _: UITraitCollection) in | |
| 468 | view.rebuildRows() | |
| 469 | view.needsLineLayoutUpdate = true | |
| 470 | view.setNeedsLayout() | |
| 471 | } | |
| 465 | 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 | 591 | rows.removeAll() |
| 585 | 592 | |
| 586 | let attributedText = CodeSyntaxHighlighter.attributedText( | |
| 587 | for: currentText, | |
| 588 | fileName: currentFileName, | |
| 589 | font: currentFont | |
| 590 | ) | |
| 593 | let attributedText = highlightedAttributedText() | |
| 591 | 594 | let lines = makeLines(from: attributedText, font: currentFont) |
| 592 | 595 | |
| 593 | 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 | 630 | private func makeRow(for line: CodeFileLineData) -> LineRow { |
| 604 | 631 | let row = LineRow() |
| 605 | 632 | |
| @@ -737,18 +764,6 @@ private struct CodeFileLineData { | ||
| 737 | 764 | let text: NSAttributedString |
| 738 | 765 | } |
| 739 | 766 | |
| 740 | private 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 | 767 | // MARK: - Tree Entry Row |
| 753 | 768 | |
| 754 | 769 | private struct TreeEntryRow: View { |
Hutch/Views/Repositories/MarkdownHTMLRenderer.swift +16 −3
| @@ -2,11 +2,16 @@ import Markdown | ||
| 2 | 2 | |
| 3 | 3 | nonisolated func markdownToHTML( |
| 4 | 4 | _ text: String, |
| 5 | codeTheme: SyntaxHighlightTheme = .light, | |
| 5 | 6 | imageURLResolver: ((String) -> String?)? = nil, |
| 6 | 7 | linkURLResolver: ((String) -> String?)? = nil |
| 7 | 8 | ) -> String { |
| 8 | 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 | 15 | return renderer.visit(document) |
| 11 | 16 | } |
| 12 | 17 | |
| @@ -15,13 +20,19 @@ private struct MarkdownHTMLRenderer: MarkupVisitor { | ||
| 15 | 20 | |
| 16 | 21 | nonisolated(unsafe) let imageURLResolver: ((String) -> String?)? |
| 17 | 22 | nonisolated(unsafe) let linkURLResolver: ((String) -> String?)? |
| 23 | nonisolated(unsafe) private let highlighter: SyntaxHighlighter | |
| 18 | 24 | nonisolated(unsafe) private var isRenderingTableHead = false |
| 19 | 25 | nonisolated(unsafe) private var currentTableAlignments: [Markdown.Table.ColumnAlignment?] = [] |
| 20 | 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 | 33 | self.imageURLResolver = imageURLResolver |
| 24 | 34 | self.linkURLResolver = linkURLResolver |
| 35 | self.highlighter = highlighter | |
| 25 | 36 | } |
| 26 | 37 | |
| 27 | 38 | nonisolated mutating func visit(_ markup: Markup) -> String { |
| @@ -78,7 +89,9 @@ private struct MarkdownHTMLRenderer: MarkupVisitor { | ||
| 78 | 89 | } else { |
| 79 | 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 | 97 | nonisolated mutating func visitInlineCode(_ inlineCode: InlineCode) -> String { |
Hutch/Views/Repositories/ReadmeView.swift +22 −6
| @@ -234,15 +234,18 @@ struct RenderedMarkupContentView: View { | ||
| 234 | 234 | @State private var renderedHTML: String? |
| 235 | 235 | |
| 236 | 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 | 240 | switch content { |
| 238 | 241 | case .html(let html): |
| 239 | "html:\(readmePath ?? "custom"):\(html)" | |
| 242 | return "html:\(readmePath ?? "custom"):\(html)" | |
| 240 | 243 | case .markdown(let text): |
| 241 | "markdown:\(readmePath ?? ""):\(text)" | |
| 244 | return "markdown:\(theme):\(readmePath ?? ""):\(text)" | |
| 242 | 245 | case .org(let text): |
| 243 | "org:\(readmePath ?? ""):\(text)" | |
| 246 | return "org:\(theme):\(readmePath ?? ""):\(text)" | |
| 244 | 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 | 280 | renderedHTML = cached |
| 278 | 281 | return |
| 279 | 282 | } |
| 283 | let theme = SyntaxHighlightTheme(colorScheme: colorScheme) | |
| 280 | 284 | let html = await Task.detached(priority: .userInitiated) { |
| 281 | 285 | markdownToHTML( |
| 282 | 286 | text, |
| 287 | codeTheme: theme, | |
| 283 | 288 | imageURLResolver: { source in |
| 284 | 289 | resolveRepositoryAssetURL( |
| 285 | 290 | source, |
| @@ -308,9 +313,11 @@ struct RenderedMarkupContentView: View { | ||
| 308 | 313 | renderedHTML = cached |
| 309 | 314 | return |
| 310 | 315 | } |
| 316 | let theme = SyntaxHighlightTheme(colorScheme: colorScheme) | |
| 311 | 317 | let html = await Task.detached(priority: .userInitiated) { |
| 312 | 318 | orgToHTML( |
| 313 | 319 | text, |
| 320 | codeTheme: theme, | |
| 314 | 321 | imageURLResolver: { source in |
| 315 | 322 | resolveRepositoryAssetURL( |
| 316 | 323 | source, |
| @@ -462,9 +469,11 @@ nonisolated func processInline( | ||
| 462 | 469 | |
| 463 | 470 | nonisolated func orgToHTML( |
| 464 | 471 | _ text: String, |
| 472 | codeTheme: SyntaxHighlightTheme = .light, | |
| 465 | 473 | imageURLResolver: ((String) -> String?)? = nil, |
| 466 | 474 | linkURLResolver: ((String) -> String?)? = nil |
| 467 | 475 | ) -> String { |
| 476 | let highlighter = SyntaxHighlighter(theme: codeTheme) | |
| 468 | 477 | let normalizedText = text |
| 469 | 478 | .replacingOccurrences(of: "\r\n", with: "\n") |
| 470 | 479 | .replacingOccurrences(of: "\r", with: "\n") |
| @@ -496,6 +505,7 @@ nonisolated func orgToHTML( | ||
| 496 | 505 | var inQuoteBlock = false |
| 497 | 506 | var inPropertyDrawer = false |
| 498 | 507 | var srcLanguage: String? |
| 508 | var srcLines: [String] = [] | |
| 499 | 509 | var inExampleBlock = false |
| 500 | 510 | var inCenterBlock = false |
| 501 | 511 | var inVerseBlock = false |
| @@ -593,9 +603,14 @@ nonisolated func orgToHTML( | ||
| 593 | 603 | } |
| 594 | 604 | |
| 595 | 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 | 611 | html += "</code></pre>\n" |
| 598 | 612 | srcLanguage = nil |
| 613 | srcLines = [] | |
| 599 | 614 | closePendingBlockWrapper() |
| 600 | 615 | } |
| 601 | 616 | } |
| @@ -659,7 +674,7 @@ nonisolated func orgToHTML( | ||
| 659 | 674 | if trimmed.lowercased() == "#+end_src" { |
| 660 | 675 | closeSourceBlock() |
| 661 | 676 | } else { |
| 662 | html += escapeHTML(line) + "\n" | |
| 677 | srcLines.append(line) | |
| 663 | 678 | } |
| 664 | 679 | continue |
| 665 | 680 | } |
| @@ -730,6 +745,7 @@ nonisolated func orgToHTML( | ||
| 730 | 745 | let classAttribute = language.map { " class=\"language-\(escapeHTMLAttribute($0))\"" } ?? "" |
| 731 | 746 | html += "<pre><code\(classAttribute)>" |
| 732 | 747 | srcLanguage = language ?? "" |
| 748 | srcLines = [] | |
| 733 | 749 | continue |
| 734 | 750 | } |
| 735 | 751 | |
Hutch/Views/Repositories/SyntaxHighlighter.swift added +222
| @@ -0,0 +1,222 @@ | ||
| 1 | import Foundation | |
| 2 | import Highlightr | |
| 3 | import SwiftUI | |
| 4 | import UIKit | |
| 5 | ||
| 6 | 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. | |
| 34 | 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 | ||
| 211 | 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 | } | |
Hutch/Views/Tickets/TicketDetailView.swift +10 −2
| @@ -553,14 +553,22 @@ private struct MarkdownContentView: View { | ||
| 553 | 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 | 558 | let html = await Task.detached(priority: .userInitiated) { |
| 558 | markdownToHTML(markdown) | |
| 559 | markdownToHTML(markdown, codeTheme: theme) | |
| 559 | 560 | }.value |
| 560 | 561 | guard !Task.isCancelled else { return } |
| 561 | 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 | 574 | // MARK: - Event Row |
HutchTests/SyntaxHighlighterTests.swift added +48
| @@ -0,0 +1,48 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | import UIKit | |
| 4 | @testable import Hutch | |
| 5 | ||
| 6 | struct 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 | } | |