Files
supabase/apps/www/lib/changelog.utils.ts
312d05af4b fix(www): changelog frontmatter (#48249)
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Security/bug fix

## What is the current behavior?

The changelog entry parser exposes all YAML frontmatter fields parsed by
`matter()` directly to the client via Next.js props. This includes
private fields like `internal:` (escalation teams, notes) and
`reviewers:`, which get serialized into the page's `__NEXT_DATA__` and
are visible in View Source even if never rendered.

## What is the new behavior?

- Added `PUBLIC_FRONTMATTER_KEYS` constant that explicitly allowlists
only the fields safe to expose to the browser
- Added `toPublicFrontmatter()` function that filters frontmatter down
to the allowlist, dropping `internal:`, `reviewers:`, and any other
private keys
- Updated `parseChangelogEntryFile()` to apply the allowlist before
returning frontmatter to callers
- Added comprehensive unit tests covering both the filtering logic and
the integration with the parser

This uses an allowlist approach rather than a denylist, so new private
fields added upstream won't silently leak to clients.

## Additional context

The allowlist is kept in sync with `ChangelogEntryFrontmatter` in
`changelog-repo.ts` per the code comment. Tests verify that:
- Only allowlisted keys are present in the returned frontmatter
- Private fields like `internal` and `reviewers` are never exposed
- Public fields flow through untouched
- Undefined values are omitted from the result

https://claude.ai/code/session_017uSmnCLsskFYR7YH8DKGkr

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

* **New Features**
* Added a shared changelog title renderer that safely displays titles as
inline Markdown.
* Added plain-text title extraction for consistent headings and SEO
metadata.
* **Bug Fixes**
* Prevented private/internal changelog frontmatter (including reviewer
metadata) from being exposed to browser-rendered pages.
* Ensured featured and non-featured changelog timelines stay consistent
even when some entries fail to serialize.
* Improved the changelog detail not-found behavior to revalidate instead
of caching 404s indefinitely.
* **Tests**
* Added coverage for public frontmatter allowlisting, date normalization
(`publish_date`/sorting), and `sortDate` consistency across YAML
variations.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Lukas Bernert <lukas@bernert.at>
2026-07-23 08:26:01 -07:00

172 lines
5.6 KiB
TypeScript

import changelogProductTags from '~/data/changelog-product-tags.json'
import { CHANGE_TYPE_LABELS } from './changelog-entries-core.mjs'
import type { ChangelogEntry, ChangeType } from './changelog-repo'
export type ChangelogTimelineIndexItem = {
slug: string
title: string
summary: string
sortDate: string
changeType: ChangeType
affectedProducts: string[]
productStage: string | null
affectsSelfHosted: boolean | null
}
export function toChangelogTimelineIndexItem(entry: ChangelogEntry): ChangelogTimelineIndexItem {
return {
slug: entry.slug,
title: entry.frontmatter.title,
summary: entry.summary,
sortDate: entry.sortDate,
changeType: entry.frontmatter.change_type,
affectedProducts: entry.frontmatter.affected_products ?? [],
productStage: entry.frontmatter.product_stage ?? null,
affectsSelfHosted: entry.frontmatter.affects_self_hosted ?? null,
}
}
/** Strips inline markdown from a title for plain-text contexts (`<title>`, meta, Open Graph). */
export function stripTitleMarkdown(title: string) {
return title
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1') // links / images
.replace(/(\*\*|__)(.*?)\1/g, '$2')
.replace(/(\*|_)(.*?)\1/g, '$2')
.replace(/~~(.*?)~~/g, '$1')
.replace(/`+([^`]*)`+/g, '$1') // inline code
.trim()
}
const CHANGE_TYPE_BADGE_VARIANT: Record<
ChangeType,
'default' | 'warning' | 'success' | 'destructive'
> = {
'breaking-change': 'destructive',
deprecation: 'warning',
'new-feature': 'success',
improvement: 'default',
'bug-fix': 'warning',
security: 'destructive',
policy: 'default',
}
export const CHANGE_TYPE_DISPLAY: Record<
ChangeType,
{ label: string; badgeVariant: 'default' | 'warning' | 'success' | 'destructive' }
> = Object.fromEntries(
(Object.keys(CHANGE_TYPE_LABELS) as ChangeType[]).map((type) => [
type,
{ label: CHANGE_TYPE_LABELS[type], badgeVariant: CHANGE_TYPE_BADGE_VARIANT[type] },
])
) as Record<
ChangeType,
{ label: string; badgeVariant: 'default' | 'warning' | 'success' | 'destructive' }
>
/** Internal changelog index URL with preselected tag filter (nuqs `tags` param). */
export function changelogTagFilterUrl(productSlug: string) {
return `/changelog?tags=${encodeURIComponent(productSlug.toLowerCase())}`
}
/** Internal changelog index URL with preselected change-type filter (nuqs `types` param). */
export function changelogTypeFilterUrl(changeType: ChangeType) {
return `/changelog?types=${encodeURIComponent(changeType)}`
}
export const CHANGE_TYPES = Object.keys(CHANGE_TYPE_DISPLAY) as ChangeType[]
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 isChangelogChangeType(value: string): value is ChangeType {
return Object.prototype.hasOwnProperty.call(CHANGE_TYPE_DISPLAY, value)
}
/** Matches the `product_stage` enum in supabase/changelog's entry schema. */
export const CHANGELOG_PRODUCT_STAGES = [
{ slug: 'private-alpha', label: 'Private Alpha' },
{ slug: 'public-alpha', label: 'Public Alpha' },
{ slug: 'beta', label: 'Beta' },
{ slug: 'public-beta', label: 'Public Beta' },
{ slug: 'general-availability', label: 'General Availability' },
] as const
const CHANGELOG_PRODUCT_STAGE_SLUG_SET = new Set<string>(
CHANGELOG_PRODUCT_STAGES.map((stage) => stage.slug)
)
export function isChangelogProductStageSlug(value: string) {
return CHANGELOG_PRODUCT_STAGE_SLUG_SET.has(value)
}
function productStageToSlug(productStage: string) {
return productStage.toLowerCase().replace(/\s+/g, '-')
}
export const CHANGELOG_SELF_HOSTED_OPTIONS = [
{ slug: 'yes', label: 'Affects self-hosted' },
{ slug: 'no', label: "Doesn't affect self-hosted" },
] as const
const CHANGELOG_SELF_HOSTED_SLUG_SET = new Set<string>(
CHANGELOG_SELF_HOSTED_OPTIONS.map((option) => option.slug)
)
export function isChangelogSelfHostedSlug(value: string) {
return CHANGELOG_SELF_HOSTED_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.affectedProducts.some((product) => product.toLowerCase().includes(normalizedQuery))
}
export function itemMatchesChangelogSelectedTags(
item: ChangelogTimelineIndexItem,
selectedTags: Set<string>
) {
if (selectedTags.size === 0) return true
const productSlugs = new Set(item.affectedProducts.map((product) => product.toLowerCase()))
for (const slug of selectedTags) {
if (productSlugs.has(slug.toLowerCase())) return true
}
return false
}
export function itemMatchesChangelogSelectedTypes(
item: ChangelogTimelineIndexItem,
selectedTypes: Set<ChangeType>
) {
if (selectedTypes.size === 0) return true
return selectedTypes.has(item.changeType)
}
export function itemMatchesChangelogSelectedStages(
item: ChangelogTimelineIndexItem,
selectedStages: Set<string>
) {
if (selectedStages.size === 0) return true
if (!item.productStage) return false
return selectedStages.has(productStageToSlug(item.productStage))
}
export function itemMatchesChangelogSelectedSelfHosted(
item: ChangelogTimelineIndexItem,
selectedSelfHosted: Set<string>
) {
if (selectedSelfHosted.size === 0) return true
const key = item.affectsSelfHosted === true ? 'yes' : 'no'
return selectedSelfHosted.has(key)
}