MicroMD: a 3 MB single-file Markdown reader

2026-11-05 · 35 min · Stack Frame

Who this is for: Go and frontend developers building single-file desktop apps on Windows; people evaluating a WebView2 shell or planning to hand-write a Markdown parser; engineers who care about binary size, startup, save semantics and the security boundary.

Bottom line first: MicroMD packs a 3.17 MiB exe, a dependency-free HTML interface and a hand-written Markdown parser into one process. What is worth reviewing is not the small size but four trade-offs: using the system WebView2, hosting the UI on file://, connecting native and JS with exactly three bindings, and a two-pass parser that protects both escaping and placeholders. Seven problems remain unsolved, each with its cost spelled out below.

Reading time ~30 minutes · Level: advanced

Contents
  • Why build another Markdown reader
  • One exe and its native shell
  • Why the interface is written to disk first
  • Three bindings and the meaning of "save"
  • A hand-written parser: two passes and a block state machine
  • Problem one: missing WebView2 and the browser fallback
  • Problem two: relative image paths resolve against the wrong base
  • Problem three: full re-render and large-file throttling
  • Problem four: draft quota and an unused MAX_DRAFT
  • Problem five: size, icon and startup time
  • Problem six: save semantics versus user expectations
  • Problem seven: no file watching, no recent files
  • Known limits and design trade-offs
  • Closing

Why build another Markdown reader

A 3 MB single-file Markdown reader sounds like compressing something that was never big. Markdown is not the hard part — a parser for headings, lists and tables can be short. The hard part is the Windows desktop shell: windows, DPI, icons, the WebView2 runtime, file permissions, and the distribution problem behind "double-click and it works".

MicroMD's answer is to externalise as much cost as possible: don't bundle a rendering engine, use the system WebView2; introduce no UI framework, write one HTML file; depend on no Markdown library, hand-write a parser in a couple of hundred lines of JavaScript; keep the native layer to window creation, three bindings and argument parsing. The result is a single exe, unzip and run, writing only to its own app-data directory, installing no service and requiring no network.

But the boundaries first. It is not cross-platform; heavy Win32 usage and the WebView2 dependency pin it to Windows 10 and 11. It is not a complete editor either: no syntax highlighting, no sidebar outline, no file watching, no recent-file list. Those are not "coming later" roadmap items — they are the current state. This post follows the implementation order: shell and disk writes, then the three bindings and the parser, then the seven exposed problems one by one.

Let us also get the size right. The release build after stripping is 3,323,392 bytes, i.e. 3.17 MiB (about 3.32 MB decimal); unstripped it is 4,943,872 bytes, so -s -w saves 32.8%. "3 MB" in the title describes that order of magnitude, not an exact 3.00 MB.

One exe and its native shell

What is actually inside? It does not bundle Chromium, so "3 MB" means "3 MB of shell plus the engine already on the system", not "a 3 MB browser". The shell has five layers:

LayerImplementationVerified fact
Native shellGo + github.com/jchv/go-webview2Three direct Go modules, all three mislabelled // indirect in go.mod
InterfaceA single web/index.html29,520 bytes, zero JavaScript libraries
Rendering engineMicrosoft Edge WebView2, system-installedNot shipped with the exe
Icon and version resource.syso produced by go-winresThe .syso is gitignored; skip generating it locally and there is no icon
Packaging//go:embedmain.go:18 compiles the HTML into the binary

The native entry point is short. main.go initialises DPI, computes the app-data directory, writes the embedded HTML to disk, parses command-line arguments, then creates a window and calls Run(). The window comes from webview2.NewWithOptions with only a few parameters: title MicroMD, size from windowSize(), IconId: 1, Center: true, and DataPath pointing at %LOCALAPPDATA%\MicroMD\webview. Debug: false disables the default context menu and DevTools — a release configuration, not a development one. Window style comes from go-webview2's defaults; the comment says WS_OVERLAPPEDWINDOW, meaning no custom title bar and no native menu bar. Appearance is entirely the system's.

