@broxium/compiler 1.5.1 → 1.5.3

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"));
@@ -236,6 +242,311 @@ export var ClientRender = Client;
236
242
  };
237
243
  }
238
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
+
239
550
  // src/compiler.ts
240
551
  var ENTRY_PRIORITY = [
241
552
  "App.tsx",
@@ -282,6 +593,21 @@ function findReactNodeModules() {
282
593
  }
283
594
  return [];
284
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
+ }
285
611
  var BrodoxCompiler = class {
286
612
  async compile(input) {
287
613
  const tmpDir = import_node_path3.default.join(import_node_os.default.tmpdir(), `brodox-compile-${input.slug}-${(0, import_node_crypto.randomUUID)()}`);
@@ -304,14 +630,17 @@ var BrodoxCompiler = class {
304
630
  if (!entryPoint) {
305
631
  throw new Error(`No entry file found in component files for ${input.slug}`);
306
632
  }
633
+ const scopeId = computeScopeId(input.slug, input.version);
307
634
  const safeName = `${input.slug}-v${input.version}`;
308
635
  const serverJsName = `${safeName}.server.esm.js`;
309
636
  const clientJsName = `${safeName}.client.esm.js`;
637
+ const browserJsName = `${safeName}.browser.js`;
310
638
  const serverJsPath = import_node_path3.default.join(input.outputDir, serverJsName);
311
639
  const clientJsPath = import_node_path3.default.join(input.outputDir, clientJsName);
640
+ const browserJsPath = import_node_path3.default.join(input.outputDir, browserJsName);
312
641
  await import_promises3.default.mkdir(input.outputDir, { recursive: true });
313
642
  const serverNodePaths = [...input.nodePaths ?? [], ...findReactNodeModules()];
314
- await esbuild.build({
643
+ await esbuild2.build({
315
644
  entryPoints: [entryPoint],
316
645
  bundle: true,
317
646
  format: "esm",
@@ -321,13 +650,11 @@ var BrodoxCompiler = class {
321
650
  nodePaths: serverNodePaths,
322
651
  external: [],
323
652
  // no externals — fully self-contained
324
- plugins: [clientStubPlugin(), runtimeServerStubPlugin(serverNodePaths)],
653
+ plugins: [clientStubPlugin(), runtimeServerStubPlugin(serverNodePaths), cssTextPlugin(scopeId)],
325
654
  outfile: serverJsPath,
326
655
  minify: false,
327
656
  sourcemap: false,
328
- define: { "process.env.NODE_ENV": '"production"' },
329
- loader: { ".css": "text" }
330
- // CSS imports return empty string in server bundle
657
+ define: { "process.env.NODE_ENV": '"production"' }
331
658
  });
332
659
  const clientComponents = [];
333
660
  for (const file of input.files) {
@@ -352,7 +679,7 @@ ${registryEntries}
352
679
  await import_promises3.default.writeFile(registryEntryPath, registryWrapper, "utf8");
353
680
  clientEntryPoint = registryEntryPath;
354
681
  }
355
- await esbuild.build({
682
+ await esbuild2.build({
356
683
  entryPoints: [clientEntryPoint],
357
684
  bundle: true,
358
685
  format: "esm",
@@ -360,21 +687,35 @@ ${registryEntries}
360
687
  target: ["es2020", "chrome90", "firefox88", "safari14"],
361
688
  jsx: "automatic",
362
689
  external: CLIENT_EXTERNALS,
363
- plugins: [serverStubPlugin()],
690
+ plugins: [serverStubPlugin(), cssTextPlugin(scopeId)],
364
691
  outfile: clientJsPath,
365
692
  minify: true,
366
693
  sourcemap: false,
367
694
  define: { "process.env.NODE_ENV": '"production"' },
368
- banner: { js: 'import React from "react";' },
369
- loader: { ".css": "text" }
370
- // 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
371
712
  });
372
713
  const hasCss = input.files.some((f) => /\.css$/.test(f.path));
373
714
  const cssName = hasCss ? `${safeName}.css` : null;
374
715
  const cssPath = hasCss ? import_node_path3.default.join(input.outputDir, cssName) : null;
375
716
  if (hasCss && cssPath) {
376
717
  try {
377
- await esbuild.build({
718
+ await esbuild2.build({
378
719
  entryPoints: [entryPoint],
379
720
  bundle: true,
380
721
  format: "esm",
@@ -384,7 +725,8 @@ ${registryEntries}
384
725
  plugins: [serverStubPlugin()],
385
726
  outfile: cssPath.replace(/\.css$/, ".css.tmp.js"),
386
727
  // esbuild needs a JS outfile
387
- minify: true,
728
+ minify: false,
729
+ // we'll minify after scoping
388
730
  sourcemap: false,
389
731
  define: { "process.env.NODE_ENV": '"production"' },
390
732
  loader: { ".css": "css" }
@@ -395,6 +737,12 @@ ${registryEntries}
395
737
  } catch {
396
738
  }
397
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
+ }
398
746
  } catch {
399
747
  }
400
748
  }
@@ -402,15 +750,68 @@ ${registryEntries}
402
750
  return {
403
751
  serverJsPath,
404
752
  clientJsPath,
753
+ browserJsPath,
405
754
  cssPath,
406
755
  serverJsName,
407
756
  clientJsName,
757
+ browserJsName,
408
758
  cssName,
759
+ scopeId,
409
760
  compiledAt: /* @__PURE__ */ new Date()
410
761
  };
411
762
  }
412
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
+ }
413
808
  // Annotate the CommonJS export names for ESM import in node:
414
809
  0 && (module.exports = {
415
- BrodoxCompiler
810
+ BrodoxCompiler,
811
+ PAGE_CSS_THRESHOLDS,
812
+ computeScopeId,
813
+ decidePageCssStrategy,
814
+ minifyScopedCss,
815
+ scopeCss,
816
+ splitCssIntoChunks
416
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";
@@ -207,6 +207,311 @@ export var ClientRender = Client;
207
207
  };
208
208
  }
209
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
+
210
515
  // src/compiler.ts
211
516
  var ENTRY_PRIORITY = [
212
517
  "App.tsx",
@@ -253,6 +558,21 @@ function findReactNodeModules() {
253
558
  }
254
559
  return [];
255
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
+ }
256
576
  var BrodoxCompiler = class {
257
577
  async compile(input) {
258
578
  const tmpDir = path3.join(os.tmpdir(), `brodox-compile-${input.slug}-${randomUUID()}`);
@@ -275,14 +595,17 @@ var BrodoxCompiler = class {
275
595
  if (!entryPoint) {
276
596
  throw new Error(`No entry file found in component files for ${input.slug}`);
277
597
  }
598
+ const scopeId = computeScopeId(input.slug, input.version);
278
599
  const safeName = `${input.slug}-v${input.version}`;
279
600
  const serverJsName = `${safeName}.server.esm.js`;
280
601
  const clientJsName = `${safeName}.client.esm.js`;
602
+ const browserJsName = `${safeName}.browser.js`;
281
603
  const serverJsPath = path3.join(input.outputDir, serverJsName);
282
604
  const clientJsPath = path3.join(input.outputDir, clientJsName);
605
+ const browserJsPath = path3.join(input.outputDir, browserJsName);
283
606
  await fs3.mkdir(input.outputDir, { recursive: true });
284
607
  const serverNodePaths = [...input.nodePaths ?? [], ...findReactNodeModules()];
285
- await esbuild.build({
608
+ await esbuild2.build({
286
609
  entryPoints: [entryPoint],
287
610
  bundle: true,
288
611
  format: "esm",
@@ -292,13 +615,11 @@ var BrodoxCompiler = class {
292
615
  nodePaths: serverNodePaths,
293
616
  external: [],
294
617
  // no externals — fully self-contained
295
- plugins: [clientStubPlugin(), runtimeServerStubPlugin(serverNodePaths)],
618
+ plugins: [clientStubPlugin(), runtimeServerStubPlugin(serverNodePaths), cssTextPlugin(scopeId)],
296
619
  outfile: serverJsPath,
297
620
  minify: false,
298
621
  sourcemap: false,
299
- define: { "process.env.NODE_ENV": '"production"' },
300
- loader: { ".css": "text" }
301
- // CSS imports return empty string in server bundle
622
+ define: { "process.env.NODE_ENV": '"production"' }
302
623
  });
303
624
  const clientComponents = [];
304
625
  for (const file of input.files) {
@@ -323,7 +644,7 @@ ${registryEntries}
323
644
  await fs3.writeFile(registryEntryPath, registryWrapper, "utf8");
324
645
  clientEntryPoint = registryEntryPath;
325
646
  }
326
- await esbuild.build({
647
+ await esbuild2.build({
327
648
  entryPoints: [clientEntryPoint],
328
649
  bundle: true,
329
650
  format: "esm",
@@ -331,21 +652,35 @@ ${registryEntries}
331
652
  target: ["es2020", "chrome90", "firefox88", "safari14"],
332
653
  jsx: "automatic",
333
654
  external: CLIENT_EXTERNALS,
334
- plugins: [serverStubPlugin()],
655
+ plugins: [serverStubPlugin(), cssTextPlugin(scopeId)],
335
656
  outfile: clientJsPath,
336
657
  minify: true,
337
658
  sourcemap: false,
338
659
  define: { "process.env.NODE_ENV": '"production"' },
339
- banner: { js: 'import React from "react";' },
340
- loader: { ".css": "text" }
341
- // 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
342
677
  });
343
678
  const hasCss = input.files.some((f) => /\.css$/.test(f.path));
344
679
  const cssName = hasCss ? `${safeName}.css` : null;
345
680
  const cssPath = hasCss ? path3.join(input.outputDir, cssName) : null;
346
681
  if (hasCss && cssPath) {
347
682
  try {
348
- await esbuild.build({
683
+ await esbuild2.build({
349
684
  entryPoints: [entryPoint],
350
685
  bundle: true,
351
686
  format: "esm",
@@ -355,7 +690,8 @@ ${registryEntries}
355
690
  plugins: [serverStubPlugin()],
356
691
  outfile: cssPath.replace(/\.css$/, ".css.tmp.js"),
357
692
  // esbuild needs a JS outfile
358
- minify: true,
693
+ minify: false,
694
+ // we'll minify after scoping
359
695
  sourcemap: false,
360
696
  define: { "process.env.NODE_ENV": '"production"' },
361
697
  loader: { ".css": "css" }
@@ -366,6 +702,12 @@ ${registryEntries}
366
702
  } catch {
367
703
  }
368
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
+ }
369
711
  } catch {
370
712
  }
371
713
  }
@@ -373,14 +715,67 @@ ${registryEntries}
373
715
  return {
374
716
  serverJsPath,
375
717
  clientJsPath,
718
+ browserJsPath,
376
719
  cssPath,
377
720
  serverJsName,
378
721
  clientJsName,
722
+ browserJsName,
379
723
  cssName,
724
+ scopeId,
380
725
  compiledAt: /* @__PURE__ */ new Date()
381
726
  };
382
727
  }
383
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
+ }
384
773
  export {
385
- BrodoxCompiler
774
+ BrodoxCompiler,
775
+ PAGE_CSS_THRESHOLDS,
776
+ computeScopeId,
777
+ decidePageCssStrategy,
778
+ minifyScopedCss,
779
+ scopeCss,
780
+ splitCssIntoChunks
386
781
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@broxium/compiler",
3
- "version": "1.5.1",
3
+ "version": "1.5.3",
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",