brustjs 0.1.21-alpha → 0.1.22-alpha

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
@@ -87,6 +87,17 @@ brustjs new <name> # scaffold a project (partial — see Status)
87
87
  `renderToString` runs once per key, then serves a frozen pair from Rust.
88
88
  - **`native: true` routes** — JSX compiled to a jinja template at build time and
89
89
  rendered Rust-side (`minijinja`), skipping React on the server entirely.
90
+ - **Native interactivity without islands** — Alpine.js-style `x-*` DOM directives
91
+ (`x-data`/`x-text`/`x-show`/`x-bind-*`/`x-on-*`/`x-for`) on a `native` page,
92
+ bound to the store by a small react-free runtime. Logic lives in a co-located
93
+ `export const behavior` (single-file component); each component's JS is a
94
+ separate chunk loaded **on demand** — a page never downloads a component it
95
+ doesn't render.
96
+ - **Isomorphic store** — `brustjs/store`: `signal`/`computed`/`effect` +
97
+ `defineStore(name, factory)`. One `window` singleton per name on the client (so
98
+ separate island/directive chunks share state), a per-request `AsyncLocalStorage`
99
+ instance on the server. `useStore` adapter for React islands; a native directive
100
+ button and a React island reactively share the same store.
90
101
  - **Typed actions** — `defineActions().get/post/put/patch/delete/head(path, ctx => R, { body, query })`
91
102
  on the server; `client<typeof actions>()` is an Eden-Treaty-style proxy that
92
103
  infers the whole API from the server types (no codegen) and returns
@@ -119,7 +130,7 @@ bun test tests/integration.test.ts # integration (real server)
119
130
  ```
120
131
  crates/brust/ Rust: accept loop, worker pool, napi exports, SAB
121
132
  crates/jsx-rust-compiler/ JSX → jinja compiler for native: true routes
122
- runtime/ Bun-side: routing, render, actions, CLI
133
+ runtime/ Bun-side: routing, render, actions, store, native directives, CLI
123
134
  example/ pokedex native-first demo
124
135
  bench/ · docs/ · architecture.md
125
136
  ```
@@ -1,96 +1,87 @@
1
- // ISLAND — the "Add to team / In your team" toggle on the detail page.
1
+ // NATIVE INTERACTIVE COMPONENT (Spec B dogfood) — the "Add to team / In your
2
+ // team" toggle on the detail page. Formerly a React island; now a single-file
3
+ // native directive component: a co-located `export const behavior` (client
4
+ // logic, react-free) + a JSX `default` export (the native template the compiler
5
+ // lowers to minijinja). The build bundles ONLY `behavior` into _directives.js;
6
+ // the JSX default is tree-shaken out so react never leaks into the client bundle.
2
7
  //
3
- // Islands run REAL React in the browser (and, when `ssr`, in a Bun worker too),
4
- // so unlike the native page shells they have NO jinja constraints: hooks, inline
5
- // `style={{…}}`, event handlers all work normally.
6
- import { useEffect, useState } from 'react'
7
- import { client, useStore } from 'brustjs/client'
8
+ // The behavior is react-free: `signal`/`computed` from brustjs/store (the window
9
+ // singleton on the client), `client` from brustjs/client (the treaty action
10
+ // client also react-free), and the shared teamStore. NO react imports.
11
+ import { client } from 'brustjs/client'
12
+ import { computed, signal } from 'brustjs/store'
8
13
  import type { Actions } from '../actions'
9
14
  import type { AddToTeamProps } from '../lib/types'
10
15
  import { teamStore } from '../stores/team'
11
16
 
12
17
  const api = client<Actions>()
13
18
 
