diff --git a/apps/docs/app/page.tsx b/apps/docs/app/page.tsx index 337d9c7cc2b..d4ba6201cb7 100644 --- a/apps/docs/app/page.tsx +++ b/apps/docs/app/page.tsx @@ -29,7 +29,10 @@ const generateMetadata = async (_, parent: ResolvingMetadata): Promise ...(parentAlternates && { languages: parentAlternates.languages || undefined, media: parentAlternates.media || undefined, - types: parentAlternates.types || undefined, + types: { + ...(parentAlternates.types ?? {}), + 'text/markdown': '/llms-full.txt', + }, }), }, } diff --git a/apps/docs/features/docs/GuidesMdx.utils.tsx b/apps/docs/features/docs/GuidesMdx.utils.tsx index 23457ffdf14..1cd8e04a9eb 100644 --- a/apps/docs/features/docs/GuidesMdx.utils.tsx +++ b/apps/docs/features/docs/GuidesMdx.utils.tsx @@ -1,23 +1,23 @@ -import * as Sentry from '@sentry/nextjs' -import { fromMarkdown } from 'mdast-util-from-markdown' -import { gfmFromMarkdown } from 'mdast-util-gfm' -import { gfm } from 'micromark-extension-gfm' -import { type Metadata, type ResolvingMetadata } from 'next' -import { notFound } from 'next/navigation' import { readdir } from 'node:fs/promises' import { extname, join, relative, sep } from 'node:path' - +import * as Sentry from '@sentry/nextjs' import { extractMessageFromAnyError, FileNotFoundError } from '~/app/api/utils' import { pluckPromise } from '~/features/helpers.fn' import { cache_fullProcess_withDevCacheBust, existsFile } from '~/features/helpers.fs' import type { OrPromise } from '~/features/helpers.types' import { generateOpenGraphImageMeta } from '~/features/seo/openGraph' import { BASE_PATH } from '~/lib/constants' +import { getCustomContent } from '~/lib/custom-content/getCustomContent' import { GUIDES_DIRECTORY, isValidGuideFrontmatter, type GuideFrontmatter } from '~/lib/docs' import { GuideModelLoader } from '~/resources/guide/guideModelLoader' +import { fromMarkdown } from 'mdast-util-from-markdown' +import { gfmFromMarkdown } from 'mdast-util-gfm' +import { gfm } from 'micromark-extension-gfm' +import { type Metadata, type ResolvingMetadata } from 'next' +import { notFound } from 'next/navigation' + import { newEditLink } from './GuidesMdx.template' import { checkGuidePageEnabled } from './NavigationPageStatus.utils' -import { getCustomContent } from '~/lib/custom-content/getCustomContent' const { metadataTitle } = getCustomContent(['metadata:title']) @@ -179,6 +179,10 @@ const genGuideMeta = alternates: { ...parentAlternates, canonical: meta.canonical || `${BASE_PATH}${pathname}`, + types: { + ...(parentAlternates?.types ?? {}), + 'text/markdown': `${BASE_PATH}${pathname}.md`, + }, }, openGraph: { ...parentOg, diff --git a/apps/www/.gitignore b/apps/www/.gitignore index 16d2ef1ae15..6645817c78c 100644 --- a/apps/www/.gitignore +++ b/apps/www/.gitignore @@ -28,6 +28,9 @@ yarn-error.log* # Sitemap public/sitemap.xml +# Generated .md content bundle (built by scripts/generateMdContent.mjs) +app/api-v2/md/content.generated.ts + # contentlayer .contentlayer .vercel diff --git a/apps/www/.vercelignore b/apps/www/.vercelignore index 30470ae55a6..b6cdb01acf3 100644 --- a/apps/www/.vercelignore +++ b/apps/www/.vercelignore @@ -30,8 +30,12 @@ public/fonts/**/* **/coverage # Documentation +# We can't use a `*.md` + `!content/md/**` pattern here — Vercel's ignore +# engine excludes content/md/ anyway, breaking generateMdContent.mjs at build +# time. Match READMEs explicitly. Other stray .md files (Edge Function READMEs, +# scratch notes) will ship in the build artifact but aren't routable. README.md -*.md +**/README.md docs/**/* # IDE files diff --git a/apps/www/app/api-v2/md/[...slug]/route.ts b/apps/www/app/api-v2/md/[...slug]/route.ts new file mode 100644 index 00000000000..af658dcf8bf --- /dev/null +++ b/apps/www/app/api-v2/md/[...slug]/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from 'next/server' + +import { MD_CONTENT } from '../content.generated' +import { generatePricingContent } from '@/lib/llms' + +// Static .md files are bundled at build time, so they're safe to cache at the +// edge for a day. Without s-maxage Vercel's CDN won't cache the response and +// every request would hit the lambda. +const STATIC_HEADERS = { + 'Content-Type': 'text/markdown; charset=utf-8', + 'X-Content-Type-Options': 'nosniff', + 'Cache-Control': 'public, max-age=86400, s-maxage=86400, stale-while-revalidate=3600', + Vary: 'Accept', +} + +// Pricing is generated dynamically from shared-data without a content rebuild, +// so use a shorter edge cache to match /llms.txt and /llms-full.txt. +const DYNAMIC_HEADERS = { + 'Content-Type': 'text/markdown; charset=utf-8', + 'X-Content-Type-Options': 'nosniff', + 'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400', + Vary: 'Accept', +} + +export async function GET(_request: Request, { params }: { params: Promise<{ slug: string[] }> }) { + const { slug } = await params + const slugPath = slug.join('/') + + if (slugPath === 'pricing') { + return new NextResponse(generatePricingContent(), { headers: DYNAMIC_HEADERS }) + } + + const content = MD_CONTENT.get(slugPath) + if (!content) { + return new NextResponse('Not found', { status: 404 }) + } + + return new NextResponse(content, { headers: STATIC_HEADERS }) +} diff --git a/apps/www/app/llms-full.txt/route.ts b/apps/www/app/llms-full.txt/route.ts index 6d6d3e78fa7..8c982c48053 100644 --- a/apps/www/app/llms-full.txt/route.ts +++ b/apps/www/app/llms-full.txt/route.ts @@ -1,7 +1,6 @@ -import { readFile } from 'node:fs/promises' -import { join } from 'node:path' import { isFeatureEnabled } from 'common/enabled-features' +import { MD_CONTENT } from '@/app/api-v2/md/content.generated' import { generatePricingContent } from '@/lib/llms' export const dynamic = 'force-dynamic' @@ -33,25 +32,10 @@ function getSources(): Source[] { ] } -// Order matters: homepage first, products alphabetical in between. -// pricing.txt is generated dynamically via generatePricingContent(). -const PRODUCT_LLM_FILES = [ - 'homepage.txt', - 'auth.txt', - 'database.txt', - 'edge-functions.txt', - 'realtime.txt', - 'storage.txt', - 'vector.txt', -] - +// Order is set by scripts/generateMdContent.mjs (homepage first, rest +// alphabetical). pricing is appended here since it's dynamic. async function readProductOverviews(): Promise { - const staticContents = await Promise.all( - PRODUCT_LLM_FILES.map((file) => { - const filePath = join(process.cwd(), 'data/llms', file) - return readFile(filePath, 'utf-8') - }) - ) + const staticContents = [...MD_CONTENT.values()] const pricingContent = generatePricingContent() return [...staticContents, pricingContent].join('\n\n---\n\n') diff --git a/apps/www/app/llms.txt/route.ts b/apps/www/app/llms.txt/route.ts index 0dd93224ca1..722d4ffec2d 100644 --- a/apps/www/app/llms.txt/route.ts +++ b/apps/www/app/llms.txt/route.ts @@ -29,15 +29,21 @@ function getSources(): Source[] { ] } +// Editorial ordering for the product overview list (mirrors the homepage +// products section); not derived from MD_PAGES because the order is +// intentional. When dropping a new content/md/.md file, add a matching +// entry here too — otherwise the page ships but won't be linked from /llms.txt. const PRODUCT_OVERVIEW_LINKS = [ - '- [Supabase Overview](https://supabase.com/llms/homepage.txt)', - '- [Supabase Database](https://supabase.com/llms/database.txt)', - '- [Supabase Auth](https://supabase.com/llms/auth.txt)', - '- [Supabase Storage](https://supabase.com/llms/storage.txt)', - '- [Supabase Edge Functions](https://supabase.com/llms/edge-functions.txt)', - '- [Supabase Realtime](https://supabase.com/llms/realtime.txt)', - '- [Supabase Vector](https://supabase.com/llms/vector.txt)', - '- [Supabase Pricing](https://supabase.com/llms/pricing.txt)', + '- [Supabase Overview](https://supabase.com/homepage.md)', + '- [Supabase Database](https://supabase.com/database.md)', + '- [Supabase Auth](https://supabase.com/auth.md)', + '- [Supabase Storage](https://supabase.com/storage.md)', + '- [Supabase Edge Functions](https://supabase.com/edge-functions.md)', + '- [Supabase Realtime](https://supabase.com/realtime.md)', + '- [Supabase Vector](https://supabase.com/vector.md)', + '- [Supabase Cron](https://supabase.com/modules/cron.md)', + '- [Supabase Queues](https://supabase.com/modules/queues.md)', + '- [Supabase Pricing](https://supabase.com/pricing.md)', ].join('\n') export async function GET() { diff --git a/apps/www/app/llms/[slug]/route.ts b/apps/www/app/llms/[slug]/route.ts index 7286ca2fe9b..08b6678478c 100644 --- a/apps/www/app/llms/[slug]/route.ts +++ b/apps/www/app/llms/[slug]/route.ts @@ -1,6 +1,3 @@ -import { readFile } from 'node:fs/promises' -import { join } from 'node:path' - import { generatePricingContent } from '@/lib/llms' export const dynamic = 'force-dynamic' @@ -14,6 +11,10 @@ function textResponse(content: string) { }) } +// Product overview slugs and pricing.txt are 301-redirected to /.md in +// apps/www/lib/redirects.js, so this handler typically only sees per-SDK +// reference files. The pricing branch stays as a fallback in case the redirect +// is bypassed. export async function GET(_request: Request, { params }: { params: Promise<{ slug: string }> }) { const { slug } = await params @@ -21,21 +22,10 @@ export async function GET(_request: Request, { params }: { params: Promise<{ slu return new Response('Not Found', { status: 404 }) } - // 1. Check for dynamically generated content (e.g. pricing) if (slug === 'pricing.txt') { return textResponse(generatePricingContent()) } - // 2. Check for a local static file in public/llms/ - try { - const filePath = join(process.cwd(), 'data/llms', slug) - const content = await readFile(filePath, 'utf-8') - return textResponse(content) - } catch { - // File doesn't exist locally, fall through - } - - // 3. Try fetching from the docs app const docsUrl = process.env.NEXT_PUBLIC_DOCS_URL if (docsUrl) { const response = await fetch(`${docsUrl}/llms/${slug}`) diff --git a/apps/www/app/pricing/page.tsx b/apps/www/app/pricing/page.tsx index 4c147170a10..e06601843a1 100644 --- a/apps/www/app/pricing/page.tsx +++ b/apps/www/app/pricing/page.tsx @@ -1,10 +1,16 @@ import type { Metadata } from 'next' + import PricingContent from './PricingContent' export const metadata: Metadata = { title: 'Pricing & Fees | Supabase', description: 'Explore Supabase fees and pricing information. Find our competitive pricing Plans, with no hidden pricing. We have a generous Free Plan for those getting started, and Pay As You Go for those scaling up.', + alternates: { + types: { + 'text/markdown': '/pricing.md', + }, + }, openGraph: { title: 'Pricing & Fees | Supabase', description: diff --git a/apps/www/data/llms/auth.txt b/apps/www/content/md/auth.md similarity index 100% rename from apps/www/data/llms/auth.txt rename to apps/www/content/md/auth.md diff --git a/apps/www/data/llms/database.txt b/apps/www/content/md/database.md similarity index 100% rename from apps/www/data/llms/database.txt rename to apps/www/content/md/database.md diff --git a/apps/www/data/llms/edge-functions.txt b/apps/www/content/md/edge-functions.md similarity index 100% rename from apps/www/data/llms/edge-functions.txt rename to apps/www/content/md/edge-functions.md diff --git a/apps/www/data/llms/homepage.txt b/apps/www/content/md/homepage.md similarity index 100% rename from apps/www/data/llms/homepage.txt rename to apps/www/content/md/homepage.md diff --git a/apps/www/content/md/modules/cron.md b/apps/www/content/md/modules/cron.md new file mode 100644 index 00000000000..c8b7fd9c6c8 --- /dev/null +++ b/apps/www/content/md/modules/cron.md @@ -0,0 +1,38 @@ +# Supabase Cron + +> Schedule and manage recurring jobs directly in Postgres with pg_cron. + +Supabase Cron is a Postgres module that uses the pg_cron extension to schedule and manage recurring jobs. Define schedules with standard cron syntax or natural language, and run jobs that call database functions, Edge Functions, or remote webhooks. + +## Key Features + +- **Postgres native**: schedule and run jobs directly within your database, no external scheduler needed +- **Cron syntax and natural language**: use familiar cron expressions or plain English to define intervals +- **Sub-minute scheduling**: run jobs as frequently as every 1-59 seconds +- **Real-time monitoring**: track and debug scheduled jobs with built-in observability tools +- **Extensible**: trigger database functions, Supabase Edge Functions, or HTTP webhooks +- **Dashboard management**: create, edit, and monitor jobs through an intuitive UI +- **SQL-based**: manage jobs using simple SQL commands, track changes with Postgres migrations +- **100% open source**: built on pg_cron, a trusted community-driven extension + +## Common Use Cases + +- Periodic data cleanup or archival +- Scheduled report generation +- Recurring API calls or webhook triggers +- Database maintenance tasks (vacuum, reindex) +- Timed cache invalidation +- Periodic data synchronization between systems + +## Technical Details + +- Extension: pg_cron (open source) +- Minimum interval: 1 second +- Schedule format: cron syntax (minute/hour/day/month/weekday) or natural language +- Job targets: SQL statements, database functions, Edge Functions, HTTP endpoints +- Monitoring: job run history with status, duration, and error details + +## Links + +- Documentation: https://supabase.com/docs/guides/cron +- Dashboard: https://supabase.com/dashboard/project/_/integrations/cron/overview diff --git a/apps/www/content/md/modules/queues.md b/apps/www/content/md/modules/queues.md new file mode 100644 index 00000000000..43b86b891b2 --- /dev/null +++ b/apps/www/content/md/modules/queues.md @@ -0,0 +1,37 @@ +# Supabase Queues + +> Durable message queues with guaranteed delivery, powered by Postgres and pgmq. + +Supabase Queues is a Postgres module that uses the pgmq extension to provide durable message queues with exactly-once delivery within a visibility window. Manage queues using SQL, the Supabase client libraries, or the Dashboard. + +## Key Features + +- **Postgres native**: create and manage queues directly within your database +- **Exactly-once delivery**: messages are delivered exactly once within a configurable visibility window +- **Message archival**: archive messages instead of deleting them for audit trails and future reference +- **Real-time monitoring**: track and manage messages with built-in observability tools +- **Multiple access methods**: manage via SQL, PostgREST API (server-side or client-side), or Dashboard +- **Dashboard management**: create queues, send messages, and monitor processing in real time +- **100% open source**: built on pgmq, a trusted community-driven extension + +## Common Use Cases + +- Asynchronous task processing +- Event-driven workflows +- Background job scheduling +- Inter-service communication +- Webhook delivery with retry logic +- Order processing and fulfillment pipelines + +## Technical Details + +- Extension: pgmq (open source) +- Delivery guarantee: exactly-once within visibility window +- Message format: JSONB payload +- Access methods: SQL, PostgREST API, Supabase client libraries +- Monitoring: queue depth, message status, processing metrics + +## Links + +- Documentation: https://supabase.com/docs/guides/queues +- Dashboard: https://supabase.com/dashboard/project/_/integrations/queues/overview diff --git a/apps/www/data/llms/realtime.txt b/apps/www/content/md/realtime.md similarity index 99% rename from apps/www/data/llms/realtime.txt rename to apps/www/content/md/realtime.md index 72e8804e641..d473344154f 100644 --- a/apps/www/data/llms/realtime.txt +++ b/apps/www/content/md/realtime.md @@ -7,12 +7,15 @@ Supabase Realtime enables live data synchronization between your database and co ## Capabilities ### Database Changes + Listen to Postgres INSERT, UPDATE, and DELETE events in real time. Subscribe to specific tables, filter by columns, and receive only the changes you care about. Powered by Postgres logical replication. ### Presence + Store and synchronize online user state across all connected clients. Track who is online, what page they are viewing, or their cursor position. State is automatically cleaned up when clients disconnect. ### Broadcast + Send arbitrary messages to all clients subscribed to the same Channel. Useful for typing indicators, live cursors, game state, notifications, or any real-time communication that does not need to be persisted. ## Technical Details diff --git a/apps/www/data/llms/storage.txt b/apps/www/content/md/storage.md similarity index 99% rename from apps/www/data/llms/storage.txt rename to apps/www/content/md/storage.md index f552c873282..596ff6e3244 100644 --- a/apps/www/data/llms/storage.txt +++ b/apps/www/content/md/storage.md @@ -15,12 +15,15 @@ Supabase Storage is an open source object store that integrates natively with Su ## Bucket Types ### Files Buckets + For everyday assets and user content: images, videos, documents, PDFs, archives. Served from the global CDN with fine-grained access controls via RLS policies. ### Analytics Buckets + For large-scale analytical workloads on open table formats (Apache Iceberg). Designed for historical data, time-series data, logs, and ETL outputs. Optionally queryable via Postgres. ### Vector Buckets + For AI/ML workloads. Store and index vector embeddings with multiple distance metrics, metadata filtering, and fast similarity queries for RAG systems and AI-powered search. ## Technical Details diff --git a/apps/www/data/llms/vector.txt b/apps/www/content/md/vector.md similarity index 100% rename from apps/www/data/llms/vector.txt rename to apps/www/content/md/vector.md diff --git a/apps/www/lib/redirects.js b/apps/www/lib/redirects.js index 150b904e831..3c1f93b6fd4 100644 --- a/apps/www/lib/redirects.js +++ b/apps/www/lib/redirects.js @@ -3127,4 +3127,13 @@ module.exports = [ source: '/docs/llms-full.txt', destination: '/llms-full.txt', }, + // Legacy product .txt URLs → new .md routes + { permanent: true, source: '/llms/homepage.txt', destination: '/homepage.md' }, + { permanent: true, source: '/llms/auth.txt', destination: '/auth.md' }, + { permanent: true, source: '/llms/database.txt', destination: '/database.md' }, + { permanent: true, source: '/llms/edge-functions.txt', destination: '/edge-functions.md' }, + { permanent: true, source: '/llms/realtime.txt', destination: '/realtime.md' }, + { permanent: true, source: '/llms/storage.txt', destination: '/storage.md' }, + { permanent: true, source: '/llms/vector.txt', destination: '/vector.md' }, + { permanent: true, source: '/llms/pricing.txt', destination: '/pricing.md' }, ] diff --git a/apps/www/middleware.test.ts b/apps/www/middleware.test.ts index 9db33bf1a4a..c6bcad36563 100644 --- a/apps/www/middleware.test.ts +++ b/apps/www/middleware.test.ts @@ -1,17 +1,26 @@ -import { NextRequest } from 'next/server' -import { describe, expect, it } from 'vitest' - import { FIRST_REFERRER_COOKIE_NAME } from 'common/first-referrer-cookie' +import { NextRequest } from 'next/server' +import { describe, expect, it, vi } from 'vitest' import { middleware } from './middleware' +// content.generated.ts is produced by scripts/generateMdContent.mjs at +// content:build time and gitignored, so it isn't on disk in CI before tests +// run. The mock seeds a representative allowlist so the .md-routing branches +// are actually exercised below. +vi.mock('./app/api-v2/md/content.generated', () => ({ + MD_CONTENT: new Map(), + MD_PAGES: new Set(['homepage', 'auth', 'pricing']), +})) + function makeRequest( url: string, - { referer, hasCookie }: { referer?: string; hasCookie?: boolean } = {} + { referer, hasCookie, accept }: { referer?: string; hasCookie?: boolean; accept?: string } = {} ): NextRequest { - const req = new NextRequest(new URL(url, 'https://supabase.com'), { - headers: referer ? { referer } : {}, - }) + const headers: Record = {} + if (referer) headers.referer = referer + if (accept) headers.accept = accept + const req = new NextRequest(new URL(url, 'https://supabase.com'), { headers }) if (hasCookie) { req.cookies.set(FIRST_REFERRER_COOKIE_NAME, 'existing') } @@ -84,4 +93,59 @@ describe('www middleware', () => { expect(res.cookies.get(FIRST_REFERRER_COOKIE_NAME)).toBeUndefined() }) }) + + describe('.md suffix routing', () => { + it('rewrites /.md for allowlisted slugs', () => { + const req = makeRequest('/auth.md') + const res = middleware(req) + + expect(res.headers.get('x-middleware-rewrite')).toBe('https://supabase.com/api-v2/md/auth') + }) + + it('falls through for non-allowlisted .md slugs', () => { + const req = makeRequest('/not-a-page.md') + const res = middleware(req) + + expect(res.headers.get('x-middleware-rewrite')).toBeNull() + }) + }) + + describe('Accept: text/markdown content negotiation', () => { + it('rewrites / to homepage when Accept: text/markdown', () => { + const req = makeRequest('/', { accept: 'text/markdown' }) + const res = middleware(req) + + expect(res.headers.get('x-middleware-rewrite')).toBe( + 'https://supabase.com/api-v2/md/homepage' + ) + }) + + it('rewrites / when Accept: text/markdown matches the allowlist', () => { + const req = makeRequest('/auth', { accept: 'text/markdown' }) + const res = middleware(req) + + expect(res.headers.get('x-middleware-rewrite')).toBe('https://supabase.com/api-v2/md/auth') + }) + + it('rewrites // (trailing slash) the same as /', () => { + const req = makeRequest('/auth/', { accept: 'text/markdown' }) + const res = middleware(req) + + expect(res.headers.get('x-middleware-rewrite')).toBe('https://supabase.com/api-v2/md/auth') + }) + + it('falls through when Accept does not include text/markdown', () => { + const req = makeRequest('/auth', { accept: 'text/html' }) + const res = middleware(req) + + expect(res.headers.get('x-middleware-rewrite')).toBeNull() + }) + + it('falls through when slug is not in the allowlist', () => { + const req = makeRequest('/not-a-page', { accept: 'text/markdown' }) + const res = middleware(req) + + expect(res.headers.get('x-middleware-rewrite')).toBeNull() + }) + }) }) diff --git a/apps/www/middleware.ts b/apps/www/middleware.ts index 025daf09785..bc4fb7cae3d 100644 --- a/apps/www/middleware.ts +++ b/apps/www/middleware.ts @@ -1,7 +1,31 @@ import { stampFirstReferrerCookie } from 'common/first-referrer-cookie' import { NextResponse, type NextRequest } from 'next/server' +import { MD_PAGES } from './app/api-v2/md/content.generated' + export function middleware(request: NextRequest) { + const { pathname } = request.nextUrl + + // Handle /.md suffix: /pricing.md -> /api-v2/md/pricing + if (pathname.endsWith('.md')) { + const slug = pathname.slice(1, -3) // strip leading / and trailing .md + if (MD_PAGES.has(slug)) { + return NextResponse.rewrite(new URL(`/api-v2/md/${slug}`, request.nextUrl)) + } + } + + // Content negotiation: Accept: text/markdown on known pages + const accept = (request.headers.get('accept') ?? '').toLowerCase() + if (accept.includes('text/markdown')) { + // Strip trailing slash so /auth/ and /auth resolve to the same allowlist entry. + // (NextURL's pathname setter preserves the trailing-slash style of the cloned + // origin, which would otherwise leak through to the rewrite target.) + const slug = (pathname === '/' ? 'homepage' : pathname.slice(1)).replace(/\/$/, '') + if (MD_PAGES.has(slug)) { + return NextResponse.rewrite(new URL(`/api-v2/md/${slug}`, request.nextUrl)) + } + } + const response = NextResponse.next() stampFirstReferrerCookie(request, response) return response diff --git a/apps/www/next.config.mjs b/apps/www/next.config.mjs index 0709069e483..72b5e5db4ec 100644 --- a/apps/www/next.config.mjs +++ b/apps/www/next.config.mjs @@ -71,10 +71,6 @@ const nextConfig = { 'public/**/*', ], }, - outputFileTracingIncludes: { - '/llms-full.txt': ['./data/llms/**/*'], - '/llms/[slug]': ['./data/llms/**/*'], - }, reactStrictMode: true, images: { dangerouslyAllowSVG: false, diff --git a/apps/www/package.json b/apps/www/package.json index da52d925fd3..6c1e36dc765 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -13,7 +13,7 @@ "clean": "rimraf node_modules", "pretypecheck": "next typegen", "typecheck": "pnpm run content:build && tsc --noEmit", - "content:build": "node scripts/generateStaticContent.mjs", + "content:build": "node scripts/generateStaticContent.mjs && node scripts/generateMdContent.mjs", "test": "vitest --run", "test:watch": "vitest watch", "postbuild": "node ./internals/generate-sitemap.mjs && ./../../scripts/upload-static-assets.sh" diff --git a/apps/www/pages/_app.tsx b/apps/www/pages/_app.tsx index f6f1e20afdb..a6e09dbebe3 100644 --- a/apps/www/pages/_app.tsx +++ b/apps/www/pages/_app.tsx @@ -26,6 +26,7 @@ import { useConsentToast } from 'ui-patterns/consent' import useDarkLaunchWeeks from '../hooks/useDarkLaunchWeeks' import { useWwwCommandMenuTelemetry } from '../hooks/useWwwCommandMenuTelemetry' +import { MD_PAGES } from '@/app/api-v2/md/content.generated' import { Toaster } from '@/app/toaster' import { WwwCommandMenu } from '@/components/CommandMenu' import { API_URL, APP_NAME, DEFAULT_META_DESCRIPTION } from '@/lib/constants' @@ -53,10 +54,20 @@ export default function App({ Component, pageProps }: AppProps) { themeColor = 'FFFFFF' } + // Advertise the .md version for AI agents on pages that have one. + const cleanPath = (router.asPath ?? '/').split('?')[0].split('#')[0].replace(/\/$/, '') || '/' + const mdSlug = cleanPath === '/' ? 'homepage' : cleanPath.slice(1) + const mdAlternateHref = MD_PAGES.has(mdSlug) + ? cleanPath === '/' + ? '/homepage.md' + : `${cleanPath}.md` + : null + return ( <> + {mdAlternateHref && } DYNAMIC_SLUGS.includes(s)) +if (collisions.length > 0) { + console.error( + `❌ Slug collision: [${collisions.join(', ')}] is reserved for a dynamic generator. ` + + `Remove the corresponding file from content/md/ or update DYNAMIC_SLUGS.` + ) + process.exit(1) +} + +const entries = [] +const errors = [] +for (const slug of slugs) { + const filePath = path.join(contentDir, `${slug}.md`) + try { + const content = await fs.readFile(filePath, 'utf-8') + entries.push(` [${JSON.stringify(slug)}, ${JSON.stringify(content)}]`) + } catch (err) { + errors.push(`${slug}.md: ${err.message}`) + } +} + +if (errors.length > 0) { + console.error('❌ Failed to read .md files:') + errors.forEach((e) => console.error(` ${e}`)) + process.exit(1) +} + +const allSlugs = [...slugs, ...DYNAMIC_SLUGS] +const pageEntries = allSlugs.map((s) => ` ${JSON.stringify(s)}`).join(',\n') + +const output = `// AUTO-GENERATED by scripts/generateMdContent.mjs — do not edit +export const MD_CONTENT = new Map([ +${entries.join(',\n')}, +]) + +export const MD_PAGES = new Set([ +${pageEntries}, +]) +` + +await fs.writeFile(outputPath, output, 'utf-8') +console.log(`✅ Generated ${outputPath} (${entries.length} files, ${allSlugs.length} pages)`)