@brust/create-app 0.0.0-stage → 2.0.0-alpha.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -2
- package/bin/create-app.ts +4 -0
- package/package.json +31 -4
- package/src/args.ts +64 -0
- package/src/cli.ts +69 -0
- package/src/copy.ts +44 -0
- package/src/dep.ts +61 -0
- package/src/tailwind.ts +10 -0
- package/src/templates.ts +64 -0
- package/templates/blank/README.md.tmpl +43 -0
- package/templates/blank/_gitignore +4 -0
- package/templates/blank/actions.ts +13 -0
- package/templates/blank/app.css +32 -0
- package/templates/blank/brust.toml +6 -0
- package/templates/blank/components/Counter.tsx +15 -0
- package/templates/blank/components/Layout.tsx +22 -0
- package/templates/blank/package.json.tmpl +30 -0
- package/templates/blank/pages/HomePage.tsx +35 -0
- package/templates/blank/public/favicon.svg +1 -0
- package/templates/blank/routes.tsx +5 -0
- package/templates/blank/tsconfig.json +37 -0
- package/templates/blank/wrangler.jsonc.tmpl +9 -0
- package/templates/pokedex/README.md.tmpl +59 -0
- package/templates/pokedex/_gitignore +5 -0
- package/templates/pokedex/actions.ts +37 -0
- package/templates/pokedex/brust.toml +28 -0
- package/templates/pokedex/components/AddToTeamButton.tsx +37 -0
- package/templates/pokedex/components/AppLayout.tsx +32 -0
- package/templates/pokedex/components/Breadcrumb.tsx +4 -0
- package/templates/pokedex/components/DexCard.tsx +11 -0
- package/templates/pokedex/components/DexFilter.tsx +26 -0
- package/templates/pokedex/components/HeroSearch.tsx +14 -0
- package/templates/pokedex/components/NavLink.tsx +6 -0
- package/templates/pokedex/components/TeamBuilder.tsx +32 -0
- package/templates/pokedex/components/ThemeToggle.tsx +16 -0
- package/templates/pokedex/components/TypeBadge.tsx +5 -0
- package/templates/pokedex/components/UnifiedIsland.tsx +28 -0
- package/templates/pokedex/components/UnifiedNative.tsx +20 -0
- package/templates/pokedex/components/UnifiedStoreNode.tsx +14 -0
- package/templates/pokedex/data/pokedex.json +8767 -0
- package/templates/pokedex/lib/api.ts +5 -0
- package/templates/pokedex/lib/cloudflare.d.ts +24 -0
- package/templates/pokedex/lib/filter.ts +6 -0
- package/templates/pokedex/lib/format.ts +4 -0
- package/templates/pokedex/lib/loaders.ts +192 -0
- package/templates/pokedex/lib/pokeapi.ts +43 -0
- package/templates/pokedex/lib/team-client.ts +26 -0
- package/templates/pokedex/lib/team-db.d1.ts +47 -0
- package/templates/pokedex/lib/team-db.sqlite.ts +49 -0
- package/templates/pokedex/lib/team-db.ts +13 -0
- package/templates/pokedex/lib/team.ts +2 -0
- package/templates/pokedex/lib/types.ts +126 -0
- package/templates/pokedex/migrations/0001_team.sql +2 -0
- package/templates/pokedex/package.json.tmpl +30 -0
- package/templates/pokedex/pages/BrowsePage.tsx +15 -0
- package/templates/pokedex/pages/DetailPage.tsx +233 -0
- package/templates/pokedex/pages/HomePage.tsx +129 -0
- package/templates/pokedex/pages/NotFoundPage.tsx +7 -0
- package/templates/pokedex/pages/TypeChart.tsx +72 -0
- package/templates/pokedex/pages/UnifiedPage.tsx +24 -0
- package/templates/pokedex/public/app.css +3 -0
- package/templates/pokedex/public/favicon.svg +1 -0
- package/templates/pokedex/routes.tsx +27 -0
- package/templates/pokedex/scripts/snapshot.ts +53 -0
- package/templates/pokedex/stores/team.ts +6 -0
- package/templates/pokedex/stores/unified.ts +4 -0
- package/templates/pokedex/styles/app.css +14 -0
- package/templates/pokedex/tsconfig.json +37 -0
- package/templates/pokedex/wrangler.jsonc +9 -0
package/README.md
CHANGED
|
@@ -1,3 +1,20 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @brust/create-app
|
|
2
2
|
|
|
3
|
-
|
|
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/main/docs/create-app.md).
|
package/package.json
CHANGED
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brust/create-app",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "2.0.0-alpha.10",
|
|
4
|
+
"description": "Scaffold a brust v2 app — bun create @brust/app <name>",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://brust.dev",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/AssetsArt/brust.git",
|
|
10
|
+
"directory": "packages/create-app"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/AssetsArt/brust/issues"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"bin": {
|
|
17
|
+
"create-app": "./bin/create-app.ts"
|
|
18
|
+
},
|
|
19
|
+
"files": ["bin", "src", "templates", "README.md"],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "bun scripts/sync-template.ts",
|
|
22
|
+
"prepack": "bun scripts/sync-template.ts",
|
|
23
|
+
"test": "bun test",
|
|
24
|
+
"typecheck": "bun check"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/bun": "^1.4.0",
|
|
28
|
+
"typescript": "^7.0.2"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/args.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
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] [--tailwind] [--dir <path>] [--yes] [--brust-dep <spec>] [--version]'
|
|
9
|
+
|
|
10
|
+
export interface ParsedArgs {
|
|
11
|
+
projectName: string
|
|
12
|
+
targetDir: string
|
|
13
|
+
template?: string
|
|
14
|
+
yes: boolean
|
|
15
|
+
brustDep?: string
|
|
16
|
+
/** `--tailwind` (L21): accepted and ignored since L28: the blank template uses Tailwind CSS v4 by default. */
|
|
17
|
+
tailwind: boolean
|
|
18
|
+
help: boolean
|
|
19
|
+
/** `--version` (F131): print the bare version and exit; no short form (see the note above). */
|
|
20
|
+
version: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function parseArgs(args: string[]): ParsedArgs {
|
|
24
|
+
let name: string | undefined
|
|
25
|
+
let dir: string | undefined
|
|
26
|
+
let template: string | undefined
|
|
27
|
+
let brustDep: string | undefined
|
|
28
|
+
let yes = false
|
|
29
|
+
let tailwind = false
|
|
30
|
+
let help = false
|
|
31
|
+
let version = false
|
|
32
|
+
const need = (flag: string, v: string | undefined): string => {
|
|
33
|
+
if (!v) throw new Error(`create-app: ${flag} requires a value`)
|
|
34
|
+
return v
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
for (let i = 0; i < args.length; i++) {
|
|
38
|
+
const a = args[i]!
|
|
39
|
+
if (a === '--') continue
|
|
40
|
+
if (a === '--help' || a === '-h') help = true
|
|
41
|
+
else if (a === '--version') version = true
|
|
42
|
+
else if (a === '--dir') dir = need('--dir', args[++i])
|
|
43
|
+
else if (a.startsWith('--dir=')) dir = need('--dir', a.slice('--dir='.length))
|
|
44
|
+
else if (a === '--template' || a === '-t') template = need('--template', args[++i])
|
|
45
|
+
else if (a.startsWith('--template=')) template = need('--template', a.slice('--template='.length))
|
|
46
|
+
else if (a === '--brust-dep') brustDep = need('--brust-dep', args[++i])
|
|
47
|
+
else if (a.startsWith('--brust-dep=')) brustDep = need('--brust-dep', a.slice('--brust-dep='.length))
|
|
48
|
+
else if (a === '--yes' || a === '-y') yes = true
|
|
49
|
+
else if (a === '--tailwind') tailwind = true
|
|
50
|
+
else if (a.startsWith('-')) throw new Error(`create-app: unknown flag "${a}"`)
|
|
51
|
+
else if (name === undefined) name = a
|
|
52
|
+
else throw new Error(`create-app: unexpected positional argument "${a}"`)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (help || version) return { projectName: name ?? '', targetDir: '', template, yes, brustDep, tailwind, help, version }
|
|
56
|
+
if (!name) throw new Error(`create-app: missing project name. ${USAGE}`)
|
|
57
|
+
if (name.length > MAX_NAME_LEN) throw new Error(`create-app: project name too long (max ${MAX_NAME_LEN} chars)`)
|
|
58
|
+
if (!NAME_RE.test(name)) {
|
|
59
|
+
throw new Error(`create-app: invalid project name "${name}" — use lowercase letters, digits, hyphens, underscores; must start with a letter or digit`)
|
|
60
|
+
}
|
|
61
|
+
const cwd = process.cwd()
|
|
62
|
+
const targetDir = dir ? (isAbsolute(dir) ? dir : resolve(cwd, dir)) : resolve(cwd, name)
|
|
63
|
+
return { projectName: name, targetDir, template, yes, brustDep, tailwind, help, version }
|
|
64
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
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, readFileSync } 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 { TAILWIND_DEFAULT_NOTE } from './tailwind.ts'
|
|
8
|
+
import { selectTemplate } from './templates.ts'
|
|
9
|
+
|
|
10
|
+
// F131: read at run time from the package.json that `bun publish` ships, so release-bump.ts stays the only place versions change.
|
|
11
|
+
const ownVersion = (): string => (JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version?: string }).version ?? '0.0.0'
|
|
12
|
+
|
|
13
|
+
const fail = (msg: string): never => {
|
|
14
|
+
console.error(msg)
|
|
15
|
+
process.exit(1)
|
|
16
|
+
}
|
|
17
|
+
const msg = (e: unknown): string => (e instanceof Error ? e.message : String(e))
|
|
18
|
+
|
|
19
|
+
export async function main(argv: string[]): Promise<void> {
|
|
20
|
+
let parsed: ReturnType<typeof parseArgs>
|
|
21
|
+
try { parsed = parseArgs(argv) } catch (e) { return fail(msg(e)) }
|
|
22
|
+
if (parsed.help) {
|
|
23
|
+
console.log(USAGE)
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
if (parsed.version) {
|
|
27
|
+
console.log(ownVersion())
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
console.log(`@brust/create-app ${ownVersion()}\n`)
|
|
31
|
+
const { projectName, targetDir } = parsed
|
|
32
|
+
|
|
33
|
+
const targetExisted = existsSync(targetDir)
|
|
34
|
+
if (targetExisted && (await readdir(targetDir)).length > 0) return fail(`create-app: target directory "${targetDir}" is not empty`)
|
|
35
|
+
|
|
36
|
+
let tmpl: ReturnType<typeof selectTemplate>
|
|
37
|
+
try { tmpl = selectTemplate({ explicit: parsed.template, yes: parsed.yes, isTTY: Boolean(process.stdin.isTTY) }) } catch (e) { return fail(msg(e)) }
|
|
38
|
+
// L28 R29.1: Tailwind is the blank template's default; the flag stays accepted so older instructions still work.
|
|
39
|
+
if (parsed.tailwind) console.log(TAILWIND_DEFAULT_NOTE)
|
|
40
|
+
const dep = resolveBrustDep({ explicit: parsed.brustDep, targetDir })
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
// R5: from a brust checkout the generated template is rebuilt first, so a developer never copies a stale one.
|
|
44
|
+
if (tmpl.generated && monorepoRoot() && !process.env.CREATE_APP_TEMPLATES_DIR) {
|
|
45
|
+
const { syncTemplate } = await import('../scripts/sync-template.ts')
|
|
46
|
+
syncTemplate()
|
|
47
|
+
}
|
|
48
|
+
await copyTemplate({
|
|
49
|
+
templateDir: tmpl.dir,
|
|
50
|
+
targetDir,
|
|
51
|
+
substitutions: { __PROJECT_NAME__: projectName, __BRUST_DEP__: JSON.stringify(dep.spec) },
|
|
52
|
+
})
|
|
53
|
+
if (dep.overrides) applyOverrides(targetDir, dep.overrides)
|
|
54
|
+
} catch (e) {
|
|
55
|
+
if (!targetExisted) await rm(targetDir, { recursive: true, force: true }).catch(() => {})
|
|
56
|
+
return fail(`create-app: failed to scaffold (${msg(e)})`)
|
|
57
|
+
}
|
|
58
|
+
printNextSteps(projectName, targetDir, tmpl.name, dep.spec, dep.kind === 'workspace')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function printNextSteps(name: string, targetDir: string, template: string, spec: string, workspace: boolean): void {
|
|
62
|
+
const cwd = process.cwd()
|
|
63
|
+
const displayPath = targetDir.startsWith(`${cwd}/`) ? `./${targetDir.slice(cwd.length + 1)}` : targetDir
|
|
64
|
+
console.log(`Created ${name} at ${targetDir} (template: ${template}, @brust/core ${spec})\n`)
|
|
65
|
+
console.log('Next:')
|
|
66
|
+
console.log(` cd ${displayPath}`)
|
|
67
|
+
console.log(workspace ? ' bun install # run it at the brust repo root: this app is a workspace member' : ' bun install')
|
|
68
|
+
console.log(' bun run dev')
|
|
69
|
+
}
|
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,61 @@
|
|
|
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-files.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
|
+
overrides['@brust/core-cf'] = `file:${root}/npm/core-cf`
|
|
48
|
+
for (const p of PLATS) overrides[`@brust/native-${p}`] = `file:${root}/npm/${p}`
|
|
49
|
+
return { kind: 'file', spec: `file:${root}/packages/brust`, overrides }
|
|
50
|
+
}
|
|
51
|
+
const version = (readJson(join(pkgRoot, 'package.json'))?.version as string | undefined) ?? '0.0.0'
|
|
52
|
+
return { kind: 'version', spec: `^${version}` }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Merge `overrides` into <appDir>/package.json (2-space JSON + trailing newline). */
|
|
56
|
+
export function applyOverrides(appDir: string, overrides: Record<string, string>): void {
|
|
57
|
+
const p = join(appDir, 'package.json')
|
|
58
|
+
const pkg = JSON.parse(readFileSync(p, 'utf8')) as Record<string, unknown>
|
|
59
|
+
pkg.overrides = { ...(pkg.overrides as Record<string, string> | undefined), ...overrides }
|
|
60
|
+
writeFileSync(p, `${JSON.stringify(pkg, null, 2)}\n`)
|
|
61
|
+
}
|
package/src/tailwind.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Tailwind CSS v4 in the blank template (gaps round L21, made the default by L28 R29.1): the template ships `app.css`
|
|
2
|
+
// (`@import "tailwindcss"` + `@theme` tokens), the layout imports it (brust builds it and links it in <head>, L19), and
|
|
3
|
+
// the pinned dev dependencies below are in its package.json (R21.1: the app's own; L32 R33.2: the compiler, its Node API
|
|
4
|
+
// and the scanner, one exact version, so the build compiles with the tailwindcss the app pinned). `--tailwind` no longer
|
|
5
|
+
// changes anything; it prints TAILWIND_DEFAULT_NOTE.
|
|
6
|
+
|
|
7
|
+
/** The exact versions brust's own tests build with (the blank template's package.json pins the same). */
|
|
8
|
+
export const TAILWIND_DEV_DEPS = { '@tailwindcss/node': '4.3.3', '@tailwindcss/oxide': '4.3.3', tailwindcss: '4.3.3' }
|
|
9
|
+
|
|
10
|
+
export const TAILWIND_DEFAULT_NOTE = 'create-app: Tailwind CSS v4 is the default; the --tailwind flag does nothing'
|
package/src/templates.ts
ADDED
|
@@ -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,43 @@
|
|
|
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 dev routes.tsx (hot reload) -> http://127.0.0.1:1337
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Scripts: `dev` (`brust dev`, with hot reload), `build`, `start`, `typecheck`, `dev:cf` (build for Cloudflare Workers and run `wrangler dev`), `deploy:cf` (build and `wrangler deploy`).
|
|
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
|
+
- `wrangler.jsonc` — your Cloudflare bindings (D1, KV, vars); brust owns `main`, `assets` and `compatibility_date`.
|
|
22
|
+
|
|
23
|
+
## The hello action
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
curl 'http://127.0.0.1:1337/_brust/action/hello?name=ash'
|
|
27
|
+
# {"message":"Hello, ash!"}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
From the browser, use the typed client:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { client } from '@brust/core/client'
|
|
34
|
+
import type { App } from './actions'
|
|
35
|
+
|
|
36
|
+
const api = client<App>()
|
|
37
|
+
const { data } = await api.hello.get({ query: { name: 'ash' } })
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Docs
|
|
41
|
+
|
|
42
|
+
- Actions: https://github.com/AssetsArt/brust/blob/main/docs/actions.md
|
|
43
|
+
- The unified store: https://github.com/AssetsArt/brust/blob/main/docs/store.md
|
|
@@ -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,32 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
|
|
3
|
+
/* Tailwind CSS v4, CSS-first: no tailwind.config. The tokens below are the brust docs palette; each one becomes a
|
|
4
|
+
utility (bg-paper, text-ink, border-rule, ...). A *.module.css that uses @apply starts with
|
|
5
|
+
@reference "../app.css"; (see brust docs/css.md). */
|
|
6
|
+
@theme {
|
|
7
|
+
--color-paper: oklch(94% 0.035 78);
|
|
8
|
+
--color-sheet: oklch(97% 0.02 82);
|
|
9
|
+
--color-ink: oklch(18% 0.035 82);
|
|
10
|
+
--color-ink-2: oklch(36% 0.045 80);
|
|
11
|
+
--color-rule: oklch(76% 0.05 77);
|
|
12
|
+
--color-amber: oklch(68% 0.145 74);
|
|
13
|
+
--color-amber-ink: oklch(46% 0.12 62);
|
|
14
|
+
--font-sans: ui-rounded, "SF Pro Rounded", system-ui, sans-serif;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/* Dark mode follows the system: the same tokens, darker values, so every utility switches with them. */
|
|
18
|
+
@media (prefers-color-scheme: dark) {
|
|
19
|
+
:root {
|
|
20
|
+
--color-paper: oklch(20% 0.028 185);
|
|
21
|
+
--color-sheet: oklch(24% 0.032 182);
|
|
22
|
+
--color-ink: oklch(91% 0.02 82);
|
|
23
|
+
--color-ink-2: oklch(77% 0.03 82);
|
|
24
|
+
--color-rule: oklch(36% 0.032 180);
|
|
25
|
+
--color-amber: oklch(74% 0.12 78);
|
|
26
|
+
--color-amber-ink: oklch(81% 0.11 80);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
:root {
|
|
31
|
+
color-scheme: light dark;
|
|
32
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
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
|
|
8
|
+
type="button"
|
|
9
|
+
className="cursor-pointer rounded-[0.6rem] border border-amber-ink bg-amber px-[1.1rem] py-2 font-semibold text-[oklch(18%_0.035_82)] hover:brightness-106 focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-amber-ink"
|
|
10
|
+
onClick={() => setCount(count + 1)}
|
|
11
|
+
>
|
|
12
|
+
count is {count}
|
|
13
|
+
</button>
|
|
14
|
+
)
|
|
15
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// The document root: a plain <html> with <Outlet/> for the page. app.css (Tailwind CSS v4) is imported here; brust
|
|
2
|
+
// builds it into one hashed file and links it in <head>. Static files are served from /public.
|
|
3
|
+
import { Outlet } from '@brust/core/routes'
|
|
4
|
+
import '../app.css'
|
|
5
|
+
|
|
6
|
+
export default function Layout() {
|
|
7
|
+
return (
|
|
8
|
+
<html lang="en">
|
|
9
|
+
<head>
|
|
10
|
+
<meta charSet="utf-8" />
|
|
11
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
12
|
+
<title>brust app</title>
|
|
13
|
+
<link rel="icon" href="/public/favicon.svg" />
|
|
14
|
+
</head>
|
|
15
|
+
<body className="bg-paper font-sans text-ink leading-[1.55]">
|
|
16
|
+
<main className="mx-auto box-border max-w-2xl px-4 py-16">
|
|
17
|
+
<Outlet />
|
|
18
|
+
</main>
|
|
19
|
+
</body>
|
|
20
|
+
</html>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__PROJECT_NAME__",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "brust dev routes.tsx",
|
|
8
|
+
"build": "brust build routes.tsx",
|
|
9
|
+
"start": "brust start",
|
|
10
|
+
"typecheck": "bun check",
|
|
11
|
+
"dev:cf": "brust dev routes.tsx --target cloudflare",
|
|
12
|
+
"deploy:cf": "brust build routes.tsx --target cloudflare && wrangler deploy -c dist/cf/wrangler.jsonc"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@brust/core": __BRUST_DEP__,
|
|
16
|
+
"@elysia/eden": "2.0.0-beta.6",
|
|
17
|
+
"elysia": "2.0.0-beta.30",
|
|
18
|
+
"react": "^19.2.0",
|
|
19
|
+
"react-dom": "^19.2.0"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@tailwindcss/node": "4.3.3",
|
|
23
|
+
"@tailwindcss/oxide": "4.3.3",
|
|
24
|
+
"@types/bun": "^1.4.0",
|
|
25
|
+
"@types/react": "^19.2.0",
|
|
26
|
+
"tailwindcss": "4.3.3",
|
|
27
|
+
"typescript": "^7.0.2",
|
|
28
|
+
"wrangler": "^4.149.0"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import Counter from '../components/Counter'
|
|
2
|
+
|
|
3
|
+
const link = 'font-semibold text-amber-ink underline underline-offset-[0.2em] hover:decoration-2 focus-visible:rounded focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-amber-ink'
|
|
4
|
+
|
|
5
|
+
export default function HomePage() {
|
|
6
|
+
return (
|
|
7
|
+
<>
|
|
8
|
+
<header className="flex items-center gap-[0.9rem]">
|
|
9
|
+
<svg className="flex-none" viewBox="0 0 32 32" width="44" height="44" aria-hidden="true">
|
|
10
|
+
<rect width="32" height="32" rx="8" fill="#062323" />
|
|
11
|
+
<path d="M7 19a9 9 0 0 1 18 0z" fill="#f2a516" />
|
|
12
|
+
<rect x="5" y="20.5" width="22" height="2.2" rx="1.1" fill="#f2a516" />
|
|
13
|
+
<rect x="10" y="24.6" width="12" height="2" rx="1" fill="#f2a516" opacity=".55" />
|
|
14
|
+
</svg>
|
|
15
|
+
<h1 className="m-0 text-4xl leading-[1.1] font-bold tracking-[-0.01em]">brust app</h1>
|
|
16
|
+
</header>
|
|
17
|
+
<p className="mt-4 mb-8 text-lg text-ink-2">Edit pages/HomePage.tsx and save. The page reloads.</p>
|
|
18
|
+
<section className="rounded-2xl border border-rule bg-sheet p-6">
|
|
19
|
+
<Counter />
|
|
20
|
+
<p className="mt-4 mb-0 text-[0.9375rem] text-ink-2">useState only, so this button is native: a signal in the browser, no React shipped.</p>
|
|
21
|
+
</section>
|
|
22
|
+
<nav className="mt-8 flex flex-wrap gap-x-6 gap-y-2" aria-label="Learn more">
|
|
23
|
+
<a className={link} href="https://brust.dev">
|
|
24
|
+
Docs
|
|
25
|
+
</a>
|
|
26
|
+
<a className={link} href="https://brust.dev/docs/routing">
|
|
27
|
+
Routes
|
|
28
|
+
</a>
|
|
29
|
+
<a className={link} href="https://brust.dev/docs/actions">
|
|
30
|
+
Actions
|
|
31
|
+
</a>
|
|
32
|
+
</nav>
|
|
33
|
+
</>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="8" fill="#062323"/><path d="M7 19a9 9 0 0 1 18 0z" fill="#f2a516"/><rect x="5" y="20.5" width="22" height="2.2" rx="1.1" fill="#f2a516"/><rect x="10" y="24.6" width="12" height="2" rx="1" fill="#f2a516" opacity=".55"/></svg>
|
|
@@ -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
|
+
}
|