/** * Converts a Squarespace/WordPress XML export to Markdown files with frontmatter. * * Usage: * node scripts/convert-xml.mjs [path/to/export.xml] [--download-images] * * Output: * content/posts/YYYY-MM-DD-slug.md — blog posts * content/pages/slug.md — static pages * public/images/ — downloaded images (with --download-images) */ import { XMLParser } from 'fast-xml-parser'; import TurndownService from 'turndown'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as https from 'node:https'; import * as http from 'node:http'; import * as crypto from 'node:crypto'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, '..'); // ─── CLI args ──────────────────────────────────────────────────────────────── const args = process.argv.slice(2); const xmlPath = args.find((a) => !a.startsWith('--')) ?? path.join(ROOT, 'export-2026-03-09.xml'); const downloadImages = args.includes('--download-images'); // ─── Output directories ────────────────────────────────────────────────────── const OUT_POSTS = path.join(ROOT, 'content', 'posts'); const OUT_PAGES = path.join(ROOT, 'content', 'pages'); const OUT_IMAGES = path.join(ROOT, 'public', 'images'); fs.mkdirSync(OUT_POSTS, { recursive: true }); fs.mkdirSync(OUT_PAGES, { recursive: true }); if (downloadImages) fs.mkdirSync(OUT_IMAGES, { recursive: true }); // ─── XML parsing ───────────────────────────────────────────────────────────── const xmlRaw = fs.readFileSync(xmlPath, 'utf8'); const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_', cdataPropName: '__cdata', // Keep namespaced tags as-is; fast-xml-parser strips the colon by default // so becomes wp:post_type when we set: parseAttributeValue: true, allowBooleanAttributes: true, }); const parsed = parser.parse(xmlRaw); const channel = parsed.rss.channel; // fast-xml-parser flattens namespace prefixes with ':', but the key names // include the full prefix, e.g. "wp:post_type", "content:encoded", etc. // However, colons in keys require bracket notation. // Build author map email → display name const authorMap = {}; const rawAuthors = Array.isArray(channel['wp:author']) ? channel['wp:author'] : [channel['wp:author']].filter(Boolean); for (const a of rawAuthors) { const email = a['wp:author_login']; const name = a['wp:author_display_name']?.['__cdata'] ?? a['wp:author_display_name'] ?? email; if (email) authorMap[email] = name; } // ─── Turndown (HTML → Markdown) ────────────────────────────────────────────── const td = new TurndownService({ headingStyle: 'atx', hr: '---', bulletListMarker: '-', codeBlockStyle: 'fenced', fence: '```', emDelimiter: '_', strongDelimiter: '**', linkStyle: 'inlined', }); // Strip Squarespace wrapper divs but keep their children td.addRule('sqs-wrapper', { filter: (node) => node.nodeName === 'DIV' && (node.getAttribute('class')?.includes('sqs-html-content') || node.getAttribute('class')?.includes('image-gallery-wrapper')), replacement: (_content, node, options) => { // Just return content — the div itself doesn't add meaning return `\n\n${_content}\n\n`; }, }); // Gallery images wrapped in a plain