Migrate
Migrate your docs from Docsify
Hand your Docsify site to a coding agent, convert its callouts, tabs, and includes with a codemod, rebuild its sidebar as folders, and keep every old #/ link and heading anchor working.
By Hayden Bleasel13 min read

By the end of this guide, your Docsify site is a Blume project. Your pages are built ahead of time instead of rendered in the browser. Your sidebar is folders, your callouts, tabs, and includes are converted, and every old #/ link still lands on the right page and heading. A coding agent does the conversion, starting with a codemod for the mechanical part, and you review it.
It's written for Docsify 4 and 5 sites with the default hash routing. The Markdown carries over. Vue widgets, comment plugins, and custom renderers don't, and the agent lists each one.
What carries over
The run behind this guide migrated a real Docsify site: 68 pages in nine sidebar groups, hundreds of code includes, tabs, and a cover page. Every page carried over. All 78 old hash routes and every ?id= deep link tested landed on the right page and heading. Here's how the pieces map:
| In Docsify | In Blume |
|---|---|
window.$docsify in index.html | blume.config.ts |
_sidebar.md groups | A folder per group, with a meta.ts in sidebar order |
_navbar.md | Header tabs and links |
_coverpage.md | The top of your home page |
README.md | index.md |
!> and ?> | :::warning and :::tip, in .mdx pages |
> [!NOTE] alerts | :::note and the other callouts |
docsify-tabs (<!-- tabs:start -->) | <Tabs> and <Tab> |
[x](file ':include'), with :type=code | <include>, with lang |
:fragment= includes | The fragment copied to its own file, then included |
## Title :id=custom | ## Title [#custom] |
The page's first # H1 | Its frontmatter title |
/#/guide/setup?id=retries | /guide/setup#retries, through a small redirect script |
| Search, copy-code, pagination, and zoom plugins | Built in |
Here's a sidebar and two pages, before and after the codemod:
- [Home](/)
- Getting started
- [Install](install.md)
- [Quickstart](quickstart.md)
- Guides
- [Webhooks](webhooks.md "Receive webhooks")# Install
Install the CLI, then sign in.
<!-- tabs:start -->
#### **macOS**
```bash
brew install acme
```
#### **Linux**
```bash
curl -fsSL https://acme.example/install.sh | sh
```
<!-- tabs:end -->
!> The installer adds `acme` to your `PATH`.
## Configure
[](_media/acme.yml ':include :type=code yaml')---
title: "Install"
---
Install the CLI, then sign in.
<Tabs>
<Tab title="macOS">
```bash
brew install acme
```
</Tab>
<Tab title="Linux">
```bash
curl -fsSL https://acme.example/install.sh | sh
```
</Tab>
</Tabs>
:::warning
The installer adds `acme` to your `PATH`.
:::
## Configure
<include lang="yaml">../_media/acme.yml</include># Quickstart
?> Numbers that start with `+1555555` never leave the sandbox.
Send your first message, then [configure the CLI](install.md?id=configure)
or [handle webhooks](webhooks.md?id=retries-amp-timeouts).---
title: "Quickstart"
---
:::tip
Numbers that start with `+1555555` never leave the sandbox.
:::
Send your first message, then [configure the CLI](./install.md#configure)
or [handle webhooks](../guides/webhooks.md#retries-amp-timeouts).The links now name files, so Blume checks them at build time. The #retries-amp-timeouts anchor is the id Docsify gave that heading. The agent pins it on the new page, so old links to it still work.
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 save the list of URLs your site serves today. A Docsify site renders in the browser, so there's no sitemap to read: every page lives after the #. This builds the list from your Markdown files and the links in your sidebar, navbar, and cover, and keeps it outside the repository. Run it from the folder that holds your docs/ folder:
d=docs # the folder that holds index.html
{
(cd "$d" && find . -name '*.md' ! -name '_*' ! -path '*/_*/*' ! -path '*/node_modules/*' | sed 's#^\.##')
find "$d" -maxdepth 2 \( -name _sidebar.md -o -name _navbar.md -o -name _coverpage.md \) \
-exec sed -E 's/!\[[^]]*\]\([^)]*\)//g' {} + | grep -oE '\]\([^)" ]+' \
| sed 's#^\](##' | grep -vE '^(https?:|mailto:|#)' | sed 's#^\([^/]\)#/\1#'
} | sed -E -e 's#\?.*$##' -e 's#\.md$##' -e 's#(^|/)README$#\1#' -e 's#/+$##' -e 's#^$#/#' \
| sort -u > ../old-routes.txtEach line is the part after # in an old URL, like /guide/setup. Add any route you know people link to that isn't a file, like an alias from index.html.
Open a few pages on your live site too. In Docsify 4, a page whose include points at a missing file renders blank, so some pages may never have worked. The agent reports each one. You also need Node.js 22.19 or later, and Claude Code or Codex installed and signed in.
Run the migration
From the folder that holds your docs/ folder, run:
npx blume migrate docsify --claudeTo use Codex, swap --claude for --codex. Leave out docsify and Blume detects the source from a docsify-cli or docsify dependency in your package.json. A Docsify site with no package.json has to name it.
blume migrate converts nothing itself. It opens the agent on the blume-migrate skill inside the package, pointed at its Docsify reference. Following it, the agent:
- Runs the bundled Docsify codemod over your pages. It converts callouts, tabs, includes, heading attributes, links, and emoji. It turns each page's H1 into its title, moves pages into a folder per sidebar group, and records the id Docsify gave every heading. Then it reports everything it leaves.
- Works through that report. That includes titles that may really be sections, include targets that don't exist, MDX errors waiting to happen, and files your pages link that have to move to
public/. - Writes
blume.config.tsfromindex.html: title, description, logo, theme color, repository, analytics, and the redirect script for your old#/links. - Rebuilds your navbar as header links or tabs, and your cover page as the top of your home page.
- Pins each heading's old Docsify id where Blume's differs, then swaps Docsify for Blume in
package.json. It runsblume build,blume validate --strict, andblume audit --only redirectsuntil they pass.
The codemod's report looks like this, one block per page:
README.md → index.md
1 × H1 → frontmatter title
1 × link → file link
install.md → getting-started/install.mdx
1 × !> → :::warning
1 × H1 → frontmatter title
1 × code include → <include lang>
1 × tab set → <Tabs>
quickstart.md → getting-started/quickstart.mdx
1 × ?> → :::tip
1 × H1 → frontmatter title
2 × link → file link
webhooks.md → guides/webhooks.md
1 × H1 → frontmatter titleAfter the pages, it lists site-wide items to review, the files that have to move to public/, how many routes changed, and totals. Keep it for the review.
How old links keep working
A Docsify URL like https://docs.acme.example/#/install?id=configure asks the server for /. Everything after the # stays in the browser, and Docsify read it there to pick the page. So no server redirect, host rule, or Blume redirects entry can ever see /install in that URL.
Instead, the agent adds a few lines of JavaScript to the <head> of every page of your new site, the 404 page included. When the address has an old #/ route, the script works out the page's new address on your site. It turns ?id=configure into #configure and replaces the URL before the page's body loads. Readers land on /getting-started/install#configure. Links inside your site never use #/, so the script does nothing else.
When the codemod moves pages, it writes the old and new routes to docsify-routes.json. A route that didn't move needs no entry: the script keeps its path.
{
"/install": "/getting-started/install",
"/quickstart": "/getting-started/quickstart",
"/webhooks": "/guides/webhooks"
}The config reads that table twice: once for the script, and once for ordinary redirects, which cover a visit to /install itself. It adds the script through a small inline Astro integration, so it runs in blume dev and every build:
import { defineConfig } from "blume";
import moved from "./docsify-routes.json" with { type: "json" };
// Old routes that now land on a file in public/: the script sends readers
// there, but a redirect can't, so they stay out of `redirects`.
const files = {}; // e.g. { "/onepage": "/onepage.md" }
// Docsify served every page at /#/<route>; a server never sees the hash.
// Swap an old hash URL for its new route, and ?id=<heading> for #<heading>.
const base = ""; // the site's deployment.base plus basePath, if it has either
const docsifyRedirects = `(() => {
const base = ${JSON.stringify(base)};
const routes = ${JSON.stringify({ ...moved, ...files })};
const go = () => {
const hash = location.hash;
if (!hash.startsWith("#/")) return;
const q = hash.indexOf("?");
let path = q === -1 ? hash.slice(1) : hash.slice(1, q);
const id = q === -1 ? null : new URLSearchParams(hash.slice(q + 1)).get("id");
try { path = decodeURI(new URL(path, "http://x").pathname); } catch {}
path = path.replace(/\\.md$/i, "").replace(/(^|\\/)README$/i, "$1");
if (path.length > 1) path = path.replace(/\\/+$/, "");
// location.origin keeps a crafted #//other.host/ link on this site.
location.replace(location.origin + base + (routes[path] ?? path) + (id ? "#" + id : ""));
};
go();
addEventListener("hashchange", go);
})();`;
export default defineConfig({
title: "Acme Docs",
redirects: Object.entries(moved).map(([from, to]) => ({ from, to })),
integrations: [
{
name: "docsify-hash-redirects",
hooks: {
"astro:config:setup": ({ injectScript }) => {
injectScript("head-inline", docsifyRedirects);
},
},
},
],
});If your site deploys under a path, like a GitHub Pages project site at /acme-docs/, set base in the script to that path as well as in deployment.base. If an old route now lands on a file in public/ rather than a page, add it to files: the script sends readers there, but it stays out of redirects.
If your site used routerMode: 'history', its URLs were real paths, so redirects already cover moved pages. Its anchors were still ?id=, so the agent adds this inside the script, after go();:
if (!location.hash) {
const id = new URLSearchParams(location.search).get("id");
if (id) location.replace(location.pathname + "#" + id);
}If your docs move to a new address and the old one keeps serving, say an old GitHub Pages URL while the new site lives on your own domain, replace the old site's index.html with a page that sends each old link to its new home:
<!doctype html>
<meta charset="utf-8">
<title>These docs have moved</title>
<script>
(() => {
const site = "https://docs.acme.example";
const routes = { "/install": "/getting-started/install" };
const hash = location.hash;
let path = "/";
let id = null;
if (hash.startsWith("#/")) {
const q = hash.indexOf("?");
path = q === -1 ? hash.slice(1) : hash.slice(1, q);
id = q === -1 ? null : new URLSearchParams(hash.slice(q + 1)).get("id");
try { path = decodeURI(new URL(path, "http://x").pathname); } catch {}
path = path.replace(/\.md$/i, "").replace(/(^|\/)README$/i, "$1");
if (path.length > 1) path = path.replace(/\/+$/, "");
}
location.replace(site + (routes[path] ?? path) + (id ? "#" + id : ""));
})();
</script>
<noscript><meta http-equiv="refresh" content="0; url=https://docs.acme.example/"></noscript>
<p>These docs have moved to <a href="https://docs.acme.example/">docs.acme.example</a>.</p>Review the changes
Start with git diff --stat, run npx blume dev, and work through these, which are where a Docsify migration most often needs a second look.
- Titles. Docsify showed each page's Markdown as written, so pages often start with a section heading or have several H1s. The codemod takes a leading H1 as the title, and the sidebar label when text comes first, and flags titles that may really be sections. Read each flagged page's title in the browser tab and sidebar.
- Pages that were blank. A missing include target blanked the whole page in Docsify 4, and Blume refuses to build it. Point each one at the file's new name, or drop it.
- Files your pages link. Docsify served every file in its folder at its own path. Blume publishes a file a page links relatively with that page, but at a new URL, so downloads, PDFs, example files, and generated HTML move to
public/at the same path, where their old URLs keep working. A raw HTML link like<a href="../_media/data.json">resolved from your docs root in Docsify, whatever page it was on, so the codemod rewrites it to/_media/data.json, and the file has to be there. If a script generates the files, link the folder intopublic/instead of copying it. - MDX pages. Pages with callouts, tabs, or diagrams are now
.mdx, which is stricter than Markdown. An indented code block renders as a plain paragraph, so fence it. HTML table cells that share a line with text, headings inside<p>banners, and braces in prose fail the build. The codemod lists each one, with its line. - The sidebar and header. Click through each group. Pages your sidebar didn't list are hidden but still built. External and PDF links from the sidebar are pinned above it on every page. Navbar links are in the header.
- The home page. A cover page's title, tagline, and buttons are now the top of
index.md. Its background image or color is gone. - Plugins. Search, copy buttons, previous and next links, and image zoom are built in. Comment widgets, Vue components, and a custom renderer, such as one that pretty-printed JSON, are listed for you. Universal Analytics IDs (
UA-) no longer collect data. - The repository.
package.jsonrunsblume devandblume build,.gitignorelists.blumeanddist, andindex.html,_sidebar.md, and the other Docsify files are gone.
Check old URLs and anchors
Start npx blume preview, then check that every route in your saved list reaches a page through the route table. Save this beside blume.config.ts and run node check-routes.cjs:
const routes = { ...require("./docsify-routes.json") }; // and your files map
const old = require("fs").readFileSync("../old-routes.txt", "utf8").split("\n").filter(Boolean);
(async () => {
for (const route of old) {
const res = await fetch("http://localhost:4321" + (routes[route] ?? route));
if (res.status !== 200) console.log(res.status, route);
}
})();It prints nothing when every route works. It can't test the script itself, because a request never carries the # part. So also open a few old URLs in a browser, with and without ?id=, like http://localhost:4321/#/install?id=configure, and check that you land on the heading.
Docsify and Blume build heading ids differently. Docsify 4 spells & as amp and ' as 39, and adds a _ before a leading digit, so ## Retries & timeouts was #retries-amp-timeouts. The agent pins every old id that differs, using the ids the codemod recorded before it changed anything. To check its work, run the anchor script again. It should find nothing to pin:
npx blume build
node node_modules/blume/skills/blume-migrate/scripts/pin-heading-ids.mjs \
--old .docsify-old --map docsify-routes.json --write
npx blume buildThen run npx blume validate --strict, which checks every internal link and anchor, including ones already broken on your old site. A page's title H1 is no longer a heading on the page, so its old id can't be pinned. The codemod drops that anchor from your own links, and an old deep link to it lands at the top of the page, which is where it pointed anyway.
Deploy and switch over
Most Docsify sites deploy to GitHub Pages from a docs/ folder, and a Blume site can stay there with a workflow that builds it. Deploy Markdown docs to GitHub Pages has the workflow. Use fetch-depth: 0 in the checkout if you show last-updated dates, and commit a lockfile, since the workflow installs from it. Then switch the Pages source from the branch folder to GitHub Actions. Blume can't detect your URL on GitHub Pages, so set it, with a project site's path as base:
deployment: { site: "https://acme.github.io", base: "/acme-docs" },Blume adds the base to root paths in Markdown links and raw HTML alike, so the ones the codemod wrote into an <a href> or <img src> need nothing more. Set the same base in the redirect script.
A custom domain stays in your repository's Pages settings, and Blume needs neither the CNAME nor the .nojekyll file. Before you merge, keep a branch at your last Docsify commit, with git branch docsify-site main. Rolling back is pointing the Pages source at that branch's docs/ folder. Once the new site is live, open a handful of old #/ links on the deployed site, and submit the new sitemap.xml in Google Search Console: for the first time, search engines can see each page at its own address.
What doesn't carry over
- Rendering in the browser. Virtual routes, Vue components, global Vue options, and scripts in pages become static content or islands. Docsify 4 ran page scripts only with
executeScriptor Vue, so many never ran anyway. - Markdown options. A custom marked renderer, line breaks on every newline (
breaks: true), and link and image attributes like':target=_blank'and':size=100'go. Rewrite one that matters as raw HTML. - Live content. An
aliasor include that fetched a file from another repository at view time becomes a copy, or a remote content source. A:fragment=of a generated file becomes a static copy, unless the generator writes the fragment itself. - Theme and chrome. The Docsify theme, a cover's background, comment plugins, and new-tab external links (Docsify's default).
Troubleshooting
A page shows !> or :::warning as text
The callout wasn't converted, or the page is still .md. Directives work only in .mdx pages, and a BLUME_MD_DIRECTIVE warning names each one left in a .md page. Rename the page, and write the callout as :::warning on its own line.
The build fails with "Expected a closing tag"
An HTML block in an .mdx page doesn't nest the way MDX needs. A <td> that shares a line with text, or a heading inside a <p>, is the usual cause. blume check reports it as BLUME_MDX_SYNTAX at its line before you build. Put each table tag on its own line, and make the <p> a <div>.
An old link shows the 404 page
Its route isn't in docsify-routes.json and no page has that path, so the script sent the reader to a page that doesn't exist. Add the old route and its new one to the table, then rebuild.
An old deep link lands at the top of the page
The heading's id changed and wasn't pinned. Run the anchor script with --write, or pin the old id by hand at the end of the heading: ## Retries & timeouts [#retries-amp-timeouts].
Next step
Migrate your docs
Run it in the folder that holds your docs/ folder, on a clean branch, after saving your old routes. Then work through the review above.
npx blume migrate docsify --claudeA step here not working for you? Report a broken step.