Configuration
mbr uses a layered configuration system:
flowchart BT
DEFAULTS["Compiled-in Defaults"]
CONFIG[".mbr/config.toml"]
ENV["Environment Variables<br/>(MBR_*)"]
CLI["Command-line Flags"]
FINAL["Final Configuration"]
DEFAULTS --> CONFIG
CONFIG --> ENV
ENV --> CLI
CLI --> FINAL
style FINAL fill:#90EE90
Later layers override earlier ones. In particular, MBR_* environment
variables override .mbr/config.toml: the config file ships inside the
markdown repository, so anyone serving a repository they did not author can
override its settings from the outside.
Configuration File
Create .mbr/config.toml in your markdown repository:
# .mbr/config.toml
# Server settings
host = "127.0.0.1"
port = 5200
# Markdown settings
markdown_extensions = ["md", "markdown"]
index_file = "index.md"
# Static file folder (relative to repo root)
static_folder = "static"
# Directories to ignore during scanning
ignore_dirs = [
"target",
"node_modules",
".git",
"build",
"dist"
]
# File patterns to ignore
ignore_globs = [
"*.log",
"*.bak",
"*.tmp"
]
# Directories ignored by file watcher (live reload)
watcher_ignore_dirs = [
".direnv",
".git",
"target"
]
# oEmbed/OpenGraph fetch timeout in milliseconds (server/GUI default: 500)
# Note: Build mode defaults to 0 (disabled) for performance. Override with CLI if needed.
oembed_timeout_ms = 500
# Folders whose tasks are left out of the task browser (empty by default).
# Patterns match a file's repository-relative path, so exclude a folder's
# *contents* with `/**`. The files still render normally.
tasks_ignore_globs = [
"templates/**",
"**/archive/**"
]
Configuration Options
Server Settings
| Option | Type | Default | Description |
|---|---|---|---|
host | string | "127.0.0.1" | IP address to bind. Warning: binding to a non-loopback address (e.g. 0.0.0.0) makes the entire repository readable to the network with no authentication; mbr logs a warning at startup. |
port | number | 5200 | Port number |
Content Settings
| Option | Type | Default | Description |
|---|---|---|---|
markdown_extensions | array | ["md"] | File extensions treated as markdown |
index_file | string | "index.md" | Default file for directories |
static_folder | string | "static" | Folder for static file overlay. Must stay inside the markdown root, or land under a directory at most two levels above it (../static, ../../static); values reaching further — or into $HOME or /, or onto a directory that contains the root — are rejected at startup, as are absolute paths from a config file. See Static Folder |
Ignore Settings
| Option | Type | Default | Description |
|---|---|---|---|
ignore_dirs | array | (see below) | Directories to skip |
ignore_globs | array | (see below) | File patterns to ignore |
watcher_ignore_dirs | array | (see below) | Dirs ignored by file watcher |
Default ignored directories:
target, result, build, node_modules, ci, templates, .git, .github, dist, out, coverage
Default ignored globs:
*.log, *.bak, *.lock, *.sh, *.css, *.scss, *.js, *.ts
Hidden files and directories (names beginning with .) are skipped in
addition to the lists above, and this is not configurable — there is no option
that re-admits them in general.
There is one exception, and it is derived from the invocation rather than from
configuration: the hidden directories on the path from the repository root down
to the PATH argument are scanned. mbr -s .scratch roots at the enclosing
repository (root discovery always walks upward) but still indexes .scratch,
because naming a directory is a statement that it holds content. Only that chain
is exempt; hidden directories inside it, or anywhere else in the repository,
are skipped as usual. ignore_dirs and ignore_globs still apply to an exempt
directory — the exception waives the leading-dot rule alone.
This cannot be set from .mbr/config.toml or an MBR_* variable. The config
file ships inside the repository being served, and letting it nominate which of
its own hidden directories an operator’s scan walks into would let the repository
assert a fact about the command line that was never typed.
Behavior Settings
| Option | Type | Default | Description |
|---|---|---|---|
oembed_timeout_ms | number | 500 (server/GUI), 0 (build) | URL metadata fetch timeout (0 to disable) |
oembed_cache_size | number | 2097152 | Oembed cache size in bytes (0 to disable) |
media_cache_size | number | 67108864 | Media metadata cache size in bytes — video/PDF covers, chapters, captions (0 to disable) |
skip_link_checks | bool | false | Skip internal link validation during builds |
link_tracking | bool | true | Enable bidirectional link tracking (backlinks) |
relationship_tracking | bool | true | Enable typed relationship tracking (named frontmatter relationships) |
tasks_enabled | bool | true | Enable the task browser (server/GUI only). See Task Settings. |
review_enabled | bool | true | Emit data-mbr-line source lines on block elements (server/GUI only). See Review Settings. |
mark_incomplete | bool / unset | mode default (server/GUI on, build off) | Highlight TK/TODO/FIXME/XXX anywhere in a line |
incomplete_markers | array | ["TK", "TODO", "FIXME", "XXX"] | Marker strings that flag work as incomplete |
Navigation Settings
| Option | Type | Default | Description |
|---|---|---|---|
sidebar_style | string | "panel" | Sidebar navigation style: "panel" (modal 3-pane) or "single" (persistent sidebar) |
sidebar_max_items | number | 100 | Maximum items per section in sidebar navigation |
graph_depth | number | 2 | Default depth (1–5) of the link graph shown in the info panel; startup fails on out-of-range values |
gui_menu_bar | string | "auto" | GUI window’s native menu bar: "auto", "always", or "never". Linux only — see below |
title_prefix | string | "" | Text to prepend to all page titles |
title_suffix | string | "" | Text to append to all page titles |
Native menu bar (gui_menu_bar):
GUI mode only, and only Linux reads it.
| Value | Effect |
|---|---|
"auto" (default) | Hidden on Linux, shown on macOS and Windows |
"always" | Start with the bar visible |
"never" | Never show the bar; F10 does not reveal it |
On Linux the menu bar is a GtkMenuBar drawn inside the window, above the
page. macOS puts the same menu in the system-wide bar at the top of the screen,
where the window pays nothing for it, and Windows treats an in-window bar as the
native convention — so only on Linux is it chrome you might not want, and only
there is a tiling Wayland compositor with no global-menu protocol to move it to.
The shortcuts keep working while it is hidden, but not by themselves. GTK
refuses to activate an accelerator whose menu item is not on screen, so mbr
handles those keys directly whenever the bar is hidden: Ctrl+O, Ctrl+R,
Ctrl+Shift+P, Ctrl+F, F3, Shift+F3, Alt+←/Alt+→, Ctrl+W and Ctrl+Q.
Exactly one route is live at a time — showing the bar hands the keys back to
GTK — so nothing fires twice.
Editing keys (Ctrl+C, Ctrl+V, Ctrl+Z, …) are unaffected either way: WebKit
handles those inside the page, not through the menu.
Press F10 to show or hide the bar at any time (unless set to "never").
mbr claims F10 for this, which means GTK’s own “F10 moves focus into the menu
bar” behaviour is off — the bar is still reachable with the pointer.
gui_menu_bar = "always" # keep the native menu bar on screen
Environment variable: MBR_GUI_MENU_BAR. There is no CLI flag.
Sidebar styles:
"panel"(default): Three-pane modal browser accessible via menu. Opens as an overlay with folders, files, and tags in separate columns."single": Persistent single-column sidebar beside content (like picocss.com/docs). Shows folder tree, files, and tags in a scrollable sidebar.
Responsive behavior (single sidebar):
| Viewport | Behavior |
|---|---|
| >= 1024px (desktop) | Sidebar appears beside content via CSS grid |
| < 1024px (mobile) | Hamburger menu triggers slide-in drawer overlay |
CSS variables for customization:
:root {
--mbr-sidebar-width: 280px; /* Sidebar width */
--mbr-hide-nav-bp: 1024px; /* Breakpoint for mobile mode */
--mbr-show-tags: block; /* Set to 'none' to hide tags */
}
Link graph depth (graph_depth):
The info panel (Ctrl+g) shows a force-directed mini graph of the current note’s
neighbourhood (inbound links, outbound internal links, and typed
relationships). graph_depth sets how many hops out the graph expands by
default; the expanded full-screen view has an on-screen depth stepper for
one-off changes. Exposed to the frontend as
window.__MBR_CONFIG__.graphDepth; also settable via MBR_GRAPH_DEPTH.
Example configuration:
# .mbr/config.toml
# Use persistent sidebar instead of modal
sidebar_style = "single"
# Show more items in large repositories
sidebar_max_items = 200
# Expand the info-panel link graph one hop further
graph_depth = 3
Title Settings
| Option | Type | Default | Description |
|---|---|---|---|
title_prefix | string | "" | Text prepended to all page <title> tags |
title_suffix | string | "" | Text appended to all page <title> tags |
These options let you brand page titles across the site without modifying individual pages. They apply to markdown pages, directory listings, tag pages, and media viewer pages (not error pages).
Example configuration:
# .mbr/config.toml
# Brand all page titles
title_prefix = "My Notes: "
title_suffix = " | Paul's Wiki"
This turns a page titled “Getting Started” into <title>My Notes: Getting Started | Paul's Wiki</title>.
CLI usage:
mbr -s --title-prefix "My Site: " --title-suffix " | Docs" ~/notes
Task Settings
The task browser finds every markdown task (- [ ], - [x], - [-], - [>])
in the repository and lets you filter and group them — see
Task Browser for the user guide and
Markdown Extensions for the syntax. It is server/GUI only:
the index is built by reading live files, so static builds never expose it —
tasks_enabled has no effect on mbr -b, and built pages always report
tasksEnabled: false to the frontend.
| Option | Type | Default | Description |
|---|---|---|---|
tasks_enabled | bool | true | Enable POST /.mbr/tasks and the task panel. Disable with --no-tasks or MBR_TASKS_ENABLED=false; the endpoint then returns 404. |
tasks_stamp_done | bool | true | Maintain the @done(...) annotation when a task is toggled through POST /.mbr/task. Set with MBR_TASKS_STAMP_DONE=false; there is no CLI flag. |
tasks_default_include | string | "tasks" | Where the panel’s Show filter starts: "tasks", "markers" or "all". Set with MBR_TASKS_DEFAULT_INCLUDE; there is no CLI flag. |
tasks_ignore_globs | array | [] | Glob patterns whose matching files contribute nothing to the task browser. Set with MBR_TASKS_IGNORE_GLOBS='["templates/**"]'; there is no CLI flag. |
Example:
# .mbr/config.toml
tasks_enabled = false
Where the Show filter starts (tasks_default_include):
The panel’s ⚙ Show filter chooses between checkbox tasks, TODO:-style
incomplete markers, or both. This option sets
where it starts:
# .mbr/config.toml
tasks_default_include = "all" # "tasks" (default), "markers", or "all"
The default is "tasks" — checkboxes only — because the two are different
kinds of thing: a checkbox is work somebody wrote down as work, while a marker
is a note to self left in the middle of a sentence. Reading them interleaved by
default makes the list noisier than the task list of a repository that uses
checkboxes properly deserves.
That default costs a repository that uses only markers nothing, because
the panel widens to all on its own when the configured default comes back
empty. It does so before drawing anything, so you never see a flash of “No
tasks match these filters”, and it moves the Show control to match — the filter
always describes what is actually on screen. The widening happens at most once
per open, and never after you have touched the Show select yourself: an explicit
choice sticks even when it selects nothing.
Set tasks_default_include = "all" to start on both, in which case nothing is
widened — there is no broader setting to fall back to.
A value that is not one of the three aborts startup with a parse error naming the option, rather than being quietly ignored.
Excluding folders from the task browser (tasks_ignore_globs):
A folder of checklist templates is full of unchecked boxes that are not anybody’s work, and left alone it drowns the task browser. List it here:
# .mbr/config.toml
tasks_ignore_globs = ["templates/**"]
Files matching any pattern are never put in the task index, so they disappear
from the whole task browser at once: no tasks in the list, no folder in the
folder pane, and nothing counted in the totals or the x/y progress figures.
The exclusion stops there. Those files still render, still show their
checkboxes, and — with editing enabled — those checkboxes
still work: in-document tasks exist whether or not the task browser lists them.
Nothing else in mbr is affected either; ignore_globs is the option that keeps
files out of the site entirely.
Each pattern is matched against the file’s repository-relative path, always
/-separated (so one pattern behaves the same on macOS, Linux and Windows):
| Pattern | Matches | Does not match |
|---|---|---|
templates/** | templates/a.md, templates/deep/b.md | docs/templates/a.md |
**/templates/** | templates/a.md, docs/templates/a.md, a/b/templates/c.md | templates.md |
docs/templates/** | docs/templates/a.md | templates/a.md |
**/*.checklist.md | templates/onboarding.checklist.md | templates/onboarding.md |
templates | (nothing) | every path — see below |
Two things are worth pinning down:
- Patterns name files, not folders.
templatesmatches a file calledtemplates, so it excludes nothing. Writetemplates/**to mean “everything in that folder”.**/matches zero or more leading folders, which is why**/templates/**also covers atemplates/at the repository root. *crosses/. As elsewhere in mbr’s globs, a single*is not stopped by a path separator, sotemplates/*.mdalso matchestemplates/deep/a.md. Use a literal path when you mean one level.
A pattern that does not compile aborts startup with an error naming it, rather than being quietly skipped and indexing the folder you meant to exclude.
@done(...) stamping (tasks_stamp_done):
When you check a box (editing must be enabled — see Editing Settings), mbr appends the completion time to the line in the format its own parser reads back:
- [ ] write the report !! ← before
- [x] write the report !! @done(2026-08-04 14:32) ← after
Reopening or canceling the task removes that annotation again, and completing an
already-completed task does not add a second one or re-date the first. The
timestamp is local wall-clock time, matching the naive/local dates @due(...)
uses.
Set tasks_stamp_done = false to have the endpoint rewrite nothing but the
marker byte, leaving any @done(...) exactly as its author wrote it. Either way
mbr never writes to a markdown file unless you ask it to.
Cost when unused: none. The index is built lazily on the first task query and then kept fresh by the file watcher, so a server whose user never opens the task panel never reads a file for it. The first query on a very large repository pays one sequential read pass; later ones are served from memory. There is no on-disk cache, so a restart discards the index — deliberately, since mbr is pointed at directories that change underneath it.
Editing Settings
In-browser editing (server/GUI mode only) is off by default. When enabled, a pencil button appears next to the info button and opens a Milkdown/Crepe editor for the current markdown file. See the editing guide.
| Option | Type | Default | Description |
|---|---|---|---|
edit_enabled | bool | false | Enable the /.mbr/raw and /.mbr/edit endpoints and the edit button. Also enabled by --edit. |
edit_token_hash | string / unset | (unset) | Argon2 PHC hash of the shared editing token. Required when editing is enabled on a non-loopback host. Generate with mbr --generate-edit-token. Never sent to the frontend. |
edit_require_token_on_loopback | bool | false | Require the token even for loopback callers. When false, local (127.0.0.1) edits need no token but are still CSRF-protected. |
upload_max_bytes | number | 26214400 (25 MiB) | Maximum size in bytes of a single asset uploaded via /.mbr/upload (the editor’s image uploader). Larger bodies are rejected with 413 Payload Too Large. |
Environment variables use the MBR_ prefix (e.g. MBR_EDIT_ENABLED=true).
Security model:
- Enabled + editing gated: every request must carry an
X-MBR-Edit: 1header and be same-origin, defeating cross-site / DNS-rebinding writes even on localhost. - Loopback callers (127.0.0.1) may edit without a token unless
edit_require_token_on_loopback = true. - Non-loopback callers must present the token as
Authorization: Bearer <token>; startup validation refuses to enable editing on a non-loopback host without anedit_token_hash. - Concurrency: saves include the hash of the loaded content and are rejected
with
409 Conflictif the file changed on disk since it was loaded. - Transport: mbr serves plain HTTP. For remote editing, put mbr behind a TLS-terminating reverse proxy — the token travels with every request.
Example:
# .mbr/config.toml
edit_enabled = true
# Only needed for remote editing (non-loopback); generate with `mbr --generate-edit-token`
edit_token_hash = "$argon2id$v=19$m=19456,t=2,p=1$...."
# Optional: require the token even from localhost
# edit_require_token_on_loopback = true
# Optional: cap for editor asset uploads (bytes); default 25 MiB
# upload_max_bytes = 26214400
The editor’s image uploader POSTs bytes to /.mbr/upload?dir=<folder>&name=<file>,
which writes the asset next to the note being edited and returns its root-absolute
URL. It is gated by the same X-MBR-Edit + same-origin (+ token) checks as the
other editing endpoints, and its body size is capped by upload_max_bytes.
Tag Settings
| Option | Type | Default | Description |
|---|---|---|---|
tag_sources | array | [{ field = "tags" }] | Frontmatter fields to extract tags from |
build_tag_pages | bool | true | Generate tag pages in static builds |
Tag source configuration:
Each tag source can specify:
field(required): Frontmatter field name (supports dot-notation for nested fields liketaxonomy.tags)label: Singular label for display (e.g., “Tag”, “Performer”)label_plural: Plural label for display (e.g., “Tags”, “Performers”)
Example configuration:
# .mbr/config.toml
tag_sources = [
{ field = "tags" },
{ field = "taxonomy.performers", label = "Performer", label_plural = "Performers" }
]
build_tag_pages = true
See the Tags feature documentation for complete details.
Relationship Settings
| Option | Type | Default | Description |
|---|---|---|---|
relationship_tracking | bool | true | Enable typed relationship tracking |
relationship_types | array | genealogy defaults | Relation types with symmetric / inverse semantics and labels |
Relation type configuration:
Each relation type can specify:
name(required): The relation predicate (e.g.,parent,spouse)symmetric:truewhen the reverse reads the same (spouse, sibling)inverse: The inverse relation-type name, if it’s one half of an inverse pair (parent ↔ child). Mutually exclusive withsymmetric, and must name a different type — see below.label/label_plural: Display labels (auto-derived fromnamewhen unset; setlabel_pluralexplicitly for irregular plurals such as “Children”)
The default relationship_types provide genealogy semantics:
# .mbr/config.toml (these are the built-in defaults)
relationship_types = [
{ name = "parent", inverse = "child", label = "Parent", label_plural = "Parents" },
{ name = "child", inverse = "parent", label = "Child", label_plural = "Children" },
{ name = "spouse", symmetric = true, label = "Spouse", label_plural = "Spouses" },
{ name = "sibling", symmetric = true, label = "Sibling", label_plural = "Siblings" },
]
relationship_tracking = true
Relation types not listed here are still tracked, but as directed edges with no automatic reverse relabelling. See the Relationships & Genealogy documentation for the frontmatter schema and a walkthrough.
Self-inverse types are coerced to symmetric. inverse names the other half
of a pair, so a type naming itself is self-contradictory:
# Wrong: a type cannot be its own inverse.
{ name = "sponsor", inverse = "sponsor" }
# Right: "the reverse reads the same" is what symmetric means.
{ name = "sponsor", symmetric = true }
mbr treats the first form as the second and warns once, naming the type
(matching is case-insensitive, so inverse = "Sponsor" counts). Without the
coercion both halves of such a pair would be indistinguishable, and every edge
using it would look like a two-note parent/child-style cycle — dropping half
of each relationship in the genealogy chart and reporting a
relationship_cycle against notes
whose data was fine. Declare symmetric = true explicitly to silence the
warning.
Note:
relationship_trackingrides on the samelinks.jsonfetch as backlinks (no extra request). Disabling it omits relationship data fromlinks.jsonandsite.jsonand hides the info-panel section.
Note: Typed relationships are visualized by the d3-based genealogy charts on
type: personpages and as edges in the info panel’s mini link graph — not by mermaid. See Relationships & Genealogy for both.
Note: Setting
oembed_timeout_msto0disables OpenGraph fetching entirely, rendering bare URLs as plain links. YouTube and Giphy embeds still work since they don’t require network calls.
Note: The oembed cache stores fetched page metadata to avoid redundant network requests. URLs are fetched in parallel and cached for reuse across files (in build mode) or requests (in server mode). Set
oembed_cache_sizeto0to disable caching. It does not size the media metadata cache — seemedia_cache_sizebelow.
Note: Fetching is bounded: at most 8 requests are in flight at once per page, and a single page fetches metadata for at most 100 distinct bare URLs. Any URLs beyond that cap render as plain links. Bare URLs inside fenced or indented code blocks are never fetched — code samples render verbatim.
Security: Oembed fetching refuses private, loopback, and link-local addresses (including hostnames that resolve to them), follows at most 5 redirects with every hop re-checked against the same rules, and caps response bodies at 512KB.
Build Mode Performance
By default, static builds (-b) disable oembed fetching (oembed_timeout_ms=0). If you want rich link previews in your static site, you can enable it by specifying a timeout:
mbr -b --oembed-timeout-ms 500 ~/notes
Oembed fetching is parallelized and cached, so the overhead is minimal even for large repositories.
Parallel Building
Static builds process markdown files in parallel for maximum speed:
| Setting | Effect |
|---|---|
| Default (auto) | Uses 2x CPU cores, capped at 32 |
--build-concurrency 1 | Sequential processing (useful for debugging) |
--build-concurrency 16 | Explicit concurrency limit |
Memory usage scales with concurrency. Use lower values if running out of memory on very large repositories.
Link Validation
By default, static builds validate all internal links (links to other pages within the site) and report broken ones. To skip this check for faster builds:
mbr -b --skip-link-checks ~/notes
Or in .mbr/config.toml:
skip_link_checks = true
Link Tracking (Backlinks)
mbr automatically tracks bidirectional links between pages. The info panel (Ctrl+g) shows both:
- Links Out: Pages this document links to
- Links In: Pages that link to this document (backlinks)
This feature enables wiki-style backlink navigation without requiring any special syntax. The same data powers the info panel’s mini link graph, which walks outward from the current page by fetching neighbouring pages’ links.json files client-side (a few at a time, capped and session-cached) — see Relationships & Genealogy.
How it works:
| Mode | Method | Performance |
|---|---|---|
| Server/GUI | On-demand grep search (cached) | First request: ~1-3s, subsequent: instant |
| Build | Eager index during render | Computed in parallel, no runtime cost |
In server mode, concurrent inbound-link scans are bounded (two repository scans
at a time, single-flight per page) and results are cached (~5 minute TTL), so
bursts of links.json requests — such as the mini link graph fetching a whole
neighbourhood — stay responsive even on large repositories.
API: Each page has a links.json endpoint:
# Server mode
curl http://localhost:5200/docs/guide/links.json
# Static build
cat build/docs/guide/links.json
Response format:
{
"inbound": [
{"from": "/other/page/", "text": "link text", "anchor": "#section"}
],
"outbound": [
{"to": "/another/page/", "text": "link text", "anchor": "#section", "internal": true}
]
}
Disable link tracking:
mbr -s --no-link-tracking ~/notes
Or in .mbr/config.toml:
link_tracking = false
When disabled, the links.json endpoint returns 404, no link files are generated during builds, and the info panel’s link sections and mini link graph don’t appear.
Review Settings
Every block-level element on a served page carries
data-mbr-line="{1-based source line}", so a frontend feature can turn a text
selection into an anchor of the form file.md:LINE — a review comment that
survives a re-render, because it names the source rather than the HTML.
It is server/GUI only. mbr -b, the CLI (mbr file.md) and the macOS
QuickLook preview pass ReviewLines::Omit unconditionally rather than reading
this option, because an anchor is only meaningful against a live file that
something can be asked about. review_enabled therefore has no effect on
mbr -b, and built pages always report reviewEnabled: false to the frontend.
| Option | Type | Default | Description |
|---|---|---|---|
review_enabled | bool | true | Emit data-mbr-line on block elements. Disable with --no-review or MBR_REVIEW_ENABLED=false. |
Example:
# .mbr/config.toml
review_enabled = false
Which elements carry the attribute:
| Element | Markdown |
|---|---|
<p> | a paragraph |
<h1>–<h6> | a heading |
<li> | a list item |
<blockquote> | a block quote |
<pre> | a fenced or indented code block |
<table> | a table |
On a heading the attribute is written before any {#id .class} the author
supplied, because an HTML parser keeps the first of a pair of duplicate
attributes — so a heading written as ## Title {data-mbr-line=99} cannot
displace the real source line.
Which elements deliberately do not, and why:
<ul>/<ol>— a list’s source line is always its first<li>’s, so an attribute there would be pure duplication.<td>/<th>— every cell in a row shares one source line, so per-cell attributes roughly triple the byte cost of a table-heavy document to repeat the row’s line over and over. The<table>carries the line instead.- Inline elements (
<em>,<a>, …) — anchoring one would need a byte offset within a text run, and mbr enables smart punctuation, which has already desynchronised text bytes from source bytes by the time the HTML is written.
Known gap: in a tight definition list the <dd>/<dt> prose is not
wrapped in a <p>, so those lines have no ancestor carrying data-mbr-line.
A selection there anchors to the nearest enclosing block instead.
Incomplete-Marker Highlighting
mbr highlights incomplete-markers like TK, TODO, FIXME, or XXX wherever
they appear in a line. Highlights are wrapped in
<span class="mbr-incomplete">…</span> and styled with a yellow background
wash (--mbr-incomplete-bg).
There are two shapes of highlight:
- A block that starts with a marker — a paragraph, heading, list item, or
table cell whose first text begins with one — has its whole content wrapped,
so
TK rewrite this paragraph.washes the paragraph. - Every other occurrence has just the marker word wrapped, so
The market fell 10% (source: TK).highlights theTKand nothing else.
Each wrapper doubles as a deep-link target: the first highlight on a source
line also carries id="mbr-marker-{line}", so …/notes/draft/#mbr-marker-42
jumps to it. Later markers on the same line — the two cells of
| TK a | TK b |, say — are still highlighted, but only one of them can hold
the anchor, because duplicate HTML ids are invalid.
Markers are not highlighted inside code blocks, inline code spans, image alt text, link destinations and titles, or YAML frontmatter.
Match rule: matching is case-sensitive, and a word boundary is required on each
side of the marker — but only on a side whose own spelling calls for one. So
TK, TK:, TODO foo, and FIXME(name) match; Tk, todo, Tomato,
TKTK, and TODOs do not. Making each boundary conditional is what lets a
custom marker that begins or ends in punctuation work at all: TODO: matches
TODO: ship it, and @todo matches at the start of a line. When two configured
markers can both match in the same place, the longer one wins, whatever order
they appear in incomplete_markers.
| Mode | Default | Reason |
|---|---|---|
| Server / GUI | on | Helps writers spot drafts at a glance |
| Static build | off | Avoids leaking unfinished markers into published sites |
CLI overrides:
# Force highlighting on (e.g., during a build to preview drafts)
mbr -b --mark-incomplete ~/notes
# Force highlighting off in server mode
mbr -s --no-mark-incomplete ~/notes
Configuration file:
# .mbr/config.toml
# Force a value (overrides the per-mode default; CLI flag still wins)
mark_incomplete = true
# Customize the marker list. Matching is case-sensitive and word-boundaried;
# see the match rule above.
incomplete_markers = ["TK", "TODO", "FIXME", "XXX"]
Environment variables:
MBR_MARK_INCOMPLETE=true
MBR_INCOMPLETE_MARKERS='["NOTE","DRAFT"]'
Setting incomplete_markers = [] disables the feature entirely (the pass
becomes a no-op even when mark_incomplete = true).
Per-Page Error Indicator (Server / GUI Only)
When running with -s (server) or -g (GUI), mbr exposes a lightweight
per-page diagnostics endpoint at /{page}/errors.json and a matching
<mbr-page-errors> navbar indicator that lights up with a ⚠ icon when the
current page has problems.
The endpoint detects these issue types:
| Type | Trigger |
|---|---|
broken_internal_link | An outbound internal link whose target does not resolve (via path_resolver). Mirrors the existing build-time link validation, but runs live and non-fatally. |
broken_media_reference | <img>, <video>, <audio>, or <source> whose internal src does not exist on disk or via the static-folder overlay. |
unresolved_wikilink | A literal [[...]] that survived into the rendered HTML (e.g. inside a raw-HTML block). Most wikilinks are caught by broken_internal_link instead. |
frontmatter_parse_error | The YAML frontmatter failed to parse, so the whole block — every otherwise-valid field included — was discarded. |
unplayable_media | A video that exists and serves correctly but whose track layout matches a combination implicated in browser decode failures. Advisory only — see Unplayable Media Detection below. Requires the media-metadata feature. |
relationship_cycle | Two or more notes form a parent/child (or other inverse-pair) cycle. Impossible data, and it makes the genealogy chart unrenderable. Reported on every note in the cycle. |
ambiguous_relationship_endpoint | A relationship endpoint named a title/alias shared by several notes; mbr resolved it to one of them. Reported on the note that declared the endpoint. |
ambiguous_wikilink | A body [[Wikilink]] named a title/stem shared by several notes. Reported on the page containing the link. |
The last three come from the relationship index and are additionally gated on
relationship_tracking (ambiguous_wikilink excepted — wikilink resolution is
always on). See
Relationships → Data problems mbr reports
for what each one means and how to fix it. relationship_cycle and
ambiguous_relationship_endpoint are also logged as WARN lines at startup, in
both server and build mode; ambiguous_wikilink is not, because whether a shared
name is ambiguous — and which note it resolves to — depends on the page the link
was written on, which only this endpoint knows.
The response is JSON with a stable, tagged shape:
{
"page_url": "/docs/guide/",
"errors": [
{ "type": "broken_internal_link", "target": "/nonexistent/", "text": "bad" },
{ "type": "broken_media_reference", "src": "./missing.png", "kind": "image" },
{ "type": "unresolved_wikilink", "raw": "[[never-a-real-page]]" },
{ "type": "frontmatter_parse_error", "message": "duplicated key in mapping" },
{
"type": "unplayable_media",
"src": "../Foo%20Bar.mp4",
"kind": "video",
"reason": "This file carries both a 'gpmd' timed-metadata track and a 'tx3g' subtitle track. Safari/WebKit sometimes fails to decode that combination, so it is the most likely cause. Files with this combination do not always fail, and other browsers are usually unaffected.",
"remedy": "ffmpeg -i in.mp4 -map 0 -c copy -dn -movflags +faststart out.mp4",
"advisory": true
},
{ "type": "relationship_cycle", "members": ["/people/ada/", "/people/bob/"], "rel_type": "child" },
{
"type": "ambiguous_relationship_endpoint",
"raw": "[[John Doe]]",
"resolved_to": "/people/john-jr/",
"candidates": ["/people/john-sr/"]
},
{
"type": "ambiguous_wikilink",
"raw": "[[John Doe]]",
"resolved_to": "/people/john-jr/",
"candidates": ["/people/john-sr/"]
}
]
}
Status codes:
200— page exists and was scanned (even whenerrorsis empty; the client uses the array length to decide whether to show the ⚠ icon).404— the path is not a markdown page, orlink_trackingis disabled.
No new flags. This feature reuses the existing --no-link-tracking /
link_tracking switch: disable link tracking to suppress the endpoint and
hide the indicator.
Zero leakage into static builds. The endpoint is registered only in
server.rs, the <mbr-page-errors> element is gated on
{% if server_mode %} in templates/_nav.html, and the Lit component
self-guards on window.__MBR_CONFIG__.serverMode. cargo run -- -b <repo>
continues to validate links at build time via the existing
--skip-link-checks flow, without emitting errors.json files or
<mbr-page-errors> elements into the output.
Unplayable Media Detection
Note: This feature requires the
media-metadataCargo feature (on by default) and, like the rest oferrors.json, runs only in server/GUI mode. Static builds (-b) never probe media and never emit these diagnostics.
Some video files are served perfectly — correct video/mp4 content type,
accept-ranges: bytes, valid 206 responses — and still refuse to play. The
browser reports the right duration on loadedmetadata and then fails with
MediaError.code === 3 (“media failed to decode”).
One known trigger is a gpmd timed-metadata track (GoPro GPMF telemetry)
combined with a tx3g subtitle track. Bisecting a reported file in Safari —
the engine behind mbr’s own GUI window — isolated the interaction: either track
type alone plays fine, both together fail. Ruled out along the way, so they are
not flagged: text data tracks, PNG cover-art video tracks, total track
count, 4K resolution, and H.264 level 5.1.
When you open a page, <mbr-page-errors> fetches errors.json in the
background and mbr probes each referenced video’s container headers. Matching
files are reported as unplayable_media with a likely cause and the ffmpeg
command that resolves it.
Advisory, not authoritative. The combination is necessary but not
sufficient: a minimal synthetic file carrying both tracks plays fine in the
same Safari build, so matching it does not mean a file is broken. Every entry
therefore carries "advisory": true, and the frontend withholds it until the
browser reports a real MediaError for the same src — at which point the
reason and remedy explain the failure. This is why an advisory entry alone never
raises the ⚠ count: a heuristic must not put a warning on a working video. The
browser’s own error is the only ground truth for “this did not play”, and it is
also reported on its own (as runtime_media_error) when mbr has no hint to add.
Only an explicit "advisory": false marks an entry as certain enough to show
proactively. An absent field is treated as advisory, so an older or third-party
payload errs toward staying quiet rather than warning wrongly.
Performance. The probe reads container headers only — no frames are decoded — so its cost is independent of file length, and it runs on a blocking thread pool with bounded concurrency. Results are cached per file path + modification time, so a page reload never re-probes an unchanged file and an edited file is transparently re-probed. Nothing about this touches the markdown render path: a page with a 1.2 GB video renders at full speed, and the diagnosis arrives asynchronously afterwards (~70 ms cold, ~2 ms cached).
The fix for an affected file is to remux it, keeping only the video and audio streams (this copies the streams — it does not re-encode, so it is fast and lossless):
ffmpeg -i in.mp4 -map 0:v -map 0:a -c copy -movflags +faststart out.mp4
mbr can also attempt that repair for you, at serve time, without touching the file — see Automatic Playback Recovery below.
Automatic Playback Recovery
Note: This feature requires the
media-metadataCargo feature (on by default) and runs only in server/GUI mode. Static builds (-b) never register these routes.
Because a file’s track table cannot tell you whether it will play, mbr does not
try to get ahead of the problem. Instead, when a <video> element reports a real
MediaError, the frontend retries the same video against a remux variant: an
HLS playlist over the same media with the non-audio/video tracks left out.
No --transcode needed. Unlike the 720p/480p ladder below, this variant is
always available. It is a stream copy, so it costs a small fraction of a
re-encode, and it exists to recover a video the reader has already watched fail.
It never re-encodes. Every video and audio packet is copied byte-for-byte. Only the container is rebuilt, carrying just the best video stream and the best audio stream — which is exactly what leaves the data and subtitle tracks behind. There is no quality loss and no resolution change.
URL patterns. For a video served at /videos/demo.mp4:
| Type | URL | Content type |
|---|---|---|
| Playlist | /videos/demo.mp4-remux.m3u8 | application/vnd.apple.mpegurl |
| Init segment | /videos/demo.mp4-remux-init.mp4 | video/mp4 |
| Media segment | /videos/demo.mp4-remux-000.m4s | video/iso.segment |
The video’s own extension stays in the path, which keeps these URLs distinct from
the -720p.m3u8 transcode URLs and from the .cover.jpg / .captions.en.vtt
sidecars.
Fragmented MP4, not MPEG-TS. The transcode ladder emits .ts segments; it
can, because it encodes fresh frames. A stream copy cannot: MP4 stores H.264 as
length-prefixed AVCC and MPEG-TS needs Annex B, and that conversion requires a
bitstream filter that mbr’s ffmpeg bindings do not expose. Fragmented MP4
(HLS v7+) avoids the problem by reusing the source’s codec configuration
unchanged.
Browser support. This is native HLS, which in practice means WebKit — Safari
and mbr’s own -g GUI window. Chrome and Firefox have no native HLS support (it
needs a JavaScript player), so recovery only applies where the browser can play
the playlist. That is a good match for the problem: the decode failure this
recovers from is itself a WebKit behaviour.
Segment boundaries. Segments target 4 seconds but always begin at an existing
keyframe, since a stream copy cannot insert one. Boundaries come from the
container’s own sync-sample index — already parsed when the file is opened — so
no part of the file is scanned to find them, and #EXTINF durations reflect the
real, slightly uneven segment lengths. A container that exposes no keyframe index
returns 422 rather than a playlist whose segments a player could not decode.
Performance and memory. Segments are generated on demand on a blocking thread pool, one generation per segment no matter how many requests arrive for it, and cached in the same in-memory HLS cache as the transcode ladder (~200 MB, no cache files on disk). Cache entries are keyed by file path + modification time, so editing a video transparently re-segments it. Playlists and init segments are evicted last, since they are tiny and needed for every playback attempt. A 4 s copy segment is roughly a tenth the size of the source’s 4 s of data — about 30 MB for extreme 60 Mbps 4K footage, single-digit MB for typical video.
Nothing here runs during markdown rendering; the first request for a variant is the first time any of it happens.
Media Compression
mbr gzip-compresses responses when the client asks for it, but never for
already-compressed or range-critical payloads: video/*, audio/*,
application/pdf, application/zip, application/gzip,
application/x-gzip, and application/octet-stream (in addition to the
images, gRPC and Server-Sent Events that tower-http excludes by default).
This is a correctness requirement, not just an optimization. Compressing a
response forces transfer-encoding: chunked and drops both content-length
and accept-ranges — which breaks seeking and duration detection for every
media player that negotiates gzip. There is no configuration knob; media is
always served verbatim.
Video Metadata Extraction
Note: This feature requires the
media-metadataCargo feature to be enabled at compile time.
mbr can extract video metadata (cover images, chapters, and captions) from video files. This works in two ways:
Server Mode (Dynamic Generation):
When running with -s or -g, mbr automatically generates metadata files on-the-fly when they don’t exist on disk. Request any of these special paths to trigger generation:
| Pattern | Description |
|---|---|
{video}.cover.jpg | Cover image (frame captured at 5 seconds, or earlier for short videos) |
{video}.chapters.en.vtt | Chapter markers in WebVTT format |
{video}.captions.en.vtt | Subtitles/captions in WebVTT format |
Example: If you have videos/demo.mp4, requesting /videos/demo.mp4.cover.jpg will dynamically extract and return a cover image.
Generated metadata is cached in memory to avoid repeated ffmpeg operations.
Cache size:
| Option | Type | Default | Description |
|---|---|---|---|
media_cache_size | number | 67108864 (64 MB) | Bytes of generated video/PDF metadata (cover JPEGs, chapters, captions) kept in memory; 0 disables caching |
This budget is separate from oembed_cache_size. Cover images are full JPEG
payloads (a PDF cover renders up to 1200 px wide), so they need far more room
than the short text metadata the oembed cache holds — and turning oembed
caching off must not turn media caching off with it. Raise it on repositories
with large media galleries:
# .mbr/config.toml
media_cache_size = 134217728 # 128 MB
Or MBR_MEDIA_CACHE_SIZE=134217728. The cache lives in server/GUI mode only;
static builds read pre-generated sidecar files instead.
CLI Mode (Pre-generation):
Use --extract-video-metadata to extract metadata and save as sidecar files:
# Extract metadata from a single video
mbr --extract-video-metadata ~/videos/demo.mp4
# Output:
# Analyzing video: /Users/you/videos/demo.mp4
# Duration: 120.5s, Chapters: yes, Subtitles: no
# + Created: /Users/you/videos/demo.mp4.cover.jpg
# + Created: /Users/you/videos/demo.mp4.chapters.en.vtt
# - No captions found in video
This is useful for pre-generating metadata for static site builds or when you want the files persisted to disk.
Video Transcoding (EXPERIMENTAL)
Note: This feature requires the
media-metadataCargo feature to be enabled at compile time.
⚠️ EXPERIMENTAL - This feature is new and feedback is welcome! Please report issues or suggestions at the project repository.
mbr can dynamically transcode videos to lower resolutions (720p, 480p) using HLS (HTTP Live Streaming) for bandwidth savings on mobile devices and slow connections. This feature only works in server/GUI mode (-s or -g).
Enable transcoding:
mbr -s --transcode ~/notes
How it works:
- When transcoding is enabled, video embeds include multiple
<source>tags with media queries - Desktop browsers (viewport >= 1280px) load the original MP4 directly
- Tablets (viewport >= 640px) use HLS 720p playlist (Safari only)
- Mobile devices use HLS 480p playlist (Safari only)
- Non-Safari browsers fall back to the original MP4 (HLS requires JavaScript in Chrome/Firefox)
- HLS segments (~10 seconds each) are transcoded on-demand and cached
URL patterns:
| Type | Example |
|---|---|
| Original video | /videos/demo.mp4 |
| 720p HLS playlist | /videos/demo-720p.m3u8 |
| 720p HLS segment | /videos/demo-720p-005.ts |
| 480p HLS playlist | /videos/demo-480p.m3u8 |
Browser compatibility:
| Browser | HLS Support | Behavior |
|---|---|---|
| Safari (macOS/iOS) | Native | Uses HLS variants on mobile/tablet |
| Chrome/Firefox/Edge | None | Falls back to original MP4 |
Hardware acceleration: Transcoding automatically uses hardware encoders when available:
- macOS: VideoToolbox (
h264_videotoolbox) - Linux: NVIDIA (
h264_nvenc), AMD (h264_amf), Intel (h264_qsv), VAAPI - Fallback: Software encoding (
libx264)
Configuration:
| Option | Type | Default | Description |
|---|---|---|---|
transcode | bool | false | Enable dynamic video transcoding via HLS |
Memory usage: HLS segments and playlists are cached in memory (~200MB max by default). Each segment is approximately:
- 720p segment (10s @ 2.5 Mbps): ~3 MB
- 480p segment (10s @ 1.0 Mbps): ~1.25 MB
- Playlist: ~1-2 KB (negligible)
Cache evicts oldest segments when full, prioritizing keeping playlists cached.
When to use:
- Serving videos to Safari users on mobile devices
- Reducing bandwidth usage for remote viewers on iOS/macOS
- Fast video startup (only first segment needs to transcode)
When NOT to use:
- Local browsing on the same machine (default: off)
- Users primarily on Chrome/Firefox (they get original MP4 anyway)
- Static site generation (transcoding is server-only)
PDF Cover Extraction
Note: This feature requires the
media-metadataCargo feature to be enabled at compile time.
mbr can extract cover images (first page) from PDF files. This works in two ways:
Server Mode (Dynamic Generation):
When running with -s or -g, mbr automatically generates cover images on-the-fly when requested. Request {pdf}.cover.jpg to get the cover:
| Pattern | Description |
|---|---|
{pdf}.cover.jpg | Cover image (first page rendered at max 1200px width) |
Example: If you have docs/report.pdf, requesting /docs/report.pdf.cover.jpg will dynamically extract and return the cover image.
Pre-generated covers: If a file {pdf}.cover.jpg already exists on disk (as a sidecar file), it will be served directly without extraction. This is useful for static builds.
CLI Mode (Pre-generation):
Use --extract-pdf-cover to extract covers and save as sidecar files:
# Extract cover from a single PDF
mbr --extract-pdf-cover ~/docs/report.pdf
# Output:
# Extracting cover: /Users/you/docs/report.pdf -> /Users/you/docs/report.pdf.cover.jpg
# ✓ Created 1 cover image
# Extract covers from all PDFs in a directory (recursive)
mbr --extract-pdf-cover ~/docs
# Output:
# Extracting cover: docs/report.pdf -> docs/report.pdf.cover.jpg
# Extracting cover: docs/manual.pdf -> docs/manual.pdf.cover.jpg
# ✓ Created 2 cover images
Exit codes:
| Code | Meaning |
|---|---|
0 | Success (all covers created) |
1 | Partial failure (some PDFs failed, others succeeded) |
2 | Total failure (no covers created) |
Error handling:
- Password-protected PDFs are skipped with an error message
- Corrupt or unreadable PDFs are reported to stderr
- The process continues even when individual PDFs fail
Static builds: For static site generation, pre-generate covers before building:
# 1. Extract all PDF covers
mbr --extract-pdf-cover ~/notes
# 2. Build static site (covers are included as assets)
mbr -b ~/notes
The sidecar .cover.jpg files are automatically included in static builds via asset symlinking.
Environment Variables
There are good reasons to allow this behavior, but in general, please don’t use environment variables for configs. It’s very easy to get unexpected behavior as you forget about the silent inputs from the environment.
Every configuration option can be set via environment variable with the MBR_ prefix:
# Server settings
MBR_HOST=0.0.0.0
MBR_PORT=3000
# Content settings
MBR_STATIC_FOLDER=assets
MBR_INDEX_FILE=README.md
# Behavior
MBR_OEMBED_TIMEOUT_MS=1000
MBR_OEMBED_CACHE_SIZE=4194304 # 4MB
MBR_MEDIA_CACHE_SIZE=134217728 # 128MB of video/PDF covers, chapters, captions
# Navigation
MBR_GRAPH_DEPTH=3
# Video transcoding (requires media-metadata feature)
MBR_TRANSCODE=true
# Incomplete-block highlighting (TK / TODO / FIXME / XXX)
MBR_MARK_INCOMPLETE=true
MBR_INCOMPLETE_MARKERS='["NOTE","DRAFT"]'
# Tasks
MBR_TASKS_ENABLED=false
MBR_TASKS_DEFAULT_INCLUDE=all # where the panel's Show filter starts
MBR_TASKS_IGNORE_GLOBS='["templates/**","**/archive/**"]' # kept out of the task browser
# Editing
MBR_EDIT_ENABLED=true
MBR_UPLOAD_MAX_BYTES=26214400 # 25 MiB cap for editor asset uploads
Environment variables override config file settings, which in turn override the compiled-in defaults. Command-line flags override everything.
Root Directory Detection
mbr automatically finds the repository root by searching upward for common repository markers:
Directory markers (searched first, in order):
| Marker | Description |
|---|---|
.mbr/ | mbr configuration folder (highest priority) |
.git/ | Git repository |
.zk/ | Zettlekasten notes |
.obsidian/ | Obsidian vault |
File markers (searched if no directory markers found):
| Marker | Description |
|---|---|
book.toml | mdBook project |
mkdocs.yml | MkDocs project |
docusaurus.config.js | Docusaurus project |
The search works as follows:
- Start from the specified path
- Search upward for directory markers in priority order
- If no directory markers found, search for file markers
- First marker found determines the root directory
- If no markers found, fall back to current working directory (if ancestor) or specified path
This allows running mbr from any subdirectory:
cd ~/notes/docs/guide
mbr -s . # Still uses ~/notes/.mbr/config.toml
Static Folder
The static_folder setting creates an overlay for serving static files. The default is static, meaning files in a static/ folder at the repo root are served at the root URL path.
notes/
├── static/ # Default static folder
│ └── images/
│ └── logo.png # Available at /images/logo.png
└── docs/
└── guide.md
To use a different folder, configure it in .mbr/config.toml:
static_folder = "assets"
Where the static folder may live
static_folder is read from the repository’s own .mbr/config.toml, which the
person running mbr may not have written. Left unrestricted it would let a
repository turn the server into an arbitrary-file reader, so mbr checks it at
startup and refuses to launch on a value that reaches too far.
Inside the root is always fine, including nested paths:
static_folder = "assets"
static_folder = "public/assets"
A peer of the root is also allowed, for the common layout where the markdown and the media are siblings:
project/
├── content/ # markdown root (holds .mbr/)
│ └── .mbr/config.toml # static_folder = "../static"
└── static/ # peer — allowed
└── videos/
└── demo.mp4 # available at /videos/demo.mp4
Two levels up is allowed as well, for a framework that owns the directory
layout. SvelteKit serves a route from its filesystem path, so the markdown root
has to be src/routes/, while the assets it serves at the site root live in the
project’s static/:
project/
├── src/
│ └── routes/ # markdown root (holds .mbr/)
│ └── .mbr/config.toml # static_folder = "../../static"
└── static/ # two levels up — allowed
└── images/
└── logo.png # available at /images/logo.png
Two levels is the limit. mbr climbs at most that far to find the anchor the overlay must live under, and it stops short at every step rather than climbing into your home directory or the filesystem root — so how far it actually gets depends on where the root sits. It also refuses any value that resolves to a directory containing the markdown root, however few levels up that is: serving one would expose every sibling of the root, and the markdown sources themselves as raw files. Concretely:
| Value | Markdown root | Result |
|---|---|---|
static, public/assets | anywhere | Allowed — inside the root |
../static | project/content | Allowed — a peer of the root |
../../static | project/src/routes | Allowed — under the anchor project |
../static | ~/notes | Refused — the next directory up is $HOME |
../../static | ~/project/content | Refused — the climb stops below $HOME |
.., ../.. | anywhere | Refused — contains the markdown root |
../../.., ../../../assets | anywhere | Refused — past the two-level limit |
/etc (from .mbr/config.toml) | anywhere | Refused — see below |
Reaching two levels up is wider than a peer: the anchor is the root’s
grandparent, so the value could have named anything under it — project/.git
and the credentials in its config, project/node_modules. mbr logs a WARN at
startup naming the directory it settled on whenever an overlay reaches that far,
so it is never quietly in effect. Serve untrusted repositories accordingly.
A symlink is judged by where it actually lands, not by how it is spelled: a
static symlink pointing at an allowed directory is accepted, one pointing past
the boundary is refused. Within the folder mbr settles on, request paths are
still contained — a file inside the static folder that symlinks to /etc/passwd
is not served.
Absolute paths: environment only
An absolute static_folder is accepted only from the MBR_STATIC_FOLDER
environment variable, never from a config file:
MBR_STATIC_FOLDER=/srv/shared-assets mbr -s ~/notes # allowed
# .mbr/config.toml — refused at startup
static_folder = "/srv/shared-assets"
The distinction is provenance, not the value: the environment variable is set by whoever runs the server, while the config file ships inside the repository. An absolute path from the environment is a deliberate operator choice and is used as given, with no ascent-boundary check.
When the static folder resolves outside the markdown root, mbr logs one line at
startup naming the resolved directory, so an external static root is never
silently in effect. A peer overlay logs at INFO (run with -v to see it); one
that reaches two levels up logs at WARN, which is visible by default.
Indexing an external static folder
An accepted external static folder is a full second scan root, not just a
serving fallback. Its assets are indexed exactly like assets inside the root, so
they appear in site.json and /.mbr/media.json and are available to the media
browser, the editor’s media picker, search, and the background pass that reads
durations, image dimensions and PDF/video cover images. In a static build they
are placed inside the output directory at their served paths — ../static/videos/demo.mp4
becomes <output>/videos/demo.mp4.
Markdown files inside an external static folder are skipped, with a warning
naming the file. A markdown file there has no representable URL: it sits outside
the markdown root, so its URL would have to contain .., which would escape the
site and, during a build, write the page outside the output directory. Put
markdown under the markdown root; the static folder is for assets. This applies
only to an external static folder — markdown in a static/ directory inside
the root is indexed normally.
No other directory gets this treatment. A directory symlink that points out of the markdown root is still skipped, whether or not an external static folder is configured: the overlay is one specific directory the policy above approved, not a general permission to index outside the root.
In guide.md:

Path Resolution Order
- Check if path matches a markdown file
- Check if path is a directory with index file
- Check if path matches file in static folder
- Return 404
Template Folder
The --template-folder flag overrides the default template resolution:
mbr -s --template-folder ./my-theme ~/notes
Files are loaded from this folder first, falling back to compiled-in defaults if not found.
Useful for:
- Theme development
- Testing template changes
- Sharing themes across repositories