@astryxdesign/build 0.4.7 → 0.5.0-canary.009bcb3

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/dist/vite.mjs CHANGED
@@ -6,6 +6,7 @@ import stylex from "@stylexjs/unplugin";
6
6
  import fs from "node:fs";
7
7
  import path from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
+ import { createRequire } from "node:module";
9
10
  var __dirname = path.dirname(fileURLToPath(import.meta.url));
10
11
  var LIBRARY_PATTERN = "node_modules/@astryxdesign/";
11
12
  var STYLEX_CSS_PATH = "/virtual:stylex.css";
@@ -119,54 +120,149 @@ function astryxStylex(options = {}) {
119
120
  if (!req.url?.startsWith(STYLEX_CSS_PATH)) {
120
121
  return next();
121
122
  }
122
- if (!stylexPlugin) {
123
- res.statusCode = 200;
124
- res.setHeader("Content-Type", "text/css");
125
- res.end("");
126
- return;
127
- }
128
- const shared = stylexPlugin.__stylexGetSharedStore?.();
129
- const rulesById = shared?.rulesById;
130
- if (!rulesById || rulesById.size === 0) {
131
- res.statusCode = 200;
132
- res.setHeader("Content-Type", "text/css");
133
- res.end("");
134
- return;
135
- }
136
- const libraryRules = [];
137
- const productRules = [];
138
- for (const [filePath, rules] of rulesById.entries()) {
139
- if (filePath.includes(libraryPattern)) {
140
- libraryRules.push(...rules);
141
- } else {
142
- productRules.push(...rules);
143
- }
144
- }
145
- const libraryCss = libraryRules.length ? stylexBabelPlugin.processStylexRules(libraryRules, {
146
- useLayers: true
147
- }) : "";
148
- const productCss = productRules.length ? stylexBabelPlugin.processStylexRules(productRules, {
149
- useLayers: true
150
- }) : "";
151
- const parts = [];
152
- if (libraryCss)
153
- parts.push(`@layer ${libraryLayer} {
154
- ${libraryCss}
155
- }`);
156
- if (productCss)
157
- parts.push(`@layer ${productLayer} {
158
- ${productCss}
159
- }`);
123
+ const rulesById = stylexPlugin?.__stylexGetSharedStore?.()?.rulesById;
160
124
  res.statusCode = 200;
161
125
  res.setHeader("Content-Type", "text/css");
162
126
  res.setHeader("Cache-Control", "no-store");
163
- res.end(parts.join("\n\n"));
127
+ res.end(
128
+ renderSplitLayers(rulesById, {
129
+ libraryPattern,
130
+ libraryLayer,
131
+ productLayer
132
+ })
133
+ );
164
134
  }
165
135
  });
166
136
  };
167
137
  }
168
138
  };
