From 433c5c3c2a04b5b34f3417ebd1f7a7a90fd3c443 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sat, 17 Oct 2026 01:13:47 +0200 Subject: [PATCH] fix: highlight fenced code by language --- Makefile | 2 +- Package.resolved | 15 ++++++ Package.swift | 5 +- README.md | 2 + Sources/Conjure/NotesController.swift | 46 ++++++++++++++++++- .../CodeHighlightingTests.swift | 31 +++++++++++++ 6 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 Package.resolved create mode 100644 Tests/ConjureKitTests/CodeHighlightingTests.swift diff --git a/Makefile b/Makefile index cc0deac..306e519 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ build: mkdir -p $(APP)/Contents/MacOS $(APP)/Contents/Resources cp .build/release/Conjure $(APP)/Contents/MacOS/Conjure cp Resources/Info.plist $(APP)/Contents/Info.plist - cp -R .build/release/Conjure_ConjureKit.bundle $(APP)/Contents/Resources/ + cp -R .build/release/*.bundle $(APP)/Contents/Resources/ swift Scripts/icon.swift .build/Conjure.iconset iconutil -c icns .build/Conjure.iconset -o $(APP)/Contents/Resources/Conjure.icns codesign --force --sign "$(CODE_SIGN_IDENTITY)" $(APP) diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..a687faf --- /dev/null +++ b/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "fc870f54e8ae0c2fe51ba1e87667691472a20f609075b305c4117df7d7db9336", + "pins" : [ + { + "identity" : "highlighterswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/smittytone/HighlighterSwift", + "state" : { + "revision" : "fe7aae9c9b31d3b296fd3d2dd575e1a207bb29e0", + "version" : "3.1.0" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift index 138f611..071e362 100644 --- a/Package.swift +++ b/Package.swift @@ -5,9 +5,12 @@ let package = Package( name: "Conjure", platforms: [.macOS(.v14)], products: [.executable(name: "Conjure", targets: ["Conjure"])], + dependencies: [.package(url: "https://github.com/smittytone/HighlighterSwift", from: "3.1.0")], targets: [ .target(name: "ConjureKit", resources: [.process("Resources")]), - .executableTarget(name: "Conjure", dependencies: ["ConjureKit"]), + .executableTarget(name: "Conjure", dependencies: [ + "ConjureKit", .product(name: "Highlighter", package: "HighlighterSwift"), + ]), .testTarget(name: "ConjureKitTests", dependencies: ["ConjureKit", "Conjure"]), ] ) diff --git a/README.md b/README.md index 81424a0..c1620ef 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,8 @@ Search for **Conjure Notes**, or assign it a global shortcut in Settings. **New Edit Markdown directly: headings, bold, italic, lists, links, inline code, and fenced code blocks are highlighted while the source remains visible. Paste images or use **Insert Image…** to embed a local image. Command-Z undoes edits; Command-Shift-Z redoes them. Undo history is cleared when switching notes so edits cannot spill into another note. +Fenced code uses [HighlighterSwift](https://github.com/smittytone/HighlighterSwift) (Highlight.js) for language-aware token colors. Add a language after the opening backticks or tildes, for example `py`, `python`, `swift`, `js`, or `json`. Unlabeled fences use automatic detection; unknown languages remain plain monospace. Highlighting runs entirely offline. + Notes autosave locally as RTFD packages, including embedded images, under `~/Library/Application Support/Conjure/Notes`. No account, network access, or cloud sync is involved. Save failures appear below the editor and prevent switching, closing, or quitting until saving succeeds, preserving the unsaved buffer. The Back chevron restores the launcher search; Escape or Command-W dismisses Conjure. ## Translation diff --git a/Sources/Conjure/NotesController.swift b/Sources/Conjure/NotesController.swift index 52b4183..2e49fc9 100644 --- a/Sources/Conjure/NotesController.swift +++ b/Sources/Conjure/NotesController.swift @@ -1,6 +1,7 @@ import AppKit import ConjureKit import UniformTypeIdentifiers +import Highlighter @MainActor final class NotesController: NSViewController, NSTextViewDelegate { @@ -208,9 +209,36 @@ enum MarkdownHighlighting { (try! NSRegularExpression(pattern: "\\*\\*[^\\n]+?\\*\\*|__[^\\n]+?__"), [.font: NSFont.boldSystemFont(ofSize: 14)]), (try! NSRegularExpression(pattern: "(? [(range: NSRange, body: NSRange, language: String?)] { + let source = text as NSString + var blocks: [(NSRange, NSRange, String?)] = [] + var cursor = 0 + while let opening = openingFence.firstMatch(in: text, range: NSRange(location: cursor, length: source.length - cursor)) { + let fence = source.substring(with: opening.range(at: 1)) + let marker = NSRegularExpression.escapedPattern(for: String(fence.first!)) + let closing = try! NSRegularExpression(pattern: "(?m)^ {0,3}\(marker){\(fence.count),}[ \\t]*\\r?$") + let start = NSMaxRange(opening.range) + let end = closing.firstMatch(in: text, range: NSRange(location: start, length: source.length - start)) + let bodyEnd = end?.range.location ?? source.length + cursor = end.map { NSMaxRange($0.range) } ?? source.length + let language = source.substring(with: opening.range(at: 2)).split(whereSeparator: { $0.isWhitespace }).first.map(String.init) + blocks.append((NSRange(location: opening.range.location, length: cursor - opening.range.location), + NSRange(location: start, length: bodyEnd - start), language)) + if cursor == source.length { break } + } + return blocks + } + static func apply(to editor: NSTextView) { guard let storage = editor.textStorage else { return } let text = editor.string @@ -226,11 +254,27 @@ enum MarkdownHighlighting { if scale < 1 { image.size = NSSize(width: size.width * scale, height: size.height * scale) } } storage.addAttributes([.font: NSFont.systemFont(ofSize: 14), .foregroundColor: SunsetPalette.text], range: range) + let blocks = codeBlocks(in: text) for (expression, attributes) in rules { for match in expression.matches(in: text, range: range) { + guard !blocks.contains(where: { NSIntersectionRange($0.range, match.range).length > 0 }) else { continue } storage.addAttributes(attributes, range: match.range) } } + for block in blocks { + storage.addAttributes([.font: NSFont.monospacedSystemFont(ofSize: 14, weight: .regular), + .foregroundColor: SunsetPalette.secondaryText], range: block.range) + storage.addAttribute(.foregroundColor, value: SunsetPalette.text, range: block.body) + let code = (text as NSString).substring(with: block.body) + guard let highlighted = highlighter.highlight(code, as: block.language), + highlighted.string == code else { continue } + highlighted.enumerateAttribute(.foregroundColor, in: NSRange(location: 0, length: highlighted.length)) { color, range, _ in + if let color { + storage.addAttribute(.foregroundColor, value: color, + range: NSRange(location: block.body.location + range.location, length: range.length)) + } + } + } editor.typingAttributes = [.font: NSFont.systemFont(ofSize: 14), .foregroundColor: SunsetPalette.text] } } diff --git a/Tests/ConjureKitTests/CodeHighlightingTests.swift b/Tests/ConjureKitTests/CodeHighlightingTests.swift new file mode 100644 index 0000000..a913224 --- /dev/null +++ b/Tests/ConjureKitTests/CodeHighlightingTests.swift @@ -0,0 +1,31 @@ +import AppKit +import Testing +@testable import Conjure + +@Test @MainActor func fencedCodeHighlightsTokensWithoutChangingSourceOrApplyingMarkdown() throws { + _ = NSApplication.shared + for fence in ["```", "~~~~"] { + let text = "# Heading 🦊\n\(fence)py\ndef main():\n # comment\n return \"**not bold**\"\n\(fence)\nAfter\n```unknown-language\n# literal\n```" + let editor = NSTextView() + editor.string = text + MarkdownHighlighting.apply(to: editor) + let storage = try #require(editor.textStorage) + func attributes(_ token: String) -> [NSAttributedString.Key: Any] { + storage.attributes(at: (text as NSString).range(of: token).location, effectiveRange: nil) + } + let keyword = try #require(attributes("def")[.foregroundColor] as? NSColor) + let name = try #require(attributes("main")[.foregroundColor] as? NSColor) + let comment = try #require(attributes("# comment")[.foregroundColor] as? NSColor) + #expect(keyword != name) + #expect(comment != keyword) + #expect((attributes("# comment")[.font] as? NSFont)?.pointSize == 14) + #expect((attributes("**not bold**")[.font] as? NSFont)?.isFixedPitch == true) + #expect((attributes("# literal")[.font] as? NSFont)?.pointSize == 14) + #expect((attributes("After")[.font] as? NSFont)?.isFixedPitch == false) + #expect(storage.string == text) + editor.string = "```python\ndef main():\n pass" + MarkdownHighlighting.apply(to: editor) + let unfinished = try #require(editor.textStorage) + #expect(unfinished.attribute(.foregroundColor, at: 10, effectiveRange: nil) as? NSColor == keyword) + } +}