diff --git a/app/feed.json/route.ts b/app/feed.json/route.ts new file mode 100644 index 00000000..8b8f64f8 --- /dev/null +++ b/app/feed.json/route.ts @@ -0,0 +1,28 @@ +import { getPhotosCached } from '@/photo/cache'; +import { INFINITE_SCROLL_FEED_INITIAL } from '@/photo'; +import { formatPhotoForFeed } from '@/app/feed'; +import { + BASE_URL, + PUBLIC_FEED_ENABLED, + META_TITLE, +} from '@/app/config'; + +export const dynamic = 'force-static'; + +export async function GET() { + if (PUBLIC_FEED_ENABLED) { + const photos = await getPhotosCached({ + limit: INFINITE_SCROLL_FEED_INITIAL, + sortBy: 'createdAt', + }); + return Response.json({ + meta: { + title: META_TITLE, + url: BASE_URL, + }, + photos: photos.map(formatPhotoForFeed), + }); + } else { + return new Response('Feed disabled', { status: 404 }); + } +} diff --git a/app/rss.xml/route.ts b/app/rss.xml/route.ts new file mode 100644 index 00000000..bffa4e6b --- /dev/null +++ b/app/rss.xml/route.ts @@ -0,0 +1,48 @@ +import { INFINITE_SCROLL_FEED_INITIAL } from '@/photo'; +import { getPhotosCached } from '@/photo/cache'; +import { + BASE_URL, + META_DESCRIPTION, + META_TITLE, + PUBLIC_FEED_ENABLED, +} from '@/app/config'; +import { feedPhotoToXml, formatPhotoForFeed } from '@/app/feed'; + +export const dynamic = 'force-static'; + +export async function GET() { + if (PUBLIC_FEED_ENABLED) { + const photos = await getPhotosCached({ + limit: INFINITE_SCROLL_FEED_INITIAL, + sortBy: 'createdAt', + }); + const items = photos.map(formatPhotoForFeed).map(feedPhotoToXml); + + return new Response( + ` + + + + ${META_TITLE} + + ${BASE_URL} + ${META_DESCRIPTION} + + ${items.join('\n\n ')} + + + + `, + { + headers: { + 'Content-Type': 'text/xml', + }, + }, + ); + } else { + return new Response('RSS feed access disabled', { status: 404 }); + } +} diff --git a/src/app/feed.ts b/src/app/feed.ts new file mode 100644 index 00000000..5ccbe104 --- /dev/null +++ b/src/app/feed.ts @@ -0,0 +1,87 @@ +import { Photo } from '@/photo'; +import { absolutePathForPhoto } from './paths'; +import { getNextImageUrlForRequest } from '@/platforms/next-image'; +import { formatDate } from '@/utility/date'; + +export const FEED_PHOTO_WIDTH_CONTENT = 1080; +export const FEED_PHOTO_WIDTH_THUMB = 640; + +export interface PublicFeed { + meta: { + title: string + url: string + } + photos: PublicFeedPhoto[] +} + +interface PublicFeedMedia { + url: string + width: number + height: number +} + +interface PublicFeedPhoto { + id: string + title?: string + description?: string + link: string + publicationDate: Date + media: Record< + 'content' | 'thumb', + PublicFeedMedia + > +} + +export const formatPhotoForFeed = (photo: Photo): PublicFeedPhoto => ({ + id: photo.id, + title: photo.title, + description: photo.caption, + link: absolutePathForPhoto({ photo }), + publicationDate: photo.createdAt, + media: { + content: { + url: getNextImageUrlForRequest({ + imageUrl: photo.url, + size: FEED_PHOTO_WIDTH_CONTENT, + }), + width: FEED_PHOTO_WIDTH_CONTENT, + height: Math.round(FEED_PHOTO_WIDTH_CONTENT / photo.aspectRatio), + }, + thumb: { + url: getNextImageUrlForRequest({ + imageUrl: photo.url, + size: FEED_PHOTO_WIDTH_THUMB, + }), + width: FEED_PHOTO_WIDTH_THUMB, + height: Math.round(FEED_PHOTO_WIDTH_THUMB / photo.aspectRatio), + }, + }, +}); + +export const feedPhotoToXml = (photo: PublicFeedPhoto): string => { + const formattedDate = formatDate({ + date: photo.publicationDate, + length: 'rss', + }); + const description = photo.description ? + ` + + ` : ''; + + return ` + ${photo.title} + ${photo.link} + ${formattedDate} + ${photo.link} + ${description} + + + + `; +};