Files
supabase/apps/www/generate-sitemap.test.ts
Pamela Chia 5e8e551e2c fix(www): include app routes in sitemap (#50277)
I added static App Router pages to the www sitemap, including the
homepage, pricing, and product pages. The generator previously scanned
only Pages Router and content files; it now strips route groups,
excludes dynamic segments, and emits these URLs without lastmod.

**Note:** The pre-existing Pages Router `/opt-out/[ref]` entry remains
outside this change.

## To test

Tested on the [www
preview](https://zone-www-dot-com-git-pamela-growth-1214-app-rou-1d4879-supabase.vercel.app/sitemap_www.xml):

- [x] Open `/sitemap_www.xml`: expect the homepage, `/pricing`, and
product routes once each, without route-group names or lastmod on those
entries.
- [x] Compare the sitemap's changelog URLs with `/changelog-rss.xml`:
expect every RSS item link to remain included, including text-slug
entries.
- [x] Open `/sitemap.xml`: expect the existing www and docs sitemap
links.

## Linear

- fixes GROWTH-1214


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Sitemap generation now includes static pages built with the Next.js
App Router.
  - Route groups are correctly omitted from generated URLs.
  - Dynamic App Router routes are excluded from the sitemap.

- **Bug Fixes**
- Improved sitemap coverage and URL accuracy for applications using both
App Router and Pages Router pages.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-16 16:54:42 +08:00

379 lines
14 KiB
TypeScript

import { spawnSync } from 'node:child_process'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { parseFrontmatter } from './lib/frontmatter.mjs'
import { blogPostingSchema, serializeJsonLd } from './lib/json-ld'
const GENERATOR = path.join(process.cwd(), 'internals', 'generate-sitemap.mjs')
const LEGACY_LINK = 'https://supabase.com/changelog/12345-legacy-entry'
const TIMED_LINK = 'https://supabase.com/changelog/23456-timed-entry'
const TEXT_SLUG_LINK = 'https://supabase.com/changelog/text-slug-entry'
const SPAWN_TIMEOUT_MS = 30_000
const createdDirs: string[] = []
type UrlEntry = { loc: string; lastmod?: string }
function writeFixture(files: Record<string, string>): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sitemap-'))
createdDirs.push(dir)
fs.mkdirSync(path.join(dir, 'public'), { recursive: true })
for (const [relativePath, content] of Object.entries(files)) {
const target = path.join(dir, relativePath)
fs.mkdirSync(path.dirname(target), { recursive: true })
fs.writeFileSync(target, content)
}
return dir
}
function runGenerator(fixtureDir: string) {
return spawnSync(process.execPath, [GENERATOR], {
cwd: fixtureDir,
encoding: 'utf-8',
env: { ...process.env, TZ: 'UTC' },
timeout: SPAWN_TIMEOUT_MS,
})
}
function mdx(frontmatter: string): string {
return `---\n${frontmatter}\n---\n\n# Body\n`
}
function blogFixture(slugWithDate: string, frontmatter: string): Record<string, string> {
return { [`_blog/${slugWithDate}.mdx`]: mdx(frontmatter) }
}
function rssItem(link: string, pubDate: string): string {
return [
' <item>',
` <guid isPermaLink="true">${link}</guid>`,
' <title>Entry</title>',
` <link>${link}</link>`,
` <pubDate>${pubDate}</pubDate>`,
' </item>',
].join('\n')
}
function rss(items: string[]): string {
return [
'<?xml version="1.0" encoding="UTF-8"?>',
'<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel>',
'<link>https://supabase.com/changelog</link>',
'<atom:link href="https://supabase.com/changelog-rss.xml" rel="self" type="application/rss+xml"/>',
...items,
'</channel></rss>',
'',
].join('\n')
}
function urlEntries(xml: string): UrlEntry[] {
return [...xml.matchAll(/<url>([\s\S]*?)<\/url>/g)].map(([, block]) => ({
loc: block.match(/<loc\s*>\s*([^<\s]+)\s*<\/loc\s*>/)![1],
lastmod: block.match(/<lastmod\s*>\s*([^<\s]+)\s*<\/lastmod\s*>/)?.[1],
}))
}
afterAll(() => {
for (const dir of createdDirs) fs.rmSync(dir, { recursive: true, force: true })
})
describe('generate-sitemap lastmod', () => {
let fixtureDir: string
let result: ReturnType<typeof runGenerator>
let sitemap = ''
let entries: UrlEntry[] = []
const entryFor = (loc: string) => entries.find((entry) => entry.loc === loc)
beforeAll(() => {
fixtureDir = writeFixture({
...blogFixture('2026-01-05-published-only', "date: '2026-01-05'"),
...blogFixture('2026-01-06-revised', "date: '2026-01-06'\nupdated: '2026-03-01'"),
...blogFixture('2026-01-07-with-time', "date: '2026-01-07T09:30:00'"),
...blogFixture('2026-01-08-unquoted-minutes', 'date: 2026-01-08T09:30'),
...blogFixture('2026-01-09-unquoted-date', 'date: 2026-01-09'),
'_alternatives/supabase-vs-example.mdx': mdx("date: '2025-11-20'"),
'_customers/acme.mdx': mdx("date: '2024-05-16T08:00:00Z'"),
'_events/2026-02-01-webinar.mdx': mdx("date: '2026-02-01T19:00:00.000-07:00'"),
'pages/company.tsx': '',
'public/changelog-rss.xml': rss([
rssItem(TIMED_LINK, 'Wed, 04 Feb 2026 20:15:00 -0700'),
rssItem(TEXT_SLUG_LINK, 'Thu, 05 Feb 2026 00:00:00 +0000'),
rssItem(LEGACY_LINK, 'Tue, 03 Feb 2026 00:00:00 +0000'),
]),
})
result = runGenerator(fixtureDir)
const sitemapPath = path.join(fixtureDir, 'public', 'sitemap_www.xml')
if (fs.existsSync(sitemapPath)) {
sitemap = fs.readFileSync(sitemapPath, 'utf-8')
entries = urlEntries(sitemap)
}
}, SPAWN_TIMEOUT_MS)
it('runs cleanly and writes sitemap_www.xml', () => {
expect(result.status, result.stderr).toBe(0)
expect(sitemap).not.toBe('')
})
it('uses the publish date when a post has no updated field', () => {
expect(entryFor('https://supabase.com/blog/published-only')?.lastmod).toBe('2026-01-05')
})
it('prefers updated over date', () => {
expect(entryFor('https://supabase.com/blog/revised')?.lastmod).toBe('2026-03-01')
})
it('drops the time part of a quoted datetime string', () => {
expect(entryFor('https://supabase.com/blog/with-time')?.lastmod).toBe('2026-01-07')
})
it('accepts an unquoted datetime without seconds, which YAML leaves as a string', () => {
expect(entryFor('https://supabase.com/blog/unquoted-minutes')?.lastmod).toBe('2026-01-08')
})
it('accepts an unquoted date-only value', () => {
expect(entryFor('https://supabase.com/blog/unquoted-date')?.lastmod).toBe('2026-01-09')
})
it('dates alternatives and customer stories from their frontmatter', () => {
expect(entryFor('https://supabase.com/alternatives/supabase-vs-example')?.lastmod).toBe(
'2025-11-20'
)
expect(entryFor('https://supabase.com/customers/acme')?.lastmod).toBe('2024-05-16')
})
it('emits no lastmod for events, static pages, and the proxied evals app', () => {
for (const loc of [
'https://supabase.com/events/webinar',
'https://supabase.com/company',
'https://supabase.com/evals',
]) {
const entry = entryFor(loc)
expect(entry, loc).toBeDefined()
expect(entry?.lastmod, loc).toBeUndefined()
}
})
it('dates legacy changelog entries from the RSS pubDate', () => {
expect(entryFor(LEGACY_LINK)?.lastmod).toBe('2026-02-03')
})
it('truncates a timed, offset pubDate to its UTC day', () => {
expect(entryFor(TIMED_LINK)?.lastmod).toBe('2026-02-05')
})
it('includes changelog entries whose slug has no numeric prefix', () => {
expect(entryFor(TEXT_SLUG_LINK)?.lastmod).toBe('2026-02-05')
})
it('emits exactly the RSS item links as changelog URLs', () => {
const changelogLocs = entries
.filter((entry) => entry.loc.startsWith('https://supabase.com/changelog'))
.map((entry) => entry.loc)
expect(changelogLocs).toEqual([TIMED_LINK, TEXT_SLUG_LINK, LEGACY_LINK])
})
it('emits only day-precision lastmod values, one per dated source', () => {
const lastmods = entries.map((entry) => entry.lastmod).filter(Boolean)
expect(lastmods).toHaveLength(10)
for (const lastmod of lastmods) expect(lastmod).toMatch(/^\d{4}-\d{2}-\d{2}$/)
})
it('leaves the sitemap index untouched', () => {
const index = fs.readFileSync(path.join(fixtureDir, 'public', 'sitemap.xml'), 'utf-8')
expect(index).toContain('<loc>https://supabase.com/sitemap_www.xml</loc>')
expect(index).toContain('<loc>https://supabase.com/docs/sitemap.xml</loc>')
})
})
describe('generate-sitemap App Router pages', () => {
it.each(['app/page.tsx', 'app/(home)/page.tsx'])(
'includes only static page URLs without lastmod, with homepage at %s',
(homepage) => {
const dir = writeFixture({
[homepage]: '',
'app/(group)/example/page.tsx': '',
'app/(marketing)/guides/(topics)/start/page.tsx': '',
'app/blog/example/page.tsx': '',
'app/events/example/page.tsx': '',
'app/reference/index/page.tsx': '',
'app/things/[slug]/page.tsx': '',
'app/things/[slug]/details/page.tsx': '',
'app/(group)/[...slug]/page.tsx': '',
'app/optional/[[...slug]]/page.tsx': '',
'app/example/layout.tsx': '',
'app/api/route.ts': '',
'app/loading.tsx': '',
'pages/company.tsx': '',
})
const result = runGenerator(dir)
expect(result.status, result.stderr).toBe(0)
const entries = urlEntries(fs.readFileSync(path.join(dir, 'public/sitemap_www.xml'), 'utf-8'))
expect(entries.sort((a, b) => a.loc.localeCompare(b.loc))).toEqual(
[
'',
'/blog/example',
'/company',
'/evals',
'/events/example',
'/example',
'/guides/start',
'/reference/index',
].map((route) => ({ loc: `https://supabase.com${route}`, lastmod: undefined }))
)
},
SPAWN_TIMEOUT_MS
)
})
describe('frontmatter dates in sitemap and blog JSON-LD', () => {
it.each([
['2026-01-14T09:30:00', '2026-01-14'],
['2026-01-15T16:00:00-08:00', '2026-01-15'],
['2026-01-16T00:00:00Z', '2026-01-16'],
['2026-01-17T00:30:00+14:00', '2026-01-17'],
['2026-01-18', '2026-01-18'],
])('preserves the authored day of %s regardless of quoting', (value, expectedDay) => {
const files = {
...blogFixture('2026-01-01-quoted', `date: '2026-01-01'\nupdated: '${value}'`),
...blogFixture('2026-01-01-unquoted', `date: 2026-01-01\nupdated: ${value} # revision`),
}
const dir = writeFixture(files)
const result = runGenerator(dir)
expect(result.status, result.stderr).toBe(0)
const entries = urlEntries(fs.readFileSync(path.join(dir, 'public/sitemap_www.xml'), 'utf-8'))
expect(
entries.filter((entry) => entry.loc.includes('/blog/')).map((entry) => entry.lastmod)
).toEqual([expectedDay, expectedDay])
for (const content of Object.values(files)) {
const { data } = parseFrontmatter(content)
const schema = JSON.parse(
serializeJsonLd(
blogPostingSchema({
url: 'https://supabase.com/blog/example',
headline: 'Example',
image: 'https://supabase.com/example.png',
datePublished: data.date,
dateModified: data.updated ?? data.date,
authors: [{ name: 'Supabase' }],
})
)
)
expect(schema.datePublished).toBe('2026-01-01')
expect(schema.dateModified).toBe(value)
expect(schema.dateModified.slice(0, 10)).toBe(expectedDay)
}
})
it.each(['javascript', 'js', 'JavaScript'])(
'rejects %s frontmatter without executing it',
(language) => {
const content = `---${language}\n(require('fs').writeFileSync('executed', 'yes'), {date: '2026-01-01'})\n---\n`
const dir = writeFixture({ '_blog/2026-01-01-script.mdx': content })
const result = runGenerator(dir)
expect(result.status).not.toBe(0)
expect(result.stderr).toContain('JavaScript frontmatter is not supported')
expect(fs.existsSync(path.join(dir, 'executed'))).toBe(false)
expect(() => parseFrontmatter(content)).toThrow('JavaScript frontmatter is not supported')
}
)
})
describe('generate-sitemap rejects dates it cannot trust', () => {
const cases: Array<{ name: string; files: Record<string, string>; stderrIncludes: string[] }> = [
{
name: 'a non-date word',
files: blogFixture('2026-01-10-bad-word', "date: '2026-01-10'\nupdated: 'soon'"),
stderrIncludes: ['_blog/2026-01-10-bad-word.mdx', '"soon"'],
},
{
name: 'an updated value earlier than the publish date',
files: blogFixture('2026-01-17-backdated', "date: '2026-01-17'\nupdated: '2025-12-31'"),
stderrIncludes: ['_blog/2026-01-17-backdated.mdx', 'earlier than date'],
},
{
name: 'trailing text after a valid day',
files: blogFixture('2026-01-11-bad-suffix', "date: '2026-01-11'\nupdated: '2026-01-11soon'"),
stderrIncludes: ['_blog/2026-01-11-bad-suffix.mdx', '"2026-01-11soon"'],
},
{
name: 'a day that does not exist',
files: blogFixture('2026-01-12-bad-day', "date: '2026-01-12'\nupdated: '2026-02-30'"),
stderrIncludes: ['_blog/2026-01-12-bad-day.mdx', '"2026-02-30"'],
},
{
name: 'a month that does not exist',
files: blogFixture('2026-01-13-bad-month', "date: '2026-01-13'\nupdated: '2026-13-45'"),
stderrIncludes: ['_blog/2026-01-13-bad-month.mdx', '"2026-13-45"'],
},
{
name: 'a time of day that does not exist',
files: blogFixture('2026-01-18-bad-time', "date: '2026-01-18'\nupdated: '2026-01-18T99:99'"),
stderrIncludes: ['_blog/2026-01-18-bad-time.mdx', '"2026-01-18T99:99"'],
},
{
name: 'an offset that does not exist',
files: blogFixture(
'2026-01-19-bad-offset',
"date: '2026-01-19'\nupdated: '2026-01-19T09:30:00+99:00'"
),
stderrIncludes: ['_blog/2026-01-19-bad-offset.mdx', '"2026-01-19T09:30:00+99:00"'],
},
{
name: 'an unparseable changelog pubDate',
files: { 'public/changelog-rss.xml': rss([rssItem(LEGACY_LINK, 'Invalid Date +0000')]) },
stderrIncludes: [`changelog-rss ${LEGACY_LINK}`, '"Invalid Date +0000"'],
},
{
name: 'an unparseable changelog pubDate on a text-slug entry',
files: { 'public/changelog-rss.xml': rss([rssItem(TEXT_SLUG_LINK, 'Invalid Date +0000')]) },
stderrIncludes: [`changelog-rss ${TEXT_SLUG_LINK}`, '"Invalid Date +0000"'],
},
]
for (const testCase of cases) {
it(
`fails the build on ${testCase.name}`,
() => {
const result = runGenerator(writeFixture(testCase.files))
expect(result.status).not.toBe(0)
for (const fragment of testCase.stderrIncludes) {
expect(result.stderr).toContain(fragment)
}
},
SPAWN_TIMEOUT_MS
)
}
})
describe('generate-sitemap against the repo content', () => {
const wwwRoot = process.cwd()
const mdxCount = (dir: string) =>
fs.readdirSync(path.join(wwwRoot, dir)).filter((name) => name.endsWith('.mdx')).length
it(
'dates every blog, alternatives, and customers file without throwing',
() => {
const result = runGenerator(wwwRoot)
expect(result.status, result.stderr).toBe(0)
const entries = urlEntries(
fs.readFileSync(path.join(wwwRoot, 'public', 'sitemap_www.xml'), 'utf-8')
)
const datedContent = entries.filter(
(entry) => entry.lastmod && !entry.loc.includes('/changelog/')
)
expect(datedContent).toHaveLength(
mdxCount('_blog') + mdxCount('_alternatives') + mdxCount('_customers')
)
expect(
entries.filter((entry) => entry.loc.includes('/events/') && entry.lastmod)
).toHaveLength(0)
},
SPAWN_TIMEOUT_MS
)
})