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

Migrate

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.

By 10 min read

By the end of this guide, your Jekyll docs are a Blume project: your Just the Docs sidebar rebuilt as folders without moving a page, your Kramdown callouts turned into callout blocks, your includes and Liquid converted, and every old URL, redirect, and heading anchor still working. A coding agent does the conversion, starting with a codemod for the mechanical part, and you review it.

It's written for sites on the Just the Docs theme, as a gem or a remote_theme, on Jekyll 4 or the Jekyll 3 GitHub Pages builds with. Sites on other Jekyll themes (Minimal Mistakes, the Documentation Theme for Jekyll, minima) get the same content conversion, but their sidebars are declared elsewhere, so the agent rebuilds them by hand.

What carries over

The run behind this guide migrated a real Just the Docs site: 61 pages under a three-level sidebar, with 26 partials and 124 callouts. Every page carried over, all 63 old URLs reached a page, and every callout came through. Here's how the pieces map:

In JekyllIn Blume
_config.ymlblume.config.ts
parent, grand_parent, nav_orderFolders with a meta.ts; (group) folders and slug where a page's URL doesn't follow its folder
{: .note } and the other names under callouts::::note and the other callouts, in .mdx pages
{% include x.md key="value" %}<include key="value">/_includes/x.md</include>
{{ site.title }}{{title}}, a variable
1. TOC + {:toc}The outline beside every page
The page's # H1Its frontmatter title
{: .label .label-green }<Badge color="green">
redirect_fromredirects
/docs/install//docs/install; the old spelling keeps working on static hosts
baseurldeployment.base
aux_links, nav_external_linksnavigation.actions, navigation.featured
Lunr searchBuilt-in search

Here's one page and the partial it includes, before and after the codemod:

---
title: Install
parent: Getting started
nav_order: 1
redirect_from:
  - /setup/
---

# Install the SDK
{: .no_toc }

<details open markdown="block">
  <summary>Table of contents</summary>
  {: .text-delta }
1. TOC
{:toc}
</details>

{% include requirements.md version="22" %}

## Install with npm

```sh
npm install @acme/sdk
```

{: .warning }
Keep your API key out of the browser.

## Next steps

Read [Configure](../configure/) next.
{: .note }
You need Node.js {{ include.version }} or later.
---
title: Install the SDK
sidebar:
  label: Install
seo:
  title: Install
---

<include version="22">/_includes/requirements.md</include>

## Install with npm

```sh
npm install @acme/sdk
```

:::warning
Keep your API key out of the browser.
:::

## Next steps

Read [Configure](/docs/configure) next.
:::note
You need Node.js {{version}} or later.
:::

The page moved into a (getting-started) folder, which adds the sidebar group without adding a URL segment, so it's still at /docs/install. Its H1 became the title, and the old nav label stayed as the sidebar label.

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 Jekyll site once, outside the repository. The build is your list of old URLs, the sidebar the codemod rebuilds, the record of what your pages showed, and the source of their heading anchors. Build a copy with a project-local bundle, so nothing touches your Ruby setup, and with JEKYLL_ENV=production, as your CI does. From the folder that holds _config.yml:

rsync -a --exclude .git --exclude _site ./ ../jekyll-old/src/
cd ../jekyll-old/src
bundle config set --local path vendor/bundle
bundle install
JEKYLL_ENV=production bundle exec jekyll build -d ../site
cd ../site
find . -name '*.html' -not -path './assets/*' \
  | sed 's|^\.||; s|/index\.html$|/|' | sort > ../old-urls.txt

A site with no Gemfile, built by GitHub Pages from a remote_theme, needs one with gem "github-pages", group: :jekyll_plugins in the copy. If an older Jekyll fails to load csv, base64, bigdecimal, or logger on a recent Ruby, add that gem to the copy's Gemfile too.

Keep ../jekyll-old until you're done; if your Jekyll files are in a docs/ folder, it lands in your repository's root, so keep it out of your commits. 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 _config.yml, run:

