mirror of
https://github.com/supabase/supabase.git
synced 2026-09-09 03:19:36 +08: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 -->
283 lines
8.5 KiB
JavaScript
283 lines
8.5 KiB
JavaScript
// @ts-check
|
|
|
|
/**
|
|
* Scans content/md/ + _blog/ + _customers/ + _events/ and emits a TypeScript
|
|
* module exporting MD_CONTENT (slug → markdown) and MD_PAGES (allowlist Set).
|
|
* The static import keeps content traceable by @vercel/nft, no runtime fs reads.
|
|
*/
|
|
|
|
import { promises as fs } from 'fs'
|
|
import path from 'path'
|
|
import { fileURLToPath } from 'url'
|
|
import matter from 'gray-matter'
|
|
|
|
import redirects from '../lib/redirects.js'
|
|
import { mdxBodyToMarkdown } from './lib/mdxToMarkdown.mjs'
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
const wwwDir = path.join(__dirname, '..')
|
|
const contentDir = path.join(wwwDir, 'content/md')
|
|
const changelogMdDir = path.join(wwwDir, 'public/changelog')
|
|
const outputPath = path.join(wwwDir, 'app/api-v2/md/content.generated.ts')
|
|
|
|
// Matches lib/posts.tsx FILENAME_SUBSTRING — strips YYYY-MM-DD- (11 chars).
|
|
const DATE_PREFIX = 11
|
|
|
|
// Slugs handled by a dynamic generator in the route handler. Listed here so
|
|
// MD_PAGES still includes them (middleware relies on the allowlist).
|
|
const DYNAMIC_SLUGS = ['pricing']
|
|
|
|
function pickFields(data, fields) {
|
|
const picked = {}
|
|
for (const field of fields) {
|
|
const value = data[field]
|
|
if (value == null || value === '' || (Array.isArray(value) && value.length === 0)) continue
|
|
picked[field] = value
|
|
}
|
|
return picked
|
|
}
|
|
|
|
const MDX_SECTIONS = [
|
|
{
|
|
dir: '_blog',
|
|
urlPrefix: 'blog',
|
|
stripDatePrefix: true,
|
|
frontmatterFields: ['title', 'description', 'author', 'date', 'tags', 'categories'],
|
|
},
|
|
{
|
|
dir: '_customers',
|
|
urlPrefix: 'customers',
|
|
stripDatePrefix: false,
|
|
frontmatterFields: [
|
|
'name',
|
|
'title',
|
|
'description',
|
|
'company_url',
|
|
'industry',
|
|
'region',
|
|
'company_size',
|
|
'supabase_products',
|
|
'date',
|
|
],
|
|
},
|
|
{
|
|
dir: '_events',
|
|
urlPrefix: 'events',
|
|
stripDatePrefix: true,
|
|
frontmatterFields: [
|
|
'title',
|
|
'subtitle',
|
|
'description',
|
|
'type',
|
|
'date',
|
|
'end_date',
|
|
'timezone',
|
|
'duration',
|
|
'onDemand',
|
|
],
|
|
skipIf: (data) => data.disable_page_build === true,
|
|
},
|
|
]
|
|
|
|
async function collectMdFiles(dir, prefix = '') {
|
|
const results = []
|
|
let dirents
|
|
try {
|
|
dirents = await fs.readdir(dir, { withFileTypes: true })
|
|
} catch (err) {
|
|
if (err.code === 'ENOENT') return results
|
|
throw err
|
|
}
|
|
for (const dirent of dirents) {
|
|
const slug = prefix ? `${prefix}/${dirent.name}` : dirent.name
|
|
if (dirent.isDirectory()) {
|
|
results.push(...(await collectMdFiles(path.join(dir, dirent.name), slug)))
|
|
} else if (dirent.name.endsWith('.md')) {
|
|
results.push(slug.replace(/\.md$/, ''))
|
|
}
|
|
}
|
|
return results
|
|
}
|
|
|
|
// _events double-underscore filenames (`2024-08-30__launch-...`) produce slugs
|
|
// with a leading underscore. The HTML page is at `/events/_launch-...`, so the
|
|
// .md slug must keep the underscore to match.
|
|
function deriveSlug(filename, stripDatePrefix) {
|
|
const base = filename.replace(/\.mdx$/, '')
|
|
return stripDatePrefix ? base.substring(DATE_PREFIX) : base
|
|
}
|
|
|
|
async function ingestMdxSection(section) {
|
|
const dir = path.join(wwwDir, section.dir)
|
|
let filenames
|
|
try {
|
|
filenames = await fs.readdir(dir)
|
|
} catch (err) {
|
|
if (err.code === 'ENOENT') {
|
|
console.warn(` ⚠ Section directory missing: ${section.dir}`)
|
|
return []
|
|
}
|
|
throw err
|
|
}
|
|
|
|
const mdxFilenames = filenames.filter((f) => f.endsWith('.mdx'))
|
|
const results = await Promise.all(
|
|
mdxFilenames.map(async (filename) => {
|
|
const fullPath = path.join(dir, filename)
|
|
try {
|
|
const raw = await fs.readFile(fullPath, 'utf-8')
|
|
const parsed = matter(raw)
|
|
const data = parsed.data ?? {}
|
|
if (section.skipIf?.(data)) return null
|
|
|
|
const slug = `${section.urlPrefix}/${deriveSlug(filename, section.stripDatePrefix)}`
|
|
const body = await mdxBodyToMarkdown(parsed.content)
|
|
const content = matter.stringify(body, pickFields(data, section.frontmatterFields))
|
|
return { slug, content }
|
|
} catch (err) {
|
|
throw new Error(`${section.dir}/${filename}: ${err.message}`, { cause: err })
|
|
}
|
|
})
|
|
)
|
|
return results.filter((entry) => entry !== null)
|
|
}
|
|
|
|
function sortSlugs(a, b) {
|
|
if (a === 'index') return -1
|
|
if (b === 'index') return 1
|
|
return a.localeCompare(b)
|
|
}
|
|
|
|
const staticSlugs = (await collectMdFiles(contentDir)).sort(sortSlugs)
|
|
|
|
if (staticSlugs.length === 0) {
|
|
console.error('❌ No .md files found in content/md/')
|
|
process.exit(1)
|
|
}
|
|
|
|
const staticEntries = await Promise.all(
|
|
staticSlugs.map(async (slug) => ({
|
|
slug,
|
|
content: await fs.readFile(path.join(contentDir, `${slug}.md`), 'utf-8'),
|
|
}))
|
|
)
|
|
|
|
const mdxEntries = []
|
|
for (const section of MDX_SECTIONS) {
|
|
console.log(`📚 Ingesting ${section.dir}...`)
|
|
const sectionEntries = await ingestMdxSection(section)
|
|
console.log(` ${sectionEntries.length} entries`)
|
|
mdxEntries.push(...sectionEntries)
|
|
}
|
|
|
|
const allEntries = [...staticEntries, ...mdxEntries]
|
|
|
|
const redirectedSlugs = new Set(
|
|
redirects
|
|
.filter((redirect) => !redirect.has && !redirect.missing && /^\/[^:*]+$/.test(redirect.source))
|
|
.map((redirect) => redirect.source.slice(1))
|
|
)
|
|
|
|
// 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) => 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(
|
|
`❌ Slug collision: [${dynamicCollisions.join(', ')}] reserved for a dynamic generator.`
|
|
)
|
|
process.exit(1)
|
|
}
|
|
|
|
const sectionRoots = MDX_SECTIONS.map((s) => s.urlPrefix)
|
|
const sectionRootCollisions = [...staticSlugs, ...DYNAMIC_SLUGS].filter((s) =>
|
|
sectionRoots.includes(s)
|
|
)
|
|
if (sectionRootCollisions.length > 0) {
|
|
console.error(
|
|
`❌ Section-root collision: [${sectionRootCollisions.join(', ')}] shadows an MDX section.`
|
|
)
|
|
process.exit(1)
|
|
}
|
|
|
|
const seen = new Set()
|
|
for (const entry of allEntries) {
|
|
if (seen.has(entry.slug)) {
|
|
console.error(`❌ Duplicate slug emitted: ${entry.slug}`)
|
|
process.exit(1)
|
|
}
|
|
seen.add(entry.slug)
|
|
}
|
|
|
|
// public/changelog/*.md is written by generateStaticContent.mjs earlier in
|
|
// content:build:core; absent locally e.g. when CHANGELOG_SYNC_APP_* secrets are unset.
|
|
const changelogSlugs = (await collectMdFiles(changelogMdDir, 'changelog')).sort()
|
|
|
|
// The middleware matches request slugs against these keys verbatim, and its
|
|
// tests mock this set — a dropped prefix would ship silently with green tests.
|
|
if (changelogSlugs.some((s) => !s.startsWith('changelog/'))) {
|
|
console.error('❌ Changelog slugs must carry the changelog/ prefix the middleware matches on.')
|
|
process.exit(1)
|
|
}
|
|
|
|
if (changelogSlugs.length === 0) {
|
|
if (process.env.VERCEL) {
|
|
console.error(
|
|
'❌ No changelog slugs found in public/changelog — changelog generation produced nothing.'
|
|
)
|
|
process.exit(1)
|
|
}
|
|
console.warn(
|
|
'⚠️ No changelog slugs found in public/changelog — changelog md negotiation off in this build'
|
|
)
|
|
}
|
|
|
|
let changelogIndexExists = false
|
|
try {
|
|
await fs.access(path.join(wwwDir, 'public/changelog.md'))
|
|
changelogIndexExists = true
|
|
} catch (err) {
|
|
if (err.code !== 'ENOENT') throw err
|
|
}
|
|
if (changelogIndexExists) {
|
|
changelogSlugs.unshift('changelog')
|
|
}
|
|
|
|
const contentEntries = liveEntries
|
|
.map((e) => ` [${JSON.stringify(e.slug)}, ${JSON.stringify(e.content)}]`)
|
|
.join(',\n')
|
|
|
|
const allPageSlugs = [...liveEntries.map((e) => e.slug), ...DYNAMIC_SLUGS]
|
|
const pageEntries = allPageSlugs.map((s) => ` ${JSON.stringify(s)}`).join(',\n')
|
|
|
|
const output = `// AUTO-GENERATED by scripts/generateMdContent.mjs — do not edit
|
|
export const MD_CONTENT = new Map<string, string>([
|
|
${contentEntries},
|
|
])
|
|
|
|
export const MD_PAGES = new Set<string>([
|
|
${pageEntries},
|
|
])
|
|
|
|
export const CHANGELOG_PAGES = new Set<string>(${JSON.stringify(changelogSlugs, null, 2)})
|
|
`
|
|
|
|
await fs.writeFile(outputPath, output, 'utf-8')
|
|
console.log(
|
|
`✅ Generated ${outputPath} (${liveEntries.length} files, ${allPageSlugs.length} pages, ${changelogSlugs.length} changelog)`
|
|
)
|