diff --git a/.circleci/config.yml b/.circleci/config.yml index e964ae2facc..c683c1d8934 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -447,7 +447,7 @@ jobs: command: | yarn test:parcel mkdir -p dist - yarn compare:apis --isCI --branch-api-dir="/tmp/dist/branch-api" --base-api-dir="/tmp/dist/base-api" | tee dist/ts-diff.txt + yarn compare:apis --isCI --branch-api-dir="/tmp/dist/branch-api" | tee dist/ts-diff.txt - persist_to_workspace: root: dist diff --git a/package.json b/package.json index f8771242416..53e611d3e00 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "publish:nightly": "yarn workspaces foreach --all --no-private -t npm publish --tag nightly --access public", "build:api-published": "node scripts/buildPublishedAPI.js", "build:api-branch": "node scripts/buildBranchAPI.js", - "compare:apis": "node scripts/compareAPIs.js", + "compare:apis": "node scripts/compareAPIs.js --compare-v3-s2", "check-apis": "yarn build:api-branch --githash=\"origin/main\" --output=\"base-api\" && yarn build:api-branch && yarn compare:apis", "check-published-apis": "yarn build:api-published && yarn build:api-branch && yarn compare:apis", "bumpVersions": "node scripts/bumpVersions.js", diff --git a/scripts/compareAPIs.js b/scripts/compareAPIs.js index 84ca3d11ce4..5f7b45f4c89 100644 --- a/scripts/compareAPIs.js +++ b/scripts/compareAPIs.js @@ -29,8 +29,8 @@ const args = parseArgs({ 'branch-api-dir': { type: 'string' }, - 'base-api-dir': { - type: 'string' + 'compare-v3-s2': { + type: 'boolean' } } }); @@ -42,6 +42,92 @@ let dependantOnLinks = new Map(); let currentlyProcessing = ''; let depth = 0; +// Style props from @react-spectrum/utils/src/styleProps.ts that should be excluded from diffs +// These are baseStyleProps and viewStyleProps +let stylePropsToExclude = new Set([ + // baseStyleProps + 'margin', 'marginStart', 'marginEnd', 'marginTop', 'marginBottom', 'marginX', 'marginY', + 'width', 'height', 'minWidth', 'minHeight', 'maxWidth', 'maxHeight', + 'isHidden', 'alignSelf', 'justifySelf', + 'position', 'zIndex', 'top', 'bottom', 'start', 'end', 'left', 'right', + 'order', 'flex', 'flexGrow', 'flexShrink', 'flexBasis', + 'gridArea', 'gridColumn', 'gridColumnEnd', 'gridColumnStart', 'gridRow', 'gridRowEnd', 'gridRowStart', + // viewStyleProps (additional to baseStyleProps) + 'backgroundColor', + 'borderWidth', 'borderStartWidth', 'borderEndWidth', 'borderLeftWidth', 'borderRightWidth', + 'borderTopWidth', 'borderBottomWidth', 'borderXWidth', 'borderYWidth', + 'borderColor', 'borderStartColor', 'borderEndColor', 'borderLeftColor', 'borderRightColor', + 'borderTopColor', 'borderBottomColor', 'borderXColor', 'borderYColor', + 'borderRadius', 'borderTopStartRadius', 'borderTopEndRadius', 'borderBottomStartRadius', + 'borderBottomEndRadius', 'borderTopLeftRadius', 'borderTopRightRadius', + 'borderBottomLeftRadius', 'borderBottomRightRadius', + 'padding', 'paddingStart', 'paddingEnd', 'paddingLeft', 'paddingRight', + 'paddingTop', 'paddingBottom', 'paddingX', 'paddingY', + 'overflow' +]); + +// Mapping of S2 component names to their v3 package locations +// Format: 'S2ComponentName': {package: '@react-spectrum/packagename', component: 'ComponentName'} +let s2ToV3ComponentMap = { + 'ActionButton': {package: '@react-spectrum/button', component: 'ActionButton'}, + 'ActionButtonGroup': {package: '@react-spectrum/actiongroup', component: 'ActionGroup'}, + 'ActionMenu': {package: '@react-spectrum/menu', component: 'ActionMenu'}, + 'AlertDialog': {package: '@react-spectrum/dialog', component: 'AlertDialog'}, + 'Breadcrumb': {package: '@react-stately/collections', component: 'Item'}, + 'CheckboxGroup': {package: '@react-spectrum/checkbox', component: 'CheckboxGroup'}, + 'ColorArea': {package: '@react-spectrum/color', component: 'ColorArea'}, + 'ColorField': {package: '@react-spectrum/color', component: 'ColorField'}, + 'ColorSlider': {package: '@react-spectrum/color', component: 'ColorSlider'}, + 'ColorWheel': {package: '@react-spectrum/color', component: 'ColorWheel'}, + 'Column': {package: '@react-spectrum/table', component: 'Column'}, + 'ComboBoxItem': {package: '@react-stately/collections', component: 'Item'}, + 'ComboBoxSection': {package: '@react-stately/collections', component: 'Section'}, + 'Content': {package: '@react-spectrum/view', component: 'Content'}, + 'DateField': {package: '@react-spectrum/datepicker', component: 'DateField'}, + 'DateRangePicker': {package: '@react-spectrum/datepicker', component: 'DateRangePicker'}, + 'DialogContainer': {package: '@react-spectrum/dialog', component: 'DialogContainer'}, + 'DialogTrigger': {package: '@react-spectrum/dialog', component: 'DialogTrigger'}, + 'Disclosure': {package: '@react-spectrum/accordion', component: 'Disclosure'}, + 'DisclosureHeader': {package: '@react-spectrum/accordion', component: 'DisclosureTitle'}, + 'DisclosurePanel': {package: '@react-spectrum/accordion', component: 'DisclosurePanel'}, + 'DisclosureTitle': {package: '@react-spectrum/accordion', component: 'DisclosureTitle'}, + 'AccordionItem': {package: '@react-spectrum/accordion', component: 'Disclosure'}, + 'AccordionItemTitle': {package: '@react-spectrum/accordion', component: 'DisclosureTitle'}, + 'AccordionItemPanel': {package: '@react-spectrum/accordion', component: 'DisclosurePanel'}, + 'AccordionItemHeader': {package: '@react-spectrum/accordion', component: 'DisclosureTitle'}, + 'Footer': {package: '@react-spectrum/view', component: 'Footer'}, + 'Header': {package: '@react-spectrum/view', component: 'Header'}, + 'Heading': {package: '@react-spectrum/text', component: 'Heading'}, + 'Keyboard': {package: '@react-spectrum/text', component: 'Keyboard'}, + 'MenuItem': {package: '@react-stately/collections', component: 'Item'}, + 'MenuSection': {package: '@react-stately/collections', component: 'Section'}, + 'MenuTrigger': {package: '@react-spectrum/menu', component: 'MenuTrigger'}, + 'PickerItem': {package: '@react-stately/collections', component: 'Item'}, + 'PickerSection': {package: '@react-stately/collections', component: 'Section'}, + 'ProgressBar': {package: '@react-spectrum/progress', component: 'ProgressBar'}, + 'ProgressCircle': {package: '@react-spectrum/progress', component: 'ProgressCircle'}, + 'RadioGroup': {package: '@react-spectrum/radio', component: 'RadioGroup'}, + 'RangeCalendar': {package: '@react-spectrum/calendar', component: 'RangeCalendar'}, + 'RangeSlider': {package: '@react-spectrum/slider', component: 'RangeSlider'}, + 'Row': {package: '@react-spectrum/table', component: 'Row'}, + 'Tab': {package: '@react-spectrum/tabs', component: 'Item'}, + 'TabList': {package: '@react-spectrum/tabs', component: 'TabList'}, + 'TabPanel': {package: '@react-spectrum/tabs', component: 'Item'}, + 'TableBody': {package: '@react-spectrum/table', component: 'TableBody'}, + 'TableHeader': {package: '@react-spectrum/table', component: 'TableHeader'}, + 'TableView': {package: '@react-spectrum/table', component: 'TableView'}, + 'TagGroup': {package: '@react-spectrum/tag', component: 'TagGroup'}, + 'Tag': {package: '@react-stately/collections', component: 'Item'}, + 'TextArea': {package: '@react-spectrum/textfield', component: 'TextArea'}, + 'TimeField': {package: '@react-spectrum/datepicker', component: 'TimeField'}, + 'ToggleButton': {package: '@react-spectrum/button', component: 'ToggleButton'}, + 'ToggleButtonGroup': {package: '@react-spectrum/actiongroup', component: 'ActionGroup'}, + 'TooltipTrigger': {package: '@react-spectrum/tooltip', component: 'TooltipTrigger'}, + 'TreeView': {package: '@react-spectrum/tree', component: 'TreeView'}, + 'TreeViewItem': {package: '@react-spectrum/tree', component: 'TreeViewItem'}, + 'TreeViewItemContent': {package: '@react-spectrum/tree', component: 'TreeViewItemContent'} +}; + compare().catch(err => { console.error(err.stack); process.exit(1); @@ -66,59 +152,196 @@ function readJsonSync(filePath) { */ async function compare() { let branchDir = args.values['branch-api-dir'] || path.join(__dirname, '..', 'dist', 'branch-api'); - let publishedDir = args.values['base-api-dir'] || path.join(__dirname, '..', 'dist', 'base-api'); - if (!(fs.existsSync(branchDir) && fs.existsSync(publishedDir))) { - console.log(chalk.redBright(`you must have both a branchDir ${branchDir} and baseDir ${publishedDir}`)); - return; - } - - let branchAPIs = fg.sync(`${branchDir}/**/api.json`); - let publishedAPIs = fg.sync(`${publishedDir}/**/api.json`); let pairs = []; - // find all matching pairs based on what's been published - for (let pubApi of publishedAPIs) { - let pubApiPath = pubApi.split(path.sep); - let pkgJson = readJsonSync(path.join('/', ...pubApiPath.slice(0, pubApiPath.length - 2), 'package.json')); - let name = pkgJson.name; - let sharedPath = path.join(name, 'dist', 'api.json'); - let found = false; - for (let branchApi of branchAPIs) { - if (branchApi.includes(sharedPath)) { - found = true; - pairs.push({pubApi, branchApi}); - break; + if (args.values['compare-v3-s2']) { + // Compare v3 vs s2 components within branch-api only + if (!fs.existsSync(branchDir)) { + console.log(chalk.redBright(`branchDir ${branchDir} does not exist`)); + return; + } + + let branchAPIs = fg.sync(`${branchDir}/**/api.json`); + + // Find the s2 API file + let s2Api = branchAPIs.find(api => api.includes('@react-spectrum/s2/dist/api.json')); + if (!s2Api) { + console.log(chalk.redBright('Could not find @react-spectrum/s2 API')); + return; + } + + let s2ApiData = getAPI(s2Api); + let s2ComponentsWithoutMatch = []; + let v3ComponentsWithoutMatch = []; + let matchedV3Packages = new Set(); + + // For each export in s2, try to find matching v3 component + for (let exportName of Object.keys(s2ApiData.exports)) { + // Skip hooks (exports starting with 'use') + if (exportName.startsWith('use')) { + continue; + } + + let exportItem = s2ApiData.exports[exportName]; + // Only process components + if (exportItem?.type !== 'component') { + continue; + } + + // Find matching v3 component package + let v3PackageName, v3ComponentName; + + // Check if there's a custom mapping for this component + if (s2ToV3ComponentMap[exportName]) { + v3PackageName = s2ToV3ComponentMap[exportName].package; + v3ComponentName = s2ToV3ComponentMap[exportName].component; + } else { + // Default: use lowercase component name as package name + let componentName = exportName.toLowerCase(); + v3PackageName = `@react-spectrum/${componentName}`; + v3ComponentName = exportName; + } + + let v3Api = branchAPIs.find(api => api.includes(`${v3PackageName}/dist/api.json`)); + + if (v3Api) { + // Check if it's not a react-aria package + let v3PackageJson = readJsonSync(path.join(v3Api, '..', '..', 'package.json')); + if (!v3PackageJson.name.includes('@react-aria/')) { + pairs.push({ + v3Api, + s2Api, + componentName: exportName, + v3PackageName: v3PackageJson.name, + v3ComponentName: v3ComponentName + }); + matchedV3Packages.add(v3PackageJson.name); + } + } else { + s2ComponentsWithoutMatch.push(exportName); + } + } + + // Find v3 @react-spectrum components that weren't matched + for (let apiPath of branchAPIs) { + if (!apiPath.includes('@react-spectrum/') || apiPath.includes('@react-spectrum/s2/')) { + continue; + } + + let pkgJsonPath = path.join(apiPath, '..', '..', 'package.json'); + if (!fs.existsSync(pkgJsonPath)) { + continue; + } + + let pkgJson = readJsonSync(pkgJsonPath); + + // Skip if not a react-spectrum package, or if it's react-aria + if (!pkgJson.name.startsWith('@react-spectrum/') || pkgJson.name.includes('@react-aria/')) { + continue; + } + + // Skip if it was already matched + if (matchedV3Packages.has(pkgJson.name)) { + continue; + } + + // Check if it has any component exports (not just hooks) + let apiData = getAPI(apiPath); + let hasComponentExport = Object.values(apiData.exports || {}).some(exp => + exp?.type === 'component' + ); + + if (hasComponentExport) { + v3ComponentsWithoutMatch.push(pkgJson.name); } } - if (!found) { - pairs.push({pubApi, branchApi: null}); + + // Log unmatched components + if (s2ComponentsWithoutMatch.length > 0 || v3ComponentsWithoutMatch.length > 0) { + console.log('\n' + chalk.yellow('='.repeat(60))); + console.log(chalk.yellow.bold('Unmatched Components Report')); + console.log(chalk.yellow('='.repeat(60))); + + if (s2ComponentsWithoutMatch.length > 0) { + console.log('\n' + chalk.cyan.bold(`S2 components without v3 match (${s2ComponentsWithoutMatch.length}):`)); + s2ComponentsWithoutMatch.sort().forEach(name => { + console.log(chalk.cyan(` - ${name}`)); + }); + } + + if (v3ComponentsWithoutMatch.length > 0) { + console.log('\n' + chalk.magenta.bold(`v3 components without S2 match (${v3ComponentsWithoutMatch.length}):`)); + v3ComponentsWithoutMatch.sort().forEach(name => { + console.log(chalk.magenta(` - ${name}`)); + }); + } + + console.log('\n' + chalk.green.bold(`Successfully matched: ${pairs.length} components`)); + console.log(chalk.yellow('='.repeat(60)) + '\n'); + } + } else { + // Original comparison logic: branch vs published + let publishedDir = args.values['base-api-dir'] || path.join(__dirname, '..', 'dist', 'base-api'); + if (!(fs.existsSync(branchDir) && fs.existsSync(publishedDir))) { + console.log(chalk.redBright(`you must have both a branchDir ${branchDir} and baseDir ${publishedDir}`)); + return; } - } - // don't care about private APIs, but we do care if we're about to publish a new one - for (let branchApi of branchAPIs) { - let branchApiPath = branchApi.split(path.sep); - let pkgJson = readJsonSync(path.join('/', ...branchApiPath.slice(0, branchApiPath.length - 2), 'package.json')); - let name = pkgJson.name; - let sharedPath = path.join(name, 'dist', 'api.json'); - let found = false; + let branchAPIs = fg.sync(`${branchDir}/**/api.json`); + let publishedAPIs = fg.sync(`${publishedDir}/**/api.json`); + + // find all matching pairs based on what's been published for (let pubApi of publishedAPIs) { - if (pubApi.includes(sharedPath)) { - found = true; - break; + let pubApiPath = pubApi.split(path.sep); + let pkgJson = readJsonSync(path.join('/', ...pubApiPath.slice(0, pubApiPath.length - 2), 'package.json')); + let name = pkgJson.name; + let sharedPath = path.join(name, 'dist', 'api.json'); + let found = false; + for (let branchApi of branchAPIs) { + if (branchApi.includes(sharedPath)) { + found = true; + pairs.push({pubApi, branchApi}); + break; + } + } + if (!found) { + pairs.push({pubApi, branchApi: null}); } } - let json = readJsonSync(path.join(branchApi, '..', '..', 'package.json')); - if (!found && !json.private) { - pairs.push({pubApi: null, branchApi}); + + // don't care about private APIs, but we do care if we're about to publish a new one + for (let branchApi of branchAPIs) { + let branchApiPath = branchApi.split(path.sep); + let pkgJson = readJsonSync(path.join('/', ...branchApiPath.slice(0, branchApiPath.length - 2), 'package.json')); + let name = pkgJson.name; + let sharedPath = path.join(name, 'dist', 'api.json'); + let found = false; + for (let pubApi of publishedAPIs) { + if (pubApi.includes(sharedPath)) { + found = true; + break; + } + } + let json = readJsonSync(path.join(branchApi, '..', '..', 'package.json')); + if (!found && !json.private) { + pairs.push({pubApi: null, branchApi}); + } } } let allDiffs = {}; + let skippedComparisons = []; for (let pair of pairs) { - let {diffs, name} = getDiff(pair); - if (diffs && diffs.length > 0) { - allDiffs[name] = diffs; + let result = getDiff(pair); + if (result.skipped) { + skippedComparisons.push({ + name: result.name, + reason: result.reason, + componentName: pair.componentName, + v3Package: pair.v3PackageName + }); + } else if (result.diffs && result.diffs.length > 0) { + allDiffs[result.name] = result.diffs; } } @@ -179,6 +402,23 @@ ${changes.join('\n')} console.log(message); }); } + + // Report skipped comparisons + if (skippedComparisons.length > 0) { + console.log('\n' + chalk.yellow('='.repeat(60))); + console.log(chalk.yellow.bold('Skipped Comparisons Report')); + console.log(chalk.yellow('='.repeat(60))); + console.log(chalk.yellow(`\n${skippedComparisons.length} component(s) could not be compared:\n`)); + + skippedComparisons.forEach(({componentName, v3Package, reason}) => { + console.log(chalk.cyan(` ${componentName}:`)); + console.log(chalk.gray(` v3 package: ${v3Package}`)); + console.log(chalk.gray(` reason: ${reason}`)); + console.log(''); + }); + + console.log(chalk.yellow('='.repeat(60)) + '\n'); + } } // takes an interface name and follows all the interfaces dependencies to @@ -253,23 +493,101 @@ function getAPI(filePath) { // bulk of the logic, read the api files, rebuild the interfaces, diff those reconstructions function getDiff(pair) { let name; - if (pair.branchApi) { - name = pair.branchApi.replace(/.*branch-api/, ''); + let publishedApi, branchApi, allExportNames; + + if (args.values['compare-v3-s2']) { + // v3 vs s2 comparison + name = `${pair.componentName} (v3: ${pair.v3PackageName} vs s2)`; + + if (args.values.package && !args.values.package.includes(pair.componentName)) { + return {diffs: [], name}; + } + + if (args.values.verbose) { + console.log(`diffing ${name}`); + } + + console.log(chalk.blue(`\nComparing ${pair.componentName}:`)); + console.log(chalk.gray(` v3 API: ${pair.v3Api}`)); + console.log(chalk.gray(` s2 API: ${pair.s2Api}`)); + + // Get the v3 component API + let v3ApiData = getAPI(pair.v3Api); + // Get the s2 API and extract just the specific component + let s2ApiData = getAPI(pair.s2Api); + + // Create filtered APIs with only the component we care about + publishedApi = { + exports: {}, + links: v3ApiData.links || {} + }; + branchApi = { + exports: {}, + links: s2ApiData.links || {} + }; + + // Find the main export in v3 (usually the component name) + // In v3, the component might be the default export or match the package name + let v3ComponentName = pair.v3ComponentName || pair.componentName; + let v3ComponentData = null; + + console.log(chalk.gray(` Looking for v3 component: ${v3ComponentName}`)); + console.log(chalk.gray(` Available v3 exports: ${Object.keys(v3ApiData.exports).join(', ')}`)); + + if (v3ApiData.exports[v3ComponentName]) { + v3ComponentData = v3ApiData.exports[v3ComponentName]; + console.log(chalk.green(` ✓ Found v3 component: ${v3ComponentName} (type: ${v3ComponentData?.type})`)); + } else { + console.log(chalk.red(` ✗ v3 component ${v3ComponentName} not found - skipping comparison`)); + // Return empty diffs to skip this comparison + return {diffs: [], name, skipped: true, reason: 'v3 component not found'}; + } + + // Use a common key for comparison (the S2 component name) + // Normalize the component name so both v3 and s2 use the same name + if (v3ComponentData) { + // Clone the component data and set the name to match the S2 component name + v3ComponentData = JSON.parse(JSON.stringify(v3ComponentData)); + v3ComponentData.name = pair.componentName; + publishedApi.exports[pair.componentName] = v3ComponentData; + } + + // Get the s2 component - also clone and normalize the name + console.log(chalk.gray(` Looking for s2 component: ${pair.componentName}`)); + let s2ComponentData = s2ApiData.exports[pair.componentName]; + if (s2ComponentData) { + console.log(chalk.green(` ✓ Found s2 component: ${pair.componentName} (type: ${s2ComponentData?.type})`)); + s2ComponentData = JSON.parse(JSON.stringify(s2ComponentData)); + s2ComponentData.name = pair.componentName; + branchApi.exports[pair.componentName] = s2ComponentData; + } else { + console.log(chalk.red(` ✗ s2 component ${pair.componentName} not found - skipping comparison`)); + // Return empty diffs to skip this comparison + return {diffs: [], name, skipped: true, reason: 's2 component not found'}; + } + + allExportNames = [pair.componentName]; } else { - name = pair.pubApi.replace(/.*published-api/, ''); - } + // Original branch vs published comparison + if (pair.branchApi) { + name = pair.branchApi.replace(/.*branch-api/, ''); + } else { + name = pair.pubApi.replace(/.*published-api/, ''); + } - if (args.values.package && !args.values.package.includes(name)) { - return {diff: {}, name}; - } - if (args.values.verbose) { - console.log(`diffing ${name}`); + if (args.values.package && !args.values.package.includes(name)) { + return {diffs: [], name}; + } + if (args.values.verbose) { + console.log(`diffing ${name}`); + } + publishedApi = pair.pubApi === null ? {exports: {}} : getAPI(pair.pubApi); + branchApi = pair.branchApi === null ? {exports: {}} : getAPI(pair.branchApi); + allExportNames = [...new Set([...Object.keys(publishedApi.exports), ...Object.keys(branchApi.exports)])]; } - let publishedApi = pair.pubApi === null ? {exports: {}} : getAPI(pair.pubApi); - let branchApi = pair.branchApi === null ? {exports: {}} : getAPI(pair.branchApi); + let publishedInterfaces = rebuildInterfaces(publishedApi); let branchInterfaces = rebuildInterfaces(branchApi); - let allExportNames = [...new Set([...Object.keys(publishedApi.exports), ...Object.keys(branchApi.exports)])]; let allInterfaces = [...new Set([...Object.keys(publishedInterfaces), ...Object.keys(branchInterfaces)])]; let formattedPublishedInterfaces = ''; let formattedBranchInterfaces = ''; @@ -281,7 +599,7 @@ function getDiff(pair) { if (args.values.interface && args.values.interface !== iname) { return; } - let simplifiedName = allExportNames[index]; + let simplifiedName = allExportNames[index] || iname; let codeDiff = Diff.structuredPatch(iname, iname, formattedPublishedInterfaces[index], formattedBranchInterfaces[index], {newlineIsToken: true}); if (args.values.verbose) { console.log(util.inspect(codeDiff, {depth: null})); @@ -291,7 +609,7 @@ function getDiff(pair) { let lines = formattedPublishedInterfaces[index].split('\n'); codeDiff.hunks.forEach(hunk => { if (hunk.oldStart > prevEnd) { - result = [...result, ...lines.slice(prevEnd - 1, hunk.oldStart - 1).map((item, index) => ` ${item}`)]; + result = [...result, ...lines.slice(prevEnd - 1, hunk.oldStart - 1).map(item => ` ${item}`)]; } if (args.values.isCI) { result = [...result, ...hunk.lines]; @@ -309,14 +627,18 @@ function getDiff(pair) { }); let joinedResult = ''; if (codeDiff.hunks.length > 0) { - joinedResult = [...result, ...lines.slice(prevEnd).map((item, index) => ` ${item}`)].join('\n'); + joinedResult = [...result, ...lines.slice(prevEnd).map(item => ` ${item}`)].join('\n'); } if (args.values.isCI && result.length > 0) { joinedResult = `\`\`\`diff ${joinedResult} \`\`\``; } - diffs.push({iname, result: joinedResult, simplifiedName: `${name.replace('/dist/api.json', '')}:${simplifiedName}`}); + + let diffSimplifiedName = args.values['compare-v3-s2'] + ? `${pair.componentName}` + : `${name.replace('/dist/api.json', '')}:${simplifiedName}`; + diffs.push({iname, result: joinedResult, simplifiedName: diffSimplifiedName}); }); return {diffs, name}; @@ -372,10 +694,36 @@ function processType(value) { return 'boolean'; } if (value.type === 'union') { - return value.elements.map(processType).join(' | '); + // Sort union elements alphabetically for consistent output, with functions last + let elements = value.elements.map(processType); + elements.sort((a, b) => { + let aIsFunction = a.startsWith('(') && a.includes('=>'); + let bIsFunction = b.startsWith('(') && b.includes('=>'); + if (aIsFunction && !bIsFunction) { + return 1; // a comes after b + } + if (!aIsFunction && bIsFunction) { + return -1; // a comes before b + } + return a.localeCompare(b); // alphabetical if both are or aren't functions + }); + return elements.join(' | '); } if (value.type === 'intersection') { - return `(${value.types.map(processType).join(' & ')})`; + // Sort intersection types alphabetically for consistent output, with functions last + let types = value.types.map(processType); + types.sort((a, b) => { + let aIsFunction = a.startsWith('(') && a.includes('=>'); + let bIsFunction = b.startsWith('(') && b.includes('=>'); + if (aIsFunction && !bIsFunction) { + return 1; // a comes after b + } + if (!aIsFunction && bIsFunction) { + return -1; // a comes before b + } + return a.localeCompare(b); // alphabetical if both are or aren't functions + }); + return `(${types.join(' & ')})`; } if (value.type === 'application') { let name = value.base.name; @@ -474,6 +822,77 @@ ${value.exact ? '\\}' : '}'}`; return `UNKNOWN: ${value.type}`; } +// Helper to find a type by name in links or exports +function findTypeByName(name, json) { + if (json.links) { + for (let linkId in json.links) { + if (json.links[linkId].name === name) { + return json.links[linkId]; + } + } + } + if (json.exports && json.exports[name]) { + return json.exports[name]; + } + return null; +} + +// Helper function to resolve a props type by following links, identifiers, and applications +function resolvePropsType(propsType, json) { + let maxDepth = 10; + let depth = 0; + + while (depth < maxDepth) { + depth++; + + // If it's a link, resolve it + if (propsType.type === 'link' && propsType.id && json.links && json.links[propsType.id]) { + propsType = json.links[propsType.id]; + continue; + } + + // If it's an application (like ItemProps), try to get the base type + if (propsType.type === 'application' && propsType.base) { + if (propsType.base.type === 'link' && propsType.base.id && json.links && json.links[propsType.base.id]) { + propsType = json.links[propsType.base.id]; + continue; + } + if (propsType.base.type === 'identifier' && propsType.base.name) { + let resolved = findTypeByName(propsType.base.name, json); + if (resolved) { + propsType = resolved; + continue; + } + } + } + + // If the resolved type is a component, extract props from it + if (propsType.type === 'component' && propsType.props) { + propsType = propsType.props; + continue; + } + + // If it's an identifier, try to resolve it + if (propsType.type === 'identifier' && propsType.name) { + let resolved = findTypeByName(propsType.name, json); + if (resolved) { + propsType = resolved; + continue; + } + } + + // If we have properties, we're done + if ((propsType.type === 'object' || propsType.type === 'interface') && propsType.properties) { + break; + } + + // Can't resolve further + break; + } + + return propsType; +} + function rebuildInterfaces(json) { let exports = {}; if (!json.exports) { @@ -503,6 +922,13 @@ function rebuildInterfaces(json) { if (prop.access === 'private') { return; } + // Skip style props from baseStyleProps and viewStyleProps + if (stylePropsToExclude.has(prop.name)) { + if (args.values.verbose) { + console.log(chalk.gray(` [FILTERED] Skipping style prop: ${prop.name} from ${key}`)); + } + return; + } let name = prop.name; if (item.name === null) { name = key; @@ -533,22 +959,93 @@ function rebuildInterfaces(json) { exports[name] = compInterface; } else if (item.type === 'function') { let funcInterface = {}; - Object.entries(item.parameters).sort((([keyA], [keyB]) => keyA > keyB ? 1 : -1)).forEach(([, param]) => { - if (param.access === 'private') { - return; + + // Check if this is a React component function with a props parameter + // In that case, extract the props from the parameter type instead of treating it as a function + let isReactComponent = false; + let propsParam = null; + + if (item.parameters && item.parameters.length > 0) { + let firstParam = item.parameters[0]; + // Check if it's a props parameter (has a value that's a link/identifier/object/application) + if (firstParam && firstParam.value && + (firstParam.value.type === 'link' || + firstParam.value.type === 'identifier' || + firstParam.value.type === 'object' || + firstParam.value.type === 'application')) { + isReactComponent = true; + propsParam = firstParam; } - let name = param.name; - let optional = param.optional; - let defaultVal = param.default; - let value = processType(param.value); - funcInterface[name] = {optional, defaultVal, value}; - }); - let returnVal = processType(item.return); - let name = item.name ?? key; - funcInterface['returnVal'] = returnVal; - if (item.typeParameters?.length > 0) { - funcInterface['typeParameters'] = `<${item.typeParameters.map(processType).sort().join(', ')}>`; } + + if (isReactComponent && propsParam) { + // Extract props from the parameter type + let propsType = resolvePropsType(propsParam.value, json); + + // If it's an object or interface type, extract properties + if (propsType.type === 'object' && propsType.properties) { + Object.entries(propsType.properties).sort((([keyA], [keyB]) => keyA > keyB ? 1 : -1)).forEach(([, prop]) => { + if (prop.access === 'private') { + return; + } + // Skip style props from baseStyleProps and viewStyleProps + if (stylePropsToExclude.has(prop.name)) { + return; + } + let name = prop.name; + let optional = prop.optional; + let defaultVal = prop.default; + let value = processType(prop.value); + funcInterface[name] = {optional, defaultVal, value}; + }); + } else if (propsType.type === 'interface' && propsType.properties) { + Object.entries(propsType.properties).sort((([keyA], [keyB]) => keyA > keyB ? 1 : -1)).forEach(([, prop]) => { + if (prop.access === 'private' || prop.access === 'protected') { + return; + } + // Skip style props from baseStyleProps and viewStyleProps + if (stylePropsToExclude.has(prop.name)) { + return; + } + let name = prop.name; + let optional = prop.optional; + let defaultVal = prop.default; + let value = processType(prop.value); + funcInterface[name] = {optional, defaultVal, value}; + }); + } else { + // Fallback: treat as regular function parameter + let name = propsParam.name; + let optional = propsParam.optional; + let defaultVal = propsParam.default; + let value = processType(propsParam.value); + funcInterface[name] = {optional, defaultVal, value}; + } + + // Add type parameters if present + if (item.typeParameters?.length > 0) { + funcInterface['typeParameters'] = `<${item.typeParameters.map(processType).sort().join(', ')}>`; + } + } else { + // Regular function: process all parameters + Object.entries(item.parameters).sort((([keyA], [keyB]) => keyA > keyB ? 1 : -1)).forEach(([, param]) => { + if (param.access === 'private') { + return; + } + let name = param.name; + let optional = param.optional; + let defaultVal = param.default; + let value = processType(param.value); + funcInterface[name] = {optional, defaultVal, value}; + }); + let returnVal = processType(item.return); + funcInterface['returnVal'] = returnVal; + if (item.typeParameters?.length > 0) { + funcInterface['typeParameters'] = `<${item.typeParameters.map(processType).sort().join(', ')}>`; + } + } + + let name = item.name ?? key; exports[name] = funcInterface; } else if (item.type === 'interface') { let funcInterface = {}; @@ -556,6 +1053,13 @@ function rebuildInterfaces(json) { if (property.access === 'private' || property.access === 'protected') { return; } + // Skip style props from baseStyleProps and viewStyleProps + if (stylePropsToExclude.has(property.name)) { + if (args.values.verbose) { + console.log(chalk.gray(` [FILTERED] Skipping style prop: ${property.name} from ${key}`)); + } + return; + } let name = property.name; let optional = property.optional; let defaultVal = property.default; @@ -600,7 +1104,13 @@ function rebuildInterfaces(json) { } function formatProp([name, prop]) { - return ` ${name}${prop.optional ? '?' : ''}: ${prop.value}${prop.defaultVal != null ? ` = ${prop.defaultVal}` : ''}`; + let defaultValue = ''; + if (prop.defaultVal != null) { + // Normalize default values to use single quotes instead of double quotes + let normalizedDefault = String(prop.defaultVal).replace(/"/g, "'"); + defaultValue = ` = ${normalizedDefault}`; + } + return ` ${name}${prop.optional ? '?' : ''}: ${prop.value}${defaultValue}`; } function formatInterfaces(interfaces, allInterfaces) {