feat: improve RSS feed with full content and best practices
- Include full HTML content in RSS posts using content:encoded - Support both TipTap and EditorJS content formats - Use date as title for posts without titles - Add featured images as enclosures for posts - Implement RSS best practices: - Add CORS headers for wider compatibility - Support conditional requests (ETag/Last-Modified) - Set appropriate cache headers (5 min client, 10 min CDN) - Improve content parsing for various editor formats 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
1ed0afb5b2
commit
89360aa1ff
1 changed files with 188 additions and 30 deletions
|
|
@ -15,37 +15,169 @@ function escapeXML(str: string): string {
|
|||
|
||||
// Helper function to convert content to HTML for full content
|
||||
function convertContentToHTML(content: any): string {
|
||||
if (!content || !content.blocks) return ''
|
||||
if (!content) return ''
|
||||
|
||||
// Handle legacy content format (if it's just a string)
|
||||
if (typeof content === 'string') {
|
||||
return `<p>${escapeXML(content)}</p>`
|
||||
}
|
||||
|
||||
// Handle TipTap/EditorJS JSON format
|
||||
if (content.blocks && Array.isArray(content.blocks)) {
|
||||
return content.blocks
|
||||
.map((block: any) => {
|
||||
switch (block.type) {
|
||||
case 'paragraph':
|
||||
return `<p>${escapeXML(block.content || '')}</p>`
|
||||
// Handle both data.text and content formats
|
||||
const paragraphText = block.data?.text || block.content || ''
|
||||
return paragraphText ? `<p>${escapeXML(paragraphText)}</p>` : ''
|
||||
case 'heading':
|
||||
const level = block.level || 2
|
||||
return `<h${level}>${escapeXML(block.content || '')}</h${level}>`
|
||||
case 'header':
|
||||
const level = block.data?.level || block.level || 2
|
||||
const headingText = block.data?.text || block.content || ''
|
||||
return headingText ? `<h${level}>${escapeXML(headingText)}</h${level}>` : ''
|
||||
case 'list':
|
||||
const items = (block.content || [])
|
||||
.map((item: any) => `<li>${escapeXML(item)}</li>`)
|
||||
const items = (block.data?.items || block.content || [])
|
||||
.map((item: any) => {
|
||||
const itemText = typeof item === 'string' ? item : item.content || item.text || ''
|
||||
return itemText ? `<li>${escapeXML(itemText)}</li>` : ''
|
||||
})
|
||||
.filter((item: string) => item)
|
||||
.join('')
|
||||
return block.listType === 'ordered' ? `<ol>${items}</ol>` : `<ul>${items}</ul>`
|
||||
if (!items) return ''
|
||||
const listType = block.data?.style || block.listType
|
||||
return listType === 'ordered' ? `<ol>${items}</ol>` : `<ul>${items}</ul>`
|
||||
case 'image':
|
||||
const imageUrl = block.data?.file?.url || block.data?.url || ''
|
||||
const caption = block.data?.caption || ''
|
||||
if (!imageUrl) return ''
|
||||
return `<figure><img src="${escapeXML(imageUrl)}" alt="${escapeXML(caption)}" />${caption ? `<figcaption>${escapeXML(caption)}</figcaption>` : ''}</figure>`
|
||||
case 'code':
|
||||
const code = block.data?.code || block.content || ''
|
||||
return code ? `<pre><code>${escapeXML(code)}</code></pre>` : ''
|
||||
case 'quote':
|
||||
case 'blockquote':
|
||||
const quoteText = block.data?.text || block.content || ''
|
||||
const citation = block.data?.caption || ''
|
||||
if (!quoteText) return ''
|
||||
return `<blockquote>${escapeXML(quoteText)}${citation ? `<cite>${escapeXML(citation)}</cite>` : ''}</blockquote>`
|
||||
default:
|
||||
return `<p>${escapeXML(block.content || '')}</p>`
|
||||
// Fallback for unknown block types
|
||||
const defaultText = block.data?.text || block.content || ''
|
||||
return defaultText ? `<p>${escapeXML(defaultText)}</p>` : ''
|
||||
}
|
||||
})
|
||||
.filter((html: string) => html) // Remove empty blocks
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
// Handle TipTap format with doc root
|
||||
if (content.type === 'doc' && content.content && Array.isArray(content.content)) {
|
||||
return content.content
|
||||
.map((node: any) => {
|
||||
switch (node.type) {
|
||||
case 'paragraph':
|
||||
const text = extractTextFromNode(node)
|
||||
return text ? `<p>${text}</p>` : ''
|
||||
case 'heading':
|
||||
const headingText = extractTextFromNode(node)
|
||||
const level = node.attrs?.level || 2
|
||||
return headingText ? `<h${level}>${headingText}</h${level}>` : ''
|
||||
case 'bulletList':
|
||||
case 'orderedList':
|
||||
const listItems = (node.content || [])
|
||||
.map((item: any) => {
|
||||
if (item.type === 'listItem') {
|
||||
const itemText = extractTextFromNode(item)
|
||||
return itemText ? `<li>${itemText}</li>` : ''
|
||||
}
|
||||
return ''
|
||||
})
|
||||
.filter((item: string) => item)
|
||||
.join('')
|
||||
if (!listItems) return ''
|
||||
return node.type === 'orderedList' ? `<ol>${listItems}</ol>` : `<ul>${listItems}</ul>`
|
||||
case 'blockquote':
|
||||
const quoteText = extractTextFromNode(node)
|
||||
return quoteText ? `<blockquote>${quoteText}</blockquote>` : ''
|
||||
case 'codeBlock':
|
||||
const code = extractTextFromNode(node)
|
||||
return code ? `<pre><code>${code}</code></pre>` : ''
|
||||
case 'image':
|
||||
const src = node.attrs?.src || ''
|
||||
const alt = node.attrs?.alt || ''
|
||||
return src ? `<figure><img src="${escapeXML(src)}" alt="${escapeXML(alt)}" /></figure>` : ''
|
||||
default:
|
||||
const defaultText = extractTextFromNode(node)
|
||||
return defaultText ? `<p>${defaultText}</p>` : ''
|
||||
}
|
||||
})
|
||||
.filter((html: string) => html)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
// Helper to extract text from TipTap nodes
|
||||
function extractTextFromNode(node: any): string {
|
||||
if (!node) return ''
|
||||
|
||||
// Direct text content
|
||||
if (node.text) return escapeXML(node.text)
|
||||
|
||||
// Nested content
|
||||
if (node.content && Array.isArray(node.content)) {
|
||||
return node.content
|
||||
.map((child: any) => {
|
||||
if (child.type === 'text') {
|
||||
return escapeXML(child.text || '')
|
||||
}
|
||||
return extractTextFromNode(child)
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
// Helper function to extract text summary from content
|
||||
function extractTextSummary(content: any, maxLength: number = 300): string {
|
||||
if (!content || !content.blocks) return ''
|
||||
if (!content) return ''
|
||||
|
||||
const text = content.blocks
|
||||
.filter((block: any) => block.type === 'paragraph' && block.content)
|
||||
.map((block: any) => block.content)
|
||||
let text = ''
|
||||
|
||||
// Handle string content
|
||||
if (typeof content === 'string') {
|
||||
text = content
|
||||
}
|
||||
// Handle EditorJS format
|
||||
else if (content.blocks && Array.isArray(content.blocks)) {
|
||||
text = content.blocks
|
||||
.filter((block: any) => block.type === 'paragraph')
|
||||
.map((block: any) => block.data?.text || block.content || '')
|
||||
.filter((t: string) => t)
|
||||
.join(' ')
|
||||
}
|
||||
// Handle TipTap format
|
||||
else if (content.type === 'doc' && content.content && Array.isArray(content.content)) {
|
||||
text = content.content
|
||||
.filter((node: any) => node.type === 'paragraph')
|
||||
.map((node: any) => {
|
||||
if (node.content && Array.isArray(node.content)) {
|
||||
return node.content
|
||||
.filter((child: any) => child.type === 'text')
|
||||
.map((child: any) => child.text || '')
|
||||
.join('')
|
||||
}
|
||||
return ''
|
||||
})
|
||||
.filter((t: string) => t)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
// Clean up and truncate
|
||||
text = text.replace(/\s+/g, ' ').trim()
|
||||
return text.length > maxLength ? text.substring(0, maxLength) + '...' : text
|
||||
}
|
||||
|
||||
|
|
@ -77,7 +209,11 @@ export const GET: RequestHandler = async (event) => {
|
|||
section: 'universe',
|
||||
id: post.id.toString(),
|
||||
title:
|
||||
post.title || `${post.postType.charAt(0).toUpperCase() + post.postType.slice(1)} Post`,
|
||||
post.title || new Date(post.publishedAt || post.createdAt).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
}),
|
||||
description: extractTextSummary(post.content) || '',
|
||||
content: convertContentToHTML(post.content),
|
||||
link: `${event.url.origin}/universe/${post.slug}`,
|
||||
|
|
@ -163,6 +299,10 @@ ${
|
|||
<enclosure url="${item.coverPhoto.url.startsWith('http') ? item.coverPhoto.url : event.url.origin + item.coverPhoto.url}" type="image/jpeg" length="${item.coverPhoto.size || 0}"/>
|
||||
<media:thumbnail url="${(item.coverPhoto.thumbnailUrl || item.coverPhoto.url).startsWith('http') ? (item.coverPhoto.thumbnailUrl || item.coverPhoto.url) : event.url.origin + (item.coverPhoto.thumbnailUrl || item.coverPhoto.url)}"/>
|
||||
<media:content url="${item.coverPhoto.url.startsWith('http') ? item.coverPhoto.url : event.url.origin + item.coverPhoto.url}" type="image/jpeg"/>`
|
||||
: item.type === 'post' && item.featuredImage
|
||||
? `
|
||||
<enclosure url="${item.featuredImage.startsWith('http') ? item.featuredImage : event.url.origin + item.featuredImage}" type="image/jpeg" length="0"/>
|
||||
<media:content url="${item.featuredImage.startsWith('http') ? item.featuredImage : event.url.origin + item.featuredImage}" type="image/jpeg"/>`
|
||||
: ''
|
||||
}
|
||||
${item.location ? `<category domain="location">${escapeXML(item.location)}</category>` : ''}
|
||||
|
|
@ -175,14 +315,32 @@ ${item.location ? `<category domain="location">${escapeXML(item.location)}</cate
|
|||
|
||||
logger.info('Combined RSS feed generated', { itemCount: items.length })
|
||||
|
||||
// Generate ETag based on content
|
||||
const etag = `W/"${Buffer.from(rssXml).length}-${Date.now()}"`
|
||||
|
||||
// Check for conditional requests
|
||||
const ifNoneMatch = event.request.headers.get('if-none-match')
|
||||
const ifModifiedSince = event.request.headers.get('if-modified-since')
|
||||
|
||||
if (ifNoneMatch === etag) {
|
||||
return new Response(null, { status: 304 })
|
||||
}
|
||||
|
||||
if (ifModifiedSince && new Date(ifModifiedSince) >= new Date(lastBuildDate)) {
|
||||
return new Response(null, { status: 304 })
|
||||
}
|
||||
|
||||
return new Response(rssXml, {
|
||||
headers: {
|
||||
'Content-Type': 'application/rss+xml; charset=utf-8',
|
||||
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400',
|
||||
'Cache-Control': 'public, max-age=300, s-maxage=600, stale-while-revalidate=86400',
|
||||
'Last-Modified': lastBuildDate,
|
||||
ETag: `"${Buffer.from(rssXml).toString('base64').slice(0, 16)}"`,
|
||||
'ETag': etag,
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
Vary: 'Accept-Encoding'
|
||||
'Vary': 'Accept-Encoding',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
||||
'Access-Control-Max-Age': '86400'
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue