Commit 45599c2892
Unsigned
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 | ||
| 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 | // MARK: - Tree Entry Row | 767 | // MARK: - Tree Entry Row |
| 753 | 768 | ||
| 754 | private struct TreeEntryRow: View { | 769 | private struct TreeEntryRow: View { |
Hutch/Views/Repositories/MarkdownHTMLRenderer.swift +16 −3
| @@ -2,11 +2,16 @@ import Markdown | |||
| 2 | 2 | ||
| 3 | nonisolated func markdownToHTML( | 3 | nonisolated 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 | ||
| 463 | nonisolated func orgToHTML( | 470 | nonisolated 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 @@ | |||
| 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 | .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 @@ | |||
| 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 | } | ||