Configuration Reference

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

OptionTypeDefaultDescription
hoststring"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.
portnumber5200Port number

Content Settings

OptionTypeDefaultDescription
markdown_extensionsarray["md"]File extensions treated as markdown
index_filestring"index.md"Default file for directories
static_folderstring"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

OptionTypeDefaultDescription
ignore_dirsarray(see below)Directories to skip
ignore_globsarray(see below)File patterns to ignore
watcher_ignore_dirsarray(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

OptionTypeDefaultDescription
oembed_timeout_msnumber500 (server/GUI), 0 (build)URL metadata fetch timeout (0 to disable)
oembed_cache_sizenumber2097152Oembed cache size in bytes (0 to disable)
media_cache_sizenumber67108864Media metadata cache size in bytes — video/PDF covers, chapters, captions (0 to disable)
skip_link_checksboolfalseSkip internal link validation during builds
link_trackingbooltrueEnable bidirectional link tracking (backlinks)
relationship_trackingbooltrueEnable typed relationship tracking (named frontmatter relationships)
tasks_enabledbooltrueEnable the task browser (server/GUI only). See Task Settings.
review_enabledbooltrueEmit data-mbr-line source lines on block elements (server/GUI only). See Review Settings.
mark_incompletebool / unsetmode default (server/GUI on, build off)Highlight TK/TODO/FIXME/XXX anywhere in a line
incomplete_markersarray["TK", "TODO", "FIXME", "XXX"]Marker strings that flag work as incomplete
OptionTypeDefaultDescription
sidebar_stylestring"panel"Sidebar navigation style: "panel" (modal 3-pane) or "single" (persistent sidebar)
sidebar_max_itemsnumber100Maximum items per section in sidebar navigation
graph_depthnumber2Default depth (15) of the link graph shown in the info panel; startup fails on out-of-range values
gui_menu_barstring"auto"GUI window’s native menu bar: "auto", "always", or "never". Linux only — see below
title_prefixstring""Text to prepend to all page titles
title_suffixstring""Text to append to all page titles

Native menu bar (gui_menu_bar):

GUI mode only, and only Linux reads it.

ValueEffect
"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:

Responsive behavior (single sidebar):

ViewportBehavior
>= 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

OptionTypeDefaultDescription
title_prefixstring""Text prepended to all page <title> tags
title_suffixstring""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.

OptionTypeDefaultDescription
tasks_enabledbooltrueEnable POST /.mbr/tasks and the task panel. Disable with --no-tasks or MBR_TASKS_ENABLED=false; the endpoint then returns 404.
tasks_stamp_donebooltrueMaintain 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_includestring"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_globsarray[]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):

PatternMatchesDoes not match
templates/**templates/a.md, templates/deep/b.mddocs/templates/a.md
**/templates/**templates/a.md, docs/templates/a.md, a/b/templates/c.mdtemplates.md
docs/templates/**docs/templates/a.mdtemplates/a.md
**/*.checklist.mdtemplates/onboarding.checklist.mdtemplates/onboarding.md
templates(nothing)every path — see below

Two things are worth pinning down:

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.

OptionTypeDefaultDescription
edit_enabledboolfalseEnable the /.mbr/raw and /.mbr/edit endpoints and the edit button. Also enabled by --edit.
edit_token_hashstring / 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_loopbackboolfalseRequire the token even for loopback callers. When false, local (127.0.0.1) edits need no token but are still CSRF-protected.
upload_max_bytesnumber26214400 (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:

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

OptionTypeDefaultDescription
tag_sourcesarray[{ field = "tags" }]Frontmatter fields to extract tags from
build_tag_pagesbooltrueGenerate tag pages in static builds

Tag source configuration:

Each tag source can specify:

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

OptionTypeDefaultDescription
relationship_trackingbooltrueEnable typed relationship tracking
relationship_typesarraygenealogy defaultsRelation types with symmetric / inverse semantics and labels

Relation type configuration:

Each relation type can specify:

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_tracking rides on the same links.json fetch as backlinks (no extra request). Disabling it omits relationship data from links.json and site.json and hides the info-panel section.

Note: Typed relationships are visualized by the d3-based genealogy charts on type: person pages and as edges in the info panel’s mini link graph — not by mermaid. See Relationships & Genealogy for both.

Note: Setting oembed_timeout_ms to 0 disables 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_size to 0 to disable caching. It does not size the media metadata cache — see media_cache_size below.

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:

SettingEffect
Default (auto)Uses 2x CPU cores, capped at 32
--build-concurrency 1Sequential processing (useful for debugging)
--build-concurrency 16Explicit concurrency limit

Memory usage scales with concurrency. Use lower values if running out of memory on very large repositories.

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

mbr automatically tracks bidirectional links between pages. The info panel (Ctrl+g) shows both:

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:

ModeMethodPerformance
Server/GUIOn-demand grep search (cached)First request: ~1-3s, subsequent: instant
BuildEager index during renderComputed 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.

OptionTypeDefaultDescription
review_enabledbooltrueEmit 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:

ElementMarkdown
<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:

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:

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.

ModeDefaultReason
Server / GUIonHelps writers spot drafts at a glance
Static buildoffAvoids 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:

TypeTrigger
broken_internal_linkAn 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_wikilinkA 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_errorThe YAML frontmatter failed to parse, so the whole block — every otherwise-valid field included — was discarded.
unplayable_mediaA 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_cycleTwo 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_endpointA 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_wikilinkA 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:

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-metadata Cargo feature (on by default) and, like the rest of errors.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-metadata Cargo 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:

TypeURLContent type
Playlist/videos/demo.mp4-remux.m3u8application/vnd.apple.mpegurl
Init segment/videos/demo.mp4-remux-init.mp4video/mp4
Media segment/videos/demo.mp4-remux-000.m4svideo/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-metadata Cargo 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:

PatternDescription
{video}.cover.jpgCover image (frame captured at 5 seconds, or earlier for short videos)
{video}.chapters.en.vttChapter markers in WebVTT format
{video}.captions.en.vttSubtitles/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:

OptionTypeDefaultDescription
media_cache_sizenumber67108864 (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-metadata Cargo 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:

  1. When transcoding is enabled, video embeds include multiple <source> tags with media queries
  2. Desktop browsers (viewport >= 1280px) load the original MP4 directly
  3. Tablets (viewport >= 640px) use HLS 720p playlist (Safari only)
  4. Mobile devices use HLS 480p playlist (Safari only)
  5. Non-Safari browsers fall back to the original MP4 (HLS requires JavaScript in Chrome/Firefox)
  6. HLS segments (~10 seconds each) are transcoded on-demand and cached

URL patterns:

TypeExample
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:

BrowserHLS SupportBehavior
Safari (macOS/iOS)NativeUses HLS variants on mobile/tablet
Chrome/Firefox/EdgeNoneFalls back to original MP4

Hardware acceleration: Transcoding automatically uses hardware encoders when available:

Configuration:

OptionTypeDefaultDescription
transcodeboolfalseEnable dynamic video transcoding via HLS

Memory usage: HLS segments and playlists are cached in memory (~200MB max by default). Each segment is approximately:

Cache evicts oldest segments when full, prioritizing keeping playlists cached.

When to use:

When NOT to use:

PDF Cover Extraction

Note: This feature requires the media-metadata Cargo 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:

PatternDescription
{pdf}.cover.jpgCover 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:

CodeMeaning
0Success (all covers created)
1Partial failure (some PDFs failed, others succeeded)
2Total failure (no covers created)

Error handling:

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):

MarkerDescription
.mbr/mbr configuration folder (highest priority)
.git/Git repository
.zk/Zettlekasten notes
.obsidian/Obsidian vault

File markers (searched if no directory markers found):

MarkerDescription
book.tomlmdBook project
mkdocs.ymlMkDocs project
docusaurus.config.jsDocusaurus project

The search works as follows:

  1. Start from the specified path
  2. Search upward for directory markers in priority order
  3. If no directory markers found, search for file markers
  4. First marker found determines the root directory
  5. 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:

ValueMarkdown rootResult
static, public/assetsanywhereAllowed — inside the root
../staticproject/contentAllowed — a peer of the root
../../staticproject/src/routesAllowed — under the anchor project
../static~/notesRefused — the next directory up is $HOME
../../static~/project/contentRefused — the climb stops below $HOME
.., ../..anywhereRefused — contains the markdown root
../../.., ../../../assetsanywhereRefused — past the two-level limit
/etc (from .mbr/config.toml)anywhereRefused — 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:

![Logo](/images/logo.png)

Path Resolution Order

  1. Check if path matches a markdown file
  2. Check if path is a directory with index file
  3. Check if path matches file in static folder
  4. 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: