mirror of
https://github.com/supabase/supabase.git
synced 2026-05-06 22:18:00 +08:00
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:
committed by
GitHub
parent
34241f1f66
commit
8ba1054dfe
@@ -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>
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 ?? [],
|
||||
},
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user