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

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 (62) hide show
  1. package/README.md +19 -2
  2. package/bin/create-app.ts +4 -0
  3. package/package.json +27 -4
  4. package/src/args.ts +56 -0
  5. package/src/cli.ts +58 -0
  6. package/src/copy.ts +44 -0
  7. package/src/dep.ts +60 -0
  8. package/src/templates.ts +64 -0
  9. package/templates/blank/README.md.tmpl +42 -0
  10. package/templates/blank/_gitignore +2 -0
  11. package/templates/blank/actions.ts +13 -0
  12. package/templates/blank/brust.toml +6 -0
  13. package/templates/blank/components/Counter.tsx +11 -0
  14. package/templates/blank/components/Layout.tsx +21 -0
  15. package/templates/blank/package.json.tmpl +24 -0
  16. package/templates/blank/pages/HomePage.tsx +11 -0
  17. package/templates/blank/public/app.css +14 -0
  18. package/templates/blank/public/favicon.svg +1 -0
  19. package/templates/blank/routes.tsx +5 -0
  20. package/templates/blank/tsconfig.json +37 -0
  21. package/templates/pokedex/README.md.tmpl +44 -0
  22. package/templates/pokedex/_gitignore +3 -0
  23. package/templates/pokedex/actions.ts +37 -0
  24. package/templates/pokedex/brust.toml +8 -0
  25. package/templates/pokedex/components/AddToTeamButton.tsx +37 -0
  26. package/templates/pokedex/components/AppLayout.tsx +32 -0
  27. package/templates/pokedex/components/Breadcrumb.tsx +4 -0
  28. package/templates/pokedex/components/DexCard.tsx +11 -0
  29. package/templates/pokedex/components/DexFilter.tsx +26 -0
  30. package/templates/pokedex/components/HeroSearch.tsx +14 -0
  31. package/templates/pokedex/components/NavLink.tsx +6 -0
  32. package/templates/pokedex/components/TeamBuilder.tsx +32 -0
  33. package/templates/pokedex/components/ThemeToggle.tsx +16 -0
  34. package/templates/pokedex/components/TypeBadge.tsx +5 -0
  35. package/templates/pokedex/components/UnifiedIsland.tsx +28 -0
  36. package/templates/pokedex/components/UnifiedNative.tsx +20 -0
  37. package/templates/pokedex/components/UnifiedStoreNode.tsx +14 -0
  38. package/templates/pokedex/data/pokedex.json +8767 -0
  39. package/templates/pokedex/lib/api.ts +5 -0
  40. package/templates/pokedex/lib/filter.ts +6 -0
  41. package/templates/pokedex/lib/format.ts +4 -0
  42. package/templates/pokedex/lib/loaders.ts +192 -0
  43. package/templates/pokedex/lib/pokeapi.ts +43 -0
  44. package/templates/pokedex/lib/team-client.ts +26 -0
  45. package/templates/pokedex/lib/team-db.ts +52 -0
  46. package/templates/pokedex/lib/team.ts +2 -0
  47. package/templates/pokedex/lib/types.ts +126 -0
  48. package/templates/pokedex/package.json.tmpl +25 -0
  49. package/templates/pokedex/pages/BrowsePage.tsx +15 -0
  50. package/templates/pokedex/pages/DetailPage.tsx +233 -0
  51. package/templates/pokedex/pages/HomePage.tsx +129 -0
  52. package/templates/pokedex/pages/NotFoundPage.tsx +7 -0
  53. package/templates/pokedex/pages/TypeChart.tsx +72 -0
  54. package/templates/pokedex/pages/UnifiedPage.tsx +24 -0
  55. package/templates/pokedex/public/app.css +3 -0
  56. package/templates/pokedex/public/favicon.svg +1 -0
  57. package/templates/pokedex/routes.tsx +27 -0
  58. package/templates/pokedex/scripts/snapshot.ts +53 -0
  59. package/templates/pokedex/stores/team.ts +6 -0
  60. package/templates/pokedex/stores/unified.ts +4 -0
  61. package/templates/pokedex/styles/app.css +14 -0
  62. package/templates/pokedex/tsconfig.json +37 -0
