feat(frontend): update pages, components and branding
Refresh Astro frontend implementation including new pages (Portfolio, Teams, Services), components, and styling updates.
This commit is contained in:
212
apps/frontend/src/lib/api/blog.ts
Normal file
212
apps/frontend/src/lib/api/blog.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Blog API utilities
|
||||
* Helper functions for fetching blog posts and categories from Payload CMS
|
||||
*/
|
||||
|
||||
interface PostImage {
|
||||
url: string
|
||||
alt?: string
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: string
|
||||
title: string
|
||||
slug: string
|
||||
backgroundColor?: string
|
||||
textColor?: string
|
||||
nameEn?: string
|
||||
}
|
||||
|
||||
export interface Post {
|
||||
id: string
|
||||
title: string
|
||||
slug: string
|
||||
heroImage?: PostImage | null
|
||||
ogImage?: PostImage | null
|
||||
excerpt?: string | null
|
||||
content?: any
|
||||
categories?: Category[] | null
|
||||
relatedPosts?: Post[] | null
|
||||
publishedAt?: string | null
|
||||
createdAt?: string | null
|
||||
updatedAt?: string | null
|
||||
status?: string
|
||||
meta?: {
|
||||
title?: string
|
||||
description?: string
|
||||
image?: PostImage
|
||||
}
|
||||
}
|
||||
|
||||
export interface BlogResponse {
|
||||
docs: Post[]
|
||||
totalDocs: number
|
||||
totalPages: number
|
||||
page: number
|
||||
hasPreviousPage: boolean
|
||||
hasNextPage: boolean
|
||||
}
|
||||
|
||||
// Use production CMS API (enchun-cms.anlstudio.cc) for all environments
|
||||
// Production CMS has 35+ published posts with proper slugs
|
||||
const PAYLOAD_API_URL = 'https://enchun-cms.anlstudio.cc/api'
|
||||
|
||||
/**
|
||||
* Fetch published posts from Payload CMS
|
||||
*/
|
||||
export async function fetchPosts(
|
||||
page = 1,
|
||||
limit = 12,
|
||||
categorySlug?: string | null
|
||||
): Promise<BlogResponse> {
|
||||
try {
|
||||
// Build query parameters
|
||||
const params = new URLSearchParams()
|
||||
|
||||
if (categorySlug) {
|
||||
// For category filtering, we need to filter by category
|
||||
params.append('where[categories][slug][equals]', categorySlug)
|
||||
}
|
||||
// Always filter by published status (use _status for Payload's internal status)
|
||||
params.append('where[_status][equals]', 'published')
|
||||
|
||||
params.append('sort', '-publishedAt')
|
||||
params.append('limit', limit.toString())
|
||||
params.append('page', page.toString())
|
||||
params.append('depth', '1')
|
||||
|
||||
const response = await fetch(`${PAYLOAD_API_URL}/posts?${params}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch posts: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data as BlogResponse
|
||||
} catch (error) {
|
||||
console.error('Error fetching posts:', error)
|
||||
return {
|
||||
docs: [],
|
||||
totalDocs: 0,
|
||||
totalPages: 0,
|
||||
page: 1,
|
||||
hasPreviousPage: false,
|
||||
hasNextPage: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single post by slug
|
||||
*/
|
||||
export async function fetchPostBySlug(slug: string): Promise<Post | null> {
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.append('where[slug][equals]', slug)
|
||||
params.append('where[_status][equals]', 'published')
|
||||
params.append('depth', '2')
|
||||
|
||||
const response = await fetch(`${PAYLOAD_API_URL}/posts?${params}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch post: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.docs?.[0] || null
|
||||
} catch (error) {
|
||||
console.error('Error fetching post by slug:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all categories
|
||||
*/
|
||||
export async function fetchCategories(): Promise<Category[]> {
|
||||
try {
|
||||
const response = await fetch(`${PAYLOAD_API_URL}/categories?sort=order&limit=20&depth=0`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch categories: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.docs || []
|
||||
} catch (error) {
|
||||
console.error('Error fetching categories:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch category by slug
|
||||
*/
|
||||
export async function fetchCategoryBySlug(slug: string): Promise<Category | null> {
|
||||
try {
|
||||
const response = await fetch(`${PAYLOAD_API_URL}/categories?where[slug][equals]=${slug}&depth=0`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch category: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.docs?.[0] || null
|
||||
} catch (error) {
|
||||
console.error('Error fetching category by slug:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch related posts (same category, excluding current post)
|
||||
*/
|
||||
export async function fetchRelatedPosts(
|
||||
currentPostId: string,
|
||||
categorySlugs: string[],
|
||||
limit = 3
|
||||
): Promise<Post[]> {
|
||||
try {
|
||||
if (categorySlugs.length === 0) return []
|
||||
|
||||
// Use a simpler approach - fetch published posts and filter
|
||||
const params = new URLSearchParams()
|
||||
params.append('where[_status][equals]', 'published')
|
||||
params.append('limit', '20') // Fetch more to filter client-side
|
||||
params.append('sort', '-publishedAt')
|
||||
params.append('depth', '1')
|
||||
|
||||
const response = await fetch(`${PAYLOAD_API_URL}/posts?${params}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch related posts: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const allPosts = data.docs || []
|
||||
|
||||
// Filter: same category, exclude current, limit
|
||||
return allPosts
|
||||
.filter((post: Post) => post.id !== currentPostId)
|
||||
.filter((post: Post) =>
|
||||
post.categories?.some((cat: Category) => categorySlugs.includes(cat.slug))
|
||||
)
|
||||
.slice(0, limit)
|
||||
} catch (error) {
|
||||
console.error('Error fetching related posts:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date in Traditional Chinese
|
||||
*/
|
||||
export function formatDate(dateStr: string | null | undefined): string {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('zh-TW', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
169
apps/frontend/src/lib/api/home.ts
Normal file
169
apps/frontend/src/lib/api/home.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
// Home Page API Service
|
||||
// Fetches data from Payload CMS for the homepage
|
||||
|
||||
export interface ServiceFeature {
|
||||
icon?: string
|
||||
title?: string
|
||||
description?: string
|
||||
link?: {
|
||||
url?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface PortfolioSection {
|
||||
headline?: string
|
||||
subheadline?: string
|
||||
itemsToShow?: number
|
||||
}
|
||||
|
||||
interface CTASection {
|
||||
headline?: string
|
||||
description?: string
|
||||
buttonText?: string
|
||||
buttonLink?: string
|
||||
}
|
||||
|
||||
export interface HomeData {
|
||||
heroHeadline?: string
|
||||
heroSubheadline?: string
|
||||
heroDesktopVideo?: {
|
||||
url?: string
|
||||
filename?: string
|
||||
alt?: string
|
||||
sizes?: {
|
||||
thumbnail?: {
|
||||
url?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
heroMobileVideo?: {
|
||||
url?: string
|
||||
filename?: string
|
||||
alt?: string
|
||||
}
|
||||
heroFallbackImage?: {
|
||||
url?: string
|
||||
filename?: string
|
||||
alt?: string
|
||||
}
|
||||
heroLogo?: {
|
||||
url?: string
|
||||
filename?: string
|
||||
alt?: string
|
||||
}
|
||||
serviceFeatures?: ServiceFeature[]
|
||||
portfolioSection?: PortfolioSection
|
||||
ctaSection?: CTASection
|
||||
}
|
||||
|
||||
export interface ServiceItem {
|
||||
icon?: string
|
||||
title?: string
|
||||
description?: string
|
||||
link?: {
|
||||
url?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface PortfolioItem {
|
||||
id: string
|
||||
title: string
|
||||
slug: string
|
||||
url?: string
|
||||
image?: {
|
||||
url?: string
|
||||
alt?: string
|
||||
filename?: string
|
||||
sizes?: {
|
||||
thumbnail?: {
|
||||
url?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
description?: string
|
||||
websiteType?: string
|
||||
tags?: Array<{ tag?: string }>
|
||||
}
|
||||
|
||||
// Get API base URL
|
||||
function getApiBaseUrl(): string {
|
||||
if (typeof process !== 'undefined' && process.env?.PAYLOAD_CMS_URL) {
|
||||
return process.env.PAYLOAD_CMS_URL
|
||||
}
|
||||
return import.meta.env.PUBLIC_PAYLOAD_CMS_URL || 'https://enchun-admin.anlstudio.cc'
|
||||
}
|
||||
|
||||
const PAYLOAD_URL = getApiBaseUrl()
|
||||
const PAYLOAD_API_KEY = import.meta.env.PAYLOAD_CMS_API_KEY
|
||||
|
||||
// Build common headers
|
||||
function getHeaders(): HeadersInit {
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
if (PAYLOAD_API_KEY) {
|
||||
headers['Authorization'] = `Bearer ${PAYLOAD_API_KEY}`
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
// Fetch Home global data
|
||||
export async function getHomeData(draft = false): Promise<HomeData | null> {
|
||||
try {
|
||||
const draftParam = draft ? '?draft=true' : ''
|
||||
const response = await fetch(`${PAYLOAD_URL}/api/globals/home${draftParam}`, {
|
||||
headers: getHeaders(),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Failed to fetch home data:', response.status, response.statusText)
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error('Error fetching home data:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch Portfolio items for preview
|
||||
export async function getPortfolioPreview(limit = 3): Promise<PortfolioItem[]> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${PAYLOAD_URL}/api/portfolio?sort=-updatedAt&limit=${limit}&depth=1`,
|
||||
{
|
||||
headers: getHeaders(),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Failed to fetch portfolio items:', response.status)
|
||||
return []
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.docs || []
|
||||
} catch (error) {
|
||||
console.error('Error fetching portfolio items:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Combined fetch for homepage
|
||||
export async function getHomepageData(draft = false): Promise<{
|
||||
home: HomeData | null
|
||||
portfolioItems: PortfolioItem[]
|
||||
}> {
|
||||
// First fetch home data to get the itemsToShow value
|
||||
const home = await getHomeData(draft)
|
||||
const limit = home?.portfolioSection?.itemsToShow || 3
|
||||
|
||||
// Then fetch portfolio items with the correct limit
|
||||
const portfolioItems = await getPortfolioPreview(limit)
|
||||
|
||||
return { home, portfolioItems }
|
||||
}
|
||||
139
apps/frontend/src/lib/api/portfolio.ts
Normal file
139
apps/frontend/src/lib/api/portfolio.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Portfolio API utilities
|
||||
* Helper functions for fetching portfolio items from Payload CMS
|
||||
*/
|
||||
|
||||
interface PortfolioImage {
|
||||
url: string
|
||||
alt?: string
|
||||
}
|
||||
|
||||
export interface PortfolioTag {
|
||||
id: string
|
||||
tag: string
|
||||
}
|
||||
|
||||
export interface PortfolioItem {
|
||||
id: string
|
||||
title: string
|
||||
slug: string
|
||||
url?: string | null
|
||||
image?: PortfolioImage | null
|
||||
description?: string | null
|
||||
websiteType?: 'corporate' | 'ecommerce' | 'landing' | 'brand' | 'other' | null
|
||||
tags?: PortfolioTag[] | null
|
||||
createdAt?: string | null
|
||||
updatedAt?: string | null
|
||||
}
|
||||
|
||||
export interface PortfolioResponse {
|
||||
docs: PortfolioItem[]
|
||||
totalDocs: number
|
||||
totalPages: number
|
||||
page: number
|
||||
hasPreviousPage: boolean
|
||||
hasNextPage: boolean
|
||||
}
|
||||
|
||||
// Use production CMS API (enchun-cms.anlstudio.cc) for all environments
|
||||
const PAYLOAD_API_URL = 'https://enchun-cms.anlstudio.cc/api'
|
||||
|
||||
/**
|
||||
* Fetch all portfolio items
|
||||
*/
|
||||
export async function fetchPortfolios(
|
||||
page = 1,
|
||||
limit = 100
|
||||
): Promise<PortfolioResponse> {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
sort: '-createdAt',
|
||||
limit: limit.toString(),
|
||||
page: page.toString(),
|
||||
depth: '1'
|
||||
})
|
||||
|
||||
const response = await fetch(`${PAYLOAD_API_URL}/portfolio?${params}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch portfolios: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data as PortfolioResponse
|
||||
} catch (error) {
|
||||
console.error('Error fetching portfolios:', error)
|
||||
return {
|
||||
docs: [],
|
||||
totalDocs: 0,
|
||||
totalPages: 0,
|
||||
page: 1,
|
||||
hasPreviousPage: false,
|
||||
hasNextPage: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single portfolio item by slug
|
||||
*/
|
||||
export async function fetchPortfolioBySlug(slug: string): Promise<PortfolioItem | null> {
|
||||
try {
|
||||
const response = await fetch(`${PAYLOAD_API_URL}/portfolio?where[slug][equals]=${slug}&depth=1`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch portfolio: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.docs?.[0] || null
|
||||
} catch (error) {
|
||||
console.error('Error fetching portfolio by slug:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get website type label in Traditional Chinese
|
||||
*/
|
||||
export function getWebsiteTypeLabel(type: string | null | undefined): string {
|
||||
const labels: Record<string, string> = {
|
||||
corporate: '企業官網',
|
||||
ecommerce: '電商網站',
|
||||
landing: '活動頁面',
|
||||
brand: '品牌網站',
|
||||
other: '其他'
|
||||
}
|
||||
return labels[type || ''] || '其他'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tags from portfolio items
|
||||
*/
|
||||
export function getAllTags(portfolios: PortfolioItem[]): string[] {
|
||||
const tagSet = new Set<string>()
|
||||
portfolios.forEach(item => {
|
||||
item.tags?.forEach(tagObj => {
|
||||
tagSet.add(tagObj.tag)
|
||||
})
|
||||
})
|
||||
return Array.from(tagSet).sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter portfolios by website type
|
||||
*/
|
||||
export function filterByType(portfolios: PortfolioItem[], type: string): PortfolioItem[] {
|
||||
if (!type || type === 'all') return portfolios
|
||||
return portfolios.filter(item => item.websiteType === type)
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter portfolios by tag
|
||||
*/
|
||||
export function filterByTag(portfolios: PortfolioItem[], tag: string): PortfolioItem[] {
|
||||
if (!tag) return portfolios
|
||||
return portfolios.filter(item =>
|
||||
item.tags?.some(tagObj => tagObj.tag === tag)
|
||||
)
|
||||
}
|
||||
221
apps/frontend/src/lib/serializeLexical.spec.ts
Normal file
221
apps/frontend/src/lib/serializeLexical.spec.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Tests for serializeLexical utility
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { serializeLexical } from './serializeLexical'
|
||||
|
||||
describe('serializeLexical', () => {
|
||||
it('should return empty string for null input', async () => {
|
||||
const result = await serializeLexical(null)
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('should return empty string for undefined input', async () => {
|
||||
const result = await serializeLexical(undefined)
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('should return empty string for empty string', async () => {
|
||||
const result = await serializeLexical('')
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('should return plain text as-is', async () => {
|
||||
const result = await serializeLexical('Hello world')
|
||||
expect(result).toBe('Hello world')
|
||||
})
|
||||
|
||||
it('should return HTML as-is', async () => {
|
||||
const html = '<p>Hello <strong>world</strong></p>'
|
||||
const result = await serializeLexical(html)
|
||||
expect(result).toBe(html)
|
||||
})
|
||||
|
||||
it('should convert Lexical JSON to HTML - simple paragraph', async () => {
|
||||
const lexicalData = {
|
||||
root: {
|
||||
type: 'root',
|
||||
children: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Hello world'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const result = await serializeLexical(lexicalData)
|
||||
expect(result).toContain('Hello world')
|
||||
expect(result).toContain('<p>')
|
||||
expect(result).toContain('</p>')
|
||||
})
|
||||
|
||||
it('should convert Lexical JSON with heading', async () => {
|
||||
const lexicalData = {
|
||||
root: {
|
||||
type: 'root',
|
||||
children: [
|
||||
{
|
||||
type: 'heading',
|
||||
tag: 'h2',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Title'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const result = await serializeLexical(lexicalData)
|
||||
expect(result).toContain('<h2>')
|
||||
expect(result).toContain('Title')
|
||||
expect(result).toContain('</h2>')
|
||||
})
|
||||
|
||||
it('should convert Lexical JSON with bold text', async () => {
|
||||
const lexicalData = {
|
||||
root: {
|
||||
type: 'root',
|
||||
children: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Hello',
|
||||
bold: true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const result = await serializeLexical(lexicalData)
|
||||
expect(result).toContain('<strong>')
|
||||
expect(result).toContain('Hello')
|
||||
expect(result).toContain('</strong>')
|
||||
})
|
||||
|
||||
it('should convert Lexical JSON with link', async () => {
|
||||
const lexicalData = {
|
||||
root: {
|
||||
type: 'root',
|
||||
children: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
children: [
|
||||
{
|
||||
type: 'link',
|
||||
url: 'https://example.com',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Click here'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const result = await serializeLexical(lexicalData)
|
||||
expect(result).toContain('<a href="https://example.com"')
|
||||
expect(result).toContain('Click here')
|
||||
expect(result).toContain('</a>')
|
||||
})
|
||||
|
||||
it('should handle stringified Lexical JSON', async () => {
|
||||
const lexicalString = JSON.stringify({
|
||||
root: {
|
||||
type: 'root',
|
||||
children: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Test content'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const result = await serializeLexical(lexicalString)
|
||||
expect(result).toContain('Test content')
|
||||
})
|
||||
|
||||
it('should handle array input gracefully', async () => {
|
||||
const result = await serializeLexical(['not', 'valid'])
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('should convert list nodes', async () => {
|
||||
const lexicalData = {
|
||||
root: {
|
||||
type: 'root',
|
||||
children: [
|
||||
{
|
||||
type: 'list',
|
||||
listType: 'bullet',
|
||||
children: [
|
||||
{
|
||||
type: 'listitem',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Item 1'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const result = await serializeLexical(lexicalData)
|
||||
expect(result).toContain('<ul>')
|
||||
expect(result).toContain('<li>')
|
||||
expect(result).toContain('Item 1')
|
||||
expect(result).toContain('</li>')
|
||||
expect(result).toContain('</ul>')
|
||||
})
|
||||
|
||||
it('should handle blockquote', async () => {
|
||||
const lexicalData = {
|
||||
root: {
|
||||
type: 'root',
|
||||
children: [
|
||||
{
|
||||
type: 'quote',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Quote text'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const result = await serializeLexical(lexicalData)
|
||||
expect(result).toContain('<blockquote>')
|
||||
expect(result).toContain('Quote text')
|
||||
expect(result).toContain('</blockquote>')
|
||||
})
|
||||
})
|
||||
357
apps/frontend/src/lib/serializeLexical.ts
Normal file
357
apps/frontend/src/lib/serializeLexical.ts
Normal file
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* Lexical to HTML Converter
|
||||
* Converts Payload CMS Lexical editor JSON format to HTML
|
||||
*
|
||||
* This implementation uses a simple recursive renderer that works in
|
||||
* both browser and SSR environments without requiring heavy dependencies.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Serialized Lexical node types
|
||||
*/
|
||||
interface LexicalTextNode {
|
||||
type: 'text'
|
||||
text: string
|
||||
bold?: boolean
|
||||
italic?: boolean
|
||||
underline?: boolean
|
||||
strikethrough?: boolean
|
||||
code?: boolean
|
||||
}
|
||||
|
||||
interface LexicalHeadingNode {
|
||||
type: 'heading'
|
||||
tag: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
|
||||
children: any[]
|
||||
}
|
||||
|
||||
interface LexicalParagraphNode {
|
||||
type: 'paragraph'
|
||||
children: any[]
|
||||
}
|
||||
|
||||
interface LexicalListNode {
|
||||
type: 'list'
|
||||
listType: 'bullet' | 'number'
|
||||
children: any[]
|
||||
}
|
||||
|
||||
interface LexicalListItemNode {
|
||||
type: 'listitem'
|
||||
children: any[]
|
||||
}
|
||||
|
||||
interface LexicalLinkNode {
|
||||
type: 'link'
|
||||
url: string
|
||||
title?: string
|
||||
children: any[]
|
||||
}
|
||||
|
||||
interface LexicalQuoteNode {
|
||||
type: 'quote'
|
||||
children: any[]
|
||||
}
|
||||
|
||||
interface LexicalLineBreakNode {
|
||||
type: 'linebreak'
|
||||
}
|
||||
|
||||
interface LexicalUploadNode {
|
||||
type: 'upload'
|
||||
value?: {
|
||||
url: string
|
||||
alt?: string
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
}
|
||||
|
||||
interface LexicalBlockNode {
|
||||
type: 'block'
|
||||
fields?: {
|
||||
[key: string]: any
|
||||
}
|
||||
}
|
||||
|
||||
interface LexicalRootNode {
|
||||
type: 'root'
|
||||
children: any[]
|
||||
}
|
||||
|
||||
interface SerializedEditorState {
|
||||
root?: LexicalRootNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Lexical JSON to HTML string
|
||||
*
|
||||
* @param lexicalData - The Lexical editor state from Payload CMS (object or JSON string)
|
||||
* @returns HTML string
|
||||
*/
|
||||
export async function serializeLexical(lexicalData: unknown): Promise<string> {
|
||||
// Handle null, undefined, empty values
|
||||
if (lexicalData === null || lexicalData === undefined) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// Handle string content (already HTML or JSON string)
|
||||
if (typeof lexicalData === 'string') {
|
||||
const trimmed = lexicalData.trim()
|
||||
|
||||
// Empty string
|
||||
if (trimmed === '') {
|
||||
return ''
|
||||
}
|
||||
|
||||
// Check if it's a JSON object (Lexical format)
|
||||
if (trimmed.startsWith('{')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as SerializedEditorState | LexicalRootNode
|
||||
return renderLexical(parsed)
|
||||
} catch {
|
||||
// Not valid JSON, return as-is (might be HTML already)
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
|
||||
// Already HTML or plain text
|
||||
return trimmed
|
||||
}
|
||||
|
||||
// Handle object (Lexical editor state)
|
||||
if (typeof lexicalData === 'object') {
|
||||
// Ensure it's not an array
|
||||
if (Array.isArray(lexicalData)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return renderLexical(lexicalData as SerializedEditorState | LexicalRootNode)
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Render Lexical editor state to HTML
|
||||
*/
|
||||
function renderLexical(state: SerializedEditorState | LexicalRootNode): string {
|
||||
if (!state) return ''
|
||||
|
||||
// Get root children - handle both formats
|
||||
let children: any[] = []
|
||||
if ('root' in state && state.root?.children) {
|
||||
children = state.root.children
|
||||
} else if ('children' in state && Array.isArray(state.children)) {
|
||||
children = state.children
|
||||
}
|
||||
|
||||
return children
|
||||
.map((child) => renderNode(child))
|
||||
.filter(Boolean)
|
||||
.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single Lexical node to HTML
|
||||
*/
|
||||
function renderNode(node: any): string {
|
||||
if (!node || typeof node !== 'object') return ''
|
||||
|
||||
const type = node.type
|
||||
|
||||
switch (type) {
|
||||
case 'root':
|
||||
return (node.children || []).map((child: any) => renderNode(child)).join('')
|
||||
|
||||
case 'text':
|
||||
return renderTextNode(node)
|
||||
|
||||
case 'paragraph':
|
||||
return renderParagraphNode(node)
|
||||
|
||||
case 'heading':
|
||||
return renderHeadingNode(node)
|
||||
|
||||
case 'link':
|
||||
return renderLinkNode(node)
|
||||
|
||||
case 'list':
|
||||
return renderListNode(node)
|
||||
|
||||
case 'listitem':
|
||||
return renderListItemNode(node)
|
||||
|
||||
case 'quote':
|
||||
return renderQuoteNode(node)
|
||||
|
||||
case 'linebreak':
|
||||
return '<br>'
|
||||
|
||||
case 'upload':
|
||||
case 'image':
|
||||
return renderUploadNode(node)
|
||||
|
||||
case 'block':
|
||||
return renderBlockNode(node)
|
||||
|
||||
default:
|
||||
// Unknown node type - try to render children if available
|
||||
if (node.children && Array.isArray(node.children)) {
|
||||
return node.children.map((child: any) => renderNode(child)).join('')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render text node with formatting
|
||||
*/
|
||||
function renderTextNode(node: LexicalTextNode): string {
|
||||
let text = escapeHtml(node.text || '')
|
||||
|
||||
// Apply formatting styles (order matters for nested tags)
|
||||
if (node.code) text = `<code>${text}</code>`
|
||||
if (node.strikethrough) text = `<s>${text}</s>`
|
||||
if (node.underline) text = `<u>${text}</u>`
|
||||
if (node.italic) text = `<em>${text}</em>`
|
||||
if (node.bold) text = `<strong>${text}</strong>`
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
/**
|
||||
* Render paragraph node
|
||||
*/
|
||||
function renderParagraphNode(node: LexicalParagraphNode): string {
|
||||
const children = (node.children || []).map((child) => renderNode(child)).join('')
|
||||
return children ? `<p>${children}</p>` : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Render heading node
|
||||
*/
|
||||
function renderHeadingNode(node: LexicalHeadingNode): string {
|
||||
const tag = node.tag || 'h2'
|
||||
const children = (node.children || []).map((child) => renderNode(child)).join('')
|
||||
return children ? `<${tag}>${children}</${tag}>` : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Render link node
|
||||
*/
|
||||
function renderLinkNode(node: LexicalLinkNode): string {
|
||||
const url = escapeHtml(node.url || '#')
|
||||
const title = node.title ? ` title="${escapeHtml(node.title)}"` : ''
|
||||
const children = (node.children || []).map((child) => renderNode(child)).join('')
|
||||
return children ? `<a href="${url}"${title}>${children}</a>` : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Render list node
|
||||
*/
|
||||
function renderListNode(node: LexicalListNode): string {
|
||||
const listType = node.listType === 'number' ? 'ol' : 'ul'
|
||||
const children = (node.children || []).map((child) => renderNode(child)).join('')
|
||||
return children ? `<${listType}>${children}</${listType}>` : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Render list item node
|
||||
*/
|
||||
function renderListItemNode(node: LexicalListItemNode): string {
|
||||
const children = (node.children || []).map((child) => renderNode(child)).join('')
|
||||
return children ? `<li>${children}</li>` : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Render quote node
|
||||
*/
|
||||
function renderQuoteNode(node: LexicalQuoteNode): string {
|
||||
const children = (node.children || []).map((child) => renderNode(child)).join('')
|
||||
return children ? `<blockquote>${children}</blockquote>` : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Render upload/image node
|
||||
*/
|
||||
function renderUploadNode(node: LexicalUploadNode): string {
|
||||
const url = node.value?.url || ''
|
||||
const alt = escapeHtml(node.value?.alt || '')
|
||||
const width = node.value?.width ? ` width="${node.value.width}"` : ''
|
||||
const height = node.value?.height ? ` height="${node.value.height}"` : ''
|
||||
|
||||
if (!url) return ''
|
||||
|
||||
return `<img src="${escapeHtml(url)}" alt="${alt}"${width}${height} style="max-width: 100%; height: auto;" loading="lazy" />`
|
||||
}
|
||||
|
||||
/**
|
||||
* Render block node (for custom blocks like media, banners, etc.)
|
||||
*/
|
||||
function renderBlockNode(node: LexicalBlockNode): string {
|
||||
const fields = node.fields || {}
|
||||
|
||||
// Handle media block
|
||||
if (fields.media) {
|
||||
const media = fields.media
|
||||
const url = media.url || ''
|
||||
const alt = escapeHtml(media.alt || '')
|
||||
|
||||
if (url) {
|
||||
return `<figure><img src="${escapeHtml(url)}" alt="${alt}" style="max-width: 100%; height: auto; border-radius: 8px; margin: 2rem 0;" loading="lazy" /></figure>`
|
||||
}
|
||||
}
|
||||
|
||||
// Handle other block types
|
||||
if (fields.blockType === 'banner') {
|
||||
const content = fields.content || ''
|
||||
return `<div class="banner">${renderNode({ type: 'root', children: content.root?.children || [] })}</div>`
|
||||
}
|
||||
|
||||
if (fields.blockType === 'code') {
|
||||
const code = escapeHtml(fields.code || '')
|
||||
const language = escapeHtml(fields.language || '')
|
||||
return `<pre><code class="language-${language}">${code}</code></pre>`
|
||||
}
|
||||
|
||||
// Fallback: try to render any nested content
|
||||
if (fields.content?.root?.children) {
|
||||
return fields.content.root.children.map((child: any) => renderNode(child)).join('')
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML special characters
|
||||
*/
|
||||
function escapeHtml(text: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}
|
||||
return text.replace(/[&<>"']/g, (m) => map[m] || m)
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous version for compatibility (not recommended for use with Lexical)
|
||||
* @deprecated Use serializeLexical instead
|
||||
*/
|
||||
export function serializeLexicalSync(lexicalData: unknown): string {
|
||||
// This is a stub - the actual implementation should be async
|
||||
// For simple cases, we'll try to return something useful
|
||||
if (!lexicalData || typeof lexicalData !== 'object') {
|
||||
return String(lexicalData ?? '')
|
||||
}
|
||||
|
||||
if (typeof lexicalData === 'string') {
|
||||
return lexicalData
|
||||
}
|
||||
|
||||
// For Lexical objects, return empty since we can't properly render synchronously
|
||||
return ''
|
||||
}
|
||||
Reference in New Issue
Block a user