diff --git a/flow-typed/json5.js b/flow-typed/json5.js new file mode 100644 index 000000000..56187dfda --- /dev/null +++ b/flow-typed/json5.js @@ -0,0 +1,29 @@ +// @flow +type TSConfig = { + compilerOptions?: { + baseUrl?: string, + paths?: { [key: string]: Array }, + }, +}; + +type DenoConfig = { + imports?: { [key: string]: string | Array }, +}; + +type PackageJSON = { + name?: string, + imports?: { [key: string]: string | Array }, +}; + +type ConfigType = TSConfig | DenoConfig | PackageJSON; + +declare module 'json5' { + declare module.exports: { + parse: (input: string) => mixed, + stringify: ( + value: mixed, + replacer?: ?Function | ?Array, + space?: string | number, + ) => string, + }; +} diff --git a/package-lock.json b/package-lock.json index 6b113c438..a656c82e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19743,6 +19743,7 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", "bin": { "json5": "lib/cli.js" }, @@ -26900,7 +26901,8 @@ "@babel/types": "^7.25.8", "@stylexjs/shared": "0.9.3", "@stylexjs/stylex": "0.9.3", - "esm-resolve": "^1.0.11" + "esm-resolve": "^1.0.11", + "json5": "^2.2.3" } }, "packages/cli": { @@ -31870,7 +31872,8 @@ "@babel/types": "^7.25.8", "@stylexjs/shared": "0.9.3", "@stylexjs/stylex": "0.9.3", - "esm-resolve": "^1.0.11" + "esm-resolve": "^1.0.11", + "json5": "^2.2.3" } }, "@stylexjs/cli": { diff --git a/packages/babel-plugin/__tests__/stylex-transform-alias-config-test.js b/packages/babel-plugin/__tests__/stylex-transform-alias-config-test.js new file mode 100644 index 000000000..64c86eaa9 --- /dev/null +++ b/packages/babel-plugin/__tests__/stylex-transform-alias-config-test.js @@ -0,0 +1,177 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * + */ + +'use strict'; + +import path from 'path'; +import fs from 'fs'; +import os from 'os'; +import StateManager from '../src/utils/state-manager'; + +describe('StyleX Alias Configuration', () => { + let tmpDir; + let state; + + beforeEach(() => { + // Create a temporary directory for our test files + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'stylex-test-')); + + // Create a mock babel state + state = { + file: { + metadata: {}, + }, + filename: path.join(tmpDir, 'src/components/Button.js'), + }; + }); + + afterEach(() => { + // Clean up temporary directory + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + const setupFiles = (files) => { + for (const [filePath, content] of Object.entries(files)) { + const fullPath = path.join(tmpDir, filePath); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, JSON.stringify(content, null, 2)); + } + }; + + test('discovers aliases from package.json imports', () => { + setupFiles({ + 'package.json': { + name: 'test-package', + imports: { + '#components': './src/components', + '#utils/*': './src/utils/*', + }, + }, + }); + + const manager = new StateManager(state); + + expect(manager.options.aliases).toEqual({ + components: ['./src/components'], + 'utils/*': ['./src/utils/*'], + }); + }); + + test('discovers aliases from tsconfig.json', () => { + setupFiles({ + 'package.json': { name: 'test-package' }, + 'tsconfig.json': { + compilerOptions: { + baseUrl: '.', + paths: { + '@components/*': ['src/components/*'], + '@utils/*': ['src/utils/*'], + }, + }, + }, + }); + + const manager = new StateManager(state); + + expect(manager.options.aliases).toEqual({ + '@components': ['src/components'], + '@utils': ['src/utils'], + }); + }); + + test('discovers aliases from deno.json', () => { + setupFiles({ + 'package.json': { name: 'test-package' }, + 'deno.json': { + imports: { + '@components/': './src/components/', + '@utils/': './src/utils/', + }, + }, + }); + + const manager = new StateManager(state); + + expect(manager.options.aliases).toEqual({ + '@components/': ['./src/components/'], + '@utils/': ['./src/utils/'], + }); + }); + + test('merges aliases from all config files', () => { + setupFiles({ + 'package.json': { + name: 'test-package', + imports: { + '#components': './src/components', + }, + }, + 'tsconfig.json': { + compilerOptions: { + baseUrl: '.', + paths: { + '@utils/*': ['src/utils/*'], + }, + }, + }, + 'deno.json': { + imports: { + '@styles/': './src/styles/', + }, + }, + }); + + const manager = new StateManager(state); + + expect(manager.options.aliases).toEqual({ + components: ['./src/components'], + '@utils': ['src/utils'], + '@styles/': ['./src/styles/'], + }); + }); + + test('manual configuration overrides discovered aliases', () => { + setupFiles({ + 'package.json': { + name: 'test-package', + imports: { + '#components': './src/components', + }, + }, + }); + + state.opts = { + aliases: { + components: './custom/path', + }, + }; + + const manager = new StateManager(state); + + expect(manager.options.aliases).toEqual({ + components: ['./custom/path'], + }); + }); + + test('handles missing configuration files gracefully', () => { + const manager = new StateManager(state); + expect(manager.options.aliases).toBeNull(); + }); + + test('handles invalid JSON files gracefully', () => { + setupFiles({ + 'package.json': '{invalid json', + 'tsconfig.json': '{also invalid', + 'deno.json': '{more invalid', + }); + + const manager = new StateManager(state); + expect(manager.options.aliases).toBeNull(); + }); +}); diff --git a/packages/babel-plugin/package.json b/packages/babel-plugin/package.json index ec9e4d9a2..ec0205d36 100644 --- a/packages/babel-plugin/package.json +++ b/packages/babel-plugin/package.json @@ -13,13 +13,14 @@ "test": "jest" }, "dependencies": { - "@babel/helper-module-imports": "^7.22.15", - "@stylexjs/shared": "0.9.3", - "@stylexjs/stylex": "0.9.3", "@babel/core": "^7.25.8", + "@babel/helper-module-imports": "^7.22.15", "@babel/traverse": "^7.25.7", "@babel/types": "^7.25.8", - "esm-resolve": "^1.0.11" + "@stylexjs/shared": "0.9.3", + "@stylexjs/stylex": "0.9.3", + "esm-resolve": "^1.0.11", + "json5": "^2.2.3" }, "jest": { "verbose": true, diff --git a/packages/babel-plugin/src/utils/state-manager.js b/packages/babel-plugin/src/utils/state-manager.js index 6918ff488..5db952d96 100644 --- a/packages/babel-plugin/src/utils/state-manager.js +++ b/packages/babel-plugin/src/utils/state-manager.js @@ -23,6 +23,7 @@ import { addDefault, addNamed } from '@babel/helper-module-imports'; import type { ImportOptions } from '@babel/helper-module-imports'; import * as pathUtils from '../babel-path-utils'; import { buildResolver } from 'esm-resolve'; +import JSON5 from 'json5'; type ImportAdditionOptions = Omit< Partial, @@ -262,17 +263,7 @@ export default class StateManager { 'options.aliases', ); - const aliases: StyleXStateOptions['aliases'] = - aliasesOption == null - ? aliasesOption - : Object.fromEntries( - Object.entries(aliasesOption).map(([key, value]) => { - if (typeof value === 'string') { - return [key, [value]]; - } - return [key, value]; - }), - ); + const aliases = this.loadAliases(aliasesOption); const opts: StyleXStateOptions = { aliases, @@ -623,6 +614,144 @@ export default class StateManager { ): void { this.styleVarsToKeep.add(memberExpression); } + + loadAliases( + manualAliases: ?$ReadOnly<{ [string]: string | $ReadOnlyArray }>, + ): ?$ReadOnly<{ [string]: $ReadOnlyArray }> { + if (!this.filename) { + return manualAliases ? this.normalizeAliases(manualAliases) : null; + } + + let packageAliases = {}; + let tsconfigAliases = {}; + let denoAliases = {}; + + const pkgInfo = this.getPackageNameAndPath(this.filename); + if (!pkgInfo) { + return manualAliases ? this.normalizeAliases(manualAliases) : null; + } + + const [_packageName, projectDir] = pkgInfo; + + // Load aliases from package.json imports field + try { + const packageJsonPath = path.join(projectDir, 'package.json'); + if (fs.existsSync(packageJsonPath)) { + const rawConfig: mixed = JSON5.parse( + fs.readFileSync(packageJsonPath, 'utf8'), + ); + if (!isPackageJSON(rawConfig)) { + throw new Error('Invalid package.json format'); + } + const packageJson: PackageJSON = rawConfig as $FlowFixMe; + + // Handle Node.js native imports + const imports = packageJson.imports; + if (imports && typeof imports === 'object') { + packageAliases = Object.fromEntries( + Object.entries(imports) + .filter(([key]) => key.startsWith('#')) + .map(([key, value]) => [ + key.slice(1), + Array.isArray(value) ? value : [value], + ]), + ); + } + } + } catch (err) { + console.warn('Failed to load aliases from package.json:', err.message); + } + + // Load aliases from tsconfig.json + try { + const tsconfigPath = path.join(projectDir, 'tsconfig.json'); + if (fs.existsSync(tsconfigPath)) { + const rawConfig: mixed = JSON5.parse( + fs.readFileSync(tsconfigPath, 'utf8'), + ); + if (!isTSConfig(rawConfig)) { + throw new Error('Invalid tsconfig.json format'); + } + const tsconfig: TSConfig = rawConfig as $FlowFixMe; + const baseUrl = tsconfig.compilerOptions?.baseUrl || '.'; + if (tsconfig.compilerOptions?.paths) { + tsconfigAliases = Object.fromEntries( + Object.entries(tsconfig.compilerOptions.paths).map( + ([key, value]) => [ + key.replace(/\/\*$/, ''), + Array.isArray(value) + ? value.map((p) => + this.normalizePath( + path.join(baseUrl, p.replace(/\/\*$/, '')), + ), + ) + : [ + this.normalizePath( + path.join(baseUrl, value.replace(/\/\*$/, '')), + ), + ], + ], + ), + ); + } + } + } catch (err) { + console.warn('Failed to load aliases from tsconfig.json:', err.message); + } + + // Load aliases from deno.json + try { + const denoConfigPath = path.join(projectDir, 'deno.json'); + if (fs.existsSync(denoConfigPath)) { + const rawConfig: mixed = JSON5.parse( + fs.readFileSync(denoConfigPath, 'utf8'), + ); + if (!isDenoConfig(rawConfig)) { + throw new Error('Invalid deno.json format'); + } + const denoConfig: DenoConfig = rawConfig as $FlowFixMe; + if (denoConfig.imports) { + denoAliases = Object.fromEntries( + Object.entries(denoConfig.imports).map(([key, value]) => [ + key, + Array.isArray(value) ? value : [value], + ]), + ); + } + } + } catch (err) { + console.warn('Failed to load aliases from deno.json:', err.message); + } + + // Merge aliases in priority: manual > package.json > tsconfig.json > deno.json + const mergedAliases = { + ...denoAliases, + ...tsconfigAliases, + ...packageAliases, + ...(manualAliases || {}), + }; + + return Object.keys(mergedAliases).length > 0 + ? this.normalizeAliases(mergedAliases) + : null; + } + + normalizeAliases( + aliases: $ReadOnly<{ [string]: string | $ReadOnlyArray }>, + ): $ReadOnly<{ [string]: $ReadOnlyArray }> { + return Object.fromEntries( + Object.entries(aliases).map(([key, value]) => [ + key, + Array.isArray(value) + ? value.map((p) => this.normalizePath(p)) + : [this.normalizePath(value)], + ]), + ); + } + + normalizePath(filePath: string): string { + return filePath.split(path.sep).join('/'); + } } function possibleAliasedPaths( @@ -764,3 +893,29 @@ const getProgramStatement = (path: NodePath<>): NodePath<> => { } return programPath; }; + +function isPackageJSON(obj: mixed): boolean { + return ( + obj != null && + typeof obj === 'object' && + (!('imports' in obj) || typeof obj.imports === 'object') + ); +} + +function isTSConfig(obj: mixed): boolean { + return ( + obj != null && + typeof obj === 'object' && + 'compilerOptions' in obj && + obj.compilerOptions != null && + typeof obj.compilerOptions === 'object' + ); +} + +function isDenoConfig(obj: mixed): boolean { + return ( + obj != null && + typeof obj === 'object' && + (!('imports' in obj) || typeof obj.imports === 'object') + ); +}