169
- return [configPlugin, layerOrderPlugin, basePlugin, splitLayerPlugin];
139
+ return [
140
+ configPlugin,
141
+ layerOrderPlugin,
142
+ basePlugin,
143
+ splitLayerPlugin,
144
+ buildLayerSplitPlugin(basePlugin, {
145
+ libraryPattern,
146
+ libraryLayer,
147
+ productLayer,
148
+ lightningcssOptions: stylexOptions.lightningcssOptions
149
+ })
150
+ ];
151
+ }
152
+ function renderSplitLayers(rulesById, options) {
153
+ if (!rulesById || rulesById.size === 0) return "";
154
+ const libraryRules = [];
155
+ const productRules = [];
156
+ for (const [filePath, rules] of rulesById.entries()) {
157
+ if (filePath.includes(options.libraryPattern)) {
158
+ libraryRules.push(...rules);
159
+ } else {
160
+ productRules.push(...rules);
161
+ }
162
+ }
163
+ const render = (rules, layer) => rules.length ? `@layer ${layer} {
164
+ ${stylexBabelPlugin.processStylexRules(rules, { useLayers: true })}
165
+ }` : "";
166
+ return [
167
+ render(libraryRules, options.libraryLayer),
168
+ render(productRules, options.productLayer)
169
+ ].filter(Boolean).join("\n\n");
170
+ }
171
+ function buildLayerSplitPlugin(basePlugin, options) {
172
+ const stylex2 = basePlugin;
173
+ let base = "/";
174
+ return {
175
+ name: "astryx-build-layer-split",
176
+ apply: "build",
177
+ enforce: "post",
178
+ configResolved(config) {
179
+ base = config.base ?? "/";
180
+ },
181
+ writeBundle(outputOptions) {
182
+ const rulesById = stylex2.__stylexGetSharedStore?.().rulesById;
183
+ if (!rulesById || rulesById.size === 0) return;
184
+ const merged = stylex2.__stylexCollectCss?.();
185
+ if (!merged) return;
186
+ const split = postProcessCss(
187
+ renderSplitLayers(rulesById, options),
188
+ options.lightningcssOptions
189
+ );
190
+ const outDir = outputOptions.dir ? outputOptions.dir : outputOptions.file ? path.dirname(outputOptions.file) : null;
191
+ if (!outDir || !fs.existsSync(outDir)) return;
192
+ const patched = [];
193
+ for (const file of listCssFiles(outDir)) {
194
+ const css = fs.readFileSync(file, "utf-8");
195
+ const at = css.lastIndexOf(merged);
196
+ if (at === -1) continue;
197
+ fs.writeFileSync(
198
+ file,
199
+ css.slice(0, at) + split + css.slice(at + merged.length)
200
+ );
201
+ patched.push(file);
202
+ }
203
+ if (patched.length === 0) {
204
+ this.error(
205
+ `astryx-build-layer-split: StyleX emitted rules but its CSS block was not found in any stylesheet under ${outDir}, so Astryx and product styles could not be separated into their cascade layers. Leaving the build unsplit would let product styles lose to a theme. This usually means the StyleX plugin version changed how it emits CSS.`
206
+ );
207
+ return;
208
+ }
209
+ linkOrphanStylesheets(outDir, patched, base);
210
+ }
211
+ };
212
+ }
213
+ function listCssFiles(dir) {
214
+ const out = [];
215
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
216
+ const full = path.join(dir, entry.name);
217
+ if (entry.isDirectory()) out.push(...listCssFiles(full));
218
+ else if (entry.name.endsWith(".css")) out.push(full);
219
+ }
220
+ return out;
221
+ }
222
+ function listHtmlFiles(dir) {
223
+ const out = [];
224
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
225
+ const full = path.join(dir, entry.name);
226
+ if (entry.isDirectory()) out.push(...listHtmlFiles(full));
227
+ else if (entry.name.endsWith(".html")) out.push(full);
228
+ }
229
+ return out;
230
+ }
231
+ function linkOrphanStylesheets(outDir, cssFiles, base) {
232
+ const pages = listHtmlFiles(outDir);
233
+ if (pages.length === 0) return;
234
+ const orphans = cssFiles.filter((css) => {
235
+ const name = path.basename(css);
236
+ return !pages.some((page) => fs.readFileSync(page, "utf-8").includes(name));
237
+ });
238
+ if (orphans.length === 0) return;
239
+ const links = orphans.map((css) => {
240
+ const href = base.replace(/\/$/, "") + "/" + path.relative(outDir, css).split(path.sep).join("/");
241
+ return `<link rel="stylesheet" crossorigin href="${href}">`;
242
+ }).join("\n ");
243
+ for (const page of pages) {
244
+ const html = fs.readFileSync(page, "utf-8");
245
+ if (!html.includes("</head>")) continue;
246
+ fs.writeFileSync(page, html.replace("</head>", ` ${links}
247
+ </head>`));
248
+ }
249
+ }
250
+ function postProcessCss(css, lightningcssOptions) {
251
+ if (!css) return css;
252
+ try {
253
+ const require_ = createRequire(import.meta.url);
254
+ const { transform, browserslistToTargets } = require_("lightningcss");
255
+ const browserslist = require_("browserslist");
256
+ const { code } = transform({
257
+ targets: browserslistToTargets(browserslist()),
258
+ ...lightningcssOptions,
259
+ filename: "stylex.css",
260
+ code: Buffer.from(css)
261
+ });
262
+ return code.toString();
263
+ } catch {
264
+ return css;
265
+ }
170
266
  }
171
267
  function astryxStylexLegacy(options) {
172
268
  const {
@@ -226,54 +322,33 @@ function astryxStylexLegacy(options) {
226
322
  if (!req.url?.startsWith(STYLEX_CSS_PATH)) {
227
323
  return next();
228
324
  }
229
- if (!stylexPlugin) {
230
- res.statusCode = 200;
231
- res.setHeader("Content-Type", "text/css");
232
- res.end("");
233
- return;
234
- }
235
- const shared = stylexPlugin.__stylexGetSharedStore?.();
236
- const rulesById = shared?.rulesById;
237
- if (!rulesById || rulesById.size === 0) {
238
- res.statusCode = 200;
239
- res.setHeader("Content-Type", "text/css");
240
- res.end("");
241
- return;
242
- }
243
- const libraryRules = [];
244
- const productRules = [];
245
- for (const [filePath, rules] of rulesById.entries()) {
246
- if (filePath.includes(libraryPattern)) {
247
- libraryRules.push(...rules);
248
- } else {
249
- productRules.push(...rules);
250
- }
251
- }
252
- const libraryCss = libraryRules.length ? stylexBabelPlugin.processStylexRules(libraryRules, {
253
- useLayers: true
254
- }) : "";
255
- const productCss = productRules.length ? stylexBabelPlugin.processStylexRules(productRules, {
256
- useLayers: true
257
- }) : "";
258
- const parts = [];
259
- if (libraryCss)
260
- parts.push(`@layer ${libraryLayer} {
261
- ${libraryCss}
262
- }`);
263
- if (productCss)
264
- parts.push(`@layer ${productLayer} {
265
- ${productCss}
266
- }`);
325
+ const rulesById = stylexPlugin?.__stylexGetSharedStore?.()?.rulesById;
267
326
  res.statusCode = 200;
268
327
  res.setHeader("Content-Type", "text/css");
269
328
  res.setHeader("Cache-Control", "no-store");
270
- res.end(parts.join("\n\n"));
329
+ res.end(
330
+ renderSplitLayers(rulesById, {
331
+ libraryPattern,
332
+ libraryLayer,
333
+ productLayer
334
+ })
335
+ );
271
336
  }
272
337
  });