That choice saves work and hard-codes limits. Without a custom title bar there is no drag region, no minimise animation, no window shadow to adapt; without a menu bar every action must live in HTML buttons and shortcuts. Pointing DataPath at the app directory means WebView2's cache, cookies and localStorage stay in %LOCALAPPDATA%\MicroMD\webview, never polluting the user's other browser profiles, and uninstalling is deleting a directory. The cost is that this directory grows with use and no cleanup logic exists.

One easily missed detail: when NewWithOptions returns nil, main calls openExternal(page) to open the same page in the default browser and returns. That path is the fallback for "no WebView2 runtime" and gets its own section later. Note that not every initialisation failure returns nil — some error paths inside go-webview2 call log.Fatal directly — so the fallback covers window creation failure, not all startup failures.

Why the interface is written to disk first

//go:embed web/index.html compiles the 29,520-byte interface into the binary, and then the code does not use NavigateToString: it writes the file out and navigates with file://. Startup order:

  1. appDataDir() reads LOCALAPPDATA and builds %LOCALAPPDATA%\MicroMD; on failure it falls back to os.UserCacheDir(), then to the relative path MicroMD.
  2. Append app\index.html and os.MkdirAll(appDir, 0o755).
  3. Read the existing file and compare it with indexHTML; only on read failure or difference does it os.WriteFile(page, indexHTML, 0o644).
  4. view.Navigate(fileURL(page)), where fileURL uses net/url for drive letters, slashes and escaping.

Why not navigate from a string? The observable benefits: the page gets a stable file:// origin, which is what lets localStorage persist per origin; <input type="file">, the File System Access API and relative resource resolution all behave like an ordinary page; and there is no custom scheme to register and no local HTTP server. The last point matters most — a local port brings port conflicts, firewall prompts and the "is this thing online?" question, while one of the project's selling points is that rendering is entirely local.

The write policy is "rewrite only when the bytes differ", not every launch. Good: upgrades update the UI, normal launches cost one 29,520-byte read. Bad: if a user hand-edits %LOCALAPPDATA%\MicroMD\app\index.html, the next launch overwrites it. There is no "use an external UI" switch, deliberately.

Two silent failure points sit on this path: MkdirAll failure returns immediately, and so does WriteFile failure. Neither shows a dialog, so the user sees "double-click does nothing". Under normal permissions this almost never happens, but with an antivirus lock, a full disk or an unwritable LOCALAPPDATA there is nothing to diagnose from. That is the direct cost of "single file, no installer": no setup program checks directory permissions for you.

Three bindings and the meaning of "save"

Native and JS share exactly three bindings, all plainly named:

BindingNative behaviourReturn
micromdLoadIf a file path was on the command line, read it and json.Marshal into {name, content}Empty string when there is no file
micromdSaveIf docPath is non-empty, os.WriteFile backok on success, error no file without a path
micromdOpenExternalAccept only http://, https://, mailto: prefixes, then ShellExecuteWok when accepted, empty otherwise

micromdLoad is economical: it returns no path, only a filename and content. The frontend sets app.native = true when it receives JSON, so Ctrl+S calls micromdSave. micromdSave takes no path argument and only honours the docPath parsed at startup; when the frontend calls it while there is no native file, the native side rejects it with no file — one layer of defence.

The real semantic fork is in the frontend. native is true in exactly one case: a file path on the command line — or a .md dropped on the exe icon, which Windows also presents as an argument. Every other way of opening goes through setDocument(file, handle), whose first act is app.native = false. Saving therefore has three paths:

  • Native file: micromdSave writes back to the original path and shows "saved to filename".
  • File picker: if showOpenFilePicker is available, keep the FileSystemHandle and write through createWritable(); the toast says "saved to handle.name".
  • Dropped into the window or the <input type="file"> fallback: neither a native path nor a writable handle, so it degrades to a Blob download and says "exported filename".

