@broxium/compiler 1.3.3 → 1.5.1

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
@@ -154,9 +154,31 @@ export function useRouter() {
154
154
 
155
155
  export function useParams() { return {}; }
156
156
 
157
- export function BrodoxHead() { return null; }
157
+ export function BrodoxHead({ title, description, cssContent }) {
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
+ if (cssContent && typeof globalThis.__brodoxCollectHead === 'function')
163
+ globalThis.__brodoxCollectHead({ type: 'style', props: { content: cssContent } });
164
+ return null;
165
+ }
158
166
 
159
- export function BrodoxFont() { return null; }
167
+ export function BrodoxFont({ href, family, weights, display }) {
168
+ weights = weights || [400, 700];
169
+ display = display || 'swap';
170
+ var url = href;
171
+ if (!url && family) {
172
+ url = 'https://fonts.googleapis.com/css2?family='
173
+ + encodeURIComponent(family) + ':wght@' + weights.join(';') + '&display=' + display;
174
+ }
175
+ if (url && typeof globalThis.__brodoxCollectHead === 'function') {
176
+ globalThis.__brodoxCollectHead({ type: 'link', props: { rel: 'preconnect', href: 'https://fonts.googleapis.com' } });
177
+ globalThis.__brodoxCollectHead({ type: 'link', props: { rel: 'preconnect', href: 'https://fonts.gstatic.com', crossOrigin: 'anonymous' } });
178
+ globalThis.__brodoxCollectHead({ type: 'link', props: { rel: 'stylesheet', href: url } });
179
+ }
180
+ return null;
181
+ }
160
182
 
161
183
  export function BrodoxRouter({ children }) {
162
184
  return createElement(Fragment, null, children);
@@ -170,7 +192,7 @@ export function BrodoxRouter({ children }) {
170
192
  * The IslandHydrator walks the live DOM and mounts the component from the
171
193
  * parent bundle's __registry__ after page load.
172
194
  */
173
- export function Client({ children }) {
195
+ export function Client({ children, hydration = 'load' }) {
174
196
  var id = __nextIslandId();
175
197
  var child = Children.only(children);
176
198
  var compType = child.type;
@@ -185,13 +207,13 @@ export function Client({ children }) {
185
207
  return createElement(Fragment, null,
186
208
  createElement('div', {
187
209
  'data-brodox-island': id,
188
- 'data-hydration': 'load',
210
+ 'data-hydration': hydration,
189
211
  'data-client-js': '',
190
212
  'data-component-slug': '',
191
213
  'data-version': '',
192
214
  'data-component': name,
193
215
  }),
194
- createElement('script', {
216
+ hydration === 'none' ? null : createElement('script', {
195
217
  type: 'application/json',
196
218
  'data-brodox-props': id,
197
219
  dangerouslySetInnerHTML: { __html: safeProps },
@@ -303,7 +325,9 @@ var BrodoxCompiler = class {
303
325
  outfile: serverJsPath,
304
326
  minify: false,
305
327
  sourcemap: false,
306
- define: { "process.env.NODE_ENV": '"production"' }
328
+ define: { "process.env.NODE_ENV": '"production"' },
329
+ loader: { ".css": "text" }
330
+ // CSS imports return empty string in server bundle
307
331
  });
308
332
  const clientComponents = [];
309
333
  for (const file of input.files) {
@@ -341,14 +365,47 @@ ${registryEntries}
341
365
  minify: true,
342
366
  sourcemap: false,
343
367
  define: { "process.env.NODE_ENV": '"production"' },
344
- banner: { js: 'import React from "react";' }
368
+ banner: { js: 'import React from "react";' },
369
+ loader: { ".css": "text" }
370
+ // CSS imports return the CSS string (injected via BrodoxHead or style tag)
345
371
  });
372
+ const hasCss = input.files.some((f) => /\.css$/.test(f.path));
373
+ const cssName = hasCss ? `${safeName}.css` : null;
374
+ const cssPath = hasCss ? import_node_path3.default.join(input.outputDir, cssName) : null;
375
+ if (hasCss && cssPath) {
376
+ try {
377
+ await esbuild.build({
378
+ entryPoints: [entryPoint],
379
+ bundle: true,
380
+ format: "esm",
381
+ platform: "browser",
382
+ jsx: "automatic",
383
+ external: CLIENT_EXTERNALS,
384
+ plugins: [serverStubPlugin()],
385
+ outfile: cssPath.replace(/\.css$/, ".css.tmp.js"),
386
+ // esbuild needs a JS outfile
387
+ minify: true,
388
+ sourcemap: false,
389
+ define: { "process.env.NODE_ENV": '"production"' },
390
+ loader: { ".css": "css" }
391
+ });
392
+ const defaultCssOut = cssPath.replace(/\.css$/, ".css.tmp.css");
393
+ try {
394
+ await import_promises3.default.rename(defaultCssOut, cssPath);
395
+ } catch {
396
+ }
397
+ await import_promises3.default.rm(cssPath.replace(/\.css$/, ".css.tmp.js"), { force: true });
398
+ } catch {
399
+ }
400
+ }
346
401
  await import_promises3.default.rm(tmpDir, { recursive: true, force: true });
347
402
  return {
348
403
  serverJsPath,
349
404
  clientJsPath,
405
+ cssPath,
350
406
  serverJsName,
351
407
  clientJsName,
408
+ cssName,
352
409
  compiledAt: /* @__PURE__ */ new Date()
353
410
  };
354
411
  }
package/dist/index.mjs CHANGED
@@ -125,9 +125,31 @@ export function useRouter() {
125
125
 
126
126
  export function useParams() { return {}; }
127
127
 
128
- export function BrodoxHead() { return null; }
128
+ export function BrodoxHead({ title, description, cssContent }) {
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
+ if (cssContent && typeof globalThis.__brodoxCollectHead === 'function')
134
+ globalThis.__brodoxCollectHead({ type: 'style', props: { content: cssContent } });
135
+ return null;
136
+ }
129
137
 
130
- export function BrodoxFont() { return null; }
138
+ export function BrodoxFont({ href, family, weights, display }) {
139
+ weights = weights || [400, 700];
140
+ display = display || 'swap';
141
+ var url = href;
142
+ if (!url && family) {
143
+ url = 'https://fonts.googleapis.com/css2?family='
144
+ + encodeURIComponent(family) + ':wght@' + weights.join(';') + '&display=' + display;
145
+ }
146
+ if (url && typeof globalThis.__brodoxCollectHead === 'function') {
147
+ globalThis.__brodoxCollectHead({ type: 'link', props: { rel: 'preconnect', href: 'https://fonts.googleapis.com' } });
148
+ globalThis.__brodoxCollectHead({ type: 'link', props: { rel: 'preconnect', href: 'https://fonts.gstatic.com', crossOrigin: 'anonymous' } });
149
+ globalThis.__brodoxCollectHead({ type: 'link', props: { rel: 'stylesheet', href: url } });
150
+ }
151
+ return null;
152
+ }
131
153
 
132
154
  export function BrodoxRouter({ children }) {
133
155
  return createElement(Fragment, null, children);
@@ -141,7 +163,7 @@ export function BrodoxRouter({ children }) {
141
163
  * The IslandHydrator walks the live DOM and mounts the component from the
142
164
  * parent bundle's __registry__ after page load.
143
165
  */
144
- export function Client({ children }) {
166
+ export function Client({ children, hydration = 'load' }) {
145
167
  var id = __nextIslandId();
146
168
  var child = Children.only(children);
147
169
  var compType = child.type;
@@ -156,13 +178,13 @@ export function Client({ children }) {
156
178
  return createElement(Fragment, null,
157
179
  createElement('div', {
158
180
  'data-brodox-island': id,
159
- 'data-hydration': 'load',
181
+ 'data-hydration': hydration,
160
182
  'data-client-js': '',
161
183
  'data-component-slug': '',
162
184
  'data-version': '',
163
185
  'data-component': name,
164
186
  }),
165
- createElement('script', {
187
+ hydration === 'none' ? null : createElement('script', {
166
188
  type: 'application/json',
167
189
  'data-brodox-props': id,
168
190
  dangerouslySetInnerHTML: { __html: safeProps },
@@ -274,7 +296,9 @@ var BrodoxCompiler = class {
274
296
  outfile: serverJsPath,
275
297
  minify: false,
276
298
  sourcemap: false,
277
- define: { "process.env.NODE_ENV": '"production"' }
299
+ define: { "process.env.NODE_ENV": '"production"' },
300
+ loader: { ".css": "text" }
301
+ // CSS imports return empty string in server bundle
278
302
  });
279
303
  const clientComponents = [];
280
304
  for (const file of input.files) {
@@ -312,14 +336,47 @@ ${registryEntries}
312
336
  minify: true,
313
337
  sourcemap: false,
314
338
  define: { "process.env.NODE_ENV": '"production"' },
315
- banner: { js: 'import React from "react";' }
339
+ banner: { js: 'import React from "react";' },
340
+ loader: { ".css": "text" }
341
+ // CSS imports return the CSS string (injected via BrodoxHead or style tag)
316
342
  });
343
+ const hasCss = input.files.some((f) => /\.css$/.test(f.path));
344
+ const cssName = hasCss ? `${safeName}.css` : null;
345
+ const cssPath = hasCss ? path3.join(input.outputDir, cssName) : null;
346
+ if (hasCss && cssPath) {
347
+ try {
348
+ await esbuild.build({
349
+ entryPoints: [entryPoint],
350
+ bundle: true,
351
+ format: "esm",
352
+ platform: "browser",
353
+ jsx: "automatic",
354
+ external: CLIENT_EXTERNALS,
355
+ plugins: [serverStubPlugin()],
356
+ outfile: cssPath.replace(/\.css$/, ".css.tmp.js"),
357
+ // esbuild needs a JS outfile
358
+ minify: true,
359
+ sourcemap: false,
360
+ define: { "process.env.NODE_ENV": '"production"' },
361
+ loader: { ".css": "css" }
362
+ });
363
+ const defaultCssOut = cssPath.replace(/\.css$/, ".css.tmp.css");
364
+ try {
365
+ await fs3.rename(defaultCssOut, cssPath);
366
+ } catch {
367
+ }
368
+ await fs3.rm(cssPath.replace(/\.css$/, ".css.tmp.js"), { force: true });
369
+ } catch {
370
+ }
371
+ }
317
372
  await fs3.rm(tmpDir, { recursive: true, force: true });
318
373
  return {
319
374
  serverJsPath,
320
375
  clientJsPath,
376
+ cssPath,
321
377
  serverJsName,
322
378
  clientJsName,
379
+ cssName,
323
380
  compiledAt: /* @__PURE__ */ new Date()
324
381
  };
325
382
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@broxium/compiler",
3
- "version": "1.3.3",
3
+ "version": "1.5.1",
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",