Skip to main content
Back to projects

Markdown Previewer

Live

A macOS Quick Look extension that renders Markdown from Finder with a press of Space — built and shipped in one evening, from an unfamiliar language to a Homebrew cask.

SwiftmacOSQuick LookHomebrew

Try it now

Select any .md file in Finder and press Space. macOS 13+.

brew install --cask 4rek/tap/markdown-previewerView source
Select a .md file, press Space. No app to open, no preview tab to click.

The frustration

I live in the terminal. But macOS has no built-in preview for Markdown files — press Space on a .md in Finder and you get raw, unstyled text. Every time I wanted to actually read a document, I opened VS Code, found the file, and clicked through to the preview tab. For a single file that's mildly annoying. Doing it a dozen times a day, for files I only wanted to skim, was the kind of small friction that quietly taxes a whole workday.

So one evening I decided to fix it properly instead of tolerating it. Five hours later there was a signed build, a Homebrew cask, CI, and docs.

What it does

Select any .md file in Finder, press Space, and see it rendered — headings, tables, task lists, code blocks, blockquotes, nested lists — following your system light or dark appearance. That's the entire feature set. There are no settings, because I couldn't think of one worth the configuration surface.

The decision that made it fast

Quick Look extensions come in two shapes, and picking the wrong one costs you everything.

The obvious approach — and the one I started with — is a view-based extension: a PreviewViewController that hosts a WKWebView, loads your HTML into it, and hands the whole view controller to Quick Look. It works. It's also slow, because your extension is now booting a full web view process every time someone taps Space.

The better approach is a data-based provider. A QLPreviewProvider converts the file to HTML and returns it as data. Quick Look renders it itself, in a process it already has warm:

func providePreview(for request: QLFilePreviewRequest) async throws -> QLPreviewReply {
    let url = request.fileURL
    let data = try Data(contentsOf: url)
    let markdown = String(decoding: data, as: UTF8.self)
    let html = MarkdownHTMLRenderer.render(markdown: markdown, title: url.lastPathComponent)

    return QLPreviewReply(dataOfContentType: .html,
                          contentSize: CGSize(width: 800, height: 600)) { reply in
        reply.stringEncoding = .utf8
        reply.title = url.lastPathComponent
        return Data(html.utf8)
    }
}

The rendering itself is apple/swift-markdown parsing the file into an AST, and a small MarkupVisitor walking that AST into HTML, wrapped in a template with inline CSS. No JavaScript, no external stylesheet, no network. The preview is a string.

I spent a good chunk of that evening optimizing the web view version before concluding it was the wrong architecture and deleting it. That's the honest version of the story.

Two processes, two sandbox postures

The project ships two binaries, and they have deliberately different security profiles.

The preview extension is fully sandboxed. It processes untrusted file content — arbitrary Markdown, possibly containing raw HTML, from files I've never seen. It gets app-sandbox and read-only access to user-selected files, and nothing else.

The container app runs outside the sandbox. It's a trivial SwiftUI window whose job is maintenance: register the extension, prune stale registrations, refresh Quick Look's cache. Those tasks mean shelling out to qlmanage and lsregister, which a sandboxed process cannot do.

That's a real trade-off, not a free win. Running unsandboxed means this can't go to the Mac App Store as-is — it would need re-sandboxing, which would mean dropping the automatic maintenance and telling users to run qlmanage -r by hand. I chose the version that's better for the user who installs it today, and wrote the trade-off down in the README rather than pretending it doesn't exist.

The part nobody sees: making it installable

Writing the renderer was the easy half. The half that decides whether anyone actually uses your tool is everything after the build succeeds.

  • macOS ships Quick Look extensions turned off. A user who installs this and presses Space sees… raw text, exactly as before. So the app's first-launch window has a button that jumps straight to System Settings → Login Items & Extensions → Quick Look. Without it, the tool looks broken to every new user.
  • One install command. brew install --cask 4rek/tap/markdown-previewer — via my own Homebrew tap. No DMG to drag, no README steps to follow.
  • No "unidentified developer" wall. Apple notarization needs the $99/yr Developer Program. Instead the build is ad-hoc signed and the cask's postflight strips the quarantine attribute, so the scary dialog never appears. The README documents exactly how to upgrade to notarization later — it's a build-script change, not an app change.
  • The rare failure, documented before anyone hit it. Homebrew setups with HOMEBREW_REQUIRE_TAP_TRUST enabled will prompt to approve a third-party tap that runs a post-install step. Almost nobody will see it. The one person who does will find the fix already in the README.
  • It maintains itself. Every launch, the app re-registers itself, unregisters any other on-disk copies (the classic cause of duplicate Quick Look entries — an old build left in Downloads), and refreshes the cache. Users never touch Terminal, including after an upgrade.

That list is the difference between a repo and a tool.

Things that didn't go as planned

The build-in-public version, straight from the commit log.

  • The preview came up blank. I'd written the data-based provider correctly, but Quick Look kept treating my principal class as a view controller. The missing piece was QLIsDataBasedPreview in the extension's Info.plist — without that key, the class you register is interpreted as the wrong kind of object entirely. Correct code, wrong declaration.
  • Multi-second stalls on a 2 KB file. In the web view version, I'd set baseURL to the file's parent directory, which felt right. But the Quick Look sandbox grants read access to the file, not its folder — so WebKit sat there for seconds trying to establish a document origin it would never be allowed to reach. Pointing baseURL at the file's own URL made it instant. A sandbox denial that presents as a performance bug is a genuinely nasty class of problem.
  • Waiting for the wrong signal. I was returning the preview on didFinish, which waits for every subresource. Switching to didCommit — content committed and rendering — plus a 1.5s hard timeout meant a preview could appear promptly and could never hang. (This code is gone now; the data-based rewrite made it moot.)
  • Duplicate entries in System Settings. Xcode's build folder contains a copy of the app, and LaunchServices happily registers it alongside the real one, so the Quick Look settings pane showed the extension twice. This is what pushed me toward the self-healing maintenance pass on every launch.

What happened next

I shared it in our company Slack — not as a launch, just "here's a thing I built because it annoyed me." It got a genuinely warm response, and more usefully, people installed it. A month later a colleague came back to the thread because he'd finally hit the same friction, installed it, and had already patched the rendered dark-mode background to #1E1E1E to match the system's. Someone reading the source and adjusting it to taste is a better outcome than any download count.

What I learned

Swift isn't on my CV, and it wasn't the hard part. Working with an AI pair closed the syntax gap almost entirely — what it did not do was tell me that a data-based provider beats a hosted web view, that the extension and the container app want opposite sandbox postures, or that shipping a Quick Look extension without a "turn me on" button produces a tool that appears broken on first run. Those are product and architecture calls, and they're where the evening actually went.

The takeaway I keep coming back to: the code was maybe 20% of the work. Distribution, first-run experience, the upgrade path, and the failure modes were the other 80% — on a tool that does exactly one thing. That ratio doesn't get better on bigger products. It gets worse.