chore(www): changelog formatting (#45364)

- change changelog.md formatting
- make changelog entries slugs more descriptive (eg
/changelog/123-new-change)

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

## Summary by CodeRabbit

* **Refactor**
* Updated changelog entry URLs to use slug-based identifiers instead of
numeric IDs for improved readability and SEO-friendliness, with
automatic redirects for existing links.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Francesco Sansalvadore
2026-04-29 13:56:32 +02:00
committed by GitHub
parent 34241f1f66
commit 8ba1054dfe
11 changed files with 104 additions and 50 deletions
@@ -10,15 +10,15 @@ import type { ChangelogLabel } from '@/lib/changelog-github'
import { SITE_ORIGIN } from '@/lib/constants'
type Props = {
number: number
slug: string
url: string
labels: ChangelogLabel[]
className?: string
}
export function ChangelogDetailSidebar({ number, url, labels, className }: Props) {
export function ChangelogDetailSidebar({ slug, url, labels, className }: Props) {
const { copied, copyMarkdown } = useCopyMarkdownFromUrl()
const mdPath = `/changelog/${number}.md`
const mdPath = `/changelog/${slug}.md`
const mdAbs = `${SITE_ORIGIN}${mdPath}`
const aiPrompt = `Read from ${mdAbs} so I can ask questions about its contents`
@@ -141,7 +141,7 @@ export function ChangelogTimelineList(props: Props) {
<div className="min-w-0 lg:col-span-10 [&>*:last-child]:border-b-0">
{yearItems.map((item) => (
<TimelineRow key={item.number} item={item} href={`/changelog/${item.number}`} />
<TimelineRow key={item.number} item={item} href={`/changelog/${item.slug}`} />
))}
</div>
</div>
+2 -2
View File
@@ -64,7 +64,7 @@ async function generate() {
if (route === '/partners/integrations/[slug]') return null
if (route === '/launch-week/ticket-image') return null
if (route === '/launch-week/tickets/[username]') return null
if (route === '/changelog/[number]') return null
if (route === '/changelog/[slug]') return null
/**
* Blog based urls
@@ -120,7 +120,7 @@ async function generate() {
const changelogDetailUrls = (() => {
try {
const rss = readFileSync('public/changelog-rss.xml', 'utf-8')
const matches = [...rss.matchAll(/<link>(https:\/\/supabase\.com\/changelog\/\d+)<\/link>/g)]
const matches = [...rss.matchAll(/<link>(https:\/\/supabase\.com\/changelog\/\d+[^<]*)<\/link>/g)]
const uniqueUrls = [...new Set(matches.map((match) => match[1]))]
return uniqueUrls.map(
+3 -1
View File
@@ -3,7 +3,7 @@ import { Octokit } from '@octokit/core'
import { paginateGraphql } from '@octokit/plugin-paginate-graphql'
import dayjs from 'dayjs'
import { discussionDisplayDate } from './changelog.utils'
import { changelogEntrySlug, discussionDisplayDate } from './changelog.utils'
export const CHANGELOG_CATEGORY_ID = 'DIC_kwDODMpXOc4CAFUr'
@@ -11,6 +11,7 @@ export type ChangelogLabel = { name: string; color: string }
export type ChangelogTimelineIndexItem = {
number: number
slug: string
title: string
url: string
sortDate: string
@@ -168,6 +169,7 @@ export async function getChangelogTimelineSortedIndex(): Promise<ChangelogTimeli
return raw
.map((item) => ({
number: item.number,
slug: changelogEntrySlug(item.number, item.title),
title: item.title,
url: item.url,
sortDate: discussionDisplayDate(item) ?? item.createdAt,
+22 -2
View File
@@ -1,6 +1,6 @@
/**
* Pure ESM changelog RSS document builder (used by generateStaticContent.mjs and re-exported from rss.tsx).
* @typedef {{ number?: number; title: string; url: string; sortDate: string; labels?: string[] }} ChangelogRssItemInput
* @typedef {{ number?: number; slug?: string; title: string; url: string; sortDate: string; labels?: string[] }} ChangelogRssItemInput
*/
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc.js'
@@ -25,7 +25,11 @@ function buildItemsXml(sorted) {
return sorted
.map((e) => {
const encodedTitle = xmlEncodeRss(e.title)
const canonicalUrl = e.number ? `https://supabase.com/changelog/${e.number}` : e.url
const canonicalUrl = e.slug
? `https://supabase.com/changelog/${e.slug}`
: e.number
? `https://supabase.com/changelog/${e.number}`
: e.url
const encodedCanonical = xmlEncodeRss(canonicalUrl)
const pubDate = formatRssPubDate(e.sortDate)
return ` <item>
@@ -38,6 +42,22 @@ function buildItemsXml(sorted) {
.join('\n')
}
/**
* Generates the URL slug for a changelog entry: `<number>-<slugified-title>`.
* Mirrors changelogEntrySlug in apps/www/lib/changelog.utils.ts.
* @param {number} number
* @param {string} title
*/
export function changelogEntrySlug(number, title) {
const titlePart = String(title ?? '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80)
.replace(/-+$/, '')
return `${number}-${titlePart}`
}
/**
* Converts a display label into a lowercase, URL-safe filename slug.
* e.g. "Edge Functions" → "edge-functions", "AI & Vector" → "ai-vector"
+10 -2
View File
@@ -149,8 +149,16 @@ export function changelogLabelDisplayName(name: string): string {
return CHANGELOG_LABEL_DISPLAY_NAME[name.toLowerCase()] ?? name
}
const GITHUB_CHANGELOG_DISCUSSIONS_BASE =
'https://github.com/orgs/supabase/discussions/categories/changelog'
/** Generates the URL slug for a changelog entry: `<number>-<slugified-title>`. */
export function changelogEntrySlug(number: number, title: string): string {
const titlePart = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80)
.replace(/-+$/, '')
return `${number}-${titlePart}`
}
/** Internal changelog index URL with preselected tag filter (nuqs `tags` param). */
export function changelogTagFilterUrl(labelName: string) {
+3 -3
View File
@@ -34,9 +34,9 @@ export function middleware(request: NextRequest) {
if (MD_PAGES.has(slug)) {
return NextResponse.rewrite(new URL(`/api-v2/md/${slug}`, request.nextUrl))
}
// Individual changelog entries (/changelog/<number>) are served as static
// .md files from public/; rewrite directly to the static path.
if (slug === 'changelog' || /^changelog\/\d+$/.test(slug)) {
// Individual changelog entries are served as static .md files from public/;
// rewrite directly to the static path. The slug always starts with the number.
if (slug === 'changelog' || /^changelog\/\d+/.test(slug)) {
return NextResponse.rewrite(new URL(`/${slug}.md`, request.nextUrl))
}
}
+1 -1
View File
@@ -95,7 +95,7 @@ const nextConfig = {
],
},
{
source: '/changelog/:number.md',
source: '/changelog/:slug.md',
headers: [
{ key: 'Content-Type', value: 'text/markdown; charset=utf-8' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
+3 -1
View File
@@ -40,6 +40,7 @@ const FEATURED_COUNT = 3
type FeaturedEntry = {
number: number
slug: string
title: string
url: string
created_at: string
@@ -81,6 +82,7 @@ export const getServerSideProps: GetServerSideProps<PageProps> = async ({ res })
}) ?? discussion.createdAt
return {
number: meta.number,
slug: meta.slug,
title: discussion.title,
url: discussion.url ?? '',
created_at,
@@ -362,7 +364,7 @@ function ChangelogIndex({ featured, restIndex, allIndex }: PageProps) {
</div>
<div className="flex w-full flex-col gap-1">
{entry.title && (
<Link href={`/changelog/${entry.number}`}>
<Link href={`/changelog/${entry.slug}`}>
<h3 className="text-foreground text-lg hover:underline">
{entry.title}
</h3>
@@ -14,7 +14,7 @@ import {
fetchChangelogDiscussionByNumber,
type ChangelogLabel,
} from '@/lib/changelog-github'
import { discussionDisplayDate } from '@/lib/changelog.utils'
import { changelogEntrySlug, discussionDisplayDate } from '@/lib/changelog.utils'
import mdxComponents from '@/lib/mdx/mdxComponents'
import { mdxSerialize } from '@/lib/mdx/mdxSerialize'
@@ -23,21 +23,22 @@ type PageProps = {
url: string
created_at: string
number: number
slug: string
source: MDXRemoteSerializeResult
labels: ChangelogLabel[]
}
const ChangelogDetailPage = ({ title, url, created_at, number, source, labels }: PageProps) => (
const ChangelogDetailPage = ({ title, url, created_at, slug, source, labels }: PageProps) => (
<>
<Head>
<link rel="alternate" type="text/markdown" href={`/changelog/${number}.md`} />
<link rel="alternate" type="text/markdown" href={`/changelog/${slug}.md`} />
</Head>
<NextSeo
title={`${title} · Changelog`}
description={title}
openGraph={{
title,
url: `https://supabase.com/changelog/${number}`,
url: `https://supabase.com/changelog/${slug}`,
type: 'article',
}}
/>
@@ -67,7 +68,7 @@ const ChangelogDetailPage = ({ title, url, created_at, number, source, labels }:
<aside className="border-default border-t pt-6 lg:col-span-4 lg:border-t-0 lg:pl-4 lg:pt-0">
<div className="thin-scrollbar lg:sticky lg:top-24 lg:max-h-[calc(100vh-8rem)] lg:overflow-y-auto">
<ChangelogDetailSidebar number={number} url={url} labels={labels} />
<ChangelogDetailSidebar slug={slug} url={url} labels={labels} />
</div>
</aside>
</div>
@@ -78,19 +79,15 @@ const ChangelogDetailPage = ({ title, url, created_at, number, source, labels }:
)
export const getStaticPaths: GetStaticPaths = async () => {
return {
paths: [],
fallback: 'blocking',
}
return { paths: [], fallback: 'blocking' }
}
export const getStaticProps: GetStaticProps<PageProps> = async ({ params }) => {
const raw = params?.number
const numStr = Array.isArray(raw) ? raw[0] : raw
const number = Number(numStr)
if (!Number.isFinite(number)) {
return { notFound: true }
}
const raw = params?.slug
const slugStr = Array.isArray(raw) ? raw[0] : (raw ?? '')
// The slug always starts with the numeric discussion number.
const number = parseInt(slugStr, 10)
if (!Number.isFinite(number) || number <= 0) return { notFound: true }
try {
const octokit = createChangelogOctokit()
@@ -105,6 +102,13 @@ export const getStaticProps: GetStaticProps<PageProps> = async ({ params }) => {
return { notFound: true }
}
const expectedSlug = changelogEntrySlug(number, discussion.title)
// Redirect number-only or mismatched slugs to the canonical slug URL.
if (slugStr !== expectedSlug) {
return { redirect: { destination: `/changelog/${expectedSlug}`, permanent: true } }
}
const source = await mdxSerialize(discussion.body)
const created_at =
discussionDisplayDate({
@@ -118,6 +122,7 @@ export const getStaticProps: GetStaticProps<PageProps> = async ({ params }) => {
url: discussion.url,
created_at,
number,
slug: expectedSlug,
source,
labels: discussion.labels?.nodes ?? [],
},
+36 -19
View File
@@ -391,7 +391,7 @@ try {
const { Octokit } = await import('@octokit/core')
const { paginateGraphql } = await import('@octokit/plugin-paginate-graphql')
const { generateChangelogRssXml, generateChangelogTagRssXml, labelToFileSlug } =
const { generateChangelogRssXml, generateChangelogTagRssXml, labelToFileSlug, changelogEntrySlug } =
await import('../lib/changelog-rss.mjs')
const rewritesPath = path.join(__dirname, 'data/changelog-deleted-discussions.json')
const rewrites = JSON.parse(await fs.readFile(rewritesPath, 'utf8'))
@@ -465,6 +465,7 @@ try {
const entries = collected.map((item) => ({
number: item.number,
slug: changelogEntrySlug(item.number, item.title),
title: item.title,
url: item.url,
sortDate: discussionDisplayDate({ title: item.title, createdAt: item.createdAt }),
@@ -498,26 +499,41 @@ try {
// LLM-friendly changelog markdown index (RSS remains canonical syndication format).
const visibleEntries = entries.filter((entry) => !entry.title.includes('[d]'))
const escapeMd = (value) =>
String(value ?? '')
.replace(/\\/g, '\\\\')
.replace(/\|/g, '\\|')
.replace(/\n/g, ' ')
const mdRows = visibleEntries.map((entry) => {
/**
* Extracts the first meaningful paragraph from a markdown body.
* Skips headings, code fences, HTML blocks, and empty lines.
*/
const extractSummary = (body) => {
if (!body) return ''
for (const para of body.split(/\n{2,}/)) {
const trimmed = para.trim()
if (
!trimmed ||
trimmed.startsWith('#') ||
trimmed.startsWith('```') ||
trimmed.startsWith('<') ||
trimmed.startsWith('|') ||
trimmed.startsWith('---')
) continue
const oneLiner = trimmed.replace(/\n/g, ' ')
return oneLiner.length > 200 ? oneLiner.slice(0, 200).replace(/\s+\S*$/, '') + '…' : oneLiner
}
return ''
}
const mdSections = visibleEntries.map((entry) => {
const date = dayjs(entry.sortDate).isValid() ? dayjs(entry.sortDate).format('YYYY-MM-DD') : ''
const labels = (entry.labels ?? []).join(', ')
return `| ${date} | ${entry.number} | ${escapeMd(labels)} | ${escapeMd(entry.title)} | [/changelog/${entry.number}.md](https://supabase.com/changelog/${entry.number}.md) |`
const meta = [date, labels, `[supabase.com/changelog/${entry.slug}](https://supabase.com/changelog/${entry.slug})`]
.filter(Boolean)
.join(' · ')
const summary = extractSummary(entry.body)
return [`## ${entry.title}`, meta, summary].filter(Boolean).join('\n\n')
})
const changelogMd = `# Supabase Changelog
All paths are relative to \`https://supabase.com\`.
| Date | # | Labels | Title | Path |
| --- | --- | --- | --- | --- |
${mdRows.join('\n')}
`
const changelogMd = `# Supabase Changelog\n\n${mdSections.join('\n\n---\n\n')}\n`
const changelogMdPath = path.join(__dirname, '../public/changelog.md')
await fs.writeFile(changelogMdPath, changelogMd.trim() + '\n', 'utf8')
await fs.writeFile(changelogMdPath, changelogMd, 'utf8')
console.log(`✅ Generated changelog.md (${visibleEntries.length} entries)`)
// One markdown file per entry → /changelog/<number>.md (same content shape as the web page body).
@@ -531,9 +547,10 @@ ${mdRows.join('\n')}
.replace(/\n/g, ' ')
.trim()
const labelsYaml = (entry.labels ?? []).map((l) => ` - ${l}`).join('\n')
const pageUrl = `https://supabase.com/changelog/${entry.number}`
const pageUrl = `https://supabase.com/changelog/${entry.slug}`
const entryMd = `---
number: ${entry.number}
slug: ${entry.slug}
published: ${published}
discussion: ${entry.url}
labels:
@@ -546,7 +563,7 @@ page: ${pageUrl}
${entry.body ?? ''}
`
await fs.writeFile(
path.join(changelogEntryMdDir, `${entry.number}.md`),
path.join(changelogEntryMdDir, `${entry.slug}.md`),
entryMd.trim() + '\n',
'utf8'
)