brustjs 0.1.16-alpha → 0.1.17-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 +40 -0
- package/example/pokedex/app.css +1686 -0
- package/example/pokedex/components/AddToTeamButton.tsx +93 -0
- package/example/pokedex/components/PageLayout.tsx +90 -0
- package/example/pokedex/components/TeamBuilder.tsx +214 -0
- package/example/pokedex/components/team-bus.ts +25 -0
- package/example/pokedex/index.ts +12 -0
- package/example/pokedex/lib/loaders.ts +290 -0
- package/example/pokedex/lib/pokeapi.ts +174 -0
- package/example/pokedex/lib/team-store.ts +28 -0
- package/example/pokedex/lib/types.ts +137 -0
- package/example/pokedex/pages/DetailPage.tsx +167 -0
- package/example/pokedex/pages/ListPage.tsx +83 -0
- package/example/pokedex/pages/TypeChart.tsx +51 -0
- package/example/pokedex/routes.tsx +21 -0
- package/package.json +14 -8
- package/runtime/cli/help.ts +6 -1
- package/runtime/cli/new.ts +127 -41
- package/runtime/cli/templates.ts +139 -0
- package/runtime/index.js +52 -52
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// Route loaders. Each runs in a Bun worker (full JS), fetches from PokeAPI, and
|
|
2
|
+
// returns a fully render-ready view-model. Templates now do conditionals (S11),
|
|
3
|
+
// `style={{…}}` objects (S1), and dynamic head props (S8); the loader still
|
|
4
|
+
// precomputes formatted strings, booleans for conditionals, and multi-property
|
|
5
|
+
// style strings (no template-literals / arithmetic / helper calls in templates).
|
|
6
|
+
// See ../FRAMEWORK-GAPS.md.
|
|
7
|
+
|
|
8
|
+
import { z } from 'zod'
|
|
9
|
+
import { type BrustRequest, type NativeVerdict, notFound } from 'brustjs/routes'
|
|
10
|
+
import {
|
|
11
|
+
ALL_TYPES,
|
|
12
|
+
artwork,
|
|
13
|
+
cap,
|
|
14
|
+
fetchEvolution,
|
|
15
|
+
fetchList,
|
|
16
|
+
fetchPokemon,
|
|
17
|
+
fetchSpecies,
|
|
18
|
+
fetchTypeRelations,
|
|
19
|
+
pad,
|
|
20
|
+
STAT_LABEL,
|
|
21
|
+
statBucket,
|
|
22
|
+
TYPE_COLOR,
|
|
23
|
+
} from './pokeapi'
|
|
24
|
+
import { teamStore } from './team-store'
|
|
25
|
+
import type { DetailData, ListData, TypeBadgeVM, TypeChartData } from './types'
|
|
26
|
+
|
|
27
|
+
/** Loader context shape — `loader: ({ params, path, req }) => data`. */
|
|
28
|
+
interface LoaderCtx {
|
|
29
|
+
params: Record<string, string>
|
|
30
|
+
path: string
|
|
31
|
+
req: BrustRequest
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const PAGE = 20
|
|
35
|
+
const NATIONAL_MAX = 1302
|
|
36
|
+
|
|
37
|
+
// GAP S1: query validation is not symmetric with actions. Actions bind a schema
|
|
38
|
+
// in the descriptor and hand the handler a typed+validated `query`; loaders get
|
|
39
|
+
// `req.search` as a raw Record<string,string> and must validate by hand here.
|
|
40
|
+
const ListQuery = z.object({
|
|
41
|
+
offset: z.coerce.number().int().min(0).max(NATIONAL_MAX).catch(0),
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const fmt = (n: number) => n.toLocaleString('en-US')
|
|
45
|
+
const typeBadge = (t: string): TypeBadgeVM => ({
|
|
46
|
+
label: cap(t),
|
|
47
|
+
className: `dex-type dex-type--${t}`,
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
export async function listLoader({ req }: LoaderCtx): Promise<ListData> {
|
|
51
|
+
const offset = ListQuery.parse(req?.search ?? {}).offset
|
|
52
|
+
const { results, total } = await fetchList(offset, PAGE)
|
|
53
|
+
|
|
54
|
+
const items = results.map((r) => ({
|
|
55
|
+
id: r.id,
|
|
56
|
+
name: r.name,
|
|
57
|
+
displayName: cap(r.name),
|
|
58
|
+
num: pad(r.id),
|
|
59
|
+
artwork: artwork(r.id),
|
|
60
|
+
detailHref: `/pokemon/${r.name}`,
|
|
61
|
+
}))
|
|
62
|
+
|
|
63
|
+
const lastPage = Math.ceil(total / PAGE)
|
|
64
|
+
const pageNo = Math.floor(offset / PAGE) + 1
|
|
65
|
+
const hasPrev = offset > 0
|
|
66
|
+
const hasNext = offset + PAGE < total
|
|
67
|
+
const prevOffset = Math.max(0, offset - PAGE)
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
items,
|
|
71
|
+
total,
|
|
72
|
+
totalLabel: fmt(total),
|
|
73
|
+
offset,
|
|
74
|
+
offsetLabel: String(offset),
|
|
75
|
+
showingLabel: `${fmt(offset + 1)}–${fmt(Math.min(offset + PAGE, total))} of ${fmt(total)}`,
|
|
76
|
+
pageLabel: `${pageNo} / ${lastPage}`,
|
|
77
|
+
// Real conditionals now exist in native routes (GAPS S11 closed): the
|
|
78
|
+
// template branches on these booleans with `{flags.hasPrev ? <a/> : <span/>}`
|
|
79
|
+
// instead of always-rendering a loader-computed hide-class.
|
|
80
|
+
hasPrev,
|
|
81
|
+
hasNext,
|
|
82
|
+
prevHref: hasPrev ? (prevOffset > 0 ? `/?offset=${prevOffset}` : '/') : '#',
|
|
83
|
+
nextHref: hasNext ? `/?offset=${offset + PAGE}` : '#',
|
|
84
|
+
teamProps: { teamInitial: teamStore.list() },
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function detailLoader({ params }: LoaderCtx): Promise<DetailData | NativeVerdict> {
|
|
89
|
+
const name = params?.name ?? ''
|
|
90
|
+
const empty = emptyDetail(name)
|
|
91
|
+
|
|
92
|
+
const p = await fetchPokemon(name)
|
|
93
|
+
// GAP S9 (FIXED): native loaders can now `return notFound(data)` to render the
|
|
94
|
+
// route's OWN template with HTTP 404. The `notFound: true` flag on `empty`
|
|
95
|
+
// still drives the template's 404-block branch (S11); the sentinel sets the
|
|
96
|
+
// HTTP STATUS (404 instead of 200). They are complementary. See GAPS S9.
|
|
97
|
+
if (!p) return notFound(empty)
|
|
98
|
+
|
|
99
|
+
const species = await fetchSpecies(p.id)
|
|
100
|
+
// GAP (native↔streaming): a native route renders in Rust with NO React tree,
|
|
101
|
+
// so <Suspense> streaming is impossible. The evolution chain — the slow fetch
|
|
102
|
+
// the design wanted to stream — is therefore loaded BLOCKING here in the
|
|
103
|
+
// loader. See GAPS S3.
|
|
104
|
+
const rawEvo = await fetchEvolution(species.evolutionUrl)
|
|
105
|
+
|
|
106
|
+
const primary = p.types[0] ?? 'normal'
|
|
107
|
+
const tint = TYPE_COLOR[primary] ?? 'var(--primary-500)'
|
|
108
|
+
|
|
109
|
+
const stats = p.stats.map((s) => {
|
|
110
|
+
const pct = Math.min(100, Math.round((s.base / 200) * 100))
|
|
111
|
+
return {
|
|
112
|
+
label: STAT_LABEL[s.name] ?? s.name,
|
|
113
|
+
base: s.base,
|
|
114
|
+
// Bare percent — the template builds the declaration via the S1 style
|
|
115
|
+
// object: `style={{ width: st.barWidth }}` → `width:62%`.
|
|
116
|
+
barWidth: `${pct}%`,
|
|
117
|
+
barClassName: `dex-statbar__fill dex-statbar__fill--${statBucket(s.base)}`,
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
const abilities = p.abilities.map((a) => ({
|
|
122
|
+
displayName: cap(a),
|
|
123
|
+
initial: a.charAt(0).toUpperCase(),
|
|
124
|
+
// Bare color value — template does `style={{ background: a.iconColor }}`.
|
|
125
|
+
iconColor: tint,
|
|
126
|
+
}))
|
|
127
|
+
|
|
128
|
+
const evolution = rawEvo.map((s, i) => ({
|
|
129
|
+
id: s.id,
|
|
130
|
+
name: s.name,
|
|
131
|
+
displayName: cap(s.name),
|
|
132
|
+
num: pad(s.id),
|
|
133
|
+
artwork: artwork(s.id),
|
|
134
|
+
detailHref: `/pokemon/${s.name}`,
|
|
135
|
+
levelLabel: s.minLevel != null ? `Lv ${s.minLevel}` : '',
|
|
136
|
+
// Real per-item conditionals now work in native routes (S11): the template
|
|
137
|
+
// tests these booleans instead of toggling a precomputed `dex-hide` class.
|
|
138
|
+
isFirst: i === 0,
|
|
139
|
+
showLevel: i > 0 && s.minLevel != null,
|
|
140
|
+
cardClassName: s.id === p.id ? 'dex-evo__card dex-evo__card--current' : 'dex-evo__card',
|
|
141
|
+
}))
|
|
142
|
+
|
|
143
|
+
const hasEvolution = evolution.length > 1
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
notFound: false,
|
|
147
|
+
pageTitle: `${cap(p.name)} · PokéDex`,
|
|
148
|
+
name: p.name,
|
|
149
|
+
id: p.id,
|
|
150
|
+
displayName: cap(p.name),
|
|
151
|
+
num: pad(p.id),
|
|
152
|
+
artwork: p.artwork,
|
|
153
|
+
genus: species.genus,
|
|
154
|
+
flavorText: species.flavorText,
|
|
155
|
+
heightLabel: `${(p.height / 10).toFixed(1)} m`,
|
|
156
|
+
weightLabel: `${(p.weight / 10).toFixed(1)} kg`,
|
|
157
|
+
abilityCount: p.abilities.length,
|
|
158
|
+
heroBg: `linear-gradient(160deg, color-mix(in srgb, ${tint} 22%, var(--surface-raised)), var(--surface-raised) 70%)`,
|
|
159
|
+
types: p.types.map(typeBadge),
|
|
160
|
+
stats,
|
|
161
|
+
statTotal: p.stats.reduce((a, s) => a + s.base, 0),
|
|
162
|
+
abilities,
|
|
163
|
+
hasAbilities: abilities.length > 0,
|
|
164
|
+
evolution,
|
|
165
|
+
hasEvolution,
|
|
166
|
+
addProps: {
|
|
167
|
+
id: p.id,
|
|
168
|
+
name: p.name,
|
|
169
|
+
displayName: cap(p.name),
|
|
170
|
+
num: pad(p.id),
|
|
171
|
+
types: p.types,
|
|
172
|
+
artwork: p.artwork,
|
|
173
|
+
},
|
|
174
|
+
teamProps: { teamInitial: teamStore.list() },
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function emptyDetail(name: string): DetailData {
|
|
179
|
+
return {
|
|
180
|
+
notFound: true,
|
|
181
|
+
pageTitle: `${cap(name)} · PokéDex`,
|
|
182
|
+
name,
|
|
183
|
+
id: 0,
|
|
184
|
+
displayName: cap(name),
|
|
185
|
+
num: '',
|
|
186
|
+
artwork: '',
|
|
187
|
+
genus: '',
|
|
188
|
+
flavorText: '',
|
|
189
|
+
heightLabel: '',
|
|
190
|
+
weightLabel: '',
|
|
191
|
+
abilityCount: 0,
|
|
192
|
+
heroBg: '',
|
|
193
|
+
types: [],
|
|
194
|
+
stats: [],
|
|
195
|
+
statTotal: 0,
|
|
196
|
+
abilities: [],
|
|
197
|
+
hasAbilities: false,
|
|
198
|
+
evolution: [],
|
|
199
|
+
hasEvolution: false,
|
|
200
|
+
addProps: { id: 0, name, displayName: cap(name), num: '', types: [], artwork: '' },
|
|
201
|
+
teamProps: { teamInitial: teamStore.list() },
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const SHORT: Record<string, string> = {
|
|
206
|
+
normal: 'NOR',
|
|
207
|
+
fire: 'FIR',
|
|
208
|
+
water: 'WAT',
|
|
209
|
+
electric: 'ELE',
|
|
210
|
+
grass: 'GRA',
|
|
211
|
+
ice: 'ICE',
|
|
212
|
+
fighting: 'FIG',
|
|
213
|
+
poison: 'POI',
|
|
214
|
+
ground: 'GRO',
|
|
215
|
+
flying: 'FLY',
|
|
216
|
+
psychic: 'PSY',
|
|
217
|
+
bug: 'BUG',
|
|
218
|
+
rock: 'ROC',
|
|
219
|
+
ghost: 'GHO',
|
|
220
|
+
dragon: 'DRA',
|
|
221
|
+
dark: 'DAR',
|
|
222
|
+
steel: 'STE',
|
|
223
|
+
fairy: 'FAI',
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export async function typeChartLoader(): Promise<TypeChartData> {
|
|
227
|
+
// GAP S2: no loader-level batch/parallel helper or request-scoped cache — we
|
|
228
|
+
// fan out 18 fetches by hand with Promise.all (and there is no dedupe).
|
|
229
|
+
const relations = await Promise.all(ALL_TYPES.map((t) => fetchTypeRelations(t)))
|
|
230
|
+
|
|
231
|
+
// Flatten the 19×19 grid (1 header row/col + 18×18 matrix) into a single
|
|
232
|
+
// row-major array so the native template renders it with ONE `.map()`.
|
|
233
|
+
const cells: Array<Omit<TypeChartData['cells'][number], 'id'>> = []
|
|
234
|
+
|
|
235
|
+
// Header row: corner + 18 defending-type column heads.
|
|
236
|
+
cells.push({
|
|
237
|
+
className: 'dex-tc__corner',
|
|
238
|
+
content: 'ATK \ DEF',
|
|
239
|
+
title: 'Attacking \ Defending',
|
|
240
|
+
})
|
|
241
|
+
for (const def of ALL_TYPES) {
|
|
242
|
+
cells.push({
|
|
243
|
+
className: `dex-tc__colhead dex-tc__colhead--${def}`,
|
|
244
|
+
content: SHORT[def] ?? def.slice(0, 3).toUpperCase(),
|
|
245
|
+
title: cap(def),
|
|
246
|
+
})
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// One row per attacking type: row head + 18 effectiveness cells.
|
|
250
|
+
ALL_TYPES.forEach((atk, i) => {
|
|
251
|
+
const rel = relations[i]!
|
|
252
|
+
cells.push({
|
|
253
|
+
className: `dex-tc__rowhead dex-tc__rowhead--${atk}`,
|
|
254
|
+
content: SHORT[atk] ?? atk.slice(0, 3).toUpperCase(),
|
|
255
|
+
title: cap(atk),
|
|
256
|
+
})
|
|
257
|
+
for (const def of ALL_TYPES) {
|
|
258
|
+
const mult = rel[def]
|
|
259
|
+
if (mult === 2)
|
|
260
|
+
cells.push({
|
|
261
|
+
className: 'dex-tc__cell dex-tc__cell--super',
|
|
262
|
+
content: '2',
|
|
263
|
+
title: `${cap(atk)} → ${cap(def)}: 2× (super effective)`,
|
|
264
|
+
})
|
|
265
|
+
else if (mult === 0.5)
|
|
266
|
+
cells.push({
|
|
267
|
+
className: 'dex-tc__cell dex-tc__cell--weak',
|
|
268
|
+
content: '½',
|
|
269
|
+
title: `${cap(atk)} → ${cap(def)}: ½× (not very effective)`,
|
|
270
|
+
})
|
|
271
|
+
else if (mult === 0)
|
|
272
|
+
cells.push({
|
|
273
|
+
className: 'dex-tc__cell dex-tc__cell--none',
|
|
274
|
+
content: '0',
|
|
275
|
+
title: `${cap(atk)} → ${cap(def)}: 0× (no effect)`,
|
|
276
|
+
})
|
|
277
|
+
else
|
|
278
|
+
cells.push({
|
|
279
|
+
className: 'dex-tc__cell',
|
|
280
|
+
content: '',
|
|
281
|
+
title: `${cap(atk)} → ${cap(def)}: 1×`,
|
|
282
|
+
})
|
|
283
|
+
}
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
cells: cells.map((c, i) => ({ id: String(i), ...c })),
|
|
288
|
+
teamProps: { teamInitial: teamStore.list() },
|
|
289
|
+
}
|
|
290
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// PokeAPI fetch wrappers + formatting helpers.
|
|
2
|
+
//
|
|
3
|
+
// These run inside route LOADERS (plain Bun/JS — full language, no native
|
|
4
|
+
// constraint). Their job is to fetch from PokeAPI and return fully render-ready
|
|
5
|
+
// view-models so the native (jinja) page templates only interpolate fields.
|
|
6
|
+
|
|
7
|
+
export const API = 'https://pokeapi.co/api/v2'
|
|
8
|
+
|
|
9
|
+
export const idFromUrl = (url: string): number => Number((url.match(/\/pokemon\/(\d+)\//) || [])[1])
|
|
10
|
+
|
|
11
|
+
/** Official-artwork PNG from the PokeAPI sprites CDN — derived from id so the
|
|
12
|
+
* list page needs ONE PokeAPI call per page (no per-Pokémon detail fetch). */
|
|
13
|
+
export const artwork = (id: number): string =>
|
|
14
|
+
`https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/${id}.png`
|
|
15
|
+
|
|
16
|
+
export const cap = (s: string): string =>
|
|
17
|
+
s ? s.charAt(0).toUpperCase() + s.slice(1).replace(/-/g, ' ') : s
|
|
18
|
+
|
|
19
|
+
export const pad = (n: number): string => `#${String(n).padStart(4, '0')}`
|
|
20
|
+
|
|
21
|
+
/** Pokémon type → AssetsArt design-token CSS variable (stays inside brand —
|
|
22
|
+
* no canonical Pokémon colours). Mirrored by `.dex-type--<name>` rules in
|
|
23
|
+
* app.css. */
|
|
24
|
+
export const TYPE_COLOR: Record<string, string> = {
|
|
25
|
+
normal: 'var(--ink-400)',
|
|
26
|
+
fire: 'var(--magenta-500)',
|
|
27
|
+
water: 'var(--blue-500)',
|
|
28
|
+
grass: 'var(--success-500)',
|
|
29
|
+
electric: 'var(--warning-500)',
|
|
30
|
+
ice: 'var(--viz-4)',
|
|
31
|
+
fighting: 'var(--viz-3)',
|
|
32
|
+
poison: 'var(--purple-600)',
|
|
33
|
+
ground: 'var(--viz-7)',
|
|
34
|
+
flying: 'var(--viz-5)',
|
|
35
|
+
psychic: 'var(--purple-500)',
|
|
36
|
+
bug: 'var(--viz-8)',
|
|
37
|
+
rock: 'var(--ink-500)',
|
|
38
|
+
ghost: 'var(--purple-700)',
|
|
39
|
+
dragon: 'var(--viz-2)',
|
|
40
|
+
dark: 'var(--ink-800)',
|
|
41
|
+
steel: 'var(--ink-400)',
|
|
42
|
+
fairy: 'var(--magenta-300)',
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const STAT_LABEL: Record<string, string> = {
|
|
46
|
+
hp: 'HP',
|
|
47
|
+
attack: 'Atk',
|
|
48
|
+
defense: 'Def',
|
|
49
|
+
'special-attack': 'Sp.Atk',
|
|
50
|
+
'special-defense': 'Sp.Def',
|
|
51
|
+
speed: 'Spd',
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Bucket a base stat into one of three fill modifier classes. */
|
|
55
|
+
export const statBucket = (base: number): string =>
|
|
56
|
+
base >= 100 ? 'hi' : base >= 60 ? 'mid' : base >= 35 ? 'low' : 'min'
|
|
57
|
+
|
|
58
|
+
export interface RawPokemon {
|
|
59
|
+
id: number
|
|
60
|
+
name: string
|
|
61
|
+
types: string[]
|
|
62
|
+
stats: { name: string; base: number }[]
|
|
63
|
+
height: number
|
|
64
|
+
weight: number
|
|
65
|
+
abilities: string[]
|
|
66
|
+
artwork: string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface RawSpecies {
|
|
70
|
+
flavorText: string
|
|
71
|
+
genus: string
|
|
72
|
+
evolutionUrl: string
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface RawEvolutionStage {
|
|
76
|
+
id: number
|
|
77
|
+
name: string
|
|
78
|
+
minLevel: number | null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function fetchList(offset: number, limit: number) {
|
|
82
|
+
const res = await fetch(`${API}/pokemon?limit=${limit}&offset=${offset}`)
|
|
83
|
+
if (!res.ok) throw new Error(`PokeAPI list ${res.status}`)
|
|
84
|
+
const page = (await res.json()) as {
|
|
85
|
+
count: number
|
|
86
|
+
results: { name: string; url: string }[]
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
results: page.results.map((r) => ({ id: idFromUrl(r.url), name: r.name })),
|
|
90
|
+
total: page.count,
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function fetchPokemon(name: string): Promise<RawPokemon | null> {
|
|
95
|
+
const res = await fetch(`${API}/pokemon/${name}`)
|
|
96
|
+
if (!res.ok) return null
|
|
97
|
+
const p = (await res.json()) as any
|
|
98
|
+
return {
|
|
99
|
+
id: p.id,
|
|
100
|
+
name: p.name,
|
|
101
|
+
types: p.types.map((t: any) => t.type.name),
|
|
102
|
+
stats: p.stats.map((s: any) => ({ name: s.stat.name, base: s.base_stat })),
|
|
103
|
+
height: p.height,
|
|
104
|
+
weight: p.weight,
|
|
105
|
+
abilities: p.abilities.map((a: any) => a.ability.name),
|
|
106
|
+
artwork: p.sprites?.other?.['official-artwork']?.front_default || artwork(p.id),
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function fetchSpecies(id: number): Promise<RawSpecies> {
|
|
111
|
+
const res = await fetch(`${API}/pokemon-species/${id}`)
|
|
112
|
+
const s = (await res.json()) as any
|
|
113
|
+
const flavor = s.flavor_text_entries?.find((e: any) => e.language.name === 'en')?.flavor_text as
|
|
114
|
+
| string
|
|
115
|
+
| undefined
|
|
116
|
+
return {
|
|
117
|
+
flavorText: (flavor || '').replace(/[\n\f\r]+/g, ' ').trim(),
|
|
118
|
+
genus: s.genera?.find((g: any) => g.language.name === 'en')?.genus || '',
|
|
119
|
+
evolutionUrl: s.evolution_chain?.url || '',
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Walk the (linear) evolution chain. Branching chains (e.g. Eevee) are
|
|
124
|
+
* flattened to the first branch — noted as an open question in the design. */
|
|
125
|
+
export async function fetchEvolution(url: string): Promise<RawEvolutionStage[]> {
|
|
126
|
+
if (!url) return []
|
|
127
|
+
const res = await fetch(url)
|
|
128
|
+
if (!res.ok) return []
|
|
129
|
+
const data = (await res.json()) as any
|
|
130
|
+
const stages: RawEvolutionStage[] = []
|
|
131
|
+
let node = data.chain
|
|
132
|
+
while (node) {
|
|
133
|
+
const id = idFromUrl(node.species.url.replace('-species', ''))
|
|
134
|
+
const det = node.evolution_details?.[0] || {}
|
|
135
|
+
stages.push({ id, name: node.species.name, minLevel: det.min_level || null })
|
|
136
|
+
node = node.evolves_to?.[0]
|
|
137
|
+
}
|
|
138
|
+
return stages
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The 18 canonical type names, in chart order. */
|
|
142
|
+
export const ALL_TYPES = [
|
|
143
|
+
'normal',
|
|
144
|
+
'fire',
|
|
145
|
+
'water',
|
|
146
|
+
'electric',
|
|
147
|
+
'grass',
|
|
148
|
+
'ice',
|
|
149
|
+
'fighting',
|
|
150
|
+
'poison',
|
|
151
|
+
'ground',
|
|
152
|
+
'flying',
|
|
153
|
+
'psychic',
|
|
154
|
+
'bug',
|
|
155
|
+
'rock',
|
|
156
|
+
'ghost',
|
|
157
|
+
'dragon',
|
|
158
|
+
'dark',
|
|
159
|
+
'steel',
|
|
160
|
+
'fairy',
|
|
161
|
+
]
|
|
162
|
+
|
|
163
|
+
/** Fetch one type's damage relations → a map of defendingType → multiplier. */
|
|
164
|
+
export async function fetchTypeRelations(type: string): Promise<Record<string, number>> {
|
|
165
|
+
const res = await fetch(`${API}/type/${type}`)
|
|
166
|
+
if (!res.ok) return {}
|
|
167
|
+
const d = (await res.json()) as any
|
|
168
|
+
const rel = d.damage_relations
|
|
169
|
+
const out: Record<string, number> = {}
|
|
170
|
+
for (const t of rel.double_damage_to || []) out[t.name] = 2
|
|
171
|
+
for (const t of rel.half_damage_to || []) out[t.name] = 0.5
|
|
172
|
+
for (const t of rel.no_damage_to || []) out[t.name] = 0
|
|
173
|
+
return out
|
|
174
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// In-process team store — a module-scope Map that lives for the whole server
|
|
2
|
+
// process. It is GLOBAL across every request and every visitor: brust ships no
|
|
3
|
+
// per-session / request-context primitive, so for this dogfood the team is a
|
|
4
|
+
// single shared roster. See ../FRAMEWORK-GAPS.md S6.
|
|
5
|
+
|
|
6
|
+
import type { TeamMember } from './types'
|
|
7
|
+
|
|
8
|
+
const team = new Map<number, TeamMember>()
|
|
9
|
+
export const MAX_TEAM = 6
|
|
10
|
+
|
|
11
|
+
export const teamStore = {
|
|
12
|
+
list: (): TeamMember[] => [...team.values()].sort((a, b) => a.addedAt - b.addedAt),
|
|
13
|
+
|
|
14
|
+
/** Add a member. Returns `false` when the team is full (and the member is
|
|
15
|
+
* new); idempotent for a member already on the team. */
|
|
16
|
+
add: (m: Omit<TeamMember, 'addedAt'>): boolean => {
|
|
17
|
+
if (team.has(m.id)) return true
|
|
18
|
+
if (team.size >= MAX_TEAM) return false
|
|
19
|
+
team.set(m.id, { ...m, addedAt: Date.now() })
|
|
20
|
+
return true
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
remove: (id: number): void => {
|
|
24
|
+
team.delete(id)
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
isFull: (): boolean => team.size >= MAX_TEAM,
|
|
28
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Domain + view-model types for the PokéDex example.
|
|
2
|
+
//
|
|
3
|
+
// NATIVE NOTE: the page components are `native: true` routes compiled to
|
|
4
|
+
// minijinja. They now support conditionals (S11), `style={{…}}` object attrs
|
|
5
|
+
// (S1), and dynamic `<BrustPage>` head props (S8) — all ✅ FIXED. Still
|
|
6
|
+
// precomputed in the loader: formatted strings, helper-derived values, and
|
|
7
|
+
// multi-property style strings (templates have no template-literals / arithmetic
|
|
8
|
+
// / helper calls). See ../FRAMEWORK-GAPS.md.
|
|
9
|
+
|
|
10
|
+
/** A single list cell — derived from the list endpoint alone (no detail fetch,
|
|
11
|
+
* see FRAMEWORK-GAPS.md S2 / N+1 avoidance). */
|
|
12
|
+
export interface CardVM {
|
|
13
|
+
id: number
|
|
14
|
+
name: string
|
|
15
|
+
displayName: string // "Bulbasaur"
|
|
16
|
+
num: string // "#0001"
|
|
17
|
+
artwork: string // CDN URL derived from id
|
|
18
|
+
detailHref: string // "/pokemon/bulbasaur"
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ListData {
|
|
22
|
+
items: CardVM[]
|
|
23
|
+
total: number
|
|
24
|
+
totalLabel: string // "1,302"
|
|
25
|
+
offset: number
|
|
26
|
+
showingLabel: string // "1–20 of 1,302"
|
|
27
|
+
pageLabel: string // "1 / 66"
|
|
28
|
+
// Native routes now support conditionals (GAPS S11 closed): the template
|
|
29
|
+
// branches with `{flags.hasPrev ? <a/> : <span/>}` on these booleans.
|
|
30
|
+
hasPrev: boolean
|
|
31
|
+
hasNext: boolean
|
|
32
|
+
prevHref: string
|
|
33
|
+
nextHref: string
|
|
34
|
+
offsetLabel: string // raw offset for the loader-echo line
|
|
35
|
+
teamProps: { teamInitial: TeamMember[] }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface TypeBadgeVM {
|
|
39
|
+
label: string // "Grass"
|
|
40
|
+
className: string // "dex-type dex-type--grass"
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface StatVM {
|
|
44
|
+
label: string // "HP" / "Atk" / …
|
|
45
|
+
base: number
|
|
46
|
+
barWidth: string // "62%" — fed into `style={{ width: barWidth }}` (S1)
|
|
47
|
+
barClassName: string // "dex-statbar__fill dex-statbar__fill--mid"
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface AbilityVM {
|
|
51
|
+
displayName: string // "Overgrow"
|
|
52
|
+
initial: string // "O"
|
|
53
|
+
iconColor: string // type tint — fed into `style={{ background: iconColor }}` (S1)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface EvolutionStageVM {
|
|
57
|
+
id: number
|
|
58
|
+
name: string
|
|
59
|
+
displayName: string
|
|
60
|
+
num: string
|
|
61
|
+
artwork: string
|
|
62
|
+
detailHref: string
|
|
63
|
+
levelLabel: string // "Lv 16"
|
|
64
|
+
// Native routes now support per-item conditionals (GAPS S11 closed): the
|
|
65
|
+
// template tests these booleans (`{!s.isFirst && <Arrow/>}`) instead of
|
|
66
|
+
// toggling a precomputed `dex-hide` class.
|
|
67
|
+
isFirst: boolean // true on the first stage (no leading arrow)
|
|
68
|
+
showLevel: boolean // true when this stage has a min level to show
|
|
69
|
+
cardClassName: string // adds the "current" highlight when this is the open Pokémon
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The full view-model handed to DetailPage. Every field is render-ready. */
|
|
73
|
+
export interface DetailData {
|
|
74
|
+
notFound: boolean
|
|
75
|
+
// Native routes now branch with `{notFound ? <NotFound/> : <Content/>}` (S11),
|
|
76
|
+
// so the content and 404 block are mutually exclusive at render time rather
|
|
77
|
+
// than both emitted with one hidden via a precomputed class.
|
|
78
|
+
pageTitle: string // dynamic <title> via `<BrustPage title={d.pageTitle}>` (S8)
|
|
79
|
+
name: string
|
|
80
|
+
// present only when notFound === false:
|
|
81
|
+
id: number
|
|
82
|
+
displayName: string
|
|
83
|
+
num: string
|
|
84
|
+
artwork: string
|
|
85
|
+
genus: string
|
|
86
|
+
flavorText: string
|
|
87
|
+
heightLabel: string // "0.7 m"
|
|
88
|
+
weightLabel: string // "6.9 kg"
|
|
89
|
+
abilityCount: number
|
|
90
|
+
heroBg: string // gradient value for `style={{ background: heroBg }}` — type-tinted
|
|
91
|
+
types: TypeBadgeVM[]
|
|
92
|
+
stats: StatVM[]
|
|
93
|
+
statTotal: number
|
|
94
|
+
abilities: AbilityVM[]
|
|
95
|
+
hasAbilities: boolean
|
|
96
|
+
evolution: EvolutionStageVM[]
|
|
97
|
+
hasEvolution: boolean
|
|
98
|
+
// island props (a single path each — native island props can't be object literals):
|
|
99
|
+
addProps: AddToTeamProps
|
|
100
|
+
teamProps: { teamInitial: TeamMember[] }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Props for the AddToTeamButton island (raw types kept for the action body). */
|
|
104
|
+
export interface AddToTeamProps {
|
|
105
|
+
id: number
|
|
106
|
+
name: string
|
|
107
|
+
displayName: string
|
|
108
|
+
num: string
|
|
109
|
+
types: string[]
|
|
110
|
+
artwork: string
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** One cell of the type chart, FLATTENED into a single row-major array so the
|
|
114
|
+
* native template renders it with ONE `.map()` into a CSS grid — nested maps
|
|
115
|
+
* aren't proven on the native path, so we avoid them. See FRAMEWORK-GAPS.md S10. */
|
|
116
|
+
export interface TypeChartCellVM {
|
|
117
|
+
id: string // stable key (row/col coordinate)
|
|
118
|
+
className: string // "dex-tc__cell dex-tc__cell--super"
|
|
119
|
+
content: string // "2", "½", "0", a type short-code, or ""
|
|
120
|
+
title: string // tooltip
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface TypeChartData {
|
|
124
|
+
cells: TypeChartCellVM[] // (18+1) × (18+1) row-major, including headers
|
|
125
|
+
teamProps: { teamInitial: TeamMember[] }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** In-process team store member. */
|
|
129
|
+
export interface TeamMember {
|
|
130
|
+
id: number
|
|
131
|
+
name: string
|
|
132
|
+
displayName: string
|
|
133
|
+
types: string[]
|
|
134
|
+
artwork: string
|
|
135
|
+
num: string
|
|
136
|
+
addedAt: number
|
|
137
|
+
}
|