Out of WordPress, Into the Repository: @11ty/import in Practice

One command pulls an entire WordPress blog into your own repository as Markdown — with Mastodon, Bluesky and YouTube thrown in. What @11ty/import 2.0.1 actually delivers, measured across all six sources, including the point where it breaks.

by ·

In my article about six years of 11ty I wrote a sentence yesterday that was far too smooth: "I made the cut in June 2020." That sounds like an afternoon's work. It was not.

What the sentence leaves out is the labour behind it. Getting content out of a CMS is the unromantic part of every migration — and the part where most moves die, long before anyone writes a line of template code. Back then I exported, converted, patched up and sorted images by hand. There was no tool that would have done it for me.

Today there is one, and it comes from the 11ty project itself: @11ty/import. For me it arrived four years too late. For you it might land exactly on time — version 2.0.1 shipped six days ago, on 17 August 2026.

I did not read about it, I ran it: all six sources, real targets, measured results. Three of them produce clean Markdown. Two produce something that looks like Markdown and is not. Both halves are in this article, with the commands to reproduce them.

What the tool is

Its own description is short:

"A small utility (and CLI) to import content files from various content sources. Requires Node 20.19 or newer."

That really is all of it, and that is the strength. There is nothing to install, nothing to configure, no config file, no Eleventy context. One call, two arguments:

npx @11ty/import [type] [target]

type is the kind of source, target is the thing to pull — a URL, a username or a channel id. Where the files land is decided by --output alone. Six sources are built in, and these examples come straight from the project README:

npx @11ty/import wordpress https://blog.fontawesome.com
npx @11ty/import atom https://www.11ty.dev/blog/feed.xml
npx @11ty/import rss https://fosstodon.org/users/eleventy.rss
npx @11ty/import fediverse eleventy@fosstodon.org
npx @11ty/import bluesky @11ty.dev
npx @11ty/import youtubeuser UCskGTioqrMBcw8pd14_334A

What comes out are files in your directory: Markdown with YAML front matter, alongside the downloaded images. No database, no intermediate format, no service. The project names four properties that matter day to day:

"Clean: Converts imported content to markdown files in your repository […] Standalone: downloads all referenced assets (images, videos, stylesheets, scripts, etc) in content and co-locates assets with the content […] Resumable: Can stop and resume a large import later, reusing a local cache […] Repeatable: avoids overwriting existing content files (unless you opt-in with --overwrite)."

That last point is worth more than it sounds. The import is not a one-off bang, it is a command you can repeat. Whatever you reworked in your editor after the first run stays untouched on the second — only new posts get added. That turns the import from a deadline into a bridge you can keep walking across for as long as you like.

The package is MIT-licensed, comes from Zach Leatherman, lives at 11ty/import (opens in a new tab) and has 125 stars today. Remember that number — it comes back in the section about the downsides.

The WordPress case

This is why the tool exists. The README names it as the first use case, and the project's companion video is called "Start Your Escape from WordPress Using 11ty (in 3 minutes!)".

So I did exactly that. Target: wordpress.org/news, the official WordPress news blog. A real WordPress installation, public, and big enough to be honest.

npx @11ty/import wordpress https://wordpress.org/news/ --within=45d --output=run

The result, unedited from my terminal:

Wrote 11 documents and 100 assets (450 cleaned, unused) from WordPress (1 error) in 40.07 seconds (v2.0.1)

Eleven posts, a hundred images, 40 seconds, 106 MB on disk. The 450 "cleaned" files are the interesting part: WordPress serves half a dozen srcset sizes per image, the tool downloads all of them first and then throws away whatever no longer appears in the Markdown version. The comment in MarkdownToHtml.js says it drily: "Removes unnecessarily downloaded and srcset assets that didn’t end up in the markdown simplification". So you do not inherit the image variants of someone else's theme, you get the originals — and you compute your own sizes from them. Exactly right.

And this is what an imported file looks like, head unedited:

---
title: WordPress 7.0.4 Release
authors:
  - name: John Blackbourn
    url: https://johnblackbourn.com
    avatarUrl: >-
      https://secure.gravatar.com/avatar/51f55d…
date: 2026-08-12T12:45:53.000Z
metadata:
  categories:
    - Releases
    - Security
  tags:
    - minor-releases
    - releases
  uuid: 11ty/import::wordpress::https://wordpress.org/news/?p=21371
  type: wordpress
  url: https://wordpress.org/news/2026/08/wordpress-7-0-4-release/
tags:
  - releases
  - security
---

