@brust/core 2.0.0-alpha.3 → 2.0.0-alpha.31

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 (64) hide show
  1. package/README.md +66 -6
  2. package/native/index.d.ts +82 -4
  3. package/native/index.js +58 -54
  4. package/package.json +29 -15
  5. package/src/actions/client.ts +53 -7
  6. package/src/actions/elysia.ts +1 -1
  7. package/src/actions/load.ts +82 -1
  8. package/src/actions/prefix.ts +44 -2
  9. package/src/actions-bridge.ts +44 -9
  10. package/src/assets.d.ts +55 -0
  11. package/src/browser.ts +6 -1
  12. package/src/build/actions-only.ts +86 -0
  13. package/src/build/build-eval.ts +332 -0
  14. package/src/build/build-icons.ts +69 -0
  15. package/src/build/bundle.ts +466 -54
  16. package/src/build/cf-assets.ts +4 -2
  17. package/src/build/cf-entry.ts +9 -4
  18. package/src/build/cf-wrangler.ts +3 -1
  19. package/src/build/cloudflare.ts +34 -13
  20. package/src/build/compile.ts +263 -6
  21. package/src/build/config.ts +138 -11
  22. package/src/build/css.ts +353 -0
  23. package/src/build/esm-resolve.ts +170 -0
  24. package/src/build/features.ts +119 -0
  25. package/src/build/index.ts +210 -27
  26. package/src/build/manifest.ts +94 -7
  27. package/src/build/pkg-src.ts +514 -0
  28. package/src/build/recognised.ts +19 -0
  29. package/src/build/scan.ts +71 -20
  30. package/src/build/server-only.ts +62 -0
  31. package/src/build/ssg-render.ts +65 -0
  32. package/src/build/ssg.ts +146 -0
  33. package/src/build/static-worker.ts +91 -0
  34. package/src/build/static.ts +259 -0
  35. package/src/build/tailwind.ts +117 -0
  36. package/src/build/tsconfig-paths.ts +67 -0
  37. package/src/build-time.ts +10 -0
  38. package/src/cache-adapter/types.ts +1 -1
  39. package/src/cf.ts +4 -1
  40. package/src/cli.ts +40 -4
  41. package/src/config.ts +111 -25
  42. package/src/css-modules.d.ts +8 -0
  43. package/src/dev/bun.ts +378 -0
  44. package/src/dev/client.ts +92 -0
  45. package/src/dev/devtools-data.ts +23 -0
  46. package/src/dev/devtools.ts +358 -0
  47. package/src/dev/diag.ts +30 -0
  48. package/src/dev/proxy.ts +256 -0
  49. package/src/dev/tui.ts +144 -0
  50. package/src/dev/watch.ts +130 -0
  51. package/src/dev.ts +15 -28
  52. package/src/handlers.ts +116 -10
  53. package/src/index.ts +6 -1
  54. package/src/native.ts +32 -1
  55. package/src/navigate-browser.ts +19 -0
  56. package/src/navigate.ts +19 -0
  57. package/src/navigation.ts +91 -0
  58. package/src/routes.ts +31 -1
  59. package/src/run.ts +65 -13
  60. package/src/shadcn/init.ts +513 -0
  61. package/src/shadcn/jsonc.ts +129 -0
  62. package/src/shadcn/registry.ts +149 -0
  63. package/src/store/builtin.ts +4 -0
  64. package/src/worker.ts +105 -22
@@ -1,16 +1,37 @@
1
1
  // Build-time settings from `brust.toml` `[build]` (the runtime sections are `../config.ts`):
2
2
  // [build]
3
3
  // server_only = ["lib/server", "@acme/db"] # import prefixes / app-root-relative paths
4
+ // source_roots = ["../shared"] # F256: directories besides the app a relative import may reach (monorepo)
4
5
  // `server_only` is the app config `serverOnly` of the compiler spec (§8.2): the compiler refuses
5
6
  // such an import in client code, and every browser bundle refuses to include it.
6
- import { join } from 'node:path'
7
- import { DEFAULT_ACTION_PREFIX, DEFAULT_MAX_BODY_BYTES, DEFAULT_MAX_RESPONSE_BYTES, MAX_ACTION_BYTES, prefixError } from '../actions/prefix'
7
+ import { statSync } from 'node:fs'
8
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path'
9
+ import {
10
+ ACTION_CREDENTIALS,
11
+ type ActionCredentials,
12
+ DEFAULT_ACTION_PREFIX,
13
+ DEFAULT_MAX_BODY_BYTES,
14
+ DEFAULT_MAX_RESPONSE_BYTES,
15
+ DEFAULT_MAX_STREAM_BYTES,
16
+ DEFAULT_STREAM_IDLE_MS,
17
+ DEFAULT_STREAM_TOTAL_MS,
18
+ MAX_ACTION_BYTES,
19
+ MAX_STREAM_BYTES,
20
+ baseUrlError,
21
+ prefixError,
22
+ streamEntryError,
23
+ } from '../actions/prefix'
8
24
  import { ADAPTER_NAMES, BUILTINS, installHint } from '../cache-adapter/load'
