diff --git a/apps/docsite/src/__tests__/changelog-linkify.test.ts b/apps/docsite/src/__tests__/changelog-linkify.test.ts index ec801835393e..e836e61341bf 100644 --- a/apps/docsite/src/__tests__/changelog-linkify.test.ts +++ b/apps/docsite/src/__tests__/changelog-linkify.test.ts @@ -12,6 +12,7 @@ import { linkifyContributors, linkifyComponents, stripTitle, + fillEmptyReleases, } from '../components/changelogLinkify'; describe('linkifyPRs', () => { @@ -136,6 +137,69 @@ describe('stripTitle', () => { }); }); +describe('fillEmptyReleases', () => { + it('fills a version whose only body is the separator to the next heading', () => { + const md = [ + '# 0.4.6', + '', + '---', + '', + '# 0.4.5', + '', + '#### Fixes', + '', + '- A real fix.', + ].join('\n'); + expect(fillEmptyReleases(md)).toBe( + [ + '# 0.4.6', + '', + 'No changes in this release.', + '', + '---', + '', + '# 0.4.5', + '', + '#### Fixes', + '', + '- A real fix.', + ].join('\n'), + ); + }); + + it('fills the last version in the file, which has no trailing separator', () => { + expect(fillEmptyReleases('# 0.4.2')).toBe( + '# 0.4.2\n\nNo changes in this release.\n\n', + ); + }); + + it('leaves a version with real content untouched', () => { + const md = [ + '# 0.4.3', + '', + '#### Fixes', + '', + '- A real fix.', + '', + '---', + '', + '# 0.4.2', + '', + '#### Fixes', + '', + '- Another real fix.', + ].join('\n'); + expect(fillEmptyReleases(md)).toBe(md); + }); + + it('does not mistake the stripped package title for an empty version', () => { + // fillEmptyReleases is documented to run after stripTitle, so the h1 + // package-name line should already be gone by the time it sees this. + const md = ['# 0.4.6', '', '#### Fixes', '', '- A fix.'].join('\n'); + expect(fillEmptyReleases(md)).toBe(md); + }); +}); + describe('full changelog pipeline', () => { it('linkifies contributors without mangling package bullets or PRs', () => { const md = [ diff --git a/apps/docsite/src/components/ChangelogView.tsx b/apps/docsite/src/components/ChangelogView.tsx index 30e2f30c95a4..4f6ac242be30 100644 --- a/apps/docsite/src/components/ChangelogView.tsx +++ b/apps/docsite/src/components/ChangelogView.tsx @@ -2,7 +2,8 @@ 'use client'; -import {useState} from 'react'; +import {Suspense} from 'react'; +import {usePathname, useRouter, useSearchParams} from 'next/navigation'; import * as stylex from '@stylexjs/stylex'; import {Markdown} from '@astryxdesign/core/Markdown'; import {Text, Heading} from '@astryxdesign/core/Text'; @@ -17,6 +18,7 @@ import { linkifyContributors, linkifyComponents, stripTitle, + fillEmptyReleases, } from './changelogLinkify'; interface ChangelogEntry { @@ -29,6 +31,10 @@ interface ChangelogViewProps { componentNames: string[]; } +// The package most people land on this page to read about; picked over +// "whichever package happens to sort first" (@astryxdesign/cli). +const DEFAULT_PACKAGE = '@astryxdesign/core'; + const styles = stylex.create({ section: { marginInline: 'auto', @@ -42,11 +48,32 @@ const styles = stylex.create({ }, }); -export function ChangelogView({ - changelogs, - componentNames, -}: ChangelogViewProps) { - const [activeTab, setActiveTab] = useState(changelogs[0]?.pkg ?? ''); +function ChangelogViewInner({changelogs, componentNames}: ChangelogViewProps) { + const searchParams = useSearchParams(); + const router = useRouter(); + const pathname = usePathname(); + + const fallbackPackage = + changelogs.find(c => c.pkg === DEFAULT_PACKAGE)?.pkg ?? + changelogs[0]?.pkg ?? + ''; + const requestedPackage = searchParams.get('package'); + // Clamp to a package that actually has a changelog so a stale or + // hand-edited `?package=` never lands on a blank panel. + const activeTab = + requestedPackage != null && changelogs.some(c => c.pkg === requestedPackage) + ? requestedPackage + : fallbackPackage; + const setActiveTab = (value: string) => { + const params = new URLSearchParams(searchParams.toString()); + if (value === fallbackPackage) { + params.delete('package'); + } else { + params.set('package', value); + } + const qs = params.toString(); + router.replace(`${pathname}${qs ? `?${qs}` : ''}`, {scroll: false}); + }; const active = changelogs.find(c => c.pkg === activeTab); return ( @@ -77,7 +104,9 @@ export function ChangelogView({ {active != null && ( {linkifyComponents( - linkifyContributors(linkifyPRs(stripTitle(active.content))), + linkifyContributors( + linkifyPRs(fillEmptyReleases(stripTitle(active.content))), + ), componentNames, )} @@ -92,3 +121,11 @@ export function ChangelogView({ ); } + +export function ChangelogView(props: ChangelogViewProps) { + return ( + + + + ); +} diff --git a/apps/docsite/src/components/changelogLinkify.ts b/apps/docsite/src/components/changelogLinkify.ts index 2da60169613d..0a28970ea279 100644 --- a/apps/docsite/src/components/changelogLinkify.ts +++ b/apps/docsite/src/components/changelogLinkify.ts @@ -107,3 +107,33 @@ export function linkifyComponents( export function stripTitle(markdown: string): string { return markdown.replace(/^#\s+.+\n+/, ''); } + +const NO_CHANGES_TEXT = 'No changes in this release.'; + +/** + * A release with no notable changes still gets a version heading from the + * changeset tooling, with nothing between it and the next heading (or the + * `---` separator before it). Rendered as-is that reads as a broken page — + * a heading followed immediately by a divider or another heading, with no + * indication the release was simply empty. Fill each of those bodies with + * an explicit "no changes" line instead of leaving them bare. + * + * Call this after `stripTitle`, so the package-name h1 itself is not + * mistaken for an empty version section. + */ +export function fillEmptyReleases(markdown: string): string { + const parts = markdown.split(/(^#{1,2}\s+.+$)/gm); + for (let i = 2; i < parts.length; i += 2) { + const body = parts[i]; + const separatorMatch = /^\s*---\s*$/m.exec(body); + const bodyWithoutSeparator = separatorMatch + ? body.slice(0, separatorMatch.index) + + body.slice(separatorMatch.index + separatorMatch[0].length) + : body; + if (bodyWithoutSeparator.trim() === '') { + parts[i] = + '\n\n' + NO_CHANGES_TEXT + '\n\n' + (separatorMatch ? '---\n\n' : ''); + } + } + return parts.join(''); +}