rmd

module
v0.8.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 2, 2026 License: MIT

README

Read Markdown

Platform Go Build License

rmd <file.md> renders a markdown file into a single self-contained HTML page, serves it once on a random loopback port, opens it in your default browser, and exits as soon as the page confirms it loaded. Every asset - fonts, CSS, JS, local images, Mermaid, KaTeX - is inlined into that one HTML document, so the tab keeps working (themes, font sizes, diagrams) long after the process is gone.

Pass several files and they all land on one page, navigable from the sidebar. Add --tunnel and the page also gets a public URL for as long as you keep the session open. Add --edit and the page can write your changes back to the files it was rendered from. Both of those sessions follow the files while they run: a change made in your editor re-renders in every open tab about a second later, with no reload and nothing to press.

rmd notes.md                  # render, open in the browser, exit
rmd --dark notes.md           # the same, starting in the dark theme
rmd docs/*.md                 # one page for the whole set, sidebar navigation
rmd docs/*.md --tunnel        # plus a public link, up until Ctrl+C
rmd --edit notes.md           # edit the blocks in the browser, save to disk
rmd --version                 # print version

Quick install via the personal Homebrew tap (other methods under Install):

brew install dsbasko/tap/rmd   # install
brew upgrade rmd               # update to the latest release


Contents


Why

Reading .md in a terminal or an IDE is a compromise: monospaced everything, no real typography, no themes. The usual fixes are worse in their own way - a dedicated viewer app you have to install and keep updated, or a grip-style preview server that stays resident for as long as the tab is open.

rmd is the third option: browser typography with a one-shot process. It does its job - render, serve once, open - and exits. The page it leaves behind is fully autonomous: close the laptop, go offline, reopen the tab tomorrow - the theme switcher and every diagram still work, because nothing on that page references the network or the dead server.

How it works

  1. Parse. goldmark with GFM, footnotes, and auto heading ids turns the markdown into HTML. Along the way, AST transformers inline local ![]() images as data:<mime>;base64,…, convert ```mermaid fences into <pre class="mermaid"> blocks, and capture math ($$…$$, \(…\), \[…\]) at parse time, before CommonMark can mangle the LaTeX. Code blocks get chroma classes, coloured by a palette written in the page's own theme tokens.
  2. Assemble. html/template + go:embed build one self-contained page: CSS, JS, woff2 fonts, mermaid.min.js, and katex.min.js are embedded into the binary and inlined into the HTML. Mermaid and KaTeX are included only when the document actually uses them. Several input files become several <article> blocks on that same page - one visible, the rest in the DOM but hidden. There is not a single external URL in the output.
  3. Serve and open. An HTTP server starts on 127.0.0.1:<random port>, the URL is printed to stdout, and the browser opens it (open / xdg-open / cmd depending on the platform). With --tunnel, an ngrok child process is started next to it and the public URL is printed too.
  4. Exit. On window.load the page sends navigator.sendBeacon('/done'); the server shuts down and the process exits 0. With --tunnel or --edit the beacon is ignored and the session runs until Ctrl+C.

Two safety valves keep it from hanging around:

  • no GET / within 60 s → exit 1 (the URL is already on stdout, so you can still open it by hand);
  • page served but no beacon within 15 s → exit 0 (the content was delivered in full; the server has nothing left to do).

Neither applies under --tunnel or --edit: there the session is meant to outlive the first page load, and only Ctrl+C (or a dead tunnel) ends it.

Install

Homebrew (easiest):

brew install dsbasko/tap/rmd   # install
brew upgrade rmd               # update to the latest release

Installs from the personal tap dsbasko/homebrew-tap (not homebrew-core), building from source. The build needs no network access beyond fetching the tarball: all page assets are committed to the repository and embedded via go:embed.

Prebuilt binaries:

Every release carries an archive per platform - macOS, Linux and Windows, amd64 and arm64 each - plus checksums.txt, on the Releases page. Unpack it and drop rmd anywhere in PATH: the binary is static and carries its own assets, so nothing else is installed and nothing is fetched at runtime.

Quick (go install):

go install github.com/dsbasko/rmd/cmd/rmd@latest

Drops the binary in $(go env GOPATH)/bin.

From a clone:

git clone https://github.com/dsbasko/rmd
cd rmd
make install   # go install into $GOBIN
make build     # or: local binary in ./bin/rmd

Usage

rmd notes.md                    # render and open in the browser
rmd --paper notes.md            # open in the paper theme
rmd --dark --l notes.md         # dark theme + the largest text size
rmd README.md CONTRIBUTING.md   # two documents on one page
rmd docs/*.md --tunnel          # the whole set, plus a public link
rmd --edit notes.md             # editable page, runs until Ctrl+C
rmd --version                   # print version and exit
Flags
Flag Effect
--light / --dark / --paper Open with that theme instead of the system prefers-color-scheme default.
--s / --m / --l Open with that text size instead of the default m.
--edit Edit the blocks in the browser and write the changes back to the files; the session runs until Ctrl+C and follows the files on disk.
--tunnel Publish the page through ngrok and keep the session up until Ctrl+C; the published page follows the files too.
--no-qr Do not draw the public URL as a QR code.
--version Print version and exit.
--help / -h Print the grouped flag help and exit.

At most one theme flag and one size flag per run - conflicting flags (say, --dark --light) exit with code 2. The flags only set the starting state: the on-page controls still switch theme and size freely afterwards. Single-dash spellings (-dark, -l) work too, and flags may come before or after the file list (rmd docs/*.md --tunnel is the same as rmd --tunnel docs/*.md). To pass a file whose name starts with a dash, put it after --.

What happens on a run:

  1. the markdown is parsed into a self-contained HTML page;
  2. a local server starts on 127.0.0.1:<random>, its URL is printed to stdout;
  3. the page opens in the default browser (a failed open is a warning, not fatal - the URL is already printed);
  4. after window.load the browser sends navigator.sendBeacon('/done'), the server shuts down, the process exits 0.

The tab stays fully functional after the process ends - theme, size and document switching all work offline. Steps 3-4 are where --edit and --tunnel differ: both keep the server up until Ctrl+C instead of exiting on the beacon.

Several files at once

rmd docs/index.md docs/api.md docs/faq.md
rmd docs/*.md

Every file is rendered into the same page and the sidebar grows a Документы list above the Оглавление of the file being read. One document is visible at a time, the rest wait in the DOM - which is why navigation keeps working after the process is gone, exactly like the theme switcher does. On narrow windows (≤1100 px) the sidebar slides in as a drawer behind a burger button in the top-left corner - close it with Esc, a click outside it, or by following any link in it.

What the multi-file mode does to your markdown:

  • Anchors get a per-document prefix. Headings in api.md become #api-overview, footnotes become api-fn:1. A single file keeps its plain unprefixed anchors, so nothing about the one-file output changes.
  • In-page links keep working. [see](#overview) inside api.md resolves to that document's actual heading id - even when another file in the set produces a colliding candidate.
  • Links between the files become in-page jumps. ./api.md and ./api.md#endpoints turn into anchors when the target is part of the same run; anything else (external URLs, files you did not pass) is left alone. A link forward to a file that has not been rendered yet lands on the document rather than on the exact heading - see Known limitations.
  • Duplicate paths collapse. rmd a.md a.md is one document.
  • A bad path is fatal. Nothing is served if any file in the list is missing or is not a regular file (exit 1).

The page title comes from the first document. If the assembled HTML ends up over 20 MB - realistic for a large set with inlined images - a warning is printed to stderr, but the page is still served.

Picking up where you left off

A forty-screen specification is read in several sittings, and every one of them used to start at the top. It no longer does: the page opens where you stopped last time. Nothing to turn on, no flag, no prompt - it works in the plain one-shot rmd file.md as well as in a live session.

rmd spec.md      # read half of it, close the tab
rmd spec.md      # tomorrow: the same half
  • The place is remembered per document, not per run. rmd docs/*.md leaves off in api.md; the next rmd docs/*.md opens api.md at that spot, and so does rmd api.md on its own. When several of the documents on the page have a remembered place, the page opens on the one read most recently.
  • The unit is a heading and a fraction of its section, not a pixel offset and not an anchor id. The theme, the font size and the window width change where a paragraph sits on the screen; the argument list changes the ids (#overview becomes #api-overview). A heading index survives all of it, and a document rewritten since the last reading degrades to roughly the right place rather than to an error.
  • An explicit anchor wins. A #install in the address bar, a click in the table of contents, a link in the text - all of them name a place and the memory keeps quiet. Naming a document (which is what the sidebar and the address bar do between documents) does not: switching documents lands on that document's remembered place. Clicking the document you are already reading still takes you to its top, which is the one-click way back up.
  • It is stored in a cookie on 127.0.0.1, and nothing is written to disk. The one-shot process exits as soon as the page has loaded, so there is nobody left to write a state file; the port is random, so localStorage - keyed by origin, port included - would be unreachable on the next run. Cookies are keyed by host and ignore the port, which is exactly the scope needed here. The page keeps the 50 most recently read documents for 30 days each: per document a hash of the file path, two numbers for the place, and the second it was written - the one that decides which 50 and how old.

Worth knowing:

  • Recording starts once the page has finished loading, and under --edit it stops while a block editor is open (the textarea stands where the headings were). A tab closed before the images, the formulas and the diagrams are in place leaves the previous memory alone rather than writing a half-laid-out one.
  • There is no switch, and no way to forget from inside the page. An explicit #anchor in the address bar opens at that anchor, and clicking the document you are already reading takes you to its top - but to drop the memory itself, clear the rmd_pos cookie for 127.0.0.1 (DevTools → Application → Cookies).
  • Safari keeps it for 7 days, not 30. Its tracking prevention caps the lifetime of any cookie written from JavaScript.
  • The cookie travels to every server on 127.0.0.1. Any local dev server you have running will see an rmd_pos header. It is under 2 KB at its fullest, and it carries hashes and numbers - no paths, no file names, no document contents.
  • Under --tunnel the memory belongs to the ngrok domain. The page served through the tunnel is a different host, so its reader gets their own memory there - and on the free plan, where the domain changes from session to session, it is lost between them.

Editing in the browser (--edit)

rmd --edit notes.md
rmd --edit docs/*.md
http://127.0.0.1:53187/e/hT8kZ2mQ1sVv0pXn9rLbF3aY7cJ4uEwD/
Press Ctrl+C to stop

The page opens in reading mode and looks exactly as it does without the flag, except for a second pill at the top holding the mode switch and "Сохранить". It sits at the edge of the text column, on the side the navigation is not on: to the left of the text where the sidebar is docked beside it, to the right where the sidebar is a drawer and the burger has taken the left corner. The pencil button there (or Cmd/Ctrl+E) switches it to editing: click a block and it turns into a <textarea> holding that block's markdown source; leaving it renders the block back. Cmd/Ctrl+S (or the "Сохранить" button) writes the changed files to disk.

--edit cancels the one-shot process. Without it rmd exits as soon as the page reports it loaded; with it the server stays up until Ctrl+C, because it is what serves the sources and takes the edits. After Ctrl+C the tab stays readable - themes, sizes, navigation and diagrams keep working like on any other rmd page - but no longer editable: the first failed request flips the page back to reading mode and shows a banner instead of losing the text. The event stream gives up with it: after ten reconnects in a row that reach nothing, the page stops calling a dead origin.

What counts as a block is what markdown calls a top-level block: a paragraph, a heading, a whole list, a whole table, a blockquote, a fenced code block (including ```mermaid), a $$…$$ display formula, the footnotes block. Clicking a list item hands you the whole list, clicking a table cell the whole table - the same granularity Typora uses.

Keys and controls:

Action How
Toggle reading / editing Cmd/Ctrl+E, or the eye / pencil buttons
Open a block Click it (blocks with nothing to edit, like ---, are inert)
Close and submit Esc, or a click outside the editor
New paragraph after this block Cmd/Ctrl+Enter
Save to disk Cmd/Ctrl+S, or the "Сохранить" button (it appears with the first unsaved edit and leaves when the files are written)
See render warnings The ⚠ badge in the toolbar, hidden while there are none

Editing starts from a block that is already there: Cmd/Ctrl+Enter grows the document from the one you have open, so a file with no blocks at all - an empty one - has nothing to click and gets its first line in an editor.

Nothing is written to disk until you save: Cmd/Ctrl+S is the only thing that touches the files, there is no autosave. Documents with unsaved edits are marked - a dot next to the document in the sidebar, and the "Сохранить" button itself, which is only on the page while there is something to write - and closing the tab with edits pending asks for confirmation.

On a touch device, where there is no hover to reveal the per-block affordance, every block that can be opened carries a thin rule in the left margin for as long as edit mode is on.

How it holds together:

  • The markdown source on the server is the truth, not the DOM. Nothing is serialised back from HTML: the page has base64 images, rendered diagrams and formulas, prefixed ids and rewritten links in it, none of which survive the trip back. What you edit is the source text of one block; what the page shows is the server's re-render of it.
  • Every edit re-parses the whole page. Editing a heading changes its id, so the table of contents and any link to it from a neighbouring document are rebuilt in the same response. Only blocks whose HTML actually changed are sent back.
  • Reloading the tab is safe. The page of an editing session is built per request, so F5 answers the documents as the session holds them now, edits included - saved or not. A reload that still has unsaved work says so: the fresh page arrives with the "Сохранить" button and the sidebar marks already on it, and a file that diverged under those edits arrives with its banner and its "Перечитать" as well. It opens in reading mode, like every rmd page does.
  • The files are watched, and the page follows them. Twice a second the session re-stats the documents it opened; anything that moved behind its back
    • a git checkout, a formatter, the same file open in nvim - is re-read and pushed to every open tab over an event stream. No reload, and no second tab to keep in step by hand. A change is acted on the tick after it is noticed: editors write by truncating first, and a file read mid-write would reach the page as half a document.
  • A disk change under unsaved edits asks instead of choosing. A document you have edited in the browser is never re-read behind your back. The page keeps what you typed and raises a banner - "notes.md изменился на диске, а в нём несохранённые правки. Перечитать? Правки в нём пропадут" - with a "Перечитать" button that takes the file as it is on disk and drops the edits. Until you press it neither side moves and nothing on disk is touched; saving instead goes through the same conflict refusal as any other outside write. Put the file back the way it was - an undo, a git checkout - and the banner goes down by itself: there is nothing left to decide, and your edits are still there.
  • Writes are atomic and keep the file as it was. The new text goes into a temporary file in the same directory, gets fsynced, takes the target's permissions and is renamed over it. A symlink stays a symlink - its target is what gets rewritten.
  • A write is atomic per file, not per page. A document that cannot be written at all - a read-only directory, a full disk - costs only itself: the rest are on disk with their marks cleared, the failed one keeps its mark, the banner names it and the next save retries just that one. The only all-or-nothing refusal is the disk conflict below.
  • Render warnings live in the page. A missing image or a link to a file that is not on the page is printed to stderr at startup and shown in the ⚠ badge besides: the count in the toolbar, the list one click away. The badge starts filled if the first render had anything to say and is replaced by every edit's own warnings after that - a session opened on a phone through --tunnel has no terminal to print to.
  • Conflicts are refused, not merged. If the file changed on disk since it was read, the save is rejected as a whole (no file is written) and the banner offers to overwrite or cancel. If a second tab edited the same document first, the block edit is rejected and your text stays in the textarea for you to take out by hand.
  • Nothing that crosses the wire names your directories. Warnings, the files a conflict refused over and the reason a write failed are all reported by file name and cause alone. The page an --edit --tunnel session hands out is the same page you are looking at, and an absolute argument would otherwise publish your OS user name with the first missing image.
  • The URL carries a token and nothing is served without it. The path segment after /e/ is 24 random bytes; the root and any wrong token get an empty 404. The token is not a nicety even on loopback: any page open in your browser can POST to 127.0.0.1 blindly, and this API writes to your files.
  • --edit --tunnel publishes that token. The public link is not read-only - anyone who opens it can rewrite the files on disk, and rmd says so on stderr when it prints the URL. Do not hand that link out the way you would a reading one.

Sharing a page (--tunnel)

rmd docs/*.md --tunnel
Local:   http://127.0.0.1:53187/
Public:  https://a1b2-93-184-216-34.ngrok-free.app

    █▀▀▀▀▀█ ▄▄▄ █▄██ ▄▀█▄ █▀▀▀▀▀█
    █ ███ █  ▀▄ █ ▄██ ▄   █ ███ █
    █ ▀▀▀ █ ▀  ▀▄▄██   █▄ █ ▀▀▀ █
    ▀▀▀▀▀▀▀ ▀▄█▄▀▄█ █▄▀▄█ ▀▀▀▀▀▀▀
             (37 x 19 characters)

Press Ctrl+C to stop

warning: anyone with the public link can read these files

The public URL is also drawn as a QR code, because the device that needs it is usually a phone or a tablet with no keyboard worth typing an ngrok hostname on - and under --edit the link carries a 32-character token as well. Point a camera at the terminal and the link opens.

The code is drawn only when stdout is a terminal: redirected output is read as text, by a pager, a log or a script grepping for the URL, and half a kilobyte of escape sequences is noise there. --no-qr turns it off in a terminal too - for a window narrower than the code, or a screen recording that should not carry a scannable link.

Instead of exiting after the first load, rmd keeps the server up and runs ngrok alongside it, forwarding the public URL to the local one. The browser opens the local address; the public one is printed for other people to use. Ctrl+C shuts down both rmd and its ngrok child.

The published page is live. The session behind it re-stats the files twice a second and pushes whatever changed to every open tab, so an edit made in your editor re-renders on the phone reading the link about a second later, with no reload and nothing to press. Traffic in the other direction is refused: without --edit there is no write API at all, and the link stays read-only. Warnings a re-read turns up - a missing image, a link that leads nowhere - are shown in a banner on the page as well as on stderr, because the device reading the link has no terminal to look at.

The public URL is deliberately not baked into the page: the HTML is assembled before the tunnel exists, and patching finished bytes would blur the self-contained guarantee for something copy-paste already solves.

Requirements:

brew install ngrok                        # macOS; otherwise https://ngrok.com/download
ngrok config add-authtoken <your-token>   # a free account is enough

ngrok has to be in PATH - this is checked before any file is read, so a missing binary fails instantly instead of after a minute of base64 inlining:

rmd: --tunnel requires ngrok, which was not found in PATH
     macOS: brew install ngrok
     other: https://ngrok.com/download
     then:  ngrok config add-authtoken <your-token>

An unconfigured authtoken is reported just as plainly: rmd: ngrok is not authenticated; run: ngrok config add-authtoken <your-token>.

Worth knowing before you share the link:

  • Anyone with the URL can read the files. There is no auth in front of it, and the link is guessable-adjacent at best - treat the page as public.
  • With --edit the link is not read-only. It carries the edit token, so anyone who opens it can rewrite the files on disk - see Editing in the browser. The warning line under the URL says so; share that link with nobody you would not hand a shell in the directory.
  • Free ngrok shows an interstitial page to visitors before the content. Nothing on the rmd side can skip it.
  • If the tunnel dies on its own (network gone, session revoked), the run ends with exit 1 and the reason on stderr.
  • A tunnel that never comes up fails after 20 s with rmd: ngrok did not report a public URL within 20s, and the local server goes down with it. Ctrl+C during that wait ends the run right away rather than sitting it out.
  • A child ngrok that will not die is the one cleanup failure you hear about: the run ends with exit 1 and a stderr line saying the public URL may still be reachable, because a leftover session keeps serving your files - and blocks the next --tunnel run with the endpoint is already online.
  • A file passed twice is one document. A plain run collapses arguments by absolute path only; a --tunnel run resolves symlinks as well, so rmd --tunnel a.md link-to-a.md publishes one document instead of the same text twice.
  • The paths you invoke rmd with stay on your machine. rmd ~/work/client-x/*.md leaves api.md in the sidebar tooltip, never /Users/you/work/client-x/api.md - the directory a glob expanded to is not published. A relative path is published as you typed it, so rmd ../clients/acme/*.md does put ../clients/acme/api.md in the tooltip.
  • Whatever an ![img]() points at is published, contents and all. Inlining is how the page becomes self-contained, and it follows ../ and absolute paths out of the document tree without asking - the destination is read and embedded as data:, extension unknown or not. So ![](../../.ssh/id_rsa) in a markdown file someone sent you is served to anyone with the link. Read a file you did not write before tunnelling it.
  • Paths written inside the markdown are published as written. This is about destinations rmd cannot rewrite: an ![img](/Users/you/…) whose file is missing (a readable one is inlined instead - see above) and a [link](/Users/you/…md) to a file outside the set both keep their absolute path in the served HTML. Grep your files for absolute paths before sharing a tunnel link.

Themes and sizes

Settings are not persisted: every run starts from the system theme (prefers-color-scheme) and size m, unless a CLI flag preselects another starting point (rmd --paper --l notes.md). The controls are a floating pill at the bottom of the viewport - the same pill at every window width and with or without a sidebar. On a phone it shrinks, once you start scrolling, to the theme and the size that are currently set; a tap brings the rest of it back, and scrolling on puts it away again. Choices live until the tab is closed (no localStorage). The reading position is the one thing that does outlive the tab - see Picking up where you left off - the theme and the size do not.

  • Themes: ☀ light · ☾ dark · ¶ paper. With no choice made, the system light/dark preference applies; paper is manual-only.
  • Sizes: s / m / l → --prose-font-size 13 / 16 / 20 px (code font 11 / 14 / 17 px), with the content column width scaling along (820 / 980 / 1220 px). Default is m. Three steps rather than five: each one is a different way of reading - a dense reference, the default, a screen someone else is looking at
    • and the jump between them says so.

Rendering features

  • GFM: tables, blockquotes (including nested), task-list checkboxes, strikethrough, autolinks, footnotes.
  • Table of contents: documents with ##/### headings get a floating panel beside the text - a Документы list when there are several files, and the Оглавление of the one being read, with scroll-spy highlighting and smooth-scroll navigation. It stays a fixed distance from the column rather than at the window edge, so the text and the panel read as one centred block at any window width. On narrow windows (≤1100 px) it becomes a drawer behind a burger button, and it is hidden in print.
  • Code highlighting: chroma with CSS classes, coloured from the theme tokens - keywords in the accent, strings in the success tone, numbers in the notice one, comments quiet and italic. One palette, not one per theme: the tokens are what data-theme moves, so the code block follows the page into dark and paper without a second copy of anything. A fence with no language has nothing to highlight but still gets the block's chrome, including its own horizontal scroll - long lines scroll inside the block instead of widening the page.
  • Mermaid: ```mermaid fences render client-side; diagram theme follows the page theme.
  • KaTeX: $$…$$, \(…\), \[…\]. Single $…$ is deliberately not supported, so "from $10 to $20" never turns into math.
  • Images: local paths (relative or absolute) are inlined as data:<mime>;base64,…; http(s):// and data: sources are left as is; a missing file produces a warning on stderr and the node is left untouched.
  • Fonts: Roboto Slab (prose) and JetBrains Mono (code) are embedded with Latin and Cyrillic subsets - nothing needs to be installed in the OS.

Known limitations

  • Raw HTML <img src="…"> and url() inside user HTML are not inlined - only markdown ![]() nodes are processed. The same goes for raw HTML <a href="#…">: cross-document link rewriting only touches markdown links.
  • A link to a heading in a file that comes later in the argument list (./api.md#endpoints from index.md) lands on the document, not on the heading - at that point the target has not been rendered and its final id does not exist yet. Backward links resolve exactly.
  • Paths that differ only by case (API.md and api.md on macOS) are not recognised as the same file, so the document appears twice.
  • The same image used in several files is inlined once per file - base64 payloads are not deduplicated.
  • The tab title is the first document's heading and stays that way. When the remembered position opens another document of the set - or you switch to one in the sidebar - the title no longer names what is on the screen.
  • GitHub alerts are not supported.

In a live session (--edit or --tunnel) on top of that:

  • A change takes up to a second to show up. The files are polled twice a second and a change is acted on the tick after it is first seen, so an outside write lands in roughly 0.5-1 s. Nothing is instant, and nothing is missed.
  • Only the files on the command line are watched. A document created in the directory mid-session is not picked up, and a deleted one keeps its last rendering: the set is fixed at startup, and a file that is not there for an instant - which is how vim renames its temporary over the target - is simply skipped for that tick.
  • Tabs follow the documents, not each other's view of them. A document that changed re-renders in every open tab, and a save made in one of them puts the unsaved marks out in all of them. The scroll position, the caret and any open editor belong to the tab they are in; there is no shared cursor.
  • A diagram or a formula added mid-session renders after a reload. Whether mermaid and KaTeX are inlined is decided when the page is built, from the documents as they were then. Type the first one into a session that started without any and the block stays visible source until the tab is reloaded - the banner says as much. The reload rebuilds the page from the current documents and brings the bundle with it.
  • The articles follow the files; the frame around them only partly. A changed document re-renders and its table of contents is rebuilt, but the name in the sidebar list of documents and the title of the tab keep the heading the file had when the page was built, and a page that started without a sidebar - a single file with no headings - does not grow one when the first heading is typed. A reload puts both right.

In editing mode (--edit) on top of that:

  • The caret lands at the end of the block, not where you clicked. Opening a block is a round trip to the server for its source, and mapping a click on rendered HTML back to an offset in markdown is a different feature.
  • Undo does not cross block boundaries. Each block is its own <textarea>, so the browser's undo stack dies with it: Cmd/Ctrl+Z cannot walk back into the block you edited before this one, and there is no page-level history.
  • Simultaneous edits are refused, never merged. An edit submitted against a version the document has already moved past - the other tab got there first while this block was open - is rejected with a conflict banner and the text stays in the textarea; nothing is merged automatically.
  • A block open while it changes elsewhere keeps its text and is refused on submit. Its version is behind and there is nowhere for the text to land, so take it out of the textarea by hand. A block that the change removed from the document altogether stays on the page for as long as its editor is open, for the same reason: that textarea holds the only copy of it.
  • Files are neither created nor deleted. The session writes exactly the files given on the command line - you cannot add a document to the page or drop one from disk through it.
  • Blocks with no source of their own cannot be opened - a --- is the one that occurs in practice. They stay inert in editing mode. So is the footnotes block when its definitions are written on either side of another block: its range spans that block, and an editor over it would show a paragraph that belongs elsewhere and drop it on submit.
  • A block over 1 MB cannot be submitted. The edit API caps a request body at a megabyte, far above anything typed by hand but reachable for a block holding an inline data URI. The editor stays open with the text in it, so the way out is to shorten the block.
  • An empty file has nothing to click. A document with no blocks renders no block wrappers, and the page has no way to make the first one; write a line into the file before starting the session.
  • Duplicates are resolved differently in each mode. A plain reading run collapses arguments by absolute path and shows a symlink and its target twice; --tunnel resolves symlinks as well and keeps the first copy; an editing session resolves them too but refuses to start (exit 1) rather than open one file as two documents, because saving would drop the edits made in one of them. Letter case is still not resolved: on a case-insensitive filesystem rmd --edit API.md api.md opens the same file twice, each copy is written on save, and the second write moves the mtime out from under the first - the next save then reports the file as changed on disk and overwriting it drops whatever the other copy held.
  • A new paragraph is written by appending to the block above it (Cmd/Ctrl+Enter), because an empty block has no range of its own to write into. Right after a block that has no source - a --- again - there is nothing to append to, and the page says so instead of sending anything.

Building from source

Go 1.26+, no cgo, no network needed for the build - every embedded asset is committed to the repository.

make build     # ./bin/rmd with -ldflags "-s -w -X main.version=…"
make test      # go test ./...
make lint      # golangci-lint run
make install   # go install into $GOBIN
make clean     # remove ./bin

Rough layout:

cmd/rmd/            CLI entry point; run(args, stdout, stderr) int is the testable wrapper
internal/render/    goldmark → Result{HTML, Blocks, Title, TOC, HasMermaid, HasKatex, Warnings};
                    IDSpace hands out per-document anchor prefixes and resolves links
internal/page/      Build(Params{Docs}) []byte - html/template + go:embed → self-contained HTML
internal/edit/      the live session behind --edit and --tunnel: owns the markdown
                    sources, polls the files for outside changes, applies block edits,
                    re-renders the page, writes files atomically; Handler() is its API,
                    Events() the state stream alone (read-only sessions mount only that)
internal/server/    loopback server: Start() → url, Addr(); Wait(ctx) handles shutdown;
                    New() is one-shot and takes finished bytes, NewKeepAlive() and
                    NewEdit() take a page function and run until the context is
                    cancelled, so a reload is answered from the live session; NewEdit()
                    mounts the page and the edit API under /e/<token>/
internal/tunnel/    Start(ctx, addr) - an ngrok child process behind a mockable runner;
                    ctx bounds the startup wait, Stop() reports a child it cannot reap
internal/browser/   Open(url) - open / xdg-open / cmd behind a mockable platform runner
internal/qrterm/    Render(url) - the public URL as half-block glyphs for a terminal
scripts/            vendor-assets.sh - re-vendors the pinned embedded assets

Updating embedded assets (fonts, Mermaid, KaTeX) goes through scripts/vendor-assets.sh - versions are pinned in its header, and the result is committed to the repository.

Directories

Path Synopsis
cmd
rmd command
internal
qrterm
Package qrterm draws a QR code as terminal text, so a URL printed on one machine can be opened on another by pointing a camera at the terminal.
Package qrterm draws a QR code as terminal text, so a URL printed on one machine can be opened on another by pointing a camera at the terminal.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL