fix: highlight fenced code by language
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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"]),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user