14
- export default function AddToTeamButton(p: AddToTeamProps) {
19
+ // behavior client bundle, registered as "addToTeamButton" (camelCase filename).
20
+ // `props` is the JSON parsed out of the element's x-props attribute (precomputed
21
+ // by the loader as a JSON string — native templates can't call JSON.stringify).
22
+ export const behavior = ({ props }: { props: AddToTeamProps }) => {
15
23
  // Shared store (GAP S4): writing teamStore.members here is observed by the
16
- // TeamBuilder island — they resolve the same window singleton. This is a
17
- // client-only island (no `ssr`), so the store read always hits the client branch.
18
- const { members } = useStore(teamStore)
19
- const team = members ?? []
20
- const [busy, setBusy] = useState(false)
21
- const [toast, setToast] = useState<string | null>(null)
22
-
23
- useEffect(() => {
24
- api.team.get().then((r) => {
25
- if (r.data) teamStore.members.set(r.data.team)
26
- })
27
- }, [])
24
+ // TeamBuilder island — they resolve the same window singleton. A native
25
+ // x-on-click mutation is therefore seen reactively by a React island.
26
+ const busy = signal(false)
27
+ const inTeam = computed(() => (teamStore.members() ?? []).some((m) => m.id === props.id))
28
+ const label = computed(() =>
29
+ inTeam() ? '✓ In your team' : disabled() ? 'Team Full' : '+ Add to team',
30
+ )
31
+ const btnClass = computed(() => `aa-btn aa-btn--full${inTeam() ? ' aa-btn--secondary' : ''}`)
32
+ const disabled = computed(() => busy() || ((teamStore.members()?.length ?? 0) >= 6 && !inTeam()))
28
33
 
29
- const inTeam = team.some((m) => m.id === p.id)
34
+ async function init() {
35
+ const r = await api.team.get()
36
+ if (r.data) teamStore.members.set(r.data.team)
37
+ }
30
38
 
31
39
  async function toggle() {
32
- setBusy(true)
40
+ busy.set(true)
33
41
  try {
34
- if (inTeam) {
35
- // NOTE: `.delete({})` passing an empty body is REQUIRED. A bodyless
36
- // DELETE from the browser sends no Content-Length, and brust's action
37
- // dispatch returns 411 on non-GET/HEAD without one. See GAPS S12.
38
- const { data } = await api.team({ id: p.id }).delete({})
42
+ if (inTeam()) {
43
+ // Bodyless DELETE is OK now (GAPS S12 fixed) no more `.delete({})`.
44
+ const { data } = await api.team({ id: props.id }).delete()
39
45
  if (data) teamStore.members.set(data.team)
40
46
  } else {
41
47
  const { data } = await api.team.post({
42
- id: p.id,
43
- name: p.name,
44
- displayName: p.displayName,
45
- num: p.num,
46
- types: p.types,
47
- artwork: p.artwork,
48
+ id: props.id,
49
+ name: props.name,
50
+ displayName: props.displayName,
51
+ num: props.num,
52
+ types: props.types,
53
+ artwork: props.artwork,
48
54
  })
49
- if (data?.full) {
50
- setToast('ทีมเต็มแล้ว · สูงสุด 6 ตัว')
51
- setTimeout(() => setToast(null), 2200)
52
- } else if (data) {
55
+ if (data && !data?.full) {
53
56
  teamStore.members.set(data.team)
54
57
  }
55
58
  }
56
59
  } finally {
57
- setBusy(false)
60
+ busy.set(false)
58
61
  }
59
62
  }
60
63
 
64
+ return { init, label, btnClass, toggle, disabled }
65
+ }
66
+
67
+ // default → jinja (server). The x-* directives are static string attributes the
68
+ // native compiler passes straight through; the directive runtime binds them to
69
+ // the behavior instance on the client. `data` is the loader-precomputed JSON
70
+ // string, emitted by the compiler as x-props="{{ (data) | e }}" (XSS-safe).
71
+ export default function AddToTeamButton({ data }: { data: string }) {
61
72
  return (
62
- <div style={{ position: 'relative' }}>
73
+ <div x-data="addToTeamButton" x-props={data} style={{ position: 'relative' }}>
63
74
  <button
64
75
  type="button"
65
- className={`aa-btn aa-btn--full${inTeam ? ' aa-btn--secondary' : ''}`}
76
+ x-text="label"
77
+ x-bind-class="btnClass"
78
+ x-bind-disabled="disabled"
79
+ x-on-click="toggle"
80
+ className="aa-btn aa-btn--full"
66
81
  style={{ width: '100%' }}
67
- onClick={toggle}
68
- disabled={busy}
69
82
  >
70
- {inTeam ? '✓ In your team' : '+ Add to team'}
83
+ + Add to team
71
84
  </button>
72
- {toast && (
73
- <div
74
- style={{
75
- position: 'absolute',
76
- top: 'calc(100% + 8px)',
77
- left: 0,
78
- right: 0,
79
- zIndex: 50,
80
- padding: '8px 12px',
81
- borderRadius: 'var(--radius-md)',
82
- background: 'var(--danger-50)',
83
- color: 'var(--danger-700)',
84
- border: '1px solid rgba(212,28,89,0.25)',
85
- fontSize: 'var(--text-xs)',
86
- fontWeight: 600,
87
- textAlign: 'center',
88
- boxShadow: 'var(--shadow-md)',
89
- }}
90
- >
91
- {toast}
92
- </div>
93
- )}
94
85
  </div>
95
86
  )
96
87
  }
@@ -163,14 +163,17 @@ export async function detailLoader({ params }: LoaderCtx): Promise<DetailData |
163
163
  hasAbilities: abilities.length > 0,
164
164
  evolution,
165
165
  hasEvolution,
166
- addProps: {
166
+ // Native templates can't call JSON.stringify, so precompute the x-props JSON
167
+ // here. The compiler emits it as x-props="{{ (addProps) | e }}" (XSS-safe);
168
+ // the directive runtime JSON.parses it back into the behavior's `props`.
169
+ addProps: JSON.stringify({
167
170
  id: p.id,
168
171
  name: p.name,
169
172
  displayName: cap(p.name),
170
173
  num: pad(p.id),
171
174
  types: p.types,
172
175
  artwork: p.artwork,
173
- },
176
+ }),
174
177
  teamProps: { teamInitial: teamStore.list() },
175
178
  }
176
179
  }
@@ -197,7 +200,14 @@ function emptyDetail(name: string): DetailData {
197
200
  hasAbilities: false,
198
201
  evolution: [],
199
202
  hasEvolution: false,
200
- addProps: { id: 0, name, displayName: cap(name), num: '', types: [], artwork: '' },
203
+ addProps: JSON.stringify({
204
+ id: 0,
205
+ name,
206
+ displayName: cap(name),
207
+ num: '',
208
+ types: [],
209
+ artwork: '',
210
+ }),
201
211
  teamProps: { teamInitial: teamStore.list() },
202
212
  }
203
213
  }