npx blume migrate jekyll --claude

To use Codex, swap --claude for --codex. Leave out jekyll and Blume detects the source from a _config.yml at the root or in docs/, after checking for every other framework it detects.

blume migrate converts nothing itself. It opens the agent on the blume-migrate skill inside the package, pointed at its Jekyll reference. Following it, the agent:

  1. Runs the bundled Jekyll codemod over your pages and partials, against your old build. It converts callouts, the in-page TOC, H1s, front matter, Markdown includes, Liquid links and variables, and relative links, and rebuilds the sidebar as folders and meta.ts files without moving a URL.
  2. Works through the codemod's report: HTML includes, Liquid logic, CSS rules that hid or added content, and the theme's custom files.
  3. Writes blume.config.ts from _config.yml: title, logo, base path, header links, edit links, analytics, and the redirects and variables the codemod collected.
  4. Moves your images and other served files into public/, pins the old heading anchors, and swaps the Jekyll build for Blume's in package.json and your workflow.
  5. Runs blume build, blume validate --strict, and blume audit --only redirects until they pass, then summarizes what it migrated, dropped, and approximated.

The codemod runs like this, and its report has one block per file:

node node_modules/blume/skills/blume-migrate/scripts/jekyll-codemod.mjs \
  --old ../jekyll-old/site .
docs/getting-started.md → docs/(getting-started)/index.md
  1 × body H1 → title
  1 × front matter has_children removed
  1 × front matter nav_order removed
  1 × section index row hidden (the group row links it)
  1 × slug pins the old URL

docs/install.md → docs/(getting-started)/install.mdx
  1 × {: .warning } → :::warning
  1 × {% include %} → <include>
  1 × body H1 → title
  1 × front matter nav_order removed
  1 × front matter parent removed
  1 × old title → sidebar.label + seo.title
  1 × redirect_from → redirects
  1 × relative link → route
  1 × TOC <details> block removed

meta.ts: docs/meta.ts, docs/(getting-started)/meta.ts, docs/api/meta.ts

Notes
  docs/ isn't a section in the old sidebar: it becomes one group, kept open (collapsed: false)

On the Acme site, a parent page whose children sat beside it became a (group) folder, and the docs/ folder, which Just the Docs never showed, became one sidebar group:

docs/
  meta.ts                  collapsed: false
  (getting-started)/
    meta.ts                title, directory, pages
    index.md               slug: docs/getting-started
    install.mdx            still /docs/install
    configure.mdx          still /docs/configure
  api/
    meta.ts                directory
    index.md
    clients.mdx

The redirects from redirect_from and the values behind {{ site.x }} land in jekyll-migration.json, which the config imports:

import { defineConfig } from "blume";

import migration from "./jekyll-migration.json" with { type: "json" };

export default defineConfig({
  title: "Acme Docs",
  description: "Documentation for the Acme SDK.",
  content: {
    root: ".",
    include: ["*.{md,mdx}", "docs/**/*.{md,mdx}"],
    exclude: ["README.md", "node_modules/**", "vendor/**"],
  },
  navigation: {
    sidebar: { display: "group" },
    actions: [{ label: "Acme on GitHub", href: "https://github.com/acme/acme" }],
  },
  deployment: { site: "https://acme.github.io", base: "/acme-docs" },
  redirects: migration.redirects,
  variables: migration.variables,
});

Review the changes

