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