-
Notifications
You must be signed in to change notification settings - Fork 0
Markdown Plugin Architecture
markdown-wlx turns a .md file into a rendered web page inside Double Commander's
viewer. This page walks the pipeline and the design decisions — including two macOS
gotchas worth internalizing.
DC viewer (F3)
│ ListLoad(parentNSView, "/path/file.md", flags)
▼
MDView (NSView)
├─ MDWebView (WKWebView subclass)
│ loads ↓
└─ generated HTML ──<script src=file://…/assets/marked.min.js>──┐
<base href="file://<doc dir>/"> │ client-side
<article id=content></article> │ render
<script id=md-data type=…>BASE64(markdown)</script> │
bootstrap: atob → TextDecoder → marked.parse → hljs ────────┘
-
ListLoadbuilds anMDViewcontaining anMDWebView. - The Markdown file is read and base64-embedded into a generated HTML document.
- The document references vendored assets (marked.js, highlight.js, GitHub CSS) by
file://URL and is loaded withloadFileURL:allowingReadAccessToURL:. - In the page, marked.js converts Markdown → HTML; highlight.js colors code blocks;
prefers-color-schemedrives light/dark live. -
ListLoadNextre-runs step 2–4 in the sameMDViewfor viewer navigation.
Markdown's natural target is HTML+CSS, and the best-looking, most faithful renderer
on the platform is WebKit. Using WKWebView + the same CSS GitHub publishes
(github-markdown-css) gets tables, task lists, blockquotes, and syntax highlighting
"for free" and correct, rather than reimplementing a Markdown→AppKit layout engine.
The tradeoff is a heavier view and an async render; for a file viewer that's fine. If
you needed thousands of these in a grid, a native NSAttributedString path would beat
it — that's the "when not to" boundary.
The first implementation inlined marked.min.js directly into a <script>…</script>
block in the generated HTML. It failed with ReferenceError: marked is not defined.
Why: a minified library can contain a literal </script> substring (inside a
string or regex). The HTML parser sees that and closes the script tag early,
dumping the rest of the library as text. The library never defines its global.
Fix: reference scripts by URL instead of inlining them —
<script src="file://…/assets/marked.min.js">. The only inline data is the Markdown
itself, carried as base64 inside a non-executable
<script type="application/x-markdown-base64"> element and decoded by a tiny
bootstrap (atob → TextDecoder → marked.parse). Base64's alphabet can't contain
</script>, so the truncation class of bug is structurally impossible.
WKWebView won't load file:// subresources from arbitrary directories by default.
markdown-wlx loads the generated page with:
[web loadFileURL:tempHTMLURL allowingReadAccessToURL:[NSURL fileURLWithPath:@"/"]];and sets <base href="file://<document dir>/"> so relative image paths in the
Markdown resolve against the document's own folder. The read-access grant is what
lets both the vendored assets and the document's images load. (A tighter grant than
/ is possible if you only need the doc dir + asset dir under a common root.)
Symptom: with the plugin active, Escape didn't close the viewer. But pressing 1
(switch to DC's Text mode) and then Escape worked.
Diagnosis: that "press 1 first" clue points at keyboard focus. In plugin mode
the focused view is the WKWebView; in Text mode it's DC's own view. So the web view
was consuming Escape instead of letting it travel up the responder chain to the
viewer window, which is what closes on Escape.
A first fix that looked right but wasn't. The obvious move is to forward Escape
up the responder chain ([self.nextResponder keyDown:event]). A probe confirmed the
keyDown: override fires and the event reaches the parent view — and a synthetic
test passed. But it did not work in the real app. That failure is the more
interesting lesson.
The real root cause. Double Commander is a Lazarus/LCL application. LCL
processes key shortcuts (including Esc → close viewer) through NSApplication's
event dispatch — -sendEvent: — not through synthetic -keyDown: method calls.
Walking the responder chain reaches DC's NSWindow but never re-enters LCL's
keyboard handling, so nothing closes. A stand-in NSView in a unit test does
receive a forwarded keyDown: (which is why the synthetic test passed) — but it
isn't LCL, so the test was validating the wrong thing.
The fix that works mirrors the manual workaround exactly: move focus off the web
view onto DC's own view, then re-post Escape into the event queue so
NSApplication dispatches it the normal way and LCL closes the viewer.
@implementation MDWebView // : WKWebView
- (void)keyDown:(NSEvent *)event {
if (event.keyCode != 53 /* kVK_Escape */) { [super keyDown:event]; return; }
NSWindow *win = self.window; if (!win) return;
NSView *dcView = self.superview.superview; // the view DC handed us
if (![win makeFirstResponder:dcView]) // focus DC's view…
if (![win makeFirstResponder:win.contentView]) [win makeFirstResponder:nil];
if (win.firstResponder == self) return; // couldn't move → no loop
[NSApp postEvent:/* a fresh Esc keyDown */ atStart:YES]; // …let NSApp→LCL handle it
}
@endThis routes through DC's own Esc handler, so it's safe for both the F3 viewer and the embedded quick-view (it never force-closes a window). Every other key is left to normal web handling (scrolling, find, selection).
Takeaways:
- The "press 1 first" detail located the bug in the focus model — but the deeper cause was the host's framework (LCL), not Cocoa's responder chain.
-
A green test against a mock can hide a wrong mental model. The stand-in parent
wasn't an LCL window, so it couldn't reproduce the real dispatch path. When a fix
passes tests but fails in the field, suspect the test's fidelity to the real host,
not just the code. The regression test now drives the event through
NSApplication's queue (not a directkeyDown:call) so it exercises the path that actually matters.
Mermaid (~3 MB) and KaTeX (with fonts) are heavy. Loading them for every Markdown
file would be wasteful, so the host detects before it includes them: the
<script> tags for Mermaid/KaTeX are emitted only when the source actually contains
a ```mermaid block or a $. A plain README pays nothing for features it doesn't
use. After marked renders, the page bootstrap:
- converts
code.language-mermaidblocks into<div class="mermaid">and runs Mermaid (theme synced to light/dark); - highlights the remaining code with highlight.js;
- runs KaTeX auto-render (which ignores
pre/code, so$inside code is safe).
This ordering matters — highlight after Mermaid extraction, math last.
ListSetDefaultParams hands the plugin an ini path; the plugin reads an optional
[MarkdownView] section (theme, maxwidth, fontsize, mermaid, math) on
every open, so edits apply without restarting DC. theme=light|dark swaps in the
forced GitHub light/dark stylesheet and the matching highlight theme instead of the
prefers-color-scheme auto pair.
The same MDView is reused as the user pages through files (ListLoadNext). To make
that feel right, the page posts its scroll offset back to the host through a
WKScriptMessageHandler; the host keeps a per-path map and injects the saved
offset into the HTML it regenerates. Result: returning to a file restores where you
were, while a new file opens at the top. The handler object holds the view weakly
so the userContentController → handler → view chain isn't a retain cycle.
| File | Role |
|---|---|
MarkdownView.m |
the plugin: MDView, MDWebView, the five WLX exports |
listplug.h |
the slice of the TC/DC ABI this plugin uses |
assets/ |
vendored marked.js, highlight.js, GitHub CSS, Mermaid, KaTeX (+ fonts) — attributed |
MarkdownView.ini.sample |
documented optional settings |
samples/ |
Markdown files exercising the renderer |
test/ |
headless harnesses that load the real built plugin |
Next: Building, Testing & CI.