44 lines
4.5 KiB
Markdown
44 lines
4.5 KiB
Markdown
# 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()`.
|