brustjs 0.1.16-alpha → 0.1.18-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/public/favicon.svg +1 -0
- package/example/pokedex/routes.tsx +21 -0
- package/package.json +14 -8
- package/runtime/cli/build.ts +12 -0
- package/runtime/cli/help.ts +6 -1
- package/runtime/cli/new.ts +127 -41
- package/runtime/cli/templates.ts +139 -0
- package/runtime/index.d.ts +7 -0
- package/runtime/index.js +53 -52
- package/runtime/index.ts +21 -0
- package/runtime/islands/bootstrap.ts +40 -17
|
@@ -0,0 +1,167 @@
|
|
|
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
|
|
3
|
+
// native/jinja route has no React tree, so <Suspense> streaming is impossible —
|
|
4
|
+
// see ../FRAMEWORK-GAPS.md S3).
|
|
5
|
+
//
|
|
6
|
+
// Native routes now support conditionals (S11): the page uses a real
|
|
7
|
+
// `{notFound ? <NotFound/> : <Content/>}` branch, `{hasAbilities && …}` /
|
|
8
|
+
// `{hasEvolution && …}` sections, and per-item `{!s.isFirst && <Arrow/>}` /
|
|
9
|
+
// `{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
|
+
import { Island } from 'brustjs'
|
|
13
|
+
import AddToTeamButton from '../components/AddToTeamButton'
|
|
14
|
+
import PageLayout from '../components/PageLayout'
|
|
15
|
+
import type { DetailData } from '../lib/types'
|
|
16
|
+
|
|
17
|
+
export default function DetailPage({
|
|
18
|
+
notFound,
|
|
19
|
+
pageTitle,
|
|
20
|
+
hasAbilities,
|
|
21
|
+
hasEvolution,
|
|
22
|
+
displayName,
|
|
23
|
+
num,
|
|
24
|
+
artwork,
|
|
25
|
+
genus,
|
|
26
|
+
flavorText,
|
|
27
|
+
heightLabel,
|
|
28
|
+
weightLabel,
|
|
29
|
+
abilityCount,
|
|
30
|
+
heroBg,
|
|
31
|
+
types,
|
|
32
|
+
stats,
|
|
33
|
+
statTotal,
|
|
34
|
+
abilities,
|
|
35
|
+
evolution,
|
|
36
|
+
addProps,
|
|
37
|
+
teamProps,
|
|
38
|
+
}: DetailData) {
|
|
39
|
+
return (
|
|
40
|
+
<PageLayout native title={pageTitle} active="list" crumb={displayName} teamProps={teamProps}>
|
|
41
|
+
<a className="aa-btn aa-btn--ghost aa-btn--sm dex-back" href="/">
|
|
42
|
+
‹ Pokédex
|
|
43
|
+
</a>
|
|
44
|
+
|
|
45
|
+
{notFound ? (
|
|
46
|
+
<div className="dex-notfound">
|
|
47
|
+
<div className="dex-notfound__code">404</div>
|
|
48
|
+
<h2 className="aa-h3">No Pokémon named “{displayName}”</h2>
|
|
49
|
+
<p className="dex-notfound__desc">
|
|
50
|
+
loader ได้ 404 จาก PokeAPI. brust ยังไม่มี notFound() sentinel — หน้านี้จึงตอบ HTTP 200 พร้อม
|
|
51
|
+
body นี้ (ดู FRAMEWORK-GAPS.md S9).
|
|
52
|
+
</p>
|
|
53
|
+
<a className="aa-btn" href="/">
|
|
54
|
+
‹ Back to Pokédex
|
|
55
|
+
</a>
|
|
56
|
+
</div>
|
|
57
|
+
) : (
|
|
58
|
+
<>
|
|
59
|
+
<div className="dex-detail-grid">
|
|
60
|
+
<div className="aa-card dex-hero" style={{ background: heroBg }}>
|
|
61
|
+
<div className="dex-hero__num">{num}</div>
|
|
62
|
+
<h1 className="dex-hero__name">{displayName}</h1>
|
|
63
|
+
<div className="dex-hero__genus">{genus}</div>
|
|
64
|
+
<div className="dex-hero__art">
|
|
65
|
+
<img src={artwork} alt={displayName} className="dex-hero__img" />
|
|
66
|
+
</div>
|
|
67
|
+
<div className="dex-hero__types">
|
|
68
|
+
{types.map((t) => (
|
|
69
|
+
<span key={t.label} className={t.className}>
|
|
70
|
+
{t.label}
|
|
71
|
+
</span>
|
|
72
|
+
))}
|
|
73
|
+
</div>
|
|
74
|
+
<Island component={AddToTeamButton} props={addProps} hydrate="load" />
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
<div className="dex-detail-right">
|
|
78
|
+
<div className="aa-card aa-card--padded">
|
|
79
|
+
<p className="dex-flavor">{flavorText}</p>
|
|
80
|
+
<div className="dex-measures">
|
|
81
|
+
<div className="dex-measure">
|
|
82
|
+
<div className="dex-measure__v">{heightLabel}</div>
|
|
83
|
+
<div className="dex-measure__k">Height</div>
|
|
84
|
+
</div>
|
|
85
|
+
<div className="dex-measure">
|
|
86
|
+
<div className="dex-measure__v">{weightLabel}</div>
|
|
87
|
+
<div className="dex-measure__k">Weight</div>
|
|
88
|
+
</div>
|
|
89
|
+
<div className="dex-measure">
|
|
90
|
+
<div className="dex-measure__v">{abilityCount}</div>
|
|
91
|
+
<div className="dex-measure__k">Abilities</div>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
{hasAbilities && (
|
|
95
|
+
<div className="dex-abilities">
|
|
96
|
+
{abilities.map((a) => (
|
|
97
|
+
<span key={a.displayName} className="aa-chip">
|
|
98
|
+
<span className="aa-chip__icon" style={{ background: a.iconColor }}>
|
|
99
|
+
{a.initial}
|
|
100
|
+
</span>
|
|
101
|
+
{a.displayName}
|
|
102
|
+
</span>
|
|
103
|
+
))}
|
|
104
|
+
</div>
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
<div className="aa-section dex-stats">
|
|
109
|
+
<div className="aa-section__head">
|
|
110
|
+
<h2 className="aa-section__title">Base stats</h2>
|
|
111
|
+
<span className="dex-stats__total">Σ {statTotal}</span>
|
|
112
|
+
</div>
|
|
113
|
+
<div className="aa-section__body dex-stats__body">
|
|
114
|
+
{stats.map((st) => (
|
|
115
|
+
<div key={st.label} className="dex-statbar">
|
|
116
|
+
<span className="dex-statbar__label">{st.label}</span>
|
|
117
|
+
<span className="dex-statbar__val">{st.base}</span>
|
|
118
|
+
<div className="dex-statbar__track">
|
|
119
|
+
<div className={st.barClassName} style={{ width: st.barWidth }} />
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
))}
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
{hasEvolution && (
|
|
129
|
+
<div className="aa-section dex-evo">
|
|
130
|
+
<div className="aa-section__head">
|
|
131
|
+
<div>
|
|
132
|
+
<h2 className="aa-section__title">Evolution chain</h2>
|
|
133
|
+
<div className="aa-section__desc">
|
|
134
|
+
โหลดใน loader (native route stream{' '}
|
|
135
|
+
<code className="dex-code"><Suspense></code> ไม่ได้ — ดู GAPS S3)
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
<div className="aa-section__body dex-evo__body">
|
|
140
|
+
{evolution.map((s) => (
|
|
141
|
+
<div key={s.id} className="dex-evo__stage">
|
|
142
|
+
{!s.isFirst && (
|
|
143
|
+
<div className="dex-evo__sep">
|
|
144
|
+
<span className="dex-evo__arrow">›</span>
|
|
145
|
+
{s.showLevel && <span className="dex-evo__lv">{s.levelLabel}</span>}
|
|
146
|
+
</div>
|
|
147
|
+
)}
|
|
148
|
+
<a className={s.cardClassName} href={s.detailHref}>
|
|
149
|
+
<img
|
|
150
|
+
className="dex-evo__img"
|
|
151
|
+
src={s.artwork}
|
|
152
|
+
alt={s.displayName}
|
|
153
|
+
loading="lazy"
|
|
154
|
+
/>
|
|
155
|
+
<div className="dex-evo__num">{s.num}</div>
|
|
156
|
+
<div className="dex-evo__name">{s.displayName}</div>
|
|
157
|
+
</a>
|
|
158
|
+
</div>
|
|
159
|
+
))}
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
)}
|
|
163
|
+
</>
|
|
164
|
+
)}
|
|
165
|
+
</PageLayout>
|
|
166
|
+
)
|
|
167
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
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.
|
|
5
|
+
//
|
|
6
|
+
// Native route components support `.map()` AND conditionals (S11): pagination
|
|
7
|
+
// "disabled" state is a real `{hasPrev ? <a/> : <span/>}` branch, not a
|
|
8
|
+
// precomputed hide-class. See ../FRAMEWORK-GAPS.md S11.
|
|
9
|
+
import PageLayout from '../components/PageLayout'
|
|
10
|
+
import type { ListData } from '../lib/types'
|
|
11
|
+
|
|
12
|
+
export default function ListPage({
|
|
13
|
+
items,
|
|
14
|
+
totalLabel,
|
|
15
|
+
showingLabel,
|
|
16
|
+
pageLabel,
|
|
17
|
+
offsetLabel,
|
|
18
|
+
hasPrev,
|
|
19
|
+
hasNext,
|
|
20
|
+
prevHref,
|
|
21
|
+
nextHref,
|
|
22
|
+
teamProps,
|
|
23
|
+
}: ListData) {
|
|
24
|
+
return (
|
|
25
|
+
<PageLayout
|
|
26
|
+
native
|
|
27
|
+
title="PokéDex · brust example"
|
|
28
|
+
active="list"
|
|
29
|
+
crumb="All Pokémon"
|
|
30
|
+
teamProps={teamProps}
|
|
31
|
+
>
|
|
32
|
+
<div className="aa-page-header">
|
|
33
|
+
<div>
|
|
34
|
+
<h1 className="aa-page-header__title">Pokédex</h1>
|
|
35
|
+
<div className="aa-page-header__desc">
|
|
36
|
+
National Dex · {totalLabel} Pokémon · loader{' '}
|
|
37
|
+
<code className="dex-code">GET /pokemon?limit=20&offset={offsetLabel}</code>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<div className="aa-alert aa-alert--info dex-note">
|
|
43
|
+
<div className="aa-alert__body">
|
|
44
|
+
List แสดงเฉพาะ number · name · artwork — types/stats โหลดตอนเปิด detail. artwork ถูก derive
|
|
45
|
+
จาก id (ยิง PokeAPI ครั้งเดียวต่อหน้า) เพื่อเลี่ยง N+1 waterfall.
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<div className="dex-grid">
|
|
50
|
+
{items.map((p) => (
|
|
51
|
+
<a key={p.id} className="aa-card aa-card--interactive dex-card" href={p.detailHref}>
|
|
52
|
+
<div className="dex-card__art">
|
|
53
|
+
<span className="dex-card__num">{p.num}</span>
|
|
54
|
+
<img className="dex-card__img" src={p.artwork} alt={p.displayName} loading="lazy" />
|
|
55
|
+
</div>
|
|
56
|
+
<div className="dex-card__body">{p.displayName}</div>
|
|
57
|
+
</a>
|
|
58
|
+
))}
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<div className="dex-pager">
|
|
62
|
+
<div className="dex-pager__info">{showingLabel}</div>
|
|
63
|
+
<div className="dex-pager__nav">
|
|
64
|
+
{hasPrev ? (
|
|
65
|
+
<a className="aa-btn aa-btn--secondary aa-btn--sm" href={prevHref}>
|
|
66
|
+
‹ Prev
|
|
67
|
+
</a>
|
|
68
|
+
) : (
|
|
69
|
+
<span className="aa-btn aa-btn--secondary aa-btn--sm dex-pager__btn--off">‹ Prev</span>
|
|
70
|
+
)}
|
|
71
|
+
<span className="dex-pager__page">{pageLabel}</span>
|
|
72
|
+
{hasNext ? (
|
|
73
|
+
<a className="aa-btn aa-btn--secondary aa-btn--sm" href={nextHref}>
|
|
74
|
+
Next ›
|
|
75
|
+
</a>
|
|
76
|
+
) : (
|
|
77
|
+
<span className="aa-btn aa-btn--secondary aa-btn--sm dex-pager__btn--off">Next ›</span>
|
|
78
|
+
)}
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
</PageLayout>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
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 is pre-flattened in the loader
|
|
4
|
+
// to a single row-major `cells` array so the template uses ONE `.map()` into a
|
|
5
|
+
// CSS grid (nested maps aren't proven on the native path — see GAPS S10).
|
|
6
|
+
import PageLayout from '../components/PageLayout'
|
|
7
|
+
import type { TypeChartData } from '../lib/types'
|
|
8
|
+
|
|
9
|
+
export default function TypeChart({ cells, teamProps }: TypeChartData) {
|
|
10
|
+
return (
|
|
11
|
+
<PageLayout
|
|
12
|
+
native
|
|
13
|
+
title="PokéDex · type chart"
|
|
14
|
+
active="typechart"
|
|
15
|
+
crumb="Type chart"
|
|
16
|
+
teamProps={teamProps}
|
|
17
|
+
>
|
|
18
|
+
<div className="aa-page-header">
|
|
19
|
+
<div>
|
|
20
|
+
<h1 className="aa-page-header__title">Type chart</h1>
|
|
21
|
+
<div className="aa-page-header__desc">
|
|
22
|
+
Damage relations · row attacks column · rendered ฝั่ง Rust จาก jinja (native:true · ไม่มี
|
|
23
|
+
React runtime ใน payload)
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
<div className="dex-tc-legend">
|
|
29
|
+
<span className="dex-tc-legend__item">
|
|
30
|
+
<span className="dex-tc__cell dex-tc__cell--super dex-tc__swatch">2</span> super effective
|
|
31
|
+
</span>
|
|
32
|
+
<span className="dex-tc-legend__item">
|
|
33
|
+
<span className="dex-tc__cell dex-tc__cell--weak dex-tc__swatch">½</span> not very
|
|
34
|
+
</span>
|
|
35
|
+
<span className="dex-tc-legend__item">
|
|
36
|
+
<span className="dex-tc__cell dex-tc__cell--none dex-tc__swatch">0</span> no effect
|
|
37
|
+
</span>
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<div className="dex-tc-scroll">
|
|
41
|
+
<div className="dex-tc">
|
|
42
|
+
{cells.map((c) => (
|
|
43
|
+
<div key={c.id} className={c.className} title={c.title}>
|
|
44
|
+
{c.content}
|
|
45
|
+
</div>
|
|
46
|
+
))}
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
</PageLayout>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><circle cx="16" cy="16" r="15" fill="#ef4444"/><rect x="1" y="14" width="30" height="4" fill="#111"/><circle cx="16" cy="16" r="5" fill="#fff" stroke="#111" stroke-width="2"/></svg>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { defineRoutes } from 'brustjs/routes'
|
|
2
|
+
import { detailLoader, listLoader, typeChartLoader } from './lib/loaders'
|
|
3
|
+
import DetailPage from './pages/DetailPage'
|
|
4
|
+
import ListPage from './pages/ListPage'
|
|
5
|
+
import TypeChart from './pages/TypeChart'
|
|
6
|
+
|
|
7
|
+
// Every route is `native: true` — each page is compiled from JSX to a minijinja
|
|
8
|
+
// template at build time and rendered in Rust (no per-request React). The loader
|
|
9
|
+
// runs in a Bun worker and its return value becomes the template scope. The only
|
|
10
|
+
// React that ever boots in the browser is the islands (TeamBuilder /
|
|
11
|
+
// AddToTeamButton). See ./FRAMEWORK-GAPS.md for what this costs.
|
|
12
|
+
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 },
|
|
15
|
+
|
|
16
|
+
// Dynamic param {name} + a (non-streamed) evolution chain loaded in the loader.
|
|
17
|
+
{ path: '/pokemon/{name}', Component: DetailPage, native: true, loader: detailLoader },
|
|
18
|
+
|
|
19
|
+
// Static 18×18 effectiveness matrix — the ideal native page.
|
|
20
|
+
{ path: '/type-chart', Component: TypeChart, native: true, loader: typeChartLoader },
|
|
21
|
+
])
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brustjs",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.18-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.18-alpha",
|
|
44
|
+
"brustjs-darwin-arm64": "0.1.18-alpha",
|
|
45
|
+
"brustjs-linux-x64-gnu": "0.1.18-alpha",
|
|
46
|
+
"brustjs-linux-arm64-gnu": "0.1.18-alpha",
|
|
47
|
+
"brustjs-linux-x64-musl": "0.1.18-alpha",
|
|
48
|
+
"brustjs-linux-arm64-musl": "0.1.18-alpha"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
51
|
"react": "^19.2.6",
|
|
@@ -74,7 +74,13 @@
|
|
|
74
74
|
"!runtime/node_modules",
|
|
75
75
|
"!runtime/package.json",
|
|
76
76
|
"!runtime/*.node",
|
|
77
|
-
"!runtime/**/*.test.ts"
|
|
77
|
+
"!runtime/**/*.test.ts",
|
|
78
|
+
"example/pokedex",
|
|
79
|
+
"!example/pokedex/FRAMEWORK-GAPS.md",
|
|
80
|
+
"!example/pokedex/README.md",
|
|
81
|
+
"!example/pokedex/.brust",
|
|
82
|
+
"!example/pokedex/dist",
|
|
83
|
+
"!example/pokedex/node_modules"
|
|
78
84
|
],
|
|
79
85
|
"publishConfig": {
|
|
80
86
|
"access": "public"
|
package/runtime/cli/build.ts
CHANGED
|
@@ -351,6 +351,18 @@ export async function runBuild(args: string[]): Promise<void> {
|
|
|
351
351
|
}
|
|
352
352
|
}
|
|
353
353
|
|
|
354
|
+
// Static public assets: copy <project>/public → <dist>/public so a deployed
|
|
355
|
+
// dist is self-contained. No .brust mirror — the source/dev runtime reads
|
|
356
|
+
// <scanRoot>/public directly.
|
|
357
|
+
const publicSrc = path.join(entryDir, 'public')
|
|
358
|
+
if (existsSync(publicSrc)) {
|
|
359
|
+
const publicOut = path.join(outDir, 'public')
|
|
360
|
+
await cp(publicSrc, publicOut, { recursive: true })
|
|
361
|
+
console.log(`[brust build] public: ${publicSrc} → ${publicOut}`)
|
|
362
|
+
} else {
|
|
363
|
+
console.log('[brust build] public: skipped (no public/ dir)')
|
|
364
|
+
}
|
|
365
|
+
|
|
354
366
|
// 5. Bun.build the server bundle with the native shim plugin + banner. No
|
|
355
367
|
// actions codegen: `defineActions(...)` actions register via the app entry's
|
|
356
368
|
// `import { actions } from './actions'` → `brust.run({ actions })` path, which
|
package/runtime/cli/help.ts
CHANGED
|
@@ -65,10 +65,15 @@ export const COMMANDS: CommandDef[] = [
|
|
|
65
65
|
{
|
|
66
66
|
name: 'new',
|
|
67
67
|
summary: 'Scaffold a new brust project',
|
|
68
|
-
usage: 'brust new <name> [
|
|
68
|
+
usage: 'brust new <name> [--dir <path>] [--template <name>] [--yes]',
|
|
69
69
|
flags: [
|
|
70
70
|
{ flag: '<name>', desc: 'Project name (lowercase letters, digits, - _)' },
|
|
71
71
|
{ flag: '--dir <path>', desc: 'Target directory (default ./<name>)' },
|
|
72
|
+
{
|
|
73
|
+
flag: '--template, -t <name>',
|
|
74
|
+
desc: 'Template to scaffold (minimal | pokedex). Prompts if omitted on a TTY; defaults to minimal otherwise.',
|
|
75
|
+
},
|
|
76
|
+
{ flag: '--yes, -y', desc: 'Skip the prompt; use the default template (minimal).' },
|
|
72
77
|
],
|
|
73
78
|
},
|
|
74
79
|
]
|
package/runtime/cli/new.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs'
|
|
2
2
|
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
3
3
|
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_TEMPLATE,
|
|
6
|
+
type EmittedFile,
|
|
7
|
+
findBrustPackageRoot,
|
|
8
|
+
listTemplates,
|
|
9
|
+
type TemplateDef,
|
|
10
|
+
} from './templates.ts'
|
|
6
11
|
|
|
7
12
|
const NAME_RE = /^[a-z0-9][a-z0-9_-]*$/
|
|
8
13
|
const MAX_NAME_LEN = 50
|
|
@@ -10,11 +15,15 @@ const MAX_NAME_LEN = 50
|
|
|
10
15
|
export interface ParsedNewArgs {
|
|
11
16
|
projectName: string
|
|
12
17
|
targetDir: string
|
|
18
|
+
template?: string
|
|
19
|
+
yes: boolean
|
|
13
20
|
}
|
|
14
21
|
|
|
15
22
|
export function parseArgs(args: string[]): ParsedNewArgs {
|
|
16
23
|
let name: string | undefined
|
|
17
24
|
let dir: string | undefined
|
|
25
|
+
let template: string | undefined
|
|
26
|
+
let yes = false
|
|
18
27
|
|
|
19
28
|
for (let i = 0; i < args.length; i++) {
|
|
20
29
|
const a = args[i]!
|
|
@@ -23,6 +32,15 @@ export function parseArgs(args: string[]): ParsedNewArgs {
|
|
|
23
32
|
if (!dir) throw new Error('brust new: --dir requires a value')
|
|
24
33
|
} else if (a.startsWith('--dir=')) {
|
|
25
34
|
dir = a.slice('--dir='.length)
|
|
35
|
+
if (!dir) throw new Error('brust new: --dir requires a value')
|
|
36
|
+
} else if (a === '--template' || a === '-t') {
|
|
37
|
+
template = args[++i]
|
|
38
|
+
if (!template) throw new Error('brust new: --template requires a value')
|
|
39
|
+
} else if (a.startsWith('--template=')) {
|
|
40
|
+
template = a.slice('--template='.length)
|
|
41
|
+
if (!template) throw new Error('brust new: --template requires a value')
|
|
42
|
+
} else if (a === '--yes' || a === '-y') {
|
|
43
|
+
yes = true
|
|
26
44
|
} else if (a.startsWith('-')) {
|
|
27
45
|
throw new Error(`brust new: unknown flag "${a}"`)
|
|
28
46
|
} else if (name === undefined) {
|
|
@@ -47,7 +65,7 @@ export function parseArgs(args: string[]): ParsedNewArgs {
|
|
|
47
65
|
const cwd = process.cwd()
|
|
48
66
|
const targetDir = dir ? (isAbsolute(dir) ? dir : resolve(cwd, dir)) : resolve(cwd, name)
|
|
49
67
|
|
|
50
|
-
return { projectName: name, targetDir }
|
|
68
|
+
return { projectName: name, targetDir, template, yes }
|
|
51
69
|
}
|
|
52
70
|
|
|
53
71
|
export interface BrustRef {
|
|
@@ -70,35 +88,21 @@ function hasSourceMarkers(dir: string): boolean {
|
|
|
70
88
|
}
|
|
71
89
|
|
|
72
90
|
export function resolveBrustRef(startDir: string = import.meta.dir): BrustRef {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
if (existsSync(pkgPath)) {
|
|
77
|
-
try {
|
|
78
|
-
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
|
79
|
-
if (pkg.name === 'brustjs') {
|
|
80
|
-
if (hasSourceMarkers(dir)) {
|
|
81
|
-
return { kind: 'file', spec: `file:${dir}` }
|
|
82
|
-
}
|
|
83
|
-
const version = typeof pkg.version === 'string' ? pkg.version : '0.0.0'
|
|
84
|
-
return { kind: 'version', spec: `^${version}` }
|
|
85
|
-
}
|
|
86
|
-
} catch {
|
|
87
|
-
// malformed package.json — keep walking
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
const parent = dirname(dir)
|
|
91
|
-
if (parent === dir) {
|
|
92
|
-
throw new Error('brust new: cannot locate the brust package — is your installation intact?')
|
|
93
|
-
}
|
|
94
|
-
dir = parent
|
|
91
|
+
const dir = findBrustPackageRoot(startDir)
|
|
92
|
+
if (hasSourceMarkers(dir)) {
|
|
93
|
+
return { kind: 'file', spec: `file:${dir}` }
|
|
95
94
|
}
|
|
95
|
+
const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
|
|
96
|
+
const version = typeof pkg.version === 'string' ? pkg.version : '0.0.0'
|
|
97
|
+
return { kind: 'version', spec: `^${version}` }
|
|
96
98
|
}
|
|
97
99
|
|
|
98
100
|
export interface CopyTemplateOpts {
|
|
99
101
|
templateDir: string
|
|
100
102
|
targetDir: string
|
|
101
103
|
substitutions: Record<string, string>
|
|
104
|
+
exclude?: Set<string>
|
|
105
|
+
extraFiles?: EmittedFile[]
|
|
102
106
|
}
|
|
103
107
|
|
|
104
108
|
export async function copyTemplate(opts: CopyTemplateOpts): Promise<void> {
|
|
@@ -107,27 +111,38 @@ export async function copyTemplate(opts: CopyTemplateOpts): Promise<void> {
|
|
|
107
111
|
`brust new: template directory not found at ${opts.templateDir}; this is a brust installation bug`,
|
|
108
112
|
)
|
|
109
113
|
}
|
|
110
|
-
await copyDir(opts.templateDir, opts.targetDir, opts.substitutions)
|
|
114
|
+
await copyDir(opts.templateDir, opts.targetDir, opts.substitutions, opts.exclude ?? new Set(), '')
|
|
115
|
+
for (const f of opts.extraFiles ?? []) {
|
|
116
|
+
const dstPath = join(opts.targetDir, f.relPath)
|
|
117
|
+
await mkdir(dirname(dstPath), { recursive: true })
|
|
118
|
+
await writeFile(dstPath, f.content)
|
|
119
|
+
}
|
|
111
120
|
}
|
|
112
121
|
|
|
113
|
-
async function copyDir(
|
|
122
|
+
async function copyDir(
|
|
123
|
+
src: string,
|
|
124
|
+
dst: string,
|
|
125
|
+
subs: Record<string, string>,
|
|
126
|
+
exclude: Set<string>,
|
|
127
|
+
relBase: string,
|
|
128
|
+
): Promise<void> {
|
|
114
129
|
await mkdir(dst, { recursive: true })
|
|
115
130
|
const entries = await readdir(src, { withFileTypes: true })
|
|
116
131
|
for (const ent of entries) {
|
|
132
|
+
const relPath = relBase ? `${relBase}/${ent.name}` : ent.name
|
|
133
|
+
if (exclude.has(relPath)) continue
|
|
117
134
|
const srcPath = join(src, ent.name)
|
|
118
135
|
const dstName = renameForEmit(ent.name)
|
|
119
136
|
const dstPath = join(dst, dstName)
|
|
120
137
|
if (ent.isDirectory()) {
|
|
121
|
-
await copyDir(srcPath, dstPath, subs)
|
|
138
|
+
await copyDir(srcPath, dstPath, subs, exclude, relPath)
|
|
122
139
|
} else if (ent.isFile()) {
|
|
123
140
|
const isTmpl = ent.name.endsWith('.tmpl')
|
|
124
141
|
if (isTmpl) {
|
|
125
142
|
const raw = await readFile(srcPath, 'utf8')
|
|
126
|
-
|
|
127
|
-
await writeFile(dstPath, out)
|
|
143
|
+
await writeFile(dstPath, applySubstitutions(raw, subs))
|
|
128
144
|
} else {
|
|
129
|
-
|
|
130
|
-
await writeFile(dstPath, buf)
|
|
145
|
+
await writeFile(dstPath, await readFile(srcPath))
|
|
131
146
|
}
|
|
132
147
|
}
|
|
133
148
|
}
|
|
@@ -147,6 +162,63 @@ function applySubstitutions(text: string, subs: Record<string, string>): string
|
|
|
147
162
|
return out
|
|
148
163
|
}
|
|
149
164
|
|
|
165
|
+
export interface SelectTemplateOpts {
|
|
166
|
+
explicit?: string
|
|
167
|
+
yes: boolean
|
|
168
|
+
isTTY: boolean
|
|
169
|
+
read?: (label: string) => string | null
|
|
170
|
+
print?: (s: string) => void
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function selectTemplate(opts: SelectTemplateOpts): TemplateDef {
|
|
174
|
+
const templates = listTemplates()
|
|
175
|
+
if (opts.explicit !== undefined) {
|
|
176
|
+
const t = templates.find((x) => x.name === opts.explicit)
|
|
177
|
+
if (!t) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
`brust new: unknown template "${opts.explicit}" — choose one of: ${templates
|
|
180
|
+
.map((x) => x.name)
|
|
181
|
+
.join(', ')}`,
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
return t
|
|
185
|
+
}
|
|
186
|
+
if (opts.yes || !opts.isTTY) {
|
|
187
|
+
return templates.find((t) => t.name === DEFAULT_TEMPLATE) ?? templates[0]!
|
|
188
|
+
}
|
|
189
|
+
const read = opts.read ?? ((label: string) => prompt(label))
|
|
190
|
+
const print = opts.print ?? ((s: string) => process.stdout.write(s))
|
|
191
|
+
return promptPicker(templates, read, print)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function promptPicker(
|
|
195
|
+
templates: TemplateDef[],
|
|
196
|
+
read: (label: string) => string | null,
|
|
197
|
+
print: (s: string) => void,
|
|
198
|
+
): TemplateDef {
|
|
199
|
+
const def = templates.find((t) => t.name === DEFAULT_TEMPLATE) ?? templates[0]!
|
|
200
|
+
const defIndex = templates.indexOf(def) + 1
|
|
201
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
202
|
+
print('Select a template:\n')
|
|
203
|
+
for (let i = 0; i < templates.length; i++) {
|
|
204
|
+
const t = templates[i]!
|
|
205
|
+
print(` ${i + 1}) ${t.name} — ${t.description}\n`)
|
|
206
|
+
}
|
|
207
|
+
const raw = read(`Template [${defIndex}]: `)
|
|
208
|
+
if (raw === null) return def
|
|
209
|
+
const input = raw.trim()
|
|
210
|
+
if (input === '') return def
|
|
211
|
+
const asNum = Number.parseInt(input, 10)
|
|
212
|
+
if (!Number.isNaN(asNum) && asNum >= 1 && asNum <= templates.length) {
|
|
213
|
+
return templates[asNum - 1]!
|
|
214
|
+
}
|
|
215
|
+
const byName = templates.find((t) => t.name === input)
|
|
216
|
+
if (byName) return byName
|
|
217
|
+
print(`Invalid selection "${input}". Try again.\n`)
|
|
218
|
+
}
|
|
219
|
+
return def
|
|
220
|
+
}
|
|
221
|
+
|
|
150
222
|
export async function runNew(args: string[]): Promise<void> {
|
|
151
223
|
let parsed: ParsedNewArgs
|
|
152
224
|
try {
|
|
@@ -175,14 +247,28 @@ export async function runNew(args: string[]): Promise<void> {
|
|
|
175
247
|
process.exit(1)
|
|
176
248
|
}
|
|
177
249
|
|
|
250
|
+
let tmpl: TemplateDef
|
|
251
|
+
try {
|
|
252
|
+
tmpl = selectTemplate({
|
|
253
|
+
explicit: parsed.template,
|
|
254
|
+
yes: parsed.yes,
|
|
255
|
+
isTTY: Boolean(process.stdin.isTTY),
|
|
256
|
+
})
|
|
257
|
+
} catch (e) {
|
|
258
|
+
console.error(e instanceof Error ? e.message : String(e))
|
|
259
|
+
process.exit(1)
|
|
260
|
+
}
|
|
261
|
+
|
|
178
262
|
try {
|
|
179
263
|
await copyTemplate({
|
|
180
|
-
templateDir:
|
|
264
|
+
templateDir: tmpl.sourceDir,
|
|
181
265
|
targetDir,
|
|
182
266
|
substitutions: {
|
|
183
267
|
__PROJECT_NAME__: projectName,
|
|
184
268
|
__BRUST_DEP__: JSON.stringify(brustRef.spec),
|
|
185
269
|
},
|
|
270
|
+
exclude: tmpl.exclude,
|
|
271
|
+
extraFiles: tmpl.extraFiles?.({ projectName, brustSpec: brustRef.spec }),
|
|
186
272
|
})
|
|
187
273
|
} catch (e) {
|
|
188
274
|
if (!targetExisted) {
|
|
@@ -192,17 +278,17 @@ export async function runNew(args: string[]): Promise<void> {
|
|
|
192
278
|
process.exit(1)
|
|
193
279
|
}
|
|
194
280
|
|
|
195
|
-
printNextSteps(projectName, targetDir)
|
|
281
|
+
printNextSteps(projectName, targetDir, tmpl.name)
|
|
196
282
|
}
|
|
197
283
|
|
|
198
|
-
function printNextSteps(name: string, targetDir: string): void {
|
|
284
|
+
function printNextSteps(name: string, targetDir: string, template: string): void {
|
|
199
285
|
const cwd = process.cwd()
|
|
200
|
-
const displayPath = targetDir.startsWith(cwd
|
|
201
|
-
?
|
|
286
|
+
const displayPath = targetDir.startsWith(`${cwd}/`)
|
|
287
|
+
? `./${targetDir.slice(cwd.length + 1)}`
|
|
202
288
|
: targetDir
|
|
203
|
-
console.log(`Created ${name} at ${targetDir}\n`)
|
|
204
|
-
console.log(
|
|
289
|
+
console.log(`Created ${name} at ${targetDir} (template: ${template})\n`)
|
|
290
|
+
console.log('Next:')
|
|
205
291
|
console.log(` cd ${displayPath}`)
|
|
206
|
-
console.log(
|
|
207
|
-
console.log(
|
|
292
|
+
console.log(' bun install')
|
|
293
|
+
console.log(' bun run dev')
|
|
208
294
|
}
|