Migrate to Blume
Blume is a
markdown-first documentation framework on Astro/Vite. You drop Markdown/MDX into a folder and get navigation, search, theming, Open Graph images, and a component library with no app boilerplate —
the framework is the template. There is no starter to clone; the only thing a project owns is its content and a
.
Your job is to convert a source docs repo into an
idiomatic Blume project — not a 1:1 transliteration. Read this file, detect the source framework, open the matching
references/<framework>.md
for the exact mappings, and work the loop below. Report everything you drop or approximate.
Throughout this skill (including the
files),
means the absolute path of the directory containing this SKILL.md — resolve it from wherever you read this file (e.g.
node_modules/blume/skills/blume-migrate
or
.claude/skills/blume-migrate
). It is a placeholder to substitute, never a literal path.
Migration philosophy
- Target idiomatic Blume, not a mechanical port. Prefer filesystem-derived navigation over an exhaustive explicit . Prefer directives over JSX callouts. Prefer Blume defaults over restating them in config.
- Every field has a default; is a valid config. Map only what the source declares. If the source uses a framework default, don't write it.
- Drop chrome that has no Blume equivalent — and say so. Navbar CTAs, footer columns, custom theming, dynamic redirects, and unmappable icons get reported to the user, not silently discarded or faked.
- Convert, don't preserve. Blume's page frontmatter schema is strict — unknown keys are build errors. A source-only frontmatter key must be mapped to a Blume key or removed (and reported), never left to "maybe validate."
Migration workflow
- Detect the source framework and read its reference file:
- / → Mintlify () — the deepest, config-declared nav.
- → Docusaurus ().
- + deps (content under ) → Fumadocs ().
- + deps → Nextra ().
- calling → Starlight ().
- Anything else → apply this file's mental model directly; there's no framework-specific reference, so inventory by hand.
- Also note the host repo, independent of source framework: a pnpm/Turbo workspace, a non- content layout, or a Vercel deploy each need integration steps ( scoping, , lockfile, , an Astro/Vite patch) — all in . Read it whenever the target isn't a bare single-package docs folder.
- Inventory the repo before changing anything: the config file(s), the content tree, the nav definition, snippets/partials/includes, static assets, OpenAPI/AsyncAPI specs, redirects, i18n locales, custom components, and icon usage. Note what's declared vs. defaulted.
- Write with from . Map only declared fields (see the reference's mapping table); rely on defaults everywhere else. A minimal result is
defineConfig({ title: "…" })
.
- Restructure content. Choose (default ) — detect where / actually live, don't assume a folder. Many repos keep content directly under an app dir (, ) with no subfolder; when so, set to that dir and scope to the real content folders rather than leaving a bare that scans everything (see §1). Order with numeric prefixes (), group without a URL segment via folders, and add a () only where filesystem order isn't enough. A source that already declares per-folder navigation in a sidecar file — Fumadocs , Nextra — is that case: convert each one to a , carrying over its title/icon/order/collapse, rather than dropping it and hoping filenames reproduce the intent. Filesystem inference is the fallback for folders that declare no per-folder nav, never a reason to discard one that does. Reach for an explicit only when the source nav genuinely can't be expressed by files. Reshaping into folder-per-tab moves URLs — track every old→new path as you go; you'll turn them into in step 5.
- Rewrite pages. Map frontmatter to Blume's strict schema; convert callout JSX to directives — directives (and math/mermaid/package-install fences) are MDX-only, so rename any page that needs them to ; rename components; inline snippets/partials (Blume has no import-based includes); fix asset paths; rewrite internal links to their new routes (including OpenAPI operation links — see the OpenAPI section, their slugs differ from most sources); add a entry for every route you moved in step 4; convert every icon name to Lucide (Blume is Lucide-only — no FontAwesome/Tabler). Remove any duplicated H1 in the body ( renders the H1; bodies start at ). If the source has a hand-maintained changelog and the repo is open source on GitHub, offer to swap it for the source (see "Changelogs" below) rather than porting the entries. For Mintlify, run the bundled codemod first —
node <skill>/scripts/mintlify-codemod.mjs --write <content-dir>
deterministically remaps icons and drops/renames unsupported frontmatter keys, and reports the rest (unknown icons, OpenAPI-stub flags) for you to finish by hand (see ).
- Adopt . Repoint // → //, remove the old framework's deps, add . A config-only source (e.g. a bare Mintlify ) has no manifest — scaffold one. In a pnpm workspace: if / sets , add only to (don't disable the guard) so the just-published version installs. Always regenerate the lockfile in the same change: after editing deps run a plain (from the workspace root) and commit alongside — CI/Vercel use , so a stale lockfile fails the build before it starts. If the repo uses (or the user wants) Ultracite for formatting: its oxfmt formatter mangles the directives you just wrote unless you ship the bundled
assets/oxfmt@0.55.0.patch
and register it under — see §6. See §2–3.
- Wire up the host repo & deploy (non-trivial repos). For a monorepo on Vercel, emit the root-aware install/build recipe and , and tell the user the two settings you can't commit (Vercel Root Directory, Node 22). If the workspace pins Vite and crashes inside Astro/Vite, apply the pnpm-patch workaround. All copy-pasteable in §4–5.
- Verify. Run (frontmatter schema, duplicate routes, config — without a build exits 0 despite content errors, silently dropping invalid pages) and (internal links, heading anchors, assets — the link checker lives in , not ), fix diagnostics, then for a visual pass. End with a written summary of what was migrated, dropped, and approximated — and every repo-specific edit you made (pnpm-workspace, vercel.json, config globs) with the reason, plus any manual step left to the user (the Astro patch, Vercel dashboard settings).
The Blume mental model
The single biggest shift for most sources — especially Mintlify — is that navigation is derived from the filesystem, not declared in config.
Navigation is the file tree
- Folders become groups, files become pages. A page's sidebar label is its frontmatter ; a group's label is the humanized folder name.
- Ordering resolves highest-priority-first: an explicit (replaces the whole tree) → a folder's array → a page's frontmatter → the filesystem ( first, then numeric filename prefix like , then alphabetical).
- refines one folder (
defineMeta({ title, icon, order, collapsed, pages, display })
). The array lists children by slug (numeric prefix and parentheses stripped); children you omit fall back to their own , then filesystem order ( still sorts first) — so a partial list is safe, but list every child when the source declared a complete order.
- Sidebar render mode: a global default with per-folder overrides.
navigation.sidebar.display
in is (default), (collapsible), or (drill-in sub-panel) and sets the mode for every group at once. A folder can override its own group: in its , or — sugar when the folder has an page — in that index page's frontmatter. Precedence: index frontmatter → → global config → ; an override applies to that one group only (nested subgroups resolve their own chain). So a source's per-category collapse/drill-in modes migrate per folder — only reach for the global mode when the whole sidebar changes. Under an explicit , the config item's own field is the only per-group control (frontmatter/meta is ignored there, with a BLUME_SIDEBAR_DISPLAY_IGNORED
warning).
- An explicit replaces filesystem generation entirely. Use it only for a nav shape files can't express. Its items are a page route string, a group (), or a link ().
- Config-declared nesting has no on-disk counterpart — materialize it or it flattens silently. When a source (Mintlify , Nextra , a Docusaurus sidebar…) declares a nested group, its pages usually sit flat in one folder and the grouping lives only in config. Filesystem-derived nav sees the flat folder and drops the inner group. To keep the nesting you must either move those pages into a real subfolder ( for label/) — which changes their URLs, so add — or declare the group in an explicit , which nests the existing routes without moving a file. Walk config /nav arrays recursively during inventory and record where config nesting depth exceeds on-disk depth; that gap is exactly what gets lost.
Tabs and selectors
- () render top-of-header sections and scope the sidebar by route — the folder at a tab's becomes the section, so this needs no config beyond the tabs themselves; structure content as one folder per tab. A source's top-level tabs (Mintlify , a top-level product/section switcher) map to these header tabs — keep them as tabs; don't flatten them into a single global . Blume picks the active tab by URL prefix (longest tab that prefixes the route), so every page in a tab must live under that tab's single ; a source tab that mixes arbitrary routes isn't portable as-is — either move its pages under one prefix (route change → add ) or accept the closest shape, and say which in the report (details in ). The filtering runs both ways: on a route under a tab's , the sidebar shows only that tab's folder (a tab also highlights when the current route is under it); on a root or untabbed route (or a tab whose is ), the tab folders are hidden and the sidebar shows only the loose pages that belong to no tab (full tree as a fallback, so it's never blank). Consequence for migrations: once you add tabs, the landing sidebar automatically drops the sectioned content — that's intended, not lost pages; don't hand-build excludes for it.
- (
{ kind, label, items: [{ label, path, icon?, description?, tag? }] }
, = ///) partition a whole site (products, versions) via a header dropdown keyed on the current route.
- () pins links to the top of the sidebar, above every section — a blog, changelog, or support page that should always be one click away. These are the exception to tab scoping: unlike the generated tree, featured links show on every route and breakpoint. points anywhere — an external URL opens in a new tab, an internal route () is validated against your pages at build time. is a Lucide name (or image path/URL/inline SVG), as everywhere else. This is the home for a source's always-visible header/utility links (Mintlify anchors, Blog/Contact links) — see .
Routes and pathing
- A route is the content path relative to , with numeric prefixes stripped ( → ) and folders adding no segment. An file maps to its folder's route. Frontmatter overrides the generated route.
shape
— every field optional, all with defaults:
- Site: , , (string SVG, or
{ image: string | { light, dark, alt }, text, href }
), ({ content, link, dismissible, id }
— no color/type). A logo renders beside in the header, so a wordmark logo doubles the brand ("Acme Acme") — set to render the mark alone. Prefer the string form over : if you have the logo SVG locally and it's monochrome (solid black or white), rewrite its / to and use — it then inherits the theme's text color and adapts to light/dark automatically, so you don't need separate light/dark files.
- : (a color string for both modes, or per mode), (color), (//), , ( — each a curated Google-font slug, a
{ name, provider?, weights? }
object for any Google/Fontsource/Bunny/Fontshare family, or { name, variants: [{ src, weight?, style? }] }
for local font files), and (each a string, or per mode). The old // fields were merged into these per-mode objects — a bare string still applies to both modes, so only reach for when the two modes differ. There is no and no config field — custom CSS goes in a project-root file (auto-picked-up), and a source's "strict appearance" flags drop.
- : (default , relative to the project dir where runs), / (arrays of globs relative to ; defaults / ), (staged sources: , , , , , — OpenAPI is not one of these; it's the top-level field), (custom dir), . When docs sit directly under the project dir (no subfolder), set there and scope to the real content folders instead of scanning everything — §1.
- (top-level): a site-wide mount point (e.g. ) prepended to every route while staying invisible to the sidebar (no wrapper group). This is the right target for a source that served all docs under a prefix (Docusaurus , a Fumadocs of ) — distinct from a per-source (which adds a nav group) and from (host subdirectory).
- : , , (links pinned above the sidebar on every route), ( — is the global render mode above; is an explicit tree), . Avoid an explicit unless you have to — lean on the filesystem-derived sidebar. It only works when the file tree roughly matches the intended sidebar layout, so reshape folders to match first; reach for only for a shape files genuinely can't express (see "Config-declared nesting" above).
- (Orama default, Pagefind opt-in), (llms.txt, Ask AI, the MCP server), , , , , , , , , , .
- Don't set . Blume auto-fills it: the dev server's URL in dev, and the deployment URL (
VERCEL_PROJECT_PRODUCTION_URL
/) on Vercel. Hardcoding it in overrides that auto-detection and pins the wrong absolute URL (canonical links, sitemap, OG, llms.txt) everywhere but the one host you typed — so leave it unset even when a source config had a / field. (Sitemap still generates in production because the deploy URL is present there.)
- Favicon is a filename convention, not config. Drop / (and ) in the project root or — Blume auto-detects it. There is no config field. A source favicon given as collapses to one — pick a single file and report the loss.
The schema is exported from
; the full field reference is in the
directory of the installed
package (see "Full documentation" below for how to locate it).
Icons are Lucide, period
Blume resolves
bare kebab-case Lucide names everywhere an icon is accepted — frontmatter
,
,
,
/
icons, and
/
/
/etc. props. There is
no FontAwesome or Tabler support and
no prop. Names must be
kebab-case (
, not
) — a PascalCase React-component name (common in Fumadocs/lucide-react sources) does not resolve and renders nothing. When migrating a source that uses another icon set (Mintlify defaults to FontAwesome),
map each name to its closest Lucide equivalent; where none exists, drop the icon and report it. Verify a name exists at
lucide.dev/icons before writing it.
Page frontmatter (strict — unknown keys are build errors)
yaml
---
title: Install # renders as the page H1 — remove any duplicate H1 in the body
description: Install Blume and scaffold your first project.
type: doc # doc (default) | blog | changelog | api
icon: download # a Lucide name
sidebar:
label: Install # overrides title in the sidebar
order: 2
icon: download
badge: New
hidden: false
seo:
title: …
description: …
image: /og/install.png
canonical: https://…
noindex: false
search:
exclude: false
tags: [api]
slug: install # override the generated route
draft: false
lastModified: 2026-06-20 # pin the "last updated" date
---
Also valid:
/
(blog/changelog feeds),
(changelog metadata),
,
,
.
Authoring features (no imports needed in )
- The rich features are MDX-only. Directives, , mermaid, and math are wired into the MDX processor; in a plain file a stays literal text — and the build stays green. Rename any file that uses (or should use) these to during migration. This bites hardest on Docusaurus/Starlight sources, whose content is full of admonitions. Plain Markdown (headings, tables, fenced code with titles/highlighting) is fine in .
- Callouts as directives: , , , , , , with an optional title in brackets: . Aliases →warning, →danger, →note, →warning.
- No-import MDX components: , /, /, /, /, /, , , //, , , , , , , , /, , , , , , , , , . (Not shipped — convert away: → the directive, and the // field family → rows or the OpenAPI reference. See the reference files for targets.)
- Fenced-code superpowers: → package-manager tabs; → a rendered diagram; code-block titles (), line numbers (), and highlighting (, ).
- Math: block math renders in with no config (there is no field). Inline is not supported — a bare stays literal text; convert inline math to display math or drop it (report).
OpenAPI
openapi: { enabled: true, sources: [{ spec, label?, route? }] }
generates
one real page per operation — with routing, sidebar, search, and OG images for free.
The reference does not get a header tab automatically — add a
entry pointing at the reference's
(reference routes are valid tab targets) or the API reference is unreachable from the header.
Never hand-migrate generated API-reference pages (per-endpoint stub pages in the source): delete them and point
at the spec. (
keeps the Scalar embed instead; AsyncAPI uses the same embed.)
- Vendor the spec by default. A remote URL makes every build depend on fetching it at build time — a single point of failure in CI, offline, or behind a proxy, and a failed fetch skips the whole reference. Prefer committing the spec into the repo () and pointing at the local path; if you keep the URL, say so and consider a step that refreshes the local copy with a fallback.
- Operation routes have their own slug scheme —
<route>/<slugified-tag>/<slugified-operationId>
(e.g. tag , id → /api-reference/models/listmodels
). This rarely matches the source's endpoint links (Mintlify/others kebab-case differently), so rewrite every inbound link to an operation. resolves operation pages like any other route, so it catches the ones you miss.
- Keep hand-written conceptual pages. Sources often pair a written "Introduction/Authentication" page with the endpoint group in the same tab. A normal content page placed under the openapi merges into the reference tab's sidebar — so keep those (auth, errors, rate limits) and delete only the per-endpoint stubs.
Changelogs
If the source ships a
hand-maintained changelog (a
, a folder of dated entries, Mintlify
blocks)
and the project is open source on GitHub, offer to replace it with the
content source — release notes become the changelog automatically, with no files to maintain. It's an offer, not an automatic rewrite: some teams keep a curated changelog that doesn't map 1:1 to GitHub releases, so confirm the release notes are the source of truth before deleting their pages.
Add it under
alongside the filesystem source:
ts
content: {
sources: [
{ include: ["docs/**/*.mdx"], root: ".", type: "filesystem" },
{
owner: "haydenbleasel",
repo: "ultracite",
prefix: "changelog",
type: "github-releases",
},
],
},
- Each release materializes as a page under ( → ); omit to mount at the root.
- Optional fields: (cap materialized releases, newest-first, default 100), (include prereleases), (include drafts — needs a token with repo write access), (dev polling seconds; omit to freeze for the session).
- A private repo reads a token from ; it is never inlined in config. A public repo needs no token.
- Delete the old changelog pages once the source is wired (and add from their old routes to the new slugs). Pin a header/sidebar link with if the source had one.
Redirects are static
A
redirects: [{ from, to, status? }]
array
in maps old URLs when you restructure routes — Blume serves these itself, so any reorganization that moves a page (folder-per-tab, materialized nested groups, renamed slugs, index promotion) is fixed by adding an entry there; no host config needed.
Restructuring is the main source of these: every page you moved in step 4 (folder-per-tab, renamed slugs, index promotion) needs an entry, or old URLs 404.
defaults to
301 (permanent — browsers cache it indefinitely); that's correct for genuine moves, but never use 301/308 for redirects you might reverse. Dynamic/wildcard patterns (
) can't be modeled as static path-to-path; move those to host-level config (
,
) and report them.
Verification & reporting
- Run — it validates the frontmatter schema, duplicate routes, and config, and makes diagnostics fail the build (without it, exits 0 despite content errors and silently drops invalid pages). Then run — links, heading anchors, and assets live here, not in (add to also check outbound HTTP links). OpenAPI operation pages are real routes to , so dead links to them are caught too. Iterate until both are clean.
- Run and review the site visually — nav structure, tabs, theme, rendered components.
- Write a migration summary covering: what was migrated (config, N pages, nav, OpenAPI), what was dropped (navbar CTAs, footers, custom theming, dynamic redirects, unmappable icons, unsupported components), and suggested follow-ups ( for full control, to vendor a component for customization).
Full documentation
The mapping details live in
: one file per source framework (
,
,
,
,
), plus
for host-repo integration (content-layout detection, pnpm
, frozen-lockfile regeneration, the Vercel monorepo recipe, and the Astro/Vite patch). The Mintlify icon + frontmatter pass is automated by
scripts/mintlify-codemod.mjs
(zero-dependency, deterministic, idempotent;
to apply). The authoritative Blume docs are bundled in the installed package's
directory — not necessarily at the repository root: in a workspace monorepo (pnpm especially) the package lives in the depending workspace's
(e.g.
apps/docs/node_modules/blume/docs
);
node -e "console.log(require.resolve('blume/package.json'))"
run from the depending package prints the exact location. (In a repo checkout of Blume itself, the docs source is
.) The most relevant pages:
- — every field.
- — the sidebar/tabs/selectors model.
- — and display modes.
- — directives, code features, math.
- — the component library and APIs.
reference/frontmatter.mdx
— the strict page schema.