From a8750919c2876ddf8196fc6ecd3a681a96a23409 Mon Sep 17 00:00:00 2001 From: kevin8181 <66894759+kevin8181@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:59:02 -0400 Subject: [PATCH 1/6] switch feed generation to feedsmith --- package.json | 1 - pnpm-lock.yaml | 21 ------------ src/pages/feeds/[slug]/rss.ts | 62 ++++++++++++++++++++--------------- 3 files changed, 35 insertions(+), 49 deletions(-) diff --git a/package.json b/package.json index bf99880..3895ac8 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,6 @@ "dependencies": { "@astrojs/check": "^0.9.7", "@astrojs/react": "^5.0.0", - "@astrojs/rss": "^4.0.17", "@astrojs/sitemap": "^3.7.1", "@astrojs/vercel": "^10.0.0", "@fortawesome/fontawesome-svg-core": "^7.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 163593c..05ed576 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,9 +14,6 @@ importers: '@astrojs/react': specifier: ^5.0.0 version: 5.0.0(@types/node@24.12.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(jiti@2.6.1)(lightningcss@1.31.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tsx@4.21.0)(yaml@2.8.2) - '@astrojs/rss': - specifier: ^4.0.17 - version: 4.0.17 '@astrojs/sitemap': specifier: ^3.7.1 version: 3.7.1 @@ -157,9 +154,6 @@ packages: react: ^17.0.2 || ^18.0.0 || ^19.0.0 react-dom: ^17.0.2 || ^18.0.0 || ^19.0.0 - '@astrojs/rss@4.0.17': - resolution: {integrity: sha512-eV+wdMbeVKC9+sPaV0LN8JL1LGo9YAh3GKl4Ou4nzMNLmXM/aswYpSGxVEAuHilgBZ6/++/Pv08ICmuOqX107w==} - '@astrojs/sitemap@3.7.1': resolution: {integrity: sha512-IzQqdTeskaMX+QDZCzMuJIp8A8C1vgzMBp/NmHNnadepHYNHcxQdGLQZYfkbd2EbRXUfOS+UDIKx8sKg0oWVdw==} @@ -1695,10 +1689,6 @@ packages: fast-xml-builder@1.1.2: resolution: {integrity: sha512-NJAmiuVaJEjVa7TjLZKlYd7RqmzOC91EtPFXHvlTcqBVo50Qh7XV5IwvXi1c7NRz2Q/majGX9YLcwJtWgHjtkA==} - fast-xml-parser@5.4.1: - resolution: {integrity: sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==} - hasBin: true - fast-xml-parser@5.5.3: resolution: {integrity: sha512-Ymnuefk6VzAhT3SxLzVUw+nMio/wB1NGypHkgetwtXcK1JfryaHk4DWQFGVwQ9XgzyS5iRZ7C2ZGI4AMsdMZ6A==} hasBin: true @@ -3208,12 +3198,6 @@ snapshots: - tsx - yaml - '@astrojs/rss@4.0.17': - dependencies: - fast-xml-parser: 5.4.1 - piccolore: 0.1.3 - zod: 4.3.6 - '@astrojs/sitemap@3.7.1': dependencies: sitemap: 9.0.1 @@ -4758,11 +4742,6 @@ snapshots: dependencies: path-expression-matcher: 1.1.3 - fast-xml-parser@5.4.1: - dependencies: - fast-xml-builder: 1.1.2 - strnum: 2.2.0 - fast-xml-parser@5.5.3: dependencies: fast-xml-builder: 1.1.2 diff --git a/src/pages/feeds/[slug]/rss.ts b/src/pages/feeds/[slug]/rss.ts index 9350819..ccba167 100644 --- a/src/pages/feeds/[slug]/rss.ts +++ b/src/pages/feeds/[slug]/rss.ts @@ -1,8 +1,6 @@ import type { APIRoute } from "astro"; -import rss from "@astrojs/rss"; import { FeedManager } from "@/features/feeds/feedManager"; - -//todo generate this with feedsmith instead of astro package. serve as text/xml and include stylesheet +import { generateRssFeed } from "feedsmith"; export function getStaticPaths() { return FeedManager.feeds.map((feed) => ({ @@ -15,31 +13,41 @@ export const GET: APIRoute = async (context) => { const feed = FeedManager.getFeedBySlug(slug)!; const posts = await feed.posts(); - return rss({ - title: feed.name, - description: feed.homepageUrl, //todo add a description to each feed - - // Pull in your project "site" from the endpoint context - // https://docs.astro.build/en/reference/api-reference/#site - site: context.site ?? "", + const generated = generateRssFeed( + { + title: feed.name, + description: feed.homepageUrl, //todo add a description to each feed - // Array of ``s in output xml - // See "Generating items" section for examples using content collections and glob imports - items: posts.map((post) => ({ - title: post.title, - description: post.description, - pubDate: post.date, - link: post.url, - categories: [post.feed.name], - source: { - title: feed.name, - url: feed.homepageUrl, //todo I think this is supposed to be an rss feed - }, - })), - // (optional) inject custom xml - customData: `en-us`, - trailingSlash: false, + items: posts.map((post) => ({ + title: post.title, + ...(post.description && { description: post.description }), + pubDate: post.date, + link: post.url, + categories: [ + { + name: post.feed.name, + }, + ], + source: { + title: feed.name, + url: feed.homepageUrl, //todo I think this is supposed to be an rss feed + }, + })), + }, + { + stylesheets: [ + { + title: "RSS Stylesheet", + type: "text/xsl", + href: "/xslt/rss.xslt", + }, + ], + }, + ); - stylesheet: "/xslt/rss.xslt", + return new Response(generated, { + headers: { + "Content-Type": "text/xml", + }, }); }; From 1072af3996869750e07772c44a8871894d641e0e Mon Sep 17 00:00:00 2001 From: kevin8181 <66894759+kevin8181@users.noreply.github.com> Date: Wed, 11 Mar 2026 17:13:51 -0400 Subject: [PATCH 2/6] secure the updateAllFeeds endpoint --- astro.config.ts | 7 +++++++ src/pages/api/updateAllFeeds.ts | 11 ++++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/astro.config.ts b/astro.config.ts index 55abe1a..3976dd2 100644 --- a/astro.config.ts +++ b/astro.config.ts @@ -35,6 +35,13 @@ export default defineConfig({ context: "server", access: "secret", }), + + // vercel.com/docs/cron-jobs/manage-cron-jobs?framework=other#securing-cron-jobs + /** token to secure the vercel cron job that updates all the upstream feeds. */ + CRON_SECRET: envField.string({ + context: "server", + access: "secret", + }), }, }, diff --git a/src/pages/api/updateAllFeeds.ts b/src/pages/api/updateAllFeeds.ts index 7f83e5e..45f5c91 100644 --- a/src/pages/api/updateAllFeeds.ts +++ b/src/pages/api/updateAllFeeds.ts @@ -1,12 +1,17 @@ import type { APIRoute } from "astro"; import { FeedManager } from "@/features/feeds/feedManager"; +import { CRON_SECRET } from "astro:env/server"; export const prerender = false; -//todo secure this endpoint with cron secret https://vercel.com/docs/cron-jobs/manage-cron-jobs?framework=other#securing-cron-jobs +export const GET: APIRoute = async (context) => { + const authHeader = context.request.headers.get("authorization"); + + if (authHeader !== `Bearer ${CRON_SECRET}`) { + return new Response("401 Unauthorized", { status: 401 }); + } -export const GET: APIRoute = async () => { await FeedManager.updateAllFeeds(); - return new Response(); + return new Response("200 OK", { status: 200 }); }; From 7e1d829075a7755a20ea98f8f6e500fe161d22be Mon Sep 17 00:00:00 2001 From: kevin8181 <66894759+kevin8181@users.noreply.github.com> Date: Thu, 12 Mar 2026 01:05:33 -0400 Subject: [PATCH 3/6] feed providers rename to adapters --- knip.json | 2 +- notes.md | 2 +- .../adapters}/rss.ts | 8 +- .../adapters}/tta.ts | 6 +- .../adapters}/wordpressApi.ts | 10 +- .../feedAdapters.ts} | 12 +- src/features/feeds/config.ts | 163 +++++++++--------- src/features/feeds/feed.ts | 16 +- 8 files changed, 106 insertions(+), 113 deletions(-) rename src/features/{feedProviders/providers => feedAdapters/adapters}/rss.ts (83%) rename src/features/{feedProviders/providers => feedAdapters/adapters}/tta.ts (83%) rename src/features/{feedProviders/providers => feedAdapters/adapters}/wordpressApi.ts (83%) rename src/features/{feedProviders/feedProvider.ts => feedAdapters/feedAdapters.ts} (54%) diff --git a/knip.json b/knip.json index ec30a6e..ff4bb0c 100644 --- a/knip.json +++ b/knip.json @@ -1,5 +1,5 @@ { "$schema": "https://unpkg.com/knip@5/schema.json", "ignoreDependencies": [""], - "ignoreFiles": ["src/features/feedProviders/providers/tta.ts"] + "ignoreFiles": ["src/features/feedAdapters/adapters/tta.ts"] } diff --git a/notes.md b/notes.md index 51c9ac6..3fbc56b 100644 --- a/notes.md +++ b/notes.md @@ -24,7 +24,7 @@ // find new post types on scouting.org at https://www.scouting.org/wp-json/wp/v2/types // find new/edited pages at https://www.scouting.org/wp-json/wp/v2/pages -//todo add podcast rss feed provider? +//todo add podcast rss feed adapter? ## periodicals diff --git a/src/features/feedProviders/providers/rss.ts b/src/features/feedAdapters/adapters/rss.ts similarity index 83% rename from src/features/feedProviders/providers/rss.ts rename to src/features/feedAdapters/adapters/rss.ts index 201c5ac..03d5758 100644 --- a/src/features/feedProviders/providers/rss.ts +++ b/src/features/feedAdapters/adapters/rss.ts @@ -1,7 +1,7 @@ -import { FeedProvider } from "@/features/feedProviders/feedProvider"; +import { FeedAdapter } from "@/features/feedAdapters/feedAdapters"; import { parseRssFeed } from "feedsmith"; -export function RssProvider(opts: RssProviderOpts) { +export function RssAdapter(opts: RssAdapterOpts) { const execute = async () => { const response = await fetch(opts.feedUrl); const xml = await response.text(); @@ -34,7 +34,7 @@ export function RssProvider(opts: RssProviderOpts) { }); }; - return new FeedProvider({ + return new FeedAdapter({ type: { id: "rss", human: "RSS", @@ -43,6 +43,6 @@ export function RssProvider(opts: RssProviderOpts) { }); } -type RssProviderOpts = { +type RssAdapterOpts = { feedUrl: string; }; diff --git a/src/features/feedProviders/providers/tta.ts b/src/features/feedAdapters/adapters/tta.ts similarity index 83% rename from src/features/feedProviders/providers/tta.ts rename to src/features/feedAdapters/adapters/tta.ts index 798215d..3b2124b 100644 --- a/src/features/feedProviders/providers/tta.ts +++ b/src/features/feedAdapters/adapters/tta.ts @@ -1,10 +1,10 @@ -import { FeedProvider } from "@/features/feedProviders/feedProvider"; +import { FeedAdapter } from "@/features/feedAdapters/feedAdapters"; import he from "he"; //todo fetch the full post history -export function TtaProvider() { - return new FeedProvider({ +export function TtaAdapter() { + return new FeedAdapter({ type: { id: "tta", human: "Trail to Adventure (bespoke)", diff --git a/src/features/feedProviders/providers/wordpressApi.ts b/src/features/feedAdapters/adapters/wordpressApi.ts similarity index 83% rename from src/features/feedProviders/providers/wordpressApi.ts rename to src/features/feedAdapters/adapters/wordpressApi.ts index fb816ad..64f803f 100644 --- a/src/features/feedProviders/providers/wordpressApi.ts +++ b/src/features/feedAdapters/adapters/wordpressApi.ts @@ -1,15 +1,15 @@ -import { FeedProvider } from "@/features/feedProviders/feedProvider"; +import { FeedAdapter } from "@/features/feedAdapters/feedAdapters"; import type { PostData } from "@/features/posts/post"; import he from "he"; -export function WordpressApiProvider(opts: WordpressApiProviderOpts) { +export function WordpressApiAdapter(opts: WordpressApiAdapterOpts) { const execute = async () => { const page1 = await fetchPage(1, opts); return page1.posts; }; - return new FeedProvider({ + return new FeedAdapter({ type: { id: "wordpressApi", human: "Wordpress", @@ -18,7 +18,7 @@ export function WordpressApiProvider(opts: WordpressApiProviderOpts) { }); } -async function fetchPage(page: number, opts: WordpressApiProviderOpts) { +async function fetchPage(page: number, opts: WordpressApiAdapterOpts) { console.log(`fetch page ${page} from ${opts.baseUrl}`); const url = new URL( @@ -51,7 +51,7 @@ async function fetchPage(page: number, opts: WordpressApiProviderOpts) { }; } -type WordpressApiProviderOpts = { +type WordpressApiAdapterOpts = { /** the base url of the wordpress site */ baseUrl: string; /** return only posts which have this category id */ diff --git a/src/features/feedProviders/feedProvider.ts b/src/features/feedAdapters/feedAdapters.ts similarity index 54% rename from src/features/feedProviders/feedProvider.ts rename to src/features/feedAdapters/feedAdapters.ts index 5a581c0..1219d0b 100644 --- a/src/features/feedProviders/feedProvider.ts +++ b/src/features/feedAdapters/feedAdapters.ts @@ -1,6 +1,6 @@ import type { PostData } from "@/features/posts/post"; -export class FeedProvider { +export class FeedAdapter { type: { id: string; human: string; @@ -8,18 +8,18 @@ export class FeedProvider { execute: () => Promise; - constructor(opts: FeedProviderOpts) { + constructor(opts: FeedAdapterOpts) { this.type = opts.type; this.execute = opts.execute; } } -type FeedProviderOpts = { - /** metadata about the feed provider type */ +type FeedAdapterOpts = { + /** metadata about the feed adapter type */ type: { - /** machine id for the type of provider (rss, wordpressApi, etc) */ + /** machine id for the type of adapter (rss, wordpressApi, etc) */ id: string; - /** human-readable name for the type of feed provider (RSS, Wordpress API, etc) */ + /** human-readable name for the type of feed adapter (RSS, Wordpress API, etc) */ human: string; }; diff --git a/src/features/feeds/config.ts b/src/features/feeds/config.ts index 13b3170..1e7d77c 100644 --- a/src/features/feeds/config.ts +++ b/src/features/feeds/config.ts @@ -1,11 +1,11 @@ import type { CreateFeedOpts } from "@/features/feeds/feed"; -import { WordpressApiProvider } from "@/features/feedProviders/providers/wordpressApi"; -import { RssProvider } from "@/features/feedProviders/providers/rss"; -// import { TtaProvider } from "@/features/feedProviders/providers/tta"; +import { WordpressApiAdapter } from "@/features/feedAdapters/adapters/wordpressApi"; +import { RssAdapter } from "@/features/feedAdapters/adapters/rss"; +// import { TtaAdapter } from "@/features/feedAdapters/adapters/tta"; export const feedConfigs: CreateFeedOpts[] = [ - // todo this is not working any more because of cloudflare + // todo these are not working any more because of cloudflare // { // name: "Scouts BSA Program Updates", // slug: "scouts-bsa-program-updates", @@ -13,13 +13,11 @@ export const feedConfigs: CreateFeedOpts[] = [ // "Information about changes and updates to the Scouts BSA program.", // homepageUrl: // "https://www.scouting.org/topics/program-updates/program-updates-scouts-bsa", - // provider: WordpressApiProvider({ + // adapter: WordpressApiAdapter({ // baseUrl: "https://scouting.org", // categoryFilter: 15054, // }), // }, - - // todo this is not working any more because of cloudflare // { // name: "Cub Scouts Program Updates", // slug: "cub-scouts-program-updates", @@ -27,11 +25,72 @@ export const feedConfigs: CreateFeedOpts[] = [ // "Information about changes and updates to the Cub Scouts program.", // homepageUrl: // "https://www.scouting.org/topics/program-updates/program-updates-cub-scouts", - // provider: WordpressApiProvider({ + // adapter: WordpressApiAdapter({ // baseUrl: "https://www.scouting.org", // categoryFilter: 15053, // }), // }, + // { + // name: "Scouting Magazine", + // slug: "scouting-magazine", + // description: + // "Editorial content for parents and volunteers. The adult counterpart of Scout Life.", + // homepageUrl: "https://blog.scoutingmagazine.org", + // adapter: WordpressApiAdapter({ + // baseUrl: "https://blog.scoutingmagazine.org", + // }), + // }, + // { + // name: "Trail to Adventure", + // slug: "trail-to-adventure", + // description: + // "News and updates regarding scout camp administration. The Official Blog of the National Outdoor Programs and Properties Subcommittees.", + // homepageUrl: "https://www.scouting.org/outdoor-programs/trail-to-adventure", + // adapter: TtaAdapter(), + // }, + + // todo disabled this one because it's spammy in the main feed. will re-enable once filtering is all implimented + // { + // name: "Scout Life", + // slug: "scout-life", + // description: "Editorial and entertainment content mainly for youth.", + // homepageUrl: "https://scoutlife.org", + // adapter: WordpressApiAdapter({ + // baseUrl: "https://scoutlife.org", + // }), + // }, + + // todo these are gone. rebuild from scraped copy + // { + // name: "ScoutCast", + // slug: "scoutcast", + // description: "A defunct podcast about the Scouts BSA program.", + // homepageUrl: "https://podcast.scouting.org/category/scoutcast", + // adapter: RssAdapter({ + // feedUrl: "https://podcast.scouting.org/category/scoutcast/feed", + // }), + // }, + // { + // name: "CubCast", + // slug: "cubcast", + // description: "A defunct podcast about the Cub Scouts program.", + // homepageUrl: "https://podcast.scouting.org/category/cubcast", + // adapter: RssAdapter({ + // feedUrl: "https://podcast.scouting.org/category/cubcast/feed", + // }), + // }, + + // todo why did I disable this one? + // { + // name: "The Lookout", + // slug: "the-lookout", + // description: + // "The Lookout: Sea Scout Podcast Network. Features both news and interviews.", + // homepageUrl: "https://seascout.org/the-lookout-sea-scout-podcast-network/", + // adapter: RssAdapter({ + // feedUrl: "https://feeds.buzzsprout.com/983503.rss", + // }), + // }, { name: "Scouting Wire", @@ -39,7 +98,7 @@ export const feedConfigs: CreateFeedOpts[] = [ description: "Billed as 'The Official Blog of the Scouting Movement'. General news and updates for professionals, volunteers, and parents.", homepageUrl: "https://scoutingwire.org", - provider: WordpressApiProvider({ + adapter: WordpressApiAdapter({ baseUrl: "https://scoutingwire.org", //todo split by categories? }), @@ -50,7 +109,7 @@ export const feedConfigs: CreateFeedOpts[] = [ description: "Provides updates and news about the national Scouting administration.", homepageUrl: "https://scoutingnewsroom.org", - provider: RssProvider({ + adapter: RssAdapter({ feedUrl: "https://scoutingnewsroom.org/feed", //for some reason the wordpress posts api doesn't return any results on this site }), @@ -61,52 +120,29 @@ export const feedConfigs: CreateFeedOpts[] = [ description: "Provides updates and news about special needs scouting. A publication of the National Special Needs and Disabilities Committee.", homepageUrl: "https://ablescouts.org", - provider: RssProvider({ + adapter: RssAdapter({ feedUrl: "https://ablescouts.org/feed", //entire wordpress api is 404 }), }, - - // todo this is not working any more because of cloudflare - // { - // name: "Scouting Magazine", - // slug: "scouting-magazine", - // description: - // "Editorial content for parents and volunteers. The adult counterpart of Scout Life.", - // homepageUrl: "https://blog.scoutingmagazine.org", - // provider: WordpressApiProvider({ - // baseUrl: "https://blog.scoutingmagazine.org", - // }), - // }, - { name: "Summit Blog", slug: "summit-blog", description: "News and updates about the Summit Bechtel Reserve and National Scout Jamboree.", homepageUrl: "https://www.summitbsa.org/blog", - provider: WordpressApiProvider({ + adapter: WordpressApiAdapter({ baseUrl: "https://summitbsa.org", }), }, - // { - // name: "Scout Life", - // slug: "scout-life", - // description: "Editorial and entertainment content mainly for youth.", - // homepageUrl: "https://scoutlife.org", - // provider: WordpressApiProvider({ - // baseUrl: "https://scoutlife.org", - // }), - // }, - { name: "Scouting Alumni", slug: "scouting-alumni", description: "The news feed of Scouting Alumni. Primarily editorial content with occasional news.", homepageUrl: "https://scoutingalumni.org/news", - provider: WordpressApiProvider({ + adapter: WordpressApiAdapter({ baseUrl: "https://scoutingalumni.org", }), }, @@ -116,7 +152,7 @@ export const feedConfigs: CreateFeedOpts[] = [ description: "The news feed of the National Eagle Scout Association. A mixture of editorial content and news.", homepageUrl: "https://nesa.org/news", - provider: WordpressApiProvider({ + adapter: WordpressApiAdapter({ baseUrl: "https://nesa.org", }), }, @@ -126,7 +162,7 @@ export const feedConfigs: CreateFeedOpts[] = [ description: "The news feed of the Scouting America Foundation. Mostly entertainment and editorial content.", homepageUrl: "https://scoutingamericafoundation.org/foundation-news", - provider: WordpressApiProvider({ + adapter: WordpressApiAdapter({ baseUrl: "https://scoutingamericafoundation.org", }), }, @@ -136,7 +172,7 @@ export const feedConfigs: CreateFeedOpts[] = [ description: "News and updates about the Order of the Arrow on the national level.", homepageUrl: "https://oa-scouting.org/news", - provider: RssProvider({ + adapter: RssAdapter({ feedUrl: "https://oa-scouting.org/rss.xml", //not wordpress. might be bespoke. find out about if there's an api or a way to source better data }), @@ -147,7 +183,7 @@ export const feedConfigs: CreateFeedOpts[] = [ description: "Updates on Order of the Arrow's digital infrastructure maintenance and outages.", homepageUrl: "https://status.oa-scouting.org/", - provider: RssProvider({ + adapter: RssAdapter({ feedUrl: "https://status.oa-scouting.org/history.rss", // atom feed, text, and email also available }), @@ -157,7 +193,7 @@ export const feedConfigs: CreateFeedOpts[] = [ slug: "sea-scouts-news", description: "News and updates about the Sea Scouts program.", homepageUrl: "https://seascout.org/latest-news", - provider: WordpressApiProvider({ + adapter: WordpressApiAdapter({ baseUrl: "https://seascout.org/", }), }, @@ -166,51 +202,8 @@ export const feedConfigs: CreateFeedOpts[] = [ slug: "troop-leader-resource-updates", description: "Updates and news about the Troop Leader Resource Hub.", homepageUrl: "https://troopleader.scouting.org/updates-blog", - provider: WordpressApiProvider({ + adapter: WordpressApiAdapter({ baseUrl: "https://troopleader.scouting.org", }), }, - - // todo this is gone. rebuild from scraped copy - // { - // name: "ScoutCast", - // slug: "scoutcast", - // description: "A defunct podcast about the Scouts BSA program.", - // homepageUrl: "https://podcast.scouting.org/category/scoutcast", - // provider: RssProvider({ - // feedUrl: "https://podcast.scouting.org/category/scoutcast/feed", - // }), - // }, - - // todo this is gone. rebuild from scraped copy - // { - // name: "CubCast", - // slug: "cubcast", - // description: "A defunct podcast about the Cub Scouts program.", - // homepageUrl: "https://podcast.scouting.org/category/cubcast", - // provider: RssProvider({ - // feedUrl: "https://podcast.scouting.org/category/cubcast/feed", - // }), - // }, - - // { - // name: "The Lookout", - // slug: "the-lookout", - // description: - // "The Lookout: Sea Scout Podcast Network. Features both news and interviews.", - // homepageUrl: "https://seascout.org/the-lookout-sea-scout-podcast-network/", - // provider: RssProvider({ - // feedUrl: "https://feeds.buzzsprout.com/983503.rss", - // }), - // }, - - // todo this is not working any more because of cloudflare - // { - // name: "Trail to Adventure", - // slug: "trail-to-adventure", - // description: - // "News and updates regarding scout camp administration. The Official Blog of the National Outdoor Programs and Properties Subcommittees.", - // homepageUrl: "https://www.scouting.org/outdoor-programs/trail-to-adventure", - // provider: TtaProvider(), - // }, ]; diff --git a/src/features/feeds/feed.ts b/src/features/feeds/feed.ts index 7a49255..b3b42a0 100644 --- a/src/features/feeds/feed.ts +++ b/src/features/feeds/feed.ts @@ -1,7 +1,7 @@ import { Post } from "@/features/posts/post"; -import type { FeedProvider } from "@/features/feedProviders/feedProvider"; +import type { FeedAdapter } from "@/features/feedAdapters/feedAdapters"; import type { UrlShaped } from "@/util/utilTypes"; -import { redis } from "@/util/redis"; +import { redis } from "@/util/redisClient"; import type { PostData } from "@/features/posts/post"; export type CreateFeedOpts = { @@ -9,7 +9,7 @@ export type CreateFeedOpts = { slug: string; description: string; homepageUrl: UrlShaped; - provider: FeedProvider; + adapter: FeedAdapter; }; export class Feed { @@ -19,7 +19,7 @@ export class Feed { readonly slug: string; readonly description: string; readonly homepageUrl: UrlShaped; - private readonly _provider: FeedProvider; + private readonly _adapter: FeedAdapter; // LIFECYCLE @@ -28,13 +28,13 @@ export class Feed { this.slug = opts.slug; this.description = opts.description; this.homepageUrl = opts.homepageUrl; - this._provider = opts.provider; + this._adapter = opts.adapter; } // GETTERS get type() { - return this._provider.type; + return this._adapter.type; } /** relative href to the detail page for this feed */ get overviewUrl() { @@ -75,8 +75,8 @@ export class Feed { async updatePosts() { console.log(`updating cached posts for ${this.name}`); - //execute the feed provider to fetch the data from the original source - const postData = await this._provider.execute(); + //execute the feed adapter to fetch the data from the original source + const postData = await this._adapter.execute(); // write the data to redis await Feed.writeCache(this.slug, postData); From f72d5061d00361077acde2574da8720cfdc12ae1 Mon Sep 17 00:00:00 2001 From: kevin8181 <66894759+kevin8181@users.noreply.github.com> Date: Thu, 12 Mar 2026 01:06:12 -0400 Subject: [PATCH 4/6] rename redis to redis client --- src/util/{redis.ts => redisClient.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/util/{redis.ts => redisClient.ts} (100%) diff --git a/src/util/redis.ts b/src/util/redisClient.ts similarity index 100% rename from src/util/redis.ts rename to src/util/redisClient.ts From 4d5929e75c25f2d5182d9071c740c01478711229 Mon Sep 17 00:00:00 2001 From: kevin8181 <66894759+kevin8181@users.noreply.github.com> Date: Thu, 12 Mar 2026 01:06:36 -0400 Subject: [PATCH 5/6] add pagination util --- src/util/paginate.ts | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 src/util/paginate.ts diff --git a/src/util/paginate.ts b/src/util/paginate.ts new file mode 100644 index 0000000..2a0266d --- /dev/null +++ b/src/util/paginate.ts @@ -0,0 +1,43 @@ +/** paginate an array of items */ +function paginate({ + page, + pageSize, + totalItems, +}: PaginateOpts): PaginateResult { + const startIndex = (page - 1) * pageSize; + const endIndex = startIndex + pageSize; + const totalPages = Math.ceil(totalItems / pageSize); + + return { + startIndex, + endIndex, + page, + pageSize, + totalItems, + totalPages, + }; +} + +type PaginateOpts = { + /** the total number of items */ + totalItems: number; + /** the page size */ + pageSize: number; + /** the page number */ + page: number; +}; + +type PaginateResult = { + /** the start index of the items on this page */ + startIndex: number; + /** the end index of the items on this page */ + endIndex: number; + /** the current page number */ + page: number; + /** the maximum number of items per page */ + pageSize: number; + /** the total number of items */ + totalItems: number; + /** the total number of pages */ + totalPages: number; +}; From ff9d45e5bc067ef7cf9e8e6538ec4f3fafec3690 Mon Sep 17 00:00:00 2001 From: kevin8181 <66894759+kevin8181@users.noreply.github.com> Date: Thu, 12 Mar 2026 15:57:54 -0400 Subject: [PATCH 6/6] query api --- src/features/feeds/filter.ts | 44 ----------------------- src/features/postsQuery/filter.ts | 35 +++++++++++++++++++ src/features/postsQuery/query.ts | 26 ++++++++++++++ src/pages/api/posts.ts | 30 +++++++++++----- src/util/paginate.ts | 58 ++++++++++++++++++------------- 5 files changed, 116 insertions(+), 77 deletions(-) delete mode 100644 src/features/feeds/filter.ts create mode 100644 src/features/postsQuery/filter.ts create mode 100644 src/features/postsQuery/query.ts diff --git a/src/features/feeds/filter.ts b/src/features/feeds/filter.ts deleted file mode 100644 index 9651c33..0000000 --- a/src/features/feeds/filter.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { z } from "astro/zod"; -import { Post } from "@/features/posts/post"; - -export function filterPosts({ - posts, - filter, -}: { - posts: Post[]; - filter: z.infer; -}) { - return posts.filter((_post, index) => { - const predicates = buildPredicates({ index, filter }); - - // if all of the predicates are true, return true - if (Object.values(predicates).every((predicate) => predicate)) { - return true; - } - - // if any of the predicates are false, return false - return false; - }); -} - -export const filterSchema = z - .object({ - startIndex: z.coerce.number().optional(), - endIndex: z.coerce.number().optional(), - }) - .strict(); - -/** check a post against each filter. return an object with a bool for each predicate*/ -function buildPredicates({ - index, - filter, -}: { - index: number; - filter: z.infer; -}) { - return { - startIndex: - filter.startIndex === undefined ? true : index >= filter.startIndex, - endIndex: filter.endIndex === undefined ? true : index <= filter.endIndex, - }; -} diff --git a/src/features/postsQuery/filter.ts b/src/features/postsQuery/filter.ts new file mode 100644 index 0000000..235061e --- /dev/null +++ b/src/features/postsQuery/filter.ts @@ -0,0 +1,35 @@ +// import { z } from "astro/zod"; +// import { Post } from "@/features/posts/post"; + +// export function filterPosts({ +// posts, +// filterOpts, +// }: { +// posts: Post[]; +// filterOpts: z.infer; +// }) { +// return posts.filter((_post) => { +// const predicates = buildPredicates(filterOpts); + +// // if all of the predicates are true, return true +// if (Object.values(predicates).every((predicate) => predicate)) { +// return true; +// } + +// // if any of the predicates are false, return false +// return false; +// }); +// } + +// /** check a post against each filter. return an object with a bool for each predicate */ +// function buildPredicates(filters: FilterOpts) { +// return { + +// }; +// } + +// export const filterOptsSchema = z.object({ +// dateBefore: z.coerce.date().optional(), +// dateAfter: z.coerce.date().optional(), +// }).strict(); +// export type FilterOpts = z.infer; diff --git a/src/features/postsQuery/query.ts b/src/features/postsQuery/query.ts new file mode 100644 index 0000000..6465d00 --- /dev/null +++ b/src/features/postsQuery/query.ts @@ -0,0 +1,26 @@ +import { FeedManager } from "@/features/feeds/feedManager"; +import { z } from "astro/zod"; +import { + paginate, + paginateOptsSchema, + type PaginatedResults, +} from "@/util/paginate"; +import { Post } from "@/features/posts/post"; + +export async function queryPosts( + opts: QueryOpts, +): Promise> { + // get all the posts. todo make it so you can start with only a subset of the feeds + const posts = await FeedManager.allPosts(); + + // todo add filtering and sorting + + return paginate(posts, opts.paginate); +} + +type QueryOpts = z.infer; +//todo +//eslint-disable-next-line +const queryOptsSchema = z.object({ + paginate: paginateOptsSchema, +}); diff --git a/src/pages/api/posts.ts b/src/pages/api/posts.ts index e514cde..a0a50a5 100644 --- a/src/pages/api/posts.ts +++ b/src/pages/api/posts.ts @@ -1,12 +1,16 @@ export const prerender = false; import type { APIRoute } from "astro"; -import { FeedManager } from "@/features/feeds/feedManager"; -import { filterPosts, filterSchema } from "@/features/feeds/filter"; +import { z } from "astro/zod"; +import { queryPosts } from "@/features/postsQuery/query"; -export const GET: APIRoute = async ({ url }) => { - const params = Object.fromEntries(url.searchParams); +export const GET: APIRoute = async (context) => { + const params = context.url.searchParams; - const { error } = filterSchema.safeParse(params); + console.log(params); + + const paramsObj = Object.fromEntries(params); + + const { error, data: query } = postsQueryParamsSchema.safeParse(paramsObj); if (error) { return new Response(JSON.stringify({ error }), { @@ -17,14 +21,22 @@ export const GET: APIRoute = async ({ url }) => { }); } - const posts = await FeedManager.allPosts(); - - const filteredPosts = filterPosts({ posts, filter: params }); + const posts = await queryPosts({ + paginate: { + page: query.page, + pageSize: query.pageSize, + }, + }); - return new Response(JSON.stringify(filteredPosts), { + return new Response(JSON.stringify(posts), { status: 200, headers: { "Content-Type": "application/json", }, }); }; + +const postsQueryParamsSchema = z.object({ + page: z.coerce.number().min(1), + pageSize: z.coerce.number().min(1).max(1000).default(20), +}); diff --git a/src/util/paginate.ts b/src/util/paginate.ts index 2a0266d..e0f06f3 100644 --- a/src/util/paginate.ts +++ b/src/util/paginate.ts @@ -1,41 +1,51 @@ +import { z } from "astro/zod"; + /** paginate an array of items */ -function paginate({ - page, - pageSize, - totalItems, -}: PaginateOpts): PaginateResult { - const startIndex = (page - 1) * pageSize; - const endIndex = startIndex + pageSize; - const totalPages = Math.ceil(totalItems / pageSize); +export function paginate( + data: T[], + opts: PaginateOpts, +): PaginatedResults { + const firstItemIndex = (opts.page - 1) * opts.pageSize; + const lastItemIndex = firstItemIndex + opts.pageSize - 1; + const totalPages = Math.ceil(data.length / opts.pageSize); + + const items = data.slice(firstItemIndex, lastItemIndex + 1); return { - startIndex, - endIndex, - page, - pageSize, - totalItems, - totalPages, + items, + pagination: { + page: opts.page, + pageSize: opts.pageSize, + firstItemIndex, + lastItemIndex, + totalItems: data.length, + totalPages, + }, }; } -type PaginateOpts = { - /** the total number of items */ - totalItems: number; +type PaginateOpts = z.infer; +export const paginateOptsSchema = z.object({ /** the page size */ - pageSize: number; + pageSize: z.number().min(1), /** the page number */ - page: number; + page: z.number().min(1), +}); + +export type PaginatedResults = { + items: T[]; + pagination: PaginationResultsMetadata; }; -type PaginateResult = { - /** the start index of the items on this page */ - startIndex: number; - /** the end index of the items on this page */ - endIndex: number; +type PaginationResultsMetadata = { /** the current page number */ page: number; /** the maximum number of items per page */ pageSize: number; + /** the start index of the items on this page */ + firstItemIndex: number; + /** the end index of the items on this page */ + lastItemIndex: number; /** the total number of items */ totalItems: number; /** the total number of pages */