Files
2026-10-20 11:51:52 -05:00

4.5 KiB

mdview

A macOS command-line tool, mdview file.md, that opens a native window showing the rendered markdown and live-reloads it whenever the file changes. Written in Swift with AppKit and WKWebView, built with SwiftPM. There's no Xcode project and no .app bundle: the output is one self-contained binary.

Build / run

make build                      # swift build -c release -> .build/release/mdview
make install [PREFIX=~/.local]  # default PREFIX is /usr/local
.build/release/mdview -f file.md   # -f/--foreground: stay attached to the terminal (use when debugging)

There are no automated tests. To verify changes, run the binary against a sample markdown file that covers tables, task lists, code fences, footnotes, relative images and links.

Layout

  • Sources/mdview/main.swift: parses arguments, validates the file, then detaches. It re-launches itself via posix_spawn with --foreground, in a new session (POSIX_SPAWN_SETSID), with stdio sent to /dev/null, and the parent exits so the shell gets its prompt back. Only the --foreground process starts NSApplication. File errors must be caught before the detach, or the user never sees them.
  • AppDelegate.swift: builds the main menu in code (there are no nibs), creates the single viewer, and quits when the last window closes. The activation policy .regular is set in main.swift; without it a bare binary gets no Dock icon or focus.
  • ViewerController.swift: owns the NSWindow and WKWebView.
    • The full page is written to $TMPDIR/mdview-<pid>.html and loaded with loadFileURL(_, allowingReadAccessTo: "/"). loadHTMLString can't read local images, which is why the temp file exists. The file is deleted on window close and on quit.
    • Live reload doesn't reload the page. It calls the JS function mdviewUpdate(html) to swap the article's innerHTML, which preserves scroll position. A full page load happens only on first open or after navigating to another .md file.
    • Link handling: links to .md/.markdown files (and similar extensions) open in the same window; every other link goes to NSWorkspace.open. Links with target=_blank arrive through the WKUIDelegate createWebViewWith method.
  • FileWatcher.swift: a DispatchSource vnode watcher with a 150 ms debounce. On .delete/.rename it re-opens the path, because editors like vim and VS Code save by writing a new file and renaming it over the old one. If the file is missing, it retries every 0.5 s.
  • Renderer.swift: markdown to an HTML fragment via cmark-gfm (swiftlang/swift-cmark, products cmark-gfm and cmark-gfm-extensions).
    • Extensions: table, strikethrough, autolink, tagfilter, tasklist.
    • Options: UNSAFE (raw HTML passes through, as on GitHub; tagfilter still neutralizes script/iframe/style) and FOOTNOTES.
  • Template.swift: the HTML page shell, extra CSS and in-page JS.
    • <base href> points at the markdown file's directory so relative paths resolve. Because of that, #anchor links would navigate away, so a JS click handler intercepts them and calls scrollIntoView instead.
    • The JS decorate() function adds GitHub-style heading ids (cmark doesn't emit them), adds the task-list-item classes that github-markdown-css expects, and runs highlight.js only on code[class*="language-"], so there's no auto-detection on unlabeled blocks.
    • Swift multi-line string: backslashes in JS regexes must be doubled (\\s).
  • Assets.swift: generated, do not edit. It holds the files in assets/ embedded as ##""" raw strings. To update or add an asset, change assets/ and scripts/gen-assets.sh, then run make assets. The assets are embedded rather than shipped as SwiftPM resources so the binary stays a single file.

Assets

  • github-markdown.css (v5.8.1): its light and dark themes switch automatically with prefers-color-scheme, which follows the macOS appearance.
  • highlight.js 11.11.1 (the common-languages build), with the github theme and the github-dark theme. The dark theme is wrapped in @media (prefers-color-scheme: dark).

Conventions / gotchas

  • Swift 6 toolchain, but swiftLanguageModes: [.v5] in Package.swift, which avoids strict-concurrency friction with the DispatchSource and WebKit callbacks. Everything runs on the main queue.
  • Minimum macOS version is 13.
  • Killing the viewer with a signal skips cleanup and leaves the temp HTML file behind. That's harmless.
  • No Mermaid or math support yet. Either would mean adding the JS to assets/ (about 1 to 3 MB each) and calling it from decorate().