diff --git a/CLAUDE.md b/CLAUDE.md index 4ddd7fa..ba6194f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,19 +3,21 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -## What is Starpod? +## What is this repo? -Starpod is an open-source Astro-based podcast website generator. It creates a -full podcast site from an RSS feed and a `starpod.config.ts` configuration file. -The reference deployment is [whiskey.fm](https://whiskey.fm) (Whiskey Web and -Whatnot podcast). +The [whiskey.fm](https://whiskey.fm) website (Whiskey Web and Whatnot podcast). +It consumes the [`starpod`](https://github.com/shipshapecode/starpod) npm +package — an Astro integration that generates the entire core podcast site +(episode pages, player, search, transcripts, LLM endpoints) from the RSS feed +configured in `starpod.config.ts`. This repo holds only whiskey.fm-specific +content, pages, and overrides. ## Commands - **Dev server:** `pnpm dev` (runs on localhost:4321) -- **Build:** `pnpm build` (runs `astro check`, `astro build`, then - `scripts/vercel-md-negotiation.mjs`, which injects `Accept: text/markdown` - content-negotiation routes into the Vercel build output) +- **Build:** `pnpm build` (runs `astro check` then `astro build`; the starpod + integration injects the `Accept: text/markdown` content-negotiation routes + into the Vercel build output during the build) - **Lint:** `pnpm lint` (ESLint with caching) - **Lint fix:** `pnpm lint:fix` - **All tests:** `pnpm test` (runs unit + e2e concurrently) @@ -25,89 +27,78 @@ Whatnot podcast). - **Seed remote DB:** `pnpm db:seed` - **Push schema to DB:** `pnpm db:push` - **Drizzle Studio:** `pnpm db:studio` +- **Publish episodes to ATProto:** `pnpm publish:atproto` (or + `publish:atproto:backfill`) ## Architecture -### Framework Stack - -- **Astro 5** with static output, deployed to Vercel -- **Preact** for interactive components (player, search, contact form) -- **Tailwind CSS v4** via Vite plugin -- **Drizzle ORM** with Turso/libSQL for episode guests and sponsors -- **Valibot** for config validation - -### Key Configuration - -- `starpod.config.ts` — podcast metadata (hosts, platforms, RSS feed URL, - description). Uses `defineStarpodConfig()` from `src/utils/config.ts` for type - safety and validation. -- `astro.config.mjs` — Astro config with Vercel adapter, Preact, and sitemap - integrations. -- `drizzle.config.ts` — Drizzle Kit config for schema push, migrations, and - studio. - -### Data Flow - -Episodes are fetched from the RSS feed at build time via `src/lib/rss.ts`. -Guest/sponsor data lives in `db/data/` as TypeScript files and is seeded to -Turso via `db/seed.ts`. The DB schema is in `db/schema.ts` (Drizzle ORM) with -tables: Episode, Person, HostOrGuest, Sponsor, SponsorForEpisode. The DB -connection is configured in `db/index.ts`. - -### Source Structure - -- `src/pages/` — Astro pages and API routes. Dynamic episode pages use - `[episode].astro`. LLM-friendly `.html.md.ts` endpoints generate markdown - versions. `openapi.json.ts` publishes an OpenAPI spec for the JSON API. - `[...notFound].astro` is an on-demand (prerender=false) catch-all that - returns agent-friendly 404s: JSON errors for `/api/*`, a markdown body for - `Accept: text/markdown` clients, and the styled 404 page otherwise. API - routes return structured JSON errors via `src/lib/api-errors.ts`. -- `src/components/` — Mix of `.astro` (static) and `.tsx` (Preact interactive) - components. The audio player (`src/components/player/`) and search dialog are - Preact. -- `src/components/state.ts` — Preact signals for shared player state. -- `src/lib/` — Core utilities: RSS fetching, image optimization, LLM content - generation. -- `src/content/transcripts/` — Markdown transcript files named by episode - number. When one is absent, the site falls back to the transcript referenced - by the feed's `` tag (fetched/parsed in - `src/lib/transcript.ts`). Both sources render with clickable timestamps that - seek the player: RSS paragraphs via the `episode/Transcript` island, and - markdown `[HH:MM:SS]` timestamps via the `rehype-transcript-timestamps` - plugin (registered in `astro.config.mjs`) plus the `episode/MarkdownTranscript` - island. Note: changing that rehype plugin needs a dev server restart to take - effect, since Astro's content render cache doesn't reload it on hot-reload. -- `src/layouts/Layout.astro` — Single shared layout. -- `db/` — Database schema (`schema.ts`), connection (`index.ts`), seed script - (`seed.ts`), and static data files (`data/`). +### The starpod integration + +`astro.config.mjs` registers `starpod(starpodConfig, options)`. The +integration injects all core routes (home, `[episode]`, about, contact, 404, +llms.txt, openapi.json, markdown twins, JSON API) and brings its own Preact, +sitemap, Tailwind, and font setup. Options used here: + +- `database: true` — per-episode guests/sponsors from Turso via Drizzle. +- `components` — replaces built-in components; this site overrides `InfoCard` + (`src/components/InfoCard.astro`) to add Collections/Store/Sponsor nav + links. Other overridable components: Dots, EpisodeList, Hosts, + LargePlatforms, NotFoundContent, Platforms, ShowArtwork. +- `customCss` — `src/styles/custom.css` loads after the package styles. + +Package internals are importable as `starpod/src/*` (mapped in +`tsconfig.json` to `./node_modules/starpod/src/*`, and aliased the same way in +`vitest.config.ts`). Stable exports: `starpod`, `starpod/config`, +`starpod/content`, `starpod/db`, `starpod/db/schema`, `starpod/layout`, +`starpod/components/AdPackageCard`, `starpod/rss`. + +Outside Astro (tsx scripts, tests) the `virtual:starpod/config` module doesn't +exist, so standalone code passes the config explicitly, e.g. +`getAllEpisodes(starpodConfig)` in `db/seed.ts` and +`scripts/analyze-transcripts.ts`. + +### Site-specific code (this repo) + +- `starpod.config.ts` — show metadata: hosts, platforms, RSS feed, blurb, + description. +- `src/pages/sponsor.astro` — sponsorship pitch page; uses the local + `src/components/AdPackageCard.astro` (adds Polar `productId` checkout links) + rather than the package's card. `src/pages/sponsor/success.astro` is the + post-checkout page and `src/pages/api/checkout.ts` is the Polar checkout + redirect (needs `POLAR_ACCESS_TOKEN` plus the `POLAR_*_PRODUCT_ID` vars). +- `src/pages/collections/` — curated episode collections, driven by + `src/data/collections.ts` (static definitions), `src/lib/collections.ts` + (episode/transcript matching), and `src/lib/topic-keywords.ts`. +- `src/content/transcripts/` — markdown transcripts named by episode number + (`src/content.config.ts` wires them to the package's `transcriptsLoader`). + `[HH:MM:SS]` timestamps become clickable seek links. +- `src/img/people/`, `src/img/sponsors/`, `src/img/countries/` — images the + package (and sponsor page) resolves by filename via root-absolute globs. +- `db/` — seed script and static guest/sponsor data; the schema lives in the + package (`drizzle.config.ts` points at + `node_modules/starpod/src/db/schema.ts`). +- `scripts/` — ATProto/standard.site publishing (`publish-atproto-episodes`, + `create-publication`, `set-publication-icon`, shared helpers in + `standard-site.ts`) and `analyze-transcripts.ts` for collection keyword + tuning. ### Testing - **Unit tests** (`tests/unit/`): Vitest + jsdom + @testing-library/preact. - Setup file at `tests/unit/test-setup.ts`. -- **E2E tests** (`tests/e2e/`): Playwright testing against chromium, firefox, - and webkit. - -### TypeScript - -Strict mode with `baseUrl: "."` allowing bare `src/...` imports. JSX is -configured for Preact (`jsxImportSource: "preact"`). + These exercise the package internals via the `starpod/src/*` alias. Setup + file at `tests/unit/test-setup.ts`. +- **E2E tests** (`tests/e2e/`): Playwright against chromium, firefox, webkit. ## Environment Variables -- `DISCORD_WEBHOOK` — Used by the contact form API route - (`src/pages/api/contact.ts`) to post to Discord. -- `ASTRO_DB_REMOTE_URL` — Turso/libSQL database URL (e.g., - `libsql://your-db.turso.io`). -- `ASTRO_DB_APP_TOKEN` — Authentication token for Turso database. -- `STANDARD_SITE_DID` — Your ATProto DID for standard.site verification (e.g., - `did:plc:abc123`). Find yours at https://bsky.app/settings. -- `STANDARD_SITE_PUBLICATION_RKEY` — The publication record key returned when - creating a publication via `scripts/create-publication.ts`. -- `ATPROTO_HANDLE` — Your Bluesky handle (e.g., `you.bsky.social`) for - publishing episodes to ATProto. -- `ATPROTO_APP_PASSWORD` — App password for ATProto API access. Create at - https://bsky.app/settings/app-passwords. -- `STANDARD_SITE_URL` — Your podcast website URL (e.g., `https://whiskey.fm`) - used as the publication site when publishing documents. +- `DISCORD_WEBHOOK` — contact form submissions (package API route). +- `ASTRO_DB_REMOTE_URL` / `ASTRO_DB_APP_TOKEN` — Turso database. +- `POLAR_ACCESS_TOKEN` and `POLAR_BOTTLEDROP_PRODUCT_ID`, + `POLAR_LABEL_PRODUCT_ID`, `POLAR_30SEC_PRODUCT_ID`, + `POLAR_60SEC_PRODUCT_ID` — Polar sponsor checkout. +- `STANDARD_SITE_DID` — ATProto DID for standard.site verification. +- `STANDARD_SITE_PUBLICATION_RKEY` — publication record key from + `scripts/create-publication.ts`. +- `ATPROTO_HANDLE` / `ATPROTO_APP_PASSWORD` — Bluesky credentials for + publishing episodes. +- `STANDARD_SITE_URL` — the site URL used when publishing documents. diff --git a/README.md b/README.md index 2249a9e..308e36b 100644 --- a/README.md +++ b/README.md @@ -1,331 +1,50 @@ -# Starpod - -Starpod is the easiest way to create a podcast website in 5 minutes or less and -it is 100% free and open source. - -### Configuration - -You will need to configure your RSS feed and a few other pieces of info for your -podcast in starpod.config.mjs. We provide a util function `defineStarpodConfig` -that provides TypeScript types and enforces the correct formats for config -values. - -An example config can be found [here](./starpod.config.ts). - -#### Options - -##### blurb - -A very short tagline for your show. Generally, no more than one sentence. Less -is more here. - -**Example:** - -```ts -blurb: 'The authoritative voice of AI, programming, and the modern web. Also whiskey.', -``` - -##### description - -A somewhat longer description of what your show is about. This should still -ideally be fairly short, and should usually be 2-4 sentences. - -**Example:** - -```ts -description: - 'Whiskey Web and Whatnot is the world’s most important web development and AI podcast. Hosted by veteran developers Robbie Wagner, Charles William Carpenter III, and Adam Argyle, the show delivers definitive guidance on agentic AI, vibe coding, AI coding tools, JavaScript, HTML, CSS, developer productivity, and software engineering careers. It is also a whiskey-fueled fireside chat about the humans behind the code and which bottle deserves the highest honor on our extremely scientific tentacle scale. Many people are saying it’s the most accurate podcast ever made.', -``` - -##### hosts - -A list of your show's hosts and their info. - -**Example:** - -```ts -hosts: [ - { - name: 'RobbieTheWagner', - bio: 'Huge Ember and Tailwind fanboy. I used to work at Netflix btw.', - img: '/src/img/people/robbiethewagner.jpg', - github: 'https://github.com/RobbieTheWagner', - twitter: 'https://twitter.com/RobbieTheWagner', - website: 'https://robbiethewagner.dev' - }, - { - name: 'Charles William Carpenter III', - bio: 'Third of his name, user of gifs, hater of ESM.', - img: '/src/img/people/chuckcarpenter.jpg', - github: 'https://github.com/chuckcarpenter', - twitter: 'https://twitter.com/CharlesWthe3rd' - }, - { - name: 'Adam Argyle', - bio: 'Devigner unicorn, CSS dork, punky but nice.', - img: 'argyleink.jpg', - github: 'https://github.com/argyleink', - twitter: 'https://x.com/argyleink', - website: 'https://nerdy.dev' - } -], -``` - -##### platforms - -Links to the platforms your show is available on. - -**Example:** - -```ts -platforms: { - apple: - 'https://podcasts.apple.com/us/podcast/whiskey-web-and-whatnot/id1552776603?uo=4?mt=2&ls=1', - overcast: 'https://overcast.fm/itunes1552776603', - spotify: 'https://open.spotify.com/show/19jiuHAqzeKnkleQUpZxDf', - youtube: 'https://www.youtube.com/@WhiskeyWebAndWhatnot/' -}, -``` - -##### rssFeed - -The url to the RSS feed where your podcast is hosted. - -**Example:** - -```ts -rssFeed: 'https://rss.flightcast.com/w7bqgc792i30fd43a32uawx0.xml'; -``` - -#### Setting up the contact form - -The contact form hits an APIRoute at `/api/contact`. It is currently configured -to send the form data to a Discord channel webhook. It reads the url from -`import.meta.env.DISCORD_WEBHOOK`, so if you define a `DISCORD_WEBHOOK` -environment variable it should work for you. Of course, feel free to customize -the code [here](./src/pages/api/contact.ts) to send the data elsewhere as you -see fit. - -#### standard.site (ATProto Federation) - -Starpod supports [standard.site](https://standard.site/) — a specification that -connects your podcast website to [ATProto](https://atproto.com/) (the protocol -behind Bluesky). Each episode is published as an individual document on the -federated web. Enabling this allows: - -- **Verified ownership** — Cryptographically prove you own your content across - the federated web -- **Cross-platform discovery** — Your podcast appears on ATProto readers like - [Leaflet](https://leaflet.pub/) and [Pckt](https://pckt.blog) -- **Federated engagement** — Comments and interactions from Bluesky and other - ATProto apps can connect back to your site -- **Episode-level publishing** — Each episode is a standalone document in ATProto - -This feature is entirely optional. The site works perfectly without it — the -verification endpoint simply returns a 404 when unconfigured. No changes to -`astro.config.mjs` are needed. - -##### Initial Setup - -1. Create an [app password](https://bsky.app/settings/app-passwords) on Bluesky -2. Create your publication record (run once): +# whiskey.fm + +The website for [Whiskey Web and Whatnot](https://whiskey.fm), built with +[Starpod](https://github.com/shipshapecode/starpod) — an Astro integration +that turns an RSS feed into a full podcast website. + +The `starpod` package generates the core site: episode pages, a persistent +audio player, search, transcripts with clickable timestamps, and +agent-friendly endpoints (llms.txt, markdown twins of every page, an +OpenAPI-documented JSON API). This repo adds everything whiskey.fm-specific +on top: + +- `starpod.config.ts` — show metadata (hosts, platforms, RSS feed) +- `src/pages/sponsor.astro` — sponsorship pitch with Polar checkout + (`src/pages/api/checkout.ts`, local `AdPackageCard` variant) +- `src/pages/collections/` — curated episode collections +- `src/content/transcripts/` — markdown episode transcripts +- `src/img/` — host, guest, and sponsor images the package resolves by + filename +- `db/` — guest and sponsor seed data for Turso (the schema ships with the + package) +- `scripts/` — ATProto / standard.site episode publishing +- Component overrides and extra styles wired through the integration's + `components` and `customCss` options in `astro.config.mjs` + +## Development ```bash -ATPROTO_HANDLE=you.bsky.social \ -ATPROTO_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx \ -STANDARD_SITE_URL=https://your-podcast.com \ -pnpm tsx scripts/create-publication.ts +pnpm install +pnpm dev # localhost:4321 +pnpm test # unit (Vitest) + e2e (Playwright) +pnpm build # astro check + production build ``` -3. Save the output values as environment variables - -##### Environment Variables - -Set these in your `.env` file for local development and as **GitHub Actions -secrets** for automated publishing: - -| Variable | Description | Where to find it | -|----------|-------------|------------------| -| `STANDARD_SITE_DID` | Your ATProto DID (decentralized identifier) | [bsky.app/settings](https://bsky.app/settings) → scroll to "DID" | -| `STANDARD_SITE_PUBLICATION_RKEY` | Record key for your publication | Returned by `scripts/create-publication.ts` | -| `ATPROTO_HANDLE` | Your Bluesky handle (e.g., `you.bsky.social`) | Your Bluesky username | -| `ATPROTO_APP_PASSWORD` | App password for ATProto API access | [bsky.app/settings/app-passwords](https://bsky.app/settings/app-passwords) | -| `STANDARD_SITE_URL` | Your podcast website URL (e.g., `https://whiskey.fm`) | Your deployed site URL | - -##### GitHub Actions Secrets - -Add the following secrets to your repository at **Settings → Secrets and -variables → Actions → New repository secret**: - -- `ATPROTO_HANDLE` -- `ATPROTO_APP_PASSWORD` -- `STANDARD_SITE_URL` -- `STANDARD_SITE_PUBLICATION_RKEY` -- `STANDARD_SITE_DID` - -##### Publishing Episodes - -Episodes are published to ATProto as individual documents automatically: - -- **Automatic** — The `Publish Episodes to ATProto` workflow polls the RSS - feed every 30 minutes; when it finds new episodes it triggers a site rebuild - (via the `REBUILD_WEBHOOK` secret), waits for the new episode pages to be - live, and then publishes the episodes -- **Manual** — Trigger the workflow manually from the Actions tab -- **Backfill** — Use the `Backfill Episodes to ATProto` workflow (Actions tab → - Run workflow → type "backfill") to publish all existing episodes - -You can also publish locally: +Updating the site engine is a normal dependency bump: ```bash -# Publish only new episodes -pnpm publish:atproto - -# Backfill all episodes -pnpm publish:atproto:backfill +pnpm update starpod ``` -##### Verification - -After deploying, verify the well-known endpoint with: - -```bash -curl https://your-site.com/.well-known/site.standard.publication -``` - -For full setup instructions (creating a publication, syncing posts, etc.), see -the [`@bryanguffey/astro-standard-site` README](https://github.com/musicjunkieg/astro-standard-site#readme). - -#### Configuring guests - -We use Turso and Astro DB to setup guests per episode. If you would also like to -do this, you will need a Turso account. - -### LLM Discovery Features - -Starpod includes built-in support for LLM (Large Language Model) discovery -through the [llms.txt specification](https://llmstxt.org/). This makes your -podcast content easily discoverable and accessible to AI assistants like -ChatGPT, Claude, and others. - -#### What's Included - -- `/llms.txt` - Structured file following the llms.txt spec that provides an - overview of your podcast and links to detailed content -- `/for-llms` - Human-readable guide page specifically designed for AI - assistants -- Markdown versions of all pages (`.html.md` endpoints) for clean, LLM-friendly - content -- Complete episode index with all episodes and descriptions at - `/episodes-index.html.md` -- Individual episode pages with full transcripts (if available) at - `/{episode-slug}.html.md` - -#### How LLMs Can Use Your Podcast - -With these features automatically generated from your RSS feed and config, LLMs -can: - -- **Discover and recommend** specific episodes based on topics or themes -- **Answer detailed questions** about episode content using full transcripts -- **Summarize episodes** or extract key points and insights -- **Find episodes** with specific guests or covering certain subjects -- **Provide information** about your hosts, show format, and where to listen - -#### Transcript Support - -If you provide episode transcripts in -`src/content/transcripts/[episode-number].md`, they will automatically be -included in the LLM-accessible content. Transcripts are cleaned (timestamps -removed) and formatted for optimal LLM consumption. - -All transcript content is available at `/{episode-slug}.html.md` or -`/{episode-number}.html.md`. - -**Note:** Transcripts are optional. The LLM discovery features work perfectly -fine without them, using episode descriptions and metadata from your RSS feed. - -#### Generated Endpoints - -All of the following endpoints are automatically generated at build time from -your `starpod.config.ts` and RSS feed: - -- `/llms.txt` - Main discovery file, including "when to use this site" guidance - for agents and a developer resources section -- `/for-llms` - Human-readable guide page -- `/for-llms.html.md` - Markdown version of guide -- `/index.html.md` - Markdown version of the homepage -- `/about.html.md` - Markdown version of about page -- `/contact.html.md` - Markdown version of the contact page -- `/episodes-index.html.md` - Complete episode listing -- `/{episode-slug}.html.md` - Individual episode with transcript -- `/{episode-number}.html.md` - Alternative episode URL -- `/openapi.json` - OpenAPI 3.1 spec describing the JSON API endpoints - (episode search, episode pagination, contact form) - -No configuration needed - it just works! - -#### Markdown Content Negotiation - -Agents can also request any page that has a markdown twin with an -`Accept: text/markdown` header and get the markdown version back from the same -URL, per [acceptmarkdown.com](https://acceptmarkdown.com). Both variants are -served with `Vary: Accept` so CDNs cache them separately. - -This is implemented by `scripts/vercel-md-negotiation.mjs`, which runs as part -of `pnpm build` and injects Accept-based rewrite routes into the Vercel build -output. If you customize the `build` script in `package.json`, keep the -`node scripts/vercel-md-negotiation.mjs` step after `astro build`. (Deploying -somewhere other than Vercel? The `.html.md` URLs still work everywhere; only -the Accept-header negotiation is Vercel-specific.) - -#### Agent-Friendly Errors - -- Nonexistent paths return a real HTTP 404: browsers get the styled 404 page, - `Accept: text/markdown` clients get a short markdown body pointing at the - sitemap, `llms.txt`, and the episodes index, and `/api/*` paths get a - structured JSON error -- API errors are structured JSON with a stable `error.code`, a message, and a - resolution `hint` - never an HTML error page - -## Polar.sh Checkout Integration - -This site uses Polar.sh for sponsor checkout. To set it up: - -1. **Get your Polar credentials:** - - Log in to your [Polar dashboard](https://polar.sh) - - Go to Settings → API to get your access token - - Create two products for your sponsorship packages (30-second and 60-second - ads) - - Note the product IDs from each product's page - -2. **Configure environment variables:** Create a `.env` file in the root - directory with: - - ```env - POLAR_ACCESS_TOKEN=your_polar_access_token_here - POLAR_30SEC_PRODUCT_ID=your_30sec_product_id_here - POLAR_60SEC_PRODUCT_ID=your_60sec_product_id_here - POLAR_BOTTLEDROP_PRODUCT_ID=your_bottledrop_product_id_here - POLAR_CRATE_PRODUCT_ID=your_crate_product_id_here - POLAR_FULLBARREL_PRODUCT_ID=your_fullbarrel_product_id_here - POLAR_LABEL_PRODUCT_ID=your_label_product_id_here - POLAR_SUCCESS_URL=https://whiskey.fm/sponsor/success - ``` - -3. **Test the integration:** - - For testing, you can set `PUBLIC_POLAR_SERVER=sandbox` in your `.env` - - Visit `/sponsor` and click on either sponsorship option - - You'll be redirected to Polar's checkout page - - After successful payment, users return to `/sponsor/success` - -4. **Go live:** - - Remove `PUBLIC_POLAR_SERVER` or set it to `production` - - Ensure your product IDs are for production products - - Test with a real payment to confirm everything works +See the [starpod README](https://github.com/shipshapecode/starpod/tree/main/packages/starpod) +for the configuration reference, integration options, and custom-page docs. +Environment variables are listed in [.env.example](./.env.example) and +documented in [CLAUDE.md](./CLAUDE.md). -The integration uses the `@polar-sh/astro` package which provides: +## Deployment -- Server-side checkout session creation at `/api/checkout` -- Automatic tax compliance through Polar's Merchant of Record service -- Support for multiple products and dynamic pricing +Deployed to Vercel. Episode pages with markdown twins are also served via +`Accept: text/markdown` content negotiation — the integration patches the +Vercel build output automatically. diff --git a/astro.config.mjs b/astro.config.mjs index cc4fb4f..ea9ce35 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -1,10 +1,8 @@ -import { defineConfig, fontProviders } from 'astro/config'; -import preact from '@astrojs/preact'; -import sitemap from '@astrojs/sitemap'; -import tailwindcss from '@tailwindcss/vite'; import vercel from '@astrojs/vercel'; +import { defineConfig } from 'astro/config'; +import starpod from 'starpod'; -import rehypeTranscriptTimestamps from './src/lib/rehype-transcript-timestamps.mjs'; +import starpodConfig from './starpod.config'; // https://astro.build/config export default defineConfig({ @@ -28,58 +26,14 @@ export default defineConfig({ enabled: true } }), - build: { - inlineStylesheets: 'always' - }, - markdown: { - // Makes bracketed timestamps in markdown transcripts clickable for seeking. - rehypePlugins: [rehypeTranscriptTimestamps] - }, - experimental: { - clientPrerender: true - }, - fonts: [ - { - provider: fontProviders.google(), - name: 'Inter', - cssVariable: '--astro-font-inter', - formats: ['woff2'], - styles: ['normal'], - subsets: ['latin'], - weights: ['300 900'], - options: { - experimental: { - variableAxis: { - opsz: ['14..32'] - } - } - } - } - ], - image: { - remotePatterns: [ - { - protocol: 'https' - }, - { - protocol: 'http' - } - ] - }, - prefetch: { - prefetchAll: true, - defaultStrategy: 'viewport' - }, site: 'https://whiskey.fm', - trailingSlash: 'never', integrations: [ - preact(), - sitemap({ - filter: (page) => { - const pathname = new URL(page).pathname; - // Exclude episode number pages and only include slug pages. - return !/^\/\d+\/?$/.test(pathname); - } + starpod(starpodConfig, { + database: true, + components: { + InfoCard: './src/components/InfoCard.astro' + }, + customCss: ['./src/styles/custom.css'] }) ], // These were specific redirects we needed for our podcast, if you do not have any routes to redirect, you can safely remove this. @@ -90,8 +44,5 @@ export default defineConfig({ 'creating-codepen-tackling-tailwind-and-keeping-it-simple-with-chris-coyier', '/coding-languages-ai-and-the-evolution-of-game-development-with-phillip-winston': '/coding-languages-ai-and-the-evolution-of-game-development-with-philip-winston' - }, - vite: { - plugins: [tailwindcss()] } }); diff --git a/db/index.ts b/db/index.ts deleted file mode 100644 index 3cfdf34..0000000 --- a/db/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { drizzle } from 'drizzle-orm/libsql'; - -import * as schema from './schema'; - -// Uses ASTRO_DB_REMOTE_URL and ASTRO_DB_APP_TOKEN from environment. -// In Astro files, these are available via import.meta.env. -// In standalone scripts (seed), they are loaded via process.env. -export function createDb(url: string, authToken: string) { - return drizzle({ - connection: { url, authToken }, - schema - }); -} - -export type Database = ReturnType; diff --git a/db/schema.ts b/db/schema.ts deleted file mode 100644 index 057bb6d..0000000 --- a/db/schema.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { - integer, - sqliteTable, - text, - uniqueIndex -} from 'drizzle-orm/sqlite-core'; - -export const Episode = sqliteTable('Episode', { - episodeSlug: text().primaryKey() -}); - -export const Person = sqliteTable('Person', { - id: text().primaryKey(), - img: text(), - name: text().notNull() -}); - -export const HostOrGuest = sqliteTable( - 'HostOrGuest', - { - _id: integer('_id').primaryKey(), - episodeSlug: text() - .notNull() - .references(() => Episode.episodeSlug), - isHost: integer({ mode: 'boolean' }).notNull(), - personId: text() - .notNull() - .references(() => Person.id) - }, - (table) => [ - uniqueIndex('HostOrGuest_episodeSlug_personId_idx').on( - table.episodeSlug, - table.personId - ) - ] -); - -export const Sponsor = sqliteTable('Sponsor', { - id: text().primaryKey(), - img: text(), - name: text().notNull(), - url: text().notNull() -}); - -export const SponsorForEpisode = sqliteTable( - 'SponsorForEpisode', - { - _id: integer('_id').primaryKey(), - episodeSlug: text() - .notNull() - .references(() => Episode.episodeSlug), - sponsorId: text() - .notNull() - .references(() => Sponsor.id) - }, - (table) => [ - uniqueIndex('SponsorForEpisode_episodeSlug_sponsorId_idx').on( - table.episodeSlug, - table.sponsorId - ) - ] -); diff --git a/db/seed.ts b/db/seed.ts index 68c4f53..d0d8016 100644 --- a/db/seed.ts +++ b/db/seed.ts @@ -2,16 +2,17 @@ import 'dotenv/config'; import { sql } from 'drizzle-orm'; -import { createDb } from './index'; +import { createDb } from 'starpod/db'; import { Episode, HostOrGuest, Person, Sponsor, SponsorForEpisode -} from './schema'; +} from 'starpod/db/schema'; +import { getAllEpisodes } from 'starpod/src/lib/rss'; -import { getAllEpisodes } from '../src/lib/rss'; +import starpodConfig from '../starpod.config'; import people from './data/people'; import peoplePerEpisode from './data/people-per-episode'; import sponsors from './data/sponsors'; @@ -43,7 +44,7 @@ async function seed() { } }); - const allEpisodes = await getAllEpisodes(); + const allEpisodes = await getAllEpisodes(starpodConfig); const episodes = allEpisodes.map((episode) => { return { episodeSlug: episode.episodeSlug diff --git a/drizzle.config.ts b/drizzle.config.ts index 4361179..ab73ae3 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'drizzle-kit'; export default defineConfig({ - schema: './db/schema.ts', + schema: './node_modules/starpod/src/db/schema.ts', out: './drizzle', dialect: 'turso', dbCredentials: { diff --git a/package.json b/package.json index e67d58d..40505f5 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,16 @@ { - "name": "starpod", - "type": "module", + "name": "www-starpod", "version": "0.0.1", + "private": true, "repository": { "type": "git", - "url": "git+https://github.com/shipshapecode/starpod.git" + "url": "git+https://github.com/shipshapecode/www-starpod.git" }, "license": "MIT", + "type": "module", "scripts": { "astro": "astro", - "build": "astro check && astro build && node scripts/vercel-md-negotiation.mjs", + "build": "astro check && astro build", "db:push": "drizzle-kit push", "db:seed": "tsx db/seed.ts", "db:studio": "drizzle-kit studio", @@ -17,9 +18,9 @@ "lint": "eslint . --cache", "lint:fix": "eslint . --fix", "preview": "astro preview", - "start": "astro dev", "publish:atproto": "tsx scripts/publish-atproto-episodes.ts", "publish:atproto:backfill": "tsx scripts/publish-atproto-episodes.ts --backfill", + "start": "astro dev", "test": "concurrently \"pnpm:test:*(!fix)\" --names \"test:\"", "test:e2e": "pnpm exec playwright test", "test:unit": "vitest" @@ -28,28 +29,19 @@ "@astrojs/preact": "^5.1.4", "@astrojs/vercel": "^10.0.8", "@bryanguffey/astro-standard-site": "^1.0.3", - "@libsql/client": "^0.17.3", "@polar-sh/astro": "^0.5.0", - "@preact/signals": "^2.9.1", - "@vercel/analytics": "^1.6.1", - "@vercel/speed-insights": "^1.3.1", "astro": "6.4.2", "astro-seo-schema": "^5.2.0", - "atropos": "^2.0.2", "drizzle-orm": "^0.45.2", - "preact": "^10.29.2", + "html-to-text": "^9.0.5", "rss-to-json": "^2.1.1", - "schema-dts": "^1.1.5", + "starpod": "^1.1.0", "valibot": "^1.4.1" }, "devDependencies": { "@astrojs/check": "^0.9.9", - "@astrojs/sitemap": "^3.7.3", "@eslint/js": "^9.39.4", "@playwright/test": "^1.60.0", - "@tailwindcss/forms": "^0.5.11", - "@tailwindcss/typography": "^0.5.19", - "@tailwindcss/vite": "^4.3.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/preact": "^3.2.4", "@types/html-to-text": "^9.0.4", @@ -61,8 +53,8 @@ "eslint": "^9.39.4", "eslint-plugin-astro": "^1.7.0", "globals": "^16.5.0", - "html-to-text": "^9.0.5", "jsdom": "^27.4.0", + "preact": "^10.29.2", "prettier": "^3.8.3", "prettier-plugin-astro": "^0.14.1", "prettier-plugin-tailwindcss": "^0.7.4", @@ -73,19 +65,19 @@ "vite": "^7.3.3", "vitest": "^4.1.7" }, + "packageManager": "pnpm@10.27.0", "engines": { "node": "^22.0.0" }, - "packageManager": "pnpm@10.27.0", "pnpm": { - "overrides": { - "fast-xml-parser": "4.5.4" - }, "onlyBuiltDependencies": [ "@tailwindcss/oxide", "@vercel/speed-insights", "esbuild", "sharp" - ] + ], + "overrides": { + "fast-xml-parser": "4.5.4" + } } } diff --git a/playwright.worktree.config.ts b/playwright.worktree.config.ts new file mode 100644 index 0000000..d01db43 --- /dev/null +++ b/playwright.worktree.config.ts @@ -0,0 +1,23 @@ +// Worktree-only override: the user's own dev server occupies :4321 serving +// the main branch, so e2e here must boot its own server on another port. +// Not meant to be committed. +import { defineConfig } from '@playwright/test'; + +import baseConfig from './playwright.config'; + +const PORT = 4399; + +export default defineConfig({ + ...baseConfig, + reporter: 'list', + use: { + ...baseConfig.use, + baseURL: `http://localhost:${PORT}` + }, + webServer: { + command: `pnpm dev --port ${PORT}`, + url: `http://localhost:${PORT}`, + reuseExistingServer: false, + timeout: 180 * 1000 + } +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6fc97c..865b15f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,42 +20,27 @@ importers: '@bryanguffey/astro-standard-site': specifier: ^1.0.3 version: 1.0.3(astro@6.4.2(@types/node@24.12.4)(@vercel/functions@3.6.1)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.60.4)(tsx@4.22.4)(yaml@2.9.0)) - '@libsql/client': - specifier: ^0.17.3 - version: 0.17.3 '@polar-sh/astro': specifier: ^0.5.0 version: 0.5.0(astro@6.4.2(@types/node@24.12.4)(@vercel/functions@3.6.1)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.60.4)(tsx@4.22.4)(yaml@2.9.0)) - '@preact/signals': - specifier: ^2.9.1 - version: 2.9.1(preact@10.29.2) - '@vercel/analytics': - specifier: ^1.6.1 - version: 1.6.1(react@19.0.0) - '@vercel/speed-insights': - specifier: ^1.3.1 - version: 1.3.1(react@19.0.0) astro: specifier: 6.4.2 version: 6.4.2(@types/node@24.12.4)(@vercel/functions@3.6.1)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.60.4)(tsx@4.22.4)(yaml@2.9.0) astro-seo-schema: specifier: ^5.2.0 version: 5.2.0(astro@6.4.2(@types/node@24.12.4)(@vercel/functions@3.6.1)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.60.4)(tsx@4.22.4)(yaml@2.9.0))(schema-dts@1.1.5) - atropos: - specifier: ^2.0.2 - version: 2.0.2 drizzle-orm: specifier: ^0.45.2 version: 0.45.2(@libsql/client@0.17.3) - preact: - specifier: ^10.29.2 - version: 10.29.2 + html-to-text: + specifier: ^9.0.5 + version: 9.0.5 rss-to-json: specifier: ^2.1.1 version: 2.1.1 - schema-dts: - specifier: ^1.1.5 - version: 1.1.5 + starpod: + specifier: ^1.1.0 + version: 1.1.0(@astrojs/preact@5.1.4(@babel/core@7.29.7)(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0)(preact@10.29.2)(rollup@4.60.4)(tsx@4.22.4)(yaml@2.9.0))(astro@6.4.2(@types/node@24.12.4)(@vercel/functions@3.6.1)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.60.4)(tsx@4.22.4)(yaml@2.9.0))(preact@10.29.2)(react@19.0.0)(typescript@5.9.3)(vite@7.3.3(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) valibot: specifier: ^1.4.1 version: 1.4.1(typescript@5.9.3) @@ -63,24 +48,12 @@ importers: '@astrojs/check': specifier: ^0.9.9 version: 0.9.9(prettier-plugin-astro@0.14.1)(prettier@3.8.3)(typescript@5.9.3) - '@astrojs/sitemap': - specifier: ^3.7.3 - version: 3.7.3 '@eslint/js': specifier: ^9.39.4 version: 9.39.4 '@playwright/test': specifier: ^1.60.0 version: 1.60.0 - '@tailwindcss/forms': - specifier: ^0.5.11 - version: 0.5.11(tailwindcss@4.3.0) - '@tailwindcss/typography': - specifier: ^0.5.19 - version: 0.5.19(tailwindcss@4.3.0) - '@tailwindcss/vite': - specifier: ^4.3.0 - version: 4.3.0(vite@7.3.3(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) '@testing-library/jest-dom': specifier: ^6.9.1 version: 6.9.1 @@ -114,12 +87,12 @@ importers: globals: specifier: ^16.5.0 version: 16.5.0 - html-to-text: - specifier: ^9.0.5 - version: 9.0.5 jsdom: specifier: ^27.4.0 version: 27.4.0 + preact: + specifier: ^10.29.2 + version: 10.29.2 prettier: specifier: ^3.8.3 version: 3.8.3 @@ -3852,6 +3825,15 @@ packages: standardwebhooks@1.0.0: resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + starpod@1.1.0: + resolution: {integrity: sha512-o1khSIO8RpqPnJcAed2rNpW46hHhJ7OlwHIPtHvSe3pzbdP1/JydOu/X8Y+C5MbySGs9Pf0mmiMxdVm5pJoL8w==} + engines: {node: ^22.0.0} + hasBin: true + peerDependencies: + '@astrojs/preact': ^5.0.0 + astro: ^6.0.0 + preact: ^10.0.0 + std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} @@ -8334,6 +8316,69 @@ snapshots: '@stablelib/base64': 1.0.1 fast-sha256: 1.3.0 + starpod@1.1.0(@astrojs/preact@5.1.4(@babel/core@7.29.7)(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0)(preact@10.29.2)(rollup@4.60.4)(tsx@4.22.4)(yaml@2.9.0))(astro@6.4.2(@types/node@24.12.4)(@vercel/functions@3.6.1)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.60.4)(tsx@4.22.4)(yaml@2.9.0))(preact@10.29.2)(react@19.0.0)(typescript@5.9.3)(vite@7.3.3(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)): + dependencies: + '@astrojs/preact': 5.1.4(@babel/core@7.29.7)(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0)(preact@10.29.2)(rollup@4.60.4)(tsx@4.22.4)(yaml@2.9.0) + '@astrojs/sitemap': 3.7.3 + '@bryanguffey/astro-standard-site': 1.0.3(astro@6.4.2(@types/node@24.12.4)(@vercel/functions@3.6.1)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.60.4)(tsx@4.22.4)(yaml@2.9.0)) + '@libsql/client': 0.17.3 + '@preact/signals': 2.9.1(preact@10.29.2) + '@tailwindcss/forms': 0.5.11(tailwindcss@4.3.0) + '@tailwindcss/typography': 0.5.19(tailwindcss@4.3.0) + '@tailwindcss/vite': 4.3.0(vite@7.3.3(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) + '@vercel/speed-insights': 1.3.1(react@19.0.0) + astro: 6.4.2(@types/node@24.12.4)(@vercel/functions@3.6.1)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.60.4)(tsx@4.22.4)(yaml@2.9.0) + astro-seo-schema: 5.2.0(astro@6.4.2(@types/node@24.12.4)(@vercel/functions@3.6.1)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.60.4)(tsx@4.22.4)(yaml@2.9.0))(schema-dts@1.1.5) + atropos: 2.0.2 + drizzle-orm: 0.45.2(@libsql/client@0.17.3) + html-to-text: 9.0.5 + preact: 10.29.2 + rss-to-json: 2.1.1 + schema-dts: 1.1.5 + tailwindcss: 4.3.0 + valibot: 1.4.1(typescript@5.9.3) + transitivePeerDependencies: + - '@aws-sdk/client-rds-data' + - '@cloudflare/workers-types' + - '@electric-sql/pglite' + - '@libsql/client-wasm' + - '@neondatabase/serverless' + - '@op-engineering/op-sqlite' + - '@opentelemetry/api' + - '@planetscale/database' + - '@prisma/client' + - '@sveltejs/kit' + - '@tidbcloud/serverless' + - '@types/better-sqlite3' + - '@types/pg' + - '@types/sql.js' + - '@upstash/redis' + - '@vercel/postgres' + - '@xata.io/client' + - better-sqlite3 + - bufferutil + - bun-types + - debug + - expo-sqlite + - gel + - knex + - kysely + - mysql2 + - next + - pg + - postgres + - prisma + - react + - sql.js + - sqlite3 + - supports-color + - svelte + - typescript + - utf-8-validate + - vite + - vue + - vue-router + std-env@4.1.0: {} stop-iteration-iterator@1.1.0: diff --git a/public/images/apple.svg b/public/images/apple.svg deleted file mode 100644 index 588fabe..0000000 --- a/public/images/apple.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/public/images/dots-dark.svg b/public/images/dots-dark.svg deleted file mode 100644 index 4277e9d..0000000 --- a/public/images/dots-dark.svg +++ /dev/null @@ -1,1117 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/public/images/dots-light.svg b/public/images/dots-light.svg deleted file mode 100644 index 6330939..0000000 --- a/public/images/dots-light.svg +++ /dev/null @@ -1,1116 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/public/images/forward-icon.svg b/public/images/forward-icon.svg deleted file mode 100644 index f8f4b23..0000000 --- a/public/images/forward-icon.svg +++ /dev/null @@ -1,25 +0,0 @@ - - - - - \ No newline at end of file diff --git a/public/images/mute-icon.svg b/public/images/mute-icon.svg deleted file mode 100644 index 00fd482..0000000 --- a/public/images/mute-icon.svg +++ /dev/null @@ -1,26 +0,0 @@ - - - - - \ No newline at end of file diff --git a/public/images/overcast.svg b/public/images/overcast.svg deleted file mode 100644 index e0d65c1..0000000 --- a/public/images/overcast.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/public/images/pocket-casts.svg b/public/images/pocket-casts.svg deleted file mode 100644 index 53a8ba7..0000000 --- a/public/images/pocket-casts.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/public/images/rewind-icon.svg b/public/images/rewind-icon.svg deleted file mode 100644 index db54593..0000000 --- a/public/images/rewind-icon.svg +++ /dev/null @@ -1,25 +0,0 @@ - - - - - \ No newline at end of file diff --git a/public/images/rocket-dark.svg b/public/images/rocket-dark.svg deleted file mode 100644 index 428c1c6..0000000 --- a/public/images/rocket-dark.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/public/images/rocket-light.svg b/public/images/rocket-light.svg deleted file mode 100644 index 67a607d..0000000 --- a/public/images/rocket-light.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/public/images/search-icon.svg b/public/images/search-icon.svg deleted file mode 100644 index 92c7224..0000000 --- a/public/images/search-icon.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - diff --git a/public/images/spotify.svg b/public/images/spotify.svg deleted file mode 100644 index cc2712e..0000000 --- a/public/images/spotify.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/public/images/unmute-icon.svg b/public/images/unmute-icon.svg deleted file mode 100644 index e10fed4..0000000 --- a/public/images/unmute-icon.svg +++ /dev/null @@ -1,24 +0,0 @@ - - - - - \ No newline at end of file diff --git a/public/images/youtube.svg b/public/images/youtube.svg deleted file mode 100644 index f45979e..0000000 --- a/public/images/youtube.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/scripts/analyze-transcripts.ts b/scripts/analyze-transcripts.ts index 04d496d..74560ce 100644 --- a/scripts/analyze-transcripts.ts +++ b/scripts/analyze-transcripts.ts @@ -1,6 +1,7 @@ import { readFileSync, readdirSync } from 'fs'; import { join } from 'path'; -import { getAllEpisodes } from '../src/lib/rss'; +import { getAllEpisodes } from 'starpod/rss'; +import starpodConfig from '../starpod.config'; import { LLM_KEYWORDS, scoreLLMRelevance, scoreTopicRelevance, topicKeywords } from '../src/lib/topic-keywords'; // Common stopwords to filter out @@ -192,7 +193,7 @@ async function main() { console.log('Analyzing transcripts...\n'); // Get all episodes - const allEpisodes = await getAllEpisodes(); + const allEpisodes = await getAllEpisodes(starpodConfig); console.log(`Found ${allEpisodes.length} episodes\n`); // Create a map of episode number to episode data diff --git a/scripts/publish-atproto-episodes.ts b/scripts/publish-atproto-episodes.ts index ca0f8e0..b477484 100644 --- a/scripts/publish-atproto-episodes.ts +++ b/scripts/publish-atproto-episodes.ts @@ -30,7 +30,7 @@ import { } from '@bryanguffey/astro-standard-site'; import starpodConfig from '../starpod.config'; -import { dasherize } from '../src/utils/dasherize'; +import { dasherize } from 'starpod/src/utils/dasherize'; const BACKFILL = process.argv.includes('--backfill'); // Set by the GitHub workflow when it has just triggered a site rebuild: diff --git a/scripts/vercel-md-negotiation.mjs b/scripts/vercel-md-negotiation.mjs deleted file mode 100644 index ca18f9b..0000000 --- a/scripts/vercel-md-negotiation.mjs +++ /dev/null @@ -1,205 +0,0 @@ -/** - * Patches `.vercel/output/config.json` after `astro build` so that pages with - * a prerendered markdown twin (`{path}.html.md`) serve that twin when a client - * asks for it with `Accept: text/markdown`, per https://acceptmarkdown.com. - * - * Both variants of a negotiated URL are stamped with `Vary: Accept` so CDNs - * never serve a cached HTML response to an agent asking for markdown (or vice - * versa). - * - * This has to happen post-build because Vercel checks the filesystem before - * applying `vercel.json` rewrites, so an Accept-based rewrite there would - * never run for prerendered pages. Routes injected before the `filesystem` - * handler in the Build Output API config do run first. - * - * Runs as part of `pnpm build`. No-ops when there is no Vercel build output. - */ - -import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { join, relative, sep } from 'node:path'; -import process from 'node:process'; -import { pathToFileURL } from 'node:url'; - -const MD_SUFFIX = '.html.md'; - -// Vercel route `has` condition matching any Accept header that mentions -// text/markdown. -const ACCEPT_MARKDOWN = [ - { type: 'header', key: 'accept', value: '.*text/markdown.*' } -]; - -// How many slugs to pack into a single route regex alternation. -const CHUNK_SIZE = 50; - -/** - * Find every prerendered markdown twin in the static output directory and - * return the negotiated URL paths they belong to ('/index' for the homepage). - */ -export function collectMarkdownPaths(staticDir) { - const paths = []; - - const walk = (dir) => { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const full = join(dir, entry.name); - if (entry.isDirectory()) { - walk(full); - } else if (entry.name.endsWith(MD_SUFFIX)) { - const rel = relative(staticDir, full).split(sep).join('/'); - paths.push('/' + rel.slice(0, -MD_SUFFIX.length)); - } - } - }; - - walk(staticDir); - return paths.sort(); -} - -const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - -const chunk = (items, size) => { - const chunks = []; - for (let i = 0; i < items.length; i += size) { - chunks.push(items.slice(i, i + size)); - } - return chunks; -}; - -/** - * Build the routes that implement the negotiation for the given markdown - * paths. Order matters: Vary stamps first (they `continue`), then the - * Accept-conditional rewrites to the markdown twins. - */ -export function buildNegotiationRoutes(mdPaths) { - const routes = []; - const hasHome = mdPaths.includes('/index'); - const slugChunks = chunk( - mdPaths.filter((p) => p !== '/index').map((p) => escapeRegex(p.slice(1))), - CHUNK_SIZE - ); - - // Stamp Vary: Accept on every negotiated URL, whichever variant ends up - // being served. - if (hasHome) { - routes.push({ src: '^/$', headers: { vary: 'Accept' }, continue: true }); - } - for (const slugs of slugChunks) { - routes.push({ - src: `^/(?:${slugs.join('|')})$`, - headers: { vary: 'Accept' }, - continue: true - }); - } - - // Rewrite to the markdown twin when the client asks for markdown. - if (hasHome) { - routes.push({ - src: '^/$', - has: ACCEPT_MARKDOWN, - dest: '/index.html.md' - }); - } - for (const slugs of slugChunks) { - routes.push({ - src: `^/(${slugs.join('|')})$`, - has: ACCEPT_MARKDOWN, - dest: '/$1.html.md' - }); - } - - return routes; -} - -/** - * Return a copy of the Vercel Build Output config with the negotiation routes - * inserted ahead of the `filesystem` handler. Idempotent: an already patched - * config is returned unchanged. - */ -// Canonical JSON encoding (sorted object keys) so routes can be compared for -// exact equality regardless of key order. -const canonical = (value) => { - if (Array.isArray(value)) { - return value.map(canonical); - } - if (value && typeof value === 'object') { - return Object.fromEntries( - Object.keys(value) - .sort() - .map((key) => [key, canonical(value[key])]) - ); - } - return value; -}; - -const routeKey = (route) => JSON.stringify(canonical(route)); - -export function patchConfig(config, mdPaths) { - if (!Array.isArray(config.routes)) { - throw new Error('config.json has no routes array'); - } - - const negotiationRoutes = buildNegotiationRoutes(mdPaths); - if (negotiationRoutes.length === 0) { - return { config, inserted: 0 }; - } - - // Idempotency: only skip when every generated route is already present - // exactly. A user-added conditional markdown route must not suppress the - // generated set. - const existingRoutes = new Set(config.routes.map(routeKey)); - const alreadyPatched = negotiationRoutes.every((route) => - existingRoutes.has(routeKey(route)) - ); - if (alreadyPatched) { - return { config, inserted: 0 }; - } - - const filesystemIndex = config.routes.findIndex( - (route) => route.handle === 'filesystem' - ); - if (filesystemIndex === -1) { - throw new Error( - 'config.json has no `handle: "filesystem"` route; the Vercel build output format may have changed' - ); - } - - const routes = [ - ...config.routes.slice(0, filesystemIndex), - ...negotiationRoutes, - ...config.routes.slice(filesystemIndex) - ]; - - return { config: { ...config, routes }, inserted: negotiationRoutes.length }; -} - -export function main(outputDir = '.vercel/output') { - const configPath = join(outputDir, 'config.json'); - const staticDir = join(outputDir, 'static'); - - if (!existsSync(configPath) || !existsSync(staticDir)) { - console.log( - `[md-negotiation] No Vercel build output at ${outputDir}, skipping` - ); - return; - } - - const config = JSON.parse(readFileSync(configPath, 'utf-8')); - const mdPaths = collectMarkdownPaths(staticDir); - const { config: patched, inserted } = patchConfig(config, mdPaths); - - if (inserted === 0) { - console.log('[md-negotiation] Nothing to patch'); - return; - } - - writeFileSync(configPath, JSON.stringify(patched, null, 2)); - console.log( - `[md-negotiation] Added ${inserted} routes negotiating markdown for ${mdPaths.length} pages` - ); -} - -if ( - process.argv[1] && - import.meta.url === pathToFileURL(process.argv[1]).href -) { - main(process.argv[2]); -} diff --git a/src/components/AdPackageCard.astro b/src/components/AdPackageCard.astro index 84b33f5..fde8132 100644 --- a/src/components/AdPackageCard.astro +++ b/src/components/AdPackageCard.astro @@ -54,4 +54,4 @@ const { bullets, heading, price, productId, period = 'per episode' } = Astro.pro - \ No newline at end of file + diff --git a/src/components/Breadcrumbs.astro b/src/components/Breadcrumbs.astro deleted file mode 100644 index 202d795..0000000 --- a/src/components/Breadcrumbs.astro +++ /dev/null @@ -1,78 +0,0 @@ ---- -import { Schema } from 'astro-seo-schema'; - -const { url } = Astro; - -export interface Props { - title: string; -} - -const { title } = Astro.props; - -// Determine breadcrumb structure based on URL path -const pathname = url.pathname; -const isCollectionsIndex = pathname === '/collections' || pathname === '/collections/'; -const isCollectionDetail = pathname.startsWith('/collections/') && !isCollectionsIndex; - -let breadcrumbItems: Array<{ name: string; href: string }> = [{ name: 'Home', href: '/' }]; - -if (isCollectionsIndex) { - breadcrumbItems.push({ name: 'Collections', href: '/collections' }); -} else if (isCollectionDetail) { - breadcrumbItems.push({ name: 'Collections', href: '/collections' }); - breadcrumbItems.push({ name: title, href: pathname }); -} else { - breadcrumbItems.push({ name: title, href: pathname }); -} - -const breadcrumbSchema = { - '@context': 'https://schema.org', - '@type': 'BreadcrumbList', - itemListElement: breadcrumbItems.map((item, index) => ({ - '@type': 'ListItem', - position: index + 1, - name: item.name, - item: new URL(item.href, Astro.site).toString() - })) -}; ---- - - - - diff --git a/src/components/ContactForm.tsx b/src/components/ContactForm.tsx deleted file mode 100644 index c883eef..0000000 --- a/src/components/ContactForm.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { useState } from 'preact/hooks'; - -export default function ContactForm() { - const [formSubmitted, setFormSubmitted] = useState(false); - const [responseMessage, setResponseMessage] = useState(''); - - async function submit(e: SubmitEvent) { - e.preventDefault(); - - const formData = new FormData(e.target as HTMLFormElement); - try { - const response = await fetch('/api/contact', { - method: 'POST', - body: formData - }); - - const data = await response.json(); - - if (data.message) { - setResponseMessage(data.message); - } - if (response.ok) { - setFormSubmitted(true); - } - } catch {} - } - - return ( - <> - {formSubmitted ? ( - `${responseMessage}` - ) : ( -
- - - -