diff --git a/packages/@stylexjs/postcss-plugin/__tests__/__fixtures__/.babelrc.js b/packages/@stylexjs/postcss-plugin/__tests__/__fixtures__/.babelrc.js new file mode 100644 index 000000000..54b77c397 --- /dev/null +++ b/packages/@stylexjs/postcss-plugin/__tests__/__fixtures__/.babelrc.js @@ -0,0 +1,5 @@ +module.exports = { + plugins: [ + ['@stylexjs/babel-plugin', { dev: false, runtimeInjection: false }], + ], +}; diff --git a/packages/@stylexjs/postcss-plugin/__tests__/__fixtures__/import-sources-object.js b/packages/@stylexjs/postcss-plugin/__tests__/__fixtures__/import-sources-object.js new file mode 100644 index 000000000..96950afc9 --- /dev/null +++ b/packages/@stylexjs/postcss-plugin/__tests__/__fixtures__/import-sources-object.js @@ -0,0 +1,14 @@ +/** + * 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. + */ + +import { css } from 'react-strict-dom'; + +export const styles = css.create({ + object: { + backgroundColor: 'yellow', + }, +}); diff --git a/packages/@stylexjs/postcss-plugin/__tests__/__fixtures__/import-sources-string.js b/packages/@stylexjs/postcss-plugin/__tests__/__fixtures__/import-sources-string.js new file mode 100644 index 000000000..65f393cb7 --- /dev/null +++ b/packages/@stylexjs/postcss-plugin/__tests__/__fixtures__/import-sources-string.js @@ -0,0 +1,14 @@ +/** + * 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. + */ + +import * as custom from 'custom'; + +export const styles = custom.create({ + string: { + backgroundColor: 'blue', + }, +}); diff --git a/packages/@stylexjs/postcss-plugin/__tests__/__fixtures__/styles-second.js b/packages/@stylexjs/postcss-plugin/__tests__/__fixtures__/styles-second.js new file mode 100644 index 000000000..9239bc7ad --- /dev/null +++ b/packages/@stylexjs/postcss-plugin/__tests__/__fixtures__/styles-second.js @@ -0,0 +1,14 @@ +/** + * 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. + */ + +import * as stylex from '@stylexjs/stylex'; + +export const styles = stylex.create({ + second: { + backgroundColor: 'green', + }, +}); diff --git a/packages/@stylexjs/postcss-plugin/__tests__/__fixtures__/styles.js b/packages/@stylexjs/postcss-plugin/__tests__/__fixtures__/styles.js new file mode 100644 index 000000000..e88aefacd --- /dev/null +++ b/packages/@stylexjs/postcss-plugin/__tests__/__fixtures__/styles.js @@ -0,0 +1,14 @@ +/** + * 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. + */ + +import * as stylex from '@stylexjs/stylex'; + +export const styles = stylex.create({ + container: { + backgroundColor: 'red', + }, +}); diff --git a/packages/@stylexjs/postcss-plugin/__tests__/index-test.js b/packages/@stylexjs/postcss-plugin/__tests__/index-test.js new file mode 100644 index 000000000..081201b26 --- /dev/null +++ b/packages/@stylexjs/postcss-plugin/__tests__/index-test.js @@ -0,0 +1,153 @@ +/** + * 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'; + +const path = require('path'); +const postcss = require('postcss'); +const createPlugin = require('../src/plugin'); + +describe('@stylexjs/postcss-plugin', () => { + const fixturesDir = path.resolve(__dirname, '__fixtures__'); + + async function runStylexPostcss(options = {}, inputCSS = '@stylex;') { + // Create a new instance for each test as the plugin is stateful + const stylexPostcssPlugin = createPlugin(); + + const plugin = stylexPostcssPlugin({ + cwd: fixturesDir, + include: ['**/*.js'], + babelConfig: { + configFile: path.join(fixturesDir, '.babelrc.js'), + }, + ...options, + }); + + const processor = postcss([plugin]); + const result = await processor.process(inputCSS, { + from: path.join(fixturesDir, 'input.css'), + }); + + return result; + } + + test('extracts CSS from StyleX files', async () => { + const result = await runStylexPostcss(); + + expect(result.css).toMatchInlineSnapshot(` + ".x1u857p9{background-color:green} + .xrkmrrc{background-color:red}" + `); + + // Check that messages contain dependency information + expect(result.messages.length).toBeGreaterThan(0); + expect(result.messages.some((m) => m.type === 'dir-dependency')).toBe(true); + }); + + test('handles empty CSS input without @stylex rule', async () => { + const result = await runStylexPostcss({}, '/* No stylex rule here */'); + + expect(result.css).toMatchInlineSnapshot('"/* No stylex rule here */"'); + expect(result.messages.length).toBe(0); + }); + + test('supports CSS layers', async () => { + const result = await runStylexPostcss({ useCSSLayers: true }); + + expect(result.css).toContain('@layer'); + expect(result.css).toMatchInlineSnapshot(` + " + @layer priority1; + @layer priority1{ + .x1u857p9{background-color:green} + .xrkmrrc{background-color:red} + }" + `); + }); + + test('handles exclude patterns', async () => { + const result = await runStylexPostcss({ + exclude: ['**/styles-second.js'], + }); + + // Should not contain styles-second.js styles + expect(result.css).not.toContain('green'); + + expect(result.css).toMatchInlineSnapshot( + '".xrkmrrc{background-color:red}"', + ); + }); + + test('respects string syntax for importSources', async () => { + // Default importSources should not process any files + const defaultResult = await runStylexPostcss({ + include: ['**/import-sources-*.js'], + }); + + expect(defaultResult.css).toBe(''); + + // Custom importSources should process only import-sources-string.js + const customResult = await runStylexPostcss({ + include: ['**/import-sources-*.js'], + importSources: ['custom'], + babelConfig: { + babelrc: false, + plugins: [ + [ + '@stylexjs/babel-plugin', + { + dev: false, + runtimeInjection: false, + importSources: ['custom'], + }, + ], + ], + }, + }); + + expect(customResult.css).toMatchInlineSnapshot( + '".x1t391ir{background-color:blue}"', + ); + }); + + test('supports object syntax for importSources', async () => { + const result = await runStylexPostcss({ + include: ['**/import-sources-object.js'], + importSources: [{ as: 'css', from: 'react-strict-dom' }], + babelConfig: { + babelrc: false, + plugins: [ + [ + '@stylexjs/babel-plugin', + { + dev: false, + runtimeInjection: false, + importSources: [{ as: 'css', from: 'react-strict-dom' }], + }, + ], + ], + }, + }); + + expect(result.css).toMatchInlineSnapshot( + '".x1cu41gw{background-color:yellow}"', + ); + }); + + test('skips files that do not match include/exclude patterns', async () => { + const result = await runStylexPostcss({ + include: ['**/styles-second.js'], + }); + + // Should contain styles-second.js styles but not styles.js + expect(result.css).not.toContain('red'); + + expect(result.css).toMatchInlineSnapshot( + '".x1u857p9{background-color:green}"', + ); + }); +}); diff --git a/packages/@stylexjs/postcss-plugin/jest.config.js b/packages/@stylexjs/postcss-plugin/jest.config.js new file mode 100644 index 000000000..6de6c2485 --- /dev/null +++ b/packages/@stylexjs/postcss-plugin/jest.config.js @@ -0,0 +1,13 @@ +/** + * 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'; + +module.exports = { + testPathIgnorePatterns: ['/__fixtures__/'], + testEnvironment: 'node', +}; diff --git a/packages/@stylexjs/postcss-plugin/package.json b/packages/@stylexjs/postcss-plugin/package.json index a26b16312..868177c5e 100644 --- a/packages/@stylexjs/postcss-plugin/package.json +++ b/packages/@stylexjs/postcss-plugin/package.json @@ -8,6 +8,9 @@ "url": "git+https://github.com/facebook/stylex.git" }, "license": "MIT", + "scripts": { + "test": "jest" + }, "dependencies": { "@babel/core": "^7.26.8", "@stylexjs/babel-plugin": "0.12.0", diff --git a/packages/@stylexjs/postcss-plugin/src/index.js b/packages/@stylexjs/postcss-plugin/src/index.js index 67c7f9315..c8edf91f1 100644 --- a/packages/@stylexjs/postcss-plugin/src/index.js +++ b/packages/@stylexjs/postcss-plugin/src/index.js @@ -5,97 +5,6 @@ * LICENSE file in the root directory of this source tree. */ -const postcss = require('postcss'); -const createBuilder = require('./builder'); +const createPlugin = require('./plugin'); -const PLUGIN_NAME = '@stylexjs/postcss-plugin'; - -const builder = createBuilder(); - -const isDev = process.env.NODE_ENV === 'development'; - -const plugin = ({ - cwd = process.cwd(), - // By default reuses the Babel configuration from the project root. - // Use `babelrc: false` to disable this behavior. - babelConfig = {}, - include, - exclude, - useCSSLayers = false, - importSources = ['@stylexjs/stylex', 'stylex'], -}) => { - exclude = [ - // Exclude type declaration files by default because it never contains any CSS rules. - '**/*.d.ts', - '**/*.flow', - ...(exclude ?? []), - ]; - - // Whether to skip the error when transforming StyleX rules. - // Useful in watch mode where Fast Refresh can recover from errors. - // Initial transform will still throw errors in watch mode to surface issues early. - let shouldSkipTransformError = false; - - return { - postcssPlugin: PLUGIN_NAME, - plugins: [ - // Processes the PostCSS root node to find and transform StyleX at-rules. - async function (root, result) { - const fileName = result.opts.from; - - // Configure the builder with the provided options - await builder.configure({ - include, - exclude, - cwd, - babelConfig, - useCSSLayers, - importSources, - isDev, - }); - - // Find the "@stylex" at-rule - const styleXAtRule = builder.findStyleXAtRule(root); - if (styleXAtRule == null) { - return; - } - - // Get dependencies to be watched for changes - const dependencies = builder.getDependencies(); - - // Add each dependency to the PostCSS result messages. - // This watches the entire "./src" directory for "./src/**/*.{ts,tsx}" - // to handle new files and deletions reliably in watch mode. - for (const dependency of dependencies) { - result.messages.push({ - plugin: PLUGIN_NAME, - parent: fileName, - ...dependency, - }); - } - - // Build and parse the CSS from collected StyleX rules - const css = await builder.build({ - shouldSkipTransformError, - }); - const parsed = await postcss.parse(css, { - from: fileName, - }); - - // Replace the "@stylex" rule with the generated CSS - styleXAtRule.replaceWith(parsed); - - result.root = root; - - if (!shouldSkipTransformError) { - // Build was successful, subsequent builds are for watch mode - shouldSkipTransformError = true; - } - }, - ], - }; -}; - -plugin.postcss = true; - -module.exports = plugin; +module.exports = createPlugin(); diff --git a/packages/@stylexjs/postcss-plugin/src/plugin.js b/packages/@stylexjs/postcss-plugin/src/plugin.js new file mode 100644 index 000000000..b6a5aac1d --- /dev/null +++ b/packages/@stylexjs/postcss-plugin/src/plugin.js @@ -0,0 +1,102 @@ +/** + * 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. + */ +const postcss = require('postcss'); +const createBuilder = require('./builder'); + +module.exports = function createPlugin() { + const PLUGIN_NAME = '@stylexjs/postcss-plugin'; + + const builder = createBuilder(); + + const isDev = process.env.NODE_ENV === 'development'; + + const plugin = ({ + cwd = process.cwd(), + // By default reuses the Babel configuration from the project root. + // Use `babelrc: false` to disable this behavior. + babelConfig = {}, + include, + exclude, + useCSSLayers = false, + importSources = ['@stylexjs/stylex', 'stylex'], + }) => { + exclude = [ + // Exclude type declaration files by default because it never contains any CSS rules. + '**/*.d.ts', + '**/*.flow', + ...(exclude ?? []), + ]; + + // Whether to skip the error when transforming StyleX rules. + // Useful in watch mode where Fast Refresh can recover from errors. + // Initial transform will still throw errors in watch mode to surface issues early. + let shouldSkipTransformError = false; + + return { + postcssPlugin: PLUGIN_NAME, + plugins: [ + // Processes the PostCSS root node to find and transform StyleX at-rules. + async function (root, result) { + const fileName = result.opts.from; + + // Configure the builder with the provided options + await builder.configure({ + include, + exclude, + cwd, + babelConfig, + useCSSLayers, + importSources, + isDev, + }); + + // Find the "@stylex" at-rule + const styleXAtRule = builder.findStyleXAtRule(root); + if (styleXAtRule == null) { + return; + } + + // Get dependencies to be watched for changes + const dependencies = builder.getDependencies(); + + // Add each dependency to the PostCSS result messages. + // This watches the entire "./src" directory for "./src/**/*.{ts,tsx}" + // to handle new files and deletions reliably in watch mode. + for (const dependency of dependencies) { + result.messages.push({ + plugin: PLUGIN_NAME, + parent: fileName, + ...dependency, + }); + } + + // Build and parse the CSS from collected StyleX rules + const css = await builder.build({ + shouldSkipTransformError, + }); + const parsed = await postcss.parse(css, { + from: fileName, + }); + + // Replace the "@stylex" rule with the generated CSS + styleXAtRule.replaceWith(parsed); + + result.root = root; + + if (!shouldSkipTransformError) { + // Build was successful, subsequent builds are for watch mode + shouldSkipTransformError = true; + } + }, + ], + }; + }; + + plugin.postcss = true; + + return plugin; +};