Initial commit
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
.build/
|
||||||
|
.swiftpm/
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# 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()`.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
PREFIX ?= /usr/local
|
||||||
|
BIN := .build/release/mdview
|
||||||
|
|
||||||
|
.PHONY: build install uninstall assets clean
|
||||||
|
|
||||||
|
build:
|
||||||
|
swift build -c release
|
||||||
|
|
||||||
|
install: build
|
||||||
|
install -d $(PREFIX)/bin
|
||||||
|
install -m 755 $(BIN) $(PREFIX)/bin/mdview
|
||||||
|
|
||||||
|
uninstall:
|
||||||
|
rm -f $(PREFIX)/bin/mdview
|
||||||
|
|
||||||
|
# Re-embed assets/*.css and assets/*.js into Sources/mdview/Assets.swift
|
||||||
|
assets:
|
||||||
|
scripts/gen-assets.sh
|
||||||
|
|
||||||
|
clean:
|
||||||
|
swift package clean
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"originHash" : "0e85d0eda1891a2db813d4ba5c8ec92e62a2753814d8b0a9e636b8dc9696b3d1",
|
||||||
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "swift-cmark",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/swiftlang/swift-cmark.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "08ddb528923cc1a6527e02b7a1aee9e516ca749a",
|
||||||
|
"version" : "0.9.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version" : 3
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// swift-tools-version:6.0
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "mdview",
|
||||||
|
platforms: [.macOS(.v13)],
|
||||||
|
dependencies: [
|
||||||
|
.package(url: "https://github.com/swiftlang/swift-cmark.git", from: "0.8.0"),
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.executableTarget(
|
||||||
|
name: "mdview",
|
||||||
|
dependencies: [
|
||||||
|
.product(name: "cmark-gfm", package: "swift-cmark"),
|
||||||
|
.product(name: "cmark-gfm-extensions", package: "swift-cmark"),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
],
|
||||||
|
swiftLanguageModes: [.v5]
|
||||||
|
)
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import AppKit
|
||||||
|
|
||||||
|
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
|
private let fileURL: URL
|
||||||
|
private var viewer: ViewerController?
|
||||||
|
|
||||||
|
init(fileURL: URL) {
|
||||||
|
self.fileURL = fileURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
|
NSApp.mainMenu = buildMainMenu()
|
||||||
|
let viewer = ViewerController(fileURL: fileURL)
|
||||||
|
self.viewer = viewer
|
||||||
|
viewer.window.makeKeyAndOrderFront(nil)
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationWillTerminate(_ notification: Notification) {
|
||||||
|
viewer?.cleanUp()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func reload(_ sender: Any?) {
|
||||||
|
viewer?.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func zoomIn(_ sender: Any?) { viewer?.zoomIn(sender) }
|
||||||
|
@objc private func zoomOut(_ sender: Any?) { viewer?.zoomOut(sender) }
|
||||||
|
@objc private func actualSize(_ sender: Any?) { viewer?.actualSize(sender) }
|
||||||
|
|
||||||
|
private func buildMainMenu() -> NSMenu {
|
||||||
|
let main = NSMenu()
|
||||||
|
|
||||||
|
let appMenu = NSMenu()
|
||||||
|
appMenu.addItem(withTitle: "Hide mdview", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h")
|
||||||
|
let hideOthers = appMenu.addItem(
|
||||||
|
withTitle: "Hide Others",
|
||||||
|
action: #selector(NSApplication.hideOtherApplications(_:)),
|
||||||
|
keyEquivalent: "h"
|
||||||
|
)
|
||||||
|
hideOthers.keyEquivalentModifierMask = [.command, .option]
|
||||||
|
appMenu.addItem(.separator())
|
||||||
|
appMenu.addItem(withTitle: "Quit mdview", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
|
||||||
|
addSubmenu(appMenu, titled: "mdview", to: main)
|
||||||
|
|
||||||
|
let fileMenu = NSMenu(title: "File")
|
||||||
|
fileMenu.addItem(withTitle: "Close", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w")
|
||||||
|
fileMenu.addItem(withTitle: "Reload", action: #selector(reload(_:)), keyEquivalent: "r").target = self
|
||||||
|
addSubmenu(fileMenu, titled: "File", to: main)
|
||||||
|
|
||||||
|
let editMenu = NSMenu(title: "Edit")
|
||||||
|
editMenu.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c")
|
||||||
|
editMenu.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")
|
||||||
|
addSubmenu(editMenu, titled: "Edit", to: main)
|
||||||
|
|
||||||
|
let viewMenu = NSMenu(title: "View")
|
||||||
|
viewMenu.addItem(withTitle: "Actual Size", action: #selector(actualSize(_:)), keyEquivalent: "0").target = self
|
||||||
|
viewMenu.addItem(withTitle: "Zoom In", action: #selector(zoomIn(_:)), keyEquivalent: "=").target = self
|
||||||
|
viewMenu.addItem(withTitle: "Zoom Out", action: #selector(zoomOut(_:)), keyEquivalent: "-").target = self
|
||||||
|
addSubmenu(viewMenu, titled: "View", to: main)
|
||||||
|
|
||||||
|
let windowMenu = NSMenu(title: "Window")
|
||||||
|
windowMenu.addItem(withTitle: "Minimize", action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m")
|
||||||
|
addSubmenu(windowMenu, titled: "Window", to: main)
|
||||||
|
NSApp.windowsMenu = windowMenu
|
||||||
|
|
||||||
|
return main
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addSubmenu(_ submenu: NSMenu, titled title: String, to menu: NSMenu) {
|
||||||
|
let item = NSMenuItem(title: title, action: nil, keyEquivalent: "")
|
||||||
|
item.submenu = submenu
|
||||||
|
menu.addItem(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,65 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Watches a single file and calls `onChange` (debounced, on the main queue)
|
||||||
|
/// whenever it is modified. Editors that save by writing a new file and
|
||||||
|
/// renaming it over the old one are handled by re-opening the path.
|
||||||
|
final class FileWatcher {
|
||||||
|
private let url: URL
|
||||||
|
private let onChange: () -> Void
|
||||||
|
private var source: DispatchSourceFileSystemObject?
|
||||||
|
private var pending: DispatchWorkItem?
|
||||||
|
private var stopped = false
|
||||||
|
|
||||||
|
init(url: URL, onChange: @escaping () -> Void) {
|
||||||
|
self.url = url
|
||||||
|
self.onChange = onChange
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
guard !stopped, source == nil else { return }
|
||||||
|
|
||||||
|
let fd = open(url.path, O_EVTONLY)
|
||||||
|
if fd < 0 {
|
||||||
|
// The file may be mid-replacement or temporarily gone; keep trying.
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in self?.start() }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let src = DispatchSource.makeFileSystemObjectSource(
|
||||||
|
fileDescriptor: fd,
|
||||||
|
eventMask: [.write, .extend, .delete, .rename, .attrib],
|
||||||
|
queue: .main
|
||||||
|
)
|
||||||
|
src.setEventHandler { [weak self, unowned src] in
|
||||||
|
guard let self else { return }
|
||||||
|
let events = src.data
|
||||||
|
if events.contains(.delete) || events.contains(.rename) {
|
||||||
|
self.source?.cancel()
|
||||||
|
self.source = nil
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in self?.start() }
|
||||||
|
}
|
||||||
|
self.scheduleChange()
|
||||||
|
}
|
||||||
|
src.setCancelHandler { close(fd) }
|
||||||
|
source = src
|
||||||
|
src.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
stopped = true
|
||||||
|
pending?.cancel()
|
||||||
|
source?.cancel()
|
||||||
|
source = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleChange() {
|
||||||
|
pending?.cancel()
|
||||||
|
let item = DispatchWorkItem { [weak self] in self?.onChange() }
|
||||||
|
pending = item
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15, execute: item)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import Foundation
|
||||||
|
import cmark_gfm
|
||||||
|
import cmark_gfm_extensions
|
||||||
|
|
||||||
|
/// Converts GitHub Flavored Markdown to an HTML fragment using cmark-gfm.
|
||||||
|
enum Renderer {
|
||||||
|
private static let extensionNames = ["table", "strikethrough", "autolink", "tagfilter", "tasklist"]
|
||||||
|
|
||||||
|
// Raw HTML is passed through (as on GitHub); the tagfilter extension
|
||||||
|
// still neutralizes <script>, <iframe>, <style> and friends.
|
||||||
|
private static let options = CMARK_OPT_DEFAULT | CMARK_OPT_UNSAFE | CMARK_OPT_FOOTNOTES
|
||||||
|
|
||||||
|
static func html(fromMarkdown markdown: String) -> String {
|
||||||
|
cmark_gfm_core_extensions_ensure_registered()
|
||||||
|
|
||||||
|
guard let parser = cmark_parser_new(options) else { return "" }
|
||||||
|
defer { cmark_parser_free(parser) }
|
||||||
|
|
||||||
|
for name in extensionNames {
|
||||||
|
if let ext = cmark_find_syntax_extension(name) {
|
||||||
|
cmark_parser_attach_syntax_extension(parser, ext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cmark_parser_feed(parser, markdown, markdown.utf8.count)
|
||||||
|
guard let doc = cmark_parser_finish(parser) else { return "" }
|
||||||
|
defer { cmark_node_free(doc) }
|
||||||
|
|
||||||
|
guard let cString = cmark_render_html(doc, options, cmark_parser_get_syntax_extensions(parser)) else {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer { free(cString) }
|
||||||
|
return String(cString: cString)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func escape(_ text: String) -> String {
|
||||||
|
text.replacingOccurrences(of: "&", with: "&")
|
||||||
|
.replacingOccurrences(of: "<", with: "<")
|
||||||
|
.replacingOccurrences(of: ">", with: ">")
|
||||||
|
.replacingOccurrences(of: "\"", with: """)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Wraps rendered markdown in a complete HTML page with styles and scripts.
|
||||||
|
enum Template {
|
||||||
|
/// - Parameters:
|
||||||
|
/// - body: The rendered HTML fragment.
|
||||||
|
/// - baseURL: Directory of the markdown file, so relative links and images resolve.
|
||||||
|
static func page(title: String, body: String, baseURL: URL) -> String {
|
||||||
|
"""
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<base href="\(Renderer.escape(baseURL.absoluteString))">
|
||||||
|
<title>\(Renderer.escape(title))</title>
|
||||||
|
<style>\(Assets.markdownCSS)</style>
|
||||||
|
<style>\(Assets.highlightLightCSS)</style>
|
||||||
|
<style>@media (prefers-color-scheme: dark) { \(Assets.highlightDarkCSS) }</style>
|
||||||
|
<style>\(css)</style>
|
||||||
|
<script>\(Assets.highlightJS)</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<article id="content" class="markdown-body">\(body)</article>
|
||||||
|
<script>\(script)</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let css = """
|
||||||
|
:root { color-scheme: light dark; }
|
||||||
|
html, body { margin: 0; background: #ffffff; }
|
||||||
|
@media (prefers-color-scheme: dark) { html, body { background: #0d1117; } }
|
||||||
|
.markdown-body { box-sizing: border-box; max-width: 980px; margin: 0 auto; padding: 32px 45px; }
|
||||||
|
.markdown-body pre code.hljs { background: transparent; padding: 0; }
|
||||||
|
.mdview-error { color: #d1242f; }
|
||||||
|
"""
|
||||||
|
|
||||||
|
private static let script = """
|
||||||
|
(function () {
|
||||||
|
function slugify(text) {
|
||||||
|
return text.trim().toLowerCase()
|
||||||
|
.replace(/[^\\p{L}\\p{N}\\s_-]/gu, '')
|
||||||
|
.replace(/\\s/g, '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give headings GitHub-style ids and highlight fenced code blocks.
|
||||||
|
function decorate(root) {
|
||||||
|
var seen = {};
|
||||||
|
root.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach(function (h) {
|
||||||
|
var slug = slugify(h.textContent);
|
||||||
|
if (slug in seen) { seen[slug] += 1; slug += '-' + seen[slug]; } else { seen[slug] = 0; }
|
||||||
|
if (!h.id) h.id = slug;
|
||||||
|
});
|
||||||
|
root.querySelectorAll('li > input[type="checkbox"]:first-child').forEach(function (box) {
|
||||||
|
box.classList.add('task-list-item-checkbox');
|
||||||
|
box.parentElement.classList.add('task-list-item');
|
||||||
|
box.parentElement.parentElement.classList.add('contains-task-list');
|
||||||
|
});
|
||||||
|
root.querySelectorAll('pre code[class*="language-"]').forEach(function (el) {
|
||||||
|
hljs.highlightElement(el);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The <base> tag points at the markdown file's directory, so in-page
|
||||||
|
// anchors would otherwise navigate away. Scroll to them instead.
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
var a = e.target.closest('a[href^="#"]');
|
||||||
|
if (!a) return;
|
||||||
|
e.preventDefault();
|
||||||
|
var id = decodeURIComponent(a.getAttribute('href').slice(1));
|
||||||
|
var target = document.getElementById(id) || document.getElementsByName(id)[0];
|
||||||
|
if (target) target.scrollIntoView();
|
||||||
|
});
|
||||||
|
|
||||||
|
hljs.configure({ ignoreUnescapedHTML: true });
|
||||||
|
|
||||||
|
// Called from Swift on live reload; swapping the content in place
|
||||||
|
// keeps the scroll position.
|
||||||
|
window.mdviewUpdate = function (html) {
|
||||||
|
var content = document.getElementById('content');
|
||||||
|
content.innerHTML = html;
|
||||||
|
decorate(content);
|
||||||
|
};
|
||||||
|
|
||||||
|
decorate(document.getElementById('content'));
|
||||||
|
})();
|
||||||
|
"""
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import AppKit
|
||||||
|
import WebKit
|
||||||
|
|
||||||
|
/// Owns the window and web view for one markdown file and keeps it live-updated.
|
||||||
|
final class ViewerController: NSObject, WKNavigationDelegate, WKUIDelegate, NSWindowDelegate {
|
||||||
|
private static let markdownExtensions: Set<String> = ["md", "markdown", "mdown", "mkd", "mkdn"]
|
||||||
|
|
||||||
|
let window: NSWindow
|
||||||
|
private let webView: WKWebView
|
||||||
|
private let pageURL: URL
|
||||||
|
private var fileURL: URL
|
||||||
|
private var watcher: FileWatcher?
|
||||||
|
private var pageLoaded = false
|
||||||
|
|
||||||
|
init(fileURL: URL) {
|
||||||
|
self.fileURL = fileURL
|
||||||
|
pageURL = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("mdview-\(getpid()).html")
|
||||||
|
|
||||||
|
webView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration())
|
||||||
|
webView.allowsMagnification = true
|
||||||
|
|
||||||
|
window = NSWindow(
|
||||||
|
contentRect: NSRect(x: 0, y: 0, width: 900, height: 1000),
|
||||||
|
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
||||||
|
backing: .buffered,
|
||||||
|
defer: false
|
||||||
|
)
|
||||||
|
window.contentView = webView
|
||||||
|
window.center()
|
||||||
|
window.setFrameAutosaveName("mdview")
|
||||||
|
window.isReleasedWhenClosed = false
|
||||||
|
|
||||||
|
super.init()
|
||||||
|
|
||||||
|
webView.navigationDelegate = self
|
||||||
|
webView.uiDelegate = self
|
||||||
|
window.delegate = self
|
||||||
|
open(fileURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Displays `url` in this window and starts watching it for changes.
|
||||||
|
func open(_ url: URL) {
|
||||||
|
fileURL = url
|
||||||
|
window.title = url.lastPathComponent
|
||||||
|
window.representedURL = url
|
||||||
|
|
||||||
|
watcher?.stop()
|
||||||
|
watcher = FileWatcher(url: url) { [weak self] in self?.reload() }
|
||||||
|
watcher?.start()
|
||||||
|
|
||||||
|
loadFullPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-renders the current file. If the page is already showing, only the
|
||||||
|
/// content is swapped so the scroll position is preserved.
|
||||||
|
@objc func reload() {
|
||||||
|
guard pageLoaded else {
|
||||||
|
loadFullPage()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
webView.callAsyncJavaScript(
|
||||||
|
"mdviewUpdate(html)",
|
||||||
|
arguments: ["html": renderBody()],
|
||||||
|
in: nil,
|
||||||
|
in: .page,
|
||||||
|
completionHandler: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanUp() {
|
||||||
|
watcher?.stop()
|
||||||
|
try? FileManager.default.removeItem(at: pageURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func renderBody() -> String {
|
||||||
|
do {
|
||||||
|
let markdown = try String(contentsOf: fileURL, encoding: .utf8)
|
||||||
|
return Renderer.html(fromMarkdown: markdown)
|
||||||
|
} catch {
|
||||||
|
return "<p class=\"mdview-error\">Could not read \(Renderer.escape(fileURL.path)): "
|
||||||
|
+ "\(Renderer.escape(error.localizedDescription))</p>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The page is written to a temp file and loaded with read access to the
|
||||||
|
// whole filesystem so that relative images next to the markdown work.
|
||||||
|
private func loadFullPage() {
|
||||||
|
pageLoaded = false
|
||||||
|
let html = Template.page(
|
||||||
|
title: fileURL.lastPathComponent,
|
||||||
|
body: renderBody(),
|
||||||
|
baseURL: fileURL.deletingLastPathComponent()
|
||||||
|
)
|
||||||
|
do {
|
||||||
|
try html.write(to: pageURL, atomically: true, encoding: .utf8)
|
||||||
|
webView.loadFileURL(pageURL, allowingReadAccessTo: URL(fileURLWithPath: "/"))
|
||||||
|
} catch {
|
||||||
|
webView.loadHTMLString(html, baseURL: fileURL.deletingLastPathComponent())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Zoom
|
||||||
|
|
||||||
|
@objc func zoomIn(_ sender: Any?) { webView.pageZoom = min(webView.pageZoom * 1.1, 5) }
|
||||||
|
@objc func zoomOut(_ sender: Any?) { webView.pageZoom = max(webView.pageZoom / 1.1, 0.25) }
|
||||||
|
@objc func actualSize(_ sender: Any?) { webView.pageZoom = 1 }
|
||||||
|
|
||||||
|
// MARK: - WKNavigationDelegate
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||||
|
pageLoaded = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(
|
||||||
|
_ webView: WKWebView,
|
||||||
|
decidePolicyFor action: WKNavigationAction,
|
||||||
|
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
|
||||||
|
) {
|
||||||
|
guard let url = action.request.url else {
|
||||||
|
decisionHandler(.cancel)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Our own page loads, and anything inside iframes.
|
||||||
|
if action.navigationType != .linkActivated || action.targetFrame?.isMainFrame == false {
|
||||||
|
decisionHandler(.allow)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
decisionHandler(.cancel)
|
||||||
|
followLink(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - WKUIDelegate
|
||||||
|
|
||||||
|
// Links with target="_blank" arrive here instead of the navigation delegate.
|
||||||
|
func webView(
|
||||||
|
_ webView: WKWebView,
|
||||||
|
createWebViewWith configuration: WKWebViewConfiguration,
|
||||||
|
for action: WKNavigationAction,
|
||||||
|
windowFeatures: WKWindowFeatures
|
||||||
|
) -> WKWebView? {
|
||||||
|
if let url = action.request.url {
|
||||||
|
followLink(url)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Markdown files open in this window; everything else goes to the default app.
|
||||||
|
private func followLink(_ url: URL) {
|
||||||
|
if url.isFileURL && Self.markdownExtensions.contains(url.pathExtension.lowercased()) {
|
||||||
|
open(url.standardizedFileURL)
|
||||||
|
} else {
|
||||||
|
NSWorkspace.shared.open(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - NSWindowDelegate
|
||||||
|
|
||||||
|
func windowWillClose(_ notification: Notification) {
|
||||||
|
cleanUp()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import AppKit
|
||||||
|
|
||||||
|
let usage = """
|
||||||
|
usage: mdview [-f|--foreground] <file.md>
|
||||||
|
|
||||||
|
Opens a window showing the rendered markdown, reloading it whenever the
|
||||||
|
file changes. Returns to the shell immediately unless --foreground is given.
|
||||||
|
"""
|
||||||
|
|
||||||
|
func fail(_ message: String) -> Never {
|
||||||
|
FileHandle.standardError.write(Data("mdview: \(message)\n".utf8))
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-launches this executable in the foreground mode, in its own session and
|
||||||
|
/// with stdio pointed at /dev/null, so the shell gets its prompt back.
|
||||||
|
func detach(path: String) -> Never {
|
||||||
|
guard let executable = Bundle.main.executablePath else { fail("cannot locate own executable") }
|
||||||
|
|
||||||
|
var attr = posix_spawnattr_t(nil as OpaquePointer?)
|
||||||
|
posix_spawnattr_init(&attr)
|
||||||
|
defer { posix_spawnattr_destroy(&attr) }
|
||||||
|
posix_spawnattr_setflags(&attr, Int16(POSIX_SPAWN_SETSID))
|
||||||
|
|
||||||
|
var actions = posix_spawn_file_actions_t(nil as OpaquePointer?)
|
||||||
|
posix_spawn_file_actions_init(&actions)
|
||||||
|
defer { posix_spawn_file_actions_destroy(&actions) }
|
||||||
|
posix_spawn_file_actions_addopen(&actions, 0, "/dev/null", O_RDONLY, 0)
|
||||||
|
posix_spawn_file_actions_addopen(&actions, 1, "/dev/null", O_WRONLY, 0)
|
||||||
|
posix_spawn_file_actions_addopen(&actions, 2, "/dev/null", O_WRONLY, 0)
|
||||||
|
|
||||||
|
let args = [executable, "--foreground", path]
|
||||||
|
var cArgs = args.map { strdup($0) } + [nil]
|
||||||
|
defer { cArgs.forEach { free($0) } }
|
||||||
|
|
||||||
|
var pid: pid_t = 0
|
||||||
|
let status = posix_spawn(&pid, executable, &actions, &attr, &cArgs, environ)
|
||||||
|
if status != 0 {
|
||||||
|
fail("could not start viewer: \(String(cString: strerror(status)))")
|
||||||
|
}
|
||||||
|
exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
var foreground = false
|
||||||
|
var paths: [String] = []
|
||||||
|
for arg in CommandLine.arguments.dropFirst() {
|
||||||
|
switch arg {
|
||||||
|
case "-f", "--foreground":
|
||||||
|
foreground = true
|
||||||
|
case "-h", "--help":
|
||||||
|
print(usage)
|
||||||
|
exit(0)
|
||||||
|
case _ where arg.hasPrefix("-") && arg != "-":
|
||||||
|
fail("unknown option \(arg)\n\n\(usage)")
|
||||||
|
default:
|
||||||
|
paths.append(arg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard paths.count == 1 else { fail("expected exactly one file\n\n\(usage)") }
|
||||||
|
|
||||||
|
let fileURL = URL(fileURLWithPath: paths[0]).standardizedFileURL
|
||||||
|
var isDirectory: ObjCBool = false
|
||||||
|
guard FileManager.default.fileExists(atPath: fileURL.path, isDirectory: &isDirectory) else {
|
||||||
|
fail("\(paths[0]): no such file")
|
||||||
|
}
|
||||||
|
guard !isDirectory.boolValue else { fail("\(paths[0]): is a directory") }
|
||||||
|
guard FileManager.default.isReadableFile(atPath: fileURL.path) else { fail("\(paths[0]): permission denied") }
|
||||||
|
|
||||||
|
if !foreground {
|
||||||
|
detach(path: fileURL.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
let app = NSApplication.shared
|
||||||
|
let delegate = AppDelegate(fileURL: fileURL)
|
||||||
|
app.delegate = delegate
|
||||||
|
app.setActivationPolicy(.regular)
|
||||||
|
app.run()
|
||||||
File diff suppressed because it is too large
Load Diff
Vendored
+1244
File diff suppressed because one or more lines are too long
@@ -0,0 +1,10 @@
|
|||||||
|
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
|
||||||
|
Theme: GitHub Dark
|
||||||
|
Description: Dark theme as seen on github.com
|
||||||
|
Author: github.com
|
||||||
|
Maintainer: @Hirse
|
||||||
|
Updated: 2021-05-15
|
||||||
|
|
||||||
|
Outdated base version: https://github.com/primer/github-syntax-dark
|
||||||
|
Current colors taken from GitHub's CSS
|
||||||
|
*/.hljs{color:#c9d1d9;background:#0d1117}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#ff7b72}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#d2a8ff}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#79c0ff}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#a5d6ff}.hljs-built_in,.hljs-symbol{color:#ffa657}.hljs-code,.hljs-comment,.hljs-formula{color:#8b949e}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#7ee787}.hljs-subst{color:#c9d1d9}.hljs-section{color:#1f6feb;font-weight:700}.hljs-bullet{color:#f2cc60}.hljs-emphasis{color:#c9d1d9;font-style:italic}.hljs-strong{color:#c9d1d9;font-weight:700}.hljs-addition{color:#aff5b4;background-color:#033a16}.hljs-deletion{color:#ffdcd7;background-color:#67060c}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
|
||||||
|
Theme: GitHub
|
||||||
|
Description: Light theme as seen on github.com
|
||||||
|
Author: github.com
|
||||||
|
Maintainer: @Hirse
|
||||||
|
Updated: 2021-05-15
|
||||||
|
|
||||||
|
Outdated base version: https://github.com/primer/github-syntax-light
|
||||||
|
Current colors taken from GitHub's CSS
|
||||||
|
*/.hljs{color:#24292e;background:#fff}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#d73a49}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#6f42c1}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#005cc5}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#032f62}.hljs-built_in,.hljs-symbol{color:#e36209}.hljs-code,.hljs-comment,.hljs-formula{color:#6a737d}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#22863a}.hljs-subst{color:#24292e}.hljs-section{color:#005cc5;font-weight:700}.hljs-bullet{color:#735c0f}.hljs-emphasis{color:#24292e;font-style:italic}.hljs-strong{color:#24292e;font-weight:700}.hljs-addition{color:#22863a;background-color:#f0fff4}.hljs-deletion{color:#b31d28;background-color:#ffeef0}
|
||||||
Executable
+32
@@ -0,0 +1,32 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Regenerates Sources/mdview/Assets.swift from the files in assets/, embedding
|
||||||
|
# them as Swift raw string literals so the binary is fully self-contained.
|
||||||
|
set -eu
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
out=Sources/mdview/Assets.swift
|
||||||
|
|
||||||
|
emit() {
|
||||||
|
name=$1
|
||||||
|
file=$2
|
||||||
|
if grep -q '"""##' "$file"; then
|
||||||
|
echo "error: $file contains the raw string delimiter" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf ' static let %s = ##"""\n' "$name"
|
||||||
|
cat "$file"
|
||||||
|
printf '\n"""##\n\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
echo '// Generated by scripts/gen-assets.sh -- do not edit by hand.'
|
||||||
|
echo
|
||||||
|
echo 'enum Assets {'
|
||||||
|
emit markdownCSS assets/github-markdown.css
|
||||||
|
emit highlightLightCSS assets/hljs-github.css
|
||||||
|
emit highlightDarkCSS assets/hljs-github-dark.css
|
||||||
|
emit highlightJS assets/highlight.min.js
|
||||||
|
echo '}'
|
||||||
|
} > "$out"
|
||||||
|
|
||||||
|
echo "wrote $out"
|
||||||
Reference in New Issue
Block a user