Initial commit
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user