@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.
- package/README.md +66 -6
- package/native/index.d.ts +82 -4
- package/native/index.js +58 -54
- package/package.json +29 -15
- package/src/actions/client.ts +53 -7
- package/src/actions/elysia.ts +1 -1
- package/src/actions/load.ts +82 -1
- package/src/actions/prefix.ts +44 -2
- package/src/actions-bridge.ts +44 -9
- package/src/assets.d.ts +55 -0
- package/src/browser.ts +6 -1
- package/src/build/actions-only.ts +86 -0
- package/src/build/build-eval.ts +332 -0
- package/src/build/build-icons.ts +69 -0
- package/src/build/bundle.ts +466 -54
- package/src/build/cf-assets.ts +4 -2
- package/src/build/cf-entry.ts +9 -4
- package/src/build/cf-wrangler.ts +3 -1
- package/src/build/cloudflare.ts +34 -13
- package/src/build/compile.ts +263 -6
- package/src/build/config.ts +138 -11
- package/src/build/css.ts +353 -0
- package/src/build/esm-resolve.ts +170 -0
- package/src/build/features.ts +119 -0
- package/src/build/index.ts +210 -27
- package/src/build/manifest.ts +94 -7
- package/src/build/pkg-src.ts +514 -0
- package/src/build/recognised.ts +19 -0
- package/src/build/scan.ts +71 -20
- package/src/build/server-only.ts +62 -0
- package/src/build/ssg-render.ts +65 -0
- package/src/build/ssg.ts +146 -0
- package/src/build/static-worker.ts +91 -0
- package/src/build/static.ts +259 -0
- package/src/build/tailwind.ts +117 -0
- package/src/build/tsconfig-paths.ts +67 -0
- package/src/build-time.ts +10 -0
- package/src/cache-adapter/types.ts +1 -1
- package/src/cf.ts +4 -1
- package/src/cli.ts +40 -4
- package/src/config.ts +111 -25
- package/src/css-modules.d.ts +8 -0
- package/src/dev/bun.ts +378 -0
- package/src/dev/client.ts +92 -0
- package/src/dev/devtools-data.ts +23 -0
- package/src/dev/devtools.ts +358 -0
- package/src/dev/diag.ts +30 -0
- package/src/dev/proxy.ts +256 -0
- package/src/dev/tui.ts +144 -0
- package/src/dev/watch.ts +130 -0
- package/src/dev.ts +15 -28
- package/src/handlers.ts +116 -10
- package/src/index.ts +6 -1
- package/src/native.ts +32 -1
- package/src/navigate-browser.ts +19 -0
- package/src/navigate.ts +19 -0
- package/src/navigation.ts +91 -0
- package/src/routes.ts +31 -1
- package/src/run.ts +65 -13
- package/src/shadcn/init.ts +513 -0
- package/src/shadcn/jsonc.ts +129 -0
- package/src/shadcn/registry.ts +149 -0
- package/src/store/builtin.ts +4 -0
- package/src/worker.ts +105 -22
package/src/build/cf-assets.ts
CHANGED
|
@@ -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
|
-
|
|
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[] = []
|
package/src/build/cf-entry.ts
CHANGED
|
@@ -8,7 +8,8 @@ export interface CfConfigJson {
|
|
|
8
8
|
stats: boolean
|
|
9
9
|
edgeCache: 'off'
|
|
10
10
|
cors?: unknown
|
|
11
|
-
|
|
11
|
+
/** F475: `stream` + `maxStreamBytes` only when the app lists streamed routes; the idle/total timeouts and `maxStreams` are the platform's on Workers. */
|
|
12
|
+
actions?: { prefix: string; maxBodyBytes: number; maxResponseBytes: number; crossSite: 'reject' | 'allow'; stream?: string[]; maxStreamBytes?: number }
|
|
12
13
|
/** The cache adapter (optional under abi 1, cache adapters R13). */
|
|
13
14
|
cache?: { adapter: string; getTimeoutMs: number; maxBodyBytes: number; namespace: string; buildId: string; epochIntervalMs: number }
|
|
14
15
|
}
|
|
@@ -28,6 +29,8 @@ export interface CfEntryInput {
|
|
|
28
29
|
entry: string
|
|
29
30
|
jobsEntry: string
|
|
30
31
|
actionsEntry: string | null
|
|
32
|
+
/** L16: no pages: the routes module and the jobs module are not imported; the handlers serve an empty route table. */
|
|
33
|
+
actionsOnly?: boolean
|
|
31
34
|
prefix: string
|
|
32
35
|
manifestPath: string
|
|
33
36
|
templatesPath: string
|
|
@@ -41,16 +44,18 @@ export function cfEntrySource(i: CfEntryInput): string {
|
|
|
41
44
|
const q = JSON.stringify
|
|
42
45
|
const actions = i.actionsEntry !== null
|
|
43
46
|
const c = i.cache ?? null
|
|
47
|
+
const pages = i.actionsOnly !== true
|
|
44
48
|
return `${[
|
|
45
49
|
'// 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)}`,
|
|
50
|
+
...(pages ? [`import * as R from ${q(i.entry)}`, `import J, * as JM from ${q(i.jobsEntry)}`] : []),
|
|
48
51
|
...(actions ? [`import A from ${q(i.actionsEntry)}`] : []),
|
|
49
52
|
`import M from ${q(i.manifestPath)}`,
|
|
50
53
|
`import T from ${q(i.templatesPath)}`,
|
|
51
54
|
`import { fetchInScope, flattenRoutes, jsonCall, makeHandlers, noActions } from "@brust/core/server-handlers"`,
|
|
52
55
|
...(actions ? ['import { mountActions } from "@brust/core/server-handlers/actions"'] : []),
|
|
53
|
-
|
|
56
|
+
pages
|
|
57
|
+
? 'const h = makeHandlers({ leaves: flattenRoutes(R.routes ?? R.default).leaves, jobs: J, manifest: M, stores: JM.stores ?? {} })'
|
|
58
|
+
: 'const h = makeHandlers({ leaves: [], jobs: {}, manifest: M, stores: {} })',
|
|
54
59
|
...(actions ? [`const app = mountActions(A, ${q(i.prefix)}).compile() // startup-phase compile (R9)`] : []),
|
|
55
60
|
'export const loader = jsonCall(h.loader)',
|
|
56
61
|
'export const jobs = jsonCall(h.jobs)',
|
package/src/build/cf-wrangler.ts
CHANGED
|
@@ -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 }
|
package/src/build/cloudflare.ts
CHANGED
|
@@ -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 {
|
|
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'
|
|
@@ -73,16 +76,23 @@ export interface CfBuildCtx {
|
|
|
73
76
|
/** the FINAL dist dir (`out` is a tmp sibling that is renamed to it): wrangler paths are relative to `<dist>/cf` */
|
|
74
77
|
dist: string
|
|
75
78
|
compiled: Map<string, Compiled>
|
|
76
|
-
manifest: { components: Record<string, { template: string }>; actions?: { prefix: string; maxBodyBytes: number; maxResponseBytes?: number; crossSite?: 'reject' | 'allow' } | null; cache?: CacheRecord }
|
|
79
|
+
manifest: { components: Record<string, { template: string }>; actions?: { prefix: string; maxBodyBytes: number; maxResponseBytes?: number; crossSite?: 'reject' | 'allow'; stream?: string[]; maxStreamBytes?: number } | 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 !==
|
|
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('{}')
|
|
@@ -205,6 +215,17 @@ if (bad.length) { console.error(bad.join('\\n')); process.exit(1) }
|
|
|
205
215
|
return { preload, smoke }
|
|
206
216
|
}
|
|
207
217
|
|
|
218
|
+
/** The `actions` block of the Worker's CONFIG. F475: the `stream` list and its ceiling only when the app lists streamed routes; the idle and total timeouts and `maxStreams` are the platform's on Workers, so they are not sent. */
|
|
219
|
+
export function cfActions(a: NonNullable<CfBuildCtx['manifest']['actions']>): NonNullable<CfConfigJson['actions']> {
|
|
220
|
+
return {
|
|
221
|
+
prefix: a.prefix,
|
|
222
|
+
maxBodyBytes: a.maxBodyBytes,
|
|
223
|
+
maxResponseBytes: a.maxResponseBytes ?? 8_388_608,
|
|
224
|
+
crossSite: a.crossSite ?? 'reject',
|
|
225
|
+
...(a.stream?.length ? { stream: a.stream, ...(a.maxStreamBytes !== undefined ? { maxStreamBytes: a.maxStreamBytes } : {}) } : {}),
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
208
229
|
export async function buildCloudflare(out: string, ctx: CfBuildCtx, p: CfPrepared): Promise<string[]> {
|
|
209
230
|
const cfDir = join(out, 'cf')
|
|
210
231
|
const stage = join(cfDir, '.stage')
|
|
@@ -240,18 +261,18 @@ export async function buildCloudflare(out: string, ctx: CfBuildCtx, p: CfPrepare
|
|
|
240
261
|
stats: p.cfg.stats,
|
|
241
262
|
edgeCache: p.cfg.edgeCache,
|
|
242
263
|
...(cors ? { cors } : {}),
|
|
243
|
-
...(a ? { actions:
|
|
264
|
+
...(a ? { actions: cfActions(a) } : {}),
|
|
244
265
|
...(cache ? { cache: { adapter: cache.adapter, getTimeoutMs: cache.getTimeoutMs, maxBodyBytes: cache.maxBodyBytes, namespace: cache.namespace, buildId: cache.buildId, epochIntervalMs: cache.epochIntervalMs } } : {}),
|
|
245
266
|
}
|
|
246
267
|
const entryPath = join(stage, 'brust-app.entry.js')
|
|
247
268
|
const jobsEntry = join(out, '.stage', 'jobs.ts')
|
|
248
269
|
writeFileSync(
|
|
249
270
|
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 }),
|
|
271
|
+
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
272
|
)
|
|
252
273
|
// Bare imports of the generated files resolve from the app root (an --out-dir outside the app has no node_modules above it).
|
|
253
274
|
// (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]) {
|
|
275
|
+
for (const f of ctx.actionsOnly ? [entryPath] : [entryPath, jobsEntry]) {
|
|
255
276
|
ctx.sourceOf.set(f, ctx.entry)
|
|
256
277
|
ctx.sourceOf.set(realpathSync(f), ctx.entry)
|
|
257
278
|
}
|
|
@@ -265,11 +286,11 @@ export async function buildCloudflare(out: string, ctx: CfBuildCtx, p: CfPrepare
|
|
|
265
286
|
entrypoints: [entryPath],
|
|
266
287
|
target: 'browser',
|
|
267
288
|
format: 'esm',
|
|
268
|
-
conditions:
|
|
289
|
+
conditions: CF_CONDITIONS,
|
|
269
290
|
define: PRODUCTION,
|
|
270
291
|
minify: false,
|
|
271
292
|
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:*'])],
|
|
293
|
+
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
294
|
})
|
|
274
295
|
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
296
|
if (!res.success) throw new BuildError('bundle', `cloudflare: ${res.logs.map(String).join('\n')}`)
|
|
@@ -280,8 +301,8 @@ export async function buildCloudflare(out: string, ctx: CfBuildCtx, p: CfPrepare
|
|
|
280
301
|
mkdirSync(appDir, { recursive: true })
|
|
281
302
|
writeFileSync(appFile, await js[0]!.text())
|
|
282
303
|
|
|
283
|
-
|
|
284
|
-
warnings.push(...
|
|
304
|
+
// An actions-only Worker has no pages and so no Static Assets (no `assets` binding in its wrangler.jsonc either).
|
|
305
|
+
if (!ctx.actionsOnly) warnings.push(...layoutAssets(out, cfDir, p.coreVersion).warnings)
|
|
285
306
|
const compatibilityDate = p.cfg.compatibilityDate ?? p.core.meta.compatibilityDate
|
|
286
307
|
if (compatibilityDate < CF_MIN_COMPAT_DATE) throw new BuildError('config', `compatibility_date ${compatibilityDate} is older than ${CF_MIN_COMPAT_DATE}`)
|
|
287
308
|
if (doClass) {
|
|
@@ -292,7 +313,7 @@ export async function buildCloudflare(out: string, ctx: CfBuildCtx, p: CfPrepare
|
|
|
292
313
|
`// 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
314
|
)
|
|
294
315
|
}
|
|
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 })
|
|
316
|
+
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
317
|
warnings.push(...merged.warnings)
|
|
297
318
|
writeFileSync(join(cfDir, 'wrangler.jsonc'), renderWrangler(merged.config))
|
|
298
319
|
|
package/src/build/compile.ts
CHANGED
|
@@ -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 {
|
|
6
|
-
import {
|
|
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: {
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|