SuperAPI AI Directory

blume-migrate

Writing & research

View repository

Updated 10-16 10:56

About this skill

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 blume.config.ts.

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 references/ files), <skill> 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 navigation.sidebar. 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, custom theming, conditional 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

  1. Detect the source framework and read its reference file:
    • docs.json / mint.json → Mintlify (references/mintlify.md) — the deepest, config-declared nav.
    • docusaurus.config.* → Docusaurus (references/docusaurus.md).
    • meta.json + fumadocs-* deps (content under content/docs/) → Fumadocs (references/fumadocs.md).
    • _meta.{js,ts,json} + nextra deps → Nextra (references/nextra.md).
    • astro.config.* calling starlight({…}) → Starlight (references/starlight.md).
    • .vitepress/config.{js,ts,mjs,mts} (often docs/.vitepress/) or a vitepress dependency → VitePress (references/vitepress.md). .vuepress/ is VuePress (next bullet).
    • .vuepress/config.{js,ts,mjs} (v1 also config.yml/config.toml; often docs/.vuepress/), a vuepress.config.*, or a vuepress dependency → VuePress (references/vuepress.md). It builds on references/vitepress.md for the Markdown layer the two share, so read both.
    • A docus dependency (often in docs/package.json beside a nuxt.config.ts with extends: ['docus']), or @nuxt-themes/docus for 1.x → Docus (references/docus.md). Pages are MDC, Nuxt Content's component syntax: ::note … :: is not a Blume directive, and Blume prints it as text (BLUME_MDC_SYNTAX warns at its line).
    • mkdocs.yml/mkdocs.yaml (also Zensical's zensical.toml and ProperDocs' properdocs.yml) → MkDocs / Material for MkDocs (references/mkdocs.md): nav declared in config, Python-Markdown extension syntax (!!! admonitions, === tabs, --8<-- snippets), and Python deps, so usually no package.json. Run node <skill>/scripts/mkdocs-codemod.mjs --write <content-dir> first, then pin-heading-ids.mjs after the first build.
    • fern/docs.yml (beside fern/fern.config.json) → Fern (references/fern.md). A fern/ folder without docs.yml only generates SDKs: the docs are elsewhere.
    • .gitbook.yaml/.gitbook.yml, gitbook-docs.yaml, or a SUMMARY.md beside a .gitbook/ folder → GitBook (references/gitbook.md). book.json + SUMMARY.md is the legacy GitBook CLI or HonKit (same reference, its own section); book.toml + src/SUMMARY.md is mdBook (references/mdbook.md).
    • book.toml (at the book root, often docs/, book/, or guide/ in a code repo; chapters in its [book] src, beside a SUMMARY.md) → mdBook (references/mdbook.md). Every URL ends in .html, SUMMARY.md is the whole nav, and {{#include}} pulls anchored code from across the repo: build the old book first, since it's the URL list, the old ids, and the only record of what the includes showed.
    • sidebars.yaml or an @redocly/realm-family dependency, usually beside a redocly.yaml and with Markdoc {% … %} tags in pages → Redocly (references/redocly.md). GitBook pages use {% … %} tags too, so check for GitBook's files first. A redocly.yaml holding only lint/bundle keys (extends, rules, decorators, apis) is Redocly CLI config, not a docs site: keep it and detect the framework from the other signals.
    • _order.yaml files in docs/, reference/, recipes/, or custom_pages/ (ReadMe's bi-directional Git sync), or a CI step running readmeio/rdme beside pages with category/parentDoc (or category.uri/parent.uri) frontmatter (rdme uploads) → ReadMe (references/readme.md). ReadMe keeps settings, redirects, the glossary, and variables in its dashboard, not the repo, and changelogs only on the main branch.
    • An index.html setting window.$docsify (usually in docs/, beside _sidebar.md), or docsify-cli in package.json → Docsify (references/docsify.md). Run node <skill>/scripts/docsify-codemod.mjs --write --snapshot .docsify-old --group-folders <docs-dir> first, then pin-heading-ids.mjs --old .docsify-old --map docsify-routes.json after the first build. Its default URLs live after # (/#/page), which no server-side redirect can match; the reference adds a client-side script.
    • _config.yml (root or docs/) → Jekyll (references/jekyll.md); Just the Docs is just-the-docs in _config.yml or the Gemfile. Build the old site first with a project-local bundle: the nav comes from front matter (parent/nav_order) and is rebuilt from the old build's sidebar, and URLs end in / or .html. A _toc.yml beside it is Jupyter Book, not Jekyll. Run node <skill>/scripts/jekyll-codemod.mjs --old <old build> --write . from the Jekyll source dir first, then pin-heading-ids.mjs --old <old build> after the first build.
    • A clone of <repo>.wiki.git (Home.md at the root, often _Sidebar.md and _Footer.md, [[Page Name]] links, no config) → GitHub Wiki (references/github-wiki.md). Never detected: the source is named. Its old URLs stay on github.com, which can't redirect, so the reference writes a route table and link stubs for the old wiki instead of redirects. Run node <skill>/scripts/github-wiki-codemod.mjs routes --repo <owner>/<repo> --write first, classify wiki-routes.json, then convert --repo <owner>/<repo> --write.
    • 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-docs/ content layout, or a Vercel deploy each need integration steps (content.root scoping, minimumReleaseAge, lockfile, vercel.json, an Astro/Vite patch) — all in references/monorepo.md. Read it whenever the target isn't a bare single-package docs folder.
  2. Inventory the repo before changing anything (a page whose body is only a <meta http-equiv="refresh"> is a redirect, not a page: add the redirect from its old URL and report it): the config file(s), the content tree, the nav definition, snippets/partials/includes, static assets, OpenAPI/AsyncAPI specs and GraphQL schemas, redirects, i18n locales, custom components, and icon usage. Note what's declared vs. defaulted. Pages the old site published by accident (a .github/ template, files under dot-folders, planning notes) aren't content: leave them out, add no redirects, and list them in the report.
  3. Write blume.config.ts with defineConfig from blume. Map only declared fields (see the reference's mapping table); rely on defaults everywhere else. A minimal result is defineConfig({ title: "…" }).
  4. Restructure content. Put blume.config.ts in the directory whose package.json runs the old docs build, which may be a workspace package such as packages/docs rather than the repo root. Choose content.root (default docs) — detect where .md/.mdx actually live, don't assume a docs/ folder. Many repos keep content directly under an app dir (apps/docs/api/, .../getting-started/) with no docs/ subfolder; when so, set content.root to that dir and scope content.include to the real content folders rather than leaving a bare content.root: "." that scans everything (see references/monorepo.md §1). Order with numeric prefixes (01-intro.mdx), group without a URL segment via (group)/ folders, and add a meta.ts (defineMeta) only where filesystem order isn't enough. A source that already declares per-folder navigation in a sidecar file — Fumadocs meta.json, Nextra _meta.* — is that case: convert each one to a meta.ts, 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. A group that exists only in the source's nav config, with its pages flat on disk, becomes a (group)/ folder: it adds the sidebar group and keeps every URL, with no redirects (moving pages a folder deeper instead changes their URLs and breaks their relative asset paths). Reach for an explicit navigation.sidebar 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 redirects in step 5.
  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 .md page that needs them to .mdx; rename components; turn snippets/partials into <include> statements or inline them (Blume has no import-based includes: import Snippet from "…" plus <Snippet /> has to become one or the other; when a source includes part of a file (mdBook ANCHORs, VitePress #regions, MkDocs snippet line ranges) or a file outside the content root, copy <skill>/scripts/include-excerpts.mjs into the project and run it before every dev, build, and validate rather than pasting code the project compiles: it reports every missing anchor or region in one pass, and a restored marker in the source beats a line range, which goes stale silently); fix asset paths; rewrite internal links to their new routes (including OpenAPI/GraphQL operation links — see the API-reference sections, their slugs differ from most sources — and links inside OpenAPI descriptions, which Blume renders and validates, though a redirect from hides a stale one; rewrite links that hardcode the old site's own origin to root-relative paths); add a redirects entry for every route you moved in step 4 (a hash-routed source like Docsify also needs its reference's client-side redirect, because redirects only see paths); convert every icon name to Lucide (Blume is Lucide-only — no FontAwesome/Tabler). Remove any duplicated H1 in the body (title renders the H1; bodies start at ##), and fix links to its anchor (#<h1-slug>), which no longer exists: drop the fragment. blume validate --strict also reports fragments the old site never checked; fix each or drop it, and report it. A page with no frontmatter title takes its opening # H1 as the title and shows it once, so that one can stay; moving it into title is still the idiomatic form. If the source has a hand-maintained changelog and the repo is open source on GitHub, offer to swap it for the githubReleases() 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 references/mintlify.md). For Docus, run the bundled codemod first: node <skill>/scripts/docus-codemod.mjs --write content pairs MDC blocks as remark-mdc does, converts the Nuxt UI prose components, fence labels, icons, frontmatter, and .navigation.yml files, makes the MDX prose safe, and lists every REVIEW item (custom and landing-page components stay as written; check them against the live site, not only a local build; see references/docus.md). For VitePress, run the bundled codemod first: node <skill>/scripts/vitepress-codemod.mjs --write <srcDir> converts containers (paired as markdown-it pairs them), code groups, alerts, fence labels, <<< imports and includes, .html links, badges, and H1 titles, and lists every REVIEW item for you to finish (see references/vitepress.md). Before converting any source's components, check its built output: a component its theme never registered rendered nothing there (<!---->), so converting it shows content readers never saw; report it. For mdBook, convert every {{#…}} helper before anything else: anchored, line-range, and out-of-root code becomes generated excerpts, one-line files become variables; then strip hidden # lines and rust,… fence attributes, and convert admonish blocks (see references/mdbook.md). For Jekyll, convert every Kramdown IAL ({: .note }) and Liquid tag: in .md they ship as literal text (a Liquid tag warns BLUME_TEMPLATE_TAG, an IAL BLUME_MD_ATTRIBUTE_LIST), and in .mdx they fail the build (blume check names each IAL's line as BLUME_MDX_ATTRIBUTE_LIST and a Liquid tag's as BLUME_MDX_SYNTAX, at the partial's own line for one in an included partial). Run the bundled codemod first: it converts callout IALs, the in-page TOC, H1s, front matter, Markdown includes, and Liquid links and variables, rebuilds the Just the Docs sidebar as folders, slugs, and meta.ts, and writes the redirects and variables to jekyll-migration.json (see references/jekyll.md). For a GitHub Wiki, run the bundled codemod (routes, then convert): it builds the route table (one flat route per page name) and converts every [[…]] link outside code, which Blume would otherwise render as literal text (BLUME_WIKILINK_UNSUPPORTED warns only for one that names a page or has a |; Markdown pages put the link text first: [[text|Page]]). For Redocly, convert every Markdoc {% … %} tag: Blume never parses them, so an unconverted tag ships as literal text in .md (BLUME_TEMPLATE_TAG warns at its line) and fails an .mdx build. For VuePress, rename every README.md to index first, then run the VitePress codemod (its limits on VuePress content are in references/vuepress.md), then grep for hyphenated theme tags (<code-group>, <code-block>, <router-link>): they pass through .mdx as unknown HTML elements with no diagnostic. For MkDocs, decide .md vs .mdx per page before converting: MDX rejects HTML comments, <https://…> autolinks, unclosed void tags, and {…} attribute lists that Python-Markdown accepts (blume check names each at its line: BLUME_MDX_SYNTAX for what MDX can't parse, and the last two as BLUME_MDX_UNCLOSED_ELEMENT and BLUME_MDX_ATTRIBUTE_LIST), and snippet paths resolve from the snippets base_path (default: where MkDocs runs), not the page (see references/mkdocs.md). For Fern, URLs come from docs.yml, not file paths: run node <skill>/scripts/fern-codemod.mjs --sitemap old-urls.txt before moving anything (dry run, then --write), and after the first build its endpoints step, which joins operation-routes.mjs output to Fern's old API URLs and writes fern-redirects.json; export a Fern Definition to OpenAPI with fern export (see references/fern.md). For GitBook, put each page at its old URL first (GitBook's URLs follow SUMMARY.md, not the file tree), then convert every {% … %} tag: in .md an unconverted tag ships as literal text (BLUME_TEMPLATE_TAG warns), and in .mdx it fails the build. For ReadMe, run the bundled codemod first: node <skill>/scripts/readme-codemod.mjs --write --site <hub origin> --glossary <saved hub page> . keeps every page at its flat URL, converts callouts, doc:/ref: links (blume validate names any left as BLUME_UNSUPPORTED_LINK_SCHEME), ReadMe components, and frontmatter, writes meta.ts, overlays, and redirects, and lists what needs a person; after the first build, its --routes pass adds the endpoint redirects and per-tag order (see references/readme.md). A pasted widget's JSX event handler (onClick={…}) builds green and never runs on a static page: BLUME_MDX_EVENT_HANDLER names each one, so move the widget into an island (islands/) or drop it and report it.
  6. Adopt package.json. Repoint dev/build/start → blume dev/blume build/blume preview, remove the old framework's deps, add blume. A config-only source (e.g. a bare Mintlify docs.json) has no manifest — scaffold one. In a pnpm workspace: if pnpm-workspace.yaml/.npmrc sets minimumReleaseAge, add only blume to minimumReleaseAgeExclude (don't disable the guard) so the just-published version installs. Yarn's npmMinimalAgeGate refuses a fresh blume the same way: add it to npmPreapprovedPackages. Always regenerate the lockfile in the same change with the repo's own package manager (pnpm install from the workspace root, yarn install, npm install, or bun install; check packageManager and the lockfile's name) and commit it alongside package.json — CI and hosts install from a frozen lockfile, so a stale one fails the build before it starts. After creating the first new file in the content root (a meta.ts, a partial), run git check-ignore -v <content root>/meta.ts: a repo .gitignore rule that covers the content root leaves every new file out of git add while the old pages stay tracked, so fix the rule or report that each new file needs git add -f. Blume needs Node 22.19 or later: update .nvmrc/.node-version, engines.node, and any CI node-version in the same change. pnpm 11 and later run a dependency's build script only once pnpm-workspace.yaml approves it: add allowBuilds: { esbuild: true } (plus "@scarf/scarf": false, as blume init writes) unless the workspace already lists esbuild, keeping its other entries. Add .blume/, dist/, and .env.local to the docs package's own .gitignore when it has one, else the root's (as blume init does; a vercel() build appends .vercel/ itself), and remove any line that ignores public/ before moving assets into it. blume build empties <project>/dist: if the host repo already uses dist/ for something else (Python wheels, a library build), put the Blume project in its own folder (docs/). If the repo formats with Prettier, extend its *.md overrides (.prettierrc, .editorconfig) to *.mdx and keep {/* */} comments on one line. After renaming or moving pages, grep the whole repo (release config, CI, bot and ignore files) for their old paths. Check any tsconfig.json in the Blume project dir too: Blume's build reads it, so an extends that resolves only after a root workspace install, or includes of the old framework's folders, fail blume build. Install the workspace first, fix the paths, or delete the file if only the old framework used it (a Nuxt tsconfig.json that extends or references ./.nuxt/* fails with BLUME_TSCONFIG_EXTENDS, and a postinstall: nuxt prepare script goes with the Nuxt dependency). 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.71.0.patch and register it under patchedDependencies — see references/monorepo.md §6. See references/monorepo.md §2–3.
  7. Wire up the host repo & deploy (non-trivial repos). Settings a host keeps in its dashboard (Netlify's publish directory, build command, NODE_VERSION) can't be committed: list them in the summary. In .gitignore, replace only the docs' own entries and keep ones other packages use. For a monorepo on Vercel, emit the root-aware install/build recipe and apps/docs/vercel.json: with outputDirectory: "dist" for a static build, and with no outputDirectory for deployment: vercel(), whose build writes .vercel/output (add .vercel/output/** to Turborepo outputs). Tell the user the settings you can't commit (Vercel Root Directory, Node 22, and server secrets such as AI_GATEWAY_API_KEY). If the workspace pins Vite and blume build crashes inside Astro/Vite, apply the pnpm-patch workaround. All copy-pasteable in references/monorepo.md §4–5.
  8. Verify. Run blume build (frontmatter schema, duplicate routes, config — it fails on any error diagnostic by default; never pass --no-strict, which builds anyway and silently drops pages with invalid front matter or .mdx that doesn't parse) and blume validate --strict (internal links, heading anchors, assets, redirect targets — the link checker lives in validate, not build), fix diagnostics, then blume dev 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 title; a group's label is the humanized folder name.
  • Ordering resolves highest-priority-first: an explicit navigation.sidebar (replaces the whole tree) → a folder's meta.ts pages array → a page's frontmatter sidebar.order → the filesystem (index first, then numeric filename prefix like 01-, then alphabetical).
  • meta.ts refines one folder (defineMeta({ title, icon, order, collapsed, pages, display, directory })). The pages array lists children by slug (numeric prefix, parentheses, and extension stripped; an entry that names nothing warns BLUME_META_UNKNOWN_PAGE with the slug it likely meant, so write that one); children you omit fall back to their own sidebar.order, then filesystem order (index still sorts first) — so a partial pages list is safe, but list every child when the source declared a complete order. directory inherits down the tree: set directory: "none" on nested folders that shouldn't list cards. pages can't interleave loose pages with subgroups inside a flat group, or at the top of the sidebar in any mode: when a source interleaves them (mdBook, MkDocs navigation.sections), use display: "group", where the order holds inside groups, and say what moved at the top level.
  • Sidebar render mode: a global default with per-folder overrides. navigation.sidebar.display in blume.config.ts is "flat" (default), "group" (collapsible), or "page" (drill-in sub-panel) and sets the mode for every group at once. A folder can override its own group: display in its meta.ts, or — sugar when the folder has an index page — sidebar.display in that index page's frontmatter. Precedence: index frontmatter → meta.ts → global config → flat; 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 navigation.sidebar, the config item's own display field is the only per-group control (frontmatter/meta display is ignored there, with a BLUME_SIDEBAR_DISPLAY_IGNORED warning).
  • An explicit navigation.sidebar replaces filesystem generation entirely. Use it only for a nav shape files can't express. Its items are a page route string, a group ({ label, items }), or a link ({ label, href }).
  • Config-declared nesting has no on-disk counterpart — materialize it or it flattens silently. When a source (Mintlify groups, Nextra _meta, 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, use a (group)/ folder first: move the pages into <folder>/(<group>)/ with a meta.ts for the label, order, and collapsed. A parenthesized folder adds the sidebar group but no URL segment, so no route changes and no redirects are needed, and a page from another folder can join with a slug that keeps its old route. A group whose landing page is a sibling file keeps every URL too: move the landing page to (group)/index.md with slug: <its old route>, and the slugged index becomes the group's link. Moving a page a folder deeper breaks its relative links and assets (./img.png, ../other.md), so re-base them. (Docsify is the exception: its old #/ URLs are remapped by script whatever the paths, so its reference moves pages into real folders.) Only when that can't express the shape, move the pages into a real subfolder (URLs change: add redirects) or declare the group in an explicit navigation.sidebar, which nests the existing routes without moving a file. Walk config pages/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

  • navigation.tabs ({ label, path, icon? }) render top-of-header sections and scope the sidebar by route — the folder at a tab's path 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 navigation.tabs, a top-level product/section switcher) map to these header tabs — keep them as tabs; don't flatten them into a single global navigation.sidebar. Blume picks the active tab by URL prefix (longest tab path that prefixes the route), so every page in a tab must live under that tab's single path; a source tab that mixes arbitrary routes isn't portable as-is — either move its pages under one prefix (route change → add redirects) or accept the closest shape, and say which in the report (details in references/mintlify.md). The filtering runs both ways: on a route under a tab's path, 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 path 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.
  • navigation.selectors ({ kind, label, items: [{ label, path, icon?, description?, tag? }] }, kind = dropdown/product/version/language) partition a whole site (products, versions) via a header dropdown keyed on the current route.
  • navigation.actions ([{ label, href }]) puts plain links in the header, left of the icon buttons, and navigation.cta ({ label, href }, singular) is the header's one filled call-to-action button. This is the home for a source's header bar — a "Log in"/"Status" link goes in actions, a "Sign up"/"Get started" button in cta. An http(s) href opens in a new tab; an internal route is validated against your pages at build time, so a route served by another app on the same host must be written as an absolute URL. Both hide on phones (below sm), except cta on a page with no navigation toggle; a link that must survive on a phone on docs pages belongs in featured.
  • navigation.featured ({ label, href, icon? }) 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. href points anywhere — an external URL opens in a new tab, an internal route (/contact) is validated against your pages at build time. icon 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 references/mintlify.md.

Routes and pathing

  • A route is the content path relative to content.root, with numeric prefixes stripped (01-intro.mdx → /intro; a version or a name that starts with a date, YYYY-MM-DD, D-M-YYYY/M-D-YYYY, or YYYY-MM, stays whole: 12-05-2022.mdx → /12-05-2022; pin slug for any other digit-led name the old site kept whole) and (group)/ folders adding no segment. An index file maps to its folder's route. Frontmatter slug overrides the generated route. A README.md is an ordinary page at /README, not its folder's index: rename it index.md (Docsify, MkDocs, GitBook, and VuePress sources all use README as the index).

blume.config.ts shape

defineConfig({...}) — every field optional, all with defaults:

  • Site: title, description, logo (string SVG, or { image: string | { light, dark, alt }, text, href }), banner (a string, or { content, link: { href, text }, dismissible, id } — no color/type). A logo renders beside title in the header, so a wordmark logo doubles the brand ("Acme Acme") — set text: "" to render the mark alone. Prefer the string form over { light, dark }: if you have the logo SVG locally and it's monochrome (solid black or white), rewrite its fill/stroke to currentColor and use logo: "/logo.svg" — it then inherits the theme's text color and adapts to light/dark automatically, so you don't need separate light/dark files.
  • theme: accent (a color string for both modes, or { light, dark } per mode), action (color), mode (light/dark/system), radius, fonts ({ body, display, mono } — 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), background and backgroundImage (each a string, or { light, dark } per mode). The old accentDark/backgroundDark/backgroundImageDark fields were merged into these per-mode objects — a bare string still applies to both modes, so only reach for { light, dark } when the two modes differ. Each color must be a CSS color or an accent preset (a source's bare "3b82f6" needs its #), or BLUME_THEME_COLOR_INVALID warns and browsers ignore it. There is no theme.strict and no theme.css config field — custom CSS goes in a project-root theme.css file (auto-picked-up), and a source's "strict appearance" flags drop.
  • content: root (default "docs", relative to the project dir where blume runs), include/exclude (arrays of globs relative to content.root; defaults ["**/*.{md,mdx}"] / ["**/_*", "**/.*"] — a set exclude adds to those defaults, so partials and dot-folders stay unpublished; to publish _-prefixed or dot-files on purpose, list the default with a !: exclude: ["!**/_*"]), sources (an array of adapters imported from blume/sources: filesystem({ root, include, exclude }), obsidian({ vault }), githubReleases({ owner, repo }), notion({ database }), sanity({ projectId, dataset, query }), contentful({ space, contentType }), payload({ url, collection }), strapi({ url, contentType }), mdxRemote({ github }), custom(source); every factory with an options object also takes prefix and pollInterval, while custom(source) takes a ContentSource instance that sets its own prefix. The 1.x { type: "…" } objects were removed — rename type to the factory call and pass the other fields as its options — except { type: "custom", source }, which becomes custom(source) with the instance as the only argument. root/include/exclude are shorthand for a single filesystem() and are rejected beside sources — move them into the filesystem() entry. OpenAPI/AsyncAPI/GraphQL are not among these; they're adapters in the top-level reference list), pages (custom .astro dir), defaultType. When docs sit directly under the project dir (no docs/ subfolder), set root there and scope include to the real content folders instead of scanning everything — references/monorepo.md §1.
  • basePath (top-level): a site-wide mount point (e.g. "/docs") 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 routeBasePath, a Fumadocs baseUrl of /docs) — distinct from a per-source prefix (which adds a nav group) and from deployment.base (host subdirectory).
  • navigation: tabs, selectors, actions and cta (header links and the one filled button), featured (links pinned above the sidebar on every route), sidebar ({ display, items } — display is the global render mode above; items is an explicit tree), repo (true/false, or an absolute GitHub URL for the footer's GitHub link when the docs repo is private and github must stay unset). Avoid an explicit navigation.sidebar 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 sidebar.items only for a shape files genuinely can't express (see "Config-declared nesting" above).
  • search — an adapter imported from blume/search: orama() (default, so omit search entirely for it), flexsearch(), pagefind(), algolia({ appId, apiKey, indexName }), oramaCloud({ endpoint, apiKey, indexId? }), typesense({ host, collection, apiKey }), mixedbread({ storeId }), or false to disable. Pass the adapter directly (search: algolia({…})) or as search: { provider, popular, indexing } when you also set curated links or indexing options. Blume 1.x's search.provider string and its search.algolia/oramaCloud/typesense/mixedbread credential blocks are gone — when a source config (or an older blume.config.ts) has provider: "algolia", algolia: { appId, indexName, searchApiKey }, rewrite it as search: algolia({ appId, indexName, apiKey: searchApiKey }) (the search-only key is now apiKey in every adapter that takes one; admin keys stay in env vars). ai (the assistant, Open in chat — ai.assistant.provider takes an adapter descriptor imported from blume/ai: gateway({ model }) (the default, openai/gpt-5.5), openai({ model }) (or openai({ baseUrl, name, model, apiKeyEnv }) for any OpenAI-compatible endpoint), anthropic({ model }), gemini({ model }), grok({ model }), openrouter({ model, reasoning }), llmgateway({ model }), or inkeep({ model }). Every adapter takes model, apiKeyEnv, headers, and a verbatim providerOptions passthrough (headers values are written into the generated route source as-is, so they are for non-secret static headers only — a bearer token or any other credential belongs in the env var apiKeyEnv names, never in headers). Every adapter except inkeep() also takes reasoning: Inkeep runs its own answer pipeline, so inkeep({ reasoning }) fails validation with an unrecognized key — drop a source's reasoning setting there and report it. There are no flat provider/model/apiKeyEnv/baseUrl/headers/reasoning fields on ai.assistant — a source that configured an AI assistant that way (Blume < 2.0 included) maps onto one adapter call. enabled, instructions, retrieval, suggestions, cors, and endpoint sit on ai.assistant itself; there is no ai.ask — Blume 2.0.0 and earlier used that name, so an older blume.config.ts moves the whole block to ai.assistant), agents (llms.txt, the JSON API, the MCP server, published skills, discovery manifests, robots content signals), reference (a list of adapters imported from blume/reference — openapi({ spec | sources, route, … }), asyncapi({ … }), graphql({ spec, endpoint, … }) — never the 1.x openapi/asyncapi/graphql blocks), redirects, seo, markdown, analytics (a list of adapters imported from blume/analytics — one factory per provider (posthog({ key, host }), googleAnalytics({ id }), plausible({ domain, host }), mixpanel({ token, region }), segment({ key }), …; the docs page lists them all), vercel(), and script({ src | content, strategy, attributes }) for anything without one — never an object keyed by provider), deployment, i18n, toc, lastModified, github ({ owner, repo, branch?, dir?, host?, api? } — set host whenever the source's edit URL is on a GitHub Enterprise origin rather than github.com).
  • analytics — adapters from blume/analytics, including script() for a source's custom head scripts. A known analytics snippet in the source's head scripts (the gtag loader plus gtag('config') pair, GTM, Plausible, …) becomes that provider's factory, not script(). Adapters load only in production builds and wait for consent when it's configured, so a script a page needs to work (a support widget, a form embed) goes in a PageFooter layout slot component instead (defineComponents).
  • deployment is a host adapter from blume/deploy — vercel(), netlify(), cloudflare(), or node() — or a plain { site, base } for a static build on any host (the default is a static build). Naming a host adapter switches the build to server output on that host (pass output: "static" to stay static there); site, base, and output are the named options, and anything else is forwarded verbatim to the underlying @astrojs/* adapter. There is no deployment.adapter string and no deployment.output field: a source that needs server rendering (the assistant, the MCP server, Mixedbread search, the API playground proxy) gets import { vercel } from "blume/deploy" and deployment: vercel(); a static source gets nothing — unless it served its docs under a subpath, which becomes deployment: { base: "/docs" } (see below for when to add site). Note that cloudflare and vercel are also exported from blume/analytics — alias one (import { cloudflare as cloudflareDeploy } from "blume/deploy") when a config uses both.
  • Keep the source's site URL as deployment.site unless the target host is one Blume auto-detects. Blume fills site in from the platform's build environment only on Vercel and Netlify (their stable production domain), and uses the dev server's localhost URL during blume dev; on those two hosts leave it unset and let detection pick the deployed URL. Everywhere else — Cloudflare Pages (it exposes only the per-deploy CF_PAGES_URL), GitHub Pages, S3 or another static host, a custom CDN, Cloudflare Workers, a node() server — nothing detects it, and an unset site silently drops the sitemap, OG images, RSS feeds, the AI catalog, and absolute canonical URLs. So when the source config had a url/site field and the target isn't one of those two hosts, carry it over: deployment: { site: "https://…" } for a static build, or the site option of a host adapter (node({ site })). If you can't tell where the site will deploy, keep it and say so in the report.
  • Favicon is a filename convention, not config. Drop icon.{svg,png,ico} or favicon.{svg,png,ico} (and apple-icon.png or apple-touch-icon.png) in the project root or public/ — Blume auto-detects it. There is no favicon config field. A source favicon given as { light, dark } maps to a filename pair: copy the light file to a conventional name (e.g. public/icon.png) and the dark file to its -dark sibling — same directory and extension, -dark before the extension (public/icon-dark.png). If the two files have different formats, convert one so the extensions match; only an exact sibling of the resolved icon is picked up.

The schema is exported from blume/schema; the full field reference is in the docs/configuration/ directory of the installed blume 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 icon, sidebar.icon, meta.ts icon, navigation.tabs/selectors icons, and Card/Step/Icon/etc. props. There is no FontAwesome or Tabler support and no iconType prop. Names must be kebab-case (book-open, not BookOpen) — 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 and Fern use 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)

---
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 and changelog drive feeds, other values only mean something under content.types
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: date/authors (blog/changelog feeds), changelog (changelog metadata), deprecated, and the top-level shorthands hidden (for sidebar.hidden) and noindex (for seo.noindex).

Authoring features (no imports needed in .mdx)

  • The rich features are MDX-only. Directives, package-install, mermaid, and math are wired into the MDX processor; in a plain .md file a :::note stays literal text — and the build stays green, with only a BLUME_MD_DIRECTIVE warning at its line. Rename any .md file that uses (or should use) these to .mdx during migration. MDX is stricter prose than Markdown: any < followed by a non-space character that doesn't open a tag (<7, <50 ms, <= 5, List<T>) and a bare { or } fail the build, so escape them (&lt; or \<, and \{) in any page you rename. With lastModified: "git", every page the migration edits shows the migration commit's date, so say so in the report when the site shows dates; a page it only renames or moves keeps its date (Blume follows renames). This bites hardest on Docusaurus/Starlight sources, whose .md content is full of ::: admonitions. Plain Markdown (headings, tables, fenced code with titles/highlighting) is fine in .md.
  • Callouts as directives: :::note, :::tip, :::warning, :::danger, :::info, :::success, with an optional title in brackets, which renders its inline Markdown: :::warning[Heads up]. Aliases caution→warning, error→danger, important→note, warn→warning. A callout that needs its own icon or tint is <Callout type icon color title> (a Lucide icon, any CSS color), which a directive can't set. markdown-it sources (VitePress, VuePress, Docusaurus v2) write ::: tip Title: with a space after the colons it's never a directive, even in .mdx, and renders as literal text (.mdx warns BLUME_DIRECTIVE_SPACED_NAME only for a callout name); inside an open ::: container it closes that container and its text is dropped (BLUME_DIRECTIVE_CLOSING_TEXT); a title after the name (:::tip Title) is dropped too (BLUME_DIRECTIVE_OPENING_TEXT). Normalize to :::tip[Title]. MDC's two-colon blocks (Docus, any Nuxt Content site: ::note … ::) are leaf directives to Blume and print as text (BLUME_MDC_SYNTAX), and MDC [text]{.class} spans fail the MDX build (BLUME_MDX_ATTRIBUTE_LIST names the line). GitHub alerts (> [!NOTE], any of GitHub's five, [!CAUTION] as danger) render as callouts in .mdx, text after the marker as the title; in .md they stay quotes that show the marker (BLUME_MD_GITHUB_ALERT), so rename those pages to .mdx.
  • No-import MDX components: Callout, Card/CardGroup, Columns/Column, Steps/Step, Tabs/Tab, Accordion/AccordionItem, Expandable, FileTree, Tree/Tree.Folder/Tree.File, CodeGroup, Frame, Panel, Tooltip, Tile, Badge, Icon, TypeTable/AutoTypeTable, Color, YouTube, Visibility, GithubInfo, Component, CodeBlock, Diff, Prompt, Math, Changelog, and the hand-written endpoint fields ParamField/ResponseField/RequestExample/ResponseExample (Mintlify's semantics: <ParamField path|query|header|body="name"> names the field by its location; see docs/references/api-pages.mdx). (Not shipped — convert away: <Warning>/<Note>/<Info>/<Tip>/<Check>/<Error> → the matching ::: directive, and RequestField. A <CodeBlock> with no code prop, used as a wrapper around a fence as Fern and Mintlify do, renders the fence as written but ignores its own title: unwrap the fence and put the title in its meta. A component that shows its children, used with none and another tool's props (<Badge type="tip" text="beta" />), renders empty and warns BLUME_UNKNOWN_PROP: put the content between the tags. See the reference files for targets.)
  • Fenced-code superpowers: ```package-install → package-manager tabs; ```mermaid → a rendered diagram; code-block titles (```ts server.ts), line numbers (lineNumbers), and highlighting ({1,4-5}, // [!code ++]).
  • Fences and titles that trip migrations: a fence language Shiki doesn't know renders as plain text and warns BLUME_UNKNOWN_CODE_LANGUAGE (case doesn't matter: JSON highlights like json): fix the id, or write text for plain text. A language glued to attributes with a comma (```rust,ignore) is one of those; with a space instead, rustdoc's and mdBook's keywords (ignore, no_run, should_panic, edition2021, editable, …) warn BLUME_CODE_FENCE_OPTION and stay out of the title, but any other second word becomes the block's title: keep only the language. Other tools' fence options (hl_lines="2 3", linenums="1", showLineNumbers, Mintlify's lines, wordWrap, filename="…") do nothing, stay out of the title, and warn BLUME_CODE_FENCE_OPTION with the Blume spelling ({2,3}, lineNumbers, wrap, title="…"). A fence with options but no language needs one (```text title="x" lineNumbers), or the first option is read as the language. A title is every word after the language, and a bracketed one (```ts [file.ts], from VitePress and MDC) drops its brackets, with a range glued after them ([file.ts]{2}) highlighting its lines. Expandable, AccordionItem, and callout titles render inline Markdown (code, emphasis, links); a Step title is a plain string that shows Markdown as written and has no anchor, so keep an anchored heading as the first line inside <Step>. Raw HTML <img src> and <a href> are never optimized: a relative one naming a file beside the page publishes that file with the page (as [spec](./spec.pdf) and <Card img="./cover.png"> do), a root path gains deployment.base like a Markdown link, and blume validate checks both, but a relative <a href> to a page resolves against the page's URL, not its folder. Convert images to Markdown so they're optimized, and page links to Markdown links or root routes.
  • Math: $$…$$ renders in .mdx with no config (there is no markdown.math field) — as a block on lines of its own, or inline within a sentence. A single $ always stays literal text, so convert each inline $…$ to $$…$$ in place, inside its sentence (not onto a line of its own), checking every pair is math and not currency or a shell variable.

OpenAPI

reference: [openapi({ sources: [{ spec, label?, route? }] })] — the openapi() adapter imported from blume/reference (spec is the single-source shorthand) — 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 navigation.tabs entry pointing at the adapter's route (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 openapi() at the spec. To keep a source's Scalar embed instead, list scalar({ spec, theme?, …scalarOptions }) (also from blume/reference) in reference in place of openapi(): it renders an OpenAPI or AsyncAPI document as one embedded page per source, forwards every key it doesn't name verbatim to Scalar, and doesn't take the native display options (codeSamples, expandSchemas, playground). There is no renderer option on openapi()/asyncapi() — it fails validation. Blume 1.x's top-level openapi/asyncapi/graphql blocks are gone — when a source config (or an older blume.config.ts) has openapi: { enabled: true, ... }, rewrite it as an entry in reference and drop enabled; a 1.x block with renderer: "scalar" becomes its own scalar({ … }) entry instead, keeping the block's route, sources, and noindex, with its theme and the keys of its scalar: { … } object passed straight to scalar() (scalar({ spec, theme: "purple", localization })).

  • Vendor the spec by default. A remote spec: 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 (openapi/<name>.json) and pointing spec at the local path; if the source shipped it as public/openapi.json, move it (public/specs/openapi.json if readers download it): that URL is where Blume publishes its own docs API description (BLUME_PUBLIC_OPENAPI_JSON). If you keep the URL, say so and consider a prebuild step that refreshes the local copy with a fallback.
  • Operation routes have their own slug scheme — <route>/<slugified-tag>/<slugified-operationId> (e.g. tag Models, id listModels → /api-reference/models/list-models). A camelCase or PascalCase tag or operation id is split at its word boundaries into kebab-case before slugifying (InboxesThreads → inboxes-threads, getHTTPResponse → get-http-response), so don't just lowercase it; an operation with no operationId takes its method and path instead (GET /pets/{id} → get-pets-id). A slug only has to be unique within its tag: a second list in the same tag gets its method added (list-post). This rarely matches the source's endpoint links (Mintlify/others kebab-case differently), so rewrite every inbound link to an operation. blume validate resolves operation pages like any other route, so it catches the ones you miss.
  • A spec served at its own path (Redocly serves apis/x/openapi.yaml at /apis/x/openapi): set the source's route so the overview keeps its old URL where the sidebar allows it (the source's reference says when a folder route reads better); operation, tag, and section URLs still need redirects.
  • Operation URL redirects come from the old sitemap, one exact entry per operation, generated rather than typed: build once, then node <skill>/scripts/operation-routes.mjs dist prints each reference's endpoints (GET /pets/{id}) mapped to their Blume routes, read from the build (--spec <route>=<file.json> adds operationIds); match each old URL to its endpoint through the spec. Rewrite inbound links before adding them: blume validate accepts any redirect from as a valid link target, so it stops flagging old-shaped links once the redirects exist.
  • Tag labels and order come from the spec. A tag's x-displayName labels its group; tags follow the spec's tags list, and operations its paths and methods as written. x-tagGroups isn't read: when the source ordered by it, give each tag <route>/<tag-slug>/meta.ts → defineMeta({ order }) with its position in the flattened x-tagGroups. A tag folder's meta.ts merges over the generated settings, so set only what differs (title, order, or a pages list to reorder operations). Under a scoped content.include, add a glob that reaches the tag folder, or the file is never read (BLUME_META_OUTSIDE_INCLUDE).
  • Prose a source attached to a generated endpoint page (ReadMe and Mintlify allow it) belongs in that operation's description, set with an overlay: Markdown and ::: directives render on the operation page, but JSX doesn't (< and { are escaped).
  • An API defined in a non-OpenAPI format (a Fern Definition) is exported to OpenAPI first; the source's reference says how.
  • Try it requests go straight from the reader's browser. Probe each API server for CORS (curl -si -X OPTIONS -H 'Origin: https://<docs-host>' -H 'Access-Control-Request-Method: GET' <server>/<path>); list the ones without a matching access-control-allow-origin as a follow-up (playground: { proxy: true } fixes them on a server-output host), or turn the playground off for them.
  • 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() adapter's route merges into the reference tab's sidebar — so keep those (auth, errors, rate limits) and delete only the per-endpoint stubs.

GraphQL

reference: [graphql({ spec, endpoint? })] is the GraphQL counterpart: Blume lowers a schema — SDL text or an introspection JSON result, local path or URL — into one real page per root field (grouped as Queries/Mutations/Subscriptions) plus one page per named type (Objects, Input Objects, Enums, Interfaces, Unions, custom Scalars). Every OpenAPI rule above carries over: never hand-migrate a source's generated GraphQL reference pages (delete them and point spec at the schema), vendor a remote schema locally, keep hand-written conceptual pages under the route (default /graphql), add the navigation.tabs entry, and rewrite inbound links — routes are <route>/<slugified-group>/<slugified-name> (e.g. /graphql/queries/pets, /graphql/objects/pet), which blume validate resolves like any other page. Differences from the openapi() adapter:

  • Set endpoint to the live GraphQL URL. A schema, unlike an OpenAPI document, names no server — endpoint is what the Try It playground and code samples target (without it they render a placeholder URL, and playground: { proxy: true } warns at build time because the proxy has no origin to allow). Multiple schemas use sources: [{ spec, endpoint?, label?, route? }]; a per-source endpoint overrides the adapter-level one.
  • No Scalar embed — there is no scalar() counterpart for a GraphQL schema (and graphql() takes no expandSchemas); the reference is always Blume-rendered (the Scalar embed reads OpenAPI and AsyncAPI documents only). A source's GraphQL playground/explorer embed (GraphiQL, Apollo Explorer) has no direct equivalent beyond the built-in Try It panel; report anything it did that the panel doesn't.
  • Only SDL and introspection JSON are accepted. A source that builds its schema programmatically (a GraphQLSchema instance in code) must be printed to SDL (printSchema from graphql) and committed; report that conversion.

Changelogs

If the source ships a hand-maintained changelog (a changelog.mdx, a folder of dated entries, Mintlify <Update> blocks, a ReadMe changelogs/ folder) and its release notes live in that repository's GitHub releases, offer to replace it with the githubReleases() 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 content.sources alongside the filesystem source (both imported from blume/sources; with sources present, the content root moves into filesystem()):

import { filesystem, githubReleases } from "blume/sources";

content: {
  sources: [
    filesystem({ include: ["docs/**/*.mdx"], root: "." }),
    githubReleases({
      owner: "haydenbleasel",
      repo: "ultracite",
      prefix: "changelog",
    }),
  ],
},
  • Each release materializes as a type: changelog page under /<prefix>/ (prefix: "changelog" → /changelog/…); omit prefix to mount at the root.
  • Optional fields: limit (cap materialized releases, newest-first, default 100), prereleases (include prereleases), drafts (include drafts — needs a token with repo write access), pollInterval (dev polling seconds; omit to freeze for the session).
  • A private repo reads a token from GITHUB_TOKEN; it is never inlined in config. The adapter declares it, so blume dev/build warn when it is unset; a public repo still works without it.
  • Delete the old changelog pages once the source is wired (and add redirects from their old routes to the new /<prefix>/… slugs). Pin a header/sidebar link with navigation.featured if the source had one. To keep the old changelog's introduction above the list, write changelog/index.mdx (mode: center) with the intro and <Changelog />, which renders the generated release list.

Redirects are static

A redirects: [{ from, to, status? }] array in blume.config.ts 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. status defaults to 301 (permanent — browsers cache it indefinitely); that's correct for genuine moves, but never use 301/308 for redirects you might reverse. Patterns carry over as written: :name for one segment, :name* or a trailing * for the rest of the path ({ from: "/beta/:slug*", to: "/v2/:slug*" }). A pattern can't also match a page, so the build fails (BLUME_REDIRECT_MATCHES_PAGE) if one covers a page you kept; narrow it. Rules a pattern can't express (regex params, header or cookie conditions) move to host-level config (_redirects, vercel.json); report them.

A redirect whose from equals a page's route (a generated reference overview included) warns BLUME_REDIRECT_MATCHES_PAGE and wins: that page never publishes. Drop the redirect, or move the page. The same goes for a page's Markdown mirror (/x.md, /x.mdx, the home page's /index.md): agents lose it, so drop the redirect. Never write a redirect from a page's own folder URL either (/x/, common when the source served trailing-slash URLs): it takes the page over the same way. One from /x/index.html (a source that served .html URLs) is harmless but unneeded: static hosts serve the page there from x/index.html, so a static build writes no redirect page for it, and only the host files (_redirects, vercel.json) carry it. And redirects match paths only: a URL whose page sits after # never reaches them (see references/docsify.md). When the old site lives on a host you don't control (a GitHub wiki), no redirect can reach its URLs: don't promise any (see references/github-wiki.md for what can be done there). Trailing slashes need no redirects of their own: blume dev and blume preview answer /a/ with a 301 to /a (query kept), for pages and redirect sources alike, as Vercel does; other static hosts serve /a/ from a/index.html, and server builds (vercel(), node()) match /a/ for a redirect from. Whether a static host's redirect rules match a slashed from depends on the host, so when old links used trailing slashes, check one on a preview deployment. On Netlify, set deployment: netlify({ output: "static" }): only that build's _redirects rules are forced (301!), so without it Netlify serves the meta-refresh page Astro writes at each from with a 200 instead of a real 301 (a bare netlify() switches to server output).

Verification & reporting

  1. Run blume build — it validates the frontmatter schema, duplicate routes, and config, and fails on any error diagnostic by default (don't pass --no-strict: that builds anyway and silently drops pages with invalid front matter or .mdx that doesn't parse). Then run blume validate --strict — links, heading anchors, assets, and redirect targets live here, not in build (add --external to also check outbound HTTP links). OpenAPI operation pages are real routes to validate, so dead links to them are caught too. Iterate until both are clean. Treat every warning build (or blume check) prints about another tool's syntax left in a page (BLUME_TEMPLATE_TAG, BLUME_MDC_SYNTAX, BLUME_WIKILINK_UNSUPPORTED, BLUME_MD_*, BLUME_CODE_FENCE_OPTION, BLUME_MDX_*, …) as a conversion you missed: each names the file and line and the Blume spelling, and validate --strict fails on them. validate reads links as the page renders them: a link definition shadowed by an earlier one with the same label isn't checked (it's dead text; delete it), and neither are links in comments. validate --strict also fails on OpenAPI spec warnings (BLUME_OPENAPI_*, such as an undeclared path parameter, a localhost server, or nullable in a 3.1 spec), which no content edit clears: fix them with an OpenAPI Overlay in the source's overlays so a generated or third-party spec stays untouched, and report them for a fix at the source; a codeSamples id Blume has no generator for (BLUME_OPENAPI_UNKNOWN_CODE_SAMPLE) is dropped from the config instead. If you added redirects, run blume audit --only redirects after the build: validate warns about a redirect whose to leads nowhere (BLUME_BROKEN_REDIRECT, at its line in blume.config.ts), but accepts a to that is another redirect's from, so only audit follows each one through the build and reports loops and chains. To check every old URL, request them against blume dev or blume preview (curl -sL; both apply every redirect, patterns included, with its status), or check them against dist/ plus the redirect rules (dist/blume-redirects.json when no host adapter is set, else the host's _redirects or vercel.json). Old heading anchors: every source slugs heading ids differently from Blume. After the first build, run node <skill>/scripts/pin-heading-ids.mjs --old <old site URL or its built-HTML dir> (--map dist/blume-redirects.json when pages moved, built with no host adapter, --write to apply, --only-linked to pin only linked ids) and rebuild until it reports 0; on a server build it reads dist/client itself (skip it for ReadMe and a GitHub wiki: their heading ids already match Blume's, a ReadMe hub rate-limits a crawl, and the script can't read a wiki's pages); any BLUME_BROKEN_ANCHOR left was already dead on the old site, so retarget the obvious ones, drop the fragment from the rest, and report them.
  2. Run blume dev and review the site visually (blume dev/preview --port N move to the next free port when N is taken, so read the Local http://… line they print before requesting URLs) — nav structure, tabs, theme, rendered components.
  3. Write a migration summary covering: what was migrated (config, N pages, nav, API references), what was dropped (navbar CTAs, custom theming, conditional redirects, unmappable icons, unsupported components), and suggested follow-ups (blume eject for full control, blume add to vendor a component for customization).

Full documentation

The mapping details live in references/: one file per source framework (mintlify.md, docusaurus.md, fumadocs.md, nextra.md, starlight.md, vitepress.md, vuepress.md, mkdocs.md, mdbook.md, docus.md, fern.md, gitbook.md, readme.md, redocly.md, docsify.md, jekyll.md, github-wiki.md), plus monorepo.md for host-repo integration (content-layout detection, pnpm minimumReleaseAge, 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; --write to apply). The Docsify content pass is automated by scripts/docsify-codemod.mjs (zero-dependency, deterministic, idempotent; --write to apply, --snapshot <dir> to record the old heading ids for pin-heading-ids.mjs, --group-folders for a folder per sidebar group and the route table). The Fern placement, frontmatter, component, and redirect pass is automated by scripts/fern-codemod.mjs (zero-dependency, deterministic, idempotent; --write to apply, endpoints after the first build). The ReadMe content pass is automated by scripts/readme-codemod.mjs (zero-dependency, deterministic, idempotent; --write to apply, --routes <file> for endpoint redirects after a build). The MkDocs content pass is automated by scripts/mkdocs-codemod.mjs (zero-dependency, deterministic, idempotent; --write to apply). The Jekyll content and navigation pass is automated by scripts/jekyll-codemod.mjs (zero-dependency, deterministic; it runs once, writing jekyll-migration.json and refusing a folder that already has one; --write to apply, --old <build> for URLs and the sidebar, --callouts for a site whose _config.yml names none). The GitHub wiki pass is automated by scripts/github-wiki-codemod.mjs (zero-dependency, deterministic, idempotent; routes writes the route table, convert --write applies, check verifies the table and old URLs against a build, stubs writes link stubs for the old wiki). The Docus content pass is automated by scripts/docus-codemod.mjs (zero-dependency, deterministic, idempotent; --write to apply). The VitePress content pass is automated by scripts/vitepress-codemod.mjs (zero-dependency, deterministic, idempotent; --write to apply, --redirects <old build> for the .html redirect list). Operation routes for redirects come from scripts/operation-routes.mjs (zero-dependency, read-only; reads a built dist/), partial-file and out-of-root includes are generated by scripts/include-excerpts.mjs (zero-dependency, deterministic; copied into the project, it runs before every build), and old heading ids are pinned by scripts/pin-heading-ids.mjs (zero-dependency; reports by default, --write to apply). The authoritative Blume docs are bundled in the installed package's docs/ directory — not necessarily at the repository root: in a workspace monorepo (pnpm especially) the package lives in the depending workspace's node_modules (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 apps/docs/content/docs.) The most relevant pages:

  • configuration/index.mdx — every blume.config.ts field.
  • content/navigation.mdx — the sidebar/tabs/selectors model.
  • content/meta.mdx — meta.ts and display modes.
  • content/syntax.mdx — directives, code features, math.
  • content/components.mdx — the component library and APIs.
  • content/frontmatter.mdx — the strict page schema.