9
25
  import { BuildError } from './errors'
10
26
 
11
27
  // [actions] (opt-in, ruling R6): present table => actions on. Both byte caps cross napi as u32 (F82: 1..64 MiB / 256 KiB..64 MiB).
12
28
  // [actions]
13
29
  // entry = "actions.ts" prefix = "/_brust/action" max_body_bytes = 1048576 max_response_bytes = 8388608 cross_site = "reject" | "allow" (F87)
30
+ // base_url = "https://api.example.com" credentials = "same-origin" | "include" | "omit" (L16: the browser client's target; runtime settings, validated here too)
31
+ // `prefix = "/"` is accepted only for an actions-only build (L16 R16.4).
32
+ // F475: stream = ["POST /upload", "PUT /files/*"] (relative to the prefix; each a `{ parse: 'none' }` route) streams those bodies
33
+ // max_stream_bytes = 268435456 (<= 4294967296) stream_idle_ms = 30000 stream_total_ms = 900000 max_streams = <workers x slots / 4>
34
+ // `ACTION_INLINE_BODY_BYTES` (Rust): the inline/heap threshold of an action body, independent of the response slot size (`slotBytes`).
14
35
  const MIN_RESPONSE_BYTES = 262_144
15
36
 
16
37
  export interface ActionsBuildConfig {
@@ -19,6 +40,22 @@ export interface ActionsBuildConfig {
19
40
  maxBodyBytes: number
20
41
  maxResponseBytes: number
21
42
  crossSite: 'reject' | 'allow'
43
+ /** `[actions] base_url`; with no actions file present the build is page-only (L16 R16.4b). */
44
+ baseUrl: string | undefined
45
+ credentials: ActionCredentials
46
+ /** `entry` was written in brust.toml (a missing file is then an error, never a page-only build). */
47
+ entryExplicit: boolean
48
+ /** F475: the streamed routes and their bounds; absent = no `stream` list (or an empty one). */
49
+ stream?: ActionsStreamConfig
50
+ }
51
+
52
+ /** F475 `[actions] stream` and its bounds, as the manifest carries them. */
53
+ export interface ActionsStreamConfig {
54
+ stream: string[]
55
+ maxStreamBytes: number
56
+ streamIdleMs: number
57
+ streamTotalMs: number
58
+ maxStreams?: number
22
59
  }
23
60
 
24
61
  // [cloudflare] (M4-CF; only read by `--target cloudflare`, validated always so errors surface early):
@@ -66,6 +103,10 @@ export interface BuildConfig {
66
103
  actions: ActionsBuildConfig | null
67
104
  cloudflare: CloudflareBuildConfig | null
68
105
  cache: CacheBuildConfig | null
106
+ /** `[css] global` (L19, R19.1): app-root-relative stylesheets every route links first, in order. */
107
+ cssGlobal: string[]
108
+ /** `[build] source_roots` (F256): directories outside the app a relative import may reach, normalised and relative to the app root (`../shared`). Empty = the app only. */
109
+ sourceRoots: string[]
69
110
  }
70
111
 
71
112
  const isTable = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v)
@@ -232,7 +273,7 @@ function resolveAlias(file: string, appRoot: string, p: string): string {
232
273
  }
233
274
  }
234
275
 
