From 580598f0e823042c0ee664f94e8532f061e96a40 Mon Sep 17 00:00:00 2001 From: Francesco Sansalvadore Date: Wed, 29 Apr 2026 12:31:30 +0200 Subject: [PATCH] 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 Screenshot 2026-04-27 at 17 07 55 ## After changelog-index ### Detail page Screenshot 2026-04-27 at 18 27 27 ## 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 --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .prettierignore | 3 + apps/docs/components/GuidesSidebar.tsx | 10 +- apps/www/.gitignore | 6 + .../Changelog/ChangelogDetailSidebar.tsx | 93 +++ .../Changelog/ChangelogLlmMarkdownButton.tsx | 78 ++ .../Changelog/ChangelogTimelineList.tsx | 152 ++++ apps/www/data/changelog-product-tags.json | 25 + apps/www/internals/generate-sitemap.mjs | 26 +- apps/www/lib/changelog-github.ts | 181 +++++ apps/www/lib/changelog-rss.mjs | 115 +++ apps/www/lib/changelog.utils.ts | 66 ++ apps/www/lib/mdx/mdxSerialize.ts | 92 ++- apps/www/lib/rss.tsx | 40 +- apps/www/middleware.ts | 5 + apps/www/next.config.mjs | 34 + .../api/changelog-discussion/[number].ts | 52 ++ apps/www/pages/changelog.tsx | 678 ++++++++++-------- apps/www/pages/changelog/[number].tsx | 132 ++++ .../data/changelog-deleted-discussions.json | 21 + apps/www/scripts/generateStaticContent.mjs | 234 +++++- packages/common/hooks/index.ts | 1 + .../common/hooks/useCopyMarkdownFromUrl.ts | 50 ++ 22 files changed, 1751 insertions(+), 343 deletions(-) create mode 100644 apps/www/components/Changelog/ChangelogDetailSidebar.tsx create mode 100644 apps/www/components/Changelog/ChangelogLlmMarkdownButton.tsx create mode 100644 apps/www/components/Changelog/ChangelogTimelineList.tsx create mode 100644 apps/www/data/changelog-product-tags.json create mode 100644 apps/www/lib/changelog-github.ts create mode 100644 apps/www/lib/changelog-rss.mjs create mode 100644 apps/www/pages/api/changelog-discussion/[number].ts create mode 100644 apps/www/pages/changelog/[number].tsx create mode 100644 apps/www/scripts/data/changelog-deleted-discussions.json create mode 100644 packages/common/hooks/useCopyMarkdownFromUrl.ts diff --git a/.prettierignore b/.prettierignore index 5f74f6bc0d..e1ba10c880 100644 --- a/.prettierignore +++ b/.prettierignore @@ -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/* diff --git a/apps/docs/components/GuidesSidebar.tsx b/apps/docs/components/GuidesSidebar.tsx index 544efe3fe2..7031d74dd3 100644 --- a/apps/docs/components/GuidesSidebar.tsx +++ b/apps/docs/components/GuidesSidebar.tsx @@ -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 diff --git a/apps/www/.gitignore b/apps/www/.gitignore index b27dc123df..8417dcf87c 100644 --- a/apps/www/.gitignore +++ b/apps/www/.gitignore @@ -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 diff --git a/apps/www/components/Changelog/ChangelogDetailSidebar.tsx b/apps/www/components/Changelog/ChangelogDetailSidebar.tsx new file mode 100644 index 0000000000..3d9e2f5f86 --- /dev/null +++ b/apps/www/components/Changelog/ChangelogDetailSidebar.tsx @@ -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 ( +
+ {labels.length > 0 && ( + <> +
+

+ Tags +

+ e.stopPropagation()} /> +
+
+ + )} + +
+ + +
+
+ ) +} diff --git a/apps/www/components/Changelog/ChangelogLlmMarkdownButton.tsx b/apps/www/components/Changelog/ChangelogLlmMarkdownButton.tsx new file mode 100644 index 0000000000..cac7e01bde --- /dev/null +++ b/apps/www/components/Changelog/ChangelogLlmMarkdownButton.tsx @@ -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 ( +
+ + + + +
+ ) +} diff --git a/apps/www/components/Changelog/ChangelogTimelineList.tsx b/apps/www/components/Changelog/ChangelogTimelineList.tsx new file mode 100644 index 0000000000..00f219f679 --- /dev/null +++ b/apps/www/components/Changelog/ChangelogTimelineList.tsx @@ -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() + 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 ( +
+ {labels.map((label) => ( + + + {changelogLabelDisplayName(label.name)} + + + ))} +
+ ) +} + +function TimelineRow({ item, href }: { item: ChangelogTimelineIndexItem; href: string }) { + const dateLabel = dayjs(item.sortDate).format('MMM D') + const labels = item.labels ?? [] + + return ( +
+
+ +

{item.title}

+ +
+
+ + e.stopPropagation()} /> +
+
+ ) +} + +type Props = { + items: ChangelogTimelineIndexItem[] + omitOuterTimelineBorder?: boolean +} + +export function ChangelogTimelineList(props: Props) { + const { items, omitOuterTimelineBorder } = props + const yearGroups = groupChangelogIndexByYear(items) + + return ( +
+ {yearGroups.map(([year, yearItems], yearIndex) => ( +
+ + {year} + + +
+
+
+
+
+ +
+ + {year} + +
+
+
+ +
+ {yearItems.map((item) => ( + + ))} +
+
+
+ ))} +
+ ) +} diff --git a/apps/www/data/changelog-product-tags.json b/apps/www/data/changelog-product-tags.json new file mode 100644 index 0000000000..6d7da04a24 --- /dev/null +++ b/apps/www/data/changelog-product-tags.json @@ -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" } +] diff --git a/apps/www/internals/generate-sitemap.mjs b/apps/www/internals/generate-sitemap.mjs index c9ac6349b8..415ef8b82c 100644 --- a/apps/www/internals/generate-sitemap.mjs +++ b/apps/www/internals/generate-sitemap.mjs @@ -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(/(https:\/\/supabase\.com\/changelog\/\d+)<\/link>/g)] + const uniqueUrls = [...new Set(matches.map((match) => match[1]))] + + return uniqueUrls.map( + (url) => ` + + ${url} + weekly + 0.5 + + ` + ) + } catch { + return [] + } + })() + const sitemap = ` - ${[...staticUrls].join('')} + ${[...staticUrls, ...changelogDetailUrls].join('')} ` diff --git a/apps/www/lib/changelog-github.ts b/apps/www/lib/changelog-github.ts new file mode 100644 index 0000000000..5d4d3a5203 --- /dev/null +++ b/apps/www/lib/changelog-github.ts @@ -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, + owner: string, + repo: string, + categoryId: string +): Promise { + 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, + owner: string, + repo: string, + number: number +): Promise { + 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 { + 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))) +} diff --git a/apps/www/lib/changelog-rss.mjs b/apps/www/lib/changelog-rss.mjs new file mode 100644 index 0000000000..86f39765f8 --- /dev/null +++ b/apps/www/lib/changelog-rss.mjs @@ -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, ''') +} + +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 ` + ${encodedCanonical} + ${encodedTitle} + ${encodedCanonical} + ${pubDate} + ` + }) + .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 ` + + + Supabase Changelog + https://supabase.com/changelog + Product updates and improvements from Supabase + en + ${lastBuildDate} + +${buildItemsXml(sorted)} + + +` +} + +/** + * 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 ` + + + Supabase Changelog · ${displayLabel} + https://supabase.com/changelog + ${displayLabel} updates and improvements from Supabase + en + ${lastBuildDate} + +${buildItemsXml(sorted)} + + +` +} diff --git a/apps/www/lib/changelog.utils.ts b/apps/www/lib/changelog.utils.ts index b3f627b983..8dcb14b8b6 100644 --- a/apps/www/lib/changelog.utils.ts +++ b/apps/www/lib/changelog.utils.ts @@ -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 = { + 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(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 +) { + 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 +} diff --git a/apps/www/lib/mdx/mdxSerialize.ts b/apps/www/lib/mdx/mdxSerialize.ts index f281ed6a6a..485a469403 100644 --- a/apps/www/lib/mdx/mdxSerialize.ts +++ b/apps/www/lib/mdx/mdxSerialize.ts @@ -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 @@ -15,16 +25,75 @@ function addSelfClosingTags(htmlString: string): string { if (!htmlString || typeof htmlString !== 'string') { return '' } + return transformOutsideCodeFences(htmlString, (chunk) => + chunk.replace(/]*>|]*>|]*>/g, (match) => + match.endsWith('/>') ? match : match.slice(0, -1) + ' />' + ) + ) +} - const modifiedHTML = htmlString.replace(/]*>|]*>|]*>/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 `` nodes and MDX JSX `` 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*]*)\/?>\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 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 diff --git a/apps/www/lib/rss.tsx b/apps/www/lib/rss.tsx index 4fbd2fcb55..3befa67f46 100644 --- a/apps/www/lib/rss.tsx +++ b/apps/www/lib/rss.tsx @@ -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, ''') +export function xmlEncodeRss(str: string | undefined | null): string { + if (str === undefined || str === null) { + return '' } + return str + .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. diff --git a/apps/www/middleware.ts b/apps/www/middleware.ts index 331f66a2db..f354564a14 100644 --- a/apps/www/middleware.ts +++ b/apps/www/middleware.ts @@ -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/) 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() diff --git a/apps/www/next.config.mjs b/apps/www/next.config.mjs index 72b5e5db4e..97f3d0ff09 100644 --- a/apps/www/next.config.mjs +++ b/apps/www/next.config.mjs @@ -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: [ diff --git a/apps/www/pages/api/changelog-discussion/[number].ts b/apps/www/pages/api/changelog-discussion/[number].ts new file mode 100644 index 0000000000..64ffd1d02a --- /dev/null +++ b/apps/www/pages/api/changelog-discussion/[number].ts @@ -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' }) + } +} diff --git a/apps/www/pages/changelog.tsx b/apps/www/pages/changelog.tsx index 069bd3c208..e6ec17c19c 100644 --- a/apps/www/pages/changelog.tsx +++ b/apps/www/pages/changelog.tsx @@ -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 +export const getServerSideProps: GetServerSideProps = 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 => { + 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(query, queryVars) - - return { discussions, pageInfo } } -function isEncoded(uri: string | null | undefined) { - uri = uri ?? '' - return uri !== decodeURIComponent(uri) +export default function ChangelogPage(props: PageProps) { + return ( + + + + ) } -// 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() + 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 => { - 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 => { - 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 ( <> + + + -
-
+
+

