brustjs 0.1.23-alpha → 0.1.24-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/example/pokedex/actions.ts +5 -7
- package/example/pokedex/components/AddToTeamButton.tsx +9 -4
- package/example/pokedex/components/{PageLayout.tsx → AppLayout.tsx} +17 -11
- package/example/pokedex/lib/loaders.ts +17 -2
- package/example/pokedex/lib/types.ts +18 -7
- package/example/pokedex/pages/DetailPage.tsx +9 -9
- package/example/pokedex/pages/ListPage.tsx +7 -14
- package/example/pokedex/pages/TypeChart.tsx +9 -14
- package/example/pokedex/routes.tsx +21 -6
- package/package.json +7 -7
- package/runtime/action-error.ts +31 -0
- package/runtime/cli/build.ts +4 -1
- package/runtime/cli/dev.ts +4 -1
- package/runtime/cli/native-routes-emit.ts +180 -8
- package/runtime/index.js +52 -52
- package/runtime/index.ts +3 -0
- package/runtime/routes.ts +153 -46
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// api.team({ id }).delete() → DELETE /_brust/action/team/{id}
|
|
7
7
|
|
|
8
8
|
import { z } from 'zod'
|
|
9
|
-
import { defineActions } from 'brustjs'
|
|
9
|
+
import { defineActions, ActionError } from 'brustjs'
|
|
10
10
|
import { MAX_TEAM, teamStore } from './lib/team-store'
|
|
11
11
|
|
|
12
12
|
const TeamMemberInput = z.object({
|
|
@@ -23,12 +23,10 @@ export const actions = defineActions()
|
|
|
23
23
|
.post(
|
|
24
24
|
'/team',
|
|
25
25
|
({ body }) => {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
// AND `data.full`). See ./FRAMEWORK-GAPS.md S7.
|
|
31
|
-
return { team: teamStore.list(), max: MAX_TEAM, full: !ok }
|
|
26
|
+
if (!teamStore.add(body)) {
|
|
27
|
+
throw new ActionError(409, 'TEAM_FULL', { data: { max: MAX_TEAM } })
|
|
28
|
+
}
|
|
29
|
+
return { team: teamStore.list(), max: MAX_TEAM }
|
|
32
30
|
},
|
|
33
31
|
{ body: TeamMemberInput },
|
|
34
32
|
)
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// The behavior is react-free: `signal`/`computed` from brustjs/store (the window
|
|
9
9
|
// singleton on the client), `client` from brustjs/client (the treaty action
|
|
10
10
|
// client — also react-free), and the shared teamStore. NO react imports.
|
|
11
|
+
import type { ActionErrorBody } from 'brustjs'
|
|
11
12
|
import { client } from 'brustjs/client'
|
|
12
13
|
import { computed, signal } from 'brustjs/store'
|
|
13
14
|
import type { Actions } from '../actions'
|
|
@@ -24,9 +25,10 @@ export const behavior = ({ props }: { props: AddToTeamProps }) => {
|
|
|
24
25
|
// TeamBuilder island — they resolve the same window singleton. A native
|
|
25
26
|
// x-on-click mutation is therefore seen reactively by a React island.
|
|
26
27
|
const busy = signal(false)
|
|
28
|
+
const full = signal(false)
|
|
27
29
|
const inTeam = computed(() => (teamStore.members() ?? []).some((m) => m.id === props.id))
|
|
28
30
|
const label = computed(() =>
|
|
29
|
-
inTeam() ? '✓ In your team' : disabled() ? 'Team Full' : '+ Add to team',
|
|
31
|
+
inTeam() ? '✓ In your team' : disabled() || full() ? 'Team Full' : '+ Add to team',
|
|
30
32
|
)
|
|
31
33
|
const btnClass = computed(() => `aa-btn aa-btn--full${inTeam() ? ' aa-btn--secondary' : ''}`)
|
|
32
34
|
const disabled = computed(() => busy() || ((teamStore.members()?.length ?? 0) >= 6 && !inTeam()))
|
|
@@ -44,7 +46,7 @@ export const behavior = ({ props }: { props: AddToTeamProps }) => {
|
|
|
44
46
|
const { data } = await api.team({ id: props.id }).delete()
|
|
45
47
|
if (data) teamStore.members.set(data.team)
|
|
46
48
|
} else {
|
|
47
|
-
const { data } = await api.team.post({
|
|
49
|
+
const { data, error } = await api.team.post({
|
|
48
50
|
id: props.id,
|
|
49
51
|
name: props.name,
|
|
50
52
|
displayName: props.displayName,
|
|
@@ -52,8 +54,11 @@ export const behavior = ({ props }: { props: AddToTeamProps }) => {
|
|
|
52
54
|
types: props.types,
|
|
53
55
|
artwork: props.artwork,
|
|
54
56
|
})
|
|
55
|
-
if (data
|
|
57
|
+
if (data) {
|
|
58
|
+
full.set(false)
|
|
56
59
|
teamStore.members.set(data.team)
|
|
60
|
+
} else if ((error?.value as ActionErrorBody)?.code === 'TEAM_FULL') {
|
|
61
|
+
full.set(true) // server rejected — team full; surface instead of silent no-op
|
|
57
62
|
}
|
|
58
63
|
}
|
|
59
64
|
} finally {
|
|
@@ -61,7 +66,7 @@ export const behavior = ({ props }: { props: AddToTeamProps }) => {
|
|
|
61
66
|
}
|
|
62
67
|
}
|
|
63
68
|
|
|
64
|
-
return { init, label, btnClass, toggle, disabled }
|
|
69
|
+
return { init, label, btnClass, toggle, disabled, full }
|
|
65
70
|
}
|
|
66
71
|
|
|
67
72
|
// default → jinja (server). The x-* directives are static string attributes the
|
|
@@ -1,29 +1,33 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Router-level native layout (Approach a: nested routes + <Outlet/>). Written
|
|
2
|
+
// ONCE and nested as the parent of every page route in routes.tsx. The synth
|
|
3
|
+
// wrapper the compiler builds is `<AppLayout native><Leaf native/></AppLayout>`
|
|
4
|
+
// — AppLayout receives NO props at the call site, so the chrome data it renders
|
|
5
|
+
// (title / active / crumb / teamProps) comes from the MERGED LOADER CONTEXT
|
|
6
|
+
// instead (F5 chrome-prop migration). Each leaf loader returns those fields and
|
|
7
|
+
// the chain-loader merge folds them into one flat jinja context; the names
|
|
8
|
+
// below are destructured so the native compiler resolves them as member-paths
|
|
9
|
+
// ({{ title }}, active === 'list', <TeamBuilder props={teamProps}/>).
|
|
5
10
|
//
|
|
6
11
|
// MUST stay single-return with no local bindings above it — a local `const`
|
|
7
12
|
// would make the compiler soft-fall-back to an SSR component (no <html> shell).
|
|
8
13
|
// active-nav uses conditional ELEMENTS (S11), not a className ternary
|
|
9
|
-
// (unsupported).
|
|
10
|
-
|
|
11
|
-
|
|
14
|
+
// (unsupported). AppLayout owns the single <main> (leaves are fragments in the
|
|
15
|
+
// <Outlet/> slot) — a leaf adding its own <main> breaks SPA-nav extraction.
|
|
16
|
+
// See ../FRAMEWORK-GAPS.md.
|
|
17
|
+
import { BrustPage, Island, Outlet } from 'brustjs'
|
|
12
18
|
import type { TeamMember } from '../lib/types'
|
|
13
19
|
import TeamBuilder from './TeamBuilder'
|
|
14
20
|
|
|
15
|
-
export default function
|
|
21
|
+
export default function AppLayout({
|
|
16
22
|
title,
|
|
17
23
|
active,
|
|
18
24
|
crumb,
|
|
19
25
|
teamProps,
|
|
20
|
-
children,
|
|
21
26
|
}: {
|
|
22
27
|
title: string
|
|
23
28
|
active: 'list' | 'typechart'
|
|
24
29
|
crumb: string
|
|
25
30
|
teamProps: { teamInitial: TeamMember[] }
|
|
26
|
-
children: ReactNode
|
|
27
31
|
}) {
|
|
28
32
|
return (
|
|
29
33
|
<BrustPage
|
|
@@ -85,7 +89,9 @@ export default function PageLayout({
|
|
|
85
89
|
</div>
|
|
86
90
|
</header>
|
|
87
91
|
|
|
88
|
-
<div className="aa-content">
|
|
92
|
+
<div className="aa-content">
|
|
93
|
+
<Outlet />
|
|
94
|
+
</div>
|
|
89
95
|
</main>
|
|
90
96
|
|
|
91
97
|
<Island component={TeamBuilder} props={teamProps} ssr hydrate="load" />
|
|
@@ -81,6 +81,11 @@ export async function listLoader({ req }: LoaderCtx): Promise<ListData> {
|
|
|
81
81
|
hasNext,
|
|
82
82
|
prevHref: hasPrev ? (prevOffset > 0 ? `/?offset=${prevOffset}` : '/') : '#',
|
|
83
83
|
nextHref: hasNext ? `/?offset=${offset + PAGE}` : '#',
|
|
84
|
+
// Chrome fields (ChromeData) — merged into the flat jinja context and read by
|
|
85
|
+
// the router-level AppLayout (Approach a: native <Outlet/> nesting).
|
|
86
|
+
title: 'PokéDex · brust example',
|
|
87
|
+
active: 'list',
|
|
88
|
+
crumb: 'All Pokémon',
|
|
84
89
|
teamProps: { teamInitial: teamStore.list() },
|
|
85
90
|
}
|
|
86
91
|
}
|
|
@@ -144,7 +149,10 @@ export async function detailLoader({ params }: LoaderCtx): Promise<DetailData |
|
|
|
144
149
|
|
|
145
150
|
return {
|
|
146
151
|
notFound: false,
|
|
147
|
-
|
|
152
|
+
// Chrome fields (ChromeData) read by AppLayout from the merged context.
|
|
153
|
+
title: `${cap(p.name)} · PokéDex`,
|
|
154
|
+
active: 'list',
|
|
155
|
+
crumb: cap(p.name),
|
|
148
156
|
name: p.name,
|
|
149
157
|
id: p.id,
|
|
150
158
|
displayName: cap(p.name),
|
|
@@ -181,7 +189,10 @@ export async function detailLoader({ params }: LoaderCtx): Promise<DetailData |
|
|
|
181
189
|
function emptyDetail(name: string): DetailData {
|
|
182
190
|
return {
|
|
183
191
|
notFound: true,
|
|
184
|
-
|
|
192
|
+
// Chrome fields (ChromeData) read by AppLayout from the merged context.
|
|
193
|
+
title: `${cap(name)} · PokéDex`,
|
|
194
|
+
active: 'list',
|
|
195
|
+
crumb: cap(name),
|
|
185
196
|
name,
|
|
186
197
|
id: 0,
|
|
187
198
|
displayName: cap(name),
|
|
@@ -311,6 +322,10 @@ export async function typeChartLoader(): Promise<TypeChartData> {
|
|
|
311
322
|
|
|
312
323
|
return {
|
|
313
324
|
rows,
|
|
325
|
+
// Chrome fields (ChromeData) read by AppLayout from the merged context.
|
|
326
|
+
title: 'PokéDex · type chart',
|
|
327
|
+
active: 'typechart',
|
|
328
|
+
crumb: 'Type chart',
|
|
314
329
|
teamProps: { teamInitial: teamStore.list() },
|
|
315
330
|
}
|
|
316
331
|
}
|
|
@@ -7,6 +7,19 @@
|
|
|
7
7
|
// multi-property style strings (templates have no template-literals / arithmetic
|
|
8
8
|
// / helper calls). See ../FRAMEWORK-GAPS.md.
|
|
9
9
|
|
|
10
|
+
/** Chrome view-model the router-level AppLayout reads from the MERGED loader
|
|
11
|
+
* context (Approach a — native <Outlet/> nesting). AppLayout takes NO props at
|
|
12
|
+
* its call site (`<AppLayout native>`), so each leaf loader returns these
|
|
13
|
+
* fields and the chain-loader merge folds them into the one flat jinja context
|
|
14
|
+
* AppLayout's template reads ({{ title }}, active === 'list', teamProps). The
|
|
15
|
+
* key names are chosen NOT to collide with any page-data field. */
|
|
16
|
+
export interface ChromeData {
|
|
17
|
+
title: string // dynamic <title> + nav header, via AppLayout's <BrustPage title={title}>
|
|
18
|
+
active: 'list' | 'typechart' // which sidebar nav item gets is-active (S11 conditional)
|
|
19
|
+
crumb: string // topbar breadcrumb leaf label
|
|
20
|
+
teamProps: { teamInitial: TeamMember[] } // floating team-dock island initial state
|
|
21
|
+
}
|
|
22
|
+
|
|
10
23
|
/** A single list cell — derived from the list endpoint alone (no detail fetch,
|
|
11
24
|
* see FRAMEWORK-GAPS.md S2 / N+1 avoidance). */
|
|
12
25
|
export interface CardVM {
|
|
@@ -18,7 +31,7 @@ export interface CardVM {
|
|
|
18
31
|
detailHref: string // "/pokemon/bulbasaur"
|
|
19
32
|
}
|
|
20
33
|
|
|
21
|
-
export interface ListData {
|
|
34
|
+
export interface ListData extends ChromeData {
|
|
22
35
|
items: CardVM[]
|
|
23
36
|
total: number
|
|
24
37
|
totalLabel: string // "1,302"
|
|
@@ -32,7 +45,6 @@ export interface ListData {
|
|
|
32
45
|
prevHref: string
|
|
33
46
|
nextHref: string
|
|
34
47
|
offsetLabel: string // raw offset for the loader-echo line
|
|
35
|
-
teamProps: { teamInitial: TeamMember[] }
|
|
36
48
|
}
|
|
37
49
|
|
|
38
50
|
export interface TypeBadgeVM {
|
|
@@ -70,12 +82,13 @@ export interface EvolutionStageVM {
|
|
|
70
82
|
}
|
|
71
83
|
|
|
72
84
|
/** The full view-model handed to DetailPage. Every field is render-ready. */
|
|
73
|
-
export interface DetailData {
|
|
85
|
+
export interface DetailData extends ChromeData {
|
|
74
86
|
notFound: boolean
|
|
75
87
|
// Native routes now branch with `{notFound ? <NotFound/> : <Content/>}` (S11),
|
|
76
88
|
// so the content and 404 block are mutually exclusive at render time rather
|
|
77
89
|
// than both emitted with one hidden via a precomputed class.
|
|
78
|
-
|
|
90
|
+
// The dynamic <title> is `title` (ChromeData), read by AppLayout's
|
|
91
|
+
// <BrustPage title={title}> — no separate pageTitle field.
|
|
79
92
|
name: string
|
|
80
93
|
// present only when notFound === false:
|
|
81
94
|
id: number
|
|
@@ -99,7 +112,6 @@ export interface DetailData {
|
|
|
99
112
|
// object literals). addProps is the loader-precomputed JSON string handed to
|
|
100
113
|
// <AddToTeamButton data={addProps} /> → x-props (Spec B native directives).
|
|
101
114
|
addProps: string
|
|
102
|
-
teamProps: { teamInitial: TeamMember[] }
|
|
103
115
|
}
|
|
104
116
|
|
|
105
117
|
/** Shape of the AddToTeamButton native behavior's `props` (JSON-parsed from
|
|
@@ -129,9 +141,8 @@ export interface TypeChartRowVM {
|
|
|
129
141
|
cells: TypeChartCellVM[] // 19 cells (1 head + 18)
|
|
130
142
|
}
|
|
131
143
|
|
|
132
|
-
export interface TypeChartData {
|
|
144
|
+
export interface TypeChartData extends ChromeData {
|
|
133
145
|
rows: TypeChartRowVM[] // 19 rows (1 header + 18), each 19 cells
|
|
134
|
-
teamProps: { teamInitial: TeamMember[] }
|
|
135
146
|
}
|
|
136
147
|
|
|
137
148
|
/** In-process team store member. */
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
// Route "/pokemon/{name}" — NATIVE detail
|
|
2
|
-
//
|
|
1
|
+
// Route "/pokemon/{name}" — NATIVE detail leaf route, rendered into AppLayout's
|
|
2
|
+
// <Outlet/> slot (chrome lives in AppLayout). Returns JUST its inner aa-content
|
|
3
|
+
// fragment — no <BrustPage>, no <main>. Per the decision to push native as far
|
|
4
|
+
// as possible, the evolution chain is loaded BLOCKING in the loader (a
|
|
3
5
|
// native/jinja route has no React tree, so <Suspense> streaming is impossible —
|
|
4
6
|
// see ../FRAMEWORK-GAPS.md S3).
|
|
5
7
|
//
|
|
@@ -7,15 +9,14 @@
|
|
|
7
9
|
// `{notFound ? <NotFound/> : <Content/>}` branch, `{hasAbilities && …}` /
|
|
8
10
|
// `{hasEvolution && …}` sections, and per-item `{!s.isFirst && <Arrow/>}` /
|
|
9
11
|
// `{s.showLevel && <Level/>}` separators — no more loader-computed hide-classes.
|
|
10
|
-
// The <title> is dynamic via `<BrustPage title={
|
|
11
|
-
//
|
|
12
|
+
// The <title> is dynamic via AppLayout's `<BrustPage title={title}>` (S8, the
|
|
13
|
+
// loader merges `title` into the shared context) and inline styles use
|
|
14
|
+
// `style={{…}}` objects (S1).
|
|
12
15
|
import AddToTeamButton from '../components/AddToTeamButton'
|
|
13
|
-
import PageLayout from '../components/PageLayout'
|
|
14
16
|
import type { DetailData } from '../lib/types'
|
|
15
17
|
|
|
16
18
|
export default function DetailPage({
|
|
17
19
|
notFound,
|
|
18
|
-
pageTitle,
|
|
19
20
|
hasAbilities,
|
|
20
21
|
hasEvolution,
|
|
21
22
|
displayName,
|
|
@@ -33,10 +34,9 @@ export default function DetailPage({
|
|
|
33
34
|
abilities,
|
|
34
35
|
evolution,
|
|
35
36
|
addProps,
|
|
36
|
-
teamProps,
|
|
37
37
|
}: DetailData) {
|
|
38
38
|
return (
|
|
39
|
-
|
|
39
|
+
<>
|
|
40
40
|
<a className="aa-btn aa-btn--ghost aa-btn--sm dex-back" href="/">
|
|
41
41
|
‹ Pokédex
|
|
42
42
|
</a>
|
|
@@ -161,6 +161,6 @@ export default function DetailPage({
|
|
|
161
161
|
)}
|
|
162
162
|
</>
|
|
163
163
|
)}
|
|
164
|
-
|
|
164
|
+
</>
|
|
165
165
|
)
|
|
166
166
|
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
// Route "/" — the Pokédex list. NATIVE route:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Route "/" — the Pokédex list. NATIVE leaf route: compiled to a minijinja
|
|
2
|
+
// template and rendered into AppLayout's <Outlet/> slot (chrome lives in
|
|
3
|
+
// AppLayout, nested as the parent in routes.tsx). The page therefore returns
|
|
4
|
+
// JUST its inner aa-content fragment — no <BrustPage>, no <main>: AppLayout owns
|
|
5
|
+
// the document shell and the single <main>.
|
|
5
6
|
//
|
|
6
7
|
// Native route components support `.map()` AND conditionals (S11): pagination
|
|
7
8
|
// "disabled" state is a real `{hasPrev ? <a/> : <span/>}` branch, not a
|
|
8
9
|
// precomputed hide-class. See ../FRAMEWORK-GAPS.md S11.
|
|
9
|
-
import PageLayout from '../components/PageLayout'
|
|
10
10
|
import type { ListData } from '../lib/types'
|
|
11
11
|
|
|
12
12
|
export default function ListPage({
|
|
@@ -19,16 +19,9 @@ export default function ListPage({
|
|
|
19
19
|
hasNext,
|
|
20
20
|
prevHref,
|
|
21
21
|
nextHref,
|
|
22
|
-
teamProps,
|
|
23
22
|
}: ListData) {
|
|
24
23
|
return (
|
|
25
|
-
|
|
26
|
-
native
|
|
27
|
-
title="PokéDex · brust example"
|
|
28
|
-
active="list"
|
|
29
|
-
crumb="All Pokémon"
|
|
30
|
-
teamProps={teamProps}
|
|
31
|
-
>
|
|
24
|
+
<>
|
|
32
25
|
<div className="aa-page-header">
|
|
33
26
|
<div>
|
|
34
27
|
<h1 className="aa-page-header__title">Pokédex</h1>
|
|
@@ -78,6 +71,6 @@ export default function ListPage({
|
|
|
78
71
|
)}
|
|
79
72
|
</div>
|
|
80
73
|
</div>
|
|
81
|
-
|
|
74
|
+
</>
|
|
82
75
|
)
|
|
83
76
|
}
|
|
@@ -1,19 +1,14 @@
|
|
|
1
|
-
// Route "/type-chart" — NATIVE route
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// native
|
|
5
|
-
|
|
1
|
+
// Route "/type-chart" — NATIVE leaf route, rendered into AppLayout's <Outlet/>
|
|
2
|
+
// slot (chrome lives in AppLayout). Returns JUST its inner aa-content fragment.
|
|
3
|
+
// A static 18×18 type-effectiveness matrix: pure read-only data, the ideal
|
|
4
|
+
// native page (compiled to jinja, rendered in Rust, zero React on the server).
|
|
5
|
+
// The 19×19 grid uses nested `.map()` on the native path: rows.map(r =>
|
|
6
|
+
// r.cells.map(c => …)) into a CSS grid.
|
|
6
7
|
import type { TypeChartData } from '../lib/types'
|
|
7
8
|
|
|
8
|
-
export default function TypeChart({ rows
|
|
9
|
+
export default function TypeChart({ rows }: TypeChartData) {
|
|
9
10
|
return (
|
|
10
|
-
|
|
11
|
-
native
|
|
12
|
-
title="PokéDex · type chart"
|
|
13
|
-
active="typechart"
|
|
14
|
-
crumb="Type chart"
|
|
15
|
-
teamProps={teamProps}
|
|
16
|
-
>
|
|
11
|
+
<>
|
|
17
12
|
<div className="aa-page-header">
|
|
18
13
|
<div>
|
|
19
14
|
<h1 className="aa-page-header__title">Type chart</h1>
|
|
@@ -49,6 +44,6 @@ export default function TypeChart({ rows, teamProps }: TypeChartData) {
|
|
|
49
44
|
))}
|
|
50
45
|
</div>
|
|
51
46
|
</div>
|
|
52
|
-
|
|
47
|
+
</>
|
|
53
48
|
)
|
|
54
49
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { defineRoutes } from 'brustjs/routes'
|
|
2
|
+
import AppLayout from './components/AppLayout'
|
|
2
3
|
import { detailLoader, listLoader, typeChartLoader } from './lib/loaders'
|
|
3
4
|
import DetailPage from './pages/DetailPage'
|
|
4
5
|
import ListPage from './pages/ListPage'
|
|
@@ -9,13 +10,27 @@ import TypeChart from './pages/TypeChart'
|
|
|
9
10
|
// runs in a Bun worker and its return value becomes the template scope. The only
|
|
10
11
|
// React that ever boots in the browser is the islands (TeamBuilder /
|
|
11
12
|
// AddToTeamButton). See ./FRAMEWORK-GAPS.md for what this costs.
|
|
13
|
+
//
|
|
14
|
+
// ROUTER-LEVEL LAYOUT (Approach a): the chrome (sidebar / topbar / team-dock) is
|
|
15
|
+
// written ONCE in AppLayout, nested as the parent of the three leaf routes. Each
|
|
16
|
+
// leaf renders into AppLayout's <Outlet/> slot. The compiler builds the synth
|
|
17
|
+
// wrapper `<AppLayout native><Leaf native/></AppLayout>` per leaf and runs the
|
|
18
|
+
// chain loaders top-down, shallow-merging into one flat jinja context. The leaf
|
|
19
|
+
// loaders therefore also return the chrome fields (title/active/crumb/teamProps)
|
|
20
|
+
// that AppLayout reads — see lib/loaders.ts and components/AppLayout.tsx.
|
|
12
21
|
export const routes = defineRoutes([
|
|
13
|
-
|
|
14
|
-
|
|
22
|
+
{
|
|
23
|
+
Component: AppLayout,
|
|
24
|
+
native: true,
|
|
25
|
+
children: [
|
|
26
|
+
// List + pagination (query string via req.search, validated by hand in the loader).
|
|
27
|
+
{ path: '/', Component: ListPage, native: true, loader: listLoader },
|
|
15
28
|
|
|
16
|
-
|
|
17
|
-
|
|
29
|
+
// Dynamic param {name} + a (non-streamed) evolution chain loaded in the loader.
|
|
30
|
+
{ path: '/pokemon/{name}', Component: DetailPage, native: true, loader: detailLoader },
|
|
18
31
|
|
|
19
|
-
|
|
20
|
-
|
|
32
|
+
// Static 18×18 effectiveness matrix — the ideal native page.
|
|
33
|
+
{ path: '/type-chart', Component: TypeChart, native: true, loader: typeChartLoader },
|
|
34
|
+
],
|
|
35
|
+
},
|
|
21
36
|
])
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brustjs",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.24-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.24-alpha",
|
|
44
|
+
"brustjs-darwin-arm64": "0.1.24-alpha",
|
|
45
|
+
"brustjs-linux-x64-gnu": "0.1.24-alpha",
|
|
46
|
+
"brustjs-linux-arm64-gnu": "0.1.24-alpha",
|
|
47
|
+
"brustjs-linux-x64-musl": "0.1.24-alpha",
|
|
48
|
+
"brustjs-linux-arm64-musl": "0.1.24-alpha"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
51
|
"react": "^19.2.6",
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Typed domain error for treaty actions. `throw new ActionError(status, code, { data })`
|
|
2
|
+
// from a handler (or nested business logic) → dispatchAction maps it to an HTTP
|
|
3
|
+
// non-2xx with a flat body `{ code, message, data }`. Branded with Symbol.for so the
|
|
4
|
+
// guard survives the class being duplicated across bundles (user code → framework).
|
|
5
|
+
const ACTION_ERROR: unique symbol = Symbol.for('brust.actionError')
|
|
6
|
+
|
|
7
|
+
export interface ActionErrorBody {
|
|
8
|
+
code: string
|
|
9
|
+
message: string
|
|
10
|
+
data?: unknown
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class ActionError extends Error {
|
|
14
|
+
readonly [ACTION_ERROR] = true as const
|
|
15
|
+
readonly status: number
|
|
16
|
+
readonly code: string
|
|
17
|
+
readonly data?: unknown
|
|
18
|
+
constructor(status: number, code: string, opts?: { message?: string; data?: unknown }) {
|
|
19
|
+
super(opts?.message ?? code)
|
|
20
|
+
this.name = 'ActionError'
|
|
21
|
+
this.status = status
|
|
22
|
+
this.code = code
|
|
23
|
+
this.data = opts?.data
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isActionError(v: unknown): v is ActionError {
|
|
28
|
+
return (
|
|
29
|
+
typeof v === 'object' && v !== null && (v as Record<symbol, unknown>)[ACTION_ERROR] === true
|
|
30
|
+
)
|
|
31
|
+
}
|
package/runtime/cli/build.ts
CHANGED
|
@@ -328,7 +328,10 @@ export async function runBuild(args: string[]): Promise<void> {
|
|
|
328
328
|
// an empty importMap (no native routes to emit anyway).
|
|
329
329
|
await emitNativeTemplates({
|
|
330
330
|
entryFile: existsSync(routesFile) ? routesFile : entry,
|
|
331
|
-
flatRoutes: (loadedRoutes ?? []) as {
|
|
331
|
+
flatRoutes: (loadedRoutes ?? []) as {
|
|
332
|
+
nativeTemplate?: string
|
|
333
|
+
chain?: Array<{ Component?: { name?: string } }>
|
|
334
|
+
}[],
|
|
332
335
|
outDir: jinjaDir,
|
|
333
336
|
repoRoot: REPO_ROOT,
|
|
334
337
|
})
|
package/runtime/cli/dev.ts
CHANGED
|
@@ -82,7 +82,10 @@ export async function runDev(args: string[]): Promise<void> {
|
|
|
82
82
|
const jinjaDir = path.join(process.cwd(), '.brust/jinja')
|
|
83
83
|
const emitOpts = {
|
|
84
84
|
entryFile: existsSync(routesFile) ? routesFile : entry,
|
|
85
|
-
flatRoutes: loadedRoutes as {
|
|
85
|
+
flatRoutes: loadedRoutes as {
|
|
86
|
+
nativeTemplate?: string
|
|
87
|
+
chain?: Array<{ Component?: { name?: string } }>
|
|
88
|
+
}[],
|
|
86
89
|
outDir: jinjaDir,
|
|
87
90
|
repoRoot: REPO_ROOT,
|
|
88
91
|
}
|