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:
Justin Edmund 2025-07-24 04:00:14 -07:00
parent 1ed0afb5b2
commit 89360aa1ff

View file

@ -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) {