diff --git a/apps/www/components/Changelog/ChangelogDetailSidebar.tsx b/apps/www/components/Changelog/ChangelogDetailSidebar.tsx index 3d9e2f5f86..ede2362574 100644 --- a/apps/www/components/Changelog/ChangelogDetailSidebar.tsx +++ b/apps/www/components/Changelog/ChangelogDetailSidebar.tsx @@ -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` diff --git a/apps/www/components/Changelog/ChangelogTimelineList.tsx b/apps/www/components/Changelog/ChangelogTimelineList.tsx index 00f219f679..fc41faefdb 100644 --- a/apps/www/components/Changelog/ChangelogTimelineList.tsx +++ b/apps/www/components/Changelog/ChangelogTimelineList.tsx @@ -141,7 +141,7 @@ export function ChangelogTimelineList(props: Props) {
{yearItems.map((item) => ( - + ))}
diff --git a/apps/www/internals/generate-sitemap.mjs b/apps/www/internals/generate-sitemap.mjs index 415ef8b82c..8bca05cc71 100644 --- a/apps/www/internals/generate-sitemap.mjs +++ b/apps/www/internals/generate-sitemap.mjs @@ -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(/(https:\/\/supabase\.com\/changelog\/\d+)<\/link>/g)] + const matches = [...rss.matchAll(/(https:\/\/supabase\.com\/changelog\/\d+[^<]*)<\/link>/g)] const uniqueUrls = [...new Set(matches.map((match) => match[1]))] return uniqueUrls.map( diff --git a/apps/www/lib/changelog-github.ts b/apps/www/lib/changelog-github.ts index 5d4d3a5203..be049de5ae 100644 --- a/apps/www/lib/changelog-github.ts +++ b/apps/www/lib/changelog-github.ts @@ -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 ({ number: item.number, + slug: changelogEntrySlug(item.number, item.title), title: item.title, url: item.url, sortDate: discussionDisplayDate(item) ?? item.createdAt, diff --git a/apps/www/lib/changelog-rss.mjs b/apps/www/lib/changelog-rss.mjs index 86f39765f8..0016b573ee 100644 --- a/apps/www/lib/changelog-rss.mjs +++ b/apps/www/lib/changelog-rss.mjs @@ -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 ` @@ -38,6 +42,22 @@ function buildItemsXml(sorted) { .join('\n') } +/** + * Generates the URL slug for a changelog entry: `-`. + * 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" diff --git a/apps/www/lib/changelog.utils.ts b/apps/www/lib/changelog.utils.ts index 8dcb14b8b6..72cb77ec55 100644 --- a/apps/www/lib/changelog.utils.ts +++ b/apps/www/lib/changelog.utils.ts @@ -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: `-`. */ +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) { diff --git a/apps/www/middleware.ts b/apps/www/middleware.ts index f354564a14..e83020bf96 100644 --- a/apps/www/middleware.ts +++ b/apps/www/middleware.ts @@ -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/) 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)) } } diff --git a/apps/www/next.config.mjs b/apps/www/next.config.mjs index 97f3d0ff09..67fb7526e5 100644 --- a/apps/www/next.config.mjs +++ b/apps/www/next.config.mjs @@ -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' }, diff --git a/apps/www/pages/changelog.tsx b/apps/www/pages/changelog.tsx index e6ec17c19c..62c3e5e2a3 100644 --- a/apps/www/pages/changelog.tsx +++ b/apps/www/pages/changelog.tsx @@ -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 = 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) {
{entry.title && ( - +

{entry.title}

diff --git a/apps/www/pages/changelog/[number].tsx b/apps/www/pages/changelog/[slug].tsx similarity index 79% rename from apps/www/pages/changelog/[number].tsx rename to apps/www/pages/changelog/[slug].tsx index 27a8a68187..f9305bb9b7 100644 --- a/apps/www/pages/changelog/[number].tsx +++ b/apps/www/pages/changelog/[slug].tsx @@ -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) => ( <> - + @@ -67,7 +68,7 @@ const ChangelogDetailPage = ({ title, url, created_at, number, source, labels }:
@@ -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 = 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 = 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 = async ({ params }) => { url: discussion.url, created_at, number, + slug: expectedSlug, source, labels: discussion.labels?.nodes ?? [], }, diff --git a/apps/www/scripts/generateStaticContent.mjs b/apps/www/scripts/generateStaticContent.mjs index e9971a649f..7c8fe24c57 100644 --- a/apps/www/scripts/generateStaticContent.mjs +++ b/apps/www/scripts/generateStaticContent.mjs @@ -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/.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' )