mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 01:15:03 +03:00
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 <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 -->
This commit is contained in:
1 parent
5c6ef8ae3d
commit
21a27eeb4f
11 files changed
+67
-17
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: '/' },
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -10,7 +10,7 @@ import { middleware } from './middleware'
|
||||
// are actually exercised below.
|
||||
vi.mock('./app/api-v2/md/content.generated', () => ({
|
||||
MD_CONTENT: new Map<string, string>(),
|
||||
MD_PAGES: new Set<string>(['homepage', 'auth', 'pricing']),
|
||||
MD_PAGES: new Set<string>(['index', 'auth', 'pricing']),
|
||||
CHANGELOG_PAGES: new Set<string>(['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 /<slug> when Accept: text/markdown matches the allowlist', () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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?.()) || []
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user