/** * Migration Utilities * Story 1.3: Content Migration Script */ import { MigrationConfig } from './types' // ============================================================ // LOGGING UTILITIES // ============================================================ export const colors = { reset: '\x1b[0m', bright: '\x1b[1m', dim: '\x1b[2m', red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[34m', cyan: '\x1b[36m', white: '\x1b[37m', } export class Logger { private verbose: boolean constructor(verbose: boolean = false) { this.verbose = verbose } info(message: string): void { console.log(`${colors.blue}ℹ${colors.reset} ${message}`) } success(message: string): void { console.log(`${colors.green}✓${colors.reset} ${message}`) } error(message: string): void { console.log(`${colors.red}✗${colors.reset} ${message}`) } warn(message: string): void { console.log(`${colors.yellow}⚠${colors.reset} ${message}`) } debug(message: string): void { if (this.verbose) { console.log(`${colors.dim} →${colors.reset} ${message}`) } } header(message: string): void { console.log(`\n${colors.bright}${colors.cyan}${message}${colors.reset}`) } progress(current: number, total: number, message: string = ''): void { const percent = Math.round((current / total) * 100) const bar = '█'.repeat(Math.floor(percent / 2)) + '░'.repeat(50 - Math.floor(percent / 2)) process.stdout.write( `\r${colors.cyan}[${bar}]${colors.reset} ${percent}% ${message}`.padEnd(100), ) if (current === total) { process.stdout.write('\n') } } } // ============================================================ // STRING UTILITIES // ============================================================ /** * Convert any string to a URL-friendly slug */ export function toSlug(value: string): string { return value .toString() .toLowerCase() .trim() .normalize('NFD') // Separate accented characters .replace(/[\u0300-\u036f]/g, '') // Remove diacritics .replace(/[^a-z0-9\u4e00-\u9fa5/-]/g, '-') // Replace non-alphanumeric with hyphen (keep Chinese) .replace(/-+/g, '-') // Replace multiple hyphens with single .replace(/^-+|-+$/g, '') // Trim hyphens from start/end } /** * Extract filename from URL */ export function getFilenameFromUrl(url: string): string { try { const urlObj = new URL(url) const pathname = urlObj.pathname const filename = pathname.split('/').pop() return filename || `file-${Date.now()}` } catch { return `file-${Date.now()}` } } /** * Get file extension from filename or URL */ export function getFileExtension(filename: string): string { const match = filename.match(/\.([^.]+)$/) return match ? match[1].toLowerCase() : 'jpg' } /** * Parse comma-separated tags into array */ export function parseTagsString(tagsString: string): Array<{ tag: string }> { if (!tagsString || typeof tagsString !== 'string') { return [] } return tagsString .split(',') .map((tag) => tag.trim()) .filter(Boolean) .map((tag) => ({ tag })) } /** * Convert hex color to text/background pair * Uses luminance to determine if text should be black or white */ export function splitColorToTextBackground( hexColor: string, ): { textColor: string; backgroundColor: string } { // Default to black text on white background const defaultResult = { textColor: '#000000', backgroundColor: '#ffffff', } if (!hexColor) { return defaultResult } // Ensure hex format let hex = hexColor.replace('#', '') if (hex.length === 3) { hex = hex.split('').map((c) => c + c).join('') } if (hex.length !== 6) { return defaultResult } // Calculate luminance const r = parseInt(hex.substr(0, 2), 16) / 255 const g = parseInt(hex.substr(2, 2), 16) / 255 const b = parseInt(hex.substr(4, 2), 16) / 255 const luminance = 0.299 * r + 0.587 * g + 0.114 * b // Use original color as background, choose contrasting text return { textColor: luminance > 0.5 ? '#000000' : '#ffffff', backgroundColor: `#${hex}`, } } /** * Truncate text to max length */ export function truncate(text: string, maxLength: number): string { if (!text || text.length <= maxLength) { return text || '' } return text.substring(0, maxLength - 3) + '...' } // ============================================================ // DATE UTILITIES // ============================================================ /** * Parse various date formats */ export function parseDate(dateValue: string | Date): Date { if (dateValue instanceof Date) { return dateValue } const parsed = new Date(dateValue) if (isNaN(parsed.getTime())) { return new Date() // Fallback to now } return parsed } /** * Format date for display */ export function formatDate(date: Date): string { return date.toISOString().split('T')[0] } // ============================================================ // HTML CLEANING UTILITIES // ============================================================ /** * Clean HTML content by removing Webflow-specific classes and attributes */ export function cleanHTML(html: string): string { if (!html) { return '' } return html // Remove Webflow-specific classes .replace(/\sclass="[^"]*w-[^"]*"/g, '') .replace(/\sclass="[^"]*wf-[^"]*"/g, '') // Remove data attributes used by Webflow .replace(/\sdata-[a-z-]+="[^"]*"/gi, '') // Remove empty style attributes .replace(/\sstyle=""/g, '') // Clean up multiple spaces .replace(/\s+/g, ' ') .trim() } /** * Extract plain text from HTML (for excerpts) */ export function htmlToPlainText(html: string, maxLength: number = 200): string { if (!html) { return '' } // Remove script and style tags let text = html.replace(/]*>[\s\S]*?<\/script>/gi, '') text = text.replace(/]*>[\s\S]*?<\/style>/gi, '') // Replace block elements with newlines text = text.replace(/<\/(div|p|h[1-6]|li|tr)>/gi, '\n') text = text.replace(/<(br|hr)\s*\/?>/gi, '\n') // Remove all other tags text = text.replace(/<[^>]+>/g, '') // Decode HTML entities text = text.replace(/ /g, ' ') text = text.replace(/&/g, '&') text = text.replace(/</g, '<') text = text.replace(/>/g, '>') text = text.replace(/"/g, '"') text = text.replace(/'/g, "'") // Clean up whitespace text = text.replace(/\n\s*\n/g, '\n\n').trim() return truncate(text, maxLength) } // ============================================================ // VALIDATION UTILITIES // ============================================================ /** * Check if a value is a valid URL */ export function isValidUrl(value: string): boolean { try { new URL(value) return true } catch { return false } } /** * Check if a value is a valid image URL */ export function isValidImageUrl(value: string): boolean { if (!isValidUrl(value)) { return false } const ext = getFileExtension(value) return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext) } // ============================================================ // CONFIG UTILITIES // ============================================================ /** * Parse CLI arguments into MigrationConfig */ export function parseCliArgs(args: string[]): MigrationConfig { const config: MigrationConfig = { dryRun: false, verbose: false, collections: ['all'], force: false, batchSize: 5, sourcePath: './data/webflow-export.json', } for (let i = 0; i < args.length; i++) { const arg = args[i] switch (arg) { case '--dry-run': case '-n': config.dryRun = true break case '--verbose': case '-v': config.verbose = true break case '--force': case '-f': config.force = true break case '--collection': case '-c': if (args[i + 1]) { const collection = args[++i] if (collection === 'all') { config.collections = ['all'] } else if (['categories', 'posts', 'portfolio'].includes(collection)) { config.collections = [collection as any] } } break case '--source': case '-s': if (args[i + 1]) { config.sourcePath = args[++i] } break case '--batch-size': if (args[i + 1]) { config.batchSize = parseInt(args[++i], 10) || 5 } break case '--help': case '-h': printHelp() process.exit(0) } } return config } /** * Print help message */ export function printHelp(): void { console.log(` ${colors.bright}Webflow to Payload CMS Migration Script${colors.reset} ${colors.cyan}Usage:${colors.reset} pnpm tsx scripts/migration/migrate.ts [options] ${colors.cyan}Options:${colors.reset} -n, --dry-run Run without making changes (preview mode) -v, --verbose Show detailed logging output -f, --force Overwrite existing items (skip deduplication) -c, --collection Specific collection to migrate (categories|posts|portfolio|all) -s, --source Path to HTML/JSON export file --batch-size Number of items to process in parallel (default: 5) -h, --help Show this help message ${colors.cyan}Examples:${colors.reset} pnpm tsx scripts/migration/migrate.ts --dry-run --verbose pnpm tsx scripts/migration/migrate.ts --collection posts pnpm tsx scripts/migration/migrate.ts --source ./data/export.json ${colors.cyan}Environment Variables:${colors.reset} PAYLOAD_CMS_URL Payload CMS URL (default: http://localhost:3000) MIGRATION_ADMIN_EMAIL Admin user email for authentication MIGRATION_ADMIN_PASSWORD Admin user password `) }