blume-migrate
GitHub将Mintlify、Docusaurus等文档站点迁移至Blume,自动转换配置、导航和组件。
Trigger Scenarios
Install
npx skills add haydenbleasel/blume --skill blume-migrate -g -y
SKILL.md
Frontmatter
{
"name": "blume-migrate",
"description": "Migrate an existing documentation site (Mintlify, Docusaurus, Fumadocs, Nextra, Starlight, or any docs framework) to Blume, the markdown-first docs framework on Astro. Translate the source config to blume.config.ts, restructure content into Blume's filesystem-derived navigation, rewrite JSX callouts to directives, convert icons to Lucide, and inline snippets. Use when the user asks to migrate\/convert\/port a docs repo to Blume, or when the repo has a docs.json\/mint.json, docusaurus.config.*, meta.json with fumadocs, _meta.* with nextra, or an astro.config.* with starlight()."
}
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, footer copyright text, 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
- 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 undercontent/docs/) → Fumadocs (references/fumadocs.md)._meta.{js,ts,json}+nextradeps → Nextra (references/nextra.md).astro.config.*callingstarlight({…})→ Starlight (references/starlight.md).- 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.rootscoping,minimumReleaseAge, lockfile,vercel.json, an Astro/Vite patch) — all inreferences/monorepo.md. 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 and GraphQL schemas, redirects, i18n locales, custom components, and icon usage. Note what's declared vs. defaulted.
- Write
blume.config.tswithdefineConfigfromblume. Map only declared fields (see the reference's mapping table); rely on defaults everywhere else. A minimal result isdefineConfig({ title: "…" }). - Restructure content. Choose
content.root(defaultdocs) — detect where.md/.mdxactually live, don't assume adocs/folder. Many repos keep content directly under an app dir (apps/docs/api/,.../getting-started/) with nodocs/subfolder; when so, setcontent.rootto that dir and scopecontent.includeto the real content folders rather than leaving a barecontent.root: "."that scans everything (seereferences/monorepo.md§1). Order with numeric prefixes (01-intro.mdx), group without a URL segment via(group)/folders, and add ameta.ts(defineMeta) only where filesystem order isn't enough. A source that already declares per-folder navigation in a sidecar file — Fumadocsmeta.json, Nextra_meta.*— is that case: convert each one to ameta.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. Reach for an explicitnavigation.sidebaronly 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 intoredirectsin 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.mdpage 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); 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); add aredirectsentry 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 (titlerenders 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 thegithubReleases()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 (seereferences/mintlify.md). - Adopt
package.json. Repointdev/build/start→blume dev/blume build/blume preview, remove the old framework's deps, addblume. A config-only source (e.g. a bare Mintlifydocs.json) has no manifest — scaffold one. In a pnpm workspace: ifpnpm-workspace.yaml/.npmrcsetsminimumReleaseAge, add onlyblumetominimumReleaseAgeExclude(don't disable the guard) so the just-published version installs. Always regenerate the lockfile in the same change: after editing deps run a plainpnpm install(from the workspace root) and commitpnpm-lock.yamlalongsidepackage.json— CI/Vercel use--frozen-lockfile, 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 bundledassets/oxfmt@0.67.0.patchand register it underpatchedDependencies— seereferences/monorepo.md§6. Seereferences/monorepo.md§2–3. - Wire up the host repo & deploy (non-trivial repos). For a monorepo on Vercel, emit the root-aware install/build recipe and
apps/docs/vercel.json, and tell the user the two settings you can't commit (Vercel Root Directory, Node 22). If the workspace pins Vite andblume buildcrashes inside Astro/Vite, apply the pnpm-patch workaround. All copy-pasteable inreferences/monorepo.md§4–5. - 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 invalid pages) andblume validate --strict(internal links, heading anchors, assets — the link checker lives invalidate, notbuild), fix diagnostics, thenblume devfor 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'smeta.tspagesarray → a page's frontmattersidebar.order→ the filesystem (indexfirst, then numeric filename prefix like01-, then alphabetical). meta.tsrefines one folder (defineMeta({ title, icon, order, collapsed, pages, display, directory })). Thepagesarray lists children by slug (numeric prefix and parentheses stripped); children you omit fall back to their ownsidebar.order, then filesystem order (indexstill sorts first) — so a partialpageslist 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.displayinblume.config.tsis"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:displayin itsmeta.ts, or — sugar when the folder has anindexpage —sidebar.displayin 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 explicitnavigation.sidebar, the config item's owndisplayfield is the only per-group control (frontmatter/metadisplayis ignored there, with aBLUME_SIDEBAR_DISPLAY_IGNOREDwarning). - An explicit
navigation.sidebarreplaces 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 you must either move those pages into a real subfolder (meta.tsfor label/collapsed) — which changes their URLs, so addredirects— or declare the group in an explicitnavigation.sidebar, which nests the existing routes without moving a file. Walk configpages/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'spathbecomes the section, so this needs no config beyond the tabs themselves; structure content as one folder per tab. A source's top-level tabs (Mintlifynavigation.tabs, a top-level product/section switcher) map to these header tabs — keep them as tabs; don't flatten them into a single globalnavigation.sidebar. Blume picks the active tab by URL prefix (longest tabpaththat prefixes the route), so every page in a tab must live under that tab's singlepath; a source tab that mixes arbitrary routes isn't portable as-is — either move its pages under one prefix (route change → addredirects) or accept the closest shape, and say which in the report (details inreferences/mintlify.md). The filtering runs both ways: on a route under a tab'spath, 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 whosepathis/), 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, andnavigation.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 inactions, a "Sign up"/"Get started" button incta. Anhttp(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 (belowsm), exceptctaon a page with no navigation toggle; a link that must survive on a phone on docs pages belongs infeatured.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.hrefpoints anywhere — an external URL opens in a new tab, an internal route (/contact) is validated against your pages at build time.iconis 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) — seereferences/mintlify.md.
Routes and pathing
- A route is the content path relative to
content.root, with numeric prefixes stripped (01-intro.mdx→/intro) and(group)/folders adding no segment. Anindexfile maps to its folder's route. Frontmatterslugoverrides the generated route.
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 besidetitlein the header, so a wordmark logo doubles the brand ("Acme Acme") — settext: ""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 itsfill/stroketocurrentColorand uselogo: "/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),backgroundandbackgroundImage(each a string, or{ light, dark }per mode). The oldaccentDark/backgroundDark/backgroundImageDarkfields 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. There is notheme.strictand notheme.cssconfig field — custom CSS goes in a project-roottheme.cssfile (auto-picked-up), and a source's "strict appearance" flags drop.content:root(default"docs", relative to the project dir whereblumeruns),include/exclude(arrays of globs relative tocontent.root; defaults["**/*.{md,mdx}"]/["**/_*", "**/.*"]),sources(an array of adapters imported fromblume/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 takesprefixandpollInterval, whilecustom(source)takes aContentSourceinstance that sets its ownprefix. The 1.x{ type: "…" }objects were removed — renametypeto the factory call and pass the other fields as its options — except{ type: "custom", source }, which becomescustom(source)with the instance as the only argument.root/include/excludeare shorthand for a singlefilesystem()and are rejected besidesources— move them into thefilesystem()entry. OpenAPI/AsyncAPI/GraphQL are not among these; they're adapters in the top-levelreferencelist),pages(custom.astrodir),defaultType. When docs sit directly under the project dir (nodocs/subfolder), setrootthere and scopeincludeto 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 (DocusaurusrouteBasePath, a FumadocsbaseUrlof/docs) — distinct from a per-sourceprefix(which adds a nav group) and fromdeployment.base(host subdirectory).navigation:tabs,selectors,actionsandcta(header links and the one filled button),featured(links pinned above the sidebar on every route),sidebar({ display, items }—displayis the global render mode above;itemsis an explicit tree),repo(true/false, or an absolute GitHub URL for the header mark when the docs repo is private andgithubmust stay unset). Avoid an explicitnavigation.sidebarunless 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 forsidebar.itemsonly for a shape files genuinely can't express (see "Config-declared nesting" above).search— an adapter imported fromblume/search:orama()(default, so omitsearchentirely for it),flexsearch(),pagefind(),algolia({ appId, apiKey, indexName }),oramaCloud({ endpoint, apiKey, indexId? }),typesense({ host, collection, apiKey }),mixedbread({ storeId }), orfalseto disable. Pass the adapter directly (search: algolia({…})) or assearch: { provider, popular, indexing }when you also set curated links or indexing options. Blume 1.x'ssearch.providerstring and itssearch.algolia/oramaCloud/typesense/mixedbreadcredential blocks are gone — when a source config (or an olderblume.config.ts) hasprovider: "algolia", algolia: { appId, indexName, searchApiKey }, rewrite it assearch: algolia({ appId, indexName, apiKey: searchApiKey })(the search-only key is nowapiKeyin every adapter that takes one; admin keys stay in env vars).ai(the assistant, Open in chat —ai.assistant.providertakes an adapter descriptor imported fromblume/ai:gateway({ model })(the default,openai/gpt-5.5),openai({ model })(oropenai({ baseUrl, name, model, apiKeyEnv })for any OpenAI-compatible endpoint),anthropic({ model }),gemini({ model }),grok({ model }),openrouter({ model, reasoning }),llmgateway({ model }), orinkeep({ model }). Every adapter takesmodel,apiKeyEnv,headers, and a verbatimproviderOptionspassthrough (headersvalues 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 varapiKeyEnvnames, never inheaders). Every adapter exceptinkeep()also takesreasoning: Inkeep runs its own answer pipeline, soinkeep({ reasoning })fails validation with an unrecognized key — drop a source's reasoning setting there and report it. There are no flatprovider/model/apiKeyEnv/baseUrl/headers/reasoningfields onai.assistant— a source that configured an AI assistant that way (Blume < 2.0 included) maps onto one adapter call.enabled,instructions,retrieval,suggestions,cors, andendpointsit onai.assistantitself; there is noai.ask— Blume 2.0.0 and earlier used that name, so an olderblume.config.tsmoves the whole block toai.assistant),agents(llms.txt, the JSON API, the MCP server, published skills, discovery manifests, robots content signals),reference(a list of adapters imported fromblume/reference—openapi({ spec | sources, route, … }),asyncapi({ … }),graphql({ spec, endpoint, … })— never the 1.xopenapi/asyncapi/graphqlblocks),redirects,seo,markdown,analytics(a list of adapters imported fromblume/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(), andscript({ 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? }— sethostwhenever the source's edit URL is on a GitHub Enterprise origin rather thangithub.com).deploymentis a host adapter fromblume/deploy—vercel(),netlify(),cloudflare(), ornode()— 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 (passoutput: "static"to stay static there);site,base, andoutputare the named options, and anything else is forwarded verbatim to the underlying@astrojs/*adapter. There is nodeployment.adapterstring and nodeployment.outputfield: a source that needs server rendering (the assistant, the MCP server, Mixedbread search, the API playground proxy) getsimport { vercel } from "blume/deploy"anddeployment: vercel(); a static source gets nothing — unless it served its docs under a subpath, which becomesdeployment: { base: "/docs" }(see below for when to addsite). Note thatcloudflareandvercelare also exported fromblume/analytics— alias one (import { cloudflare as cloudflareDeploy } from "blume/deploy") when a config uses both.- Keep the source's site URL as
deployment.siteunless the target host is one Blume auto-detects. Blume fillssitein from the platform's build environment only on Vercel, Netlify, and Cloudflare Pages, and uses the dev server'slocalhostURL duringblume dev; on those three hosts leave it unset and let detection pick the deployed URL. Everywhere else — GitHub Pages, S3 or another static host, a custom CDN, Cloudflare Workers, anode()server — nothing detects it, and an unsetsitesilently drops the sitemap, OG images, RSS feeds, the AI catalog, and absolute canonical URLs. So when the source config had aurl/sitefield and the target isn't one of those three hosts, carry it over:deployment: { site: "https://…" }for a static build, or thesiteoption 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}orfavicon.{svg,png,ico}(andapple-icon.png) in the project root orpublic/— Blume auto-detects it. There is nofaviconconfig 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-darksibling — same directory and extension,-darkbefore 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 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)
---
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, hidden, 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.mdfile a:::notestays literal text — and the build stays green. Rename any.mdfile that uses (or should use) these to.mdxduring migration. This bites hardest on Docusaurus/Starlight sources, whose.mdcontent 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::::warning[Heads up]. Aliasescaution→warning,error→danger,important→note,warn→warning. - 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. (Not shipped — convert away:<Warning>→ the:::warningdirective, and theParamField/ResponseField/RequestFieldfield family →TypeTablerows or the OpenAPI reference. 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 ++]). - Math: block math
$$…$$renders in.mdxwith no config (there is nomarkdown.mathfield). Inline$…$is not supported — a bare$stays literal text; convert inline math to display math or drop it (report).
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 pointingspecat the local path; if you keep the URL, say so and consider aprebuildstep that refreshes the local copy with a fallback. - Operation routes have their own slug scheme —
<route>/<slugified-tag>/<slugified-operationId>(e.g. tagModels, idlistModels→/api-reference/models/list-models). A camelCase operation id is split at its word boundaries into kebab-case before slugifying (getHTTPResponse→get-http-response), so don't just lowercase it; an operation with nooperationIdtakes its method and path instead (GET /pets/{id}→get-pets-id). This rarely matches the source's endpoint links (Mintlify/others kebab-case differently), so rewrite every inbound link to an operation.blume validateresolves 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()adapter'sroutemerges 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
endpointto the live GraphQL URL. A schema, unlike an OpenAPI document, names no server —endpointis what the Try It playground and code samples target (without it they render a placeholder URL, andplayground: { proxy: true }warns at build time because the proxy has no origin to allow). Multiple schemas usesources: [{ spec, endpoint?, label?, route? }]; a per-sourceendpointoverrides the adapter-level one. - No Scalar embed — there is no
scalar()counterpart for a GraphQL schema (andgraphql()takes noexpandSchemas); 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
GraphQLSchemainstance in code) must be printed to SDL (printSchemafromgraphql) 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) and the project is open source on GitHub, 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: changelogpage under/<prefix>/(prefix: "changelog"→/changelog/…); omitprefixto 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, soblume dev/buildwarn when it is unset; a public repo still works without it. - Delete the old changelog pages once the source is wired (and add
redirectsfrom their old routes to the new/<prefix>/…slugs). Pin a header/sidebar link withnavigation.featuredif the source had one.
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.
Verification & reporting
- 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 invalid pages). Then runblume validate --strict— links, heading anchors, and assets live here, not inbuild(add--externalto also check outbound HTTP links). OpenAPI operation pages are real routes tovalidate, so dead links to them are caught too. Iterate until both are clean. - Run
blume devand review the site visually — nav structure, tabs, theme, rendered components. - Write a migration summary covering: what was migrated (config, N pages, nav, API references), what was dropped (navbar CTAs, footer copyright text, custom theming, conditional redirects, unmappable icons, unsupported components), and suggested follow-ups (
blume ejectfor full control,blume addto 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), 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 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— everyblume.config.tsfield.content/navigation.mdx— the sidebar/tabs/selectors model.content/meta.mdx—meta.tsand display modes.content/syntax.mdx— directives, code features, math.content/components.mdx— the component library and APIs.content/frontmatter.mdx— the strict page schema.
Version History
- db27a1b Current 2026-09-27 13:15
- ecd01a0 2026-09-22 02:21
-
b3d59c7
2026-09-08 20:38
修复 migrate skill 中 display flat、graphql sources、github host 及 favicon 模式的配置问题。
-
e823872
2026-09-02 22:44
新增导航头部插槽支持 URL 格式及自定义操作按钮(CTA),修复非根部署路径下链接跳转错误问题。
- 962e46e 2026-08-27 10:31
-
97a9b30
2026-08-19 12:49
更新迁移技能以支持明暗模式 favicon 映射为文件名对,而非合并为单一文件。
-
eab66c5
2026-08-12 09:57
更新迁移技能以支持 per-group sidebar display 功能,确保迁移时保留源站点的分组显示模式而非扁平化。
- 50a9ea7 2026-08-04 19:25
-
2d525de
2026-08-02 22:19
修复 pnpm workspace 下文档路径解析问题;定义 <skill> 占位符以解决命令执行失败。
-
9a6345f
2026-07-19 17:06
修复 codemod 在目标父块位于源键前时错误拼接行导致的数据丢失问题;保留图标重映射后的尾随注释;更新过时的 mcp 配置引用。
- 725b0ab 2026-07-19 08:55


