Merge pull request #266 from sambecker/rss

Add RSS feed (refinements)
This commit is contained in:
Sam Becker 2025-06-12 20:38:45 -07:00 committed by GitHub
commit d06c8c3a55
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 278 additions and 101 deletions

View File

@ -3,6 +3,7 @@
"ABCDEFGHIJKLMNOP",
"Acros",
"affordance",
"apos",
"ARROWLEFT",
"ARROWRIGHT",
"Astia",

View File

@ -158,7 +158,7 @@ Application behavior can be changed by configuring the following environment var
#### Settings
- `NEXT_PUBLIC_GEO_PRIVACY = 1` disables collection/display of location-based data (⚠️ re-compresses uploaded images in order to remove GPS information)
- `NEXT_PUBLIC_ALLOW_PUBLIC_DOWNLOADS = 1` enables public photo downloads for all visitors (⚠️ may result in increased bandwidth usage)
- `NEXT_PUBLIC_PUBLIC_API = 1` enables public API available at `/api`
- `NEXT_PUBLIC_SITE_FEEDS = 1` enables feeds at `/feed.json` and `/rss.xml`
- `NEXT_PUBLIC_IGNORE_PRIORITY_ORDER = 1` prevents `priority_order` field affecting photo order
- `NEXT_PUBLIC_OG_TEXT_ALIGNMENT = BOTTOM` keeps OG image text bottom aligned (default is top)

View File

@ -1,24 +0,0 @@
import { getPhotosCached } from '@/photo/cache';
import { API_PHOTO_REQUEST_LIMIT, formatPhotoForApi } from '@/app/api';
import {
BASE_URL,
PUBLIC_API_ENABLED,
META_TITLE,
} from '@/app/config';
export const dynamic = 'force-dynamic';
export async function GET() {
if (PUBLIC_API_ENABLED) {
const photos = await getPhotosCached({ limit: API_PHOTO_REQUEST_LIMIT });
return Response.json({
meta: {
title: META_TITLE,
url: BASE_URL,
},
photos: photos.map(formatPhotoForApi),
});
} else {
return new Response('API access disabled', { status: 404 });
}
}

19
app/feed.json/route.ts Normal file
View File

@ -0,0 +1,19 @@
import { getPhotosCached } from '@/photo/cache';
import { SITE_FEEDS_ENABLED } from '@/app/config';
import { FEED_PHOTO_REQUEST_LIMIT } from '@/feed';
import { formatFeedJson } from '@/feed/json';
// Cache for 24 hours
export const revalidate = 86_400;
export async function GET() {
if (SITE_FEEDS_ENABLED) {
const photos = await getPhotosCached({
limit: FEED_PHOTO_REQUEST_LIMIT,
sortBy: 'createdAt',
});
return Response.json(formatFeedJson(photos));
} else {
return new Response('Feeds disabled', { status: 404 });
}
}

View File

