diff --git a/README.md b/README.md index bb260a5..a755a3e 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ See the [installation docs](docs/installation/) for setup instructions: - [VS Code](docs/installation/vscode.md) +- [macOS Quick Look](editors/macos/README.md) — Finder file recognition + syntax-highlighted preview for `.jaw` files ## Syntax diff --git a/editors/macos/.gitignore b/editors/macos/.gitignore new file mode 100644 index 0000000..08d9f0f --- /dev/null +++ b/editors/macos/.gitignore @@ -0,0 +1,8 @@ +# Generated by `xcodegen generate` — do not commit. +JAWQuickLook.xcodeproj/ + +# Xcode build output & local state. +build/ +DerivedData/ +*.xcuserstate +xcuserdata/ diff --git a/editors/macos/JAWQuickLook/Info.plist b/editors/macos/JAWQuickLook/Info.plist new file mode 100644 index 0000000..26227f8 --- /dev/null +++ b/editors/macos/JAWQuickLook/Info.plist @@ -0,0 +1,79 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + JAW Quick Look + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSPrincipalClass + NSApplication + NSHumanReadableCopyright + JAW — Just A Word. MIT Licensed. + + + CFBundleDocumentTypes + + + CFBundleTypeName + JAW Pseudocode + CFBundleTypeRole + Viewer + LSHandlerRank + Owner + LSItemContentTypes + + com.dishmint.jaw.source + + + + + + UTExportedTypeDeclarations + + + UTTypeIdentifier + com.dishmint.jaw.source + UTTypeDescription + JAW Pseudocode + UTTypeConformsTo + + public.source-code + public.utf8-plain-text + + UTTypeTagSpecification + + public.filename-extension + + jaw + + public.mime-type + + text/x-jaw + + + + + + diff --git a/editors/macos/JAWQuickLook/JAWQuickLook.entitlements b/editors/macos/JAWQuickLook/JAWQuickLook.entitlements new file mode 100644 index 0000000..18aff0c --- /dev/null +++ b/editors/macos/JAWQuickLook/JAWQuickLook.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-only + + + diff --git a/editors/macos/JAWQuickLook/JAWQuickLookApp.swift b/editors/macos/JAWQuickLook/JAWQuickLookApp.swift new file mode 100644 index 0000000..e4f4cc9 --- /dev/null +++ b/editors/macos/JAWQuickLook/JAWQuickLookApp.swift @@ -0,0 +1,38 @@ +import SwiftUI + +// The container app exists so macOS has somewhere to register the `jaw` UTI and +// load the Quick Look extension. It shows a short explainer window; the real work +// happens in JAWQuickLookExtension. +@main +struct JAWQuickLookApp: App { + var body: some Scene { + WindowGroup("JAW Quick Look") { + ContentView() + } + } +} + +struct ContentView: View { + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text("JAW Quick Look") + .font(.system(size: 22, weight: .bold)) + + Text("This app registers the .jaw file type with macOS and provides a " + + "syntax-highlighted Quick Look preview.") + .fixedSize(horizontal: false, vertical: true) + + Divider() + + VStack(alignment: .leading, spacing: 8) { + Label("Keep this app in /Applications.", systemImage: "folder") + Label("Select a .jaw file in Finder and press Space.", systemImage: "space") + Label("You can quit this window — the preview keeps working.", + systemImage: "checkmark.seal") + } + .font(.callout) + } + .padding(28) + .frame(width: 420) + } +} diff --git a/editors/macos/JAWQuickLookExtension/Info.plist b/editors/macos/JAWQuickLookExtension/Info.plist new file mode 100644 index 0000000..2c2f5b8 --- /dev/null +++ b/editors/macos/JAWQuickLookExtension/Info.plist @@ -0,0 +1,42 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + JAW Preview + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.quicklook.preview + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).PreviewViewController + NSExtensionAttributes + + + QLSupportedContentTypes + + com.dishmint.jaw.source + + QLSupportsSearchableItems + + + + + diff --git a/editors/macos/JAWQuickLookExtension/JAWHighlighter.swift b/editors/macos/JAWQuickLookExtension/JAWHighlighter.swift new file mode 100644 index 0000000..7afb2e7 --- /dev/null +++ b/editors/macos/JAWQuickLookExtension/JAWHighlighter.swift @@ -0,0 +1,210 @@ +import Foundation + +// A small, dependency-free JAW highlighter. It mirrors the constructs the +// TextMate grammar (editors/vscode/syntaxes/jaw.tmLanguage.json) recognizes, +// but as a line-oriented tokenizer that emits HTML spans. It is intentionally +// approximate — a Quick Look preview only needs to read well, not to match the +// parser exactly — and it never throws: anything it can't classify falls through +// as plain, escaped text. +enum JAWHighlighter { + static func html(for source: String) -> String { + let lines = source.components(separatedBy: "\n") + let body = lines.map(renderLine).joined(separator: "\n") + return page(body: body) + } + + // MARK: - Line rendering + + private static let markerSymbols: Set = + ["^", "*", "•", "!", ">", "~", "&", "+", "-"] + + private static func renderLine(_ line: String) -> String { + let (indent, rest) = splitLeadingWhitespace(line) + let lineClass = lineLevelClass(for: rest) + let inner = escape(indent) + tokenize(rest) + return "\(inner)" + } + + // Classify the whole line by its leading marker so notes/logs/comments can + // carry the spec's emphasis (notes are red + bold, comments muted, etc.). + private static func lineLevelClass(for rest: String) -> String? { + if rest.hasPrefix("[!]") { return "note" } + if rest.hasPrefix("[•]") { return "log" } + if rest.hasPrefix("[*]") || rest.hasPrefix("[^]") { return "comment" } + return nil + } + + // MARK: - Tokenizing + + // One pass, non-overlapping. Order inside the alternation matters only where + // patterns could both start at the same index; brackets and function refs + // are anchored to distinct lead chars so they don't collide. + private static let tokenRegex: NSRegularExpression = { + let pattern = [ + "(/[A-Za-z][A-Za-z0-9_]*)", // 1 function ref + "(\\[[^\\]\\n]*\\])", // 2 bracketed token + "(#[A-Za-z][A-Za-z0-9_]*(?::[^\\s]+)?)", // 3 decorator + "(—)", // 4 em dash separator + "(\\+=|\\*\\*|==|!=|>=|<=|<<|[-+*/<>=?|@:])", // 5 operator / access + "(\\b\\d+(?:\\.\\d+)?\\b)" // 6 number + ].joined(separator: "|") + // swiftlint:disable:next force_try + return try! NSRegularExpression(pattern: pattern) + }() + + private static func tokenize(_ text: String) -> String { + guard !text.isEmpty else { return "" } + let ns = text as NSString + let full = NSRange(location: 0, length: ns.length) + var out = "" + var cursor = 0 + + tokenRegex.enumerateMatches(in: text, range: full) { match, _, _ in + guard let match = match else { return } + let r = match.range + if r.location > cursor { + let gap = ns.substring(with: NSRange(location: cursor, length: r.location - cursor)) + out += escape(gap) + } + let token = ns.substring(with: r) + out += span(for: token, match: match) + cursor = r.location + r.length + } + + if cursor < ns.length { + out += escape(ns.substring(from: cursor)) + } + return out + } + + private static func span(for token: String, match: NSTextCheckingResult) -> String { + // Which alternation group fired tells us the kind without re-matching. + if match.range(at: 1).location != NSNotFound { + return wrap(token, "fn") + } + if match.range(at: 2).location != NSNotFound { + return bracketSpan(token) + } + if match.range(at: 3).location != NSNotFound { + return wrap(token, "deco") + } + if match.range(at: 4).location != NSNotFound { + return wrap(token, "sep") + } + if match.range(at: 5).location != NSNotFound { + return wrap(token, "op") + } + if match.range(at: 6).location != NSNotFound { + return wrap(token, "num") + } + return escape(token) + } + + // `[42]` is a step, `[^]`/`[~]`/… are markers, everything else (`[V]`, `[ID]`) + // is a variable reference. + private static func bracketSpan(_ token: String) -> String { + let inner = String(token.dropFirst().dropLast()) + if !inner.isEmpty && inner.allSatisfy({ $0.isNumber }) { + return wrap(token, "step") + } + if markerSymbols.contains(inner) { + return wrap(token, "marker") + } + return wrap(token, "var") + } + + // MARK: - HTML helpers + + private static func wrap(_ text: String, _ cls: String) -> String { + "\(escape(text))" + } + + private static func splitLeadingWhitespace(_ line: String) -> (String, String) { + guard let idx = line.firstIndex(where: { !$0.isWhitespace || $0 == "\n" }) else { + return (line, "") + } + return (String(line[.. String { + var r = "" + r.reserveCapacity(s.count) + for ch in s { + switch ch { + case "&": r += "&" + case "<": r += "<" + case ">": r += ">" + default: r.append(ch) + } + } + return r + } + + // MARK: - Page shell + + private static func page(body: String) -> String { + """ + + + + + + + + +
\(body)
+ + + """ + } + + // Palette tracks the VS Code extension where it can (the [•] log amber comes + // straight from extension.ts). Light and dark variants via prefers-color-scheme. + private static let css = """ + :root { + --bg: #ffffff; --fg: #1f2328; + --marker: #6f42c1; --step: #0969da; --var: #0a7ea4; + --fn: #8250df; --deco: #953800; --sep: #6e7781; + --op: #cf222e; --num: #0550ae; --log: #b45309; + --comment: #6e7781; --note: #cf222e; + } + @media (prefers-color-scheme: dark) { + :root { + --bg: #1e1e1e; --fg: #d4d4d4; + --marker: #c586c0; --step: #569cd6; --var: #4ec9b0; + --fn: #dcdcaa; --deco: #ce9178; --sep: #808080; + --op: #d16969; --num: #b5cea8; --log: #d7ba7d; + --comment: #6a9955; --note: #f14c4c; + } + } + html, body { margin: 0; padding: 0; background: var(--bg); } + pre.jaw { + margin: 0; + padding: 16px 20px; + background: var(--bg); + color: var(--fg); + font-family: ui-monospace, "SF Mono", Menlo, Monaco, "Cascadia Code", monospace; + font-size: 13px; + line-height: 1.5; + white-space: pre; + tab-size: 4; + -moz-tab-size: 4; + } + .ln { display: block; min-height: 1.5em; } + .marker { color: var(--marker); font-weight: 600; } + .step { color: var(--step); font-weight: 600; } + .var { color: var(--var); font-weight: 600; } + .fn { color: var(--fn); } + .deco { color: var(--deco); } + .sep { color: var(--sep); } + .op { color: var(--op); } + .num { color: var(--num); } + .ln.note { color: var(--note); font-weight: 700; } + .ln.note .marker { color: var(--note); } + .ln.log .marker { color: var(--log); } + .ln.comment { color: var(--comment); font-style: italic; } + .ln.comment .var, .ln.comment .marker { color: inherit; } + """ +} diff --git a/editors/macos/JAWQuickLookExtension/JAWQuickLookExtension.entitlements b/editors/macos/JAWQuickLookExtension/JAWQuickLookExtension.entitlements new file mode 100644 index 0000000..18aff0c --- /dev/null +++ b/editors/macos/JAWQuickLookExtension/JAWQuickLookExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-only + + + diff --git a/editors/macos/JAWQuickLookExtension/PreviewViewController.swift b/editors/macos/JAWQuickLookExtension/PreviewViewController.swift new file mode 100644 index 0000000..18ef156 --- /dev/null +++ b/editors/macos/JAWQuickLookExtension/PreviewViewController.swift @@ -0,0 +1,51 @@ +import Cocoa +import Quartz +import WebKit + +// Renders a .jaw file as syntax-highlighted HTML inside a WKWebView. Quick Look +// instantiates this class (named in Info.plist) for each preview request. +class PreviewViewController: NSViewController, QLPreviewingController, WKNavigationDelegate { + private var webView: WKWebView! + private var loadContinuation: CheckedContinuation? + + override func loadView() { + let configuration = WKWebViewConfiguration() + let web = WKWebView(frame: .zero, configuration: configuration) + web.navigationDelegate = self + self.webView = web + self.view = web + } + + func preparePreviewOfFile(at url: URL) async throws { + let data = try Data(contentsOf: url) + // .jaw is UTF-8 by spec (em dashes, the [•] marker); fall back to a lossy + // decode rather than failing the preview outright. + let source = String(data: data, encoding: .utf8) + ?? String(decoding: data, as: UTF8.self) + let html = JAWHighlighter.html(for: source) + + // Wait for the page to finish loading so Quick Look captures the rendered + // result, not a blank web view. + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + self.loadContinuation = continuation + self.webView.loadHTMLString(html, baseURL: nil) + } + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + loadContinuation?.resume() + loadContinuation = nil + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + loadContinuation?.resume(throwing: error) + loadContinuation = nil + } + + func webView(_ webView: WKWebView, + didFailProvisionalNavigation navigation: WKNavigation!, + withError error: Error) { + loadContinuation?.resume(throwing: error) + loadContinuation = nil + } +} diff --git a/editors/macos/README.md b/editors/macos/README.md new file mode 100644 index 0000000..a47fad2 --- /dev/null +++ b/editors/macos/README.md @@ -0,0 +1,126 @@ +# JAW Quick Look (macOS) + +Makes `.jaw` a **recognized file type** in Finder and adds a **syntax-highlighted +Quick Look preview** (press Space on a `.jaw` file). Resolves +[#63](https://github.com/dishmint/jaw/issues/63). + +It ships as a small macOS app (`JAW Quick Look.app`) that contains a Quick Look +preview extension. The app has to exist because macOS only registers a file type +(a Uniform Type Identifier) and loads a preview extension when they're declared by +an installed application bundle — there's no standalone "register this extension" +on modern macOS. + +| Piece | Role | +| --- | --- | +| `JAWQuickLook` (app) | Exports the `com.dishmint.jaw.source` UTI for the `.jaw` extension and hosts the preview extension. | +| `JAWQuickLookExtension` (app extension) | A `QLPreviewingController` that renders the file as highlighted HTML in a `WKWebView`. | +| `JAWHighlighter.swift` | Dependency-free, line-oriented JAW → HTML highlighter. Light + dark via `prefers-color-scheme`. | + +Because the UTI conforms to `public.source-code`, even before the extension loads +macOS will already preview `.jaw` files as plain text instead of "unknown file". + +## Requirements + +- macOS 12 or later +- Xcode 14+ (command-line tools alone are not enough — this builds an app bundle) +- [XcodeGen](https://github.com/yonom/XcodeGen): `brew install xcodegen` + +The Xcode project is **generated** from `project.yml` rather than checked in, so the +repo stays free of a giant `project.pbxproj`. Regenerate it any time the file set +changes. + +## Build + +```bash +cd editors/macos +xcodegen generate # writes JAWQuickLook.xcodeproj +open JAWQuickLook.xcodeproj # then ⌘R, or build from the CLI below +``` + +Command-line build: + +```bash +cd editors/macos +xcodegen generate +xcodebuild -project JAWQuickLook.xcodeproj \ + -scheme JAWQuickLook \ + -configuration Release \ + -derivedDataPath build +# Result: build/Build/Products/Release/JAW Quick Look.app +``` + +### Signing + +Set your Apple Developer **Team ID** so the app and its embedded extension share a +signing identity — either edit `DEVELOPMENT_TEAM` in `project.yml` (then +re-run `xcodegen generate`), or pick a team in Xcode's *Signing & Capabilities* +tab. For purely local testing, Xcode's automatic "Sign to Run Locally" works too. + +## Install + +macOS discovers extensions from apps in a launchable location. Move the built app +into `/Applications` (or `~/Applications`) and launch it once: + +```bash +cp -R "build/Build/Products/Release/JAW Quick Look.app" /Applications/ +open "/Applications/JAW Quick Look.app" +``` + +Launching registers the UTI and the extension with Launch Services. You can quit +the window afterward — the preview keeps working. + +If macOS doesn't pick it up immediately, nudge Launch Services and the Quick Look +daemon: + +```bash +/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister \ + -f "/Applications/JAW Quick Look.app" +qlmanage -r && qlmanage -r cache +``` + +> **Gatekeeper note:** an unsigned/un-notarized app downloaded from the internet +> gets quarantined. If you distribute the built `.app`, the same notarization work +> tracked in [#24](https://github.com/dishmint/jaw/issues/24) applies, or users can +> clear the flag with `xattr -dr com.apple.quarantine "/Applications/JAW Quick Look.app"`. + +## Test + +In Finder, select any file from `../../samples/` (e.g. `full.jaw`, `notes.jaw`) and +press Space. + +From the command line, render a preview without Finder: + +```bash +qlmanage -p ../../samples/full.jaw # opens a preview window +qlmanage -m plugins | grep -i jaw # confirm the generator is registered +``` + +Tail the extension's logs while previewing: + +```bash +log stream --predicate 'subsystem CONTAINS "quicklook"' --info +``` + +## How highlighting works + +`JAWHighlighter` tokenizes each line with a single combined regular expression and +emits ``s with CSS classes: + +| Class | JAW construct | +| --- | --- | +| `marker` | `[^] [*] [•] [!] [>] [~] [&] [+] [-]` | +| `step` | `[1]`, `[2]`, … (all-digit brackets) | +| `var` | `[V]`, `[ID]` variable refs | +| `fn` | `/Name` function refs | +| `deco` | `#name` / `#name:value` decorators | +| `sep` | the `—` em dash | +| `op` | operators and `@` array access | +| `num` | numeric literals | + +Line-level classes (`note`, `log`, `comment`) carry the spec's emphasis — important +notes render red + bold, comments muted/italic, the `[•]` log marker amber. The +palette tracks the VS Code extension (`editors/vscode/src/extension.ts`). + +It's intentionally approximate: a preview only needs to read well, and the +highlighter never fails — anything it can't classify falls through as plain text. +The authoritative grammar remains `jaw-grammar.md` and the parser in `jaw-parse`. diff --git a/editors/macos/project.yml b/editors/macos/project.yml new file mode 100644 index 0000000..bf78ada --- /dev/null +++ b/editors/macos/project.yml @@ -0,0 +1,59 @@ +# XcodeGen spec for the JAW Quick Look app + preview extension. +# +# brew install xcodegen +# xcodegen generate # produces JAWQuickLook.xcodeproj +# open JAWQuickLook.xcodeproj +# +# See README.md for the full build / install / test walkthrough. + +name: JAWQuickLook +options: + bundleIdPrefix: com.dishmint.jaw + createIntermediateGroups: true + deploymentTarget: + macOS: "12.0" + +settings: + base: + SWIFT_VERSION: "5.0" + MARKETING_VERSION: "0.1.0" + CURRENT_PROJECT_VERSION: "1" + GENERATE_INFOPLIST_FILE: NO + CODE_SIGN_STYLE: Automatic + # Set DEVELOPMENT_TEAM here (or in Xcode) to your Apple Developer Team ID so + # the app and the embedded extension are signed with the same identity. + DEVELOPMENT_TEAM: "" + +targets: + # The container app. Its only jobs are to (a) export the `jaw` Uniform Type + # Identifier so Finder recognizes .jaw files, and (b) host the preview + # extension. macOS will not load an extension that lives outside an app. + JAWQuickLook: + type: application + platform: macOS + sources: + - path: JAWQuickLook + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.dishmint.jaw.quicklook + INFOPLIST_FILE: JAWQuickLook/Info.plist + CODE_SIGN_ENTITLEMENTS: JAWQuickLook/JAWQuickLook.entitlements + ENABLE_HARDENED_RUNTIME: YES + COMBINE_HIDPI_IMAGES: YES + dependencies: + - target: JAWQuickLookExtension + embed: true + + # The Quick Look preview extension. Renders a .jaw file as syntax-highlighted + # HTML inside a WKWebView when you press space in Finder. + JAWQuickLookExtension: + type: app-extension + platform: macOS + sources: + - path: JAWQuickLookExtension + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.dishmint.jaw.quicklook.JAWQuickLookExtension + INFOPLIST_FILE: JAWQuickLookExtension/Info.plist + CODE_SIGN_ENTITLEMENTS: JAWQuickLookExtension/JAWQuickLookExtension.entitlements + ENABLE_HARDENED_RUNTIME: YES