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

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 +63 -4
  3. package/native/index.js +57 -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 +24 -1
  8. package/src/actions/prefix.ts +24 -2
  9. package/src/actions-bridge.ts +2 -2
  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 +7 -3
  18. package/src/build/cf-wrangler.ts +3 -1
  19. package/src/build/cloudflare.ts +21 -11
  20. package/src/build/compile.ts +263 -6
  21. package/src/build/config.ts +85 -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 +88 -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 +371 -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 +236 -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 +115 -9
  53. package/src/index.ts +6 -1
  54. package/src/native.ts +18 -0
  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 +53 -11
  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 +98 -22
@@ -21,8 +21,10 @@ export function isHashed(file: string): boolean {
21
21
 
22
22
  const segOk = (s: string) => s !== '' && !s.startsWith('.') && SEG.test(s)
23
23
 
24
- /** `client/<file>.js|.css`: `is_safe_island_filename` / `is_safe_css_filename`. */
25
- const clientOk = (f: string) => segOk(f) && !f.includes('..') && (f.endsWith('.js') || f.endsWith('.css'))
24
+ /** `client/<file>.js|.css`: `is_safe_island_filename` / `is_safe_css_filename`, plus the fonts and images a
25
+ * stylesheet references (`is_safe_client_asset_filename`, L19). */
26
+ const CLIENT_EXT = /\.(js|css|woff2|woff|ttf|otf|eot|png|jpg|jpeg|gif|webp|avif|svg|ico)$/
27
+ const clientOk = (f: string) => segOk(f) && !f.includes('..') && CLIENT_EXT.test(f)
26
28
 
27
29
  function walk(dir: string, rel = ''): string[] {
28
30
  const out: string[] = []
@@ -28,6 +28,8 @@ export interface CfEntryInput {
28
28
  entry: string
29
29
  jobsEntry: string
30
30
  actionsEntry: string | null
31
+ /** L16: no pages: the routes module and the jobs module are not imported; the handlers serve an empty route table. */
32
+ actionsOnly?: boolean
31
33
  prefix: string
32
34
  manifestPath: string
33
35
  templatesPath: string
@@ -41,16 +43,18 @@ export function cfEntrySource(i: CfEntryInput): string {
41
43
  const q = JSON.stringify
42
44
  const actions = i.actionsEntry !== null
43
45
  const c = i.cache ?? null
46
+ const pages = i.actionsOnly !== true
44
47
  return `${[
45
48
  '// generated by `brust build --target cloudflare` — do not edit (M4-CF spec §4)',
46
- `import * as R from ${q(i.entry)}`,
47
- `import J, * as JM from ${q(i.jobsEntry)}`,
49
+ ...(pages ? [`import * as R from ${q(i.entry)}`, `import J, * as JM from ${q(i.jobsEntry)}`] : []),
48
50
  ...(actions ? [`import A from ${q(i.actionsEntry)}`] : []),
49
51
  `import M from ${q(i.manifestPath)}`,
50
52
  `import T from ${q(i.templatesPath)}`,
51
53
  `import { fetchInScope, flattenRoutes, jsonCall, makeHandlers, noActions } from "@brust/core/server-handlers"`,
52
54
  ...(actions ? ['import { mountActions } from "@brust/core/server-handlers/actions"'] : []),
53
- 'const h = makeHandlers({ leaves: flattenRoutes(R.routes ?? R.default).leaves, jobs: J, manifest: M, stores: JM.stores ?? {} })',
55
+ pages
56
+ ? 'const h = makeHandlers({ leaves: flattenRoutes(R.routes ?? R.default).leaves, jobs: J, manifest: M, stores: JM.stores ?? {} })'
57
+ : 'const h = makeHandlers({ leaves: [], jobs: {}, manifest: M, stores: {} })',
54
58
  ...(actions ? [`const app = mountActions(A, ${q(i.prefix)}).compile() // startup-phase compile (R9)`] : []),
55
59
  'export const loader = jsonCall(h.loader)',
56
60
  'export const jobs = jsonCall(h.jobs)',
@@ -20,6 +20,8 @@ export interface MergeOpts {
20
20
  /** worker-build entry, relative to `worker/` (core-cf `brust.main`) */
21
21
  main: string
22
22
  version: string
23
+ /** L16: an actions-only Worker has no Static Assets (and no ASSETS binding). */
24
+ noAssets?: boolean
23
25
  }
24
26
 
25
27
  const OWNED = ['main', 'assets', 'compatibility_date', 'no_bundle', 'find_additional_modules', 'base_dir', 'rules', 'build', 'site']
@@ -106,7 +108,7 @@ export function mergeWrangler(user: UserWrangler, o: MergeOpts): { config: Json;
106
108
  ...(name !== undefined ? { name } : {}),
107
109
  main: `worker/${o.main}`,
108
110
  compatibility_date: o.compatibilityDate,
109
- assets: { directory: 'assets', binding: 'ASSETS', run_worker_first: false, html_handling: 'none', not_found_handling: 'none' },
111
+ ...(o.noAssets ? {} : { assets: { directory: 'assets', binding: 'ASSETS', run_worker_first: false, html_handling: 'none', not_found_handling: 'none' } }),
110
112
  ...Object.fromEntries(Object.entries(rest).filter(([k]) => k !== 'name')),
111
113
  }
112
114
  return { config, warnings }
@@ -8,12 +8,15 @@ import { dirname, join, relative } from 'node:path'
8
8
  import type { BunPlugin } from 'bun'
9
9
  import { BrustConfigError, type CorsConfig, loadCors } from '../config'
10
10
  import { layoutAssets } from './cf-assets'
11
+ import { serverOnlyEmptyPlugin } from './server-only'
11
12
  import { type CfConfigJson, cfEntrySource } from './cf-entry'
12
13
  import { type CoreCf, resolveCoreCf } from './cf-core'
13
14
  import { type CacheNeed, mergeWrangler, readUserWrangler, renderWrangler, type UserWrangler, verifyCacheBindings } from './cf-wrangler'
14
15
  import { type CloudflareBuildConfig, CF_MIN_COMPAT_DATE } from './config'
15
- import { PRODUCTION, type SourceOf, sourceDirPlugin } from './bundle'
16
+ import { MANIFEST_VERSION } from './manifest'
17
+ import { exposePlugin, PRODUCTION, type SourceOf, sourceDirPlugin } from './bundle'
16
18
  import type { Compiled } from './compile'
19
+ import { cssPlugin } from './css'
17
20
  import { BuildError } from './errors'
18
21
  import type { CacheRecord } from './manifest'
19
22
  import { BUILTINS as CACHE_BUILTINS } from '../cache-adapter/load'
@@ -76,13 +79,20 @@ export interface CfBuildCtx {
76
79
  manifest: { components: Record<string, { template: string }>; actions?: { prefix: string; maxBodyBytes: number; maxResponseBytes?: number; crossSite?: 'reject' | 'allow' } | null; cache?: CacheRecord }
77
80
  sourceOf: SourceOf
78
81
  actionsEntry: string | null
82
+ /** L16 R16.3: an actions-only Worker: no pages (no routes entry, no jobs, no templates), no Static Assets. */
83
+ actionsOnly?: boolean
79
84
  /** A custom cache adapter's source module (absolute). */
80
85
  cacheModule?: string | null
81
86
  /** `[cache] get_timeout_ms` as written (absent → the Workers default, 100). */
82
87
  cacheGetTimeoutMs?: number
88
+ /** L19: CSS Module class maps (the worker reads the same map as the browser). */
89
+ cssMaps?: Map<string, Record<string, string>>
83
90
  log: (s: string) => void
84
91
  }
85
92
 
93
+ /** The resolution conditions of the Worker bundle; the package-resolver probe (`sourceDirPlugin`) must carry the same (F223). */
94
+ export const CF_CONDITIONS = ['workerd', 'worker', 'browser']
95
+
86
96
  const CORE_SRC = join(import.meta.dir, '..')
87
97
  const BUILTINS = new Set(builtinModules.filter((m) => !m.startsWith('_')))
88
98
 
@@ -124,7 +134,7 @@ function cfAlias(alias: Record<string, string>, sourceOf: SourceOf): BunPlugin {
124
134
  /** The CF bundle's stand-in for the napi addon (`native.ts` `require('../native/index.js')`, R8): every call
125
135
  * fails loudly, since a Worker has no addon. */
126
136
  function cfNativeStub(handlersPath: string): BunPlugin {
127
- const names = ['startServer', 'registerWorker', 'untilReady', 'beginDrain', 'cacheInvalidate', 'cacheStats', 'localAddr', 'planJobs', 'takeFetchBody', 'putFetchReply', 'cacheReplay', 'cacheL2Count', 'cacheClear']
137
+ const names = ['startServer', 'registerWorker', 'untilReady', 'beginDrain', 'cacheInvalidate', 'cacheStats', 'localAddr', 'planJobs', 'planJobsBuf', 'takeFetchBody', 'putFetchReply', 'cacheReplay', 'cacheL2Count', 'cacheClear']
128
138
  return {
129
139
  name: 'brust-cf-native',
130
140
  setup(b) {
@@ -166,7 +176,7 @@ function cfEntryCache(c: CacheRecord, customModule: string | null) {
166
176
  }
167
177
 
168
178
  /** R2/R10: which Cloudflare bindings the configured cache needs (`[cache.cloudflare]` keys land in the adapter's options), or null. */
169
- export function cacheNeeds(c: CacheRecord | undefined): CacheNeed | null {
179
+ export function cacheNeeds(c: Pick<CacheRecord, 'adapter' | 'broadcast' | 'options' | 'broadcastOptions'> | undefined): CacheNeed | null {
170
180
  if (!c) return null
171
181
  const opt = (o: Record<string, unknown> | undefined, k: string) => (typeof o?.[k] === 'string' ? (o[k] as string) : undefined)
172
182
  const all = [c.options, c.broadcastOptions]
@@ -191,7 +201,7 @@ plugin({
191
201
  const m = await import(${JSON.stringify(app)})
192
202
  const bad = []
193
203
  for (const k of ['loader', 'jobs', 'action', 'install']) if (typeof m[k] !== 'function') bad.push(k + ' is not a function')
194
- if (typeof m.MANIFEST !== 'string' || JSON.parse(m.MANIFEST).version !== 1) bad.push('MANIFEST.version !== 1')
204
+ if (typeof m.MANIFEST !== 'string' || JSON.parse(m.MANIFEST).version !== ${MANIFEST_VERSION}) bad.push('MANIFEST.version !== ${MANIFEST_VERSION}')
195
205
  if (typeof m.CONFIG !== 'string' || JSON.parse(m.CONFIG).abi !== 1) bad.push('CONFIG.abi !== 1')
196
206
  if (typeof m.TEMPLATES !== 'object' || m.TEMPLATES === null) bad.push('TEMPLATES is not an object')
197
207
  const r = await m.loader('{}')
@@ -247,11 +257,11 @@ export async function buildCloudflare(out: string, ctx: CfBuildCtx, p: CfPrepare
247
257
  const jobsEntry = join(out, '.stage', 'jobs.ts')
248
258
  writeFileSync(
249
259
  entryPath,
250
- cfEntrySource({ entry: ctx.entry, jobsEntry, actionsEntry: a ? ctx.actionsEntry : null, prefix: a?.prefix ?? '', manifestPath: join(out, 'manifest.json'), templatesPath, config, cache: entryCache, doClass }),
260
+ cfEntrySource({ entry: ctx.entry, jobsEntry, actionsOnly: ctx.actionsOnly === true, actionsEntry: a ? ctx.actionsEntry : null, prefix: a?.prefix ?? '', manifestPath: join(out, 'manifest.json'), templatesPath, config, cache: entryCache, doClass }),
251
261
  )
252
262
  // Bare imports of the generated files resolve from the app root (an --out-dir outside the app has no node_modules above it).
253
263
  // (registered by absolute AND real path: the bundler reports the real one, e.g. /private/var on macOS)
254
- for (const f of [entryPath, jobsEntry]) {
264
+ for (const f of ctx.actionsOnly ? [entryPath] : [entryPath, jobsEntry]) {
255
265
  ctx.sourceOf.set(f, ctx.entry)
256
266
  ctx.sourceOf.set(realpathSync(f), ctx.entry)
257
267
  }
@@ -265,11 +275,11 @@ export async function buildCloudflare(out: string, ctx: CfBuildCtx, p: CfPrepare
265
275
  entrypoints: [entryPath],
266
276
  target: 'browser',
267
277
  format: 'esm',
268
- conditions: ['workerd', 'worker', 'browser'],
278
+ conditions: CF_CONDITIONS,
269
279
  define: PRODUCTION,
270
280
  minify: false,
271
281
  external: ['node:*', 'cloudflare:*', glueSpec],
272
- plugins: [cfGuard(ctx.appRoot, violations), cfAlias(p.cfg.alias, ctx.sourceOf), cfNativeStub(Bun.resolveSync('@brust/core/server-handlers', ctx.appRoot)), cfStorePlugin(ctx.appRoot), sourceDirPlugin(ctx.sourceOf, ctx.appRoot, ['node:*', 'cloudflare:*'])],
282
+ plugins: [serverOnlyEmptyPlugin(), cssPlugin(ctx.cssMaps ?? new Map(), join(out, 'client')), cfGuard(ctx.appRoot, violations), cfAlias(p.cfg.alias, ctx.sourceOf), cfNativeStub(Bun.resolveSync('@brust/core/server-handlers', ctx.appRoot)), cfStorePlugin(ctx.appRoot), sourceDirPlugin(ctx.sourceOf, ctx.appRoot, ['node:*', 'cloudflare:*'], 'browser', CF_CONDITIONS), exposePlugin(ctx.compiled)],
273
283
  })
274
284
  if (violations.length > 0) throw new BuildError('cf-unsupported', `${[...new Set(violations)].join('\n')}\nthese modules do not exist on Cloudflare Workers; swap them with [cloudflare] alias`)
275
285
  if (!res.success) throw new BuildError('bundle', `cloudflare: ${res.logs.map(String).join('\n')}`)
@@ -280,8 +290,8 @@ export async function buildCloudflare(out: string, ctx: CfBuildCtx, p: CfPrepare
280
290
  mkdirSync(appDir, { recursive: true })
281
291
  writeFileSync(appFile, await js[0]!.text())
282
292
 
283
- const assets = layoutAssets(out, cfDir, p.coreVersion)
284
- warnings.push(...assets.warnings)
293
+ // An actions-only Worker has no pages and so no Static Assets (no `assets` binding in its wrangler.jsonc either).
294
+ if (!ctx.actionsOnly) warnings.push(...layoutAssets(out, cfDir, p.coreVersion).warnings)
285
295
  const compatibilityDate = p.cfg.compatibilityDate ?? p.core.meta.compatibilityDate
286
296
  if (compatibilityDate < CF_MIN_COMPAT_DATE) throw new BuildError('config', `compatibility_date ${compatibilityDate} is older than ${CF_MIN_COMPAT_DATE}`)
287
297
  if (doClass) {
@@ -292,7 +302,7 @@ export async function buildCloudflare(out: string, ctx: CfBuildCtx, p: CfPrepare
292
302
  `// generated by brust build --target cloudflare (do not edit)\nexport * from "./${p.core.meta.main}"\nexport { default } from "./${p.core.meta.main}"\nexport { BrustCacheDO } from "./${appRel}"\n`,
293
303
  )
294
304
  }
295
- const merged = mergeWrangler(p.user, { appRoot: ctx.appRoot, cfDir: join(ctx.dist, 'cf'), name: p.cfg.name, pkgName: p.pkgName, compatibilityDate, main: doClass ? 'main.js' : p.core.meta.main, version: p.coreVersion })
305
+ const merged = mergeWrangler(p.user, { appRoot: ctx.appRoot, cfDir: join(ctx.dist, 'cf'), name: p.cfg.name, pkgName: p.pkgName, compatibilityDate, main: doClass ? 'main.js' : p.core.meta.main, version: p.coreVersion, ...(ctx.actionsOnly ? { noAssets: true } : {}) })
296
306
  warnings.push(...merged.warnings)
297
307
  writeFileSync(join(cfDir, 'wrangler.jsonc'), renderWrangler(merged.config))
298
308
 
@@ -2,10 +2,13 @@
2
2
  // `compileTree` (analysis + lowering of its whole tree). `Err` = build error; fallback and
3
3
  // warning diagnostics are logged; `outlet-outside-layout` is raised here (contract 5).
4
4
  // A component several trees reach keeps one compile, chosen by role, not by route order (F75).
5
- import { relative } from 'node:path'
6
- import { type CompiledComponent, type CompiledTree, compileTree } from '../../native/index.js'
5
+ import { readFileSync, realpathSync } from 'node:fs'
6
+ import { dirname, relative, sep } from 'node:path'
7
+ import { type CompiledComponent, type CompiledTree, compileTree, scanLoadWrites } from '../../native/index.js'
7
8
  import type { FlatRoute, Route } from '../routes'
8
9
  import { BuildError } from './errors'
10
+ import { checkAliasAgreement, readPathAliases } from './tsconfig-paths'
11
+ import type { PackageSources } from './pkg-src'
9
12
 
10
13
  /** `ComponentIR` as the compiler serialises it (snake_case), with the fields that are absent
11
14
  * when default (`ir/mod.rs`) normalised. The build reads it; it never rewrites it. */
@@ -17,13 +20,44 @@ export type ComponentIR = Record<string, any> & {
17
20
  tier: any
18
21
  // biome-ignore lint/suspicious/noExplicitAny: IR JSON
19
22
  jobs: any[]
20
- instances: { child_id: string; k: number; loops: (string | null)[]; props: Record<string, string | null> }[]
23
+ instances: {
24
+ child_id: string
25
+ k: number
26
+ loops: (string | null)[]
27
+ props: Record<string, string | null>
28
+ literals?: Record<string, unknown>
29
+ exprs?: Record<string, string>
30
+ /** F142 R34.43: the rows of a constant enclosing list (`loops` is then `[null]`). */
31
+ rows_literal?: unknown[]
32
+ }[]
33
+ /** F149: inlined instances whose child job the build may run once (`decls.rs` `BuildEvalInstance`). */
34
+ build_eval_instances?: {
35
+ key: string
36
+ child_id: string
37
+ k: number
38
+ inputs: Record<string, unknown>
39
+ outputs: string[]
40
+ stubs?: { source: string; code: string }[]
41
+ /** R34.96: the job modules fused into the child's job, which it imports (`FusedEvalJob`). */
42
+ fused?: { child_id: string; stubs?: { source: string; code: string }[] }[]
43
+ line: number
44
+ col: number
45
+ }[]
21
46
  use_id_slots: number
22
47
  uses_outlet: boolean
23
48
  needs_worker: boolean
24
49
  /** `useStore(handle)` bindings (`decls.rs` `StoreUse`); `resolved` = `StoreRef`, `null` when
25
50
  * unresolved (the component falls back). `module` is root-relative (C3). */
26
51
  stores?: { handle: string; resolved: { name: string; module: string } | null }[]
52
+ /** A package export rendered as a React island (L22): `source` is then not a file. */
53
+ external?: { source: string; export: string; member?: string[] }
54
+ /** A component compiled from a package source and written in app code as a package import (F188): the published
55
+ * export a React island or revive hook imports instead of the source. */
56
+ dist?: { source: string; export: string; member?: string[] }
57
+ /** The binding a React island imports this component by from `source` (absent: the default export). */
58
+ export?: string
59
+ /** Child components; `nested` is set when this file passes lowered JSX children to that child, an island (shadcn S1). */
60
+ children?: { id: string | null; nested?: string[]; x?: string[]; holes?: string[]; [field: string]: unknown }[]
27
61
  }
28
62
 
29
63
  export interface Compiled {
@@ -33,11 +67,23 @@ export interface Compiled {
33
67
  ir: ComponentIR
34
68
  jinja: string
35
69
  serverTs?: string
70
+ /** Build-time evaluation module (L2): one thunk per `ir.build_eval` slot. */
71
+ evalTs?: string
36
72
  clientJs?: string
73
+ /** F362 PR2: `clientJs` importing every imported list as written; the build uses it when a module in
74
+ * `projectedModules` is still in the client graph through another importer (`bundle.ts` `buildClientChunks`). */
75
+ clientJsWhole?: string
76
+ /** F362 PR2: data modules (app-root-relative) whose imported list `clientJs` declares projected instead of importing. */
77
+ projectedModules?: string[]
78
+ /** Modules the chunk imports that other chunks share (R34.124): `<name>.js` beside it. */
79
+ clientModules?: { name: string; code: string }[]
80
+ /** The compiler's diagnostics for this component (`dev.json`, L14). */
81
+ diagnostics?: { class: string; rule: string; message: string; line: number; col: number; remediation: string; file?: string | null }[]
37
82
  }
38
83
 
39
84
  type Diag = NonNullable<CompiledTree['error']>
40
- const where = (file: string, d: Diag) => `${file}:${d.line}:${d.col}`
85
+ /** `file:line:col`; a finding raised in another module than the component it is reported on names that module (F395). */
86
+ const where = (file: string, d: Diag) => `${d.file ?? file}:${d.line}:${d.col}`
41
87
 
42
88
  /** How a tree reached a component (F75). A route root's props are fixed, so after F70 it may drop
43
89
  * the row links a parent elsewhere drives; a child that no parent in its tree links may have no
@@ -74,15 +120,61 @@ export function replaces(kept: Seen, next: Seen): boolean {
74
120
  return next.role > kept.role
75
121
  }
76
122
 
123
+ /**
124
+ * F230 (closes F219): scans the app's modules once for writes into another app module's exports (`LABELS.a = …`,
125
+ * `list.push(…)` through an import) and returns the JSON every `compileTree` of the build reads: a component whose
126
+ * compiled code reads such an export falls back to React (`module-written`). `modules` are absolute paths.
127
+ */
128
+ export function scanAppWrites(appRoot: string, modules: string[], log: (s: string) => void, sourceRoots: string[] = []): string {
129
+ // Keyed as `compileApp` names a file to the compiler (relative to `appRoot`), plus the real-path spelling (as
130
+ // `routeRoots`), so an import resolves to the same key either way.
131
+ const real = realpathSync(appRoot)
132
+ const toKey = (base: string, f: string) => relative(base, f).split(sep).join('/')
133
+ // F276: a module under a `[build] source_roots` directory (`../shared/x.ts`) is scanned too; any other `..` key is not.
134
+ const inSourceRoot = (k: string) => sourceRoots.some((r) => k.startsWith(`${r}/`))
135
+ const rel = [...new Set(modules.flatMap((f) => [toKey(appRoot, f), toKey(real, realpathSync(f))]))].filter((k) => !k.startsWith('..') || inSourceRoot(k)).sort()
136
+ const { aliases } = readPathAliases(appRoot)
137
+ const t0 = performance.now()
138
+ const json = scanLoadWrites(appRoot, rel, aliases.length > 0 ? JSON.stringify(aliases) : undefined, sourceRoots.length > 0 ? sourceRoots : undefined)
139
+ const ms = performance.now() - t0
140
+ if (process.env.BRUST_BUILD_TIMINGS) log(`[brust] scan load-time writes: ${rel.length} modules in ${ms.toFixed(1)} ms`)
141
+ return json
142
+ }
143
+
77
144
  export function compileApp(opts: {
78
145
  appRoot: string
79
146
  leaves: FlatRoute[]
80
147
  componentFile: Map<Function, string>
148
+ /** Why a Component the scan saw has no file (named export only, outside the root); optional. */
149
+ componentIssue?: Map<Function, string>
81
150
  runtimeImport: string
82
151
  serverOnly: string[]
152
+ /** L20: every CSS Module's class map by absolute source path; `styles.x` folds to the literal class. */
153
+ cssMaps?: Map<string, Record<string, string>>
83
154
  log: (s: string) => void
155
+ /** SVG markup the build rendered for lucide icon requests (L23), by `ir.icon_requests[].key`. */
156
+ iconSvgs?: Record<string, { svg: string; base: string }>
157
+ /** The compile after icons were rendered (even if none could be): icons still components are judged as islands. */
158
+ iconsFinal?: boolean
159
+ /** Values the build computed for `ir.build_eval` slots, by key (L2): the slots become literals. */
160
+ slotValues?: Record<string, unknown>
161
+ /** F153c: the package sources this build wrote (`pkg-src.ts`); absent = package imports are islands. */
162
+ packages?: PackageSources
163
+ /** F256: `[build] source_roots`, normalised and relative to the app root (`../shared`). */
164
+ sourceRoots?: string[]
165
+ /** F230: `scanAppWrites`'s result; absent = no module is refused for a load-time write. */
166
+ loadWrites?: string
84
167
  }): { compiled: Map<string, Compiled>; routeComponent: Map<string, string> } {
85
168
  const { appRoot, leaves, componentFile } = opts
169
+ const iconSvgsJson = opts.iconsFinal ? JSON.stringify({ final: true, svgs: opts.iconSvgs ?? {} }) : undefined
170
+ const slotValuesJson = opts.slotValues && Object.keys(opts.slotValues).length > 0 ? JSON.stringify(opts.slotValues) : undefined
171
+ const packagesJson = opts.packages ? JSON.stringify(opts.packages) : undefined
172
+ const sourceRoots = opts.sourceRoots && opts.sourceRoots.length > 0 ? opts.sourceRoots : undefined
173
+ // The Rust side keys each map by its path relative to the app root (`/`-separated), like an import resolves.
174
+ const { aliases, notes } = readPathAliases(appRoot)
175
+ for (const n of notes) opts.log(`warning tsconfig-paths ${n}`)
176
+ const pathAliasesJson = aliases.length > 0 ? JSON.stringify(aliases) : undefined
177
+ const cssModules = Object.fromEntries([...(opts.cssMaps ?? [])].map(([abs, map]) => [relative(realpathSync(appRoot), abs).split(sep).join('/'), map]))
86
178
  const seen = new Map<string, Seen>()
87
179
  const rootIdOfFile = new Map<string, string>()
88
180
  const routeComponent = new Map<string, string>()
@@ -90,17 +182,22 @@ export function compileApp(opts: {
90
182
  const nodes = new Map<string, Route>()
91
183
  for (const l of leaves) l.chain.forEach((r, i) => r.Component && nodes.set(l.chainIds[i]!, r))
92
184
 
185
+ // F158 (R34.69): the route components (pages and layouts) take request data as props, so the compiler never hands
186
+ // them lowered JSX from a caller. Keyed like an import resolves: relative to the app root, `/`-separated.
187
+ const rootRel = (f: string) => relative(appRoot, f).split(sep).join('/')
188
+ const routeRoots = [...new Set([...nodes.values()].flatMap((r) => componentFile.get(r.Component!) ?? []).flatMap((f) => [rootRel(f), relative(realpathSync(appRoot), realpathSync(f)).split(sep).join('/')]))]
189
+
93
190
  for (const [routeId, route] of nodes) {
94
191
  const file = componentFile.get(route.Component!)
95
192
  if (!file)
96
193
  throw new BuildError(
97
194
  'component-source',
98
- `${route.Component!.name || '(anonymous)'} is not a default import of a .tsx file (route ${routeId})`,
195
+ `${opts.componentIssue?.get(route.Component!) ?? `${route.Component!.name || '(anonymous)'} is not the default export of a .tsx file in the import graph`} (route ${routeId})`,
99
196
  )
100
197
  let rootId = rootIdOfFile.get(file)
101
198
  if (!rootId) {
102
199
  const rel = relative(appRoot, file)
103
- const tree = compileTree(rel, appRoot, opts.runtimeImport, opts.serverOnly)
200
+ const tree = compileTree(rel, appRoot, opts.runtimeImport, opts.serverOnly, slotValuesJson, cssModules, iconSvgsJson, routeRoots, pathAliasesJson, packagesJson, sourceRoots, opts.loadWrites)
104
201
  if (tree.error) throw new BuildError(tree.error.rule, `${where(rel, tree.error)} ${tree.error.message}`)
105
202
  for (const [i, c] of tree.components.entries()) {
106
203
  // A lowering Error that came back as a diagnostic is a build error too (contract 9).
@@ -122,6 +219,7 @@ export function compileApp(opts: {
122
219
  for (const d of c.diagnostics)
123
220
  if (d.class === 'fallback' || d.class === 'warning') opts.log(`warning ${d.rule} ${where(c.source, d)} ${d.message}`)
124
221
  const ir = JSON.parse(c.ir) as ComponentIR
222
+ if (ir.alias_imports?.length) checkAliasAgreement(appRoot, ir.alias_imports)
125
223
  ir.use_id_slots ??= 0
126
224
  ir.uses_outlet ??= false
127
225
  ir.instances ??= []
@@ -132,9 +230,21 @@ export function compileApp(opts: {
132
230
  ir,
133
231
  jinja: c.jinja,
134
232
  serverTs: c.serverTs,
233
+ evalTs: c.evalTs,
135
234
  clientJs: c.clientJs,
235
+ clientJsWhole: c.clientJsWhole ?? undefined,
236
+ projectedModules: c.projectedModules?.length ? c.projectedModules : undefined,
237
+ clientModules: c.clientModules?.length ? c.clientModules : undefined,
238
+ diagnostics: c.diagnostics.map((d) => ({ class: d.class, rule: d.rule, message: d.message, line: d.line, col: d.col, remediation: d.remediation, file: d.file })),
136
239
  })
137
240
  }
241
+ // A package export has no file of its own: bare imports in its island entries resolve from a
242
+ // component that uses it (the first one in build order).
243
+ for (const c of compiled.values()) {
244
+ if (!c.ir.external) continue
245
+ const user = [...compiled.values()].find((p) => !p.ir.external && (p.ir.children ?? []).some((ref: { id: string | null }) => ref.id === c.id))
246
+ c.file = user?.file ?? c.file
247
+ }
138
248
  for (const [routeId, route] of nodes) {
139
249
  const rootId = routeComponent.get(routeId)!
140
250
  if (compiled.get(rootId)!.ir.uses_outlet && !route.children)
@@ -143,5 +253,152 @@ export function compileApp(opts: {
143
253
  `${route.Component!.name || rootId} renders <Outlet/> but route ${routeId} has no children`,
144
254
  )
145
255
  }
256
+ checkLayoutContexts(leaves, compiled, routeComponent)
146
257
  return { compiled, routeComponent }
147
258
  }
259
+
260
+ /** Script modules only (`.ts/.tsx/.js/.jsx/.mjs/.cjs/.mts/.cts`), judged on the RESOLVED file (a dotted basename like `./theme.context`
261
+ * resolves to theme.context.tsx; a stylesheet, JSON or asset resolves to itself): those never read a context. */
262
+ const SCRIPT = /\.[cm]?[jt]sx?$/
263
+
264
+ let importScanner: InstanceType<typeof Bun.Transpiler> | undefined
265
+ /** The module specifiers `file` imports or re-exports (`undefined`: unreadable or unparsable). */
266
+ function moduleSpecs(file: string): string[] | undefined {
267
+ try {
268
+ importScanner ??= new Bun.Transpiler({ loader: 'tsx' })
269
+ return importScanner.scanImports(readFileSync(file, 'utf8')).map((i) => i.path)
270
+ } catch {
271
+ return undefined
272
+ }
273
+ }
274
+
275
+ /**
276
+ * F437 (module-level rule): whether the island `file` imports ANY binding (a hook, the context, a helper) from a module that
277
+ * declares a context a layout above provides, directly or through re-exporting modules (followed transitively, each app file
278
+ * once). A relative specifier that cannot be resolved, or a barrel that cannot be read, is refused too.
279
+ */
280
+ function importsProvidedModule(
281
+ file: string,
282
+ provided: { file: string; layout: string; contexts: string[] }[],
283
+ ): { spec: string; via?: string; hit: { file: string; layout: string; contexts: string[] } } | undefined {
284
+ const real = (f: string) => {
285
+ try {
286
+ return realpathSync(f)
287
+ } catch {
288
+ return f
289
+ }
290
+ }
291
+ const resolve = (spec: string, from: string) => {
292
+ try {
293
+ return real(Bun.resolveSync(spec, dirname(from)))
294
+ } catch {
295
+ return undefined
296
+ }
297
+ }
298
+ const unfollowable = { file: '', layout: '(unresolved re-export)', contexts: ['?'] }
299
+ type Hit = { file: string; layout: string; contexts: string[] }
300
+ // The app modules `from` imports or re-exports, followed transitively (a barrel behind barrels) with a visited set.
301
+ const reach = (from: string, visited: Set<string>): { hit: Hit; via: string } | undefined => {
302
+ const specs = moduleSpecs(from)
303
+ if (specs === undefined) return { hit: unfollowable, via: from }
304
+ for (const spec of specs) {
305
+ const target = resolve(spec, from)
306
+ if (target === undefined) {
307
+ if (spec.startsWith('.')) return { hit: unfollowable, via: from }
308
+ continue
309
+ }
310
+ if (!SCRIPT.test(target)) continue
311
+ const hit = provided.find((p) => p.file === target)
312
+ if (hit) return { hit, via: from }
313
+ if (target.includes(`${sep}node_modules${sep}`) || visited.has(target)) continue
314
+ visited.add(target)
315
+ const deeper = reach(target, visited)
316
+ if (deeper) return deeper
317
+ }
318
+ return undefined
319
+ }
320
+ for (const spec of moduleSpecs(file) ?? []) {
321
+ const target = resolve(spec, file)
322
+ if (target === undefined) {
323
+ if (spec.startsWith('.')) return { spec, hit: unfollowable }
324
+ continue
325
+ }
326
+ if (!SCRIPT.test(target)) continue
327
+ const direct = provided.find((p) => p.file === target)
328
+ if (direct) return { spec, hit: direct }
329
+ if (target.includes(`${sep}node_modules${sep}`)) continue
330
+ const found = reach(target, new Set([file, target]))
331
+ if (found) return { spec, via: relative(dirname(file), target), hit: found.hit }
332
+ }
333
+ return undefined
334
+ }
335
+
336
+ /**
337
+ * F437: a component the compiler refused to lower because it reads a context imported from another module
338
+ * (`useContext(Ctx)`, `ir.foreign_ctx_reads`) ships as a React island, and an island's SSR and `hydrateRoot` render it
339
+ * alone, without the Providers a layout above its route renders: it would paint the `createContext` default where React
340
+ * paints the layout's value. Refused here, by name, conservatively: a Provider anywhere in the layout's compiled tree
341
+ * (`ir.provides` of the layout or of any component it renders) declared in a file counts as providing every context that
342
+ * file declares, so a read of ANY binding imported from that file is refused (an alias, `export { A as B }`, a re-export of
343
+ * the file's own context cannot slip by). A context nobody provides still reads its default, which is React's own answer.
344
+ * Native resolution of an imported context is F439.
345
+ */
346
+ function checkLayoutContexts(leaves: FlatRoute[], compiled: Map<string, Compiled>, routeComponent: Map<string, string>): void {
347
+ const real = (f: string) => {
348
+ try {
349
+ return realpathSync(f)
350
+ } catch {
351
+ return f
352
+ }
353
+ }
354
+ for (const leaf of leaves) {
355
+ const provided: { file: string; layout: string; contexts: string[] }[] = []
356
+ for (const id of leaf.chainIds) {
357
+ const rootId = routeComponent.get(id)
358
+ const root = rootId === undefined ? undefined : compiled.get(rootId)
359
+ if (!root) continue
360
+ // The tree of this route's component: the layouts above it provided `provided` so far.
361
+ if (provided.length > 0) {
362
+ const stack = [root.id]
363
+ const done = new Set<string>()
364
+ while (stack.length > 0) {
365
+ const c = compiled.get(stack.pop()!)
366
+ if (!c || done.has(c.id)) continue
367
+ done.add(c.id)
368
+ for (const [src, name] of (c.ir.foreign_ctx_reads ?? []) as [string, string][]) {
369
+ let from: string
370
+ try {
371
+ from = real(Bun.resolveSync(src, dirname(c.file)))
372
+ } catch {
373
+ continue
374
+ }
375
+ const hit = provided.find((p) => p.file === from)
376
+ if (hit)
377
+ throw new BuildError(
378
+ 'island-layout-context',
379
+ `${c.ir.source} reads the context \`${name}\` (useContext) that the layout ${hit.layout} provides, but this component renders as a React island, and an island does not see its layout's Provider (it would paint the createContext default). Workaround: pass the value to the page through its loader (a prop), or render the Provider inside this component's own tree. Native resolution of an imported context is F439.`,
380
+ )
381
+ }
382
+ const viaModule = c.ir.tier && typeof c.ir.tier === 'object' && 'React' in c.ir.tier ? importsProvidedModule(c.file, provided) : undefined
383
+ if (viaModule)
384
+ throw new BuildError(
385
+ 'island-layout-context',
386
+ `${c.ir.source} renders as a React island and imports ${viaModule.spec}${viaModule.via ? ` (through ${viaModule.via})` : ''}, a module that declares the context \`${viaModule.hit.contexts.join('`, `')}\` which the layout ${viaModule.hit.layout} provides: an island does not see its layout's Provider (it would paint the createContext default), and a hook or helper of that module may read it. Workaround: pass the value to the page through its loader (a prop), or render the Provider inside this component's own tree. Native resolution of an imported context is F439.`,
387
+ )
388
+ for (const ch of (c.ir.children ?? []) as { id: string | null }[]) if (ch.id) stack.push(ch.id)
389
+ }
390
+ }
391
+ // Every Provider the layout's compiled tree renders (the layout itself or a component it calls), by declaring file.
392
+ const stack = [root.id]
393
+ const done = new Set<string>()
394
+ while (stack.length > 0) {
395
+ const c = compiled.get(stack.pop()!)
396
+ if (!c || done.has(c.id)) continue
397
+ done.add(c.id)
398
+ if ((c.ir.provides ?? []).length > 0)
399
+ provided.push({ file: real(c.file), layout: root.ir.source, contexts: (c.ir.provides as { context: string }[]).map((p) => p.context.replace(/_m[0-9a-f]{8}$/, '')) })
400
+ for (const ch of (c.ir.children ?? []) as { id: string | null }[]) if (ch.id) stack.push(ch.id)
401
+ }
402
+ }
403
+ }
404
+ }