Skip to content
Blume
Esc
↑↓navigate↵open⌘Jpreview
Guides

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 9 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 mdBookIn Blume
book.tomlblume.config.ts, in the same folder
SUMMARY.md parts and nested chaptersFolders with a meta.ts; a parent chapter becomes its folder's index page
README.md chaptersindex.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 versionA {{variable}} read from that file
```rust,ignore and the other Rust attributes```rust
Hidden # lines in Rust blocksRemoved: readers only saw them through the eye toggle
```admonish tip:::tip, in .mdx pages
```mermaidThe same fence, rendered natively in .mdx
[output.html.redirect]redirects in blume.config.ts
[output.linkcheck]blume validate --strict
Built-in searchBlume'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-blume

Then 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.txt

If 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 --claude

To 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:

  1. Writes blume.config.ts beside book.toml, with your book's src as the content root, from book.toml and your theme: title, edit links, colors, analytics, and the site URL.
  2. Rebuilds SUMMARY.md as folders and meta.ts files. A parent chapter like guide.md moves to guide/index.md, which keeps its URL. Each part becomes a sidebar heading.
  3. Converts every {{#include}}: code becomes a generated excerpt, a one-line file becomes a variable, and a Markdown file becomes an <include>.
  4. Cleans up the code blocks and converts admonish blocks to callouts, renaming the pages that now need MDX to .mdx.
  5. Adds a redirect from every old .html URL, carries over your [output.html.redirect] entries, and pins any heading anchor that changed.
  6. Scaffolds package.json, replaces the mdBook steps in CI, and runs blume build, blume validate --strict, and blume audit --only redirects until 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: retry

The 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.md gave 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 --ignored on your content folder. A .gitignore rule that covers it hides every new meta.ts from git add, even though your old pages stay tracked.
  • The repository. CI that ran mdbook build and mdbook test now runs npm run build and npm run validate, and .gitignore lists dist, .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.txt

It 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-book

It 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.html showed 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 --claude
Read the migration reference

A step here not working for you? Report a broken step.

Keep going.More guides.

  • Migrate your docs from GitBook

    Hand your Git-synced GitBook repository to a coding agent, keep every page at the URL it has today, convert GitBook's blocks to components, and deploy docs you host yourself.

  • Migrate your docs from ReadMe

    Hand your Git-synced ReadMe repository to a coding agent, keep every page at its flat URL, generate the API reference from your specs, and deploy docs you host yourself.

  • Migrate your docs from VitePress

    Hand your VitePress site to a coding agent, convert its Markdown extensions with a codemod, rebuild sidebar groups without moving URLs, and keep every old .html address and heading anchor working.

Upgrade your docs with Blume.

Install today and ship a production-grade docs site in minutes. Free and open source, forever.

npx blume init