Start with git diff --stat, run npx blume dev, and work through these, which are where a Just the Docs migration most often needs a second look.

  • Callouts. Just the Docs only styled the names listed under callouts: in _config.yml. A misspelled marker, or a class the config doesn't name, showed as a plain paragraph, so the codemod drops it rather than boxing text readers never saw. Check the ones it lists.
  • HTML includes. Blume splices Markdown partials, but an .html include needs a replacement at each use: a figure becomes a Markdown image or <Frame>, a video embed <YouTube>, a jQuery accordion <Accordion>. Then the partial can go.
  • Custom CSS. Rules in _sass/custom that hid a section or added text with ::before changed what readers saw. The codemod lists each one: delete content a rule hid, and write added text into the page.
  • Titles. Just the Docs showed each page's H1 and used its frontmatter title for the sidebar, so a page whose two differed now has the H1 as its title and the old one as its sidebar label. A page that took its heading from an include needs its title moved by hand.
  • Navigation. Click through the sidebar. Each section's page is its group's link, and the codemod hid the page's duplicate row under it. Pages Just the Docs listed under their parent now list there too, from directory: "accordion". Pages your old sidebar left out are hidden, which in Blume also takes them out of search and the sitemap. The home page is the exception: hidden, it stays in both.
  • Liquid logic. {% if %}, {% for %} over _data, and values that aren't plain config become the content your old build shows. Liquid inside a code block ran in Jekyll too, unless it was wrapped in {% raw %}.
  • Media. Folders your site served by path, like /media/, move whole to public/. Keep files no page uses until you know nothing else links to them.

Run from the folder that holds blume.config.ts, this prints Kramdown attribute lists, Liquid, and old-style links left in your pages. It should print only code samples and the variables and include props you meant to keep:

grep -rnE '\{:[ .#a-z:]|\{%|\{\{|^\*\[|^: |markdown="|<i class="fa|\.html[)#"]|\]\(\.\.?/[^).]*\)|\]\(/[^)]*/(#[^)]*)?\)' \
  --include='*.md' --include='*.mdx' --exclude-dir={node_modules,vendor,_site,.blume,dist} .

An HTML include for a video, and what it becomes in an .mdx page:

{% include youtube.html url="https://www.youtube.com/embed/aqz-KE-bpKQ" caption="Install in 3 minutes" %}
<Frame caption="Install in 3 minutes">
  <YouTube url="https://www.youtube.com/embed/aqz-KE-bpKQ" />
</Frame>

A YouTube playlist URL (/embed/videoseries?list=…) works the same way: <YouTube> embeds the playlist.

Check every old URL

Build the site, then check that each URL in your saved list is either a page Blume built or a redirect to one. Save this beside blume.config.ts:

import { existsSync, readFileSync } from "node:fs";

const [list, base = ""] = process.argv.slice(2);
const urls = readFileSync(list, "utf8").split("\n").filter(Boolean);
const unbase = (url) => (base && url.startsWith(base) ? url.slice(base.length) || "/" : url);
const rules = existsSync("dist/blume-redirects.json")
  ? JSON.parse(readFileSync("dist/blume-redirects.json", "utf8"))
  : [];
const redirects = new Map(rules.map(({ from, to }) => [unbase(from), unbase(to)]));
const routeOf = (url) => url.replace(/(?:\/index)?\.html$|\/$/, "") || "/";
const isPage = (route) => {
  const file = `dist${route === "/" ? "" : route}/index.html`;
  return existsSync(file) && !readFileSync(file, "utf8").includes('http-equiv="refresh"');
};

let missing = 0;
for (const url of urls) {
  const target = redirects.get(url) ?? redirects.get(routeOf(url)) ?? routeOf(url);
  if (!isPage(routeOf(target))) {
    console.log(`MISSING ${url} -> ${target}`);
    missing += 1;
  }
}
console.log(missing === 0 ? "Every old URL reaches a page." : `${missing} old URL(s) don't.`);

Run it after a build, passing your old baseurl if the site had one:

npx blume build
node check-urls.mjs ../jekyll-old/old-urls.txt /acme-docs

It reads dist/blume-redirects.json, which a build writes when no host adapter is set, and treats Blume's redirect pages as redirects, not pages. Add a redirect for each URL it prints and run it again until it prints nothing. Index URLs need none: /docs/api/ already serves the page Blume writes at docs/api/index.html. npx blume preview serves the build too, and redirects a slashed URL like /docs/install/ to /docs/install, where GitHub Pages serves the page as is.

Check the heading anchors

