Projects
SkriveSkriveSkrive

Skrive

Markdown and rich text, the same file.

A writing app written entirely in TypeScript and Electron native Zig.

Why I built it

Skrive started as a personal itch. I wanted one writing surface that did three things at once, and I couldn't find it anywhere. It had to be plain Markdown, so my words stay portable. A .md file is about the closest thing writing has to a universal format: it opens anywhere, it diffs in git, and every AI model already speaks it. It had to be local, so the work lives in my own folders, not someone else's cloud. And it had to treat rich-text editing and raw Markdown as equals, instead of making me choose a side.

Every few months someone ships another Markdown editor and calls it done. Another run at being the next Obsidian or Bear. I didn't want to add a cliche to the pile, so I put my own spin on it. The non-negotiable is round-trip fidelity: your document stays plain Markdown on disk, byte for byte. The spin is that it does that while actually serving both crowds at once, the people who want to see the syntax and the people who never want to touch it.

Most tools make you choose. Rich-text editors hand you a clean surface but bury your words in a format you can't grep or version or hand off to anything else. Source-only Markdown editors keep the portability but assume you always want the markers in your face. Skrive serves both over one file, and lets you decide how much of the machinery to see, document by document.

One file, however you write

Every document in Skrive is a single Markdown file. There are a few ways to work on it, all switched with a keystroke, all editing the same bytes underneath.

The Rich surface is the no-syntax view. Headings, bold, lists, quotes, dividers, links, and tables all render as real elements. You format the way you would in a word processor, and never type a ** or a # unless you want to. The Text surface is the source itself, Markdown right there in front of you, with a dial for how present the syntax is: fully raw, recessed so the markers fade into the margins, or concealed, where they disappear but the text stays editable in place. And if you like the classic split, a live preview renders alongside the source as you type.

All of them work on the exact same bytes on disk. Switch mid-sentence and nothing converts or re-flows or gets lost. The Rich surface isn't a rendered copy of the source. It's just another set of hands on the same file.

The workspace

Skrive opens a folder of Markdown files and treats it as a project, the way an IDE treats a repository. No import step, no vault. You point it at a directory and it comes to your files instead of asking you to move them into it.

From there it adds the things that make a loose folder of notes feel connected. Backlinks show what points at the current document and what it points to. An outline rail maps the heading structure off to one side, so you can scrub and jump without a panel eating your margin. Project search is full-text across the whole folder, with context previews. Rename a file and every reference to it updates, so the link graph doesn't rot. The whole app is keyboard-first, run off one command registry that also feeds the keybindings and the cheat sheet. Version history gives you structural diffs between revisions, from both git and Skrive's own checkpoints. And copy is smart: it carries raw Markdown and rendered HTML at once, so pasting into Gmail or Docs gives you formatted text instead of literal ## markers.

One thing is deliberately missing: there's no AI inside Skrive, and that's on purpose. The bet runs the other way. Keep the format plain and portable, and your writing is never walled off from whatever tools you do want to reach for, an AI model included.

A bespoke editor, built on blocks

The hard part behind “both surfaces, one file” is round-tripping. Let someone edit rich text and then write it back to Markdown, and a naive serializer quietly rewrites the whole document: re-wrapping lines, normalizing list markers, reordering attributes. The diff explodes even though nothing about the meaning changed.

Skrive's editor is bespoke, written from scratch in TypeScript. There's no ProseMirror, no CodeMirror, no rich-text framework underneath it, because every off-the-shelf option owns its own document model and treats Markdown as an import you convert to and from. A document here is a list of typed blocks, and that block model is the source of truth while you write. Markdown is how those blocks serialize to disk, not the thing you edit through.

Every block remembers the exact bytes it was parsed from. When you save, the untouched blocks are written back verbatim and only the blocks you actually changed get re-serialized. The round-trip stays byte-faithful: edit one paragraph and the diff shows one paragraph. Both surfaces, Rich and Text, are just two ways of rendering the same blocks, which is why switching between them mid-sentence never converts or loses anything.

A diff that reads structure

Comparing two revisions of prose with a line-based diff is maddening: reflow a paragraph and the whole block lights up red and green even though you changed three words. So Skrive ships a native structural diff written in Rust, compiled to a Node addon with napi-rs and called straight from the desktop shell. It compares the documents paragraph by paragraph instead of line by line. A rewrapped block reads as “unchanged,” and a real edit reads as exactly the words that moved.

Linting off the main thread

Skrive lints the whole project as you write: broken internal links, duplicate headings, heading-hierarchy slips, missing frontmatter, orphaned files. Run all of that on the main thread on every keystroke, and one multi-file pass is enough to make typing feel like it's dragging.

So the lint engine lives in a Web Worker. It keeps its own copy of every file's text, the editor sends only the deltas as you type, and a path-keyed cache means unchanged files never get re-parsed. The project-wide checks run the whole time without ever touching the frame budget the cursor needs.

