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.mdorREADME.mdas a page - Multi-origin docs: Stitch a
README.mdfrom 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:
| Level | Mount Path | Effect |
|---|---|---|
| 0 (root) | / | Pages merge directly into the top-level URL space |
| 1 (section) | /docs | Pages appear under /docs/page-name |
| 2 (subsection) | /docs/api/reference | Pages 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:
| Field | Required | Default | Description |
|---|---|---|---|
source | yes | – | A directory path, a single .md/.markdown file path, a plugin:<name> identifier, or an http(s):// URL |
mount | yes | – | URL prefix where content appears (must start with /) |
priority | no | low | Collision resolution: low or high |
default_template | no | – | Fallback template for pages without template: in frontmatter |
fetch | no | – | Per-mount fetch options, valid only on remote (http(s)://) mounts (see below) |
The source scheme determines how the mount loads:
source: value | Scheme | Produces |
|---|---|---|
./path/to/dir | Directory | A tree of pages under the mount prefix |
./path/to/file.md | Single file | One page whose URL is the mount path |
https://host/path.md | Remote | One page (fetched over HTTP) whose URL is the mount path |
plugin:<name> | Plugin | Pages 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
- At startup, Accent calls the plugin’s
on_content_loadWASM function - The plugin fetches content from its external source (API, database, etc.)
- The plugin returns pages as JSON with relative URLs and markdown content
- Accent applies the mount prefix to each URL and merges pages into the content tree
- 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.
| Field | Default | Description |
|---|---|---|
refresh_seconds | 0 | Serve-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_seconds | 30 | Per-request timeout. |
max_size_mb | 10 | Maximum response body size; an oversized body is rejected while streaming. |
headers | – | Static request headers sent with every fetch. ${VAR} placeholders are expanded from the environment, so a token never appears literally in config.yaml. |
allow_html | false | Whether to keep inline raw HTML. By default remote HTML is escaped (see below). |
optional | false | Whether 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. Setallow_html: trueonly for an origin you fully control. - Relative links and images are rewritten against the source URL, so
./img/arch.pngin 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 atext/htmlresponse is rejected with a hint to use the raw file URL (the common “I pasted the rendered GitHub page” mistake).
Lifecycle per Command
accent servefetches 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 positiverefresh_secondsare retried – and kept fresh – on their interval, live-reloading the browser on change.accent buildsnapshots each remote mount once so the output is deterministic. A fetch failure fails the build unless that mount setsoptional: true, which downgrades to a warning and omits the page. Fetched bodies are cached under.remote-cache/(next toconfig.yaml, keyed by URL hash) so rebuilds revalidate cheaply with a conditional request.accent build --offlinebuilds 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 validateprobes 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-optionalfailure makesvalidateexit 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-startedshows breadcrumbs likeHome > Docs > Vendor > Getting Started. - Parent/child relationships are resolved by URL structure. The parent of
/docs/vendor/setupis/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/reloadre-scans content within existing mounts (new and changed pages appear), but adding, removing, or changing mount source paths inconfig.yamlrequires a server restart. This matches the behavior ofcontent.directoryitself.
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.