242 lines
7.6 KiB
TypeScript
242 lines
7.6 KiB
TypeScript
import type { RequestHandler } from './$types'
|
|
import { prisma } from '$lib/server/database'
|
|
import { logger } from '$lib/server/logger'
|
|
|
|
// Helper function to escape XML special characters
|
|
function escapeXML(str: string): string {
|
|
if (!str) return ''
|
|
return str
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''')
|
|
}
|
|
|
|
// Helper function to convert content to HTML for full content
|
|
function convertContentToHTML(content: any): string {
|
|
if (!content || !content.blocks) return ''
|
|
|
|
return content.blocks
|
|
.map((block: any) => {
|
|
switch (block.type) {
|
|
case 'paragraph':
|
|
return `<p>${escapeXML(block.content || '')}</p>`
|
|
case 'heading':
|
|
const level = block.level || 2
|
|
return `<h${level}>${escapeXML(block.content || '')}</h${level}>`
|
|
case 'list':
|
|
const items = (block.content || [])
|
|
.map((item: any) => `<li>${escapeXML(item)}</li>`)
|
|
.join('')
|
|
return block.listType === 'ordered' ? `<ol>${items}</ol>` : `<ul>${items}</ul>`
|
|
default:
|
|
return `<p>${escapeXML(block.content || '')}</p>`
|
|
}
|
|
})
|
|
.join('\n')
|
|
}
|
|
|
|
// Helper function to extract text summary from content
|
|
function extractTextSummary(content: any, maxLength: number = 300): string {
|
|
if (!content || !content.blocks) return ''
|
|
|
|
const text = content.blocks
|
|
.filter((block: any) => block.type === 'paragraph' && block.content)
|
|
.map((block: any) => block.content)
|
|
.join(' ')
|
|
|
|
return text.length > maxLength ? text.substring(0, maxLength) + '...' : text
|
|
}
|
|
|
|
// Helper function to format RFC 822 date
|
|
function formatRFC822Date(date: Date): string {
|
|
return date.toUTCString()
|
|
}
|
|
|
|
export const GET: RequestHandler = async (event) => {
|
|
try {
|
|
// Get published posts from Universe
|
|
const posts = await prisma.post.findMany({
|
|
where: {
|
|
status: 'published',
|
|
publishedAt: { not: null }
|
|
},
|
|
orderBy: { publishedAt: 'desc' },
|
|
take: 25
|
|
})
|
|
|
|
// Get published albums that show in universe
|
|
const universeAlbums = await prisma.album.findMany({
|
|
where: {
|
|
status: 'published',
|
|
showInUniverse: true
|
|
},
|
|
include: {
|
|
photos: {
|
|
where: {
|
|
status: 'published',
|
|
showInPhotos: true
|
|
},
|
|
orderBy: { displayOrder: 'asc' },
|
|
take: 1 // Get first photo for cover image
|
|
},
|
|
_count: {
|
|
select: { photos: true }
|
|
}
|
|
},
|
|
orderBy: { createdAt: 'desc' },
|
|
take: 15
|
|
})
|
|
|
|
// Get published photography albums
|
|
const photoAlbums = await prisma.album.findMany({
|
|
where: {
|
|
status: 'published',
|
|
isPhotography: true
|
|
},
|
|
include: {
|
|
photos: {
|
|
where: {
|
|
status: 'published',
|
|
showInPhotos: true
|
|
},
|
|
orderBy: { displayOrder: 'asc' },
|
|
take: 1 // Get first photo for cover image
|
|
},
|
|
_count: {
|
|
select: {
|
|
photos: {
|
|
where: {
|
|
status: 'published',
|
|
showInPhotos: true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
orderBy: { createdAt: 'desc' },
|
|
take: 15
|
|
})
|
|
|
|
// Combine all content types
|
|
const items = [
|
|
...posts.map((post) => ({
|
|
type: 'post',
|
|
section: 'universe',
|
|
id: post.id.toString(),
|
|
title:
|
|
post.title || `${post.postType.charAt(0).toUpperCase() + post.postType.slice(1)} Post`,
|
|
description: post.excerpt || extractTextSummary(post.content) || '',
|
|
content: convertContentToHTML(post.content),
|
|
link: `${event.url.origin}/universe/${post.slug}`,
|
|
guid: `${event.url.origin}/universe/${post.slug}`,
|
|
pubDate: post.publishedAt || post.createdAt,
|
|
updatedDate: post.updatedAt,
|
|
postType: post.postType,
|
|
linkUrl: post.linkUrl || null
|
|
})),
|
|
...universeAlbums.map((album) => ({
|
|
type: 'album',
|
|
section: 'universe',
|
|
id: album.id.toString(),
|
|
title: album.title,
|
|
description:
|
|
album.description ||
|
|
`Photo album with ${album._count.photos} photo${album._count.photos !== 1 ? 's' : ''}`,
|
|
content: album.description ? `<p>${escapeXML(album.description)}</p>` : '',
|
|
link: `${event.url.origin}/photos/${album.slug}`,
|
|
guid: `${event.url.origin}/photos/${album.slug}`,
|
|
pubDate: album.createdAt,
|
|
updatedDate: album.updatedAt,
|
|
photoCount: album._count.photos,
|
|
coverPhoto: album.photos[0],
|
|
location: album.location
|
|
})),
|
|
...photoAlbums
|
|
.filter((album) => !universeAlbums.some((ua) => ua.id === album.id)) // Avoid duplicates
|
|
.map((album) => ({
|
|
type: 'album',
|
|
section: 'photos',
|
|
id: album.id.toString(),
|
|
title: album.title,
|
|
description:
|
|
album.description ||
|
|
`Photography album${album.location ? ` from ${album.location}` : ''} with ${album._count.photos} photo${album._count.photos !== 1 ? 's' : ''}`,
|
|
content: album.description ? `<p>${escapeXML(album.description)}</p>` : '',
|
|
link: `${event.url.origin}/photos/${album.slug}`,
|
|
guid: `${event.url.origin}/photos/${album.slug}`,
|
|
pubDate: album.createdAt,
|
|
updatedDate: album.updatedAt,
|
|
photoCount: album._count.photos,
|
|
coverPhoto: album.photos[0],
|
|
location: album.location,
|
|
date: album.date
|
|
}))
|
|
].sort((a, b) => new Date(b.pubDate).getTime() - new Date(a.pubDate).getTime())
|
|
|
|
const now = new Date()
|
|
const lastBuildDate = formatRFC822Date(now)
|
|
|
|
// Build RSS XML following best practices
|
|
const rssXml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
|
|
<channel>
|
|
<title>jedmund.com</title>
|
|
<description>Creative work, thoughts, and photography by Justin Edmund</description>
|
|
<link>${event.url.origin}/</link>
|
|
<atom:link href="${event.url.origin}/rss" rel="self" type="application/rss+xml"/>
|
|
<language>en-us</language>
|
|
<lastBuildDate>${lastBuildDate}</lastBuildDate>
|
|
<managingEditor>noreply@jedmund.com (Justin Edmund)</managingEditor>
|
|
<webMaster>noreply@jedmund.com (Justin Edmund)</webMaster>
|
|
<generator>SvelteKit RSS Generator</generator>
|
|
<docs>https://cyber.harvard.edu/rss/rss.html</docs>
|
|
<ttl>60</ttl>
|
|
${items
|
|
.map(
|
|
(item) => `
|
|
<item>
|
|
<title>${escapeXML(item.title)}</title>
|
|
<description><![CDATA[${item.description}]]></description>
|
|
${item.content ? `<content:encoded><![CDATA[${item.content}]]></content:encoded>` : ''}
|
|
<link>${item.link}</link>
|
|
<guid isPermaLink="true">${item.guid}</guid>
|
|
<pubDate>${formatRFC822Date(new Date(item.pubDate))}</pubDate>
|
|
${item.updatedDate ? `<atom:updated>${new Date(item.updatedDate).toISOString()}</atom:updated>` : ''}
|
|
<category>${item.section}</category>
|
|
<category>${item.type === 'post' ? item.postType : 'album'}</category>
|
|
${item.type === 'post' && item.linkUrl ? `<comments>${item.linkUrl}</comments>` : ''}
|
|
${
|
|
item.type === 'album' && item.coverPhoto
|
|
? `
|
|
<enclosure url="${event.url.origin}${item.coverPhoto.url}" type="image/jpeg" length="0"/>
|
|
<media:thumbnail url="${event.url.origin}${item.coverPhoto.thumbnailUrl || item.coverPhoto.url}"/>
|
|
<media:content url="${event.url.origin}${item.coverPhoto.url}" type="image/jpeg"/>`
|
|
: ''
|
|
}
|
|
${item.location ? `<category domain="location">${escapeXML(item.location)}</category>` : ''}
|
|
<author>noreply@jedmund.com (Justin Edmund)</author>
|
|
</item>`
|
|
)
|
|
.join('')}
|
|
</channel>
|
|
</rss>`
|
|
|
|
logger.info('Combined RSS feed generated', { itemCount: items.length })
|
|
|
|
return new Response(rssXml, {
|
|
headers: {
|
|
'Content-Type': 'application/rss+xml; charset=utf-8',
|
|
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400',
|
|
'Last-Modified': lastBuildDate,
|
|
ETag: `"${Buffer.from(rssXml).toString('base64').slice(0, 16)}"`,
|
|
'X-Content-Type-Options': 'nosniff',
|
|
Vary: 'Accept-Encoding'
|
|
}
|
|
})
|
|
} catch (error) {
|
|
logger.error('Failed to generate combined RSS feed', error as Error)
|
|
return new Response('Failed to generate RSS feed', { status: 500 })
|
|
}
|
|
}
|