Files

778 lines
29 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server'
import { draftMode } from 'next/headers'
import { CMS_SITE_ORIGIN } from '~/lib/constants'
import { generateReadingTime } from '~/lib/helpers'
// Lightweight runtime for better performance
export const runtime = 'edge'
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
}
const cfHeaders = {
'CF-Access-Client-Id': process.env.CF_ACCESS_CLIENT_ID ?? '',
'CF-Access-Client-Secret': process.env.CF_ACCESS_CLIENT_SECRET ?? '',
}
// Lightweight TOC generation for edge runtime
type TocItem = { content: string; slug: string; lvl: number }
function generateTocFromMarkdown(markdown: string, maxDepth: number = 2) {
const lines = markdown.split(/\r?\n/)
const items: TocItem[] = []
for (const line of lines) {
const match = /^(#{1,6})\s+(.*)$/.exec(line)
if (!match) continue
const depth = match[1].length
if (depth > maxDepth) continue
const text = match[2].trim()
if (!text) continue
const slug = text
.trim()
.toLowerCase()
.replace(/[`~!@#$%^&*()+=|{}\[\]\\:\";'<>?,./]+/g, '')
.replace(/\s+/g, '-')
items.push({ content: text, slug, lvl: depth })
}
const content = items
.map((h) => `${' '.repeat(Math.max(0, h.lvl - 1))}- [${h.content}](#${h.slug})`)
.join('\n')
return { content, json: items }
}
// Minimal rich-text to plain text for reading time
function richTextToPlainText(content: any): string {
try {
const blocks = content?.root?.children
if (!Array.isArray(blocks)) return ''
const segments: string[] = []
for (const node of blocks) {
if (node?.type === 'heading') {
const text = Array.isArray(node.children)
? node.children.map((c: any) => c?.text || '').join('')
: ''
if (text) segments.push(text)
} else if (node?.type === 'paragraph') {
const text = Array.isArray(node.children)
? node.children.map((c: any) => c?.text || '').join('')
: ''
if (text) segments.push(text)
} else if (node?.type === 'list') {
const items = Array.isArray(node.children)
? node.children
.map((item: any) =>
Array.isArray(item?.children)
? item.children.map((c: any) => c?.text || '').join('')
: ''
)
.filter(Boolean)
: []
if (items.length > 0) segments.push(items.join(' '))
} else if (node?.type === 'link') {
const text = Array.isArray(node.children)
? node.children.map((c: any) => c?.text || '').join('')
: ''
if (text) segments.push(text)
}
}
return segments.join('\n')
} catch {
return ''
}
}
// Convert Payload rich text content to markdown
function convertRichTextToMarkdown(content: any): string {
if (!content?.root?.children) return ''
return content.root.children
.map((node: any) => {
if (node.type === 'heading') {
const level = node.tag && typeof node.tag === 'string' ? node.tag.replace('h', '') : '1'
const text = node.children?.map((child: any) => child.text).join('') || ''
return `${'#'.repeat(Number(level))} ${text}`
}
if (node.type === 'paragraph') {
return node.children?.map((child: any) => child.text).join('') || ''
}
if (node.type === 'list') {
const items = node.children
?.map((item: any) => {
if (item.type === 'list-item') {
return `- ${item.children?.map((child: any) => child.text).join('') || ''}`
}
return ''
})
.filter(Boolean)
.join('\n')
return items
}
if (node.type === 'link') {
const text = node.children?.map((child: any) => child.text).join('') || ''
const url = node.url || ''
return `[${text}](${url})`
}
return ''
})
.filter(Boolean)
.join('\n\n')
}
// Handle preflight requests
export async function OPTIONS() {
return new Response(null, {
status: 200,
headers: corsHeaders,
})
}
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const mode = searchParams.get('mode') || 'preview' // 'preview' or 'full'
const limit = searchParams.get('limit') || '100'
const slug = searchParams.get('slug') // For fetching specific post
const draftParam = searchParams.get('draft') === 'true' // Explicit draft parameter
const baseUrl = CMS_SITE_ORIGIN
const apiKey = process.env.CMS_API_KEY
// Check if we're in draft mode (either Next.js draft mode OR explicit draft parameter)
const { isEnabled: isDraftMode } = await draftMode()
const shouldFetchDraft = isDraftMode || draftParam
// When fetching a specific post, we need to handle versioning correctly
if (slug) {
// If in draft mode, fetch the latest version (draft or published)
if (shouldFetchDraft) {
// Strategy 1: Try to get the latest version from versions API (including drafts)
const allVersionsUrl = new URL('/api/posts/versions', baseUrl)
allVersionsUrl.searchParams.set('where[version.slug][equals]', slug)
allVersionsUrl.searchParams.set('sort', '-updatedAt') // Get the most recent version regardless of status
allVersionsUrl.searchParams.set('limit', '1')
allVersionsUrl.searchParams.set('depth', '2')
const allVersionsResponse = await fetch(allVersionsUrl.toString(), {
headers: {
'Content-Type': 'application/json',
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
...cfHeaders,
},
cache: 'no-store',
})
if (allVersionsResponse.ok) {
const versionsData = await allVersionsResponse.json()
if (versionsData.docs && versionsData.docs.length > 0) {
const latestVersion = versionsData.docs[0].version
if (latestVersion) {
const markdownContent = convertRichTextToMarkdown(latestVersion.content)
const readingTime = generateReadingTime(richTextToPlainText(latestVersion.content))
const tocResult = generateTocFromMarkdown(
markdownContent,
latestVersion.toc_depth || 3
)
const processedPost = {
slug: latestVersion.slug || '',
title: latestVersion.title || '',
description: latestVersion.description || '',
date: latestVersion.date || new Date().toISOString(),
formattedDate: new Date(latestVersion.date || new Date()).toLocaleDateString(
'en-US',
{
month: 'long',
day: 'numeric',
year: 'numeric',
}
),
readingTime,
authors: Array.isArray(latestVersion.authors)
? latestVersion.authors.map((a: any) => ({
author: a?.author || 'Unknown Author',
author_id: a?.author_id || '',
position: a?.position || '',
author_url: a?.author_url || '#',
author_image_url: a?.author_image_url?.url
? typeof a.author_image_url.url === 'string' &&
a.author_image_url.url.includes('http')
? a.author_image_url.url
: `${baseUrl}${a.author_image_url.url}`
: null,
username: a?.username || '',
}))
: [],
thumb: latestVersion.thumb?.url
? `${baseUrl}${latestVersion.thumb.url}`
: undefined,
image: latestVersion.image?.url
? `${baseUrl}${latestVersion.image.url}`
: undefined,
meta: latestVersion.meta || null,
url: `/blog/${latestVersion.slug}`,
path: `/blog/${latestVersion.slug}`,
tags: latestVersion.tags || [],
categories: [],
isCMS: true,
content: markdownContent,
richContent: latestVersion.content,
toc: tocResult,
toc_depth: latestVersion.toc_depth || 3,
isDraft: true,
_status: latestVersion._status,
}
return NextResponse.json(
{
success: true,
post: processedPost,
mode,
isDraft: shouldFetchDraft,
},
{ headers: corsHeaders }
)
}
}
}
// Strategy 2: Try to get the most recent draft
const draftUrl = new URL('/api/posts', baseUrl)
draftUrl.searchParams.set('where[slug][equals]', slug)
draftUrl.searchParams.set('depth', '2')
draftUrl.searchParams.set('draft', 'true')
draftUrl.searchParams.set('sort', '-updatedAt') // Get the most recent version
const draftResponse = await fetch(draftUrl.toString(), {
headers: {
'Content-Type': 'application/json',
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
...cfHeaders,
},
cache: 'no-store', // Never cache draft content
})
if (draftResponse.ok) {
const draftData = await draftResponse.json()
if (draftData.docs && draftData.docs.length > 0) {
const post = draftData.docs[0]
const markdownContent = convertRichTextToMarkdown(post.content)
const readingTime = generateReadingTime(richTextToPlainText(post.content))
const tocResult = generateTocFromMarkdown(markdownContent, post.toc_depth || 3)
const processedPost = {
slug: post.slug || '',
title: post.title || '',
description: post.description || '',
date: post.date || new Date().toISOString(),
formattedDate: new Date(post.date || new Date()).toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
}),
readingTime,
authors: Array.isArray(post.authors)
? post.authors.map((a: any) => ({
author: a?.author || 'Unknown Author',
author_id: a?.author_id || '',
position: a?.position || '',
author_url: a?.author_url || '#',
author_image_url: a?.author_image_url?.url
? typeof a.author_image_url.url === 'string' &&
a.author_image_url.url.includes('http')
? a.author_image_url.url
: `${baseUrl}${a.author_image_url.url}`
: null,
username: a?.username || '',
}))
: [],
thumb: post.thumb?.url ? `${baseUrl}${post.thumb.url}` : undefined,
image: post.image?.url ? `${baseUrl}${post.image.url}` : undefined,
meta: post.meta || null,
url: `/blog/${post.slug}`,
path: `/blog/${post.slug}`,
tags: post.tags || [],
categories: [],
isCMS: true,
content: markdownContent,
richContent: post.content,
toc: tocResult,
toc_depth: post.toc_depth || 3,
isDraft: true,
_status: post._status,
}
return NextResponse.json(
{
success: true,
post: processedPost,
mode,
isDraft: shouldFetchDraft,
},
{ headers: corsHeaders }
)
}
}
// Strategy 3: If no draft found, try published version but still mark as draft mode
const publishedUrl = new URL('/api/posts', baseUrl)
publishedUrl.searchParams.set('where[slug][equals]', slug)
publishedUrl.searchParams.set('depth', '2')
publishedUrl.searchParams.set('draft', 'false')
const publishedResponse = await fetch(publishedUrl.toString(), {
headers: {
'Content-Type': 'application/json',
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
...cfHeaders,
},
cache: 'no-store',
})
if (publishedResponse.ok) {
const publishedData = await publishedResponse.json()
if (publishedData.docs && publishedData.docs.length > 0) {
const post = publishedData.docs[0]
const markdownContent = convertRichTextToMarkdown(post.content)
const readingTime = generateReadingTime(richTextToPlainText(post.content))
const tocResult = generateTocFromMarkdown(markdownContent, post.toc_depth || 3)
const processedPost = {
slug: post.slug || '',
title: post.title || '',
description: post.description || '',
date: post.date || new Date().toISOString(),
formattedDate: new Date(post.date || new Date()).toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
}),
readingTime,
authors: Array.isArray(post.authors)
? post.authors.map((a: any) => ({
author: a?.author || 'Unknown Author',
author_id: a?.author_id || '',
position: a?.position || '',
author_url: a?.author_url || '#',
author_image_url: a?.author_image_url?.url
? typeof a.author_image_url.url === 'string' &&
a.author_image_url.url.includes('http')
? a.author_image_url.url
: `${baseUrl}${a.author_image_url.url}`
: null,
username: a?.username || '',
}))
: [],
thumb: post.thumb?.url ? `${baseUrl}${post.thumb.url}` : undefined,
image: post.image?.url ? `${baseUrl}${post.image.url}` : undefined,
meta: post.meta || null,
url: `/blog/${post.slug}`,
path: `/blog/${post.slug}`,
tags: post.tags || [],
categories: [],
isCMS: true,
content: markdownContent,
richContent: post.content,
toc: tocResult,
toc_depth: post.toc_depth || 3,
isDraft: true,
_status: post._status,
}
return NextResponse.json(
{
success: true,
post: processedPost,
mode,
isDraft: shouldFetchDraft,
},
{ headers: corsHeaders }
)
}
}
}
// Strategy 1: Try to get the latest published version using the versions API
const versionsUrl = new URL('/api/posts/versions', baseUrl)
versionsUrl.searchParams.set('where[version.slug][equals]', slug)
versionsUrl.searchParams.set('where[version._status][equals]', 'published')
versionsUrl.searchParams.set('sort', '-createdAt') // Use createdAt instead of updatedAt for versions
versionsUrl.searchParams.set('limit', '1')
versionsUrl.searchParams.set('depth', '2')
const versionsResponse = await fetch(versionsUrl.toString(), {
headers: {
'Content-Type': 'application/json',
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
...cfHeaders,
},
// For published posts: allow caching with revalidation
next: { revalidate: 60 }, // 1 minute
})
if (versionsResponse.ok) {
const versionsData = await versionsResponse.json()
if (versionsData.docs && versionsData.docs.length > 0) {
const latestPublishedVersion = versionsData.docs[0].version
if (latestPublishedVersion) {
const post = latestPublishedVersion
const markdownContent = convertRichTextToMarkdown(post.content)
const plain = richTextToPlainText(post.content)
const readingTime = generateReadingTime(plain)
const tocResult = generateTocFromMarkdown(markdownContent, post.toc_depth || 3)
const processedPost = {
type: 'blog' as const,
slug: post.slug,
title: post.title || '',
description: post.description || '',
date: post.date || post.createdAt || new Date().toISOString(),
formattedDate: new Date(post.date || post.createdAt || new Date()).toLocaleDateString(
'en-IN',
{ month: 'long', day: 'numeric', year: 'numeric' }
),
readingTime,
authors: Array.isArray(post.authors)
? post.authors.map((a: any) => ({
author: a?.author || 'Unknown Author',
author_id: a?.author_id || '',
position: a?.position || '',
author_url: a?.author_url || '#',
author_image_url: a?.author_image_url?.url
? typeof a.author_image_url.url === 'string' &&
a.author_image_url.url.includes('http')
? a.author_image_url.url
: `${baseUrl}${a.author_image_url.url}`
: null,
username: a?.username || '',
}))
: [],
thumb: post.thumb?.url
? typeof post.thumb.url === 'string' && post.thumb.url.includes('http')
? post.thumb.url
: `${baseUrl}${post.thumb.url}`
: '',
image: post.image?.url
? typeof post.image.url === 'string' && post.image.url.includes('http')
? post.image.url
: `${baseUrl}${post.image.url}`
: undefined,
meta: post.meta || null,
url: `/blog/${post.slug}`,
path: `/blog/${post.slug}`,
tags: post.tags || [],
categories: [],
isCMS: true,
content: mode === 'full' ? markdownContent : undefined,
richContent: mode === 'full' ? post.content : undefined,
toc: tocResult,
toc_depth: post.toc_depth || 3,
}
return NextResponse.json(
{
success: true,
post: processedPost,
mode,
source: 'versions-api',
},
{ headers: corsHeaders }
)
}
}
} else {
if (process.env.NODE_ENV !== 'production') {
console.log('[cms-posts] Versions API failed, response:', await versionsResponse.text())
}
}
// Strategy 2: If versions API didn't work, try finding the parent post first, then get its latest published version
const parentUrl = new URL('/api/posts', baseUrl)
parentUrl.searchParams.set('where[slug][equals]', slug)
parentUrl.searchParams.set('limit', '1')
parentUrl.searchParams.set('depth', '0') // Just get the ID
const parentResponse = await fetch(parentUrl.toString(), {
headers: {
'Content-Type': 'application/json',
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
...cfHeaders,
},
next: { revalidate: 60 }, // 1 minute for published posts
})
if (parentResponse.ok) {
const parentData = await parentResponse.json()
if (parentData.docs && parentData.docs.length > 0) {
const parentId = parentData.docs[0].id
// Now get the latest published version of this specific post
const versionsByParentUrl = new URL('/api/posts/versions', baseUrl)
versionsByParentUrl.searchParams.set('where[parent][equals]', parentId)
versionsByParentUrl.searchParams.set('where[version._status][equals]', 'published')
versionsByParentUrl.searchParams.set('sort', '-createdAt')
versionsByParentUrl.searchParams.set('limit', '1')
versionsByParentUrl.searchParams.set('depth', '2')
const versionsByParentResponse = await fetch(versionsByParentUrl.toString(), {
headers: {
'Content-Type': 'application/json',
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
...cfHeaders,
},
next: { revalidate: 60 }, // 1 minute for published posts
})
if (versionsByParentResponse.ok) {
const versionsByParentData = await versionsByParentResponse.json()
if (versionsByParentData.docs && versionsByParentData.docs.length > 0) {
const latestPublishedVersion = versionsByParentData.docs[0].version
if (latestPublishedVersion) {
const post = latestPublishedVersion
const markdownContent = convertRichTextToMarkdown(post.content)
const plain = richTextToPlainText(post.content)
const readingTime = generateReadingTime(plain)
const processedPost = {
type: 'blog' as const,
slug: post.slug,
title: post.title || '',
description: post.description || '',
date: post.date || post.createdAt || new Date().toISOString(),
formattedDate: new Date(
post.date || post.createdAt || new Date()
).toLocaleDateString('en-IN', { month: 'long', day: 'numeric', year: 'numeric' }),
readingTime,
authors: Array.isArray(post.authors)
? post.authors.map((a: any) => ({
author: a?.author || 'Unknown Author',
author_id: a?.author_id || '',
position: a?.position || '',
author_url: a?.author_url || '#',
author_image_url: a?.author_image_url?.url
? typeof a.author_image_url.url === 'string' &&
a.author_image_url.url.includes('http')
? a.author_image_url.url
: `${baseUrl}${a.author_image_url.url}`
: null,
username: a?.username || '',
}))
: [],
thumb: post.thumb?.url
? typeof post.thumb.url === 'string' && post.thumb.url.includes('http')
? post.thumb.url
: `${baseUrl}${post.thumb.url}`
: '',
image: post.image?.url
? typeof post.image.url === 'string' && post.image.url.includes('http')
? post.image.url
: `${baseUrl}${post.image.url}`
: undefined,
url: `/blog/${post.slug}`,
path: `/blog/${post.slug}`,
tags: post.tags || [],
categories: [],
isCMS: true,
content: mode === 'full' ? markdownContent : undefined,
richContent: mode === 'full' ? post.content : undefined,
}
return NextResponse.json(
{
success: true,
post: processedPost,
mode,
source: 'versions-by-parent-api',
},
{ headers: corsHeaders }
)
}
}
}
}
}
}
// Fallback to regular posts API for listing or if versions API fails
const url = new URL('/api/posts', baseUrl)
url.searchParams.set('depth', '2')
url.searchParams.set('draft', 'false')
url.searchParams.set('limit', limit)
url.searchParams.set('where[_status][equals]', 'published')
// If fetching specific post by slug (fallback)
if (slug) {
url.searchParams.set('where[slug][equals]', slug)
url.searchParams.set('limit', '1')
}
const response = await fetch(url.toString(), {
headers: {
'Content-Type': 'application/json',
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
...cfHeaders,
},
// For individual post requests, don't cache to ensure fresh data
cache: slug ? 'no-store' : 'default',
next: slug ? undefined : { revalidate: 300 },
// Add SSL configuration for production
...(process.env.NODE_ENV === 'production' && {
// Allow self-signed certificates in development, but use proper SSL in production
// This helps with Vercel's internal networking
agent: false,
}),
})
if (!response.ok) {
console.error('[cms-posts] Non-OK response:', response.status, response.statusText)
return NextResponse.json(
{
success: false,
error: 'Failed to fetch posts from CMS',
status: response.status,
},
{ status: response.status, headers: corsHeaders }
)
}
const contentType = response.headers.get('content-type') || ''
if (!contentType.toLowerCase().includes('application/json')) {
console.error('[cms-posts] Non-JSON response, content-type:', contentType)
return NextResponse.json(
{
success: false,
error: 'CMS returned non-JSON response',
contentType,
},
{ status: 502, headers: corsHeaders }
)
}
const data = await response.json()
const docs = Array.isArray(data?.docs) ? data.docs : []
const dateFmt: Intl.DateTimeFormatOptions = { month: 'long', day: 'numeric', year: 'numeric' }
const posts = docs
.filter((p: any) => !!p?.slug)
.map((p: any) => {
const thumbUrl = p?.thumb?.url
? typeof p.thumb.url === 'string' && p.thumb.url.includes('http')
? p.thumb.url
: `${baseUrl}${p.thumb.url}`
: ''
const imageUrl = p?.image?.url
? typeof p.image.url === 'string' && p.image.url.includes('http')
? p.image.url
: `${baseUrl}${p.image.url}`
: ''
const date = p.date || p.createdAt || new Date().toISOString()
const formattedDate = new Date(date).toLocaleDateString('en-IN', dateFmt)
const plain = richTextToPlainText(p?.content)
const readingTime = generateReadingTime(plain)
const authors = Array.isArray(p?.authors)
? p.authors.map((a: any) => ({
author: a?.author || 'Unknown Author',
author_id: a?.author_id || '',
position: a?.position || '',
author_url: a?.author_url || '#',
author_image_url: a?.author_image_url?.url
? typeof a.author_image_url.url === 'string' &&
a.author_image_url.url.includes('http')
? a.author_image_url.url
: `${baseUrl}${a.author_image_url.url}`
: null,
username: a?.username || '',
}))
: []
// Base post structure (always included)
const basePost = {
type: 'blog' as const,
slug: p.slug,
title: p.title || '',
description: p.description || '',
date,
formattedDate,
readingTime,
authors,
thumb: thumbUrl || imageUrl || '',
image: imageUrl || undefined,
meta: p.meta || null,
url: `/blog/${p.slug}`,
path: `/blog/${p.slug}`,
tags: p.tags || [],
categories: [],
isCMS: true,
toc_depth: p.toc_depth || 3,
}
// Add content for full mode
if (mode === 'full') {
const markdownContent = convertRichTextToMarkdown(p.content)
const tocResult = generateTocFromMarkdown(markdownContent, p.toc_depth || 3)
return {
...basePost,
content: markdownContent, // Convert rich text to markdown for MDX processing
richContent: p.content, // Keep original rich text for reference
toc: tocResult,
toc_depth: p.toc_depth || 3,
}
}
return basePost
})
.sort((a: any, b: any) => new Date(b.date).getTime() - new Date(a.date).getTime())
// For single post requests, return the post directly
if (slug && posts.length > 0) {
return NextResponse.json(
{
success: true,
post: posts[0],
mode,
},
{ headers: corsHeaders }
)
}
return NextResponse.json(
{
success: true,
posts,
total: posts.length,
mode,
cached: true,
},
{ headers: corsHeaders }
)
} catch (error) {
console.error('[cms-posts] Error:', error)
return NextResponse.json(
{
success: false,
error: 'Internal server error',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500, headers: corsHeaders }
)
}
}