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 Hayden Bleasel10 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 Jekyll | In Blume |
|---|---|
_config.yml | blume.config.ts |
parent, grand_parent, nav_order | Folders 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 # H1 | Its frontmatter title |
{: .label .label-green } | <Badge color="green"> |
redirect_from | redirects |
/docs/install/ | /docs/install; the old spelling keeps working on static hosts |
baseurl | deployment.base |
aux_links, nav_external_links | navigation.actions, navigation.featured |
| Lunr search | Built-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-blumeThen 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.txtA 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 --claudeTo 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:
- 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.tsfiles without moving a URL. - Works through the codemod's report: HTML includes, Liquid logic, CSS rules that hid or added content, and the theme's custom files.
- Writes
blume.config.tsfrom_config.yml: title, logo, base path, header links, edit links, analytics, and the redirects and variables the codemod collected. - Moves your images and other served files into
public/, pins the old heading anchors, and swaps the Jekyll build for Blume's inpackage.jsonand your workflow. - Runs
blume build,blume validate --strict, andblume audit --only redirectsuntil 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.mdxThe 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
.htmlinclude 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/customthat hid a section or added text with::beforechanged 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
titlefor 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 topublic/. 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-docsIt 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/siteAfter 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,
_sassrules 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 --claudeA step here not working for you? Report a broken step.