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 a3ead274f..2b25ef2d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26940,6 +26940,7 @@ "@babel/types": "^7.26.8", "@dual-bundle/import-meta-resolve": "^4.1.0", "@stylexjs/stylex": "0.14.1", + "json5": "^2.2.3", "postcss-value-parser": "^4.1.0" }, "devDependencies": { diff --git a/packages/@stylexjs/babel-plugin/__tests__/options-alias-config-test.js b/packages/@stylexjs/babel-plugin/__tests__/options-alias-config-test.js new file mode 100644 index 000000000..1821542b1 --- /dev/null +++ b/packages/@stylexjs/babel-plugin/__tests__/options-alias-config-test.js @@ -0,0 +1,178 @@ +/** + * 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': [path.join(tmpDir, './src/components')], + '#utils/*': [path.join(tmpDir, './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/*': [path.join(tmpDir, 'src/components/*')], + '@utils/*': [path.join(tmpDir, '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/': [path.join(tmpDir, 'src/components')], + '@utils/': [path.join(tmpDir, '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': [path.join(tmpDir, 'src/components')], + '@utils/*': [path.join(tmpDir, 'src/utils/*')], + '@styles/': [path.join(tmpDir, 'src/styles')], + }); + }); + + test('manual configuration overrides discovered aliases', () => { + setupFiles({ + 'package.json': { + name: 'test-package', + imports: { + '#components': './src/components', + }, + }, + }); + + state.opts = { + aliases: { + components: path.join(tmpDir, 'custom/path'), + }, + }; + + const manager = new StateManager(state); + + expect(manager.options.aliases).toEqual({ + '#components': [path.join(tmpDir, 'src/components')], + components: [path.join(tmpDir, '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/@stylexjs/babel-plugin/package.json b/packages/@stylexjs/babel-plugin/package.json index 77a38be88..715a413dd 100644 --- a/packages/@stylexjs/babel-plugin/package.json +++ b/packages/@stylexjs/babel-plugin/package.json @@ -22,7 +22,8 @@ "@babel/types": "^7.26.8", "@dual-bundle/import-meta-resolve": "^4.1.0", "@stylexjs/stylex": "0.14.1", - "postcss-value-parser": "^4.1.0" + "postcss-value-parser": "^4.1.0", + "json5": "^2.2.3" }, "devDependencies": { "@rollup/plugin-alias": "^5.1.1", diff --git a/packages/@stylexjs/babel-plugin/src/utils/state-manager.js b/packages/@stylexjs/babel-plugin/src/utils/state-manager.js index 69bab89c2..86c396412 100644 --- a/packages/@stylexjs/babel-plugin/src/utils/state-manager.js +++ b/packages/@stylexjs/babel-plugin/src/utils/state-manager.js @@ -24,6 +24,7 @@ import url from 'url'; import * as z from './validate'; import { addDefault, addNamed } from '@babel/helper-module-imports'; import { moduleResolve } from '@dual-bundle/import-meta-resolve'; +import JSON5 from 'json5'; type ImportAdditionOptions = Omit< Partial, @@ -322,17 +323,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, @@ -725,6 +716,124 @@ 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; + } + + const pkgInfo = this.getPackageNameAndPath(this.filename); + if (!pkgInfo) { + return manualAliases ? this.normalizeAliases(manualAliases) : null; + } + + const [_packageName, projectDir] = pkgInfo; + + const resolveAliasPaths = (value: string | $ReadOnlyArray) => + (Array.isArray(value) ? value : [value]).map((p) => { + const endsWithStar = p.endsWith('/*'); + let basePath = p.replace(/\/\*$/, ''); + if (!path.isAbsolute(basePath)) { + basePath = path.resolve(projectDir, basePath); + } + if (endsWithStar) { + basePath = basePath.endsWith('/') ? basePath + '*' : basePath + '/*'; + } + return basePath; + }); + + const [packageAliases, tsconfigAliases, denoAliases] = [ + [ + 'package.json', + (rawConfig: mixed) => { + if (!isPackageJSON(rawConfig)) { + throw new Error('Invalid package.json format'); + } + return rawConfig.imports; + }, + ], + [ + 'tsconfig.json', + (rawConfig: mixed) => { + if (!isTSConfig(rawConfig)) { + throw new Error('Invalid tsconfig.json format'); + } + const config = rawConfig as $FlowFixMe; + return config.compilerOptions?.paths; + }, + ], + [ + 'deno.json', + (rawConfig: mixed) => { + if (!isDenoConfig(rawConfig)) { + throw new Error('Invalid deno.json format'); + } + return rawConfig.imports; + }, + ], + ].map( + ([fileName, getConfig]): $ReadOnly<{ + [string]: string | $ReadOnlyArray, + }> => { + try { + const filePath = path.join(projectDir, fileName); + if (fs.existsSync(filePath)) { + const rawConfig: mixed = JSON5.parse( + fs.readFileSync(filePath, 'utf8'), + ); + const config = getConfig(rawConfig); + + // Handle Node.js native imports + if (isImportsObject(config)) { + return Object.fromEntries( + Object.entries(config).map(([k, v]) => [ + k, + resolveAliasPaths(v as $FlowFixMe), + ]), + ) as $ReadOnly<{ [string]: $ReadOnlyArray }>; + } + } + return {}; + } catch (err) { + console.warn(`Failed to load aliases from ${fileName}`, err.message); + } + return {}; + }, + ); + + // Merge aliases in priority: manual > package.json > tsconfig.json > deno.json + const mergedAliases: { + [string]: string | $ReadOnlyArray, + } = { + ...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( @@ -864,3 +973,35 @@ function toPosixPath(filePath: string): string { function formatRelativePath(filePath: string) { return filePath.startsWith('.') ? filePath : './' + filePath; } + +function isImportsObject(obj: mixed): implies obj is { ... } { + return obj != null && typeof obj === 'object'; +} + +function isPackageJSON(obj: mixed): implies obj is { +imports: mixed, ... } { + return ( + obj != null && + typeof obj === 'object' && + (!('imports' in obj) || typeof obj.imports === 'object') + ); +} + +function isTSConfig( + obj: mixed, +): implies obj is { +compilerOptions: mixed, ... } { + return ( + obj != null && + typeof obj === 'object' && + 'compilerOptions' in obj && + obj.compilerOptions != null && + typeof obj.compilerOptions === 'object' + ); +} + +function isDenoConfig(obj: mixed): implies obj is { +imports: mixed, ... } { + return ( + obj != null && + typeof obj === 'object' && + (!('imports' in obj) || typeof obj.imports === 'object') + ); +}