@@ -95,12 +95,15 @@ export interface DetailData {
95
95
  hasAbilities: boolean
96
96
  evolution: EvolutionStageVM[]
97
97
  hasEvolution: boolean
98
- // island props (a single path each native island props can't be object literals):
99
- addProps: AddToTeamProps
98
+ // native interactive props: a single string path each (native props can't be
99
+ // object literals). addProps is the loader-precomputed JSON string handed to
100
+ // <AddToTeamButton data={addProps} /> → x-props (Spec B native directives).
101
+ addProps: string
100
102
  teamProps: { teamInitial: TeamMember[] }
101
103
  }
102
104
 
103
- /** Props for the AddToTeamButton island (raw types kept for the action body). */
105
+ /** Shape of the AddToTeamButton native behavior's `props` (JSON-parsed from
106
+ * x-props). Matches the action body fields so toggle() can post it directly. */
104
107
  export interface AddToTeamProps {
105
108
  id: number
106
109
  name: string
@@ -9,7 +9,6 @@
9
9
  // `{s.showLevel && <Level/>}` separators — no more loader-computed hide-classes.
10
10
  // The <title> is dynamic via `<BrustPage title={pageTitle}>` (S8) and inline
11
11
  // styles use `style={{…}}` objects (S1).
12
- import { Island } from 'brustjs'
13
12
  import AddToTeamButton from '../components/AddToTeamButton'
14
13
  import PageLayout from '../components/PageLayout'
15
14
  import type { DetailData } from '../lib/types'
@@ -71,7 +70,7 @@ export default function DetailPage({
71
70
  </span>
72
71
  ))}
73
72
  </div>
74
- <Island component={AddToTeamButton} props={addProps} hydrate="load" />
73
+ <AddToTeamButton native data={addProps} />
75
74
  </div>
76
75
 
