@astryxdesign/build 0.0.0-bootstrap.0 → 0.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/babel.js ADDED
@@ -0,0 +1,106 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ "use strict";
4
+
5
+ /**
6
+ * @astryxdesign/build/babel
7
+ *
8
+ * Babel plugin that delegates to @stylexjs/babel-plugin with a
9
+ * different classNamePrefix for Astryx library files vs product files.
10
+ *
11
+ * Library files get 'astryx' prefix (.astryx78zum5), product files get 'x' (.x78zum5).
12
+ */
13
+
14
+ const LIBRARY_PATTERNS = [
15
+ 'packages/core/',
16
+ 'packages/themes/',
17
+ 'packages/lab/',
18
+ 'node_modules/@astryxdesign/',
19
+ ];
20
+
21
+ module.exports = function xdsBabelPlugin(api, options) {
22
+ const stylexPlugin = require('@stylexjs/babel-plugin');
23
+
24
+ const {
25
+ libraryPatterns = LIBRARY_PATTERNS,
26
+ libraryPrefix = 'astryx',
27
+ classNamePrefix = 'x',
28
+ ...stylexOptions
29
+ } = options;
30
+
31
+ // Build the two sets of options — only classNamePrefix differs
32
+ const libraryOpts = {...stylexOptions, classNamePrefix: libraryPrefix};
33
+ const productOpts = {...stylexOptions, classNamePrefix};
34
+
35
+ // Create two plugin instances
36
+ const libraryPlugin = stylexPlugin(api, libraryOpts);
37
+ const productPlugin = stylexPlugin(api, productOpts);
38
+
39
+ function getPluginAndOpts(filename) {
40
+ const isLibrary = libraryPatterns.some(p => filename.includes(p));
41
+ return isLibrary
42
+ ? {plugin: libraryPlugin, opts: libraryOpts}
43
+ : {plugin: productPlugin, opts: productOpts};
44
+ }
45
+
46
+ // Helper: call a visitor function with overridden state.opts
47
+ function callVisitor(visitor, key, path, state) {
48
+ const {plugin, opts} = getPluginAndOpts(state.filename || '');
49
+ const v = plugin.visitor[key];
50
+ if (!v) return;
51
+
52
+ // Override state.opts so StyleX reads the correct classNamePrefix
53
+ const originalOpts = state.opts;
54
+ state.opts = opts;
55
+
56
+ try {
57
+ if (typeof v === 'function') {
58
+ v.call(this, path, state);
59
+ } else if (typeof v === 'object') {
60
+ // enter/exit form — should not happen at top level for stylex
61
+ // but handle it anyway
62
+ if (visitor === 'enter' && v.enter) v.enter.call(this, path, state);
63
+ if (visitor === 'exit' && v.exit) v.exit.call(this, path, state);
64
+ }
65
+ } finally {
66
+ state.opts = originalOpts;
67
+ }
68
+ }
69
+
70
+ // Get all visitor keys
71
+ const allKeys = new Set([
72
+ ...Object.keys(libraryPlugin.visitor || {}),
73
+ ...Object.keys(productPlugin.visitor || {}),
74
+ ]);
75
+
76
+ const visitor = {};
77
+
78
+ for (const key of allKeys) {
79
+ const sample = libraryPlugin.visitor[key] || productPlugin.visitor[key];
80
+
81
+ if (typeof sample === 'object' && (sample.enter || sample.exit)) {
82
+ // enter/exit form (Program uses this)
83
+ visitor[key] = {};
84
+ if (sample.enter) {
85
+ visitor[key].enter = function(path, state) {
86
+ callVisitor.call(this, 'enter', key, path, state);
87
+ };
88
+ }
89
+ if (sample.exit) {
90
+ visitor[key].exit = function(path, state) {
91
+ callVisitor.call(this, 'exit', key, path, state);
92
+ };
93
+ }
94
+ } else {
95
+ // Simple function visitor
96
+ visitor[key] = function(path, state) {
97
+ callVisitor.call(this, 'enter', key, path, state);
98
+ };
99
+ }
100
+ }
101
+
102
+ return {
103
+ name: 'xds-babel-plugin',
104
+ visitor,
105
+ };
106
+ };
@@ -0,0 +1,73 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file babel.test.mjs
5
+ * @description Verifies that the XDS babel wrapper applies the configured
6
+ * library StyleX class-name prefix to XDS library files. Part of the
7
+ * the library atom prefix defaults to `astryx` and is configurable
8
+ * before the final cutover.
9
+ */
10
+
11
+ import {describe, it, expect} from 'vitest';
12
+ import {createRequire} from 'node:module';
13
+
14
+ const require = createRequire(import.meta.url);
15
+ const babel = require('@babel/core');
16
+ const xdsBabelPlugin = require('./babel.js');
17
+
18
+ const SOURCE = `
19
+ import * as stylex from '@stylexjs/stylex';
20
+ export const styles = stylex.create({
21
+ box: {color: 'red'},
22
+ });
23
+ `;
24
+
25
+ /**
26
+ * Transform a StyleX source through the XDS babel wrapper as if it were a
27
+ * library file, returning the emitted code. `libraryPrefix` controls the
28
+ * atomic class-name prefix for library files.
29
+ */
30
+ function transformLibraryFile(libraryPrefix) {
31
+ const result = babel.transformSync(SOURCE, {
32
+ // A path matching one of the library patterns so the wrapper routes it
33
+ // through the library plugin instance.
34
+ filename: 'node_modules/@astryxdesign/core/src/Box/XDSBox.tsx',
35
+ babelrc: false,
36
+ configFile: false,
37
+ plugins: [
38
+ [
39
+ xdsBabelPlugin,
40
+ {
41
+ ...(libraryPrefix ? {libraryPrefix} : {}),
42
+ unstable_moduleResolution: {type: 'commonJS', rootDir: process.cwd()},
43
+ },
44
+ ],
45
+ ],
46
+ });
47
+ return result?.code ?? '';
48
+ }
49
+
50
+ /** Extract the StyleX atomic class names referenced in the emitted code. */
51
+ function atomicClasses(code) {
52
+ // StyleX emits atoms like "xds1a2b3c" / "astryx1a2b3c" in the compiled output.
53
+ return code.match(/\b(?:xds|astryx|lib)[a-z0-9]{4,}\b/g) ?? [];
54
+ }
55
+
56
+ describe('xds babel wrapper -- library StyleX prefix', () => {
57
+ it('defaults library atoms to the `astryx` prefix', () => {
58
+ const code = transformLibraryFile(undefined);
59
+ const atoms = atomicClasses(code);
60
+ expect(atoms.length).toBeGreaterThan(0);
61
+ expect(atoms.every(c => c.startsWith('astryx'))).toBe(true);
62
+ expect(
63
+ atoms.some(c => c.startsWith('xds') && !c.startsWith('astryx')),
64
+ ).toBe(false);
65
+ });
66
+
67
+ it('uses a configured custom prefix for library atoms', () => {
68
+ const code = transformLibraryFile('lib');
69
+ const atoms = atomicClasses(code);
70
+ expect(atoms.length).toBeGreaterThan(0);
71
+ expect(atoms.every(c => c.startsWith('lib'))).toBe(true);
72
+ });
73
+ });
package/src/config.js ADDED
@@ -0,0 +1,92 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ "use strict";
4
+
5
+ /**
6
+ * @astryxdesign/build
7
+ *
8
+ * Unified build configuration for Astryx source builds.
9
+ *
10
+ * Usage:
11
+ * // babel.config.js
12
+ * const {babel} = require('@astryxdesign/build');
13
+ * module.exports = babel(__dirname);
14
+ *
15
+ * // postcss.config.js
16
+ * const {postcss} = require('@astryxdesign/build');
17
+ * module.exports = postcss(__dirname);
18
+ */
19
+
20
+ const path = require('node:path');
21
+
22
+ /**
23
+ * Resolve Astryx package aliases from a root directory.
24
+ * Handles both npm installs (node_modules/@astryxdesign/core) and
25
+ * monorepo layouts (packages/core).
26
+ */
27
+ function resolveAliases(rootDir) {
28
+ const coreDir = path.join(rootDir, 'node_modules/@astryxdesign/core');
29
+ return {
30
+ '@astryxdesign/core/*': [path.join(coreDir, '*')],
31
+ '@astryxdesign/core': [coreDir],
32
+ };
33
+ }
34
+
35
+ /**
36
+ * Build shared StyleX babel plugin options from a root directory.
37
+ */
38
+ function stylexOptions(rootDir, overrides = {}) {
39
+ return {
40
+ dev: process.env.NODE_ENV !== 'production',
41
+ runtimeInjection: false,
42
+ enableInlinedConditionalMerge: true,
43
+ treeshakeCompensation: true,
44
+ aliases: resolveAliases(rootDir),
45
+ unstable_moduleResolution: {
46
+ type: 'commonJS',
47
+ },
48
+ ...overrides,
49
+ };
50
+ }
51
+
52
+ /**
53
+ * Generate a complete babel.config.js for Astryx source builds.
54
+ *
55
+ * @param {string} rootDir — __dirname of the project root
56
+ * @param {object} [overrides] — extra StyleX options to merge
57
+ * @returns {object} babel config object
58
+ */
59
+ function babel(rootDir, overrides = {}) {
60
+ return {
61
+ presets: ['next/babel'],
62
+ plugins: [
63
+ [require.resolve('./babel.js'), stylexOptions(rootDir, overrides)],
64
+ ],
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Generate a complete postcss.config.js for Astryx source builds.
70
+ *
71
+ * @param {string} rootDir — __dirname of the project root
72
+ * @param {object} [overrides] — extra options (appDir, extraInclude, etc.)
73
+ * @returns {object} postcss config object
74
+ */
75
+ function postcss(rootDir, overrides = {}) {
76
+ const {appDir = 'src', extraInclude = [], ...rest} = overrides;
77
+ return {
78
+ plugins: {
79
+ [require.resolve('./index.js')]: {
80
+ cwd: rootDir,
81
+ appDir,
82
+ babelPlugins: [
83
+ ['@stylexjs/babel-plugin', stylexOptions(rootDir, rest)],
84
+ ],
85
+ extraInclude,
86
+ },
87
+ autoprefixer: {},
88
+ },
89
+ };
90
+ }
91
+
92
+ module.exports = {babel, postcss, stylexOptions, resolveAliases};
package/src/index.js ADDED
@@ -0,0 +1,251 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ "use strict";
4
+
5
+ /**
6
+ * @astryxdesign/postcss-plugin
7
+ *
8
+ * PostCSS plugin for Astryx source builds. Compiles StyleX from both
9
+ * Astryx library source and product code in two separate passes with
10
+ * different class name prefixes, then outputs them in separate layers:
11
+ *
12
+ * reset < astryx-base (library, prefix: 'astryx') < astryx-theme < product (prefix: 'x')
13
+ *
14
+ * The separate prefixes ensure atomic classes don't collide between
15
+ * layers, which would break theme overrides.
16
+ */
17
+
18
+ const path = require('node:path');
19
+ const fs = require('node:fs');
20
+ const postcss = require('postcss');
21
+ const babel = require('@babel/core');
22
+ const stylexBabelPlugin = require('@stylexjs/babel-plugin');
23
+ const {globSync} = require('fast-glob');
24
+ const isGlob = require('is-glob');
25
+ const globParent = require('glob-parent');
26
+
27
+ const PLUGIN_NAME = '@astryxdesign/postcss-plugin';
28
+
29
+ const LIBRARY_GLOB = 'node_modules/@astryxdesign/**/*.{ts,tsx}';
30
+ const LIBRARY_PATTERNS = ['node_modules/@astryxdesign/', 'packages/core/', 'packages/themes/'];
31
+ const STYLEX_IMPORT_SOURCE = '@stylexjs/stylex';
32
+
33
+ function parseDependency(fileOrGlob, cwd) {
34
+ if (fileOrGlob.startsWith('!')) return null;
35
+ if (isGlob(fileOrGlob)) {
36
+ const base = globParent(fileOrGlob);
37
+ let glob = fileOrGlob.substring(base === '.' ? 0 : base.length);
38
+ if (glob.charAt(0) === '/') glob = glob.substring(1);
39
+ return {
40
+ type: 'dir-dependency',
41
+ dir: path.normalize(path.resolve(cwd, base)),
42
+ glob,
43
+ };
44
+ }
45
+ return {
46
+ type: 'dependency',
47
+ file: path.normalize(path.resolve(cwd, fileOrGlob)),
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Clone a babel plugins array, overriding the classNamePrefix on the
53
+ * StyleX babel plugin entry.
54
+ */
55
+ function withClassNamePrefix(plugins, prefix) {
56
+ return plugins.map(p => {
57
+ if (!Array.isArray(p)) return p;
58
+ const [pluginPath, opts] = p;
59
+ // Match @stylexjs/babel-plugin by name
60
+ const name = typeof pluginPath === 'string' ? pluginPath : '';
61
+ if (name.includes('@stylexjs/babel-plugin') || name.includes('stylex')) {
62
+ return [pluginPath, {...opts, classNamePrefix: prefix}];
63
+ }
64
+ return p;
65
+ });
66
+ }
67
+
68
+ function createPlugin() {
69
+ const isDev = process.env.NODE_ENV === 'development';
70
+
71
+ // Separate rule maps for library and product
72
+ const libraryRulesMap = new Map();
73
+ const productRulesMap = new Map();
74
+ const fileModifiedMap = new Map();
75
+
76
+ const plugin = ({
77
+ cwd = process.cwd(),
78
+ appDir = 'src',
79
+ babelPlugins = [],
80
+ layers = {
81
+ library: 'astryx-base',
82
+ product: 'product',
83
+ },
84
+ // Class name prefix for library styles (product keeps default 'x')
85
+ libraryPrefix = 'astryx',
86
+ extraInclude = [],
87
+ libraryPatterns = LIBRARY_PATTERNS,
88
+ exclude = [],
89
+ }) => {
90
+ const include = [
91
+ `${appDir}/**/*.{js,jsx,ts,tsx}`,
92
+ LIBRARY_GLOB,
93
+ ...extraInclude,
94
+ ];
95
+
96
+ const excludeWithDefaults = ['**/*.d.ts', '**/*.flow', ...exclude];
97
+
98
+ // Two babel configs — different classNamePrefix
99
+ const libraryBabelConfig = {
100
+ babelrc: false,
101
+ parserOpts: {plugins: ['typescript', 'jsx']},
102
+ plugins: withClassNamePrefix(babelPlugins, libraryPrefix),
103
+ };
104
+
105
+ const productBabelConfig = {
106
+ babelrc: false,
107
+ parserOpts: {plugins: ['typescript', 'jsx']},
108
+ plugins: babelPlugins, // keeps default 'x' prefix
109
+ };
110
+
111
+ let shouldSkipTransformError = false;
112
+
113
+ return {
114
+ postcssPlugin: PLUGIN_NAME,
115
+ plugins: [
116
+ async function (root, result) {
117
+ const fileName = result.opts.from;
118
+
119
+ let styleXAtRule = null;
120
+ root.walkAtRules((atRule) => {
121
+ if (atRule.name === 'stylex' && !atRule.params) {
122
+ styleXAtRule = atRule;
123
+ }
124
+ });
125
+ if (styleXAtRule == null) return;
126
+
127
+ // Discover files
128
+ const files = new Set();
129
+ for (const pattern of include) {
130
+ const matched = globSync(pattern, {
131
+ onlyFiles: true,
132
+ ignore: excludeWithDefaults,
133
+ cwd,
134
+ });
135
+ for (const f of matched) {
136
+ files.add(path.normalize(path.resolve(cwd, f)));
137
+ }
138
+ }
139
+
140
+ // Watch dependencies
141
+ for (const pattern of include) {
142
+ const dep = parseDependency(pattern, cwd);
143
+ if (dep) {
144
+ result.messages.push({
145
+ plugin: PLUGIN_NAME,
146
+ parent: fileName,
147
+ ...dep,
148
+ });
149
+ }
150
+ }
151
+
152
+ // Remove deleted files
153
+ for (const f of fileModifiedMap.keys()) {
154
+ if (!files.has(f)) {
155
+ fileModifiedMap.delete(f);
156
+ libraryRulesMap.delete(f);
157
+ productRulesMap.delete(f);
158
+ }
159
+ }
160
+
161
+ // Partition files into library vs product, then compile
162
+ // each group with its own babel config (different prefix)
163
+ const transforms = [];
164
+ for (const filePath of files) {
165
+ const mtimeMs = fs.existsSync(filePath)
166
+ ? fs.statSync(filePath).mtimeMs
167
+ : -Infinity;
168
+ if (
169
+ fileModifiedMap.has(filePath) &&
170
+ mtimeMs === fileModifiedMap.get(filePath)
171
+ ) {
172
+ continue;
173
+ }
174
+ fileModifiedMap.set(filePath, mtimeMs);
175
+
176
+ const contents = fs.readFileSync(filePath, 'utf-8');
177
+ if (!contents.includes(STYLEX_IMPORT_SOURCE)) continue;
178
+
179
+ const isLibrary = libraryPatterns.some(p => filePath.includes(p));
180
+ const config = isLibrary ? libraryBabelConfig : productBabelConfig;
181
+ const rulesMap = isLibrary ? libraryRulesMap : productRulesMap;
182
+
183
+ transforms.push(
184
+ babel
185
+ .transformAsync(contents, {
186
+ filename: filePath,
187
+ caller: {name: PLUGIN_NAME, platform: 'web', isDev},
188
+ ...config,
189
+ })
190
+ .then(({metadata}) => {
191
+ const stylex = metadata?.stylex;
192
+ if (stylex != null && stylex.length > 0) {
193
+ rulesMap.set(filePath, stylex);
194
+ }
195
+ })
196
+ .catch((error) => {
197
+ if (shouldSkipTransformError) {
198
+ console.warn(
199
+ `[${PLUGIN_NAME}] Failed to transform "${filePath}": ${error.message}`,
200
+ );
201
+ } else {
202
+ throw error;
203
+ }
204
+ }),
205
+ );
206
+ }
207
+ await Promise.all(transforms);
208
+
209
+ // Collect rules from each map
210
+ const libraryRules = Array.from(libraryRulesMap.values()).flat();
211
+ const productRules = Array.from(productRulesMap.values()).flat();
212
+
213
+ // Process each group separately
214
+ const libraryCss = libraryRules.length
215
+ ? stylexBabelPlugin.processStylexRules(libraryRules, {
216
+ useLayers: true,
217
+ })
218
+ : '';
219
+ const productCss = productRules.length
220
+ ? stylexBabelPlugin.processStylexRules(productRules, {
221
+ useLayers: true,
222
+ })
223
+ : '';
224
+
225
+ // Wrap in named layers
226
+ const parts = [];
227
+ if (libraryCss) {
228
+ parts.push(`@layer ${layers.library} {\n${libraryCss}\n}`);
229
+ }
230
+ if (productCss) {
231
+ parts.push(`@layer ${layers.product} {\n${productCss}\n}`);
232
+ }
233
+
234
+ const finalCss = parts.join('\n\n');
235
+ const parsed = await postcss.parse(finalCss, {from: fileName});
236
+ styleXAtRule.replaceWith(parsed);
237
+ result.root = root;
238
+
239
+ if (!shouldSkipTransformError) {
240
+ shouldSkipTransformError = true;
241
+ }
242
+ },
243
+ ],
244
+ };
245
+ };
246
+
247
+ plugin.postcss = true;
248
+ return plugin;
249
+ }
250
+
251
+ module.exports = createPlugin();
package/src/next.js ADDED
@@ -0,0 +1,66 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ "use strict";
4
+
5
+ /**
6
+ * @astryxdesign/build/next
7
+ *
8
+ * Next.js configuration helper for Astryx source builds.
9
+ *
10
+ * Usage in next.config.mjs:
11
+ * import {withXDS} from '@astryxdesign/build/next';
12
+ * export default withXDS({
13
+ * // your normal next config
14
+ * });
15
+ */
16
+
17
+ /**
18
+ * Wraps a Next.js config to enable Astryx source builds.
19
+ * - Adds transpilePackages for @astryxdesign/* packages
20
+ * - Sets conditionNames to resolve source exports
21
+ */
22
+ function withXDS(nextConfig = {}) {
23
+ const xdsPackages = [
24
+ '@astryxdesign/core',
25
+ '@astryxdesign/theme-default',
26
+ '@astryxdesign/theme-neutral',
27
+ '@astryxdesign/theme-brutalist',
28
+ '@astryxdesign/theme-daily',
29
+ '@astryxdesign/lab',
30
+ ];
31
+
32
+ const existingTranspile = nextConfig.transpilePackages || [];
33
+ const merged = Array.from(new Set([...existingTranspile, ...xdsPackages]));
34
+
35
+ const existingWebpack = nextConfig.webpack;
36
+
37
+ return {
38
+ ...nextConfig,
39
+ transpilePackages: merged,
40
+ webpack: (config, context) => {
41
+ // Resolve to source exports
42
+ config.resolve.conditionNames = [
43
+ 'source',
44
+ 'import',
45
+ 'require',
46
+ 'default',
47
+ ];
48
+
49
+ // Preserve the symlinked node_modules path so Next.js's
50
+ // transpilePackages matcher recognizes @astryxdesign/* packages under
51
+ // pnpm's symlinked layout. Without this, webpack dereferences
52
+ // the symlink to packages/<name>/... which doesn't contain
53
+ // "node_modules/@astryxdesign" and transpilation is silently skipped,
54
+ // breaking subpath imports like '@astryxdesign/core/AlertDialog'.
55
+ config.resolve.symlinks = false;
56
+
57
+ // Call user's webpack config if provided
58
+ if (existingWebpack) {
59
+ return existingWebpack(config, context);
60
+ }
61
+ return config;
62
+ },
63
+ };
64
+ }
65
+
66
+ module.exports = {withXDS};
@@ -0,0 +1,45 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file vite.test.ts
5
+ * @description Verifies CSS layer-order injection in the XDS Vite plugin.
6
+ * The library layer name is configurable (default `astryx-base`); the
7
+ * theme layer name is fixed at `astryx-theme`.
8
+ */
9
+
10
+ import {describe, it, expect} from 'vitest';
11
+ import {xdsStylex} from './vite';
12
+
13
+ /** Pull the injected `@layer ...;` order statement out of the plugin set. */
14
+ function getLayerOrder(plugins: ReturnType<typeof xdsStylex>): string {
15
+ const layerPlugin = plugins.find(p => p.name === 'xds-css-layer-order');
16
+ expect(layerPlugin, 'xds-css-layer-order plugin should exist').toBeTruthy();
17
+ const transform = (layerPlugin as any).transformIndexHtml;
18
+ const tags =
19
+ typeof transform === 'function' ? transform() : transform.handler();
20
+ const styleTag = tags.find((t: any) => t.tag === 'style');
21
+ expect(styleTag, 'a <style> tag should be injected').toBeTruthy();
22
+ return styleTag.children as string;
23
+ }
24
+
25
+ describe('xdsStylex layer order (modern API)', () => {
26
+ it('uses the astryx-* layer names (theme layer is astryx-theme)', () => {
27
+ const order = getLayerOrder(xdsStylex());
28
+ expect(order).toBe('@layer reset, astryx-base, astryx-theme, product;');
29
+ });
30
+
31
+ it('honors configured library and product layer names', () => {
32
+ const order = getLayerOrder(
33
+ xdsStylex({layers: {library: 'custom-base', product: 'app'}}),
34
+ );
35
+ // The theme layer stays astryx-theme regardless of other layer config.
36
+ expect(order).toBe('@layer reset, custom-base, astryx-theme, app;');
37
+ });
38
+ });
39
+
40
+ describe('xdsStylex layer order (legacy API)', () => {
41
+ it('uses the astryx-* layer names (theme layer is astryx-theme)', () => {
42
+ const order = getLayerOrder(xdsStylex({stylexOptions: {}}));
43
+ expect(order).toBe('@layer reset, astryx-base, astryx-theme, product;');
44
+ });
45
+ });