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('');
+}