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.
@@ -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">&lt;Suspense&gt;</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.16-alpha",
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.16-alpha",
44
- "brustjs-darwin-arm64": "0.1.16-alpha",
45
- "brustjs-linux-x64-gnu": "0.1.16-alpha",
46
- "brustjs-linux-arm64-gnu": "0.1.16-alpha",
47
- "brustjs-linux-x64-musl": "0.1.16-alpha",
48
- "brustjs-linux-arm64-musl": "0.1.16-alpha"
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"
@@ -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
@@ -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> [options]',
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
  ]
@@ -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
- const TEMPLATE_DIR = join(import.meta.dir, 'templates', 'minimal')
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
- let dir = startDir
74
- while (true) {
75
- const pkgPath = join(dir, 'package.json')
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(src: string, dst: string, subs: Record<string, string>): Promise<void> {
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
- const out = applySubstitutions(raw, subs)
127
- await writeFile(dstPath, out)
143
+ await writeFile(dstPath, applySubstitutions(raw, subs))
128
144
  } else {
129
- const buf = await readFile(srcPath)
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: TEMPLATE_DIR,
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
- ? './' + targetDir.slice(cwd.length + 1)
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(`Next:`)
289
+ console.log(`Created ${name} at ${targetDir} (template: ${template})\n`)
290
+ console.log('Next:')
205
291
  console.log(` cd ${displayPath}`)
206
- console.log(` bun install`)
207
- console.log(` bun run dev`)
292
+ console.log(' bun install')
293
+ console.log(' bun run dev')
208
294
  }