fix: highlight fenced code by language

This commit is contained in:
2026-10-17 01:13:47 +02:00
parent e36a8bbc6b
commit 433c5c3c2a
6 changed files with 98 additions and 3 deletions
+1 -1
View File
@@ -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)
+15
View File
@@ -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
}
+4 -1
View File
@@ -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"]),
]
)
+2
View File
@@ -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
+45 -1
View File
@@ -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: "(?<!\\*)\\*(?!\\*)[^*\\n]+\\*(?!\\*)|(?<!_)_(?!_)[^_\\n]+_(?!_)"), [.font: NSFontManager.shared.convert(.systemFont(ofSize: 14), toHaveTrait: .italicFontMask)]),
(try! NSRegularExpression(pattern: "!?\\[[^\\]\\n]+\\]\\([^\\n)]+\\)|(?m)^\\s*(?:[-+*]|[0-9]+\\.)\\s"), [.foregroundColor: SunsetPalette.accent]),
(try! NSRegularExpression(pattern: "`[^`\\n]+`|(?ms)^```[^\\n]*\\n.*?(?:^```[^\\n]*$|\\z)"), [.font: NSFont.monospacedSystemFont(ofSize: 14, weight: .regular), .foregroundColor: NSColor.systemGreen]),
(try! NSRegularExpression(pattern: "`[^`\\n]+`"), [.font: NSFont.monospacedSystemFont(ofSize: 14, weight: .regular), .foregroundColor: NSColor.systemGreen]),
]
private static let highlighter: Highlighter = {
guard let value = Highlighter() else { preconditionFailure("Missing bundled syntax highlighter") }
value.setTheme("atom-one-dark", withFont: "Menlo-Regular", ofSize: 14)
return value
}()
private static let openingFence = try! NSRegularExpression(pattern: "(?m)^ {0,3}(`{3,}|~{3,})([^\\r\\n]*)\\r?\\n")
private static func codeBlocks(in text: String) -> [(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]
}
}
@@ -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)
}
}