Migrate
Migrate your docs from VuePress
Hand your VuePress site to a coding agent, turn its Vue-flavored Markdown into Blume pages in every language, and keep every old .html URL and heading anchor working.
By Hayden Bleasel10 min read

By the end of this guide, your VuePress docs are a Blume project: your containers, badges, and code groups converted, your sidebars rebuilt as folders and tabs without changing a URL, every old .html URL redirecting to its page, and every heading anchor where it was. A coding agent does the conversion, and you review it.
It's written for VuePress 1 sites on the default theme or a theme that extends it, in one language or several. VuePress 2 sites go through the same command, but that path hasn't been run end to end. The Markdown carries over; the Vue 2 app around it doesn't, so your theme, global components, and plugins are yours to review.
VuePress 1 is in maintenance mode, and its README points to two successors: VitePress, which the Vue team supports, and the community's VuePress 2. If you want to keep writing Vue components inside your pages, either is the closer move. Blume keeps your Markdown and replaces the Vue app with its own components.
What carries over
The run behind this guide migrated VuePress 1's own documentation: 92 pages in English and Chinese, with containers, badges, code groups, snippet imports, and custom components. Every page built, all 92 old URLs reached a page, and every heading kept its old anchor.
| In VuePress | In Blume |
|---|---|
.vuepress/config.js and themeConfig | blume.config.ts |
README.md as a folder's index | index.md, at the same URL |
A sidebar per section ('/guide/': [...]) | Header tabs, one folder each |
| Sidebar groups | Folders with a meta.ts; (group) folders when the pages share one folder, so URLs don't change |
::: tip, ::: danger STOP | :::tip, :::danger[STOP], in .mdx pages |
::: details | <Expandable> |
<code-group> and <code-block title> | <CodeGroup>, each block titled |
<Badge text="beta" type="warning"/> | <Badge variant="warning">beta</Badge> |
<<< @/path/file.js{2} | <include meta="{2}"> of a file in your docs |
The page's # H1 and frontmatter title | The H1 as title, the old title as its sidebar label |
metaTitle | seo.title |
home: true with a hero and features | An index.mdx page with cards |
locales with a /zh/ folder | i18n, with the same folders and translated tab labels |
/guide/assets.html | /guide/assets, with a redirect from the old URL |
.vuepress/public/ | public/ beside blume.config.ts |
| Built-in search, or Algolia DocSearch | Blume's built-in search |
Global components in .vuepress/components/ | Blume components, Markdown, or Vue islands |
Here's part of one page before and after the run:
# Markdown Extensions
## Import Code Snippets <Badge text="beta" type="warning"/>
<<< @/../@vuepress/markdown/__tests__/fragments/snippet.js{2}
::: tip
The default value of `@` is `process.cwd()`.
:::---
title: "Markdown Extensions"
---
## Import Code Snippets <Badge variant="warning">beta</Badge> [#import-code-snippets]
<include meta="{2}">/_snippets/snippet.js</include>
:::tip
The default value of `@` is `process.cwd()`.
:::The badge's text moved inside the tag, the heading keeps its old anchor, and the snippet now lives in the docs folder, since Blume includes only files inside it.
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 your VuePress site once, outside the folder the agent will delete. VuePress writes no sitemap unless a plugin adds one, so the build is your list of old URLs, and its HTML is the record of every heading anchor. From the folder whose package.json runs VuePress, with its dependencies installed, run:
NODE_OPTIONS=--openssl-legacy-provider npx vuepress build docs --dest ../old-site
find ../old-site -name '*.html' | sed 's#^\.\./old-site##' | sort > ../old-urls.txtVuePress 1 builds with webpack 4, which fails on current Node.js with error:0308010C:digital envelope routines::unsupported. The NODE_OPTIONS flag above works around it; building under Node.js 16, without the flag, also works. Keep ../old-site until you're done. For the migration itself, you need Node.js 22.19 or later, and Claude Code or Codex installed and signed in.
Run the migration
From the same folder, run:
npx blume migrate vuepress --claudeTo use Codex, swap --claude for --codex. Leave out vuepress and Blume detects the source from a .vuepress/config file at the root or in docs/, a vuepress.config file, or a vuepress dependency.
blume migrate converts nothing itself. It opens the agent on the blume-migrate skill inside the package, pointed at its VuePress reference, which builds on the VitePress one for the Markdown the two share. Following it, the agent:
- Writes
blume.config.tsfrom.vuepress/config: title, locales, header tabs, edit links, last-updated dates, and analytics. - Renames every
README.mdtoindex.mdand moves.vuepress/publictopublic/. - Converts containers, badges, code groups, snippet imports, and Vue syntax, moves each H1 into
title, and renames the pages that need MDX to.mdx. - Rebuilds each sidebar as folders and
meta.tsfiles, in every locale. - Adds a redirect from every old
.htmlURL, and pins each heading whose id changed to its old one, using a bundled script and your old build. - Swaps VuePress for Blume in
package.json, deletes.vuepress/, points your deploy and CI at Blume, and runsblume build,blume validate --strict, andblume audit --only redirectsuntil they pass.
The redirects and anchor pins come from your old build, so if the agent asks for it or starts rebuilding VuePress, point it at ../old-site. It ends with a summary of what it migrated, dropped, and approximated. Keep it for the review.
Review the changes
Start with git diff --stat, run npx blume dev, and work through these, which are where a VuePress migration most often needs a second look.
- Vue components. VuePress registered every file in
.vuepress/components/as a global component, so pages use them without imports. Each becomes a Blume component, plain Markdown, or a Vue island, or it's dropped. VuePress 1 components are Vue 2, and islands run Vue 3, so an interactive one needs porting. Look for tags with dashes, like<code-group>or<router-link>: Blume passes an unconverted one through as a plain HTML element without an error, so a code group shows every block at once and a router link goes nowhere. Check the old build too: a tag the theme never registered, like the docs'<Bit/>, rendered nothing there. - The theme. Layout overrides, ad slots, and Stylus styles in
.vuepress/styles/don't carry over. Your accent color becomestheme.accent, and CSS worth keeping moves to atheme.csswritten against Blume's tokens. - Plugins. Image zoom and back-to-top are built into Blume. A PWA, its service worker, and a Universal Analytics id (
UA-) are dropped; add a GA4 id if you have one. An Algolia DocSearch index is left out in favor of Blume's built-in search, since Algolia's crawler fills that index and Blume's sync would replace it. - Languages. Each locale keeps its folder, and tab labels are translated. Translated pages keep their own old anchors, so a link to a Chinese heading still lands on it. Blume's interface text comes from its own language packs, so labels like "Last Updated" may read differently.
- Navigation. Click through each tab in each language. Groups that lived only in your sidebar config are now
(group)folders, which add a sidebar group without adding a URL segment. Pages your navbar linked but no sidebar listed, like a config reference or FAQ, now appear in Blume's sidebar.
Sidebar groups that existed only in config end up like this:
docs/
guide/
index.md was README.md, still /guide/
meta.ts pages: [..., "advanced"]
getting-started.mdx
(advanced)/
meta.ts
frontmatter.mdx still /guide/frontmatter
zh/
guide/ the same shape, with Chinese titles- Titles. VuePress named a page by its frontmatter
titlein the sidebar and itsmetaTitlein the browser tab, and showed the H1 on the page. Blume keeps all three:
---
title: pwa
metaTitle: PWA Plugin | VuePress
---
# [@vuepress/plugin-pwa](https://github.com/vuejs/vuepress/tree/master/packages/%40vuepress/plugin-pwa)---
title: "@vuepress/plugin-pwa"
sidebar:
label: "pwa"
seo:
title: "PWA Plugin"
---
[@vuepress/plugin-pwa](https://github.com/vuejs/vuepress/tree/master/packages/%40vuepress/plugin-pwa)- The home page. The
home: truehero is now a centered block at the top ofindex.mdx, and the features are cards. Its footer line is gone. - The repository. Scripts run
blume, Node.js pins in CI,.nvmrc, andenginessay 22.19 or later, and.gitignorelists.blumeanddist. In a workspace, regenerate the lockfile from its root.
These print spaced containers, leftover theme tags, and links to README.md or .html files. They should print only code samples that show VuePress syntax on purpose:
cd docs
grep -rn '^ *::: ' . --include='*.md' --include='*.mdx'
grep -rlE '<code-group|<router-link|<OutboundLink|<Bit' . --include='*.md' --include='*.mdx'
grep -rnE '\]\([^)]*(README\.md|\.html)[)#]' . --include='*.md' --include='*.mdx' | grep -v '://'Check every old URL and anchor
Build the site, then check that each URL in your saved list is either a page Blume built or an old .html address with a redirect to one. Save this beside blume.config.ts:
import { existsSync, readFileSync, statSync } from "node:fs";
const lines = (file) => readFileSync(file, "utf8").split("\n").filter(Boolean);
const isFile = (path) => existsSync(path) && statSync(path).isFile();
const isPage = (route) => isFile(`dist${route === "/" ? "" : route}/index.html`);
const redirects = new Map(lines("dist/_redirects").map((line) => line.split(/\s+/)));
let missing = 0;
for (const url of lines(process.argv[2])) {
// A file served as is: 404.html, index.html, anything from public/.
if (isFile(`dist${url}`)) continue;
// An old /x.html needs a redirect; /x/ and /x/index.html are the page itself.
const route = url.replace(/(?:\/index)?\.html$|\/$/, "") || "/";
const pageUrl = !url.endsWith(".html") || url.endsWith("/index.html");
const target = redirects.get(url) ?? (pageUrl ? route : undefined);
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:
npx blume build
node check-urls.mjs ../old-urls.txtIt reads dist/_redirects, which a static build writes for Netlify, Cloudflare, or no named host. Add a redirect for each URL it prints as MISSING, and run it again until it prints Every old URL reaches a page. Folder pages need no redirect: on a static host, /guide/ already serves the page Blume writes at guide/index.html, and blume dev and blume preview redirect it to /guide.
Then check the anchors. VuePress and Blume build heading ids differently: VuePress turned punctuation into dashes, and numbered a repeated heading from -2, where Blume starts at -1. The bundled script the agent uses compares every heading in your old build with the new one and reports each id that changed:
npx blume build
node node_modules/blume/skills/blume-migrate/scripts/pin-heading-ids.mjs --old ../old-site/plugin/official/plugin-medium-zoom "options" #options-1 → #options-2 (docs/plugin/official/plugin-medium-zoom.md:56)
/zh/config "palette.styl" #palettestyl → #palette-styl (docs/zh/config/index.mdx:136)
NOTE /api/node: no source line for "createApp([options]): Promise<App>" (#createappoptions-promiseapp)
711 heading(s) paired · 98 heading line(s) need a pin in 32 file(s) · 19 note(s)In a workspace, blume may be installed in the root's node_modules, so adjust the path. After the agent's pass it should find nothing to pin but each home page hero's #main-title, which is safe to leave. Add --write to pin what it finds, rebuild, and run it again. A NOTE is a heading it couldn't find in your source, usually one with inline code or escaped characters: pin it by hand by adding [#old-id] at the end of the heading. Then run npx blume validate --strict, which checks every link to an anchor. It also flags links that were already broken on your old site, since VuePress never checked them.
Deploy and switch over
If your site is on Netlify, name it as the host, even for a static build:
import { defineConfig } from "blume";
import { netlify } from "blume/deploy";
export default defineConfig({
title: "VuePress",
description: "Vue-powered Static Site Generator",
// themeConfig.repo, editLinks: true, docsDir: "packages/docs/docs"
github: { owner: "vuejs", repo: "vuepress", branch: "master", dir: "packages/docs" },
// themeConfig.locales[*].lastUpdated
lastModified: "git",
i18n: {
defaultLocale: "en",
locales: [
{ code: "en", label: "English" },
{ code: "zh", label: "简体中文" },
],
},
navigation: {
tabs: [
{ label: { en: "Guide", zh: "指南" }, path: "/guide" },
{ label: { en: "Config Reference", zh: "配置" }, path: "/config" },
{ label: { en: "Plugin", zh: "插件" }, path: "/plugin" },
{ label: { en: "Theme", zh: "主题" }, path: "/theme" },
],
},
deployment: netlify({ output: "static" }),
redirects: [
{ from: "/guide/assets.html", to: "/guide/assets" },
// ...one for every page that isn't a folder index
],
});With netlify(), the redirects file forces each rule, so an old .html URL answers with a real 301. Without it, Netlify serves Blume's redirect page instead, which moves the reader on in the browser but tells search engines less. In Netlify's settings, set the publish directory to the Blume project's dist, the build command to its build script, and the NODE_VERSION environment variable to 22. If you kept last-updated dates, look for a BLUME_SHALLOW_GIT_HISTORY warning in the build log, which means the build ran in a shallow clone and some pages lost their dates.
On GitHub Pages, set your site's URL as deployment.site, since Blume can't detect it there, and point the workflow at Blume's dist. Deploy Markdown docs to GitHub Pages has a complete workflow; for other hosts, see Deployment.
Before switching your domain, deploy a preview and open a few old .html URLs and anchored links on it. Keep the old deployment until the new one passes, so rolling back is one change. Then submit the new sitemap.xml in Google Search Console.
What doesn't carry over
- The Vue app. Themes, layout overrides,
enhanceApp.js, Markdown slots, and Vue syntax in pages ({{ }},<script>,v-for) become static content, Blume components, or islands. - Some plugins. The PWA and its service worker, flowchart blocks (redraw them as Mermaid diagrams), and DocSearch.
- Some Markdown extensions.
[[toc]](Blume shows the outline beside the page), emoji shortcodes, snippet regions, and a site-wide line-number switch. Regions become their own snippet files. - Sidebar details. Heading links under each page in the sidebar (
sidebarDepth,sidebar: auto) move to the outline, and a navbar dropdown of version links is dropped. - Site chrome. The home page footer and per-page switches like
navbar: falseandpageClass.
Troubleshooting
The old site won't build
An error:0308010C from webpack means current Node.js: build with NODE_OPTIONS=--openssl-legacy-provider, or under Node.js 16 without it. That's only for the old build; Blume itself needs 22.19 or later.
A page shows ::: tip as text
Remove the space after the colons (:::tip), and make sure the page is .mdx. A title goes in brackets: :::danger[STOP].
A code group shows every tab at once
It's still a VuePress <code-group>. Rewrite it as a <CodeGroup> with the titles on the code blocks, and indent it to match when it sits inside a list item.
validate reports a broken anchor to a page's first heading
VuePress gave the H1 an id, and links like using-a-plugin.html#using-a-plugin pointed at the top of a page. Blume's title has no id, so drop the fragment.
Next step
Migrate your docs
Run it in the folder whose package.json runs VuePress, on a clean branch, after saving your old build. Then work through the review above.
npx blume migrate vuepress --claudeA step here not working for you? Report a broken step.