@broxium/compiler 1.3.2 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -123,6 +123,60 @@ Source files are written to a temporary directory under `os.tmpdir()` before com
123
123
 
124
124
  ---
125
125
 
126
+ ## `config.json` format
127
+
128
+ Every component must include a `config.json` file alongside `App.tsx`. This file defines the props exposed in the website builder's property inspector panel.
129
+
130
+ **The format is a flat object** — prop name as key, field definition as value. Do not use a `name`/`slug`/`props` wrapper.
131
+
132
+ ```json
133
+ {
134
+ "title": {
135
+ "label": "Title",
136
+ "type": "text",
137
+ "default": "Hello World"
138
+ },
139
+ "count": {
140
+ "label": "Item Count",
141
+ "type": "number",
142
+ "default": 3
143
+ },
144
+ "theme": {
145
+ "label": "Theme",
146
+ "type": "select",
147
+ "options": ["light", "dark"],
148
+ "default": "light"
149
+ }
150
+ }
151
+ ```
152
+
153
+ ### Supported field types
154
+
155
+ | `type` | Builder input | Extra keys |
156
+ |---|---|---|
157
+ | `text` | Text input | — |
158
+ | `textarea` | Multiline textarea | — |
159
+ | `url` | URL input (validated) | — |
160
+ | `number` | Number input | — |
161
+ | `select` | Dropdown | `options: string[]` |
162
+ | `color` | Color picker + hex | — |
163
+ | `boolean` | Checkbox | — |
164
+ | `range` | Slider | `min`, `max` |
165
+ | `brodox-*` | Custom Brodox field | varies |
166
+
167
+ ### Optional field keys
168
+
169
+ | Key | Type | Description |
170
+ |---|---|---|
171
+ | `label` | `string` | Display name in the inspector panel |
172
+ | `type` | `string` | Input type (required) |
173
+ | `default` | `any` | Initial value when component is first dropped |
174
+ | `render` | `"client"` \| `"server"` | Badge shown in inspector (cosmetic only) |
175
+
176
+ > **Common crash:** If `config.json` is not a flat object (e.g. has a top-level `name` or `props` array key), the builder will crash with `Cannot read properties of undefined (reading 'startsWith')` when the component is dragged onto the canvas.
177
+
178
+ ---
179
+
126
180
  ## `'use client'` and `'use server'` handling
127
181
 
128
182
  The two esbuild builds use custom plugins to handle React-style directives:
@@ -153,11 +207,44 @@ Server-only code never runs in the browser.
153
207
 
154
208
  The directive on the **entry file** (`App.tsx`) determines the `renderMode` stored in the Page Manifest:
155
209
 
156
- | Entry file starts with | `renderMode` | Server renders | Client hydrates |
157
- |---|---|---|---|
158
- | `'use client'` | `client` | No | Yes |
159
- | `'use server'` | `server` | Yes | No |
160
- | _(nothing)_ | `both` | Yes | Yes |
210
+ | Entry file starts with | `renderMode` | Server renders | Client hydrates | Use when |
211
+ |---|---|---|---|---|
212
+ | `'use client'` | `client` | No | Yes (full) | `useState`, `useEffect`, event handlers, browser APIs |
213
+ | `'use server'` | `server` | Yes (full) | No | Display-only, no interactivity needed |
214
+ | _(nothing)_ | `both` | Yes | Yes (islands only) | Static shell with `<Client>` islands for interactive parts |
215
+
216
+ > **Note:** These directives are **not** the same as Next.js. `'use server'` here means "exclude from client bundle" — it does not create a server action.
217
+
218
+ ### Common mistake — missing `'use client'`
219
+
220
+ Using `useState`, `useEffect`, or any hook at the top level of the entry file **without** `'use client'` causes the web engine to crash during SSR:
221
+
222
+ ```
223
+ Cannot read properties of null (reading 'useState')
224
+ ```
225
+
226
+ Fix: add `'use client'` as the very first line of `App.tsx`.
227
+
228
+ ```jsx
229
+ 'use client';
230
+
231
+ import { useState } from 'react';
232
+
233
+ export default function App() {
234
+ const [count, setCount] = useState(0);
235
+ // ...
236
+ }
237
+ ```
238
+
239
+ ### Sub-file directives
240
+
241
+ You can split a multi-file component by marking individual files:
242
+
243
+ ```
244
+ App.tsx ← no directive (both: SSR shell + client hydration)
245
+ ├── Counter.tsx ← 'use client' (stubbed on server, runs in browser)
246
+ └── DataTable.tsx← 'use server' (stubbed in browser, runs on server)
247
+ ```
161
248
 