Below it sits clean Markdown with headings, lists, links and relative image paths. No <div> soup, no shortcode leftovers, no Gutenberg comments. The duplication in the tags is worth noticing, and it runs the opposite way to what you would guess: metadata.categories and metadata.tags preserve the original names of the WordPress categories and tags. The front-matter tags that 11ty reads as a collection tag is built solely from the categories, slugified and lower-cased. The WordPress tags stay in metadata alone. You can see it in the sample above: the categories "Releases" and "Security" become the tags releases and security, while metadata.tags with minor-releases never surfaces. The source comments the spot explicitly with "map WordPress categories for use in Eleventy tags (not WordPress metadata tags, which are different)". If you want to keep your tags, you have to lift them out of metadata yourself.

The folder mirrors the source path structure: run/news/2026/08/wordpress-7-0-4-release.md, with an assets/ directory per month next to it. If you want to keep your permalinks, half that job is already done.

Two traps you should not have to guess

The trailing slash is mandatory. My first attempt was https://wordpress.org/news without it, and the failure was unfriendly:

Error: Bad response for https://wordpress.org/newswp-json/wp/v2/posts/ (404): Not Found

The URL gets concatenated without a separator. One character short, and the error points at a 404 instead of at the cause. So always write the blog address with a trailing /.

The README's own example no longer works. npx @11ty/import wordpress https://blog.fontawesome.com fails today with 403: Forbidden. I checked whether that was my invocation: a plain browser request to blog.fontawesome.com/wp-json/wp/v2/posts/ returns 403 as well. So Font Awesome has locked down its REST API — on the very blog its own example wanted to import. That is not a flaw in the tool, but it is a useful lesson: whether an import is possible at all is decided by the target site, not by you.

Why the REST API and not the XML export

WordPress has its own export route: Tools → Export produces an XML file in the WXR format ("WordPress eXtended RSS"), which according to the documentation contains "posts, pages, custom post types, comments, custom fields, categories, tags, custom taxonomies, and users". Sounds more complete — and formally it is.

@11ty/import still goes the other way and queries wp-json/wp/v2/posts. The reason is practical: WXR contains the post body exactly as it sits in the database, including shortcodes, block comments and everything plugins have written into it. The REST API serves content.rendered, meaning what a visitor actually sees. For a conversion to Markdown that is by far the better starting point, because the shortcodes are already resolved.

The price: you need a reachable API. It is on by default — WordPress registers it unconditionally in core, and the handbook is unusually blunt about it:

"You should not disable the REST API; doing so will break WordPress Admin functionality that depends on the API being active."

Security plugins and server rules do it regularly anyway, as the Font Awesome case shows. Check this before anything else, before you promise anyone a migration date: open https://your-site.tld/wp-json/wp/v2/posts?per_page=1 in a browser. If JSON comes back, you are good. If you get 401, 403 or a blank page, that is your actual first ticket.

What does not come along

