From 21a27eeb4f2a5edc7de231144b82ee19b758794d Mon Sep 17 00:00:00 2001 From: Pamela Chia Date: Mon, 24 Aug 2026 15:19:56 +0800 Subject: [PATCH] feat(www): canonicalize homepage markdown at /index.md (#49384) The www root markdown lived at an accidental URL: `/.md` served the homepage markdown only because middleware strips the `.md` suffix and the empty slug fell through to the homepage allowlist entry, while the canonical-looking `/index.md` 404'd. The served markdown also opened with stale legacy positioning copy that no longer matches the site. I renamed the homepage content slug to `index` end-to-end so `/index.md` is the one canonical markdown URL. **Changed:** - **`/index.md` serves the homepage markdown (200 `text/markdown`)**: `content/md/homepage.md` renamed to `index.md`; the middleware bare-root slug mapping, the generator's sort special-case, and the homepage alternate tag follow, so the tag now advertises `/index.md`. - **Legacy aliases 308 to the canonical URL**: `/.md`, `/homepage.md`, and bare `/index` redirect via `lib/redirects.js`; `/llms/homepage.txt` retargeted straight to `/index.md` to avoid a redirect chain. New `next.config.test.ts` assertions pin all four. - **Positioning refreshed**: the markdown now opens with "Supabase is the Postgres development platform" (matching the site title), replacing the outdated tagline. - **Generator safety**: the redirect-exclusion filter in `generateMdContent.mjs` now exempts the `index` slug (its HTML page is `/`, not `/index`, so a `/index` redirect never refers to it), and the build fails if `content/md/index.md` ever goes missing while middleware still maps `/` to the `index` slug. - **CI actually runs the new assertions**: I widened the `www-tests.yml` paths filter to include `apps/www/lib/**/*.js`, `apps/www/content/md/**`, and `apps/www/scripts/**/*.mjs`. It previously only matched `.ts*` and the next.config files, so a PR touching only `lib/redirects.js`, the markdown content, or the generator would skip the tests that pin these redirects. **Note:** the existing homepage alternate tag still exists, re-pointed to the canonical URL. Whether the homepage should advertise a markdown sibling at all is a separate decision; leaving it aimed at a 308 would break tag consumers. Positioning wording is editorial, happy to tweak. ## To test Tested on Vercel preview: - [x] `curl -si /index.md`: expect 200 `content-type: text/markdown`, body opens with the Postgres development platform positioning and no longer contains the old tagline - [x] `curl -sI /.md`: expect 308 with `location: /index.md` - [x] `curl -sI /homepage.md` and `curl -sI /llms/homepage.txt`: expect 308 with `location: /index.md` - [x] `curl -sI /index`: expect 308 with `location: /` - [x] `curl -s -H "Accept: text/markdown" -o /dev/null -w "%{http_code} %{content_type}" /`: expect `200 text/markdown` (bare-URL negotiation unchanged) - [x] `curl -s / | grep -o 'type="text/markdown" href="[^"]*"'`: expect href ending `/index.md` ## Linear - fixes GROWTH-1117 ## Summary by CodeRabbit - **New Features** - Added support for `/index.md` as the canonical Markdown representation of the homepage. - Added permanent redirects for legacy homepage Markdown and text URLs. - Added `/index` to `/` redirect handling. - **Bug Fixes** - Updated homepage metadata, alternate links, Markdown negotiation, and content generation to consistently use the new canonical path. - Improved homepage content description. - **Tests** - Expanded coverage for homepage Markdown routes, redirects, and URL matching. --- .github/workflows/www-tests.yml | 3 +++ apps/www/app/(home)/page.tsx | 2 +- apps/www/components/MarkdownActions.tsx | 2 +- apps/www/content/md/{homepage.md => index.md} | 2 +- apps/www/lib/redirects.js | 5 +++- apps/www/md-alternates.test.ts | 2 +- apps/www/middleware.test.ts | 22 ++++++++++++++---- apps/www/middleware.ts | 2 +- apps/www/next.config.test.ts | 23 +++++++++++++++++++ apps/www/pages/_app.tsx | 4 ++-- apps/www/scripts/generateMdContent.mjs | 17 ++++++++++---- 11 files changed, 67 insertions(+), 17 deletions(-) rename apps/www/content/md/{homepage.md => index.md} (89%) diff --git a/.github/workflows/www-tests.yml b/.github/workflows/www-tests.yml index a31cffd6d62..a29e1b4765c 100644 --- a/.github/workflows/www-tests.yml +++ b/.github/workflows/www-tests.yml @@ -7,6 +7,9 @@ on: - 'apps/www/**/*.ts*' - 'apps/www/next.config.mjs' - 'apps/www/next.config.js' + - 'apps/www/lib/**/*.js' + - 'apps/www/content/md/**' + - 'apps/www/scripts/**/*.mjs' # Cancel old builds on new commit for same workflow + branch/PR concurrency: diff --git a/apps/www/app/(home)/page.tsx b/apps/www/app/(home)/page.tsx index cf0bf976c51..26e4afba594 100644 --- a/apps/www/app/(home)/page.tsx +++ b/apps/www/app/(home)/page.tsx @@ -5,7 +5,7 @@ import { HomeContent } from './_components/HomeContent' import { mdAlternates } from '@/lib/md-alternates' export const metadata: Metadata = { - alternates: mdAlternates('homepage'), + alternates: mdAlternates('index'), } export default function HomePage() { diff --git a/apps/www/components/MarkdownActions.tsx b/apps/www/components/MarkdownActions.tsx index 236c0679ee3..c82218e0e31 100644 --- a/apps/www/components/MarkdownActions.tsx +++ b/apps/www/components/MarkdownActions.tsx @@ -27,7 +27,7 @@ export function MarkdownActions({ }: Props) { const { copied, copyMarkdown } = useCopyMarkdownFromUrl() const sendTelemetryEvent = useSendTelemetryEvent() - const mdPath = pagePath === '/' ? '/homepage.md' : `${pagePath}.md` + const mdPath = pagePath === '/' ? '/index.md' : `${pagePath}.md` const urls = askAiUrls(`${SITE_ORIGIN}${pagePath === '/' ? '' : pagePath}`) async function handleCopy() { diff --git a/apps/www/content/md/homepage.md b/apps/www/content/md/index.md similarity index 89% rename from apps/www/content/md/homepage.md rename to apps/www/content/md/index.md index 98a9b5be841..fdbd5f352a2 100644 --- a/apps/www/content/md/homepage.md +++ b/apps/www/content/md/index.md @@ -2,7 +2,7 @@ > Build in a weekend. Scale to millions. -Supabase is an open source Firebase alternative built on Postgres. It provides a complete backend platform for building web and mobile applications, with a suite of integrated tools that work together out of the box. +Supabase is the Postgres development platform: an open source backend for building web and mobile applications, with a suite of integrated tools that work together out of the box. ## Products diff --git a/apps/www/lib/redirects.js b/apps/www/lib/redirects.js index 29839256f39..5a7e091eb1a 100644 --- a/apps/www/lib/redirects.js +++ b/apps/www/lib/redirects.js @@ -3384,7 +3384,7 @@ module.exports = [ permanent: false, }, // Legacy product .txt URLs → new .md routes - { permanent: true, source: '/llms/homepage.txt', destination: '/homepage.md' }, + { permanent: true, source: '/llms/homepage.txt', destination: '/index.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' }, @@ -3393,4 +3393,7 @@ module.exports = [ { permanent: true, source: '/llms/vector.txt', destination: '/modules/vector.md' }, { permanent: true, source: '/llms/pricing.txt', destination: '/pricing.md' }, { permanent: true, source: '/vector.md', destination: '/modules/vector.md' }, + { permanent: true, source: '/homepage.md', destination: '/index.md' }, + { permanent: true, source: '/.md', destination: '/index.md' }, + { permanent: true, source: '/index', destination: '/' }, ] diff --git a/apps/www/md-alternates.test.ts b/apps/www/md-alternates.test.ts index 707d7d7df89..1f1fdde175b 100644 --- a/apps/www/md-alternates.test.ts +++ b/apps/www/md-alternates.test.ts @@ -36,7 +36,7 @@ async function collectAppRouterPages( const nested = await collectAppRouterPages(path.join(dir, dirent.name), nextSegments) nested.forEach((filePath, slug) => pages.set(slug, filePath)) } else if (dirent.name === 'page.tsx') { - pages.set(segments.join('/') || 'homepage', path.join(dir, dirent.name)) + pages.set(segments.join('/') || 'index', path.join(dir, dirent.name)) } } return pages diff --git a/apps/www/middleware.test.ts b/apps/www/middleware.test.ts index 9e486bef4f8..aac6a0de94a 100644 --- a/apps/www/middleware.test.ts +++ b/apps/www/middleware.test.ts @@ -10,7 +10,7 @@ import { middleware } from './middleware' // are actually exercised below. vi.mock('./app/api-v2/md/content.generated', () => ({ MD_CONTENT: new Map(), - MD_PAGES: new Set(['homepage', 'auth', 'pricing']), + MD_PAGES: new Set(['index', 'auth', 'pricing']), CHANGELOG_PAGES: new Set(['changelog', 'changelog/100', 'changelog/pipelines']), })) @@ -116,6 +116,20 @@ describe('www middleware', () => { expect(res.headers.get('x-middleware-rewrite')).toBeNull() }) + it('rewrites /index.md to the homepage markdown', () => { + const req = makeRequest('/index.md') + const res = middleware(req) + + expect(res.headers.get('x-middleware-rewrite')).toBe('https://supabase.com/api-v2/md/index') + }) + + it('falls back to serving /.md as homepage markdown if the config 308 is ever removed', () => { + const req = makeRequest('/.md') + const res = middleware(req) + + expect(res.headers.get('x-middleware-rewrite')).toBe('https://supabase.com/api-v2/md/index') + }) + it('rewrites changelog entry .md requests without doubling the suffix', () => { const req = makeRequest('/changelog/100.md', { accept: 'text/markdown' }) const res = middleware(req) @@ -154,13 +168,11 @@ describe('www middleware', () => { }) describe('Accept: text/markdown content negotiation', () => { - it('rewrites / to homepage when Accept: text/markdown', () => { + it('rewrites / to the homepage index slug 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' - ) + expect(res.headers.get('x-middleware-rewrite')).toBe('https://supabase.com/api-v2/md/index') }) it('rewrites / when Accept: text/markdown matches the allowlist', () => { diff --git a/apps/www/middleware.ts b/apps/www/middleware.ts index 75aa9a99537..3800925883c 100644 --- a/apps/www/middleware.ts +++ b/apps/www/middleware.ts @@ -12,7 +12,7 @@ export function middleware(request: NextRequest) { // Strip trailing slash so /auth/ and /auth resolve to the same allowlist // entry — NextURL preserves trailing-slash style on rewrite targets. - const slug = (basePathname === '/' ? 'homepage' : basePathname.slice(1)).replace(/\/$/, '') + const slug = (basePathname === '/' ? 'index' : basePathname.slice(1)).replace(/\/$/, '') const isMdEligible = MD_PAGES.has(slug) const isChangelogEntry = CHANGELOG_PAGES.has(slug) diff --git a/apps/www/next.config.test.ts b/apps/www/next.config.test.ts index 8c0850e6aa0..e88f7141ece 100644 --- a/apps/www/next.config.test.ts +++ b/apps/www/next.config.test.ts @@ -61,6 +61,29 @@ describe('next.config.mjs', () => { ).toBeLessThan(redirects.findIndex((redirect) => redirect.source === '/ui/:path*')) }) + it('permanently redirects the legacy root markdown aliases to /index.md', async () => { + const { default: config } = (await import('./next.config.mjs')) as { default: NextConfig } + const redirects = (await config.redirects?.()) || [] + + for (const source of ['/.md', '/homepage.md', '/llms/homepage.txt']) { + expect(redirects).toEqual( + expect.arrayContaining([ + expect.objectContaining({ source, destination: '/index.md', permanent: true }), + ]) + ) + } + + expect(getPathMatch('/.md')('/.md')).toBeTruthy() + expect(getPathMatch('/.md')('/foo.md')).toBe(false) + + expect(redirects).toEqual( + expect.arrayContaining([ + expect.objectContaining({ source: '/index', destination: '/', permanent: true }), + ]) + ) + expect(getPathMatch('/index')('/index.md')).toBe(false) + }) + it('preserves the filename when redirecting legacy customer logos', async () => { const { default: config } = (await import('./next.config.mjs')) as { default: NextConfig } const redirects = (await config.redirects?.()) || [] diff --git a/apps/www/pages/_app.tsx b/apps/www/pages/_app.tsx index 42538c5fa4d..06ad5ec8455 100644 --- a/apps/www/pages/_app.tsx +++ b/apps/www/pages/_app.tsx @@ -52,10 +52,10 @@ export default function App({ Component, pageProps }: AppProps) { // 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 mdSlug = cleanPath === '/' ? 'index' : cleanPath.slice(1) const mdAlternateHref = MD_PAGES.has(mdSlug) ? cleanPath === '/' - ? '/homepage.md' + ? '/index.md' : `${cleanPath}.md` : null diff --git a/apps/www/scripts/generateMdContent.mjs b/apps/www/scripts/generateMdContent.mjs index 842a9c10811..6059b701de8 100644 --- a/apps/www/scripts/generateMdContent.mjs +++ b/apps/www/scripts/generateMdContent.mjs @@ -143,8 +143,8 @@ async function ingestMdxSection(section) { } function sortSlugs(a, b) { - if (a === 'homepage') return -1 - if (b === 'homepage') return 1 + if (a === 'index') return -1 + if (b === 'index') return 1 return a.localeCompare(b) } @@ -178,14 +178,23 @@ const redirectedSlugs = new Set( .map((redirect) => redirect.source.slice(1)) ) -const liveEntries = allEntries.filter((entry) => !redirectedSlugs.has(entry.slug)) +// The index slug is exempt: its HTML page is /, not /index, so a /index +// redirect never refers to the homepage and must not strip its markdown. +const liveEntries = allEntries.filter( + (entry) => entry.slug === 'index' || !redirectedSlugs.has(entry.slug) +) const excludedSlugs = allEntries - .filter((entry) => redirectedSlugs.has(entry.slug)) + .filter((entry) => entry.slug !== 'index' && redirectedSlugs.has(entry.slug)) .map((entry) => entry.slug) if (excludedSlugs.length > 0) { console.log(`🚫 Excluded ${excludedSlugs.length} redirected slugs: ${excludedSlugs.join(', ')}`) } +if (!liveEntries.some((entry) => entry.slug === 'index')) { + console.error('❌ Missing content/md/index.md — middleware maps / to the index slug.') + process.exit(1) +} + const dynamicCollisions = staticSlugs.filter((s) => DYNAMIC_SLUGS.includes(s)) if (dynamicCollisions.length > 0) { console.error(