When you decide to migrate a WordPress archive to an AT Protocol PDS (or any CMS), you have two options. You can write something quick and dirty that works once. Or you can write something that you can run again tomorrow, and the day after, without waking up to a duplicate-record disaster at 2 a.m.
The importer for jasonbu.online took the second path. This is what that looked like: parsing WXR, building a dry-run mode that tells you what's up, designing for idempotency from the start, and why slug preservation is a hard rule.
What WXR is and why it's annoying
WXR (WordPress eXtended RSS) is the export format WordPress has shipped since roughly forever. It's XML, and it mostly looks like RSS, until you hit the custom namespaces.
WordPress embeds content under wp:, content:, excerpt:, and dc: prefixes. Standard XML parsers handle this fine (in theory.) In practice, the namespace declarations are inconsistent across export versions, and half the content you care about sits inside content:encoded, which is CDATA-wrapped HTML that you then have to convert to something useful.
The importer handles this by renaming namespaces before the XML hits the parser: wp: becomes WP_, content: becomes CONTENT_, and so on. Ugly, but stable across every WXR file I've thrown at it.
From there, the conversion pipeline for each post looks like this:
The validation step runs after conversion: if the paragraph count drops by more than 20%, you get a warning. Blunt, but it catches cases where the HTML-to-text step swallowed content instead of converting it.
Dry-run mode: the report before the write
The importer runs in two modes. Dry-run scans the WXR file, resolves each post against the PDS, and returns what would happen without actually writing anything. Import mode executes.
This was needed because the first time you run against a real archive, you want to know what you're walking into. How many posts? How many already exist as PDS records(because remember I ran the WP Plugin that did this and I learned a few tricks from it)? How many would be skipped vs. overwritten? What percentage of the conversion is images that are now placeholders?
The dry-run report surfaces all of this before you commit. It's the difference between "let's find out" and "I know what this is going to do."
A dry-run also gives you the chance to spot the edge cases you didn't think of. The first run against my archive turned up some posts with embedded path prefixes in their stored slugs that the resolver hadn't accounted for. Found in dry-run. Fixed before a single write happened.
Idempotency: run it twice, get the same result
You don't know how many times I wrote "impotencey" for that. I digress. The importer matches posts to PDS records by normalised slug. The slug is extracted from the WordPress post's permalink, stripped of host-app prefixes, and normalised to its final segment. If a record with that slug already exists in the PDS, the importer knows it.
From there, you choose the mode:
The first production run produced 20 created, 9 updated, 0 errors. Running the same import file again the next day produces the same result: same 20 exist, same 9 updated, nothing created twice. That's idempotency. You can run it again after a content fix, after a conversion change, after you add image blob upload support. The outcome is predictable.
The key implementation detail: the join key between the importer and the PDS is the at:// URI built from the normalised slug as the record key (rkey). Never the CID. CIDs change on every write, so if you use the CID as your deduplication key, you lose idempotency the moment you do your first overwrite. The rkey is stable. Use the rkey.
Why slugs are sacred
This is the part that seems obvious after you've broken it once or it you've migrated any site.
A slug is not just a URL component. It's the permanent identity of a piece of content on the open web. Search engines have indexed it. Other sites have linked to it. Readers have bookmarked it. If you change it during migration, those links 404. Permanently. No redirect infrastructure in the world catches everything.
The importer has one rule about slugs: preserve them exactly. The WordPress slug is the rkey. The path on the new site is derived from the rkey. There is no step where the importer "normalises" the slug into something cleaner or more consistent with a new naming convention. That ship has sailed.
The path normalisation bug we hit early in the site build was a version of this problem. Offprint (the writing tool used to publish to the PDS) stores document paths with a host-app prefix: /a/3mobazrgvdi23-thirty-years... for articles, /book/... for books. An earlier version of the resolver stripped only the leading /writing/, so the slug retained the embedded prefix and its slash. The Next.js dynamic route is a single segment, and anything with a slash 404s before the page code runs. The fix was to reduce the path to its final segment rather than strip known prefixes one by one. The lesson: your slug extraction logic needs to be robust to whatever shape the source system stores paths in.
Rate limiting: don't hammer the PDS
AT Protocol PDS instances have rate limits. The official Bluesky infrastructure is reasonably permissive, but if you're self-hosting or running against a smaller venue like Eurosky or Gander Social (OAuth/Canadian PDS's soon?), the limits are tighter.
The importer processes posts sequentially with a configurable delay between writes. This is not glamorous. A 31-post import with a 500ms delay takes about 15 seconds. That's fine. What's not fine is bursting 31 writes in parallel, hitting the rate limit halfway through, and ending up with a partial import that is now more difficult to reason about.
Sequential writes with a delay also make the log readable. You watch each post write. If something fails, you know exactly where it failed. Parallel writes produce parallel log noise and parallel confusion.
The npm audit fix --force scar
Early in the project setup, faced with a wall of vulnerability warnings, there's a temptation to run npm audit fix --force. The --force flag bypasses semver checks and installs whatever versions resolve the audit, regardless of compatibility.
The result: My Next.js was downgraded from v16 to v9. Silently. The build broke in ways that took some time to trace back to the root cause because the error messages were about unrelated things.
The rule that came out of that: npm audit fix --force is banned in my CDE/CLI. Full stop. If a vulnerability needs addressing, address it explicitly: find the package, understand the severity and the actual attack surface, update with a pinned version or override, verify the build. The audit tool is useful. The --force flag is a way to trade known problems for unknown ones and high blood presure.
Where byline.pub fits in
The importer built for jasonbu.online handles the case of one author migrating their own archive. That's a single writer, a single PDS, a normalised slug namespace that they control.
byline.pub is a different problem. It's an open AT Protocol book directory, designed for the pub.byline.* NSID namespace, that will eventually need to ingest records from multiple authors across multiple venues: Bluesky, Eurosky, Gander Social. The same principles apply, but the complexity surface is larger. I'm adding that in now.
Idempotency (almost, there's that word again) is still the baseline requirement. If a new author's book records are ingested, the importer can't create duplicates on a second run. The deduplication key there is the author's DID plus the book's identifier (likely ISBN or slug within their PDS), not a WordPress post slug.
Again dry-run matters more, not less, as the data sources multiply. Before ingesting a second author's records into a shared directory, you want to see exactly what's going to be written before it is written.
And slugs in the byline.pub context mean permanent canonical URLs for book and author pages in the directory. Whatever structure those take, they need to be stable from first publication. A directory is only useful if the links don't move.
The moderation question is the one that byline.pub hasn't fully resolved yet: what happens when someone creates a pub.byline.book record that's clearly spam or bad-faith? The architecture is ready to receive it. The policy to handle it isn't written yet. That's the gating item before any second-author ingest goes live.
A migration mechanism done right is infrastructure. You can re-run it when you fix a conversion bug, extend it when you add image upload support, and get the same result on the fifth run as the first.
The importer for jasonbu.online is built. It moved 31 articles from a WordPress export to a live AT Protocol PDS with their slugs intact, their Bluesky discussion threads preserved on the overwritten records, and a clean log at the end. I reused it on another hosted CMS to pull 100+ articles. The next time I run it, I know what it will do.
That's the goal. Not clever. Predictable.
