Content sources
Pull docs from local files, a remote repository, a CMS, or any custom backend — and mix several sources into one static-first site read at build time.
By default Blume reads a folder of .md/.mdx files. Content sources let you pull pages from somewhere else — a remote repository, a CMS, or any custom backend — and mix several sources into a single site. Sources are read at build time; Blume stays static-first.
The default
With no configuration, Blume scans your content root (docs by default) as one filesystem source. The top-level content.root, content.include, and content.exclude options are shorthand for that single source — nothing to import, nothing to change.
import { defineConfig } from "blume";
export default defineConfig({
content: { root: "docs" },
});
Adapters
Each entry in content.sources is an adapter: a factory imported from blume/sources that returns a plain descriptor Blume reads at build time. Add a sources array to compose sources. When sources is present it replaces the implicit default, so include a filesystem() entry for your local docs — and move root, include, or exclude into it. The shorthand and sources can’t be combined; Blume reports which field to move.
import { defineConfig } from "blume";
import { filesystem, mdxRemote } from "blume/sources";
export default defineConfig({
content: {
sources: [
// Local docs at the site root
filesystem({ root: "docs" }),
// Remote MDX from a GitHub repo, mounted under /sdk
mdxRemote({
prefix: "sdk",
github: { owner: "acme", repo: "sdk", ref: "main", path: "docs" },
}),
],
},
});
Beyond filesystem(), each built-in adapter has its own page, and custom() plugs in any other backend:
| Adapter | Reads | Runtime dependency | Environment variable |
|---|---|---|---|
obsidian() |
An Obsidian vault, in place | — | — |
mdxRemote() |
.md/.mdx from a GitHub repo or a raw URL |
— | GITHUB_TOKEN (private repos) |
githubReleases() |
A repo’s releases, as changelog entries | — | GITHUB_TOKEN (private repos) |
sanity() |
A GROQ query over a Sanity dataset | @sanity/client |
SANITY_TOKEN (private datasets) |
notion() |
A Notion database | @notionhq/client |
NOTION_TOKEN |
contentful() |
One Contentful content type | — | CONTENTFUL_ACCESS_TOKEN |
payload() |
A Payload collection | — | PAYLOAD_API_KEY |
strapi() |
A Strapi content type | — | STRAPI_API_TOKEN |
custom() |
Any ContentSource you implement |
— | — |
Every adapter that takes an options object accepts two shared options:
prefixnamespaces the source’s routes under/<prefix>/…, which also becomes the source’s name in diagnostics and its cache directory. If two sources resolve to the same route, Blume reports aBLUME_DUPLICATE_ROUTEbuild error — give each source a distinctprefix.pollInterval(seconds) makes a remote source re-fetch on that interval in dev, reloading only when the content actually changes. Leave it unset to fetch once and freeze for the session. Local sources (filesystem(),obsidian()) watch the filesystem instead and ignore it.
An adapter’s descriptor also declares the SDK it needs and the environment variables it reads, so the generated project declares that package, blume dev, blume build, and blume doctor warn when a variable is unset, and blume doctor lists the configured sources. The warning prints before any source fetches, so it isn’t lost when the fetch then fails. notion() and contentful() can’t read anything without their token, so with it unset they stop before their first request with a BLUME_MISSING_SECRET error, and a source whose SDK isn’t installed stops the same way with BLUME_SOURCE_SDK_MISSING; either serves its cached snapshot instead when it has one. Options are validated when the config loads: a missing required option, an unknown key, or a leftover 1.x { type: "…" } object fails with a message naming the fix.
A single filesystem() source roots the generated docs collection at its own directory. Several filesystem sources must share one root and partition it with include globs — a second source rooted elsewhere is reported as BLUME_ENTRY_ID_MISMATCH so its pages can’t silently 404.
Caching and offline builds
Each remote source keeps a snapshot under .blume/cache/<source>/. If a fetch fails — a network blip or a CMS outage — Blume serves the last-known-good snapshot with a warning rather than failing the build. Preview and published content get separate snapshots, and so does each set of source options, so a build never falls back to drafts fetched under --preview, and editing a source’s query or fields fetches afresh. A custom() source is the exception: its snapshot is kept per preview mode only, so after editing its options run blume sync. The cache lives inside .blume/ and is regenerated, never committed.
In dev, a remote source is served from its snapshot when it has one, so restarting the dev server doesn’t refetch it. Run blume sync to pull the latest content (a running dev server hot-reloads), or blume sync --force to drop the snapshots first. Local filesystem sources hot-reload as usual. To poll a remote source for changes instead, set the shared pollInterval option (seconds) on it — the dev server re-fetches on that interval and reloads only when the content actually changes. Leave it unset to avoid hitting the API while you work.
Preview and sync
Two flags control how remote content is fetched and what’s included:
--previewonblume devorblume buildrenders drafts and pulls unpublished CMS content — Sanity switches to itspreviewDraftsperspective, Contentful reads through the Preview API, and Payload and Strapi request drafts. Notion always reads every page and stages the ones whoseStatusisn’t published as drafts, soblume devshows them with or without the flag, andblume build --previewrenders them. Production builds without the flag exclude drafts as usual, so a preview build is a safe way to review unpublished work before it ships.blume syncre-fetches every remote source and regenerates the runtime. Dev is cache-first — a remote source is fetched once and served from.blume/cacheon restart (fast and offline-tolerant), soblume syncis how you pull the latest CMS content without restarting the dev server (a running server hot-reloads). Add--forceto drop the cache first, or setpollIntervalon a source to refresh automatically.
blume dev --preview # author workflow: see drafts live
blume build --preview # render a full preview build
blume sync # refresh remote content now
blume sync --force # ...ignoring any cached snapshot
Rendering and escaping
A source normalizes its native shape (Portable Text, Notion blocks, remote HTML) to Markdown/MDX text, so the same components and markdown features apply no matter where a page comes from. The built-in adapters escape rich text as they lower it, so what an author typed in the CMS — a {, a <b>, a paragraph starting with import, or © — renders as written. Links keep only http(s), mailto:, tel:, and relative targets; any other scheme (javascript:, data:) renders as the link’s text, and SVG images a source downloads are served sandboxed. Release notes from githubReleases() and files from mdxRemote() are treated as your own content: their raw HTML renders as written, so point them only at repositories you trust.