diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ef6c8b0 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Polar.sh Integration +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 + +# Optional: Set to "sandbox" for testing, "production" for live (defaults to production) +PUBLIC_POLAR_SERVER=production diff --git a/.gitignore b/.gitignore index 7fdd9ce..5cca4cb 100644 --- a/.gitignore +++ b/.gitignore @@ -27,4 +27,9 @@ test-results/ # macOS-specific files .DS_Store -package-lock.json + +# AI settings +AGENTS.md + +# generated collections +src/data/collections.generated.ts diff --git a/README.md b/README.md index c3ae676..e06c09a 100644 --- a/README.md +++ b/README.md @@ -169,3 +169,45 @@ your `starpod.config.ts` and RSS feed: - `/{episode-number}.html.md` - Alternative episode URL No configuration needed - it just works! + +## 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 + +The integration uses the `@polar-sh/astro` package which provides: + +- 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 diff --git a/package.json b/package.json index 608428e..21da5f5 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@astrojs/preact": "^5.1.1", "@astrojs/vercel": "^10.0.4", "@libsql/client": "^0.17.2", + "@polar-sh/astro": "^0.5.0", "@preact/signals": "^2.9.0", "@vercel/analytics": "^1.6.1", "@vercel/speed-insights": "^1.3.1", @@ -36,7 +37,8 @@ "preact": "^10.29.1", "rss-to-json": "^2.1.1", "schema-dts": "^1.1.5", - "valibot": "^1.3.1" + "valibot": "^1.3.1", + "zod": "^4.1.12" }, "devDependencies": { "@astrojs/check": "^0.9.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b22d66d..e3b42b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@libsql/client': specifier: ^0.17.2 version: 0.17.2 + '@polar-sh/astro': + specifier: ^0.5.0 + version: 0.5.0(astro@6.0.3(@types/node@24.12.2)(@vercel/functions@3.4.3)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)) '@preact/signals': specifier: ^2.9.0 version: 2.9.0(preact@10.29.1) @@ -53,6 +56,9 @@ importers: valibot: specifier: ^1.3.1 version: 1.3.1(typescript@5.9.3) + zod: + specifier: ^4.1.12 + version: 4.3.6 devDependencies: '@astrojs/check': specifier: ^0.9.8 @@ -1145,6 +1151,18 @@ packages: engines: {node: '>=18'} hasBin: true + '@polar-sh/adapter-utils@0.3.0': + resolution: {integrity: sha512-4B8uVlB6u2iwbF7COtAcaYbtW98+pSHEKwHIf0bUKC6xyClqa/3NivrtFXaoVv/tmFz6LRmWm7yloIlFjIR63g==} + + '@polar-sh/astro@0.5.0': + resolution: {integrity: sha512-Bk9SNuD55rrob8BsAu787r+OtM1m+/XnmpwK+5ulp0eXiB5eiCvSXau61wzLIpvsfybU/8X5h15xSA6rP8S+Kw==} + engines: {node: '>=16'} + peerDependencies: + astro: ^5.0.0 + + '@polar-sh/sdk@0.40.3': + resolution: {integrity: sha512-RDCqxg+scC9oPwwXj9KdL4NM44h7l8iu4PvlzPP7Z6E+0aOuBh/zskruoNUvna0WWvx8IP2I0JOkmHCJ0pgkuQ==} + '@preact/preset-vite@2.10.5': resolution: {integrity: sha512-p0vJpxiVO7KWWazWny3LUZ+saXyZKWv6Ju0bYMWNJRp2YveufRPgSUB1C4MTqGJfz07EehMgfN+AJNwQy+w6Iw==} peerDependencies: @@ -1348,6 +1366,9 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -2355,6 +2376,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fast-string-truncated-width@1.2.1: resolution: {integrity: sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow==} @@ -3616,6 +3640,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standardwebhooks@1.0.0: + resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} @@ -4234,6 +4261,9 @@ packages: zimmerframe@1.1.4: resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} @@ -5116,6 +5146,21 @@ snapshots: dependencies: playwright: 1.59.1 + '@polar-sh/adapter-utils@0.3.0': + dependencies: + '@polar-sh/sdk': 0.40.3 + + '@polar-sh/astro@0.5.0(astro@6.0.3(@types/node@24.12.2)(@vercel/functions@3.4.3)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))': + dependencies: + '@polar-sh/adapter-utils': 0.3.0 + '@polar-sh/sdk': 0.40.3 + astro: 6.0.3(@types/node@24.12.2)(@vercel/functions@3.4.3)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + + '@polar-sh/sdk@0.40.3': + dependencies: + standardwebhooks: 1.0.0 + zod: 3.25.76 + '@preact/preset-vite@2.10.5(@babel/core@7.29.0)(preact@10.29.1)(rollup@4.60.1)(vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@babel/core': 7.29.0 @@ -5295,6 +5340,8 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@stablelib/base64@1.0.1': {} + '@standard-schema/spec@1.1.0': {} '@tailwindcss/forms@0.5.11(tailwindcss@4.2.2)': @@ -6396,6 +6443,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-sha256@1.3.0: {} + fast-string-truncated-width@1.2.1: {} fast-string-width@1.1.0: @@ -7896,6 +7945,11 @@ snapshots: stackback@0.0.2: {} + standardwebhooks@1.0.0: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + std-env@4.1.0: {} stop-iteration-iterator@1.1.0: @@ -8425,6 +8479,8 @@ snapshots: zimmerframe@1.1.4: {} + zod@3.25.76: {} + zod@4.3.6: {} zwitch@2.0.4: {} diff --git a/public/android-chrome-192x192.png b/public/android-chrome-192x192.png index 370f456..72e2a4b 100644 Binary files a/public/android-chrome-192x192.png and b/public/android-chrome-192x192.png differ diff --git a/public/android-chrome-384x384.png b/public/android-chrome-384x384.png deleted file mode 100644 index a0fe272..0000000 Binary files a/public/android-chrome-384x384.png and /dev/null differ diff --git a/public/android-chrome-512x512.png b/public/android-chrome-512x512.png new file mode 100644 index 0000000..15ce056 Binary files /dev/null and b/public/android-chrome-512x512.png differ diff --git a/public/apple-touch-icon-120x120.png b/public/apple-touch-icon-120x120.png deleted file mode 100644 index b18eaf0..0000000 Binary files a/public/apple-touch-icon-120x120.png and /dev/null differ diff --git a/public/apple-touch-icon-152x152.png b/public/apple-touch-icon-152x152.png deleted file mode 100644 index bf650bd..0000000 Binary files a/public/apple-touch-icon-152x152.png and /dev/null differ diff --git a/public/apple-touch-icon-180x180.png b/public/apple-touch-icon-180x180.png deleted file mode 100644 index faa78d2..0000000 Binary files a/public/apple-touch-icon-180x180.png and /dev/null differ diff --git a/public/apple-touch-icon-60x60.png b/public/apple-touch-icon-60x60.png deleted file mode 100644 index b06679e..0000000 Binary files a/public/apple-touch-icon-60x60.png and /dev/null differ diff --git a/public/apple-touch-icon-76x76.png b/public/apple-touch-icon-76x76.png deleted file mode 100644 index f8cc074..0000000 Binary files a/public/apple-touch-icon-76x76.png and /dev/null differ diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png index faa78d2..6873132 100644 Binary files a/public/apple-touch-icon.png and b/public/apple-touch-icon.png differ diff --git a/public/favicon-16x16.png b/public/favicon-16x16.png index 23d11fd..10a2b25 100644 Binary files a/public/favicon-16x16.png and b/public/favicon-16x16.png differ diff --git a/public/favicon-32x32.png b/public/favicon-32x32.png index 95d15e6..0cf94d1 100644 Binary files a/public/favicon-32x32.png and b/public/favicon-32x32.png differ diff --git a/public/favicon.ico b/public/favicon.ico index 03d85ed..ef1388e 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/public/mstile-150x150.png b/public/mstile-150x150.png index d81d0d0..3bdb633 100644 Binary files a/public/mstile-150x150.png and b/public/mstile-150x150.png differ diff --git a/public/safari-pinned-tab.svg b/public/safari-pinned-tab.svg index 5e5f081..f9c73ba 100644 --- a/public/safari-pinned-tab.svg +++ b/public/safari-pinned-tab.svg @@ -2,25 +2,524 @@ Created by potrace 1.14, written by Peter Selinger 2001-2017 - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/site.webmanifest b/public/site.webmanifest index a1553eb..1f1e1fb 100644 --- a/public/site.webmanifest +++ b/public/site.webmanifest @@ -1,6 +1,6 @@ { - "name": "", - "short_name": "", + "name": "WWW", + "short_name": "WWW", "icons": [ { "src": "/android-chrome-192x192.png", @@ -8,8 +8,8 @@ "type": "image/png" }, { - "src": "/android-chrome-384x384.png", - "sizes": "384x384", + "src": "/android-chrome-512x512.png", + "sizes": "512x512", "type": "image/png" } ], diff --git a/scripts/analyze-transcripts.ts b/scripts/analyze-transcripts.ts new file mode 100644 index 0000000..04d496d --- /dev/null +++ b/scripts/analyze-transcripts.ts @@ -0,0 +1,407 @@ +import { readFileSync, readdirSync } from 'fs'; +import { join } from 'path'; +import { getAllEpisodes } from '../src/lib/rss'; +import { LLM_KEYWORDS, scoreLLMRelevance, scoreTopicRelevance, topicKeywords } from '../src/lib/topic-keywords'; + +// Common stopwords to filter out +const STOPWORDS = new Set([ + 'the', + 'a', + 'an', + 'and', + 'or', + 'but', + 'in', + 'on', + 'at', + 'to', + 'for', + 'of', + 'with', + 'by', + 'from', + 'as', + 'is', + 'was', + 'are', + 'were', + 'be', + 'been', + 'being', + 'have', + 'has', + 'had', + 'do', + 'does', + 'did', + 'will', + 'would', + 'should', + 'could', + 'may', + 'might', + 'must', + 'can', + 'this', + 'that', + 'these', + 'those', + 'i', + 'you', + 'he', + 'she', + 'it', + 'we', + 'they', + 'what', + 'which', + 'who', + 'whom', + 'whose', + 'where', + 'when', + 'why', + 'how', + 'all', + 'each', + 'every', + 'both', + 'few', + 'more', + 'most', + 'other', + 'some', + 'such', + 'no', + 'nor', + 'not', + 'only', + 'own', + 'same', + 'so', + 'than', + 'too', + 'very', + 'just', + 'now', + 'then', + 'here', + 'there', + 'up', + 'down', + 'out', + 'off', + 'over', + 'under', + 'again', + 'further', + 'once', + 'about', + 'into', + 'through', + 'during', + 'before', + 'after', + 'above', + 'below', + 'between', + 'among', + 'around', + 'against', + 'within', + 'without' +]); + +// Extract words from text (simple tokenization) +function extractWords(text: string): string[] { + return text + .toLowerCase() + .replace(/[^\w\s]/g, ' ') + .split(/\s+/) + .filter((word) => word.length > 2 && !STOPWORDS.has(word)); +} + +// Count word frequencies +function countWords(words: string[]): Map { + const counts = new Map(); + for (const word of words) { + counts.set(word, (counts.get(word) || 0) + 1); + } + return counts; +} + +// Check if text matches LLM keywords +function matchesLLMKeywords(text: string): boolean { + const lowerText = text.toLowerCase(); + return LLM_KEYWORDS.some((keyword) => lowerText.includes(keyword.toLowerCase())); +} + +// Common tech topics to look for (reused from shared module) + +// Group episodes by potential topics +function extractTopics( + episodes: Array<{ episodeNumber: string; episodeSlug: string; title: string; transcript: string }> +): Map> { + const topicMap = new Map< + string, + Array<{ episodeNumber: string; episodeSlug: string; title: string; score: number }> + >(); + + for (const episode of episodes) { + const lowerText = episode.transcript.toLowerCase(); + const words = extractWords(episode.transcript); + const wordCounts = countWords(words); + + for (const [topic, keywords] of Object.entries(topicKeywords)) { + let score = 0; + for (const keyword of keywords) { + const regex = new RegExp(keyword.toLowerCase(), 'gi'); + const matches = lowerText.match(regex); + if (matches) { + score += matches.length * 2; // Keyword matches are weighted + } + // Also check word frequency + const wordFreq = wordCounts.get(keyword.toLowerCase()) || 0; + score += wordFreq; + } + + if (score > 3) { + // Threshold for inclusion + if (!topicMap.has(topic)) { + topicMap.set(topic, []); + } + topicMap.get(topic)!.push({ + episodeNumber: episode.episodeNumber, + episodeSlug: episode.episodeSlug, + title: episode.title, + score + }); + } + } + } + + // Sort episodes by score within each topic + for (const [topic, episodes] of topicMap.entries()) { + episodes.sort((a, b) => b.score - a.score); + } + + return topicMap; +} + +async function main() { + console.log('Analyzing transcripts...\n'); + + // Get all episodes + const allEpisodes = await getAllEpisodes(); + console.log(`Found ${allEpisodes.length} episodes\n`); + + // Create a map of episode number to episode data + const episodeMap = new Map(); + for (const episode of allEpisodes) { + if (episode.episodeNumber && episode.episodeNumber !== 'Bonus') { + episodeMap.set(episode.episodeNumber, { + episodeSlug: episode.episodeSlug, + title: episode.title + }); + } + } + + // Load transcripts directly from filesystem + const transcriptsDir = join(process.cwd(), 'src/content/transcripts'); + const transcriptFiles = readdirSync(transcriptsDir).filter((f) => f.endsWith('.md')); + + const episodesWithTranscripts: Array<{ + episodeNumber: string; + episodeSlug: string; + title: string; + transcript: string; + }> = []; + + for (const file of transcriptFiles) { + const episodeNumber = file.replace('.md', ''); + const episodeData = episodeMap.get(episodeNumber); + if (episodeData) { + try { + const transcriptPath = join(transcriptsDir, file); + const transcriptText = readFileSync(transcriptPath, 'utf-8'); + episodesWithTranscripts.push({ + episodeNumber, + episodeSlug: episodeData.episodeSlug, + title: episodeData.title, + transcript: transcriptText + }); + } catch (error) { + console.error(`Error reading ${file}:`, error); + } + } + } + + console.log(`Loaded ${episodesWithTranscripts.length} transcripts\n`); + + // Find episodes matching LLM keywords + const llmEpisodes = episodesWithTranscripts + .map((ep) => ({ + ...ep, + score: scoreLLMRelevance(ep.transcript) + })) + .filter((ep) => ep.score > 0) + .sort((a, b) => b.score - a.score); + + console.log('=== Building with LLMs Collection ==='); + console.log(`Found ${llmEpisodes.length} episodes matching LLM keywords:\n`); + console.log('Episode slugs for collection:'); + const llmSlugs = llmEpisodes.map((ep) => ep.episodeSlug); + console.log(JSON.stringify(llmSlugs, null, 2)); + console.log('\nTop matches:'); + llmEpisodes.slice(0, 10).forEach((ep) => { + console.log(` ${ep.episodeNumber}: ${ep.title} (score: ${ep.score})`); + }); + + // Extract potential topics + console.log('\n\n=== Potential Collections ===\n'); + const topics = extractTopics(episodesWithTranscripts); + + const topicSuggestions: Array<{ + slug: string; + title: string; + subtitle?: string; + episodeCount: number; + topEpisodes: Array<{ title: string; score: number }>; + }> = []; + + for (const [topic, episodes] of topics.entries()) { + if (episodes.length >= 3) { + // Only suggest topics with at least 3 episodes + const slug = topic.replace(/\s+/g, '-').toLowerCase(); + const title = topic + .split('-') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + + topicSuggestions.push({ + slug, + title, + episodeCount: episodes.length, + topEpisodes: episodes.slice(0, 5).map((ep) => ({ + title: ep.title, + score: ep.score + })) + }); + } + } + + // Sort by episode count + topicSuggestions.sort((a, b) => b.episodeCount - a.episodeCount); + + console.log(`Found ${topicSuggestions.length} potential collections:\n`); + topicSuggestions.slice(0, 10).forEach((topic, index) => { + console.log(`${index + 1}. ${topic.title}`); + console.log(` Slug: ${topic.slug}`); + console.log(` Episodes: ${topic.episodeCount}`); + console.log(` Top episodes:`); + topic.topEpisodes.forEach((ep) => { + console.log(` - ${ep.title} (score: ${ep.score})`); + }); + console.log(''); + }); + + // Output the LLM collection data + console.log('\n=== Collection Data ===\n'); + console.log('LLM Collection slugs:'); + console.log(JSON.stringify(llmSlugs, null, 2)); + + // Extract episodes for specific collections + console.log('\n\n=== Specific Collection Episodes ===\n'); + + // CSS Collection + const cssEpisodes = episodesWithTranscripts + .map((ep) => ({ + ...ep, + score: scoreTopicRelevance(ep.transcript, topicKeywords['css']) + })) + .filter((ep) => ep.score > 5) + .sort((a, b) => b.score - a.score) + .slice(0, 30); // Top 30 + + console.log('CSS Collection:'); + console.log(JSON.stringify(cssEpisodes.map((ep) => ep.episodeSlug), null, 2)); + + // TypeScript Collection + const tsEpisodes = episodesWithTranscripts + .map((ep) => ({ + ...ep, + score: scoreTopicRelevance(ep.transcript, topicKeywords['typescript']) + })) + .filter((ep) => ep.score > 5) + .sort((a, b) => b.score - a.score) + .slice(0, 30); // Top 30 + + console.log('\nTypeScript Collection:'); + console.log(JSON.stringify(tsEpisodes.map((ep) => ep.episodeSlug), null, 2)); + + // Testing Collection + const testingEpisodes = episodesWithTranscripts + .map((ep) => ({ + ...ep, + score: scoreTopicRelevance(ep.transcript, topicKeywords['testing']) + })) + .filter((ep) => ep.score > 5) + .sort((a, b) => b.score - a.score) + .slice(0, 30); // Top 30 + + console.log('\nTesting Collection:'); + console.log(JSON.stringify(testingEpisodes.map((ep) => ep.episodeSlug), null, 2)); + + // Accessibility Collection + const a11yEpisodes = episodesWithTranscripts + .map((ep) => ({ + ...ep, + score: scoreTopicRelevance(ep.transcript, [ + 'accessibility', + 'a11y', + 'aria', + 'screen reader', + 'wcag', + 'semantic html', + 'inclusive design', + 'accessible', + 'keyboard navigation', + 'focus management' + ]) + })) + .filter((ep) => ep.score > 3) + .sort((a, b) => b.score - a.score) + .slice(0, 25); // Top 25 + + console.log('\nAccessibility Collection:'); + console.log(JSON.stringify(a11yEpisodes.map((ep) => ep.episodeSlug), null, 2)); + + // AI/ML Collection (broader than LLMs) + const aiMlEpisodes = episodesWithTranscripts + .map((ep) => ({ + ...ep, + score: scoreTopicRelevance(ep.transcript, [ + 'ai', + 'artificial intelligence', + 'machine learning', + 'ml', + 'neural network', + 'deep learning', + 'tensorflow', + 'pytorch', + 'model', + 'training', + 'inference', + 'data science', + 'mlops' + ]) + })) + .filter((ep) => ep.score > 5) + .sort((a, b) => b.score - a.score) + .slice(0, 30); // Top 30 + + console.log('\nAI/ML Collection:'); + console.log(JSON.stringify(aiMlEpisodes.map((ep) => ep.episodeSlug), null, 2)); +} + +main().catch(console.error); diff --git a/src/components/AdPackageCard.astro b/src/components/AdPackageCard.astro index 893e125..84b33f5 100644 --- a/src/components/AdPackageCard.astro +++ b/src/components/AdPackageCard.astro @@ -3,27 +3,35 @@ export interface Props { bullets: Array; heading: string; price: string; + productId: string; + period?: string; } -const { bullets, heading, price } = Astro.props; +const { bullets, heading, price, productId, period = 'per episode' } = Astro.props; --- -
+