273
338
  };
274
339
  }
275
340
  };
276
- return [layerOrderPlugin, basePlugin, splitLayerPlugin];
341
+ return [
342
+ layerOrderPlugin,
343
+ basePlugin,
344
+ splitLayerPlugin,
345
+ buildLayerSplitPlugin(basePlugin, {
346
+ libraryPattern,
347
+ libraryLayer,
348
+ productLayer,
349
+ lightningcssOptions: stylexOptions?.lightningcssOptions
350
+ })
351
+ ];
277
352
  }
278
353
  export {
279
354
  LIGHTNINGCSS_TARGETS,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/build",
3
- "version": "0.4.7",
3
+ "version": "0.5.0-canary.009bcb3",
4
4
  "description": "Build plugins for XDS source builds — babel, PostCSS, and Vite integrations",
5
5
  "author": "Meta Open Source",
6
6
  "license": "MIT",
@@ -0,0 +1,179 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Runs REAL Vite production builds of fixture apps and asserts on the
5
+ * stylesheets they emit.
6
+ *
7
+ * The unit tests in vite.test.ts check the plugin's wiring, and they passed
8
+ * while the build shipped a version that wrapped every StyleX rule — Astryx's
9
+ * and the app's alike — in the library layer. That inverts the bug rather than
10
+ * fixing it: product styles are supposed to outrank a theme, and wrapped in
11
+ * `astryx-base` they lose to one. Only the built artifact shows it, so this
12
+ * builds one.
13
+ *
14
+ * Two shapes, because StyleX emits through two different paths:
15
+ *
16
+ * `layer-split` the app imports a stylesheet, so the bundle has a CSS
17
+ * asset and StyleX appends to it in `generateBundle`
18
+ * `layer-split-nocss` the app imports none, so StyleX writes its own file in
19
+ * `writeBundle`, outside Rollup's graph
20
+ *
21
+ * The second is the one that was missing, and it hid two defects: the split did
22
+ * not run there at all, and the file StyleX writes is not linked by any page.
23
+ *
24
+ * What none of this can prove is which declaration actually paints — that is a
25
+ * cascade fact, and it lives in .github/scripts/theme-layer-cascade.js.
26
+ */
27
+
28
+ import {describe, it, expect, beforeAll, afterAll} from 'vitest';
29
+ import {build} from 'vite';
30
+ import react from '@vitejs/plugin-react';
31
+ import {mkdtempSync, readdirSync, readFileSync, rmSync} from 'node:fs';
32
+ import {tmpdir} from 'node:os';
33
+ import * as path from 'node:path';
34
+ import {fileURLToPath} from 'node:url';
35
+ import {astryxStylex} from './vite';
36
+
37
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
38
+ const REPO_ROOT = path.resolve(__dirname, '../../..');
39
+ const FIXTURES = path.resolve(__dirname, '../__fixtures__');
40
+ const CORE_SRC = path.join(REPO_ROOT, 'packages/core/src');
41
+
42
+ /** The class prefixes @astryxdesign/build/babel assigns per origin. */
43
+ const LIBRARY_CLASS = /\.astryx[a-z0-9]{5,}/g;
44
+ const PRODUCT_CLASS = /\.x[a-z0-9]{5,}/g;
45
+
46
+ type Built = {css: string; cssName: string; html: string; outDir: string};
47
+
48
+ async function buildFixture(name: string): Promise<Built> {
49
+ const root = path.join(FIXTURES, name);
50
+ const outDir = mkdtempSync(path.join(tmpdir(), `astryx-${name}-`));
51
+ await build({
52
+ root,
53
+ logLevel: 'error',
54
+ build: {outDir, emptyOutDir: true},
55
+ resolve: {alias: {'@astryxdesign/core': CORE_SRC}},
56
+ plugins: [
57
+ react(),
58
+ ...astryxStylex({
59
+ stylexOptions: {
60
+ dev: false,
61
+ unstable_moduleResolution: {type: 'commonJS', rootDir: REPO_ROOT},
62
+ aliases: {
63
+ '@astryxdesign/core/*': [path.join(CORE_SRC, '*')],
64
+ '@astryxdesign/core': [CORE_SRC],
65
+ },
66
+ },
67
+ libraryPattern: 'packages/core/',
68
+ }),
69
+ ],
70
+ });
71
+
72
+ const assets = path.join(outDir, 'assets');
73
+ const cssName = readdirSync(assets).find(f => f.endsWith('.css'));
74
+ if (!cssName) throw new Error(`no stylesheet emitted into ${assets}`);
75
+ return {
76
+ css: readFileSync(path.join(assets, cssName), 'utf-8'),
77
+ cssName,
78
+ html: readFileSync(path.join(outDir, 'index.html'), 'utf-8'),
79
+ outDir,
80
+ };
81
+ }
82
+
83
+ /** The slice of a stylesheet inside a given top-level layer block. */
84
+ function layerBlock(css: string, name: string): string {
85
+ const start = css.indexOf(`@layer ${name} {`);
86
+ expect(
87
+ start,
88
+ `@layer ${name} is not in the emitted stylesheet`,
89
+ ).toBeGreaterThan(-1);
90
+ let depth = 0;
91
+ for (let i = css.indexOf('{', start); i < css.length; i++) {
92
+ if (css[i] === '{') depth++;
93
+ else if (css[i] === '}' && --depth === 0) return css.slice(start, i + 1);
94
+ }
95
+ throw new Error(`@layer ${name} is unterminated`);
96
+ }
97
+
98
+ /** Both shapes must satisfy the same contract, so both run the same checks. */
99
+ function itSplitsCorrectly(get: () => Built) {
100
+ it('declares the layer order the split depends on', () => {
101
+ expect(get().html).toContain(
102
+ '@layer reset, astryx-base, astryx-theme, product;',
103
+ );
104
+ });
105
+
106
+ it("puts Astryx's own rules in the library layer", () => {
107
+ const library = layerBlock(get().css, 'astryx-base');
108
+ expect(library.match(LIBRARY_CLASS)?.length ?? 0).toBeGreaterThan(50);
109
+ // The fixture's Button pulls in real component CSS, so a token it styles
110
+ // with is a fact about this block rather than about class-name shape.
111
+ expect(library).toContain('--radius-element');
112
+ });
113
+
114
+ // The regression this file exists for. The wrapped-not-split version put the
115
+ // app's own StyleX in astryx-base, below `astryx-theme`, so a theme could
116
+ // silently restyle code the theme has no business reaching.
117
+ it("puts the app's own rules in the product layer, not the library one", () => {
118
+ const {css} = get();
119
+ const product = layerBlock(css, 'product');
120
+ const library = layerBlock(css, 'astryx-base');
121
+
122
+ expect(product).toContain('11px');
123
+ expect(library).not.toContain('11px');
124
+
125
+ expect(product.match(LIBRARY_CLASS)).toBeNull();
126
+ expect(library.match(PRODUCT_CLASS)).toBeNull();
127
+ });
128
+
129
+ it('leaves no StyleX rule outside the two layers', () => {
130
+ const {css} = get();
131
+ const outside = css
132
+ .replace(layerBlock(css, 'astryx-base'), '')
133
+ .replace(layerBlock(css, 'product'), '');
134
+ expect(outside).not.toContain('@layer priority');
135
+ });
136
+
137
+ // StyleX runs its CSS through lightningcss before emitting it. Replacing that
138
+ // output means running the same pass, or the build quietly loses the prefixes
139
+ // the original had.
140
+ it('keeps the vendor prefixing StyleX applies', () => {
141
+ expect(get().css).toContain('-webkit-');
142
+ });
143
+
144
+ it('links the stylesheet from the page', () => {
145
+ const {html, cssName} = get();
146
+ expect(html).toContain(cssName);
147
+ expect(html.split(cssName).length - 1, 'linked more than once').toBe(1);
148
+ });
149
+ }
150
+
151
+ describe('a production build separates Astryx and product styles by layer', () => {
152
+ describe('when the app imports a stylesheet of its own', () => {
153
+ let built: Built;
154
+ beforeAll(async () => {
155
+ built = await buildFixture('layer-split');
156
+ }, 180_000);
157
+ afterAll(
158
+ () => built && rmSync(built.outDir, {recursive: true, force: true}),
159
+ );
160
+
161
+ itSplitsCorrectly(() => built);
162
+ });
163
+
164
+ // StyleX has no bundle asset to append to here, so it writes its own file in
165
+ // `writeBundle` — outside Rollup's graph, which is why Vite emits no `<link>`
166
+ // for it. Every assertion below failed before this case was handled: the
167
+ // stylesheet was unsplit AND the page loaded no styles at all.
168
+ describe('when the app imports no stylesheet at all', () => {
169
+ let built: Built;
170
+ beforeAll(async () => {
171
+ built = await buildFixture('layer-split-nocss');
172
+ }, 180_000);
173
+ afterAll(
174
+ () => built && rmSync(built.outDir, {recursive: true, force: true}),
175
+ );
176
+
177
+ itSplitsCorrectly(() => built);
178
+ });
179
+ });
package/src/vite.test.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * theme layer name is fixed at `astryx-theme`.
8
8
  */
9
9
 
10
- import {describe, it, expect, beforeAll, afterAll} from 'vitest';
10
+ import {describe, it, expect, beforeAll, afterAll, vi} from 'vitest';
11
11
  import {mkdtempSync, mkdirSync, rmSync} from 'node:fs';
12
12
  import {tmpdir} from 'node:os';
13
13
  import path from 'node:path';
@@ -53,6 +53,52 @@ describe('astryxStylex layer order (legacy API)', () => {
53
53
  });
54
54
  });
