brustjs 0.1.21-alpha → 0.1.23-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
  ```
@@ -1617,6 +1617,7 @@ code, pre { font-family: var(--font-mono); }
1617
1617
  .dex-tc-legend__item { display: inline-flex; align-items: center; gap: 6px; }
1618
1618
  .dex-tc-scroll { overflow-x: auto; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface-raised); }
1619
1619
  .dex-tc { display: grid; grid-template-columns: 58px repeat(18, minmax(30px, 1fr)); min-width: 700px; }
1620
+ .dex-tc__row { display: contents; }
1620
1621
  .dex-tc__corner {
1621
1622
  font-size: 8px;
1622
1623
  font-weight: 700;
@@ -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
  }
@@ -22,7 +22,7 @@ import {
22
22
  TYPE_COLOR,
23
23
  } from './pokeapi'
24
24
  import { teamStore } from './team-store'
25
- import type { DetailData, ListData, TypeBadgeVM, TypeChartData } from './types'
25
+ import type { DetailData, ListData, TypeBadgeVM, TypeChartCellVM, TypeChartData } from './types'
26
26
 
27
27
  /** Loader context shape — `loader: ({ params, path, req }) => data`. */
28
28
  interface LoaderCtx {
@@ -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
  }
@@ -228,63 +238,79 @@ export async function typeChartLoader(): Promise<TypeChartData> {
228
238
  // fan out 18 fetches by hand with Promise.all (and there is no dedupe).
229
239
  const relations = await Promise.all(ALL_TYPES.map((t) => fetchTypeRelations(t)))
230
240
 
231
- // Flatten the 19×19 grid (1 header row/col + 18×18 matrix) into a single
232
- // row-major array so the native template renders it with ONE `.map()`.
233
- const cells: Array<Omit<TypeChartData['cells'][number], 'id'>> = []
241
+ // Build the 19×19 grid as nested rows (header row + one row per attacking
242
+ // type). The native template renders it with nested `.map()` — rows.map(r =>
243
+ // r.cells.map(c => …)) into the CSS grid (`.dex-tc__row{display:contents}`
244
+ // keeps every cell a direct grid item, so the layout is unchanged).
245
+ const rows: TypeChartData['rows'] = []
234
246
 
235
247
  // Header row: corner + 18 defending-type column heads.
236
- cells.push({
237
- className: 'dex-tc__corner',
238
- content: 'ATK \ DEF',
239
- title: 'Attacking \ Defending',
240
- })
241
- for (const def of ALL_TYPES) {
242
- cells.push({
248
+ const headerCells: TypeChartCellVM[] = [
249
+ {
250
+ id: '0-0',
251
+ className: 'dex-tc__corner',
252
+ content: 'ATK \ DEF',
253
+ title: 'Attacking Defending',
254
+ },
255
+ ]
256
+ ALL_TYPES.forEach((def, j) => {
257
+ headerCells.push({
258
+ id: `0-${j + 1}`,
243
259
  className: `dex-tc__colhead dex-tc__colhead--${def}`,
244
260
  content: SHORT[def] ?? def.slice(0, 3).toUpperCase(),
245
261
  title: cap(def),
246
262
  })
247
- }
263
+ })
264
+ rows.push({ id: '0', cells: headerCells })
248
265
 
249
266
  // One row per attacking type: row head + 18 effectiveness cells.
250
267
  ALL_TYPES.forEach((atk, i) => {
251
268
  const rel = relations[i]!
252
- cells.push({
253
- className: `dex-tc__rowhead dex-tc__rowhead--${atk}`,
254
- content: SHORT[atk] ?? atk.slice(0, 3).toUpperCase(),
255
- title: cap(atk),
256
- })
257
- for (const def of ALL_TYPES) {
269
+ const rowCells: TypeChartCellVM[] = [
270
+ {
271
+ id: `${i + 1}-0`,
272
+ className: `dex-tc__rowhead dex-tc__rowhead--${atk}`,
273
+ content: SHORT[atk] ?? atk.slice(0, 3).toUpperCase(),
274
+ title: cap(atk),
275
+ },
276
+ ]
277
+ ALL_TYPES.forEach((def, j) => {
258
278
  const mult = rel[def]
279
+ const id = `${i + 1}-${j + 1}`
259
280
  if (mult === 2)
260
- cells.push({
281
+ rowCells.push({
282
+ id,
261
283
  className: 'dex-tc__cell dex-tc__cell--super',
262
284
  content: '2',
263
285
  title: `${cap(atk)} → ${cap(def)}: 2× (super effective)`,
264
286
  })
265
287
  else if (mult === 0.5)
266
- cells.push({
288
+ rowCells.push({
289
+ id,
267
290
  className: 'dex-tc__cell dex-tc__cell--weak',
268
291
  content: '½',
269
292
  title: `${cap(atk)} → ${cap(def)}: ½× (not very effective)`,
270
293
  })
271
294
  else if (mult === 0)
272
- cells.push({
295
+ rowCells.push({
296
+ id,
273
297
  className: 'dex-tc__cell dex-tc__cell--none',
274
298
  content: '0',
275
299
  title: `${cap(atk)} → ${cap(def)}: 0× (no effect)`,
276
300
  })
277
301
  else
278
- cells.push({
302
+ rowCells.push({
303
+ id,
279
304
  className: 'dex-tc__cell',
280
305
  content: '',
281
306
  title: `${cap(atk)} → ${cap(def)}: 1×`,
282
307
  })
283
- }
308
+ })
309
+ rows.push({ id: String(i + 1), cells: rowCells })
284
310
  })
285
311
 
286
312
  return {
287
- cells: cells.map((c, i) => ({ id: String(i), ...c })),
313
+ rows,
288
314
  teamProps: { teamInitial: teamStore.list() },
289
315
  }
290
316
  }
@@ -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
@@ -110,18 +113,24 @@ export interface AddToTeamProps {
110
113
  artwork: string
111
114
  }
112
115
 
113
- /** One cell of the type chart, FLATTENED into a single row-major array so the
114
- * native template renders it with ONE `.map()` into a CSS grid — nested maps
115
- * aren't proven on the native path, so we avoid them. See FRAMEWORK-GAPS.md S10. */
116
+ /** One cell of the type chart. */
116
117
  export interface TypeChartCellVM {
117
- id: string // stable key (row/col coordinate)
118
+ id: string // stable key "row-col"
118
119
  className: string // "dex-tc__cell dex-tc__cell--super"
119
120
  content: string // "2", "½", "0", a type short-code, or ""
120
121
  title: string // tooltip
121
122
  }
122
123
 
124
+ /** One row of the type chart (header row + one row per attacking type). The
125
+ * native template renders rows.map(r => r.cells.map(c => …)) — nested `.map()`
126
+ * is supported on the native path. */
127
+ export interface TypeChartRowVM {
128
+ id: string // row index as string
129
+ cells: TypeChartCellVM[] // 19 cells (1 head + 18)
130
+ }
131
+
123
132
  export interface TypeChartData {
124
- cells: TypeChartCellVM[] // (18+1) × (18+1) row-major, including headers
133
+ rows: TypeChartRowVM[] // 19 rows (1 header + 18), each 19 cells
125
134
  teamProps: { teamInitial: TeamMember[] }
126
135
  }
127
136
 
@@ -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">
@@ -1,12 +1,11 @@
1
1
  // Route "/type-chart" — NATIVE route. A static 18×18 type-effectiveness matrix:
2
2
  // pure read-only data, the ideal native page (compiled to jinja, rendered in
3
- // Rust, zero React on the server). The 19×19 grid is pre-flattened in the loader
4
- // to a single row-major `cells` array so the template uses ONE `.map()` into a
5
- // CSS grid (nested maps aren't proven on the native path — see GAPS S10).
3
+ // Rust, zero React on the server). The 19×19 grid uses nested `.map()` on the
4
+ // native path: rows.map(r => r.cells.map(c => )) into a CSS grid.
6
5
  import PageLayout from '../components/PageLayout'
7
6
  import type { TypeChartData } from '../lib/types'
8
7
 
9
- export default function TypeChart({ cells, teamProps }: TypeChartData) {
8
+ export default function TypeChart({ rows, teamProps }: TypeChartData) {
10
9
  return (
11
10
  <PageLayout
12
11
  native
@@ -39,9 +38,13 @@ export default function TypeChart({ cells, teamProps }: TypeChartData) {
39
38
 
40
39
  <div className="dex-tc-scroll">
41
40
  <div className="dex-tc">
42
- {cells.map((c) => (
43
- <div key={c.id} className={c.className} title={c.title}>
44
- {c.content}
41
+ {rows.map((r) => (
42
+ <div key={r.id} className="dex-tc__row">
43
+ {r.cells.map((c) => (
44
+ <div key={c.id} className={c.className} title={c.title}>
45
+ {c.content}
46
+ </div>
47
+ ))}
45
48
  </div>
46
49
  ))}
47
50
  </div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brustjs",
3
- "version": "0.1.21-alpha",
3
+ "version": "0.1.23-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.23-alpha",
44
+ "brustjs-darwin-arm64": "0.1.23-alpha",
45
+ "brustjs-linux-x64-gnu": "0.1.23-alpha",
46
+ "brustjs-linux-arm64-gnu": "0.1.23-alpha",
47
+ "brustjs-linux-x64-musl": "0.1.23-alpha",
48
+ "brustjs-linux-arm64-musl": "0.1.23-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