macOS still previews Markdown as raw text. Press Space on a README in Finder and you get hashes, pipes and backticks. I wanted to just read the thing, so I wrote a Quick Look extension. It turned into a full app eventually (QuickMark, and yes, I'm the developer, so take the product mentions with that in mind).
Four things cost me real evenings. None of them were where I expected to find them.
Re-signing after the build silently killed my extension
This one is nasty because nothing errors.
My install step used to be: build, copy the .app into /Applications, then run a codesign pass over it for good measure. After that, Quick Look would show the plain text preview again, exactly as if my extension did not exist. No crash, no log in Xcode, no dialog.
I no longer have the exact invocation, and I want to be precise about that rather than hand you a rule I cannot back up. A plain codesign --force on the outer bundle does not normally touch nested code, so mine was either re-signing the whole tree with --deep (which Apple tells you not to do, and recommends signing nested components individually, inside out) or re-signing without handing over the extension's entitlements. Either way the outcome was the same: the appex came out the other side without its sandbox entitlement.
The build's own signing pass had already signed both the app and the embedded appex correctly. Everything I did afterwards only made it worse. pkd then refuses to load the extension and falls back to the system text preview, quietly.
You only see it if you go looking:
log show --predicate 'process == "pkd"' --last 5m
There it was: rejecting; ... plug-ins must be sandboxed.
Useful companion command, to check whether your extension is even a candidate:
pluginkit -m -p com.apple.quicklook.preview -A | grep -i yourapp
If your extension is missing from that list, it is not currently a candidate, which can mean it never registered, or it registered and is disabled, or it is installed somewhere the system does not scan. If it is present but Quick Look ignores it, you may be losing a priority fight to another installed extension for the same UTI. In my case that is what it was, another Markdown Quick Look extension in /Applications out-ranked a fresh build, and uninstalling it fixed things. I never found a way to see or influence the ranking, so treat that as one case report, not an algorithm.
I chose the worse syntax highlighter on purpose
I started with Shiki. TextMate grammars, genuinely more accurate tokenizing, output that matches what VS Code shows you. It is the better highlighter and I am not going to pretend otherwise.
I ripped it out and went back to Prism.
Cold render went from roughly 200 to 400 ms down to about 50 to 100 ms. Those numbers are wall clock from opening the panel to first paint, on an Apple Silicon Mac, against my own pile of README files, eyeballed over a handful of runs. It was not a benchmark harness. Take it as the shape of the gap, not a figure to quote.
The shape was enough. For most apps a couple hundred milliseconds is a rounding error. For a Quick Look panel it is the entire feature. The promise is that you tap Space and the document is there. A third of a second of blank panel breaks that promise, and no amount of grammar accuracy buys it back.
There was a second win, and I need to state it carefully. Prism emits CSS classes like token.keyword, which I resolve through CSS variables:
[data-theme="dark"] { --syntax-keyword: #ff7b72; }
.token.keyword { color: var(--syntax-keyword); }
So when the system flips between light and dark, the theme changes with no re-render and no re-tokenize. In the Shiki setup I had, colors were baked into inline styles at highlight time, so a theme switch meant highlighting the document again. That was my configuration, not a limit of the library. Shiki supports dual themes and CSS-variable output that avoid exactly this. I simply had not reached for them, and with Prism I got the behaviour without having to think about it.
The lesson I took: pick the tool that is best at your actual constraint, not the one that is best in general. Mine was time-to-first-paint, not correctness.
While I was measuring things, I also made Mermaid a lazy import. It is around 600 KB and most documents have no diagrams in them, so loading it on every render was pure waste. One check for a mermaid fence, then await import() only if there is one.
Some editors save files by renaming them
My file watcher worked perfectly. Then a friend tried it with Vim and the preview stopped updating.
NSFilePresenter gives you presentedItemDidChange(), which fires when someone writes to the file in place. Plenty of editors do that. Vim and Helix, by default, do not. They write a temp file and rename it over the original, because an atomic replace cannot leave you with a half-written file if the editor dies mid-save. Vim's exact strategy depends on backupcopy and on what the filesystem supports, so this is a common default rather than a law, which is precisely why it is easy to miss during testing.
A rename is not a change. It fires a different callback:
func presentedItemDidChange() {
subject.send(())
}
func presentedItemDidMove(to newURL: URL) {
subject.send(())
}
Both go to the same place. Miss the second one and your app looks broken to precisely the users most likely to try it, which is a special kind of unlucky.
One caveat on that snippet, because it is incomplete as general advice. presentedItemDidMove(to:) is really telling you the item moved, and Apple expects a presenter to update its presentedItemURL when that happens. I ignore newURL on purpose: in the atomic-save case the replacement lands back at the path I was already watching, so re-reading the original URL is the correct thing. If you also need to follow a genuine move to a new location, you have to store the new URL, and my two-line version will not do that for you.
One more detail worth stealing: debounce before you re-read. I wait 80 ms and then read through NSFileCoordinator. The coordinated read is the part that actually synchronises, and only against writers that coordinate too. The delay is a heuristic on top, because without it you sometimes catch a non-coordinating writer mid-flush and render a truncated document for a frame.
callAsyncJavaScript exists for a reason
My renderer's entry point is async. So I called it the obvious way:
webView.evaluateJavaScript("window.QuickMark.render(md)")
And got WKErrorJavaScriptResultTypeIsUnsupported, which reads as "JavaScript execution returned a result of an unsupported type". Not an obviously helpful message when your JS clearly worked.
The cause: an async function returns a Promise, and evaluateJavaScript cannot serialize a Promise across the bridge. It is not waiting for anything. It grabs the return value immediately and gives up on it.
The fix is a different API:
let js = "await window.QuickMark.render(\(JSEscape.string(markdown))); return null;"
webView.callAsyncJavaScript(js, in: nil, in: .page) { result in ... }
callAsyncJavaScript runs the string as an async function body, so the await in there is what actually waits. return null; is not doing the waiting; it is only there to hand the bridge a value it can serialize, so you do not trade one unsupported-type error for another.
Note what that snippet makes you responsible for. Interpolating the document into the source string means you own the escaping, which in my case goes through a JSON-escape helper. callAsyncJavaScript also takes an arguments dictionary that passes values in as named variables, which is the cleaner option and the one I would reach for first if I were writing this today.
Closing
Three of these four have the same shape: the failure is silent, and the real signal lives in a log or an API I had no reason to know about yet. That is most of what macOS extension development felt like. The code is not hard. Finding out what went wrong is.
If you want to see how the renderer behaves, it runs in the browser with nothing to install: https://quickmarkmd.com/preview
Top comments (0)