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