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 +12 -1
- package/example/pokedex/app.css +1 -0
- package/example/pokedex/components/AddToTeamButton.tsx +55 -64
- package/example/pokedex/lib/loaders.ts +53 -27
- package/example/pokedex/lib/types.ts +17 -8
- package/example/pokedex/pages/DetailPage.tsx +1 -2
- package/example/pokedex/pages/TypeChart.tsx +10 -7
- package/package.json +9 -8
- package/runtime/cli/build.ts +41 -0
- package/runtime/cli/native-routes-emit.ts +28 -4
- package/runtime/index.js +52 -52
- package/runtime/index.ts +24 -1
- package/runtime/islands/brust-page.tsx +6 -1
- package/runtime/islands/importmap.ts +6 -0
- package/runtime/native/build.ts +147 -0
- package/runtime/native/index.ts +3 -0
- package/runtime/native/runtime.ts +324 -0
- package/runtime/store/signal.ts +36 -18
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
|
```
|
package/example/pokedex/app.css
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
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
|
-
|
|
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.
|
|
17
|
-
//
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
40
|
+
busy.set(true)
|
|
33
41
|
try {
|
|
34
|
-
if (inTeam) {
|
|
35
|
-
//
|
|
36
|
-
|
|
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:
|
|
43
|
-
name:
|
|
44
|
-
displayName:
|
|
45
|
-
num:
|
|
46
|
-
types:
|
|
47
|
-
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
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
|
-
//
|
|
232
|
-
//
|
|
233
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
99
|
-
addProps
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
|
4
|
-
//
|
|
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({
|
|
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
|
-
{
|
|
43
|
-
<div key={
|
|
44
|
-
{c
|
|
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.
|
|
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.
|
|
44
|
-
"brustjs-darwin-arm64": "0.1.
|
|
45
|
-
"brustjs-linux-x64-gnu": "0.1.
|
|
46
|
-
"brustjs-linux-arm64-gnu": "0.1.
|
|
47
|
-
"brustjs-linux-x64-musl": "0.1.
|
|
48
|
-
"brustjs-linux-arm64-musl": "0.1.
|
|
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",
|
package/runtime/cli/build.ts
CHANGED
|
@@ -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
|
|