The shell I wrote to leave Electron

This section used to be titled “Electron, deliberately,” and I meant it. Electron got Skrive real on macOS and Windows fast, on top of a React renderer, a shared layer of types and IPC contracts, and a native Rust diff crate. But it bundles a whole browser and a Node runtime into every copy: a hundred-odd megabytes of Chromium per install, and a cold start that idles while a second browser boots. For a writing app whose entire pitch is small, plain, yours, shipping a browser to render a text file became a contradiction I got tired of defending.

So I replaced the Electron host with a native shell I wrote myself. The React frontend didn't change, not a byte, same editor and panels. What changed is everything underneath it. Instead of bundled Chromium, the app now runs in the operating system's own webview, the same engine the OS already keeps resident: WebKit on macOS, WebView2 on Windows. Driving it is a tiny core I wrote in Zig, with a thin native host per platform, Swift on the Mac and more Zig on Windows. It's the shape Ghostty uses. A small native core sits behind a C ABI, the platform-native shell owns the window, and the system's browser does the rendering it was doing anyway.

And yes, you read that correctly: I ported a shipping desktop app off Electron and onto a native shell written by hand in Zig, solo, across two operating systems. It sounds a little unhinged when I say it out loud, the kind of thing you're supposed to talk yourself out of. It shipped anyway, and it's what you download today.

The frontend stayed put on purpose. All of Skrive's actual logic, the block model, the link graph, search, lint, lives in that shared React layer, and every crossing into native code is one entry in a closed command table both hosts implement the same way. The native side is deliberately tiny. It opens a window, reads and writes files, watches the folder, and talks to the OS. Keeping it that small is the whole discipline. A capable Electron main process lets native code sprawl. A shell you write by hand makes you earn every crossing.

Most of the port was calm. The one that wasn't was first light on Windows: the window opened and rendered nothing, a blank white void, while every single WebView2 call returned success. I chased it for days, ruled out the disk, the served files, the GPU. The tell was a process query showing zero WebView2 child processes despite a clean startup. The cause was a missing AddRef on a borrowed COM reference: the browser controller was being torn down the instant its own setup callback returned, so calls worked during the callback and the webview vanished right after. One retained pointer fixed it, the kind of retain the C++ samples get for free and you don't when you write the binding yourself.

What it bought, and what it didn't

Measured packaged against packaged on the same machine, two of the numbers moved a lot. The installer dropped from 132 MB to 3.4 MB, about 39 times smaller, and auto-updates now pull single-digit megabytes instead of re-downloading a browser every release. Cold start to a ready cursor roughly halved.

Install size
132 MB3.4 MB
39× smaller
Cold start
1.25 s0.61 s
2× faster
Memory (RSS)
543 MB480 MB
12% lighter

Against the apps I keep open next to it, the size gap is the part that surprises people. Skrive is 28 to 48 times smaller on disk, and it starts as fast as Zed, a native editor written in Rust, from what is basically a webview.

On-disk install (MB, smaller is better)
Skrive
10
Notion
283
Zed
373
Obsidian
482
Cold start (seconds, lower is better)
Skrive
0.61
Zed
0.81
Obsidian
1.36
Notion
2.36

But I want to be honest about the part that isn't a clean win. Memory barely moved, about 12 percent, and the reason is the interesting bit.

Electron — 4 processes543 MB total
main
renderer
GPU
utility
Skrive — 2 processes480 MB total
host
WebKit content

The honest part: Skrive's one WebKit content process (387 MB) isheavier than Electron's renderer (239 MB). Native wins on the total only because Electron also runs a separate GPU process, a utility process, and a fatter main. Once the same web UI is loaded, memory is about the same either way. The browser is the browser, bundled or borrowed from the OS. Hover a segment for its footprint.

Footprint is about what you ship, not what language you write in. Skrive is tiny because it reuses the browser the OS already has loaded, not because Zig is magic. Zed is Rust and native and fast, and still 373 MB, heavier than Notion, because it ships its own renderer. And this migration deliberately did not touch the one thing it can't: the editor still renders in a browser engine. I shed the cost of bundling a browser, not the browser itself. That was the honest scope, and I tried not to sell the rebuild on a win it doesn't have.

Where it is now

Skrive is at 1.0 and still early. It's a working desktop app I use every day, not a finished product. The builds above are a signed, notarized universal build for macOS and an unsigned build for Windows that clicks past the usual SmartScreen warning. No Linux build, no web version. It's a native writing tool on purpose.

It's source-available under the PolyForm Noncommercial license: free for personal use, commercial rights reserved. No accounts, no telemetry, no network calls. Bug reports are welcome on GitHub.

TypeScriptReactZigSwiftRustVite
3.4 MB
Download size
0.61 s
Cold start
0
Network calls