feat(www): update changelog layout, rss and md files (#45219)

- Update Changelog [index page
layout](https://zone-www-dot-com-git-feat-changelog-update-supabase.vercel.app/changelog):
  - with full timeline
  - filterable based on text search and tags
- New Changelog [detail
pages](https://zone-www-dot-com-git-feat-changelog-update-supabase.vercel.app/changelog/45071)
  - all added to www_sitemap
- Changelog [RSS
Feed](https://zone-www-dot-com-git-feat-changelog-update-supabase.vercel.app/changelog/45071)
+ llm-friendly
[/changelog.md](https://zone-www-dot-com-git-feat-changelog-update-supabase.vercel.app/changelog.md)
- and llm-friendly changelog detail md files:
https://zone-www-dot-com-git-feat-changelog-update-supabase.vercel.app/changelog/45071.md

## Before
<img width="1604" height="1094" alt="Screenshot 2026-04-27 at 17 07 55"
src="https://github.com/user-attachments/assets/eac52f14-e447-4f64-8d50-a8e287ccf989"
/>

## After
<img width="1247" height="849" alt="changelog-index"
src="https://github.com/user-attachments/assets/69b7bae1-63eb-4a4d-a065-7541ed9738b4"
/>

### Detail page
<img width="1695" height="1101" alt="Screenshot 2026-04-27 at 18 27 27"
src="https://github.com/user-attachments/assets/accd4be8-d665-43ed-bcb7-0e6baf537762"
/>


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

## Summary by CodeRabbit

## Release Notes

* **New Features**
* Redesigned changelog page with full-text search and product tag
filtering
  * Individual pages for each changelog entry with dedicated URLs
  * Added RSS feeds for changelog updates and product-specific feeds
  * Copy changelog entries as markdown with one click
  * Direct sharing integration with ChatGPT and Claude

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
This commit is contained in:
Francesco Sansalvadore
2026-04-29 12:31:30 +02:00
committed by GitHub
parent 45ffa97240
commit 580598f0e8
22 changed files with 1751 additions and 343 deletions
+3
View File
@@ -8,6 +8,9 @@ apps/**/out
**/supabase/migrations/*.sql
apps/www/schema.sql
apps/www/public/images/*
# Generated by apps/www/scripts/generateStaticContent.mjs (GitHub discussion bodies)
apps/www/public/changelog.md
apps/www/public/changelog/*.md
apps/www/.generated/*
apps/docs/**/generated/*
apps/docs/examples/*
+5 -5
View File
@@ -1,17 +1,17 @@
'use client'
import { Feedback } from '~/components/Feedback'
import { useSendTelemetryEvent } from '~/lib/telemetry'
import { isFeatureEnabled } from 'common'
import { Chatgpt, Claude } from 'icons'
import { Check, Copy, ExternalLink } from 'lucide-react'
import { usePathname } from 'next/navigation'
import { useState } from 'react'
import { isFeatureEnabled } from 'common'
import { cn } from 'ui'
import { ExpandableVideo } from 'ui-patterns/ExpandableVideo'
import { Toc, TOCItems, TOCScrollArea } from 'ui-patterns/Toc'
import { Feedback } from '~/components/Feedback'
import { useTocAnchors } from '../features/docs/GuidesMdx.state'
import { Chatgpt } from 'icons'
import { Claude } from 'icons'
import { useSendTelemetryEvent } from '~/lib/telemetry'
interface TOCHeader {
id?: string
+6
View File
@@ -32,6 +32,12 @@ public/sitemap_www.xml
# Generated .md content bundle (built by scripts/generateMdContent.mjs)
app/api-v2/md/content.generated.ts
# Changelog generated feeds and markdown (built by content:build)
/public/changelog-rss.xml
/public/changelog-rss
/public/changelog.md
/public/changelog
# contentlayer
.contentlayer
.vercel
@@ -0,0 +1,93 @@
'use client'
import { useCopyMarkdownFromUrl } from 'common'
import { Chatgpt, Claude } from 'icons'
import { Check, Copy, ExternalLink } from 'lucide-react'
import { cn } from 'ui'
import { LabelBadges } from '@/components/Changelog/ChangelogTimelineList'
import type { ChangelogLabel } from '@/lib/changelog-github'
import { SITE_ORIGIN } from '@/lib/constants'
type Props = {
number: number
url: string
labels: ChangelogLabel[]
className?: string
}
export function ChangelogDetailSidebar({ number, url, labels, className }: Props) {
const { copied, copyMarkdown } = useCopyMarkdownFromUrl()
const mdPath = `/changelog/${number}.md`
const mdAbs = `${SITE_ORIGIN}${mdPath}`
const aiPrompt = `Read from ${mdAbs} so I can ask questions about its contents`
return (
<div className={cn('flex flex-col gap-6', className)}>
{labels.length > 0 && (
<>
<section aria-labelledby="changelog-detail-tags">
<h2
id="changelog-detail-tags"
className="text-foreground-light mb-3 font-mono text-xs uppercase tracking-wide"
>
Tags
</h2>
<LabelBadges labels={labels} onBadgeClick={(e) => e.stopPropagation()} />
</section>
<div className="border-default border-t" role="presentation" />
</>
)}
<section aria-labelledby="changelog-detail-links">
<h2
id="changelog-detail-links"
className="text-foreground-light mb-3 font-mono text-xs uppercase tracking-wide"
>
Links
</h2>
<nav className="flex flex-col gap-2">
<a
href={url}
target="_blank"
rel="noreferrer noopener"
className="text-foreground-lighter hover:text-foreground flex items-center gap-1.5 text-xs transition-colors"
>
<ExternalLink size={14} strokeWidth={1.5} />
View discussion on GitHub
</a>
<button
type="button"
onClick={() => void copyMarkdown(mdPath)}
className="text-foreground-lighter hover:text-foreground flex items-center gap-1.5 text-left text-xs transition-colors"
>
{copied ? (
<Check size={14} strokeWidth={1.5} className="text-brand" />
) : (
<Copy size={14} strokeWidth={1.5} />
)}
{copied ? 'Copied as markdown' : 'Copy page as markdown'}
</button>
<a
href={`https://chatgpt.com/?hint=search&q=${encodeURIComponent(`Read from ${mdAbs} so I can ask questions about its contents`)}`}
target="_blank"
rel="noreferrer noopener"
className="text-foreground-lighter hover:text-foreground flex items-center gap-1.5 text-xs transition-colors"
>
<Chatgpt size={14} />
Ask ChatGPT
</a>
<a
href={`https://claude.ai/new?q=${encodeURIComponent(aiPrompt)}`}
target="_blank"
rel="noreferrer noopener"
className="text-foreground-lighter hover:text-foreground flex items-center gap-1.5 text-xs transition-colors"
>
<Claude size={14} />
Ask Claude
</a>
</nav>
</section>
</div>
)
}
@@ -0,0 +1,78 @@
'use client'
import { useCopyMarkdownFromUrl } from 'common'
import { Chatgpt, Claude } from 'icons'
import { Check, ChevronDown, Copy } from 'lucide-react'
import {
Button,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from 'ui'
import { SITE_ORIGIN } from '@/lib/constants'
type Props = {
className?: string
markdownPath?: string
}
export function ChangelogLlmMarkdownButton({ className, markdownPath = '/changelog.md' }: Props) {
const { copied, copyMarkdown } = useCopyMarkdownFromUrl()
const mdAbs = `${SITE_ORIGIN}${markdownPath}`
const aiPrompt = `Read from ${mdAbs} so I can ask questions about its contents`
return (
<div className={cn('flex items-center', className)}>
<Button
type="default"
className="rounded-r-none border-r-0"
icon={
copied ? (
<Check className="h-4 w-4" strokeWidth={2} aria-hidden />
) : (
<Copy className="h-4 w-4" strokeWidth={2} aria-hidden />
)
}
onClick={() => void copyMarkdown(markdownPath)}
>
{copied ? 'Copied as Markdown' : 'Copy as Markdown'}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="default"
className="rounded-l-none px-1"
icon={<ChevronDown className="h-4 w-4" strokeWidth={2} aria-hidden />}
aria-label="Open LLM options for this changelog page"
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem asChild className="gap-2">
<a
href={`https://chatgpt.com/?hint=search&q=${encodeURIComponent(aiPrompt)}`}
target="_blank"
rel="noreferrer noopener"
>
<Chatgpt className="h-4 w-4 shrink-0" />
Ask ChatGPT
</a>
</DropdownMenuItem>
<DropdownMenuItem asChild className="gap-2">
<a
href={`https://claude.ai/new?q=${encodeURIComponent(aiPrompt)}`}
target="_blank"
rel="noreferrer noopener"
>
<Claude className="h-4 w-4 shrink-0" />
Ask Claude
</a>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
@@ -0,0 +1,152 @@
import type { ChangelogLabel, ChangelogTimelineIndexItem } from '~/lib/changelog-github'
import { changelogLabelDisplayName, changelogTagFilterUrl } from '~/lib/changelog.utils'
import dayjs from 'dayjs'
import { GitCommit } from 'lucide-react'
import Link from 'next/link'
import type { MouseEvent } from 'react'
import { Badge, cn } from 'ui'
function groupChangelogIndexByYear(
items: ChangelogTimelineIndexItem[]
): [number, ChangelogTimelineIndexItem[]][] {
const map = new Map<number, ChangelogTimelineIndexItem[]>()
for (const item of items) {
const y = dayjs(item.sortDate).year()
if (!map.has(y)) map.set(y, [])
map.get(y)!.push(item)
}
return [...map.entries()].sort((a, b) => b[0] - a[0])
}
export function LabelBadges({
labels,
onBadgeClick,
tiny,
className,
}: {
labels: ChangelogLabel[]
onBadgeClick?: (e: MouseEvent) => void
tiny?: boolean
className?: string
}) {
if (labels.length === 0) return null
return (
<div className={cn('flex flex-wrap items-center', tiny ? 'gap-0.5' : 'gap-1', className)}>
{labels.map((label) => (
<a
key={label.name}
href={changelogTagFilterUrl(label.name)}
className={
tiny
? 'inline-flex shrink-0 no-underline focus-visible:ring-brand-default rounded focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-none'
: 'inline-flex shrink-0 no-underline focus-visible:ring-brand-default rounded-md focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none'
}
onClick={onBadgeClick}
>
<Badge
variant="default"
className={cn(
'tracking-normal lowercase border-default',
tiny
? 'text-foreground-lighter hover:text-foreground-light rounded-full border px-0.5 py-px text-[8px] font-medium leading-none'
: 'text-foreground-light hover:text-foreground rounded-full border px-1.5 py-px text-[11px] font-medium leading-tight'
)}
>
{changelogLabelDisplayName(label.name)}
</Badge>
</a>
))}
</div>
)
}
function TimelineRow({ item, href }: { item: ChangelogTimelineIndexItem; href: string }) {
const dateLabel = dayjs(item.sortDate).format('MMM D')
const labels = item.labels ?? []
return (
<div
className="group border-default flex w-full flex-col gap-0.5 border-b py-3 text-left scroll-mt-16"
id={item.number.toString()}
>
<div className="min-w-0">
<Link href={href} prefetch={false} className="min-w-0 text-left">
<h3 className="text-foreground text-lg leading-snug hover:underline">{item.title}</h3>
</Link>
</div>
<div className="flex min-w-0 gap-2 pt-0.5">
<time dateTime={item.sortDate} className="text-foreground-lighter text-xs tracking-normal">
{dateLabel}
</time>
<LabelBadges labels={labels} onBadgeClick={(e) => e.stopPropagation()} />
</div>
</div>
)
}
type Props = {
items: ChangelogTimelineIndexItem[]
omitOuterTimelineBorder?: boolean
}
export function ChangelogTimelineList(props: Props) {
const { items, omitOuterTimelineBorder } = props
const yearGroups = groupChangelogIndexByYear(items)
return (
<div
className={
omitOuterTimelineBorder ? 'relative' : 'border-muted relative lg:border-l lg:ml-2 lg:pl-8'
}
>
{yearGroups.map(([year, yearItems], yearIndex) => (
<section
key={year}
id={year.toString()}
aria-labelledby={`${year}`}
className="relative scroll-mt-20"
>
<Link
href={`#${year}`}
prefetch={false}
id={`${year}`}
className="lg:hidden block border-default bg-default text-foreground-light sticky top-[65px] scroll-mt-10 z-20 border-b py-2 pl-0 font-mono text-sm tracking-wide"
>
{year}
</Link>
<div
className={
yearIndex === yearGroups.length - 1
? 'grid lg:grid-cols-12 lg:gap-4 pt-2 lg:pt-2'
: 'grid lg:grid-cols-12 lg:gap-4 pb-8 lg:pb-20 lg:py-2'
}
>
<div className="relative hidden lg:col-span-2 lg:block">
<div className="-ml-[42px] text-foreground lg:sticky lg:top-[calc(65px+1rem)] lg:pt-4">
<div className="text-foreground-light mb-1 flex items-center gap-2">
<div className="bg-border border-muted flex h-5 w-5 shrink-0 items-center justify-center rounded border drop-shadow-sm">
<GitCommit size={14} strokeWidth={1.5} />
</div>
<Link
href={`#${year}`}
prefetch={false}
className="font-mono text-base leading-none"
>
{year}
</Link>
</div>
</div>
</div>
<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}`} />
))}
</div>
</div>
</section>
))}
</div>
)
}
+25
View File
@@ -0,0 +1,25 @@
[
{ "slug": "database", "label": "Database" },
{ "slug": "auth", "label": "Auth" },
{ "slug": "storage", "label": "Storage" },
{ "slug": "realtime", "label": "Realtime" },
{ "slug": "edge functions", "label": "Edge Functions" },
{ "slug": "postgres", "label": "postgres" },
{ "slug": "postgrest", "label": "PostgREST" },
{ "slug": "ai", "label": "AI & Vector" },
{ "slug": "analytics", "label": "Analytics" },
{ "slug": "billing", "label": "Billing" },
{ "slug": "breaking-change", "label": "Breaking Change" },
{ "slug": "cli", "label": "CLI" },
{ "slug": "etl", "label": "ETL" },
{ "slug": "frontend", "label": "Dashboard" },
{ "slug": "graphql", "label": "GraphQL" },
{ "slug": "documentation", "label": "Docs" },
{ "slug": "infra", "label": "Infra" },
{ "slug": "multigres", "label": "Multigres" },
{ "slug": "self-hosted", "label": "Self-hosted" },
{ "slug": "javascript", "label": "supabase-js" },
{ "slug": "swift", "label": "supabase-swift" },
{ "slug": "flutter", "label": "supabase-flutter" },
{ "slug": "python", "label": "supabase-py" }
]
+24 -2
View File
@@ -1,4 +1,4 @@
import { writeFileSync } from 'fs'
import { readFileSync, writeFileSync } from 'fs'
import { globby } from 'globby'
import prettier from 'prettier'
@@ -64,6 +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
/**
* Blog based urls
@@ -115,10 +116,31 @@ async function generate() {
})
.filter(Boolean)
// Changelog detail pages are dynamic routes; include them from generated changelog RSS links.
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 uniqueUrls = [...new Set(matches.map((match) => match[1]))]
return uniqueUrls.map(
(url) => `
<url>
<loc>${url}</loc>
<changefreq>weekly</changefreq>
<priority>0.5</priority>
</url>
`
)
} catch {
return []
}
})()
const sitemap = `
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${[...staticUrls].join('')}
${[...staticUrls, ...changelogDetailUrls].join('')}
</urlset>
`
+181
View File
@@ -0,0 +1,181 @@
import { createAppAuth } from '@octokit/auth-app'
import { Octokit } from '@octokit/core'
import { paginateGraphql } from '@octokit/plugin-paginate-graphql'
import dayjs from 'dayjs'
import { discussionDisplayDate } from './changelog.utils'
export const CHANGELOG_CATEGORY_ID = 'DIC_kwDODMpXOc4CAFUr'
export type ChangelogLabel = { name: string; color: string }
export type ChangelogTimelineIndexItem = {
number: number
title: string
url: string
sortDate: string
labels: ChangelogLabel[]
}
export type ChangelogDiscussionMetadata = {
id: string
number: number
title: string
publishedAt: string | null
createdAt: string
url: string
labels?: {
nodes: ChangelogLabel[]
}
}
export function createChangelogOctokit() {
const ExtendedOctokit = Octokit.plugin(paginateGraphql)
return new ExtendedOctokit({
authStrategy: createAppAuth,
auth: {
appId: process.env.GITHUB_CHANGELOG_APP_ID,
installationId: process.env.GITHUB_CHANGELOG_APP_INSTALLATION_ID,
privateKey: process.env.GITHUB_CHANGELOG_APP_PRIVATE_KEY,
},
})
}
export async function fetchAllChangelogDiscussionMetadata(
octokit: ReturnType<typeof createChangelogOctokit>,
owner: string,
repo: string,
categoryId: string
): Promise<ChangelogDiscussionMetadata[]> {
type DiscussionMetadataResponse = {
repository: {
discussions: {
nodes: ChangelogDiscussionMetadata[]
pageInfo: { hasNextPage: boolean; endCursor: string | null }
}
}
}
const query = `
query changelogDiscussionMetadata($cursor: String, $owner: String!, $repo: String!, $categoryId: ID!) {
repository(owner: $owner, name: $repo) {
discussions(
first: 100
after: $cursor
categoryId: $categoryId
orderBy: { field: CREATED_AT, direction: DESC }
) {
nodes {
id
number
title
publishedAt
createdAt
url
labels(first: 25) {
nodes {
name
color
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
`
const collected: ChangelogDiscussionMetadata[] = []
let cursor: string | null = null
let hasNextPage = true
while (hasNextPage) {
const response: DiscussionMetadataResponse = await octokit.graphql(query, {
owner,
repo,
categoryId,
cursor,
})
const { nodes, pageInfo } = response.repository.discussions
collected.push(...nodes)
hasNextPage = pageInfo.hasNextPage
cursor = pageInfo.endCursor
}
return collected
}
export type FetchedChangelogDiscussion = {
number: number
title: string
body: string
url: string
createdAt: string
category: { id: string; name: string } | null
labels: { nodes: ChangelogLabel[] }
}
export async function fetchChangelogDiscussionByNumber(
octokit: ReturnType<typeof createChangelogOctokit>,
owner: string,
repo: string,
number: number
): Promise<FetchedChangelogDiscussion | null> {
const query = `
query changelogDiscussionByNumber($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
discussion(number: $number) {
number
title
body
url
createdAt
category {
id
name
}
labels(first: 25) {
nodes {
name
color
}
}
}
}
}
`
const data = await octokit.graphql<{
repository: { discussion: FetchedChangelogDiscussion | null }
}>(query, { owner, repo, number })
return data.repository.discussion
}
export async function getChangelogTimelineSortedIndex(): Promise<ChangelogTimelineIndexItem[]> {
const octokit = createChangelogOctokit()
const raw = await fetchAllChangelogDiscussionMetadata(
octokit,
'supabase',
'supabase',
CHANGELOG_CATEGORY_ID
)
return raw
.map((item) => ({
number: item.number,
title: item.title,
url: item.url,
sortDate: discussionDisplayDate(item) ?? item.createdAt,
labels:
item.labels?.nodes?.map((l) => ({
name: l.name,
color: (l.color || '6b7280').replace(/^#/, ''),
})) ?? [],
}))
.sort((a, b) => dayjs(b.sortDate).diff(dayjs(a.sortDate)))
}
+115
View File
@@ -0,0 +1,115 @@
/**
* 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
*/
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc.js'
dayjs.extend(utc)
function xmlEncodeRss(str) {
if (str === undefined || str === null) return ''
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
}
function formatRssPubDate(isoOrDate) {
return `${dayjs(isoOrDate).utc().startOf('day').format('ddd, DD MMM YYYY HH:mm:ss')} +0000`
}
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 encodedCanonical = xmlEncodeRss(canonicalUrl)
const pubDate = formatRssPubDate(e.sortDate)
return ` <item>
<guid isPermaLink="true">${encodedCanonical}</guid>
<title>${encodedTitle}</title>
<link>${encodedCanonical}</link>
<pubDate>${pubDate}</pubDate>
</item>`
})
.join('\n')
}
/**
* Converts a display label into a lowercase, URL-safe filename slug.
* e.g. "Edge Functions" → "edge-functions", "AI & Vector" → "ai-vector"
* @param {string} label
*/
export function labelToFileSlug(label) {
return label
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
/** @param {ChangelogRssItemInput[]} entries */
export function generateChangelogRssXml(entries) {
const visible = entries.filter((e) => !e.title.includes('[d]'))
const sorted = [...visible].sort(
(a, b) => dayjs(b.sortDate).valueOf() - dayjs(a.sortDate).valueOf()
)
const lastBuildDate = sorted[0]?.sortDate
? formatRssPubDate(sorted[0].sortDate)
: formatRssPubDate(dayjs().toISOString())
return `
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>Supabase Changelog</title>
<link>https://supabase.com/changelog</link>
<description>Product updates and improvements from Supabase</description>
<language>en</language>
<lastBuildDate>${lastBuildDate}</lastBuildDate>
<atom:link href="https://supabase.com/changelog-rss.xml" rel="self" type="application/rss+xml"/>
${buildItemsXml(sorted)}
</channel>
</rss>
`
}
/**
* Generates a tag-filtered RSS feed.
* @param {ChangelogRssItemInput[]} allEntries - all entries with labels as lowercase strings
* @param {{ githubLabelSlug: string; displayLabel: string }} tag
*/
export function generateChangelogTagRssXml(allEntries, tag) {
const { githubLabelSlug, displayLabel } = tag
const fileSlug = labelToFileSlug(displayLabel)
const filtered = allEntries.filter(
(e) =>
!e.title.includes('[d]') && (e.labels ?? []).includes(githubLabelSlug.toLowerCase())
)
const sorted = [...filtered].sort(
(a, b) => dayjs(b.sortDate).valueOf() - dayjs(a.sortDate).valueOf()
)
const lastBuildDate = sorted[0]?.sortDate
? formatRssPubDate(sorted[0].sortDate)
: formatRssPubDate(dayjs().toISOString())
const feedUrl = `https://supabase.com/changelog-rss/${fileSlug}.xml`
return `
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>Supabase Changelog · ${displayLabel}</title>
<link>https://supabase.com/changelog</link>
<description>${displayLabel} updates and improvements from Supabase</description>
<language>en</language>
<lastBuildDate>${lastBuildDate}</lastBuildDate>
<atom:link href="${feedUrl}" rel="self" type="application/rss+xml"/>
${buildItemsXml(sorted)}
</channel>
</rss>
`
}
+66
View File
@@ -1,6 +1,13 @@
import changelogProductTags from '~/data/changelog-product-tags.json'
import type { ChangelogTimelineIndexItem } from './changelog-github'
// hackery to fix Terry accidentally deleting
// a bunch of releases and their associted discussions in Dec 2023
// checks if titles match and grabs this original createdAt timestamp
//
// When editing entries with `createdAt`, also sync `scripts/data/changelog-deleted-discussions.json`
// (title + createdAt only) — used by generateStaticContent.mjs for changelog-rss.xml.
export const deletedDiscussions = [
{
title: 'Platform updates: October 2023',
@@ -120,3 +127,62 @@ export const deletedDiscussions = [
createdAt: '2021-05-05T05:17:27Z',
},
]
export function discussionDisplayDate(item: { title: string; createdAt: string }) {
const dateRewrite = deletedDiscussions.find(
(rewrite) => item.title && rewrite.title && item.title.includes(rewrite.title)
)
return dateRewrite ? dateRewrite.createdAt : item.createdAt
}
const CHANGELOG_LABEL_DISPLAY_NAME: Record<string, string> = {
documentation: 'docs',
frontend: 'dashboard',
javascript: 'supabase-js',
swift: 'supabase-swift',
flutter: 'supabase-flutter',
python: 'supabase-py',
}
/** Returns the display name for a GitHub label, falling back to the original name. */
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'
/** Internal changelog index URL with preselected tag filter (nuqs `tags` param). */
export function changelogTagFilterUrl(labelName: string) {
return `/changelog?tags=${encodeURIComponent(labelName.toLowerCase())}`
}
export const CHANGELOG_PRODUCT_TAGS = changelogProductTags as Array<{
slug: string
label: string
}>
const CHANGELOG_PRODUCT_SLUG_SET = new Set<string>(CHANGELOG_PRODUCT_TAGS.map((tag) => tag.slug))
export function isChangelogProductSlug(value: string) {
return CHANGELOG_PRODUCT_SLUG_SET.has(value)
}
export function itemMatchesChangelogSearch(item: ChangelogTimelineIndexItem, query: string) {
const normalizedQuery = query.trim().toLowerCase()
if (!normalizedQuery) return true
if (item.title.toLowerCase().includes(normalizedQuery)) return true
return item.labels.some((label) => label.name.toLowerCase().includes(normalizedQuery))
}
export function itemMatchesChangelogSelectedTags(
item: ChangelogTimelineIndexItem,
selectedTags: Set<string>
) {
if (selectedTags.size === 0) return true
const labelNames = new Set(item.labels.map((label) => label.name.toLowerCase()))
for (const slug of selectedTags) {
if (labelNames.has(slug.toLowerCase())) return true
}
return false
}
+81 -11
View File
@@ -1,10 +1,20 @@
import { remarkCodeHike, type CodeHikeConfig } from '@code-hike/mdx'
import { preprocessMdxWithCodeTabs } from '~/components/CodeTabs'
import codeHikeTheme from 'config/code-hike.theme.json' with { type: 'json' }
import { serialize } from 'next-mdx-remote/serialize'
import rehypeSlug from 'rehype-slug'
import remarkGfm from 'remark-gfm'
import { type CodeHikeConfig, remarkCodeHike } from '@code-hike/mdx'
import codeHikeTheme from 'config/code-hike.theme.json' with { type: 'json' }
import { preprocessMdxWithCodeTabs } from '~/components/CodeTabs'
/**
* Applies a string transform only to content outside fenced code blocks
* (``` or ~~~) so pre-parse fixes never corrupt code examples.
*/
function transformOutsideCodeFences(source: string, transform: (chunk: string) => string): string {
const fenceRe = /(^(?:`{3,}|~{3,})[^\n]*\n[\s\S]*?\n(?:`{3,}|~{3,})[ \t]*$)/gm
const parts = source.split(fenceRe)
// split() with a capture group: even indices are outside fences, odd are fence blocks
return parts.map((part, i) => (i % 2 === 0 ? transform(part) : part)).join('')
}
// mdx2 needs self-closing tags.
// dragging an image onto a GitHub discussion creates an <img>
@@ -15,16 +25,75 @@ function addSelfClosingTags(htmlString: string): string {
if (!htmlString || typeof htmlString !== 'string') {
return ''
}
return transformOutsideCodeFences(htmlString, (chunk) =>
chunk.replace(/<img[^>]*>|<br[^>]*>|<hr[^>]*>/g, (match) =>
match.endsWith('/>') ? match : match.slice(0, -1) + ' />'
)
)
}
const modifiedHTML = htmlString.replace(/<img[^>]*>|<br[^>]*>|<hr[^>]*>/g, (match) => {
if (match.endsWith('/>')) {
return match
} else {
// Add slash (/) to make it self-closing
return match.slice(0, -1) + ' />'
/**
* Remark plugin: converts raw HTML `<img>` nodes and MDX JSX `<img />` elements
* to standard markdown `image` AST nodes so they go through the mdxComponents
* img rendering pipeline.
*
* Operating on the AST means code/inlineCode nodes are already separate subtrees
* and are never visited, so code fences are never affected.
*/
function remarkNormalizeHtmlImages() {
return function transformer(tree: any) {
const getAttrFromString = (attrs: string, name: string) => {
const m = attrs.match(new RegExp(`${name}\\s*=\\s*("([^"]*)"|'([^']*)')`, 'i'))
return (m?.[2] ?? m?.[3] ?? '').trim()
}
})
return modifiedHTML
const walk = (node: any, parent: any, index: number) => {
// Never descend into code nodes — content there is literal text.
if (node.type === 'code' || node.type === 'inlineCode') return
if (parent !== null) {
// Standard markdown / non-MDX: raw HTML blocks become `html` nodes.
if (node.type === 'html') {
const match = /^\s*<img\b([^>]*)\/?>\s*$/i.exec(node.value)
if (match) {
const src = getAttrFromString(match[1], 'src')
if (src) {
const alt = getAttrFromString(match[1], 'alt')
parent.children[index] = { type: 'image', url: src, alt, title: null }
return
}
}
}
// MDX mode: remark-mdx parses <img /> as mdxJsxFlowElement / mdxJsxTextElement.
if (
(node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') &&
node.name === 'img'
) {
const getJsxAttr = (name: string): string => {
const attr = node.attributes?.find(
(a: any) => a.type === 'mdxJsxAttribute' && a.name === name
)
return typeof attr?.value === 'string' ? attr.value : ''
}
const src = getJsxAttr('src')
if (src) {
const alt = getJsxAttr('alt')
parent.children[index] = { type: 'image', url: src, alt, title: null }
return
}
}
}
if (node.children) {
for (let i = 0; i < node.children.length; i++) {
walk(node.children[i], node, i)
}
}
}
walk(tree, null, 0)
}
}
type TocItem = { content: string; slug: string; lvl: number }
@@ -95,6 +164,7 @@ export async function mdxSerialize(source: string, options?: { tocDepth?: number
},
mdxOptions: {
remarkPlugins: [
remarkNormalizeHtmlImages,
[remarkCodeHike, codeHikeOptions],
remarkGfm,
// Collect headings into a simple TOC structure
+25 -15
View File
@@ -1,27 +1,27 @@
import authors from 'lib/authors.json'
const dayjs = require('dayjs')
var utc = require('dayjs/plugin/utc')
var advancedFormat = require('dayjs/plugin/advancedFormat')
dayjs.extend(utc)
dayjs.extend(advancedFormat)
const generateRssItem = (post: any): string => {
const xmlEncode = (str: string) => {
if (str === undefined || str === null) {
return ''
}
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
export function xmlEncodeRss(str: string | undefined | null): string {
if (str === undefined || str === null) {
return ''
}
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
}
const encodedTitle = xmlEncode(post.title)
const encodedPath = xmlEncode(post.path)
const encodedDescription = xmlEncode(post.description)
const generateRssItem = (post: any): string => {
const encodedTitle = xmlEncodeRss(post.title)
const encodedPath = xmlEncodeRss(post.path)
const encodedDescription = xmlEncodeRss(post.description)
const formattedDate = dayjs(post.date)
.utcOffset(0, true)
.startOf('day')
@@ -37,9 +37,19 @@ const generateRssItem = (post: any): string => {
`
}
export type ChangelogRssItemInput = {
title: string
url: string
sortDate: string
}
/** Implemented in `./changelog-rss.mjs` (used by `scripts/generateStaticContent.mjs`). */
export { generateChangelogRssXml } from './changelog-rss.mjs'
// This utility generates RSS feeds for specialized content:
// 1. Customer stories RSS feed (customers-rss.xml) - used by pages/customers.tsx via getStaticProps
// 2. Author-specific PlanetPG RSS feeds (planetpg-{authorID}-rss.xml) - filtered feeds for individual authors
// 3. Changelog RSS (changelog-rss.xml) — built in generateStaticContent.mjs via ./changelog-rss.mjs
//
// Note: The main blog RSS feed (rss.xml) containing all blog posts is generated separately
// in generateStaticContent.mjs during the build process. This file is NOT used for the main blog feed.
+5
View File
@@ -34,6 +34,11 @@ 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)) {
return NextResponse.rewrite(new URL(`/${slug}.md`, request.nextUrl))
}
}
const response = NextResponse.next()
+34
View File
@@ -78,6 +78,40 @@ const nextConfig = {
},
async headers() {
return [
{
source: '/changelog-rss.xml',
headers: [
{ key: 'Content-Type', value: 'application/rss+xml; charset=utf-8' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Cache-Control', value: 'public, s-maxage=900, stale-while-revalidate=900' },
],
},
{
source: '/changelog-rss/:slug.xml',
headers: [
{ key: 'Content-Type', value: 'application/rss+xml; charset=utf-8' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Cache-Control', value: 'public, s-maxage=900, stale-while-revalidate=900' },
],
},
{
source: '/changelog/:number.md',
headers: [
{ key: 'Content-Type', value: 'text/markdown; charset=utf-8' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Cache-Control', value: 'public, s-maxage=900, stale-while-revalidate=900' },
{ key: 'Vary', value: 'Accept' },
],
},
{
source: '/changelog.md',
headers: [
{ key: 'Content-Type', value: 'text/markdown; charset=utf-8' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Cache-Control', value: 'public, s-maxage=900, stale-while-revalidate=900' },
{ key: 'Vary', value: 'Accept' },
],
},
{
source: '/',
headers: [
@@ -0,0 +1,52 @@
import {
CHANGELOG_CATEGORY_ID,
createChangelogOctokit,
fetchChangelogDiscussionByNumber,
} from '~/lib/changelog-github'
import { discussionDisplayDate } from '~/lib/changelog.utils'
import { mdxSerialize } from '~/lib/mdx/mdxSerialize'
import type { NextApiRequest, NextApiResponse } from 'next'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'GET') {
res.setHeader('Allow', 'GET')
return res.status(405).json({ error: 'Method not allowed' })
}
const raw = req.query.number
const numStr = Array.isArray(raw) ? raw[0] : raw
const number = Number(numStr)
if (!Number.isFinite(number)) {
return res.status(400).json({ error: 'Invalid discussion number' })
}
try {
const octokit = createChangelogOctokit()
const discussion = await fetchChangelogDiscussionByNumber(
octokit,
'supabase',
'supabase',
number
)
if (!discussion || discussion.category?.id !== CHANGELOG_CATEGORY_ID) {
return res.status(404).json({ error: 'Not found' })
}
const source = await mdxSerialize(discussion.body)
const created_at = discussionDisplayDate({
title: discussion.title,
createdAt: discussion.createdAt,
})
res.setHeader('Cache-Control', 'public, max-age=900, stale-while-revalidate=900')
return res.status(200).json({
title: discussion.title,
url: discussion.url,
created_at,
source,
})
} catch (e) {
console.error(e)
return res.status(500).json({ error: 'Failed to load discussion' })
}
}
+371 -307
View File
@@ -1,364 +1,428 @@
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/outline'
import { createAppAuth } from '@octokit/auth-app'
import { Octokit } from '@octokit/core'
import { paginateGraphql } from '@octokit/plugin-paginate-graphql'
import { Octokit as OctokitRest } from '@octokit/rest'
import { useBreakpoint } from 'common'
import dayjs from 'dayjs'
import { GitCommit } from 'lucide-react'
import { GetServerSideProps } from 'next'
import { MDXRemote, MDXRemoteSerializeResult } from 'next-mdx-remote'
import { GitCommit, ListFilter, Rss, X } from 'lucide-react'
import type { GetServerSideProps } from 'next'
import type { MDXRemoteSerializeResult } from 'next-mdx-remote'
import { MDXRemote } from 'next-mdx-remote'
import { NextSeo } from 'next-seo'
import Head from 'next/head'
import Link from 'next/link'
import CTABanner from '~/components/CTABanner'
import DefaultLayout from '~/components/Layouts/Default'
import { deletedDiscussions } from '~/lib/changelog.utils'
import mdxComponents from '~/lib/mdx/mdxComponents'
import { mdxSerialize } from '~/lib/mdx/mdxSerialize'
import { parseAsArrayOf, parseAsString, useQueryState } from 'nuqs'
import { NuqsAdapter } from 'nuqs/adapters/next/pages'
import { useEffect, useMemo, useState } from 'react'
import { Badge, Button, cn, IconYCombinator, Input_Shadcn_ } from 'ui'
export type Discussion = {
id: string
updatedAt: string
url: string
title: string
body: string
}
import { ChangelogLlmMarkdownButton } from '@/components/Changelog/ChangelogLlmMarkdownButton'
import { ChangelogTimelineList } from '@/components/Changelog/ChangelogTimelineList'
import CTABanner from '@/components/CTABanner'
import DefaultLayout from '@/components/Layouts/Default'
import changelogProductTags from '@/data/changelog-product-tags.json'
import {
createChangelogOctokit,
fetchChangelogDiscussionByNumber,
getChangelogTimelineSortedIndex,
type ChangelogLabel,
type ChangelogTimelineIndexItem,
} from '@/lib/changelog-github'
import {
CHANGELOG_PRODUCT_TAGS,
changelogLabelDisplayName,
changelogTagFilterUrl,
discussionDisplayDate,
isChangelogProductSlug,
itemMatchesChangelogSearch,
itemMatchesChangelogSelectedTags,
} from '@/lib/changelog.utils'
import mdxComponents from '@/lib/mdx/mdxComponents'
import { mdxSerialize } from '@/lib/mdx/mdxSerialize'
type Entry = {
id: string
const FEATURED_COUNT = 3
type FeaturedEntry = {
number: number
title: string
url: string
created_at: string
source: MDXRemoteSerializeResult
type: string
labels: ChangelogLabel[]
}
export type DiscussionsResponse = {
repository: {
discussions: {
totalCount: number
nodes: Discussion[]
pageInfo: any
}
}
type PageProps = {
featured: FeaturedEntry[]
restIndex: ChangelogTimelineIndexItem[]
allIndex: ChangelogTimelineIndexItem[]
}
// uses the graphql api
async function fetchDiscussions(
owner: string,
repo: string,
categoryId: string,
cursor: string | null = null
) {
const ExtendedOctokit = Octokit.plugin(paginateGraphql)
type ExtendedOctokit = InstanceType<typeof ExtendedOctokit>
export const getServerSideProps: GetServerSideProps<PageProps> = async ({ res }) => {
try {
const changelogIndex = await getChangelogTimelineSortedIndex()
const visible = changelogIndex.filter((item) => !item.title.includes('[d]'))
const allIndex = visible
const firstMeta = visible.slice(0, FEATURED_COUNT)
const restIndex = visible.slice(FEATURED_COUNT)
const octokit = new ExtendedOctokit({
authStrategy: createAppAuth,
auth: {
appId: process.env.GITHUB_CHANGELOG_APP_ID,
installationId: process.env.GITHUB_CHANGELOG_APP_INSTALLATION_ID,
privateKey: process.env.GITHUB_CHANGELOG_APP_PRIVATE_KEY,
},
})
const query = `
query troubleshootDiscussions($cursor: String, $owner: String!, $repo: String!, $categoryId: ID!) {
repository(owner: $owner, name: $repo) {
discussions(first: 10, after: $cursor, categoryId: $categoryId, orderBy: { field: CREATED_AT, direction: DESC }) {
totalCount
pageInfo {
hasPreviousPage
hasNextPage
startCursor
endCursor
}
nodes {
id
publishedAt
createdAt
url
title
body
const octokit = createChangelogOctokit()
const featuredResults = await Promise.all(
firstMeta.map(
async (meta): Promise<FeaturedEntry | { failedMeta: ChangelogTimelineIndexItem }> => {
try {
const discussion = await fetchChangelogDiscussionByNumber(
octokit,
'supabase',
'supabase',
meta.number
)
if (!discussion) return { failedMeta: meta }
const source = await mdxSerialize(discussion.body)
const created_at =
discussionDisplayDate({
title: discussion.title,
createdAt: discussion.createdAt,
}) ?? discussion.createdAt
return {
number: meta.number,
title: discussion.title,
url: discussion.url ?? '',
created_at,
source,
labels: meta.labels,
}
} catch (e) {
console.error(e)
return { failedMeta: meta }
}
}
}
}
`
const queryVars = {
owner,
repo,
categoryId,
cursor,
)
)
const featured = featuredResults.filter((e): e is FeaturedEntry => !('failedMeta' in e))
const fallbackItems = featuredResults
.filter((e): e is { failedMeta: ChangelogTimelineIndexItem } => 'failedMeta' in e)
.map((e) => e.failedMeta)
const restIndexWithFallbacks = fallbackItems.concat(restIndex)
res.setHeader('Cache-Control', 'public, max-age=900, stale-while-revalidate=900')
return { props: { featured, restIndex: restIndexWithFallbacks, allIndex } }
} catch (e) {
console.error(e)
res.setHeader('Cache-Control', 'private, no-store, max-age=0, must-revalidate')
return { props: { featured: [], restIndex: [], allIndex: [] } }
}
// fetch discussions
const {
repository: {
discussions: { nodes: discussions, pageInfo },
},
} = await octokit.graphql<DiscussionsResponse>(query, queryVars)
return { discussions, pageInfo }
}
function isEncoded(uri: string | null | undefined) {
uri = uri ?? ''
return uri !== decodeURIComponent(uri)
export default function ChangelogPage(props: PageProps) {
return (
<NuqsAdapter>
<ChangelogIndex {...props} />
</NuqsAdapter>
)
}
// Decodes a URI if it is encoded
const recursiveDecodeURI = (uri: string | null) => {
if (!uri) {
return uri
}
let tries = 0
while (isEncoded(uri)) {
uri = decodeURIComponent(uri)
tries++
if (tries > 10) {
break
}
}
const nuqsUrlOptions = { shallow: true, history: 'push' as const }
return uri
}
/**
* [Terry]
* this page powers supabase.com/changelog
* this page used to just be a feed of the releases endpoint
* (https://api.github.com/repos/supabase/supabase/releases) (rest api)
* but is now a blend of that legacy relases and the new Changelog category of the Discussions
* https://github.com/orgs/supabase/discussions/categories/changelog (graphql api)
* We should use the Changelog Discussions category for all future changelog entries and stop using releases
*/
export const getServerSideProps: GetServerSideProps = async ({ res, query }) => {
// refresh every 15 minutes
res.setHeader('Cache-Control', 'public, max-age=900, stale-while-revalidate=900')
const encodedNext = (query.next ?? null) as string | null
// in some cases the next cursor is encoded twice or more times due to the user pasting the url, so we need to decode it multiple times.
const next = recursiveDecodeURI(encodedNext)
const restPage = query.restPage ? Number(query.restPage) : 1
const octokitRest = new OctokitRest({
authStrategy: createAppAuth,
auth: {
appId: process.env.GITHUB_CHANGELOG_APP_ID,
installationId: process.env.GITHUB_CHANGELOG_APP_INSTALLATION_ID,
privateKey: process.env.GITHUB_CHANGELOG_APP_PRIVATE_KEY,
},
})
// uses the rest api
async function fetchGitHubReleases() {
try {
const response = await octokitRest.repos.listReleases({
owner: 'supabase',
repo: 'supabase',
per_page: 10,
page: restPage,
})
return response.data || []
} catch (error) {
console.error(error)
return []
}
}
// Process as of Feb. 2024:
// create a Release each month and create a corresponding changelog discussion
// — we don't want to pull in both the changelog entry and the release entry
// — we want to ignore new releases and only show the old ones that don't have a corresponding changelog discussion
// — so we have this list of old releases that we want to show
const oldReleases = [
40981345, 39091930, 37212777, 35927141, 34612423, 33383788, 32302703, 30830915, 29357247,
28108378,
]
const releases = (await fetchGitHubReleases()).filter(
(release) => release.id && oldReleases.includes(release.id)
function ChangelogIndex({ featured, restIndex, allIndex }: PageProps) {
const [querySearch, setQuerySearch] = useQueryState(
'q',
parseAsString.withOptions(nuqsUrlOptions)
)
const [queryTags, setQueryTags] = useQueryState(
'tags',
parseAsArrayOf(parseAsString).withOptions(nuqsUrlOptions)
)
const { discussions, pageInfo } = await fetchDiscussions(
'supabase',
'supabase',
'DIC_kwDODMpXOc4CAFUr', // 'Changelog' category
next
const isMobile = useBreakpoint('lg')
const [filterPanelOpen, setFilterPanelOpen] = useState(false)
const filterSearch = querySearch ?? ''
const selectedTags = useMemo(() => {
const next = new Set<string>()
for (const raw of queryTags ?? []) {
if (isChangelogProductSlug(raw)) next.add(raw)
}
return next
}, [queryTags])
const hasNuqsFilters = useMemo(
() => filterSearch.trim().length > 0 || selectedTags.size > 0,
[filterSearch, selectedTags]
)
if (!discussions) {
return {
props: {
notFound: true,
},
}
useEffect(() => {
if (hasNuqsFilters) setFilterPanelOpen(true)
}, [hasNuqsFilters])
const filteredIndex = useMemo(() => {
const q = filterSearch
const hasSearch = q.trim().length > 0
const hasTags = selectedTags.size > 0
if (!hasSearch && !hasTags) return null
return allIndex
.filter(
(item) =>
itemMatchesChangelogSearch(item, q) &&
itemMatchesChangelogSelectedTags(item, selectedTags)
)
.sort((a, b) => dayjs(b.sortDate).diff(dayjs(a.sortDate)))
}, [allIndex, filterSearch, selectedTags])
const toggleProductTag = (slug: string) => {
const current = (queryTags ?? []).filter(isChangelogProductSlug)
const has = current.includes(slug)
const next = has ? current.filter((t) => t !== slug) : [...current, slug]
void setQueryTags(next.length > 0 ? next : null)
}
// Process discussions
const formattedDiscussions = await Promise.all(
discussions.map(async (item: any): Promise<any> => {
try {
const discussionsMdxSource: MDXRemoteSerializeResult = await mdxSerialize(item.body)
// Find a date rewrite for the current item's title
const dateRewrite = deletedDiscussions.find((rewrite) => {
return item.title && rewrite.title && item.title.includes(rewrite.title)
})
// Use the createdAt date from dateRewrite if found, otherwise use item.createdAt
const created_at = dateRewrite ? dateRewrite.createdAt : item.createdAt
return {
...item,
source: discussionsMdxSource,
type: 'discussion',
created_at,
url: item.url,
}
} catch (err) {
console.error(`Problem processing discussion MDX: ${err}`)
}
})
)
// Process releases
const formattedReleases = await Promise.all(
releases.map(async (item: any): Promise<any> => {
try {
const releasesMdxSource: MDXRemoteSerializeResult = await mdxSerialize(item.body)
return {
...item,
source: releasesMdxSource,
type: 'release',
created_at: item.created_at,
title: item.name ?? '',
url: item.html_url ?? '',
}
} catch (err) {
console.error(`Problem processing discussion MDX: ${err}`)
}
})
)
// Combine discussions and releases into a single array of entries
const combinedEntries = formattedDiscussions.concat(formattedReleases).filter(Boolean)
const sortedCombinedEntries = combinedEntries.sort((a: any, b: any) => {
const dateA = dayjs(a.created_at)
const dateB = dayjs(b.created_at)
if (dateA.isValid() && dateB.isValid()) {
return dateB.diff(dateA)
} else {
return 0
}
})
return {
props: {
changelog: sortedCombinedEntries,
pageInfo: pageInfo,
restPage: Number(restPage),
},
const clearFilters = () => {
void setQuerySearch(null)
void setQueryTags(null)
}
}
interface ChangelogPageProps {
changelog: Entry[]
pageInfo: any
restPage: number
}
function ChangelogPage({ changelog, pageInfo, restPage }: ChangelogPageProps) {
const { endCursor: end, hasNextPage, hasPreviousPage } = pageInfo
const isSingleQueryTag = queryTags?.length === 1
const TITLE = 'Changelog'
const DESCRIPTION = 'New updates and improvements to Supabase'
return (
<>
<Head>
<link rel="alternate" type="text/markdown" href="/changelog.md" />
</Head>
<NextSeo
title={TITLE}
description={DESCRIPTION}
openGraph={{
title: TITLE,
description: DESCRIPTION,
url: `https://supabase.com/changelog`,
url: 'https://supabase.com/changelog',
type: 'article',
}}
/>
<DefaultLayout>
<div
className="
container mx-auto flex flex-col
gap-20
px-4 py-10 sm:px-16
xl:px-20
"
>
<div className="py-10">
<div className="container mx-auto max-w-5xl flex flex-col gap-8 px-4 py-10 sm:px-16 xl:px-20">
<div className="pb-4">
<h1 className="h1">Changelog</h1>
<p className="text-foreground-lighter text-lg">New updates and product improvements</p>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-foreground-lighter text-lg">
New updates and product improvements
</p>
<div className="w-full lg:w-auto flex flex-wrap items-center gap-1">
<div className="flex-1">
<Button
type="default"
size="tiny"
className={cn('shrink-0', !filterPanelOpen && 'px-1.5')}
aria-expanded={filterPanelOpen}
aria-controls="changelog-filters"
title="Filter changelog"
icon={
filterPanelOpen ? (
<X className="h-4 w-4" strokeWidth={1.5} aria-hidden />
) : (
<ListFilter className="h-4 w-4" strokeWidth={1.5} aria-hidden />
)
}
onClick={() => {
if (filterPanelOpen) setFilterPanelOpen(false)
else setFilterPanelOpen(true)
}}
>
{filterPanelOpen ? 'Hide filters' : isMobile && 'Filter changelog'}
</Button>
</div>
<Button
asChild
type="default"
className="shrink-0"
icon={<Rss className="h-4 w-4" strokeWidth={2} aria-hidden />}
>
<Link
href={
isSingleQueryTag
? `/changelog-rss/${queryTags?.[0]}.xml`
: '/changelog-rss.xml'
}
>
{isSingleQueryTag &&
changelogProductTags.find((tag) => tag.slug === queryTags?.[0])?.label +
' '}{' '}
RSS
</Link>
</Button>
<ChangelogLlmMarkdownButton />
</div>
</div>
</div>
{/* Content */}
<div className="grid gap-12 lg:gap-36">
{changelog.length > 0 &&
changelog
.filter((entry: Entry) => !entry.title.includes('[d]'))
.map((entry: Entry, i: number) => {
return (
<div key={i} className="border-muted grid border-l lg:grid-cols-12 lg:gap-8">
<div
className="col-span-12 mb-8 self-start lg:sticky lg:top-0 lg:col-span-4 lg:-mt-32 lg:pt-32
"
{filterPanelOpen && (
<div id="changelog-filters" className="flex flex-col gap-2 -mt-4 sm:mx-0">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="relative min-w-0 flex-1">
<label htmlFor="changelog-filter-search" className="sr-only">
Search changelog
</label>
<Input_Shadcn_
id="changelog-filter-search"
size="small"
placeholder="Search changelog..."
value={filterSearch}
onChange={(e) => {
const v = e.target.value
void setQuerySearch(v.length === 0 ? null : v)
}}
/>
{(filterSearch.trim().length > 0 || selectedTags.size > 0) && (
<Button
type="outline"
size="tiny"
className="absolute inset-1 my-auto left-auto shrink-0"
onClick={clearFilters}
icon={<X className="h-4 w-4" strokeWidth={1.5} aria-hidden />}
>
Clear filters
</Button>
)}
</div>
</div>
<div>
<p className="sr-only">Filter by tags</p>
<div className="flex flex-wrap gap-1.5">
{CHANGELOG_PRODUCT_TAGS.map(({ slug, label }) => {
const on = selectedTags.has(slug)
return (
<button key={slug} type="button" onClick={() => toggleProductTag(slug)}>
<Badge
variant={on ? 'success' : 'default'}
className={cn(!on && 'hover:text-foreground')}
>
{label}
</Badge>
</button>
)
})}
</div>
</div>
</div>
)}
{filteredIndex != null ? (
<section aria-label="Filtered changelog entries" className="min-w-0">
{filteredIndex.length === 0 ? (
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<p className="text-foreground-lighter text-sm">No entries match your filters.</p>
{!filterPanelOpen && (
<Button
type="text"
size="tiny"
className="shrink-0"
icon={<X className="h-4 w-4" strokeWidth={1.5} aria-hidden />}
onClick={clearFilters}
>
Clear filters
</Button>
)}
</div>
) : (
<>
<div className="text-foreground-lighter mb-3 flex flex-wrap items-center justify-between gap-2 text-sm">
<p>
{filteredIndex.length} {filteredIndex.length === 1 ? 'result' : 'results'}
</p>
{!filterPanelOpen && (
<Button
type="text"
size="tiny"
className="shrink-0"
icon={<X className="h-4 w-4" strokeWidth={1.5} aria-hidden />}
onClick={clearFilters}
>
<div className="flex w-full items-baseline gap-6">
<div className="bg-border border-muted text-foreground-lighter -ml-2.5 flex h-5 w-5 items-center justify-center rounded border drop-shadow-sm">
<GitCommit size={14} strokeWidth={1.5} />
</div>
<div className="flex w-full flex-col gap-1">
{entry.title && (
<Link href={entry.url}>
<h3 className="text-foreground text-2xl">{entry.title}</h3>{' '}
</Link>
)}
<p className="text-muted text-lg">
{dayjs(entry.created_at).format('MMM D, YYYY')}
</p>
</div>
Clear filters
</Button>
)}
</div>
<ChangelogTimelineList items={filteredIndex} />
</>
)}
</section>
) : (
<section
className="border-muted relative lg:ml-2 lg:border-l lg:pl-8 mb-12 lg:mb-20"
aria-label="Changelog timeline"
>
<div className="grid">
{featured.map((entry) => (
<div
key={entry.number}
id={entry.number.toString()}
className="grid pb-12 lg:grid-cols-12 lg:gap-8 lg:pb-36 scroll-mt-32"
>
<div className="col-span-12 lg:-ml-[31px] mb-8 lg:mb-0 self-start z-10 sticky top-[65px] lg:top-32 lg:col-span-4">
<div className="flex w-full items-baseline relative bg-background pt-4 lg:pt-0 border-b pb-4 lg:gap-4 lg:border-none lg:pb-0">
<div className="hidden lg:flex bg-border border-muted text-foreground-lighter -ml-2.5 h-5 w-5 items-center justify-center rounded border drop-shadow-sm">
<GitCommit size={14} strokeWidth={1.5} />
</div>
<div className="flex w-full flex-col gap-1">
{entry.title && (
<Link href={`/changelog/${entry.number}`}>
<h3 className="text-foreground text-lg hover:underline">
{entry.title}
</h3>
</Link>
)}
<p className="text-foreground-lighter font-mono text-xs">
{dayjs(entry.created_at).format('MMM D, YYYY')}
</p>
{entry.labels && entry.labels.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1.5">
{entry.labels.map((label) => (
<a
key={`${entry.number}-${label.name}`}
href={changelogTagFilterUrl(label.name)}
className="group inline-flex no-underline focus-visible:ring-brand-default rounded-md focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
>
<Badge className="group-hover:text-foreground-light text-foreground-lighter group-hover:border-foreground-muted px-1.5 py-px text-[11px] tracking-normal lowercase">
{changelogLabelDisplayName(label.name)}
</Badge>
</a>
))}
</div>
)}
</div>
</div>
<div className="col-span-8 ml-8 lg:ml-0 max-w-[calc(100vw-80px)]">
<article className="prose prose-docs max-w-none [overflow-wrap:break-word]">
<MDXRemote {...entry.source} components={mdxComponents('blog')} />
</article>
</div>
</div>
)
})}
</div>
<div className="my-8 flex items-center gap-4">
{hasPreviousPage && (
<Link href={`/changelog`} className="flex items-center gap-2">
<ArrowLeftIcon width={14} /> Previous
</Link>
)}
{hasNextPage && (
<Link
href={`/changelog?next=${end}&restPage=${restPage + 1}`}
className="flex items-center gap-2"
>
Next <ArrowRightIcon width={14} />
</Link>
)}
</div>
</div>
<div className="col-span-8 lg:max-w-[calc(100vw-80px)]">
<article className="prose prose-docs max-w-none [overflow-wrap:break-word] [&>*:first-child:not(style):not(script)]:mt-0 [&>style:first-child+*]:mt-0 [&>script:first-child+*]:mt-0 [&>*:last-child:not(style):not(script)]:mb-0">
<MDXRemote {...entry.source} components={mdxComponents('blog')} />
</article>
</div>
</div>
))}
</div>
{restIndex.length > 0 && (
<section aria-label="Earlier changelog entries" className="lg:pb-20">
<ChangelogTimelineList items={restIndex} omitOuterTimelineBorder />
</section>
)}
<div className="hidden lg:grid">
<div className="col-span-12 -ml-8 mb-8 lg:mb-0 self-start lg:sticky lg:top-0 lg:col-span-4 lg:-mt-20 lg:pt-20">
<div className="flex w-full items-baseline border-b pb-4 lg:gap-4 lg:border-none lg:pb-0">
<Link
href="https://www.ycombinator.com/companies/supabase"
target="_blank"
rel="noopener noreferrer"
className="hidden lg:flex -ml-2 text-foreground-lighter hover:text-foreground"
title="YCombinator —  Summer 2020"
>
<IconYCombinator size={16} className="text-current" />
</Link>
</div>
</div>
</div>
</section>
)}
</div>
<CTABanner />
</DefaultLayout>
</>
)
}
export default ChangelogPage
+132
View File
@@ -0,0 +1,132 @@
import dayjs from 'dayjs'
import type { GetStaticPaths, GetStaticProps } from 'next'
import { MDXRemote, type MDXRemoteSerializeResult } from 'next-mdx-remote'
import { NextSeo } from 'next-seo'
import Head from 'next/head'
import Link from 'next/link'
import { ChangelogDetailSidebar } from '@/components/Changelog/ChangelogDetailSidebar'
import CTABanner from '@/components/CTABanner'
import DefaultLayout from '@/components/Layouts/Default'
import {
CHANGELOG_CATEGORY_ID,
createChangelogOctokit,
fetchChangelogDiscussionByNumber,
type ChangelogLabel,
} from '@/lib/changelog-github'
import { discussionDisplayDate } from '@/lib/changelog.utils'
import mdxComponents from '@/lib/mdx/mdxComponents'
import { mdxSerialize } from '@/lib/mdx/mdxSerialize'
type PageProps = {
title: string
url: string
created_at: string
number: number
source: MDXRemoteSerializeResult
labels: ChangelogLabel[]
}
const ChangelogDetailPage = ({ title, url, created_at, number, source, labels }: PageProps) => (
<>
<Head>
<link rel="alternate" type="text/markdown" href={`/changelog/${number}.md`} />
</Head>
<NextSeo
title={`${title} · Changelog`}
description={title}
openGraph={{
title,
url: `https://supabase.com/changelog/${number}`,
type: 'article',
}}
/>
<DefaultLayout>
<div className="container mx-auto max-w-5xl px-4 py-10 sm:px-16 xl:px-20">
<nav
aria-label="Breadcrumb"
className="text-foreground-lighter mb-6 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm"
>
<Link href="/changelog" className="text-foreground-lighter hover:underline">
Changelog
</Link>
</nav>
<header className="border-default mb-8 flex flex-col gap-2 border-b pb-6">
<h1 className="h1 text-2xl sm:text-3xl">{title}</h1>
<p className="text-foreground-lighter font-mono text-xs">
{dayjs(created_at).format('MMM D, YYYY')}
</p>
</header>
<div className="grid grid-cols-1 gap-8 lg:grid-cols-12 lg:gap-10 mb-8 lg:mb-20">
<div className="min-w-0 lg:col-span-8">
<article className="prose prose-docs max-w-none [overflow-wrap:break-word] [&>*:first-child:not(style):not(script)]:mt-0 [&>style:first-child+*]:mt-0 [&>script:first-child+*]:mt-0 [&>*:last-child:not(style):not(script)]:mb-0">
<MDXRemote {...source} components={mdxComponents('blog')} />
</article>
</div>
<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} />
</div>
</aside>
</div>
</div>
<CTABanner />
</DefaultLayout>
</>
)
export const getStaticPaths: GetStaticPaths = async () => {
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 }
}
try {
const octokit = createChangelogOctokit()
const discussion = await fetchChangelogDiscussionByNumber(
octokit,
'supabase',
'supabase',
number
)
if (!discussion || discussion.category?.id !== CHANGELOG_CATEGORY_ID) {
return { notFound: true }
}
const source = await mdxSerialize(discussion.body)
const created_at =
discussionDisplayDate({
title: discussion.title,
createdAt: discussion.createdAt,
}) ?? discussion.createdAt
return {
props: {
title: discussion.title,
url: discussion.url,
created_at,
number,
source,
labels: discussion.labels?.nodes ?? [],
},
revalidate: 900,
}
} catch (e) {
console.error(e)
return { notFound: true }
}
}
export default ChangelogDetailPage
@@ -0,0 +1,21 @@
[
{ "title": "Platform updates: October 2023", "createdAt": "2023-11-06T16:25:12Z" },
{ "title": "Platform update September 2023", "createdAt": "2023-10-06T09:23:56Z" },
{ "title": "Platform updates: August 2023", "createdAt": "2023-09-08T13:00:39Z" },
{ "title": "Platform updates June 2023", "createdAt": "2023-07-07T16:09:32Z" },
{ "title": "Platform updates: May 2023", "createdAt": "2023-06-09T16:40:16Z" },
{ "title": "Platform updates: April 2023", "createdAt": "2023-05-10T18:40:02Z" },
{ "title": "Platform Update February 2023", "createdAt": "2023-03-09T12:06:01Z" },
{ "title": "Platform update January 2023", "createdAt": "2023-02-08T18:29:41Z" },
{ "title": "Platform Updates November 2022", "createdAt": "2022-12-08T12:00:48Z" },
{ "title": "Platform updates: October 2022", "createdAt": "2022-11-02T16:07:47Z" },
{ "title": "Platform Update September 2022", "createdAt": "2022-10-07T10:18:21Z" },
{ "title": "Platform updates: 30 Nov 2021", "createdAt": "2021-11-30T10:06:55Z" },
{ "title": "October Beta 2021", "createdAt": "2021-11-08T13:14:35Z" },
{ "title": "September Beta 2021", "createdAt": "2021-10-04T18:10:57Z" },
{ "title": "August Beta 2021", "createdAt": "2021-09-13T09:47:18Z" },
{ "title": "July Beta 2021", "createdAt": "2021-08-12T13:46:14Z" },
{ "title": "June Beta 2021", "createdAt": "2021-07-04T13:23:35Z" },
{ "title": "May Beta 2021", "createdAt": "2021-06-10T11:53:14Z" },
{ "title": "April Beta 2021", "createdAt": "2021-05-05T05:17:27Z" }
]
+231 -3
View File
@@ -8,6 +8,55 @@ import advancedFormat from 'dayjs/plugin/advancedFormat.js'
import utc from 'dayjs/plugin/utc.js'
import matter from 'gray-matter'
/**
* Plain `node` does not read `.env` / `.env.local` (Next.js loads those when you run `next`).
* Minimal parser: no extra dependency; `.env` first, then `.env.local` overrides.
*/
function loadLocalEnvFiles(rootDir) {
const parseValue = (raw) => {
const val = raw.trim()
if (val.startsWith('"') && val.endsWith('"')) {
return val.slice(1, -1).replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\')
}
if (val.startsWith("'") && val.endsWith("'")) {
return val.slice(1, -1).replace(/\\n/g, '\n').replace(/\\'/g, "'").replace(/\\\\/g, '\\')
}
return val
}
const applyLine = (line, override) => {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) return
const eq = trimmed.indexOf('=')
if (eq === -1) return
const key = trimmed
.slice(0, eq)
.trim()
.replace(/^export\s+/i, '')
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return
const value = parseValue(trimmed.slice(eq + 1))
if (override || process.env[key] === undefined) {
process.env[key] = value
}
}
for (const name of ['.env', '.env.local']) {
try {
const fp = path.join(rootDir, name)
const raw = fsSync.readFileSync(fp, 'utf8')
const override = name === '.env.local'
for (const line of raw.split(/\r?\n/)) {
applyLine(line, override)
}
} catch {
/* file missing */
}
}
}
const wwwRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
loadLocalEnvFiles(wwwRoot)
dayjs.extend(utc)
dayjs.extend(advancedFormat)
@@ -261,9 +310,9 @@ await fs.writeFile(
'utf8'
)
console.log(`✅ Generated static content with ${latestBlogPosts.length} blog posts`)
console.log(`✅ Generated static content with ${latestBlogPosts.length} latest blog posts`)
// Generate RSS feed
// Generate blog and changelog RSS feed
try {
const allBlogPosts = await getAllBlogPosts()
@@ -324,7 +373,186 @@ try {
// Write RSS feed to public directory
const rssPath = path.join(__dirname, '../public/rss.xml')
await fs.writeFile(rssPath, rss.trim(), 'utf8')
console.log(`✅ Generated RSS feed with ${allBlogPosts.length} blog posts`)
console.log(`✅ Generated RSS feed with ${allBlogPosts.length} entries`)
} catch (error) {
console.warn('Error generating RSS feed:', error)
}
// Changelog RSS → public/changelog-rss.xml (same GitHub App + category as lib/changelog-github.ts)
try {
const appId = process.env.GITHUB_CHANGELOG_APP_ID
const installationId = process.env.GITHUB_CHANGELOG_APP_INSTALLATION_ID
const privateKey = process.env.GITHUB_CHANGELOG_APP_PRIVATE_KEY
if (!appId || !installationId || !privateKey) {
console.warn('Skipping changelog RSS: missing GITHUB_CHANGELOG_APP_* env vars')
} else {
const { createAppAuth } = await import('@octokit/auth-app')
const { Octokit } = await import('@octokit/core')
const { paginateGraphql } = await import('@octokit/plugin-paginate-graphql')
const { generateChangelogRssXml, generateChangelogTagRssXml, labelToFileSlug } =
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'))
const discussionDisplayDate = (item) => {
const dateRewrite = rewrites.find(
(r) => item.title && r.title && item.title.includes(r.title)
)
return dateRewrite ? dateRewrite.createdAt : item.createdAt
}
const CHANGELOG_CATEGORY_ID = 'DIC_kwDODMpXOc4CAFUr'
const changelogQuery = `
query changelogDiscussionMetadata($cursor: String, $owner: String!, $repo: String!, $categoryId: ID!) {
repository(owner: $owner, name: $repo) {
discussions(
first: 100
after: $cursor
categoryId: $categoryId
orderBy: { field: CREATED_AT, direction: DESC }
) {
nodes {
number
title
body
createdAt
url
labels(first: 25) {
nodes {
name
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
`
const ExtendedOctokit = Octokit.plugin(paginateGraphql)
const octokit = new ExtendedOctokit({
authStrategy: createAppAuth,
auth: {
appId,
installationId,
privateKey: privateKey.replace(/\\n/g, '\n'),
},
})
const collected = []
let cursor = null
let hasNextPage = true
while (hasNextPage) {
const {
repository: {
discussions: { nodes, pageInfo },
},
} = await octokit.graphql(changelogQuery, {
owner: 'supabase',
repo: 'supabase',
categoryId: CHANGELOG_CATEGORY_ID,
cursor,
})
collected.push(...nodes)
hasNextPage = pageInfo.hasNextPage
cursor = pageInfo.endCursor
}
const entries = collected.map((item) => ({
number: item.number,
title: item.title,
url: item.url,
sortDate: discussionDisplayDate({ title: item.title, createdAt: item.createdAt }),
labels: (item.labels?.nodes ?? []).map((l) => l.name.toLowerCase()),
body: item.body ?? '',
}))
const changelogXml = generateChangelogRssXml(entries)
const changelogRssPath = path.join(__dirname, '../public/changelog-rss.xml')
await fs.writeFile(changelogRssPath, changelogXml.trim(), 'utf8')
const visibleCount = entries.filter((e) => !e.title.includes('[d]')).length
console.log(`✅ Generated changelog RSS with ${visibleCount} entries`)
// Per-tag feeds → public/changelog-rss/<label-slug>.xml
const productTagsPath = path.join(__dirname, '../data/changelog-product-tags.json')
const productTags = JSON.parse(await fs.readFile(productTagsPath, 'utf8'))
const tagFeedsDir = path.join(__dirname, '../public/changelog-rss')
await fs.mkdir(tagFeedsDir, { recursive: true })
const tagResults = await Promise.allSettled(
productTags.map(async ({ slug, label }) => {
const fileSlug = labelToFileSlug(label)
const tagXml = generateChangelogTagRssXml(entries, {
githubLabelSlug: slug,
displayLabel: label,
})
await fs.writeFile(path.join(tagFeedsDir, `${fileSlug}.xml`), tagXml.trim(), 'utf8')
})
)
const succeeded = tagResults.filter((r) => r.status === 'fulfilled').length
console.log(`✅ Generated ${succeeded}/${productTags.length} per-tag changelog RSS feeds`)
// 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) => {
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 changelogMd = `# Supabase Changelog
All paths are relative to \`https://supabase.com\`.
| Date | # | Labels | Title | Path |
| --- | --- | --- | --- | --- |
${mdRows.join('\n')}
`
const changelogMdPath = path.join(__dirname, '../public/changelog.md')
await fs.writeFile(changelogMdPath, changelogMd.trim() + '\n', '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).
const changelogEntryMdDir = path.join(__dirname, '../public/changelog')
await fs.mkdir(changelogEntryMdDir, { recursive: true })
for (const entry of visibleEntries) {
const published = dayjs(entry.sortDate).isValid()
? dayjs(entry.sortDate).format('YYYY-MM-DD')
: ''
const titleLine = String(entry.title ?? '')
.replace(/\n/g, ' ')
.trim()
const labelsYaml = (entry.labels ?? []).map((l) => ` - ${l}`).join('\n')
const pageUrl = `https://supabase.com/changelog/${entry.number}`
const entryMd = `---
number: ${entry.number}
published: ${published}
discussion: ${entry.url}
labels:
${labelsYaml || ' []'}
page: ${pageUrl}
---
# ${titleLine}
${entry.body ?? ''}
`
await fs.writeFile(
path.join(changelogEntryMdDir, `${entry.number}.md`),
entryMd.trim() + '\n',
'utf8'
)
}
console.log(`✅ Generated changelog/*.md (${visibleEntries.length} files)`)
}
} catch (error) {
console.warn('Error generating changelog RSS:', error)
}
+1
View File
@@ -4,6 +4,7 @@ export * from './useAnchorObserver'
export * from './useBreakpoint'
export * from './useConstant'
export * from './useCopy'
export * from './useCopyMarkdownFromUrl'
export * from './useDebounce'
export * from './useDocsSearch'
export * from './useDragToClose'
@@ -0,0 +1,50 @@
'use client'
import { useCallback, useState } from 'react'
export type CopyMarkdownFromUrlOptions = {
/** When the markdown URL is missing or not OK, use this HTML string instead (e.g. rendered article). */
fallbackHtml?: () => string
}
const COPIED_FEEDBACK_MS = 2000
/**
* Fetches markdown from `mdUrl`, falls back to optional HTML when the response is not OK,
* then writes the result to the clipboard.
*/
export async function copyMarkdownFromUrl(
mdUrl: string,
options?: CopyMarkdownFromUrlOptions
): Promise<boolean> {
try {
const res = await fetch(mdUrl)
let text: string
if (res.ok) {
text = await res.text()
} else {
text = options?.fallbackHtml?.() ?? ''
if (!text) return false
}
await navigator.clipboard.writeText(text)
return true
} catch (error) {
console.error('Failed to copy markdown', error)
return false
}
}
export function useCopyMarkdownFromUrl() {
const [copied, setCopied] = useState(false)
const copyMarkdown = useCallback(async (mdUrl: string, options?: CopyMarkdownFromUrlOptions) => {
const ok = await copyMarkdownFromUrl(mdUrl, options)
if (ok) {
setCopied(true)
setTimeout(() => setCopied(false), COPIED_FEEDBACK_MS)
}
return ok
}, [])
return { copied, copyMarkdown }
}