Development Guide
This guide covers building mbr from source, development workflows, and contributing.
Quick Start
For rapid UI iteration without full Rust rebuilds, use the --template-folder flag to load templates and assets from disk instead of the compiled-in defaults.
Terminal 1: Component Watcher
Watches TypeScript sources and rebuilds to crates/mbr-core/templates/components-js/ on change:
cd components
bun install # First time only
bun run watch
Terminal 2: Rust Server with Hot Reload
Watches Rust files and restarts the server, while ignoring template/component changes (those are handled by Terminal 1):
cargo watch -i "crates/mbr-core/templates/**" -i "components/**" -i "*.md" -q -c -x 'run --release --bin mbr -- -s --template-folder ./crates/mbr-core/templates ./docs'
This command:
-i "crates/mbr-core/templates/**"- Ignores template file changes (HTML, CSS, JS)-i "components/**"- Ignores TypeScript source changes-i "*.md"- Ignores the markdown files we might be using for testing-q- Quiet mode (less cargo-watch output)-c- Clears screen between runs--template-folder ./crates/mbr-core/templates- Loads templates from disk instead of compiled defaults
How It Works
With --template-folder ./crates/mbr-core/templates:
- Templates (
*.html) are loaded from./crates/mbr-core/templates/with fallback to compiled defaults - Assets (
*.css,*.js) are served from./crates/mbr-core/templates/with fallback to compiled defaults - Components (
/.mbr/components/*) are mapped to./crates/mbr-core/templates/components-js/* - File watcher monitors both the markdown directory and the template folder for hot reload
When you edit:
- Rust files → cargo watch rebuilds and restarts the server
- HTML/CSS files in crates/mbr-core/templates/ → Browser auto-reloads via WebSocket
- TypeScript in components/src/ → Vite rebuilds to
crates/mbr-core/templates/components-js/, then browser auto-reloads
Code Quality Requirements
All Rust code must pass formatting and linting checks before commit. CI enforces these as blocking checks.
Formatting (cargo fmt)
All Rust code must be formatted with rustfmt:
# Check formatting (CI runs this)
cargo fmt --all -- --check
# Auto-format every crate in the workspace
cargo fmt --all
Linting (cargo clippy)
All clippy warnings are treated as errors:
# Check for lint issues (CI runs this)
cargo clippy --workspace --all-targets -- -D warnings
# See warnings without failing
cargo clippy --workspace --all-targets
The workspace’s default-members are all of its crates, so the plain commands
(cargo clippy, cargo test) already cover every crate; --workspace only
makes that explicit.
Pre-commit Hook
The project includes a pre-commit hook that automatically:
- Runs
cargo fmtand re-stages formatted files - Runs
cargo clippy -- -D warningsand blocks commit on failure - Syncs npm dependencies if
components/package.jsonchanged
Setup (automatic in nix shell):
git config core.hooksPath .githooks
The nix dev shell automatically configures this when you run nix develop.
Manual setup:
# If not using nix, manually configure the hooks
git config --local core.hooksPath .githooks
CI Checks
GitHub Actions runs on every push to main and all PRs. Everything except the Windows and component legs goes through Nix, so the same derivation is shared between CI, Release, and your laptop:
nix build .#fmt— rustfmtnix build .#clippy— clippy with-D warnings(Linux + macOS)nix build .#tests— the shipped feature set (cliFeatures), not--all-features:ffiis macOS-only by design, so--all-featuresfails to compile on Linuxnix build .#clippy-minimal/.#tests-minimal— the feature set Windows ships (--no-default-features --features gui,server,watcher,ssg,cli: everything butmedia-metadata), run on Linuxnix build .#mbr— full build on x86_64-linux, aarch64-linux, and macOSnix build .#swiftfmt/.#swiftlint-check— QuickLook extension (macOS)cargo clippy/cargo test/cargo buildon Windows (no Nix there)bun run testandbun run build(components)
All checks must pass before merge.
Binary Cache
CI pushes to zmre.cachix.org so that contributors and end users substitute prebuilt outputs instead of compiling ffmpeg, pdfium, and the full crate graph. Two things keep that working, and both are easy to break:
pushFilter on every cachix-action block. The action’s post-job daemon
uploads everything the job put in the store, so the filter belongs on every
block — including jobs that build nothing new. A new job with a cachix-action
step and no pushFilter silently starts uploading source and vendor trees.
Pins on the consumer artifacts. Cachix’s free tier garbage-collects
least-recently-used, which is exactly backwards here: the ~950 MB crane
dependency layer is touched by every CI run and stays warm, while
packages.default is fetched only when someone runs nix run and is therefore
always the coldest thing in the cache. The build job pins these after a green
build on main (see the comment on that step for the full reasoning):
| Pin name | Attr | Why |
|---|---|---|
mbr-<system> | .#default | What nix run / nix build / nix profile install resolve to |
ffmpeg-minimal-static-<system> | .#ffmpegMinimalStatic | Fixed-version, 30–60 min to rebuild |
x264-static-<system> | .#x264Static | Same; pinned separately because Cachix does not document pins as covering the closure |
The crate dependency layer is deliberately not pinned — it churns on every
Cargo.lock bump and CI keeps it warm by itself.
Verify coverage from outside without building anything. Store paths are content-addressed, so evaluating a revision anywhere reproduces what CI built:
REV=github:zmre/mbr-markdown-browser/<commit-sha>
for s in x86_64-linux aarch64-linux aarch64-darwin; do
p=$(nix eval --raw "$REV#packages.$s.default.outPath")
code=$(curl -s -o /dev/null -w '%{http_code}' \
"https://zmre.cachix.org/$(basename "$p" | cut -d- -f1).narinfo")
echo "$code $s $(basename "$p")"
done
200 means a consumer gets a download; 404 means they compile. A 404 for a
path CI demonstrably pushed is eviction, not a broken push — check the cache’s
usage page before touching the workflow.
Pushes and pins are skipped when CACHIX_AUTH_TOKEN is absent, so PRs from
forks are unaffected rather than failing.
Performance Benchmarks
See the interactive benchmark dashboard for performance trends across releases.
Benchmarks are automatically captured during the release process (scripts/bump-version.sh). To run benchmarks manually:
# Run benchmarks and save results for a version
./scripts/save-benchmarks.sh 0.5.0
# Save from existing criterion results without re-running
./scripts/save-benchmarks.sh 0.5.0 --no-run
# Import a saved baseline
./scripts/save-benchmarks.sh 0.4.2 --no-run --from-baseline v0.4.2
Skip benchmarks during a release with SKIP_BENCHMARKS=1 ./scripts/bump-version.sh 0.5.0.
Checking for Horizontal Overflow
A page that scrolls sideways is easy to cause and hard to notice on a wide
monitor. components/scripts/check-overflow.sh opens every page listed in
/.mbr/site.json in headless Chromium and reports each one whose
scrollWidth exceeds its clientWidth, naming the elements where the overflow
starts. It needs agent-browser,
curl and jq on PATH, and a running server:
cargo run -- -s -p 5220 docs/ &
components/scripts/check-overflow.sh http://127.0.0.1:5220
# Narrow the sweep: widths, then color schemes
components/scripts/check-overflow.sh http://127.0.0.1:5220 "390 768" light
By default it checks widths 2400, 1280, 1024, 768 and 390 in light and dark
(2400 is wide enough for margin notes to sit in the gutter, with or without the
docked sidebar), and at widths where the docked sidebar is shown it checks each
page a second time without it. The exit status is the number of overflowing
combinations (capped at 125). It drives Chromium only; Safari and the GUI’s
WebKit can differ, so check a layout change there by hand. It is not part of
CI, since it needs a browser; run it after layout or theme.css changes. The
rules it guards are also pinned by components/src/overflow.test.ts, which
does run in CI.
Build Commands
# Build release binary (every workspace crate; `-p mbr-markdown-browser` for
# the binary alone)
cargo build --release
# Run tests (every workspace crate: `cargo test` covers the default members,
# which are all of them; `--workspace` says so explicitly)
cargo test --workspace
# Build components only
cd components && bun run build
# Format and lint
cargo fmt --all && cargo clippy --workspace --all-targets -- -D warnings
Release Packaging and Nix-Store Independence
# Build the distributable archives (macOS: .app + CLI tarballs)
nix build .#release
# Wrap a built app in the drag-to-/Applications disk image
scripts/make-macos-dmg.sh /path/to/MBR.app dist/mbr-macos-arm64.dmg
Release artifacts have to run on a Mac that has never had Nix installed, so
no Mach-O in them may reference /nix/store — not in LC_LOAD_DYLIB, not
in LC_LOAD_WEAK_DYLIB, not in LC_RPATH.
Most of this is structural: ffmpeg and x264 are statically linked, and pdfium is
dlopen’d at runtime from Contents/Frameworks/ (app bundle) or lib/ next to
the executable (CLI tarball), never from a compiled-in store path. The one
genuine dynamic leak is libiconv, which Nix’s linker resolves to its own copy
instead of the /usr/lib/libiconv.2.dylib that macOS ships.
The release derivation in flake.nix therefore does two things, in this order
and before any codesign step (install_name_tool invalidates signatures):
portablizewalks every Mach-O in the staged tree and rewrites store libiconv references to/usr/lib/libiconv.2.dylib.auditPortablere-scans and fails the build if any store reference survives.
The audit exists because both halves of step 1 fail silently on their own:
install_name_tool -change is a no-op when the old path does not match, and an
earlier version applied it to a hardcoded list of two files, which would have
missed a third Mach-O such as PlugIns/MBRPreview.appex. A store dependency
other than libiconv is deliberately not auto-mapped to /usr/lib/<name> — a
wrong guess would trade a loud failure for a subtle one — so it fails the audit
and must be handled consciously.
To verify a downloaded artifact by hand:
# Should print nothing
otool -L MBR.app/Contents/MacOS/mbr | grep /nix/store
# Strongest check: run it and watch what dyld actually loads
DYLD_PRINT_LIBRARIES=1 MBR.app/Contents/MacOS/mbr --version 2>&1 | grep /nix/store
Note that strings on these binaries does show /nix/store/eeee…eeee/...
paths. Those are harmless: Rust’s --remap-path-prefix scrubs the real hashes
to es, and they appear only in panic-location and tracing metadata strings,
never in a load command.
Code signing: never strip an ad-hoc signature
On Apple Silicon an unsigned Mach-O is SIGKILLed by the kernel. It is not a
Gatekeeper prompt or a warning — the process dies with exit 137 and prints
nothing. The ad-hoc signature that the linker (and install_name_tool) applies
automatically is what makes a binary runnable at all.
codesign cannot reach its daemon inside the Nix sandbox, so every signing call
in the release derivation is expected to fail and is written || true. What it
must never do is fall back to codesign --remove-signature: that fallback
ran on every build and stripped the ad-hoc signature, so the app tarball shipped
a bundle that could not launch (codesign --verify reported “code object is not
signed at all”). Leaving the failed-to-re-sign ad-hoc signature in place is
strictly better. The derivation now fails the build if any shipped Mach-O ends up
unsigned.
Proper signing happens later, in scripts/make-macos-dmg.sh, which runs on the
CI runner outside the sandbox where codesign works, and gates itself with
codesign --verify --deep --strict. In CI, when the release secrets are
present, that script signs with a real Developer ID identity and notarizes and
staples both the app and the DMG; otherwise (a local run, or a dry_run
release dispatch) it falls back to the same ad-hoc signing described above. See
docs/releasing.md for the signing/notarization pipeline
and the secrets it needs. Either way, the raw .tar.gz app bundle is never
touched by this script and ships only ad-hoc signed.
# Both should print "Signature=adhoc" (or better) and then exit 0
codesign -dv MBR.app/Contents/MacOS/mbr
MBR.app/Contents/MacOS/mbr --version; echo "rc=$?"
Architecture Notes
The --template-folder flag serves dual purposes:
- Development: Point to
./templatesfor rapid UI iteration - User customization: Share a custom theme across multiple markdown repos
# Use a shared theme for any markdown repo
mbr -s --template-folder ~/my-mbr-theme /path/to/markdown/repo
Fallback Chain
Asset resolution follows this priority:
--template-folderpath (if specified).mbr/folder in the markdown repo- Compiled-in defaults
This means you can partially override - missing files fall back to defaults.
QuickLook Extension
MBR includes a macOS QuickLook preview extension that renders markdown files using MBR’s rendering engine. The extension is bundled with MBR.app and auto-registers when the app is run.
Building the QuickLook Extension
The extension uses UniFFI to call Rust code from Swift. Build with:
# From nix development shell
nix develop -c bash -c './apple/quicklook/build.sh'
# Build and install into local MBR.app
nix develop -c bash -c './apple/quicklook/build.sh install'
Requirements:
- Nix development shell (provides xcodegen, ffmpeg, pkg-config)
- Xcode command line tools
Extension Architecture
apple/quicklook/
├── build.sh # Build script
├── project.yml # xcodegen project definition
├── Host/ # Minimal host app (required for embedding)
│ ├── AppDelegate.swift
│ └── Info.plist
├── MBRPreview/ # QuickLook extension target
│ ├── PreviewProvider.swift # QLPreviewProvider (data-based preview)
│ ├── Info.plist # Supported UTIs, extension config
│ └── MBRPreview.entitlements # Sandbox entitlements
└── Generated/ # UniFFI-generated Swift bindings
├── mbr.swift
└── mbrFFI.modulemap
How It Works
- UniFFI Bindings: The Rust
render_preview()function (incrates/mbr-ffi/src/quicklook.rs) is exposed to Swift via UniFFI. It returns aPreviewDocument: the HTML plus the local files that HTML references. - Static Library: Rust code is compiled as
libmbr_ffi.afrom thembr-fficrate (cargo build --release -p mbr-ffi --lib), which depends on the render core (mbr-core) alone: no server, watcher, static site generator, CLI, GUI or media-metadata. The extension links it with dead-code stripping, so only whatrender_preview()reaches ends up inMBRPreview - Swift Extension:
PreviewProvider.swiftcalls the Rust function and returns aQLPreviewReplycarrying the HTML and its attachments
The preview is data-based, not view-based: QLIsDataBasedPreview is true and
the principal class subclasses QLPreviewProvider. macOS asks for a
generation-based preview and rejects a view-based reply with
"View based preview response received when expecting a generation based preview",
which looks from the outside exactly like the extension not being installed.
There is therefore no WKWebView in the extension. Local assets (sibling images,
video, .mbr/theme.css) travel as QLPreviewReply.attachments and the HTML
addresses them as cid:<id>.
Set
reply.attachmentsbefore returning the reply, not inside thedataCreationBlock. The header documentation says the block may update them, but on macOS 26 attachments added there never reach QuickLook: thecid:URLs resolve to nothing and every image in the preview is silently blank.
Workspace Layout
mbr is a Cargo workspace. The crate boundaries are the layering: a crate can only use what it depends on, so the render core cannot reach the server, and the QuickLook staticlib cannot link the GUI.
| Crate | Path | Contents | Depends on |
|---|---|---|---|
mbr-core | crates/mbr-core | markdown, templates (and the templates/ assets they embed), config, repository scan, search, task/link/tag indexes, path resolution; media-metadata feature for ffmpeg/pdfium probes and HLS | — |
mbr-server | crates/mbr-server | the axum server, edit/move endpoints, file_write, link_grep, link_rewrite; watcher feature (notify) | mbr-core |
mbr-ssg | crates/mbr-ssg | the static site generator and pagefind | mbr-core |
mbr-ffi | crates/mbr-ffi | UniFFI exports (quicklook.rs, mbr.udl) → libmbr_ffi.a; empty on non-Apple targets. The mobile feature adds the iOS app’s core (mobile/) | mbr-core; mbr-server with mobile |
mbr-markdown-browser | . (root) | the mbr binary (CLI, GUI window, OS integrations), the uniffi-bindgen binary, and the mbr library re-exporting everything for the integration tests and benches | all of the above |
The version and shared metadata live once, in the root Cargo.toml’s
[workspace.package]; every crate inherits them.
Feature Flags
Each subsystem is a Cargo feature of the root package, forwarding to the crate
that implements it. The default set is the full desktop app; with
--no-default-features the library is the render core alone (markdown,
templates, config, repository scan, search, indexes, embedded assets).
| Feature | Adds | Implies |
|---|---|---|
server | the axum HTTP server (mbr-server): routes, live-reload WebSocket, edit endpoints | |
watcher | the filesystem watcher (mbr-server’s watcher feature, notify) feeding live reload and index invalidation | |
ssg | the static site generator (mbr-ssg) and its pagefind index | |
cli | the mbr binary (cli.rs, main.rs) | server, ssg |
gui | the native window (wry/tao/muda/rfd) and OS link hand-off | server |
media-metadata | video/audio metadata, HLS transcoding, PDF covers (ffmpeg, pdfium; mbr-core’s feature) | |
ffmpeg-static | statically linked ffmpeg | |
ffi | re-exports mbr-ffi, and builds the uniffi-bindgen binary |
# Full desktop app (default)
cargo build --release
# What Windows ships: everything except media-metadata
cargo build --release -p mbr-markdown-browser --no-default-features --features gui,server,watcher,ssg,cli
# QuickLook staticlib: the mbr-ffi crate (UniFFI over the render core)
cargo build --release -p mbr-ffi --lib
# The iOS app's core, tested on the desktop
cargo test -p mbr-ffi --features mobile
cargo clippy -p mbr-ffi --features mobile --all-targets -- -D warnings
iOS staticlib and Swift bindings
The iOS app links libmbr_ffi.a built with the mobile feature: the in-process
server behind its mbr:// scheme handler, the coordinated vault (writes through
NSFileCoordinator), not-downloaded file handling and the index snapshot the
app keeps in its Caches/ directory.
# Inside `nix develop` (its dev toolchain carries the iOS std), with the
# target side pointed at Xcode (see CLAUDE.md, "iOS check"):
cargo build --release -p mbr-ffi --features mobile --lib --target aarch64-apple-ios
cargo build --release -p mbr-ffi --features mobile --lib --target aarch64-apple-ios-sim
# Swift bindings for the app (QuickLook's surface is included, unused)
cargo run --no-default-features --features ffi --bin uniffi-bindgen -- generate \
--library target/aarch64-apple-ios/release/libmbr_ffi.a \
--language swift --out-dir apple/ios/Generated
git add -f apple/ios/Generated # a global `*.sw*` ignore would hide mbr.swift
To time cold (no snapshot) against warm (snapshot) opens of a large repository on the desktop:
MBR_TIMING_REPO=~/Notes cargo test --release -p mbr-ffi --features mobile \
-- --ignored --nocapture index_timings
The server works without watcher: live reload still announces the server’s
own writes (task toggles, edits), just not changes made on disk by other
programs.
The QuickLook extension must be built without the gui and
media-metadata features because:
- QuickLook extensions run in a sandboxed environment without GUI access
- wry/tao require SDL3 which isn’t available in the sandbox
- ffmpeg/pdfium crash in the sandbox
The server, watcher, static site generator and CLI are left out as well: nothing in a preview reaches them, and they are most of the code.
Testing the Extension
# After running build.sh install and launching MBR.app once:
qlmanage -p /path/to/file.md
# Check if extension is registered
pluginkit -m -i com.zmre.mbr.MBRPreview
Troubleshooting
Extension not appearing:
- Run MBR.app once to register the extension
- Check
pluginkit -moutput for registration
Extension crashes:
- Check crash logs in
~/Library/Logs/DiagnosticReports/ - Ensure the staticlib is
libmbr_ffi.afromcargo build --release -p mbr-ffi --lib
Conflicting extensions:
# List all markdown QuickLook extensions
pluginkit -m | grep -i markdown