@brust/core 2.0.0-alpha.34 → 2.0.0-alpha.35
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/native/index.d.ts +19 -1
- package/native/index.js +55 -54
- package/package.json +10 -10
- package/src/actions/client.ts +3 -4
- package/src/browser.ts +3 -0
- package/src/build/actions-only.ts +2 -1
- package/src/build/bundle.ts +17 -10
- package/src/build/cf-assets.ts +11 -9
- package/src/build/cloudflare.ts +7 -4
- package/src/build/compile.ts +2 -0
- package/src/build/config.ts +25 -7
- package/src/build/css.ts +18 -12
- package/src/build/index.ts +22 -10
- package/src/build/manifest.ts +6 -0
- package/src/build/route-conflicts.ts +39 -0
- package/src/build/ssg.ts +3 -2
- package/src/build/static.ts +3 -2
- package/src/build/tailwind.ts +13 -3
- package/src/build/tsconfig-paths.ts +31 -3
- package/src/cache-adapter/conformance.ts +77 -0
- package/src/cache-adapter/memory.ts +44 -12
- package/src/cache-adapter/types.ts +7 -1
- package/src/cache.ts +11 -0
- package/src/cf.ts +3 -0
- package/src/config.ts +29 -1
- package/src/dev/bun.ts +8 -6
- package/src/dev/client.ts +4 -1
- package/src/dev/devtools.ts +5 -3
- package/src/dev/proxy.ts +21 -17
- package/src/index.ts +5 -0
- package/src/native.ts +7 -0
- package/src/routes.ts +13 -0
- package/src/run.ts +15 -0
package/src/build/cloudflare.ts
CHANGED
|
@@ -77,7 +77,7 @@ export interface CfBuildCtx {
|
|
|
77
77
|
/** the FINAL dist dir (`out` is a tmp sibling that is renamed to it): wrangler paths are relative to `<dist>/cf` */
|
|
78
78
|
dist: string
|
|
79
79
|
compiled: Map<string, Compiled>
|
|
80
|
-
manifest: { components: Record<string, { template: string }>; actions?: { prefix: string; maxBodyBytes: number; maxResponseBytes?: number; crossSite?: 'reject' | 'allow'; stream?: string[]; maxStreamBytes?: number } | null; cache?: CacheRecord }
|
|
80
|
+
manifest: { components: Record<string, { template: string }>; /** F495 `[server] base_path`: the asset tree is laid out under it. */ base_path?: string; actions?: { prefix: string; maxBodyBytes: number; maxResponseBytes?: number; crossSite?: 'reject' | 'allow'; stream?: string[]; maxStreamBytes?: number } | null; cache?: CacheRecord }
|
|
81
81
|
sourceOf: SourceOf
|
|
82
82
|
actionsEntry: string | null
|
|
83
83
|
/** L16 R16.3: an actions-only Worker: no pages (no routes entry, no jobs, no templates), no Static Assets. */
|
|
@@ -91,6 +91,9 @@ export interface CfBuildCtx {
|
|
|
91
91
|
log: (s: string) => void
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/** F495: the Worker bundle's `define`: production, and the app's base path baked in for `basePath` of `@brust/core` (cf.ts reads it; a Worker has no build-time env). */
|
|
95
|
+
export const workerDefine = (basePath = ''): Record<string, string> => ({ ...PRODUCTION, 'process.env.BRUST_BASE_PATH': JSON.stringify(basePath) })
|
|
96
|
+
|
|
94
97
|
/** The resolution conditions of the Worker bundle; the package-resolver probe (`sourceDirPlugin`) must carry the same (F223). */
|
|
95
98
|
export const CF_CONDITIONS = ['workerd', 'worker', 'browser']
|
|
96
99
|
|
|
@@ -288,10 +291,10 @@ export async function buildCloudflare(out: string, ctx: CfBuildCtx, p: CfPrepare
|
|
|
288
291
|
target: 'browser',
|
|
289
292
|
format: 'esm',
|
|
290
293
|
conditions: CF_CONDITIONS,
|
|
291
|
-
define:
|
|
294
|
+
define: workerDefine(ctx.manifest.base_path),
|
|
292
295
|
minify: false,
|
|
293
296
|
external: ['node:*', 'cloudflare:*', glueSpec],
|
|
294
|
-
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)],
|
|
297
|
+
plugins: [serverOnlyEmptyPlugin(), cssPlugin(ctx.cssMaps ?? new Map(), join(out, 'client'), ctx.manifest.base_path ?? ''), 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)],
|
|
295
298
|
})
|
|
296
299
|
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`)
|
|
297
300
|
if (!res.success) throw new BuildError('bundle', `cloudflare: ${res.logs.map(String).join('\n')}`)
|
|
@@ -303,7 +306,7 @@ export async function buildCloudflare(out: string, ctx: CfBuildCtx, p: CfPrepare
|
|
|
303
306
|
writeFileSync(appFile, await js[0]!.text())
|
|
304
307
|
|
|
305
308
|
// An actions-only Worker has no pages and so no Static Assets (no `assets` binding in its wrangler.jsonc either).
|
|
306
|
-
if (!ctx.actionsOnly) warnings.push(...layoutAssets(out, cfDir, p.coreVersion).warnings)
|
|
309
|
+
if (!ctx.actionsOnly) warnings.push(...layoutAssets(out, cfDir, p.coreVersion, ctx.manifest.base_path ?? '').warnings)
|
|
307
310
|
const compatibilityDate = p.cfg.compatibilityDate ?? p.core.meta.compatibilityDate
|
|
308
311
|
if (compatibilityDate < CF_MIN_COMPAT_DATE) throw new BuildError('config', `compatibility_date ${compatibilityDate} is older than ${CF_MIN_COMPAT_DATE}`)
|
|
309
312
|
if (doClass) {
|
package/src/build/compile.ts
CHANGED
|
@@ -30,6 +30,8 @@ export type ComponentIR = Record<string, any> & {
|
|
|
30
30
|
exprs?: Record<string, string>
|
|
31
31
|
/** F142 R34.43: the rows of a constant enclosing list (`loops` is then `[null]`). */
|
|
32
32
|
rows_literal?: unknown[]
|
|
33
|
+
/** F502: the branch condition the server checks per instance before it runs the child's job. */
|
|
34
|
+
when?: unknown
|
|
33
35
|
}[]
|
|
34
36
|
/** F149: inlined instances whose child job the build may run once (`decls.rs` `BuildEvalInstance`). */
|
|
35
37
|
build_eval_instances?: {
|
package/src/build/config.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
streamEntryError,
|
|
23
23
|
} from '../actions/prefix'
|
|
24
24
|
import { ADAPTER_NAMES, BUILTINS, installHint } from '../cache-adapter/load'
|
|
25
|
+
import { normalizeBasePath } from '../config'
|
|
25
26
|
import { BuildError } from './errors'
|
|
26
27
|
import { posixOf } from './posix'
|
|
27
28
|
|
|
@@ -108,6 +109,8 @@ export interface BuildConfig {
|
|
|
108
109
|
cssGlobal: string[]
|
|
109
110
|
/** `[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. */
|
|
110
111
|
sourceRoots: string[]
|
|
112
|
+
/** `[server] base_path` (F495), normalised (`/portal`); `''` = the app is mounted at the root. Baked into every URL brust emits. */
|
|
113
|
+
basePath: string
|
|
111
114
|
}
|
|
112
115
|
|
|
113
116
|
const isTable = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
@@ -274,7 +277,7 @@ function resolveAlias(file: string, appRoot: string, p: string): string {
|
|
|
274
277
|
}
|
|
275
278
|
}
|
|
276
279
|
|
|
277
|
-
function parseActions(file: string, raw: unknown, actionsOnly: boolean): ActionsBuildConfig | null {
|
|
280
|
+
function parseActions(file: string, raw: unknown, actionsOnly: boolean, basePath = ''): ActionsBuildConfig | null {
|
|
278
281
|
if (raw === undefined) return null
|
|
279
282
|
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) throw new BuildError('config', `${file}: [actions] must be a table`)
|
|
280
283
|
const t = raw as Record<string, unknown>
|
|
@@ -283,12 +286,16 @@ function parseActions(file: string, raw: unknown, actionsOnly: boolean): Actions
|
|
|
283
286
|
if (typeof t.entry !== 'string' || t.entry.trim() === '') throw new BuildError('config', `${file}: actions.entry must be a non-empty string`)
|
|
284
287
|
entry = t.entry.trim().replace(/^\.\//, '')
|
|
285
288
|
}
|
|
286
|
-
|
|
289
|
+
// F495: the default prefix lives under the base path, like every URL brust owns.
|
|
290
|
+
let prefix = `${basePath}${DEFAULT_ACTION_PREFIX}`
|
|
287
291
|
if (t.prefix !== undefined) {
|
|
288
292
|
// `/` is the prefix of an actions-only deployment; a page-only build (base_url, no local app) may name it too.
|
|
289
293
|
const why = prefixError(t.prefix, actionsOnly || t.base_url !== undefined)
|
|
290
294
|
if (why) throw new BuildError('config', `${file}: actions.prefix ${why}`)
|
|
291
295
|
prefix = t.prefix as string
|
|
296
|
+
// F495: an explicit prefix is used as written, and must itself be under the base path.
|
|
297
|
+
if (basePath !== '' && prefix !== basePath && !prefix.startsWith(`${basePath}/`))
|
|
298
|
+
throw new BuildError('config', `${file}: actions.prefix ${JSON.stringify(prefix)} must lie under server.base_path ${JSON.stringify(basePath)}`)
|
|
292
299
|
}
|
|
293
300
|
let maxBodyBytes = DEFAULT_MAX_BODY_BYTES
|
|
294
301
|
if (t.max_body_bytes !== undefined) {
|
|
@@ -397,10 +404,20 @@ export function parseSourceRoots(file: string, appRoot: string, raw: unknown): s
|
|
|
397
404
|
return [...out].sort()
|
|
398
405
|
}
|
|
399
406
|
|
|
407
|
+
/** `[server] base_path` (F495): the one validator is config.ts's; a bad value is a build error here too. */
|
|
408
|
+
function parseBasePath(file: string, server: unknown): string {
|
|
409
|
+
if (server === undefined) return ''
|
|
410
|
+
if (!isTable(server)) throw new BuildError('config', `${file}: [server] must be a table`)
|
|
411
|
+
if (server.base_path === undefined) return ''
|
|
412
|
+
const r = normalizeBasePath(server.base_path)
|
|
413
|
+
if (!r.ok) throw new BuildError('config', `${file}: server.base_path ${r.why}`)
|
|
414
|
+
return r.value
|
|
415
|
+
}
|
|
416
|
+
|
|
400
417
|
export async function loadBuildConfig(appRoot: string, o: { actionsOnly?: boolean } = {}): Promise<BuildConfig> {
|
|
401
418
|
const file = join(appRoot, 'brust.toml')
|
|
402
419
|
const f = Bun.file(file)
|
|
403
|
-
if (!(await f.exists())) return { serverOnly: [], actions: null, cloudflare: null, cache: null, cssGlobal: [], sourceRoots: [] }
|
|
420
|
+
if (!(await f.exists())) return { serverOnly: [], actions: null, cloudflare: null, cache: null, cssGlobal: [], sourceRoots: [], basePath: '' }
|
|
404
421
|
let parsed: unknown
|
|
405
422
|
try {
|
|
406
423
|
parsed = Bun.TOML.parse(await f.text())
|
|
@@ -408,17 +425,18 @@ export async function loadBuildConfig(appRoot: string, o: { actionsOnly?: boolea
|
|
|
408
425
|
throw new BuildError('config', `failed to parse ${file}: ${(e as Error).message}`)
|
|
409
426
|
}
|
|
410
427
|
const root = (parsed ?? {}) as Record<string, unknown>
|
|
411
|
-
const
|
|
428
|
+
const basePath = parseBasePath(file, root.server)
|
|
429
|
+
const actions = parseActions(file, root.actions, o.actionsOnly === true, basePath)
|
|
412
430
|
const cloudflare = parseCloudflare(file, appRoot, root.cloudflare)
|
|
413
431
|
const cache = await parseCache(file, appRoot, root.cache)
|
|
414
432
|
const cssGlobal = parseCss(file, root.css)
|
|
415
433
|
const build = root.build
|
|
416
|
-
if (build === undefined) return { serverOnly: [], actions, cloudflare, cache, cssGlobal, sourceRoots: [] }
|
|
434
|
+
if (build === undefined) return { serverOnly: [], actions, cloudflare, cache, cssGlobal, sourceRoots: [], basePath }
|
|
417
435
|
if (build === null || typeof build !== 'object' || Array.isArray(build)) throw new BuildError('config', `${file}: [build] must be a table`)
|
|
418
436
|
const sourceRoots = parseSourceRoots(file, appRoot, (build as Record<string, unknown>).source_roots)
|
|
419
437
|
const so = (build as Record<string, unknown>).server_only
|
|
420
|
-
if (so === undefined) return { serverOnly: [], actions, cloudflare, cache, cssGlobal, sourceRoots }
|
|
438
|
+
if (so === undefined) return { serverOnly: [], actions, cloudflare, cache, cssGlobal, sourceRoots, basePath }
|
|
421
439
|
if (!Array.isArray(so) || so.some((p) => typeof p !== 'string' || p.trim() === ''))
|
|
422
440
|
throw new BuildError('config', `${file}: build.server_only must be an array of non-empty strings`)
|
|
423
|
-
return { serverOnly: so.map((p: string) => posixOf(p.trim()).replace(/^\.\//, '')), actions, cloudflare, cache, cssGlobal, sourceRoots }
|
|
441
|
+
return { serverOnly: so.map((p: string) => posixOf(p.trim()).replace(/^\.\//, '')), actions, cloudflare, cache, cssGlobal, sourceRoots, basePath }
|
|
424
442
|
}
|
package/src/build/css.ts
CHANGED
|
@@ -132,13 +132,17 @@ export function cssUnescape(s: string): string {
|
|
|
132
132
|
)
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
/** F495: a URL brust serves itself, `/public/…` or `/_brust/…`, under the app's base path (`<base>/public/…`) or at the root:
|
|
136
|
+
* written as it is, never a file on this machine. */
|
|
137
|
+
export const brustUrl = (v: string, base = ''): boolean => ['/public/', '/_brust/'].some((p) => v.startsWith(p) || (base !== '' && v.startsWith(`${base}${p}`)))
|
|
138
|
+
|
|
135
139
|
/** F490: an absolute local path in either spelling: POSIX `/…`, a Windows drive `C:\…` / `C:/…`, or a UNC `\\host\…`. */
|
|
136
140
|
export const localAbsolute = (p: string): boolean => p.startsWith('/') || /^[A-Za-z]:[\\/]/.test(p) || p.startsWith('\\\\')
|
|
137
141
|
|
|
138
142
|
/** F393: a `url()` that is the absolute path of a file on disk (see the `loader` in `buildCss`) becomes a
|
|
139
143
|
* hashed `./<name>-<hex10>.<ext>` client asset at any size. `/public/…` and `/_brust/…` stay as written.
|
|
140
144
|
* F490: on Windows that path is `C:\\…` (CSS-escaped), so the value is unescaped and both spellings are taken. */
|
|
141
|
-
function emitSourceAssets(text: string, assets: { name: string; bytes: Uint8Array }[], suffixed: Map<string, string
|
|
145
|
+
function emitSourceAssets(text: string, assets: { name: string; bytes: Uint8Array }[], suffixed: Map<string, string>, base = ''): string {
|
|
142
146
|
const emit = (path: string, suffix: string) => {
|
|
143
147
|
const { name, bytes } = hashedAsset(path)
|
|
144
148
|
if (!assets.some((a) => a.name === name)) assets.push({ name, bytes })
|
|
@@ -149,7 +153,7 @@ function emitSourceAssets(text: string, assets: { name: string; bytes: Uint8Arra
|
|
|
149
153
|
if (abs !== undefined) return emit(abs, path.slice(path.search(/[?#]/)))
|
|
150
154
|
const file = cssUnescape(path)
|
|
151
155
|
const ext = extname(file).toLowerCase()
|
|
152
|
-
if (!localAbsolute(file) || !SERVED_ASSET_EXT.includes(ext) || file
|
|
156
|
+
if (!localAbsolute(file) || !SERVED_ASSET_EXT.includes(ext) || brustUrl(file, base) || !existsSync(file)) return whole
|
|
153
157
|
return emit(file, '')
|
|
154
158
|
})
|
|
155
159
|
}
|
|
@@ -158,11 +162,11 @@ function emitSourceAssets(text: string, assets: { name: string; bytes: Uint8Arra
|
|
|
158
162
|
* path and 404 in the browser, so the build fails instead. A leak is a Windows drive or UNC path, or a `/…` path under
|
|
159
163
|
* the app root or the dist dir (`roots`). Any other root URL passes, even one that happens to exist on the build
|
|
160
164
|
* machine (`url(/tmp/x)`, `url(/fonts/x.woff2)`): only the app's own files are known to be local. */
|
|
161
|
-
export function leakedLocalPaths(text: string, roots: string[]): string[] {
|
|
165
|
+
export function leakedLocalPaths(text: string, roots: string[], base = ''): string[] {
|
|
162
166
|
const out: string[] = []
|
|
163
167
|
for (const m of text.matchAll(SOURCE_URL)) {
|
|
164
168
|
const v = cssUnescape(m[2]!)
|
|
165
|
-
if (v
|
|
169
|
+
if (brustUrl(v, base) || !localAbsolute(v)) continue
|
|
166
170
|
const posix = v.replaceAll('\\', '/')
|
|
167
171
|
const local = !v.startsWith('/') || roots.some((r) => posix === r || posix.startsWith(`${r}/`))
|
|
168
172
|
if (local && !out.includes(v)) out.push(v)
|
|
@@ -203,7 +207,7 @@ export async function buildCss(
|
|
|
203
207
|
dist: string,
|
|
204
208
|
sources: string[],
|
|
205
209
|
minify = true,
|
|
206
|
-
o: { appRoot?: string; log?: (s: string) => void } = {},
|
|
210
|
+
o: { appRoot?: string; log?: (s: string) => void; basePath?: string; ownOutput?: string[] } = {},
|
|
207
211
|
): Promise<CssBuild> {
|
|
208
212
|
const file = new Map<string, string>()
|
|
209
213
|
const maps = new Map<string, Record<string, string>>()
|
|
@@ -220,7 +224,9 @@ export async function buildCss(
|
|
|
220
224
|
if (hit === undefined) via.set(k, (hit = tailwindUse([k]).using.length > 0))
|
|
221
225
|
return hit
|
|
222
226
|
}
|
|
223
|
-
|
|
227
|
+
// F501: the output dirs of this app that Tailwind must not scan, as app-root-relative patterns (only those under it).
|
|
228
|
+
const own = (o.ownOutput ?? []).map((d) => relative(real(appRoot), real(d)).split(sep).join('/')).filter((r) => r !== '' && r !== '..' && !r.startsWith('../') && !isAbsolute(r))
|
|
229
|
+
plugins.push(await loadTailwind(appRoot, tw.using[0]!, uses, o.log, [...new Set(own)]))
|
|
224
230
|
if (tw.imports.length > 1)
|
|
225
231
|
o.log?.(
|
|
226
232
|
`[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`,
|
|
@@ -241,7 +247,7 @@ export async function buildCss(
|
|
|
241
247
|
format: 'esm',
|
|
242
248
|
minify,
|
|
243
249
|
naming: { entry: '[name]-[hash].[ext]', chunk: '[name]-[hash].[ext]', asset: '[name]-[hash].[ext]' },
|
|
244
|
-
external: ['/public/*', '/_brust/*'],
|
|
250
|
+
external: ['/public/*', '/_brust/*', ...(o.basePath ? [`${o.basePath}/public/*`, `${o.basePath}/_brust/*`] : [])],
|
|
245
251
|
root: appRoot,
|
|
246
252
|
plugins,
|
|
247
253
|
// F393, verified on Bun 1.4.4 only: its CSS bundler turns an asset under the size threshold into a `data:`
|
|
@@ -289,9 +295,9 @@ export async function buildCss(
|
|
|
289
295
|
`${lines.join('; ')}: brust serves only ${SERVED_ASSET_EXT.join(' ')} from /_brust/client; put the file in public/ and reference it as /public/…`,
|
|
290
296
|
)
|
|
291
297
|
}
|
|
292
|
-
for (const [i, t] of css) css.set(i, emitSourceAssets(t, assets, suffixed))
|
|
298
|
+
for (const [i, t] of css) css.set(i, emitSourceAssets(t, assets, suffixed, o.basePath))
|
|
293
299
|
const roots = [appRoot, dist].flatMap((r) => [r, real(r)]).map((r) => r.replaceAll('\\', '/').replace(/\/+$/, '')).filter((r) => r !== '')
|
|
294
|
-
const leaks = [...css].flatMap(([i, t]) => leakedLocalPaths(t, roots).map((p) => `url(${p}) in ${sources[i]}`))
|
|
300
|
+
const leaks = [...css].flatMap(([i, t]) => leakedLocalPaths(t, roots, o.basePath).map((p) => `url(${p}) in ${sources[i]}`))
|
|
295
301
|
if (leaks.length > 0)
|
|
296
302
|
throw new BuildError('css-asset-path', `${leaks.join('; ')}: the built CSS would name a file on this machine; reference it relative to the stylesheet or as /public/…`)
|
|
297
303
|
for (const a of assets) writeFileSync(join(dist, 'client', a.name), a.bytes)
|
|
@@ -351,9 +357,9 @@ export function tailwindUse(sources: string[]): { using: string[]; imports: stri
|
|
|
351
357
|
* (`export default Object.freeze({…})`); any other `.css` import is an empty module, because the
|
|
352
358
|
* stylesheet itself is linked by the server (R19.3), never bundled into JS.
|
|
353
359
|
* F411: with `clientDir` (`<dist>/client`), an import of a served asset (`SERVED_ASSET_EXT`: a font, an image) is
|
|
354
|
-
* the string `/_brust/client/<name>-<hex10>.<ext
|
|
360
|
+
* the string `/_brust/client/<name>-<hex10>.<ext>` (under `basePath`, F495), the very file a stylesheet's `url()` of it is rewritten to; the
|
|
355
361
|
* file is written there, so the URL can be preloaded (`<link rel="preload" as="font" href={font}>`). */
|
|
356
|
-
export function cssPlugin(maps: Map<string, Record<string, string>>, clientDir?: string): BunPlugin {
|
|
362
|
+
export function cssPlugin(maps: Map<string, Record<string, string>>, clientDir?: string, basePath = ''): BunPlugin {
|
|
357
363
|
return {
|
|
358
364
|
name: 'brust-css',
|
|
359
365
|
setup(b) {
|
|
@@ -371,7 +377,7 @@ export function cssPlugin(maps: Map<string, Record<string, string>>, clientDir?:
|
|
|
371
377
|
mkdirSync(clientDir, { recursive: true })
|
|
372
378
|
writeFileSync(to, bytes)
|
|
373
379
|
}
|
|
374
|
-
return { contents: `export default ${JSON.stringify(
|
|
380
|
+
return { contents: `export default ${JSON.stringify(`${basePath}/_brust/client/${name}`)}\n`, loader: 'js' }
|
|
375
381
|
})
|
|
376
382
|
},
|
|
377
383
|
}
|
package/src/build/index.ts
CHANGED
|
@@ -8,8 +8,9 @@ import { createHash } from 'node:crypto'
|
|
|
8
8
|
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
9
9
|
import { homedir } from 'node:os'
|
|
10
10
|
import { basename, dirname, isAbsolute, join, parse, relative, resolve } from 'node:path'
|
|
11
|
-
import { BrustRouteError, type FlatRoute, validateRoutes } from '../routes'
|
|
12
|
-
import {
|
|
11
|
+
import { BrustRouteError, checkBasePath, type FlatRoute, validateRoutes } from '../routes'
|
|
12
|
+
import { assertRoutesInstallable, type CheckPatterns, nativeCheckPatterns } from './route-conflicts'
|
|
13
|
+
import { type BrowserCtx, brustRoot, setDevBuild, buildActions, buildCacheAdapter, buildClientChunks, buildJobs, buildReactChunks, buildRuntime, nestedOf, storeModules } from './bundle'
|
|
13
14
|
import { runActionsOnly } from './actions-only'
|
|
14
15
|
import { type CfPrepared, buildCloudflare, prepareCloudflare } from './cloudflare'
|
|
15
16
|
import { renderIcons } from './build-icons'
|
|
@@ -106,6 +107,8 @@ export async function runBuild(opts: {
|
|
|
106
107
|
strict?: boolean
|
|
107
108
|
/** The cache key grammar's read-set (static; tests swap it). Default: the addon. */
|
|
108
109
|
exprReads?: ExprReads
|
|
110
|
+
/** The router's refusal of a pattern set (F496; tests swap it). Default: the addon. */
|
|
111
|
+
checkPatterns?: CheckPatterns
|
|
109
112
|
/** `--wasm-from-source` (cloudflare only). */
|
|
110
113
|
wasmFromSource?: boolean
|
|
111
114
|
/** `--actions-only` (L16 R16.3): no pages, only the `[actions]` app; with `bun` (default) or `cloudflare`. */
|
|
@@ -123,9 +126,11 @@ export async function runBuild(opts: {
|
|
|
123
126
|
assertSafeOutDir(dist, appRoot, [entry, join(appRoot, 'public')])
|
|
124
127
|
assertReplaceable(dist, opts.force)
|
|
125
128
|
const built = await loadBuildConfig(appRoot, { actionsOnly: opts.actionsOnly === true })
|
|
129
|
+
// F495: `basePath` of `@brust/core` while the build loads the app's modules (route scan, static render).
|
|
130
|
+
process.env.BRUST_BASE_PATH = built.basePath
|
|
126
131
|
if (opts.actionsOnly)
|
|
127
132
|
return runActionsOnly({ appRoot, dist, config: built, target: opts.target, wasmFromSource: opts.wasmFromSource === true, statOk, assertSafeOutDir, log: opts.log })
|
|
128
|
-
const { serverOnly, cloudflare, cache, cssGlobal, sourceRoots } = built
|
|
133
|
+
const { serverOnly, cloudflare, cache, cssGlobal, sourceRoots, basePath } = built
|
|
129
134
|
// L16 R16.4b: `[actions]` with a `base_url` and no actions file (the default name, not one written in the toml)
|
|
130
135
|
// is a page-only build: the app is deployed elsewhere, so nothing is bundled or mounted here.
|
|
131
136
|
const remote =
|
|
@@ -138,10 +143,13 @@ export async function runBuild(opts: {
|
|
|
138
143
|
let leaves: ReturnType<typeof validateRoutes>
|
|
139
144
|
try {
|
|
140
145
|
leaves = validateRoutes(routes)
|
|
146
|
+
checkBasePath(leaves, basePath)
|
|
141
147
|
} catch (e) {
|
|
142
148
|
if (e instanceof BrustRouteError) throw new BuildError(e.rule, e.message)
|
|
143
149
|
throw e
|
|
144
150
|
}
|
|
151
|
+
// F496: what the server's router would refuse at `brust start` (a `{*rest}` next to a `{slug}`) fails here.
|
|
152
|
+
assertRoutesInstallable(leaves, opts.checkPatterns ?? (await nativeCheckPatterns()))
|
|
145
153
|
let actionsEntry: string | null = null
|
|
146
154
|
if (actions) {
|
|
147
155
|
actionsEntry = resolve(appRoot, actions.entry)
|
|
@@ -176,7 +184,7 @@ export async function runBuild(opts: {
|
|
|
176
184
|
// R9.3/R9.4: every reason to refuse a static build, before any write.
|
|
177
185
|
const ssgPlan =
|
|
178
186
|
opts.target === 'static'
|
|
179
|
-
? await prepareStatic(leaves, actions !== null, opts.exprReads ?? (await import('../native')).cacheExprReads, opts.log, cache)
|
|
187
|
+
? await prepareStatic(leaves, actions !== null, opts.exprReads ?? (await import('../native')).cacheExprReads, opts.log, cache, basePath)
|
|
180
188
|
: null
|
|
181
189
|
|
|
182
190
|
// Config, @brust/core-cf resolution and the user's wrangler file fail here, before any write.
|
|
@@ -190,7 +198,7 @@ export async function runBuild(opts: {
|
|
|
190
198
|
rmSync(tmp, { recursive: true, force: true })
|
|
191
199
|
mkdirSync(tmp, { recursive: true })
|
|
192
200
|
try {
|
|
193
|
-
await buildInto(tmp, { dev: opts.dev === true, appRoot, entry, dist, leaves, componentFile, componentIssue, appModules, appGraph, serverOnly, actions, actionsEntry, cache, cacheModule, cf, hybrid, cssGlobal, sourceRoots, actionsRemote: remote?.prefix ?? null, log: opts.log })
|
|
201
|
+
await buildInto(tmp, { dev: opts.dev === true, appRoot, entry, dist, leaves, componentFile, componentIssue, appModules, appGraph, serverOnly, actions, actionsEntry, cache, cacheModule, cf, hybrid, cssGlobal, sourceRoots, basePath, actionsRemote: remote?.prefix ?? null, log: opts.log })
|
|
194
202
|
if (ssgPlan) {
|
|
195
203
|
// Rendered from the finished temp dist, so a failed render leaves the previous dist untouched.
|
|
196
204
|
const version: string = (await Bun.file(join(import.meta.dir, '../../package.json')).json()).version
|
|
@@ -233,6 +241,8 @@ async function buildInto(
|
|
|
233
241
|
cssGlobal: string[]
|
|
234
242
|
/** `[build] source_roots` (F256): normalised, app-root-relative directories outside the app a relative import may reach. */
|
|
235
243
|
sourceRoots: string[]
|
|
244
|
+
/** `[server] base_path` (F495), `''` = the root. */
|
|
245
|
+
basePath: string
|
|
236
246
|
/** L16 R16.4b: the prefix of an actions app deployed elsewhere (a page-only build); absent = a local app or none. */
|
|
237
247
|
actionsRemote: string | null
|
|
238
248
|
/** `--target static` with `[actions]`: the worker is built without the cache (static pages are never cached). */
|
|
@@ -248,13 +258,13 @@ async function buildInto(
|
|
|
248
258
|
const serverCss = [o.actionsEntry, o.cacheModule].flatMap((f) => (f ? graph.of(f) : []))
|
|
249
259
|
mkdirSync(join(out, 'client'), { recursive: true })
|
|
250
260
|
// Review N3: `brust dev` builds with BRUST_BUILD_MODE=development (as the JS bundles): readable, unminified CSS.
|
|
251
|
-
const css = await buildCss(out, [...new Set([...[...routeCss.values()].flat(), ...serverCss])], process.env.BRUST_BUILD_MODE !== 'development', { appRoot, log: o.log })
|
|
261
|
+
const css = await buildCss(out, [...new Set([...[...routeCss.values()].flat(), ...serverCss])], process.env.BRUST_BUILD_MODE !== 'development', { appRoot, log: o.log, basePath: o.basePath, ownOutput: [o.dist, join(appRoot, '.brust')] })
|
|
252
262
|
for (const c of directCss(entry))
|
|
253
263
|
o.log(`[brust] warning css ${relative(appRoot, entry)} imports ${c}: the route entry belongs to no route, so it is linked nowhere; import it from a layout, or list it in [css] global`)
|
|
254
|
-
const ctx: BrowserCtx = { sourceOf: new Map(), appRoot, serverOnly: o.serverOnly, serverFiles: o.actionsEntry ? [realpathSync(o.actionsEntry)] : [], cssMaps: css.maps, clientDir: join(out, 'client') }
|
|
264
|
+
const ctx: BrowserCtx = { sourceOf: new Map(), appRoot, serverOnly: o.serverOnly, serverFiles: o.actionsEntry ? [realpathSync(o.actionsEntry)] : [], cssMaps: css.maps, clientDir: join(out, 'client'), basePath: o.basePath }
|
|
255
265
|
// The runtime first: its hashed URL is the `runtimeImport` every client chunk is compiled with.
|
|
256
266
|
const runtime = await buildRuntime(out, ctx)
|
|
257
|
-
const runtimeUrls = { core:
|
|
267
|
+
const runtimeUrls = { core: `${brustRoot(ctx)}/${runtime.core}`, store: `${brustRoot(ctx)}/${runtime.store}` }
|
|
258
268
|
// F230: one scan per build for load-time writes into another app module's exports; every compile reads it.
|
|
259
269
|
const loadWrites = scanAppWrites(appRoot, o.appModules, o.log, o.sourceRoots)
|
|
260
270
|
// Where a React island renders, the load-time writers run first: every one in the SSR jobs bundle (server code the
|
|
@@ -346,10 +356,12 @@ async function buildInto(
|
|
|
346
356
|
const files = [...new Set((routeCss.get(r.id) ?? []).flatMap((s) => (css.file.has(s) ? [css.file.get(s)!] : [])))]
|
|
347
357
|
if (files.length > 0) r.css = files
|
|
348
358
|
}
|
|
349
|
-
await buildJobs(out, compiled, ctx.sourceOf, appRoot, storeModules(manifest), css.maps, routeRoots, reachedJobs(manifest), writers)
|
|
359
|
+
await buildJobs(out, compiled, ctx.sourceOf, appRoot, storeModules(manifest), css.maps, routeRoots, reachedJobs(manifest), writers, o.basePath)
|
|
360
|
+
// F495: absent without a base path, so an app at the root writes the manifest it always did.
|
|
361
|
+
if (o.basePath !== '') manifest.base_path = o.basePath
|
|
350
362
|
manifest.actions =
|
|
351
363
|
o.actions && o.actionsEntry
|
|
352
|
-
? { entry: await buildActions(out, o.actionsEntry, css.maps), prefix: o.actions.prefix, maxBodyBytes: o.actions.maxBodyBytes, maxResponseBytes: o.actions.maxResponseBytes, crossSite: o.actions.crossSite, ...o.actions.stream }
|
|
364
|
+
? { entry: await buildActions(out, o.actionsEntry, css.maps, o.basePath), prefix: o.actions.prefix, maxBodyBytes: o.actions.maxBodyBytes, maxResponseBytes: o.actions.maxResponseBytes, crossSite: o.actions.crossSite, ...o.actions.stream }
|
|
353
365
|
: null
|
|
354
366
|
if (o.actionsRemote !== null) manifest.actions_remote = { prefix: o.actionsRemote }
|
|
355
367
|
if (o.cache) manifest.cache = await cacheRecord(out, manifest, o.cache, o.cacheModule, css.maps)
|
package/src/build/manifest.ts
CHANGED
|
@@ -36,6 +36,9 @@ export interface ChildRecord {
|
|
|
36
36
|
exprs?: Record<string, string>
|
|
37
37
|
/** F142 R34.43: the list of a `per-row-literal` instance (a constant the compiler read). */
|
|
38
38
|
rows?: unknown[]
|
|
39
|
+
/** F502: the branch condition (`{truthy}`/`{eq}`/`{not}`/`{all}`/`{any}` over props paths) the server checks per instance before
|
|
40
|
+
* it runs the child's job: a row whose branch does not render the child gets no job. */
|
|
41
|
+
when?: unknown
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
export interface ComponentRecord {
|
|
@@ -97,6 +100,8 @@ export interface ManifestJson {
|
|
|
97
100
|
actions: ActionsRecord | null
|
|
98
101
|
/** L16 R16.3: an actions-only build (no routes, no templates); everything but the prefix is a 404 problem+json. */
|
|
99
102
|
actions_only?: true
|
|
103
|
+
/** F495 `[server] base_path`: set only for an app mounted under a path. The server answers under it alone and every URL brust owns lies below it. */
|
|
104
|
+
base_path?: string
|
|
100
105
|
/** L16 R16.4b: a page-only build: no local actions app, `base_url` points at the deployment that has it. */
|
|
101
106
|
actions_remote?: { prefix: string }
|
|
102
107
|
/** `[cache]` (cache adapters); absent without one (the manifest is then byte-identical). */
|
|
@@ -315,6 +320,7 @@ export function childRecords(c: Compiled, clientOnly: string[] = []): ChildRecor
|
|
|
315
320
|
if (inst.literals && Object.keys(inst.literals).length > 0) rec.literals = { ...inst.literals }
|
|
316
321
|
if (inst.exprs && Object.keys(inst.exprs).length > 0) rec.exprs = { ...inst.exprs }
|
|
317
322
|
if (literal) rec.rows = inst.rows_literal
|
|
323
|
+
if (inst.when !== undefined && inst.when !== null) rec.when = inst.when
|
|
318
324
|
return rec
|
|
319
325
|
})
|
|
320
326
|
for (const id of clientOnly) out.push({ id, instances: 'static', props: {} })
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// F496: the server's router refuses some pattern sets (`/{*rest}` next to `/{slug}`); without this the build passes and
|
|
2
|
+
// the first `brust start` fails. The check is the router's own insertion (`routing/routes.rs` `find_pattern_conflict`,
|
|
3
|
+
// through the addon the build already loads): there is no second matcher here.
|
|
4
|
+
import type { FlatRoute } from '../routes'
|
|
5
|
+
import { BuildError } from './errors'
|
|
6
|
+
|
|
7
|
+
export interface PatternConflict {
|
|
8
|
+
/** Index into the patterns given of the pattern the router refused. */
|
|
9
|
+
index: number
|
|
10
|
+
/** Index of the earlier pattern it conflicts with, when there is one. */
|
|
11
|
+
withIndex?: number | null
|
|
12
|
+
/** The router's refusal, as `brust start` would print it. */
|
|
13
|
+
message: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type CheckPatterns = (patterns: string[]) => PatternConflict | null
|
|
17
|
+
|
|
18
|
+
/** The addon's `checkRoutePatterns` (crates/brust-napi/src/compile.rs `check_route_patterns`). */
|
|
19
|
+
export async function nativeCheckPatterns(): Promise<CheckPatterns> {
|
|
20
|
+
const { native } = await import('../native')
|
|
21
|
+
const b = native() as unknown as { checkRoutePatterns?: CheckPatterns }
|
|
22
|
+
if (typeof b.checkRoutePatterns !== 'function')
|
|
23
|
+
throw new BuildError('route-conflict', 'the native addon is older than this build (no checkRoutePatterns): rebuild it')
|
|
24
|
+
return b.checkRoutePatterns.bind(b)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Fails the build when the server's router would refuse the leaves' patterns. Catch-all (`*`) leaves never enter the
|
|
28
|
+
* router, so they are left out, as the server leaves them out. The error names the route ids. */
|
|
29
|
+
export function assertRoutesInstallable(leaves: FlatRoute[], check: CheckPatterns): void {
|
|
30
|
+
const routed = leaves.filter((l) => !l.catchAll)
|
|
31
|
+
const found = check(routed.map((l) => l.pattern))
|
|
32
|
+
if (!found) return
|
|
33
|
+
const at = routed[found.index]
|
|
34
|
+
const partner = found.withIndex != null ? routed[found.withIndex] : undefined
|
|
35
|
+
const who = partner
|
|
36
|
+
? `routes ${partner.id} (${partner.pattern}) and ${at?.id} (${at?.pattern}) cannot be installed together`
|
|
37
|
+
: `route ${at?.id} (${at?.pattern}) cannot be installed`
|
|
38
|
+
throw new BuildError('route-conflict', `${who}: ${found.message}`)
|
|
39
|
+
}
|
package/src/build/ssg.ts
CHANGED
|
@@ -77,7 +77,8 @@ function checkValue(leaf: FlatRoute, i: number, name: string, rest: boolean, v:
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
/** Plans every page. `params` holds each dynamic route's resolved `ssg.params()` by route id. */
|
|
80
|
-
|
|
80
|
+
/** F495: under `base` (`[server] base_path`) the not-found probe is a path under it, so the app's `*` route answers it rather than the server's plain 404. */
|
|
81
|
+
export function planStatic(leaves: FlatRoute[], params: Map<string, unknown>, base = ''): StaticPlan {
|
|
81
82
|
const pages: PlannedPage[] = []
|
|
82
83
|
const skipped: StaticPlan['skipped'] = []
|
|
83
84
|
let catchAll: FlatRoute | undefined
|
|
@@ -133,7 +134,7 @@ export function planStatic(leaves: FlatRoute[], params: Map<string, unknown>): S
|
|
|
133
134
|
seen.set(key, p)
|
|
134
135
|
}
|
|
135
136
|
if (seen.has('404.html')) throw new BuildError('ssg-params', `${seen.get('404.html')!.path} would overwrite 404.html`)
|
|
136
|
-
if (catchAll) pages.push({ path: NOT_FOUND_PATH
|
|
137
|
+
if (catchAll) pages.push({ path: `${base}${NOT_FOUND_PATH}`, file: '404.html', routeId: catchAll.id, pattern: catchAll.pattern, expect: 404 })
|
|
137
138
|
return { pages, skipped, defaultNotFound: catchAll === undefined }
|
|
138
139
|
}
|
|
139
140
|
|
package/src/build/static.ts
CHANGED
|
@@ -28,6 +28,7 @@ export async function prepareStatic(
|
|
|
28
28
|
reads: ExprReads,
|
|
29
29
|
log: (s: string) => void,
|
|
30
30
|
cache?: Parameters<typeof assertNoCacheBindings>[0],
|
|
31
|
+
base = '',
|
|
31
32
|
): Promise<StaticPlan> {
|
|
32
33
|
// L12: `[actions]` makes the output hybrid (static pages + a Pages Worker for the action prefix).
|
|
33
34
|
if (actions) assertNoCacheBindings(cache)
|
|
@@ -65,7 +66,7 @@ export async function prepareStatic(
|
|
|
65
66
|
throw new BuildError('ssg-params', `route ${leaf.id} (${leaf.pattern}) ssg.params() threw: ${(e as Error).message ?? String(e)}`)
|
|
66
67
|
}
|
|
67
68
|
}
|
|
68
|
-
return planStatic(leaves, params)
|
|
69
|
+
return planStatic(leaves, params, base)
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
export interface StaticReport {
|
|
@@ -167,7 +168,7 @@ export async function writeStatic(
|
|
|
167
168
|
const dir = join(dist, 'static')
|
|
168
169
|
rmSync(dir, { recursive: true, force: true })
|
|
169
170
|
const assetsStage = join(dist, '.ssg-assets')
|
|
170
|
-
const assets = layoutAssets(dist, assetsStage, o.version)
|
|
171
|
+
const assets = layoutAssets(dist, assetsStage, o.version, (JSON.parse(readFileSync(join(dist, 'manifest.json'), 'utf8')) as { base_path?: string }).base_path ?? '')
|
|
171
172
|
renameSync(join(assetsStage, 'assets'), dir)
|
|
172
173
|
rmSync(assetsStage, { recursive: true, force: true })
|
|
173
174
|
const warnings = [...assets.warnings]
|
package/src/build/tailwind.ts
CHANGED
|
@@ -68,8 +68,15 @@ const addCommand = (v: string) => `bun add -d --exact ${TAILWIND_PACKAGES.map((p
|
|
|
68
68
|
/** The Tailwind plugin of the CSS build, resolved from the app. Missing packages → `tailwind-missing` with the
|
|
69
69
|
* exact install command; versions that differ → one warning through `log` with the command that aligns them.
|
|
70
70
|
* `uses(file)` says whether a stylesheet needs the compiler (it, or a relative `@import` of it, uses Tailwind);
|
|
71
|
-
* other stylesheets stay Bun's.
|
|
72
|
-
|
|
71
|
+
* other stylesheets stay Bun's. `ownOutput`: app-root-relative, `/`-separated directories of brust's own output that no
|
|
72
|
+
* scan may read (F501). */
|
|
73
|
+
export async function loadTailwind(
|
|
74
|
+
appRoot: string,
|
|
75
|
+
usedBy: string,
|
|
76
|
+
uses: (file: string) => boolean,
|
|
77
|
+
log?: (s: string) => void,
|
|
78
|
+
ownOutput: string[] = [],
|
|
79
|
+
): Promise<BunPlugin> {
|
|
73
80
|
const v = tailwindVersions(appRoot)
|
|
74
81
|
const missing = TAILWIND_PACKAGES.filter((p) => versionFrom(p, appRoot) === undefined)
|
|
75
82
|
if (missing.length > 0)
|
|
@@ -105,7 +112,10 @@ export async function loadTailwind(appRoot: string, usedBy: string, uses: (file:
|
|
|
105
112
|
const compiler = await node.compile(css, { base: dirname(a.path), from: a.path, shouldRewriteUrls: true, onDependency: () => {} })
|
|
106
113
|
const root: SourceEntry[] =
|
|
107
114
|
compiler.root === 'none' ? [] : compiler.root === null ? [{ base: appRoot, pattern: '**/*', negated: false }] : [{ ...compiler.root, negated: false }]
|
|
108
|
-
|
|
115
|
+
// F501: brust's own output (the dist dir, `.brust`) is never a source, whatever the ignore files say and whatever
|
|
116
|
+
// the stylesheet's `source(...)` and `@source` lines add: last, so a stale build can never feed the next one.
|
|
117
|
+
const own: SourceEntry[] = ownOutput.map((dir) => ({ base: appRoot, pattern: `${dir}/**`, negated: true }))
|
|
118
|
+
const scanner = new oxide.Scanner({ sources: [...root, ...compiler.sources, ...own] })
|
|
109
119
|
let out = compiler.build(scanner.scan())
|
|
110
120
|
// `@reference "./app.css"` pulls in Tailwind's license banner too; only the file that imports Tailwind ships it
|
|
111
121
|
// (one banner per page, as before)
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// this module reads out of the app's own `tsconfig.json` / `jsconfig.json`. v1 reads that one file: no `extends`, no
|
|
4
4
|
// `references` (a named fallback below); exact and single-`*` patterns only. The bundler resolves the same specifiers
|
|
5
5
|
// natively; `checkAliasAgreement` fails the build if the two ever disagree.
|
|
6
|
-
import { existsSync, readFileSync, realpathSync } from 'node:fs'
|
|
6
|
+
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
|
|
7
7
|
import { dirname, isAbsolute, join, normalize, relative, sep } from 'node:path'
|
|
8
8
|
import { BuildError } from './errors'
|
|
9
9
|
|
|
@@ -26,10 +26,21 @@ export function readPathAliases(appRoot: string): { aliases: PathAlias[]; notes:
|
|
|
26
26
|
}
|
|
27
27
|
const notes: string[] = []
|
|
28
28
|
const paths = json?.compilerOptions?.paths
|
|
29
|
-
|
|
29
|
+
const name = relative(appRoot, file)
|
|
30
|
+
const hasPaths = !!paths && typeof paths === 'object'
|
|
31
|
+
// TS semantics: a local `compilerOptions.paths` replaces any inherited `paths`, so beside a local `paths` an `extends`
|
|
32
|
+
// can only matter through the `baseUrl` it supplies (F497). Without local `paths` the aliases live elsewhere.
|
|
33
|
+
if (hasPaths ? json?.references !== undefined : json?.extends !== undefined || json?.references !== undefined)
|
|
30
34
|
notes.push(
|
|
31
|
-
`${
|
|
35
|
+
`${name} uses \`${json.extends !== undefined && !hasPaths ? 'extends' : 'references'}\`, which brust does not follow: \`paths\` aliases written in another tsconfig stay package imports (put \`paths\` in ${name})`,
|
|
32
36
|
)
|
|
37
|
+
if (hasPaths && json?.extends !== undefined && typeof json.compilerOptions?.baseUrl !== 'string') {
|
|
38
|
+
const from = inheritedBaseUrlFile(file, json.extends)
|
|
39
|
+
if (from)
|
|
40
|
+
notes.push(
|
|
41
|
+
`\`baseUrl\` comes from ${relative(appRoot, from).split(sep).join('/')}, which brust does not follow: aliases resolve from ${name}'s directory (set \`baseUrl\` in ${name})`,
|
|
42
|
+
)
|
|
43
|
+
}
|
|
33
44
|
if (!paths || typeof paths !== 'object') return { aliases: [], notes }
|
|
34
45
|
const base = typeof json.compilerOptions?.baseUrl === 'string' ? json.compilerOptions.baseUrl : '.'
|
|
35
46
|
const aliases: PathAlias[] = []
|
|
@@ -52,6 +63,23 @@ export function readPathAliases(appRoot: string): { aliases: PathAlias[]; notes:
|
|
|
52
63
|
return { aliases, notes }
|
|
53
64
|
}
|
|
54
65
|
|
|
66
|
+
/** The extended tsconfig that sets `baseUrl`, when `extends` names a relative file (one level, JSONC; a package specifier,
|
|
67
|
+
* an unreadable file or a file without `baseUrl` is none). */
|
|
68
|
+
function inheritedBaseUrlFile(file: string, extendsValue: unknown): string | null {
|
|
69
|
+
const refs = Array.isArray(extendsValue) ? extendsValue : [extendsValue]
|
|
70
|
+
for (const ref of refs) {
|
|
71
|
+
if (typeof ref !== 'string' || !(ref.startsWith('./') || ref.startsWith('../'))) continue
|
|
72
|
+
const base = join(dirname(file), ref)
|
|
73
|
+
const target = [base, `${base}.json`].find((f) => existsSync(f) && statSync(f).isFile())
|
|
74
|
+
if (!target) continue
|
|
75
|
+
try {
|
|
76
|
+
const ext = Bun.JSONC.parse(readFileSync(target, 'utf8')) as { compilerOptions?: { baseUrl?: unknown } } | null
|
|
77
|
+
if (typeof ext?.compilerOptions?.baseUrl === 'string') return target
|
|
78
|
+
} catch {}
|
|
79
|
+
}
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
|
|
55
83
|
/** The compiler's answer for every alias import must be the file the bundler resolves the same specifier to. */
|
|
56
84
|
export function checkAliasAgreement(appRoot: string, imports: { from: string; spec: string; target: string }[]): void {
|
|
57
85
|
for (const { from, spec, target } of imports) {
|