Content Mounts

Content mounts let you combine multiple sources into a single site. Each mount attaches a directory, a single markdown file, a remote URL, or a plugin at a specific URL prefix in the content tree – no symlinks, no copy scripts, no build-time workarounds.

When to Use Mounts

  • Shared documentation: Mount a company-wide docs library under /docs/shared/
  • Vendored content: Mount third-party documentation at /docs/vendor/
  • Loose repository files: Surface a single CHANGELOG.md or README.md as a page
  • Multi-origin docs: Stitch a README.md from each of several repositories (local or remote) into one site
  • Split editorial and technical content: Keep blog posts in one directory and API docs in another
  • Multi-team sites: Each team maintains their own content directory, merged into one site

Configuration

Add content.mounts to your config.yaml:

# config.yaml
content:
  directory: ./content                  # primary content (always present)

  mounts:
    # Merge shared content into root namespace
    - source: ./shared-content
      mount: /

    # Vendor docs appear under /docs/vendor/
    - source: /opt/docs/vendor
      mount: /docs/vendor

    # API reference with high priority (overrides primary on collision)
    - source: ./api-docs
      mount: /docs/api/reference
      priority: high
      default_template: api-page.html.jinja

The primary content.directory always loads first. Mounts are processed in declaration order after it.

Mount Levels

Mounts can attach at any level of the content tree:

LevelMount PathEffect
0 (root)/Pages merge directly into the top-level URL space
1 (section)/docsPages appear under /docs/page-name
2 (subsection)/docs/api/referencePages nested two levels deep

Root Mounts

A mount at / merges its pages into the same URL space as your primary content directory. A page at getting-started/default.md in the mount source becomes /getting-started – the same as if it were in your primary content directory.

mounts:
  - source: ./shared-content
    mount: /

Section and Subsection Mounts

Mounts at deeper levels prefix all page URLs with the mount path. A page at widgets/default.md in a mount at /products becomes /products/widgets.

mounts:
  - source: ./product-pages
    mount: /products

Mount Options

Each mount supports these fields:

