Update Payload CMS configuration, collections (Audit, Posts), and add migration scripts/reports.
378 lines
9.7 KiB
TypeScript
378 lines
9.7 KiB
TypeScript
/**
|
||
* 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(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
||
text = text.replace(/<style[^>]*>[\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 <name> Specific collection to migrate (categories|posts|portfolio|all)
|
||
-s, --source <path> Path to HTML/JSON export file
|
||
--batch-size <number> 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
|
||
`)
|
||
}
|