Changelog

-

New updates and product improvements

+
+

+ New updates and product improvements +

+
+
+ +
+ + +
+
- {/* Content */} -
- {changelog.length > 0 && - changelog - .filter((entry: Entry) => !entry.title.includes('[d]')) - .map((entry: Entry, i: number) => { - return ( -
-
+
+
+ + { + const v = e.target.value + void setQuerySearch(v.length === 0 ? null : v) + }} + /> + {(filterSearch.trim().length > 0 || selectedTags.size > 0) && ( + + )} +
+
+
+

Filter by tags

+
+ {CHANGELOG_PRODUCT_TAGS.map(({ slug, label }) => { + const on = selectedTags.has(slug) + return ( + + ) + })} +
+
+
+ )} + + {filteredIndex != null ? ( +
+ {filteredIndex.length === 0 ? ( +
+

No entries match your filters.

+ {!filterPanelOpen && ( + + )} +
+ ) : ( + <> +
+

+ {filteredIndex.length} {filteredIndex.length === 1 ? 'result' : 'results'} +

+ {!filterPanelOpen && ( + + )} +
+ + + )} +
+ ) : ( +
+
+ {featured.map((entry) => ( +
+
+
+
+ +
+
+ {entry.title && ( + +

+ {entry.title} +

+ + )} +

+ {dayjs(entry.created_at).format('MMM D, YYYY')} +

+ {entry.labels && entry.labels.length > 0 && ( +
+ {entry.labels.map((label) => ( + + + {changelogLabelDisplayName(label.name)} + + + ))} +
+ )}
-
-
- -
-
- ) - })} -
-
- {hasPreviousPage && ( - - Previous - - )} - {hasNextPage && ( - - Next - - )} -
-
+
+
+ +
+
+
+ ))} +
+ {restIndex.length > 0 && ( +
+ +
+ )} +
+
+
+ + + +
+
+
+ + )} +
) } - -export default ChangelogPage diff --git a/apps/www/pages/changelog/[number].tsx b/apps/www/pages/changelog/[number].tsx new file mode 100644 index 0000000000..27a8a68187 --- /dev/null +++ b/apps/www/pages/changelog/[number].tsx @@ -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) => ( + <> + + + + + +
+ +
+

{title}

+

+ {dayjs(created_at).format('MMM D, YYYY')} +

+
+ +
+
+
+ +
+
+ + +
+
+ +
+ +) + +export const getStaticPaths: GetStaticPaths = async () => { + return { + paths: [], + fallback: 'blocking', + } +} + +export const getStaticProps: GetStaticProps = async ({ params }) => { + const raw = params?.number + const numStr = Array.isArray(raw) ? raw[0] : raw + const number = Number(numStr) + if (!Number.isFinite(number)) { + return { notFound: true } + } + + 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 diff --git a/apps/www/scripts/data/changelog-deleted-discussions.json b/apps/www/scripts/data/changelog-deleted-discussions.json new file mode 100644 index 0000000000..d4d9761681 --- /dev/null +++ b/apps/www/scripts/data/changelog-deleted-discussions.json @@ -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" } +] diff --git a/apps/www/scripts/generateStaticContent.mjs b/apps/www/scripts/generateStaticContent.mjs index 92c0f35b7c..e9971a649f 100644 --- a/apps/www/scripts/generateStaticContent.mjs +++ b/apps/www/scripts/generateStaticContent.mjs @@ -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/.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/.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) +} diff --git a/packages/common/hooks/index.ts b/packages/common/hooks/index.ts index 495fbf0ee3..a4b85a25c7 100644 --- a/packages/common/hooks/index.ts +++ b/packages/common/hooks/index.ts @@ -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' diff --git a/packages/common/hooks/useCopyMarkdownFromUrl.ts b/packages/common/hooks/useCopyMarkdownFromUrl.ts new file mode 100644 index 0000000000..8174c6734b --- /dev/null +++ b/packages/common/hooks/useCopyMarkdownFromUrl.ts @@ -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 { + 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 } +}