mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 01:15:03 +03:00
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 <preview>/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 <preview>/.md`: expect 308 with `location: /index.md`
- [x] `curl -sI <preview>/homepage.md` and `curl -sI
<preview>/llms/homepage.txt`: expect 308 with `location: /index.md`
- [x] `curl -sI <preview>/index`: expect 308 with `location: /`
- [x] `curl -s -H "Accept: text/markdown" -o /dev/null -w "%{http_code}
%{content_type}" <preview>/`: expect `200 text/markdown` (bare-URL
negotiation unchanged)
- [x] `curl -s <preview>/ | grep -o 'type="text/markdown"
href="[^"]*"'`: expect href ending `/index.md`
## Linear
- fixes GROWTH-1117
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
401 lines
15 KiB
TypeScript
401 lines
15 KiB
TypeScript
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<string, string>(),
|
|
MD_PAGES: new Set<string>(['index', 'auth', 'pricing']),
|
|
CHANGELOG_PAGES: new Set<string>(['changelog', 'changelog/100', 'changelog/pipelines']),
|
|
}))
|
|
|
|
function makeRequest(
|
|
url: string,
|
|
{
|
|
referer,
|
|
hasCookie,
|
|
accept,
|
|
userAgent,
|
|
}: { referer?: string; hasCookie?: boolean; accept?: string; userAgent?: string } = {}
|
|
): NextRequest {
|
|
const headers: Record<string, string> = {}
|
|
if (referer) headers.referer = referer
|
|
if (accept) headers.accept = accept
|
|
if (userAgent) headers['user-agent'] = userAgent
|
|
const req = new NextRequest(new URL(url, 'https://supabase.com'), { headers })
|
|
if (hasCookie) {
|
|
req.cookies.set(FIRST_REFERRER_COOKIE_NAME, 'existing')
|
|
}
|
|
return req
|
|
}
|
|
|
|
describe('www middleware', () => {
|
|
describe('cookie stamping on www paths', () => {
|
|
it('stamps cookie for external referrer on www path', () => {
|
|
const req = makeRequest('/pricing', { referer: 'https://google.com' })
|
|
const res = middleware(req)
|
|
|
|
expect(res.cookies.get(FIRST_REFERRER_COOKIE_NAME)).toBeDefined()
|
|
})
|
|
|
|
it('does not stamp cookie for internal referrer', () => {
|
|
const req = makeRequest('/pricing', { referer: 'https://supabase.com/docs' })
|
|
const res = middleware(req)
|
|
|
|
expect(res.cookies.get(FIRST_REFERRER_COOKIE_NAME)).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('cookie stamping on /dashboard paths', () => {
|
|
it('stamps cookie for external referrer', () => {
|
|
const req = makeRequest('/dashboard/project/123', { referer: 'https://google.com' })
|
|
const res = middleware(req)
|
|
|
|
expect(res.cookies.get(FIRST_REFERRER_COOKIE_NAME)).toBeDefined()
|
|
})
|
|
|
|
it('does not stamp cookie for internal referrer', () => {
|
|
const req = makeRequest('/dashboard/project/123', {
|
|
referer: 'https://supabase.com/pricing',
|
|
})
|
|
const res = middleware(req)
|
|
|
|
expect(res.cookies.get(FIRST_REFERRER_COOKIE_NAME)).toBeUndefined()
|
|
})
|
|
|
|
it('does not stamp cookie for direct navigation (no referrer)', () => {
|
|
const req = makeRequest('/dashboard/project/123')
|
|
const res = middleware(req)
|
|
|
|
expect(res.cookies.get(FIRST_REFERRER_COOKIE_NAME)).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('cookie stamping on /docs paths', () => {
|
|
it('stamps cookie for external referrer', () => {
|
|
const req = makeRequest('/docs/guides/auth', { referer: 'https://google.com' })
|
|
const res = middleware(req)
|
|
|
|
expect(res.cookies.get(FIRST_REFERRER_COOKIE_NAME)).toBeDefined()
|
|
})
|
|
|
|
it('does not stamp cookie for internal referrer', () => {
|
|
const req = makeRequest('/docs/guides/auth', {
|
|
referer: 'https://supabase.com/pricing',
|
|
})
|
|
const res = middleware(req)
|
|
|
|
expect(res.cookies.get(FIRST_REFERRER_COOKIE_NAME)).toBeUndefined()
|
|
})
|
|
|
|
it('does not stamp cookie for direct navigation (no referrer)', () => {
|
|
const req = makeRequest('/docs/guides/auth')
|
|
const res = middleware(req)
|
|
|
|
expect(res.cookies.get(FIRST_REFERRER_COOKIE_NAME)).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('.md suffix routing', () => {
|
|
it('rewrites /<slug>.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()
|
|
})
|
|
|
|
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)
|
|
|
|
expect(res.headers.get('x-middleware-rewrite')).toBe('https://supabase.com/changelog/100.md')
|
|
})
|
|
|
|
it('serves markdown for explicit changelog .md requests even when Accept excludes it', () => {
|
|
const req = makeRequest('/changelog/100.md', {
|
|
accept: 'application/x-content-negotiation-probe',
|
|
})
|
|
const res = middleware(req)
|
|
|
|
expect(res.status).not.toBe(406)
|
|
expect(res.headers.get('x-middleware-rewrite')).toBe('https://supabase.com/changelog/100.md')
|
|
})
|
|
|
|
it('serves markdown for explicit non-legacy changelog .md requests even when Accept excludes it', () => {
|
|
const req = makeRequest('/changelog/pipelines.md', {
|
|
accept: 'application/x-content-negotiation-probe',
|
|
})
|
|
const res = middleware(req)
|
|
|
|
expect(res.status).not.toBe(406)
|
|
expect(res.headers.get('x-middleware-rewrite')).toBe(
|
|
'https://supabase.com/changelog/pipelines.md'
|
|
)
|
|
})
|
|
|
|
it('rewrites the changelog index .md request without doubling the suffix', () => {
|
|
const req = makeRequest('/changelog.md', { accept: 'text/markdown' })
|
|
const res = middleware(req)
|
|
|
|
expect(res.headers.get('x-middleware-rewrite')).toBe('https://supabase.com/changelog.md')
|
|
})
|
|
})
|
|
|
|
describe('Accept: text/markdown content negotiation', () => {
|
|
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/index')
|
|
})
|
|
|
|
it('rewrites /<slug> 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 /<slug>/ (trailing slash) the same as /<slug>', () => {
|
|
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()
|
|
})
|
|
|
|
it('rewrites changelog entries to their static .md file', () => {
|
|
const req = makeRequest('/changelog/100', { accept: 'text/markdown' })
|
|
const res = middleware(req)
|
|
|
|
expect(res.headers.get('x-middleware-rewrite')).toBe('https://supabase.com/changelog/100.md')
|
|
})
|
|
|
|
it('negotiates markdown for non-legacy changelog slugs', () => {
|
|
const req = makeRequest('/changelog/pipelines', { accept: 'text/markdown' })
|
|
const res = middleware(req)
|
|
|
|
expect(res.headers.get('x-middleware-rewrite')).toBe(
|
|
'https://supabase.com/changelog/pipelines.md'
|
|
)
|
|
})
|
|
|
|
it('passes through unpublished numeric-prefix changelog slugs', () => {
|
|
const req = makeRequest('/changelog/999-not-published', { accept: 'text/markdown' })
|
|
const res = middleware(req)
|
|
|
|
expect(res.headers.get('x-middleware-rewrite')).toBeNull()
|
|
expect(res.status).not.toBe(406)
|
|
})
|
|
|
|
it('passes through deep paths under a published changelog slug', () => {
|
|
const req = makeRequest('/changelog/100/bar', { accept: 'text/markdown' })
|
|
const res = middleware(req)
|
|
|
|
expect(res.headers.get('x-middleware-rewrite')).toBeNull()
|
|
expect(res.status).not.toBe(406)
|
|
})
|
|
|
|
it('rewrites the bare changelog index to its static .md file', () => {
|
|
const req = makeRequest('/changelog', { accept: 'text/markdown' })
|
|
const res = middleware(req)
|
|
|
|
expect(res.headers.get('x-middleware-rewrite')).toBe('https://supabase.com/changelog.md')
|
|
})
|
|
})
|
|
|
|
describe('Accept header q-value parsing', () => {
|
|
it('serves markdown for Cursor-style Accept (markdown preferred, plain fallback)', () => {
|
|
const req = makeRequest('/auth', {
|
|
accept: 'text/markdown, text/plain;q=0.9, */*;q=0.8',
|
|
})
|
|
const res = middleware(req)
|
|
|
|
expect(res.headers.get('x-middleware-rewrite')).toBe('https://supabase.com/api-v2/md/auth')
|
|
})
|
|
|
|
it('serves markdown when md and html have equal q-values', () => {
|
|
const req = makeRequest('/auth', { accept: 'text/markdown, text/html, */*' })
|
|
const res = middleware(req)
|
|
|
|
expect(res.headers.get('x-middleware-rewrite')).toBe('https://supabase.com/api-v2/md/auth')
|
|
})
|
|
|
|
it('serves HTML when html q-value beats markdown q-value', () => {
|
|
const req = makeRequest('/auth', { accept: 'text/html;q=1.0, text/markdown;q=0.5' })
|
|
const res = middleware(req)
|
|
|
|
expect(res.headers.get('x-middleware-rewrite')).toBeNull()
|
|
})
|
|
|
|
it('serves HTML for browser-style Accept (html with */* fallback)', () => {
|
|
const req = makeRequest('/auth', {
|
|
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
|
})
|
|
const res = middleware(req)
|
|
|
|
expect(res.headers.get('x-middleware-rewrite')).toBeNull()
|
|
})
|
|
|
|
it('serves markdown when md q-value beats html q-value', () => {
|
|
const req = makeRequest('/auth', { accept: 'text/html;q=0.5, text/markdown;q=1.0' })
|
|
const res = middleware(req)
|
|
|
|
expect(res.headers.get('x-middleware-rewrite')).toBe('https://supabase.com/api-v2/md/auth')
|
|
})
|
|
|
|
it('tolerates OWS around the q parameter (per RFC 9110)', () => {
|
|
const req = makeRequest('/auth', { accept: 'text/html ; q = 1.0, text/markdown ; q = 0.5' })
|
|
const res = middleware(req)
|
|
|
|
expect(res.headers.get('x-middleware-rewrite')).toBeNull()
|
|
})
|
|
|
|
it('ignores out-of-range q-values rather than treating them as preference', () => {
|
|
const req = makeRequest('/auth', { accept: 'text/html;q=2.0, text/markdown;q=1.0' })
|
|
const res = middleware(req)
|
|
|
|
// text/html's q=2.0 is invalid and falls back to default 1.0; tie -> markdown.
|
|
expect(res.headers.get('x-middleware-rewrite')).toBe('https://supabase.com/api-v2/md/auth')
|
|
})
|
|
})
|
|
|
|
describe('406 Not Acceptable', () => {
|
|
it('returns 406 on MD-eligible page when Accept excludes every type we serve', () => {
|
|
const req = makeRequest('/pricing', { accept: 'application/x-content-negotiation-probe' })
|
|
const res = middleware(req)
|
|
|
|
expect(res.status).toBe(406)
|
|
expect(res.headers.get('x-middleware-rewrite')).toBeNull()
|
|
})
|
|
|
|
it('does not return 406 on non-MD pages (no negotiation contract there)', () => {
|
|
const req = makeRequest('/not-a-page', { accept: 'application/x-content-negotiation-probe' })
|
|
const res = middleware(req)
|
|
|
|
expect(res.status).not.toBe(406)
|
|
})
|
|
|
|
it('does not return 406 when Accept includes */*', () => {
|
|
const req = makeRequest('/pricing', { accept: '*/*' })
|
|
const res = middleware(req)
|
|
|
|
expect(res.status).not.toBe(406)
|
|
})
|
|
|
|
it('returns 406 for a probe Accept header regardless of user agent', () => {
|
|
const req = makeRequest('/pricing', {
|
|
accept: 'application/x-content-negotiation-probe',
|
|
userAgent: 'Claude-User/1.0',
|
|
})
|
|
const res = middleware(req)
|
|
|
|
expect(res.status).toBe(406)
|
|
})
|
|
|
|
it('returns 406 on changelog entries when Accept excludes every type', () => {
|
|
const req = makeRequest('/changelog/100', {
|
|
accept: 'application/x-content-negotiation-probe',
|
|
})
|
|
const res = middleware(req)
|
|
|
|
expect(res.status).toBe(406)
|
|
})
|
|
|
|
it('returns 406 for non-legacy changelog entries when Accept matches nothing', () => {
|
|
const req = makeRequest('/changelog/pipelines', {
|
|
accept: 'application/x-content-negotiation-probe',
|
|
})
|
|
const res = middleware(req)
|
|
|
|
expect(res.status).toBe(406)
|
|
})
|
|
|
|
it('sets Cache-Control: no-store and Vary: Accept on 406 responses', () => {
|
|
const req = makeRequest('/pricing', { accept: 'application/x-content-negotiation-probe' })
|
|
const res = middleware(req)
|
|
|
|
expect(res.status).toBe(406)
|
|
expect(res.headers.get('Cache-Control')).toBe('no-store')
|
|
expect(res.headers.get('Vary')).toBe('Accept')
|
|
})
|
|
})
|
|
|
|
describe('user-agent independence', () => {
|
|
it('serves HTML to agent and bot user agents that send no markdown Accept preference', () => {
|
|
for (const ua of [
|
|
'Claude-User (claude-code/2.1.119; +https://support.anthropic.com/)',
|
|
'Claude-Web/1.0',
|
|
'Mozilla/5.0 (compatible; ChatGPT-User/1.0)',
|
|
'PerplexityBot/1.0',
|
|
'GPTBot/1.0',
|
|
'ClaudeBot/1.0',
|
|
'CCBot/2.0',
|
|
'chatgpt-userscript/2.0',
|
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36',
|
|
]) {
|
|
const req = makeRequest('/auth', { userAgent: ua })
|
|
const res = middleware(req)
|
|
|
|
expect(res.headers.get('x-middleware-rewrite')).toBeNull()
|
|
}
|
|
})
|
|
|
|
it('negotiates by Accept as usual when an agent user agent is present', () => {
|
|
const req = makeRequest('/auth', {
|
|
accept: 'text/markdown',
|
|
userAgent: 'Claude-User (claude-code/2.1.119; +https://support.anthropic.com/)',
|
|
})
|
|
const res = middleware(req)
|
|
|
|
expect(res.headers.get('x-middleware-rewrite')).toBe('https://supabase.com/api-v2/md/auth')
|
|
})
|
|
|
|
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()
|
|
})
|
|
})
|
|
})
|