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

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 10 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 VuePressIn Blume
.vuepress/config.js and themeConfigblume.config.ts
README.md as a folder's indexindex.md, at the same URL
A sidebar per section ('/guide/': [...])Header tabs, one folder each
Sidebar groupsFolders 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 titleThe H1 as title, the old title as its sidebar label
metaTitleseo.title
home: true with a hero and featuresAn index.mdx page with cards
locales with a /zh/ folderi18n, 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 DocSearchBlume'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-blume

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

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

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

  1. Writes blume.config.ts from .vuepress/config: title, locales, header tabs, edit links, last-updated dates, and analytics.
  2. Renames every README.md to index.md and moves .vuepress/public to public/.
  3. Converts containers, badges, code groups, snippet imports, and Vue syntax, moves each H1 into title, and renames the pages that need MDX to .mdx.
  4. Rebuilds each sidebar as folders and meta.ts files, in every locale.
  5. Adds a redirect from every old .html URL, and pins each heading whose id changed to its old one, using a bundled script and your old build.
  6. Swaps VuePress for Blume in package.json, deletes .vuepress/, points your deploy and CI at Blume, and runs blume build, blume validate --strict, and blume audit --only redirects until 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 becomes theme.accent, and CSS worth keeping moves to a theme.css written 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 title in the sidebar and its metaTitle in 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: true hero is now a centered block at the top of index.mdx, and the features are cards. Its footer line is gone.
  • The repository. Scripts run blume, Node.js pins in CI, .nvmrc, and engines say 22.19 or later, and .gitignore lists .blume and dist. 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.txt

It 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: false and pageClass.

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 --claude
Read the migration reference

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

Keep going.More guides.

  • Migrate your docs from Docus

    Hand your Docus site to a coding agent, convert its MDC components with a codemod, turn sections into tabs without moving a URL, and keep your assistant, MCP server, redirects, and heading anchors.

  • 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.

  • Migrate your docs from Just the Docs

    Hand your Just the Docs site to a coding agent, convert its Kramdown callouts, includes, and Liquid with a codemod, rebuild its front-matter sidebar as folders, and keep every old URL, redirect, 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