Migrate
Migrate your docs from mdBook
Hand your mdBook to a coding agent, rebuild SUMMARY.md as folders that keep each chapter's path, redirect every old .html page, and keep included code generated from your source on every build.
By Hayden Bleasel9 min read

By the end of this guide, your mdBook is a Blume site: your SUMMARY.md rebuilt as folders that keep each chapter's path, every old .html address redirecting to its page, your admonitions and diagrams converted, and the code your chapters pull in with {{#include}} still generated from the source on every build. A coding agent does the conversion, and you review it.
It's written for mdBook 0.4 and 0.5 books on the built-in HTML renderer, with plugins like mdbook-admonish and mdbook-mermaid. The Markdown carries over; the runnable Rust examples and mdbook test don't.
What carries over
The run behind this guide migrated a real mdBook: 39 chapters in parts, with 52 includes and code pulled from the crate's examples. Every chapter carried over (one that only sent readers to another site became a redirect), all 43 old URLs reached a page, and every old heading anchor still works. Here's how the pieces map:
| In mdBook | In Blume |
|---|---|
book.toml | blume.config.ts, in the same folder |
SUMMARY.md parts and nested chapters | Folders with a meta.ts; a parent chapter becomes its folder's index page |
README.md chapters | index.md |
/guide/setup.html | /guide/setup, with a redirect from the old URL |
{{#include file.rs:anchor}} and line ranges | <include> of an excerpt generated from the source before each build |
| An included one-line file, like a version | A {{variable}} read from that file |
```rust,ignore and the other Rust attributes | ```rust |
Hidden # lines in Rust blocks | Removed: readers only saw them through the eye toggle |
```admonish tip | :::tip, in .mdx pages |
```mermaid | The same fence, rendered natively in .mdx |
[output.html.redirect] | redirects in blume.config.ts |
[output.linkcheck] | blume validate --strict |
| Built-in search | Blume's built-in search |
Here's one chapter before and after:
# Connecting to Acme
Every program starts with a client:
```rust,ignore
{{#include ../../../examples/hello/src/main.rs:connect}}
```
Then call it from `main`:
```rust
# use acme::Client;
# fn connect() -> Client { unimplemented!() }
fn main() {
let client = connect();
}
```
```admonish tip title="Keep it running"
Reuse one client for the whole program.
```
## Next steps
Read [the introduction](../README.md).---
title: Connecting to Acme
sidebar:
label: Connecting
---
Every program starts with a client:
<include lang="rust">/_excerpts/hello/connect.rs</include>
Then call it from `main`:
```rust
fn main() {
let client = connect();
}
```
:::tip[Keep it running]
Reuse one client for the whole program.
:::
## Next steps
Read [the introduction](../index.md).Before you start
The agent edits your repository in place, so start on a new branch with a clean working tree:
git switch -c migrate-to-blumeThen build the book once with the mdBook version your CI pins and the same plugins, into folders outside it. The build is your list of old URLs and the source of its heading anchors. The second command writes each chapter as Markdown after mdBook ran its includes, which is the only record of what each {{#include}} showed. From the folder holding book.toml (book here):
cd book
mdbook build -d ../old-book
MDBOOK_OUTPUT='{"markdown": {}}' mdbook build -d ../old-md
(cd ../old-book && find . -name '*.html' ! -name print.html ! -name toc.html ! -name 404.html \
| sed 's|^\.||' | sort) > ../old-urls.txtIf book.toml also has an [output.linkcheck] or another output table, the HTML lands in ../old-book/html: use that folder in the last command, and wherever this guide says ../old-book. Keep these folders until you're done, and don't commit them. You also need Node.js 22.19 or later, and Claude Code or Codex installed and signed in.
Run the migration
From the folder holding book.toml, run:
npx blume migrate mdbook --claudeTo use Codex, swap --claude for --codex. Leave out mdbook and Blume detects the source from a book.toml in that folder, or in docs/ or book/ below it.
blume migrate converts nothing itself. It opens the agent on the blume-migrate skill inside the package, pointed at its mdBook reference. Following it, the agent:
- Writes
blume.config.tsbesidebook.toml, with your book'ssrcas the content root, frombook.tomland your theme: title, edit links, colors, analytics, and the site URL. - Rebuilds
SUMMARY.mdas folders andmeta.tsfiles. A parent chapter likeguide.mdmoves toguide/index.md, which keeps its URL. Each part becomes a sidebar heading. - Converts every
{{#include}}: code becomes a generated excerpt, a one-line file becomes a variable, and a Markdown file becomes an<include>. - Cleans up the code blocks and converts admonish blocks to callouts, renaming the pages that now need MDX to
.mdx. - Adds a redirect from every old
.htmlURL, carries over your[output.html.redirect]entries, and pins any heading anchor that changed. - Scaffolds
package.json, replaces the mdBook steps in CI, and runsblume build,blume validate --strict, andblume audit --only redirectsuntil they pass.
It ends with a summary of what it migrated, dropped, and approximated. Keep it for the review. If it asks for the old build, point it at ../old-book and ../old-md.
Includes and excerpts
mdBook can include part of any file in the repository, by an ANCHOR name or a line range. Blume's <include> takes whole files, and only from inside your content folder. Pasting the code into each page would freeze it, while the real code keeps changing and passing its tests. So the agent copies a small script into your book folder, include-excerpts.mjs, which writes each excerpt to a file before every build, and the pages include those files.
Each excerpt is one line in excerpts.json: the file to write, and what goes in it, in mdBook's own syntax with the path relative to excerpts.json:
{
"out": "src/_excerpts",
"excerpts": {
"hello/connect.rs": "../examples/hello/src/main.rs:connect"
}
}The script writes the excerpts to src/_excerpts/. Blume doesn't publish a folder whose name starts with _, and the folder is generated, so it's in .gitignore. A code block that mixed an include with lines written in the page keeps both, joined in order:
"hello/client.rs": [
{ "text": "impl Client {" },
"../src/client.rs:ping",
{ "text": "}" }
]The script runs first in your package.json scripts, so npm run dev, npm run build, and your host's build all start from current code. Run it again after you edit a source file while npm run dev is running.
"scripts": {
"dev": "node include-excerpts.mjs && blume dev",
"build": "node include-excerpts.mjs && blume build",
"preview": "blume preview",
"validate": "node include-excerpts.mjs && blume validate --strict"
}Before it writes anything, the script checks every excerpt and lists every problem it finds. The usual one is an anchor that no longer exists:
excerpts.json: "hello/retry.rs": no "ANCHOR: retry" line in ../examples/hello/src/main.rs
excerpts.json: "hello/shutdown.rs": lines 20-31 run past the end of ../examples/hello/src/main.rs (12 lines)
include-excerpts: 2 problem(s); nothing written.mdBook showed a missing anchor as an empty code block, with no warning, so your book may already have some. For each one the agent finds where the code went and lists what it chose. If your team owns that code, the best fix is to put the anchor back around it, and the excerpt follows the code from then on:
// ANCHOR: retry
fn retry(client: &Client) -> Result<(), Error> {
client.ping().or_else(|_| client.ping())
}
// ANCHOR_END: retryThe alternative is a line range like "../examples/hello/src/main.rs:4:6". It keeps working until lines are added above it, and then it quietly selects the wrong code. Code that now fills a block that used to be empty can also contradict the prose around it, so read those pages.
Review the changes
Start with git diff --stat, run npm run dev, and work through these, which are where an mdBook migration most often needs a second look.
- Excerpts. Read the agent's list of missing anchors and what it did with each, and compare a few excerpts with the old build.
- Navigation. Click through the sidebar against your old table of contents. Parts are headings, and parent chapters are collapsible groups whose first row is the chapter itself. Section numbers, separators, and draft chapters are gone. A chapter listed after the parts, or between parent chapters in a book without parts, now sits at the top of the sidebar unless the agent grouped it.
- Titles. Each page's title is its old H1. Where your
SUMMARY.mdgave it another name, that name is now its sidebar label. - Callouts. An admonish block with a type mdbook-admonish didn't know, like
warn, showed as a plain note, so it's a note now. Change the ones that should warn. - Git. Run
git status --ignoredon your content folder. A.gitignorerule that covers it hides every newmeta.tsfromgit add, even though your old pages stay tracked. - The repository. CI that ran
mdbook buildandmdbook testnow runsnpm run buildandnpm run validate, and.gitignorelistsdist,.blume, and the excerpts folder. A deploy that runs from another repository needs the same change.
Check every old URL
Build the site, then check that each URL in your saved list is a page Blume built or an old .html address with a redirect to one. Save this in the folder holding blume.config.ts:
import { existsSync, readFileSync, statSync } from "node:fs";
const lines = (file) => readFileSync(file, "utf8").split("\n").filter(Boolean);
const isFile = (file) => existsSync(file) && statSync(file).isFile();
const isPage = (route) =>
/^https?:\/\//.test(route) ||
isFile(`dist${route.replace(/#.*/, "").replace(/\/$/, "")}/index.html`);
const rules = isFile("dist/_redirects") ? lines("dist/_redirects") : [];
const redirects = new Map(rules.map((rule) => rule.split(/\s+/)));
let missing = 0;
for (const url of lines(process.argv[2])) {
// /x/index.html is the page itself; every other old URL needs a redirect.
const target = url.endsWith("/index.html")
? url.slice(0, -"index.html".length)
: redirects.get(url);
if (!(target && isPage(target))) {
console.log(`MISSING ${url}${target ? ` -> ${target}` : " (no redirect)"}`);
missing += 1;
}
}
console.log(missing === 0 ? "Every old URL reaches a page." : `${missing} old URL(s) don't.`);Run it after a build:
npm run build
node check-urls.mjs ../old-urls.txtIt reads dist/_redirects, which a static build writes when you name no host or name Netlify or Cloudflare. Add a redirect for each URL it prints and run it again until it prints nothing. Don't add one for an index page like /guide/index.html: that URL already serves the page. print.html and toc.html aren't in the list, since they have no Blume equivalent.
Heading anchors usually match already, since mdBook and Blume build them almost the same way. To check, run the script the agent used, which compares every heading in your old build with the new one:
npm run build
node node_modules/blume/skills/blume-migrate/scripts/pin-heading-ids.mjs --old ../old-bookIt should report 0 heading line(s) need a pin. If it finds some, add --write, rebuild, and run it again until it reports 0. Then run npm run validate, which checks every link to an anchor, including ones that were already broken on your old site.
Deploy and switch over
Many mdBooks are on GitHub Pages, and a Blume site can stay there. Blume can't detect your URL on GitHub Pages, so set it as deployment.site, and turn an mdBook site-url into deployment.base:
deployment: { site: "https://docs.acme.example" },Deploy Markdown docs to GitHub Pages has a complete workflow. Run its steps from your book folder, with defaults.run.working-directory: book on the job, and use npm run validate and npm run build in place of its npx blume validate and npm run docs:build so the excerpts are generated. Then upload book/dist. If your deploy pushes the build to a branch instead, it needs a .nojekyll file, or GitHub drops Blume's _astro folder. peaceiris/actions-gh-pages adds one for you.
On Netlify, name the host even for a static build: deployment: netlify({ output: "static" }), with netlify imported from blume/deploy, so an old .html URL gets a real 301 instead of a redirect page. For other hosts, see Deployment.
Before switching your domain, deploy a preview and open a few old .html URLs on it, since hosts differ in how they serve redirects. Keep the old deployment until the new one passes.
What doesn't carry over
- Running code. The playground's Run button, editable examples, and
mdbook test. Excerpts stay tested wherever cargo compiles their source, but code written only in a page isn't compiled any more, so move examples you care about into the crate. - The print page.
print.htmlshowed the whole book on one page.export: { pdf: true }lets readers print one page at a time. - Book structure. Chapter numbers, separators, and draft chapters, and the hidden-line toggle in Rust examples.
- The theme. mdBook's theme switcher and themes, a custom
index.hbs, and admonish title bars, icons, and anchors. Your colors and fonts carry over as Blume theme settings. - Some plugins. Anything without a Blume equivalent, like mdbook-quiz or a custom preprocessor, is listed in the agent's summary with what it did.
Troubleshooting
The build fails with BLUME_META_LOAD_FAILED
If it says Cannot find module 'blume', your content folder is outside the book folder (src = "../docs"), and Blume 2.1.3 and earlier can't resolve blume from a meta.ts there. Update Blume, or export a plain object instead of calling defineMeta:
export default {
title: "Developers",
display: "flat",
pages: ["getting-started", "core-concepts", "backend"],
};A Rust block isn't highlighted
Its fence still says ```rust,ignore or another attribute after a comma. Blume reads the whole word as the language and warns about it as BLUME_UNKNOWN_CODE_LANGUAGE: keep only rust.
The build fails with BLUME_INCLUDE_NOT_FOUND on an excerpt
The excerpts weren't generated. Build with npm run build, which runs include-excerpts.mjs first, or run node include-excerpts.mjs yourself.
Next step
Migrate your book
Run it in the folder holding book.toml, on a clean branch, after building your old book. Then work through the review above.
npx blume migrate mdbook --claudeA step here not working for you? Report a broken step.