@ -10,6 +10,7 @@ import {
META_TITLE,
HTML_LANG,
NAV_CAPTION,
SITE_FEEDS_ENABLED,
} from '@/app/config';
import AppStateProvider from '@/state/AppStateProvider';
import ToasterWithThemes from '@/toast/ToasterWithThemes';
@ -65,6 +66,13 @@ export const metadata: Metadata = {
type: 'image/png',
sizes: '180x180',
}],
...SITE_FEEDS_ENABLED && {
alternates: {
types: {
'application/rss+xml': '/rss.xml',
},
},
},
};
export default function RootLayout({

23
app/rss.xml/route.ts Normal file
View File

@ -0,0 +1,23 @@
import { getPhotosCached } from '@/photo/cache';
import { SITE_FEEDS_ENABLED } from '@/app/config';
import { FEED_PHOTO_REQUEST_LIMIT } from '@/feed';
import { formatFeedRss } from '@/feed/rss';
// Cache for 24 hours
export const revalidate = 86_400;
export async function GET() {
if (SITE_FEEDS_ENABLED) {
const photos = await getPhotosCached({
limit: FEED_PHOTO_REQUEST_LIMIT,
sortBy: 'createdAt',
});
return new Response(
formatFeedRss(photos),
{ headers: { 'Content-Type': 'text/xml' } },
);
} else {
return new Response('Feeds disabled', { status: 404 });
}
}

View File

@ -31,6 +31,8 @@ import ScoreCardContainer from '@/components/ScoreCardContainer';
import { DEFAULT_CATEGORY_KEYS, getHiddenCategories } from '@/category';
import { AI_AUTO_GENERATED_FIELDS_ALL } from '@/photo/ai';
import clsx from 'clsx/lite';
import Link from 'next/link';
import { PATH_FEED_JSON, PATH_RSS_XML } from '@/app/paths';
export default function AdminAppConfigurationClient({
// Storage
@ -103,7 +105,7 @@ export default function AdminAppConfigurationClient({
// Settings
isGeoPrivacyEnabled,
arePublicDownloadsEnabled,
isPublicApiEnabled,
areSiteFeedsEnabled,
isPriorityOrderEnabled,
isOgTextBottomAligned,
// Internal
@ -177,6 +179,15 @@ export default function AdminAppConfigurationClient({
{message}
</ErrorNote>;
const renderLink = (href: string, children?: ReactNode) =>
<Link
href={href}
className="underline underline-offset-3 hover:no-underline"
target="_blank"
>
{children || href}
</Link>;
return (
<ScoreCardContainer>
<ChecklistGroup
@ -734,13 +745,14 @@ export default function AdminAppConfigurationClient({
{renderEnvVars(['NEXT_PUBLIC_ALLOW_PUBLIC_DOWNLOADS'])}
</ChecklistRow>
<ChecklistRow
title="Public API"
status={isPublicApiEnabled}
title="Site feeds (JSON/RSS)"
status={areSiteFeedsEnabled}
optional
>
Set environment variable to {'"1"'} to enable
a public API available at <code>/api</code>:
{renderEnvVars(['NEXT_PUBLIC_PUBLIC_API'])}
Set environment variable to {'"1"'} to enable feeds at
{' '}
{renderLink(PATH_FEED_JSON)} and {renderLink(PATH_RSS_XML)}:
{renderEnvVars(['NEXT_PUBLIC_SITE_FEEDS'])}
</ChecklistRow>
<ChecklistRow
title="Priority order"

View File

@ -1,43 +0,0 @@
import { Photo } from '@/photo';
import { absolutePathForPhoto } from './paths';
import { formatDateFromPostgresString } from '@/utility/date';
import { getNextImageUrlForRequest } from '@/platforms/next-image';
export const API_PHOTO_REQUEST_LIMIT = 40;
export interface PublicApi {
meta: {
title: string
url: string
}
photos: PublicApiPhoto[]
}
interface PublicApiPhoto {
id: string
title?: string
url: string
make?: string
model?: string
tags?: string[]
takenAtNaive: string
src: Record<
'small' | 'medium' | 'large',
string
>
}
export const formatPhotoForApi = (photo: Photo): PublicApiPhoto => ({
id: photo.id,
title: photo.title,
url: absolutePathForPhoto({ photo }),
...photo.make && { make: photo.make },
...photo.model && { model: photo.model },
...photo.tags.length > 0 && { tags: photo.tags },
takenAtNaive: formatDateFromPostgresString(photo.takenAtNaive),
src: {
small: getNextImageUrlForRequest({ imageUrl: photo.url, size: 200 }),
medium: getNextImageUrlForRequest({ imageUrl: photo.url, size: 640 }),
large: getNextImageUrlForRequest({ imageUrl: photo.url, size: 1200 }),
},
});

View File

@ -307,8 +307,8 @@ export const GEO_PRIVACY_ENABLED =
process.env.NEXT_PUBLIC_GEO_PRIVACY === '1';
export const ALLOW_PUBLIC_DOWNLOADS =
process.env.NEXT_PUBLIC_ALLOW_PUBLIC_DOWNLOADS === '1';
export const PUBLIC_API_ENABLED =
process.env.NEXT_PUBLIC_PUBLIC_API === '1';
export const SITE_FEEDS_ENABLED =
process.env.NEXT_PUBLIC_SITE_FEEDS === '1';
export const PRIORITY_ORDER_ENABLED =
process.env.NEXT_PUBLIC_IGNORE_PRIORITY_ORDER !== '1';
export const OG_TEXT_BOTTOM_ALIGNMENT =
@ -417,7 +417,7 @@ export const APP_CONFIGURATION = {
// Settings
isGeoPrivacyEnabled: GEO_PRIVACY_ENABLED,
arePublicDownloadsEnabled: ALLOW_PUBLIC_DOWNLOADS,
isPublicApiEnabled: PUBLIC_API_ENABLED,
areSiteFeedsEnabled: SITE_FEEDS_ENABLED,
isPriorityOrderEnabled: PRIORITY_ORDER_ENABLED,
isOgTextBottomAligned: OG_TEXT_BOTTOM_ALIGNMENT,
// Internal

View File

@ -15,6 +15,10 @@ export const PATH_API = '/api';
export const PATH_SIGN_IN = '/sign-in';
export const PATH_OG = '/og';
// Feeds
export const PATH_FEED_JSON = '/feed.json';
export const PATH_RSS_XML = '/rss.xml';
export const PATH_GRID_INFERRED = GRID_HOMEPAGE_ENABLED
? PATH_ROOT
: PATH_GRID;
@ -167,6 +171,12 @@ export const pathForRecipe = (recipe: string) =>
`${PREFIX_RECIPE}/${recipe}`;
// Absolute paths
export const ABSOLUTE_PATH_FOR_FEED_JSON =
`${getBaseUrl()}${PATH_FEED_JSON}`;
export const ABSOLUTE_PATH_FOR_RSS_XML =
`${getBaseUrl()}${PATH_RSS_XML}`;
export const ABSOLUTE_PATH_FOR_HOME_IMAGE =
`${getBaseUrl()}/home-image`;

32
src/feed/index.ts Normal file
View File

@ -0,0 +1,32 @@
import { descriptionForPhoto, Photo, titleForPhoto } from '@/photo';
import {
getNextImageUrlForRequest,
NextImageSize,
} from '@/platforms/next-image';
export const FEED_PHOTO_REQUEST_LIMIT = 40;
export const FEED_PHOTO_WIDTH_SMALL = 200;
export const FEED_PHOTO_WIDTH_MEDIUM = 640;
export const FEED_PHOTO_WIDTH_LARGE = 1200;
export interface FeedMedia {
url: string
width: number
height: number
}
export const generateFeedMedia = (
photo: Photo,
size: NextImageSize,
): FeedMedia => ({
url: getNextImageUrlForRequest({ imageUrl: photo.url, size }),
width: size,
height: Math.round(size / photo.aspectRatio),
});
export const getCoreFeedFields = (photo: Photo) => ({
id: photo.id,
title: titleForPhoto(photo),
description: descriptionForPhoto(photo, true),
});

46
src/feed/json.ts Normal file
View File

@ -0,0 +1,46 @@
import { absolutePathForPhoto } from '@/app/paths';
import {
FEED_PHOTO_WIDTH_LARGE,
FEED_PHOTO_WIDTH_MEDIUM,
FEED_PHOTO_WIDTH_SMALL,
FeedMedia,
generateFeedMedia,
getCoreFeedFields,
} from '.';
import { formatDateFromPostgresString } from '@/utility/date';
import { Photo } from '@/photo';
import { BASE_URL } from '@/app/config';
import { META_TITLE } from '@/app/config';
interface FeedPhotoJson {
id: string
title: string
url: string
make?: string
model?: string
tags?: string[]
takenAtNaive: string
src: Record<'small' | 'medium' | 'large', FeedMedia>
}
const formatPhotoForFeedJson = (photo: Photo): FeedPhotoJson => ({
...getCoreFeedFields(photo),
url: absolutePathForPhoto({ photo }),
...photo.make && { make: photo.make },
...photo.model && { model: photo.model },
...photo.tags.length > 0 && { tags: photo.tags },
takenAtNaive: formatDateFromPostgresString(photo.takenAtNaive),
src: {
small: generateFeedMedia(photo, FEED_PHOTO_WIDTH_SMALL),
medium: generateFeedMedia(photo, FEED_PHOTO_WIDTH_MEDIUM),
large: generateFeedMedia(photo, FEED_PHOTO_WIDTH_LARGE),
},
});
export const formatFeedJson = (photos: Photo[]) => ({
meta: {
title: META_TITLE,
url: BASE_URL,
},
photos: photos.map(formatPhotoForFeedJson),
});

78
src/feed/rss.ts Normal file
View File

@ -0,0 +1,78 @@
import { Photo } from '@/photo';
import {
FEED_PHOTO_WIDTH_LARGE,
FEED_PHOTO_WIDTH_MEDIUM,
FeedMedia,
generateFeedMedia,
getCoreFeedFields,
} from '.';
import { ABSOLUTE_PATH_FOR_RSS_XML, absolutePathForPhoto } from '@/app/paths';
import { formatDate } from '@/utility/date';
import { formatStringForXml } from '@/utility/string';
import { BASE_URL, META_DESCRIPTION, META_TITLE } from '@/app/config';
interface FeedPhotoRss {
id: string
title: string
description?: string
link: string
pubDate: Date
media: Record<'content' | 'thumb', FeedMedia>
}
const formatPhotoForFeedRss = (photo: Photo): FeedPhotoRss => ({
...getCoreFeedFields(photo),
link: absolutePathForPhoto({ photo }),
pubDate: photo.createdAt,
media: {
content: generateFeedMedia(photo, FEED_PHOTO_WIDTH_LARGE),
thumb: generateFeedMedia(photo, FEED_PHOTO_WIDTH_MEDIUM),
},
});
const feedPhotoToXml = (photo: FeedPhotoRss): string => {
return `<item>
<title>${photo.title}</title>
<link>${photo.link}</link>
<pubDate>
${formatDate({ date: photo.pubDate, length: 'rss' })}
</pubDate>
<guid isPermaLink="true">${photo.link}</guid>
${photo.description
? `<description><![CDATA[${photo.description}]]></description>`
: ''}
<media:content
url="${formatStringForXml(photo.media.content.url)}"
type="image/jpeg"
medium="image"
width="${photo.media.content.width}"
height="${photo.media.content.height}"
>
<media:thumbnail
url="${formatStringForXml(photo.media.thumb.url)}"
width="${photo.media.thumb.width}"
height="${photo.media.thumb.height}"
/>
</media:content>
</item>`;
};
export const formatFeedRss = (photos: Photo[]) =>
`<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
xmlns:content="http://purl.org/rss/1.0/modules/content/"
xmlns:atom="http://www.w3.org/2005/Atom"
xmlns:media="http://search.yahoo.com/mrss/"
>
<channel>
<title>${META_TITLE}</title>
<atom:link
href="${ABSOLUTE_PATH_FOR_RSS_XML}"
rel="self"
type="application/rss+xml"
/>
<link>${BASE_URL}</link>
<description>${META_DESCRIPTION}</description>
${photos.map(formatPhotoForFeedRss).map(feedPhotoToXml).join('\n')}
</channel>
</rss>`;

View File

@ -5,26 +5,29 @@ import { setDefaultDateFnLocale } from '@/i18n';
setDefaultDateFnLocale();
const DATE_STRING_FORMAT_TINY = 'dd MMM yy';
const DATE_STRING_FORMAT_TINY_PLACEHOLDER = '00 000 00';
const DATE_FORMAT_TINY = 'dd MMM yy';
const DATE_FORMAT_TINY_PLACEHOLDER = '00 000 00';
const DATE_STRING_FORMAT_SHORT = 'dd MMM yyyy';
const DATE_STRING_FORMAT_SHORT_PLACEHOLDER = '00 000 0000';
const DATE_FORMAT_SHORT = 'dd MMM yyyy';
const DATE_FORMAT_SHORT_PLACEHOLDER = '00 000 0000';
const DATE_STRING_FORMAT_MEDIUM = 'dd MMM yy h:mma';
const DATE_STRING_FORMAT_MEDIUM_PLACEHOLDER = '00 000 00 00:0000';
const DATE_FORMAT_MEDIUM = 'dd MMM yy h:mma';
const DATE_FORMAT_MEDIUM_PLACEHOLDER = '00 000 00 00:0000';
const DATE_STRING_FORMAT_LONG = 'dd MMM yyyy h:mma';
const DATE_STRING_FORMAT_LONG_PLACEHOLDER = '00 000 0000 00:0000';
const DATE_FORMAT_LONG = 'dd MMM yyyy h:mma';
const DATE_FORMAT_LONG_PLACEHOLDER = '00 000 0000 00:0000';
const DATE_STRING_FORMAT_POSTGRES = 'yyyy-MM-dd HH:mm:ss';
const DATE_FORMAT_RSS = 'EEE, dd LLL yyyy HH:mm:ss xx';
const DATE_FORMAT_RSS_PLACEHOLDER = '000, 00 000 0000 00:00:00 00';
const DATE_FORMAT_POSTGRES = 'yyyy-MM-dd HH:mm:ss';
export const VALIDATION_EXAMPLE_POSTGRES = '2025-01-03T21:00:44.000Z';
export const VALIDATION_EXAMPLE_POSTGRES_NAIVE = '2025-01-03 16:00:44';
type AmbiguousTimestamp = number | string;
type Length = 'tiny' | 'short' | 'medium' | 'long';
type Length = 'tiny' | 'short' | 'medium' | 'long' | 'rss';
export const formatDate = ({
date,
@ -40,28 +43,32 @@ export const formatDate = ({
showPlaceholder?: boolean,
}) => {
let formatString = !hideTime
? DATE_STRING_FORMAT_LONG
: DATE_STRING_FORMAT_SHORT;
? DATE_FORMAT_LONG
: DATE_FORMAT_SHORT;
let placeholderString = !hideTime
? DATE_STRING_FORMAT_LONG_PLACEHOLDER
: DATE_STRING_FORMAT_SHORT_PLACEHOLDER;
? DATE_FORMAT_LONG_PLACEHOLDER
: DATE_FORMAT_SHORT_PLACEHOLDER;
switch (length) {
case 'rss':
formatString = DATE_FORMAT_RSS;
placeholderString = DATE_FORMAT_RSS_PLACEHOLDER;
break;
case 'tiny':
formatString = DATE_STRING_FORMAT_TINY;
placeholderString = DATE_STRING_FORMAT_TINY_PLACEHOLDER;
formatString = DATE_FORMAT_TINY;
placeholderString = DATE_FORMAT_TINY_PLACEHOLDER;
break;
case 'short':
formatString = DATE_STRING_FORMAT_SHORT;
placeholderString = DATE_STRING_FORMAT_SHORT_PLACEHOLDER;
formatString = DATE_FORMAT_SHORT;
placeholderString = DATE_FORMAT_SHORT_PLACEHOLDER;
break;
case 'medium':
formatString = !hideTime
? DATE_STRING_FORMAT_MEDIUM
: DATE_STRING_FORMAT_SHORT;
? DATE_FORMAT_MEDIUM
: DATE_FORMAT_SHORT;
placeholderString = !hideTime
? DATE_STRING_FORMAT_MEDIUM_PLACEHOLDER
: DATE_STRING_FORMAT_SHORT_PLACEHOLDER;
? DATE_FORMAT_MEDIUM_PLACEHOLDER
: DATE_FORMAT_SHORT_PLACEHOLDER;
break;
}
@ -77,7 +84,7 @@ export const formatDateFromPostgresString = (
length?: Length,
) =>
formatDate({
date: parse(date, DATE_STRING_FORMAT_POSTGRES, new Date()),
date: parse(date, DATE_FORMAT_POSTGRES, new Date()),
length,
});
@ -130,7 +137,7 @@ export const generateLocalPostgresString = () =>
formatDateForPostgres(new Date());
export const generateLocalNaivePostgresString = () =>
format(new Date(), DATE_STRING_FORMAT_POSTGRES);
format(new Date(), DATE_FORMAT_POSTGRES);
// Form validation to prevent Postgres runtime errors

View File

@ -35,6 +35,14 @@ export const parameterize = (
)
.toLocaleLowerCase();
export const formatStringForXml = (string: string) =>
string
.replace(/&/g, '&amp;')
.replace(/'/g, '&apos;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
export const deparameterize = (string: string) =>
capitalizeWords(string.replaceAll('-', ' '));