mirror of
https://github.com/supabase/supabase.git
synced 2026-05-06 08:56:46 -04:00
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:
committed by
GitHub
parent
45ffa97240
commit
580598f0e8
@@ -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/*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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" }
|
||||
]
|
||||
@@ -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>
|
||||
`
|
||||
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
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>
|
||||
`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
export function xmlEncodeRss(str: string | undefined | null): string {
|
||||
if (str === undefined || str === null) {
|
||||
return ''
|
||||
}
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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" }
|
||||
]
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user