55
55
 
56
+ /**
57
+ * A production build had no equivalent of the dev server's split-layer plugin,
58
+ * so StyleX's `@layer priorityN` blocks sat outside the declared order and
59
+ * outranked `astryx-theme` — every component override a theme set was dropped.
60
+ * The first fix WRAPPED those blocks in the library layer instead of splitting
61
+ * them, which fixed the theme and broke the app: product StyleX landed below
62
+ * `astryx-theme` too, so a theme could silently restyle code it does not own.
63
+ *
64
+ * These pin the plugin's wiring. That the partition is right in a real build is
65
+ * vite.build.test.ts, and what it means for the cascade is
66
+ * .github/scripts/theme-layer-cascade.js — unit tests over this hook passed
67
+ * throughout both bugs.
68
+ */
69
+ describe('astryxStylex build-time layer split', () => {
70
+ const find = (plugins: ReturnType<typeof astryxStylex>) =>
71
+ plugins.find(p => p.name === 'astryx-build-layer-split');
72
+
73
+ it('is present on both the modern and the legacy API', () => {
74
+ expect(find(astryxStylex())).toBeTruthy();
75
+ expect(find(astryxStylex({stylexOptions: {}}))).toBeTruthy();
76
+ });
77
+
78
+ it('runs on a build only, after the StyleX plugin that emits the CSS', () => {
79
+ const plugin = find(astryxStylex());
80
+ expect(plugin?.apply).toBe('build');
81
+ expect(plugin?.enforce).toBe('post');
82
+ });
83
+
84
+ // The dev middleware and the build hook read the same partition, so a build
85
+ // that emits nothing is a build where StyleX collected nothing — never a
86
+ // silent pass-through of unsplit CSS.
87
+ it('does nothing when StyleX collected no rules', () => {
88
+ const plugin = find(astryxStylex());
89
+ const hook = (plugin as any).writeBundle;
90
+ const fn = typeof hook === 'function' ? hook : hook.handler;
91
+ const error = vi.fn();
92
+ expect(() =>
93
+ fn.call(
94
+ {error},
95
+ {dir: mkdtempSync(path.join(tmpdir(), 'astryx-empty-'))},
96
+ ),
97
+ ).not.toThrow();
98
+ expect(error).not.toHaveBeenCalled();
99
+ });
100
+ });
101
+
56
102
  describe('astryxStylex optimizeDeps package discovery', () => {
57
103
  let rootDir: string;
58
104
 
package/src/vite.ts CHANGED
@@ -6,6 +6,7 @@ import stylex from '@stylexjs/unplugin';
6
6
  import fs from 'node:fs';
7
7
  import path from 'node:path';
8
8
  import {fileURLToPath} from 'node:url';
9
+ import {createRequire} from 'node:module';
9
10
 
10
11
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
12
 
@@ -242,62 +243,273 @@ export function astryxStylex(
242
243
  return next();
243
244
  }
244
245
 
245
- if (!stylexPlugin) {
246
- res.statusCode = 200;
247
- res.setHeader('Content-Type', 'text/css');
248
- res.end('');
249
- return;
250
- }
251
-
252
- const shared = stylexPlugin.__stylexGetSharedStore?.();
253
- const rulesById = shared?.rulesById;
254
-
255
- if (!rulesById || rulesById.size === 0) {
256
- res.statusCode = 200;
257
- res.setHeader('Content-Type', 'text/css');
258
- res.end('');
259
- return;
260
- }
261
-
262
- const libraryRules: any[] = [];
263
- const productRules: any[] = [];
264
-
265
- for (const [filePath, rules] of rulesById.entries()) {
266
- if (filePath.includes(libraryPattern)) {
267
- libraryRules.push(...rules);
268
- } else {
269
- productRules.push(...rules);
270
- }
271
- }
272
-
273
- const libraryCss = libraryRules.length
274
- ? stylexBabelPlugin.processStylexRules(libraryRules, {
275
- useLayers: true,
276
- })
277
- : '';
278
- const productCss = productRules.length
279
- ? stylexBabelPlugin.processStylexRules(productRules, {
280
- useLayers: true,
281
- })
282
- : '';
283
-
284
- const parts: string[] = [];
285
- if (libraryCss)
286
- parts.push(`@layer ${libraryLayer} {\n${libraryCss}\n}`);
287
- if (productCss)
288
- parts.push(`@layer ${productLayer} {\n${productCss}\n}`);
246
+ const rulesById =
247
+ stylexPlugin?.__stylexGetSharedStore?.()?.rulesById;
289
248
 
290
249
  res.statusCode = 200;
291
250
  res.setHeader('Content-Type', 'text/css');
292
251
  res.setHeader('Cache-Control', 'no-store');
293
- res.end(parts.join('\n\n'));
252
+ res.end(
253
+ renderSplitLayers(rulesById, {
254
+ libraryPattern,
255
+ libraryLayer,
256
+ productLayer,
257
+ }),
258
+ );
294
259
  },
295
260
  });
