Skip to content

Markdown Plugin Architecture

Nikolai Sachok edited this page Jun 24, 2026 · 3 revisions

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.

High-level flow

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 ────────┘
  1. ListLoad builds an MDView containing an MDWebView.
  2. The Markdown file is read and base64-embedded into a generated HTML document.
  3. The document references vendored assets (marked.js, highlight.js, GitHub CSS) by file:// URL and is loaded with loadFileURL:allowingReadAccessToURL:.
  4. In the page, marked.js converts Markdown → HTML; highlight.js colors code blocks; prefers-color-scheme drives light/dark live.
  5. ListLoadNext re-runs step 2–4 in the same MDView for viewer navigation.

Why render in a WebView at all

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.

Gotcha 1 — never inline minified JS into a <script> tag

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 (atobTextDecodermarked.parse). Base64's alphabet can't contain </script>, so the truncation class of bug is structurally impossible.

Gotcha 2 — local file access & relative images

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.)

Case study — WKWebView swallows the Escape key

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 a WKWebView subclass actually get called? Yes.
  • Does forwarding the event to nextResponder reach 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];
}
@end

Why 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 map

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.

Clone this wiki locally