@brust/create-app 0.0.0-stage → 2.0.0-alpha.10

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.
Files changed (69) hide show
  1. package/README.md +19 -2
  2. package/bin/create-app.ts +4 -0
  3. package/package.json +31 -4
  4. package/src/args.ts +64 -0
  5. package/src/cli.ts +69 -0
  6. package/src/copy.ts +44 -0
  7. package/src/dep.ts +61 -0
  8. package/src/tailwind.ts +10 -0
  9. package/src/templates.ts +64 -0
  10. package/templates/blank/README.md.tmpl +43 -0
  11. package/templates/blank/_gitignore +4 -0
  12. package/templates/blank/actions.ts +13 -0
  13. package/templates/blank/app.css +32 -0
  14. package/templates/blank/brust.toml +6 -0
  15. package/templates/blank/components/Counter.tsx +15 -0
  16. package/templates/blank/components/Layout.tsx +22 -0
  17. package/templates/blank/package.json.tmpl +30 -0
  18. package/templates/blank/pages/HomePage.tsx +35 -0
  19. package/templates/blank/public/favicon.svg +1 -0
  20. package/templates/blank/routes.tsx +5 -0
  21. package/templates/blank/tsconfig.json +37 -0
  22. package/templates/blank/wrangler.jsonc.tmpl +9 -0
  23. package/templates/pokedex/README.md.tmpl +59 -0
  24. package/templates/pokedex/_gitignore +5 -0
  25. package/templates/pokedex/actions.ts +37 -0
  26. package/templates/pokedex/brust.toml +28 -0
  27. package/templates/pokedex/components/AddToTeamButton.tsx +37 -0
  28. package/templates/pokedex/components/AppLayout.tsx +32 -0
  29. package/templates/pokedex/components/Breadcrumb.tsx +4 -0
  30. package/templates/pokedex/components/DexCard.tsx +11 -0
  31. package/templates/pokedex/components/DexFilter.tsx +26 -0
  32. package/templates/pokedex/components/HeroSearch.tsx +14 -0
  33. package/templates/pokedex/components/NavLink.tsx +6 -0
  34. package/templates/pokedex/components/TeamBuilder.tsx +32 -0
  35. package/templates/pokedex/components/ThemeToggle.tsx +16 -0
  36. package/templates/pokedex/components/TypeBadge.tsx +5 -0
  37. package/templates/pokedex/components/UnifiedIsland.tsx +28 -0
  38. package/templates/pokedex/components/UnifiedNative.tsx +20 -0
  39. package/templates/pokedex/components/UnifiedStoreNode.tsx +14 -0
  40. package/templates/pokedex/data/pokedex.json +8767 -0
  41. package/templates/pokedex/lib/api.ts +5 -0
  42. package/templates/pokedex/lib/cloudflare.d.ts +24 -0
  43. package/templates/pokedex/lib/filter.ts +6 -0
  44. package/templates/pokedex/lib/format.ts +4 -0
  45. package/templates/pokedex/lib/loaders.ts +192 -0
  46. package/templates/pokedex/lib/pokeapi.ts +43 -0
  47. package/templates/pokedex/lib/team-client.ts +26 -0
  48. package/templates/pokedex/lib/team-db.d1.ts +47 -0
  49. package/templates/pokedex/lib/team-db.sqlite.ts +49 -0
  50. package/templates/pokedex/lib/team-db.ts +13 -0
  51. package/templates/pokedex/lib/team.ts +2 -0
  52. package/templates/pokedex/lib/types.ts +126 -0
  53. package/templates/pokedex/migrations/0001_team.sql +2 -0
  54. package/templates/pokedex/package.json.tmpl +30 -0
  55. package/templates/pokedex/pages/BrowsePage.tsx +15 -0
  56. package/templates/pokedex/pages/DetailPage.tsx +233 -0
  57. package/templates/pokedex/pages/HomePage.tsx +129 -0
  58. package/templates/pokedex/pages/NotFoundPage.tsx +7 -0
  59. package/templates/pokedex/pages/TypeChart.tsx +72 -0
  60. package/templates/pokedex/pages/UnifiedPage.tsx +24 -0
  61. package/templates/pokedex/public/app.css +3 -0
  62. package/templates/pokedex/public/favicon.svg +1 -0
  63. package/templates/pokedex/routes.tsx +27 -0
  64. package/templates/pokedex/scripts/snapshot.ts +53 -0
  65. package/templates/pokedex/stores/team.ts +6 -0
  66. package/templates/pokedex/stores/unified.ts +4 -0
  67. package/templates/pokedex/styles/app.css +14 -0
  68. package/templates/pokedex/tsconfig.json +37 -0
  69. package/templates/pokedex/wrangler.jsonc +9 -0
