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.

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
}
@end

This 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 direct keyDown: call) so it exercises the path that actually matters.

Optional stages, loaded only when needed

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:

  1. converts code.language-mermaid blocks into <div class="mermaid"> and runs Mermaid (theme synced to light/dark);
  2. highlights the remaining code with highlight.js;
  3. runs KaTeX auto-render (which ignores pre/code, so $ inside code is safe).

This ordering matters — highlight after Mermaid extraction, math last.

Configuration

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.

Scroll position across navigation

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

Clone this wiki locally