package/README.md CHANGED
@@ -1,3 +1,20 @@
1
- # Temporary Holding Version
1
+ # @brust/create-app
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Scaffold a [brust](https://github.com/AssetsArt/brust) v2 app.
4
+
5
+ ```bash
6
+ bun create @brust/app my-app # --template blank (default) | pokedex
7
+ cd my-app && bun install
8
+ bun run dev # http://127.0.0.1:1337
9
+ ```
10
+
11
+ | Option | Meaning |
12
+ |---|---|
13
+ | `--template blank\|pokedex` | The starting point (default `blank`; a terminal asks when omitted). |
14
+ | `--dir <path>` | Target directory (default `./<name>`; it must be empty). |
15
+ | `--yes` | Accept defaults, never prompt. |
16
+ | `--brust-dep <spec>` | The `@brust/core` spec to write (CI and tarball tests). |
17
+
18
+ `bun create` rejects `-t` and `-y`; use the long flags (the short ones work with `bunx @brust/create-app`).
19
+
20
+ Full guide: [docs/create-app.md](https://github.com/AssetsArt/brust/blob/v2/docs/create-app.md).
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env bun
2
+ // `bun create @brust/app <name> …` runs this bin (bunx @brust/create-app) with the remaining argv.
3
+ import { main } from '../src/cli.ts'
4
+ await main(process.argv.slice(2))
package/package.json CHANGED
@@ -1,6 +1,29 @@
1
1
  {
2
2
  "name": "@brust/create-app",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "2.0.0-alpha.2",
4
+ "description": "Scaffold a brust v2 app — bun create @brust/app <name>",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/AssetsArt/brust.git",
9
+ "directory": "packages/create-app"
10
+ },
11
+ "type": "module",
12
+ "bin": {
13
+ "create-app": "./bin/create-app.ts"
14
+ },
15
+ "files": ["bin", "src", "templates", "README.md"],
16
+ "scripts": {
17
+ "build": "bun scripts/sync-template.ts",
18
+ "prepack": "bun scripts/sync-template.ts",
19
+ "test": "bun test",
20
+ "typecheck": "bun check"
21
+ },
22
+ "devDependencies": {
23
+ "@types/bun": "^1.4.0",
24
+ "typescript": "^7.0.2"
25
+ },
26
+ "publishConfig": {
27
+ "access": "public"
28
+ }
29
+ }
package/src/args.ts ADDED
@@ -0,0 +1,56 @@
1
+ // Argument parsing — a port of 0.1.x `brust new` (runtime/cli/new.ts:12-69).
2
+ // Note: `bun create` itself rejects the short flags -t and -y before this bin runs; they work under `bunx @brust/create-app`.
3
+ import { isAbsolute, resolve } from 'node:path'
4
+
5
+ const NAME_RE = /^[a-z0-9][a-z0-9_-]*$/
6
+ const MAX_NAME_LEN = 50
7
+
8
+ export const USAGE = 'usage: bun create @brust/app <name> [--template blank|pokedex] [--dir <path>] [--yes] [--brust-dep <spec>]'
9
+
10
+ export interface ParsedArgs {
11
+ projectName: string
12
+ targetDir: string
13
+ template?: string
14
+ yes: boolean
15
+ brustDep?: string
16
+ help: boolean
17
+ }
18
+
19
+ export function parseArgs(args: string[]): ParsedArgs {
20
+ let name: string | undefined
21
+ let dir: string | undefined
22
+ let template: string | undefined
23
+ let brustDep: string | undefined
24
+ let yes = false
25
+ let help = false
26
+ const need = (flag: string, v: string | undefined): string => {
27
+ if (!v) throw new Error(`create-app: ${flag} requires a value`)
28
+ return v
29
+ }
30
+
31
+ for (let i = 0; i < args.length; i++) {
32
+ const a = args[i]!
33
+ if (a === '--') continue
34
+ if (a === '--help' || a === '-h') help = true
35
+ else if (a === '--dir') dir = need('--dir', args[++i])
36
+ else if (a.startsWith('--dir=')) dir = need('--dir', a.slice('--dir='.length))
37
+ else if (a === '--template' || a === '-t') template = need('--template', args[++i])
38
+ else if (a.startsWith('--template=')) template = need('--template', a.slice('--template='.length))
39
+ else if (a === '--brust-dep') brustDep = need('--brust-dep', args[++i])
40
+ else if (a.startsWith('--brust-dep=')) brustDep = need('--brust-dep', a.slice('--brust-dep='.length))
41
+ else if (a === '--yes' || a === '-y') yes = true
42
+ else if (a.startsWith('-')) throw new Error(`create-app: unknown flag "${a}"`)
43
+ else if (name === undefined) name = a
44
+ else throw new Error(`create-app: unexpected positional argument "${a}"`)
45
+ }
46
+
47
+ if (help) return { projectName: name ?? '', targetDir: '', template, yes, brustDep, help }
48
+ if (!name) throw new Error(`create-app: missing project name. ${USAGE}`)
49
+ if (name.length > MAX_NAME_LEN) throw new Error(`create-app: project name too long (max ${MAX_NAME_LEN} chars)`)
50
+ if (!NAME_RE.test(name)) {
51
+ throw new Error(`create-app: invalid project name "${name}" — use lowercase letters, digits, hyphens, underscores; must start with a letter or digit`)
52
+ }
53
+ const cwd = process.cwd()
54
+ const targetDir = dir ? (isAbsolute(dir) ? dir : resolve(cwd, dir)) : resolve(cwd, name)
55
+ return { projectName: name, targetDir, template, yes, brustDep, help }
56
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,58 @@
1
+ // `create-app` main — a port of 0.1.x runNew (runtime/cli/new.ts:221-294) with the v2 dependency resolution.
2
+ import { existsSync } from 'node:fs'
3
+ import { readdir, rm } from 'node:fs/promises'
4
+ import { copyTemplate } from './copy.ts'
5
+ import { parseArgs, USAGE } from './args.ts'
6
+ import { applyOverrides, monorepoRoot, resolveBrustDep } from './dep.ts'
7
+ import { selectTemplate } from './templates.ts'
8
+
9
+ const fail = (msg: string): never => {
10
+ console.error(msg)
11
+ process.exit(1)
12
+ }
13
+ const msg = (e: unknown): string => (e instanceof Error ? e.message : String(e))
14
+
15
+ export async function main(argv: string[]): Promise<void> {
16
+ let parsed: ReturnType<typeof parseArgs>
17
+ try { parsed = parseArgs(argv) } catch (e) { return fail(msg(e)) }
18
+ if (parsed.help) {
19
+ console.log(USAGE)
20
+ return
21
+ }
22
+ const { projectName, targetDir } = parsed
23
+
24
+ const targetExisted = existsSync(targetDir)
25
+ if (targetExisted && (await readdir(targetDir)).length > 0) return fail(`create-app: target directory "${targetDir}" is not empty`)
26
+
27
+ let tmpl: ReturnType<typeof selectTemplate>
28
+ try { tmpl = selectTemplate({ explicit: parsed.template, yes: parsed.yes, isTTY: Boolean(process.stdin.isTTY) }) } catch (e) { return fail(msg(e)) }
29
+ const dep = resolveBrustDep({ explicit: parsed.brustDep, targetDir })
30
+
31
+ try {
32
+ // R5: from a brust checkout the generated template is rebuilt first, so a developer never copies a stale one.
33
+ if (tmpl.generated && monorepoRoot() && !process.env.CREATE_APP_TEMPLATES_DIR) {
34
+ const { syncTemplate } = await import('../scripts/sync-template.ts')
35
+ syncTemplate()
36
+ }
37
+ await copyTemplate({
38
+ templateDir: tmpl.dir,
39
+ targetDir,
40
+ substitutions: { __PROJECT_NAME__: projectName, __BRUST_DEP__: JSON.stringify(dep.spec) },
41
+ })
42
+ if (dep.overrides) applyOverrides(targetDir, dep.overrides)
43
+ } catch (e) {
44
+ if (!targetExisted) await rm(targetDir, { recursive: true, force: true }).catch(() => {})
45
+ return fail(`create-app: failed to scaffold (${msg(e)})`)
46
+ }
47
+ printNextSteps(projectName, targetDir, tmpl.name, dep.kind === 'workspace')
48
+ }
49
+
50
+ function printNextSteps(name: string, targetDir: string, template: string, workspace: boolean): void {
51
+ const cwd = process.cwd()
52
+ const displayPath = targetDir.startsWith(`${cwd}/`) ? `./${targetDir.slice(cwd.length + 1)}` : targetDir
53
+ console.log(`Created ${name} at ${targetDir} (template: ${template})\n`)
54
+ console.log('Next:')
55
+ console.log(` cd ${displayPath}`)
56
+ console.log(workspace ? ' bun install # run it at the brust repo root: this app is a workspace member' : ' bun install')
57
+ console.log(' bun run dev')
58
+ }
package/src/copy.ts ADDED
@@ -0,0 +1,44 @@
1
+ // copyTemplate — a port of 0.1.x runtime/cli/new.ts:100-163. `*.tmpl` files are substituted and lose the suffix, `_gitignore`
2
+ // becomes `.gitignore` (npm silently drops a nested .gitignore from a tarball), everything else is copied byte-for-byte.
3
+ // Symlinks are skipped, as in 0.1.x.
4
+ import { existsSync } from 'node:fs'
5
+ import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
6
+ import { join } from 'node:path'
7
+
8
+ export interface CopyTemplateOpts {
9
+ templateDir: string
10
+ targetDir: string
11
+ substitutions: Record<string, string>
12
+ }
13
+
14
+ export async function copyTemplate(opts: CopyTemplateOpts): Promise<void> {
15
+ if (!existsSync(opts.templateDir)) {
16
+ throw new Error(`create-app: template directory not found at ${opts.templateDir}; this is a @brust/create-app installation bug`)
17
+ }
18
+ await copyDir(opts.templateDir, opts.targetDir, opts.substitutions)
19
+ }
20
+
21
+ async function copyDir(src: string, dst: string, subs: Record<string, string>): Promise<void> {
22
+ await mkdir(dst, { recursive: true })
23
+ for (const ent of await readdir(src, { withFileTypes: true })) {
24
+ const srcPath = join(src, ent.name)
25
+ const dstPath = join(dst, renameForEmit(ent.name))
26
+ if (ent.isDirectory()) await copyDir(srcPath, dstPath, subs)
27
+ else if (ent.isFile()) {
28
+ if (ent.name.endsWith('.tmpl')) await writeFile(dstPath, applySubstitutions(await readFile(srcPath, 'utf8'), subs))
29
+ else await writeFile(dstPath, await readFile(srcPath))
30
+ }
31
+ }
32
+ }
33
+
34
+ function renameForEmit(name: string): string {
35
+ if (name.endsWith('.tmpl')) return name.slice(0, -'.tmpl'.length)
36
+ if (name === '_gitignore') return '.gitignore'
37
+ return name
38
+ }
39
+
40
+ function applySubstitutions(text: string, subs: Record<string, string>): string {
41
+ let out = text
42
+ for (const [key, value] of Object.entries(subs)) out = out.split(key).join(value)
43
+ return out
44
+ }
package/src/dep.ts ADDED
@@ -0,0 +1,60 @@
1
+ // Resolves the `@brust/core` spec written into a scaffolded package.json (`__BRUST_DEP__`, M3-F rulings R2/R3).
2
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
3
+ import { dirname, join, relative, resolve } from 'node:path'
4
+
5
+ /** = scripts/release-bump.ts PLATS. */
6
+ export const PLATS = ['darwin-x64', 'darwin-arm64', 'linux-x64-gnu', 'linux-arm64-gnu', 'linux-x64-musl', 'linux-arm64-musl'] as const
7
+
8
+ export interface BrustDep {
9
+ kind: 'explicit' | 'version' | 'workspace' | 'file'
10
+ spec: string
11
+ overrides?: Record<string, string>
12
+ }
13
+
14
+ export const packageRoot = (): string => resolve(import.meta.dir, '..')
15
+
16
+ const readJson = (p: string): Record<string, unknown> | null => {
17
+ try { return JSON.parse(readFileSync(p, 'utf8')) as Record<string, unknown> } catch { return null }
18
+ }
19
+
20
+ /** The brust checkout this package lives in, or null. Exact markers two levels up, no walking: a bunx cache can never match. */
21
+ export function monorepoRoot(pkgRoot = packageRoot()): string | null {
22
+ const root = resolve(pkgRoot, '../..')
23
+ const rootPkg = readJson(join(root, 'package.json'))
24
+ if (rootPkg?.name !== 'brust-v2' || !Array.isArray(rootPkg.workspaces)) return null
25
+ if (readJson(join(root, 'packages/brust/package.json'))?.name !== '@brust/core') return null
26
+ if (!existsSync(join(root, 'examples/pokedex/routes.tsx'))) return null
27
+ return root
28
+ }
29
+
30
+ /** `x/*` matches a direct child of `<root>/x`; a literal `x` matches `<root>/x` itself. */
31
+ function inWorkspace(root: string, target: string): boolean {
32
+ const rootPkg = readJson(join(root, 'package.json'))
33
+ const globs = (rootPkg?.workspaces as string[] | undefined) ?? []
34
+ const rel = relative(root, resolve(target))
35
+ if (rel.startsWith('..') || rel === '') return false
36
+ return globs.some((g) => (g.endsWith('/*') ? dirname(rel) === g.slice(0, -2) : rel === g))
37
+ }
38
+
39
+ export function resolveBrustDep(o: { explicit?: string; targetDir: string; pkgRoot?: string }): BrustDep {
40
+ if (o.explicit) return { kind: 'explicit', spec: o.explicit }
41
+ const pkgRoot = o.pkgRoot ?? packageRoot()
42
+ const root = monorepoRoot(pkgRoot)
43
+ if (root) {
44
+ if (inWorkspace(root, o.targetDir)) return { kind: 'workspace', spec: 'workspace:*' }
45
+ // `file:` alone fails: core's own workspace:* deps do not resolve outside the workspace globs, so override them.
46
+ const overrides: Record<string, string> = { '@brust/runtime-dom': `file:${root}/packages/runtime-dom` }
47
+ for (const p of PLATS) overrides[`@brust/native-${p}`] = `file:${root}/npm/${p}`
48
+ return { kind: 'file', spec: `file:${root}/packages/brust`, overrides }
49
+ }
50
+ const version = (readJson(join(pkgRoot, 'package.json'))?.version as string | undefined) ?? '0.0.0'
51
+ return { kind: 'version', spec: `^${version}` }
52
+ }
53
+
54
+ /** Merge `overrides` into <appDir>/package.json (2-space JSON + trailing newline). */
55
+ export function applyOverrides(appDir: string, overrides: Record<string, string>): void {
56
+ const p = join(appDir, 'package.json')
57
+ const pkg = JSON.parse(readFileSync(p, 'utf8')) as Record<string, unknown>
58
+ pkg.overrides = { ...(pkg.overrides as Record<string, string> | undefined), ...overrides }
59
+ writeFileSync(p, `${JSON.stringify(pkg, null, 2)}\n`)
60
+ }
@@ -0,0 +1,64 @@
1
+ // Template registry + selection (port of 0.1.x new.ts:165-220). `blank` is committed; `pokedex` is GENERATED from
2
+ // examples/pokedex by scripts/sync-template.ts (prepack, monorepo runs, tests) and gitignored.
3
+ import { join, resolve } from 'node:path'
4
+
5
+ export interface TemplateDef {
6
+ name: string
7
+ description: string
8
+ dir: string
9
+ /** true → produced by scripts/sync-template.ts, never hand-edited. */
10
+ generated: boolean
11
+ }
12
+
13
+ export const DEFAULT_TEMPLATE = 'blank'
14
+
15
+ /** Test-only hook: point both templates at another directory (CREATE_APP_TEMPLATES_DIR) to exercise the copy-failure rollback. */
16
+ const templatesDir = (): string => process.env.CREATE_APP_TEMPLATES_DIR || resolve(import.meta.dir, '../templates')
17
+
18
+ export function listTemplates(): TemplateDef[] {
19
+ const root = templatesDir()
20
+ return [
21
+ { name: 'blank', description: 'One page, a native counter, an Elysia hello action', dir: join(root, 'blank'), generated: false },
22
+ { name: 'pokedex', description: 'The v2 Pokédex: loaders, L1 cache, native + island, the team store on Elysia actions + SQLite', dir: join(root, 'pokedex'), generated: true },
23
+ ]
24
+ }
25
+
26
+ export interface SelectTemplateOpts {
27
+ explicit?: string
28
+ yes: boolean
29
+ isTTY: boolean
30
+ read?: (label: string) => string | null
31
+ print?: (s: string) => void
32
+ }
33
+
34
+ export function selectTemplate(opts: SelectTemplateOpts): TemplateDef {
35
+ const templates = listTemplates()
36
+ if (opts.explicit !== undefined) {
37
+ const t = templates.find((x) => x.name === opts.explicit)
38
+ if (!t) throw new Error(`create-app: unknown template "${opts.explicit}" — choose one of: ${templates.map((x) => x.name).join(', ')}`)
39
+ return t
40
+ }
41
+ if (opts.yes || !opts.isTTY) return templates.find((t) => t.name === DEFAULT_TEMPLATE) ?? templates[0]!
42
+ const read = opts.read ?? ((label: string) => prompt(label))
43
+ const print = opts.print ?? ((s: string) => process.stdout.write(s))
44
+ return promptPicker(templates, read, print)
45
+ }
46
+
47
+ function promptPicker(templates: TemplateDef[], read: (label: string) => string | null, print: (s: string) => void): TemplateDef {
48
+ const def = templates.find((t) => t.name === DEFAULT_TEMPLATE) ?? templates[0]!
49
+ const defIndex = templates.indexOf(def) + 1
50
+ for (let attempt = 0; attempt < 3; attempt++) {
51
+ print('Select a template:\n')
52
+ for (let i = 0; i < templates.length; i++) print(` ${i + 1}) ${templates[i]!.name} — ${templates[i]!.description}\n`)
53
+ const raw = read(`Template [${defIndex}]: `)
54
+ if (raw === null) return def
55
+ const input = raw.trim()
56
+ if (input === '') return def
57
+ const asNum = Number.parseInt(input, 10)
58
+ if (!Number.isNaN(asNum) && asNum >= 1 && asNum <= templates.length) return templates[asNum - 1]!
59
+ const byName = templates.find((t) => t.name === input)
60
+ if (byName) return byName
61
+ print(`Invalid selection "${input}". Try again.\n`)
62
+ }
63
+ return def
64
+ }
@@ -0,0 +1,42 @@
1
+ # __PROJECT_NAME__
2
+
3
+ A [brust](https://github.com/AssetsArt/brust) app: React components compiled ahead of time, served by Rust.
4
+
5
+ ## Run
6
+
7
+ ```bash
8
+ bun install
9
+ bun run dev # brust build routes.tsx && brust start -> http://127.0.0.1:1337
10
+ ```
11
+
12
+ Scripts: `dev` (build then start), `build`, `start`, `typecheck`.
13
+
14
+ ## Layout
15
+
16
+ - `routes.tsx` — `defineRoutes`: one layout, one page.
17
+ - `components/Layout.tsx` — the document root (`<html>` + `<Outlet/>`).
18
+ - `components/Counter.tsx` — a `useState` counter; the compiler makes it a native component (no React in the browser).
19
+ - `actions.ts` — an Elysia 2 app on `brust()`, served by Rust under `/_brust/action` (enabled by `[actions]` in `brust.toml`).
20
+ - `public/` — static files, served at `/public/...`.
21
+
22
+ ## The hello action
23
+
24
+ ```bash
25
+ curl 'http://127.0.0.1:1337/_brust/action/hello?name=ash'
26
+ # {"message":"Hello, ash!"}
27
+ ```
28
+
29
+ From the browser, use the typed client:
30
+
31
+ ```ts
32
+ import { client } from '@brust/core/client'
33
+ import type { App } from './actions'
34
+
35
+ const api = client<App>()
36
+ const { data } = await api.hello.get({ query: { name: 'ash' } })
37
+ ```
38
+
39
+ ## Docs
40
+
41
+ - Actions: https://github.com/AssetsArt/brust/blob/v2/docs/actions.md
42
+ - The unified store: https://github.com/AssetsArt/brust/blob/v2/docs/store.md
@@ -0,0 +1,2 @@
1
+ node_modules/
2
+ dist/
@@ -0,0 +1,13 @@
1
+ import { brust } from '@brust/core/elysia'
2
+ import { Elysia, t } from 'elysia'
3
+
4
+ // One Elysia 2 app, served by the Rust server under /_brust/action (brust.toml [actions]).
5
+ // Elysia 2 takes (path, hook, handler). Never call app.listen(): Rust owns HTTP.
6
+ const app = new Elysia({ adapter: brust() }).get(
7
+ '/hello',
8
+ { query: t.Object({ name: t.Optional(t.String()) }) },
9
+ ({ query }) => ({ message: `Hello, ${query.name ?? 'world'}!` }),
10
+ )
11
+
12
+ export default app
13
+ export type App = typeof app
@@ -0,0 +1,6 @@
1
+ [server]
2
+ address = "127.0.0.1"
3
+ port = 1337
4
+
5
+ # Elysia actions (docs/actions.md). Defaults: entry = "actions.ts", prefix = "/_brust/action", max_body_bytes = 1048576, max_response_bytes = 8388608
6
+ [actions]
@@ -0,0 +1,11 @@
1
+ // useState only, so the compiler lowers this to a native component: a signal in the browser, no React shipped.
2
+ import { useState } from 'react'
3
+
4
+ export default function Counter() {
5
+ const [count, setCount] = useState(0)
6
+ return (
7
+ <button type="button" onClick={() => setCount(count + 1)}>
8
+ count is {count}
9
+ </button>
10
+ )
11
+ }
@@ -0,0 +1,21 @@
1
+ // The document root: a plain <html> with <Outlet/> for the page. Static files are served from /public.
2
+ import { Outlet } from '@brust/core/routes'
3
+
4
+ export default function Layout() {
5
+ return (
6
+ <html lang="en">
7
+ <head>
8
+ <meta charSet="utf-8" />
9
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
10
+ <title>brust app</title>
11
+ <link rel="icon" href="/public/favicon.svg" />
12
+ <link rel="stylesheet" href="/public/app.css" />
13
+ </head>
14
+ <body>
15
+ <main>
16
+ <Outlet />
17
+ </main>
18
+ </body>
19
+ </html>
20
+ )
21
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "__PROJECT_NAME__",
3
+ "version": "0.0.1",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "brust build routes.tsx && brust start",
8
+ "build": "brust build routes.tsx",
9
+ "start": "brust start",
10
+ "typecheck": "bun check"
11
+ },
12
+ "dependencies": {
13
+ "@brust/core": __BRUST_DEP__,
14
+ "@elysia/eden": "2.0.0-beta.6",
15
+ "elysia": "2.0.0-beta.30",
16
+ "react": "^19.2.0",
17
+ "react-dom": "^19.2.0"
18
+ },
19
+ "devDependencies": {
20
+ "@types/bun": "^1.4.0",
21
+ "@types/react": "^19.2.0",
22
+ "typescript": "^7.0.2"
23
+ }
24
+ }
@@ -0,0 +1,11 @@
1
+ import Counter from '../components/Counter'
2
+
3
+ export default function HomePage() {
4
+ return (
5
+ <section>
6
+ <h1>Hello from brust</h1>
7
+ <p>Edit pages/HomePage.tsx, then run bun run dev again.</p>
8
+ <Counter />
9
+ </section>
10
+ )
11
+ }
@@ -0,0 +1,14 @@
1
+ :root { color-scheme: light dark; font-family: system-ui, sans-serif; }
2
+ body { margin: 0; }
3
+ main { max-width: 40rem; margin: 4rem auto; padding: 0 1.5rem; line-height: 1.5; }
4
+ h1 { font-size: 2rem; margin: 0 0 0.5rem; }
5
+ button {
6
+ cursor: pointer;
7
+ font: inherit;
8
+ padding: 0.5rem 1rem;
9
+ border: 1px solid currentColor;
10
+ border-radius: 0.5rem;
11
+ background: transparent;
12
+ color: inherit;
13
+ }
14
+ button:hover { opacity: 0.8; }
@@ -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,5 @@
1
+ import { defineRoutes } from '@brust/core/routes'
2
+ import Layout from './components/Layout'
3
+ import HomePage from './pages/HomePage'
4
+
5
+ export const routes = defineRoutes([{ Component: Layout, children: [{ path: '/', Component: HomePage }] }])
@@ -0,0 +1,37 @@
1
+ {
2
+ "compilerOptions": {
3
+ // Environment setup & latest features
4
+ "lib": [
5
+ "ESNext",
6
+ "DOM",
7
+ "DOM.Iterable"
8
+ ],
9
+ "target": "ESNext",
10
+ "module": "Preserve",
11
+ "moduleDetection": "force",
12
+ "jsx": "react-jsx",
13
+ "allowJs": true,
14
+ "types": [
15
+ "bun"
16
+ ],
17
+ // Bundler mode
18
+ "moduleResolution": "bundler",
19
+ "allowImportingTsExtensions": true,
20
+ "verbatimModuleSyntax": true,
21
+ "noEmit": true,
22
+ // Best practices
23
+ "strict": true,
24
+ "skipLibCheck": true,
25
+ "noFallthroughCasesInSwitch": true,
26
+ "noUncheckedIndexedAccess": true,
27
+ "noImplicitOverride": true,
28
+ // Some stricter flags (disabled by default)
29
+ "noUnusedLocals": false,
30
+ "noUnusedParameters": false,
31
+ "noPropertyAccessFromIndexSignature": false
32
+ },
33
+ "exclude": [
34
+ "dist",
35
+ "node_modules"
36
+ ]
37
+ }
@@ -0,0 +1,44 @@
1
+ # __PROJECT_NAME__
2
+
3
+ A [brust](https://github.com/AssetsArt/brust) Pokédex: loaders over an offline dataset, L1 page cache, native components,
4
+ React islands, and a persistent team built with Elysia actions + SQLite and one unified store.
5
+
6
+ ## Run
7
+
8
+ ```bash
9
+ bun install
10
+ bun run dev # brust build routes.tsx && brust start -> http://127.0.0.1:1337
11
+ ```
12
+
13
+ Scripts: `dev` (build then start), `build`, `start`, `typecheck`, `snapshot` (needs network; rewrites `data/pokedex.json`).
14
+
15
+ ## The team
16
+
17
+ The team lives in `data/team.sqlite` (created on first use, gitignored). Delete the file to reset it, or set
18
+ `POKEDEX_TEAM_DB=/path/to/team.sqlite` to put it elsewhere. A fresh database is empty.
19
+
20
+ The actions are served by Rust under `/_brust/action` (`GET /team`, `POST /team {id}`, `DELETE /team/:id`); the team holds
21
+ at most 6 members and a seventh add answers `409` (`TeamFull`). The team store (`stores/team.ts`) is read by the native
22
+ `AddToTeamButton` and the `TeamBuilder` island, and seeded by the layout loader, so the first paint is already right.
23
+ `/unified` shows the same idea with a counter (`/unified?start=5` seeds it on the server).
24
+
25
+ ## Layout
26
+
27
+ - `routes.tsx` — `/`, `/pokedex`, `/pokemon/{name}` (L1 60 s, `?nocache` bypass), `/type-chart` (L1 1 h), `/unified`, and a
28
+ root-level `*` 404 outside the layout. Pages that show the team carry the cache tag `team`.
29
+ - `lib/` — loaders over the snapshot, `team-db.ts` (sqlite, server-only), `team-client.ts` + `api.ts` (the typed actions client).
30
+ - `actions.ts` — the Elysia 2 team app on `brust()`; `stores/` — `team` and `unified` (`defineStore`).
31
+ - `components/`, `pages/` — plain React functions.
32
+
33
+ ## Regenerating the stylesheet (never hand-edit `public/app.css`)
34
+
35
+ ```bash
36
+ bunx @tailwindcss/cli@4 -i styles/app.css -o public/app.css --minify
37
+ ```
38
+
39
+ (Tailwind must resolve from the input's directory: install `tailwindcss` in the project first.)
40
+
41
+ ## Docs
42
+
43
+ - Actions: https://github.com/AssetsArt/brust/blob/v2/docs/actions.md
44
+ - The unified store: https://github.com/AssetsArt/brust/blob/v2/docs/store.md
@@ -0,0 +1,3 @@
1
+ node_modules/
2
+ dist/
3
+ data/team.sqlite*
@@ -0,0 +1,37 @@
1
+ // examples/pokedex/actions.ts — the team RPC surface (M3-F E7). One Elysia 2 app behind the Rust server
2
+ // (brust.toml [actions]); never imported by browser code except as `import type { App }`.
3
+ import { cache, type InvalidateArgs } from '@brust/core'
4
+ import { brust } from '@brust/core/elysia'
5
+ import { Elysia, problem, t } from 'elysia'
6
+ import { pokemonById } from './lib/pokeapi'
7
+ import { MAX_TEAM } from './lib/team'
8
+ import { type TeamDb, teamDb } from './lib/team-db'
9
+
10
+ export interface TeamDeps {
11
+ db: () => TeamDb
12
+ invalidate: (a: InvalidateArgs) => unknown
13
+ }
14
+
15
+ export function teamApp({ db, invalidate }: TeamDeps = { db: teamDb, invalidate: cache.invalidate }) {
16
+ const reply = () => ({ team: db().list(), max: MAX_TEAM })
17
+ // R5: cached pages that render the team carry the tag `team`; drop them after a real change.
18
+ const changed = () => invalidate({ tags: ['team'] })
19
+ return new Elysia({ adapter: brust() })
20
+ .get('/team', () => reply())
21
+ .post('/team', { body: t.Object({ id: t.Integer({ minimum: 1 }) }) }, ({ body }) => {
22
+ const p = pokemonById(body.id)
23
+ if (!p) return problem(404, { title: 'UnknownPokemon', detail: `no Pokémon #${body.id}` })
24
+ const r = db().add(p)
25
+ if (r === 'full') return problem(409, { title: 'TeamFull', detail: `the team already has ${MAX_TEAM} members`, max: MAX_TEAM })
26
+ if (r === 'added') changed()
27
+ return reply()
28
+ })
29
+ .delete('/team/:id', { params: t.Object({ id: t.Integer({ minimum: 1 }) }) }, ({ params }) => {
30
+ if (db().remove(params.id)) changed()
31
+ return reply()
32
+ })
33
+ }
34
+
35
+ const app = teamApp()
36
+ export default app
37
+ export type App = typeof app
@@ -0,0 +1,8 @@
1
+ [server]
2
+ address = "127.0.0.1"
3
+ port = 1337
4
+
5
+ [build]
6
+ server_only = ["lib/team-db.ts"]
7
+
8
+ [actions]