77
76
  <div className="dex-detail-right">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brustjs",
3
- "version": "0.1.21-alpha",
3
+ "version": "0.1.22-alpha",
4
4
  "description": "Bun + Rust SSR framework — React on the server, Rust everywhere else (napi cdylib + per-worker SharedArrayBuffer).",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -40,12 +40,12 @@
40
40
  "typescript": "^6.0.3"
41
41
  },
42
42
  "optionalDependencies": {
43
- "brustjs-darwin-x64": "0.1.21-alpha",
44
- "brustjs-darwin-arm64": "0.1.21-alpha",
45
- "brustjs-linux-x64-gnu": "0.1.21-alpha",
46
- "brustjs-linux-arm64-gnu": "0.1.21-alpha",
47
- "brustjs-linux-x64-musl": "0.1.21-alpha",
48
- "brustjs-linux-arm64-musl": "0.1.21-alpha"
43
+ "brustjs-darwin-x64": "0.1.22-alpha",
44
+ "brustjs-darwin-arm64": "0.1.22-alpha",
45
+ "brustjs-linux-x64-gnu": "0.1.22-alpha",
46
+ "brustjs-linux-arm64-gnu": "0.1.22-alpha",
47
+ "brustjs-linux-x64-musl": "0.1.22-alpha",
48
+ "brustjs-linux-arm64-musl": "0.1.22-alpha"
49
49
  },
50
50
  "peerDependencies": {
51
51
  "react": "^19.2.6",
@@ -68,7 +68,8 @@
68
68
  "./routes": "./runtime/routes.ts",
69
69
  "./client": "./runtime/client/index.ts",
70
70
  "./create": "./runtime/create.ts",
71
- "./store": "./runtime/store/index.ts"
71
+ "./store": "./runtime/store/index.ts",
72
+ "./native": "./runtime/native/index.ts"
72
73
  },