FieldRequiredDefaultDescription
sourceyesA directory path, a single .md/.markdown file path, a plugin:<name> identifier, or an http(s):// URL
mountyesURL prefix where content appears (must start with /)
prioritynolowCollision resolution: low or high
default_templatenoFallback template for pages without template: in frontmatter
fetchnoPer-mount fetch options, valid only on remote (http(s)://) mounts (see below)

The source scheme determines how the mount loads:

source: valueSchemeProduces
./path/to/dirDirectoryA tree of pages under the mount prefix
./path/to/file.mdSingle fileOne page whose URL is the mount path
https://host/path.mdRemoteOne page (fetched over HTTP) whose URL is the mount path
plugin:<name>PluginPages returned by the plugin’s on_content_load hook

Plugin Mounts

Mounts can also source content from WASM plugins using the plugin:<name> syntax. The plugin’s on_content_load hook is called at startup, and the returned pages are merged into the content tree under the mount prefix.

content:
  mounts:
    - source: plugin:accent-notion
      mount: /kb
      default_template: kb-article.html.jinja

The plugin must declare on_content_load = true in its plugin.toml:

[hooks]
on_content_load = true

[config]
api_url = "https://api.notion.com/v1"
api_key = ""
refresh_interval_seconds = 120

[network]
allowed_hosts = ["api.notion.com"]

How Plugin Mounts Work

  1. At startup, Accent calls the plugin’s on_content_load WASM function
  2. The plugin fetches content from its external source (API, database, etc.)
  3. The plugin returns pages as JSON with relative URLs and markdown content
  4. Accent applies the mount prefix to each URL and merges pages into the content tree
  5. Plugin pages participate in taxonomy, collections, navigation, and static builds

Plugin JSON Protocol

The plugin receives:

{
  "config": { "api_url": "...", "api_key": "..." },
  "mount": "/kb",
  "host": {
    "production": false,
    "site_name": "My Site",
    "site_url": "https://example.com",
    "language": "en"
  }
}

And returns:

{
  "pages": [
    {
      "url": "/getting-started",
      "markdown": "# Getting Started\n\nWelcome...",
      "frontmatter": {
        "title": "Getting Started",
        "tags": ["docs"],
        "template": "kb-article.html.jinja"
      }
    }
  ],
  "error": null
}

Plugins return relative URLs (like /getting-started). Accent prepends the mount path, so the page appears at /kb/getting-started. This makes plugins mount-agnostic – the same plugin can be mounted at different paths in different sites.

Plugin Failures

Plugin failures during on_content_load are non-fatal. If a plugin cannot reach its API or returns an error, the server still starts with all other content sources. A warning is logged for each failure.

Hot Reload (dev mode)

By default a plugin mount loads its pages once at startup. When an external source changes – a Notion page is edited, a database row is updated – those pages would otherwise go stale until you restart the server.

To keep them fresh while you work, set refresh_interval_seconds in the plugin’s [config]:

[config]
refresh_interval_seconds = 60

In dev mode (accent serve with dev.hot_reload: true, i.e. not --production), Accent re-invokes the plugin’s on_content_load hook every refresh_interval_seconds. When the returned page set changes – a page added, removed, or edited (body, title, tags, or other listing fields) – Accent refreshes the content index and, if dev.browser_reload is on, reloads your browser automatically. An unchanged source triggers no reload, so a tight interval is cheap. A value of 0 (or an absent field) means startup-only loading. Each plugin mount polls independently on its own cadence; refreshing one mount leaves the others untouched.

In production (accent serve --production) there is no polling: on_content_load runs once at startup. Force a re-run of every content hook with SIGHUP (pkill -HUP accent, Unix) or POST /_admin/reload.

Single-File Mounts

Point a mount at a single local .md (or .markdown) file to surface it as one page. The mount path is the page’s URL – no default.md directory wrapper, no children. This is ideal for stitching a repository’s loose files (a CHANGELOG.md, a top-level README.md) into a themed site.

content:
  directory: ./content
  mounts:
    - source: ./CHANGELOG.md
      mount: /changelog
    - source: ../api/README.md
      mount: /reference/api
      default_template: reference

The page’s title resolves the usual way: frontmatter title, then the first # H1, then the file’s name. The file is read fresh on every load, so in dev mode editing it triggers the standard hot-reload refresh. The source path must exist and be a file, or the configuration is rejected at startup.

Remote Mounts

Point a mount at an http(s):// URL to fetch a remote markdown document and serve it as one page at the mount path. Documentation for a product is often spread across origins – a README.md in each of several repositories, a runbook on an internal server – and remote mounts stitch them into one site with one theme, one navigation, one search index, and one sitemap.

content:
  directory: ./content
  mounts:
    - source: https://raw.githubusercontent.com/acme/widget/main/README.md
      mount: /projects/widget
      default_template: remote-doc

    - source: https://intranet.example.com/ops/runbook.md
      mount: /ops/runbook
      fetch:
        refresh_seconds: 300
        timeout_seconds: 30
        max_size_mb: 10
        headers:
          Authorization: "Bearer ${INTRANET_TOKEN}"
        allow_html: false
        optional: false

The fetch: Block

A fetch: block tunes how a remote mount is retrieved. It is only valid on a remote mount – setting it on a directory, single-file, or plugin mount is a configuration error.

FieldDefaultDescription
refresh_seconds0Serve-mode poll interval. 0 fetches once at startup; a positive value re-fetches on that cadence and live-reloads the page when it changes.
timeout_seconds30Per-request timeout.
max_size_mb10Maximum response body size; an oversized body is rejected while streaming.
headersStatic request headers sent with every fetch. ${VAR} placeholders are expanded from the environment, so a token never appears literally in config.yaml.
allow_htmlfalseWhether to keep inline raw HTML. By default remote HTML is escaped (see below).
optionalfalseWhether a fetch failure is tolerated during accent build.

Untrusted-Content Safety

Remote markdown is third-party input rendered into your branded site, so it is treated as untrusted by default:

  • Inline raw HTML is escaped. A <script> (or any tag) in the fetched document renders as visible text, not live markup – closing the obvious stored-XSS vector. Set allow_html: true only for an origin you fully control.
  • Relative links and images are rewritten against the source URL, so ./img/arch.png in a fetched README loads cross-origin from the origin. Accent never proxies those subresources.
  • Transport is hardened: TLS verification is always on, redirects are capped and must stay on http/https, and a text/html response is rejected with a hint to use the raw file URL (the common “I pasted the rendered GitHub page” mistake).

Lifecycle per Command

  • accent serve fetches every remote mount concurrently at startup. A failed mount logs a warning and is skipped (its URL 404s) rather than blocking the site; mounts with a positive refresh_seconds are retried – and kept fresh – on their interval, live-reloading the browser on change.
  • accent build snapshots each remote mount once so the output is deterministic. A fetch failure fails the build unless that mount sets optional: true, which downgrades to a warning and omits the page. Fetched bodies are cached under .remote-cache/ (next to config.yaml, keyed by URL hash) so rebuilds revalidate cheaply with a conditional request.
  • accent build --offline builds entirely from .remote-cache/ with no network request – for air-gapped CI. A remote mount with no cached entry (a cold cache) fails the build; warm the cache with an ordinary online build first.
  • accent validate probes each remote mount and reports unreachable origins, size violations, and content-type rejections as findings, so CI catches a dead origin before a deploy does. A non-optional failure makes validate exit non-zero.

Add .remote-cache/ to your .gitignore – it is a build cache, not source.

Collision Resolution

When a mounted page has the same URL as a page in your primary content directory (or another mount), Accent resolves the conflict based on priority:

  • priority: low (default) – The primary content page wins. The mount page is skipped and a warning is logged.
  • priority: high – The mount page wins and replaces the primary content page. A warning is still logged.
mounts:
  # These API docs override anything at /docs/api/ in primary content
  - source: ./generated-api-docs
    mount: /docs/api
    priority: high

Collisions always produce a log warning so you can diagnose unexpected shadowing:

WARN Mount '/docs/vendor' page at '/docs/vendor/setup' collides with existing page from primary content, keeping existing (mount priority: low)

Default Templates

Mounts can specify a default_template that applies to pages without an explicit template: field in their frontmatter. This is useful when mounting content that wasn’t authored for your theme:

mounts:
  - source: ./api-docs
    mount: /docs/api
    default_template: api-page.html.jinja

Pages that explicitly set template: in their frontmatter are unaffected – default_template only fills in the gap when no template is specified.

Hierarchy and Navigation

Mounted pages integrate naturally into Accent’s page hierarchy:

  • Breadcrumbs work across mount boundaries. A page at /docs/vendor/getting-started shows breadcrumbs like Home > Docs > Vendor > Getting Started.
  • Parent/child relationships are resolved by URL structure. The parent of /docs/vendor/setup is /docs/vendor (if an index page exists there), or /docs (the next ancestor with a page).
  • Sibling navigation (previous/next) works within the mount’s URL subtree.

For breadcrumbs to show the mount root as a level, include an index page (e.g., default.md) in the mount source directory’s root.

Hot Reload

In development mode, Accent watches all mounted directories for changes. When you edit a file in a mount source directory, the page cache is invalidated and your browser refreshes automatically (if browser reload is enabled).

No server restart is needed – save a file in any mounted directory and refresh.

Static Build

accent build includes all mounted content in the static output. Mounted pages appear in:

  • The generated HTML files under their mount prefix
  • The sitemap (/sitemap.xml)
  • RSS feeds (/feed.xml) if they match the feed criteria
  • Taxonomy pages (/tags/) based on their frontmatter tags

Reserved Routes

Mounts cannot claim Accent’s built-in route prefixes. The following paths are protected:

/healthz, /readyz, /health, /_dev, /_admin, /_search, /feed.xml, /sitemap.xml, /robots.txt, /.well-known, /api, /media, /content-media, /theme, /tags

Attempting to mount at any of these paths produces a configuration error at startup.

Root mounts (mount: /) are allowed – they merge into the root namespace without claiming a reserved prefix.

Example: Documentation Site with Shared Library

# config.yaml
content:
  directory: ./content

  mounts:
    # Company-wide shared docs
    - source: ../shared-docs-library/content
      mount: /docs/shared

    # Auto-generated API reference
    - source: ./target/api-docs
      mount: /docs/api/reference
      priority: high
      default_template: api-reference.html.jinja

Directory structure:

my-site/
  config.yaml
  content/
    01.home/default.md          -> /home
    02.docs/default.md          -> /docs
    02.docs/01.guides/default.md -> /docs/guides
  target/api-docs/
    users/default.md            -> /docs/api/reference/users
    products/default.md         -> /docs/api/reference/products

../shared-docs-library/content/
  style-guide/default.md        -> /docs/shared/style-guide
  brand-guidelines/default.md   -> /docs/shared/brand-guidelines

Limitations

  • Config reload does not pick up mount changes. Sending SIGHUP or calling POST /_admin/reload re-scans content within existing mounts (new and changed pages appear), but adding, removing, or changing mount source paths in config.yaml requires a server restart. This matches the behavior of content.directory itself.

Backward Compatibility

Sites without content.mounts work identically to before. The content.directory field is unchanged and continues to be the only required content configuration. Mounts are fully opt-in.