The downgrade is explicit in saveFile():

if (app.native && typeof window.micromdSave === 'function') {
  await window.micromdSave(content);
  return;
}
if (app.handle && app.handle.createWritable) {
  const writable = await app.handle.createWritable();
  await writable.write(content);
  await writable.close();
  return;
}
const link = document.createElement('a');
link.href = URL.createObjectURL(new Blob([content], { type: 'text/markdown;charset=utf-8' }));
link.download = app.name || 'untitled.md';
link.click();

One detail deserves credit: setDocument resets native every time a new file is opened. Open A.md from the command line and the native docPath points at A; then open B.md through the picker. Without the reset, Ctrl+S could write B's content into A. The code ties "can this document be written natively" to "which document this is", avoiding the most dangerous class of mis-write.

The cost is three meanings for one shortcut. The same Ctrl+S means "overwrite the original", "overwrite the picker file" or "download a copy" depending on how the document was opened. The README documents the rules; the UI only distinguishes "saved" from "exported" in a toast, and never warns at open time that this file will not be written back. Not an implementation error — a gap between the safety model and user expectation, expanded below.

A hand-written parser: two passes and a block state machine

Between /* md:start */ and /* md:end */ in web/index.html, roughly lines 166 to 412, sits the parser. No marked, no markdown-it: it implements inline() and blocks() itself. What is worth explaining is inline()'s two-pass design, because it solves two seemingly contradictory problems at once — Markdown syntax must still be recognised, and the source must never inject HTML.

The first pass is not parsing but hiding. Code spans, autolinks and backslash escapes are replaced with placeholders of the form \u0001 + number + \u0001, with the corresponding HTML pushed into a stash array:

const stash = [];
const put = (html) => '\u0001' + (stash.push(html) - 1) + '\u0001';
t = t.replace(/(`+)([\s\S]*?)\1/g, (m, ticks, code) =>
  put('<code>' + esc(code.replace(/^ ([\s\S]*?) $/, '$1')) + '</code>'));
t = t.replace(/<((?:https?:\/\/|mailto:)[^\s<>]+)>/g, (m, url) =>
  put('<a href="' + esc(url) + '">' + esc(url) + '</a>'));
t = t.replace(/\\([\\`*_{}\[\]()#+\-.!>~|])/g, (m, ch) => put(esc(ch)));
t = esc(t);

The second pass escapes everything: &, <, >, " become entities. The third pass recognises images, links, bare URLs and emphasis on the escaped text, then restores the placeholders. The order is the point: escape first and backticks inside code spans plus brackets inside links get mangled by entities, breaking syntax recognition; recognise first and the <a> and <img> you emitted get escaped as user input. Placeholders move "HTML that must survive" out of the text temporarily, so esc only touches real user text, and put it back afterwards.

blocks() is a line-by-line state machine. It normalises newlines and expands tabs to four spaces, then in a while loop dispatches by priority: fenced code, horizontal rule, heading, blockquote, table, list, and finally a paragraph. Each block type has its own boundary rules:

  • Fenced code records the number of backticks or tildes; only a closing line of the same character and at least the same length ends it. The body is escaped wholesale and the language becomes class="language-xxx".
  • Headings match ^ {0,3}(#{1,6}); the id strips tags and punctuation, then turns whitespace into hyphens. Repeated headings produce repeated ids.
  • Blockquotes strip the > prefix from consecutive lines and recurse into blocks(), so lists and code fences work inside quotes.
  • Tables split the header row, read the separator row for alignment, and split body rows on \| with a negative lookbehind so escaped pipes are not column boundaries.
  • Lists record the first item's indent and ordered/unordered type, absorb deeper-indented lines into the current item, and recurse blocks() per item, which is what makes nesting work. Task items are matched by ^\[([ xX])\][ \t]+ and render a disabled checkbox.

Paragraphs join consecutive non-blank lines with <br> rather than CommonMark's soft-break-to-space. That is a deliberate choice, not a bug: for a local reader, an author's line break usually means they want a visible break. It also means the output is not strictly CommonMark — copy the same Markdown elsewhere and paragraph shapes may differ.

There is no syntax highlighting: class="language-xxx" is left for future or external styles, and no colour rules exist. There is no sidebar outline either; heading ids are generated but nothing scans them into navigation. The block machine is line-oriented with no AST, so complex nesting, cross-line emphasis and extreme list/table combinations are covered by rules, not by automated tests — the repository contains no *_test.go, and parser quality rests on manual samples.

Security boundary: escape before parsing

The parse order is the first boundary. <script>, <iframe> and onerror= in the source pass through esc and appear as text instead of entering the DOM. The second is safeUrl(): before building an image or link, it checks for javascript:, vbscript: or data:text/html prefixes and renders the original text escaped on a hit. The third is click interception: ordinary links and bare URLs in the preview carry target="_blank" and rel="noreferrer noopener", and a capture-phase handler allows only http(s) and mailto, then preventDefault()s and hands off to micromdOpenExternal; the native side applies the prefix allowlist again before ShellExecuteW. Debug: false removes DevTools and the context menu, narrowing the ways to debug the page from inside the app.

These layers block ordinary script injection, with two caveats worth stating. First, remote images are not disabled: an https:// image in the Markdown will still be fetched. "Rendering is local, no network" means the app itself needs no network, not that remote resources inside a document never load. Second, safeUrl blocks only three dangerous schemes; other schemes and relative paths pass through, and relative links under file:// resolve against the current page's base — which is exactly the image-path problem below. Also note that ordinary links and bare URLs get target and rel, while the angle-bracket autolink branch does not — though it is still intercepted on click, and the real boundary remains the native allowlist.

Problem one: missing WebView2 and the browser fallback

Symptom. On a Windows machine without the WebView2 runtime, double-clicking the exe opens no app window but the default browser, showing what still looks like MicroMD — except "save" becomes a download and native write-back is gone. micromd.bat offers another route: try Edge, then Chrome, then the system default browser with --app on web/index.html.

Cause. The whole rendering layer depends on the system-installed WebView2 Runtime. Most Windows 10 and 11 installs have it; LTSC, slimmed images and some managed enterprise fleets may not. When window creation inside webview2.NewWithOptions fails it returns nil, leaving main with openExternal(page) as the only fallback; in that mode typeof window.micromdLoad is not a function, micromdSave does not exist, and saving degrades. The batch file also depends on web/index.html in the source tree, while the release ships only an exe — so it helps people who cloned the repository, not end users.

Fix. The native layer detects nil and opens the already-written page via ShellExecuteW; the README documents "no runtime, open it in a browser"; the batch file tries Edge, Chrome, then the default browser to reuse whatever Chromium exists. The frontend needs no branch for these modes because it already treats native bindings as optional.

Cost and leftovers. The two modes have different capabilities, and the UI gives almost no indication which one you are in. Browser mode has no native window, no icon, no direct write-back, and draft localStorage depends on the browser's file:// policy. The code never says "please install WebView2" and offers no install link. The fallback covers only window creation failure; some initialisation errors call log.Fatal inside the library, so the process exits silently. The essence of the problem: externalising the engine buys size, and the price is a runtime you do not control.

Problem two: relative image paths resolve against the wrong base

Symptom. A document containing ![shot](images/shot.png) renders fine elsewhere and shows a broken image in MicroMD. The URL resolves to %LOCALAPPDATA%\MicroMD\app\images\shot.png instead of images\shot.png next to the Markdown file.

Cause. The page is navigated from the app directory over file://, so the browser's relative base is the directory containing app/index.html. The parser puts src into <img> verbatim; no <base href="..."> is injected and no rewrite against the document path happens. The native binding returns only filename and content, never a directory, so the frontend could not rewrite even if it wanted to. In command-line mode the native side knows docPath — it just never passes it to the page.

Fix. Not fixed in the current version. Workarounds: absolute paths such as file:///C:/docs/images/shot.png, or data: embedded images; both pass safeUrl and load in WebView2. But absolute paths destroy portability and data: balloons the file, so neither is general.

Cost and leftovers. This is the direct side effect of writing the UI to disk. Fixing it means returning a directory from micromdLoad and rewriting relative src into absolute file:// URLs after render, or injecting a <base> computed from the document directory. The first must handle URL encoding, drive letters, .. and mixed slashes; the second affects every relative URL on the page, links included. Worse, dropping a file into the window and the file picker never expose a full path for security reasons — only a File object — so the same document behaves differently depending on how it was opened. That is why this is still a known limit rather than a fixed item.

Problem three: full re-render and large-file throttling

Symptom. Typing into a long document keeps the cursor moving while the preview stutters; the longer the document, the more obvious the lag. Scrolling sometimes desynchronises preview and editor.

Cause. render() has one line of core logic: preview.innerHTML = MD.render(editor.value). Every keystroke re-parses the entire document and replaces the whole preview tree's HTML string. No incremental parsing, no DOM diff, no Worker. scheduleRender() only merges several keystrokes inside one frame via requestAnimationFrame — it merges call counts, not work; parsing and layout still run on the main thread at full-document scale.

Fix. There is a threshold, BIG_DOC = 200000. Above 200k characters scheduleRender() drops requestAnimationFrame for a 150 ms setTimeout, roughly six or seven renders per second; below it, everything renders every frame. A very cheap degradation: no Worker, no incrementality, just a coarser cadence.

Cost and leftovers. The threshold is character-based and does not map cleanly to parse cost. A 199k-character document still renders every frame; at 201k it suddenly slows, so experience is discontinuous across the boundary. 150 ms is fixed and does not adapt to document size or machine speed; on longer documents a single parse can exceed 150 ms, so throttling reduces frequency but not the length of each stall. Replacing innerHTML also loses the preview selection, reloads images and changes scroll height abruptly; scroll sync computes scrollTop proportionally and goes wrong when heights jump. A real fix needs incremental parsing, virtualised lists, or at least a Worker — all of which significantly raise code size and state-management cost, against this project's size goal.

Problem four: draft quota and an unused MAX_DRAFT

Symptom. The README claims "very long drafts are not written to local storage", but MAX_DRAFT = 1000000 is declared and referenced nowhere. Actual behaviour: every keystroke tries to write localStorage, failures are swallowed by try/catch, the UI says nothing, and the draft may already be gone when the window closes.

Cause. saveDraft() runs 400 ms after typing stops and calls store.set('draft', editor.value) and store.set('name', app.name); store.set is try { localStorage.setItem(...) } catch (e) {}, so quota errors, private-mode restrictions and serialisation failures never surface. MAX_DRAFT was probably intended for a "skip when over the limit" branch that never got wired up. localStorage quota is typically a few MB per origin depending on the browser and its file:// policy; writing the whole document as one string repeatedly means setItem throws past the quota and the draft freezes at the last successful version.

Fix. No real oversize protection exists, only "exceptions are not fatal". Straightforward options: check editor.value.length > MAX_DRAFT in saveDraft(), skip and toast once; move drafts to IndexedDB; or, in native mode, write a draft file into the app directory. All three are more honest than an empty catch.

Cost and leftovers. Users are promised automatic draft saving and get best-effort saving. The 400 ms debounce resets on every keystroke, so a write only happens after a pause; type for ten minutes and power off, and everything since the last successful write is at risk. When a native file has unsaved changes the draft is the only recovery path, which makes the "opened from the command line, edited, never saved" scenario the most dangerous. That README sentence should be read as a current defect, not a shipped limit.

Problem five: size, icon and startup time

Symptom. The release exe is 3,323,392 bytes; unstripped 4,943,872; an empty windowsgui Go program is 1,214,976; the embedded WebView2Loader.dll is about 134 KB. Window-handle appearance measured 203 ms, 112 ms and 106 ms on this machine. Also: a locally built exe from plain go build has no custom icon.

Cause. Size has four parts: the Go runtime and link baseline, the code from go-webview2 and golang.org/x/sys, the 134 KB WebView2Loader and the 29,520-byte HTML. -H windowsgui removes the console window; -s -w removes the symbol table and debug info; together they take 4.94 MB down to 3.17 MiB, 32.8%. Icons are not something Go code can carry directly: IconId: 1 only looks the icon up by resource id at window creation. The actual icon and version info are written by go-winres into rsrc_windows_amd64.syso, and that file is gitignored. CI installs go-winres and generates resources before building; skip that locally and IconId: 1 finds nothing, so the window has no expected icon.

Fix. The build command is fixed at -trimpath -buildvcs=false -ldflags "-H windowsgui -s -w"; the release workflow installs and generates go-winres output before building and fails if rsrc_windows_amd64.syso is missing; windowSize() clamps a 1280×860 scaling to 92% and 88% of the screen so small displays are not overrun; DPI tries SetProcessDpiAwarenessContext(-4) first and falls back to SetProcessDpiAwareness(2). The icon itself is generated by tools/icon/main.go with a signed distance field and 4× supersampling, emitting 16 to 256 pixel sizes with no external design file.

Cost and leftovers. -s -w strips symbols from the release binary, making crash diagnosis harder. Local builds gain a toolchain step: forget the .syso and you get "runs, but no icon", with no compile-time error. The startup numbers are "window handle appeared", not first frame, not finished rendering; after the window appears, WebView2 still creates its environment, navigates to file://, runs the boot script and reads the draft or native document. Calling 106 ms "startup complete" is inaccurate — it says the native window is created quickly. Size is the same story: 3.17 MiB is the result of not shipping an engine, not of compressing Chromium to 3 MB.

Problem six: save semantics versus user expectations

Symptom. Drop notes.md into the window, edit a few lines, press Ctrl+S: the toast says "exported notes.md" and the original is untouched; drop it again and you still see the old content. Drop the same file onto the exe icon instead and Ctrl+S writes back to the original. The UI makes the difference hard to see.

Cause. The browser security model does not allow a page to write to an arbitrary path from a File object, and native is only true when a startup argument exists. Dropping into the window yields a File API File with no writable handle; the picker yields a FileSystemHandle via showOpenFilePicker, but setDocument still sets native = false and saving goes through the handle, not a native path. The code chose "safe by default": without an explicit native path it never guesses which file to overwrite, preferring to export a copy.

Fix. The three-level degradation covers three capabilities: native path, writable handle, Blob download. setDocument resets native to prevent cross-file writes; micromdSave rejects path-less calls natively; toasts distinguish "saved to" from "exported". The implementation is not wrong — it turns "can we write back" into a capability probe instead of a memory test for the user.

Cost and leftovers. The probe result is not surfaced in advance: nothing tells you on drop that this document will be saved by export, and you find out at Ctrl+S. There is no native "save as" dialog either; once in the download branch, WebView2's download policy decides the location, usually the Downloads folder, with a duplicate for identical names. The picker branch depends on the File System Access API, whose availability and permission prompts inside WebView2 vary by runtime; when unavailable it silently falls back to <input type="file"> with no handle at all. No extra branch closes this gap — the UI would have to show the save mode at open time, or expose a native "save as".

Problem seven: no file watching, no recent files

Symptom. Edit notes.md in another editor and MicroMD still shows the old content; press Ctrl+S in native mode and the old content overwrites the external change with no conflict prompt. Close and reopen and there is no recent list; you drag the file in again or retype the command.

Cause. The native layer reads the file once at startup through micromdLoad, then nothing: no ReadDirectoryChangesW, no fsnotify, no mtime polling. Frontend state is app.name, app.saved, app.handle, app.native; localStorage holds only draft, name, theme, view, split — no path history. There is no single-instance lock or IPC either, so a second instance cannot tell the first that the file changed.

Fix. No auto-reload and no conflict detection in this version. Workarounds are closing and reopening, re-dropping, or reopening through the picker — none of which warn that the on-disk version moved. The minimal addition would be native polling of docPath's mtime, notifying the page through a binding or Eval, and showing a bar offering "reload / keep current"; the fuller version uses ReadDirectoryChangesW and handles rename, delete and atomic replace.

Cost and leftovers. Without watching, the boundary between reader and author disappears. As a pure reader that would be acceptable; but the app also edits and writes back, so stale content overwriting newer content is a real data risk. No recent files pushes the cost of opening onto the file system, which sits oddly with "double-click and it works". Watching also introduces decisions — auto-reload loses unsaved edits, a prompt interrupts reading, and save needs an overwrite-versus-copy policy — so adding it carelessly would be more dangerous than not having it.

Known limits and design trade-offs

Pulling the scattered limits into one table, to judge whether this fits your use:

LimitCurrent stateImpact
Relative image pathsBase is the app directory; no <base>, no src rewriteImages beside the document do not display
Draft ceilingMAX_DRAFT declared but unused; quota errors swallowedVery long drafts may vanish silently
Large documentsFull innerHTML replacement; 150 ms throttle past 200k charactersTyping lags, scroll sync can drift
File changesNo watching, no polling, no conflict promptExternal edits can be overwritten
Recent filesNo path history; localStorage stores no pathEvery open requires reselecting
Syntax highlightingOnly a language-xxx class, no colouriserCode blocks are plain text
Outline sidebarHeading ids exist, no navigation generatedLong documents require scrolling
Parser testsNo *_test.go in the repositoryEdge-case syntax verified by hand
Local build iconDepends on go-winres output, which is gitignoredSkip generation and there is no icon
Startup failure messageMkdirAll and WriteFile failures return silentlyDouble-click does nothing in extreme environments

Most of these are not oversights but "not done within the size and complexity budget". They group into four trade-offs:

  • Externalise the engine for size: no bundled Chromium, accepting a WebView2 dependency and two capability modes.
  • One file for engineering simplicity: UI, parser and styles in one HTML file, accepting no module split, no build step, no frontend tests.
  • Full render for short code: one innerHTML line works, accepting a performance ceiling on long documents.
  • Safe by default: no native path means no write-back, accepting three outcomes from one shortcut.

If work continues, data safety should outrank experience polish: wire up the draft ceiling and handle external-change conflicts first, then consider incremental rendering and image bases. Highlighting and an outline are reading comfort and do not risk data loss, so they come later. That ordering is also why this post spends more words on the seven problems than on the features: features can be added, lost data cannot.

Closing

MicroMD compresses into three sentences:

  1. Size is not one optimisation but a budget split. The 3.17 MiB exe is roughly a 1.21 MB empty windowsgui baseline, three Go modules, a 134 KB WebView2Loader, 29,520 bytes of HTML and link output; the Chromium that was omitted is supplied by the system WebView2.
  2. Shell complexity concentrates in Windows details. Writing to disk for file://, three bindings, save semantics, DPI fallback, icon resources, runtime-missing fallback — each is small, and each can break "double-click and it works".
  3. The hand-written parser's key is order, not syntax coverage. Hide code spans, autolinks and escapes in placeholders, escape everything, then restore. The two-pass design solves "syntax must be recognised" and "source must not inject" at once.

It is not a finished product either. Relative images, draft quota, full re-render, file changes, recent files, highlighting, outline, parser tests — all missing or half-done. They are documented as current state because the value of a 3 MB single-file app is not how small it is, but what it cost to get there, and whether those costs are stated honestly.

Further reading