Jekyll and Blume build heading ids almost the same way, except inside an HTML block marked markdown="1", where Jekyll keeps only letters, digits, and hyphens: ## f_position there is #fposition in Jekyll and #f_position in Blume. On the run's site, about one heading in twenty changed. The agent pins each old id with a script that compares every heading in your old build with the new one. To check its work, run it from the same folder:

npx blume build
node node_modules/blume/skills/blume-migrate/scripts/pin-heading-ids.mjs --old ../jekyll-old/site

After the agent's pass it should find nothing to pin. If it finds some, add --write, rebuild, and run it again until it reports 0. Then run npx blume validate --strict, which checks every link to an anchor, including ones that were already broken on your old site.

Deploy to GitHub Pages

Most Just the Docs sites live 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. A project site's baseurl becomes deployment.base. A site on a custom domain serves at the root and needs no base: GitHub's Pages workflow passes the base from the Pages settings, whatever _config.yml says.

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 writes into an <img src> or <a href> need nothing more.

Replace your Jekyll workflow with the one in Deploy Markdown docs to GitHub Pages, and commit the package-lock.json from npm install with it, since the workflow installs from it. In your repository's settings, set Pages to deploy from GitHub Actions; a custom domain stays there too, so CNAME and .nojekyll can go. For other hosts, see Deployment.

Before switching your domain, deploy a preview and open a few old URLs on it, slashed and unslashed. Keep the old deployment until the new one passes, so rolling back is a settings change.

What doesn't carry over

  • The theme. Just the Docs' layouts, custom color schemes beyond the accent color, _sass rules with no Blume equivalent, the footer text, and the back-to-top link.
  • Callout labels. Just the Docs printed each callout's configured title above it. Blume shows an icon instead, so a title that only names the type is dropped.
  • Kramdown extras. Abbreviations, definition-list styling, and line numbers on every code block (Blume sets them per block). The in-page TOC listed every heading level, where Blume's outline shows two and three unless you raise toc.maxHeadingLevel.
  • Liquid and plugins. Logic, loops, and filters become static content, and plugins with no Blume equivalent are removed. Page scripts and jQuery widgets become islands, layout slots, or static content.

Troubleshooting

A page shows {: .note } or {% include %} as text

It's a .md page, which shows leftover Kramdown and Liquid as text without an error. Liquid gets a BLUME_TEMPLATE_TAG warning with its line, and an attribute list a BLUME_MD_ATTRIBUTE_LIST one. Convert it, and rename the page .mdx if it now has a callout.

The build fails with "Could not parse expression"

An .mdx page, or a partial it includes, still has a { in its text: leftover Liquid or a Kramdown attribute list, or a brace in prose. Convert it, or escape a brace as \{. blume check names the line first, in the partial when it's there: BLUME_MDX_ATTRIBUTE_LIST for an attribute list, and BLUME_MDX_SYNTAX for anything MDX can't parse.

Validate fails with BLUME_NAV_INDEX_TITLE_MISMATCH

A section's hidden page has a title different from its folder's meta.ts title. Make the two titles match, set the page's sidebar.label to the folder's title to keep its own title, or remove its sidebar.hidden to show its row again.

Next step

Migrate your docs

Run it in the folder that holds _config.yml, on a clean branch, after building your old site. Then work through the review above.

npx blume migrate jekyll --claude
Read the migration reference

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

Keep going.More guides.

  • Migrate your docs from a GitHub wiki

    Hand your GitHub wiki to a coding agent, convert its wiki links, alerts, and images with a codemod, rebuild _Sidebar.md as folders, and prepare a link stub for every old wiki page, ready for you to push.

  • Migrate your docs from Mintlify

    Hand your Mintlify repository to a coding agent, review what it changed, keep your URLs working, and deploy a docs site you host yourself.

  • Migrate your docs from Docusaurus

    Move a Docusaurus site's docs to Blume with a coding agent, and check the sidebars, admonitions, and React components it carries over.

Upgrade your docs with Blume.

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

npx blume init