Initial commit

This commit is contained in:
2026-10-20 11:51:52 -05:00
commit b3ae1e5afe
17 changed files with 5651 additions and 0 deletions
+79
View File
@@ -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
+65
View File
@@ -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)
}
}
+42
View File
@@ -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: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
.replacingOccurrences(of: "\"", with: "&quot;")
}
}
+89
View File
@@ -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'));
})();
"""
}
+164
View File
@@ -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()
}
}
+78
View File
@@ -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()