73
74
  "files": [
74
75
  "runtime",
@@ -248,6 +248,47 @@ export async function runBuild(args: string[]): Promise<void> {
248
248
  console.log('[brust build] islands: skipped (no <Island> usage)')
249
249
  }
250
250
 
251
+ // 3.5. Build the directive runtime bundle (if any native interactive component —
252
+ // a file with `export const behavior` — is reachable from the routes graph).
253
+ // MUST run AFTER buildIslands: buildIslands does `rm -rf outDir/islands`, so
254
+ // running this first would wipe _directives.js. This block creates the islands
255
+ // dir itself (the islands block is skipped when there are no <Island> usages).
256
+ {
257
+ const { scanDirectiveComponents, buildDirectives } = await import('../native/build.ts')
258
+ let directiveComponents = new Map<string, string>()
259
+ if (existsSync(routesFile)) {
260
+ try {
261
+ directiveComponents = scanDirectiveComponents(routesFile)
262
+ } catch (err) {
263
+ // e.g. two files derive the same directive register name — surface a clean
264
+ // message instead of an unformatted stack out of `brust build`.
265
+ console.error(`[brust build] directives: ${(err as Error).message}`)
266
+ process.exit(1)
267
+ }
268
+ }
269
+ if (directiveComponents.size > 0) {
270
+ const islandsOutDir = path.join(outDir, 'islands')
271
+ const result = await buildDirectives(directiveComponents, { outDir: islandsOutDir })
272
+ console.log(
273
+ `[brust build] directives: runtime + ${result.count} component chunk(s) → ${islandsOutDir}`,
274
+ )
275
+
276
+ // Mirror every directive file (_directives.js + each <name>.directive.js) into
277
+ // cwd/.brust/islands for the source runtime (the islands block's whole-dir mirror
278
+ // ran before these existed, so copy them explicitly). Create the dir in case the
279
+ // islands block was skipped.
280
+ const localIslandsDir = path.join(process.cwd(), '.brust', 'islands')
281
+ if (path.resolve(localIslandsDir) !== path.resolve(islandsOutDir)) {
282
+ await mkdir(localIslandsDir, { recursive: true })
283
+ for (const f of result.files) {
284
+ await cp(path.join(islandsOutDir, f), path.join(localIslandsDir, f))
285
+ }
286
+ }
287
+ } else {
288
+ console.log('[brust build] directives: skipped (no export-const-behavior components)')
289
+ }
290
+ }
291
+
251
292
  // 4. MCP manifest (if routes.tsx exists).
252
293
  let loadedRoutes: any[] | undefined
253
294
  if (existsSync(routesFile)) {
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
2
  import { dirname, relative, resolve } from 'node:path'
3
3
  import { buildDevClientTag } from '../dev/client.ts'
4
- import { ISLANDS_IMPORTMAP_AND_BOOTSTRAP } from '../islands/importmap.ts'
4
+ import { DIRECTIVES_BOOTSTRAP, ISLANDS_IMPORTMAP_AND_BOOTSTRAP } from '../islands/importmap.ts'
5
5
 
6
6
  /** Gather transitive component sources starting from a page source file.
7
7
  *
@@ -104,6 +104,20 @@ function injectDevClientIntoTemplate(template: string): string {
104
104
  return template + tag
105
105
  }
106
106
 
107
+ /** Bake the directive runtime loader into a native template iff it uses any
108
+ * x-data directive. Idempotent. Wrapped in {% raw %} for symmetry with the islands
109
+ * bootstrap bake (the tag has no {{ }} but the wrap is harmless + consistent). */
110
+ export function bakeDirectivesIfUsed(template: string, force = false): string {
111
+ // `force` (app has ≥1 directive component) bakes on EVERY native page so the
112
+ // runtime is live to catch SPA-nav swaps into a directive page. Otherwise
113
+ // attribute-anchored (`x-data=`) so a literal "x-data" in text/content can't
114
+ // trigger a stray <script> that would 404 (no bundle built for that route).
115
+ if (!force && !/x-data=/.test(template)) return template
116
+ const baked = `{% raw %}${DIRECTIVES_BOOTSTRAP}{% endraw %}`
117
+ if (template.includes(baked)) return template
118
+ return template + baked
119
+ }
120
+
107
121
  /** Sub-project J — build pass that turns user's `pages/<Name>.tsx` files into
108
122
  * `.brust/jinja/<Name>.jinja` templates. Invoked from `brust build` and
109
123
  * `brust dev` after the user's routes are flattened.
@@ -327,6 +341,17 @@ export async function emitNativeTemplates(opts: NativeRouteEmitOpts): Promise<vo
327
341
  const importMap =
328
342
  nativeRoutes.length > 0 ? scanImports(opts.entryFile) : new Map<string, string>()
329
343
 
344
+ // App-wide directive presence: if ANY native interactive component exists, the
345
+ // directive runtime (`_directives.js`) must load on EVERY native page — not just
346
+ // pages whose own template uses x-data. SPA nav (owned by the islands bootstrap)
347
+ // swaps <main> but does NOT execute <script> tags in the swapped HTML, so the
348
+ // runtime must already be live on the page you navigate FROM for its
349
+ // MutationObserver to mount the incoming x-data. Dynamic import = call-time
350
+ // (avoids a module-eval cycle with native/build.ts → scanImports here).
351
+ const hasDirectives =
352
+ nativeRoutes.length > 0 &&
353
+ (await import('../native/build.ts')).scanDirectiveComponents(opts.entryFile).size > 0
354
+
330
355
  const built: string[] = []
331
356
  for (const r of nativeRoutes) {
332
357
  const name = r.nativeTemplate!
@@ -357,10 +382,9 @@ export async function emitNativeTemplates(opts: NativeRouteEmitOpts): Promise<vo
357
382
  // Dev-only: native routes don't pass through the React renderer's dev-client
358
383
  // injection, so splice the /_brust/dev WS script in here. reEmitJinja() runs
359
384
  // this on every hot reload, so the script is always present in dev.
385
+ const withDirectives = bakeDirectivesIfUsed(compiled.template, hasDirectives)
360
386
  const template =
361
- process.env.BRUST_DEV === '1'
362
- ? injectDevClientIntoTemplate(compiled.template)
363
- : compiled.template
387
+ process.env.BRUST_DEV === '1' ? injectDevClientIntoTemplate(withDirectives) : withDirectives
364
388
  writeFileSync(outPath, template)
365
389
  built.push(name)
366
390