Files
supabase/apps/www/lib/get-cms-posts.tsx
Francesco Sansalvadore b4922199cd unblock cms draft mode (#38409)
* remove port from cms start script

* address cors

* 30s revalidation on blog index

* fix types

* remove duplicate cache strategy

* disable graphql

* fix cms build

* fix ProductDropdown crash

* fix env var turbo
2025-09-16 13:03:13 +02:00

597 lines
17 KiB
TypeScript

import { CMS_SITE_ORIGIN } from './constants'
import { generateReadingTime } from './helpers'
import { generateTocFromMarkdown } from './toc'
import { convertRichTextToMarkdown } from './cms/convertRichTextToMarkdown'
// Payload API configuration
const PAYLOAD_URL = CMS_SITE_ORIGIN || 'http://localhost:3030'
const CMS_API_KEY = process.env.CMS_API_KEY
type CMSBlogPost = {
id: string
title: string
slug: string
description: string
content: {
root: {
children: Array<{
type: string
children: Array<{
text: string
type: string
[key: string]: any
}>
[key: string]: any
}>
}
}
date?: string
launchweek?: string
toc_depth?: number
readingTime?: number
categories?: string[]
tags?: string[]
industry?: string[]
supabase_products?: string[]
company_size?: string
region?: string
logo?: string
logo_inverse?: string
thumb?: {
url: string
}
createdAt: string
updatedAt: string
authors?: {
author: string
author_id: string
position: string
author_url: string
author_image_url: {
url: string
}
username: string
}[]
meta?: {
title?: string | null
image?: (number | string | null) | Media
description?: string | null
}
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media".
*/
export interface Media {
id: number
alt?: string | null
caption?: {
root: {
type: string
children: {
type: string
version: number
[k: string]: unknown
}[]
direction: ('ltr' | 'rtl') | null
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''
indent: number
version: number
}
[k: string]: unknown
} | null
prefix?: string | null
updatedAt: string
createdAt: string
url?: string | null
thumbnailURL?: string | null
filename?: string | null
mimeType?: string | null
filesize?: number | null
width?: number | null
height?: number | null
focalX?: number | null
focalY?: number | null
sizes?: {
thumbnail?: {
url?: string | null
width?: number | null
height?: number | null
mimeType?: string | null
filesize?: number | null
filename?: string | null
}
square?: {
url?: string | null
width?: number | null
height?: number | null
mimeType?: string | null
filesize?: number | null
filename?: string | null
}
small?: {
url?: string | null
width?: number | null
height?: number | null
mimeType?: string | null
filesize?: number | null
filename?: string | null
}
medium?: {
url?: string | null
width?: number | null
height?: number | null
mimeType?: string | null
filesize?: number | null
filename?: string | null
}
large?: {
url?: string | null
width?: number | null
height?: number | null
mimeType?: string | null
filesize?: number | null
filename?: string | null
}
xlarge?: {
url?: string | null
width?: number | null
height?: number | null
mimeType?: string | null
filesize?: number | null
filename?: string | null
}
og?: {
url?: string | null
width?: number | null
height?: number | null
mimeType?: string | null
filesize?: number | null
filename?: string | null
}
}
}
type ProcessedPost = {
slug: string
title: string
description: string
date: string
formattedDate: string
readingTime: string
industry?: string[]
supabase_products?: string[]
company_size?: string
region?: string
logo?: string
logo_inverse?: string
authors: Array<{
author: string
author_id: string
position: string
author_url: string
author_image_url: string | null
username: string
}>
toc_depth: number
thumb: string | null
url: string
path: string
isCMS: boolean
tags: string[]
content: string
meta?: {
title?: string | null
image?: (number | string | null) | Media
description?: string | null
}
}
/**
* Fetch all blog post slugs from the CMS
*/
export async function getAllCMSPostSlugs() {
try {
const response = await fetch(
`${PAYLOAD_URL}/api/posts?limit=100&depth=1&draft=false&where[_status][equals]=published`,
{
headers: {
'Content-Type': 'application/json',
...(CMS_API_KEY && { Authorization: `Bearer ${CMS_API_KEY}` }),
},
}
)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const contentType = response.headers.get('content-type') || ''
if (!contentType.toLowerCase().includes('application/json')) {
const body = await response.text()
console.warn(
`[getAllCMSPostSlugs] Non-JSON response from ${PAYLOAD_URL}/api/posts (content-type: '${contentType}'). Returning empty slugs. Body (truncated): ${body.slice(
0,
200
)}`
)
return []
}
const data = await response.json()
// console.log(
// '[getAllCMSPostSlugs] Found posts:',
// data.docs.map((post: any) => ({
// id: post.id,
// title: post.title,
// slug: post.slug,
// status: post._status,
// }))
// )
return data.docs
.filter((post: CMSBlogPost) => post.slug) // Filter out posts with null/undefined slugs
.map((post: CMSBlogPost) => ({
params: {
slug: post.slug,
},
}))
} catch (_error) {
// don't console error to avoid noise if env vars not set
return []
}
}
/**
* Fetch a single blog post from the CMS by slug
*/
export async function getCMSPostBySlug(slug: string, preview = false) {
// console.log(
// `[getCMSPostBySlug] Fetching post '${slug}', preview: ${preview}, from ${PAYLOAD_URL}`
// )
try {
let url: string
let response: Response
if (!preview) {
// For published posts, try to get the latest published version using versions API
const versionsUrl = `${PAYLOAD_URL}/api/posts/versions?where[version.slug][equals]=${slug}&where[version._status][equals]=published&sort=-updatedAt&limit=1&depth=2`
response = await fetch(versionsUrl, {
headers: {
'Content-Type': 'application/json',
...(CMS_API_KEY && { Authorization: `Bearer ${CMS_API_KEY}` }),
},
// For published posts: allow static generation with revalidation
next: { revalidate: 60 }, // 1 minute
})
if (response.ok) {
const versionsData = await response.json()
if (versionsData.docs && versionsData.docs.length > 0) {
const latestPublishedVersion = versionsData.docs[0].version
if (latestPublishedVersion) {
return processPostData(latestPublishedVersion)
}
}
}
}
// Fallback to regular API (for preview mode or if versions API fails)
if (preview) {
// In preview mode, always try to get the latest draft first
url = `${PAYLOAD_URL}/api/posts?where[slug][equals]=${slug}&depth=2&draft=true`
response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
...(CMS_API_KEY && { Authorization: `Bearer ${CMS_API_KEY}` }),
},
// cache: 'no-store',
next: { revalidate: 0 },
})
// If no draft found, try published version
if (!response.ok || (await response.clone().json()).docs?.length === 0) {
url = `${PAYLOAD_URL}/api/posts?where[slug][equals]=${slug}&depth=2&draft=false`
response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
...(CMS_API_KEY && { Authorization: `Bearer ${CMS_API_KEY}` }),
},
// cache: 'no-store',
next: { revalidate: 0 },
})
}
} else {
// For non-preview mode, get published version
url = `${PAYLOAD_URL}/api/posts?where[slug][equals]=${slug}&depth=2&draft=false`
response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
...(CMS_API_KEY && { Authorization: `Bearer ${CMS_API_KEY}` }),
},
// For published posts: allow static generation with revalidation
next: { revalidate: 60 }, // 1 minute
})
}
if (!response.ok) {
console.error(`[getCMSPostBySlug] HTTP error: ${response.status} ${response.statusText}`)
const errorText = await response.text()
console.error(`[getCMSPostBySlug] Error response body:`, errorText)
throw new Error(`HTTP error! status: ${response.status}`)
}
const contentType = response.headers.get('content-type') || ''
if (!contentType.toLowerCase().includes('application/json')) {
const body = await response.text()
console.warn(
`[getCMSPostBySlug] Non-JSON response from ${url} (content-type: '${contentType}'). Returning null. Body (truncated): ${body.slice(
0,
200
)}`
)
return null
}
const data = await response.json()
if (!data.docs.length && !preview) {
return null
}
// If we're in preview mode and there's no draft, try to get the published version
if (!data.docs.length && preview) {
const publishedUrl = `${PAYLOAD_URL}/api/posts?where[slug][equals]=${slug}&depth=2`
const publishedResponse = await fetch(publishedUrl, {
headers: {
'Content-Type': 'application/json',
...(CMS_API_KEY && { Authorization: `Bearer ${CMS_API_KEY}` }),
},
})
if (!publishedResponse.ok) {
console.error(
`[getCMSPostBySlug] HTTP error for published version: ${publishedResponse.status}`
)
throw new Error(`HTTP error! status: ${publishedResponse.status}`)
}
const publishedContentType = publishedResponse.headers.get('content-type') || ''
if (!publishedContentType.toLowerCase().includes('application/json')) {
const body = await publishedResponse.text()
console.warn(
`[getCMSPostBySlug] Non-JSON response from ${publishedUrl} (content-type: '${publishedContentType}'). Returning null. Body (truncated): ${body.slice(
0,
200
)}`
)
return null
}
const publishedData = await publishedResponse.json()
if (!publishedData.docs.length) {
return null
}
return processPostData(publishedData.docs[0])
}
// If we have a post (either draft or published), process it
const post = data.docs[0]
try {
const processedPost = await processPostData(post)
return processedPost
} catch (error) {
console.error(`[getCMSPostBySlug] Error in processPostData:`, error)
return null
}
} catch (error) {
console.error('Error fetching CMS post by slug:', error)
return null
}
}
// Helper function to process post data
async function processPostData(post: any) {
try {
const options: Intl.DateTimeFormatOptions = { month: 'long', day: 'numeric', year: 'numeric' }
const formattedDate = new Date(post.date || new Date()).toLocaleDateString('en-IN', options)
const markdownContent = convertRichTextToMarkdown(post.content)
const readingTime = post.readingTime || generateReadingTime(markdownContent)
// Generate TOC from content for CMS blog posts
const tocResult = await generateTocFromMarkdown(
markdownContent,
post.toc_depth ? post.toc_depth : 3
)
// Extract thumb and image URLs from the nested structure
const thumbUrl = post.thumb?.url
? typeof post.thumb.url === 'string' && post.thumb.url.includes('http')
? post.thumb.url
: `${PAYLOAD_URL}${post.thumb.url}`
: null
const imageUrl = post.meta?.image
? typeof post.meta?.image === 'string' && post.meta?.image.includes('http')
? post.meta?.image
: `${PAYLOAD_URL}${post.meta?.image}`
: thumbUrl
const processedData = {
slug: post.slug,
source: markdownContent,
title: post.title || 'Untitled Post',
date: post.date || new Date().toISOString(),
formattedDate,
readingTime,
launchweek: post.launchweek || null,
authors:
post.authors?.map((author: any) => ({
author: author.author || 'Unknown Author',
author_id: author.author_id || '',
position: author.position || '',
author_url: author.author_url || '#',
author_image_url: author.author_image_url?.url
? typeof author.author_image_url.url === 'string' &&
author.author_image_url.url.includes('http')
? author.author_image_url?.url
: `${PAYLOAD_URL}${author.author_image_url.url}`
: null,
username: author.username || '',
})) || [],
toc_depth: post.toc_depth || 3,
thumb: thumbUrl,
image: imageUrl,
url: `/blog/${post.slug}`,
path: `/blog/${post.slug}`,
isCMS: true,
content: markdownContent,
richContent: post.content,
categories: post.categories?.map((category: any) => category.name) || [],
tags: post.tags || [],
toc: {
content: tocResult.content,
json: tocResult.json,
},
}
return processedData
} catch (error) {
console.error(`[processPostData] Error processing post data:`, error)
throw error
}
}
/**
* Fetch all blog posts from the CMS
*/
export async function getAllCMSPosts({
limit,
tags,
currentPostSlug,
}: {
limit?: number
tags?: string[]
currentPostSlug?: string
} = {}): Promise<ProcessedPost[]> {
try {
const response = await fetch(
`${PAYLOAD_URL}/api/posts?depth=2&draft=false&where[_status][equals]=published`,
{
headers: {
'Content-Type': 'application/json',
...(CMS_API_KEY && { Authorization: `Bearer ${CMS_API_KEY}` }),
},
// cache: 'no-store', // Ensure we always get fresh data
next: { revalidate: 30 }, // Disable caching for this fetch
}
)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const contentType = response.headers.get('content-type') || ''
if (!contentType.toLowerCase().includes('application/json')) {
const body = await response.text()
console.warn(
`[getAllCMSPosts] Non-JSON response from ${PAYLOAD_URL}/api/posts (content-type: '${contentType}'). Returning empty posts. Body (truncated): ${body.slice(
0,
200
)}`
)
return []
}
const data = await response.json()
let posts = data.docs
.filter((post: CMSBlogPost) => post.slug !== currentPostSlug)
.map((post: CMSBlogPost) => {
const options: Intl.DateTimeFormatOptions = {
month: 'long',
day: 'numeric',
year: 'numeric',
}
const formattedDate = new Date(post.date || new Date()).toLocaleDateString('en-IN', options)
const markdownContent = convertRichTextToMarkdown(post.content)
const readingTime = post.readingTime || generateReadingTime(markdownContent)
// Extract thumb and image URLs from the nested structure (handle absolute and relative)
const thumbUrl = post.thumb?.url
? typeof post.thumb.url === 'string' && post.thumb.url.includes('http')
? post.thumb.url
: `${PAYLOAD_URL}${post.thumb.url}`
: null
const imageUrl = post.meta?.image
? typeof post.meta?.image === 'string' && post.meta?.image.includes('http')
? post.meta?.image
: `${PAYLOAD_URL}${post.meta?.image}`
: thumbUrl
return {
slug: post.slug || '',
title: post.title || '',
description: post.description || '',
date: post.date || new Date().toISOString(),
formattedDate,
readingTime,
authors:
post.authors?.map((author: any) => ({
author: author.author || '',
author_id: author.author_id || '',
position: author.position || '',
author_url: author.author_url || '#',
author_image_url: author.author_image_url?.url
? typeof author.author_image_url.url === 'string' &&
author.author_image_url.url.includes('http')
? author.author_image_url.url
: `${PAYLOAD_URL}${author.author_image_url.url}`
: null,
username: author.username || '',
})) || [],
toc_depth: post.toc_depth || 3,
thumb: thumbUrl,
image: imageUrl,
url: `/blog/${post.slug || ''}`,
path: `/blog/${post.slug || ''}`,
isCMS: true,
categories: post.categories?.map((category: any) => category.name) || [],
tags: post.tags || [],
content: markdownContent,
}
})
// Sort by date (newest first)
posts = posts.sort(
(a: ProcessedPost, b: ProcessedPost) =>
new Date(b.date).getTime() - new Date(a.date).getTime()
)
// Filter by tags if provided
if (tags && tags.length > 0) {
posts = posts.filter((post: ProcessedPost) => {
const found = tags.some((tag) => post.tags?.includes(tag))
return found
})
}
// Limit results if specified
if (limit) {
posts = posts.slice(0, limit)
}
return posts
} catch (_error) {
// don't console error to avoid noise if env vars not set
return []
}
}