162
249
  ---
163
250
 
@@ -238,14 +325,46 @@ When `BundleService` catches a compilation error, it blocks the component from b
238
325
 
239
326
  ---
240
327
 
328
+ ## `runtimeServerStubPlugin` — @broxium/runtime server stubs
329
+
330
+ During server bundle compilation, all `@broxium/runtime` imports are replaced
331
+ by inline server-safe stubs so the server bundle has zero external dependencies.
332
+
333
+ Key stub behaviours:
334
+
335
+ | Export | Server stub renders |
336
+ |---|---|
337
+ | `BrodoxLink` | `<a data-brodox-link href={href}>` — the `data-brodox-link` attribute is required for the shell's click interceptor |
338
+ | `BrodoxImage` | `<img src="/api/image?...">` with srcset, or raw `src` if `direct={true}` |
339
+ | `Client` | Empty placeholder div + sibling `<script type="application/json">` with props |
340
+ | `Server` | Transparent passthrough (Fragment) |
341
+ | `useRouter`, `useParams` | Static no-ops (return empty objects) |
342
+ | `BrodoxHead`, `BrodoxFont` | null |
343
+
344
+ **Important:** If you compile a component and the rendered `<a>` tags do not
345
+ have `data-brodox-link`, the shell will not intercept clicks on those links and
346
+ the browser will do a full page reload. This was fixed in v1.3.2 — ensure all
347
+ projects use `@broxium/compiler@^1.3.2` or later. Existing pre-1.3.2 server
348
+ bundles must be patched manually or recompiled.
349
+
350
+ ---
351
+
241
352
  ## Package info
242
353
 
243
354
  | | |
244
355
  |---|---|
245
356
  | Package | `@broxium/compiler` |
246
- | Version | `1.0.0` |
357
+ | Version | `1.3.3` |
247
358
  | Formats | ESM (`dist/index.mjs`), CJS (`dist/index.js`) |
248
359
  | Types | `dist/index.d.ts` |
249
360
  | Runtime dependency | `esbuild ^0.25` |
250
361
  | Node.js requirement | 20+ |
251
362
  | Side effects | Writes files to `outputDir`, creates/removes temp directory |
