@broxium/compiler 1.5.0 → 1.5.2

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/index.d.mts CHANGED
@@ -13,12 +13,17 @@ interface CompileInput {
13
13
  interface CompileOutput {
14
14
  serverJsPath: string;
15
15
  clientJsPath: string;
16
+ /** Browser (canvas) bundle — uses window.__brodox_react globals, safe to load via Blob URL. */
17
+ browserJsPath: string;
16
18
  /** Compiled CSS bundle path, or null if no CSS files were imported. */
17
19
  cssPath: string | null;
18
20
  serverJsName: string;
19
21
  clientJsName: string;
22
+ browserJsName: string;
20
23
  /** CSS bundle filename, or null if no CSS files were imported. */
21
24
  cssName: string | null;
25
+ /** 6-char base36 FNV-1a scope ID used to prefix CSS selectors ([data-bc=ID]). */
26
+ scopeId: string;
22
27
  compiledAt: Date;
23
28
  }
24
29
 
@@ -26,4 +31,67 @@ declare class BrodoxCompiler {
26
31
  compile(input: CompileInput): Promise<CompileOutput>;
27
32
  }
28
33
 
29
- export { BrodoxCompiler, type CompileInput, type CompileOutput };
34
+ declare function computeScopeId(slug: string, version: string): string;
35
+ declare function scopeCss(css: string, scopeId: string): string;
36
+ declare function minifyScopedCss(css: string, scopeId: string): Promise<string>;
37
+
38
+ /**
39
+ * Brodox Page-Level CSS Strategy Engine
40
+ *
41
+ * Decides how the merged CSS for an entire page is delivered to the browser.
42
+ * CSS is scoped per-component by the compiler ([data-bc=XXXXXX] selectors).
43
+ * After publish, all component CSS files are merged and this function chooses
44
+ * the optimal delivery strategy for the combined result.
45
+ *
46
+ * 'none' → no CSS at all → nothing emitted
47
+ * 'inline' → total ≤ INLINE_MAX_BYTES → single <style> in <head>
48
+ * 'separate' → INLINE_MAX < total ≤ CHUNK_MIN → one external <link> file
49
+ * 'chunks' → total > CHUNK_MIN_BYTES → N × CHUNK_SIZE_BYTES <link> files
50
+ * e.g. 95 KB → 3 × 25 KB + 1 × 20 KB
51
+ *
52
+ * Rationale for thresholds
53
+ * ─────────────────────────
54
+ * INLINE_MAX_BYTES 4 096 HTTP request overhead ≈ 100–300 ms RTT. Inlining
55
+ * 4 KB of CSS costs < 4 ms parse time — always faster
56
+ * than an extra round-trip for a small external file.
57
+ *
58
+ * CHUNK_MIN_BYTES 51 200 A single 50 KB external file is acceptable for one
59
+ * HTTP/2 request. Above 50 KB, splitting into parallel
60
+ * 25 KB chunks can start rendering sooner.
61
+ *
62
+ * CHUNK_SIZE_BYTES 25 600 25 KB per chunk — stays within HTTP/2's default
63
+ * initial window size and transfers in 1–2 TCP segments
64
+ * on most connections.
65
+ */
66
+ declare const PAGE_CSS_THRESHOLDS: {
67
+ /** Embed as <style> when total page CSS ≤ this value (bytes). */
68
+ readonly INLINE_MAX_BYTES: 4096;
69
+ /** Switch to multi-chunk delivery when total page CSS > this value (bytes). */
70
+ readonly CHUNK_MIN_BYTES: 51200;
71
+ /** Maximum bytes per CSS chunk file. */
72
+ readonly CHUNK_SIZE_BYTES: 25600;
73
+ };
74
+ type PageCssStrategy = 'none' | 'inline' | 'separate' | 'chunks';
75
+ interface PageCssDecision {
76
+ strategy: PageCssStrategy;
77
+ /** 'inline': the full merged CSS to embed as <style>. */
78
+ inlineCss?: string;
79
+ /**
80
+ * 'separate': one-element array with the full merged CSS.
81
+ * 'chunks': N-element array, each ≤ CHUNK_SIZE_BYTES, split at rule boundaries.
82
+ * Undefined for 'none' and 'inline'.
83
+ */
84
+ cssChunks?: string[];
85
+ }
86
+ /**
87
+ * Choose the optimal CSS delivery strategy for the merged page CSS.
88
+ * Call this with the concatenation of all scoped component CSS for the page.
89
+ */
90
+ declare function decidePageCssStrategy(mergedCss: string): PageCssDecision;
91
+ /**
92
+ * Split a CSS string into chunks of at most chunkSize bytes each.
93
+ * Splits are always made at the nearest `}` boundary so no rule is cut mid-way.
94
+ */
95
+ declare function splitCssIntoChunks(css: string, chunkSize: number): string[];
96
+
97
+ export { BrodoxCompiler, type CompileInput, type CompileOutput, PAGE_CSS_THRESHOLDS, type PageCssDecision, type PageCssStrategy, computeScopeId, decidePageCssStrategy, minifyScopedCss, scopeCss, splitCssIntoChunks };
package/dist/index.d.ts CHANGED
@@ -13,12 +13,17 @@ interface CompileInput {
13
13
  interface CompileOutput {
14
14
  serverJsPath: string;
15
15
  clientJsPath: string;
16
+ /** Browser (canvas) bundle — uses window.__brodox_react globals, safe to load via Blob URL. */
17
+ browserJsPath: string;
16
18
  /** Compiled CSS bundle path, or null if no CSS files were imported. */
17
19
  cssPath: string | null;
18
20
  serverJsName: string;
19
21
  clientJsName: string;
22
+ browserJsName: string;
20
23
  /** CSS bundle filename, or null if no CSS files were imported. */
21
24
  cssName: string | null;
25
+ /** 6-char base36 FNV-1a scope ID used to prefix CSS selectors ([data-bc=ID]). */
26
+ scopeId: string;
22
27
  compiledAt: Date;
23
28
  }
24
29
 
@@ -26,4 +31,67 @@ declare class BrodoxCompiler {
26
31
  compile(input: CompileInput): Promise<CompileOutput>;
27
32
  }
28
33
 
29
- export { BrodoxCompiler, type CompileInput, type CompileOutput };
34
+ declare function computeScopeId(slug: string, version: string): string;
35
+ declare function scopeCss(css: string, scopeId: string): string;
36
+ declare function minifyScopedCss(css: string, scopeId: string): Promise<string>;
37
+
38
+ /**
39
+ * Brodox Page-Level CSS Strategy Engine
40
+ *
41
+ * Decides how the merged CSS for an entire page is delivered to the browser.
42
+ * CSS is scoped per-component by the compiler ([data-bc=XXXXXX] selectors).
43
+ * After publish, all component CSS files are merged and this function chooses
44
+ * the optimal delivery strategy for the combined result.
45
+ *
46
+ * 'none' → no CSS at all → nothing emitted
47
+ * 'inline' → total ≤ INLINE_MAX_BYTES → single <style> in <head>
48
+ * 'separate' → INLINE_MAX < total ≤ CHUNK_MIN → one external <link> file
49
+ * 'chunks' → total > CHUNK_MIN_BYTES → N × CHUNK_SIZE_BYTES <link> files
50
+ * e.g. 95 KB → 3 × 25 KB + 1 × 20 KB
51
+ *
52
+ * Rationale for thresholds
53
+ * ─────────────────────────
54
+ * INLINE_MAX_BYTES 4 096 HTTP request overhead ≈ 100–300 ms RTT. Inlining
55
+ * 4 KB of CSS costs < 4 ms parse time — always faster
56
+ * than an extra round-trip for a small external file.
57
+ *
58
+ * CHUNK_MIN_BYTES 51 200 A single 50 KB external file is acceptable for one
59
+ * HTTP/2 request. Above 50 KB, splitting into parallel
60
+ * 25 KB chunks can start rendering sooner.
61
+ *
62
+ * CHUNK_SIZE_BYTES 25 600 25 KB per chunk — stays within HTTP/2's default
63
+ * initial window size and transfers in 1–2 TCP segments
64
+ * on most connections.
65
+ */
66
+ declare const PAGE_CSS_THRESHOLDS: {
67
+ /** Embed as <style> when total page CSS ≤ this value (bytes). */
68
+ readonly INLINE_MAX_BYTES: 4096;
69
+ /** Switch to multi-chunk delivery when total page CSS > this value (bytes). */
70
+ readonly CHUNK_MIN_BYTES: 51200;
71
+ /** Maximum bytes per CSS chunk file. */
72
+ readonly CHUNK_SIZE_BYTES: 25600;
73
+ };
74
+ type PageCssStrategy = 'none' | 'inline' | 'separate' | 'chunks';
75
+ interface PageCssDecision {
76
+ strategy: PageCssStrategy;
77
+ /** 'inline': the full merged CSS to embed as <style>. */
78
+ inlineCss?: string;
79
+ /**
80
+ * 'separate': one-element array with the full merged CSS.
81
+ * 'chunks': N-element array, each ≤ CHUNK_SIZE_BYTES, split at rule boundaries.
82
+ * Undefined for 'none' and 'inline'.
83
+ */
84
+ cssChunks?: string[];
85
+ }
86
+ /**
87
+ * Choose the optimal CSS delivery strategy for the merged page CSS.
88
+ * Call this with the concatenation of all scoped component CSS for the page.
89
+ */
90
+ declare function decidePageCssStrategy(mergedCss: string): PageCssDecision;
91
+ /**
92
+ * Split a CSS string into chunks of at most chunkSize bytes each.
93
+ * Splits are always made at the nearest `}` boundary so no rule is cut mid-way.
94
+ */
95
+ declare function splitCssIntoChunks(css: string, chunkSize: number): string[];
96
+
97
+ export { BrodoxCompiler, type CompileInput, type CompileOutput, PAGE_CSS_THRESHOLDS, type PageCssDecision, type PageCssStrategy, computeScopeId, decidePageCssStrategy, minifyScopedCss, scopeCss, splitCssIntoChunks };
package/dist/index.js CHANGED
@@ -30,12 +30,18 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
- BrodoxCompiler: () => BrodoxCompiler
33
+ BrodoxCompiler: () => BrodoxCompiler,
34
+ PAGE_CSS_THRESHOLDS: () => PAGE_CSS_THRESHOLDS,
35
+ computeScopeId: () => computeScopeId,
36
+ decidePageCssStrategy: () => decidePageCssStrategy,
37
+ minifyScopedCss: () => minifyScopedCss,
38
+ scopeCss: () => scopeCss,
39
+ splitCssIntoChunks: () => splitCssIntoChunks
34
40
  });
35
41
  module.exports = __toCommonJS(index_exports);
36
42
 
37
43
  // src/compiler.ts
38
- var esbuild = __toESM(require("esbuild"));
44
+ var esbuild2 = __toESM(require("esbuild"));
39
45
  var import_promises3 = __toESM(require("fs/promises"));
40
46
  var import_node_path3 = __toESM(require("path"));
41
47
  var import_node_os = __toESM(require("os"));
@@ -154,11 +160,13 @@ export function useRouter() {
154
160
 
155
161
  export function useParams() { return {}; }
156
162
 
157
- export function BrodoxHead({ title, description }) {
163
+ export function BrodoxHead({ title, description, cssContent }) {
158
164
  if (title && typeof globalThis.__brodoxCollectHead === 'function')
159
165
  globalThis.__brodoxCollectHead({ type: 'title', props: { content: title } });
160
166
  if (description && typeof globalThis.__brodoxCollectHead === 'function')
161
167
  globalThis.__brodoxCollectHead({ type: 'meta', props: { name: 'description', content: description } });
168
+ if (cssContent && typeof globalThis.__brodoxCollectHead === 'function')
169
+ globalThis.__brodoxCollectHead({ type: 'style', props: { content: cssContent } });
162
170
  return null;
163
171
  }
164
172
 
@@ -234,6 +242,311 @@ export var ClientRender = Client;
234
242
  };
235
243
  }
236
244
 
