diff --git a/docs-ui/README.md b/docs-ui/README.md index 41b9921413..b943f1ce7c 100644 --- a/docs-ui/README.md +++ b/docs-ui/README.md @@ -13,3 +13,28 @@ yarn start ## Deployment Deployments are done automatically when a PR is merged into the `master` branch. We host the website using Github pages. + +## Maintaining Component Changelogs + +After a `@backstage/ui` release, sync the component changelogs to keep documentation up-to-date: + +```bash +yarn sync:changelog +``` + +This script: + +- Parses `packages/ui/CHANGELOG.md` for new versions +- Extracts entries tagged with "Affected components: ..." +- Updates `src/utils/changelog.ts` with new entries +- Handles both component-specific and general package changes + +After running, review the changes in `src/utils/changelog.ts` and commit them. + +**Preview changes before writing:** + +```bash +yarn sync:changelog:dry-run +``` + +Running this gives you a summary of what would be written, without actually adding or changing any files. diff --git a/docs-ui/package.json b/docs-ui/package.json index d05fdf81bb..319c0e130f 100644 --- a/docs-ui/package.json +++ b/docs-ui/package.json @@ -8,6 +8,8 @@ "lint": "next lint", "prestart": "yarn sync:css", "start": "next dev", + "sync:changelog": "node scripts/sync-changelog.mjs", + "sync:changelog:dry-run": "node scripts/sync-changelog.mjs --dry-run", "sync:css": "node scripts/sync-css.js", "sync:css:watch": "node scripts/sync-css.js --watch" }, @@ -39,6 +41,7 @@ "storybook": "^8.6.12" }, "devDependencies": { + "@octokit/rest": "^22.0.1", "@shikijs/transformers": "^3.13.0", "@types/mdx": "^2.0.13", "@types/node": "^20", @@ -48,6 +51,7 @@ "eslint": "^8", "eslint-config-next": "15.3.4", "lightningcss": "^1.28.2", - "typescript": "^5" + "typescript": "^5", + "unified": "^11.0.4" } } diff --git a/docs-ui/scripts/sync-changelog.mjs b/docs-ui/scripts/sync-changelog.mjs new file mode 100644 index 0000000000..5eab3616dd --- /dev/null +++ b/docs-ui/scripts/sync-changelog.mjs @@ -0,0 +1,991 @@ +#!/usr/bin/env node +/* eslint-disable no-restricted-syntax */ + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { unified } from 'unified'; +import remarkParse from 'remark-parse'; +import { Octokit } from '@octokit/rest'; +import { execSync } from 'child_process'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +/** + * Compare two semantic versions. + * Returns: 1 if versionA > versionB, -1 if versionA < versionB, 0 if equal + * Handles pre-release versions (e.g., "0.9.0-next.2") + */ +function compareVersions(versionA, versionB) { + // Strip 'v' prefix if present + versionA = versionA.replace(/^v/, ''); + versionB = versionB.replace(/^v/, ''); + + // Split into [major.minor.patch, prerelease] + const [versionABase, versionAPre] = versionA.split('-'); + const [versionBBase, versionBPre] = versionB.split('-'); + + // Compare base versions + const versionAParts = versionABase.split('.').map(Number); + const versionBParts = versionBBase.split('.').map(Number); + + for ( + let i = 0; + i < Math.max(versionAParts.length, versionBParts.length); + i++ + ) { + const versionAPart = versionAParts[i] || 0; + const versionBPart = versionBParts[i] || 0; + + if (versionAPart > versionBPart) return 1; + if (versionAPart < versionBPart) return -1; + } + + // If base versions equal, compare pre-release + if (!versionAPre && !versionBPre) return 0; + if (!versionAPre) return 1; // versionA is release, versionB is pre-release + if (!versionBPre) return -1; // versionA is pre-release, versionB is release + + // Both have pre-release, compare them + return versionAPre.localeCompare(versionBPre); +} + +/** + * Check if versionA is newer than versionB + */ +function isNewerVersion(versionA, versionB) { + return compareVersions(versionA, versionB) > 0; +} + +/** + * Normalize version to base version (remove pre-release suffix) + * "0.9.0-next.3" → "0.9.0" + * "0.9.0" → "0.9.0" + */ +function normalizeVersion(version) { + return version.split('-')[0]; +} + +/** + * Read the existing changelog.ts and find the highest version number + * by scanning the changelogs/ directory for version files + */ +function getLastSyncedVersion(changelogPath) { + try { + // Scan changelogs/ directory for latest version file + const changelogsDir = path.join(path.dirname(changelogPath), 'changelogs'); + + if (!fs.existsSync(changelogsDir)) { + return null; + } + + // Read all version files (v0.1.0.ts, v0.2.0.ts, etc.) + const files = fs + .readdirSync(changelogsDir) + .filter(f => f.match(/^v\d+\.\d+\.\d+\.ts$/)) + .map(f => f.replace(/^v/, '').replace(/\.ts$/, '')); + + if (files.length === 0) { + return null; + } + + // Find highest version + let highest = files[0]; + for (const version of files) { + if (compareVersions(version, highest) > 0) { + highest = version; + } + } + + return highest; + } catch (error) { + console.error(`Error reading changelog file: ${error.message}`); + return null; + } +} + +/** + * Convert PascalCase component name to kebab-case slug + * Avatar -> avatar + * ButtonIcon -> button-icon + * SearchField -> searchfield (special case per existing data) + */ +function pascalToKebab(str) { + // Special cases observed in changelog.ts + const specialCases = { + SearchField: 'searchfield', + TextField: 'textfield', + DataTable: 'datatable', + ScrollArea: 'scrollarea', + }; + + if (specialCases[str]) { + return specialCases[str]; + } + + // General conversion: insert hyphen before uppercase letters + return str + .replace(/([A-Z])/g, '-$1') + .toLowerCase() + .replace(/^-/, ''); // Remove leading hyphen +} + +/** + * Map component name to valid Component type slug + * Returns null if component not recognized + */ +function mapComponentName(name, validComponents) { + const kebab = pascalToKebab(name.trim()); + const kebabLower = kebab.toLowerCase(); + + // Check if it's a valid component + const valid = validComponents.find(c => c.toLowerCase() === kebabLower); + + return valid || null; +} + +/** + * Get valid component names from types.ts Component type + */ +function getValidComponents(changelogPath) { + // Read from types.ts instead of changelog.ts + const typesPath = changelogPath.replace('changelog.ts', 'types.ts'); + const content = fs.readFileSync(typesPath, 'utf-8'); + + // Extract Component type union + const typeMatch = content.match(/export type Component =([^;]+);/s); + if (!typeMatch) { + throw new Error('Could not find Component type definition'); + } + + // Extract string literals from the union + const components = typeMatch[1] + .match(/['"]([^'"]+)['"]/g) + .map(s => s.replace(/['"]/g, '')); + + return components; +} + +/** + * Parse CHANGELOG.md and extract entries newer than sinceVersion + */ +async function parseChangelogMd(changelogPath, sinceVersion, validComponents) { + const content = fs.readFileSync(changelogPath, 'utf-8'); + const tree = unified().use(remarkParse).parse(content); + + const entries = []; + let currentVersion = null; + let currentSection = null; // 'Minor Changes', 'Patch Changes', 'Major Changes' + let processedCount = 0; + + // Walk through the markdown AST + async function walk(node, depth = 0) { + // Version headers (## 0.9.0) + if (node.type === 'heading' && node.depth === 2) { + const versionText = extractText(node).trim(); + // Validate that this is actually a version number (X.Y.Z or X.Y.Z-pre.N) + // Skip headings that are just markdown content within changelog entries + if (/^\d+\.\d+\.\d+/.test(versionText)) { + currentVersion = versionText; + currentSection = null; + } + return; + } + + // Section headers (### Minor Changes) + if (node.type === 'heading' && node.depth === 3) { + const sectionText = extractText(node); + currentSection = sectionText.trim(); + return; + } + + // List items (- 539cf26: description) + if (node.type === 'listItem' && currentVersion && currentSection) { + // Only process if version is newer than sinceVersion + if (!sinceVersion || isNewerVersion(currentVersion, sinceVersion)) { + const entry = await parseListItem( + node, + currentVersion, + currentSection, + validComponents, + content, + ); + if (entry) { + entries.push(entry); + processedCount++; + // Progress logging every 50 entries + if (processedCount % 50 === 0) { + process.stdout.write(`\r Processed ${processedCount} entries...`); + } + } + } + } + + // Recurse into children + if (node.children) { + for (const child of node.children) { + await walk(child, depth + 1); + } + } + } + + await walk(tree); + + // Clear progress line + if (processedCount > 0) { + process.stdout.write(`\r Processed ${processedCount} entries - done!\n`); + } + + return entries; +} + +/** + * Extract plain text from a markdown node + */ +function extractText(node) { + if (node.type === 'text') { + return node.value; + } + if (node.children) { + return node.children.map(extractText).join(''); + } + return ''; +} + +/** + * Extract raw markdown content from a node using position offsets + * This preserves all formatting: code blocks, lists, bold/italic, etc. + */ +function extractMarkdown(node, sourceText) { + if (!node.position) { + return extractText(node); // Fallback to text extraction + } + + const start = node.position.start.offset; + const end = node.position.end.offset; + + if (start === undefined || end === undefined) { + return extractText(node); + } + + return sourceText.slice(start, end).trim(); +} + +/** + * Parse a list item to extract changelog entry + * NOTE: PR numbers are NOT fetched here - they're fetched later only for new entries + */ +async function parseListItem( + node, + version, + section, + validComponents, + sourceText, +) { + // Extract the full text content for SHA parsing + const fullText = extractText(node); + + // Parse commit SHA and description + // Format: "- 539cf26: description..." + const match = fullText.match(/^-?\s*([a-f0-9]+):\s*(.+)/s); + if (!match) { + return null; + } + + const commitSha = match[1]; + + // Extract full markdown content from the list item + let fullMarkdown = extractMarkdown(node, sourceText); + + // Remove the commit SHA prefix from markdown + let description = fullMarkdown.replace(/^-?\s*[a-f0-9]+:\s*/, '').trim(); + + // Auto-detect components from "Affected components:" line + let components = []; + const componentMatch = description.match(/Affected components?:\s*([^\n]+)/i); + if (componentMatch) { + const componentNames = componentMatch[1] + .split(',') + .map(name => name.trim()) + .filter(Boolean); + + components = componentNames + .map(name => mapComponentName(name, validComponents)) + .filter(Boolean); + + // Optionally strip "Affected components:" line from description + description = description + .replace(/\n*Affected components?:[ \t]*[^\n]+/i, '') + .trim(); + } + + const prs = []; // Will be populated later by fetchPRNumbers() + + // Infer type from section and description + const type = inferChangeType(section, description, version); + + return { + version: normalizeVersion(version), + section, + commitSha, + description, + components, + prs, + type, + }; +} + +/** + * Infer change type from section and description + */ +function inferChangeType(section, description, version) { + const isPre1 = version.startsWith('0.'); + + // Check description keywords first + if (description.match(/^(New|Add(ed)?)\s/i)) { + return 'new'; + } + if (description.includes('BREAKING')) { + return 'breaking'; + } + + // Infer from section + if (section === 'Minor Changes') { + return isPre1 ? 'breaking' : undefined; + } else if (section === 'Major Changes') { + return 'breaking'; + } else if (section === 'Patch Changes') { + return 'fix'; + } + + return undefined; +} + +/** + * Generate new changelog entries and update changelog.ts + */ +/** + * Fetch PR numbers for entries that need them + * Only fetches for entries with empty prs array (new entries) + */ +async function fetchPRNumbers(entries, dryRun = false) { + const entriesNeedingPRs = entries.filter( + e => e.prs.length === 0 && e.commitSha, + ); + + if (entriesNeedingPRs.length === 0) { + return; + } + + console.log( + `\nšŸ” Fetching PR numbers for ${entriesNeedingPRs.length} new entries...`, + ); + + let fetchedCount = 0; + const startTime = Date.now(); + + for (const entry of entriesNeedingPRs) { + const prNumber = await findPRNumber(entry.commitSha); + if (prNumber) { + entry.prs.push(prNumber); + } + + fetchedCount++; + // Progress every 10 lookups + if (fetchedCount % 10 === 0 || fetchedCount === entriesNeedingPRs.length) { + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + const rate = ((fetchedCount / (Date.now() - startTime)) * 1000).toFixed( + 1, + ); + process.stdout.write( + `\r Fetched ${fetchedCount}/${entriesNeedingPRs.length} PR numbers (${elapsed}s, ~${rate} req/s)`, + ); + } + } + + const totalTime = ((Date.now() - startTime) / 1000).toFixed(1); + const foundCount = entriesNeedingPRs.filter(e => e.prs.length > 0).length; + console.log( + `\n āœ“ Found ${foundCount}/${entriesNeedingPRs.length} PR numbers in ${totalTime}s`, + ); +} + +/** + * Generate per-version changelog files in changelogs/ directory + */ +async function generateVersionFiles(entries, changelogsDir, dryRun = false) { + // Group entries by normalized version + const byVersion = {}; + entries.forEach(entry => { + const version = entry.version; + if (!byVersion[version]) { + byVersion[version] = []; + } + byVersion[version].push(entry); + }); + + const versionFiles = []; + const skippedVersions = []; + const generatedVersions = []; + + for (const [version, versionEntries] of Object.entries(byVersion)) { + const fileName = `v${version}.ts`; + const filePath = path.join(changelogsDir, fileName); + const varName = `changelog_${version.replace(/\./g, '_')}`; + + // Check if file already exists - skip if it does + if (fs.existsSync(filePath)) { + skippedVersions.push(version); + // Still add to versionFiles array so main changelog.ts can import it + versionFiles.push({ + version, + fileName, + filePath, + varName, + content: null, // No content needed for existing files + skipped: true, + }); + continue; + } + + // Generate TypeScript code for this version + const entryObjects = versionEntries + .map(entry => { + const componentsStr = `[${entry.components + .map(c => `'${c}'`) + .join(', ')}]`; + const prsStr = `[${entry.prs.map(pr => `'${pr}'`).join(', ')}]`; + const typeStr = entry.type ? `type: '${entry.type}',` : ''; + const shaStr = entry.commitSha + ? `commitSha: '${entry.commitSha}',` + : ''; + + // Escape description for template literal + const descEscaped = entry.description + .replace(/\\/g, '\\\\') + .replace(/`/g, '\\`') + .replace(/\${/g, '\\${'); + + return ` { + components: ${componentsStr}, + version: '${entry.version}', + prs: ${prsStr}, + description: \`${descEscaped}\`, + ${typeStr} + ${shaStr} + }`; + }) + .join(',\n'); + + const fileContent = `import type { ChangelogProps } from '../types'; + +export const ${varName}: ChangelogProps[] = [ +${entryObjects} +]; +`; + + generatedVersions.push(version); + versionFiles.push({ + version, + fileName, + filePath, + varName, + content: fileContent, + skipped: false, + }); + + if (!dryRun) { + fs.writeFileSync(filePath, fileContent, 'utf-8'); + } + } + + // Log summary + if (skippedVersions.length > 0) { + console.log( + `\nā­ļø Skipped ${ + skippedVersions.length + } existing version files: ${skippedVersions + .sort((a, b) => compareVersions(a, b)) + .join(', ')}`, + ); + } + if (generatedVersions.length > 0) { + console.log( + `\n✨ ${dryRun ? 'Would generate' : 'Generated'} ${ + generatedVersions.length + } new version files: ${generatedVersions + .sort((a, b) => compareVersions(a, b)) + .join(', ')}`, + ); + } + + return versionFiles; +} + +/** + * Generate main changelog.ts that imports and spreads all version files + */ +async function generateMainChangelog( + versionFiles, + changelogPath, + dryRun = false, +) { + // Get the changelogs directory path + const changelogsDir = path.join(path.dirname(changelogPath), 'changelogs'); + + // Read all version files from the changelogs directory + const allVersionFiles = new Map(); + + // First, add all files from the directory (if it exists) + if (fs.existsSync(changelogsDir)) { + const files = fs.readdirSync(changelogsDir); + files.forEach(file => { + if (file.match(/^v\d+\.\d+\.\d+\.ts$/)) { + const version = file.replace(/^v/, '').replace(/\.ts$/, ''); + const varName = `changelog_${version.replace(/\./g, '_')}`; + allVersionFiles.set(version, { + version, + fileName: file, + varName, + }); + } + }); + } + + // Then, add/update with files from this run (in case new ones were generated) + versionFiles.forEach(vf => { + allVersionFiles.set(vf.version, { + version: vf.version, + fileName: vf.fileName, + varName: vf.varName, + }); + }); + + // Sort versions in descending order + const sortedVersions = Array.from(allVersionFiles.values()).sort((a, b) => + compareVersions(b.version, a.version), + ); + + const imports = sortedVersions + .map( + v => + `import { ${v.varName} } from './changelogs/${v.fileName.replace( + '.ts', + '', + )}';`, + ) + .join('\n'); + + const spreads = sortedVersions.map(v => ` ...${v.varName},`).join('\n'); + + const content = `export * from './types'; +${imports} + +export const changelog = [ +${spreads} +]; +`; + + console.log( + `\nšŸ“ Main changelog.ts will import all ${sortedVersions.length} version files`, + ); + + if (!dryRun) { + fs.writeFileSync(changelogPath, content, 'utf-8'); + } + + return content; +} + +/** + * Check if an entry already exists in changelog.ts + */ +function isDuplicate(entry, existingContent) { + // Simple check: look for version + description substring + const descStart = entry.description.substring(0, 50); + const pattern = `version: '${entry.version}'`; + + const versionIndex = existingContent.indexOf(pattern); + if (versionIndex === -1) return false; + + // Check if description appears near this version + const contextWindow = existingContent.substring( + Math.max(0, versionIndex - 200), + versionIndex + 500, + ); + + return contextWindow.includes(descStart); +} + +/** + * Detect GitHub authentication method + * Priority: gh CLI → GITHUB_TOKEN env → unauthenticated + */ +function getGitHubAuth() { + // 1. Try gh CLI (preferred - uses keyring auth) + try { + // Check specifically for github.com authentication + const output = execSync('gh auth status -h github.com 2>&1', { + encoding: 'utf-8', + }); + if (output.includes('Logged in to github.com')) { + console.log('āœ“ Using gh CLI authentication'); + return { method: 'gh-cli' }; + } + } catch { + // gh not installed or not authenticated to github.com + } + + // 2. Try GITHUB_TOKEN env var + if (process.env.GITHUB_TOKEN) { + console.log('āœ“ Using GITHUB_TOKEN authentication'); + return { method: 'token', token: process.env.GITHUB_TOKEN }; + } + + // 3. Fallback to unauthenticated (60 req/hour limit) + console.warn('āš ļø Using unauthenticated GitHub API (60 requests/hour)'); + console.warn(' For higher limits: authenticate gh CLI or set GITHUB_TOKEN'); + return { method: 'unauthenticated' }; +} + +// Initialize auth and Octokit +const githubAuth = getGitHubAuth(); +const octokit = + githubAuth.method === 'token' + ? new Octokit({ auth: githubAuth.token }) + : new Octokit(); + +// Cache for PR lookups +const prCache = new Map(); + +/** + * Expand a truncated SHA to full SHA using local git repository + * Falls back to GitHub API if git rev-parse fails + */ +async function expandCommitSha(shortSha) { + try { + // First try local git repository (fastest, works for all commits) + // We need to run this from the backstage monorepo root + const repoRoot = path.join(__dirname, '../../'); + + try { + const fullSha = execSync(`git rev-parse ${shortSha}`, { + encoding: 'utf-8', + cwd: repoRoot, + stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + + if (fullSha && fullSha.length === 40) { + return fullSha; + } + } catch (gitError) { + // Handle ambiguous SHA - parse git's error output to find commit candidates + if (gitError.stderr && gitError.stderr.includes('ambiguous')) { + const stderr = gitError.stderr.toString(); + // Extract commit SHAs from git's hint output + // Format: "hint: 83c100e6accfb commit 2025-10-22 - message" + const commitMatch = stderr.match(/hint:\s+([a-f0-9]+)\s+commit\s+/); + if (commitMatch && commitMatch[1]) { + // Found the commit SHA from the ambiguous candidates + const candidateSha = commitMatch[1]; + // Verify this is a valid commit + try { + const fullSha = execSync(`git rev-parse ${candidateSha}`, { + encoding: 'utf-8', + cwd: repoRoot, + }).trim(); + if (fullSha && fullSha.length === 40) { + return fullSha; + } + } catch { + // Candidate didn't work, continue to GitHub API + } + } + } + // Commit not in local repo, try GitHub API + } + } catch (error) { + // Error setting up git command, try GitHub API + } + + try { + // Fallback to GitHub API + if (githubAuth.method === 'gh-cli') { + const result = execSync( + `gh api repos/backstage/backstage/commits/${shortSha} --jq '.sha' 2>/dev/null`, + { encoding: 'utf-8' }, + ).trim(); + return result || null; + } else { + // Use Octokit + const { data: commit } = await octokit.rest.repos.getCommit({ + owner: 'backstage', + repo: 'backstage', + ref: shortSha, + }); + return commit.sha; + } + } catch (error) { + // Commit not found or other error - return null silently + return null; + } +} + +/** + * Find PR number for a commit SHA using GitHub API + */ +async function findPRNumber(commitSha) { + // Check cache first + if (prCache.has(commitSha)) { + return prCache.get(commitSha); + } + + try { + // If SHA is truncated (7 chars), expand it first + let fullSha = commitSha; + if (commitSha.length === 7) { + fullSha = await expandCommitSha(commitSha); + if (!fullSha) { + // Commit not found + console.warn(`āš ļø Commit not found: ${commitSha}`); + prCache.set(commitSha, null); + return null; + } + } + + let prNumber = null; + + // Use gh CLI if available (faster, uses existing auth) + if (githubAuth.method === 'gh-cli') { + try { + const result = execSync( + `gh api repos/backstage/backstage/commits/${fullSha}/pulls --jq '.[0].number' 2>/dev/null`, + { encoding: 'utf-8' }, + ).trim(); + prNumber = result || null; + } catch (ghError) { + // Handle gh CLI errors gracefully - commit exists but has no PR + prNumber = null; + } + } else { + // Otherwise use Octokit (token or unauthenticated) + const { data: prs } = + await octokit.rest.repos.listPullRequestsAssociatedWithCommit({ + owner: 'backstage', + repo: 'backstage', + commit_sha: fullSha, + }); + prNumber = prs.length > 0 ? prs[0].number.toString() : null; + } + + // Cache the result + prCache.set(commitSha, prNumber); + return prNumber; + } catch (error) { + if (error.message?.includes('rate limit')) { + console.error('āš ļø GitHub API rate limit exceeded'); + console.error( + ' Authenticate gh CLI or set GITHUB_TOKEN for higher limits', + ); + } else { + console.warn(`āš ļø Error finding PR for ${commitSha}: ${error.message}`); + } + prCache.set(commitSha, null); + return null; + } +} + +/** + * Main sync function + */ +async function main() { + const args = process.argv.slice(2); + const dryRun = args.includes('--dry-run'); + + const changelogTsPath = path.join(__dirname, '../src/utils/changelog.ts'); + const changelogMdPath = path.join( + __dirname, + '../../packages/ui/CHANGELOG.md', + ); + + // Validate files exist + if (!fs.existsSync(changelogTsPath)) { + throw new Error(`changelog.ts not found at: ${changelogTsPath}`); + } + if (!fs.existsSync(changelogMdPath)) { + throw new Error(`CHANGELOG.md not found at: ${changelogMdPath}`); + } + + console.log('šŸ“‹ Syncing UI component changelogs...\n'); + + // Get last synced version + const lastVersion = getLastSyncedVersion(changelogTsPath); + console.log(`Last synced version: ${lastVersion || '(none)'}`); + + // Get valid components + const validComponents = getValidComponents(changelogTsPath); + console.log(`Valid components: ${validComponents.length}`); + + // Parse CHANGELOG.md + console.log('\nšŸ“– Parsing CHANGELOG.md...'); + const allEntries = await parseChangelogMd( + changelogMdPath, + lastVersion, + validComponents, + ); + console.log(`Found ${allEntries.length} total entries since ${lastVersion}`); + + // Read existing changelog content for duplicate detection + const existingContent = fs.readFileSync(changelogTsPath, 'utf-8'); + + // Filter to only new, non-duplicate entries + const relevantEntries = allEntries.filter(e => { + const hasComponents = e.components.length > 0 || e.components.length === 0; + const notDuplicate = !isDuplicate(e, existingContent); + return hasComponents && notDuplicate; + }); + + const duplicatesCount = allEntries.length - relevantEntries.length; + if (duplicatesCount > 0) { + console.log(`Skipped ${duplicatesCount} duplicate entries`); + } + + console.log( + `Relevant entries (with or without components): ${relevantEntries.length}`, + ); + + if (relevantEntries.length === 0) { + console.log('\nāœ… No new entries to sync'); + return; + } + + // Fetch PR numbers for new entries (lazy fetch - only for entries that will be written) + await fetchPRNumbers(relevantEntries, dryRun); + + // Show summary + console.log('\nšŸ“ New entries by component:'); + const byComponent = {}; + relevantEntries.forEach(entry => { + if (entry.components.length === 0) { + byComponent['(general)'] = (byComponent['(general)'] || 0) + 1; + } else { + entry.components.forEach(comp => { + byComponent[comp] = (byComponent[comp] || 0) + 1; + }); + } + }); + Object.entries(byComponent).forEach(([comp, count]) => { + console.log(` - ${comp}: ${count} ${count === 1 ? 'entry' : 'entries'}`); + }); + + // Warn about unknown components + const unknownComponents = []; + allEntries.forEach(entry => { + const fullText = entry.description; + const componentMatch = fullText.match( + /Affected components?:[ \t]*([^\n]+)/i, + ); + if (componentMatch) { + const names = componentMatch[1].split(',').map(n => n.trim()); + names.forEach(name => { + if (!mapComponentName(name, validComponents)) { + unknownComponents.push(name); + } + }); + } + }); + + if (unknownComponents.length > 0) { + console.log('\nāš ļø Unknown components (skipped):'); + [...new Set(unknownComponents)].forEach(name => { + console.log(` - ${name}`); + }); + } + + // Create changelogs directory if it doesn't exist + const changelogsDir = path.join(__dirname, '../src/utils/changelogs'); + if (!fs.existsSync(changelogsDir) && !dryRun) { + fs.mkdirSync(changelogsDir, { recursive: true }); + } + + // Generate version files + console.log( + `\n${ + dryRun ? 'šŸ” Dry run - would generate' : 'āœļø Generating' + } version files...`, + ); + + const versionFiles = await generateVersionFiles( + relevantEntries, + changelogsDir, + dryRun, + ); + + // Generate main changelog.ts + const mainContent = await generateMainChangelog( + versionFiles, + changelogTsPath, + dryRun, + ); + + if (!dryRun) { + console.log('\nāœ… Changelog sync complete!'); + const newFiles = versionFiles.filter(vf => !vf.skipped).length; + const skippedFiles = versionFiles.filter(vf => vf.skipped).length; + if (newFiles > 0) { + console.log( + ` Generated ${newFiles} new version file${newFiles === 1 ? '' : 's'}`, + ); + } + if (skippedFiles > 0) { + console.log( + ` Preserved ${skippedFiles} existing version file${ + skippedFiles === 1 ? '' : 's' + }`, + ); + } + console.log(` Updated ${changelogTsPath}`); + } else { + console.log('\nšŸ“„ Sample version file content:\n'); + // Find first non-skipped version file for sample + const sampleFile = versionFiles.find(vf => !vf.skipped && vf.content); + if (sampleFile) { + const sample = sampleFile.content.split('\n').slice(0, 30).join('\n'); + console.log(sample); + console.log('...\n'); + } else { + console.log('(No new version files to generate)\n'); + } + + console.log('\nšŸ“„ Main changelog.ts content:\n'); + console.log(mainContent); + + const newFiles = versionFiles.filter(vf => !vf.skipped).length; + const skippedFiles = versionFiles.filter(vf => vf.skipped).length; + + console.log('\nāœ… Dry run complete - no files were modified'); + if (newFiles > 0) { + console.log( + ` Run without --dry-run to generate ${newFiles} new version file${ + newFiles === 1 ? '' : 's' + }`, + ); + } + if (skippedFiles > 0) { + console.log( + ` ${skippedFiles} existing version file${ + skippedFiles === 1 ? '' : 's' + } will be preserved`, + ); + } + } +} + +// Run main if executed directly +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch(error => { + console.error('āŒ Error:', error.message); + process.exit(1); + }); +} diff --git a/docs-ui/src/components/Changelog/index.tsx b/docs-ui/src/components/Changelog/index.tsx index 224e23d668..d7a547b901 100644 --- a/docs-ui/src/components/Changelog/index.tsx +++ b/docs-ui/src/components/Changelog/index.tsx @@ -52,14 +52,15 @@ export function Changelog() { ${bumpEntries .map(e => { const prs = - e.prs.length > 0 && - e.prs - .map( - pr => - `[#${pr}](https://github.com/backstage/backstage/pull/${pr})`, - ) - .join(', '); - return `- ${e.description} ${prs}`; + e.prs.length > 0 + ? e.prs + .map( + pr => + `[#${pr}](https://github.com/backstage/backstage/pull/${pr})`, + ) + .join(', ') + : ''; + return `- ${e.description}${prs ? ` ${prs}` : ''}`; }) .join('\n')}`; }) diff --git a/docs-ui/src/components/ChangelogComponent/index.tsx b/docs-ui/src/components/ChangelogComponent/index.tsx index f8832b0731..ff86433268 100644 --- a/docs-ui/src/components/ChangelogComponent/index.tsx +++ b/docs-ui/src/components/ChangelogComponent/index.tsx @@ -17,14 +17,17 @@ export const ChangelogComponent = ({ component }: { component: Component }) => { ${componentChangelog ?.map(change => { 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}`; + 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')}`} /> diff --git a/docs-ui/src/utils/changelog.ts b/docs-ui/src/utils/changelog.ts index 05a06fdb9f..dafc974a51 100644 --- a/docs-ui/src/utils/changelog.ts +++ b/docs-ui/src/utils/changelog.ts @@ -1,538 +1,14 @@ -export type Component = - | 'avatar' - | 'box' - | 'button' - | 'button-link' - | 'heading' - | 'text' - | 'button-icon' - | 'icon' - | 'tabs' - | 'menu' - | 'textfield' - | 'datatable' - | 'select' - | 'collapsible' - | 'accordion' - | 'checkbox' - | 'container' - | 'link' - | 'tooltip' - | 'scrollarea' - | 'flex' - | 'switch' - | 'grid' - | 'searchfield' - | 'radio-group' - | 'card' - | 'skeleton' - | 'header' - | 'passwordfield'; +export * from './types'; +import { changelog_0_6_0 } from './changelogs/v0.6.0'; +import { changelog_0_5_0 } from './changelogs/v0.5.0'; +import { changelog_0_4_0 } from './changelogs/v0.4.0'; +import { changelog_0_3_0 } from './changelogs/v0.3.0'; +import { changelog_0_2_0 } from './changelogs/v0.2.0'; -export type Version = `${number}.${number}.${number}`; - -export interface ChangelogProps { - components: Component[]; - description: string; - version: Version; - prs: string[]; - type?: 'breaking' | 'new' | 'fix'; -} - -export const changelog: ChangelogProps[] = [ - { - components: ['tooltip'], - version: '0.6.0', - description: 'New `Tooltip` component.', - prs: ['30461'], - type: 'new', - }, - { - components: ['card'], - version: '0.6.0', - description: 'New `Card` component.', - prs: ['30467'], - type: 'new', - }, - { - components: ['header'], - version: '0.6.0', - description: 'New `Header` component.', - prs: ['30410', '30459', '30493'], - type: 'new', - }, - { - components: ['button', 'button-link', 'button-icon'], - version: '0.6.0', - description: 'Improve `Button`, `ButtonIcon` and `ButtonLink` styling.', - prs: ['30448'], - type: 'fix', - }, - { - components: ['text', 'heading'], - version: '0.6.0', - description: - 'Update return types for `Heading` & `Text` components for React 19.', - prs: ['30440'], - type: 'fix', - }, - { - components: ['button', 'button-link', 'button-icon'], - version: '0.6.0', - description: - 'New `tertiary` variant to `Button`, `ButtonIcon` and `ButtonLink`.', - prs: ['30453'], - type: 'new', - }, - { - components: ['skeleton'], - version: '0.6.0', - description: 'New `Skeleton` component.', - prs: ['30466'], - type: 'new', - }, - { - components: ['button-icon'], - version: '0.6.0', - description: 'Rename `IconButton` to `ButtonIcon`.', - prs: ['30297'], - type: 'breaking', - }, - { - components: ['button-link'], - version: '0.6.0', - description: - 'New `ButtonLink`, which replaces the previous render prop pattern on `Button` and `IconButton`.', - prs: ['30297'], - type: 'new', - }, - { - components: ['button', 'button-link', 'button-icon'], - version: '0.6.0', - description: 'Remove the `render` prop from all button-related components.', - prs: ['30297'], - type: 'breaking', - }, - { - components: [], - version: '0.6.0', - description: 'We are consolidating all css files into a single styles.css.', - prs: ['30325'], - type: 'fix', - }, - { - components: ['searchfield'], - version: '0.6.0', - description: 'New `SearchField` component.', - prs: ['30357'], - type: 'new', - }, - { - components: ['radio-group'], - version: '0.6.0', - description: 'New `RadioGroup` + `Radio` component.', - prs: ['30327'], - type: 'new', - }, - { - components: ['textfield'], - version: '0.6.0', - description: 'Added placeholder prop back to `TextField` component.', - prs: ['30286'], - type: 'fix', - }, - { - components: ['textfield'], - version: '0.5.0', - description: '`TextField` now has multiple label sizes.', - prs: ['30249'], - type: 'breaking', - }, - { - components: ['textfield'], - version: '0.5.0', - description: - '`TextField` can now hide label and description while keeping them available for screen readers.', - prs: ['30249'], - type: 'breaking', - }, - { - components: ['textfield'], - version: '0.5.0', - description: - 'We are removing the `render` prop in favour of the `as` prop on `Heading` and `Text`.', - prs: ['30291'], - type: 'breaking', - }, - { - components: ['textfield'], - version: '0.5.0', - description: 'Move `TextField` component to use react Aria under the hood.', - prs: ['30286'], - type: 'breaking', - }, - { - components: ['button', 'button-link'], - version: '0.5.0', - description: - 'Added a render prop to the `Button` component to use it as a link.', - prs: ['30165'], - type: 'new', - }, - { - components: ['heading'], - version: '0.5.0', - description: 'Fix styling for the title4 prop on the Heading component.', - prs: ['30167'], - type: 'fix', - }, - { - components: ['flex'], - version: '0.5.0', - description: - 'Add `min-width: 0;` by default on Flex components to support truncated text.', - prs: ['30168'], - type: 'fix', - }, - { - components: ['button', 'button-link', 'button-icon'], - version: '0.5.0', - description: - '`Button`, `ButtonLink`, `ButtonIcon` now default to size `small` instead of `medium`', - prs: ['30085', '30097'], - type: 'breaking', - }, - { - components: ['grid'], - version: '0.5.0', - description: 'Rename Grid component to ``', - prs: ['30013'], - type: 'breaking', - }, - { - components: ['flex', 'container', 'grid', 'box'], - version: '0.5.0', - description: 'Fixes spacing props on layout components', - prs: ['30013'], - type: 'fix', - }, - { - components: ['switch'], - version: '0.5.0', - description: 'New `Switch` component', - prs: ['30251'], - type: 'new', - }, - { - components: ['tabs'], - version: '0.4.0', - description: 'New `Tabs` component', - prs: ['29996'], - type: 'new', - }, - { - components: ['text', 'heading'], - version: '0.4.0', - description: 'Add `truncate` prop to `Text` and `Heading`', - prs: ['29988'], - type: 'new', - }, - { - components: ['menu'], - version: '0.4.0', - description: 'Add combobox option to `Menu`', - prs: ['29986'], - type: 'new', - }, - { - components: ['textfield'], - version: '0.4.0', - description: 'Add icon prop on `TextField`', - prs: ['29820'], - type: 'new', - }, - { - components: ['button', 'button-icon'], - version: '0.4.0', - description: 'Improve icon props on `Button` and `IconButton`', - prs: ['29667'], - }, - { - components: ['text', 'heading'], - version: '0.4.0', - description: - 'Improve the way we treat custom render on `Text` and `Heading`', - prs: ['29989'], - }, - { - components: ['menu'], - version: '0.4.0', - description: 'Improve `Menu` styles', - prs: ['29986'], - }, - { - components: ['textfield'], - version: '0.4.0', - description: 'Improve `TextField` styles', - prs: ['29974'], - }, - { - components: ['textfield'], - version: '0.4.0', - description: 'Improve clear button on `TextField`', - prs: ['29878'], - }, - { - components: ['flex'], - version: '0.4.0', - description: 'Fix spacing props on all layout components', - prs: ['30013'], - }, - { - components: [], - version: '0.4.0', - description: 'Fix - Pin Base UI version', - prs: ['29782'], - }, - { - components: ['select'], - version: '0.4.0', - description: 'Fix - Clicking `Select` label moves focus to trigger', - prs: ['29755'], - }, - { - components: ['datatable'], - version: '0.4.0', - description: 'Fix `DataTable.Pagination` count issue', - prs: ['29688'], - }, - { - components: ['datatable'], - version: '0.3.0', - description: 'Add `DataTable` component', - prs: ['29484', '29603'], - type: 'new', - }, - { - components: ['select'], - version: '0.3.0', - description: 'Add `Select` component', - prs: ['29440'], - type: 'new', - }, - { - components: ['avatar'], - version: '0.3.0', - description: 'Add `Avatar` component', - prs: ['29594'], - type: 'new', - }, - { - components: ['collapsible'], - version: '0.3.0', - description: 'Add `Collapsible` component', - prs: ['29617'], - type: 'new', - }, - { - components: ['textfield'], - version: '0.3.0', - description: 'Add `TextField` component instead of `Field` + `Input`', - prs: ['29364'], - type: 'new', - }, - { - components: ['datatable'], - version: '0.3.0', - description: 'Add `TableCellProfile`', - prs: ['29600'], - type: 'new', - }, - { - components: [], - version: '0.3.0', - description: 'Add breakpoint hooks - `up()` and `down()`', - prs: ['29564'], - type: 'new', - }, - { - components: [], - version: '0.3.0', - description: 'Add gray scale css tokens', - prs: ['29543'], - type: 'new', - }, - { - components: [], - version: '0.3.0', - description: - 'Update CSS styling API using `[data-___]` instead of class names for props', - prs: ['29560'], - }, - { - components: ['checkbox'], - version: '0.3.0', - description: 'Update `Checkbox` dark mode', - prs: ['29544'], - }, - { - components: ['container'], - version: '0.3.0', - description: 'Update `Container` styles', - prs: ['29475'], - }, - { - components: ['menu'], - version: '0.3.0', - description: 'Update `Menu` styles', - prs: ['29351'], - }, - { - components: ['select'], - version: '0.3.0', - description: 'Fix `Select` styles on small sizes + with long option names', - prs: ['29545'], - }, - { - components: ['link'], - version: '0.3.0', - description: 'Fix render prop on `Link`', - prs: ['29247'], - }, - { - components: ['textfield', 'select'], - version: '0.3.0', - description: 'Remove `Field` from `TextField` + `Select`', - prs: ['29482'], - }, - { - components: ['text', 'heading'], - version: '0.3.0', - description: 'Update `textDecoration` to `none` on `Text` / `Heading`', - prs: ['29357'], - }, - { - components: ['text', 'heading'], - version: '0.3.0', - description: 'Update `textDecoration` to `none` on `Text` / `Heading`', - prs: ['29357'], - }, - { - components: [], - version: '0.3.0', - description: 'Docs - Use stories from Storybook for all examples in Nextjs', - prs: ['29306'], - }, - { - components: [], - version: '0.3.0', - description: 'Docs - Add release page (this one šŸ¤—)', - prs: ['29461'], - }, - { - components: [], - version: '0.3.0', - description: 'Docs - Add docs for Menu, Link', - prs: ['29576'], - }, - { - components: [], - version: '0.3.0', - description: 'Fix CSS watch mode', - prs: ['29352'], - }, - { - components: ['menu'], - version: '0.2.0', - description: 'New `Menu` component', - prs: ['29151'], - type: 'new', - }, - { - components: ['button-icon'], - version: '0.2.0', - description: 'New `IconButton` component', - prs: ['29239'], - type: 'new', - }, - { - components: ['scrollarea'], - version: '0.2.0', - description: 'New `ScrollArea` component', - prs: ['29240'], - type: 'new', - }, - { - components: ['button', 'checkbox'], - version: '0.2.0', - description: 'Improve `Button` & `Checkbox` styles', - prs: ['29127', '28789'], - }, - { - components: ['text'], - version: '0.2.0', - description: 'Improve `Text` styles', - prs: ['29200'], - }, - { - components: ['icon'], - version: '0.2.0', - description: 'Renamed `CanonProvider` to `IconProvider`', - prs: ['29002'], - }, - { - components: ['icon'], - version: '0.2.0', - description: 'Added about 40+ new icons', - prs: ['29264'], - }, - { - components: [], - version: '0.2.0', - description: 'Simplified styling into a unique styles.css file', - prs: ['29199'], - }, - { - components: [], - version: '0.2.0', - description: 'Added global styles to Backstage', - prs: ['29137'], - type: 'new', - }, - { - components: [], - version: '0.2.0', - description: 'Update global CSS tokens', - prs: ['28804'], - }, - { - components: ['flex'], - version: '0.2.0', - description: 'Merge Stack + Inline into Flex', - prs: ['28634'], - }, - { - components: ['button'], - version: '0.2.0', - description: 'Improve `Button` types', - prs: ['29205'], - }, - { - components: [], - version: '0.2.0', - description: 'Move font weight and family back to each components', - prs: ['28972'], - }, - { - components: [], - version: '0.2.0', - description: 'Fix custom values in spacing props', - prs: ['28770'], - }, - { - components: [], - version: '0.2.0', - description: 'Multiple updates on the docs site', - prs: ['28760'], - }, +export const changelog = [ + ...changelog_0_6_0, + ...changelog_0_5_0, + ...changelog_0_4_0, + ...changelog_0_3_0, + ...changelog_0_2_0, ]; diff --git a/docs-ui/src/utils/changelogs/v0.2.0.ts b/docs-ui/src/utils/changelogs/v0.2.0.ts new file mode 100644 index 0000000000..b90cec0309 --- /dev/null +++ b/docs-ui/src/utils/changelogs/v0.2.0.ts @@ -0,0 +1,98 @@ +import type { ChangelogProps } from '../types'; + +export const changelog_0_2_0: ChangelogProps[] = [ + { + components: ['menu'], + version: '0.2.0', + description: 'New `Menu` component', + prs: ['29151'], + type: 'new', + }, + { + components: ['button-icon'], + version: '0.2.0', + description: 'New `IconButton` component', + prs: ['29239'], + type: 'new', + }, + { + components: ['scrollarea'], + version: '0.2.0', + description: 'New `ScrollArea` component', + prs: ['29240'], + type: 'new', + }, + { + components: ['button', 'checkbox'], + version: '0.2.0', + description: 'Improve `Button` & `Checkbox` styles', + prs: ['29127', '28789'], + }, + { + components: ['text'], + version: '0.2.0', + description: 'Improve `Text` styles', + prs: ['29200'], + }, + { + components: ['icon'], + version: '0.2.0', + description: 'Renamed `CanonProvider` to `IconProvider`', + prs: ['29002'], + }, + { + components: ['icon'], + version: '0.2.0', + description: 'Added about 40+ new icons', + prs: ['29264'], + }, + { + components: [], + version: '0.2.0', + description: 'Simplified styling into a unique styles.css file', + prs: ['29199'], + }, + { + components: [], + version: '0.2.0', + description: 'Added global styles to Backstage', + prs: ['29137'], + type: 'new', + }, + { + components: [], + version: '0.2.0', + description: 'Update global CSS tokens', + prs: ['28804'], + }, + { + components: ['flex'], + version: '0.2.0', + description: 'Merge Stack + Inline into Flex', + prs: ['28634'], + }, + { + components: ['button'], + version: '0.2.0', + description: 'Improve `Button` types', + prs: ['29205'], + }, + { + components: [], + version: '0.2.0', + description: 'Move font weight and family back to each components', + prs: ['28972'], + }, + { + components: [], + version: '0.2.0', + description: 'Fix custom values in spacing props', + prs: ['28770'], + }, + { + components: [], + version: '0.2.0', + description: 'Multiple updates on the docs site', + prs: ['28760'], + }, +]; diff --git a/docs-ui/src/utils/changelogs/v0.3.0.ts b/docs-ui/src/utils/changelogs/v0.3.0.ts new file mode 100644 index 0000000000..750ad292cf --- /dev/null +++ b/docs-ui/src/utils/changelogs/v0.3.0.ts @@ -0,0 +1,139 @@ +import type { ChangelogProps } from '../types'; + +export const changelog_0_3_0: ChangelogProps[] = [ + { + components: ['datatable'], + version: '0.3.0', + description: 'Add `DataTable` component', + prs: ['29484', '29603'], + type: 'new', + }, + { + components: ['select'], + version: '0.3.0', + description: 'Add `Select` component', + prs: ['29440'], + type: 'new', + }, + { + components: ['avatar'], + version: '0.3.0', + description: 'Add `Avatar` component', + prs: ['29594'], + type: 'new', + }, + { + components: ['collapsible'], + version: '0.3.0', + description: 'Add `Collapsible` component', + prs: ['29617'], + type: 'new', + }, + { + components: ['textfield'], + version: '0.3.0', + description: 'Add `TextField` component instead of `Field` + `Input`', + prs: ['29364'], + type: 'new', + }, + { + components: ['datatable'], + version: '0.3.0', + description: 'Add `TableCellProfile`', + prs: ['29600'], + type: 'new', + }, + { + components: [], + version: '0.3.0', + description: 'Add breakpoint hooks - `up()` and `down()`', + prs: ['29564'], + type: 'new', + }, + { + components: [], + version: '0.3.0', + description: 'Add gray scale css tokens', + prs: ['29543'], + type: 'new', + }, + { + components: [], + version: '0.3.0', + description: + 'Update CSS styling API using `[data-___]` instead of class names for props', + prs: ['29560'], + }, + { + components: ['checkbox'], + version: '0.3.0', + description: 'Update `Checkbox` dark mode', + prs: ['29544'], + }, + { + components: ['container'], + version: '0.3.0', + description: 'Update `Container` styles', + prs: ['29475'], + }, + { + components: ['menu'], + version: '0.3.0', + description: 'Update `Menu` styles', + prs: ['29351'], + }, + { + components: ['select'], + version: '0.3.0', + description: 'Fix `Select` styles on small sizes + with long option names', + prs: ['29545'], + }, + { + components: ['link'], + version: '0.3.0', + description: 'Fix render prop on `Link`', + prs: ['29247'], + }, + { + components: ['textfield', 'select'], + version: '0.3.0', + description: 'Remove `Field` from `TextField` + `Select`', + prs: ['29482'], + }, + { + components: ['text', 'heading'], + version: '0.3.0', + description: 'Update `textDecoration` to `none` on `Text` / `Heading`', + prs: ['29357'], + }, + { + components: ['text', 'heading'], + version: '0.3.0', + description: 'Update `textDecoration` to `none` on `Text` / `Heading`', + prs: ['29357'], + }, + { + components: [], + version: '0.3.0', + description: 'Docs - Use stories from Storybook for all examples in Nextjs', + prs: ['29306'], + }, + { + components: [], + version: '0.3.0', + description: 'Docs - Add release page (this one šŸ¤—)', + prs: ['29461'], + }, + { + components: [], + version: '0.3.0', + description: 'Docs - Add docs for Menu, Link', + prs: ['29576'], + }, + { + components: [], + version: '0.3.0', + description: 'Fix CSS watch mode', + prs: ['29352'], + }, +]; diff --git a/docs-ui/src/utils/changelogs/v0.4.0.ts b/docs-ui/src/utils/changelogs/v0.4.0.ts new file mode 100644 index 0000000000..91cd641f4d --- /dev/null +++ b/docs-ui/src/utils/changelogs/v0.4.0.ts @@ -0,0 +1,87 @@ +import type { ChangelogProps } from '../types'; + +export const changelog_0_4_0: ChangelogProps[] = [ + { + components: ['tabs'], + version: '0.4.0', + description: 'New `Tabs` component', + prs: ['29996'], + type: 'new', + }, + { + components: ['text', 'heading'], + version: '0.4.0', + description: 'Add `truncate` prop to `Text` and `Heading`', + prs: ['29988'], + type: 'new', + }, + { + components: ['menu'], + version: '0.4.0', + description: 'Add combobox option to `Menu`', + prs: ['29986'], + type: 'new', + }, + { + components: ['textfield'], + version: '0.4.0', + description: 'Add icon prop on `TextField`', + prs: ['29820'], + type: 'new', + }, + { + components: ['button', 'button-icon'], + version: '0.4.0', + description: 'Improve icon props on `Button` and `IconButton`', + prs: ['29667'], + }, + { + components: ['text', 'heading'], + version: '0.4.0', + description: + 'Improve the way we treat custom render on `Text` and `Heading`', + prs: ['29989'], + }, + { + components: ['menu'], + version: '0.4.0', + description: 'Improve `Menu` styles', + prs: ['29986'], + }, + { + components: ['textfield'], + version: '0.4.0', + description: 'Improve `TextField` styles', + prs: ['29974'], + }, + { + components: ['textfield'], + version: '0.4.0', + description: 'Improve clear button on `TextField`', + prs: ['29878'], + }, + { + components: ['flex'], + version: '0.4.0', + description: 'Fix spacing props on all layout components', + prs: ['30013'], + }, + { + components: [], + version: '0.4.0', + description: 'Fix - Pin Base UI version', + prs: ['29782'], + }, + { + components: ['select'], + version: '0.4.0', + description: 'Fix - Clicking `Select` label moves focus to trigger', + prs: ['29755'], + }, + { + components: ['datatable'], + version: '0.4.0', + description: 'Fix `DataTable.Pagination` count issue', + prs: ['29688'], + }, +]; diff --git a/docs-ui/src/utils/changelogs/v0.5.0.ts b/docs-ui/src/utils/changelogs/v0.5.0.ts new file mode 100644 index 0000000000..a48cb96dcc --- /dev/null +++ b/docs-ui/src/utils/changelogs/v0.5.0.ts @@ -0,0 +1,86 @@ +import type { ChangelogProps } from '../types'; + +export const changelog_0_5_0: ChangelogProps[] = [ + { + components: ['textfield'], + version: '0.5.0', + description: '`TextField` now has multiple label sizes.', + prs: ['30249'], + type: 'breaking', + }, + { + components: ['textfield'], + version: '0.5.0', + description: + '`TextField` can now hide label and description while keeping them available for screen readers.', + prs: ['30249'], + type: 'breaking', + }, + { + components: ['textfield'], + version: '0.5.0', + description: + 'We are removing the `render` prop in favour of the `as` prop on `Heading` and `Text`.', + prs: ['30291'], + type: 'breaking', + }, + { + components: ['textfield'], + version: '0.5.0', + description: 'Move `TextField` component to use react Aria under the hood.', + prs: ['30286'], + type: 'breaking', + }, + { + components: ['button', 'button-link'], + version: '0.5.0', + description: + 'Added a render prop to the `Button` component to use it as a link.', + prs: ['30165'], + type: 'new', + }, + { + components: ['heading'], + version: '0.5.0', + description: 'Fix styling for the title4 prop on the Heading component.', + prs: ['30167'], + type: 'fix', + }, + { + components: ['flex'], + version: '0.5.0', + description: + 'Add `min-width: 0;` by default on Flex components to support truncated text.', + prs: ['30168'], + type: 'fix', + }, + { + components: ['button', 'button-link', 'button-icon'], + version: '0.5.0', + description: + '`Button`, `ButtonLink`, `ButtonIcon` now default to size `small` instead of `medium`', + prs: ['30085', '30097'], + type: 'breaking', + }, + { + components: ['grid'], + version: '0.5.0', + description: 'Rename Grid component to ``', + prs: ['30013'], + type: 'breaking', + }, + { + components: ['flex', 'container', 'grid', 'box'], + version: '0.5.0', + description: 'Fixes spacing props on layout components', + prs: ['30013'], + type: 'fix', + }, + { + components: ['switch'], + version: '0.5.0', + description: 'New `Switch` component', + prs: ['30251'], + type: 'new', + }, +]; diff --git a/docs-ui/src/utils/changelogs/v0.6.0.ts b/docs-ui/src/utils/changelogs/v0.6.0.ts new file mode 100644 index 0000000000..d892f2a78d --- /dev/null +++ b/docs-ui/src/utils/changelogs/v0.6.0.ts @@ -0,0 +1,105 @@ +import type { ChangelogProps } from '../types'; + +export const changelog_0_6_0: ChangelogProps[] = [ + { + components: ['tooltip'], + version: '0.6.0', + description: 'New `Tooltip` component.', + prs: ['30461'], + type: 'new', + }, + { + components: ['card'], + version: '0.6.0', + description: 'New `Card` component.', + prs: ['30467'], + type: 'new', + }, + { + components: ['header'], + version: '0.6.0', + description: 'New `Header` component.', + prs: ['30410', '30459', '30493'], + type: 'new', + }, + { + components: ['button', 'button-link', 'button-icon'], + version: '0.6.0', + description: 'Improve `Button`, `ButtonIcon` and `ButtonLink` styling.', + prs: ['30448'], + type: 'fix', + }, + { + components: ['text', 'heading'], + version: '0.6.0', + description: + 'Update return types for `Heading` & `Text` components for React 19.', + prs: ['30440'], + type: 'fix', + }, + { + components: ['button', 'button-link', 'button-icon'], + version: '0.6.0', + description: + 'New `tertiary` variant to `Button`, `ButtonIcon` and `ButtonLink`.', + prs: ['30453'], + type: 'new', + }, + { + components: ['skeleton'], + version: '0.6.0', + description: 'New `Skeleton` component.', + prs: ['30466'], + type: 'new', + }, + { + components: ['button-icon'], + version: '0.6.0', + description: 'Rename `IconButton` to `ButtonIcon`.', + prs: ['30297'], + type: 'breaking', + }, + { + components: ['button-link'], + version: '0.6.0', + description: + 'New `ButtonLink`, which replaces the previous render prop pattern on `Button` and `IconButton`.', + prs: ['30297'], + type: 'new', + }, + { + components: ['button', 'button-link', 'button-icon'], + version: '0.6.0', + description: 'Remove the `render` prop from all button-related components.', + prs: ['30297'], + type: 'breaking', + }, + { + components: [], + version: '0.6.0', + description: 'We are consolidating all css files into a single styles.css.', + prs: ['30325'], + type: 'fix', + }, + { + components: ['searchfield'], + version: '0.6.0', + description: 'New `SearchField` component.', + prs: ['30357'], + type: 'new', + }, + { + components: ['radio-group'], + version: '0.6.0', + description: 'New `RadioGroup` + `Radio` component.', + prs: ['30327'], + type: 'new', + }, + { + components: ['textfield'], + version: '0.6.0', + description: 'Added placeholder prop back to `TextField` component.', + prs: ['30286'], + type: 'fix', + }, +]; diff --git a/docs-ui/src/utils/types.ts b/docs-ui/src/utils/types.ts new file mode 100644 index 0000000000..f2f773ebc2 --- /dev/null +++ b/docs-ui/src/utils/types.ts @@ -0,0 +1,46 @@ +export type Component = + | 'avatar' + | 'box' + | 'button' + | 'button-link' + | 'heading' + | 'text' + | 'button-icon' + | 'icon' + | 'tabs' + | 'menu' + | 'textfield' + | 'datatable' + | 'select' + | 'collapsible' + | 'accordion' + | 'checkbox' + | 'container' + | 'link' + | 'tooltip' + | 'scrollarea' + | 'flex' + | 'switch' + | 'grid' + | 'searchfield' + | 'radio-group' + | 'card' + | 'skeleton' + | 'header' + | 'header-page' + | 'password-field' + | 'table' + | 'visually-hidden' + | 'dialog' + | 'tag-group'; + +export type Version = `${number}.${number}.${number}`; + +export interface ChangelogProps { + components: Component[]; + description: string; + version: Version; + prs: string[]; + type?: 'breaking' | 'new' | 'fix'; + commitSha?: string; +} diff --git a/docs-ui/yarn.lock b/docs-ui/yarn.lock index 480f7592f8..da7cc2b620 100644 --- a/docs-ui/yarn.lock +++ b/docs-ui/yarn.lock @@ -999,6 +999,130 @@ __metadata: languageName: node linkType: hard +"@octokit/auth-token@npm:^6.0.0": + version: 6.0.0 + resolution: "@octokit/auth-token@npm:6.0.0" + checksum: 10/a30f5c4c984964b57193de5b6f67169f74e4779fedbe716157dd3558dd9de3ca5c105cae521b7bd8ce1ae180773a2ef01afe2306ad5a329f4fd291eba2b7c7d1 + languageName: node + linkType: hard + +"@octokit/core@npm:^7.0.6": + version: 7.0.6 + resolution: "@octokit/core@npm:7.0.6" + dependencies: + "@octokit/auth-token": "npm:^6.0.0" + "@octokit/graphql": "npm:^9.0.3" + "@octokit/request": "npm:^10.0.6" + "@octokit/request-error": "npm:^7.0.2" + "@octokit/types": "npm:^16.0.0" + before-after-hook: "npm:^4.0.0" + universal-user-agent: "npm:^7.0.0" + checksum: 10/852d41fc3150d2a891156427dd0575c77889f1c7a109894ee541594e3fd47c0d4e0a93fee22966c507dfd6158b522e42846c2ac46b9d896078194c95fa81f4ae + languageName: node + linkType: hard + +"@octokit/endpoint@npm:^11.0.2": + version: 11.0.2 + resolution: "@octokit/endpoint@npm:11.0.2" + dependencies: + "@octokit/types": "npm:^16.0.0" + universal-user-agent: "npm:^7.0.2" + checksum: 10/0d088747baf94eafbba69da23ba840b40cd3f5d0bfbc51c692ff9d9d78de6d81f06366e6e30df8c1783355be826c27d38ab9ab0708396af8f430b06cfa29db35 + languageName: node + linkType: hard + +"@octokit/graphql@npm:^9.0.3": + version: 9.0.3 + resolution: "@octokit/graphql@npm:9.0.3" + dependencies: + "@octokit/request": "npm:^10.0.6" + "@octokit/types": "npm:^16.0.0" + universal-user-agent: "npm:^7.0.0" + checksum: 10/7b16f281f8571dce55280b3986fbb8d15465a7236164a5f6497ded7597ff9ee95d5796924555b979903fe8c6706fe6be1b3e140d807297f85ac8edeadc28f9fe + languageName: node + linkType: hard + +"@octokit/openapi-types@npm:^27.0.0": + version: 27.0.0 + resolution: "@octokit/openapi-types@npm:27.0.0" + checksum: 10/5cd2cdf4e41fdf522e15e3d53f3ece8380d98dda9173a6fc905828fb2c33e8733d5f5d2a757ae3a572525f4749748e66cb40e7939372132988d8eb4ba978d54f + languageName: node + linkType: hard + +"@octokit/plugin-paginate-rest@npm:^14.0.0": + version: 14.0.0 + resolution: "@octokit/plugin-paginate-rest@npm:14.0.0" + dependencies: + "@octokit/types": "npm:^16.0.0" + peerDependencies: + "@octokit/core": ">=6" + checksum: 10/57ddd857528dad9c02431bc6254c2374c06057872cf9656a4a88b162ebe1c2bc9f34fbec360f2ccff72c940f29b120758ce14e8135bd027223d381eb1b8b6579 + languageName: node + linkType: hard + +"@octokit/plugin-request-log@npm:^6.0.0": + version: 6.0.0 + resolution: "@octokit/plugin-request-log@npm:6.0.0" + peerDependencies: + "@octokit/core": ">=6" + checksum: 10/8a79973b1429bfead9113c4117f418aaef5ff368795daded3415ba14623d97d5fc08d1e822dbd566ecc9f041119e1a48a11853a9c48d9eb1caa62baa79c17f83 + languageName: node + linkType: hard + +"@octokit/plugin-rest-endpoint-methods@npm:^17.0.0": + version: 17.0.0 + resolution: "@octokit/plugin-rest-endpoint-methods@npm:17.0.0" + dependencies: + "@octokit/types": "npm:^16.0.0" + peerDependencies: + "@octokit/core": ">=6" + checksum: 10/e9d9ad4d9755cc7fb82fdcbfa870ddea8a432180f0f76c8469095557fd1e26f8caea8cae58401209be17c4f3d8cc48c0e16a3643e37e48f4d23c39e058bf2c55 + languageName: node + linkType: hard + +"@octokit/request-error@npm:^7.0.2": + version: 7.0.2 + resolution: "@octokit/request-error@npm:7.0.2" + dependencies: + "@octokit/types": "npm:^16.0.0" + checksum: 10/8edfaca9f5271115b090f470ebfe1006841ee152dd52cb39786e026236e2c13545aa3cf3187b7b1de717167696a42f91d8fa2f870bf9be0832fb2b11d11a741d + languageName: node + linkType: hard + +"@octokit/request@npm:^10.0.6": + version: 10.0.6 + resolution: "@octokit/request@npm:10.0.6" + dependencies: + "@octokit/endpoint": "npm:^11.0.2" + "@octokit/request-error": "npm:^7.0.2" + "@octokit/types": "npm:^16.0.0" + fast-content-type-parse: "npm:^3.0.0" + universal-user-agent: "npm:^7.0.2" + checksum: 10/b8c5cd43d3225c8c3b803e889ff251808ae180c953ca92642522843123dafcd84d919b20ca2969b6e4080ee3c2197a99afda15b5649ee51d0715f8249a5c9ca4 + languageName: node + linkType: hard + +"@octokit/rest@npm:^22.0.1": + version: 22.0.1 + resolution: "@octokit/rest@npm:22.0.1" + dependencies: + "@octokit/core": "npm:^7.0.6" + "@octokit/plugin-paginate-rest": "npm:^14.0.0" + "@octokit/plugin-request-log": "npm:^6.0.0" + "@octokit/plugin-rest-endpoint-methods": "npm:^17.0.0" + checksum: 10/ec2e94cfa8766716faeb3ca18527d9af746482d35aaa6e4265a30cb669ae3f31f4ebb6235edebe5ae62bc2cec2b8e88902584f698df2e7cabac3a15fd27da665 + languageName: node + linkType: hard + +"@octokit/types@npm:^16.0.0": + version: 16.0.0 + resolution: "@octokit/types@npm:16.0.0" + dependencies: + "@octokit/openapi-types": "npm:^27.0.0" + checksum: 10/03d5cfc29556a9b53eae8beb1bf15c0b704dc722db2c51b53f093f3c3ee6c1d8e20b682be8117a3a17034b458be7746d1b22aaefb959ceb5152ad7589b39e2c9 + languageName: node + linkType: hard + "@pkgjs/parseargs@npm:^0.11.0": version: 0.11.0 resolution: "@pkgjs/parseargs@npm:0.11.0" @@ -1946,6 +2070,13 @@ __metadata: languageName: node linkType: hard +"before-after-hook@npm:^4.0.0": + version: 4.0.0 + resolution: "before-after-hook@npm:4.0.0" + checksum: 10/9fd52bc0c3cca0fb115e04dacbeeaacff38fa23e1af725d62392254c31ef433b15da60efcba61552e44d64e26f25ea259f72dba005115924389e88d2fd56e19f + languageName: node + linkType: hard + "better-opn@npm:^3.0.2": version: 3.0.2 resolution: "better-opn@npm:3.0.2" @@ -2385,6 +2516,7 @@ __metadata: "@mdx-js/loader": "npm:^3.1.0" "@mdx-js/react": "npm:^3.1.0" "@next/mdx": "npm:15.3.4" + "@octokit/rest": "npm:^22.0.1" "@remixicon/react": "npm:^4.6.0" "@shikijs/transformers": "npm:^3.13.0" "@storybook/react": "npm:^8.6.12" @@ -2409,6 +2541,7 @@ __metadata: shiki: "npm:^3.13.0" storybook: "npm:^8.6.12" typescript: "npm:^5" + unified: "npm:^11.0.4" languageName: unknown linkType: soft @@ -3178,6 +3311,13 @@ __metadata: languageName: node linkType: hard +"fast-content-type-parse@npm:^3.0.0": + version: 3.0.0 + resolution: "fast-content-type-parse@npm:3.0.0" + checksum: 10/8616a8aa6c9b4f8f4f3c90eaa4e7bfc2240cfa6f41f0eef5b5aa2b2c8b38bd9ad435f1488b6d817ffd725c54651e2777b882ae9dd59366e71e7896f1ec11d473 + languageName: node + linkType: hard + "fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3": version: 3.1.3 resolution: "fast-deep-equal@npm:3.1.3" @@ -6763,7 +6903,7 @@ __metadata: languageName: node linkType: hard -"unified@npm:^11.0.0": +"unified@npm:^11.0.0, unified@npm:^11.0.4": version: 11.0.5 resolution: "unified@npm:11.0.5" dependencies: @@ -6864,6 +7004,13 @@ __metadata: languageName: node linkType: hard +"universal-user-agent@npm:^7.0.0, universal-user-agent@npm:^7.0.2": + version: 7.0.3 + resolution: "universal-user-agent@npm:7.0.3" + checksum: 10/c497e85f8b11eb8fa4dce584d7a39cc98710164959f494cafc3c269b51abb20fff269951838efd7424d15f6b3d001507f3cb8b52bb5676fdb642019dfd17e63e + languageName: node + linkType: hard + "unrs-resolver@npm:^1.6.2": version: 1.11.1 resolution: "unrs-resolver@npm:1.11.1" diff --git a/packages/ui/README.md b/packages/ui/README.md index 1a0fb84a0c..271b78fca4 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -16,3 +16,26 @@ yarn add @backstage/ui - [Backstage UI Documentation](https://ui.backstage.io) - [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md) - [Backstage Documentation](https://backstage.io/docs) + +## Writing Changesets for Components + +When creating changesets for component-specific changes, add component metadata to help maintain documentation: + +```markdown +--- +'@backstage/ui': patch +--- + +Fixed size prop handling for Avatar component. + +Affected components: Avatar +``` + +**Guidelines:** + +- **Component names**: Use PascalCase as they appear in imports (Avatar, ButtonIcon, SearchField) +- **Multiple components**: `Affected components: Button, ButtonLink, ButtonIcon` +- **General changes**: Omit the metadata line (build changes, package-level updates) +- **Placement**: The line can appear anywhere in the description + +The changelog sync tool will parse these tags and update the documentation site automatically.