{heading}

-
+
\ No newline at end of file diff --git a/src/components/Breadcrumbs.astro b/src/components/Breadcrumbs.astro index 5424bba..202d795 100644 --- a/src/components/Breadcrumbs.astro +++ b/src/components/Breadcrumbs.astro @@ -9,23 +9,31 @@ export interface Props { 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: [ - { - '@type': 'ListItem', - position: 1, - name: 'Home', - item: Astro.site?.toString() || '/' - }, - { - '@type': 'ListItem', - position: 2, - name: title, - item: new URL(url, Astro.site).toString() - } - ] + itemListElement: breadcrumbItems.map((item, index) => ({ + '@type': 'ListItem', + position: index + 1, + name: item.name, + item: new URL(item.href, Astro.site).toString() + })) }; --- @@ -33,31 +41,38 @@ const breadcrumbSchema = { diff --git a/src/components/InfoCard.astro b/src/components/InfoCard.astro index c20608c..1a49aaf 100644 --- a/src/components/InfoCard.astro +++ b/src/components/InfoCard.astro @@ -4,6 +4,9 @@ About + + Collections + Contact diff --git a/src/content/transcripts/155.md b/src/content/transcripts/155.md index 7e8414e..213dc74 100644 --- a/src/content/transcripts/155.md +++ b/src/content/transcripts/155.md @@ -1,4 +1,4 @@ -**Intro:** [00:00:00] Welcome to Syntax. Welcome to a brand new episode of the +[00:00:00] **Intro:** Welcome to Syntax. Welcome to a brand new episode of the Front End Happy Hour podcast. Welcome to this week's JS Party. Live from Ship Shape Studios, this is Whiskey Web and Whatnot. With your hosts, Robbie the Wagner, and me, Charles William Carpenter III. That's right Charles. We drink diff --git a/src/data/collections.ts b/src/data/collections.ts new file mode 100644 index 0000000..ccdbc9f --- /dev/null +++ b/src/data/collections.ts @@ -0,0 +1,235 @@ +export interface Collection { + slug: string; + title: string; + subtitle?: string; + description?: string; + color?: string; + episodeSlugs: string[]; +} + +export const collections: Collection[] = [ + { + slug: 'building-with-llms', + title: 'Building with LLMs', + subtitle: 'Essential Tools and Frameworks for Agentic Workflows', + description: + 'Explore the tools, frameworks, and techniques for building applications with large language models and creating agentic workflows.', + episodeSlugs: [ + 'wtf-is-mcp-w-david-cramer', + 'open-source-agents-and-the-next-ai-wave-w-angie-jones', + 'mcp-security-framework-fatigue-and-ai-agents-with-will-johnson-presented-by-coderabbit', + 'doing-one-thing-exceptionally-well-coderabbits-approach-to-ai-code-review', + 'will-frameworks-survive-web-development-in-an-ai-driven-world-w-typecraft-and-robert-jackson', + 'hot-takes-developer-relations-and-ai-with-rizel-scarlett', + 'secrets-of-a-javascript-guru-natalia-venditto-on-ai-graphql-and-more', + 'your-terminal-is-getting-smarter-with-ben-holmes', + 'agents-of-chaos-whiskey-experiments-and-the-future-of-ides', + 'the-piano-man-of-state-machines-w-david-k-piano', + 'machine-learning-in-javascript-remix-plus-netlify-and-why-dx-engineers-matter-with-charlie-gerard', + 'unlocking-the-secrets-of-ai-in-tech-with-april-yoho', + 'opensauced-developer-advocacy-and-ai-with-brian-douglas', + 'frameworks-ai-and-the-complexities-of-the-gig-economy', + 'twitter-open-source-algorithm-home-labs-and-chat-gpt-vs-bard', + 'vibe-podcasting-app-rewrites-ai-assistants-pbjs-and-more', + 'tailwind-4-deepseek-ai-and-the-end-of-the-old-web', + 'will-ai-kill-the-joy-of-coding', + 'ai-vs-human-the-future-of-job-interviews-with-taylor-desseyn', + 'advent-of-whiskey-coding-advent-calendars-and-the-strangest-ai-projects', + 'advent-of-whiskey-state-of-js-chatgpt-and-browser-apis', + 'bearded-talks-on-beardless-hosts-vscode-sidebars-ai-and-graphql-with-kelly-vaughn', + 'from-ai-deep-dives-to-giblets-and-fig-wasps-with-ken-wheeler', + 'coding-languages-ai-and-the-evolution-of-game-development-with-philip-winston', + 'advent-of-whiskey-testing-the-hype-of-chatgpt-gitlab-and-holiday-trivia' + ] + }, + { + slug: 'ai-ml', + title: 'AI & Machine Learning', + subtitle: 'Artificial Intelligence, ML Models, and Data Science', + description: + 'Explore artificial intelligence, machine learning, neural networks, and how AI is transforming web development and software engineering.', + episodeSlugs: [ + 'tech-talk-typescript-and-empowering-engineers-with-shaundai-person', + 'the-piano-man-of-state-machines-w-david-k-piano', + 'from-faang-to-fired-the-illusion-of-stability-in-big-tech-w-adam-argyle', + 'empowering-black-women-in-tech-shaundais-insightful-discussion-on-self-promotion-and-career-growth', + 'dial-up-is-dead-long-live-adam-argyle', + 'a11y-hour-with-eric-bailey', + 'are-developers-overthinking-everything-w-bdougie', + 'tech-talk-social-media-use-and-netflix-with-the-primeagen', + 'tailwind-css-headless-ui-and-powerlifting-with-adam-wathan', + 'exploring-open-source-and-solidjs-with-ryan-carniato', + 'is-cereal-soup-the-fg-scale-and-js-vs-css-with-adam-argyle', + 'why-most-developers-overcomplicate-everything-w-aaron-francis', + 'from-react-miami-to-fly-fishing-diving-deep-with-theprimeagen-and-friends', + 'fathers-day-drinking-w-typecraft', + 'understanding-whiskey-with-prime-barrels-michael-nagdi', + 'the-one-rye-to-rule-them-all-w-kendall-miller-rishi-malik', + 'is-cracker-barrel-a-js-framework', + 'a-very-merry-descent-into-holiday-madness', + 'whiskey-web-and-whatnot-100th-episode-round-table-with-chris-coyier-scott-tolinski-tracy-lee-and-wes-bos', + 'front-end-adventures-with-bad-at-css-david-east-and-adam-argyle', + 'tech-stacks-building-apps-and-gaming-nostalgia-with-david-cramer', + 'will-ai-kill-the-joy-of-coding', + 'how-to-build-a-career-when-the-rules-keep-changing-w-taylor-desseyn-jason-torres', + 'from-frontend-to-backend-lane-wagners-journey-to-bootdev', + 'building-a-better-web-open-source-simplicity-and-speed-with-jason-lengstorf', + 'creating-codepen-tackling-tailwind-and-keeping-it-simple-with-chris-coyier', + 'hughes-belle-of-bedford-ember-and-whatnot-w-robert-jackson-rwjblue', + 'typecraft-typescript-vim-foodie-youtube-linux-and-more', + 'why-you-cant-skip-the-fundamentals-w-henri-helvetica', + 'prioritizing-the-team-over-the-tool-with-jason-lengstorf' + ] + }, + { + slug: 'typescript', + title: 'TypeScript', + subtitle: 'Type Safety, Modern JavaScript, and Developer Experience', + description: + 'Learn about TypeScript, type systems, adopting TypeScript in existing projects, and improving developer experience with better tooling.', + episodeSlugs: [ + 'the-case-for-adopting-typescript-with-josh-goldberg', + 'bringing-types-to-ember-with-chris-krycho', + 'typescript-react-and-api-issues-with-matt-pocock', + 'from-react-miami-to-fly-fishing-diving-deep-with-theprimeagen-and-friends', + 'a-framework-for-ember-typescript-with-james-c-davis', + 'rust-is-overrated-w-naman-goel', + 'tech-talk-social-media-use-and-netflix-with-the-primeagen', + 'prioritizing-the-team-over-the-tool-with-jason-lengstorf', + 'runspired-vs-chris-manson-on-solving-the-number-one-open-source-maintainer-dilemma', + 'the-piano-man-of-state-machines-w-david-k-piano', + 'a11y-hour-with-crystal-preston-watson', + 'balancing-legacy-code-content-creation-and-career-growth-with-the-primeagen', + 'ember-vs-react-jamstack-and-holes-in-the-hiring-process-with-chris-manson', + 'npm-worms-rubygems-coups-trust-issues-in-open-source', + 'will-frameworks-survive-web-development-in-an-ai-driven-world-w-typecraft-and-robert-jackson', + 'alternatives-to-relay-the-graphql-stack-and-adulthood-with-charles-lowell-and-taras-mankovski', + 'should-you-learn-tailwind-before-css-w-bree-hall', + 'from-faang-to-fired-the-illusion-of-stability-in-big-tech-w-adam-argyle', + 'is-cereal-soup-the-fg-scale-and-js-vs-css-with-adam-argyle', + 'how-to-make-a-podcast-worth-listening-to-with-dan-blumberg', + 'live-from-that-conf-web-frameworks-and-developer-experience-with-james-quick', + 'spooky-scary-css', + 'stop-fixing-things-that-arent-broken', + 'a11y-hour-with-mark-steadman', + 'the-release-of-nuxt-3-with-daniel-roe', + 'embracing-new-tech-javascript-and-the-w3wc-nft-launch', + 'upgrade-your-lifestyle-from-the-ballmer-peak-to-high-tech-toilet-seats', + 'why-svelte-might-just-outdo-react-rich-harris-unveils-shocking-comparisons', + 'the-future-of-ember-and-modern-build-tools-with-chris-manson', + 'static-dynamic-generative-whats-next-for-the-web-w-guillermo-rauch' + ] + }, + { + slug: 'accessibility', + title: 'Accessibility', + subtitle: 'Building Inclusive Web Experiences for Everyone', + description: + 'Learn about web accessibility, ARIA, screen readers, WCAG guidelines, and how to create inclusive experiences that work for all users.', + episodeSlugs: [ + 'a11y-hour-with-mark-steadman', + 'a11y-hour-with-crystal-preston-watson', + 'a11y-hour-with-amber-hinds', + 'a11y-hour-with-eric-bailey', + 'is-cereal-soup-the-fg-scale-and-js-vs-css-with-adam-argyle', + 'rust-is-overrated-w-naman-goel', + 'solidjs-the-framework-creating-a-buzz-with-dan-jutan', + 'inclusive-experiences-in-react-applications-championing-neurodivergent-accessibility-w-amera-white', + 'work-life-balance-react-and-why-accessibility-is-everything-with-melanie-sumner', + 'why-svelte-might-just-outdo-react-rich-harris-unveils-shocking-comparisons', + 'htmx-open-source-retro-tech-and-dev-tools-with-carson-gross', + 'dial-up-is-dead-long-live-adam-argyle', + 'leveraging-css-web-design-and-gaming-ui-with-adam-argyle', + 'why-you-cant-skip-the-fundamentals-w-henri-helvetica', + 'tailwind-css-headless-ui-and-powerlifting-with-adam-wathan', + 'hot-takes-developer-relations-and-ai-with-rizel-scarlett', + 'coding-languages-ai-and-the-evolution-of-game-development-with-philip-winston', + 'vibe-podcasting-app-rewrites-ai-assistants-pbjs-and-more', + 'talkshop-show-w-macho-man-randy-standards', + 'html-accessibility-package-managers-and-the-whiskey-web-and-whatnot-nft', + 'nextjs-12-react-vs-svelte-and-the-future-of-frameworks-with-wes-bos', + 'getting-lost-in-git-and-goodbye-tsc', + 'features-of-astro-20-challenge-of-material-ui-and-cleanse-diets', + 'twitter-open-source-algorithm-home-labs-and-chat-gpt-vs-bard', + 'hot-takes-tanstack-and-open-source-with-tanner-linsley' + ] + }, + { + slug: 'css', + title: 'CSS', + subtitle: 'Styling, Frameworks, and Modern CSS Techniques', + description: + 'Dive deep into CSS, Tailwind, styling frameworks, and modern techniques for building beautiful, responsive web interfaces.', + episodeSlugs: [ + 'is-css-a-programming-language-w-kevin-powell', + 'tailwind-css-headless-ui-and-powerlifting-with-adam-wathan', + 'creating-codepen-tackling-tailwind-and-keeping-it-simple-with-chris-coyier', + 'should-you-learn-tailwind-before-css-w-bree-hall', + 'the-beauty-of-remix-falling-for-tailwind-and-why-nfts-are-a-scam-with-kent-c-dodds', + 'is-cereal-soup-the-fg-scale-and-js-vs-css-with-adam-argyle', + 'the-piano-man-of-state-machines-w-david-k-piano', + 'rust-is-overrated-w-naman-goel', + 'front-end-adventures-with-bad-at-css-david-east-and-adam-argyle', + 'whiskey-web-and-whatnot-100th-episode-round-table-with-chris-coyier-scott-tolinski-tracy-lee-and-wes-bos', + 'leveraging-css-web-design-and-gaming-ui-with-adam-argyle', + 'throwback-frameworks-tailwind-fandom-and-css-with-jhey-tompkins', + 'hot-takes-remix-and-nextjs-with-chance-strickland', + 'cracking-the-podcasting-code-with-andrew-lisowski-and-justin-bennett', + 'learning-angular-leadership-opportunities-and-google-culture-with-sarah-drasner', + 'from-faang-to-fired-the-illusion-of-stability-in-big-tech-w-adam-argyle', + 'features-of-astro-20-challenge-of-material-ui-and-cleanse-diets', + 'developer-communities-career-growth-and-front-end-hot-takes-with-madison-kanna', + 'from-javascript-to-php-josh-cirres-unexpected-dev-journey', + 'a-very-merry-descent-into-holiday-madness', + 'prioritizing-the-team-over-the-tool-with-jason-lengstorf', + 'html-shadowbanning-and-open-source-buyouts', + 'hot-takes-web-technologies-and-learning-to-code-with-ken-wheeler', + 'tech-careers-hot-takes-and-wix-with-emmy-cao-and-yoav-abrahami', + 'self-taught-engineering-boot-camps-and-veganism-with-welch-canavan', + 'tech-stacks-building-apps-and-gaming-nostalgia-with-david-cramer', + 'web-browsers-level-up-tutorials-and-sentry-with-scott-tolinski', + 'why-svelte-might-just-outdo-react-rich-harris-unveils-shocking-comparisons', + 'open-source-payload-and-sim-racing-with-james-mikrut', + 'spooky-scary-css' + ] + }, + { + slug: 'testing', + title: 'Testing', + subtitle: 'Test-Driven Development, E2E Testing, and Quality Assurance', + description: + 'Explore testing strategies, tools like Cypress and Playwright, TDD practices, and how to build confidence in your code through comprehensive testing.', + episodeSlugs: [ + 'css-trig-cypress-and-software-testing-alternatives', + 'mystery-makers-monday-testing-and-graphql', + 'a11y-hour-with-mark-steadman', + 'the-release-of-nuxt-3-with-daniel-roe', + 'matt-johnson-when-web3-is-worth-it-and-learning-to-lead', + 'embracing-new-tech-javascript-and-the-w3wc-nft-launch', + 'the-future-of-ember-and-modern-build-tools-with-chris-manson', + 'a11y-hour-with-crystal-preston-watson', + 'privacy-cyber-crime-stories-and-tech-with-jack-rhysider', + 'from-the-rickhouse-why-svelte-might-just-outdo-react-w-rich-harris', + 'tech-rants-supporting-open-source-and-great-tv-shows', + 'pnpm-algorithms-and-angular', + 'from-react-miami-to-fly-fishing-diving-deep-with-theprimeagen-and-friends', + 'the-right-way-to-nft-blockchain-and-making-your-mark-in-the-digital-marketplace-with-juan-palomino', + 'typescript-react-and-api-issues-with-matt-pocock', + 'flagging-features-and-dropping-beats-with-ben-rometsch', + 'from-faang-to-fired-the-illusion-of-stability-in-big-tech-w-adam-argyle', + 'tech-conferences-remote-work-and-the-intersection-of-ai-and-web-design-with-clark-sell', + 'shepherdjs-the-future-of-open-source-capitalism-and-corporate-responsibility', + 'spooky-scary-css', + 'a11y-hour-with-eric-bailey', + 'tailwind-twitter-wars-and-the-state-of-js', + 'npm-worms-rubygems-coups-trust-issues-in-open-source', + 'are-developers-overthinking-everything-w-bdougie', + 'tech-talk-social-media-use-and-netflix-with-the-primeagen', + 'nextjss-speed-vs-redwoodjss-strength-the-web-development-drama-you-cant-ignore', + 'inclusive-experiences-in-react-applications-championing-neurodivergent-accessibility-w-amera-white', + 'secrets-of-a-javascript-guru-natalia-venditto-on-ai-graphql-and-more', + 'dial-up-is-dead-long-live-adam-argyle', + 'a-very-merry-descent-into-holiday-madness' + ] + } +]; diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro index fd7057e..b0887f0 100644 --- a/src/layouts/Layout.astro +++ b/src/layouts/Layout.astro @@ -45,6 +45,7 @@ const description = Astro.props.description ?? starpodConfig.description; + @@ -64,8 +65,8 @@ const description = Astro.props.description ?? starpodConfig.description; - - + + keyword lists + thresholds reused from the analysis script +const COLLECTION_RULES: Record = { + 'building-with-llms': { slug: 'building-with-llms', threshold: 1, keywords: LLM_KEYWORDS }, + 'ai-ml': { slug: 'ai-ml', threshold: 6, keywords: topicKeywords['ai-ml'] }, + typescript: { slug: 'typescript', threshold: 6, keywords: topicKeywords['typescript'] }, + accessibility: { slug: 'accessibility', threshold: 4, keywords: topicKeywords['accessibility'] }, + css: { slug: 'css', threshold: 6, keywords: topicKeywords['css'] }, + testing: { slug: 'testing', threshold: 6, keywords: topicKeywords['testing'] } +}; + +function scoreForCollection(slug: string, text: string): number { + if (slug === 'building-with-llms') return scoreLLMRelevance(text); + const rule = COLLECTION_RULES[slug]; + if (!rule) return 0; + return scoreTopicRelevance(text, rule.keywords); +} + +export async function getComputedCollections() { + const allEpisodes = await getAllEpisodes(); + + // Map episode number -> episode data (only numeric episode numbers have transcripts) + const epByNumber = new Map(); + for (const ep of allEpisodes) { + if (ep.episodeNumber && ep.episodeNumber !== 'Bonus') { + epByNumber.set(ep.episodeNumber, { slug: ep.episodeSlug, title: ep.title }); + } + } + + // Load transcripts + const transcriptsDir = join(process.cwd(), 'src/content/transcripts'); + const transcriptFiles = existsSync(transcriptsDir) + ? readdirSync(transcriptsDir).filter((f) => f.endsWith('.md')) + : []; + + const transcriptTextBySlug = new Map(); + for (const file of transcriptFiles) { + const epNum = file.replace('.md', ''); + const ep = epByNumber.get(epNum); + if (!ep) continue; + const filePath = join(transcriptsDir, file); + if (!existsSync(filePath)) continue; + try { + const text = readFileSync(filePath, 'utf-8'); + transcriptTextBySlug.set(ep.slug, text); + } catch { + // ignore read errors; just skip + } + } + + // Build a working set per collection + const membership = new Map>(); + for (const col of staticCollections) { + membership.set(col.slug, new Set(col.episodeSlugs)); + } + + // Augment membership based on transcript matches + for (const [epSlug, text] of transcriptTextBySlug.entries()) { + for (const col of staticCollections) { + const rule = COLLECTION_RULES[col.slug]; + if (!rule) continue; // only auto-augment known rule-backed collections + const score = scoreForCollection(col.slug, text); + if (score >= rule.threshold) { + membership.get(col.slug)!.add(epSlug); + } + } + } + + // Emit the augmented collections array, preserving order and other metadata + return staticCollections.map((col) => ({ + ...col, + episodeSlugs: Array.from(membership.get(col.slug)!).sort() + })); +} diff --git a/src/lib/topic-keywords.ts b/src/lib/topic-keywords.ts new file mode 100644 index 0000000..68939f4 --- /dev/null +++ b/src/lib/topic-keywords.ts @@ -0,0 +1,90 @@ +export const LLM_KEYWORDS = [ + 'llm', + 'large language model', + 'language model', + 'gpt', + 'openai', + 'claude', + 'anthropic', + 'agent', + 'agentic', + 'workflow', + 'ai agent', + 'langchain', + 'langgraph', + 'llamaindex', + 'autogen', + 'crewai', + 'framework', + 'tool', + 'prompt engineering', + 'fine-tuning', + 'rag', + 'retrieval augmented', + 'vector database', + 'embeddings', + 'semantic search', + 'ai application', + 'ai tool', + 'ai framework', + 'ai library', + 'ai sdk', + 'ai platform', + 'ai service', + 'ai api', + 'ai model', + 'transformer', + 'neural network', + 'machine learning', + 'deep learning', + 'natural language processing', + 'nlp', + 'chatbot', + 'assistant', + 'copilot', + 'ai coding', + 'code generation', + 'ai development', + 'ai engineering' +]; + +export const topicKeywords: Record = { + react: ['react', 'jsx', 'component', 'hooks', 'next.js', 'remix', 'gatsby'], + vue: ['vue', 'nuxt', 'vuex', 'pinia', 'composition api'], + angular: ['angular', 'typescript', 'rxjs', 'ngrx'], + svelte: ['svelte', 'sveltekit', 'svelte store'], + typescript: ['typescript', 'ts', 'type system', 'generics', 'interface'], + testing: ['test', 'testing', 'jest', 'vitest', 'cypress', 'playwright', 'tdd', 'bdd', 'unit test', 'e2e'], + css: ['css', 'tailwind', 'styled-components', 'sass', 'scss', 'css-in-js', 'styling'], + performance: ['performance', 'optimization', 'lighthouse', 'core web vitals', 'bundle size', 'lazy loading'], + security: ['security', 'authentication', 'authorization', 'oauth', 'jwt', 'encryption', 'xss', 'csrf', 'sql injection'], + devops: ['devops', 'ci/cd', 'docker', 'kubernetes', 'github actions', 'deployment', 'infrastructure'], + database: ['database', 'sql', 'postgresql', 'mysql', 'mongodb', 'redis', 'prisma', 'drizzle', 'orm'], + api: ['api', 'rest', 'graphql', 'rpc', 'endpoint', 'fetch', 'axios'], + webassembly: ['webassembly', 'wasm', 'rust', 'go', 'assemblyscript'], + 'ai-ml': ['ai', 'artificial intelligence', 'machine learning', 'ml', 'neural network', 'deep learning', 'tensorflow', 'pytorch', 'model'], + accessibility: ['accessibility', 'a11y', 'aria', 'screen reader', 'wcag', 'semantic html'], + mobile: ['mobile', 'responsive', 'pwa', 'progressive web app', 'ios', 'android', 'react native'], + architecture: ['architecture', 'design pattern', 'microservices', 'monolith', 'scalability', 'system design'] +}; + +export function scoreTopicRelevance(text: string, keywords: string[]): number { + const lowerText = text.toLowerCase(); + let score = 0; + for (const keyword of keywords) { + const lowerKeyword = keyword.toLowerCase(); + const escaped = lowerKeyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const isShortAlphaNum = /^[a-z0-9]+$/i.test(lowerKeyword) && lowerKeyword.length <= 4; + const pattern = isShortAlphaNum ? `\\b${escaped}\\b` : escaped; + const regex = new RegExp(pattern, 'gi'); + const matches = lowerText.match(regex); + if (matches) { + score += matches.length * 2; + } + } + return score; +} + +export function scoreLLMRelevance(text: string): number { + return scoreTopicRelevance(text, LLM_KEYWORDS); +} diff --git a/src/pages/api/checkout.ts b/src/pages/api/checkout.ts new file mode 100644 index 0000000..f2d4b08 --- /dev/null +++ b/src/pages/api/checkout.ts @@ -0,0 +1,10 @@ +import { Checkout } from "@polar-sh/astro"; + +export const prerender = false; + +export const GET = Checkout({ + accessToken: import.meta.env.POLAR_ACCESS_TOKEN, + successUrl: import.meta.env.POLAR_SUCCESS_URL || 'https://whiskey.fm/sponsor/success', + // Use sandbox for testing, production for live + server: import.meta.env.PUBLIC_POLAR_SERVER || "production", +}); diff --git a/src/pages/collections/[slug].astro b/src/pages/collections/[slug].astro new file mode 100644 index 0000000..304f3d9 --- /dev/null +++ b/src/pages/collections/[slug].astro @@ -0,0 +1,287 @@ +--- +import Layout from '../../layouts/Layout.astro'; +import { Schema } from 'astro-seo-schema'; +import EpisodeList from '../../components/EpisodeList.astro'; +import { getComputedCollections } from '../../lib/collections'; +import { getAllEpisodes, getShowInfo } from '../../lib/rss'; + +const show = await getShowInfo(); + +export async function getStaticPaths() { + const collections = await getComputedCollections(); + return collections.map((collection) => ({ + params: { slug: collection.slug }, + props: { collection } + })); +} + +const { collection } = Astro.props; +const allEpisodes = await getAllEpisodes(); + +// Filter episodes that belong to this collection and sort newest-first +const collectionEpisodes = allEpisodes + .filter((episode) => collection.episodeSlugs.includes(episode.episodeSlug)) + .sort((a, b) => b.published - a.published); + +// Find current collection index for pagination +const computedCollections = await getComputedCollections(); +const currentIndex = computedCollections.findIndex((c) => c.slug === collection.slug); +const prevCollection = currentIndex > 0 ? computedCollections[currentIndex - 1] : null; +const nextCollection = currentIndex < computedCollections.length - 1 ? computedCollections[currentIndex + 1] : null; + +const title = `${collection.title} - ${show.title}`; +const canonicalURL = new URL(`/collections/${collection.slug}`, Astro.url); + +// Generate SEO-optimized description +const episodeCount = collectionEpisodes.length; +const description = collection.subtitle + ? `${collection.subtitle}. ${collection.description || ''} Explore ${episodeCount} curated ${episodeCount === 1 ? 'episode' : 'episodes'} from ${show.title} covering ${collection.title.toLowerCase()}.`.trim() + : `${collection.description || ''} Discover ${episodeCount} ${episodeCount === 1 ? 'episode' : 'episodes'} about ${collection.title.toLowerCase()} from ${show.title}.`.trim(); + +// Extract keywords from collection title +const keywords = [ + collection.title.toLowerCase(), + show.title.toLowerCase(), + 'podcast', + 'episodes', + ...collection.title.toLowerCase().split(' ') +].join(', '); + +// Calculate date range +const dateRange = + collectionEpisodes.length > 0 + ? { + oldest: new Date( + Math.min(...collectionEpisodes.map((e) => e.published)) + ), + newest: new Date( + Math.max(...collectionEpisodes.map((e) => e.published)) + ) + } + : null; +--- + + + + + + + + { + collection.title.split(/[&,]/).map((topic) => ( + + )) + } + + { + dateRange && ( + + ) + } + { + dateRange && ( + + ) + } + + + + + + ({ + '@type': 'ListItem', + position: index + 1, + item: { + '@type': 'PodcastEpisode', + name: episode.title, + description: episode.description, + url: new URL(`/${episode.episodeSlug}`, Astro.url).toString(), + datePublished: new Date(episode.published).toISOString(), + image: episode.episodeThumbnail || show.image + } + })) + }} + /> + + + +
+

+ {collection.title} +

+ + {collection.subtitle && ( +

+ {collection.subtitle} +

+ )} + + {collection.description && ( +

{collection.description}

+ )} + +
+

+ {collectionEpisodes.length}{' '} + {collectionEpisodes.length === 1 ? 'Episode' : 'Episodes'} +

+ {dateRange && ( +

+ {dateRange.oldest.toLocaleDateString('en-US', { + month: 'short', + year: 'numeric' + })}{' '} + —{' '} + {dateRange.newest.toLocaleDateString('en-US', { + month: 'short', + year: 'numeric' + })} +

+ )} +
+ + {collectionEpisodes.length > 0 ? ( + <> +

+ Episodes in This Collection +

+ + + ) : ( +
+

+ No episodes found in this collection. Please check the collection + configuration. +

+
+ )} + + {/* Pagination Navigation */} + {(prevCollection || nextCollection) && ( + + )} +
+
diff --git a/src/pages/collections/index.astro b/src/pages/collections/index.astro new file mode 100644 index 0000000..a8f1cd2 --- /dev/null +++ b/src/pages/collections/index.astro @@ -0,0 +1,212 @@ +--- +import Layout from '../../layouts/Layout.astro'; +import { Schema } from 'astro-seo-schema'; +import { getComputedCollections } from '../../lib/collections'; +import { getAllEpisodes, getShowInfo } from '../../lib/rss'; + +const show = await getShowInfo(); +const allEpisodes = await getAllEpisodes(); +const collections = await getComputedCollections(); +const title = `Collections - ${show.title}`; +const canonicalURL = new URL('/collections', Astro.url); + +// For each collection, include the most recent episode, then fill with up to 3 recent episodes +// that yield unique artwork across collections (avoiding repeats seen in previous collections). +const usedImageUrls = new Set(); +const collectionsWithEpisodes = collections.map((collection) => { + const eps = allEpisodes + .filter((episode) => collection.episodeSlugs.includes(episode.episodeSlug)) + .sort((a, b) => b.published - a.published); + + const getImg = (ep: any) => ep.episodeThumbnail ?? show.image; + const unique: any[] = []; + const seenInGroup = new Set(); + + // Walk newest → older and take only episodes whose image hasn't been used + for (const ep of eps) { + const img = getImg(ep); + if (!img) continue; + if (usedImageUrls.has(img)) continue; + if (seenInGroup.has(img)) continue; + + unique.push(ep); + seenInGroup.add(img); + usedImageUrls.add(img); + + if (unique.length >= 4) break; + } + + return { + ...collection, + imageGroupEpisodes: unique + }; +}); + +// Calculate total episode count +const totalEpisodeCount = collections.reduce( + (sum, collection) => sum + collection.episodeSlugs.length, + 0 +); + +// Generate SEO-optimized description +const collectionTopics = collections.map((c) => c.title).join(', '); +const description = `Explore curated podcast collections covering ${collectionTopics}. Browse ${collections.length} topic-based collections featuring ${totalEpisodeCount} episodes from ${show.title}.`; + +// Generate keywords from collection titles +const keywords = collections + .map((c) => c.title.toLowerCase()) + .concat(['podcast collections', 'curated episodes', show.title.toLowerCase()]) + .join(', '); + +// Generate collection URLs for speculation rules +const collectionUrls = collections.map((c) => `/collections/${c.slug}`); +--- + + + + + + + + + + + + ({ + '@type': 'ListItem', + position: index + 1, + name: collection.title, + description: collection.description, + url: new URL(`/collections/${collection.slug}`, Astro.url).toString() + })) + }} + /> + + + + + + \ No newline at end of file diff --git a/src/pages/sponsor/success.astro b/src/pages/sponsor/success.astro new file mode 100644 index 0000000..eec681e --- /dev/null +++ b/src/pages/sponsor/success.astro @@ -0,0 +1,36 @@ +--- +import Layout from '../../layouts/Layout.astro'; +--- + + +
+

+ Thank You for Sponsoring! +

+ +

+ Your sponsorship has been successfully processed. We'll be in touch shortly + to coordinate your ad placement and get you set up. +

+ +

+ Check your email for confirmation and next steps. If you have any questions, + feel free to contact us. +

+ + +
+
diff --git a/src/styles/global.css b/src/styles/global.css index b753f09..b933a43 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -87,3 +87,9 @@ footer:has(+ #audio-player .player) { @utility section-heading-underlined { @apply section-heading dark:border-dark-border border-b pb-4; } + +@supports (grid-template-rows: subgrid) { + .collections-grid { + grid-auto-rows: minmax(0, auto); + } +}