-
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.
Prove it before fixing. A throwaway probe (test/esc_probe.m) answered two
questions with certainty:
- Does a
keyDown:override on aWKWebViewsubclass actually get called? Yes. - Does forwarding the event to
nextResponderreach the parent view? Yes.
Fix — forward only Escape up the chain; leave every other key to normal web handling (scrolling, find, text selection):
@implementation MDWebView // : WKWebView
- (void)keyDown:(NSEvent *)event {
if (event.keyCode == 53 /* kVK_Escape */) {
[self.nextResponder keyDown:event]; // → MDView → DC's view → viewer window
return;
}
[super keyDown:event];
}
@endWhy this over the alternatives: an NSEvent local monitor or re-dispatching the
event would be more code and more fragile (and risks closing the wrong window in
DC's embedded "quick view"). Forwarding through the responder chain makes the plugin
behave exactly as if DC's own view were focused — which is precisely the state that
already worked. The regression test (test/esc_verify.m) loads the real built plugin
into a stand-in parent, focuses the web view, sends Escape, and asserts the parent
receives it.
Takeaway: the "press 1 first" detail wasn't noise — it located the bug in the focus/responder model. Reproductions that change one variable (here, which view has focus) are often the whole diagnosis.
| 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 (attributed) |
test/ |
headless harnesses that load the real built plugin |
Next: Building, Testing & CI.