README
¶
Read Markdown
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
- How it works
- Install
- Usage
- Several files at once
- Picking up where you left off
- Editing in the browser (
--edit) - Sharing a page (
--tunnel) - Themes and sizes
- Rendering features
- Known limitations
- Building from source
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
- Parse. goldmark with GFM, footnotes,
and auto heading ids turns the markdown into HTML. Along the way, AST
transformers inline local
![]()images asdata:<mime>;base64,…, convert```mermaidfences 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. - Assemble.
html/template+go:embedbuild one self-contained page: CSS, JS, woff2 fonts,mermaid.min.js, andkatex.min.jsare 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. - 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/cmddepending on the platform). With--tunnel, anngrokchild process is started next to it and the public URL is printed too. - Exit. On
window.loadthe page sendsnavigator.sendBeacon('/done'); the server shuts down and the process exits0. With--tunnelor--editthe beacon is ignored and the session runs untilCtrl+C.
Two safety valves keep it from hanging around:
- no
GET /within 60 s → exit1(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:
- the markdown is parsed into a self-contained HTML page;
- a local server starts on
127.0.0.1:<random>, its URL is printed to stdout; - the page opens in the default browser (a failed open is a warning, not fatal - the URL is already printed);
- after
window.loadthe browser sendsnavigator.sendBeacon('/done'), the server shuts down, the process exits0.
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.mdbecome#api-overview, footnotes becomeapi-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)insideapi.mdresolves 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.mdand./api.md#endpointsturn 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.mdis 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/*.mdleaves off inapi.md; the nextrmd docs/*.mdopensapi.mdat that spot, and so doesrmd api.mdon 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
(
#overviewbecomes#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
#installin 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, solocalStorage- 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
--editit 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
#anchorin 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 thermd_poscookie for127.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 anrmd_posheader. It is under 2 KB at its fullest, and it carries hashes and numbers - no paths, no file names, no document contents. - Under
--tunnelthe 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
F5answers 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 everyrmdpage 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
- 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 isrenamed 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
--tunnelhas 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 --tunnelsession 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 empty404. The token is not a nicety even on loopback: any page open in your browser can POST to127.0.0.1blindly, and this API writes to your files. --edit --tunnelpublishes that token. The public link is not read-only - anyone who opens it can rewrite the files on disk, andrmdsays 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
--editthe 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
ngrokshows an interstitial page to visitors before the content. Nothing on thermdside can skip it. - If the tunnel dies on its own (network gone, session revoked), the run
ends with exit
1and 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+Cduring that wait ends the run right away rather than sitting it out. - A child
ngrokthat will not die is the one cleanup failure you hear about: the run ends with exit1and a stderr line saying the public URL may still be reachable, because a leftover session keeps serving your files - and blocks the next--tunnelrun withthe endpoint is already online. - A file passed twice is one document. A plain run collapses arguments by
absolute path only; a
--tunnelrun resolves symlinks as well, sormd --tunnel a.md link-to-a.mdpublishes one document instead of the same text twice. - The paths you invoke
rmdwith stay on your machine.rmd ~/work/client-x/*.mdleavesapi.mdin 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, sormd ../clients/acme/*.mddoes put../clients/acme/api.mdin 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 asdata:, extension unknown or not. Soin 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
rmdcannot rewrite: anwhose 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-size13 / 16 / 20 px (code font 11 / 14 / 17 px), with the content column width scaling along (820 / 980 / 1220 px). Default ism. 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-thememoves, 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:
```mermaidfences 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)://anddata: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="…">andurl()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#endpointsfromindex.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.mdandapi.mdon 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
mermaidand 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+Zcannot 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;
--tunnelresolves symlinks as well and keeps the first copy; an editing session resolves them too but refuses to start (exit1) 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 filesystemrmd --edit API.md api.mdopens 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. |