363
+
364
+ ## Changelog
365
+
366
+ | Version | Change |
367
+ |---|---|
368
+ | 1.3.3 | `BrodoxImage` stub: added `direct` prop support |
369
+ | 1.3.2 | `BrodoxLink` stub: added `data-brodox-link` attribute |
370
+ | 1.3.1 | Initial public release |
package/dist/index.d.mts CHANGED
@@ -13,8 +13,12 @@ interface CompileInput {
13
13
  interface CompileOutput {
14
14
  serverJsPath: string;
15
15
  clientJsPath: string;
16
+ /** Compiled CSS bundle path, or null if no CSS files were imported. */
17
+ cssPath: string | null;
16
18
  serverJsName: string;
17
19
  clientJsName: string;
20
+ /** CSS bundle filename, or null if no CSS files were imported. */
21
+ cssName: string | null;
18
22
  compiledAt: Date;
19
23
  }
20
24
 
package/dist/index.d.ts CHANGED
@@ -13,8 +13,12 @@ interface CompileInput {
13
13
  interface CompileOutput {
14
14
  serverJsPath: string;
15
15
  clientJsPath: string;
16
+ /** Compiled CSS bundle path, or null if no CSS files were imported. */
17
+ cssPath: string | null;
16
18
  serverJsName: string;
17
19
  clientJsName: string;
20
+ /** CSS bundle filename, or null if no CSS files were imported. */
21
+ cssName: string | null;
18
22
  compiledAt: Date;
19
23
  }
20
24
 
package/dist/index.js CHANGED
@@ -117,7 +117,18 @@ import { createElement, Fragment, Children } from 'react';
117
117
  var __islandSeq = 0;
118
118
  function __nextIslandId() { return 'bi-' + (++__islandSeq) + '-' + Math.random().toString(36).slice(2,7); }
119
119
 
120
- export function BrodoxImage({ src, alt, width, height, fill, className, style, priority, quality = 75, sizes }) {
120
+ export function BrodoxImage({ src, alt, width, height, fill, className, style, priority, quality = 75, sizes, direct = false }) {
121
+ const imgStyle = fill
122
+ ? Object.assign({ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover' }, style || {})
123
+ : (style || {});
124
+ if (direct) {
125
+ return createElement('img', {
126
+ src, alt: alt || '',
127
+ width: fill ? undefined : width, height: fill ? undefined : height,
128
+ loading: priority ? 'eager' : 'lazy', decoding: 'async',
129
+ className, style: imgStyle,
130
+ });
131
+ }
121
132
  const maxW = width || 1920;
122
133
  const widths = [320, 640, 768, 1024, 1280, 1920].filter(w => w <= maxW);
123
134
  if (!widths.length) widths.push(maxW);
@@ -125,9 +136,6 @@ export function BrodoxImage({ src, alt, width, height, fill, className, style, p
125
136
  const enc = encodeURIComponent(src);
126
137
  const optimisedSrc = '/api/image?src=' + enc + '&w=' + maxW + '&q=' + q + '&fmt=webp';
127
138
  const srcSet = widths.map(w => '/api/image?src=' + enc + '&w=' + w + '&q=' + q + '&fmt=webp ' + w + 'w').join(', ');
128
- const imgStyle = fill
129
- ? Object.assign({ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover' }, style || {})
130
- : (style || {});
131
139
  return createElement('img', {
132
140
  src: optimisedSrc, srcSet, sizes, alt: alt || '',
133
141
  width: fill ? undefined : width, height: fill ? undefined : height,
@@ -146,9 +154,29 @@ export function useRouter() {
146
154
 
147
155
  export function useParams() { return {}; }
148
156
 
149
- export function BrodoxHead() { return null; }
157
+ export function BrodoxHead({ title, description }) {
158
+ if (title && typeof globalThis.__brodoxCollectHead === 'function')
159
+ globalThis.__brodoxCollectHead({ type: 'title', props: { content: title } });
160
+ if (description && typeof globalThis.__brodoxCollectHead === 'function')
161
+ globalThis.__brodoxCollectHead({ type: 'meta', props: { name: 'description', content: description } });
162
+ return null;
163
+ }
150
164
 
151
- export function BrodoxFont() { return null; }
165
+ export function BrodoxFont({ href, family, weights, display }) {
166
+ weights = weights || [400, 700];
167
+ display = display || 'swap';
168
+ var url = href;
169
+ if (!url && family) {
170
+ url = 'https://fonts.googleapis.com/css2?family='
171
+ + encodeURIComponent(family) + ':wght@' + weights.join(';') + '&display=' + display;
172
+ }
173
+ if (url && typeof globalThis.__brodoxCollectHead === 'function') {
174
+ globalThis.__brodoxCollectHead({ type: 'link', props: { rel: 'preconnect', href: 'https://fonts.googleapis.com' } });
175
+ globalThis.__brodoxCollectHead({ type: 'link', props: { rel: 'preconnect', href: 'https://fonts.gstatic.com', crossOrigin: 'anonymous' } });
176
+ globalThis.__brodoxCollectHead({ type: 'link', props: { rel: 'stylesheet', href: url } });
177
+ }
178
+ return null;
179
+ }
152
180
 
153
181
  export function BrodoxRouter({ children }) {
154
182
  return createElement(Fragment, null, children);
@@ -162,7 +190,7 @@ export function BrodoxRouter({ children }) {
162
190
  * The IslandHydrator walks the live DOM and mounts the component from the
163
191
  * parent bundle's __registry__ after page load.
164
192
  */
165
- export function Client({ children }) {
193
+ export function Client({ children, hydration = 'load' }) {
166
194
  var id = __nextIslandId();
167
195
  var child = Children.only(children);
168
196
  var compType = child.type;
@@ -177,13 +205,13 @@ export function Client({ children }) {
177
205
  return createElement(Fragment, null,
178
206
  createElement('div', {
179
207
  'data-brodox-island': id,
180
- 'data-hydration': 'load',
208
+ 'data-hydration': hydration,
181
209
  'data-client-js': '',
182
210
  'data-component-slug': '',
183
211
  'data-version': '',
184
212
  'data-component': name,
185
213
  }),
186
- createElement('script', {
214
+ hydration === 'none' ? null : createElement('script', {
187
215
  type: 'application/json',
188
216
  'data-brodox-props': id,
189
217
  dangerouslySetInnerHTML: { __html: safeProps },
@@ -295,7 +323,9 @@ var BrodoxCompiler = class {
295
323
  outfile: serverJsPath,
296
324
  minify: false,
297
325
  sourcemap: false,
298
- define: { "process.env.NODE_ENV": '"production"' }
326
+ define: { "process.env.NODE_ENV": '"production"' },
327
+ loader: { ".css": "text" }
328
+ // CSS imports return empty string in server bundle
299
329
  });
300
330
  const clientComponents = [];
301
331
  for (const file of input.files) {
@@ -333,14 +363,47 @@ ${registryEntries}
333
363
  minify: true,
334
364
  sourcemap: false,
335
365
  define: { "process.env.NODE_ENV": '"production"' },
336
- banner: { js: 'import React from "react";' }
366
+ banner: { js: 'import React from "react";' },
367
+ loader: { ".css": "text" }
368
+ // CSS imports return the CSS string (injected via BrodoxHead or style tag)
337
369
  });
370
+ const hasCss = input.files.some((f) => /\.css$/.test(f.path));
371
+ const cssName = hasCss ? `${safeName}.css` : null;
372
+ const cssPath = hasCss ? import_node_path3.default.join(input.outputDir, cssName) : null;
373
+ if (hasCss && cssPath) {
374
+ try {
375
+ await esbuild.build({
376
+ entryPoints: [entryPoint],
377
+ bundle: true,
378
+ format: "esm",
379
+ platform: "browser",
380
+ jsx: "automatic",
381
+ external: CLIENT_EXTERNALS,
382
+ plugins: [serverStubPlugin()],
383
+ outfile: cssPath.replace(/\.css$/, ".css.tmp.js"),
384
+ // esbuild needs a JS outfile
385
+ minify: true,
386
+ sourcemap: false,
387
+ define: { "process.env.NODE_ENV": '"production"' },
388
+ loader: { ".css": "css" }
389
+ });
390
+ const defaultCssOut = cssPath.replace(/\.css$/, ".css.tmp.css");
391
+ try {
392
+ await import_promises3.default.rename(defaultCssOut, cssPath);
393
+ } catch {
394
+ }
395
+ await import_promises3.default.rm(cssPath.replace(/\.css$/, ".css.tmp.js"), { force: true });
396
+ } catch {
397
+ }
398
+ }
338
399
  await import_promises3.default.rm(tmpDir, { recursive: true, force: true });
339
400
  return {
340
401
  serverJsPath,
341
402
  clientJsPath,
403
+ cssPath,
342
404
  serverJsName,
343
405
  clientJsName,
406
+ cssName,
344
407
  compiledAt: /* @__PURE__ */ new Date()
345
408
  };
346
409
  }
package/dist/index.mjs CHANGED
@@ -88,7 +88,18 @@ import { createElement, Fragment, Children } from 'react';
88
88
  var __islandSeq = 0;
89
89
  function __nextIslandId() { return 'bi-' + (++__islandSeq) + '-' + Math.random().toString(36).slice(2,7); }
90
90
 
91
- export function BrodoxImage({ src, alt, width, height, fill, className, style, priority, quality = 75, sizes }) {
91
+ export function BrodoxImage({ src, alt, width, height, fill, className, style, priority, quality = 75, sizes, direct = false }) {
92
+ const imgStyle = fill
93
+ ? Object.assign({ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover' }, style || {})
94
+ : (style || {});
95
+ if (direct) {
96
+ return createElement('img', {
97
+ src, alt: alt || '',
98
+ width: fill ? undefined : width, height: fill ? undefined : height,
99
+ loading: priority ? 'eager' : 'lazy', decoding: 'async',
100
+ className, style: imgStyle,
101
+ });
102
+ }
92
103
  const maxW = width || 1920;
93
104
  const widths = [320, 640, 768, 1024, 1280, 1920].filter(w => w <= maxW);
94
105
  if (!widths.length) widths.push(maxW);
@@ -96,9 +107,6 @@ export function BrodoxImage({ src, alt, width, height, fill, className, style, p
96
107
  const enc = encodeURIComponent(src);
97
108
  const optimisedSrc = '/api/image?src=' + enc + '&w=' + maxW + '&q=' + q + '&fmt=webp';
98
109
  const srcSet = widths.map(w => '/api/image?src=' + enc + '&w=' + w + '&q=' + q + '&fmt=webp ' + w + 'w').join(', ');
99
- const imgStyle = fill
100
- ? Object.assign({ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover' }, style || {})
101
- : (style || {});
102
110
  return createElement('img', {
103
111
  src: optimisedSrc, srcSet, sizes, alt: alt || '',
104
112
  width: fill ? undefined : width, height: fill ? undefined : height,
@@ -117,9 +125,29 @@ export function useRouter() {
117
125
 
118
126
  export function useParams() { return {}; }
119
127
 
120
- export function BrodoxHead() { return null; }
128
+ export function BrodoxHead({ title, description }) {
129
+ if (title && typeof globalThis.__brodoxCollectHead === 'function')
130
+ globalThis.__brodoxCollectHead({ type: 'title', props: { content: title } });
131
+ if (description && typeof globalThis.__brodoxCollectHead === 'function')
132
+ globalThis.__brodoxCollectHead({ type: 'meta', props: { name: 'description', content: description } });
133
+ return null;
134
+ }
121
135
 
122
- export function BrodoxFont() { return null; }
136
+ export function BrodoxFont({ href, family, weights, display }) {
137
+ weights = weights || [400, 700];
138
+ display = display || 'swap';
139
+ var url = href;
140
+ if (!url && family) {
141
+ url = 'https://fonts.googleapis.com/css2?family='
142
+ + encodeURIComponent(family) + ':wght@' + weights.join(';') + '&display=' + display;
143
+ }
144
+ if (url && typeof globalThis.__brodoxCollectHead === 'function') {
145
+ globalThis.__brodoxCollectHead({ type: 'link', props: { rel: 'preconnect', href: 'https://fonts.googleapis.com' } });
146
+ globalThis.__brodoxCollectHead({ type: 'link', props: { rel: 'preconnect', href: 'https://fonts.gstatic.com', crossOrigin: 'anonymous' } });
147
+ globalThis.__brodoxCollectHead({ type: 'link', props: { rel: 'stylesheet', href: url } });
148
+ }
149
+ return null;
150
+ }
123
151
 
124
152
  export function BrodoxRouter({ children }) {
125
153
  return createElement(Fragment, null, children);
@@ -133,7 +161,7 @@ export function BrodoxRouter({ children }) {
133
161
  * The IslandHydrator walks the live DOM and mounts the component from the
134
162
  * parent bundle's __registry__ after page load.
135
163
  */
136
- export function Client({ children }) {
164
+ export function Client({ children, hydration = 'load' }) {
137
165
  var id = __nextIslandId();
138
166
  var child = Children.only(children);
139
167
  var compType = child.type;
@@ -148,13 +176,13 @@ export function Client({ children }) {
148
176
  return createElement(Fragment, null,
149
177
  createElement('div', {
150
178
  'data-brodox-island': id,
151
- 'data-hydration': 'load',
179
+ 'data-hydration': hydration,
152
180
  'data-client-js': '',
153
181
  'data-component-slug': '',
154
182
  'data-version': '',
155
183
  'data-component': name,
156
184
  }),
157
- createElement('script', {
185
+ hydration === 'none' ? null : createElement('script', {
158
186
  type: 'application/json',
159
187
  'data-brodox-props': id,
160
188
  dangerouslySetInnerHTML: { __html: safeProps },
@@ -266,7 +294,9 @@ var BrodoxCompiler = class {
266
294
  outfile: serverJsPath,
267
295
  minify: false,
268
296
  sourcemap: false,
269
- define: { "process.env.NODE_ENV": '"production"' }
297
+ define: { "process.env.NODE_ENV": '"production"' },
298
+ loader: { ".css": "text" }
299
+ // CSS imports return empty string in server bundle
270
300
  });
271
301
  const clientComponents = [];
272
302
  for (const file of input.files) {
@@ -304,14 +334,47 @@ ${registryEntries}
304
334
  minify: true,
305
335
  sourcemap: false,
306
336
  define: { "process.env.NODE_ENV": '"production"' },
307
- banner: { js: 'import React from "react";' }
337
+ banner: { js: 'import React from "react";' },
338
+ loader: { ".css": "text" }
339
+ // CSS imports return the CSS string (injected via BrodoxHead or style tag)
308
340
  });
341
+ const hasCss = input.files.some((f) => /\.css$/.test(f.path));
342
+ const cssName = hasCss ? `${safeName}.css` : null;
343
+ const cssPath = hasCss ? path3.join(input.outputDir, cssName) : null;
344
+ if (hasCss && cssPath) {
345
+ try {
346
+ await esbuild.build({
347
+ entryPoints: [entryPoint],
348
+ bundle: true,
349
+ format: "esm",
350
+ platform: "browser",
351
+ jsx: "automatic",
352
+ external: CLIENT_EXTERNALS,
353
+ plugins: [serverStubPlugin()],
354
+ outfile: cssPath.replace(/\.css$/, ".css.tmp.js"),
355
+ // esbuild needs a JS outfile
356
+ minify: true,
357
+ sourcemap: false,
358
+ define: { "process.env.NODE_ENV": '"production"' },
359
+ loader: { ".css": "css" }
360
+ });
361
+ const defaultCssOut = cssPath.replace(/\.css$/, ".css.tmp.css");
362
+ try {
363
+ await fs3.rename(defaultCssOut, cssPath);
364
+ } catch {
365
+ }
366
+ await fs3.rm(cssPath.replace(/\.css$/, ".css.tmp.js"), { force: true });
367
+ } catch {
368
+ }
369
+ }
309
370
  await fs3.rm(tmpDir, { recursive: true, force: true });
310
371
  return {
311
372
  serverJsPath,
312
373
  clientJsPath,
374
+ cssPath,
313
375
  serverJsName,
314
376
  clientJsName,
377
+ cssName,
315
378
  compiledAt: /* @__PURE__ */ new Date()
316
379
  };
317
380
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@broxium/compiler",
3
- "version": "1.3.2",
3
+ "version": "1.5.0",
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",