Learn WordPress to Cosmos DB Migration Using Node.js and Turndown
AI links open with a title + excerpt (these tools can't fetch the page themselves) — use "Copy full article" to paste the complete text for a fuller summary.
Introduction
This article demonstrates how to move an old WordPress blog into Azure Cosmos DB using Node.js, fast-xml-parser and turndown.
We had 71 published posts on a WordPress site going back several years. The new site is Angular with an Azure Functions API, and the posts had to come with it. Not by copy and paste.
The WordPress Export File
WordPress exports everything into one XML file. The format is called WXR (WordPress eXtended RSS). It is an RSS feed with extra wp: elements added for the fields RSS does not have.
Our export was 1.5MB and had 104 items in it. Only 71 were the posts we wanted. The rest were pages, drafts, attachments and navigation menu items. So the filter matters as much as the parser.
Features of this import
- Runs one time, from your machine, not from the deployed app.
- Dry run mode to check before writing anything.
- Converts the post HTML into Markdown.
- Keeps the old URL, so you can set up redirects later.
- Safe to run again — it upserts, it does not duplicate.
With the following steps, you can migrate your own blog.
- Export the content from WordPress.
- Parse the WXR file.
- Keep only the real posts.
- Convert the HTML to Markdown.
- Upsert into Cosmos DB.
Export the Content from WordPress
Login to your WordPress admin. Go to Tools in the left menu and click Export.
Select All content and click Download Export File. You get one XML file.
Put it in your project, and add it to .gitignore. Ours is at api/scripts/data/wordpress-export.xml. It is a large one-time data file, and there is no reason for it to live in your repository history forever.
Parse the WXR File
We use fast-xml-parser.
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
cdataPropName: '#text',
parseTagValue: false,
});
const doc = parser.parse(xml);
const items = toArray(doc.rss.channel.item);
Each option is there for a reason.
ignoreAttributes: false — WordPress stores the difference between a category and a tag in an attribute, not in the element name. Without this you cannot tell them apart.
parseTagValue: false — keep everything as a string. Otherwise the parser is helpful and turns a post slug like 2019 into the number 2019, and your document ids break.
cdataPropName: '#text' — WordPress wraps almost every value in CDATA.
Now here is the quirk that cost me time. A CDATA value does not always parse to a string.
If the element holds only CDATA, you get a string. But if it holds CDATA followed by a space or a newline, you get an array of objects instead. Same field, two different shapes, depending on invisible whitespace in the export.
So every field goes through one small function.
function text(value: unknown): string {
if (value === null || value === undefined) return '';
if (typeof value === 'string' || typeof value === 'number') return String(value);
if (Array.isArray(value)) return value.map(text).join('');
if (typeof value === 'object' && '#text' in (value as any)) return text((value as any)['#text']);
return '';
}
Important: do not skip this and read item.title directly. It will work for most posts and then give you [object Object] on a few, and you will not notice until the site is live.
There is a second small helper for the same reason. One category comes back as an object, two categories come back as an array.
function toArray<T>(value: T | T[] | undefined): T[] {
if (value === undefined) return [];
return Array.isArray(value) ? value : [value];
}
Keep Only the Real Posts
const postType = text(item['wp:post_type']);
const status = text(item['wp:status']);
if (postType !== 'post' || status !== 'publish') continue;
Two lines, and they removed 33 of our 104 items.
wp:post_type separates a post from a page, an attachment or a menu item. wp:status separates published from draft, private and trash. If you skip this filter you will import your old junk drafts and every uploaded image as a blog post.
Categories and tags live in the same category element and are told apart by the attribute.
const categories = toArray(item.category)
.filter((c: any) => c?.['@_domain'] === 'category')
.map((c: any) => text(c))
.filter(Boolean);
Change 'category' to 'post_tag' and the same code gives you the tags.
Convert the HTML to Markdown
WordPress gives you HTML in content:encoded. We store Markdown, so we use turndown.
const turndown = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' });
const contentMarkdown = turndown.turndown(contentHtml).trim();
headingStyle: 'atx' gives you ## Heading instead of the underline style. codeBlockStyle: 'fenced' gives you triple backticks instead of four-space indentation. For a technical blog full of code samples, the fenced style is the one you want.
The excerpt is built from the Markdown, not from the HTML, so no tags leak into it.
function buildExcerpt(markdown: string): string {
const plain = markdown.replace(/[#*_`>[\]!()-]/g, ' ').replace(/\s+/g, ' ').trim();
return plain.length > 200 ? plain.slice(0, 200).trimEnd() + '…' : plain;
}
Be honest about this step. Turndown handles normal HTML very well. It does not understand WordPress shortcodes, gallery blocks or embedded players — those pass through as text and stay in your Markdown. Some old posts also carry strange characters from their original encoding.
We accepted it. 71 posts, and maybe ten need a few minutes of manual cleanup in the editor. Writing a shortcode parser to save that would have taken a day.
Upsert into Cosmos DB
The document id is the slug.
posts.push({
id: slug,
slug,
wordpressId: text(item['wp:post_id']) || '0',
title: text(item.title) || '(untitled)',
excerpt: buildExcerpt(contentMarkdown),
contentMarkdown,
pubDate: pubDateGmt ? pubDateGmt.replace(' ', 'T') + 'Z' : new Date(0).toISOString(),
categories,
tags,
sourceUrl: text(item.link),
status: 'publish',
});
Three fields are worth explaining.
pubDate — WordPress gives GMT as 2019-03-14 08:22:05, which is not valid ISO 8601. Replace the space with T and add Z and it becomes a real UTC timestamp your ORDER BY can use.
sourceUrl — the original WordPress URL. We keep it so we can set up redirects from the old address later. It costs a few bytes now and it is impossible to recover after you delete the export.
wordpressId — the old numeric id. Also just for tracing.
Now run it with --dry-run first. Always.
npm run import:blog -- --dry-run
Parsed 71 published blog post(s) from .../wordpress-export.xml
- [111] Npm is not recognized From Visual Studio (slug: npm-is-not-recognized-from-visual-studio, categories: 2, tags: 1, markdown: 2030 chars)
...
Dry run — no writes performed.
Read that list. Check the count is what you expect. Check no title says (untitled) and no markdown length is 0 — both mean your text() handling missed a shape.
When it looks correct, run it without the flag.
const { getContainer, blogContainerName } = await import('../src/cosmos');
const container = await getContainer(blogContainerName);
for (const post of posts) {
await container.items.upsert(post);
}
upsert, not create. So if you find a problem, fix the script and run it again. The same slug replaces the same document instead of failing or duplicating.
One Node.js Detail
A plain node or tsx script does not read local.settings.json. Only func start does that. But cosmos.ts reads COSMOS_CONNECTION_STRING at module load time and throws if it is missing.
So the script seeds process.env from that file first, and imports cosmos.ts dynamically afterwards.
const { getContainer, blogContainerName } = await import('../src/cosmos');
A normal import at the top of the file would not work. Static imports are hoisted and run before your first line of code, so the module would load and throw before the env was seeded.
Conclusion
In this article we learned how to export a WordPress blog, parse the WXR file with fast-xml-parser, filter it down to the published posts, convert the HTML to Markdown with turndown, and upsert everything into Cosmos DB with a dry run first.
The whole script is about 140 lines and it runs in a few seconds. The part that took the time was not the code — it was the CDATA shapes and the filter.
Reference
Comments
Be the first to comment.