235
- function parseActions(file: string, raw: unknown): ActionsBuildConfig | null {
276
+ function parseActions(file: string, raw: unknown, actionsOnly: boolean): ActionsBuildConfig | null {
236
277
  if (raw === undefined) return null
237
278
  if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) throw new BuildError('config', `${file}: [actions] must be a table`)
238
279
  const t = raw as Record<string, unknown>
@@ -243,7 +284,8 @@ function parseActions(file: string, raw: unknown): ActionsBuildConfig | null {
243
284
  }
244
285
  let prefix = DEFAULT_ACTION_PREFIX
245
286
  if (t.prefix !== undefined) {
246
- const why = prefixError(t.prefix)
287
+ // `/` is the prefix of an actions-only deployment; a page-only build (base_url, no local app) may name it too.
288
+ const why = prefixError(t.prefix, actionsOnly || t.base_url !== undefined)
247
289
  if (why) throw new BuildError('config', `${file}: actions.prefix ${why}`)
248
290
  prefix = t.prefix as string
249
291
  }
@@ -266,15 +308,98 @@ function parseActions(file: string, raw: unknown): ActionsBuildConfig | null {
266
308
  if (t.cross_site !== 'reject' && t.cross_site !== 'allow') throw new BuildError('config', `${file}: actions.cross_site must be "reject" or "allow"`)
267
309
  crossSite = t.cross_site
268
310
  }
269
- return { entry, prefix, maxBodyBytes, maxResponseBytes, crossSite }
311
+ let baseUrl: string | undefined
312
+ if (t.base_url !== undefined) {
313
+ const why = baseUrlError(t.base_url)
314
+ if (why) throw new BuildError('config', `${file}: actions.base_url ${why}`)
315
+ baseUrl = t.base_url as string
316
+ }
317
+ let credentials: ActionCredentials = 'same-origin'
318
+ if (t.credentials !== undefined) {
319
+ if (typeof t.credentials !== 'string' || !ACTION_CREDENTIALS.includes(t.credentials as ActionCredentials))
320
+ throw new BuildError('config', `${file}: actions.credentials must be one of ${ACTION_CREDENTIALS.join(', ')} (got ${JSON.stringify(t.credentials)})`)
321
+ credentials = t.credentials as ActionCredentials
322
+ }
323
+ const stream = parseStream(file, t)
324
+ return { entry, prefix, maxBodyBytes, maxResponseBytes, crossSite, baseUrl, credentials, entryExplicit: t.entry !== undefined, ...(stream ? { stream } : {}) }
325
+ }
326
+
327
+ /** F475: `stream` + its bounds; the bounds are validated even without a list, and dropped with it. */
328
+ function parseStream(file: string, t: Record<string, unknown>): ActionsStreamConfig | undefined {
329
+ const int = (key: string, min: number, max: number, dflt: number): number => {
330
+ const n = t[key]
331
+ if (n === undefined) return dflt
332
+ if (typeof n !== 'number' || !Number.isInteger(n) || n < min || n > max) throw new BuildError('config', `${file}: actions.${key} must be an integer in ${min}..${max} (got ${String(n)})`)
333
+ return n
334
+ }
335
+ const maxStreamBytes = int('max_stream_bytes', 1, MAX_STREAM_BYTES, DEFAULT_MAX_STREAM_BYTES)
336
+ const streamIdleMs = int('stream_idle_ms', 1, U32_MAX, DEFAULT_STREAM_IDLE_MS)
337
+ const streamTotalMs = int('stream_total_ms', 1, U32_MAX, DEFAULT_STREAM_TOTAL_MS)
338
+ const maxStreams = t.max_streams === undefined ? undefined : int('max_streams', 1, U32_MAX, 1)
339
+ if (t.stream === undefined) return undefined
340
+ if (!Array.isArray(t.stream)) throw new BuildError('config', `${file}: actions.stream must be an array of "<METHOD> <path>" strings`)
341
+ const stream: string[] = []
342
+ for (const e of t.stream) {
343
+ const why = streamEntryError(e)
344
+ if (why) throw new BuildError('config', `${file}: actions.stream entry ${why}`)
345
+ const [m, p] = (e as string).trim().split(/ +/)
346
+ stream.push(`${m!.toUpperCase()} ${p}`)
347
+ }
348
+ if (stream.length === 0) return undefined
349
+ return { stream, maxStreamBytes, streamIdleMs, streamTotalMs, ...(maxStreams !== undefined ? { maxStreams } : {}) }
270
350
  }
271
351
 
272
352
  const DEFAULT_ENTRY = 'actions.ts'
273
353
 
