@roobli/md is the Markdown engine inside Noto, published on its own under the MIT license so other editors can use it without taking on Noto’s AGPL. It is for people building editors rather than people writing notes, and it has no dependency on any editor or UI.
It makes two promises. Parsing a document and saving it unchanged gives back identical bytes, including line endings, a byte order mark and trailing spaces. And reparseBlocks and reparseFromText re-read only the region around an edit, yet always give the same answer as parsing the whole file again.
- For
- people building Markdown editors and tools
- Latest
- v0.1.23, 21 October 2026; Noto v0.0.3 ships v0.1.20
- License
- MIT
- Dialect
- CommonMark and GFM,
$and$$math, YAML frontmatter, footnotes, callouts, wiki links, emphasis that works next to CJK text - Install
- from a git tag,
pnpm add github:roobli/md#v0.1.23; npm comes with v1
A document as blocks and gaps
Most Markdown parsers give back a tree and forget the text it came from. md gives back the text, cut into pieces. Each top-level block is a span with its kind, its start and end offsets in the source, and its own Markdown. Between the spans it keeps the literal gaps, blank lines and all, plus whatever leads and trails the document. Joined back together, the pieces are the source.
That is enough to save without a serializer. A block nobody touched is sliced straight from the original bytes, and only an edited block is written from its new text. Here is a small note with Windows line endings, _ emphasis, two trailing spaces, a table with uneven padding and * bullets, all things a round trip through a tree tends to “fix”:
import {
parseDocument,
identityUnits,
serializeDocument,
replaceBlock,
} from "@roobli/md";
const source =
"# Retry budget\r\n\r\n" +
"Retries are _cheap_ until they are not. \r\n\r\n" +
"| limit | value |\r\n" +
"|---------|------:|\r\n" +
"| per call| 3 |\r\n\r\n" +
"* keep jitter\r\n* cap the total\r\n";
const bytes = new TextEncoder().encode(source);
const { document } = parseDocument(bytes);
console.log(
document.envelope.lineEnding,
document.blocks.map((b) => `${b.kind} ${b.start}-${b.end}`),
);
const untouched = serializeDocument(document, {
units: identityUnits(document),
});
console.log("identical:", Buffer.from(untouched.outputBytes).equals(bytes));
const edited = replaceBlock(
document,
1,
"Retries are cheap until the third one.",
);
console.log(JSON.stringify(edited.text));$ node roundtrip.mjs
crlf [
'heading 0-14',
'paragraph 18-59',
'table 63-124',
'bullet-list 128-158'
]
identical: true
"# Retry budget\r\n\r\nRetries are cheap until the third one.\r\n\r\n| limit | value |\r\n|---------|------:|\r\n| per call| 3 |\r\n\r\n* keep jitter\r\n* cap the total\r\n"The untouched save is identical to the input. After the edit, only the paragraph changed: the heading, the padded table, the * bullets and every \r\n around them are the bytes that came in. replaceBlock also reports which ranges it copied from the source, so a host can check the claim instead of trusting it.
Re-reading only what changed
An editor parses on every keystroke, so parsing the whole note each time is the cost that grows with the note. reparseFromText takes the previous split and the new text, finds the edit, and re-reads a window around it: the changed block plus one neighbour on each side by default. Blocks before the window come back as the same objects. Blocks after it are moved by the length of the edit, not read again.
import { parseBlocks, reparseFromText } from "@roobli/md";
const blocks = [];
for (let i = 0; i < 2000; i++) {
blocks.push(i % 10 ? `Paragraph ${i}.` : `## Section ${i / 10}`);
}
const text = blocks.join("\n\n") + "\n";
const edited = text.replace("Paragraph 1001.", "Paragraph 1001, edited.");
const prior = parseBlocks(text);
const next = reparseFromText(prior, edited);
const same = next.spans.filter((span, i) => span === prior.spans[i]).length;
const shape = (d) =>
JSON.stringify(d.spans.map((s) => [s.kind, s.start, s.end]));
const width = next.windowEnd - next.windowStart;
console.log(`window: ${width} of ${edited.length} characters`);
console.log(`prior blocks reparsed: ${next.dirtyFrom} to ${next.dirtyTo}`);
console.log(`blocks kept as the same objects: ${same} of ${next.spans.length}`);
const whole = shape(next) === shape(parseBlocks(edited));
console.log(`same as a whole parse: ${whole}`);$ node window.mjs
window: 89 of 32698 characters
prior blocks reparsed: 1000 to 1002
blocks kept as the same objects: 1000 of 2000
same as a whole parse: trueThe hard part is the edge of the window. An edit can open a code fence, a display math block or an HTML comment without closing it, and then everything after it belongs to that block. md pins the window at both ends: it has to finish exactly where the untouched block after it starts, with that block unchanged, or the reparse reads on to the end of the text. A line that now continues the block before the window is picked up the same way. Before v0.1.20 this was the bug the window had: an unclosed fence was read as one short block followed by the old blocks.
What it buys Noto
The splitter is a native scanner rather than a pass through micromark. On a synthetic corpus shaped like Noto’s own benchmark documents, measured on a Linux machine on 11 September with a warm process, the median of seven timed runs after two warm-ups:
| Corpus | Size | Blocks | md | micromark | Speedup |
|---|---|---|---|---|---|
| medium | 513 KiB | 2,817 | 4.5 ms | 303.9 ms | 67× |
| large | 2,048 KiB | 11,197 | 14.5 ms | 1,484.5 ms | 103× |
This times the split only, not a full open, and both backends found the same blocks; the method is in docs/design/bench.md and pnpm bench:ab reruns it. The same idea runs through saving: since v0.1.20, serializeDocument builds the next document from the blocks it moved and small pinned reparses around what changed, instead of parsing its whole output again. On an 8 MB note a one-block save went from about 130 to 200 ms to 40 to 80 ms. Measured end to end in the packaged app, Noto v0.0.3 saves an 8 MB note in 409 ms.
Checked, not assumed
The suite has 139 tests. The incremental save is checked against a whole parse on 2,500 generated saves, 500 each for LF, CRLF, a byte order mark, no final newline and no frontmatter. Dialect behaviour is checked against micromark. Sixteen of the 24 tagged versions are fix releases, most of them parity cases found that way: lazy continuation inside quotes and lists, pipe-less delimiter rows that are lists rather than tables, sibling lists with different bullets. Noto’s golden files are a second net; v0.1.23 fixes a case one of them caught in the v0.1.22 build. The old micromark path is still exported as @roobli/md/legacy-micromark, as a way back.
Noto’s docs site depends on md too. On every pull request and before every deploy, it parses each of its own pages and saves it with every block untouched, and the build fails unless the bytes match.
md serve
The root is fixed by the command line and resolved with realpath, so a symlink cannot lead out of it, and nothing in the page can change it:
$ pnpm build
$ node dist/serve/cli.js serve ./notes --port 4391Versions and use
v0.1.0 was tagged on 11 September 2026 and v0.1.23 on 21 October. md has been Noto’s default engine since Noto v0.0.2-alpha.112 and its only one since the micromark fallback was removed. Before 1.0 a minor release may change the API, and the changelog says so when one does.
It is not on npm yet; install it from a git tag and pin the tag, because main moves. A host that runs install scripts may need to allow the package’s prepare step, which builds dist/ with tsc.
$ pnpm add github:roobli/md#v0.1.23- Source: github.com/roobli/md (MIT)
