Files
website-enchun-mgr/apps/backend/scripts/migration/transformers.ts
pkupuk be7fc902fb feat(backend): update collections, config and migration tools
Update Payload CMS configuration, collections (Audit, Posts), and add migration scripts/reports.
2026-02-11 11:50:23 +08:00

208 lines
5.6 KiB
TypeScript

/**
* Data Transformers
* Story 1.3: Content Migration Script
*
* Transforms Webflow data to Payload CMS format
*/
import type {
PayloadCategory,
PayloadPostData,
PayloadPortfolioData,
WebflowCategory,
WebflowPost,
WebflowPortfolioItem,
} from './types'
import { toSlug, splitColorToTextBackground, truncate, htmlToPlainText, parseDate } from './utils'
import { htmlToLexical } from './lexicalConverter'
// ============================================================
// CATEGORY TRANSFORMER
// ============================================================
/**
* Transform Webflow category to Payload CMS format
*/
export function transformCategory(
webflowCategory: WebflowCategory,
order: number = 0,
): PayloadCategory {
const { textColor, backgroundColor } = splitColorToTextBackground(
webflowCategory.colorHex || '#ffffff',
)
return {
title: webflowCategory.name,
nameEn: '', // Can be manually set later
order,
textColor,
backgroundColor,
slug: webflowCategory.slug || toSlug(webflowCategory.name),
}
}
/**
* Transform multiple categories
*/
export function transformCategories(
webflowCategories: WebflowCategory[],
): PayloadCategory[] {
return webflowCategories.map((cat, index) => transformCategory(cat, index))
}
// ============================================================
// POST TRANSFORMER
// ============================================================
/**
* Transform Webflow post to Payload CMS format
*/
export function transformPost(webflowPost: WebflowPost): PayloadPostData {
// Generate excerpt from content if not provided
const excerpt = webflowPost.excerpt || htmlToPlainText(webflowPost.content, 200)
// Convert HTML to Lexical JSON string format (for richText field storage)
const lexicalContent = htmlToLexical(webflowPost.content || '')
return {
title: webflowPost.title,
slug: webflowPost.slug || toSlug(webflowPost.title),
heroImage: undefined, // Will be set by media handler
ogImage: undefined, // Will be set by media handler
content: lexicalContent as any, // Lexical JSON string for richText field
excerpt: truncate(excerpt, 200),
publishedAt: parseDate(webflowPost.publishedDate),
status: 'published',
categories: [], // Will be resolved after categories are migrated
meta: {
title: webflowPost.seoTitle || webflowPost.title,
description: webflowPost.seoDescription || excerpt,
image: undefined, // Will be set by media handler
},
}
}
/**
* Transform multiple posts
*/
export function transformPosts(webflowPosts: WebflowPost[]): PayloadPostData[] {
return webflowPosts.map((post) => transformPost(post))
}
// ============================================================
// PORTFOLIO TRANSFORMER
// ============================================================
/**
* Transform Webflow portfolio item to Payload CMS format
*/
export function transformPortfolio(
webflowPortfolio: WebflowPortfolioItem,
): PayloadPortfolioData {
return {
title: webflowPortfolio.name,
slug: webflowPortfolio.slug || toSlug(webflowPortfolio.name),
url: webflowPortfolio.websiteLink,
image: undefined, // Will be set by media handler
description: webflowPortfolio.description,
websiteType: webflowPortfolio.websiteType || 'other',
tags: parseTagsString(webflowPortfolio.tags),
}
}
/**
* Transform multiple portfolio items
*/
export function transformPortfolios(
webflowPortfolios: WebflowPortfolioItem[],
): PayloadPortfolioData[] {
return webflowPortfolios.map((item) => transformPortfolio(item))
}
// ============================================================
// HELPER FUNCTIONS
// ============================================================
/**
* Parse comma-separated tags into array
*/
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 }))
}
// ============================================================
// VALIDATION FUNCTIONS
// ============================================================
/**
* Validate transformed category
*/
export function validateCategory(category: PayloadCategory): { valid: boolean; errors: string[] } {
const errors: string[] = []
if (!category.title) {
errors.push('Category title is required')
}
if (!category.slug) {
errors.push('Category slug is required')
}
if (!category.textColor || !category.backgroundColor) {
errors.push('Category colors are required')
}
return { valid: errors.length === 0, errors }
}
/**
* Validate transformed post
*/
export function validatePost(post: PayloadPostData): { valid: boolean; errors: string[] } {
const errors: string[] = []
if (!post.title) {
errors.push('Post title is required')
}
if (!post.slug) {
errors.push('Post slug is required')
}
if (!post.content) {
errors.push('Post content is required')
}
if (!post.publishedAt) {
errors.push('Post published date is required')
}
return { valid: errors.length === 0, errors }
}
/**
* Validate transformed portfolio item
*/
export function validatePortfolio(
portfolio: PayloadPortfolioData,
): { valid: boolean; errors: string[] } {
const errors: string[] = []
if (!portfolio.title) {
errors.push('Portfolio title is required')
}
if (!portfolio.slug) {
errors.push('Portfolio slug is required')
}
if (!portfolio.url) {
errors.push('Portfolio URL is required')
}
if (!portfolio.websiteType) {
errors.push('Portfolio website type is required')
}
return { valid: errors.length === 0, errors }
}