274
- export async function loadBuildConfig(appRoot: string): Promise<BuildConfig> {
354
+ /** `[css]`: only `global`, a list of `.css` files relative to the app root (checked to exist by the build). */
355
+ function parseCss(file: string, raw: unknown): string[] {
356
+ if (raw === undefined) return []
357
+ if (!isTable(raw)) throw new BuildError('config', `${file}: [css] must be a table`)
358
+ for (const k of Object.keys(raw)) if (k !== 'global') throw new BuildError('config', `${file}: unknown key css.${k} (allowed: global)`)
359
+ const g = raw.global
360
+ if (g === undefined) return []
361
+ if (!Array.isArray(g) || g.some((p) => typeof p !== 'string' || !p.trim().endsWith('.css')))
362
+ throw new BuildError('config', `${file}: css.global must be an array of .css paths (relative to the app root)`)
363
+ return g.map((p: string) => p.trim())
364
+ }
365
+
366
+ /**
367
+ * `[build] source_roots` (F256): extra directories, relative to the app root, that a relative import may reach besides
368
+ * the app itself (`../shared` in a monorepo). Each is normalised (`../a/../shared` = `../shared`), must exist and be a
369
+ * directory, and confines imports the way the app root does: lexically, so `../shared/../secret` is outside it.
370
+ * An entry inside the app adds nothing and is dropped; the app root itself or an ancestor of it is refused (F272). Returns the roots that lie outside, `/`-separated.
371
+ */
372
+ export function parseSourceRoots(file: string, appRoot: string, raw: unknown): string[] {
373
+ if (raw === undefined) return []
374
+ if (!Array.isArray(raw) || raw.some((p) => typeof p !== 'string' || p.trim() === '' || p.includes('\0')))
375
+ throw new BuildError('config', `${file}: build.source_roots must be an array of non-empty strings (paths relative to the app root)`)
376
+ const out = new Set<string>()
377
+ for (const p of raw as string[]) {
378
+ if (isAbsolute(p.trim())) throw new BuildError('config', `${file}: build.source_roots entry ${JSON.stringify(p)} must be relative to the app root`)
379
+ const abs = resolve(appRoot, p.trim())
380
+ let dir = false
381
+ try {
382
+ dir = statSync(abs).isDirectory()
383
+ } catch {}
384
+ if (!dir) throw new BuildError('config', `${file}: build.source_roots entry ${JSON.stringify(p)} (${abs}) is not an existing directory`)
385
+ if (abs === resolve(abs, '..')) throw new BuildError('config', `${file}: build.source_roots entry ${JSON.stringify(p)} is the filesystem root`)
386
+ const rel = relative(appRoot, abs).split(sep).join('/')
387
+ // F272: the app root or an ancestor of it (`.`, `..`, `../..`) contains every import the app can make, so it would
388
+ // switch F243's confinement off for that whole level; name the directory that is actually shared instead.
389
+ if (rel === '' || rel.split('/').every((seg) => seg === '..'))
390
+ throw new BuildError(
391
+ 'config',
392
+ `${file}: build.source_roots entry ${JSON.stringify(p)} is the app root or a directory that contains it, which would allow every relative import; list the shared directory itself (e.g. "../shared")`,
393
+ )
394
+ if (rel === '..' || rel.startsWith('../')) out.add(rel)
395
+ }
396
+ return [...out].sort()
397
+ }
398
+
399
+ export async function loadBuildConfig(appRoot: string, o: { actionsOnly?: boolean } = {}): Promise<BuildConfig> {
275
400
  const file = join(appRoot, 'brust.toml')
276
401
  const f = Bun.file(file)
277
- if (!(await f.exists())) return { serverOnly: [], actions: null, cloudflare: null, cache: null }
402
+ if (!(await f.exists())) return { serverOnly: [], actions: null, cloudflare: null, cache: null, cssGlobal: [], sourceRoots: [] }
278
403
  let parsed: unknown
279
404
  try {
280
405
  parsed = Bun.TOML.parse(await f.text())
@@ -282,15 +407,17 @@ export async function loadBuildConfig(appRoot: string): Promise<BuildConfig> {
282
407
  throw new BuildError('config', `failed to parse ${file}: ${(e as Error).message}`)
283
408
  }
284
409
  const root = (parsed ?? {}) as Record<string, unknown>
285
- const actions = parseActions(file, root.actions)
410
+ const actions = parseActions(file, root.actions, o.actionsOnly === true)
286
411
  const cloudflare = parseCloudflare(file, appRoot, root.cloudflare)
287
412
  const cache = await parseCache(file, appRoot, root.cache)
413
+ const cssGlobal = parseCss(file, root.css)
288
414
  const build = root.build
289
- if (build === undefined) return { serverOnly: [], actions, cloudflare, cache }
415
+ if (build === undefined) return { serverOnly: [], actions, cloudflare, cache, cssGlobal, sourceRoots: [] }
290
416
  if (build === null || typeof build !== 'object' || Array.isArray(build)) throw new BuildError('config', `${file}: [build] must be a table`)
417
+ const sourceRoots = parseSourceRoots(file, appRoot, (build as Record<string, unknown>).source_roots)
291
418
  const so = (build as Record<string, unknown>).server_only
292
- if (so === undefined) return { serverOnly: [], actions, cloudflare, cache }
419
+ if (so === undefined) return { serverOnly: [], actions, cloudflare, cache, cssGlobal, sourceRoots }
293
420
  if (!Array.isArray(so) || so.some((p) => typeof p !== 'string' || p.trim() === ''))
294
421
  throw new BuildError('config', `${file}: build.server_only must be an array of non-empty strings`)
295
- return { serverOnly: so.map((p: string) => p.trim().replace(/^\.\//, '')), actions, cloudflare, cache }
422
+ return { serverOnly: so.map((p: string) => p.trim().replace(/^\.\//, '')), actions, cloudflare, cache, cssGlobal, sourceRoots }
296
423
  }
@@ -0,0 +1,353 @@
1
+ // CSS for `brust build` (gaps round L19, R19.1-R19.4): which stylesheets each route links, one
2
+ // minified content-hashed `client/<stem>-<hex10>.css` per source file, the class maps of CSS
3
+ // Modules, and the one plugin every JS build uses so no JS build ever emits CSS.
4
+ //
5
+ // - A route's CSS: `[css] global` first, then every `.css` import reachable from its chain files
6
+ // (root layout → page), depth-first in import order, first occurrence wins.
7
+ // - CSS Modules: one in-process Bun.build of every source (a JS entry per file), so one set of class
8
+ // names serves the server (jobs.js), the browser chunks and the Cloudflare worker alike.
9
+ // - Relative `url()` fonts and images are emitted next to the CSS as `client/<name>-<hex10>.<ext>`;
10
+ // absolute `/public/*` and `/_brust/*` stay as written.
11
+ import { createHash } from 'node:crypto'
12
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
13
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path'
14
+ import type { BunPlugin } from 'bun'
15
+ import type { FlatRoute } from '../routes'
16
+ import { BuildError } from './errors'
17
+ import { loadTailwind } from './tailwind'
18
+
19
+ const hex = (b: string | Uint8Array) => createHash('sha256').update(b).digest('hex').slice(0, 10)
20
+ const real = (p: string) => {
21
+ try {
22
+ return realpathSync(p)
23
+ } catch {
24
+ return p
25
+ }
26
+ }
27
+ const isCss = (p: string) => p.endsWith('.css')
28
+ const isModule = (p: string) => p.endsWith('.module.css')
29
+ /** A name the static rules serve (`[A-Za-z0-9_.-]`, no leading dot). */
30
+ const safeName = (s: string) => s.replace(/[^A-Za-z0-9_.-]/g, '_').replace(/^\.+/, '_')
31
+
32
+ /** The stylesheets one source file reaches through static and dynamic imports, in order, real paths. Source
33
+ * files outside node_modules are followed; a `.css` import (an app file or a package's CSS) is
34
+ * collected, never followed (its `@import`s are the CSS bundler's). */
35
+ export class CssGraph {
36
+ private memo = new Map<string, string[]>()
37
+ private active = new Set<string>()
38
+ private transpilers = new Map<string, InstanceType<typeof Bun.Transpiler>>()
39
+
40
+ of(file: string): string[] {
41
+ const f = real(file)
42
+ const hit = this.memo.get(f)
43
+ if (hit) return hit
44
+ if (this.active.has(f)) return [] // an import cycle: the outer visit already lists what follows
45
+ this.active.add(f)
46
+ const out: string[] = []
47
+ const add = (c: string) => {
48
+ if (!out.includes(c)) out.push(c)
49
+ }
50
+ const loader = /\.(tsx|ts|jsx|js|mjs)$/.exec(f)?.[1]
51
+ let src = ''
52
+ try {
53
+ src = loader ? readFileSync(f, 'utf8') : ''
54
+ } catch {}
55
+ if (loader && src) {
56
+ const l = loader === 'mjs' ? 'js' : (loader as 'tsx' | 'ts' | 'jsx' | 'js')
57
+ let t = this.transpilers.get(l)
58
+ if (!t) this.transpilers.set(l, (t = new Bun.Transpiler({ loader: l })))
59
+ for (const imp of t.scanImports(src)) {
60
+ // A dynamic import() is walked too (review N4): its CSS is linked eagerly for the route, so a
61
+ // lazily loaded component is styled on first paint and its CSS Modules are built.
62
+ if (imp.kind !== 'import-statement' && imp.kind !== 'dynamic-import') continue
63
+ let dep: string
64
+ try {
65
+ dep = real(Bun.resolveSync(imp.path, dirname(f)))
66
+ } catch {
67
+ continue // unresolvable: the JS build reports it
68
+ }
69
+ if (isCss(dep)) add(dep)
70
+ else if (!dep.split(sep).includes('node_modules')) for (const c of this.of(dep)) add(c)
71
+ }
72
+ }
73
+ this.active.delete(f)
74
+ this.memo.set(f, out)
75
+ return out
76
+ }
77
+ }
78
+
79
+ /** R19.1: every route's ordered source stylesheets (real paths), by route id. `chainFiles` maps a leaf to
80
+ * its chain's component files, root layout first. */
81
+ export function routeCssSources(leaves: FlatRoute[], chainFiles: (l: FlatRoute) => string[], globals: string[], graph = new CssGraph()): Map<string, string[]> {
82
+ const out = new Map<string, string[]>()
83
+ for (const leaf of leaves) {
84
+ const list: string[] = [...globals]
85
+ for (const file of chainFiles(leaf)) for (const c of graph.of(file)) if (!list.includes(c)) list.push(c)
86
+ out.set(leaf.id, list)
87
+ }
88
+ return out
89
+ }
90
+
91
+ /** `[css] global` resolved: each a `.css` file inside the app. */
92
+ export function resolveGlobals(appRoot: string, globals: string[]): string[] {
93
+ return globals.map((g) => {
94
+ const abs = resolve(appRoot, g)
95
+ const rel = relative(appRoot, abs)
96
+ if (isAbsolute(g) || rel.startsWith('..') || isAbsolute(rel) || !existsSync(abs)) throw new BuildError('css', `css.global ${g} is not a file inside the app`)
97
+ return real(abs)
98
+ })
99
+ }
100
+
101
+ export interface CssBuild {
102
+ /** source (real path) → `client/<stem>-<hex10>.css`; absent when the file emits no CSS. */
103
+ file: Map<string, string>
104
+ /** `.module.css` source (real path) → its class map. */
105
+ maps: Map<string, Record<string, string>>
106
+ }
107
+
108
+ const CLASS = /\.(-?[_a-zA-Z][_a-zA-Z0-9-]*)/g
109
+
110
+ /** The asset types `static_rules::is_safe_client_asset_filename` serves from `/_brust/client/` (review N1). */
111
+ export const SERVED_ASSET_EXT = ['.woff2', '.woff', '.ttf', '.otf', '.eot', '.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif', '.svg', '.ico']
112
+
113
+ /** F411: the served name of an asset file, `<name>-<hex10>.<ext>`: what a stylesheet's `url()` is rewritten to
114
+ * (`emitSourceAssets`) and what an `import` of the file gives (`cssPlugin`), so the two are one file. */
115
+ function hashedAsset(path: string): { name: string; bytes: Uint8Array } {
116
+ const bytes = new Uint8Array(readFileSync(path))
117
+ return { name: `${safeName(basename(path, extname(path)))}-${hex(bytes)}${extname(path).toLowerCase()}`, bytes }
118
+ }
119
+
120
+ /** An asset file by extension (`SERVED_ASSET_EXT`). */
121
+ const ASSET_FILE = /\.(?:woff2?|ttf|otf|eot|png|jpe?g|gif|webp|avif|svg|ico)$/i
122
+
123
+ const SOURCE_URL = /url\(\s*(["']?)([^"')]+?)\1\s*\)/g
124
+
125
+ /** F393: a `url()` that is the absolute path of a file on disk (see the `loader` in `buildCss`) becomes a
126
+ * hashed `./<name>-<hex10>.<ext>` client asset at any size. `/public/…` and `/_brust/…` stay as written. */
127
+ function emitSourceAssets(text: string, assets: { name: string; bytes: Uint8Array }[], suffixed: Map<string, string>): string {
128
+ const emit = (path: string, suffix: string) => {
129
+ const { name, bytes } = hashedAsset(path)
130
+ if (!assets.some((a) => a.name === name)) assets.push({ name, bytes })
131
+ return /^[^\s"'()\\]*$/.test(suffix) ? `url(./${name}${suffix})` : `url("./${name}${suffix.replace(/["\\]/g, '\\$&')}")`
132
+ }
133
+ return text.replace(SOURCE_URL, (whole, _q: string, path: string) => {
134
+ const abs = suffixed.get(path)
135
+ if (abs !== undefined) return emit(abs, path.slice(path.search(/[?#]/)))
136
+ const ext = extname(path).toLowerCase()
137
+ if (!path.startsWith('/') || !SERVED_ASSET_EXT.includes(ext) || path.startsWith('/public/') || path.startsWith('/_brust/') || !existsSync(path)) return whole
138
+ return emit(path, '')
139
+ })
140
+ }
141
+
142
+ /** F393: a `url()` asset with a `?query` / `#fragment` (`?v=3`, `?#iefix`, an SVG font's `#id`) is not given
143
+ * Bun's path form: a plugin-resolved specifier is printed as written. This plugin resolves such a specifier
144
+ * itself and records the file it names, so `emitSourceAssets` can copy it and keep the suffix verbatim. The
145
+ * same text naming two different files (from two importers) fails the build rather than guess. */
146
+ function suffixPlugin(suffixed: Map<string, string>): BunPlugin {
147
+ return {
148
+ name: 'brust-css-asset-suffix',
149
+ setup(b) {
150
+ b.onResolve({ filter: /\.(?:woff2?|ttf|otf|eot|png|jpe?g|gif|webp|avif|svg|ico)[?#]/i }, (a) => {
151
+ const at = a.path.search(/[?#]/)
152
+ const file = a.path.slice(0, at)
153
+ let abs: string
154
+ try {
155
+ abs = file.startsWith('.') || isAbsolute(file) ? resolve(dirname(a.importer), file) : Bun.resolveSync(file, dirname(a.importer))
156
+ } catch {
157
+ return undefined
158
+ }
159
+ if (!existsSync(abs)) return undefined
160
+ const have = suffixed.get(a.path)
161
+ if (have !== undefined && have !== abs) throw new BuildError('css-asset-ambiguous', `url(${a.path}) names ${have} and ${abs}; give them different specifiers`)
162
+ suffixed.set(a.path, abs)
163
+ return { path: abs }
164
+ })
165
+ },
166
+ }
167
+ }
168
+
169
+ /** R19.2/R19.4: builds every source stylesheet (one JS entry each) into `<dist>/client/`. `minify`
170
+ * is off only for a development build (`brust dev`, review N3). */
171
+ export async function buildCss(
172
+ dist: string,
173
+ sources: string[],
174
+ minify = true,
175
+ o: { appRoot?: string; log?: (s: string) => void } = {},
176
+ ): Promise<CssBuild> {
177
+ const file = new Map<string, string>()
178
+ const maps = new Map<string, Record<string, string>>()
179
+ if (sources.length === 0) return { file, maps }
180
+ const appRoot = o.appRoot ?? process.cwd()
181
+ const tw = tailwindUse(sources)
182
+ const plugins: BunPlugin[] = []
183
+ if (tw.using.length > 0) {
184
+ // a stylesheet compiles with Tailwind when it, or a relative @import of it, uses Tailwind
185
+ const via = new Map<string, boolean>()
186
+ const uses = (f: string) => {
187
+ const k = real(f)
188
+ let hit = via.get(k)
189
+ if (hit === undefined) via.set(k, (hit = tailwindUse([k]).using.length > 0))
190
+ return hit
191
+ }
192
+ plugins.push(await loadTailwind(appRoot, tw.using[0]!, uses, o.log))
193
+ if (tw.imports.length > 1)
194
+ o.log?.(
195
+ `[brust] warning tailwind @import "tailwindcss" is in ${tw.imports.length} files (${tw.imports.map((f) => relative(appRoot, f)).join(', ')}): each one ships the whole of Tailwind; import it once (e.g. app.css) and use @reference elsewhere`,
196
+ )
197
+ }
198
+ const stage = join(dist, '.stage', 'css')
199
+ mkdirSync(stage, { recursive: true })
200
+ const entries = sources.map((src, i) => {
201
+ const e = join(stage, `s${i}.js`)
202
+ writeFileSync(e, isModule(src) ? `export { default } from ${JSON.stringify(src)}\n` : `import ${JSON.stringify(src)}\n`)
203
+ return e
204
+ })
205
+ const suffixed = new Map<string, string>()
206
+ plugins.push(suffixPlugin(suffixed))
207
+ const res = await Bun.build({
208
+ entrypoints: entries,
209
+ target: 'browser',
210
+ format: 'esm',
211
+ minify,
212
+ naming: { entry: '[name]-[hash].[ext]', chunk: '[name]-[hash].[ext]', asset: '[name]-[hash].[ext]' },
213
+ external: ['/public/*', '/_brust/*'],
214
+ root: appRoot,
215
+ plugins,
216
+ // F393, verified on Bun 1.4.4 only: its CSS bundler turns an asset under the size threshold into a `data:`
217
+ // URI whatever `loader: file` says, and `loader: dataurl` does the opposite (the name is inverted): it leaves
218
+ // the url() as the absolute path of the source file. That path is the marker `emitSourceAssets` copies and
219
+ // rewrites. Re-check on every Bun upgrade; the F393 tests in test/css.test.ts go red if the quirk changes.
220
+ // bun-types 1.4.2's `Loader` omits `dataurl`, which the runtime accepts.
221
+ loader: Object.fromEntries(SERVED_ASSET_EXT.map((e) => [e, 'dataurl'])) as unknown as Bun.BuildConfig['loader'],
222
+ })
223
+ if (!res.success) throw new BuildError('css', res.logs.map(String).join('\n'))
224
+ // Assets a stylesheet references (fonts, images): `<name>-<hex10>.<ext>`, references rewritten.
225
+ const rename = new Map<string, string>()
226
+ const assets: { name: string; bytes: Uint8Array }[] = []
227
+ const css = new Map<number, string>()
228
+ const js = new Map<number, string>()
229
+ const unserved: string[] = []
230
+ for (const o of res.outputs) {
231
+ const name = basename(o.path)
232
+ const entry = /^s(\d+)-[^-.]+\.(css|js)$/.exec(name)
233
+ if (entry) {
234
+ ;(entry[2] === 'css' ? css : js).set(Number(entry[1]), await o.text())
235
+ continue
236
+ }
237
+ const m = /^(.*)-[^-.]+(\.[^.]+)$/.exec(name)
238
+ if (!m) throw new BuildError('css', `unexpected output ${name}`)
239
+ const ext = m[2]!.toLowerCase()
240
+ if (!SERVED_ASSET_EXT.includes(ext)) {
241
+ unserved.push(name)
242
+ continue
243
+ }
244
+ const bytes = new Uint8Array(await o.arrayBuffer())
245
+ const to = `${safeName(m[1]!)}-${hex(bytes)}${ext}`
246
+ rename.set(name, to)
247
+ assets.push({ name: to, bytes })
248
+ }
249
+ // Fail closed (review N1): an asset the server would not serve from /_brust/client would 404.
250
+ if (unserved.length > 0) {
251
+ const lines = unserved.map((name) => {
252
+ const by = sources.filter((_, i) => css.get(i)?.includes(name))
253
+ const [, stem, ext] = /^(.*)-[^-.]+(\.[^.]+)$/.exec(name)!
254
+ return `${stem}${ext} (url() in ${by.join(', ') || 'a stylesheet'})`
255
+ })
256
+ throw new BuildError(
257
+ 'css-asset-type',
258
+ `${lines.join('; ')}: brust serves only ${SERVED_ASSET_EXT.join(' ')} from /_brust/client; put the file in public/ and reference it as /public/…`,
259
+ )
260
+ }
261
+ for (const [i, t] of css) css.set(i, emitSourceAssets(t, assets, suffixed))
262
+ for (const a of assets) writeFileSync(join(dist, 'client', a.name), a.bytes)
263
+ for (const [i, src] of sources.entries()) {
264
+ let text = css.get(i)
265
+ if (text !== undefined && text.trim() !== '') {
266
+ for (const [from, to] of rename) text = text.split(from).join(to)
267
+ const stem = safeName(basename(src).replace(/\.css$/, ''))
268
+ const out = `${stem}-${hex(text)}.css`
269
+ writeFileSync(join(dist, 'client', out), text)
270
+ file.set(src, `client/${out}`)
271
+ }
272
+ if (!isModule(src)) continue
273
+ const mod = join(stage, `map${i}.mjs`)
274
+ writeFileSync(mod, js.get(i) ?? 'export default {}')
275
+ const map = { ...((await import(mod)).default as Record<string, string>) }
276
+ // R19.4: every class the map hands out exists in the CSS it ships with.
277
+ const have = new Set([...(text ?? '').matchAll(CLASS)].map((x) => x[1]!))
278
+ for (const [k, v] of Object.entries(map))
279
+ for (const cls of v.split(/\s+/).filter(Boolean))
280
+ if (!have.has(cls)) throw new BuildError('css', `${src}: class ${k} maps to ${cls}, which is not in the emitted CSS`)
281
+ maps.set(src, map)
282
+ }
283
+ return { file, maps }
284
+ }
285
+
286
+ // ---- Tailwind CSS v4 (gaps round L21, R21.1) ----
287
+
288
+ /** A stylesheet that needs the Tailwind compiler. */
289
+ const TAILWIND_USE = /@import\s+(?:url\(\s*)?["']tailwindcss(?:\/[^"']*)?["']|@apply\b|@reference\b|@theme\b|@plugin\b/
290
+ const TAILWIND_IMPORT = /@import\s+(?:url\(\s*)?["']tailwindcss["']/
291
+ const CSS_IMPORT = /@import\s+(?:url\(\s*)?["'](\.{1,2}\/[^"']+)["']/g
292
+
293
+ /** Which sources (or the relative `@import`s they pull in) use Tailwind, and which `@import "tailwindcss"`. */
294
+ export function tailwindUse(sources: string[]): { using: string[]; imports: string[] } {
295
+ const using: string[] = []
296
+ const imports: string[] = []
297
+ const seen = new Set<string>()
298
+ const visit = (f: string) => {
299
+ if (seen.has(f)) return
300
+ seen.add(f)
301
+ let text = ''
302
+ try {
303
+ text = readFileSync(f, 'utf8')
304
+ } catch {
305
+ return
306
+ }
307
+ if (TAILWIND_USE.test(text)) using.push(f)
308
+ if (TAILWIND_IMPORT.test(text)) imports.push(f)
309
+ for (const m of text.matchAll(CSS_IMPORT)) visit(resolve(dirname(f), m[1]!))
310
+ }
311
+ for (const s of sources) visit(s)
312
+ return { using, imports }
313
+ }
314
+
315
+ /** R19.4: the one CSS plugin of every JS build. A `.module.css` import is its class map
316
+ * (`export default Object.freeze({…})`); any other `.css` import is an empty module, because the
317
+ * stylesheet itself is linked by the server (R19.3), never bundled into JS.
318
+ * F411: with `clientDir` (`<dist>/client`), an import of a served asset (`SERVED_ASSET_EXT`: a font, an image) is
319
+ * the string `/_brust/client/<name>-<hex10>.<ext>`, the very file a stylesheet's `url()` of it is rewritten to; the
320
+ * file is written there, so the URL can be preloaded (`<link rel="preload" as="font" href={font}>`). */
321
+ export function cssPlugin(maps: Map<string, Record<string, string>>, clientDir?: string): BunPlugin {
322
+ return {
323
+ name: 'brust-css',
324
+ setup(b) {
325
+ b.onLoad({ filter: /\.css$/ }, (a) => {
326
+ if (!isModule(a.path)) return { contents: '', loader: 'js' }
327
+ const map = maps.get(real(a.path))
328
+ if (!map) throw new Error(`${a.path} is imported by code the build did not reach from a route; import it from a route's component files`)
329
+ return { contents: `export default Object.freeze(${JSON.stringify(map)})\n`, loader: 'js' }
330
+ })
331
+ if (clientDir === undefined) return
332
+ b.onLoad({ filter: ASSET_FILE }, (a) => {
333
+ const { name, bytes } = hashedAsset(a.path)
334
+ const to = join(clientDir, name)
335
+ if (!existsSync(to)) {
336
+ mkdirSync(clientDir, { recursive: true })
337
+ writeFileSync(to, bytes)
338
+ }
339
+ return { contents: `export default ${JSON.stringify(`/_brust/client/${name}`)}\n`, loader: 'js' }
340
+ })
341
+ },
342
+ }
343
+ }
344
+
345
+ /** Direct `.css` imports of `file` (the route entry: linked to no route, so the build warns). */
346
+ export function directCss(file: string): string[] {
347
+ const src = readFileSync(file, 'utf8')
348
+ const t = new Bun.Transpiler({ loader: file.endsWith('.tsx') ? 'tsx' : 'ts' })
349
+ return t
350
+ .scanImports(src)
351
+ .filter((i) => i.kind === 'import-statement' && i.path.endsWith('.css'))
352
+ .map((i) => i.path)
353
+ }