Migrating from Eleventy
If your Eleventy site is Nunjucks, the templates mostly move as they are โ same syntax, same
{% include %}, same filters where they overlap. What does not move is
eleventy.config.js, because Poops has no JavaScript config to move it into. That is the trade in
one sentence: you lose the programmable build, you stop maintaining a bundler beside it.
Read the four hard stops first.
| The thing | What happens | What you do |
|---|---|---|
permalink |
Not supported, templated or otherwise. Output path mirrors source path | Put the files where the URLs go. about/index.njk โ dist/about/index.html still works, because it is a real directory |
addFilter / addShortcode |
No API to register them | Use the bundled filters and tags, or move the logic into the template. A whole template language can be a custom engine; a single filter cannot |
addCollection |
Collections are directories, not queries | A collection is a direct subdirectory of markup.in. Cross-cutting queries โ "everything tagged X across the site" โ become taxonomies within one collection, or they go |
eleventyComputed |
No computed data layer | Compute in the template, or put the value in front matter |
Config, key by key
Everything in eleventy.config.js that has an equivalent:
| Eleventy | Poops |
|---|---|
dir.input |
markup.in |
dir.output |
markup.out |
dir.includes, dir.layouts |
markup.options.includePaths โ an array, so _layouts and _partials can both be on it |
dir.data + _data/*.js |
markup.options.data โ an explicit list of JSON/YAML files. JavaScript data files have no equivalent |
addPassthroughCopy("img") |
"copy": [{ "in": "src/img", "out": "dist/img" }] |
addGlobalData("site", โฆ) |
markup.options.site |
setTemplateFormats |
fixed: .html, .md, and .njk or .liquid |
addPlugin(pluginRss) |
markup.options.feed |
addPlugin(EleventyHtmlBasePlugin) |
{{ relativePathPrefix }} in templates, plus baseURL or --base-url when you deploy under a subpath |
eleventy-plugin-vite, or your own bundler |
styles, scripts, postcss โ top-level keys in the same file |
addFilter, addShortcode, addTransform, addCollection |
no equivalent |
The whole thing, for a blog that was Eleventy plus Vite:
{
"markup": {
"in": "src",
"out": "dist",
"options": {
"engine": "nunjucks",
"site": { "title": "My Site", "url": "https://example.com" },
"includePaths": ["_layouts", "_partials"],
"data": ["_data/site.json"],
"sitemap": "sitemap.xml",
"searchIndex": "search-index.json",
"feed": { "collection": "blog", "out": "feed.rss" }
}
},
"styles": [{ "in": "src/scss/index.scss", "out": "dist/css/styles.css", "options": { "minify": true } }],
"scripts": [{ "in": "src/js/main.ts", "out": "dist/js/main.js", "options": { "minify": true, "format": "iife" } }],
"copy": [{ "in": "src/img", "out": "dist/img" }],
"watch": ["src"],
"livereload": true
}
Collections: queries become directories
This is the conceptual change, and it is worth understanding before you move files.
In Eleventy, tags: post in front matter puts a page into collections.post no matter where the
file lives. In Poops, the directory is the collection โ every page under src/blog/ is in the
blog collection, and the index file declares it:
---
title: Blog
collection: true
paginate: 10
sort: date
taxonomies: [tags]
---
| Eleventy | Poops |
|---|---|
tags: post in each file |
put the file in blog/; the collection is automatic |
collections.post in templates |
blog.items โ a global named after the directory |
collections.post filtered by a second tag |
tags: [release] in front matter + taxonomies: [tags] on the collection, which also builds blog/tags/release/ as a real page |
addCollection with a custom sort |
sort: date or sort: { by: "title", order: "asc" } |
pagination: { data, size, alias } in front matter |
paginate: 10 on the collection; the index template reads blog.pageItems, blog.pageNumber, blog.totalPages |
permalink: "page-{{ pagination.pageNumber }}/" |
fixed shape: page 1 at blog/, page N at blog/N/ |
{% for post in collections.post %} |
{% for post in blog.items %} |
A page whose front matter has eleventyExcludeFromCollections: true becomes published: false โ
which also stops the page being built at all, so if you need the page but not the listing, move it
out of the collection directory instead.
Templates
| Eleventy (Nunjucks) | Poops |
|---|---|
layout: base.njk |
layout: base, and the file is _layouts/base.html โ the Nunjucks engine appends .html to the name, so layouts get renamed off .njk (Liquid appends .liquid). theme/layout resolves from node_modules |
{{ content | safe }} in the layout |
{% block content %}{% endblock %} โ the body is rendered into the block |
{% include "header.njk" %} |
same |
{{ page.url }} |
same, and page.filePath, page.excerpt, page.wordcount come free |
{{ post.data.title }} |
{{ post.title }} โ items are flat, no data wrapper |
{{ "now" | date: โฆ }} via a plugin |
{{ post.date | date("MMM D, YYYY") }} โ dayjs tokens, with a dateFormat default |
| A shortcode you wrote for images | the {% image %} tag, with poops-images doing the resizing |
| A shortcode for code samples | {% highlight 'javascript' %}, or a Markdown fence โ both highlighted at build time |
eleventy-plugin-syntaxhighlight |
nothing to install; it is on by default |
The order that works
- Point
markup.inat your existingdir.input. Underscore directories are ignored for output already, so_layoutsand_includeskeep working once they are onincludePaths. Rename layout files from.njkto.htmland drop the extension from thelayout:line. - Move tagged posts into a directory named for the collection, and put
collection: truein its index file. - Delete every
permalinkand move the file to match the URL it declared. - Replace
collections.xwithx.itemsand drop.datafrom item property access. - Port shortcodes and filters to bundled tags, or inline them. This is the step that decides whether the migration is an hour or a weekend โ count them before you start.
- Move the bundler config into
scripts/stylesand delete the Vite config, thenpm-run-allscript and the second watcher. poops -b, then read the warnings. Missingdate:front matter is the common one.
Note
This is a mapping, not a script. Nothing here was run against an existing Eleventy site โ the Poops side of every row is documented behaviour, the Eleventy side is from its docs, and your site will have a case neither of us thought of.
When not to migrate
You wrote a dozen shortcodes, your data comes from an API at build time, or your URLs are generated from a permalink template. Eleventy's programmable config is doing real work there, and Poops has nowhere to put it โ the comparison has the rest of the rows.