diff --git a/docs-ui/scripts/sync-changelog.mjs b/docs-ui/scripts/sync-changelog.mjs
index 0c51a67f6b..414826a7e7 100644
--- a/docs-ui/scripts/sync-changelog.mjs
+++ b/docs-ui/scripts/sync-changelog.mjs
@@ -379,6 +379,9 @@ async function parseListItem(
/**
* Infer change type from section and description
+ * Breaking change rules (semver):
+ * - version >= 1.0.0: Major changes are breaking
+ * - version < 1.0.0: Major and Minor changes are breaking
*/
function isBreakingChange(section, description, version) {
// Mark as breaking if explicitly mentioned in description
@@ -386,12 +389,17 @@ function isBreakingChange(section, description, version) {
return true;
}
- // Or if it's in the Major or Minor Changes section
- if (section === 'Major Changes' || section === 'Minor Changes') {
- return true;
+ // Parse version to determine breaking change rules
+ const normalizedVersion = normalizeVersion(version);
+ const [major] = normalizedVersion.split('.').map(Number);
+
+ // Version >= 1.0.0: Only Major Changes are breaking
+ if (major >= 1) {
+ return section === 'Major Changes';
}
- return false;
+ // Version < 1.0.0: Both Major and Minor Changes are breaking
+ return section === 'Major Changes' || section === 'Minor Changes';
}
/**
diff --git a/docs-ui/src/components/Changelog/index.tsx b/docs-ui/src/components/Changelog/index.tsx
index 7f671adcb8..c1f3a5660b 100644
--- a/docs-ui/src/components/Changelog/index.tsx
+++ b/docs-ui/src/components/Changelog/index.tsx
@@ -1,164 +1,13 @@
import { changelog } from '@/utils/changelog';
import { MDXRemote } from 'next-mdx-remote-client/rsc';
import { formattedMDXComponents } from '@/mdx-components';
-
-const Badge = ({
- children,
- variant = 'gray',
-}: {
- children: React.ReactNode;
- variant?: 'red' | 'gray';
-}) => {
- const colors = {
- red: {
- backgroundColor: 'var(--badge-red-bg)',
- color: 'var(--badge-red-color)',
- },
- gray: {
- backgroundColor: 'var(--badge-gray-bg)',
- color: 'var(--badge-gray-color)',
- },
- };
-
- return (
-
- {children}
-
- );
-};
-
-const BreakingBadge = () => Breaking;
+import { Badge, BreakingBadge, generateChangelogMarkdown } from './utils';
export function Changelog() {
- // Convert kebab-case to Title Case
- const toTitleCase = (kebabCase: string) => {
- return kebabCase
- .split('-')
- .map(word => word.charAt(0).toLocaleUpperCase('en-US') + word.slice(1))
- .join(' ');
- };
-
- // Group changelog entries by version
- const groupedChangelog = changelog.reduce((acc, entry) => {
- if (!acc[entry.version]) {
- acc[entry.version] = [];
- }
- acc[entry.version].push(entry);
- return acc;
- }, {} as Record);
-
- // Sort versions in descending order (semantic versioning)
- const sortedVersions = Object.keys(groupedChangelog).sort((a, b) => {
- const aParts = a.split('.').map(Number);
- const bParts = b.split('.').map(Number);
-
- for (let i = 0; i < 3; i++) {
- if (bParts[i] !== aParts[i]) {
- return bParts[i] - aParts[i]; // Descending order
- }
- }
- return 0;
+ const content = generateChangelogMarkdown(changelog, {
+ showComponentBadges: true,
});
- const content = sortedVersions
- .map(version => {
- const entries = groupedChangelog[version];
-
- // Group entries: Breaking vs Everything Else
- const breakingChanges = entries.filter(e => e.breaking);
- const otherChanges = entries.filter(e => !e.breaking);
-
- const sections = [];
-
- // Breaking changes section
- if (breakingChanges.length > 0) {
- sections.push({
- title: 'Breaking Changes',
- entries: breakingChanges,
- });
- }
-
- // All other changes section
- if (otherChanges.length > 0) {
- sections.push({
- title: 'Changes',
- entries: otherChanges,
- });
- }
-
- const bumpSections = sections
- .map(({ title, entries: bumpEntries }) => {
- const sectionTitle = title;
-
- const entriesMarkdown = bumpEntries
- .map(e => {
- const prs =
- e.prs.length > 0
- ? e.prs
- .map(
- pr =>
- `[#${pr}](https://github.com/backstage/backstage/pull/${pr})`,
- )
- .join(', ')
- : '';
-
- // Prepend component names as badges if available
- const componentBadges =
- e.components.length > 0
- ? e.components
- .map(
- c => `${toTitleCase(c)}`,
- )
- .join(' ') + ' '
- : '';
-
- // Add breaking badge if this is a breaking change
- const breakingBadge = e.breaking ? ' ' : '';
-
- // Remove **BREAKING**: text from description since we show it as a badge
- const description = e.description.replace(
- /\*\*BREAKING\*\*:?\s*/,
- '',
- );
-
- // Description already has proper indentation from CHANGELOG
- let entry = `- ${componentBadges}${breakingBadge}${description}`;
- if (prs) {
- entry += ` ${prs}`;
- }
-
- // Add migration if present (should already be in description, but check)
- if (e.migration && !e.description.includes('**Migration:**')) {
- entry += `\n\n Migration Guide:\n\n ${e.migration
- .split('\n')
- .join('\n ')}`;
- }
-
- return entry;
- })
- .join('\n\n');
-
- return `### ${sectionTitle}\n\n${entriesMarkdown}`;
- })
- .join('\n\n');
-
- return `## Version ${version}
-
- ${bumpSections}`;
- })
- .join('\n');
-
return (
{
+ const colors = {
+ red: {
+ backgroundColor: 'var(--badge-red-bg)',
+ color: 'var(--badge-red-color)',
+ },
+ gray: {
+ backgroundColor: 'var(--badge-gray-bg)',
+ color: 'var(--badge-gray-color)',
+ },
+ };
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const BreakingBadge = () => Breaking;
+
+// Utility Functions
+export const toTitleCase = (kebabCase: string): string => {
+ return kebabCase
+ .split('-')
+ .map(word => word.charAt(0).toLocaleUpperCase('en-US') + word.slice(1))
+ .join(' ');
+};
+
+export const groupByVersion = (
+ entries: ChangelogProps[],
+): Record => {
+ return entries.reduce((acc, entry) => {
+ if (!acc[entry.version]) {
+ acc[entry.version] = [];
+ }
+ acc[entry.version].push(entry);
+ return acc;
+ }, {} as Record);
+};
+
+export const sortVersions = (versions: string[]): string[] => {
+ return versions.sort((a, b) => {
+ const aParts = a.split('.').map(Number);
+ const bParts = b.split('.').map(Number);
+
+ for (let i = 0; i < 3; i++) {
+ if (bParts[i] !== aParts[i]) {
+ return bParts[i] - aParts[i]; // Descending order
+ }
+ }
+ return 0;
+ });
+};
+
+export const formatPRLinks = (prs: string[]): string => {
+ if (prs.length === 0) return '';
+
+ return prs
+ .map(pr => `[#${pr}](https://github.com/backstage/backstage/pull/${pr})`)
+ .join(', ');
+};
+
+export const generateEntryMarkdown = (
+ entry: ChangelogProps,
+ options: { showComponentBadges?: boolean } = {},
+): string => {
+ const { showComponentBadges = true } = options;
+ const prs = formatPRLinks(entry.prs);
+
+ // Prepend component names as badges if available and requested
+ const componentBadges =
+ showComponentBadges && entry.components.length > 0
+ ? entry.components
+ .map(c => `${toTitleCase(c)}`)
+ .join(' ') + ' '
+ : '';
+
+ // Add breaking badge if this is a breaking change
+ const breakingBadge = entry.breaking ? ' ' : '';
+
+ // Remove **BREAKING**: text from description since we show it as a badge
+ const description = entry.description.replace(/\*\*BREAKING\*\*:?\s*/, '');
+
+ // Build the entry
+ let entryMarkdown = `- ${componentBadges}${breakingBadge}${description}`;
+ if (prs) {
+ entryMarkdown += ` ${prs}`;
+ }
+
+ // Add migration if present (should already be in description, but check)
+ if (entry.migration && !entry.description.includes('**Migration:**')) {
+ entryMarkdown += `\n\n Migration Guide:\n\n ${entry.migration
+ .split('\n')
+ .join('\n ')}`;
+ }
+
+ return entryMarkdown;
+};
+
+export interface GenerateChangelogOptions {
+ showComponentBadges?: boolean;
+ headingLevel?: number;
+}
+
+export const generateChangelogMarkdown = (
+ entries: ChangelogProps[],
+ options: GenerateChangelogOptions = {},
+): string => {
+ const { showComponentBadges = true, headingLevel = 2 } = options;
+
+ // Group changelog entries by version
+ const groupedChangelog = groupByVersion(entries);
+
+ // Sort versions in descending order (semantic versioning)
+ const sortedVersions = sortVersions(Object.keys(groupedChangelog));
+
+ // Generate heading prefix based on level (e.g., "##" for level 2, "###" for level 3)
+ const versionHeading = '#'.repeat(headingLevel);
+ const sectionHeading = '#'.repeat(headingLevel + 1);
+
+ const content = sortedVersions
+ .map(version => {
+ const versionEntries = groupedChangelog[version];
+
+ // Group entries: Breaking vs Everything Else
+ const breakingChanges = versionEntries.filter(e => e.breaking);
+ const otherChanges = versionEntries.filter(e => !e.breaking);
+
+ const sections = [];
+
+ // Breaking changes section
+ if (breakingChanges.length > 0) {
+ sections.push({
+ title: 'Breaking Changes',
+ entries: breakingChanges,
+ });
+ }
+
+ // All other changes section
+ if (otherChanges.length > 0) {
+ sections.push({
+ title: 'Changes',
+ entries: otherChanges,
+ });
+ }
+
+ const bumpSections = sections
+ .map(({ title, entries: sectionEntries }) => {
+ const entriesMarkdown = sectionEntries
+ .map(e => generateEntryMarkdown(e, { showComponentBadges }))
+ .join('\n\n');
+
+ return `${sectionHeading} ${title}\n\n${entriesMarkdown}`;
+ })
+ .join('\n\n');
+
+ return `${versionHeading} Version ${version}
+
+ ${bumpSections}`;
+ })
+ .join('\n');
+
+ return content;
+};
diff --git a/docs-ui/src/components/ChangelogComponent/index.tsx b/docs-ui/src/components/ChangelogComponent/index.tsx
index ff86433268..92068d04f8 100644
--- a/docs-ui/src/components/ChangelogComponent/index.tsx
+++ b/docs-ui/src/components/ChangelogComponent/index.tsx
@@ -2,34 +2,32 @@ import { changelog } from '@/utils/changelog';
import { MDXRemote } from 'next-mdx-remote-client/rsc';
import { formattedMDXComponents } from '@/mdx-components';
import type { Component } from '@/utils/changelog';
+import {
+ Badge,
+ BreakingBadge,
+ generateChangelogMarkdown,
+} from '../Changelog/utils';
export const ChangelogComponent = ({ component }: { component: Component }) => {
const componentChangelog = changelog.filter(c =>
c.components.includes(component),
);
+ const content = `## Changelog
+
+${generateChangelogMarkdown(componentChangelog, {
+ showComponentBadges: false,
+ headingLevel: 3,
+})}`;
+
return (
{
- const prs =
- change.prs.length > 0
- ? change.prs
- .map(
- pr =>
- `[#${pr}](https://github.com/backstage/backstage/pull/${pr})`,
- )
- .join(', ')
- : '';
- return `- \`${change.version}\` - ${change.description}${
- prs ? ` ${prs}` : ''
- }`;
- })
- .join('\n')}`}
+ components={{
+ ...formattedMDXComponents,
+ Badge,
+ BreakingBadge,
+ }}
+ source={content}
/>
);
};