From 84811aad0ee871a70c107ab6ae0984ae9e6dd9f7 Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Mon, 11 May 2026 19:18:29 +0200 Subject: [PATCH 01/12] Add MarkdownEngineHighlighter adapter and onboarding improvements - New optional MarkdownEngineHighlighter product ships HighlighterSwiftBridge, a turnkey SyntaxHighlighter backed by HighlighterSwift. Core MarkdownEngine stays HighlighterSwift-free at link time. - Make NativeTextViewWrapper init params optional with sensible defaults so the minimal usage is NativeTextViewWrapper(text: $text). Demo simplified accordingly. - Parameterize CodeBlockButton with topInset/trailingInset (defaults 6/8 keep the button inside the code block). Callers wanting the legacy gutter look pass trailingInset: -25. - Fire updateCodeBlockSelection on frameDidChange so copy-button positions follow window resizes, not just scrolls. - Have LayoutBridge.boundingRect ensureLayout for the prefix range before measuring, eliminating the initial Y-jump that occurred when text-delegate callbacks fired before TextKit 2's layout pass had converged. Co-Authored-By: Claude Opus 4.7 (1M context) --- Demo/MarkdownEngineDemo/ContentView.swift | 11 +- Package.swift | 20 +++- .../Renderer/LayoutBridge.swift | 11 ++ .../TextView/CodeBlockButton.swift | 21 +++- .../TextView/NativeTextViewWrapper.swift | 25 ++-- .../HighlighterSwiftBridge.swift | 110 ++++++++++++++++++ 6 files changed, 174 insertions(+), 24 deletions(-) create mode 100644 Sources/MarkdownEngineHighlighter/HighlighterSwiftBridge.swift diff --git a/Demo/MarkdownEngineDemo/ContentView.swift b/Demo/MarkdownEngineDemo/ContentView.swift index 408329b..564de9b 100644 --- a/Demo/MarkdownEngineDemo/ContentView.swift +++ b/Demo/MarkdownEngineDemo/ContentView.swift @@ -10,18 +10,9 @@ import MarkdownEngine struct ContentView: View { @State private var text: String = sampleMarkdown - @State private var isWikiLinkActive: Bool = false - @State private var pendingReplacement: InlineReplacementRequest? var body: some View { - NativeTextViewWrapper( - text: $text, - isWikiLinkActive: $isWikiLinkActive, - pendingInlineReplacement: $pendingReplacement, - configuration: .default, - fontName: "SF Pro", - documentId: "demo" - ) + NativeTextViewWrapper(text: $text) } } diff --git a/Package.swift b/Package.swift index f8e4c03..50c3945 100644 --- a/Package.swift +++ b/Package.swift @@ -7,14 +7,32 @@ import PackageDescription // conform to the engine's service protocols (`WikiLinkResolver`, // `EmbeddedImageProvider`, `SyntaxHighlighter`, `LatexRenderer`). The engine // itself has zero external dependencies. +// +// Users who want syntax highlighting for fenced code blocks without +// writing their own bridge can additionally depend on the +// `MarkdownEngineHighlighter` product, which ships a turnkey +// `SyntaxHighlighter` conformance backed by HighlighterSwift. The +// extra product is opt-in: the core `MarkdownEngine` library stays +// HighlighterSwift-free at link time. let package = Package( name: "MarkdownEngine", platforms: [.macOS(.v14)], products: [ - .library(name: "MarkdownEngine", targets: ["MarkdownEngine"]) + .library(name: "MarkdownEngine", targets: ["MarkdownEngine"]), + .library(name: "MarkdownEngineHighlighter", targets: ["MarkdownEngineHighlighter"]), + ], + dependencies: [ + .package(url: "https://github.com/smittytone/HighlighterSwift", from: "3.0.0") ], targets: [ .target(name: "MarkdownEngine"), + .target( + name: "MarkdownEngineHighlighter", + dependencies: [ + "MarkdownEngine", + .product(name: "Highlighter", package: "HighlighterSwift"), + ] + ), .testTarget( name: "MarkdownEngineTests", dependencies: ["MarkdownEngine"] diff --git a/Sources/MarkdownEngine/Renderer/LayoutBridge.swift b/Sources/MarkdownEngine/Renderer/LayoutBridge.swift index 05ac48d..9da4068 100644 --- a/Sources/MarkdownEngine/Renderer/LayoutBridge.swift +++ b/Sources/MarkdownEngine/Renderer/LayoutBridge.swift @@ -39,6 +39,17 @@ final class LayoutBridge { func boundingRect(forCharacterRange range: NSRange, in textContainer: NSTextContainer) -> CGRect { guard let textRange = textRange(for: range) else { return .zero } + // Ensure TextKit 2 has laid out everything *before* the queried + // range, not just the range itself. The Y position of `range` + // depends on the cumulative height of all preceding fragments; + // if any of them are still at preliminary metrics (e.g. before + // syntax-highlight font has been applied), the Y is wrong. + if let docStart = textLayoutManager.textContentManager?.documentRange.location, + let prefixRange = NSTextRange(location: docStart, end: textRange.endLocation) { + textLayoutManager.ensureLayout(for: prefixRange) + } else { + textLayoutManager.ensureLayout(for: textRange) + } var result = CGRect.null textLayoutManager.enumerateTextSegments( in: textRange, type: .standard, options: [] diff --git a/Sources/MarkdownEngine/TextView/CodeBlockButton.swift b/Sources/MarkdownEngine/TextView/CodeBlockButton.swift index 4f3959c..1e2c55e 100644 --- a/Sources/MarkdownEngine/TextView/CodeBlockButton.swift +++ b/Sources/MarkdownEngine/TextView/CodeBlockButton.swift @@ -42,11 +42,26 @@ public struct CodeBlockSelection: Identifiable, Sendable { public struct CodeBlockButton: View { /// The code block this button is attached to. public let selection: CodeBlockSelection + /// Vertical inset from the code block's top edge, in points. Positive + /// values push the button down into the code block. + public let topInset: CGFloat + /// Horizontal inset from the code block's trailing edge, in points. + /// Positive values keep the button inside the code block; negative + /// values let it overflow into a parent's gutter (the legacy Nodes + /// look). + public let trailingInset: CGFloat /// Closure invoked when the user clicks the button. public let onCopy: () -> Void - public init(selection: CodeBlockSelection, onCopy: @escaping () -> Void) { + public init( + selection: CodeBlockSelection, + topInset: CGFloat = 6, + trailingInset: CGFloat = 8, + onCopy: @escaping () -> Void + ) { self.selection = selection + self.topInset = topInset + self.trailingInset = trailingInset self.onCopy = onCopy } @@ -72,8 +87,8 @@ public struct CodeBlockButton: View { .cornerRadius(6) } .buttonStyle(.plain) - .padding(.top, 6) - .padding(.trailing, -25) + .padding(.top, topInset) + .padding(.trailing, trailingInset) } .position( x: selection.rect.midX, diff --git a/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift b/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift index 23a2c9c..e5676cd 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift @@ -44,7 +44,9 @@ public struct NativeTextViewWrapper: NSViewRepresentable { /// off this value via ``MarkdownEditorConfiguration``. public var fontSize: CGFloat /// Opaque document identifier. Changing this invalidates undo history - /// and resets per-document editor state. + /// and resets per-document editor state. Set a stable, unique value + /// per document when displaying multiple editors so pending + /// replacements and undo stay scoped to each editor. public var documentId: String /// When `false` the editor renders read-only with no caret. public var isEditable: Bool @@ -69,12 +71,12 @@ public struct NativeTextViewWrapper: NSViewRepresentable { public init( text: Binding, - isWikiLinkActive: Binding, - pendingInlineReplacement: Binding, - configuration: MarkdownEditorConfiguration, - fontName: String, + isWikiLinkActive: Binding = .constant(false), + pendingInlineReplacement: Binding = .constant(nil), + configuration: MarkdownEditorConfiguration = .default, + fontName: String = "SF Pro", fontSize: CGFloat = 16, - documentId: String, + documentId: String = "default", isEditable: Bool = true, onPasteImage: ((NSPasteboard) -> String?)? = nil, onLinkClick: ((String) -> Void)? = nil, @@ -185,10 +187,13 @@ public struct NativeTextViewWrapper: NSViewRepresentable { textView.recalcOverscroll(for: scrollView) scrollView.contentView.postsBoundsChangedNotifications = true NotificationCenter.default.addObserver(forName: NSView.frameDidChangeNotification, object: scrollView.contentView, queue: nil) { _ in - // Only react when the viewport itself resizes (window resize). - // Without this guard, TextKit-induced textView frame changes echo - // back here and re-trigger recalcOverscroll, causing a 149pt - // height oscillation after clicks. + // Code block button overlays depend on layout-relative rects; refresh + // them on every frame change so window resizes don't leave stale rects. + context.coordinator.updateCodeBlockSelection(textView: textView) + // Only react with overscroll recalc when the viewport itself resizes + // (window resize). Without this guard, TextKit-induced textView frame + // changes echo back here and re-trigger recalcOverscroll, causing a + // 149pt height oscillation after clicks. guard abs(textView.frame.height - scrollView.contentView.bounds.height) > 1 else { return } textView.recalcOverscroll(for: scrollView) scrollView.clampToInsets() diff --git a/Sources/MarkdownEngineHighlighter/HighlighterSwiftBridge.swift b/Sources/MarkdownEngineHighlighter/HighlighterSwiftBridge.swift new file mode 100644 index 0000000..9e26302 --- /dev/null +++ b/Sources/MarkdownEngineHighlighter/HighlighterSwiftBridge.swift @@ -0,0 +1,110 @@ +// +// HighlighterSwiftBridge.swift +// MarkdownEngineHighlighter +// +// Ready-made SyntaxHighlighter conformance backed by HighlighterSwift. +// + +import AppKit +import Foundation +import Highlighter +import MarkdownEngine + +extension Notification.Name { + /// Posted by ``HighlighterSwiftBridge`` after the macOS appearance flips + /// and the bridge has re-applied its light/dark theme. The engine + /// subscribes to this through ``SyntaxHighlighter/appearanceDidChangeNotification`` + /// so it can invalidate cached code-block attributes. + public static let markdownEngineHighlighterDidChangeAppearance = + Notification.Name("MarkdownEngineHighlighterDidChangeAppearance") +} + +/// A drop-in ``SyntaxHighlighter`` backed by HighlighterSwift. +/// +/// Delegates the editor's code-block background color and code font to +/// HighlighterSwift's loaded theme, so changing the theme name updates +/// the entire code-block look in one place. +/// +/// When `autoSwitchAppearance` is `true` (the default), the bridge +/// observes `AppleInterfaceThemeChangedNotification` and re-applies +/// `darkTheme` / `lightTheme` accordingly, then posts +/// ``Notification/Name/markdownEngineHighlighterDidChangeAppearance`` so +/// the engine re-renders affected code blocks. +public final class HighlighterSwiftBridge: SyntaxHighlighter, @unchecked Sendable { + private let highlighter: Highlighter? + private let lightTheme: String + private let darkTheme: String + private let autoSwitchAppearance: Bool + private var currentTheme: String = "" + + /// - Parameters: + /// - lightTheme: HighlighterSwift theme name applied in light mode. + /// - darkTheme: HighlighterSwift theme name applied in dark mode. + /// - autoSwitchAppearance: When `true`, observes the system + /// appearance and swaps themes automatically. Set to `false` to + /// pin the bridge to `lightTheme` regardless of mode. + public init( + lightTheme: String = "atom-one-light", + darkTheme: String = "atom-one-dark", + autoSwitchAppearance: Bool = true + ) { + self.highlighter = Highlighter() + self.lightTheme = lightTheme + self.darkTheme = darkTheme + self.autoSwitchAppearance = autoSwitchAppearance + applyAppearanceTheme() + + if autoSwitchAppearance { + DistributedNotificationCenter.default.addObserver( + forName: NSNotification.Name("AppleInterfaceThemeChangedNotification"), + object: nil, + queue: .main + ) { [weak self] _ in + guard let self else { return } + self.applyAppearanceTheme() + NotificationCenter.default.post( + name: .markdownEngineHighlighterDidChangeAppearance, + object: nil + ) + } + } + } + + private func applyAppearanceTheme() { + guard let highlighter else { return } + let isDark = autoSwitchAppearance && + NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua + let theme = isDark ? darkTheme : lightTheme + if currentTheme != theme { + currentTheme = theme + highlighter.setTheme(theme) + } + } + + // MARK: - SyntaxHighlighter + + public var appearanceDidChangeNotification: Notification.Name? { + autoSwitchAppearance ? .markdownEngineHighlighterDidChangeAppearance : nil + } + + public func codeFont(size: CGFloat) -> NSFont { + if let themeFont = highlighter?.theme.codeFont { + return NSFont(name: themeFont.fontName, size: size) ?? themeFont + } + return .monospacedSystemFont(ofSize: size, weight: .regular) + } + + public func backgroundColor() -> NSColor { + highlighter?.theme.themeBackgroundColour ?? .clear + } + + public func highlight(code: String, language: String?) -> NSAttributedString? { + applyAppearanceTheme() + guard let highlighter else { return nil } + let normalized = language?.lowercased().trimmingCharacters(in: .whitespaces) + if let lang = normalized, !lang.isEmpty { + return highlighter.highlight(code, as: lang) + } + return highlighter.highlight(code, as: nil) + } +} From e79b5b1173a709d649a6d75f1bc8b833a8f0b329 Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Mon, 11 May 2026 19:18:35 +0200 Subject: [PATCH 02/12] Restructure README for tighter section balance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged Theme, Tuning, Wiki-Link state, Custom Services, and Syntax Highlighting into one Customization H2 with H3s — they were previously five top-level peers which made the README feel highlighter-focused and over-engineered. Installation product table collapsed to a single sentence; protocol implementation walkthroughs deferred to DocC. 303 → 244 lines, 18 → 12 H2 sections. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 155 +++++++++++++++++++++++++++++++++--------------------- 1 file changed, 95 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index eb06111..72b7e71 100644 --- a/README.md +++ b/README.md @@ -58,8 +58,6 @@ them — the engine itself stays free of any of those transitive dependencies. ## Installation -Add MarkdownEngine to your `Package.swift`: - ```swift dependencies: [ .package(url: "https://github.com/nodes-app/swift-markdown-engine", from: "0.1.0") @@ -67,13 +65,21 @@ dependencies: [ targets: [ .target( name: "YourApp", - dependencies: ["MarkdownEngine"] + dependencies: [ + .product(name: "MarkdownEngine", package: "swift-markdown-engine"), + ] ) ] ``` Or in Xcode: **File → Add Package Dependencies…** and paste the repo URL. +The package also ships an optional `MarkdownEngineHighlighter` product +for turnkey syntax highlighting via HighlighterSwift — add it as a +second product dependency if you want it (see [Customization → +Syntax Highlighting](#syntax-highlighting)). The core `MarkdownEngine` +library stays HighlighterSwift-free. + ## Quick Start ```swift @@ -82,94 +88,125 @@ import MarkdownEngine struct EditorScreen: View { @State private var text: String = "# Hello, *world*" - @State private var isLinkActive: Bool = false - @State private var pendingReplacement: InlineReplacementRequest? var body: some View { - NativeTextViewWrapper( - text: $text, - isWikiLinkActive: $isLinkActive, - pendingInlineReplacement: $pendingReplacement, - configuration: .default, - fontName: "SF Pro", - documentId: "doc-1" - ) + NativeTextViewWrapper(text: $text) } } ``` That's it. The default configuration ships with no-op services, so the -editor renders Markdown and accepts edits immediately. +editor renders Markdown and accepts edits immediately. See +[Customization](#customization) below to wire up syntax highlighting, +custom themes, wiki-link state, and more. -## Demo +> **Displaying multiple editors?** Pass a stable, unique +> `documentId: "your-doc-id"` so undo history and pending replacements +> stay scoped to each editor instance. -A runnable SwiftUI demo lives in [`Demo/`](Demo/MarkdownEngineDemo.xcodeproj). -Open [`Demo/MarkdownEngineDemo.xcodeproj`](Demo/MarkdownEngineDemo.xcodeproj) -in Xcode and hit **Run** — the demo references the package via a local -path, so Xcode resolves it on first open and any engine edit rebuilds -into the demo on the next run. +## Customization -> If you're seeing a "missing package product" error, it's almost always -> stale package cache from a previous Xcode session. Use **File → -> Packages → Reset Package Caches** once and rebuild. +### Syntax Highlighting + +The `MarkdownEngineHighlighter` product ships `HighlighterSwiftBridge`, +a turnkey `SyntaxHighlighter` backed by HighlighterSwift: + +```swift +import MarkdownEngineHighlighter + +var configuration = MarkdownEditorConfiguration.default +configuration.services = MarkdownEditorServices( + syntaxHighlighter: HighlighterSwiftBridge() +) +``` + +The bridge auto-switches between `atom-one-light` and `atom-one-dark` +with system appearance. Different theme names, a pinned single theme, +or a custom `SyntaxHighlighter` implementation are all supported — see +DocC for the full surface. -## Customizing the Theme +### Theming -Every color the editor puts on screen is read from `MarkdownEditorTheme`: +Every color the editor puts on screen reads from `MarkdownEditorTheme`: ```swift var theme = MarkdownEditorTheme.default theme.bodyText = .labelColor -theme.headingMarker = .secondaryLabelColor theme.findMatchHighlight = NSColor(named: "MyAccent")! var configuration = MarkdownEditorConfiguration.default configuration.theme = theme ``` -Defaults map to `NSColor` dynamic system colors so light / dark mode -switching keeps working without extra code. +Defaults map to `NSColor` dynamic system colors, so light/dark mode +keeps working without extra code. + +### Tuning + +`MarkdownEditorConfiguration` exposes every spacing / sizing / behavior +knob the engine has, grouped by concern: + +```swift +var configuration = MarkdownEditorConfiguration.default +configuration.codeBlock.fontSizeScale = 0.9 +configuration.headings.fontMultipliers = [2.4, 1.8, 1.4, 1.1, 0.9, 0.75] +configuration.overscroll.percent = 0.4 +configuration.lists.helpersEnabled = false +``` + +### Wiki-Links & Replacement State -## Wiring Up Services +Two optional bindings let you observe wiki-link state and push inline +replacements programmatically. Pass only what you need — each is +independent and defaults to a no-op: + +```swift +NativeTextViewWrapper( + text: $text, + isWikiLinkActive: $isWikiLinkActive, + pendingInlineReplacement: $pendingReplacement +) +``` + +- `isWikiLinkActive` — the wrapper sets this to `true` while the caret + sits inside a `[[Name]]` link, so you can present a contextual UI. +- `pendingInlineReplacement` — assign a non-nil value to push a + replacement (e.g. an autocomplete result); the engine consumes it + and clears the binding. + +### Custom Services + +When you need richer behavior than the bundled adapter — your own +wiki-link resolver, image provider, or a different syntax highlighter — +implement the relevant protocol and pass it in. Anything you omit keeps +its no-op default: ```swift struct MyResolver: WikiLinkResolver { func resolve(displayName: String, range: NSRange) -> WikiLinkResolution? { - guard let id = myIndex[displayName] else { return nil } - return WikiLinkResolution(id: id, exists: true) - } -} - -struct MyImages: EmbeddedImageProvider { - func image(for ref: EmbeddedImageRequest) -> NSImage? { - myImageStore.load(name: ref.name) + myIndex[displayName].map { WikiLinkResolution(id: $0, exists: true) } } - func fingerprint() -> AnyHashable { myImageStore.version } } -let services = MarkdownEditorServices( - wikiLinks: MyResolver(), - images: MyImages(), - syntaxHighlighter: MyHighlighter(), - latex: MyLatexRenderer() +configuration.services = MarkdownEditorServices( + wikiLinks: MyResolver() + // images, syntaxHighlighter, latex omitted → no-op defaults ) - -var configuration = MarkdownEditorConfiguration.default -configuration.services = services ``` -## Tuning Behavior +The four protocols (`WikiLinkResolver`, `EmbeddedImageProvider`, +`SyntaxHighlighter`, `LatexRenderer`) are documented in DocC alongside +their no-op defaults (`NoOpWikiLinkResolver`, …, `PlainTextSyntaxHighlighter`). -`MarkdownEditorConfiguration` exposes every spacing / sizing / behavior -knob the engine has, grouped by concern: +## Demo -```swift -var configuration = MarkdownEditorConfiguration.default -configuration.codeBlock.fontSizeScale = 0.9 -configuration.headings.fontMultipliers = [2.4, 1.8, 1.4, 1.1, 0.9, 0.75] -configuration.overscroll.percent = 0.4 -configuration.lists.helpersEnabled = false // disable list editing helpers -``` +A runnable SwiftUI demo lives in [`Demo/`](Demo/MarkdownEngineDemo.xcodeproj). +Open it in Xcode and hit **Run** — the demo references the package via +a local path, so any engine edit rebuilds into the demo on the next run. + +> If you're seeing a "missing package product" error, it's almost always +> stale package cache. Use **File → Packages → Reset Package Caches** +> once and rebuild. ## Documentation @@ -186,10 +223,8 @@ Once the package is hosted on Swift Package Index, the docs will live at ## Requirements -- macOS 14 or later -- Swift 5.9 or later -- Xcode 15 or later -- macOS 15.1+ for Apple Writing Tools integration +- macOS 14 or later (15.1+ for Apple Writing Tools integration) +- Swift 5.9 / Xcode 15 or later ## Status From 0333c9797b7c9ac144cc94813ccd26882f73bac7 Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Mon, 11 May 2026 19:21:08 +0200 Subject: [PATCH 03/12] Restore product comparison table in Installation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two-row table is more scannable than prose for a "what's in the package" overview — keeping it alongside the descriptive paragraph that links into Customization. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 72b7e71..3dbc391 100644 --- a/README.md +++ b/README.md @@ -74,11 +74,12 @@ targets: [ Or in Xcode: **File → Add Package Dependencies…** and paste the repo URL. -The package also ships an optional `MarkdownEngineHighlighter` product -for turnkey syntax highlighting via HighlighterSwift — add it as a -second product dependency if you want it (see [Customization → -Syntax Highlighting](#syntax-highlighting)). The core `MarkdownEngine` -library stays HighlighterSwift-free. +The package ships two library products — add only what you need: + +| Product | Use when | +|---|---| +| `MarkdownEngine` | You want the editor only. Zero external dependencies. | +| `MarkdownEngineHighlighter` | You want fenced-code syntax highlighting without writing your own bridge. Pulls in [HighlighterSwift](https://github.com/smittytone/HighlighterSwift) transitively. See [Customization → Syntax Highlighting](#syntax-highlighting). | ## Quick Start From adf9e8ba10eab4761e1e9cc8162b1a0acebbea22 Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Mon, 11 May 2026 19:27:16 +0200 Subject: [PATCH 04/12] Trim README: drop Architecture peer section, merge Requirements/Status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three structural cleanups from comparing the layout against snapshot-testing, MarkdownUI, HighlightedTextEditor, and Runestone: - Removed the standalone "Architecture" H2 that sat between Features and Installation. Its protocol table is now part of Customization → Custom Services, where it belongs. Reader hits Quick Start ~10 lines sooner. - Merged "Requirements" + "Status" into one H2 — both were 2–3 lines and reading two adjacent ultra-short H2s felt like filler. - Dropped the "no-op services" restatement from the Quick Start paragraph; it's now stated once, in Custom Services. 244 → 232 lines, 12 → 10 H2 sections. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 47 +++++++++++++++++------------------------------ 1 file changed, 17 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 3dbc391..32ff788 100644 --- a/README.md +++ b/README.md @@ -40,22 +40,6 @@ When we started building **[Nodes](https://nodes-web.com/#/)** a minimal, beauti - **Drag-select autoscroll boost** for long documents - **Spelling & grammar** with code/LaTeX/wiki-link suppression -## Architecture - -The engine is built around four small service protocols you implement in -your app: - -| Protocol | What you supply | Suggested library | -|---|---|---| -| `WikiLinkResolver` | Resolve a `[[Name]]` to a stable opaque id | (your data model) | -| `EmbeddedImageProvider` | Look up an `NSImage` for `![[Name]]` | (your asset store) | -| `SyntaxHighlighter` | Highlight code blocks for a given language | [HighlighterSwift](https://github.com/smittytone/HighlighterSwift) | -| `LatexRenderer` | Render a LaTeX string to an `NSImage` | [SwiftMath](https://github.com/mgriebling/SwiftMath) | - -All four ship with no-op default implementations so the editor renders -plain Markdown out of the box. Drop in real implementations as you need -them — the engine itself stays free of any of those transitive dependencies. - ## Installation ```swift @@ -96,10 +80,8 @@ struct EditorScreen: View { } ``` -That's it. The default configuration ships with no-op services, so the -editor renders Markdown and accepts edits immediately. See -[Customization](#customization) below to wire up syntax highlighting, -custom themes, wiki-link state, and more. +That's it. See [Customization](#customization) below for syntax +highlighting, themes, wiki-link state, and more. > **Displaying multiple editors?** Pass a stable, unique > `documentId: "your-doc-id"` so undo history and pending replacements @@ -177,10 +159,17 @@ NativeTextViewWrapper( ### Custom Services -When you need richer behavior than the bundled adapter — your own -wiki-link resolver, image provider, or a different syntax highlighter — -implement the relevant protocol and pass it in. Anything you omit keeps -its no-op default: +The engine talks to your app through four service protocols, each with +a no-op default so you only implement what you actually need: + +| Protocol | What you supply | Suggested library | +|---|---|---| +| `WikiLinkResolver` | Resolve a `[[Name]]` to a stable opaque id | (your data model) | +| `EmbeddedImageProvider` | Look up an `NSImage` for `![[Name]]` | (your asset store) | +| `SyntaxHighlighter` | Highlight code blocks for a given language | [HighlighterSwift](https://github.com/smittytone/HighlighterSwift) — or use the bundled bridge above | +| `LatexRenderer` | Render a LaTeX string to an `NSImage` | [SwiftMath](https://github.com/mgriebling/SwiftMath) | + +Implement what you need and pass it through `MarkdownEditorServices`: ```swift struct MyResolver: WikiLinkResolver { @@ -195,9 +184,9 @@ configuration.services = MarkdownEditorServices( ) ``` -The four protocols (`WikiLinkResolver`, `EmbeddedImageProvider`, -`SyntaxHighlighter`, `LatexRenderer`) are documented in DocC alongside -their no-op defaults (`NoOpWikiLinkResolver`, …, `PlainTextSyntaxHighlighter`). +Each protocol and its no-op default (`NoOpWikiLinkResolver`, +`NoOpEmbeddedImageProvider`, `PlainTextSyntaxHighlighter`, +`NoOpLatexRenderer`) is documented in DocC. ## Demo @@ -222,13 +211,11 @@ In Xcode: **Product → Build Documentation** (`⇧⌃⌘D`). Once the package is hosted on Swift Package Index, the docs will live at `https://swiftpackageindex.com/nodes-app/swift-markdown-engine/documentation`. -## Requirements +## Requirements & Status - macOS 14 or later (15.1+ for Apple Writing Tools integration) - Swift 5.9 / Xcode 15 or later -## Status - MarkdownEngine is currently **pre-1.0**. The public API may change between minor releases as it stabilizes. Production use is fine — pin a specific version (`0.x.y`) in your `Package.swift`. From 0bc71aa744104ae78e8e1921d4c74b091a7bf23e Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Mon, 11 May 2026 21:23:27 +0200 Subject: [PATCH 05/12] Reorder Customization H3s and strengthen bundle recommendation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two structural fixes: - Move "Service Protocols" (renamed from "Custom Services") to the front of Customization, before any specific protocol implementation example. The previous order introduced `SyntaxHighlighter` and similar terms in the Syntax Highlighting section before the four-protocol architecture was established. - Demote "Wiki-Links & Replacement State" to the end of the section — it's a wrapper-binding feature, structurally different from the config-based customizations around it. Also: explicitly call out HighlighterSwiftBridge as the recommended path in Syntax Highlighting, with concrete reasons (line-height metrics, appearance switching, layout timing) why rolling your own SyntaxHighlighter is footgun-heavy. Empirically validated in this branch's own bring-up. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 85 ++++++++++++++++++++++++++++++------------------------- 1 file changed, 46 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 32ff788..e45c6ef 100644 --- a/README.md +++ b/README.md @@ -89,10 +89,44 @@ highlighting, themes, wiki-link state, and more. ## Customization +### Service Protocols + +The engine talks to your app through four service protocols, each with +a no-op default so you only implement what you actually need: + +| Protocol | What you supply | Default / ready-made | +|---|---|---| +| `WikiLinkResolver` | Resolve a `[[Name]]` to a stable opaque id | `NoOpWikiLinkResolver` (default) | +| `EmbeddedImageProvider` | Look up an `NSImage` for `![[Name]]` | `NoOpEmbeddedImageProvider` (default) | +| `SyntaxHighlighter` | Highlight code blocks for a given language | `PlainTextSyntaxHighlighter` (default); **`HighlighterSwiftBridge`** ([recommended](#syntax-highlighting)) | +| `LatexRenderer` | Render a LaTeX string to an `NSImage` | `NoOpLatexRenderer` (default); roll your own with [SwiftMath](https://github.com/mgriebling/SwiftMath) | + +Implement what you need and pass it through `MarkdownEditorServices`: + +```swift +struct MyResolver: WikiLinkResolver { + func resolve(displayName: String, range: NSRange) -> WikiLinkResolution? { + myIndex[displayName].map { WikiLinkResolution(id: $0, exists: true) } + } +} + +configuration.services = MarkdownEditorServices( + wikiLinks: MyResolver() + // images, syntaxHighlighter, latex omitted → no-op defaults +) +``` + +Each protocol and its no-op default are documented in DocC. + ### Syntax Highlighting -The `MarkdownEngineHighlighter` product ships `HighlighterSwiftBridge`, -a turnkey `SyntaxHighlighter` backed by HighlighterSwift: +**Recommended path: depend on the `MarkdownEngineHighlighter` product +and use the bundled `HighlighterSwiftBridge`.** Implementing +`SyntaxHighlighter` from scratch has subtle footguns the bridge +already handles — line-height metrics across light/dark themes, +appearance-change observation, layout-pass timing, font name extraction +from the theme. Use the bundle unless you specifically need a +non-HighlighterSwift library. ```swift import MarkdownEngineHighlighter @@ -104,9 +138,13 @@ configuration.services = MarkdownEditorServices( ``` The bridge auto-switches between `atom-one-light` and `atom-one-dark` -with system appearance. Different theme names, a pinned single theme, -or a custom `SyntaxHighlighter` implementation are all supported — see -DocC for the full surface. +with system appearance. Different theme names or a pinned single theme +are configurable via init params — see DocC. + +Need a different highlighter library entirely? Implement +`SyntaxHighlighter` yourself (see [Service Protocols](#service-protocols) +above for the declaration) and reference the bundled bridge in +`Sources/MarkdownEngineHighlighter/` as a working example. ### Theming @@ -139,9 +177,9 @@ configuration.lists.helpersEnabled = false ### Wiki-Links & Replacement State -Two optional bindings let you observe wiki-link state and push inline -replacements programmatically. Pass only what you need — each is -independent and defaults to a no-op: +Two optional bindings on `NativeTextViewWrapper` let you observe +wiki-link state and push inline replacements programmatically. Pass +only what you need — each is independent and defaults to a no-op: ```swift NativeTextViewWrapper( @@ -157,37 +195,6 @@ NativeTextViewWrapper( replacement (e.g. an autocomplete result); the engine consumes it and clears the binding. -### Custom Services - -The engine talks to your app through four service protocols, each with -a no-op default so you only implement what you actually need: - -| Protocol | What you supply | Suggested library | -|---|---|---| -| `WikiLinkResolver` | Resolve a `[[Name]]` to a stable opaque id | (your data model) | -| `EmbeddedImageProvider` | Look up an `NSImage` for `![[Name]]` | (your asset store) | -| `SyntaxHighlighter` | Highlight code blocks for a given language | [HighlighterSwift](https://github.com/smittytone/HighlighterSwift) — or use the bundled bridge above | -| `LatexRenderer` | Render a LaTeX string to an `NSImage` | [SwiftMath](https://github.com/mgriebling/SwiftMath) | - -Implement what you need and pass it through `MarkdownEditorServices`: - -```swift -struct MyResolver: WikiLinkResolver { - func resolve(displayName: String, range: NSRange) -> WikiLinkResolution? { - myIndex[displayName].map { WikiLinkResolution(id: $0, exists: true) } - } -} - -configuration.services = MarkdownEditorServices( - wikiLinks: MyResolver() - // images, syntaxHighlighter, latex omitted → no-op defaults -) -``` - -Each protocol and its no-op default (`NoOpWikiLinkResolver`, -`NoOpEmbeddedImageProvider`, `PlainTextSyntaxHighlighter`, -`NoOpLatexRenderer`) is documented in DocC. - ## Demo A runnable SwiftUI demo lives in [`Demo/`](Demo/MarkdownEngineDemo.xcodeproj). From 1faeba821f8143d9425280eefa67cccd2a2942ce Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Mon, 11 May 2026 21:26:49 +0200 Subject: [PATCH 06/12] Split protocol table: separate default from ready-made bridge / library Previously the last column mixed defaults, bundled bridges, and suggested libraries into one cell. Splitting them makes it scannable at a glance: default class, ready-made bridge (with the library it wraps), or the suggested library plus "roll your own" hint. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index e45c6ef..48630f5 100644 --- a/README.md +++ b/README.md @@ -94,12 +94,12 @@ highlighting, themes, wiki-link state, and more. The engine talks to your app through four service protocols, each with a no-op default so you only implement what you actually need: -| Protocol | What you supply | Default / ready-made | -|---|---|---| -| `WikiLinkResolver` | Resolve a `[[Name]]` to a stable opaque id | `NoOpWikiLinkResolver` (default) | -| `EmbeddedImageProvider` | Look up an `NSImage` for `![[Name]]` | `NoOpEmbeddedImageProvider` (default) | -| `SyntaxHighlighter` | Highlight code blocks for a given language | `PlainTextSyntaxHighlighter` (default); **`HighlighterSwiftBridge`** ([recommended](#syntax-highlighting)) | -| `LatexRenderer` | Render a LaTeX string to an `NSImage` | `NoOpLatexRenderer` (default); roll your own with [SwiftMath](https://github.com/mgriebling/SwiftMath) | +| Protocol | What you supply | Default | Ready-made bridge / suggested library | +|---|---|---|---| +| `WikiLinkResolver` | Resolve a `[[Name]]` to a stable opaque id | `NoOpWikiLinkResolver` | (your data model) | +| `EmbeddedImageProvider` | Look up an `NSImage` for `![[Name]]` | `NoOpEmbeddedImageProvider` | (your asset store) | +| `SyntaxHighlighter` | Highlight code blocks for a given language | `PlainTextSyntaxHighlighter` | **`HighlighterSwiftBridge`** ([recommended](#syntax-highlighting)), wrapping [HighlighterSwift](https://github.com/smittytone/HighlighterSwift) | +| `LatexRenderer` | Render a LaTeX string to an `NSImage` | `NoOpLatexRenderer` | [SwiftMath](https://github.com/mgriebling/SwiftMath) (roll your own bridge) | Implement what you need and pass it through `MarkdownEditorServices`: From d7f1897fef561b22d4512db48f9d4ca444791c48 Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Mon, 11 May 2026 21:29:09 +0200 Subject: [PATCH 07/12] Simplify protocol table: drop Default column, plainer wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Default column is redundant with the intro line "each with a no-op default" - "wrapping HighlighterSwift" → "built on HighlighterSwift" (less jargony) - "roll your own bridge" → "build your own adapter" Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 48630f5..487b376 100644 --- a/README.md +++ b/README.md @@ -94,12 +94,12 @@ highlighting, themes, wiki-link state, and more. The engine talks to your app through four service protocols, each with a no-op default so you only implement what you actually need: -| Protocol | What you supply | Default | Ready-made bridge / suggested library | -|---|---|---|---| -| `WikiLinkResolver` | Resolve a `[[Name]]` to a stable opaque id | `NoOpWikiLinkResolver` | (your data model) | -| `EmbeddedImageProvider` | Look up an `NSImage` for `![[Name]]` | `NoOpEmbeddedImageProvider` | (your asset store) | -| `SyntaxHighlighter` | Highlight code blocks for a given language | `PlainTextSyntaxHighlighter` | **`HighlighterSwiftBridge`** ([recommended](#syntax-highlighting)), wrapping [HighlighterSwift](https://github.com/smittytone/HighlighterSwift) | -| `LatexRenderer` | Render a LaTeX string to an `NSImage` | `NoOpLatexRenderer` | [SwiftMath](https://github.com/mgriebling/SwiftMath) (roll your own bridge) | +| Protocol | What you supply | Ready-made bridge / suggested library | +|---|---|---| +| `WikiLinkResolver` | Resolve a `[[Name]]` to a stable opaque id | (your data model) | +| `EmbeddedImageProvider` | Look up an `NSImage` for `![[Name]]` | (your asset store) | +| `SyntaxHighlighter` | Highlight code blocks for a given language | **`HighlighterSwiftBridge`** ([recommended](#syntax-highlighting)) — built on [HighlighterSwift](https://github.com/smittytone/HighlighterSwift) | +| `LatexRenderer` | Render a LaTeX string to an `NSImage` | [SwiftMath](https://github.com/mgriebling/SwiftMath) — build your own adapter | Implement what you need and pass it through `MarkdownEditorServices`: From 9407836d115983c13571d6dfee84aa6e106ae8fe Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Mon, 11 May 2026 21:46:38 +0200 Subject: [PATCH 08/12] latex adapter --- Package.swift | 23 ++- README.md | 27 ++- .../MarkdownEngineLatex/SwiftMathBridge.swift | 180 ++++++++++++++++++ 3 files changed, 221 insertions(+), 9 deletions(-) create mode 100644 Sources/MarkdownEngineLatex/SwiftMathBridge.swift diff --git a/Package.swift b/Package.swift index 50c3945..0b04b10 100644 --- a/Package.swift +++ b/Package.swift @@ -8,21 +8,23 @@ import PackageDescription // `EmbeddedImageProvider`, `SyntaxHighlighter`, `LatexRenderer`). The engine // itself has zero external dependencies. // -// Users who want syntax highlighting for fenced code blocks without -// writing their own bridge can additionally depend on the -// `MarkdownEngineHighlighter` product, which ships a turnkey -// `SyntaxHighlighter` conformance backed by HighlighterSwift. The -// extra product is opt-in: the core `MarkdownEngine` library stays -// HighlighterSwift-free at link time. +// Users who want turnkey adapters for the two highest-friction protocols +// (syntax highlighting, LaTeX rendering) can additionally depend on the +// `MarkdownEngineHighlighter` and/or `MarkdownEngineLatex` products, +// which ship pre-built bridges backed by HighlighterSwift and SwiftMath +// respectively. Both products are opt-in: the core `MarkdownEngine` +// library stays free of those transitive dependencies at link time. let package = Package( name: "MarkdownEngine", platforms: [.macOS(.v14)], products: [ .library(name: "MarkdownEngine", targets: ["MarkdownEngine"]), .library(name: "MarkdownEngineHighlighter", targets: ["MarkdownEngineHighlighter"]), + .library(name: "MarkdownEngineLatex", targets: ["MarkdownEngineLatex"]), ], dependencies: [ - .package(url: "https://github.com/smittytone/HighlighterSwift", from: "3.0.0") + .package(url: "https://github.com/smittytone/HighlighterSwift", from: "3.0.0"), + .package(url: "https://github.com/mgriebling/SwiftMath", from: "1.7.0"), ], targets: [ .target(name: "MarkdownEngine"), @@ -33,6 +35,13 @@ let package = Package( .product(name: "Highlighter", package: "HighlighterSwift"), ] ), + .target( + name: "MarkdownEngineLatex", + dependencies: [ + "MarkdownEngine", + .product(name: "SwiftMath", package: "SwiftMath"), + ] + ), .testTarget( name: "MarkdownEngineTests", dependencies: ["MarkdownEngine"] diff --git a/README.md b/README.md index 487b376..be69091 100644 --- a/README.md +++ b/README.md @@ -58,12 +58,13 @@ targets: [ Or in Xcode: **File → Add Package Dependencies…** and paste the repo URL. -The package ships two library products — add only what you need: +The package ships three library products — add only what you need: | Product | Use when | |---|---| | `MarkdownEngine` | You want the editor only. Zero external dependencies. | | `MarkdownEngineHighlighter` | You want fenced-code syntax highlighting without writing your own bridge. Pulls in [HighlighterSwift](https://github.com/smittytone/HighlighterSwift) transitively. See [Customization → Syntax Highlighting](#syntax-highlighting). | +| `MarkdownEngineLatex` | You want LaTeX formula rendering without writing your own bridge. Pulls in [SwiftMath](https://github.com/mgriebling/SwiftMath) transitively. See [Customization → LaTeX Rendering](#latex-rendering). | ## Quick Start @@ -99,7 +100,7 @@ a no-op default so you only implement what you actually need: | `WikiLinkResolver` | Resolve a `[[Name]]` to a stable opaque id | (your data model) | | `EmbeddedImageProvider` | Look up an `NSImage` for `![[Name]]` | (your asset store) | | `SyntaxHighlighter` | Highlight code blocks for a given language | **`HighlighterSwiftBridge`** ([recommended](#syntax-highlighting)) — built on [HighlighterSwift](https://github.com/smittytone/HighlighterSwift) | -| `LatexRenderer` | Render a LaTeX string to an `NSImage` | [SwiftMath](https://github.com/mgriebling/SwiftMath) — build your own adapter | +| `LatexRenderer` | Render a LaTeX string to an `NSImage` | **`SwiftMathBridge`** ([recommended](#latex-rendering)) — built on [SwiftMath](https://github.com/mgriebling/SwiftMath) | Implement what you need and pass it through `MarkdownEditorServices`: @@ -146,6 +147,28 @@ Need a different highlighter library entirely? Implement above for the declaration) and reference the bundled bridge in `Sources/MarkdownEngineHighlighter/` as a working example. +### LaTeX Rendering + +**Recommended path: depend on the `MarkdownEngineLatex` product and use +the bundled `SwiftMathBridge`.** Hand-rolling a `LatexRenderer` has +real footguns the bridge already handles — appearance-aware text color, +zero-sized output guards (`lockFocus` crashes on 0×0 images), +window-vs-NSApp appearance distinction, single-letter padding, and an +internal cache keyed by (latex, font size, appearance, theme color). + +```swift +import MarkdownEngineLatex + +var configuration = MarkdownEditorConfiguration.default +configuration.services = MarkdownEditorServices( + latex: SwiftMathBridge() +) +``` + +The bridge uses the Latin Modern math font and tints formulas with +`MarkdownEditorTheme.latexLightModeText` / `latexDarkModeText`. Pass +`singleLetterPaddingBottom:` to override the engine's matching default. + ### Theming Every color the editor puts on screen reads from `MarkdownEditorTheme`: diff --git a/Sources/MarkdownEngineLatex/SwiftMathBridge.swift b/Sources/MarkdownEngineLatex/SwiftMathBridge.swift new file mode 100644 index 0000000..4059cee --- /dev/null +++ b/Sources/MarkdownEngineLatex/SwiftMathBridge.swift @@ -0,0 +1,180 @@ +// +// SwiftMathBridge.swift +// MarkdownEngineLatex +// +// Ready-made LatexRenderer conformance backed by SwiftMath. +// + +import AppKit +import Foundation +import SwiftMath +import MarkdownEngine + +/// A drop-in ``LatexRenderer`` backed by [SwiftMath]. +/// +/// Renders both block (`$$ … $$`) and inline (`$ … $`) LaTeX strings into +/// `NSImage`s using the Latin Modern math font. Results are cached per +/// (latex, font size, appearance, theme color fingerprint) so repeated +/// renders are free. +/// +/// Light/dark appearance is taken from the host editor's window +/// effective appearance, not from `NSApp.effectiveAppearance`, so apps +/// that force a light window when the system is in dark mode still get +/// correctly-tinted formulas. +/// +/// [SwiftMath]: https://github.com/mgriebling/SwiftMath +public final class SwiftMathBridge: LatexRenderer, @unchecked Sendable { + private struct CacheKey: Hashable { + let latex: String + let fontSize: CGFloat + let isDarkMode: Bool + let lightColorRGB: UInt32 + let darkColorRGB: UInt32 + } + + private struct CacheEntry { + let image: NSImage + let size: CGSize + let baselineOffset: CGFloat + } + + private let singleLetterPaddingBottom: CGFloat + private var cache: [CacheKey: CacheEntry] = [:] + private let cacheLock = NSLock() + + /// - Parameter singleLetterPaddingBottom: Extra bottom padding (in + /// points) added to single-letter formulas to prevent visual + /// clipping; matches the engine's + /// ``MarkdownEditorConfiguration/blockLatex/singleLetterPaddingBottom`` + /// default. Override to match a customized configuration. + public init(singleLetterPaddingBottom: CGFloat = 1.0) { + self.singleLetterPaddingBottom = singleLetterPaddingBottom + } + + /// Clears the rendered-image cache. Call after appearance flips if + /// the host code doesn't re-render formulas automatically. + public func clearCache() { + cacheLock.lock() + cache.removeAll() + cacheLock.unlock() + } + + // MARK: - LatexRenderer + + public func render( + latex: String, + fontSize: CGFloat, + theme: MarkdownEditorTheme + ) -> LatexRenderResult? { + let normalizedLatex = latex.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedLatex.isEmpty else { return nil } + + let appearance = NSApp.keyWindow?.effectiveAppearance ?? NSApp.effectiveAppearance + let isDarkMode = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua + let textColor = isDarkMode ? theme.latexDarkModeText : theme.latexLightModeText + let key = CacheKey( + latex: normalizedLatex, + fontSize: fontSize, + isDarkMode: isDarkMode, + lightColorRGB: Self.colorFingerprint(theme.latexLightModeText), + darkColorRGB: Self.colorFingerprint(theme.latexDarkModeText) + ) + + cacheLock.lock() + if let cached = cache[key] { + cacheLock.unlock() + return LatexRenderResult( + image: cached.image, + size: cached.size, + baselineOffset: cached.baselineOffset + ) + } + cacheLock.unlock() + + guard let entry = renderLatex(normalizedLatex, fontSize: fontSize, textColor: textColor) else { + return nil + } + + cacheLock.lock() + cache[key] = entry + cacheLock.unlock() + + return LatexRenderResult( + image: entry.image, + size: entry.size, + baselineOffset: entry.baselineOffset + ) + } + + // MARK: - Private + + /// Fold an `NSColor` to a 24-bit fingerprint that's good enough to + /// bust the cache when the theme changes the LaTeX text color. + private static func colorFingerprint(_ color: NSColor) -> UInt32 { + guard let rgb = color.usingColorSpace(.deviceRGB) else { return 0 } + let r = UInt32(max(0, min(255, Int(rgb.redComponent * 255)))) + let g = UInt32(max(0, min(255, Int(rgb.greenComponent * 255)))) + let b = UInt32(max(0, min(255, Int(rgb.blueComponent * 255)))) + return (r << 16) | (g << 8) | b + } + + private func renderLatex(_ latex: String, fontSize: CGFloat, textColor: NSColor) -> CacheEntry? { + let mathLabel = MTMathUILabel() + mathLabel.latex = latex + mathLabel.fontSize = fontSize + mathLabel.textColor = textColor + mathLabel.textAlignment = .left + mathLabel.labelMode = .text + + // Latin Modern Math gives the cleanest LaTeX glyphs at typical sizes. + if let mathFont = MTFontManager().font(withName: "latinmodern-math", size: fontSize) { + mathLabel.font = mathFont + } + + mathLabel.layoutSubtreeIfNeeded() + + guard let displayList = mathLabel.displayList else { return nil } + + // SwiftMath skips unsupported glyphs (e.g. emoji/raw Greek), which can yield + // zero-sized output. Bail instead of trying to render a 0x0 image — lockFocus + // (used internally by NSImage drawing) crashes on zero dimensions. + let exactWidth = displayList.width + let exactHeight = displayList.ascent + displayList.descent + guard exactWidth > 0, exactHeight > 0 else { return nil } + + let isSimpleSingleLetter = latex.range(of: #"^[A-Za-z]{1,3}$"#, options: .regularExpression) != nil + let paddingBottom: CGFloat = isSimpleSingleLetter ? singleLetterPaddingBottom : 0 + + let frameSize = CGSize( + width: ceil(exactWidth), + height: exactHeight + paddingBottom + ) + mathLabel.frame = CGRect(origin: .zero, size: frameSize) + + guard let image = renderLabelToImage(mathLabel, size: frameSize) else { + return nil + } + + return CacheEntry( + image: image, + size: frameSize, + baselineOffset: displayList.descent + ) + } + + private func renderLabelToImage(_ label: MTMathUILabel, size: CGSize) -> NSImage? { + // `bitmapImageRepForCachingDisplay` + `cacheDisplay(in:to:)` is the + // documented way to snapshot an NSView that isn't in a window. Setting + // `wantsLayer = true` and `layer.render(in:)` snapshots the (empty) + // backing layer instead of triggering MTMathUILabel's `draw(_:)`. + label.frame = CGRect(origin: .zero, size: size) + label.layoutSubtreeIfNeeded() + + let image = NSImage(size: size) + if let rep = label.bitmapImageRepForCachingDisplay(in: label.bounds) { + label.cacheDisplay(in: label.bounds, to: rep) + image.addRepresentation(rep) + } + return image + } +} From 18d4ae46818526a8bf408777d1b54f712f389235 Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Tue, 12 May 2026 09:17:41 +0200 Subject: [PATCH 09/12] upate Name --- Package.swift | 15 ++++++------ README.md | 24 +++++++++---------- .../HighlighterSwiftBridge.swift | 2 +- 3 files changed, 21 insertions(+), 20 deletions(-) rename Sources/{MarkdownEngineHighlighter => MarkdownEngineCodeBlocks}/HighlighterSwiftBridge.swift (99%) diff --git a/Package.swift b/Package.swift index 0b04b10..7bb0e68 100644 --- a/Package.swift +++ b/Package.swift @@ -9,17 +9,18 @@ import PackageDescription // itself has zero external dependencies. // // Users who want turnkey adapters for the two highest-friction protocols -// (syntax highlighting, LaTeX rendering) can additionally depend on the -// `MarkdownEngineHighlighter` and/or `MarkdownEngineLatex` products, -// which ship pre-built bridges backed by HighlighterSwift and SwiftMath -// respectively. Both products are opt-in: the core `MarkdownEngine` -// library stays free of those transitive dependencies at link time. +// (code-block styling/highlighting, LaTeX rendering) can additionally +// depend on the `MarkdownEngineCodeBlocks` and/or `MarkdownEngineLatex` +// products, which ship pre-built bridges backed by HighlighterSwift and +// SwiftMath respectively. Both products are opt-in: the core +// `MarkdownEngine` library stays free of those transitive dependencies +// at link time. let package = Package( name: "MarkdownEngine", platforms: [.macOS(.v14)], products: [ .library(name: "MarkdownEngine", targets: ["MarkdownEngine"]), - .library(name: "MarkdownEngineHighlighter", targets: ["MarkdownEngineHighlighter"]), + .library(name: "MarkdownEngineCodeBlocks", targets: ["MarkdownEngineCodeBlocks"]), .library(name: "MarkdownEngineLatex", targets: ["MarkdownEngineLatex"]), ], dependencies: [ @@ -29,7 +30,7 @@ let package = Package( targets: [ .target(name: "MarkdownEngine"), .target( - name: "MarkdownEngineHighlighter", + name: "MarkdownEngineCodeBlocks", dependencies: [ "MarkdownEngine", .product(name: "Highlighter", package: "HighlighterSwift"), diff --git a/README.md b/README.md index be69091..b598801 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ The package ships three library products — add only what you need: | Product | Use when | |---|---| | `MarkdownEngine` | You want the editor only. Zero external dependencies. | -| `MarkdownEngineHighlighter` | You want fenced-code syntax highlighting without writing your own bridge. Pulls in [HighlighterSwift](https://github.com/smittytone/HighlighterSwift) transitively. See [Customization → Syntax Highlighting](#syntax-highlighting). | +| `MarkdownEngineCodeBlocks` | You want the full visual code-block experience — background fill, monospace font, and syntax highlighting — without writing your own bridge. Pulls in [HighlighterSwift](https://github.com/smittytone/HighlighterSwift) transitively. See [Customization → Code Blocks](#code-blocks). | | `MarkdownEngineLatex` | You want LaTeX formula rendering without writing your own bridge. Pulls in [SwiftMath](https://github.com/mgriebling/SwiftMath) transitively. See [Customization → LaTeX Rendering](#latex-rendering). | ## Quick Start @@ -99,7 +99,7 @@ a no-op default so you only implement what you actually need: |---|---|---| | `WikiLinkResolver` | Resolve a `[[Name]]` to a stable opaque id | (your data model) | | `EmbeddedImageProvider` | Look up an `NSImage` for `![[Name]]` | (your asset store) | -| `SyntaxHighlighter` | Highlight code blocks for a given language | **`HighlighterSwiftBridge`** ([recommended](#syntax-highlighting)) — built on [HighlighterSwift](https://github.com/smittytone/HighlighterSwift) | +| `SyntaxHighlighter` | Highlight code blocks for a given language | **`HighlighterSwiftBridge`** ([recommended](#code-blocks)) — built on [HighlighterSwift](https://github.com/smittytone/HighlighterSwift) | | `LatexRenderer` | Render a LaTeX string to an `NSImage` | **`SwiftMathBridge`** ([recommended](#latex-rendering)) — built on [SwiftMath](https://github.com/mgriebling/SwiftMath) | Implement what you need and pass it through `MarkdownEditorServices`: @@ -119,18 +119,18 @@ configuration.services = MarkdownEditorServices( Each protocol and its no-op default are documented in DocC. -### Syntax Highlighting +### Code Blocks -**Recommended path: depend on the `MarkdownEngineHighlighter` product -and use the bundled `HighlighterSwiftBridge`.** Implementing -`SyntaxHighlighter` from scratch has subtle footguns the bridge -already handles — line-height metrics across light/dark themes, -appearance-change observation, layout-pass timing, font name extraction -from the theme. Use the bundle unless you specifically need a -non-HighlighterSwift library. +**Recommended path: depend on the `MarkdownEngineCodeBlocks` product +and use the bundled `HighlighterSwiftBridge`.** Rolling your own +`SyntaxHighlighter` has subtle footguns the bridge already handles — +line-height metrics across light/dark themes, appearance-change +observation, layout-pass timing, font name extraction from the theme, +and CSS-theme-derived background colors. Use the bundle unless you +specifically need a non-HighlighterSwift library. ```swift -import MarkdownEngineHighlighter +import MarkdownEngineCodeBlocks var configuration = MarkdownEditorConfiguration.default configuration.services = MarkdownEditorServices( @@ -145,7 +145,7 @@ are configurable via init params — see DocC. Need a different highlighter library entirely? Implement `SyntaxHighlighter` yourself (see [Service Protocols](#service-protocols) above for the declaration) and reference the bundled bridge in -`Sources/MarkdownEngineHighlighter/` as a working example. +`Sources/MarkdownEngineCodeBlocks/` as a working example. ### LaTeX Rendering diff --git a/Sources/MarkdownEngineHighlighter/HighlighterSwiftBridge.swift b/Sources/MarkdownEngineCodeBlocks/HighlighterSwiftBridge.swift similarity index 99% rename from Sources/MarkdownEngineHighlighter/HighlighterSwiftBridge.swift rename to Sources/MarkdownEngineCodeBlocks/HighlighterSwiftBridge.swift index 9e26302..af682e0 100644 --- a/Sources/MarkdownEngineHighlighter/HighlighterSwiftBridge.swift +++ b/Sources/MarkdownEngineCodeBlocks/HighlighterSwiftBridge.swift @@ -1,6 +1,6 @@ // // HighlighterSwiftBridge.swift -// MarkdownEngineHighlighter +// MarkdownEngineCodeBlocks // // Ready-made SyntaxHighlighter conformance backed by HighlighterSwift. // From 14ced669b4b7874eec53ed2acb811094d2e70d05 Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Tue, 12 May 2026 11:41:19 +0200 Subject: [PATCH 10/12] Fix code-block Y-jump and resize without per-keystroke perf cost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier prefix-ensureLayout on every viewRect call made boundingRect the perf bottleneck on large documents — every keystroke triggered N layout passes (one per visible code block). Replace with two narrow guards: - One-shot ensureLayout per document via a flag on the coordinator (`didEnsureLayoutForCurrentDocument`). Reset on document switch and on real viewport width changes (which invalidate TextKit's wrap layout). - frameDidChange now refreshes code-block overlays only when viewport width actually changes, filtering out TextKit's frequent height-only echoes during typing. Net result: zero per-keystroke ensureLayout cost, correct geometry on fresh load, and overlays follow window-width drags. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/MarkdownEngine/Renderer/LayoutBridge.swift | 11 ----------- .../NativeTextViewCoordinator+CodeBlocks.swift | 6 ++++++ .../Coordinator/NativeTextViewCoordinator.swift | 2 ++ .../TextView/NativeTextViewWrapper.swift | 12 +++++++++--- 4 files changed, 17 insertions(+), 14 deletions(-) diff --git a/Sources/MarkdownEngine/Renderer/LayoutBridge.swift b/Sources/MarkdownEngine/Renderer/LayoutBridge.swift index 9da4068..05ac48d 100644 --- a/Sources/MarkdownEngine/Renderer/LayoutBridge.swift +++ b/Sources/MarkdownEngine/Renderer/LayoutBridge.swift @@ -39,17 +39,6 @@ final class LayoutBridge { func boundingRect(forCharacterRange range: NSRange, in textContainer: NSTextContainer) -> CGRect { guard let textRange = textRange(for: range) else { return .zero } - // Ensure TextKit 2 has laid out everything *before* the queried - // range, not just the range itself. The Y position of `range` - // depends on the cumulative height of all preceding fragments; - // if any of them are still at preliminary metrics (e.g. before - // syntax-highlight font has been applied), the Y is wrong. - if let docStart = textLayoutManager.textContentManager?.documentRange.location, - let prefixRange = NSTextRange(location: docStart, end: textRange.endLocation) { - textLayoutManager.ensureLayout(for: prefixRange) - } else { - textLayoutManager.ensureLayout(for: textRange) - } var result = CGRect.null textLayoutManager.enumerateTextSegments( in: textRange, type: .standard, options: [] diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+CodeBlocks.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+CodeBlocks.swift index 34009a1..7f7165b 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+CodeBlocks.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+CodeBlocks.swift @@ -31,6 +31,12 @@ extension NativeTextViewCoordinator { let nsText = textView.string as NSString let scrollOffset = textView.enclosingScrollView?.contentView.bounds.origin ?? .zero + // One-shot full-document layout per document; fixes stale Y from TextKit 2's lazy layout without per-update cost. + if !didEnsureLayoutForCurrentDocument, let tlm = textView.textLayoutManager { + tlm.ensureLayout(for: tlm.documentRange) + didEnsureLayoutForCurrentDocument = true + } + let selections: [CodeBlockSelection] = cachedCodeBlockTokens.compactMap { originalIndex, token in guard !activeTokenIndices.contains(originalIndex) else { return nil } guard var boundingRect = textView.viewRect(forCharacterRange: token.range, using: layoutBridge) else { return nil } diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift index 7a2a339..51be957 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift @@ -39,6 +39,8 @@ public final class NativeTextViewCoordinator: NSObject, NSTextViewDelegate { var onInlineSelectionChange: ((InlineSelectionState?) -> Void)? var onCodeBlockSelectionChange: (([CodeBlockSelection]) -> Void)? var didInitialFormatting: Bool = false + /// One-shot guard so `updateCodeBlockSelection` only forces a full-document layout once per document. + var didEnsureLayoutForCurrentDocument: Bool = false var lastSyncedText: String var isProgrammaticEdit: Bool = false var isWritingToolsActive: Bool = false diff --git a/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift b/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift index e5676cd..0f99802 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift @@ -186,10 +186,15 @@ public struct NativeTextViewWrapper: NSViewRepresentable { textView.recalcOverscroll(for: scrollView) scrollView.contentView.postsBoundsChangedNotifications = true + var lastObservedViewportWidth = scrollView.contentView.bounds.width NotificationCenter.default.addObserver(forName: NSView.frameDidChangeNotification, object: scrollView.contentView, queue: nil) { _ in - // Code block button overlays depend on layout-relative rects; refresh - // them on every frame change so window resizes don't leave stale rects. - context.coordinator.updateCodeBlockSelection(textView: textView) + // Refresh code-block overlays only on real viewport width changes, not on TextKit height-only echoes during typing. + let newWidth = scrollView.contentView.bounds.width + if abs(newWidth - lastObservedViewportWidth) > 0.5 { + lastObservedViewportWidth = newWidth + context.coordinator.didEnsureLayoutForCurrentDocument = false + context.coordinator.updateCodeBlockSelection(textView: textView) + } // Only react with overscroll recalc when the viewport itself resizes // (window resize). Without this guard, TextKit-induced textView frame // changes echo back here and re-trigger recalcOverscroll, causing a @@ -286,6 +291,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable { context.coordinator.documentId = documentId textView.undoManager?.removeAllActions() context.coordinator.didInitialFormatting = false + context.coordinator.didEnsureLayoutForCurrentDocument = false context.coordinator.resetImageEmbedState() // Reset scroll to top of content so the previous file's scrollY // doesn't leak into a (potentially shorter) new file. From a1a4a36b93bbf81bcf11a7920fd0c36f2ad24e5e Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Tue, 12 May 2026 11:41:28 +0200 Subject: [PATCH 11/12] Make HighlighterSwiftBridge defaults match a polished editor look MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the CSS-theme-driven defaults with opaque code-block backgrounds (calibratedWhite 0.95 / 0.13) and an SF Mono → Menlo → system-monospace font chain — the same look Xcode, GitHub, and VS Code use. Also pin appearance to the editor's window rather than NSApp so apps that force a light window in dark mode still tint code blocks correctly. All three new defaults are init parameters; pass `nil` for the backgrounds or a custom `preferredFontNames` array to opt back into CSS-theme-driven appearance. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../HighlighterSwiftBridge.swift | 62 ++++++++++++------- 1 file changed, 40 insertions(+), 22 deletions(-) diff --git a/Sources/MarkdownEngineCodeBlocks/HighlighterSwiftBridge.swift b/Sources/MarkdownEngineCodeBlocks/HighlighterSwiftBridge.swift index af682e0..37fba5c 100644 --- a/Sources/MarkdownEngineCodeBlocks/HighlighterSwiftBridge.swift +++ b/Sources/MarkdownEngineCodeBlocks/HighlighterSwiftBridge.swift @@ -11,47 +11,59 @@ import Highlighter import MarkdownEngine extension Notification.Name { - /// Posted by ``HighlighterSwiftBridge`` after the macOS appearance flips - /// and the bridge has re-applied its light/dark theme. The engine - /// subscribes to this through ``SyntaxHighlighter/appearanceDidChangeNotification`` - /// so it can invalidate cached code-block attributes. + /// Posted by ``HighlighterSwiftBridge`` after the macOS appearance flips and themes are re-applied; the engine subscribes via ``SyntaxHighlighter/appearanceDidChangeNotification`` to invalidate cached attributes. public static let markdownEngineHighlighterDidChangeAppearance = Notification.Name("MarkdownEngineHighlighterDidChangeAppearance") } -/// A drop-in ``SyntaxHighlighter`` backed by HighlighterSwift. +/// Drop-in ``SyntaxHighlighter`` backed by HighlighterSwift. /// -/// Delegates the editor's code-block background color and code font to -/// HighlighterSwift's loaded theme, so changing the theme name updates -/// the entire code-block look in one place. +/// Defaults match the Nodes app's look: opaque light/dark code-block +/// backgrounds and an `SF Mono → Menlo → system monospace` font chain. +/// Override the init params if you'd rather adopt HighlighterSwift's +/// CSS-theme-driven background/font directly. /// -/// When `autoSwitchAppearance` is `true` (the default), the bridge -/// observes `AppleInterfaceThemeChangedNotification` and re-applies -/// `darkTheme` / `lightTheme` accordingly, then posts +/// When `autoSwitchAppearance` is `true`, the bridge observes +/// `AppleInterfaceThemeChangedNotification` and swaps `lightTheme` / +/// `darkTheme` accordingly, posting /// ``Notification/Name/markdownEngineHighlighterDidChangeAppearance`` so -/// the engine re-renders affected code blocks. +/// the engine can re-render code blocks. public final class HighlighterSwiftBridge: SyntaxHighlighter, @unchecked Sendable { private let highlighter: Highlighter? private let lightTheme: String private let darkTheme: String private let autoSwitchAppearance: Bool + private let lightBackground: NSColor + private let darkBackground: NSColor + private let preferredFontNames: [String] private var currentTheme: String = "" /// - Parameters: /// - lightTheme: HighlighterSwift theme name applied in light mode. /// - darkTheme: HighlighterSwift theme name applied in dark mode. - /// - autoSwitchAppearance: When `true`, observes the system - /// appearance and swaps themes automatically. Set to `false` to - /// pin the bridge to `lightTheme` regardless of mode. + /// - autoSwitchAppearance: When `true`, observes the system appearance + /// and swaps themes automatically. Set to `false` to pin to `lightTheme`. + /// - lightBackground: Code-block background in light mode. Pass `nil` + /// to use HighlighterSwift's CSS-theme background instead. + /// - darkBackground: Code-block background in dark mode. Pass `nil` + /// to use HighlighterSwift's CSS-theme background instead. + /// - preferredFontNames: PostScript font names tried in order before + /// falling back to the system monospace font. public init( lightTheme: String = "atom-one-light", darkTheme: String = "atom-one-dark", - autoSwitchAppearance: Bool = true + autoSwitchAppearance: Bool = true, + lightBackground: NSColor? = NSColor(calibratedWhite: 0.95, alpha: 1.0), + darkBackground: NSColor? = NSColor(calibratedWhite: 0.13, alpha: 1.0), + preferredFontNames: [String] = ["SF Mono", "Menlo"] ) { self.highlighter = Highlighter() self.lightTheme = lightTheme self.darkTheme = darkTheme self.autoSwitchAppearance = autoSwitchAppearance + self.lightBackground = lightBackground ?? .clear + self.darkBackground = darkBackground ?? .clear + self.preferredFontNames = preferredFontNames applyAppearanceTheme() if autoSwitchAppearance { @@ -72,15 +84,19 @@ public final class HighlighterSwiftBridge: SyntaxHighlighter, @unchecked Sendabl private func applyAppearanceTheme() { guard let highlighter else { return } - let isDark = autoSwitchAppearance && - NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua - let theme = isDark ? darkTheme : lightTheme + let theme = isDarkAppearance() ? darkTheme : lightTheme if currentTheme != theme { currentTheme = theme highlighter.setTheme(theme) } } + private func isDarkAppearance() -> Bool { + guard autoSwitchAppearance else { return false } + let appearance = NSApp.keyWindow?.effectiveAppearance ?? NSApp.effectiveAppearance + return appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua + } + // MARK: - SyntaxHighlighter public var appearanceDidChangeNotification: Notification.Name? { @@ -88,14 +104,16 @@ public final class HighlighterSwiftBridge: SyntaxHighlighter, @unchecked Sendabl } public func codeFont(size: CGFloat) -> NSFont { - if let themeFont = highlighter?.theme.codeFont { - return NSFont(name: themeFont.fontName, size: size) ?? themeFont + for name in preferredFontNames { + if let font = NSFont(name: name, size: size) { + return font + } } return .monospacedSystemFont(ofSize: size, weight: .regular) } public func backgroundColor() -> NSColor { - highlighter?.theme.themeBackgroundColour ?? .clear + isDarkAppearance() ? darkBackground : lightBackground } public func highlight(code: String, language: String?) -> NSAttributedString? { From 10bf41fb8496938e3b75faed159694f794c45776 Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Tue, 12 May 2026 12:02:50 +0200 Subject: [PATCH 12/12] Cache highlighted output in HighlighterSwiftBridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the original CodeHighlightService caching used in Nodes: NSCache with countLimit 256 / totalCostLimit 2MB, plus a parallel failed-highlight cache and an unsupportedLanguages set so a language Highlighter can't recognize by name falls back to auto-detect on first attempt and stays auto-detected after. Cache is busted on theme switch via applyAppearanceTheme. Without this every keystroke re-highlighted every visible code block through HighlighterSwift's JavaScriptCore bridge — the dominant per-keystroke cost in documents with non-trivial code-block density. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../HighlighterSwiftBridge.swift | 54 +++++++++++++++++-- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/Sources/MarkdownEngineCodeBlocks/HighlighterSwiftBridge.swift b/Sources/MarkdownEngineCodeBlocks/HighlighterSwiftBridge.swift index 37fba5c..c080cf5 100644 --- a/Sources/MarkdownEngineCodeBlocks/HighlighterSwiftBridge.swift +++ b/Sources/MarkdownEngineCodeBlocks/HighlighterSwiftBridge.swift @@ -38,6 +38,11 @@ public final class HighlighterSwiftBridge: SyntaxHighlighter, @unchecked Sendabl private let preferredFontNames: [String] private var currentTheme: String = "" + // HighlighterSwift's JavaScriptCore bridge is expensive — cache by (theme, language, code). + private let highlightCache = NSCache() + private let failedCache = NSCache() + private var unsupportedLanguages: Set = [] + /// - Parameters: /// - lightTheme: HighlighterSwift theme name applied in light mode. /// - darkTheme: HighlighterSwift theme name applied in dark mode. @@ -64,6 +69,10 @@ public final class HighlighterSwiftBridge: SyntaxHighlighter, @unchecked Sendabl self.lightBackground = lightBackground ?? .clear self.darkBackground = darkBackground ?? .clear self.preferredFontNames = preferredFontNames + highlightCache.countLimit = 256 + highlightCache.totalCostLimit = 2_000_000 + failedCache.countLimit = 256 + failedCache.totalCostLimit = 2_000_000 applyAppearanceTheme() if autoSwitchAppearance { @@ -82,12 +91,20 @@ public final class HighlighterSwiftBridge: SyntaxHighlighter, @unchecked Sendabl } } + /// Drops the internal highlight cache. Call after manual theme changes the bridge can't observe. + public func clearCache() { + highlightCache.removeAllObjects() + failedCache.removeAllObjects() + } + private func applyAppearanceTheme() { guard let highlighter else { return } let theme = isDarkAppearance() ? darkTheme : lightTheme if currentTheme != theme { currentTheme = theme highlighter.setTheme(theme) + highlightCache.removeAllObjects() + failedCache.removeAllObjects() } } @@ -119,10 +136,41 @@ public final class HighlighterSwiftBridge: SyntaxHighlighter, @unchecked Sendabl public func highlight(code: String, language: String?) -> NSAttributedString? { applyAppearanceTheme() guard let highlighter else { return nil } + let normalized = language?.lowercased().trimmingCharacters(in: .whitespaces) - if let lang = normalized, !lang.isEmpty { - return highlighter.highlight(code, as: lang) + let langKey = (normalized?.isEmpty == false) ? normalized! : "auto" + let cacheKey = "\(currentTheme)|\(langKey)|\(code)" as NSString + + if let cached = highlightCache.object(forKey: cacheKey) { + return cached + } + if failedCache.object(forKey: cacheKey) != nil { + return nil + } + + let explicit = normalized.flatMap { $0.isEmpty ? nil : $0 } + let skipExplicit = explicit.map { unsupportedLanguages.contains($0) } ?? false + + var highlighted: NSAttributedString? + if let lang = explicit, !skipExplicit { + highlighted = highlighter.highlight(code, as: lang) + if highlighted == nil { + // Unknown language — remember and fall back to auto-detect. + unsupportedLanguages.insert(lang) + highlighted = highlighter.highlight(code) + } + } else { + highlighted = highlighter.highlight(code) + } + + // Immutable copy so the cached entry can't be mutated by callers. + let result = highlighted.map { NSAttributedString(attributedString: $0) } + if let result { + highlightCache.setObject(result, forKey: cacheKey, cost: code.utf16.count) + failedCache.removeObject(forKey: cacheKey) + return result } - return highlighter.highlight(code, as: nil) + failedCache.setObject(NSNumber(value: true), forKey: cacheKey, cost: code.utf16.count) + return nil } }