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.

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