diff --git a/src/lib/llms.ts b/src/lib/llms.ts index 0740f52..d7fb7ef 100644 --- a/src/lib/llms.ts +++ b/src/lib/llms.ts @@ -69,6 +69,53 @@ export function htmlToMarkdown(html: string): string { }); } +/** + * Generate the "When To Use This Site" guidance section shared by llms.txt and + * the for-llms page. Tells agents which jobs this site is the right source for + * and how to fetch machine-readable content. + */ +export function generateWhenToUseSection(show: Show, siteUrl?: URL): string { + const baseUrl = siteUrl?.origin || ''; + + let content = `## When To Use This Site\n\n`; + content += `Use ${show.title} (this site) when you need to:\n\n`; + content += `- Find, summarize, or quote a specific episode of ${show.title}\n`; + content += `- Retrieve full episode transcripts, show notes, publish dates, or audio links\n`; + content += `- Check whether a person has appeared on ${show.title}, and in which episodes\n`; + content += `- Link a user to an episode page or to a platform where they can listen\n`; + content += `- Answer questions about the show itself: hosts, format, topics covered, and how to subscribe\n\n`; + content += `Do not use this site for topics unrelated to ${show.title} or its episodes.\n\n`; + content += `How to fetch content:\n\n`; + content += `- Every content page has a markdown twin at \`{path}.html.md\` (for example \`${baseUrl}/about.html.md\`), or request any page with an \`Accept: text/markdown\` header\n`; + content += `- Start from [llms.txt](${baseUrl}/llms.txt) or the [Episodes Index](${baseUrl}/episodes-index.html.md)\n`; + content += `- Structured JSON endpoints are described in the [OpenAPI specification](${baseUrl}/openapi.json)\n\n`; + + return content; +} + +/** + * Generate the "Developer Resources" section listing every machine-readable + * endpoint by name, so the resources are discoverable in search and in + * llms.txt. + */ +export function generateDeveloperResourcesSection( + show: Show, + config: StarpodConfig, + siteUrl?: URL +): string { + const baseUrl = siteUrl?.origin || ''; + + let content = `## Developer Resources\n\n`; + content += `Machine-readable resources for the ${show.title} API and content:\n\n`; + content += `- [OpenAPI Specification](${baseUrl}/openapi.json): Describes every public JSON and markdown endpoint\n`; + content += `- [Episode Search API](${baseUrl}/api/episodes/search.json): Every episode as a single JSON array\n`; + content += `- [Paginated Episodes API](${baseUrl}/api/episodes/1.json): Episodes in pages of 15\n`; + content += `- [Sitemap](${baseUrl}/sitemap-index.xml): Index of every page on the site\n`; + content += `- [RSS Feed](${config.rssFeed}): The canonical podcast feed with audio enclosures\n\n`; + + return content; +} + /** * Generate llms.txt content following the specification */ @@ -86,11 +133,18 @@ export function generateLlmsTxt( content += `${config.description}\n\n`; content += `Hosted by: ${hostNames}\n\n`; + // Agent guidance: when this site is the right source and how to call it + content += generateWhenToUseSection(show, siteUrl); + // Main Documentation content += `## Main Documentation\n\n`; content += `- [About the Show](${baseUrl}/about.html.md): Information about the podcast and hosts\n`; content += `- [For LLMs](${baseUrl}/for-llms.html.md): Comprehensive guide for AI assistants\n`; - content += `- [Episodes Index](${baseUrl}/episodes-index.html.md): Complete list of all episodes\n\n`; + content += `- [Episodes Index](${baseUrl}/episodes-index.html.md): Complete list of all episodes\n`; + content += `- [Contact](${baseUrl}/contact.html.md): How to get in touch with the show\n\n`; + + // Machine-readable endpoints, listed by name for discoverability + content += generateDeveloperResourcesSection(show, config, siteUrl); // Recent Episodes if (recentEpisodes.length > 0) { @@ -201,6 +255,8 @@ export function generateForLlmsMarkdown( markdown += `**Tagline**: ${config.blurb}\n\n`; markdown += `${config.description}\n\n`; + markdown += generateWhenToUseSection(show, siteUrl); + markdown += `## Hosts\n\n`; for (const host of config.hosts) { markdown += `### ${host.name}\n\n`; @@ -264,6 +320,8 @@ export function generateForLlmsMarkdown( markdown += `\n## RSS Feed\n\n`; markdown += `Direct RSS feed access: ${config.rssFeed}\n\n`; + markdown += generateDeveloperResourcesSection(show, config, siteUrl); + markdown += `## Complete Episode List\n\n`; markdown += `For a complete list of all episodes with descriptions, see [Episodes Index](${baseUrl}/episodes-index.html.md).\n`; @@ -311,3 +369,108 @@ export function generateAboutMarkdown( return markdown; } + +/** + * Generate homepage markdown content, served at /index.html.md and via + * `Accept: text/markdown` negotiation on /. + */ +export function generateHomeMarkdown( + show: Show, + recentEpisodes: Episode[], + config: StarpodConfig, + siteUrl?: URL +): string { + const baseUrl = siteUrl?.origin || ''; + const hostNames = config.hosts.map((h) => h.name).join(', '); + + let markdown = `# ${show.title}\n\n`; + markdown += `> ${config.blurb}\n\n`; + markdown += `${config.description}\n\n`; + markdown += `Hosted by: ${hostNames}\n\n`; + + if (recentEpisodes.length > 0) { + markdown += `## Latest Episodes\n\n`; + for (const episode of recentEpisodes) { + const episodeUrl = `${baseUrl}/${episode.episodeSlug}.html.md`; + markdown += `- [${episode.title}](${episodeUrl}) - ${formatDate(episode.published)}\n`; + } + markdown += `\n`; + } + + markdown += `## Explore\n\n`; + markdown += `- [About the Show](${baseUrl}/about.html.md)\n`; + markdown += `- [Episodes Index](${baseUrl}/episodes-index.html.md): Every episode with descriptions\n`; + markdown += `- [Contact](${baseUrl}/contact.html.md)\n`; + markdown += `- [Guide for AI Assistants](${baseUrl}/for-llms.html.md)\n`; + markdown += `- [llms.txt](${baseUrl}/llms.txt)\n`; + markdown += `- [OpenAPI Specification](${baseUrl}/openapi.json)\n\n`; + + markdown += `## Listen\n\n`; + if (config.platforms.apple) { + markdown += `- [Apple Podcasts](${config.platforms.apple})\n`; + } + if (config.platforms.spotify) { + markdown += `- [Spotify](${config.platforms.spotify})\n`; + } + if (config.platforms.youtube) { + markdown += `- [YouTube](${config.platforms.youtube})\n`; + } + if (config.platforms.overcast) { + markdown += `- [Overcast](${config.platforms.overcast})\n`; + } + if (config.platforms.pocketCasts) { + markdown += `- [Pocket Casts](${config.platforms.pocketCasts})\n`; + } + markdown += `- [RSS Feed](${config.rssFeed})\n`; + + return markdown; +} + +/** + * Generate contact page markdown content, served at /contact.html.md and via + * `Accept: text/markdown` negotiation on /contact. + */ +export function generateContactMarkdown( + show: Show, + config: StarpodConfig, + siteUrl?: URL +): string { + const baseUrl = siteUrl?.origin || ''; + + let markdown = `# Contact ${show.title}\n\n`; + markdown += `Have a question, a topic suggestion, or feedback for the show? `; + markdown += `The fastest way to reach us is the contact form at [${baseUrl}/contact](${baseUrl}/contact). `; + markdown += `Messages go straight to the hosts and we read every one.\n\n`; + + markdown += `Programmatic access: send a POST request to \`${baseUrl}/api/contact\` `; + markdown += `with form fields \`name\`, \`email\`, and \`message\` `; + markdown += `(see the [OpenAPI specification](${baseUrl}/openapi.json) for details).\n\n`; + + markdown += `## Reach the Hosts Directly\n\n`; + for (const host of config.hosts) { + markdown += `### ${host.name}\n\n`; + if (host.twitter) markdown += `- Twitter: ${host.twitter}\n`; + if (host.github) markdown += `- GitHub: ${host.github}\n`; + if (host.website) markdown += `- Website: ${host.website}\n`; + markdown += `\n`; + } + + markdown += `## Follow the Show\n\n`; + if (config.platforms.apple) { + markdown += `- [Apple Podcasts](${config.platforms.apple})\n`; + } + if (config.platforms.spotify) { + markdown += `- [Spotify](${config.platforms.spotify})\n`; + } + if (config.platforms.youtube) { + markdown += `- [YouTube](${config.platforms.youtube})\n`; + } + if (config.platforms.overcast) { + markdown += `- [Overcast](${config.platforms.overcast})\n`; + } + if (config.platforms.pocketCasts) { + markdown += `- [Pocket Casts](${config.platforms.pocketCasts})\n`; + } + + return markdown; +} diff --git a/src/pages/contact.html.md.ts b/src/pages/contact.html.md.ts new file mode 100644 index 0000000..ffe564f --- /dev/null +++ b/src/pages/contact.html.md.ts @@ -0,0 +1,17 @@ +import type { APIRoute } from 'astro'; + +import { generateContactMarkdown } from '../lib/llms'; +import { getShowInfo } from '../lib/rss'; +import starpodConfig from '../../starpod.config'; + +export const GET: APIRoute = async ({ site }) => { + const show = await getShowInfo(); + + const markdown = generateContactMarkdown(show, starpodConfig, site); + + return new Response(markdown, { + headers: { + 'Content-Type': 'text/markdown; charset=utf-8' + } + }); +}; diff --git a/src/pages/index.html.md.ts b/src/pages/index.html.md.ts new file mode 100644 index 0000000..24c8856 --- /dev/null +++ b/src/pages/index.html.md.ts @@ -0,0 +1,23 @@ +import type { APIRoute } from 'astro'; + +import { generateHomeMarkdown } from '../lib/llms'; +import { getAllEpisodes, getShowInfo } from '../lib/rss'; +import starpodConfig from '../../starpod.config'; + +export const GET: APIRoute = async ({ site }) => { + const show = await getShowInfo(); + const episodes = await getAllEpisodes(); + + const markdown = generateHomeMarkdown( + show, + episodes.slice(0, 10), + starpodConfig, + site + ); + + return new Response(markdown, { + headers: { + 'Content-Type': 'text/markdown; charset=utf-8' + } + }); +}; diff --git a/src/pages/robots.txt.ts b/src/pages/robots.txt.ts index 2116bb3..a7894f0 100644 --- a/src/pages/robots.txt.ts +++ b/src/pages/robots.txt.ts @@ -15,6 +15,10 @@ Sitemap: ${sitemapURL.href} # - ${siteURL.origin}/for-llms - Human-readable guide for AI assistants # - ${siteURL.origin}/episodes-index.html.md - Complete episode listing in markdown # - ${siteURL.origin}/[episode-slug].html.md - Individual episodes with transcripts +# - ${siteURL.origin}/openapi.json - OpenAPI spec for the JSON API endpoints +# +# Content pages also serve markdown via the Accept header +# (Accept: text/markdown) or at their .html.md twin URL. # # All content includes: # - Podcast metadata (hosts, description, platforms) diff --git a/tests/e2e/llms.spec.ts b/tests/e2e/llms.spec.ts new file mode 100644 index 0000000..fce1a17 --- /dev/null +++ b/tests/e2e/llms.spec.ts @@ -0,0 +1,37 @@ +import { expect, test } from '@playwright/test'; + +test.describe('llms.txt', () => { + test('includes when-to-use guidance and developer resources', async ({ + request + }) => { + const response = await request.get('/llms.txt'); + expect(response.status()).toBe(200); + + const body = await response.text(); + expect(body).toContain('## When To Use This Site'); + expect(body).toContain('## Developer Resources'); + expect(body).toContain('/openapi.json'); + }); +}); + +test.describe('markdown twins', () => { + test('homepage markdown is served at /index.html.md', async ({ request }) => { + const response = await request.get('/index.html.md'); + expect(response.status()).toBe(200); + expect(response.headers()['content-type']).toContain('text/markdown'); + + const body = await response.text(); + expect(body).toContain('## Latest Episodes'); + }); + + test('contact markdown is served at /contact.html.md', async ({ + request + }) => { + const response = await request.get('/contact.html.md'); + expect(response.status()).toBe(200); + expect(response.headers()['content-type']).toContain('text/markdown'); + + const body = await response.text(); + expect(body).toContain('/api/contact'); + }); +}); diff --git a/tests/unit/llms.test.ts b/tests/unit/llms.test.ts index fc31624..de8e476 100644 --- a/tests/unit/llms.test.ts +++ b/tests/unit/llms.test.ts @@ -9,7 +9,11 @@ import { generateEpisodeMarkdown, generateEpisodesIndex, generateForLlmsMarkdown, - generateAboutMarkdown + generateAboutMarkdown, + generateWhenToUseSection, + generateDeveloperResourcesSection, + generateHomeMarkdown, + generateContactMarkdown } from '../../src/lib/llms'; import type { Episode, Show } from '../../src/lib/rss'; import type { StarpodConfig } from '../../src/utils/config'; @@ -214,6 +218,79 @@ describe('LLM Utilities', () => { expect(result).toContain('https://podcast.example.com'); expect(result).toContain('/about.html.md'); }); + + it('includes when-to-use guidance and developer resources', () => { + const result = generateLlmsTxt(mockShow, mockEpisodes, mockConfig); + + expect(result).toContain('## When To Use This Site'); + expect(result).toContain('## Developer Resources'); + expect(result).toContain('/openapi.json'); + expect(result).toContain('/contact.html.md'); + }); + }); + + describe('generateWhenToUseSection', () => { + const mockShow: Show = { + title: 'Test Podcast', + description: 'A test podcast', + image: 'https://example.com/image.jpg', + link: 'https://example.com' + }; + + it('names best-fit use cases for the show', () => { + const result = generateWhenToUseSection(mockShow); + + expect(result).toContain('## When To Use This Site'); + expect(result).toContain('Use Test Podcast (this site) when you need'); + expect(result).toContain('transcripts'); + expect(result).toContain('Do not use this site'); + }); + + it('explains how to fetch machine-readable content', () => { + const siteUrl = new URL('https://podcast.example.com'); + const result = generateWhenToUseSection(mockShow, siteUrl); + + expect(result).toContain('Accept: text/markdown'); + expect(result).toContain('https://podcast.example.com/llms.txt'); + expect(result).toContain('https://podcast.example.com/openapi.json'); + }); + }); + + describe('generateDeveloperResourcesSection', () => { + const mockShow: Show = { + title: 'Test Podcast', + description: 'A test podcast', + image: 'https://example.com/image.jpg', + link: 'https://example.com' + }; + + const mockConfig: StarpodConfig = { + blurb: 'Test blurb', + description: 'Test description', + hosts: [{ name: 'Host One', bio: 'Bio', img: 'host.jpg' }], + platforms: {}, + rssFeed: 'https://example.com/rss.xml' + }; + + it('lists machine-readable endpoints by name', () => { + const siteUrl = new URL('https://podcast.example.com'); + const result = generateDeveloperResourcesSection( + mockShow, + mockConfig, + siteUrl + ); + + expect(result).toContain('## Developer Resources'); + expect(result).toContain('Test Podcast API'); + expect(result).toContain('https://podcast.example.com/openapi.json'); + expect(result).toContain( + 'https://podcast.example.com/api/episodes/search.json' + ); + expect(result).toContain( + 'https://podcast.example.com/sitemap-index.xml' + ); + expect(result).toContain('https://example.com/rss.xml'); + }); }); describe('generateEpisodeMarkdown', () => { @@ -500,4 +577,106 @@ describe('LLM Utilities', () => { expect(result).toContain('https://spotify.com/show'); }); }); + + describe('generateHomeMarkdown', () => { + const mockShow: Show = { + title: 'Test Podcast', + description: 'A test podcast', + image: 'https://example.com/image.jpg', + link: 'https://example.com' + }; + + const mockConfig: StarpodConfig = { + blurb: 'Test blurb', + description: 'Test description', + hosts: [{ name: 'Host One', bio: 'Bio', img: 'host.jpg' }], + platforms: { + spotify: 'https://spotify.com/show' + }, + rssFeed: 'https://example.com/rss.xml' + }; + + const mockEpisodes: Episode[] = [ + { + id: 'ep1', + title: 'Episode 1', + published: Date.now(), + description: 'First episode', + duration: 3600, + content: 'Content', + episodeSlug: 'episode-1', + episodeNumber: '1', + audio: { src: 'https://example.com/ep1.mp3', type: 'audio/mpeg' } + } + ]; + + it('generates homepage markdown with show overview and episodes', () => { + const siteUrl = new URL('https://podcast.example.com'); + const result = generateHomeMarkdown( + mockShow, + mockEpisodes, + mockConfig, + siteUrl + ); + + expect(result).toContain('# Test Podcast'); + expect(result).toContain('> Test blurb'); + expect(result).toContain('## Latest Episodes'); + expect(result).toContain( + 'https://podcast.example.com/episode-1.html.md' + ); + expect(result).toContain('## Explore'); + expect(result).toContain('/episodes-index.html.md'); + expect(result).toContain('/openapi.json'); + expect(result).toContain('## Listen'); + expect(result).toContain('https://spotify.com/show'); + }); + }); + + describe('generateContactMarkdown', () => { + const mockShow: Show = { + title: 'Test Podcast', + description: 'A test podcast', + image: 'https://example.com/image.jpg', + link: 'https://example.com' + }; + + const mockConfig: StarpodConfig = { + blurb: 'Test blurb', + description: 'Test description', + hosts: [ + { + name: 'Host One', + bio: 'Bio', + img: 'host.jpg', + twitter: 'https://twitter.com/host1', + github: 'https://github.com/host1', + website: 'https://host1.com' + } + ], + platforms: { + apple: 'https://apple.com/podcast' + }, + rssFeed: 'https://example.com/rss.xml' + }; + + it('points at the contact form and API endpoint', () => { + const siteUrl = new URL('https://podcast.example.com'); + const result = generateContactMarkdown(mockShow, mockConfig, siteUrl); + + expect(result).toContain('# Contact Test Podcast'); + expect(result).toContain('https://podcast.example.com/contact'); + expect(result).toContain('/api/contact'); + expect(result).toContain('`name`'); + }); + + it('includes host links and platforms', () => { + const result = generateContactMarkdown(mockShow, mockConfig); + + expect(result).toContain('### Host One'); + expect(result).toContain('https://twitter.com/host1'); + expect(result).toContain('https://github.com/host1'); + expect(result).toContain('https://apple.com/podcast'); + }); + }); });