Files
supabase/apps/www/scripts/generateMdContent.mjs
T
Pamela Chia 9ae6e54dd5 fix(www): exclude redirected slugs from generated markdown (#48476)
11 of the generated `MD_PAGES` entries are blog slugs whose HTML pages
308-redirect away via `apps/www/lib/redirects.js` before any `<head>`
renders. They can never carry an alternate tag and Accept negotiation
never fires (Next.js `redirects()` runs before middleware), so their
`.md` siblings are orphaned content reachable only by guessing the
suffixed URL. Six of them duplicate live, correctly-tagged pages
(`/customers/*`, `/pricing`).

**Changed:**
- `generateMdContent.mjs` derives an exclusion set from
`lib/redirects.js` at generation time: any unconditional exact-match
redirect source (wildcard/param patterns and conditional `has`/`missing`
redirects are skipped) drops the matching slug from both `MD_CONTENT`
and `MD_PAGES`. The build log names every excluded slug, currently the
11 known ones.
- Self-maintaining by design (per the decision recorded on the issue): a
future redirected post auto-excludes on the next build, and removing a
redirect brings its `.md` sibling back. The MDX sources stay in the
repo; nothing is deleted.
- Effect on the 11 slugs: alternate tags stay absent (nothing rendered
them anyway), and explicit `.md` URLs go from serving orphaned markdown
to 404, the same external effect deletion would have had.

## To test

Tested locally:
- [x] `node scripts/generateMdContent.mjs` logs `🚫 Excluded 11
redirected slugs: ...` naming exactly the 11 known slugs; output drops
483 → 472 pages
- [x] Generated file carries no MD_CONTENT/MD_PAGES key for any excluded
slug (raw URL mentions inside other posts' bodies remain, as expected)
- [x] `apps/www` vitest: 71/71 (GROWTH-1013 drift tests unaffected)

Post-merge:
- [ ] `https://supabase.com/blog/case-study-xendit.md` returns 404
(previously 200 orphaned markdown); `https://supabase.com/pricing.md`
still 200

## Linear
- fixes GROWTH-1022


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

## Summary by CodeRabbit

* **Bug Fixes**
* Excluded content with valid exact-path redirects from generated
documentation.
  * Preserved content associated with conditional or wildcard redirects.
* Updated generated page counts and output statistics to reflect the
filtered content.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-20 15:23:13 +08:00

237 lines
6.8 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 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 === 'homepage') return -1
if (b === 'homepage') 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))
)
const liveEntries = allEntries.filter((entry) => !redirectedSlugs.has(entry.slug))
const excludedSlugs = allEntries
.filter((entry) => redirectedSlugs.has(entry.slug))
.map((entry) => entry.slug)
if (excludedSlugs.length > 0) {
console.log(`🚫 Excluded ${excludedSlugs.length} redirected slugs: ${excludedSlugs.join(', ')}`)
}
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)
}
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},
])
`
await fs.writeFile(outputPath, output, 'utf-8')
console.log(
`✅ Generated ${outputPath} (${liveEntries.length} files, ${allPageSlugs.length} pages)`
)