4.5 KiB
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 viaposix_spawnwith--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--foregroundprocess startsNSApplication. 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.regularis set inmain.swift; without it a bare binary gets no Dock icon or focus.ViewerController.swift: owns theNSWindowandWKWebView.- The full page is written to
$TMPDIR/mdview-<pid>.htmland loaded withloadFileURL(_, allowingReadAccessTo: "/").loadHTMLStringcan'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'sinnerHTML, which preserves scroll position. A full page load happens only on first open or after navigating to another.mdfile. - Link handling: links to
.md/.markdownfiles (and similar extensions) open in the same window; every other link goes toNSWorkspace.open. Links withtarget=_blankarrive through theWKUIDelegatecreateWebViewWithmethod.
- The full page is written to
FileWatcher.swift: aDispatchSourcevnode watcher with a 150 ms debounce. On.delete/.renameit 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, productscmark-gfmandcmark-gfm-extensions).- Extensions: table, strikethrough, autolink, tagfilter, tasklist.
- Options:
UNSAFE(raw HTML passes through, as on GitHub; tagfilter still neutralizes script/iframe/style) andFOOTNOTES.
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,#anchorlinks would navigate away, so a JS click handler intercepts them and callsscrollIntoViewinstead.- The JS
decorate()function adds GitHub-style heading ids (cmark doesn't emit them), adds thetask-list-itemclasses that github-markdown-css expects, and runs highlight.js only oncode[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 inassets/embedded as##"""raw strings. To update or add an asset, changeassets/andscripts/gen-assets.sh, then runmake 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 withprefers-color-scheme, which follows the macOS appearance.- highlight.js 11.11.1 (the common-languages build), with the
githubtheme and thegithub-darktheme. The dark theme is wrapped in@media (prefers-color-scheme: dark).
Conventions / gotchas
- Swift 6 toolchain, but
swiftLanguageModes: [.v5]inPackage.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 fromdecorate().