@@ -0,0 +1,5 @@
1
+ // examples/pokedex/lib/api.ts — browser-safe; the App import is type-only (the elysia lane's browser guard refuses a value import).
2
+ import { client } from '@brust/core/client'
3
+ import type { App } from '../actions'
4
+
5
+ export const api = client<App>()
@@ -0,0 +1,24 @@
1
+ // Ambient types for the Cloudflare Workers surface this example uses (lib/team-db.d1.ts): just the subset of D1 that
2
+ // is called, so `bun check` needs no @cloudflare/workers-types (whose global Request/Response clash with lib DOM).
3
+ interface D1Meta {
4
+ changes: number
5
+ last_row_id: number
6
+ }
7
+ interface D1Result<T = unknown> {
8
+ success: boolean
9
+ results: T[]
10
+ meta: D1Meta
11
+ }
12
+ interface D1PreparedStatement {
13
+ bind(...values: unknown[]): D1PreparedStatement
14
+ all<T = Record<string, unknown>>(): Promise<D1Result<T>>
15
+ first<T = Record<string, unknown>>(): Promise<T | null>
16
+ run(): Promise<D1Result>
17
+ }
18
+ interface D1Database {
19
+ prepare(query: string): D1PreparedStatement
20
+ batch<T = unknown>(statements: D1PreparedStatement[]): Promise<D1Result<T>[]>
21
+ }
22
+ declare module 'cloudflare:workers' {
23
+ export const env: { TEAM_DB?: D1Database }
24
+ }
@@ -0,0 +1,6 @@
1
+ import type { DexCard } from './types'
2
+ export function filterSort(items: DexCard[], q: string, az: boolean): DexCard[] {
3
+ const needle = q.trim().toLowerCase()
4
+ const out = needle ? items.filter((c) => c.name.includes(needle)) : items.slice()
5
+ return az ? out.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) : out
6
+ }
@@ -0,0 +1,4 @@
1
+ export const fmtHeight = (dm: number) => `${(dm / 10).toFixed(1)} m`
2
+ export const fmtWeight = (hg: number) => `${(hg / 10).toFixed(1)} kg`
3
+ export const tint = (type: string) => ({ normal: '#9099a1', fire: '#ef7444', water: '#4d90d5', grass: '#63bb5b', electric: '#f5c84b', ice: '#74cec0', fighting: '#ce4069', poison: '#ab6ac8', ground: '#d97746', flying: '#8fa8dd', psychic: '#f06fa0', bug: '#90c12c', rock: '#c7b78b', ghost: '#5269ac', dragon: '#0a6dc4', dark: '#5a5366', steel: '#5a8ea1', fairy: '#ec8fe6' } as Record<string, string>)[type] ?? '#888888'
4
+ export const label = (type: string) => type.charAt(0).toUpperCase() + type.slice(1)
@@ -0,0 +1,192 @@
1
+ // examples/pokedex/lib/loaders.ts
2
+ import { type LoaderCtx, type LoaderReq, notFound, type Verdict } from '@brust/core/routes'
3
+ import { team } from '../stores/team'
4
+ import { unified } from '../stores/unified'
5
+ import { ALL_TYPES, artwork, cap, fetchEvolution, fetchList, fetchPokemon, fetchSpecies, fetchTypeRelations, pad, STAT_LABEL, statBucket, TYPE_COLOR } from './pokeapi'
6
+ import { MAX_TEAM } from './team'
7
+ import { teamDb } from './team-db'
8
+ import type { BrowseData, DetailData, HomeData, TypeChartCellVM, TypeChartData, TypeChartRowVM } from './types'
9
+
10
+ const FEATURED = [{ id: 1, name: 'bulbasaur' }, { id: 4, name: 'charmander' }, { id: 7, name: 'squirtle' }, { id: 25, name: 'pikachu' }, { id: 39, name: 'jigglypuff' }, { id: 94, name: 'gengar' }, { id: 143, name: 'snorlax' }, { id: 150, name: 'mewtwo' }]
11
+ /** Layout loader: seeds the request's store scope, so every layout page paints the DB roster (D4/E7). */
12
+ export async function layoutLoader(): Promise<void> {
13
+ team.members.set(await teamDb().list())
14
+ team.max.set(MAX_TEAM)
15
+ }
16
+ const NAV = [{ href: '/', label: 'Home' }, { href: '/pokedex', label: 'Pokédex' }, { href: '/type-chart', label: 'Type chart' }, { href: '/unified', label: 'Unified' }]
17
+ const card = (p: { id: number; name: string }) => ({ id: p.id, name: p.name, displayName: cap(p.name), num: pad(p.id), artwork: artwork(p.id), detailHref: `/pokemon/${p.name}` })
18
+
19
+ /** Chrome every leaf returns: AppLayout reads it from the merged loader context (child keys win). */
20
+ const chrome = (req: LoaderReq, path: string, title: string, crumb: string) => ({
21
+ title, crumb,
22
+ mode: (req.cookies.mode === 'light' ? 'light' : 'dark') as 'light' | 'dark',
23
+ nav: NAV.map((n) => ({ ...n, active: n.href === path })),
24
+ })
25
+
26
+ export async function homeLoader({ req, path }: LoaderCtx): Promise<HomeData> {
27
+ return { ...chrome(req, path, 'PokéDex · built with brust', 'Home'), featured: FEATURED.map(card), types: ALL_TYPES }
28
+ }
29
+ export async function browseLoader({ req, path }: LoaderCtx): Promise<BrowseData> {
30
+ const q = (req.search.q ?? '').trim().toLowerCase()
31
+ const { results } = await fetchList(0, 151)
32
+ return { ...chrome(req, path, 'Pokédex · Browse', 'Pokédex'), q, items: results.filter((r) => !q || r.name.includes(q)).map(card) }
33
+ }
34
+ const BAR_COLOR: Record<string, string> = { hi: '#16a34a', mid: '#0ea5e9', low: '#f59e0b', min: '#ef4444' }
35
+ export async function detailLoader({ params, req, path }: LoaderCtx<{ name: string }>): Promise<DetailData | Verdict> {
36
+ const name = params.name ?? ''
37
+ const p = await fetchPokemon(name)
38
+ if (!p) return notFound(emptyDetail(req, path, name))
39
+ const species = await fetchSpecies(p.id)
40
+ const evo = await fetchEvolution(species.evolutionUrl)
41
+ const tint = TYPE_COLOR[p.types[0] ?? 'normal'] ?? '#888888'
42
+ return {
43
+ ...chrome(req, path, `${cap(p.name)} · PokéDex`, cap(p.name)),
44
+ notFound: false, name: p.name, id: p.id, displayName: cap(p.name), num: pad(p.id), artwork: p.artwork,
45
+ genus: species.genus, flavorText: species.flavorText,
46
+ height: p.height, weight: p.weight, // raw: DetailPage's job formats them (D4)
47
+ typeNames: p.types, // TypeBadge per row (D4)
48
+ heroBg: `linear-gradient(160deg, ${tint}33, transparent 70%)`,
49
+ stats: p.stats.map((s) => ({ label: STAT_LABEL[s.name] ?? s.name, base: s.base, barWidth: `${Math.min(100, Math.round((s.base / 200) * 100))}%`, barColor: BAR_COLOR[statBucket(s.base)] ?? '#0ea5e9' })),
50
+ statTotal: p.stats.reduce((a, s) => a + s.base, 0),
51
+ abilities: p.abilities.map((a) => ({ displayName: cap(a), initial: a.charAt(0).toUpperCase(), iconColor: tint })),
52
+ hasAbilities: p.abilities.length > 0,
53
+ evolution: evo.map((s, i) => ({ id: s.id, displayName: cap(s.name), num: pad(s.id), artwork: artwork(s.id), detailHref: `/pokemon/${s.name}`, levelLabel: s.minLevel != null ? `Lv ${s.minLevel}` : '', isFirst: i === 0, showLevel: i > 0 && s.minLevel != null, isCurrent: s.id === p.id })),
54
+ hasEvolution: evo.length > 1,
55
+ abilityCount: p.abilities.length,
56
+ }
57
+ }
58
+ function emptyDetail(req: LoaderReq, path: string, name: string): DetailData {
59
+ return { ...chrome(req, path, `${cap(name)} · PokéDex`, cap(name)), notFound: true, name, id: 0, displayName: cap(name), num: '', artwork: '', genus: '', flavorText: '', height: 0, weight: 0, typeNames: [], heroBg: '', stats: [], statTotal: 0, abilities: [], hasAbilities: false, evolution: [], hasEvolution: false, abilityCount: 0 }
60
+ }
61
+ const SHORT: Record<string, string> = {
62
+ normal: 'NOR',
63
+ fire: 'FIR',
64
+ water: 'WAT',
65
+ electric: 'ELE',
66
+ grass: 'GRA',
67
+ ice: 'ICE',
68
+ fighting: 'FIG',
69
+ poison: 'POI',
70
+ ground: 'GRO',
71
+ flying: 'FLY',
72
+ psychic: 'PSY',
73
+ bug: 'BUG',
74
+ rock: 'ROC',
75
+ ghost: 'GHO',
76
+ dragon: 'DRA',
77
+ dark: 'DAR',
78
+ steel: 'STE',
79
+ fairy: 'FAI',
80
+ }
81
+
82
+ // Effectiveness → static Tailwind utility class string. These are literals in a
83
+ // .ts file scanned by `@source`, so the scanner sees every class. The header /
84
+ // row-head / corner / data cells share a base sizing class.
85
+ // Effectiveness → static Tailwind utility class string. Every cell gets a solid
86
+ // fill so the `gap-px` over a slate gridline background reads as a continuous
87
+ // grid (the old design left 1× cells transparent → black voids you couldn't
88
+ // trace). `hover:` lifts a cell with an inset brand ring to pinpoint a matchup.
89
+ const CELL_BASE =
90
+ 'flex items-center justify-center text-xs font-bold tabular-nums aspect-square transition-colors hover:relative hover:z-10 hover:ring-2 hover:ring-inset hover:ring-brand-500'
91
+ const HEAD_BASE =
92
+ 'flex items-center justify-center text-[10px] font-bold uppercase tracking-tight text-white aspect-square'
93
+ // Fill hierarchy so the grid reads as FULL (every cell visibly distinct from
94
+ // the slate gridline showing through `gap-px`):
95
+ // light: card white < gridline slate-200, normal slate-50, 0 slate-800
96
+ // dark: card slate-900 < gridline slate-700, normal slate-800, 0 slate-950
97
+ // (normal must NOT equal the card colour or cells vanish; 0 must differ from
98
+ // normal or "no effect" looks like a blank.)
99
+ const CELL_CLASS: Record<string, string> = {
100
+ super: `${CELL_BASE} bg-emerald-500 text-white`,
101
+ weak: `${CELL_BASE} bg-rose-400 text-white dark:bg-rose-500`,
102
+ none: `${CELL_BASE} bg-slate-800 text-slate-100 dark:bg-slate-950 dark:text-slate-400`,
103
+ normal: `${CELL_BASE} bg-slate-50 text-slate-300 dark:bg-slate-800 dark:text-slate-600`,
104
+ }
105
+
106
+ function buildRows(relations: Record<string, number>[]): TypeChartRowVM[] {
107
+ const rows: TypeChartRowVM[] = []
108
+
109
+ const headerCells: TypeChartCellVM[] = [
110
+ {
111
+ id: '0-0',
112
+ className: `${HEAD_BASE} sticky left-0 top-0 z-20 text-[9px]`,
113
+ content: 'ATK/DEF',
114
+ title: 'Attacking / Defending',
115
+ bg: '#334155', // slate-700
116
+ },
117
+ ]
118
+ ALL_TYPES.forEach((def, j) => {
119
+ headerCells.push({
120
+ id: `0-${j + 1}`,
121
+ className: `${HEAD_BASE} sticky top-0 z-10`,
122
+ content: SHORT[def] ?? def.slice(0, 3).toUpperCase(),
123
+ title: cap(def),
124
+ bg: TYPE_COLOR[def] ?? '#888888',
125
+ })
126
+ })
127
+ rows.push({ id: '0', cells: headerCells })
128
+
129
+ ALL_TYPES.forEach((atk, i) => {
130
+ const rel = relations[i] ?? {}
131
+ const rowCells: TypeChartCellVM[] = [
132
+ {
133
+ id: `${i + 1}-0`,
134
+ className: `${HEAD_BASE} sticky left-0 z-10`,
135
+ content: SHORT[atk] ?? atk.slice(0, 3).toUpperCase(),
136
+ title: cap(atk),
137
+ bg: TYPE_COLOR[atk] ?? '#888888',
138
+ },
139
+ ]
140
+ ALL_TYPES.forEach((def, j) => {
141
+ const mult = rel[def]
142
+ const id = `${i + 1}-${j + 1}`
143
+ if (mult === 2)
144
+ rowCells.push({
145
+ id,
146
+ className: CELL_CLASS.super!,
147
+ content: '2',
148
+ title: `${cap(atk)} → ${cap(def)}: 2× (super effective)`,
149
+ bg: '',
150
+ })
151
+ else if (mult === 0.5)
152
+ rowCells.push({
153
+ id,
154
+ className: CELL_CLASS.weak!,
155
+ content: '½',
156
+ title: `${cap(atk)} → ${cap(def)}: ½× (not very effective)`,
157
+ bg: '',
158
+ })
159
+ else if (mult === 0)
160
+ rowCells.push({
161
+ id,
162
+ className: CELL_CLASS.none!,
163
+ content: '0',
164
+ title: `${cap(atk)} → ${cap(def)}: 0× (no effect)`,
165
+ bg: '',
166
+ })
167
+ else
168
+ rowCells.push({
169
+ id,
170
+ className: CELL_CLASS.normal!,
171
+ content: '',
172
+ title: `${cap(atk)} → ${cap(def)}: 1×`,
173
+ bg: '',
174
+ })
175
+ })
176
+ rows.push({ id: String(i + 1), cells: rowCells })
177
+ })
178
+
179
+ return rows
180
+ }
181
+
182
+ export async function typeChartLoader({ req, path }: LoaderCtx): Promise<TypeChartData> {
183
+ const relations = await Promise.all(ALL_TYPES.map((t) => fetchTypeRelations(t)))
184
+ const rows: TypeChartRowVM[] = buildRows(relations)
185
+ return { ...chrome(req, path, 'PokéDex · type chart', 'Type chart'), rows }
186
+ }
187
+
188
+ export async function unifiedLoader({ req, path }: LoaderCtx) {
189
+ const start = Math.max(0, Math.min(1_000_000, Math.trunc(Number(req.search.start) || 0)))
190
+ unified.count.set(start)
191
+ return chrome(req, path, 'Unified store · PokéDex', 'Unified')
192
+ }
@@ -0,0 +1,43 @@
1
+ // examples/pokedex/lib/pokeapi.ts — the 0.1.x helper surface, served from data/pokedex.json (no network).
2
+ import snap from '../data/pokedex.json'
3
+ import type { TeamMember } from './types'
4
+
5
+ export interface RawPokemon { id: number; name: string; types: string[]; stats: { name: string; base: number }[]; height: number; weight: number; abilities: string[]; artwork: string }
6
+ export interface RawSpecies { flavorText: string; genus: string; evolutionUrl: string }
7
+ export interface RawEvolutionStage { id: number; name: string; minLevel: number | null }
8
+
9
+ const byName = new Map(snap.pokemon.map((p) => [p.name, p]))
10
+ const byId = new Map(snap.pokemon.map((p) => [p.id, p]))
11
+
12
+ export const artwork = (id: number): string => byId.get(id)?.artwork ?? `https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/${id}.png`
13
+ export const cap = (s: string): string => (s ? s.charAt(0).toUpperCase() + s.slice(1).replace(/-/g, ' ') : s)
14
+ export const pad = (n: number): string => `#${String(n).padStart(4, '0')}`
15
+ export const TYPE_COLOR: Record<string, string> = { normal: '#9099a1', fire: '#ef7444', water: '#4d90d5', grass: '#63bb5b', electric: '#f5c84b', ice: '#74cec0', fighting: '#ce4069', poison: '#ab6ac8', ground: '#d97746', flying: '#8fa8dd', psychic: '#f06fa0', bug: '#90c12c', rock: '#c7b78b', ghost: '#5269ac', dragon: '#0a6dc4', dark: '#5a5366', steel: '#5a8ea1', fairy: '#ec8fe6' }
16
+ export const STAT_LABEL: Record<string, string> = { hp: 'HP', attack: 'Atk', defense: 'Def', 'special-attack': 'Sp.Atk', 'special-defense': 'Sp.Def', speed: 'Spd' }
17
+ export const statBucket = (base: number): string => (base >= 100 ? 'hi' : base >= 60 ? 'mid' : base >= 35 ? 'low' : 'min')
18
+ export const ALL_TYPES = ['normal','fire','water','electric','grass','ice','fighting','poison','ground','flying','psychic','bug','rock','ghost','dragon','dark','steel','fairy']
19
+
20
+ export async function fetchList(offset: number, limit: number) {
21
+ return { results: snap.pokemon.slice(offset, offset + limit).map((p) => ({ id: p.id, name: p.name })), total: snap.pokemon.length }
22
+ }
23
+ export async function fetchPokemon(name: string): Promise<RawPokemon | null> {
24
+ const p = byName.get(name)
25
+ return p ? { id: p.id, name: p.name, types: p.types, stats: p.stats, height: p.height, weight: p.weight, abilities: p.abilities, artwork: p.artwork } : null
26
+ }
27
+ /** `evolutionUrl` is the Pokémon id as a string: the key `fetchEvolution` reads (0.1.x carried a URL). */
28
+ export async function fetchSpecies(id: number): Promise<RawSpecies> {
29
+ const p = byId.get(id)
30
+ return p ? { flavorText: p.flavorText, genus: p.genus, evolutionUrl: String(id) } : { flavorText: '', genus: '', evolutionUrl: '' }
31
+ }
32
+ export async function fetchEvolution(key: string): Promise<RawEvolutionStage[]> {
33
+ return key ? (byId.get(Number(key))?.evolution ?? []) : []
34
+ }
35
+ export async function fetchTypeRelations(type: string): Promise<Record<string, number>> {
36
+ return (snap.types as Record<string, Record<string, number>>)[type] ?? {}
37
+ }
38
+
39
+ /** The roster member for a Pokémon id, built from the offline snapshot (so a client cannot forge display data). */
40
+ export function pokemonById(id: number): TeamMember | null {
41
+ const p = byId.get(id)
42
+ return p ? { id: p.id, name: p.name, displayName: cap(p.name), num: pad(p.id), types: p.types, artwork: p.artwork } : null
43
+ }
@@ -0,0 +1,26 @@
1
+ // examples/pokedex/lib/team-client.ts — the one place UI code mutates the team; every reply is written to the store.
2
+ import { team } from '../stores/team'
3
+ import { api } from './api'
4
+ import type { TeamMember } from './types'
5
+
6
+ type Reply = { data: { team: TeamMember[]; max: number } | null; error: { status: unknown } | null }
7
+ const apply = (r: Reply): void => {
8
+ if (r.data) {
9
+ team.members.set(r.data.team)
10
+ team.max.set(r.data.max)
11
+ }
12
+ }
13
+ export async function refreshTeam(): Promise<void> {
14
+ apply(await api.team.get())
15
+ }
16
+ export async function removeMember(id: number): Promise<void> {
17
+ apply(await api.team({ id }).delete())
18
+ }
19
+ export async function addMember(id: number): Promise<void> {
20
+ const r = await api.team.post({ id })
21
+ if (r.error?.status === 409) return refreshTeam() // someone else filled the team: show the truth
22
+ apply(r)
23
+ }
24
+ export async function toggleMember(id: number, inTeam: boolean): Promise<void> {
25
+ return inTeam ? removeMember(id) : addMember(id)
26
+ }
@@ -0,0 +1,47 @@
1
+ // examples/pokedex/lib/team-db.d1.ts — the TeamDb on Cloudflare D1. `brust.toml` [cloudflare.alias] swaps lib/team-db.ts for
2
+ // this file in `brust build --target cloudflare`. The binding is read per call (never at import: the build smoke-imports the
3
+ // bundle with an empty env) and nothing is cached across requests.
4
+ import { env } from 'cloudflare:workers'
5
+ import { MAX_TEAM } from './team'
6
+ import type { AddResult, TeamDb } from './team-db'
7
+ import type { TeamMember } from './types'
8
+
9
+ export type { AddResult, TeamDb }
10
+
11
+ interface Row { id: number; name: string; display_name: string; num: string; types: string; artwork: string }
12
+
13
+ const LIST = 'SELECT id, name, display_name, num, types, artwork FROM team ORDER BY added_at, rowid'
14
+ // ONE statement decides the cap: the count check and the insert cannot be split across awaits on D1 (no transaction handle),
15
+ // so the insert is conditional. added_at is a clock that never repeats, as in the sqlite twin.
16
+ const INSERT =
17
+ 'INSERT INTO team (id, name, display_name, num, types, artwork, added_at) ' +
18
+ 'SELECT ?1, ?2, ?3, ?4, ?5, ?6, MAX(?7, (SELECT COALESCE(MAX(added_at), 0) + 1 FROM team)) ' +
19
+ 'WHERE (SELECT COUNT(*) FROM team) < ?8 ON CONFLICT(id) DO NOTHING'
20
+ const HAS = 'SELECT 1 AS one FROM team WHERE id = ?1'
21
+
22
+ export function openTeamDb(d1: D1Database): TeamDb {
23
+ return {
24
+ async list() {
25
+ const { results } = await d1.prepare(LIST).all<Row>()
26
+ return results.map((r) => ({ id: r.id, name: r.name, displayName: r.display_name, types: JSON.parse(r.types) as string[], artwork: r.artwork, num: r.num }))
27
+ },
28
+ async add(m: TeamMember): Promise<AddResult> {
29
+ // One batch = one transaction: added (1 row) | present (the id exists, even when full) | full.
30
+ const [ins, has] = await d1.batch([
31
+ d1.prepare(INSERT).bind(m.id, m.name, m.displayName, m.num, JSON.stringify(m.types), m.artwork, Date.now(), MAX_TEAM),
32
+ d1.prepare(HAS).bind(m.id),
33
+ ])
34
+ if (ins!.meta.changes === 1) return 'added'
35
+ return has!.results.length > 0 ? 'present' : 'full'
36
+ },
37
+ async remove(id: number) {
38
+ return (await d1.prepare('DELETE FROM team WHERE id = ?1').bind(id).run()).meta.changes > 0
39
+ },
40
+ }
41
+ }
42
+
43
+ export function teamDb(): TeamDb {
44
+ const d1 = env.TEAM_DB
45
+ if (!d1) throw new Error('TEAM_DB D1 binding missing: add it to wrangler.jsonc (see README)')
46
+ return openTeamDb(d1)
47
+ }
@@ -0,0 +1,49 @@
1
+ // examples/pokedex/lib/team-db.sqlite.ts — the native TeamDb (server-only via brust.toml [build] server_only = lib/team-db).
2
+ // The team is ONE shared roster, as in 0.1.x, but persisted: every worker thread opens the same file (WAL), so workers never
3
+ // diverge (M3-F E7). Methods return Promises so the interface matches D1 (lib/team-db.d1.ts); the work itself stays synchronous.
4
+ import { Database } from 'bun:sqlite'
5
+ import { mkdirSync } from 'node:fs'
6
+ import { dirname, join } from 'node:path'
7
+ import { MAX_TEAM } from './team'
8
+ import type { AddResult, TeamDb } from './team-db'
9
+ import type { TeamMember } from './types'
10
+
11
+ export type SqliteTeamDb = TeamDb & { close(): void }
12
+
13
+ export const TEAM_DDL =
14
+ 'CREATE TABLE IF NOT EXISTS team (id INTEGER PRIMARY KEY, name TEXT NOT NULL, display_name TEXT NOT NULL, num TEXT NOT NULL, types TEXT NOT NULL, artwork TEXT NOT NULL, added_at INTEGER NOT NULL)'
15
+
16
+ interface Row { id: number; name: string; display_name: string; num: string; types: string; artwork: string }
17
+
18
+ export function openTeamDb(file: string): SqliteTeamDb {
19
+ if (file !== ':memory:') mkdirSync(dirname(file), { recursive: true })
20
+ const db = new Database(file, { create: true })
21
+ db.exec('PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000')
22
+ db.exec(TEAM_DDL)
23
+ const listQ = db.query<Row, []>('SELECT id, name, display_name, num, types, artwork FROM team ORDER BY added_at, rowid')
24
+ const hasQ = db.query<{ one: number }, [number]>('SELECT 1 AS one FROM team WHERE id = ?')
25
+ const countQ = db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM team')
26
+ const insertQ = db.query('INSERT INTO team (id, name, display_name, num, types, artwork, added_at) VALUES (?, ?, ?, ?, ?, ?, MAX(?, (SELECT COALESCE(MAX(added_at), 0) + 1 FROM team)))')
27
+ const deleteQ = db.query('DELETE FROM team WHERE id = ?')
28
+ // added_at is a clock that never repeats (MAX(now, last + 1)): rowid IS the Pokémon id, so it cannot break same-ms ties.
29
+ // count check + insert under ONE write lock (.immediate): two workers adding at five members cannot both succeed.
30
+ const addTx = db.transaction((m: TeamMember): AddResult => {
31
+ if (hasQ.get(m.id)) return 'present'
32
+ if ((countQ.get()?.n ?? 0) >= MAX_TEAM) return 'full'
33
+ insertQ.run(m.id, m.name, m.displayName, m.num, JSON.stringify(m.types), m.artwork, Date.now())
34
+ return 'added'
35
+ })
36
+ return {
37
+ list: async () => listQ.all().map((r) => ({ id: r.id, name: r.name, displayName: r.display_name, types: JSON.parse(r.types) as string[], artwork: r.artwork, num: r.num })),
38
+ add: async (m) => addTx.immediate(m),
39
+ remove: async (id) => deleteQ.run(id).changes > 0,
40
+ close: () => db.close(),
41
+ }
42
+ }
43
+
44
+ let shared: SqliteTeamDb | null = null
45
+ export const teamDbPath = (): string => process.env.POKEDEX_TEAM_DB || join(process.cwd(), 'data/team.sqlite')
46
+ /** Opened lazily on first use: `brust build` imports routes.tsx, and building must not create the file. */
47
+ export function teamDb(): SqliteTeamDb {
48
+ return (shared ??= openTeamDb(teamDbPath()))
49
+ }
@@ -0,0 +1,13 @@
1
+ // examples/pokedex/lib/team-db.ts — the TeamDb interface (async: D1 has no sync API) and the native default.
2
+ // `brust build --target cloudflare` swaps this whole module for lib/team-db.d1.ts via brust.toml [cloudflare.alias],
3
+ // so app imports (`./lib/team-db`) never change. Server-only (brust.toml [build] server_only = lib/team-db).
4
+ import type { TeamMember } from './types'
5
+
6
+ export type AddResult = 'added' | 'present' | 'full'
7
+ export interface TeamDb {
8
+ list(): Promise<TeamMember[]>
9
+ add(m: TeamMember): Promise<AddResult>
10
+ remove(id: number): Promise<boolean>
11
+ }
12
+
13
+ export { openTeamDb, type SqliteTeamDb, teamDb, teamDbPath } from './team-db.sqlite'
@@ -0,0 +1,2 @@
1
+ // examples/pokedex/lib/team.ts — isomorphic: stores, the client and the server all import it.
2
+ export const MAX_TEAM = 6
@@ -0,0 +1,126 @@
1
+ // Domain + view-model types for the PokéDex example.
2
+ //
3
+ // Loaders return plain data; components are ordinary React functions.
4
+
5
+ /** Team roster member. */
6
+ export interface TeamMember {
7
+ id: number
8
+ name: string
9
+ displayName: string
10
+ types: string[]
11
+ artwork: string
12
+ num: string
13
+ }
14
+
15
+ /** Chrome view-model the router-level AppLayout reads from the MERGED loader
16
+ * context. Each leaf loader returns these fields; the chain-loader merge folds
17
+ * them into the one flat jinja context AppLayout's template reads. */
18
+ export interface NavItem {
19
+ href: string
20
+ label: string
21
+ active: boolean
22
+ }
23
+
24
+ export interface ChromeData {
25
+ title: string
26
+ crumb: string
27
+ mode: 'dark' | 'light'
28
+ nav: NavItem[]
29
+ }
30
+
31
+ /** A single dex grid cell — derived from the list endpoint alone (no detail
32
+ * fetch). */
33
+ export interface DexCard {
34
+ id: number
35
+ name: string
36
+ displayName: string // "Bulbasaur"
37
+ num: string // "#0001"
38
+ artwork: string // CDN URL derived from id
39
+ detailHref: string // "/pokemon/bulbasaur"
40
+ }
41
+
42
+ /** One base-stat bar row on the detail page. All formatting precomputed. */
43
+ export interface StatBarVM {
44
+ label: string // "HP" / "Atk" …
45
+ base: number // raw base value
46
+ barWidth: string // "62%" — fed into style={{ width }}
47
+ barColor: string // hex per bucket — fed into style={{ background }}
48
+ }
49
+
50
+ /** One ability chip on the detail page. */
51
+ export interface AbilityVM {
52
+ displayName: string // "Overgrow"
53
+ initial: string // "O"
54
+ iconColor: string // hex tint — style={{ background }}
55
+ }
56
+
57
+ /** One stage of the evolution chain. */
58
+ export interface EvoStageVM {
59
+ id: number
60
+ displayName: string
61
+ num: string // "#0001"
62
+ artwork: string
63
+ detailHref: string // "/pokemon/ivysaur"
64
+ levelLabel: string // "Lv 16" or ""
65
+ isFirst: boolean
66
+ showLevel: boolean
67
+ isCurrent: boolean
68
+ }
69
+
70
+ /** Home landing page data — curated featured strip + type names + chrome. */
71
+ export interface HomeData extends ChromeData {
72
+ featured: DexCard[]
73
+ types: string[]
74
+ }
75
+
76
+ /** Browse (dex grid) page data. `items` feeds BOTH the SSR `{% for %}` and the
77
+ * DexFilter directive's `x-props` (json_attr-serialized) — one array, no separate
78
+ * pre-stringified JSON. */
79
+ export interface BrowseData extends ChromeData {
80
+ items: DexCard[]
81
+ q: string
82
+ }
83
+
84
+ /** Detail page data. Every formatted string / className / inline-style value /
85
+ * x-props JSON is precomputed here so the native template only interpolates. */
86
+ export interface DetailData extends ChromeData {
87
+ notFound: boolean
88
+ name: string
89
+ id: number
90
+ displayName: string
91
+ num: string // "#0001"
92
+ artwork: string
93
+ genus: string // "Seed Pokémon"
94
+ flavorText: string
95
+ abilityCount: number
96
+ heroBg: string // CSS gradient string built in the loader from the type tint
97
+ stats: StatBarVM[]
98
+ statTotal: number
99
+ abilities: AbilityVM[]
100
+ hasAbilities: boolean
101
+ evolution: EvoStageVM[]
102
+ hasEvolution: boolean
103
+ height: number
104
+ weight: number
105
+ typeNames: string[]
106
+ }
107
+
108
+ /** One cell of the type chart. */
109
+ export interface TypeChartCellVM {
110
+ id: string // stable key "row-col"
111
+ className: string // static Tailwind utility string the loader picks per effectiveness
112
+ content: string // "2", "½", "0", a type short-code, or ""
113
+ title: string // tooltip
114
+ bg: string // inline background — type hex for header/row-head cells, '' for data cells
115
+ }
116
+
117
+ /** One row (header or attack) of the type chart — nested cells. */
118
+ export interface TypeChartRowVM {
119
+ id: string
120
+ cells: TypeChartCellVM[]
121
+ }
122
+
123
+ /** Type chart page data — nested rows[].cells[] grid, rendered with nested .map(). */
124
+ export interface TypeChartData extends ChromeData {
125
+ rows: TypeChartRowVM[]
126
+ }
@@ -0,0 +1,2 @@
1
+ -- pokedex team roster (lib/team-db.sqlite.ts TEAM_DDL; the same table on D1)
2
+ CREATE TABLE IF NOT EXISTS team (id INTEGER PRIMARY KEY, name TEXT NOT NULL, display_name TEXT NOT NULL, num TEXT NOT NULL, types TEXT NOT NULL, artwork TEXT NOT NULL, added_at INTEGER NOT NULL);
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "__PROJECT_NAME__",
3
+ "version": "0.0.1",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "brust dev routes.tsx",
8
+ "build": "brust build routes.tsx",
9
+ "dev:cf": "wrangler d1 migrations apply TEAM_DB --local --persist-to .wrangler/state && brust dev routes.tsx --target cloudflare",
10
+ "deploy:cf": "brust build routes.tsx --target cloudflare && wrangler deploy -c dist/cf/wrangler.jsonc",
11
+ "cf:migrate:local": "wrangler d1 migrations apply TEAM_DB --local --persist-to .wrangler/state",
12
+ "cf:migrate:remote": "wrangler d1 migrations apply TEAM_DB --remote",
13
+ "start": "brust start",
14
+ "snapshot": "bun scripts/snapshot.ts",
15
+ "typecheck": "bun check"
16
+ },
17
+ "dependencies": {
18
+ "@brust/core": __BRUST_DEP__,
19
+ "@elysia/eden": "2.0.0-beta.6",
20
+ "elysia": "2.0.0-beta.30",
21
+ "react": "^19.2.0",
22
+ "react-dom": "^19.2.0"
23
+ },
24
+ "devDependencies": {
25
+ "@types/bun": "^1.4.0",
26
+ "@types/react": "^19.2.0",
27
+ "typescript": "^7.0.2",
28
+ "wrangler": "^4.149.0"
29
+ }
30
+ }
@@ -0,0 +1,15 @@
1
+ import DexFilter from '../components/DexFilter'
2
+ import type { BrowseData } from '../lib/types'
3
+
4
+ export default function BrowsePage({ items, q }: BrowseData) {
5
+ return (
6
+ <section className="py-2">
7
+ <div className="mb-6">
8
+ <h1 className="text-3xl font-extrabold tracking-tight text-slate-900 dark:text-white">Pokédex</h1>
9
+ <p className="mt-2 max-w-2xl text-slate-500 dark:text-slate-400">The original 151. Search by name and toggle the sort order.</p>
10
+ {q && <p className="mt-2 text-sm text-slate-500">Results for “{q}”</p>}
11
+ </div>
12
+ <DexFilter items={items} />
13
+ </section>
14
+ )
15
+ }