feat(migration): complete WordPress migration and UX refinements

- Migrate historical posts, localized assets, and 103 comments
- Colocate media into posts/<slug>/images/ and remove wp-content
- Add native bilingual support for ai-can-change post (English default, /zh variant)
- Support clean taxonomy routing (/category/:category and /tag/:tag)
- Dynamically populate categories and tags in sitemap.xml
- Fix header scroll padding/margin offsets for anchor and reference links
- Constrain image and media widths to prevent content overflow
- Redesign homepage recent posts to 4 compact editorial cards
- Restore valid binary favicon.ico and high-res icon assets
- Remove placeholder content and sample test comments
This commit is contained in:
2026-09-16 23:09:09 -07:00
parent 8c0b04db0e
commit f52b3fe9e4
87 changed files with 3417 additions and 190 deletions
+540
View File
@@ -0,0 +1,540 @@
const fs = require('fs');
const path = require('path');
const https = require('https');
const http = require('http');
const { DatabaseSync } = require('node:sqlite');
const XML_FILE = path.join(__dirname, '../pengzhan.WordPress.2026-09-15.xml');
const CONTENT_DIR = path.join(__dirname, '../sample-content');
const POSTS_DIR = path.join(CONTENT_DIR, 'posts');
const STATIC_DIR = path.join(CONTENT_DIR, 'static');
const DOCS_DIR = path.join(STATIC_DIR, 'documents');
const DB_PATH = path.join(CONTENT_DIR, 'data/site.db');
const PUBLIC_DIR = path.join(__dirname, '../public');
// Ensure destination directories exist
[POSTS_DIR, STATIC_DIR, DOCS_DIR, path.dirname(DB_PATH)].forEach(dir => {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
});
// Helper to decode HTML entities
function decodeHtmlEntities(str) {
if (!str) return '';
return str
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#039;/g, "'")
.replace(/&#8217;/g, "'")
.replace(/&#8216;/g, "'")
.replace(/&#8220;/g, '"')
.replace(/&#8221;/g, '"')
.replace(/&#8211;/g, '–')
.replace(/&#8212;/g, '—')
.replace(/&hellip;/g, '…')
.replace(/&nbsp;/g, ' ');
}
// Download file helper with follow redirects
function downloadFile(url, destPath) {
return new Promise((resolve, reject) => {
if (fs.existsSync(destPath) && fs.statSync(destPath).size > 0) {
return resolve(destPath);
}
const destDir = path.dirname(destPath);
if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
function get(currentUrl, redirectCount = 0) {
if (redirectCount > 5) return reject(new Error('Too many redirects'));
const client = currentUrl.startsWith('https') ? https : http;
const req = client.get(currentUrl, {
headers: {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
},
timeout: 15000
}, res => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
const nextUrl = res.headers.location.startsWith('http')
? res.headers.location
: new URL(res.headers.location, currentUrl).href;
res.resume();
return get(nextUrl, redirectCount + 1);
}
if (res.statusCode !== 200) {
res.resume();
return reject(new Error(`HTTP ${res.statusCode} for ${currentUrl}`));
}
const fileStream = fs.createWriteStream(destPath);
res.pipe(fileStream);
fileStream.on('finish', () => {
fileStream.close();
resolve(destPath);
});
fileStream.on('error', err => {
fs.unlink(destPath, () => {});
reject(err);
});
});
req.on('error', err => reject(err));
req.on('timeout', () => {
req.destroy();
reject(new Error(`Timeout downloading ${currentUrl}`));
});
}
get(url);
});
}
// Convert WordPress Gutenberg HTML to clean Markdown
function htmlToMarkdown(html) {
if (!html) return '';
let md = html;
// Remove Gutenberg comments
md = md.replace(/<!--\s*\/?wp:[^>]*-->/g, '');
// Handle <pre class="... language-xxx"><code>...</code></pre>
md = md.replace(/<pre[^>]*><code[^>]*class="[^"]*language-([a-zA-Z0-9_-]+)[^"]*"[^>]*>([\s\S]*?)<\/code><\/pre>/gi, (m, lang, code) => {
return `\n\n\`\`\`${lang}\n${decodeHtmlEntities(code).trim()}\n\`\`\`\n\n`;
});
md = md.replace(/<pre[^>]*><code[^>]*>([\s\S]*?)<\/code><\/pre>/gi, (m, code) => {
const raw = decodeHtmlEntities(code).trim();
let lang = '';
if (raw.startsWith('#') || raw.startsWith('$ ') || raw.includes('sudo ') || raw.includes('kubectl ') || raw.includes('./')) {
lang = 'bash';
} else if (raw.includes('def ') || raw.includes('import ') || raw.includes('print(')) {
lang = 'python';
} else if (raw.includes('func ') || raw.includes('package ')) {
lang = 'go';
}
return `\n\n\`\`\`${lang}\n${raw}\n\`\`\`\n\n`;
});
md = md.replace(/<pre[^>]*>([\s\S]*?)<\/pre>/gi, (m, code) => {
return `\n\n\`\`\`\n${decodeHtmlEntities(code).trim()}\n\`\`\`\n\n`;
});
// Images inside figures: <figure class="wp-block-image..."><img src="..." alt="..."/><figcaption>...</figcaption></figure>
md = md.replace(/<figure[^>]*>\s*<img[^>]*src="([^"]+)"[^>]*alt="([^"]*)"[^>]*\/?>\s*(?:<figcaption[^>]*>([\s\S]*?)<\/figcaption>)?\s*<\/figure>/gi, (m, src, alt, caption) => {
const title = caption ? ` "${decodeHtmlEntities(caption).trim()}"` : '';
return `\n\n![${decodeHtmlEntities(alt || caption || '').trim()}](${src}${title})\n\n`;
});
// Plain <img> tags
md = md.replace(/<img[^>]*src="([^"]+)"[^>]*alt="([^"]*)"[^>]*\/?>/gi, (m, src, alt) => {
return `![${decodeHtmlEntities(alt || '').trim()}](${src})`;
});
// Headings
md = md.replace(/<h1[^>]*>([\s\S]*?)<\/h1>/gi, (m, c) => `\n\n# ${decodeHtmlEntities(c).trim()}\n\n`);
md = md.replace(/<h2[^>]*>([\s\S]*?)<\/h2>/gi, (m, c) => `\n\n## ${decodeHtmlEntities(c).trim()}\n\n`);
md = md.replace(/<h3[^>]*>([\s\S]*?)<\/h3>/gi, (m, c) => `\n\n### ${decodeHtmlEntities(c).trim()}\n\n`);
md = md.replace(/<h4[^>]*>([\s\S]*?)<\/h4>/gi, (m, c) => `\n\n#### ${decodeHtmlEntities(c).trim()}\n\n`);
md = md.replace(/<h5[^>]*>([\s\S]*?)<\/h5>/gi, (m, c) => `\n\n##### ${decodeHtmlEntities(c).trim()}\n\n`);
md = md.replace(/<h6[^>]*>([\s\S]*?)<\/h6>/gi, (m, c) => `\n\n###### ${decodeHtmlEntities(c).trim()}\n\n`);
// Blockquotes
md = md.replace(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/gi, (m, c) => {
const lines = decodeHtmlEntities(c).trim().split('\n');
return '\n\n' + lines.map(l => `> ${l.trim()}`).join('\n') + '\n\n';
});
// Lists
md = md.replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, (m, c) => `\n- ${decodeHtmlEntities(c).trim()}`);
md = md.replace(/<\/?ul[^>]*>/gi, '\n');
md = md.replace(/<\/?ol[^>]*>/gi, '\n');
// Paragraphs
md = md.replace(/<p[^>]*>([\s\S]*?)<\/p>/gi, (m, c) => `\n\n${decodeHtmlEntities(c).trim()}\n\n`);
// Inline formatting
md = md.replace(/<(strong|b)[^>]*>([\s\S]*?)<\/\1>/gi, (m, tag, c) => `**${decodeHtmlEntities(c).trim()}**`);
md = md.replace(/<(em|i)[^>]*>([\s\S]*?)<\/\1>/gi, (m, tag, c) => `*${decodeHtmlEntities(c).trim()}*`);
md = md.replace(/<code[^>]*>([\s\S]*?)<\/code>/gi, (m, c) => `\`${decodeHtmlEntities(c).trim()}\``);
md = md.replace(/<a[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi, (m, href, c) => `[${decodeHtmlEntities(c).trim()}](${href})`);
// Horizontal rules
md = md.replace(/<hr[^>]*\/?>/gi, '\n\n---\n\n');
// Stripped tags
md = md.replace(/<br\s*\/?>/gi, '\n');
md = md.replace(/<[^>]+>/g, '');
// Clean excessive newlines
md = md.replace(/\n{3,}/g, '\n\n').trim();
return md;
}
async function runMigration() {
console.log('====================================================');
console.log(' Starting WordPress Migration for pengzhan.dev');
console.log(` XML Source: ${XML_FILE}`);
console.log('====================================================');
if (!fs.existsSync(XML_FILE)) {
console.error(`❌ Export file not found at ${XML_FILE}`);
process.exit(1);
}
const xml = fs.readFileSync(XML_FILE, 'utf8');
const items = xml.split('<item>');
console.log(`Found ${items.length - 1} total items in XML export.`);
// 1. Map all Attachments
const attachments = new Map(); // id -> { id, title, url, filename, postName }
const attachmentsByUrl = new Map();
for (let i = 1; i < items.length; i++) {
const item = items[i];
const typeMatch = item.match(/<wp:post_type>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/wp:post_type>/);
if (typeMatch && typeMatch[1] === 'attachment') {
const idMatch = item.match(/<wp:post_id>(\d+)<\/wp:post_id>/);
const titleMatch = item.match(/<title>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/title>/s);
const urlMatch = item.match(/<wp:attachment_url>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/wp:attachment_url>/s);
const postNameMatch = item.match(/<wp:post_name>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/wp:post_name>/s);
if (idMatch && urlMatch) {
const id = idMatch[1];
const url = urlMatch[1].trim();
const filename = path.basename(new URL(url).pathname);
const attachment = {
id,
title: titleMatch ? titleMatch[1].trim() : filename,
url,
filename,
postName: postNameMatch ? postNameMatch[1].trim() : ''
};
attachments.set(id, attachment);
attachmentsByUrl.set(url, attachment);
}
}
}
console.log(`\n[Phase 1] Cataloged ${attachments.size} media attachments.`);
// Download key attachments (PDFs, favicons, site logo)
console.log('\n[Phase 2] Downloading static documents, publication PDFs, and site icons...');
for (const [id, att] of attachments.entries()) {
const { url, filename } = att;
// Academic publications / PDFs -> sample-content/static/documents/
if (filename.endsWith('.pdf')) {
const docPath = path.join(DOCS_DIR, filename);
process.stdout.write(` 📄 Downloading PDF: ${filename} ... `);
try {
await downloadFile(url, docPath);
console.log('✓');
} catch (err) {
console.log(`⚠️ Failed (${err.message})`);
}
}
// Site icons / favicons -> public/ and sample-content/static/
if (filename.includes('android-chrome') || filename.includes('logo') || filename.endsWith('.ico')) {
const pubPath = path.join(PUBLIC_DIR, filename);
const staticPath = path.join(STATIC_DIR, filename);
process.stdout.write(` 🎨 Downloading icon/logo: ${filename} ... `);
try {
await downloadFile(url, pubPath);
fs.copyFileSync(pubPath, staticPath);
console.log('✓');
} catch (err) {
console.log(`⚠️ Failed (${err.message})`);
}
}
// Fonts -> sample-content/static/fonts/
if (filename.endsWith('.otf') || filename.endsWith('.ttf') || filename.endsWith('.woff2')) {
const fontsDir = path.join(STATIC_DIR, 'fonts');
if (!fs.existsSync(fontsDir)) fs.mkdirSync(fontsDir, { recursive: true });
const destPath = path.join(fontsDir, filename);
process.stdout.write(` 🔤 Downloading font: ${filename} ... `);
try {
await downloadFile(url, destPath);
console.log('✓');
} catch (err) {
console.log(`⚠️ Failed (${err.message})`);
}
}
}
// Create standard favicon aliases in public/
const baseIcon = [
path.join(PUBLIC_DIR, 'cropped-android-chrome-192x192-1.png'),
path.join(PUBLIC_DIR, 'android-chrome-192x192-1.png'),
path.join(PUBLIC_DIR, 'cropped-android-chrome-192x192-1-32x32.png')
].find(f => fs.existsSync(f));
if (baseIcon) {
[
path.join(PUBLIC_DIR, 'favicon-32x32.png'),
path.join(PUBLIC_DIR, 'favicon.ico'),
path.join(PUBLIC_DIR, 'android-chrome-192x192.png'),
path.join(PUBLIC_DIR, 'apple-touch-icon.png'),
path.join(STATIC_DIR, 'favicon.ico'),
path.join(STATIC_DIR, 'favicon-32x32.png'),
path.join(STATIC_DIR, 'android-chrome-192x192.png'),
path.join(STATIC_DIR, 'apple-touch-icon.png')
].forEach(dst => {
try { fs.copyFileSync(baseIcon, dst); } catch (_) {}
});
}
// 2. Parse & Migrate Published Posts
console.log('\n[Phase 3] Migrating published blog posts to Markdown...');
const postsMigrated = [];
for (let i = 1; i < items.length; i++) {
const item = items[i];
const typeMatch = item.match(/<wp:post_type>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/wp:post_type>/);
const statusMatch = item.match(/<wp:status>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/wp:status>/);
const type = typeMatch ? typeMatch[1] : '';
const status = statusMatch ? statusMatch[1] : '';
if (type !== 'post' || status !== 'publish') continue;
const titleMatch = item.match(/<title>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/title>/s);
const title = titleMatch ? decodeHtmlEntities(titleMatch[1].trim()) : 'Untitled';
const slugMatch = item.match(/<wp:post_name>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/wp:post_name>/s);
let slug = slugMatch ? slugMatch[1].trim() : '';
if (!slug) {
slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
}
const dateMatch = item.match(/<wp:post_date>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/wp:post_date>/s);
const dateStr = dateMatch ? dateMatch[1].trim() : new Date().toISOString();
const date = new Date(dateStr);
const categories = [...item.matchAll(/<category domain=\"category\" nicename=\"(.*?)\">(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/category>/g)].map(m => decodeHtmlEntities(m[2].trim()));
const tags = [...item.matchAll(/<category domain=\"post_tag\" nicename=\"(.*?)\">(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/category>/g)].map(m => decodeHtmlEntities(m[2].trim()));
const category = categories[0] || (tags.includes('Kubernetes') || tags.includes('Debug') ? 'Tech' : 'Tech');
const contentMatch = item.match(/<content:encoded>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/content:encoded>/);
const rawHtml = contentMatch ? contentMatch[1] : '';
// Convert body HTML to Markdown
let mdBody = htmlToMarkdown(rawHtml);
// Find thumbnail / cover image
const thumbMatch = item.match(/<wp:meta_key>(?:<!\[CDATA\[)?_thumbnail_id(?:\]\]>)?<\/wp:meta_key>\s*<wp:meta_value>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/wp:meta_value>/);
let coverUrl = null;
let localCoverRel = null;
if (thumbMatch && thumbMatch[1] && attachments.has(thumbMatch[1])) {
const att = attachments.get(thumbMatch[1]);
coverUrl = att.url;
}
// Support bilingual posts (e.g. ai-can-change-coding-for-kids)
let isBilingualEn = (slug === 'ai-can-change-coding-for-kids-en');
let targetSlug = isBilingualEn ? 'ai-can-change-coding-for-kids' : slug;
let mdFilename = isBilingualEn ? 'en.md' : 'index.md';
let lang = isBilingualEn ? 'en' : (slug === 'ai-can-change-coding-for-kids' ? 'zh' : 'en');
// Prepare post folder: sample-content/posts/<targetSlug>/
const postDir = path.join(POSTS_DIR, targetSlug);
const postImagesDir = path.join(postDir, 'images');
if (!fs.existsSync(postImagesDir)) fs.mkdirSync(postImagesDir, { recursive: true });
// Download cover image to post's images folder
if (coverUrl) {
const coverExt = path.extname(new URL(coverUrl).pathname) || '.webp';
const coverFilename = isBilingualEn ? `cover-en${coverExt}` : `cover${coverExt}`;
const localCoverPath = path.join(postImagesDir, coverFilename);
try {
await downloadFile(coverUrl, localCoverPath);
localCoverRel = `/posts-assets/${targetSlug}/images/${coverFilename}`;
} catch (err) {
localCoverRel = coverUrl;
}
}
// Extract inline images and video in post body and download them
const inlineImageMatches = [...mdBody.matchAll(/!\[(.*?)\]\((https?:\/\/pengzhan\.dev\/wp-content\/uploads\/[^)]+)\)/g)];
for (const imgMatch of inlineImageMatches) {
const remoteImgUrl = imgMatch[2].split(' ')[0].replace(/['"]/g, '');
const imgFilename = path.basename(new URL(remoteImgUrl).pathname);
const localImgPath = path.join(postImagesDir, imgFilename);
try {
await downloadFile(remoteImgUrl, localImgPath);
mdBody = mdBody.replace(remoteImgUrl, `/posts-assets/${targetSlug}/images/${imgFilename}`);
} catch (_) {}
}
// Extract video if present
const videoMatches = [...rawHtml.matchAll(/<video[^>]*src=\"(https?:\/\/pengzhan\.dev\/wp-content\/uploads\/[^"]+)\"/g)];
for (const vMatch of videoMatches) {
const remoteVidUrl = vMatch[1];
const vidFilename = path.basename(new URL(remoteVidUrl).pathname);
const localVidPath = path.join(postImagesDir, vidFilename);
try {
await downloadFile(remoteVidUrl, localVidPath);
const vidHtml = `\n\n<video controls loop muted playsinline style="max-width: 100%; border-radius: 8px; margin: 1.5rem 0;" src="/posts-assets/${targetSlug}/images/${vidFilename}"></video>\n\n`;
mdBody = mdBody.replace("Here's a demo:", "Here's a demo:" + vidHtml);
} catch (_) {}
}
// Clean up any manual cross-link prefixes in bilingual posts
mdBody = mdBody.replace(/^\[(English Version|中文原文)\]\(https?:\/\/[^)]+\)\s*\n+/i, '');
// Generate excerpt from first paragraph
const firstPara = (mdBody.split('\n\n').find(p => p.trim() && !p.startsWith('#') && !p.startsWith('!') && !p.startsWith('<video')) || '')
.replace(/[#*`_\[\]]/g, '')
.slice(0, 180)
.trim();
const excerpt = firstPara ? `${firstPara}...` : '';
// Write YAML Frontmatter + Markdown
const frontmatter = [
'---',
`title: ${JSON.stringify(title)}`,
`date: ${date.toISOString().split('T')[0]}`,
`author: "Pengzhan Hao"`,
`category: ${JSON.stringify(category)}`,
`tags: ${JSON.stringify(tags)}`,
localCoverRel ? `cover: ${JSON.stringify(localCoverRel)}` : null,
excerpt ? `excerpt: ${JSON.stringify(excerpt)}` : null,
(lang !== 'en' || isBilingualEn) ? `lang: ${JSON.stringify(lang)}` : null,
'---',
''
].filter(Boolean).join('\n');
const fullContent = `${frontmatter}\n${mdBody}\n`;
fs.writeFileSync(path.join(postDir, mdFilename), fullContent, 'utf8');
postsMigrated.push({ slug: targetSlug, title, lang, date: date.toISOString().split('T')[0] });
console.log(` ✓ Migrated: [${date.toISOString().split('T')[0]}] ${title} (/${targetSlug}${isBilingualEn ? '/en' : ''})`);
}
// 3. Migrate Comments to SQLite site.db
console.log('\n[Phase 4] Migrating historical comments into SQLite site.db...');
const db = new DatabaseSync(DB_PATH);
// Ensure comments table exists
db.exec(`
CREATE TABLE IF NOT EXISTS comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
post_slug TEXT NOT NULL,
author_name TEXT NOT NULL,
author_email TEXT,
author_website TEXT,
body TEXT NOT NULL,
created_at TEXT NOT NULL,
approved INTEGER DEFAULT 0,
ip TEXT
);
CREATE INDEX IF NOT EXISTS idx_comments_slug ON comments(post_slug, approved);
CREATE INDEX IF NOT EXISTS idx_comments_email ON comments(author_email);
`);
const insertCommentStmt = db.prepare(`
INSERT INTO comments (id, post_slug, author_name, author_email, author_website, body, created_at, approved, ip)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const fallbackCommentStmt = db.prepare(`
INSERT INTO comments (post_slug, author_name, author_email, author_website, body, created_at, approved, ip)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
const parsedComments = [];
for (let i = 1; i < items.length; i++) {
const item = items[i];
const postNameMatch = item.match(/<wp:post_name>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/wp:post_name>/s);
const postSlug = postNameMatch ? postNameMatch[1].trim() : '';
if (!postSlug) continue;
const commentMatches = item.match(/<wp:comment>[\s\S]*?<\/wp:comment>/g) || [];
for (const cXml of commentMatches) {
const idMatch = cXml.match(/<wp:comment_id>(\d+)<\/wp:comment_id>/);
const authorMatch = cXml.match(/<wp:comment_author>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/wp:comment_author>/);
const emailMatch = cXml.match(/<wp:comment_author_email>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/wp:comment_author_email>/);
const urlMatch = cXml.match(/<wp:comment_author_url>([\s\S]*?)<\/wp:comment_author_url>/);
const ipMatch = cXml.match(/<wp:comment_author_IP>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/wp:comment_author_IP>/);
const dateMatch = cXml.match(/<wp:comment_date>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/wp:comment_date>/);
const contentMatch = cXml.match(/<wp:comment_content>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/wp:comment_content>/);
const approvedMatch = cXml.match(/<wp:comment_approved>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/wp:comment_approved>/);
const approvedRaw = approvedMatch ? approvedMatch[1].trim() : '0';
let approved = 0;
if (approvedRaw === '1') approved = 1;
else if (approvedRaw === 'spam' || approvedRaw === '-1') approved = -1;
else approved = 0;
const body = decodeHtmlEntities(contentMatch ? contentMatch[1].trim() : '');
if (!body) continue;
parsedComments.push({
id: idMatch ? Number(idMatch[1]) : null,
post_slug: postSlug,
author_name: decodeHtmlEntities(authorMatch ? authorMatch[1].trim() : 'Anonymous'),
author_email: emailMatch ? emailMatch[1].trim() : null,
author_website: urlMatch ? urlMatch[1].trim() : null,
body,
created_at: dateMatch ? dateMatch[1].trim() : new Date().toISOString(),
approved,
ip: ipMatch ? ipMatch[1].trim() : null
});
}
}
// Clear existing comments before full sync
db.exec('DELETE FROM comments');
db.exec('BEGIN');
let commentCount = 0;
for (const c of parsedComments) {
try {
insertCommentStmt.run(
c.id,
c.post_slug,
c.author_name,
c.author_email,
c.author_website,
c.body,
c.created_at,
c.approved,
c.ip
);
commentCount++;
} catch (_) {
try {
fallbackCommentStmt.run(
c.post_slug,
c.author_name,
c.author_email,
c.author_website,
c.body,
c.created_at,
c.approved,
c.ip
);
commentCount++;
} catch (_) {}
}
}
db.exec('COMMIT');
db.close();
console.log(` ✓ Inserted ${commentCount} historical comments into SQLite site.db.`);
console.log('\n====================================================');
console.log(` MIGRATION COMPLETE!`);
console.log(` - ${postsMigrated.length} Blog posts migrated to Markdown`);
console.log(` - ${attachments.size} Attachments cataloged & localized`);
console.log(` - ${commentCount} Comments synced to SQLite`);
console.log('====================================================\n');
}
runMigration().catch(err => {
console.error('Migration failed with error:', err);
process.exit(1);
});