Here I owe you a clear list, because the README does not offer one:

  • Not pages. The source queries posts only. Pages and custom post types missing is an open wish in the project (issue #17 (opens in a new tab), open since October 2025).
  • The featured image only if the source exposes it. Contrary to what I expected, it does come along: the tool reads jetpack_featured_media_url (falling back to og_image), downloads the file and records it as metadata.media.featuredImage. Where that field is missing — on any install without Jetpack — the featured image stays behind. The matching issue (#11 (opens in a new tab)) is open regardless, because the excerpt is still missing.
  • Not comments, menus, widgets or redirects. The tool imports content, not a website.
  • Drafts only with credentials. For drafts you need WORDPRESS_USERNAME and WORDPRESS_PASSWORD as environment variables; they go to the API as HTTP Basic auth. Use an application password for this, not your admin password.
  • WordPress.com is internally a different source. Hosted blogs run through their own type against the public-api.wordpress.com API, and its source carries an unambiguous "DRAFTS NOT SUPPORTED". You do not have to switch anything: the wordpress type detects a .wordpress.com address itself and silently delegates. Convenient — except nobody warns you that drafts are not even attempted on that path.

Six sources, measured

Now the part I actually ran. All runs with version 2.0.1 on Node 24.19, default settings — only for WordPress did I limit the window with --within=45d, so as not to plough through someone else's server without cause.

Source Target Result Clean?
wordpress wordpress.org/news/ 11 .md, 100 assets, 450 discarded, 40 s yes
bluesky @11ty.dev 19 .md, 0 assets, 1.3 s yes
youtubeuser UCskGTioqrMBcw8pd14_334A 15 .md, 0 assets, 0.5 s yes
atom 11ty.dev/blog/feed.xml 10 .md, 0 assets, 0.9 s no
fediverse eleventy@fosstodon.org 20 .md, 2 assets, 0.4 s no
rss fosstodon.org/users/eleventy.rss 20 .html instead of .md, 0.3 s no

The three clean cases really are clean. Bluesky delivers posts as plain text with proper front matter, YouTube the video descriptions plus the channel name — which for 11ty now reads "Build Awesome (11ty)", incidentally confirming the naming question from my last article.

The other three need a section of their own.

The point where it breaks

The Atom run reports success. Ten documents, zero errors, 868 milliseconds. Only when you open one of the files do you see this — in reality a single very long line, wrapped and shortened here:

\<p>As we harden our release practices in the wake of numerous recent
vulnerabilities in npm packages amongst high profile authors…\</p>

\<h2 id="dependency-watch">Dependency Watch\</h2>

That is not Markdown. Those are HTML tags treated as text and then escaped on top. I counted: 10 out of 10 files from the Atom run affected, 20 out of 20 from the Fediverse run, 0 out of 11 from WordPress. The RSS run writes no .md files at all but .html — containing raw &lt;p&gt; entities, so broken twice over.

Important for context: this is not a new regression. I imported the same feed with the predecessor 1.0.24 — identical result. The behaviour is old, it simply goes unnoticed because the run reports "0 errors".

The cause sits in Importer.js and takes a handful of lines to tell — reproduced here unchanged, indentation included:

if(Importer.isHtml(entry)) {
	let transformedHtml = content;
	if(!isWritingToMarkdown) {
		// decoding built-in with Markdown
		transformedHtml = entities.decodeHTML(content);
	}
	// …

The comment is the assumption, and the assumption is wrong. An Atom feed may carry HTML two ways: as type="xhtml" with real markup, or as type="html" entity-encoded — &lt;p&gt; rather than <p>. In practice nearly every feed uses the second, 11ty's included. On the HTML output path that gets decoded. On the Markdown output path it is skipped, because the converter supposedly handles it. It does not: it sees &lt;p&gt;, decodes the entity into a literal <, decides it is text, and escapes it to \<. That is also why the images stay behind — where no <img> is recognised, there is nothing to download. Which explains the "0 assets" in the table above.

The workaround that does work is a single flag:

npx @11ty/import atom https://www.11ty.dev/blog/feed.xml --format=html

Measured: 10 clean HTML files with correct markup — and 25 assets the Markdown path never saw. So if you import from a feed, take HTML and convert it yourself rather than trusting the default path.

I could not find an open issue for this in the project. Anyone using the tool seriously for feeds should file one — with the feed, the command and two lines of output it takes five minutes to write, and the case reproduces. For Mastodon there is also half a way out: fediverse instead of rss uses the proper API, downloads images correctly and is the better route in general — it just fails at the same escaping step.

I am not writing this to put a small project on the spot. I am writing it because a migration is one of those things you only look at properly months later. "0 errors" is not a quality statement. Open three random files after every import and read them. That is the entire check, and here it would have caught this in ten seconds.

The flags that make the difference

npx @11ty/import --help shows ten options. Thirteen are actually parsed. The ones that matter day to day:

  • --dryrun — writes nothing, shows everything. The 11ty docs say "Try it out with --dryrun first to be safe!", and for once that is not filler but the single most important piece of advice on the topic.
  • --output=directory — the default is ., meaning your current directory. Miss that, and you scatter someone else's blog across your project root.
  • --within=45d — limits the run to recently created or modified posts. For WordPress this becomes an after/modified_after query parameter server-side, so it saves real requests, not just cleanup afterwards. Ideal for a trial run.
  • --assetrefs= — four modes, and the choice depends on your project: relative (default, slug.md next to assets/image.png), absolute (/assets/image.png), colocate (slug/index.md with the images in the same folder) and disabled (no downloads at all). For a first look, disabled is gold: the run takes seconds instead of minutes, and you still see whether the text is any good.
  • --overwrite and --overwrite-allow=drafts — the first disables the safety net, the second only for drafts. The second is the smarter one: drafts are allowed to change, published posts you have already reworked are not.
  • --cacheduration=24h — the local cache an aborted run resumes from. On a large blog that is the difference between "start over" and "carry on".
  • --format=markdown|html — see above. For feeds, the rescue.

Three more flags exist but are missing from the help output: --within (at least it is in the README), --persist (experimental, publishes new posts straight to GitHub and needs a GITHUB_TOKEN) and --preserve, which appears neither in the help nor in the README's usage block. It takes class names and shields the matching elements from Markdown simplification. If your old blog had callout boxes or embedded cards that should survive as HTML, that is exactly the tool for it — you just have to know it exists.

Beyond the obvious: any website as a CMS

So far this article has been a migration story. The genuinely interesting sentence in the README is a different one:

"Make anything on the web into a CMS for your web site using Indieweb PESOS."

PESOS stands for "Publish Elsewhere, Syndicate (to your) Own Site" and comes from the IndieWeb movement: you keep posting where the people are — and afterwards you automatically pull the content back into your own repository. Not as an embed that depends on someone else's service, but as a file you own.

The difference is not a nicety. An embedded timeline is a script from a foreign server: slow, often a privacy problem, and on the day the service shuts down or changes its terms, your site has holes in it. Recent years have demonstrated repeatedly how fast a platform turns into a liability. An imported Markdown file never changes. It is in Git, it is in the backup, it gets deployed and it gets indexed.

Three applications that come to mind, all of which start with one command:

# Your own fediverse posts as a notes section
npx @11ty/import fediverse yourname@your-instance.tld --output=src/content/notes

# A GitHub project's releases as your own changelog
npx @11ty/import atom https://github.com/11ty/eleventy/releases.atom --format=html

# Conference talks as a searchable index
npx @11ty/import youtubeuser YOUR_CHANNEL_ID --output=src/content/videos

I like the middle one most, because it shows how far the idea carries. GitHub serves the releases of every public project as an Atom feed. A cron job, an import, a build — and you have a maintained changelog page that keeps itself up to date. This knowledge base has a changelog section that I currently mirror from my projects' README files. The feed route would be the automated version of the same idea — with the caveat from above: use --format=html, not the default path.

And this is the point where I get enthusiastic: this shape is the counter-proposal to everything the web has done with content over the past fifteen years. Not "your posts live with us and we kindly display them", but "your posts live with you and we are allowed to display them too". A tool that opens the way back in a single command is more than a convenience. It is the undo button every platform hopes you will not find.

The honest downsides

An article that celebrates a tool and names no limits is advertising. So:

It is a Node program, and everything that applies to Node applies here. npx @11ty/import pulls 53 packages onto your machine on first use and executes them. That is the same vector the npm incidents of recent years ran through, and it does not get any less sharp because a tool is useful. On my machine this call also goes through Socket Firewall — every package fetch is checked beforehand, and install hooks are off project-wide.

In the project's defence: it has done its own homework. Version 2.0.1 was published via npm Trusted Publishers, with SLSA provenance attestation and no long-lived access tokens. That is more care than most far larger packages manage.

It is a small project. 125 stars, one maintainer, 14 open issues. That is not a criticism but a scale you should know when planning: if you hit a bug, you will probably have to work around it yourself. How carefully the project is run anyway shows in a lovely detail: version 2.0.0 does not exist on npm at all. The automated publish failed, so the GitHub release carries a struck-through title and, in its body, just the sentence "v2.0.0 failed an automated immutable publish, so please use Import v2.0.1." That is the third time a version has got stuck at that step. I prefer a project that documents such mishaps openly to one where they quietly disappear.

It does not know about your pathPrefix. Above the write function in the source sits a bare // TODO options.pathPrefix. For this knowledge base, which lives under /db/, that means --assetrefs=absolute produces /assets/… without the prefix, and I would be searching and replacing afterwards. With relative or colocate the question never arises — one more reason not to change the default casually.

The front matter is generic, your schema is not. What arrives is title, authors, date, metadata, tags. What this site needs on top is translationKey, kind, description and tags from a controlled vocabulary. No importer will do that mapping for you. Budget for a small script that walks the files after the import — and write it before you pull two thousand posts, not after.

And errors are counted, not explained. My WordPress run ended with "(1 error)". Which one is nowhere to be found. At eleven posts I will still find it myself. At eleven hundred I will not.

My recipe

If I had to move a WordPress blog tomorrow, this is the order I would work in:

  1. Check the API. Open wp-json/wp/v2/posts?per_page=1 in a browser. No JSON, no import — then that is the first task, not the move.
  2. Dry run. --dryrun --within=30d, to see what gets found at all and what the file names will look like.
  3. A small real run without images. --within=30d --assetrefs=disabled --output=/tmp/probe. Takes seconds. Then open three files and read them — tables, code blocks, footnotes, quotes. That is where you spot escaping problems while they still cost nothing.
  4. Only then the full run, with images, into an empty directory, with a generous --cacheduration.
  5. After that the mapping script for your own front matter — and feel free to keep running the import. It overwrites nothing you have touched.

Conclusion

@11ty/import solves exactly one problem, and it solves it well: it fetches content out of foreign systems and onto your disk as files. For WordPress that works convincingly today, for Bluesky and YouTube too, for feeds only via the --format=html detour. It imports posts, not websites — pages, featured images, menus and redirects remain your job.

What genuinely convinces me is the attitude behind it. A project that actively works on getting content in is not thinking in lock-in. It fits the line I quoted yesterday — "We don't want to hold your content hostage with a custom format." A tool that opens the road towards you is only one thought away from one that keeps the road away from you open too.

Which leaves the question the import does not answer, and it is the harder one: once the content sits in the repository as Markdown — who maintains it? For me the answer is an editor and a commit. For an editorial team that has never opened a terminal, it is not. That is the gap I named myself yesterday as a limit of 11ty, and it is what the next article is about.

Further reading