#!/usr/bin/env node // One-shot migration of /Users/adrian/Developer/Websites/Content → src/content/posts/en/. // Rewrites frontmatter to this project's schema and preserves year/month/day layout. import { mkdirSync, readFileSync, writeFileSync, copyFileSync, existsSync } from 'node:fs'; import path from 'node:path'; import { globSync } from 'node:fs'; const SRC_ROOT = '/Users/adrian/Developer/Websites/Content'; const DEST_ROOT = '/Users/adrian/Developer/Websites/adrian-altner.de/src/content/posts/en'; // Skip list: starter duplicates and explicitly German .mdx posts. const SKIP = new Set([ '2022/07/08/first-post.mdx', '2022/07/15/second-post.mdx', '2022/07/22/third-post.mdx', '2024/07/01/using-mdx.mdx', '2024/07/19/markdown-style-guide.mdx', '2026/04/06/image-voice-memos.mdx', // superseded by .md sibling '2026/04/18/justified-layout.mdx', // German '2026/04/18/maple-mono.mdx', // German '2026/04/19/diigo-dsgvo-loeschung.mdx', // German ]); // Category slug normalisation. Keys map from source `[[wiki-link]]` text. const CATEGORY_MAP = { 'on-premises-private-cloud': 'on-premises-private-cloud', development: 'development', Development: 'development', 'in-eigener-sache': 'personal', projekte: 'projects', }; // Walk recursively for .md and .mdx files. function findContentFiles(dir, out = []) { const fs = require('node:fs'); for (const name of fs.readdirSync(dir)) { if (name.startsWith('.')) continue; const full = path.join(dir, name); const stat = fs.statSync(full); if (stat.isDirectory()) findContentFiles(full, out); else if (/\.mdx?$/.test(name)) out.push(full); } return out; } // Very small inline YAML frontmatter parser. Handles the exact shapes we see // in the source files: scalar lines, inline arrays [a, b], and block arrays // introduced with a colon followed by `- item` lines. function parseFrontmatter(raw) { const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/); if (!match) return { data: {}, body: raw }; const [, block, body] = match; const data = {}; const lines = block.split('\n'); let i = 0; while (i < lines.length) { const line = lines[i]; // Strip trailing "# comment" (only if preceded by whitespace; avoids // eating # inside quoted strings). Simple heuristic that matches our data. const stripped = line.replace(/\s+#\s.*$/, ''); const kv = stripped.match(/^(\w+):\s*(.*)$/); if (!kv) { i++; continue; } const key = kv[1]; let value = kv[2].trim(); if (value === '' && i + 1 < lines.length && /^\s+-\s/.test(lines[i + 1])) { const arr = []; i++; while (i < lines.length && /^\s+-\s/.test(lines[i])) { arr.push(lines[i].replace(/^\s+-\s+/, '').replace(/^["']|["']$/g, '')); i++; } data[key] = arr; continue; } if (value.startsWith('[') && value.endsWith(']')) { data[key] = value.slice(1, -1).split(',').map((s) => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean); } else if (/^(true|false)$/.test(value)) { data[key] = value === 'true'; } else if (/^-?\d+$/.test(value)) { data[key] = Number(value); } else { data[key] = value.replace(/^["']|["']$/g, ''); } i++; } return { data, body }; } function wikiLinkInner(value) { if (typeof value !== 'string') return undefined; const m = value.match(/^\[\[([^\]]+)\]\]$/); return m ? m[1] : value; } function quoteIfNeeded(s) { if (/[:#\[\]&*!|>'"%@`]/.test(s) || /^\s|\s$/.test(s)) { return `'${s.replace(/'/g, "''")}'`; } return s; } function formatFrontmatter(data) { const lines = ['---']; const order = [ 'title', 'description', 'pubDate', 'updatedDate', 'heroImage', 'heroAlt', 'hideHero', 'category', 'tags', 'seriesParent', 'seriesOrder', 'url', 'repo', 'toc', 'draft', 'translationKey', ]; for (const key of order) { if (!(key in data)) continue; const v = data[key]; if (v === undefined || v === null) continue; if (Array.isArray(v)) { if (v.length === 0) continue; lines.push(`${key}:`); for (const item of v) lines.push(` - ${quoteIfNeeded(String(item))}`); } else if (typeof v === 'boolean' || typeof v === 'number') { lines.push(`${key}: ${v}`); } else { lines.push(`${key}: ${quoteIfNeeded(String(v))}`); } } lines.push('---'); return lines.join('\n') + '\n'; } function transform(srcFile) { const rel = path.relative(SRC_ROOT, srcFile).replace(/\\/g, '/'); if (SKIP.has(rel)) return null; const raw = readFileSync(srcFile, 'utf8'); const { data, body } = parseFrontmatter(raw); const out = {}; out.title = data.title; out.description = data.description; // Date: prefer pubDate, fall back to publishDate. const rawDate = data.pubDate ?? data.publishDate; if (rawDate !== undefined) { // Strings like `2026-03-01T18:57:00+01:00` or `2026-03-22` or `Jul 08 2022` // pass through unchanged; zod's z.coerce.date() parses them. out.pubDate = String(rawDate); } if (data.updatedDate) out.updatedDate = String(data.updatedDate); // Hero image: `heroImage` in existing format, `cover` in new format. if (data.heroImage) out.heroImage = data.heroImage; else if (data.cover) out.heroImage = data.cover; if (data.coverAlt) out.heroAlt = data.coverAlt; if (typeof data.hideHero === 'boolean') out.hideHero = data.hideHero; // Category: `[[wiki-link]]` → en/. Plain string (already used // in some existing posts) stays as en/. if (data.category) { const inner = wikiLinkInner(data.category); const mapped = CATEGORY_MAP[inner] ?? inner.toLowerCase(); out.category = `en/${mapped}`; } // Series: `[[parent-slug]]` → bare parent-slug. if (data.seriesParent) out.seriesParent = wikiLinkInner(data.seriesParent); if (typeof data.seriesOrder === 'number') out.seriesOrder = data.seriesOrder; if (Array.isArray(data.tags) && data.tags.length > 0) out.tags = data.tags; if (data.url) out.url = data.url; if (data.repo) out.repo = data.repo; if (typeof data.toc === 'boolean') out.toc = data.toc; if (typeof data.draft === 'boolean') out.draft = data.draft; if (data.translationKey) out.translationKey = data.translationKey; const destRel = rel; // preserve year/month/day const destFile = path.join(DEST_ROOT, destRel); const frontmatter = formatFrontmatter(out); const content = frontmatter + '\n' + body.replace(/^\n+/, ''); return { destFile, content, srcDir: path.dirname(srcFile), destDir: path.dirname(destFile) }; } function main() { const fs = require('node:fs'); const files = findContentFiles(SRC_ROOT); let written = 0, skipped = 0, assetsCopied = 0; const destDirs = new Set(); for (const srcFile of files) { const rel = path.relative(SRC_ROOT, srcFile).replace(/\\/g, '/'); if (SKIP.has(rel)) { skipped++; continue; } const t = transform(srcFile); if (!t) { skipped++; continue; } mkdirSync(t.destDir, { recursive: true }); writeFileSync(t.destFile, t.content); written++; destDirs.add(t.srcDir + '|' + t.destDir); } // Copy accompanying asset files (images) from each source dir. for (const pair of destDirs) { const [srcDir, destDir] = pair.split('|'); for (const name of fs.readdirSync(srcDir)) { if (name.startsWith('.')) continue; if (/\.(mdx?|DS_Store)$/i.test(name)) continue; const srcAsset = path.join(srcDir, name); const destAsset = path.join(destDir, name); if (!existsSync(destAsset)) { copyFileSync(srcAsset, destAsset); assetsCopied++; } } } console.log(`wrote ${written} post files, skipped ${skipped}, copied ${assetsCopied} assets`); } // Node ESM doesn't provide require by default — fall back to createRequire. import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); main();