296
261
  };
297
262
  },
298
263
  };
299
264
 
300
- return [configPlugin, layerOrderPlugin, basePlugin, splitLayerPlugin];
265
+ return [
266
+ configPlugin,
267
+ layerOrderPlugin,
268
+ basePlugin,
269
+ splitLayerPlugin,
270
+ buildLayerSplitPlugin(basePlugin, {
271
+ libraryPattern,
272
+ libraryLayer,
273
+ productLayer,
274
+ lightningcssOptions: stylexOptions.lightningcssOptions,
275
+ }),
276
+ ];
277
+ }
278
+
279
+ /**
280
+ * Partition StyleX's collected rules by the source file that authored them and
281
+ * render each half into its cascade layer: Astryx's own styles into the library
282
+ * layer (below `astryx-theme`, so a theme can override them), product styles
283
+ * into the product layer (above it, so an app always wins).
284
+ *
285
+ * This is the one implementation. The dev middleware and the build hook differ
286
+ * only in when they run and what they do with the string — the partition itself
287
+ * must not diverge, because it did: the build shipped a version that wrapped
288
+ * everything in the library layer, which put product styles UNDER a theme.
289
+ *
290
+ * `rulesById` is keyed by absolute source path, which is the only place the
291
+ * distinction survives — by the time the CSS is text, the origin is gone.
292
+ */
293
+ function renderSplitLayers(
294
+ rulesById: Map<string, unknown[]> | undefined,
295
+ options: {
296
+ libraryPattern: string;
297
+ libraryLayer: string;
298
+ productLayer: string;
299
+ },
300
+ ): string {
301
+ if (!rulesById || rulesById.size === 0) return '';
302
+
303
+ const libraryRules: unknown[] = [];
304
+ const productRules: unknown[] = [];
305
+
306
+ for (const [filePath, rules] of rulesById.entries()) {
307
+ if (filePath.includes(options.libraryPattern)) {
308
+ libraryRules.push(...rules);
309
+ } else {
310
+ productRules.push(...rules);
311
+ }
312
+ }
313
+
314
+ const render = (rules: unknown[], layer: string) =>
315
+ rules.length
316
+ ? `@layer ${layer} {\n${stylexBabelPlugin.processStylexRules(rules as never, {useLayers: true})}\n}`
317
+ : '';
318
+
319
+ return [
320
+ render(libraryRules, options.libraryLayer),
321
+ render(productRules, options.productLayer),
322
+ ]
323
+ .filter(Boolean)
324
+ .join('\n\n');
325
+ }
326
+
327
+ /**
328
+ * The build half of the split. StyleX appends one merged block of CSS to the
329
+ * build's stylesheet, in its own top-level `@layer priority1…priorityN` — which
330
+ * is declared after `@layer reset, astryx-base, astryx-theme, product;` and so
331
+ * outranks every one of them. On the dev server `astryx-split-layers` re-serves
332
+ * the same rules already partitioned; a build had no equivalent, so a theme's
333
+ * component overrides were silently dropped in the built app while working in
334
+ * dev.
335
+ *
336
+ * This replaces that merged block with the partitioned pair. It runs in
337
+ * `writeBundle` because StyleX emits through two different paths depending on
338
+ * whether the bundle already has a stylesheet to append to (`generateBundle`)
339
+ * or has to write its own file (`writeBundle`) — on disk, after both, there is
340
+ * one case instead of two.
341
+ *
342
+ * The block is located by an exact match against StyleX's own collector rather
343
+ * than by looking for `@layer priority1`: a wrong guess about where the block
344
+ * starts silently moves rules between layers, which is the failure this exists
345
+ * to prevent. If the rules exist and the block cannot be found, the build
346
+ * fails.
347
+ */
348
+ function buildLayerSplitPlugin(
349
+ basePlugin: unknown,
350
+ options: {
351
+ libraryPattern: string;
352
+ libraryLayer: string;
353
+ productLayer: string;
354
+ lightningcssOptions?: unknown;
355
+ },
356
+ ): Plugin {
357
+ const stylex = basePlugin as {
358
+ __stylexGetSharedStore?: () => {rulesById: Map<string, unknown[]>};
359
+ __stylexCollectCss?: () => string;
360
+ };
361
+ let base = '/';
362
+
363
+ return {
364
+ name: 'astryx-build-layer-split',
365
+ apply: 'build',
366
+ enforce: 'post',
367
+ configResolved(config) {
368
+ base = config.base ?? '/';
369
+ },
370
+ writeBundle(outputOptions) {
371
+ const rulesById = stylex.__stylexGetSharedStore?.().rulesById;
372
+ if (!rulesById || rulesById.size === 0) return;
373
+
374
+ const merged = stylex.__stylexCollectCss?.();
375
+ if (!merged) return;
376
+
377
+ const split = postProcessCss(
378
+ renderSplitLayers(rulesById, options),
379
+ options.lightningcssOptions,
380
+ );
381
+
382
+ const outDir = outputOptions.dir
383
+ ? outputOptions.dir
384
+ : outputOptions.file
385
+ ? path.dirname(outputOptions.file)
386
+ : null;
387
+ // `write: false` builds keep everything in memory; there is nothing to
388
+ // patch and nothing was shipped, so this is not a failure.
389
+ if (!outDir || !fs.existsSync(outDir)) return;
390
+
391
+ const patched: string[] = [];
392
+ for (const file of listCssFiles(outDir)) {
393
+ const css = fs.readFileSync(file, 'utf-8');
394
+ const at = css.lastIndexOf(merged);
395
+ if (at === -1) continue;
396
+ fs.writeFileSync(
397
+ file,
398
+ css.slice(0, at) + split + css.slice(at + merged.length),
399
+ );
400
+ patched.push(file);
401
+ }
402
+
403
+ if (patched.length === 0) {
404
+ this.error(
405
+ 'astryx-build-layer-split: StyleX emitted rules but its CSS block ' +
406
+ `was not found in any stylesheet under ${outDir}, so Astryx and ` +
407
+ 'product styles could not be separated into their cascade layers. ' +
408
+ 'Leaving the build unsplit would let product styles lose to a ' +
409
+ 'theme. This usually means the StyleX plugin version changed how ' +
410
+ 'it emits CSS.',
411
+ );
412
+ return;
413
+ }
414
+
415
+ linkOrphanStylesheets(outDir, patched, base);
416
+ },
417
+ };
418
+ }
419
+
420
+ /** Every `.css` file under a directory, recursively. */
421
+ function listCssFiles(dir: string): string[] {
422
+ const out: string[] = [];
423
+ for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
424
+ const full = path.join(dir, entry.name);
425
+ if (entry.isDirectory()) out.push(...listCssFiles(full));
426
+ else if (entry.name.endsWith('.css')) out.push(full);
427
+ }
428
+ return out;
429
+ }
430
+
431
+ /** Every `.html` file under a directory, recursively. */
432
+ function listHtmlFiles(dir: string): string[] {
433
+ const out: string[] = [];
434
+ for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
435
+ const full = path.join(dir, entry.name);
436
+ if (entry.isDirectory()) out.push(...listHtmlFiles(full));
437
+ else if (entry.name.endsWith('.html')) out.push(full);
438
+ }
439
+ return out;
440
+ }
441
+
442
+ /**
443
+ * Link a stylesheet the build wrote but no page loads.
444
+ *
445
+ * When an app imports no CSS of its own, StyleX has no bundle asset to append
446
+ * to, so it writes `assets/stylex.css` itself — outside Rollup's graph, which
447
+ * means Vite's HTML plugin never learns about it and emits no `<link>`. The app
448
+ * ships every Astryx style correctly split into layers, and a completely
449
+ * unstyled page. Importing any stylesheet hides it, which is why it survives:
450
+ * the moment a project has one line of its own CSS the symptom disappears.
451
+ *
452
+ * Only orphans are linked. A stylesheet already referenced by a page is Vite's,
453
+ * and touching it would duplicate the load. A build with no HTML at all —
454
+ * library mode — is left alone: its consumer imports the CSS themselves.
455
+ */
456
+ function linkOrphanStylesheets(
457
+ outDir: string,
458
+ cssFiles: string[],
459
+ base: string,
460
+ ): void {
461
+ const pages = listHtmlFiles(outDir);
462
+ if (pages.length === 0) return;
463
+
464
+ const orphans = cssFiles.filter(css => {
465
+ const name = path.basename(css);
466
+ return !pages.some(page => fs.readFileSync(page, 'utf-8').includes(name));
467
+ });
468
+ if (orphans.length === 0) return;
469
+
470
+ const links = orphans
471
+ .map(css => {
472
+ const href =
473
+ base.replace(/\/$/, '') +
474
+ '/' +
475
+ path.relative(outDir, css).split(path.sep).join('/');
476
+ return `<link rel="stylesheet" crossorigin href="${href}">`;
477
+ })
478
+ .join('\n ');
479
+
480
+ for (const page of pages) {
481
+ const html = fs.readFileSync(page, 'utf-8');
482
+ if (!html.includes('</head>')) continue;
483
+ fs.writeFileSync(page, html.replace('</head>', ` ${links}\n </head>`));
484
+ }
485
+ }
486
+
487
+ /**
488
+ * StyleX runs its collected CSS through lightningcss before emitting it, so
489
+ * anything replacing that output has to run the same pass or the build quietly
490
+ * loses the vendor prefixes and lowering the original had.
491
+ *
492
+ * lightningcss ships with both Vite and the StyleX plugin, either of which must
493
+ * be installed for this plugin to run at all. If it somehow is not resolvable,
494
+ * the unprocessed CSS is correct — just less compatible — so this degrades
495
+ * rather than failing the build.
496
+ */
497
+ function postProcessCss(css: string, lightningcssOptions: unknown): string {
498
+ if (!css) return css;
499
+ try {
500
+ const require_ = createRequire(import.meta.url);
501
+ const {transform, browserslistToTargets} = require_('lightningcss');
502
+ const browserslist = require_('browserslist');
503
+ const {code} = transform({
504
+ targets: browserslistToTargets(browserslist()),
505
+ ...(lightningcssOptions as object),
506
+ filename: 'stylex.css',
507
+ code: Buffer.from(css),
508
+ });
509
+ return code.toString();
510
+ } catch {
511
+ return css;
512
+ }
301
513
  }
