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.
@@ -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
- const ok = teamStore.add(body)
27
- // GAP S7: a domain error (team full) cannot throw a typed non-2xx across
28
- // the treaty boundary, so it rides back inside the success payload as a
29
- // `full` flag. The client therefore checks two places (transport `error`
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 && !data?.full) {
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
- // Shared native layout shell. INLINED into each route at build time (T6): the
2
- // route's root is <PageLayout native …>, whose expansion roots in <BrustPage>,
3
- // which the compiler now promotes to the document shell. Pages supply only
4
- // title/active/crumb + their aa-content inner.
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). See ../FRAMEWORK-GAPS.md.
10
- import { BrustPage, Island } from 'brustjs'
11
- import type { ReactNode } from 'react'
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 PageLayout({
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">{children}</div>
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
- pageTitle: `${cap(p.name)} · PokéDex`,
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
- pageTitle: `${cap(name)} · PokéDex`,
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
- pageTitle: string // dynamic <title> via `<BrustPage title={d.pageTitle}>` (S8)
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 page. Per the decision to push native
2
- // as far as possible, the evolution chain is loaded BLOCKING in the loader (a
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={pageTitle}>` (S8) and inline
11
- // styles use `style={{…}}` objects (S1).
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
- <PageLayout native title={pageTitle} active="list" crumb={displayName} teamProps={teamProps}>
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
- </PageLayout>
164
+ </>
165
165
  )
166
166
  }
@@ -1,12 +1,12 @@
1
- // Route "/" — the Pokédex list. NATIVE route: this whole tree is compiled to a
2
- // minijinja template and rendered in Rust (no React on the server). The only
3
- // non-host tags are <BrustPage> (the document shell) and <Island> (the floating
4
- // team dock)both intercepted by the compiler.
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
- <PageLayout
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
- </PageLayout>
74
+ </>
82
75
  )
83
76
  }
@@ -1,19 +1,14 @@
1
- // Route "/type-chart" — NATIVE route. A static 18×18 type-effectiveness matrix:
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 uses nested `.map()` on the
4
- // native path: rows.map(r => r.cells.map(c => …)) into a CSS grid.
5
- import PageLayout from '../components/PageLayout'
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, teamProps }: TypeChartData) {
9
+ export default function TypeChart({ rows }: TypeChartData) {
9
10
  return (
10
- <PageLayout
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
- </PageLayout>
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
- // List + pagination (query string via req.search, validated by hand in the loader).
14
- { path: '/', Component: ListPage, native: true, loader: listLoader },
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
- // Dynamic param {name} + a (non-streamed) evolution chain loaded in the loader.
17
- { path: '/pokemon/{name}', Component: DetailPage, native: true, loader: detailLoader },
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
- // Static 18×18 effectiveness matrix — the ideal native page.
20
- { path: '/type-chart', Component: TypeChart, native: true, loader: typeChartLoader },
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.23-alpha",
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.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"
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
+ }
@@ -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 { nativeTemplate?: string }[],
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
  })
@@ -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 { nativeTemplate?: string }[],
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
  }