245
+ // src/plugins/canvasBuildPlugin.ts
246
+ function canvasReactPlugin() {
247
+ return {
248
+ name: "brodox-canvas-react",
249
+ setup(build2) {
250
+ build2.onResolve({ filter: /^react\/jsx-runtime$/ }, () => ({
251
+ path: "react/jsx-runtime",
252
+ namespace: "brodox-canvas-jsx"
253
+ }));
254
+ build2.onLoad({ filter: /.*/, namespace: "brodox-canvas-jsx" }, () => ({
255
+ loader: "js",
256
+ // Use window.__brodox_jsx (ReactJSXRuntime) — the real jsx/jsxs functions
257
+ // behave differently from createElement for children handling.
258
+ contents: `
259
+ var _jx = window.__brodox_jsx;
260
+ export var jsx = _jx.jsx;
261
+ export var jsxs = _jx.jsxs;
262
+ export var Fragment = _jx.Fragment;
263
+ `.trim()
264
+ }));
265
+ build2.onResolve({ filter: /^react\/jsx-dev-runtime$/ }, () => ({
266
+ path: "react/jsx-dev-runtime",
267
+ namespace: "brodox-canvas-jsx-dev"
268
+ }));
269
+ build2.onLoad({ filter: /.*/, namespace: "brodox-canvas-jsx-dev" }, () => ({
270
+ loader: "js",
271
+ contents: `
272
+ var _jx = window.__brodox_jsx;
273
+ export var jsxDEV = _jx.jsx || _jx.jsxDEV;
274
+ export var Fragment = _jx.Fragment;
275
+ `.trim()
276
+ }));
277
+ build2.onResolve({ filter: /^react$/ }, () => ({
278
+ path: "react",
279
+ namespace: "brodox-canvas-react"
280
+ }));
281
+ build2.onLoad({ filter: /.*/, namespace: "brodox-canvas-react" }, () => ({
282
+ loader: "js",
283
+ contents: `
284
+ var _r = window.__brodox_react;
285
+ export default _r;
286
+ export var Children = _r.Children;
287
+ export var createElement = _r.createElement;
288
+ export var cloneElement = _r.cloneElement;
289
+ export var createContext = _r.createContext;
290
+ export var createRef = _r.createRef;
291
+ export var forwardRef = _r.forwardRef;
292
+ export var Fragment = _r.Fragment;
293
+ export var isValidElement = _r.isValidElement;
294
+ export var lazy = _r.lazy;
295
+ export var memo = _r.memo;
296
+ export var Suspense = _r.Suspense;
297
+ export var useCallback = _r.useCallback;
298
+ export var useContext = _r.useContext;
299
+ export var useDebugValue = _r.useDebugValue;
300
+ export var useEffect = _r.useEffect;
301
+ export var useId = _r.useId;
302
+ export var useImperativeHandle = _r.useImperativeHandle;
303
+ export var useLayoutEffect = _r.useLayoutEffect;
304
+ export var useMemo = _r.useMemo;
305
+ export var useReducer = _r.useReducer;
306
+ export var useRef = _r.useRef;
307
+ export var useState = _r.useState;
308
+ export var useSyncExternalStore= _r.useSyncExternalStore;
309
+ export var useTransition = _r.useTransition;
310
+ export var startTransition = _r.startTransition;
311
+ `.trim()
312
+ }));
313
+ build2.onResolve({ filter: /^react-dom(\/.*)?$/ }, () => ({
314
+ path: "react-dom",
315
+ namespace: "brodox-canvas-react-dom"
316
+ }));
317
+ build2.onLoad({ filter: /.*/, namespace: "brodox-canvas-react-dom" }, () => ({
318
+ loader: "js",
319
+ contents: `
320
+ var _rd = window.__brodox_react_dom;
321
+ export default _rd;
322
+ export var createPortal = _rd && _rd.createPortal;
323
+ export var flushSync = _rd && _rd.flushSync;
324
+ `.trim()
325
+ }));
326
+ }
327
+ };
328
+ }
329
+ function canvasRuntimePlugin() {
330
+ return {
331
+ name: "brodox-runtime-canvas-stub",
332
+ setup(build2) {
333
+ build2.onResolve({ filter: /^@broxium\/runtime$/ }, () => ({
334
+ path: "@broxium/runtime",
335
+ namespace: "brodox-runtime-canvas"
336
+ }));
337
+ build2.onLoad({ filter: /.*/, namespace: "brodox-runtime-canvas" }, () => ({
338
+ loader: "js",
339
+ contents: `
340
+ // brodox-runtime-canvas:@broxium/runtime
341
+ var React = window.__brodox_react;
342
+ var h = React.createElement;
343
+ var F = React.Fragment;
344
+
345
+ export function BrodoxHead() { return null; }
346
+ export function BrodoxFont() { return null; }
347
+
348
+ export function BrodoxLink({ href, children, className, style, target }) {
349
+ return h('a', { href, className, style, target }, children);
350
+ }
351
+
352
+ export function BrodoxImage({ src, alt, width, height, fill, className, style }) {
353
+ var imgStyle = fill
354
+ ? Object.assign({ width: '100%', height: '100%', objectFit: 'cover' }, style || {})
355
+ : (style || {});
356
+ return h('img', { src: src, alt: alt || '', width: fill ? undefined : width, height: fill ? undefined : height, className: className, style: imgStyle });
357
+ }
358
+
359
+ export function BrodoxRouter({ children }) { return h(F, null, children); }
360
+
361
+ export function useRouter() {
362
+ return { pathname: '/', params: {}, navigate: function() {}, back: function() {}, forward: function() {}, prefetch: function() {} };
363
+ }
364
+
365
+ export function useParams() { return {}; }
366
+
367
+ // In the canvas, Client and Server are both transparent: render children as-is.
368
+ export function Client({ children }) { return h(F, null, children); }
369
+ export function Server({ children }) { return h(F, null, children); }
370
+ export var ClientRender = Client;
371
+ `.trim()
372
+ }));
373
+ }
374
+ };
375
+ }
376
+
377
+ // src/utils/scopeCss.ts
378
+ var esbuild = __toESM(require("esbuild"));
379
+ function computeScopeId(slug, version) {
380
+ let hash = 2166136261;
381
+ const input = `${slug}@${version}`;
382
+ for (let i = 0; i < input.length; i++) {
383
+ hash ^= input.charCodeAt(i);
384
+ hash = Math.imul(hash, 16777619) >>> 0;
385
+ }
386
+ return hash.toString(36).slice(0, 6).padStart(6, "0");
387
+ }
388
+ function scopeSelectors(selectorText, scope) {
389
+ return selectorText.split(",").map((s) => {
390
+ const t = s.trim();
391
+ if (!t) return t;
392
+ if (t === ":root") return scope;
393
+ if (t === "html" || t === "body") return t;
394
+ return `${scope} ${t}`;
395
+ }).join(",\n");
396
+ }
397
+ function scopeCss(css, scopeId) {
398
+ const scope = `[data-bc=${scopeId}]`;
399
+ return scopeBlock(css, scope);
400
+ }
401
+ function scopeBlock(css, scope) {
402
+ let i = 0;
403
+ let out = "";
404
+ const len = css.length;
405
+ while (i < len) {
406
+ const wsStart = i;
407
+ while (i < len && /\s/.test(css[i])) i++;
408
+ out += css.slice(wsStart, i);
409
+ if (i >= len) break;
410
+ if (css[i] === "/" && css[i + 1] === "*") {
411
+ const end = css.indexOf("*/", i + 2);
412
+ if (end === -1) {
413
+ out += css.slice(i);
414
+ break;
415
+ }
416
+ out += css.slice(i, end + 2);
417
+ i = end + 2;
418
+ continue;
419
+ }
420
+ if (css[i] === "@") {
421
+ let j = i + 1;
422
+ while (j < len && /[a-zA-Z-]/.test(css[j])) j++;
423
+ const atName = css.slice(i + 1, j).toLowerCase();
424
+ if (atName === "charset" || atName === "import") {
425
+ const semi = css.indexOf(";", j);
426
+ if (semi === -1) {
427
+ out += css.slice(i);
428
+ i = len;
429
+ } else {
430
+ out += css.slice(i, semi + 1);
431
+ i = semi + 1;
432
+ }
433
+ continue;
434
+ }
435
+ if (atName === "keyframes" || atName === "-webkit-keyframes" || atName === "-moz-keyframes" || atName === "font-face") {
436
+ while (j < len && css[j] !== "{") j++;
437
+ if (j >= len) {
438
+ out += css.slice(i);
439
+ i = len;
440
+ continue;
441
+ }
442
+ const { block, end: blockEnd } = readBlock(css, j);
443
+ out += css.slice(i, j) + block;
444
+ i = blockEnd;
445
+ continue;
446
+ }
447
+ if (atName === "media" || atName === "supports" || atName === "layer" || atName === "container") {
448
+ while (j < len && css[j] !== "{") j++;
449
+ if (j >= len) {
450
+ out += css.slice(i);
451
+ i = len;
452
+ continue;
453
+ }
454
+ const condition = css.slice(i, j);
455
+ const { body, end: blockEnd } = readBlockBody(css, j);
456
+ const scopedBody = scopeBlock(body, scope);
457
+ out += `${condition}{
458
+ ${scopedBody}}
459
+ `;
460
+ i = blockEnd;
461
+ continue;
462
+ }
463
+ while (j < len && css[j] !== "{" && css[j] !== ";") j++;
464
+ if (j >= len) {
465
+ out += css.slice(i);
466
+ i = len;
467
+ continue;
468
+ }
469
+ if (css[j] === ";") {
470
+ out += css.slice(i, j + 1);
471
+ i = j + 1;
472
+ continue;
473
+ }
474
+ const { block: unknownBlock, end: unknownEnd } = readBlock(css, j);
475
+ out += css.slice(i, j) + unknownBlock;
476
+ i = unknownEnd;
477
+ continue;
478
+ }
479
+ let selectorEnd = i;
480
+ while (selectorEnd < len) {
481
+ if (css[selectorEnd] === "{") break;
482
+ if (css[selectorEnd] === "/" && css[selectorEnd + 1] === "*") {
483
+ const end = css.indexOf("*/", selectorEnd + 2);
484
+ selectorEnd = end === -1 ? len : end + 2;
485
+ continue;
486
+ }
487
+ selectorEnd++;
488
+ }
489
+ if (selectorEnd >= len) {
490
+ out += css.slice(i);
491
+ break;
492
+ }
493
+ const rawSelector = css.slice(i, selectorEnd);
494
+ const trimmedSelector = rawSelector.trim();
495
+ if (!trimmedSelector) {
496
+ i = selectorEnd;
497
+ continue;
498
+ }
499
+ const cleanSelector = trimmedSelector.replace(/\/\*.*?\*\//gs, "").trim();
500
+ const scoped = scopeSelectors(cleanSelector, scope);
501
+ const { body: declBody, end: declEnd } = readBlockBody(css, selectorEnd);
502
+ out += `${scoped} {
503
+ ${declBody}}
504
+ `;
505
+ i = declEnd;
506
+ }
507
+ return out;
508
+ }
509
+ function readBlock(css, openBrace) {
510
+ let depth = 0;
511
+ let i = openBrace;
512
+ const len = css.length;
513
+ while (i < len) {
514
+ if (css[i] === "{") depth++;
515
+ else if (css[i] === "}") {
516
+ depth--;
517
+ if (depth === 0) {
518
+ return { block: css.slice(openBrace, i + 1), end: i + 1 };
519
+ }
520
+ } else if (css[i] === "/" && css[i + 1] === "*") {
521
+ const end = css.indexOf("*/", i + 2);
522
+ if (end === -1) {
523
+ i = len;
524
+ break;
525
+ }
526
+ i = end + 1;
527
+ }
528
+ i++;
529
+ }
530
+ return { block: css.slice(openBrace), end: len };
531
+ }
532
+ function readBlockBody(css, openBrace) {
533
+ const { block, end } = readBlock(css, openBrace);
534
+ const body = block.slice(1, -1);
535
+ return { body, end };
536
+ }
537
+ async function minifyScopedCss(css, scopeId) {
538
+ const scoped = scopeCss(css, scopeId);
539
+ try {
540
+ const result = await esbuild.transform(scoped, {
541
+ loader: "css",
542
+ minify: true
543
+ });
544
+ return result.code;
545
+ } catch {
546
+ return scoped;
547
+ }
548
+ }
549
+
237
550
  // src/compiler.ts
238
551
  var ENTRY_PRIORITY = [
239
552
  "App.tsx",
@@ -280,6 +593,21 @@ function findReactNodeModules() {
280
593
  }
281
594
  return [];
282
595
  }
596
+ function cssTextPlugin(scopeId) {
597
+ return {
598
+ name: "brodox-css-text",
599
+ setup(build2) {
600
+ build2.onLoad({ filter: /\.css$/ }, async (args) => {
601
+ const content = await import_promises3.default.readFile(args.path, "utf8");
602
+ const minified = await minifyScopedCss(content, scopeId);
603
+ return {
604
+ contents: `module.exports = ${JSON.stringify(minified)};`,
605
+ loader: "js"
606
+ };
607
+ });
608
+ }
609
+ };
610
+ }
283
611
  var BrodoxCompiler = class {
284
612
  async compile(input) {
285
613
  const tmpDir = import_node_path3.default.join(import_node_os.default.tmpdir(), `brodox-compile-${input.slug}-${(0, import_node_crypto.randomUUID)()}`);
@@ -302,14 +630,17 @@ var BrodoxCompiler = class {
302
630
  if (!entryPoint) {
303
631
  throw new Error(`No entry file found in component files for ${input.slug}`);
304
632
  }
633
+ const scopeId = computeScopeId(input.slug, input.version);
305
634
  const safeName = `${input.slug}-v${input.version}`;
306
635
  const serverJsName = `${safeName}.server.esm.js`;
307
636
  const clientJsName = `${safeName}.client.esm.js`;
637
+ const browserJsName = `${safeName}.browser.js`;
308
638
  const serverJsPath = import_node_path3.default.join(input.outputDir, serverJsName);
309
639
  const clientJsPath = import_node_path3.default.join(input.outputDir, clientJsName);
640
+ const browserJsPath = import_node_path3.default.join(input.outputDir, browserJsName);
310
641
  await import_promises3.default.mkdir(input.outputDir, { recursive: true });
311
642
  const serverNodePaths = [...input.nodePaths ?? [], ...findReactNodeModules()];
312
- await esbuild.build({
643
+ await esbuild2.build({
313
644
  entryPoints: [entryPoint],
314
645
  bundle: true,
315
646
  format: "esm",
@@ -319,13 +650,11 @@ var BrodoxCompiler = class {
319
650
  nodePaths: serverNodePaths,
320
651
  external: [],
321
652
  // no externals — fully self-contained
322
- plugins: [clientStubPlugin(), runtimeServerStubPlugin(serverNodePaths)],
653
+ plugins: [clientStubPlugin(), runtimeServerStubPlugin(serverNodePaths), cssTextPlugin(scopeId)],
323
654
  outfile: serverJsPath,
324
655
  minify: false,
325
656
  sourcemap: false,
326
- define: { "process.env.NODE_ENV": '"production"' },
327
- loader: { ".css": "text" }
328
- // CSS imports return empty string in server bundle
657
+ define: { "process.env.NODE_ENV": '"production"' }
329
658
  });
330
659
  const clientComponents = [];
331
660
  for (const file of input.files) {
@@ -350,7 +679,7 @@ ${registryEntries}
350
679
  await import_promises3.default.writeFile(registryEntryPath, registryWrapper, "utf8");
351
680
  clientEntryPoint = registryEntryPath;
352
681
  }
353
- await esbuild.build({
682
+ await esbuild2.build({
354
683
  entryPoints: [clientEntryPoint],
355
684
  bundle: true,
356
685
  format: "esm",
@@ -358,21 +687,35 @@ ${registryEntries}
358
687
  target: ["es2020", "chrome90", "firefox88", "safari14"],
359
688
  jsx: "automatic",
360
689
  external: CLIENT_EXTERNALS,
361
- plugins: [serverStubPlugin()],
690
+ plugins: [serverStubPlugin(), cssTextPlugin(scopeId)],
362
691
  outfile: clientJsPath,
363
692
  minify: true,
364
693
  sourcemap: false,
365
694
  define: { "process.env.NODE_ENV": '"production"' },
366
- banner: { js: 'import React from "react";' },
367
- loader: { ".css": "text" }
368
- // CSS imports return the CSS string (injected via BrodoxHead or style tag)
695
+ banner: { js: 'import React from "react";' }
696
+ });
697
+ await esbuild2.build({
698
+ entryPoints: [clientEntryPoint],
699
+ bundle: true,
700
+ format: "esm",
701
+ platform: "browser",
702
+ target: ["es2020", "chrome90", "firefox88", "safari14"],
703
+ jsx: "automatic",
704
+ external: [],
705
+ // no externals — everything inlined or replaced by plugins
706
+ plugins: [canvasReactPlugin(), canvasRuntimePlugin(), cssTextPlugin(scopeId)],
707
+ outfile: browserJsPath,
708
+ minify: true,
709
+ sourcemap: false,
710
+ define: { "process.env.NODE_ENV": '"production"' }
711
+ // no banner — canvasReactPlugin handles the React reference via window global
369
712
  });
370
713
  const hasCss = input.files.some((f) => /\.css$/.test(f.path));
371
714
  const cssName = hasCss ? `${safeName}.css` : null;
372
715
  const cssPath = hasCss ? import_node_path3.default.join(input.outputDir, cssName) : null;
373
716
  if (hasCss && cssPath) {
374
717
  try {
375
- await esbuild.build({
718
+ await esbuild2.build({
376
719
  entryPoints: [entryPoint],
377
720
  bundle: true,
378
721
  format: "esm",
@@ -382,7 +725,8 @@ ${registryEntries}
382
725
  plugins: [serverStubPlugin()],
383
726
  outfile: cssPath.replace(/\.css$/, ".css.tmp.js"),
384
727
  // esbuild needs a JS outfile
385
- minify: true,
728
+ minify: false,
729
+ // we'll minify after scoping
386
730
  sourcemap: false,
387
731
  define: { "process.env.NODE_ENV": '"production"' },
388
732
  loader: { ".css": "css" }
@@ -393,6 +737,12 @@ ${registryEntries}
393
737
  } catch {
394
738
  }
395
739
  await import_promises3.default.rm(cssPath.replace(/\.css$/, ".css.tmp.js"), { force: true });
740
+ try {
741
+ const rawCss = await import_promises3.default.readFile(cssPath, "utf8");
742
+ const minifiedScoped = await minifyScopedCss(rawCss, scopeId);
743
+ await import_promises3.default.writeFile(cssPath, minifiedScoped, "utf8");
744
+ } catch {
745
+ }
396
746
  } catch {
397
747
  }
398
748
  }
@@ -400,15 +750,68 @@ ${registryEntries}
400
750
  return {
401
751
  serverJsPath,
402
752
  clientJsPath,
753
+ browserJsPath,
403
754
  cssPath,
404
755
  serverJsName,
405
756
  clientJsName,
757
+ browserJsName,
406
758
  cssName,
759
+ scopeId,
407
760
  compiledAt: /* @__PURE__ */ new Date()
408
761
  };
409
762
  }
410
763
  };
764
+
765
+ // src/utils/cssStrategy.ts
766
+ var PAGE_CSS_THRESHOLDS = {
767
+ /** Embed as <style> when total page CSS ≤ this value (bytes). */
768
+ INLINE_MAX_BYTES: 4096,
769
+ /** Switch to multi-chunk delivery when total page CSS > this value (bytes). */
770
+ CHUNK_MIN_BYTES: 51200,
771
+ /** Maximum bytes per CSS chunk file. */
772
+ CHUNK_SIZE_BYTES: 25600
773
+ };
774
+ function decidePageCssStrategy(mergedCss) {
775
+ const size = Buffer.byteLength(mergedCss, "utf8");
776
+ if (size === 0) return { strategy: "none" };
777
+ if (size <= PAGE_CSS_THRESHOLDS.INLINE_MAX_BYTES) {
778
+ return { strategy: "inline", inlineCss: mergedCss };
779
+ }
780
+ if (size > PAGE_CSS_THRESHOLDS.CHUNK_MIN_BYTES) {
781
+ return {
782
+ strategy: "chunks",
783
+ cssChunks: splitCssIntoChunks(mergedCss, PAGE_CSS_THRESHOLDS.CHUNK_SIZE_BYTES)
784
+ };
785
+ }
786
+ return { strategy: "separate", cssChunks: [mergedCss] };
787
+ }
788
+ function splitCssIntoChunks(css, chunkSize) {
789
+ const chunks = [];
790
+ let remaining = css.trim();
791
+ while (remaining.length > 0) {
792
+ const buf = Buffer.from(remaining, "utf8");
793
+ if (buf.byteLength <= chunkSize) {
794
+ chunks.push(remaining);
795
+ break;
796
+ }
797
+ const candidate = buf.slice(0, chunkSize).toString("utf8");
798
+ const lastBrace = candidate.lastIndexOf("}");
799
+ if (lastBrace < 0) {
800
+ chunks.push(remaining);
801
+ break;
802
+ }
803
+ chunks.push(remaining.slice(0, lastBrace + 1));
804
+ remaining = remaining.slice(lastBrace + 1).trim();
805
+ }
806
+ return chunks.filter((c) => c.length > 0);
807
+ }
411
808
  // Annotate the CommonJS export names for ESM import in node:
412
809
  0 && (module.exports = {
413
- BrodoxCompiler
810
+ BrodoxCompiler,
811
+ PAGE_CSS_THRESHOLDS,
812
+ computeScopeId,
813
+ decidePageCssStrategy,
814
+ minifyScopedCss,
815
+ scopeCss,
816
+ splitCssIntoChunks
414
817
  });
package/dist/index.mjs CHANGED
@@ -6,7 +6,7 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
6
6
  });
7
7
 
8
8
  // src/compiler.ts
9
- import * as esbuild from "esbuild";
9
+ import * as esbuild2 from "esbuild";
10
10
  import fs3 from "fs/promises";
11
11
  import path3 from "path";
12
12
  import os from "os";
@@ -125,11 +125,13 @@ export function useRouter() {
125
125
 
126
126
  export function useParams() { return {}; }
127
127
 
128
- export function BrodoxHead({ title, description }) {
128
+ export function BrodoxHead({ title, description, cssContent }) {
129
129
  if (title && typeof globalThis.__brodoxCollectHead === 'function')
130
130
  globalThis.__brodoxCollectHead({ type: 'title', props: { content: title } });
131
131
  if (description && typeof globalThis.__brodoxCollectHead === 'function')
132
132
  globalThis.__brodoxCollectHead({ type: 'meta', props: { name: 'description', content: description } });
133
+ if (cssContent && typeof globalThis.__brodoxCollectHead === 'function')
134
+ globalThis.__brodoxCollectHead({ type: 'style', props: { content: cssContent } });
133
135
  return null;
134
136
  }
135
137
 
@@ -205,6 +207,311 @@ export var ClientRender = Client;
205
207
  };
206
208
  }
207
209
 
210
+ // src/plugins/canvasBuildPlugin.ts
211
+ function canvasReactPlugin() {
212
+ return {
213
+ name: "brodox-canvas-react",
214
+ setup(build2) {
215
+ build2.onResolve({ filter: /^react\/jsx-runtime$/ }, () => ({
216
+ path: "react/jsx-runtime",
217
+ namespace: "brodox-canvas-jsx"
218
+ }));
219
+ build2.onLoad({ filter: /.*/, namespace: "brodox-canvas-jsx" }, () => ({
220
+ loader: "js",
221
+ // Use window.__brodox_jsx (ReactJSXRuntime) — the real jsx/jsxs functions
222
+ // behave differently from createElement for children handling.
223
+ contents: `
224
+ var _jx = window.__brodox_jsx;
225
+ export var jsx = _jx.jsx;
226
+ export var jsxs = _jx.jsxs;
227
+ export var Fragment = _jx.Fragment;
228
+ `.trim()
229
+ }));
230
+ build2.onResolve({ filter: /^react\/jsx-dev-runtime$/ }, () => ({
231
+ path: "react/jsx-dev-runtime",
232
+ namespace: "brodox-canvas-jsx-dev"
233
+ }));
234
+ build2.onLoad({ filter: /.*/, namespace: "brodox-canvas-jsx-dev" }, () => ({
235
+ loader: "js",
236
+ contents: `
237
+ var _jx = window.__brodox_jsx;
238
+ export var jsxDEV = _jx.jsx || _jx.jsxDEV;
239
+ export var Fragment = _jx.Fragment;
240
+ `.trim()
241
+ }));
242
+ build2.onResolve({ filter: /^react$/ }, () => ({
243
+ path: "react",
244
+ namespace: "brodox-canvas-react"
245
+ }));
246
+ build2.onLoad({ filter: /.*/, namespace: "brodox-canvas-react" }, () => ({
247
+ loader: "js",
248
+ contents: `
249
+ var _r = window.__brodox_react;
250
+ export default _r;
251
+ export var Children = _r.Children;
252
+ export var createElement = _r.createElement;
253
+ export var cloneElement = _r.cloneElement;
254
+ export var createContext = _r.createContext;
255
+ export var createRef = _r.createRef;
256
+ export var forwardRef = _r.forwardRef;
257
+ export var Fragment = _r.Fragment;
258
+ export var isValidElement = _r.isValidElement;
259
+ export var lazy = _r.lazy;
260
+ export var memo = _r.memo;
261
+ export var Suspense = _r.Suspense;
262
+ export var useCallback = _r.useCallback;
263
+ export var useContext = _r.useContext;
264
+ export var useDebugValue = _r.useDebugValue;
265
+ export var useEffect = _r.useEffect;
266
+ export var useId = _r.useId;
267
+ export var useImperativeHandle = _r.useImperativeHandle;
268
+ export var useLayoutEffect = _r.useLayoutEffect;
269
+ export var useMemo = _r.useMemo;
270
+ export var useReducer = _r.useReducer;
271
+ export var useRef = _r.useRef;
272
+ export var useState = _r.useState;
273
+ export var useSyncExternalStore= _r.useSyncExternalStore;
274
+ export var useTransition = _r.useTransition;
275
+ export var startTransition = _r.startTransition;
276
+ `.trim()
277
+ }));
278
+ build2.onResolve({ filter: /^react-dom(\/.*)?$/ }, () => ({
279
+ path: "react-dom",
280
+ namespace: "brodox-canvas-react-dom"
281
+ }));
282
+ build2.onLoad({ filter: /.*/, namespace: "brodox-canvas-react-dom" }, () => ({
283
+ loader: "js",
284
+ contents: `
285
+ var _rd = window.__brodox_react_dom;
286
+ export default _rd;
287
+ export var createPortal = _rd && _rd.createPortal;
288
+ export var flushSync = _rd && _rd.flushSync;
289
+ `.trim()
290
+ }));
291
+ }
292
+ };
293
+ }
294
+ function canvasRuntimePlugin() {
295
+ return {
296
+ name: "brodox-runtime-canvas-stub",
297
+ setup(build2) {
298
+ build2.onResolve({ filter: /^@broxium\/runtime$/ }, () => ({
299
+ path: "@broxium/runtime",
300
+ namespace: "brodox-runtime-canvas"
301
+ }));
302
+ build2.onLoad({ filter: /.*/, namespace: "brodox-runtime-canvas" }, () => ({
303
+ loader: "js",
304
+ contents: `
305
+ // brodox-runtime-canvas:@broxium/runtime
306
+ var React = window.__brodox_react;
307
+ var h = React.createElement;
308
+ var F = React.Fragment;
309
+
310
+ export function BrodoxHead() { return null; }
311
+ export function BrodoxFont() { return null; }
312
+
313
+ export function BrodoxLink({ href, children, className, style, target }) {
314
+ return h('a', { href, className, style, target }, children);
315
+ }
316
+
317
+ export function BrodoxImage({ src, alt, width, height, fill, className, style }) {
318
+ var imgStyle = fill
319
+ ? Object.assign({ width: '100%', height: '100%', objectFit: 'cover' }, style || {})
320
+ : (style || {});
321
+ return h('img', { src: src, alt: alt || '', width: fill ? undefined : width, height: fill ? undefined : height, className: className, style: imgStyle });
322
+ }
323
+
324
+ export function BrodoxRouter({ children }) { return h(F, null, children); }
325
+
326
+ export function useRouter() {
327
+ return { pathname: '/', params: {}, navigate: function() {}, back: function() {}, forward: function() {}, prefetch: function() {} };
328
+ }
329
+
330
+ export function useParams() { return {}; }
331
+
332
+ // In the canvas, Client and Server are both transparent: render children as-is.
333
+ export function Client({ children }) { return h(F, null, children); }
334
+ export function Server({ children }) { return h(F, null, children); }
335
+ export var ClientRender = Client;
336
+ `.trim()
337
+ }));
338
+ }
339
+ };
340
+ }
341
+
342
+ // src/utils/scopeCss.ts
343
+ import * as esbuild from "esbuild";
344
+ function computeScopeId(slug, version) {
345
+ let hash = 2166136261;
346
+ const input = `${slug}@${version}`;
347
+ for (let i = 0; i < input.length; i++) {
348
+ hash ^= input.charCodeAt(i);
349
+ hash = Math.imul(hash, 16777619) >>> 0;
350
+ }
351
+ return hash.toString(36).slice(0, 6).padStart(6, "0");
352
+ }
353
+ function scopeSelectors(selectorText, scope) {
354
+ return selectorText.split(",").map((s) => {
355
+ const t = s.trim();
356
+ if (!t) return t;
357
+ if (t === ":root") return scope;
358
+ if (t === "html" || t === "body") return t;
359
+ return `${scope} ${t}`;
360
+ }).join(",\n");
361
+ }
362
+ function scopeCss(css, scopeId) {
363
+ const scope = `[data-bc=${scopeId}]`;
364
+ return scopeBlock(css, scope);
365
+ }
366
+ function scopeBlock(css, scope) {
367
+ let i = 0;
368
+ let out = "";
369
+ const len = css.length;
370
+ while (i < len) {
371
+ const wsStart = i;
372
+ while (i < len && /\s/.test(css[i])) i++;
373
+ out += css.slice(wsStart, i);
374
+ if (i >= len) break;
375
+ if (css[i] === "/" && css[i + 1] === "*") {
376
+ const end = css.indexOf("*/", i + 2);
377
+ if (end === -1) {
378
+ out += css.slice(i);
379
+ break;
380
+ }
381
+ out += css.slice(i, end + 2);
382
+ i = end + 2;
383
+ continue;
384
+ }
385
+ if (css[i] === "@") {
386
+ let j = i + 1;
387
+ while (j < len && /[a-zA-Z-]/.test(css[j])) j++;
388
+ const atName = css.slice(i + 1, j).toLowerCase();
389
+ if (atName === "charset" || atName === "import") {
390
+ const semi = css.indexOf(";", j);
391
+ if (semi === -1) {
392
+ out += css.slice(i);
393
+ i = len;
394
+ } else {
395
+ out += css.slice(i, semi + 1);
396
+ i = semi + 1;
397
+ }
398
+ continue;
399
+ }
400
+ if (atName === "keyframes" || atName === "-webkit-keyframes" || atName === "-moz-keyframes" || atName === "font-face") {
401
+ while (j < len && css[j] !== "{") j++;
402
+ if (j >= len) {
403
+ out += css.slice(i);
404
+ i = len;
405
+ continue;
406
+ }
407
+ const { block, end: blockEnd } = readBlock(css, j);
408
+ out += css.slice(i, j) + block;
409
+ i = blockEnd;
410
+ continue;
411
+ }
412
+ if (atName === "media" || atName === "supports" || atName === "layer" || atName === "container") {
413
+ while (j < len && css[j] !== "{") j++;
414
+ if (j >= len) {
415
+ out += css.slice(i);
416
+ i = len;
417
+ continue;
418
+ }
419
+ const condition = css.slice(i, j);
420
+ const { body, end: blockEnd } = readBlockBody(css, j);
421
+ const scopedBody = scopeBlock(body, scope);
422
+ out += `${condition}{
423
+ ${scopedBody}}
424
+ `;
425
+ i = blockEnd;
426
+ continue;
427
+ }
428
+ while (j < len && css[j] !== "{" && css[j] !== ";") j++;
429
+ if (j >= len) {
430
+ out += css.slice(i);
431
+ i = len;
432
+ continue;
433
+ }
434
+ if (css[j] === ";") {
435
+ out += css.slice(i, j + 1);
436
+ i = j + 1;
437
+ continue;
438
+ }
439
+ const { block: unknownBlock, end: unknownEnd } = readBlock(css, j);
440
+ out += css.slice(i, j) + unknownBlock;
441
+ i = unknownEnd;
442
+ continue;
443
+ }
444
+ let selectorEnd = i;
445
+ while (selectorEnd < len) {
446
+ if (css[selectorEnd] === "{") break;
447
+ if (css[selectorEnd] === "/" && css[selectorEnd + 1] === "*") {
448
+ const end = css.indexOf("*/", selectorEnd + 2);
449
+ selectorEnd = end === -1 ? len : end + 2;
450
+ continue;
451
+ }
452
+ selectorEnd++;
453
+ }
454
+ if (selectorEnd >= len) {
455
+ out += css.slice(i);
456
+ break;
457
+ }
458
+ const rawSelector = css.slice(i, selectorEnd);
459
+ const trimmedSelector = rawSelector.trim();
460
+ if (!trimmedSelector) {
461
+ i = selectorEnd;
462
+ continue;
463
+ }
464
+ const cleanSelector = trimmedSelector.replace(/\/\*.*?\*\//gs, "").trim();
465
+ const scoped = scopeSelectors(cleanSelector, scope);
466
+ const { body: declBody, end: declEnd } = readBlockBody(css, selectorEnd);
467
+ out += `${scoped} {
468
+ ${declBody}}
469
+ `;
470
+ i = declEnd;
471
+ }
472
+ return out;
473
+ }
474
+ function readBlock(css, openBrace) {
475
+ let depth = 0;
476
+ let i = openBrace;
477
+ const len = css.length;
478
+ while (i < len) {
479
+ if (css[i] === "{") depth++;
480
+ else if (css[i] === "}") {
481
+ depth--;
482
+ if (depth === 0) {
483
+ return { block: css.slice(openBrace, i + 1), end: i + 1 };
484
+ }
485
+ } else if (css[i] === "/" && css[i + 1] === "*") {
486
+ const end = css.indexOf("*/", i + 2);
487
+ if (end === -1) {
488
+ i = len;
489
+ break;
490
+ }
491
+ i = end + 1;
492
+ }
493
+ i++;
494
+ }
495
+ return { block: css.slice(openBrace), end: len };
496
+ }
497
+ function readBlockBody(css, openBrace) {
498
+ const { block, end } = readBlock(css, openBrace);
499
+ const body = block.slice(1, -1);
500
+ return { body, end };
501
+ }
502
+ async function minifyScopedCss(css, scopeId) {
503
+ const scoped = scopeCss(css, scopeId);
504
+ try {
505
+ const result = await esbuild.transform(scoped, {
506
+ loader: "css",
507
+ minify: true
508
+ });
509
+ return result.code;
510
+ } catch {
511
+ return scoped;
512
+ }
513
+ }
514
+
208
515
  // src/compiler.ts
209
516
  var ENTRY_PRIORITY = [
210
517
  "App.tsx",
@@ -251,6 +558,21 @@ function findReactNodeModules() {
251
558
  }
252
559
  return [];
253
560
  }
561
+ function cssTextPlugin(scopeId) {
562
+ return {
563
+ name: "brodox-css-text",
564
+ setup(build2) {
565
+ build2.onLoad({ filter: /\.css$/ }, async (args) => {
566
+ const content = await fs3.readFile(args.path, "utf8");
567
+ const minified = await minifyScopedCss(content, scopeId);
568
+ return {
569
+ contents: `module.exports = ${JSON.stringify(minified)};`,
570
+ loader: "js"
571
+ };
572
+ });
573
+ }
574
+ };
575
+ }
254
576
  var BrodoxCompiler = class {
255
577
  async compile(input) {
256
578
  const tmpDir = path3.join(os.tmpdir(), `brodox-compile-${input.slug}-${randomUUID()}`);
@@ -273,14 +595,17 @@ var BrodoxCompiler = class {
273
595
  if (!entryPoint) {
274
596
  throw new Error(`No entry file found in component files for ${input.slug}`);
275
597
  }
598
+ const scopeId = computeScopeId(input.slug, input.version);
276
599
  const safeName = `${input.slug}-v${input.version}`;
277
600
  const serverJsName = `${safeName}.server.esm.js`;
278
601
  const clientJsName = `${safeName}.client.esm.js`;
602
+ const browserJsName = `${safeName}.browser.js`;
279
603
  const serverJsPath = path3.join(input.outputDir, serverJsName);
280
604
  const clientJsPath = path3.join(input.outputDir, clientJsName);
605
+ const browserJsPath = path3.join(input.outputDir, browserJsName);
281
606
  await fs3.mkdir(input.outputDir, { recursive: true });
282
607
  const serverNodePaths = [...input.nodePaths ?? [], ...findReactNodeModules()];
283
- await esbuild.build({
608
+ await esbuild2.build({
284
609
  entryPoints: [entryPoint],
285
610
  bundle: true,
286
611
  format: "esm",
@@ -290,13 +615,11 @@ var BrodoxCompiler = class {
290
615
  nodePaths: serverNodePaths,
291
616
  external: [],
292
617
  // no externals — fully self-contained
293
- plugins: [clientStubPlugin(), runtimeServerStubPlugin(serverNodePaths)],
618
+ plugins: [clientStubPlugin(), runtimeServerStubPlugin(serverNodePaths), cssTextPlugin(scopeId)],
294
619
  outfile: serverJsPath,
295
620
  minify: false,
296
621
  sourcemap: false,
297
- define: { "process.env.NODE_ENV": '"production"' },
298
- loader: { ".css": "text" }
299
- // CSS imports return empty string in server bundle
622
+ define: { "process.env.NODE_ENV": '"production"' }
300
623
  });
301
624
  const clientComponents = [];
302
625
  for (const file of input.files) {
@@ -321,7 +644,7 @@ ${registryEntries}
321
644
  await fs3.writeFile(registryEntryPath, registryWrapper, "utf8");
322
645
  clientEntryPoint = registryEntryPath;
323
646
  }
324
- await esbuild.build({
647
+ await esbuild2.build({
325
648
  entryPoints: [clientEntryPoint],
326
649
  bundle: true,
327
650
  format: "esm",
@@ -329,21 +652,35 @@ ${registryEntries}
329
652
  target: ["es2020", "chrome90", "firefox88", "safari14"],
330
653
  jsx: "automatic",
331
654
  external: CLIENT_EXTERNALS,
332
- plugins: [serverStubPlugin()],
655
+ plugins: [serverStubPlugin(), cssTextPlugin(scopeId)],
333
656
  outfile: clientJsPath,
334
657
  minify: true,
335
658
  sourcemap: false,
336
659
  define: { "process.env.NODE_ENV": '"production"' },
337
- banner: { js: 'import React from "react";' },
338
- loader: { ".css": "text" }
339
- // CSS imports return the CSS string (injected via BrodoxHead or style tag)
660
+ banner: { js: 'import React from "react";' }
661
+ });
662
+ await esbuild2.build({
663
+ entryPoints: [clientEntryPoint],
664
+ bundle: true,
665
+ format: "esm",
666
+ platform: "browser",
667
+ target: ["es2020", "chrome90", "firefox88", "safari14"],
668
+ jsx: "automatic",
669
+ external: [],
670
+ // no externals — everything inlined or replaced by plugins
671
+ plugins: [canvasReactPlugin(), canvasRuntimePlugin(), cssTextPlugin(scopeId)],
672
+ outfile: browserJsPath,
673
+ minify: true,
674
+ sourcemap: false,
675
+ define: { "process.env.NODE_ENV": '"production"' }
676
+ // no banner — canvasReactPlugin handles the React reference via window global
340
677
  });
341
678
  const hasCss = input.files.some((f) => /\.css$/.test(f.path));
342
679
  const cssName = hasCss ? `${safeName}.css` : null;
343
680
  const cssPath = hasCss ? path3.join(input.outputDir, cssName) : null;
344
681
  if (hasCss && cssPath) {
345
682
  try {
346
- await esbuild.build({
683
+ await esbuild2.build({
347
684
  entryPoints: [entryPoint],
348
685
  bundle: true,
349
686
  format: "esm",
@@ -353,7 +690,8 @@ ${registryEntries}
353
690
  plugins: [serverStubPlugin()],
354
691
  outfile: cssPath.replace(/\.css$/, ".css.tmp.js"),
355
692
  // esbuild needs a JS outfile
356
- minify: true,
693
+ minify: false,
694
+ // we'll minify after scoping
357
695
  sourcemap: false,
358
696
  define: { "process.env.NODE_ENV": '"production"' },
359
697
  loader: { ".css": "css" }
@@ -364,6 +702,12 @@ ${registryEntries}
364
702
  } catch {
365
703
  }
366
704
  await fs3.rm(cssPath.replace(/\.css$/, ".css.tmp.js"), { force: true });
705
+ try {
706
+ const rawCss = await fs3.readFile(cssPath, "utf8");
707
+ const minifiedScoped = await minifyScopedCss(rawCss, scopeId);
708
+ await fs3.writeFile(cssPath, minifiedScoped, "utf8");
709
+ } catch {
710
+ }
367
711
  } catch {
368
712
  }
369
713
  }
@@ -371,14 +715,67 @@ ${registryEntries}
371
715
  return {
372
716
  serverJsPath,
373
717
  clientJsPath,
718
+ browserJsPath,
374
719
  cssPath,
375
720
  serverJsName,
376
721
  clientJsName,
722
+ browserJsName,
377
723
  cssName,
724
+ scopeId,
378
725
  compiledAt: /* @__PURE__ */ new Date()
379
726
  };
380
727
  }
381
728
  };
729
+
730
+ // src/utils/cssStrategy.ts
731
+ var PAGE_CSS_THRESHOLDS = {
732
+ /** Embed as <style> when total page CSS ≤ this value (bytes). */
733
+ INLINE_MAX_BYTES: 4096,
734
+ /** Switch to multi-chunk delivery when total page CSS > this value (bytes). */
735
+ CHUNK_MIN_BYTES: 51200,
736
+ /** Maximum bytes per CSS chunk file. */
737
+ CHUNK_SIZE_BYTES: 25600
738
+ };
739
+ function decidePageCssStrategy(mergedCss) {
740
+ const size = Buffer.byteLength(mergedCss, "utf8");
741
+ if (size === 0) return { strategy: "none" };
742
+ if (size <= PAGE_CSS_THRESHOLDS.INLINE_MAX_BYTES) {
743
+ return { strategy: "inline", inlineCss: mergedCss };
744
+ }
745
+ if (size > PAGE_CSS_THRESHOLDS.CHUNK_MIN_BYTES) {
746
+ return {
747
+ strategy: "chunks",
748
+ cssChunks: splitCssIntoChunks(mergedCss, PAGE_CSS_THRESHOLDS.CHUNK_SIZE_BYTES)
749
+ };
750
+ }
751
+ return { strategy: "separate", cssChunks: [mergedCss] };
752
+ }
753
+ function splitCssIntoChunks(css, chunkSize) {
754
+ const chunks = [];
755
+ let remaining = css.trim();
756
+ while (remaining.length > 0) {
757
+ const buf = Buffer.from(remaining, "utf8");
758
+ if (buf.byteLength <= chunkSize) {
759
+ chunks.push(remaining);
760
+ break;
761
+ }
762
+ const candidate = buf.slice(0, chunkSize).toString("utf8");
763
+ const lastBrace = candidate.lastIndexOf("}");
764
+ if (lastBrace < 0) {
765
+ chunks.push(remaining);
766
+ break;
767
+ }
768
+ chunks.push(remaining.slice(0, lastBrace + 1));
769
+ remaining = remaining.slice(lastBrace + 1).trim();
770
+ }
771
+ return chunks.filter((c) => c.length > 0);
772
+ }
382
773
  export {
383
- BrodoxCompiler
774
+ BrodoxCompiler,
775
+ PAGE_CSS_THRESHOLDS,
776
+ computeScopeId,
777
+ decidePageCssStrategy,
778
+ minifyScopedCss,
779
+ scopeCss,
780
+ splitCssIntoChunks
384
781
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@broxium/compiler",
3
- "version": "1.5.0",
3
+ "version": "1.5.2",
4
4
  "description": "Brodox component compiler — TSX to ESM server + client bundles",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -18,9 +18,9 @@
18
18
  "esbuild": "^0.25.0"
19
19
  },
20
20
  "devDependencies": {
21
+ "@types/node": "^20.19.42",
21
22
  "tsup": "^8.0.0",
22
- "typescript": "^5.0.0",
23
- "@types/node": "^20.0.0"
23
+ "typescript": "^5.0.0"
24
24
  },
25
25
  "scripts": {
26
26
  "build": "tsup src/index.ts --format esm,cjs --dts",