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. They are not served either, even when asked for
by name; see Hidden files.
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 |
flashcards_concentric_threshold | number | 0.7 | Share of the flashcard deck’s Concentric stack that must be known (rated Good/Easy last, this session) before a card is folded in; must be greater than 0 and at most 1. See Flashcard Settings. |
flashcards_progress_indicators | bool | true | Show review-progress borders and heading pies on flashcard notes. See Flashcard Settings. |
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) | The sidebar button triggers a 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 filter-options Show setting 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 is on by default in the GUI and off by default for the
server (mbr -s); static builds never edit. A value set here or in
MBR_EDIT_ENABLED beats either default (so edit_enabled = false keeps a
repository read-only in the GUI too), and --edit / --no-edit beat both. 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 | GUI: true, server: false | Enable the /.mbr/raw and /.mbr/edit endpoints and the edit button. --edit forces it on, --no-edit off. The GUI’s default is dropped (with a warning) when the host is not loopback and no edit_token_hash is set; an explicit true there fails startup instead. Without an edit_token_hash, pages reached under a non-loopback Host (e.g. tailscale serve, a reverse proxy) render read-only, since their writes would be refused. |
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(a mode default, or a non-loopback--hostwithout--edit, turns editing off instead). - 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 | family + work defaults | Relation types with symmetric / inverse semantics, hierarchy, category 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”)hierarchy:"up"or"down", read from the declaring note’s side.upmeans the note you point to ranks above you (your parent, manager, employer);downis the reverse. Omit for non-hierarchical types. The charts use it to draw edges the right way up instead of guessing from names.category: A free-form group name. The charts look forfamily(family chart, timeline tree) andwork(org chart).
The two halves of an inverse pair must point opposite ways and share a category, so
you only need to set hierarchy/category on one half: the other inherits the
flipped direction and the same category. If both halves claim the same direction,
mbr keeps the one declared first, flips the other and logs a warning — startup never
fails over it. A hierarchy on a symmetric type is ignored with a warning.
The default relationship_types cover family and work:
# .mbr/config.toml (these are the built-in defaults)
relationship_types = [
{ name = "parent", inverse = "child", hierarchy = "up", category = "family", label = "Parent", label_plural = "Parents" },
{ name = "child", inverse = "parent", hierarchy = "down", category = "family", label = "Child", label_plural = "Children" },
{ name = "spouse", symmetric = true, category = "family", label = "Spouse", label_plural = "Spouses" },
{ name = "sibling", symmetric = true, category = "family", label = "Sibling", label_plural = "Siblings" },
{ name = "reports_to", inverse = "manages", hierarchy = "up", category = "work", label = "Reports to", label_plural = "Reports to" },
{ name = "manages", inverse = "reports_to", hierarchy = "down", category = "work", label = "Manages", label_plural = "Manages" },
{ name = "assistant", inverse = "assists", hierarchy = "down", category = "work", label = "Assistant", label_plural = "Assistants" },
{ name = "assists", inverse = "assistant", hierarchy = "up", category = "work", label = "Assists", label_plural = "Assists" },
{ name = "employer", inverse = "employee", hierarchy = "up", category = "work", label = "Employer", label_plural = "Employers" },
{ name = "employee", inverse = "employer", hierarchy = "down", category = "work", label = "Employee", label_plural = "Employees" },
{ name = "colleague", symmetric = true, category = "work", label = "Colleague", label_plural = "Colleagues" },
]
relationship_tracking = true
Setting relationship_types replaces this list wholesale, so a repository that
adds its own types must copy the defaults it still wants (the employer type in
particular powers the implicit edge from a person’s company: "[[…]]" — see
Contacts).
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 and the contact card’s Relationships section (server, GUI, static builds and QuickLook alike).
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.
Flashcard Settings
Two options tune flashcard notes (type: flashcard). Both apply in every mode, static builds included: Concentric runs
session-only where nothing can be written, and the progress indicators read the
review history already in the page.
| Option | Type | Default | Description |
|---|---|---|---|
flashcards_concentric_threshold | number | 0.7 | Growth bar for Concentric (FSRS) mode |
flashcards_progress_indicators | bool | true | Reading-view progress indicators |
Concentric threshold (flashcards_concentric_threshold): after every
rating, Concentric folds one card into the stack, and checks again, while at
least this share of the stack is known — rated Good or Easy last in this
session (a card not yet rated this session is not known). The bar is applied to
whole cards, so it moves in steps: with six cards, 0.7 needs five of six.
1.0 grows the stack only when every card in it is known, and then by one; a
low value grows it after almost every rating. Values outside 0 < t ≤ 1 (including 0) stop
mbr at startup with a configuration error. Exposed to the frontend as
window.__MBR_CONFIG__.flashcardsConcentricThreshold; environment variable
MBR_FLASHCARDS_CONCENTRIC_THRESHOLD.
Progress indicators (flashcards_progress_indicators): on a flashcard note
with any review history, each reviewed question gets a left border in its
latest rating’s colour and each heading with cards under it gets a small pie of
its cards’ latest ratings. Set to false to draw neither; the collapsed
Reviewed 4× · last: Easy, Oct 9 history lines are unaffected. Exposed as
window.__MBR_CONFIG__.flashcardsProgressIndicators; environment variable
MBR_FLASHCARDS_PROGRESS_INDICATORS.
The four rating colours are CSS custom properties in theme.css, shared by the
deck’s rating buttons and these indicators, so .mbr/user.css can recolour
both at once:
:root {
--mbr-fc-again: #c62828;
--mbr-fc-hard: #b7791f;
--mbr-fc-good: var(--pico-ins-color);
--mbr-fc-easy: var(--pico-primary);
--mbr-fc-unreviewed: #9ca3af; /* the pies' grey slice */
}
# .mbr/config.toml
flashcards_concentric_threshold = 0.8
flashcards_progress_indicators = true
There are no CLI flags for either.
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. |
One exception survives --no-review. With editing
on, definition-list terms (<dt>) keep their data-mbr-line, because that line
is how a flashcard review addresses its card —
without it the spaced-repetition mode would quietly disappear. No other element
is numbered, so the flag keeps its meaning everywhere else; with both review
and editing off, nothing is.
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 |
<dt> / <dd> | a definition list term / definition (also how a flashcard review addresses its card) |
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.
<dt> and <dd> carry it themselves because a tight definition list’s
prose is not wrapped in a <p>, so without them those lines would have no
ancestor carrying data-mbr-line.
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. |
contact_data_problem | On a type: person / type: organization note, an entry in a contact field (aliases, phones, dates, …) had a shape mbr cannot read (e.g. a map with two keys where one label: value pair belongs). The entry was skipped; the rest of the card renders. Carries field and message. See Contacts. |
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
# Flashcards
MBR_FLASHCARDS_CONCENTRIC_THRESHOLD=0.8
MBR_FLASHCARDS_PROGRESS_INDICATORS=false
# 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 # or false to keep the GUI read-only
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 |
../.git, ../../.cache/x | anywhere | Refused — a hidden directory |
../../.., ../../../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/node_modules, say. A hidden directory there (project/.git and the
credentials in its config) is refused, the same as every hidden path (see
Hidden files). 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.
The external-overlay rules above — never the filesystem root, never $HOME or a
directory containing it, never a directory containing the markdown root, never
a hidden directory — are one policy, shared with
symlinked folders, so the two cannot
drift apart. An in-root static folder is the configuration’s own choice and may
itself be hidden (VuePress keeps assets in .vuepress/public); the files served
out of it still answer to the hidden-file rule.
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.
A directory symlink that points out of the markdown root (or out of an external static folder) is not part of this: it is a read-only mount, judged link by link.
Read-only symlink mounts
A symlink inside the markdown root or the static folder whose target lies outside it is served as a read-only mount: a folder of its own, reached at the link’s location. The typical case keeps large media where it already lives:
site/
├── content/ # markdown root; static_folder = "../static"
│ └── index.md # 
└── static/
├── images/logo.png
└── videos -> ~/Movies # a mount: ~/Movies/clip.mp4 is /videos/clip.mp4
Everything under the target behaves as if it were a folder at the link’s place:
- Served and indexed. Files are served at the link’s URLs, appear in
site.jsonand/.mbr/media.json, get media metadata, and are searchable. Markdown in a mount of the markdown root is a page like any other (markdown in a mount of an external static folder is skipped, for the reason given above). The static build places a mount’s files exactly like other assets — symlinked on macOS/Linux, copied on Windows — so links to them pass--fail-on-broken-links. The server and the build mount the same folders and refuse the same targets. - Read-only. Nothing mbr writes ever lands in a mount: saving, task toggles,
flashcard reviews, creating notes or folders, uploads, and moves into or out of
a mount all answer
403. When a move rewrites links across the repository, notes inside mounts are skipped (they keep their old link) rather than edited. - No way out. Inside a mount,
..and further symlinks cannot leave it. A nested link that points somewhere else is judged as a mount of its own, by the same rules; one that points back into the repository is just a path inside it (from the static folder, a link into the markdown root is refused instead: the static folder serves files raw, and a note’s raw source is not published); a link cycle (a mount containing a link to itself or an ancestor) is walked once. - Each target once. Two links to the same folder share one mount: the scanner walks it once, under the first link it reached. Both links still serve the files.
- Hidden files stay hidden inside mounts too (see Hidden files).
A link is refused — logged at WARN with the reason, then treated as if it
were not there (404 on the server, not placed by the build), never fatal — when
its target is:
| Target | Why |
|---|---|
/ (or a drive root) | exposes the whole machine |
$HOME (%USERPROFILE%), or a directory containing it (/Users) | exposes everything the user owns |
| a directory containing the markdown root or the static folder | exposes the root’s siblings and its sources |
anything with a hidden component — ~/.ssh, ~/.config/app | hidden files are never served |
| unreadable — permission denied, or missing (a dangling link) | nothing to serve; on iOS, a folder the app was not granted |
The hidden-component rule only counts components below the directory the target
shares with the root, so a repository that itself lives inside a hidden
directory (~/.local/share/notes) can still mount a sibling there.
Accepted mounts are logged at INFO (run with -v) as
Serving external folder <target> via <link> (read-only), once per link, as the
first scan reaches them. A link to a single file outside the root
(static/demo.mp4 -> ~/Movies/demo.mp4) is accepted under the same rules, as a
read-only mount of that one file.
Live reload. With the file watcher (the desktop app and mbr -s), each
directory mount’s target is watched as well, and a change inside it is reported
at the link’s location, so a new video in ~/Movies appears without a restart.
Up to 32 mounts are watched; further ones are served without live reload. On
macOS the watch is an FSEvents stream (cheap at any size); on Linux inotify
needs one watch per directory, counted against fs.inotify.max_user_watches, so
a mount of a very large tree can exhaust it — mbr then logs a warning and serves
the mount without live reload. Links created while the server runs are found by
the next full rescan.
Untrusted repositories. A repository can contain symlinks, and a git clone
recreates them. A mount of anything the refusal rules allow — /etc, another
project — is served to whoever can reach the server, and published by a build.
Before serving or building a repository you did not create, either run with
--no-symlink-mounts or review its symlinks first (find . -type l).
Turning mounts off. Mounts are on by default. The operator can switch them
off with the --no-symlink-mounts flag or the environment variable:
mbr -s --no-symlink-mounts ~/untrusted-repo
MBR_NO_SYMLINK_MOUNTS=1 mbr -b ~/untrusted-repo
Off, a link out of the repository or the static folder is refused exactly as it
was before mounts existed (404, not built), and one INFO line at startup
says so (Symlink mounts are disabled …). Hidden-file blocking is unaffected.
Any value of MBR_NO_SYMLINK_MOUNTS other than 0, false, no, off or
empty turns mounts off. The switch covers the server, the GUI (including folders
opened later from its folder picker) and the static build. QuickLook previews
and the mobile apps never use mounts, so it does not apply to them.
This is operator-only, unlike most MBR_* settings: there is no
config.toml key. A no_symlink_mounts or symlink_mounts line in a
repository’s .mbr/config.toml is ignored, so a repository can neither switch
mounts back on for itself nor turn them off.
Hidden files
A file or folder whose name starts with a dot is never served and never
published — from the markdown root, the static folder, or a mount. GET /.env,
/.git/config and /docs/.private/x/ are all 404, a link that is not itself
hidden cannot reach a hidden target (notes -> .git), and the static build
places no hidden files. Hidden directories are where repositories keep
credentials and tooling state, and the scanner never indexed them; the server
used to serve them anyway when asked by name.
Exceptions:
/.mbr/*is not a file path but mbr’s own routes: compiled-in assets plus the repository’s overrides (.mbr/user.css, templates, components). Only asset types are served from there;.mbr/config.toml(which can hold the edit-token hash) is 404..well-known(RFC 8615 site metadata:security.txt, ACME challenges, app associations) is served and published.- A hidden directory named on the command line (
mbr -s .scratch) is served, along with everything under it except further hidden names. See Ignore Settings for why the scan admits it.
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