302
514
 
303
515
  /**
@@ -370,60 +582,35 @@ function astryxStylexLegacy(options: AstryxVitePluginLegacyOptions): Plugin[] {
370
582
  return next();
371
583
  }
372
584
 
373
- if (!stylexPlugin) {
374
- res.statusCode = 200;
375
- res.setHeader('Content-Type', 'text/css');
376
- res.end('');
377
- return;
378
- }
379
-
380
- const shared = stylexPlugin.__stylexGetSharedStore?.();
381
- const rulesById = shared?.rulesById;
382
-
383
- if (!rulesById || rulesById.size === 0) {
384
- res.statusCode = 200;
385
- res.setHeader('Content-Type', 'text/css');
386
- res.end('');
387
- return;
388
- }
389
-
390
- const libraryRules: any[] = [];
391
- const productRules: any[] = [];
392
-
393
- for (const [filePath, rules] of rulesById.entries()) {
394
- if (filePath.includes(libraryPattern)) {
395
- libraryRules.push(...rules);
396
- } else {
397
- productRules.push(...rules);
398
- }
399
- }
400
-
401
- const libraryCss = libraryRules.length
402
- ? stylexBabelPlugin.processStylexRules(libraryRules, {
403
- useLayers: true,
404
- })
405
- : '';
406
- const productCss = productRules.length
407
- ? stylexBabelPlugin.processStylexRules(productRules, {
408
- useLayers: true,
409
- })
410
- : '';
411
-
412
- const parts: string[] = [];
413
- if (libraryCss)
414
- parts.push(`@layer ${libraryLayer} {\n${libraryCss}\n}`);
415
- if (productCss)
416
- parts.push(`@layer ${productLayer} {\n${productCss}\n}`);
585
+ const rulesById =
586
+ stylexPlugin?.__stylexGetSharedStore?.()?.rulesById;
417
587
 
418
588
  res.statusCode = 200;
419
589
  res.setHeader('Content-Type', 'text/css');
420
590
  res.setHeader('Cache-Control', 'no-store');
421
- res.end(parts.join('\n\n'));
591
+ res.end(
592
+ renderSplitLayers(rulesById, {
593
+ libraryPattern,
594
+ libraryLayer,
595
+ productLayer,
596
+ }),
597
+ );
422
598
  },
423
599
  });
424
600
  };
425
601
  },
426
602
  };
427
603
 
428
- return [layerOrderPlugin, basePlugin, splitLayerPlugin];
604
+ return [
605
+ layerOrderPlugin,
606
+ basePlugin,
607
+ splitLayerPlugin,
608
+ buildLayerSplitPlugin(basePlugin, {
609
+ libraryPattern,
610
+ libraryLayer,
611
+ productLayer,
612
+ lightningcssOptions: (stylexOptions as {lightningcssOptions?: unknown})
613
+ ?.lightningcssOptions,
614
+ }),
615
+ ];
429
616
  }