@astryxdesign/build 0.7.6-canary.f4abc97 → 0.7.6-canary.f73623d

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/README.md CHANGED
@@ -239,6 +239,8 @@ Wraps `@stylexjs/unplugin` and intercepts the dev CSS endpoint (`/virtual:stylex
239
239
 
240
240
  Unsupported declarations fail the build by default with StyleX's replacement guidance. Set `propertyValidationMode` explicitly in `stylexOverrides` (modern API) or `stylexOptions` (legacy API) only when a migration needs the upstream `warn` or `silent` behavior.
241
241
 
242
+ A production build also checks the StyleX stylesheet for generated custom properties (`--<prefix><hash>`) that it reads but no stylesheet in the build declares, which browsers drop: a `.stylex.ts` export read in a style that is not a `defineVars`/`defineConsts` value, or one whose module never reached the build. It warns, naming the variable and the rule that reads it. Only stylesheets count as declarations, so a property the app sets at runtime (an inline style) is reported too: give it a name outside the StyleX class-name prefixes.
243
+
242
244
  ---
243
245
 
244
246
  ## Advanced Options
@@ -0,0 +1,32 @@
1
+ /**
2
+ * @file undefinedVariables.ts
3
+ * @input A stylesheet's text, the StyleX class-name prefixes, and the other
4
+ * stylesheets the build emits
5
+ * @output undefinedGeneratedVariables, the generated custom properties the
6
+ * stylesheet reads but never declares
7
+ * @position Internal to @astryxdesign/build: astryxStylex (vite.ts) runs it
8
+ * on a production stylesheet and warns about what it finds. Not exported
9
+ * from any package entry.
10
+ */
11
+ /**
12
+ * The StyleX-generated custom properties `css` reads that it never declares.
13
+ *
14
+ * StyleX names the variables it generates `--<prefix><hash>` (a theme
15
+ * variable, or a constant waiting for its value). One that is read but never
16
+ * declared is a value that compiled to a variable nothing sets, so browsers
17
+ * drop the declaration or take its fallback: a constant whose defining module
18
+ * never reached the build, or a plain export read from a `.stylex.ts` file as
19
+ * if it were a token. The dynamic-style variables StyleX sets inline
20
+ * (`--<prefix>-<property>`) and component variables with a dash after the
21
+ * prefix are not generated names and are never reported.
22
+ *
23
+ * Only a hash-shaped name counts as generated: the prefix and four to seven
24
+ * base-36 characters (StyleX's hash is a 32-bit value in base 36, shorter than
25
+ * four characters about once in a hundred thousand). An app's own short names
26
+ * (`--xl`) are never reported, and a property declared in any stylesheet the
27
+ * build emits (`others`, the app's own CSS) is declared.
28
+ *
29
+ * SYNC: scripts/build-css.mjs `undefinedStyleXVariables` is the same check
30
+ * for the prebuilt package stylesheets.
31
+ */
32
+ export declare function undefinedGeneratedVariables(css: string, prefixes: readonly string[], others?: readonly string[]): string[];
package/dist/vite.mjs CHANGED
@@ -8,6 +8,26 @@ import path from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { createRequire } from "node:module";
10
10
  import { createHash } from "node:crypto";
11
+
12
+ // src/undefinedVariables.ts
13
+ function undefinedGeneratedVariables(css, prefixes, others = []) {
14
+ const names = prefixes.map((prefix) => prefix.replace(/[^a-zA-Z0-9_-]/g, "")).filter(Boolean).join("|");
15
+ const declared = new Set(
16
+ [css, ...others].flatMap(
17
+ (text) => [...text.matchAll(/(--[A-Za-z0-9_-]+)\s*:/g)].map((match) => match[1])
18
+ )
19
+ );
20
+ const read = new Set(
21
+ [
22
+ ...css.matchAll(
23
+ new RegExp(`var\\(\\s*(--(?:${names})[a-z0-9]{4,7})\\s*[,)]`, "g")
24
+ )
25
+ ].map((match) => match[1])
26
+ );
27
+ return [...read].filter((name) => !declared.has(name)).sort();
28
+ }
29
+
30
+ // src/vite.ts
11
31
  var __dirname = path.dirname(fileURLToPath(import.meta.url));
12
32
  var LIBRARY_PATTERN = "node_modules/@astryxdesign/";
13
33
  var STYLEX_CSS_PATH = "/virtual:stylex.css";
@@ -32,6 +52,10 @@ function astryxStylex(options = {}) {
32
52
  } = opts;
33
53
  const libraryLayer = layers.library ?? "astryx-base";
34
54
  const productLayer = layers.product ?? "product";
55
+ const classNamePrefixes = [
56
+ stylexPrefix,
57
+ stylexOverrides.classNamePrefix ?? "x"
58
+ ];
35
59
  const overrideLightningcssOptions = stylexOverrides.lightningcssOptions;
36
60
  const stylexOptions = {
37
61
  dev,
@@ -148,7 +172,8 @@ function astryxStylex(options = {}) {
148
172
  renderSplitLayers(rulesById, {
149
173
  libraryPattern,
150
174
  libraryLayer,
151
- productLayer
175
+ productLayer,
176
+ classNamePrefixes
152
177
  })
153
178
  );
154
179
  }
@@ -165,7 +190,8 @@ function astryxStylex(options = {}) {
165
190
  libraryPattern,
166
191
  libraryLayer,
167
192
  productLayer,
168
- lightningcssOptions: stylexOptions.lightningcssOptions
193
+ lightningcssOptions: stylexOptions.lightningcssOptions,
194
+ classNamePrefixes
169
195
  })
170
196
  ];
171
197
  }
@@ -173,21 +199,57 @@ function renderSplitLayers(rulesById, options) {
173
199
  if (!rulesById || rulesById.size === 0) return "";
174
200
  const libraryRules = [];
175
201
  const productRules = [];
202
+ const collectedConstants = [];
176
203
  for (const [filePath, rules] of rulesById.entries()) {
177
- if (filePath.includes(options.libraryPattern)) {
178
- libraryRules.push(...rules);
179
- } else {
180
- productRules.push(...rules);
204
+ const own = filePath.includes(options.libraryPattern) ? libraryRules : productRules;
205
+ for (const rule of rules) {
206
+ if (isConstantRule(rule)) {
207
+ collectedConstants.push(rule);
208
+ } else {
209
+ own.push(rule);
210
+ }
181
211
  }
182
212
  }
213
+ const constantRules = constantRulesForEveryPrefix(
214
+ collectedConstants,
215
+ options.classNamePrefixes
216
+ );
183
217
  const render = (rules, layer) => rules.length ? `@layer ${layer} {
184
- ${stylexBabelPlugin.processStylexRules(rules, { useLayers: true })}
218
+ ${stylexBabelPlugin.processStylexRules([...constantRules, ...rules], { useLayers: true })}
185
219
  }` : "";
186
220
  return [
187
221
  render(libraryRules, options.libraryLayer),
188
222
  render(productRules, options.productLayer)
189
223
  ].filter(Boolean).join("\n\n");
190
224
  }
225
+ function constantRulesForEveryPrefix(rules, prefixes) {
226
+ const ordered = [...new Set(prefixes)].filter(Boolean).sort((a, b) => b.length - a.length);
227
+ const out = [];
228
+ const keys = /* @__PURE__ */ new Set();
229
+ const add = (rule, key) => {
230
+ if (keys.has(key)) return;
231
+ keys.add(key);
232
+ const [, body, ...rest] = rule;
233
+ out.push([key, { ...body, constKey: key }, ...rest]);
234
+ };
235
+ for (const rule of rules) {
236
+ const key = String(rule[1].constKey);
237
+ add(rule, key);
238
+ for (const prefix of ordered) {
239
+ const match = new RegExp(`^(|.*-)${prefix}([0-9a-z]+)$`).exec(key);
240
+ if (match == null) continue;
241
+ for (const other of ordered) {
242
+ add(rule, `${match[1]}${other}${match[2]}`);
243
+ }
244
+ break;
245
+ }
246
+ }
247
+ return out;
248
+ }
249
+ function isConstantRule(rule) {
250
+ const body = Array.isArray(rule) ? rule[1] : void 0;
251
+ return body?.constKey != null && body?.constVal != null;
252
+ }
191
253
  function buildLayerSplitPlugin(basePlugin, options) {
192
254
  const stylex2 = basePlugin;
193
255
  let base = "/";
@@ -198,7 +260,7 @@ function buildLayerSplitPlugin(basePlugin, options) {
198
260
  configResolved(config) {
199
261
  base = config.base ?? "/";
200
262
  },
201
- writeBundle(outputOptions) {
263
+ writeBundle(outputOptions, bundle) {
202
264
  const rulesById = stylex2.__stylexGetSharedStore?.().rulesById;
203
265
  if (!rulesById || rulesById.size === 0) return;
204
266
  const merged = stylex2.__stylexCollectCss?.();
@@ -207,6 +269,24 @@ function buildLayerSplitPlugin(basePlugin, options) {
207
269
  renderSplitLayers(rulesById, options),
208
270
  options.lightningcssOptions
209
271
  );
272
+ const emittedCss = Object.values(bundle).filter(
273
+ (output) => output.type === "asset" && output.fileName.endsWith(".css")
274
+ ).map(
275
+ (asset) => typeof asset.source === "string" ? asset.source : Buffer.from(asset.source).toString("utf8")
276
+ );
277
+ const undeclared = undefinedGeneratedVariables(
278
+ split,
279
+ options.classNamePrefixes,
280
+ emittedCss
281
+ );
282
+ if (undeclared.length > 0) {
283
+ this.warn(
284
+ `astryx-build-layer-split: the StyleX stylesheet reads ${undeclared.length} generated custom propert${undeclared.length === 1 ? "y" : "ies"} that no stylesheet in the build declares:
285
+ ` + undeclared.map(
286
+ (name) => ` ${name}, read by: ${rulesReading(split, name).join(" ") || "(rule not found)"}`
287
+ ).join("\n") + "\nWith no declaration, browsers drop the declarations that read it. A `.stylex.ts` export read in a style must be a `defineVars`/`defineConsts` value whose module reaches the build; a property the app sets at runtime (an inline style) needs a name outside the StyleX class-name prefixes."
288
+ );
289
+ }
210
290
  const outDir = outputOptions.dir ? outputOptions.dir : outputOptions.file ? path.dirname(outputOptions.file) : null;
211
291
  if (!outDir || !fs.existsSync(outDir)) return;
212
292
  const containingFiles = [];
@@ -247,6 +327,9 @@ function buildLayerSplitPlugin(basePlugin, options) {
247
327
  }
248
328
  };
249
329
  }
330
+ function rulesReading(css, name) {
331
+ return [...css.matchAll(/[^{}]+\{[^{}]*\}/g)].map((match) => match[0].trim().replace(/\s+/g, " ")).filter((rule) => rule.includes(`var(${name}`)).slice(0, 3);
332
+ }
250
333
  function listCssFiles(dir) {
251
334
  const out = [];
252
335
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
@@ -310,12 +393,24 @@ function astryxStylexLegacy(options) {
310
393
  const legacyLightningcssOptions = stylexOptions.lightningcssOptions;
311
394
  const validatedStylexOptions = {
312
395
  propertyValidationMode: "throw",
396
+ // The unplugin applies its Vite default (true) only to its own StyleX
397
+ // instance, which runs after the babel wrapper below has already compiled
398
+ // every StyleX call. Without it here, a file that reads only `defineConsts`
399
+ // from a `.stylex.ts` module loses that import once `stylex.create` is
400
+ // compiled (the TypeScript transform drops it as unused), the module is
401
+ // never loaded, and its constants never reach the CSS: a media-query key
402
+ // fails the build and a value ships as an undefined custom property.
403
+ treeshakeCompensation: true,
313
404
  ...stylexOptions,
314
405
  lightningcssOptions: {
315
406
  targets: LIGHTNINGCSS_TARGETS,
316
407
  ...legacyLightningcssOptions
317
408
  }
318
409
  };
410
+ const classNamePrefixes = [
411
+ stylexPrefix,
412
+ validatedStylexOptions.classNamePrefix ?? "x"
413
+ ];
319
414
  const astryxBabelPlugin = path.resolve(__dirname, "../src/babel.js");
320
415
  const existingPlugins = validatedStylexOptions.babelConfig?.plugins ?? [];
321
416
  const basePlugin = stylex.vite({
@@ -373,7 +468,8 @@ function astryxStylexLegacy(options) {
373
468
  renderSplitLayers(rulesById, {
374
469
  libraryPattern,
375
470
  libraryLayer,
376
- productLayer
471
+ productLayer,
472
+ classNamePrefixes
377
473
  })
378
474
  );
379
475
  }
@@ -389,7 +485,8 @@ function astryxStylexLegacy(options) {
389
485
  libraryPattern,
390
486
  libraryLayer,
391
487
  productLayer,
392
- lightningcssOptions: validatedStylexOptions.lightningcssOptions
488
+ lightningcssOptions: validatedStylexOptions.lightningcssOptions,
489
+ classNamePrefixes
393
490
  })
394
491
  ];
395
492
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/build",
3
- "version": "0.7.6-canary.f4abc97",
3
+ "version": "0.7.6-canary.f73623d",
4
4
  "description": "Build plugins for Astryx source builds — babel, PostCSS, and Vite integrations",
5
5
  "author": "Meta Open Source",
6
6
  "license": "MIT",
@@ -0,0 +1,57 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file undefinedVariables.ts
5
+ * @input A stylesheet's text, the StyleX class-name prefixes, and the other
6
+ * stylesheets the build emits
7
+ * @output undefinedGeneratedVariables, the generated custom properties the
8
+ * stylesheet reads but never declares
9
+ * @position Internal to @astryxdesign/build: astryxStylex (vite.ts) runs it
10
+ * on a production stylesheet and warns about what it finds. Not exported
11
+ * from any package entry.
12
+ */
13
+
14
+ /**
15
+ * The StyleX-generated custom properties `css` reads that it never declares.
16
+ *
17
+ * StyleX names the variables it generates `--<prefix><hash>` (a theme
18
+ * variable, or a constant waiting for its value). One that is read but never
19
+ * declared is a value that compiled to a variable nothing sets, so browsers
20
+ * drop the declaration or take its fallback: a constant whose defining module
21
+ * never reached the build, or a plain export read from a `.stylex.ts` file as
22
+ * if it were a token. The dynamic-style variables StyleX sets inline
23
+ * (`--<prefix>-<property>`) and component variables with a dash after the
24
+ * prefix are not generated names and are never reported.
25
+ *
26
+ * Only a hash-shaped name counts as generated: the prefix and four to seven
27
+ * base-36 characters (StyleX's hash is a 32-bit value in base 36, shorter than
28
+ * four characters about once in a hundred thousand). An app's own short names
29
+ * (`--xl`) are never reported, and a property declared in any stylesheet the
30
+ * build emits (`others`, the app's own CSS) is declared.
31
+ *
32
+ * SYNC: scripts/build-css.mjs `undefinedStyleXVariables` is the same check
33
+ * for the prebuilt package stylesheets.
34
+ */
35
+ export function undefinedGeneratedVariables(
36
+ css: string,
37
+ prefixes: readonly string[],
38
+ others: readonly string[] = [],
39
+ ): string[] {
40
+ const names = prefixes
41
+ .map(prefix => prefix.replace(/[^a-zA-Z0-9_-]/g, ''))
42
+ .filter(Boolean)
43
+ .join('|');
44
+ const declared = new Set(
45
+ [css, ...others].flatMap(text =>
46
+ [...text.matchAll(/(--[A-Za-z0-9_-]+)\s*:/g)].map(match => match[1]),
47
+ ),
48
+ );
49
+ const read = new Set(
50
+ [
51
+ ...css.matchAll(
52
+ new RegExp(`var\\(\\s*(--(?:${names})[a-z0-9]{4,7})\\s*[,)]`, 'g'),
53
+ ),
54
+ ].map(match => match[1]),
55
+ );
56
+ return [...read].filter(name => !declared.has(name)).sort();
57
+ }
@@ -31,7 +31,7 @@
31
31
  */
32
32
 
33
33
  import {describe, it, expect, beforeAll, afterAll} from 'vitest';
34
- import {build} from 'vite';
34
+ import {build, createLogger} from 'vite';
35
35
  import react from '@vitejs/plugin-react';
36
36
  import {
37
37
  mkdirSync,
@@ -70,6 +70,21 @@ type Built = {
70
70
  async function buildFixture(
71
71
  name: string,
72
72
  htmlNames: string[] = ['index.html'],
73
+ {
74
+ libraryPattern = 'packages/core/',
75
+ zeroConfig = false,
76
+ stylexOverrides,
77
+ warnings = [],
78
+ }: {
79
+ /** Which source paths land in the library layer. */
80
+ libraryPattern?: string;
81
+ /** Use `astryxStylex({...})` instead of the legacy `{stylexOptions}` form. */
82
+ zeroConfig?: boolean;
83
+ /** Zero-config form only: `stylexOverrides`. */
84
+ stylexOverrides?: Record<string, unknown>;
85
+ /** Receives every warning the build logs. */
86
+ warnings?: string[];
87
+ } = {},
73
88
  ): Promise<Built> {
74
89
  const root = path.join(FIXTURES, name);
75
90
  const outDir = mkdtempSync(path.join(tmpdir(), `astryx-${name}-`));
@@ -77,6 +92,11 @@ async function buildFixture(
77
92
  await build({
78
93
  root,
79
94
  logLevel: 'error',
95
+ customLogger: {
96
+ ...createLogger('error'),
97
+ warn: (message: string) => void warnings.push(message),
98
+ warnOnce: (message: string) => void warnings.push(message),
99
+ },
80
100
  build: {
81
101
  outDir,
82
102
  emptyOutDir: true,
@@ -94,17 +114,27 @@ async function buildFixture(
94
114
  resolve: {alias: {'@astryxdesign/core': CORE_SRC}},
95
115
  plugins: [
96
116
  react(),
97
- ...astryxStylex({
98
- stylexOptions: {
99
- dev: false,
100
- unstable_moduleResolution: {type: 'commonJS', rootDir: REPO_ROOT},
101
- aliases: {
102
- '@astryxdesign/core/*': [path.join(CORE_SRC, '*')],
103
- '@astryxdesign/core': [CORE_SRC],
104
- },
105
- },
106
- libraryPattern: 'packages/core/',
107
- }),
117
+ ...(zeroConfig
118
+ ? astryxStylex({
119
+ dev: false,
120
+ rootDir: REPO_ROOT,
121
+ libraryPattern,
122
+ stylexOverrides,
123
+ })
124
+ : astryxStylex({
125
+ stylexOptions: {
126
+ dev: false,
127
+ unstable_moduleResolution: {
128
+ type: 'commonJS',
129
+ rootDir: REPO_ROOT,
130
+ },
131
+ aliases: {
132
+ '@astryxdesign/core/*': [path.join(CORE_SRC, '*')],
133
+ '@astryxdesign/core': [CORE_SRC],
134
+ },
135
+ },
136
+ libraryPattern,
137
+ })),
108
138
  ],
109
139
  });
110
140
  } catch (error) {
@@ -338,6 +368,105 @@ describe('a production build separates Astryx and product styles by layer', () =
338
368
  });
339
369
  });
340
370
 
371
+ // `defineConsts` values are compiled into the reading file as placeholders,
372
+ // `var(--<prefix><hash>)`, and the defining file contributes the values; the
373
+ // final CSS pass substitutes one for the other. Both halves must reach that
374
+ // pass. Storybook builds through the legacy `{stylexOptions}` form, whose
375
+ // StyleX instance ran without `treeshakeCompensation`: once `stylex.create`
376
+ // was compiled, nothing used the import at runtime, the TypeScript transform
377
+ // dropped it, and the defining module was never loaded. A media-query key was
378
+ // left as `var(--x…){…}` and failed the build ("Invalid empty selector"); a
379
+ // value shipped as a custom property nothing defines. And a constant defined
380
+ // on one side of the layer split and read on the other was substituted only
381
+ // within its own side, and one an Astryx package defines (compiled with the
382
+ // library class-name prefix) never matched an app file's placeholder (written
383
+ // with the product prefix).
384
+ describe('a production build resolves defineConsts imported across files', () => {
385
+ /** A constant reference StyleX left unsubstituted. */
386
+ const PLACEHOLDER = /var\(--(?:astryx|x)[a-z0-9]{4,}\)/;
387
+
388
+ for (const zeroConfig of [false, true]) {
389
+ describe(
390
+ zeroConfig ? 'zero-config form' : 'legacy stylexOptions form (Storybook)',
391
+ () => {
392
+ let built: Built;
393
+ beforeAll(async () => {
394
+ built = await buildFixture('stylex-consts', ['index.html'], {
395
+ libraryPattern: '__fixtures__/stylex-consts/library/',
396
+ zeroConfig,
397
+ });
398
+ }, 180_000);
399
+ afterAll(
400
+ () => built && rmSync(built.outDir, {recursive: true, force: true}),
401
+ );
402
+
403
+ it('substitutes a constant read as a value', () => {
404
+ expect(built.css).toMatch(/min-height:\s*52px/);
405
+ });
406
+
407
+ it('substitutes a constant read as a media-query key', () => {
408
+ expect(built.css).toMatch(
409
+ /@media \((?:max-width: ?599px|width ?<= ?599px)\)\s*\{[^}]*transition-property:\s*none/,
410
+ );
411
+ });
412
+
413
+ it('substitutes a library-side constant read from the product side', () => {
414
+ expect(built.css).toMatch(/padding-top:\s*13px/);
415
+ expect(built.css).toMatch(
416
+ /@media \((?:min-width: ?900px|width ?>= ?900px)\)\s*\{[^}]*max-width:\s*640px/,
417
+ );
418
+ });
419
+
420
+ it('substitutes a constant an Astryx package defines under the library prefix', () => {
421
+ expect(built.css).toMatch(/padding-bottom:\s*17px/);
422
+ });
423
+
424
+ it('leaves no constant placeholder behind', () => {
425
+ expect(built.css).not.toMatch(PLACEHOLDER);
426
+ });
427
+ },
428
+ );
429
+ }
430
+ });
431
+
432
+ describe('a production build checks the custom properties its stylesheet reads', () => {
433
+ const MESSAGE =
434
+ /reads 1 generated custom property that no stylesheet in the build declares[\s\S]*--x[a-z0-9]+, read by: [^\n]*row-gap/;
435
+
436
+ it('warns, naming the variable and the rule that reads it', async () => {
437
+ const warnings: string[] = [];
438
+ const built = await buildFixture('stylex-undefined-var', ['index.html'], {
439
+ warnings,
440
+ });
441
+ rmSync(built.outDir, {recursive: true, force: true});
442
+ expect(warnings.join('\n')).toMatch(MESSAGE);
443
+ }, 180_000);
444
+
445
+ it('checks the product prefix a zero-config build sets in stylexOverrides', async () => {
446
+ const warnings: string[] = [];
447
+ const built = await buildFixture('stylex-undefined-var', ['index.html'], {
448
+ zeroConfig: true,
449
+ stylexOverrides: {classNamePrefix: 'app'},
450
+ warnings,
451
+ });
452
+ rmSync(built.outDir, {recursive: true, force: true});
453
+ expect(warnings.join('\n')).toMatch(
454
+ /reads 1 generated custom property that no stylesheet in the build declares[\s\S]*--app[a-z0-9]+, read by: [^\n]*row-gap/,
455
+ );
456
+ }, 180_000);
457
+
458
+ it("never reports an app's own custom properties, declared in its own CSS", async () => {
459
+ const warnings: string[] = [];
460
+ const built = await buildFixture('stylex-app-vars', ['index.html'], {
461
+ warnings,
462
+ });
463
+ rmSync(built.outDir, {recursive: true, force: true});
464
+ expect(warnings.join('\n')).not.toMatch(
465
+ /no stylesheet in the build declares/,
466
+ );
467
+ }, 180_000);
468
+ });
469
+
341
470
  describe('a production build validates StyleX declarations', () => {
342
471
  it('fails with longhand guidance instead of dropping a border shorthand', async () => {
343
472
  await expect(buildFixture('invalid-border')).rejects.toThrow(
package/src/vite.test.ts CHANGED
@@ -14,6 +14,7 @@ import path from 'node:path';
14
14
  import {fileURLToPath, pathToFileURL} from 'node:url';
15
15
  import stylex from '@stylexjs/unplugin';
16
16
  import {astryxStylex, LIGHTNINGCSS_TARGETS} from './vite';
17
+ import {undefinedGeneratedVariables} from './undefinedVariables';
17
18
 
18
19
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
19
20
 
@@ -272,3 +273,57 @@ describe('astryxStylex optimizeDeps package discovery', () => {
272
273
  }
273
274
  });
274
275
  });
276
+
277
+ describe('undefinedGeneratedVariables', () => {
278
+ it('reports a generated variable that is read but never declared', () => {
279
+ expect(
280
+ undefinedGeneratedVariables(
281
+ '.a{min-height:var(--astryx1abc2)}.b{color:var(--x9zz9z, red)}',
282
+ ['astryx', 'x'],
283
+ ),
284
+ ).toEqual(['--astryx1abc2', '--x9zz9z']);
285
+ });
286
+
287
+ it('accepts a variable declared anywhere in the stylesheet', () => {
288
+ expect(
289
+ undefinedGeneratedVariables(
290
+ ':root{--astryx1abc2:4px}.a{padding:var(--astryx1abc2)}',
291
+ ['astryx', 'x'],
292
+ ),
293
+ ).toEqual([]);
294
+ });
295
+
296
+ it('ignores dynamic-style and component variables, which carry a dash', () => {
297
+ expect(
298
+ undefinedGeneratedVariables(
299
+ '.a{width:var(--x-width)}.b{padding:var(--astryx-card-padding,8px)}',
300
+ ['astryx', 'x'],
301
+ ),
302
+ ).toEqual([]);
303
+ });
304
+
305
+ it('counts a declaration in any other stylesheet the build emits', () => {
306
+ expect(
307
+ undefinedGeneratedVariables(
308
+ '.a{padding:var(--xlarge)}',
309
+ ['astryx', 'x'],
310
+ [':root{--xlarge:24px}'],
311
+ ),
312
+ ).toEqual([]);
313
+ });
314
+
315
+ it('reports only hash-shaped names: four to seven characters after the prefix', () => {
316
+ expect(
317
+ undefinedGeneratedVariables(
318
+ '.a{margin:var(--xl)}.b{gap:var(--xs1)}.c{inset:var(--x1abc2de)}.d{top:var(--x1abc2def)}',
319
+ ['astryx', 'x'],
320
+ ),
321
+ ).toEqual(['--x1abc2de']);
322
+ });
323
+
324
+ it('checks only the prefixes it is given', () => {
325
+ expect(
326
+ undefinedGeneratedVariables('.a{color:var(--x1abc2)}', ['astryx']),
327
+ ).toEqual([]);
328
+ });
329
+ });
package/src/vite.ts CHANGED
@@ -8,6 +8,7 @@ import path from 'node:path';
8
8
  import {fileURLToPath} from 'node:url';
9
9
  import {createRequire} from 'node:module';
10
10
  import {createHash} from 'node:crypto';
11
+ import {undefinedGeneratedVariables} from './undefinedVariables';
11
12
 
12
13
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
14
 
@@ -136,6 +137,12 @@ export function astryxStylex(
136
137
 
137
138
  const libraryLayer = layers.library ?? 'astryx-base';
138
139
  const productLayer = layers.product ?? 'product';
140
+ // Library files compile with `stylexPrefix`, product files with StyleX's
141
+ // `classNamePrefix` (the babel wrapper routes them).
142
+ const classNamePrefixes = [
143
+ stylexPrefix,
144
+ (stylexOverrides as {classNamePrefix?: string}).classNamePrefix ?? 'x',
145
+ ];
139
146
 
140
147
  const overrideLightningcssOptions = (
141
148
  stylexOverrides as {lightningcssOptions?: Record<string, unknown>}
@@ -284,6 +291,7 @@ export function astryxStylex(
284
291
  libraryPattern,
285
292
  libraryLayer,
286
293
  productLayer,
294
+ classNamePrefixes,
287
295
  }),
288
296
  );
289
297
  },
@@ -302,6 +310,7 @@ export function astryxStylex(
302
310
  libraryLayer,
303
311
  productLayer,
304
312
  lightningcssOptions: stylexOptions.lightningcssOptions,
313
+ classNamePrefixes,
305
314
  }),
306
315
  ];
307
316
  }
@@ -330,24 +339,43 @@ function renderSplitLayers(
330
339
  libraryPattern: string;
331
340
  libraryLayer: string;
332
341
  productLayer: string;
342
+ /** The StyleX class-name prefixes in play: library, then product. */
343
+ classNamePrefixes: readonly string[];
333
344
  },
334
345
  ): string {
335
346
  if (!rulesById || rulesById.size === 0) return '';
336
347
 
337
348
  const libraryRules: unknown[] = [];
338
349
  const productRules: unknown[] = [];
350
+ // `defineConsts` values. A file that reads a constant compiles it to a
351
+ // placeholder, `var(--<prefix><hash>)`, and `processStylexRules` substitutes
352
+ // the value from the defining file's rules, but only among the rules it is
353
+ // given. The defining file and the reading one can sit on opposite sides of
354
+ // the split, so every layer gets every constant, under every class-name
355
+ // prefix (see constantRulesForEveryPrefix). Constant rules emit no CSS of
356
+ // their own, so repeating them adds nothing to the output.
357
+ const collectedConstants: unknown[] = [];
339
358
 
340
359
  for (const [filePath, rules] of rulesById.entries()) {
341
- if (filePath.includes(options.libraryPattern)) {
342
- libraryRules.push(...rules);
343
- } else {
344
- productRules.push(...rules);
360
+ const own = filePath.includes(options.libraryPattern)
361
+ ? libraryRules
362
+ : productRules;
363
+ for (const rule of rules) {
364
+ if (isConstantRule(rule)) {
365
+ collectedConstants.push(rule);
366
+ } else {
367
+ own.push(rule);
368
+ }
345
369
  }
346
370
  }
347
371
 
372
+ const constantRules = constantRulesForEveryPrefix(
373
+ collectedConstants,
374
+ options.classNamePrefixes,
375
+ );
348
376
  const render = (rules: unknown[], layer: string) =>
349
377
  rules.length
350
- ? `@layer ${layer} {\n${stylexBabelPlugin.processStylexRules(rules as never, {useLayers: true})}\n}`
378
+ ? `@layer ${layer} {\n${stylexBabelPlugin.processStylexRules([...constantRules, ...rules] as never, {useLayers: true})}\n}`
351
379
  : '';
352
380
 
353
381
  return [
@@ -358,6 +386,56 @@ function renderSplitLayers(
358
386
  .join('\n\n');
359
387
  }
360
388
 
389
+ /**
390
+ * Each `defineConsts` rule once under every class-name prefix in play.
391
+ *
392
+ * StyleX keys a constant by its defining file's prefix, `<prefix><hash>` (or
393
+ * `<name>-<prefix><hash>` with debug class names), where the hash depends on
394
+ * the export and key alone, and a file that reads it writes the placeholder
395
+ * with its own prefix. An Astryx package compiles with the library prefix and
396
+ * an app with the product one, so an app reading a package's constant writes
397
+ * `var(--x<hash>)` while the package's rule is keyed `astryx<hash>`; the
398
+ * copies make every prefix's placeholder resolve. A key StyleX took verbatim
399
+ * (`'--name'`) carries no prefix and is kept as it is.
400
+ */
401
+ function constantRulesForEveryPrefix(
402
+ rules: readonly unknown[],
403
+ prefixes: readonly string[],
404
+ ): unknown[] {
405
+ const ordered = [...new Set(prefixes)]
406
+ .filter(Boolean)
407
+ .sort((a, b) => b.length - a.length);
408
+ const out: unknown[] = [];
409
+ const keys = new Set<string>();
410
+ const add = (rule: unknown[], key: string) => {
411
+ if (keys.has(key)) return;
412
+ keys.add(key);
413
+ const [, body, ...rest] = rule;
414
+ out.push([key, {...(body as object), constKey: key}, ...rest]);
415
+ };
416
+ for (const rule of rules as unknown[][]) {
417
+ const key = String((rule[1] as {constKey: unknown}).constKey);
418
+ add(rule, key);
419
+ for (const prefix of ordered) {
420
+ const match = new RegExp(`^(|.*-)${prefix}([0-9a-z]+)$`).exec(key);
421
+ if (match == null) continue;
422
+ for (const other of ordered) {
423
+ add(rule, `${match[1]}${other}${match[2]}`);
424
+ }
425
+ break;
426
+ }
427
+ }
428
+ return out;
429
+ }
430
+
431
+ /** A `defineConsts` value as StyleX collects it: a rule with `constKey`/`constVal`. */
432
+ function isConstantRule(rule: unknown): boolean {
433
+ const body = Array.isArray(rule)
434
+ ? (rule[1] as {constKey?: unknown; constVal?: unknown} | undefined)
435
+ : undefined;
436
+ return body?.constKey != null && body?.constVal != null;
437
+ }
438
+
361
439
  /**
362
440
  * The build half of the split. StyleX appends one merged block of CSS to the
363
441
  * build's stylesheet, in its own top-level `@layer priority1…priorityN` — which
@@ -387,6 +465,8 @@ function buildLayerSplitPlugin(
387
465
  libraryLayer: string;
388
466
  productLayer: string;
389
467
  lightningcssOptions?: unknown;
468
+ /** The StyleX class-name prefixes in play: library, then product. */
469
+ classNamePrefixes: readonly string[];
390
470
  },
391
471
  ): Plugin {
392
472
  const stylex = basePlugin as {
@@ -402,7 +482,7 @@ function buildLayerSplitPlugin(
402
482
  configResolved(config) {
403
483
  base = config.base ?? '/';
404
484
  },
405
- writeBundle(outputOptions) {
485
+ writeBundle(outputOptions, bundle) {
406
486
  const rulesById = stylex.__stylexGetSharedStore?.().rulesById;
407
487
  if (!rulesById || rulesById.size === 0) return;
408
488
 
@@ -414,6 +494,42 @@ function buildLayerSplitPlugin(
414
494
  options.lightningcssOptions,
415
495
  );
416
496
 
497
+ // An app's own stylesheets declare its own custom properties.
498
+ const emittedCss = Object.values(bundle)
499
+ .filter(
500
+ (output): output is Extract<typeof output, {type: 'asset'}> =>
501
+ output.type === 'asset' && output.fileName.endsWith('.css'),
502
+ )
503
+ .map(asset =>
504
+ typeof asset.source === 'string'
505
+ ? asset.source
506
+ : Buffer.from(asset.source).toString('utf8'),
507
+ );
508
+ const undeclared = undefinedGeneratedVariables(
509
+ split,
510
+ options.classNamePrefixes,
511
+ emittedCss,
512
+ );
513
+ if (undeclared.length > 0) {
514
+ this.warn(
515
+ 'astryx-build-layer-split: the StyleX stylesheet reads ' +
516
+ `${undeclared.length} generated custom ` +
517
+ `propert${undeclared.length === 1 ? 'y' : 'ies'} ` +
518
+ 'that no stylesheet in the build declares:\n' +
519
+ undeclared
520
+ .map(
521
+ name =>
522
+ ` ${name}, read by: ${rulesReading(split, name).join(' ') || '(rule not found)'}`,
523
+ )
524
+ .join('\n') +
525
+ '\nWith no declaration, browsers drop the declarations that read ' +
526
+ 'it. A `.stylex.ts` export read in a style must be a ' +
527
+ '`defineVars`/`defineConsts` value whose module reaches the build; ' +
528
+ 'a property the app sets at runtime (an inline style) needs a name ' +
529
+ 'outside the StyleX class-name prefixes.',
530
+ );
531
+ }
532
+
417
533
  const outDir = outputOptions.dir
418
534
  ? outputOptions.dir
419
535
  : outputOptions.file
@@ -472,6 +588,14 @@ function buildLayerSplitPlugin(
472
588
  };
473
589
  }
474
590
 
591
+ /** The rules of `css` that read `name`, for the failure message. */
592
+ function rulesReading(css: string, name: string): string[] {
593
+ return [...css.matchAll(/[^{}]+\{[^{}]*\}/g)]
594
+ .map(match => match[0].trim().replace(/\s+/g, ' '))
595
+ .filter(rule => rule.includes(`var(${name}`))
596
+ .slice(0, 3);
597
+ }
598
+
475
599
  /** Every `.css` file under a directory, recursively. */
476
600
  function listCssFiles(dir: string): string[] {
477
601
  const out: string[] = [];
@@ -586,12 +710,25 @@ function astryxStylexLegacy(options: AstryxVitePluginLegacyOptions): Plugin[] {
586
710
  ).lightningcssOptions;
587
711
  const validatedStylexOptions = {
588
712
  propertyValidationMode: 'throw',
713
+ // The unplugin applies its Vite default (true) only to its own StyleX
714
+ // instance, which runs after the babel wrapper below has already compiled
715
+ // every StyleX call. Without it here, a file that reads only `defineConsts`
716
+ // from a `.stylex.ts` module loses that import once `stylex.create` is
717
+ // compiled (the TypeScript transform drops it as unused), the module is
718
+ // never loaded, and its constants never reach the CSS: a media-query key
719
+ // fails the build and a value ships as an undefined custom property.
720
+ treeshakeCompensation: true,
589
721
  ...(stylexOptions as any),
590
722
  lightningcssOptions: {
591
723
  targets: LIGHTNINGCSS_TARGETS,
592
724
  ...legacyLightningcssOptions,
593
725
  },
594
726
  };
727
+ const classNamePrefixes = [
728
+ stylexPrefix,
729
+ (validatedStylexOptions as {classNamePrefix?: string}).classNamePrefix ??
730
+ 'x',
731
+ ];
595
732
  const astryxBabelPlugin = path.resolve(__dirname, 'babel.js');
596
733
  const existingPlugins = validatedStylexOptions.babelConfig?.plugins ?? [];
597
734
 
@@ -658,6 +795,7 @@ function astryxStylexLegacy(options: AstryxVitePluginLegacyOptions): Plugin[] {
658
795
  libraryPattern,
659
796
  libraryLayer,
660
797
  productLayer,
798
+ classNamePrefixes,
661
799
  }),
662
800
  );
663
801
  },
@@ -677,6 +815,7 @@ function astryxStylexLegacy(options: AstryxVitePluginLegacyOptions): Plugin[] {
677
815
  lightningcssOptions: (
678
816
  validatedStylexOptions as {lightningcssOptions?: unknown}
679
817
  ).lightningcssOptions,
818
+ classNamePrefixes,
680
819
  }),
681
820
  ];
682
821
  }