My Migration from Jekyll to Eleventy
This post chronicles my journey to migrate from Jekyll to 11ty. (aka eleventy) I began this process in December of 2020. I worked on it for a while during that holiday break, and much like many things I start, it sat for years. In February of 2024, I attempted to pick it back up and decided to migrate to Tailwind CSS while I was at it. That only served to complicate matters more. What's worse, is that I didn't attempt to upgrade 11ty at that time either. I was stuck myopically focused on migrating my custom plugins. What follows is pretty much a stream of consciousness of that process.
Highlight Migration
I quickly ran into my highlight paired shortcode not working. But... obviously since we're using Markdown the better way is to use Markdown's default fenced code block convention. I found the @pborenstein/eleventy-md-syntax-highlight NPM module that allows you to plug PrismJS into the standard markdown fenced code block. So I decided to convert all of my highlight paired shortcodes to that convention. I'm glad, because it's a more standard way of dealing with code blocks anyway.
Figure Tag Migration
I had built a Figure tag for Jekyll in Ruby. That needed to be converted to a Paired Shortcode for use with Eleventy.
figure_tag.rb:
module Jekyll
module Tags
class FigureTag < Liquid::Block
CaptionUrl = /(\S[\S\s]*)\s+(https?:\/\/\S+)\s+(.+)/i
Caption = /(\S[\S\s]*)/
Attribute = /([a-z]+=["][^"]+["]\s)/i
Classes = /(\.([^\s][a-z\-_\.])+)/i
def initialize(tag_name, markup, tokens)
super
@caption = nil
@attributes = nil
@classes = :center
@markup = markup
if @markup =~ Classes
tmp = $1
@markup = @markup.sub(Classes, '')
@classes = tmp.gsub('.',' ')
end
if @markup =~ Attribute
@attributes = $1
@markup = @markup.gsub(Attribute, '')
end
if @markup =~ CaptionUrl
@caption = "\n\t\t<figcaption>#{$1}<a href=\"#{$2}\">#{$3}</a></figcaption>\n\t"
elsif @markup =~ Caption
@caption = "\n\t\t<figcaption>#{$1}</figcaption>\n\t"
end
end
def render(context)
site = context.registers[:site]
converter = site.getConverterImpl(::Jekyll::Converters::Markdown)
output = converter.convert(super(context))
"<figure class=\"#{@classes}\" #{@attributes}>#{output}#{@caption}</figure>"
end
end
end
end
Liquid::Template.register_tag('fig', Jekyll::Tags::FigureTag)
figurePairedShortcode.js:
/**
* Figure Paired Shortcode for eleventy
* @param {String} content The text betwen the paired Shortcode tags
* @param {String} captionText The text to include in the <figcaption> element
* @param {String} url A URL to link the figcaption content to
* @param {String} classes
* @param {String} attributes
*/
module.exports = (content, captionText, url, classes = 'center', attributes = '') => {
let caption
if (url) {
caption = `<a href="${url}">${captionText}</a>`
} else {
caption = captionText
}
caption = (caption) ? `<figcaption>${caption}</figcaption>` : '';
return `<figure class="${classes}" ${attributes}>${content}${caption}</figure>`
}
image Tag
Under Jekyll, I had been using the jekyll-image-tag plugin. This plugin offered a lot of capabilities that are preventing me from easily migrating it to JavaScript. So I went in search of a replacement for 11ty. I began looking at the eleventy-plugin-sharp-respfigure module. This might actually replace my previous figurePairedShortcode module and allow me to combine the two functions.
Side journey to shave a yak... Ran into a rendering template error. I am trying to include the shortcode for @11ty/eleventy-img in the project...
Update from Six Years Later (April of 2026)
It's now April 8th, 2026 and I spent a couple hours with Claude Code and finished all of the remaining migration. Here's a summary of the tasks we crushed together:
- Upgraded Eleventy from v2 to v3.1.5 and converted the config to ESM (
eleventy.config.mjs) - Upgraded Node.js from 18 (lts/hydrogen) to 20 (lts/iron)
- Replaced all stub/notImplemented shortcodes with working implementations:
image,youtube,tip, andhighlight - Fixed Liquid shortcode arguments across 5 posts (unquoted args were silently resolving to null)
- Removed dead Jekyll asset tag shortcodes (
css_asset_tag,javascript_asset_tag) that were injecting junk HTML into every page - Fixed WordPress HTML entity encoding (
/) causing</a>tags to leak through excerpt extraction - Fixed debug output in post layout and tags index that was rendering raw page data into HTML comments
- Added
striptagsfilter for front-matter excerpts containing raw HTML on the home page - Removed deprecated dependencies (
eleventy-cache-assets,node-retrieve-globals, TypeScript tooling) - Added 29 unit tests across all custom plugins using Vitest
- Created a reusable
post_list_itempartial shared between the home page and tag index pages - Converted the Nunjucks tag index template to Liquid for consistency
- Built a base typographic style guide for posts and pages (headings, links, lists, blockquotes, code)
- Styled the tags index page with card-style tag links in a responsive 3-column grid
- Styled home page pagination as pill buttons and added tag pills to post listings
- Added draft support with environment-based filtering (
DRAFTS=truefor local dev) - Fixed Prism.js syntax highlighting by adding the CSS file as a passthrough copy
- Fixed float clearing on the photography page
- Removed the Amazon referral link from the footer
- Added a README with project documentation and philosophies
- Added HTML minification via
html-minifier-terserfor production builds - Added CSS minification via
cssnanoin the PostCSS pipeline for production builds - Added
deferto all footer scripts for better page load performance - Removed deprecated Google Analytics (UA) tracking
- Improved tag pill color contrast for WCAG accessibility compliance
- Added
.liquidto Tailwind's content glob so utility classes in Liquid templates are generated - Fixed figure shortcode arguments causing
[object Object]output in rendered HTML - Cleaned up 46 WordPress-exported posts: decoded HTML entities, removed WordPress frontmatter fields, stripped empty arrays, normalized CRLF line endings
- Converted 6 WordPress
[caption]shortcodes to semantic<figure>/<figcaption>HTML across 4 posts
The image tag, for real
Remember that image tag I trailed off on? Here is where it landed. The old jekyll-image-tag plugin did too much to port cleanly, and eleventy-plugin-sharp-respfigure was more than I needed, so I wrote a thin shortcode over @11ty/eleventy-img instead. It takes an optional preset (large or gallery), resizes to three widths, and emits WebP with a JPEG fallback and a srcset:
import Image from '@11ty/eleventy-img';
import path from 'path';
const KNOWN_PRESETS = ['large', 'gallery'];
const IMAGE_DIR = './assets/images/fullsize/';
export default async function imageShortcode(...args) {
let src, alt, sizes;
if (args.length >= 1 && KNOWN_PRESETS.includes(args[0])) {
src = args[1] || '';
alt = args[2] || '';
sizes = args[3] || '100vw';
} else {
src = args[0] || '';
alt = args[1] || '';
sizes = args[2] || '100vw';
}
const filename = src.replace(/^.*\//, '');
const inputPath = path.join(IMAGE_DIR, filename);
let metadata = await Image(inputPath, {
widths: [400, 800, 1200],
formats: ['webp', 'jpeg'],
outputDir: './_site/assets/images/optimized/',
urlPath: '/assets/images/optimized/',
});
return Image.generateHTML(metadata, {
alt: alt,
sizes: sizes,
loading: 'lazy',
decoding: 'async',
});
}
Lazy loading and async decoding come along for free in generateHTML. The yak, finally shaved.
Finally, I manually updated a lot of the templates to tweak spacing and design aesthetics.
Update from October 2026
Back at it in October 2026, another session with Claude Code. Less plumbing this time, more about getting the content and the CSS to behave. The list:
- Decoded a pile of leftover WordPress HTML entities across 28 posts and 6 portfolio pages. The fun one was the mojibake &emdash; a new term for me...
’was rendering as literal garbage where a smart quote should have been. - Converted the last of my old WordPress
<pre><code class="prettyprint">blocks to Markdown fenced code blocks in two tutorial posts. As raw HTML they were crashing the minifier on<?phpand quietly mangling the inline<script>samples. (Yes, this is the exact thing I preached about up in the Highlight Migration section. Took me a few years to take my own advice.) - Got burned by
npm audit fix --force, which "fixed" a vulnerability by downgrading Eleventy from 3.1.5 all the way back to 0.6.0 (a 2018 release). Reverted the whole thing. The remaining audit warnings are all build-time dev tooling that never ships to a static site, so they can wait for a deliberate upgrade. - Moved Tailwind to compile a committed stylesheet that Eleventy passthrough-copies, and scoped the content globs to my actual source folders so it stops scanning
node_modules. - Reordered the build so the CSS compiles before Eleventy runs. Before this, a fresh clone shipped no stylesheet at all, and every build after that shipped the previous build's CSS. (A one-build lag is a special kind of confusing.)
- Tracked down why my pages had no gutter: my custom spacing scale had replaced Tailwind's defaults instead of extending them, so
px-10and friends generated nothing. Moved the scale intotheme.extendso both live together. - Trimmed the header's vertical padding once that spacing fix made it visible.
When npm audit fix --force went backwards
This one earned its own paragraph. A routine npm audit fix --force decided the way to resolve a transitive advisory was to "upgrade" Eleventy from 3.1.5 to 0.6.0, a release from 2018. The --force flag is allowed to make major version changes, and nothing says those changes have to go forward. It cheerfully sent my site generator back seven years, which broke the layout system entirely. I reverted the whole thing, and relearned to read the diff before trusting the fix.
The deeper point: on a static site, almost everything npm audit flags lives in build-time tooling that never reaches a visitor. The generated HTML and CSS ship no dependencies. So I stopped treating the audit count as an emergency and started upgrading on purpose instead.
Why my pages had no gutter
My pages sat flush against the edge on anything wider than a phone, and the baffling part was that the spacing utilities existed but produced no CSS. The cause was one word in tailwind.config.js. I had defined my spacing scale as a replacement for Tailwind's rather than an extension of it:
// before: this REPLACES Tailwind's scale, so only steps 1-6 exist
theme: {
extend: { /* colors, typography */ },
spacing: { '1': '8px', '2': '12px', '3': '16px', '4': '24px', '5': '32px', '6': '48px' },
}
Everything in my templates that reached for px-10 or p-20 generated nothing, because those steps no longer existed. Moving the custom scale inside extend let it override 1 through 6 while keeping the rest of the defaults:
// after: custom steps override the defaults; the rest survive
theme: {
extend: {
/* colors, typography */
spacing: { '1': '8px', '2': '12px', '3': '16px', '4': '24px', '5': '32px', '6': '48px' },
},
}
The gutters came back immediately.
Then I kept going, because the "while I'm in here" list never really ends:
- Cleaned the same WordPress entities out of the remaining drafts, so they'll build cleanly whenever I promote them.
- Restored the old Jekyll post URLs and fixed an off-by-one in the post dates, so none of the existing links rot.
- Gave the site a real reading column, fixed the post type scale, and rebuilt the header with a mobile menu and a skip link.
- Led post list items with the title, clamped the excerpts, and turned the tags index into a compact pill cloud.
- Added dark mode that follows the OS setting, and rebuilt the code-block theme (and a matching Prism theme) from the site palette instead of a canned dark one.
- Moved syntax highlighting to build time and dropped client-side Prism entirely.
- Linked the newer and older post at the foot of each post, aligned the footer to the reading column, restyled the imported WordPress comments, and pulled the dead links off the résumé page.
- Built an
asideshortcode for callouts (the colored boxes in this post): five variants, a real Markdown body, and the old unstyledtiptag folded in as an alias.
And since I was finally unstuck, I did the dependency upgrades I'd been avoiding, one at a time with a build check after each:
- Eleventy Image v6 to v7, which pulled me up to Node 22 (its new floor).
- Vitest v1 to v5, and postcss-preset-env v9 to v11.
- The big one, Tailwind CSS v3 to v4. The official upgrade tool handled most of it (the config moves to a
@configlink,@tailwindbecomes@import, a few utilities get renamed), and I finished by hand: building through@tailwindcss/cli, letting Lightning CSS do the prefixing and minification, and deleting the whole PostCSS chain that v4 made redundant. The punchline: I'd upgraded postcss-preset-env one step earlier in the same session, then deleted it outright for v4.
Shipping it
None of this counted until it actually replaced the old site, so the last step was deployment. The Jekyll build lived in a jekyll/ folder on my shared host, with a root .htaccess that transparently rewrites every request into it. I set the Eleventy build up to deploy the same way, into an 11ty/ folder, so going live is a one-line change in that .htaccess and rollback is the same line in reverse.
Two things I'd been ignoring finally came due:
- My sitemap was a static file last generated in March of 2014 by a WordPress plugin, pointing at URLs that had not existed in years. I added a small template that builds
sitemap.xmlandrobots.txtfrom the actual content at build time, so they stay honest. - The migration ate its own tail. I retired the original WordPress blog, still sitting in a
blog2/folder with its database, the source everything here was eventually migrated away from. I exported the database, backed up the files outside the web root, and pulled it, along with the dead WordPress front-controller still loitering at the site root.
Then I flipped the .htaccess, loaded the site, and checked the things that break quietly: a post, the tags, a WebP image, dark mode, and a made-up URL to be sure it hit my own 404 page and not WordPress's. It held.
A few corners of the finished site, in dark mode:



So that is it. Jekyll to Eleventy, by way of a WordPress blog I started back in the 2000s, finished across a handful of sittings with Claude Code in 2026. Only took about six years.