@brust/core 2.0.0-alpha.3 → 2.0.0-alpha.4
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 +64 -6
- package/native/index.d.ts +37 -2
- package/native/index.js +56 -54
- package/package.json +11 -9
- package/src/actions/client.ts +22 -5
- package/src/actions/load.ts +2 -1
- package/src/actions/prefix.ts +24 -2
- package/src/actions-bridge.ts +2 -2
- package/src/browser.ts +3 -1
- package/src/build/actions-only.ts +86 -0
- package/src/build/build-eval.ts +199 -0
- package/src/build/build-icons.ts +69 -0
- package/src/build/bundle.ts +39 -13
- package/src/build/cf-assets.ts +4 -2
- package/src/build/cf-entry.ts +7 -3
- package/src/build/cf-wrangler.ts +3 -1
- package/src/build/cloudflare.ts +14 -9
- package/src/build/compile.ts +41 -4
- package/src/build/config.ts +46 -10
- package/src/build/css.ts +286 -0
- package/src/build/index.ts +135 -20
- package/src/build/manifest.ts +23 -4
- package/src/build/scan.ts +59 -20
- 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 +258 -0
- package/src/build-time.ts +10 -0
- package/src/cf.ts +2 -1
- package/src/cli.ts +19 -4
- package/src/config.ts +111 -25
- package/src/css-modules.d.ts +8 -0
- package/src/dev/bun.ts +365 -0
- package/src/dev/client.ts +92 -0
- package/src/dev/devtools-data.ts +23 -0
- package/src/dev/devtools.ts +356 -0
- package/src/dev/diag.ts +30 -0
- package/src/dev/proxy.ts +236 -0
- package/src/dev/tui.ts +144 -0
- package/src/dev/watch.ts +81 -0
- package/src/dev.ts +11 -28
- package/src/handlers.ts +41 -2
- package/src/index.ts +3 -1
- package/src/native.ts +18 -0
- package/src/routes.ts +20 -1
- package/src/run.ts +40 -8
- package/src/worker.ts +88 -20
package/src/build/cloudflare.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { type CacheNeed, mergeWrangler, readUserWrangler, renderWrangler, type U
|
|
|
14
14
|
import { type CloudflareBuildConfig, CF_MIN_COMPAT_DATE } from './config'
|
|
15
15
|
import { PRODUCTION, type SourceOf, sourceDirPlugin } from './bundle'
|
|
16
16
|
import type { Compiled } from './compile'
|
|
17
|
+
import { cssPlugin } from './css'
|
|
17
18
|
import { BuildError } from './errors'
|
|
18
19
|
import type { CacheRecord } from './manifest'
|
|
19
20
|
import { BUILTINS as CACHE_BUILTINS } from '../cache-adapter/load'
|
|
@@ -76,10 +77,14 @@ export interface CfBuildCtx {
|
|
|
76
77
|
manifest: { components: Record<string, { template: string }>; actions?: { prefix: string; maxBodyBytes: number; maxResponseBytes?: number; crossSite?: 'reject' | 'allow' } | null; cache?: CacheRecord }
|
|
77
78
|
sourceOf: SourceOf
|
|
78
79
|
actionsEntry: string | null
|
|
80
|
+
/** L16 R16.3: an actions-only Worker: no pages (no routes entry, no jobs, no templates), no Static Assets. */
|
|
81
|
+
actionsOnly?: boolean
|
|
79
82
|
/** A custom cache adapter's source module (absolute). */
|
|
80
83
|
cacheModule?: string | null
|
|
81
84
|
/** `[cache] get_timeout_ms` as written (absent → the Workers default, 100). */
|
|
82
85
|
cacheGetTimeoutMs?: number
|
|
86
|
+
/** L19: CSS Module class maps (the worker reads the same map as the browser). */
|
|
87
|
+
cssMaps?: Map<string, Record<string, string>>
|
|
83
88
|
log: (s: string) => void
|
|
84
89
|
}
|
|
85
90
|
|
|
@@ -124,7 +129,7 @@ function cfAlias(alias: Record<string, string>, sourceOf: SourceOf): BunPlugin {
|
|
|
124
129
|
/** The CF bundle's stand-in for the napi addon (`native.ts` `require('../native/index.js')`, R8): every call
|
|
125
130
|
* fails loudly, since a Worker has no addon. */
|
|
126
131
|
function cfNativeStub(handlersPath: string): BunPlugin {
|
|
127
|
-
const names = ['startServer', 'registerWorker', 'untilReady', 'beginDrain', 'cacheInvalidate', 'cacheStats', 'localAddr', 'planJobs', 'takeFetchBody', 'putFetchReply', 'cacheReplay', 'cacheL2Count', 'cacheClear']
|
|
132
|
+
const names = ['startServer', 'registerWorker', 'untilReady', 'beginDrain', 'cacheInvalidate', 'cacheStats', 'localAddr', 'planJobs', 'planJobsBuf', 'takeFetchBody', 'putFetchReply', 'cacheReplay', 'cacheL2Count', 'cacheClear']
|
|
128
133
|
return {
|
|
129
134
|
name: 'brust-cf-native',
|
|
130
135
|
setup(b) {
|
|
@@ -166,7 +171,7 @@ function cfEntryCache(c: CacheRecord, customModule: string | null) {
|
|
|
166
171
|
}
|
|
167
172
|
|
|
168
173
|
/** 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 {
|
|
174
|
+
export function cacheNeeds(c: Pick<CacheRecord, 'adapter' | 'broadcast' | 'options' | 'broadcastOptions'> | undefined): CacheNeed | null {
|
|
170
175
|
if (!c) return null
|
|
171
176
|
const opt = (o: Record<string, unknown> | undefined, k: string) => (typeof o?.[k] === 'string' ? (o[k] as string) : undefined)
|
|
172
177
|
const all = [c.options, c.broadcastOptions]
|
|
@@ -191,7 +196,7 @@ plugin({
|
|
|
191
196
|
const m = await import(${JSON.stringify(app)})
|
|
192
197
|
const bad = []
|
|
193
198
|
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 !==
|
|
199
|
+
if (typeof m.MANIFEST !== 'string' || JSON.parse(m.MANIFEST).version !== 2) bad.push('MANIFEST.version !== 2')
|
|
195
200
|
if (typeof m.CONFIG !== 'string' || JSON.parse(m.CONFIG).abi !== 1) bad.push('CONFIG.abi !== 1')
|
|
196
201
|
if (typeof m.TEMPLATES !== 'object' || m.TEMPLATES === null) bad.push('TEMPLATES is not an object')
|
|
197
202
|
const r = await m.loader('{}')
|
|
@@ -247,11 +252,11 @@ export async function buildCloudflare(out: string, ctx: CfBuildCtx, p: CfPrepare
|
|
|
247
252
|
const jobsEntry = join(out, '.stage', 'jobs.ts')
|
|
248
253
|
writeFileSync(
|
|
249
254
|
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 }),
|
|
255
|
+
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
256
|
)
|
|
252
257
|
// Bare imports of the generated files resolve from the app root (an --out-dir outside the app has no node_modules above it).
|
|
253
258
|
// (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]) {
|
|
259
|
+
for (const f of ctx.actionsOnly ? [entryPath] : [entryPath, jobsEntry]) {
|
|
255
260
|
ctx.sourceOf.set(f, ctx.entry)
|
|
256
261
|
ctx.sourceOf.set(realpathSync(f), ctx.entry)
|
|
257
262
|
}
|
|
@@ -269,7 +274,7 @@ export async function buildCloudflare(out: string, ctx: CfBuildCtx, p: CfPrepare
|
|
|
269
274
|
define: PRODUCTION,
|
|
270
275
|
minify: false,
|
|
271
276
|
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:*'])],
|
|
277
|
+
plugins: [cssPlugin(ctx.cssMaps ?? new Map()), 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:*'])],
|
|
273
278
|
})
|
|
274
279
|
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
280
|
if (!res.success) throw new BuildError('bundle', `cloudflare: ${res.logs.map(String).join('\n')}`)
|
|
@@ -280,8 +285,8 @@ export async function buildCloudflare(out: string, ctx: CfBuildCtx, p: CfPrepare
|
|
|
280
285
|
mkdirSync(appDir, { recursive: true })
|
|
281
286
|
writeFileSync(appFile, await js[0]!.text())
|
|
282
287
|
|
|
283
|
-
|
|
284
|
-
warnings.push(...
|
|
288
|
+
// An actions-only Worker has no pages and so no Static Assets (no `assets` binding in its wrangler.jsonc either).
|
|
289
|
+
if (!ctx.actionsOnly) warnings.push(...layoutAssets(out, cfDir, p.coreVersion).warnings)
|
|
285
290
|
const compatibilityDate = p.cfg.compatibilityDate ?? p.core.meta.compatibilityDate
|
|
286
291
|
if (compatibilityDate < CF_MIN_COMPAT_DATE) throw new BuildError('config', `compatibility_date ${compatibilityDate} is older than ${CF_MIN_COMPAT_DATE}`)
|
|
287
292
|
if (doClass) {
|
|
@@ -292,7 +297,7 @@ export async function buildCloudflare(out: string, ctx: CfBuildCtx, p: CfPrepare
|
|
|
292
297
|
`// 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
298
|
)
|
|
294
299
|
}
|
|
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 })
|
|
300
|
+
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
301
|
warnings.push(...merged.warnings)
|
|
297
302
|
writeFileSync(join(cfDir, 'wrangler.jsonc'), renderWrangler(merged.config))
|
|
298
303
|
|
package/src/build/compile.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
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 {
|
|
5
|
+
import { realpathSync } from 'node:fs'
|
|
6
|
+
import { relative, sep } from 'node:path'
|
|
6
7
|
import { type CompiledComponent, type CompiledTree, compileTree } from '../../native/index.js'
|
|
7
8
|
import type { FlatRoute, Route } from '../routes'
|
|
8
9
|
import { BuildError } from './errors'
|
|
@@ -17,13 +18,22 @@ export type ComponentIR = Record<string, any> & {
|
|
|
17
18
|
tier: any
|
|
18
19
|
// biome-ignore lint/suspicious/noExplicitAny: IR JSON
|
|
19
20
|
jobs: any[]
|
|
20
|
-
instances: {
|
|
21
|
+
instances: {
|
|
22
|
+
child_id: string
|
|
23
|
+
k: number
|
|
24
|
+
loops: (string | null)[]
|
|
25
|
+
props: Record<string, string | null>
|
|
26
|
+
literals?: Record<string, unknown>
|
|
27
|
+
exprs?: Record<string, string>
|
|
28
|
+
}[]
|
|
21
29
|
use_id_slots: number
|
|
22
30
|
uses_outlet: boolean
|
|
23
31
|
needs_worker: boolean
|
|
24
32
|
/** `useStore(handle)` bindings (`decls.rs` `StoreUse`); `resolved` = `StoreRef`, `null` when
|
|
25
33
|
* unresolved (the component falls back). `module` is root-relative (C3). */
|
|
26
34
|
stores?: { handle: string; resolved: { name: string; module: string } | null }[]
|
|
35
|
+
/** A package export rendered as a React island (L22): `source` is then not a file. */
|
|
36
|
+
external?: { source: string; export: string }
|
|
27
37
|
}
|
|
28
38
|
|
|
29
39
|
export interface Compiled {
|
|
@@ -33,7 +43,11 @@ export interface Compiled {
|
|
|
33
43
|
ir: ComponentIR
|
|
34
44
|
jinja: string
|
|
35
45
|
serverTs?: string
|
|
46
|
+
/** Build-time evaluation module (L2): one thunk per `ir.build_eval` slot. */
|
|
47
|
+
evalTs?: string
|
|
36
48
|
clientJs?: string
|
|
49
|
+
/** The compiler's diagnostics for this component (`dev.json`, L14). */
|
|
50
|
+
diagnostics?: { class: string; rule: string; message: string; line: number; col: number; remediation: string }[]
|
|
37
51
|
}
|
|
38
52
|
|
|
39
53
|
type Diag = NonNullable<CompiledTree['error']>
|
|
@@ -78,11 +92,25 @@ export function compileApp(opts: {
|
|
|
78
92
|
appRoot: string
|
|
79
93
|
leaves: FlatRoute[]
|
|
80
94
|
componentFile: Map<Function, string>
|
|
95
|
+
/** Why a Component the scan saw has no file (named export only, outside the root); optional. */
|
|
96
|
+
componentIssue?: Map<Function, string>
|
|
81
97
|
runtimeImport: string
|
|
82
98
|
serverOnly: string[]
|
|
99
|
+
/** L20: every CSS Module's class map by absolute source path; `styles.x` folds to the literal class. */
|
|
100
|
+
cssMaps?: Map<string, Record<string, string>>
|
|
83
101
|
log: (s: string) => void
|
|
102
|
+
/** SVG markup the build rendered for lucide icon requests (L23), by `ir.icon_requests[].key`. */
|
|
103
|
+
iconSvgs?: Record<string, { svg: string; base: string }>
|
|
104
|
+
/** The compile after icons were rendered (even if none could be): icons still components are judged as islands. */
|
|
105
|
+
iconsFinal?: boolean
|
|
106
|
+
/** Values the build computed for `ir.build_eval` slots, by key (L2): the slots become literals. */
|
|
107
|
+
slotValues?: Record<string, unknown>
|
|
84
108
|
}): { compiled: Map<string, Compiled>; routeComponent: Map<string, string> } {
|
|
85
109
|
const { appRoot, leaves, componentFile } = opts
|
|
110
|
+
const iconSvgsJson = opts.iconsFinal ? JSON.stringify({ final: true, svgs: opts.iconSvgs ?? {} }) : undefined
|
|
111
|
+
const slotValuesJson = opts.slotValues && Object.keys(opts.slotValues).length > 0 ? JSON.stringify(opts.slotValues) : undefined
|
|
112
|
+
// The Rust side keys each map by its path relative to the app root (`/`-separated), like an import resolves.
|
|
113
|
+
const cssModules = Object.fromEntries([...(opts.cssMaps ?? [])].map(([abs, map]) => [relative(realpathSync(appRoot), abs).split(sep).join('/'), map]))
|
|
86
114
|
const seen = new Map<string, Seen>()
|
|
87
115
|
const rootIdOfFile = new Map<string, string>()
|
|
88
116
|
const routeComponent = new Map<string, string>()
|
|
@@ -95,12 +123,12 @@ export function compileApp(opts: {
|
|
|
95
123
|
if (!file)
|
|
96
124
|
throw new BuildError(
|
|
97
125
|
'component-source',
|
|
98
|
-
`${route.Component!.name || '(anonymous)'} is not
|
|
126
|
+
`${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
127
|
)
|
|
100
128
|
let rootId = rootIdOfFile.get(file)
|
|
101
129
|
if (!rootId) {
|
|
102
130
|
const rel = relative(appRoot, file)
|
|
103
|
-
const tree = compileTree(rel, appRoot, opts.runtimeImport, opts.serverOnly)
|
|
131
|
+
const tree = compileTree(rel, appRoot, opts.runtimeImport, opts.serverOnly, slotValuesJson, cssModules, iconSvgsJson)
|
|
104
132
|
if (tree.error) throw new BuildError(tree.error.rule, `${where(rel, tree.error)} ${tree.error.message}`)
|
|
105
133
|
for (const [i, c] of tree.components.entries()) {
|
|
106
134
|
// A lowering Error that came back as a diagnostic is a build error too (contract 9).
|
|
@@ -132,9 +160,18 @@ export function compileApp(opts: {
|
|
|
132
160
|
ir,
|
|
133
161
|
jinja: c.jinja,
|
|
134
162
|
serverTs: c.serverTs,
|
|
163
|
+
evalTs: c.evalTs,
|
|
135
164
|
clientJs: c.clientJs,
|
|
165
|
+
diagnostics: c.diagnostics.map((d) => ({ class: d.class, rule: d.rule, message: d.message, line: d.line, col: d.col, remediation: d.remediation })),
|
|
136
166
|
})
|
|
137
167
|
}
|
|
168
|
+
// A package export has no file of its own: bare imports in its island entries resolve from a
|
|
169
|
+
// component that uses it (the first one in build order).
|
|
170
|
+
for (const c of compiled.values()) {
|
|
171
|
+
if (!c.ir.external) continue
|
|
172
|
+
const user = [...compiled.values()].find((p) => !p.ir.external && (p.ir.children ?? []).some((ref: { id: string | null }) => ref.id === c.id))
|
|
173
|
+
c.file = user?.file ?? c.file
|
|
174
|
+
}
|
|
138
175
|
for (const [routeId, route] of nodes) {
|
|
139
176
|
const rootId = routeComponent.get(routeId)!
|
|
140
177
|
if (compiled.get(rootId)!.ir.uses_outlet && !route.children)
|
package/src/build/config.ts
CHANGED
|
@@ -4,13 +4,16 @@
|
|
|
4
4
|
// `server_only` is the app config `serverOnly` of the compiler spec (§8.2): the compiler refuses
|
|
5
5
|
// such an import in client code, and every browser bundle refuses to include it.
|
|
6
6
|
import { join } from 'node:path'
|
|
7
|
-
import { DEFAULT_ACTION_PREFIX, DEFAULT_MAX_BODY_BYTES, DEFAULT_MAX_RESPONSE_BYTES, MAX_ACTION_BYTES, prefixError } from '../actions/prefix'
|
|
7
|
+
import { ACTION_CREDENTIALS, type ActionCredentials, DEFAULT_ACTION_PREFIX, DEFAULT_MAX_BODY_BYTES, DEFAULT_MAX_RESPONSE_BYTES, MAX_ACTION_BYTES, baseUrlError, prefixError } from '../actions/prefix'
|
|
8
8
|
import { ADAPTER_NAMES, BUILTINS, installHint } from '../cache-adapter/load'
|
|
9
9
|
import { BuildError } from './errors'
|
|
10
10
|
|
|
11
11
|
// [actions] (opt-in, ruling R6): present table => actions on. Both byte caps cross napi as u32 (F82: 1..64 MiB / 256 KiB..64 MiB).
|
|
12
12
|
// [actions]
|
|
13
13
|
// entry = "actions.ts" prefix = "/_brust/action" max_body_bytes = 1048576 max_response_bytes = 8388608 cross_site = "reject" | "allow" (F87)
|
|
14
|
+
// base_url = "https://api.example.com" credentials = "same-origin" | "include" | "omit" (L16: the browser client's target; runtime settings, validated here too)
|
|
15
|
+
// `prefix = "/"` is accepted only for an actions-only build (L16 R16.4).
|
|
16
|
+
// `ACTION_INLINE_BODY_BYTES` (Rust): the inline/heap threshold of an action body, independent of the response slot size (`slotBytes`).
|
|
14
17
|
const MIN_RESPONSE_BYTES = 262_144
|
|
15
18
|
|
|
16
19
|
export interface ActionsBuildConfig {
|
|
@@ -19,6 +22,11 @@ export interface ActionsBuildConfig {
|
|
|
19
22
|
maxBodyBytes: number
|
|
20
23
|
maxResponseBytes: number
|
|
21
24
|
crossSite: 'reject' | 'allow'
|
|
25
|
+
/** `[actions] base_url`; with no actions file present the build is page-only (L16 R16.4b). */
|
|
26
|
+
baseUrl: string | undefined
|
|
27
|
+
credentials: ActionCredentials
|
|
28
|
+
/** `entry` was written in brust.toml (a missing file is then an error, never a page-only build). */
|
|
29
|
+
entryExplicit: boolean
|
|
22
30
|
}
|
|
23
31
|
|
|
24
32
|
// [cloudflare] (M4-CF; only read by `--target cloudflare`, validated always so errors surface early):
|
|
@@ -66,6 +74,8 @@ export interface BuildConfig {
|
|
|
66
74
|
actions: ActionsBuildConfig | null
|
|
67
75
|
cloudflare: CloudflareBuildConfig | null
|
|
68
76
|
cache: CacheBuildConfig | null
|
|
77
|
+
/** `[css] global` (L19, R19.1): app-root-relative stylesheets every route links first, in order. */
|
|
78
|
+
cssGlobal: string[]
|
|
69
79
|
}
|
|
70
80
|
|
|
71
81
|
const isTable = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
@@ -232,7 +242,7 @@ function resolveAlias(file: string, appRoot: string, p: string): string {
|
|
|
232
242
|
}
|
|
233
243
|
}
|
|
234
244
|
|
|
235
|
-
function parseActions(file: string, raw: unknown): ActionsBuildConfig | null {
|
|
245
|
+
function parseActions(file: string, raw: unknown, actionsOnly: boolean): ActionsBuildConfig | null {
|
|
236
246
|
if (raw === undefined) return null
|
|
237
247
|
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) throw new BuildError('config', `${file}: [actions] must be a table`)
|
|
238
248
|
const t = raw as Record<string, unknown>
|
|
@@ -243,7 +253,8 @@ function parseActions(file: string, raw: unknown): ActionsBuildConfig | null {
|
|
|
243
253
|
}
|
|
244
254
|
let prefix = DEFAULT_ACTION_PREFIX
|
|
245
255
|
if (t.prefix !== undefined) {
|
|
246
|
-
|
|
256
|
+
// `/` is the prefix of an actions-only deployment; a page-only build (base_url, no local app) may name it too.
|
|
257
|
+
const why = prefixError(t.prefix, actionsOnly || t.base_url !== undefined)
|
|
247
258
|
if (why) throw new BuildError('config', `${file}: actions.prefix ${why}`)
|
|
248
259
|
prefix = t.prefix as string
|
|
249
260
|
}
|
|
@@ -266,15 +277,39 @@ function parseActions(file: string, raw: unknown): ActionsBuildConfig | null {
|
|
|
266
277
|
if (t.cross_site !== 'reject' && t.cross_site !== 'allow') throw new BuildError('config', `${file}: actions.cross_site must be "reject" or "allow"`)
|
|
267
278
|
crossSite = t.cross_site
|
|
268
279
|
}
|
|
269
|
-
|
|
280
|
+
let baseUrl: string | undefined
|
|
281
|
+
if (t.base_url !== undefined) {
|
|
282
|
+
const why = baseUrlError(t.base_url)
|
|
283
|
+
if (why) throw new BuildError('config', `${file}: actions.base_url ${why}`)
|
|
284
|
+
baseUrl = t.base_url as string
|
|
285
|
+
}
|
|
286
|
+
let credentials: ActionCredentials = 'same-origin'
|
|
287
|
+
if (t.credentials !== undefined) {
|
|
288
|
+
if (typeof t.credentials !== 'string' || !ACTION_CREDENTIALS.includes(t.credentials as ActionCredentials))
|
|
289
|
+
throw new BuildError('config', `${file}: actions.credentials must be one of ${ACTION_CREDENTIALS.join(', ')} (got ${JSON.stringify(t.credentials)})`)
|
|
290
|
+
credentials = t.credentials as ActionCredentials
|
|
291
|
+
}
|
|
292
|
+
return { entry, prefix, maxBodyBytes, maxResponseBytes, crossSite, baseUrl, credentials, entryExplicit: t.entry !== undefined }
|
|
270
293
|
}
|
|
271
294
|
|
|
272
295
|
const DEFAULT_ENTRY = 'actions.ts'
|
|
273
296
|
|
|
274
|
-
|
|
297
|
+
/** `[css]`: only `global`, a list of `.css` files relative to the app root (checked to exist by the build). */
|
|
298
|
+
function parseCss(file: string, raw: unknown): string[] {
|
|
299
|
+
if (raw === undefined) return []
|
|
300
|
+
if (!isTable(raw)) throw new BuildError('config', `${file}: [css] must be a table`)
|
|
301
|
+
for (const k of Object.keys(raw)) if (k !== 'global') throw new BuildError('config', `${file}: unknown key css.${k} (allowed: global)`)
|
|
302
|
+
const g = raw.global
|
|
303
|
+
if (g === undefined) return []
|
|
304
|
+
if (!Array.isArray(g) || g.some((p) => typeof p !== 'string' || !p.trim().endsWith('.css')))
|
|
305
|
+
throw new BuildError('config', `${file}: css.global must be an array of .css paths (relative to the app root)`)
|
|
306
|
+
return g.map((p: string) => p.trim())
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export async function loadBuildConfig(appRoot: string, o: { actionsOnly?: boolean } = {}): Promise<BuildConfig> {
|
|
275
310
|
const file = join(appRoot, 'brust.toml')
|
|
276
311
|
const f = Bun.file(file)
|
|
277
|
-
if (!(await f.exists())) return { serverOnly: [], actions: null, cloudflare: null, cache: null }
|
|
312
|
+
if (!(await f.exists())) return { serverOnly: [], actions: null, cloudflare: null, cache: null, cssGlobal: [] }
|
|
278
313
|
let parsed: unknown
|
|
279
314
|
try {
|
|
280
315
|
parsed = Bun.TOML.parse(await f.text())
|
|
@@ -282,15 +317,16 @@ export async function loadBuildConfig(appRoot: string): Promise<BuildConfig> {
|
|
|
282
317
|
throw new BuildError('config', `failed to parse ${file}: ${(e as Error).message}`)
|
|
283
318
|
}
|
|
284
319
|
const root = (parsed ?? {}) as Record<string, unknown>
|
|
285
|
-
const actions = parseActions(file, root.actions)
|
|
320
|
+
const actions = parseActions(file, root.actions, o.actionsOnly === true)
|
|
286
321
|
const cloudflare = parseCloudflare(file, appRoot, root.cloudflare)
|
|
287
322
|
const cache = await parseCache(file, appRoot, root.cache)
|
|
323
|
+
const cssGlobal = parseCss(file, root.css)
|
|
288
324
|
const build = root.build
|
|
289
|
-
if (build === undefined) return { serverOnly: [], actions, cloudflare, cache }
|
|
325
|
+
if (build === undefined) return { serverOnly: [], actions, cloudflare, cache, cssGlobal }
|
|
290
326
|
if (build === null || typeof build !== 'object' || Array.isArray(build)) throw new BuildError('config', `${file}: [build] must be a table`)
|
|
291
327
|
const so = (build as Record<string, unknown>).server_only
|
|
292
|
-
if (so === undefined) return { serverOnly: [], actions, cloudflare, cache }
|
|
328
|
+
if (so === undefined) return { serverOnly: [], actions, cloudflare, cache, cssGlobal }
|
|
293
329
|
if (!Array.isArray(so) || so.some((p) => typeof p !== 'string' || p.trim() === ''))
|
|
294
330
|
throw new BuildError('config', `${file}: build.server_only must be an array of non-empty strings`)
|
|
295
|
-
return { serverOnly: so.map((p: string) => p.trim().replace(/^\.\//, '')), actions, cloudflare, cache }
|
|
331
|
+
return { serverOnly: so.map((p: string) => p.trim().replace(/^\.\//, '')), actions, cloudflare, cache, cssGlobal }
|
|
296
332
|
}
|
package/src/build/css.ts
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// CSS for `brust build` (gaps round L19, R19.1-R19.4): which stylesheets each route links, one
|
|
2
|
+
// minified content-hashed `client/<stem>-<hex10>.css` per source file, the class maps of CSS
|
|
3
|
+
// Modules, and the one plugin every JS build uses so no JS build ever emits CSS.
|
|
4
|
+
//
|
|
5
|
+
// - A route's CSS: `[css] global` first, then every `.css` import reachable from its chain files
|
|
6
|
+
// (root layout → page), depth-first in import order, first occurrence wins.
|
|
7
|
+
// - CSS Modules: one in-process Bun.build of every source (a JS entry per file), so one set of class
|
|
8
|
+
// names serves the server (jobs.js), the browser chunks and the Cloudflare worker alike.
|
|
9
|
+
// - Relative `url()` fonts and images are emitted next to the CSS as `client/<name>-<hex10>.<ext>`;
|
|
10
|
+
// absolute `/public/*` and `/_brust/*` stay as written.
|
|
11
|
+
import { createHash } from 'node:crypto'
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
|
|
13
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
14
|
+
import type { BunPlugin } from 'bun'
|
|
15
|
+
import type { FlatRoute } from '../routes'
|
|
16
|
+
import { BuildError } from './errors'
|
|
17
|
+
|
|
18
|
+
const hex = (b: string | Uint8Array) => createHash('sha256').update(b).digest('hex').slice(0, 10)
|
|
19
|
+
const real = (p: string) => {
|
|
20
|
+
try {
|
|
21
|
+
return realpathSync(p)
|
|
22
|
+
} catch {
|
|
23
|
+
return p
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const isCss = (p: string) => p.endsWith('.css')
|
|
27
|
+
const isModule = (p: string) => p.endsWith('.module.css')
|
|
28
|
+
/** A name the static rules serve (`[A-Za-z0-9_.-]`, no leading dot). */
|
|
29
|
+
const safeName = (s: string) => s.replace(/[^A-Za-z0-9_.-]/g, '_').replace(/^\.+/, '_')
|
|
30
|
+
|
|
31
|
+
/** The stylesheets one source file reaches through static and dynamic imports, in order, real paths. Source
|
|
32
|
+
* files outside node_modules are followed; a `.css` import (an app file or a package's CSS) is
|
|
33
|
+
* collected, never followed (its `@import`s are the CSS bundler's). */
|
|
34
|
+
export class CssGraph {
|
|
35
|
+
private memo = new Map<string, string[]>()
|
|
36
|
+
private active = new Set<string>()
|
|
37
|
+
private transpilers = new Map<string, InstanceType<typeof Bun.Transpiler>>()
|
|
38
|
+
|
|
39
|
+
of(file: string): string[] {
|
|
40
|
+
const f = real(file)
|
|
41
|
+
const hit = this.memo.get(f)
|
|
42
|
+
if (hit) return hit
|
|
43
|
+
if (this.active.has(f)) return [] // an import cycle: the outer visit already lists what follows
|
|
44
|
+
this.active.add(f)
|
|
45
|
+
const out: string[] = []
|
|
46
|
+
const add = (c: string) => {
|
|
47
|
+
if (!out.includes(c)) out.push(c)
|
|
48
|
+
}
|
|
49
|
+
const loader = /\.(tsx|ts|jsx|js|mjs)$/.exec(f)?.[1]
|
|
50
|
+
let src = ''
|
|
51
|
+
try {
|
|
52
|
+
src = loader ? readFileSync(f, 'utf8') : ''
|
|
53
|
+
} catch {}
|
|
54
|
+
if (loader && src) {
|
|
55
|
+
const l = loader === 'mjs' ? 'js' : (loader as 'tsx' | 'ts' | 'jsx' | 'js')
|
|
56
|
+
let t = this.transpilers.get(l)
|
|
57
|
+
if (!t) this.transpilers.set(l, (t = new Bun.Transpiler({ loader: l })))
|
|
58
|
+
for (const imp of t.scanImports(src)) {
|
|
59
|
+
// A dynamic import() is walked too (review N4): its CSS is linked eagerly for the route, so a
|
|
60
|
+
// lazily loaded component is styled on first paint and its CSS Modules are built.
|
|
61
|
+
if (imp.kind !== 'import-statement' && imp.kind !== 'dynamic-import') continue
|
|
62
|
+
let dep: string
|
|
63
|
+
try {
|
|
64
|
+
dep = real(Bun.resolveSync(imp.path, dirname(f)))
|
|
65
|
+
} catch {
|
|
66
|
+
continue // unresolvable: the JS build reports it
|
|
67
|
+
}
|
|
68
|
+
if (isCss(dep)) add(dep)
|
|
69
|
+
else if (!dep.split(sep).includes('node_modules')) for (const c of this.of(dep)) add(c)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
this.active.delete(f)
|
|
73
|
+
this.memo.set(f, out)
|
|
74
|
+
return out
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** R19.1: every route's ordered source stylesheets (real paths), by route id. `chainFiles` maps a leaf to
|
|
79
|
+
* its chain's component files, root layout first. */
|
|
80
|
+
export function routeCssSources(leaves: FlatRoute[], chainFiles: (l: FlatRoute) => string[], globals: string[], graph = new CssGraph()): Map<string, string[]> {
|
|
81
|
+
const out = new Map<string, string[]>()
|
|
82
|
+
for (const leaf of leaves) {
|
|
83
|
+
const list: string[] = [...globals]
|
|
84
|
+
for (const file of chainFiles(leaf)) for (const c of graph.of(file)) if (!list.includes(c)) list.push(c)
|
|
85
|
+
out.set(leaf.id, list)
|
|
86
|
+
}
|
|
87
|
+
return out
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** `[css] global` resolved: each a `.css` file inside the app. */
|
|
91
|
+
export function resolveGlobals(appRoot: string, globals: string[]): string[] {
|
|
92
|
+
return globals.map((g) => {
|
|
93
|
+
const abs = resolve(appRoot, g)
|
|
94
|
+
const rel = relative(appRoot, abs)
|
|
95
|
+
if (isAbsolute(g) || rel.startsWith('..') || isAbsolute(rel) || !existsSync(abs)) throw new BuildError('css', `css.global ${g} is not a file inside the app`)
|
|
96
|
+
return real(abs)
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface CssBuild {
|
|
101
|
+
/** source (real path) → `client/<stem>-<hex10>.css`; absent when the file emits no CSS. */
|
|
102
|
+
file: Map<string, string>
|
|
103
|
+
/** `.module.css` source (real path) → its class map. */
|
|
104
|
+
maps: Map<string, Record<string, string>>
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const CLASS = /\.(-?[_a-zA-Z][_a-zA-Z0-9-]*)/g
|
|
108
|
+
|
|
109
|
+
/** The asset types `static_rules::is_safe_client_asset_filename` serves from `/_brust/client/` (review N1). */
|
|
110
|
+
export const SERVED_ASSET_EXT = ['.woff2', '.woff', '.ttf', '.otf', '.eot', '.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif', '.svg', '.ico']
|
|
111
|
+
|
|
112
|
+
/** R19.2/R19.4: builds every source stylesheet (one JS entry each) into `<dist>/client/`. `minify`
|
|
113
|
+
* is off only for a development build (`brust dev`, review N3). */
|
|
114
|
+
export async function buildCss(
|
|
115
|
+
dist: string,
|
|
116
|
+
sources: string[],
|
|
117
|
+
minify = true,
|
|
118
|
+
o: { appRoot?: string; log?: (s: string) => void } = {},
|
|
119
|
+
): Promise<CssBuild> {
|
|
120
|
+
const file = new Map<string, string>()
|
|
121
|
+
const maps = new Map<string, Record<string, string>>()
|
|
122
|
+
if (sources.length === 0) return { file, maps }
|
|
123
|
+
const appRoot = o.appRoot ?? process.cwd()
|
|
124
|
+
const tw = tailwindUse(sources)
|
|
125
|
+
const plugins: BunPlugin[] = []
|
|
126
|
+
if (tw.using.length > 0) {
|
|
127
|
+
plugins.push(await loadTailwind(appRoot, tw.using[0]!))
|
|
128
|
+
if (tw.imports.length > 1)
|
|
129
|
+
o.log?.(
|
|
130
|
+
`[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`,
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
const stage = join(dist, '.stage', 'css')
|
|
134
|
+
mkdirSync(stage, { recursive: true })
|
|
135
|
+
const entries = sources.map((src, i) => {
|
|
136
|
+
const e = join(stage, `s${i}.js`)
|
|
137
|
+
writeFileSync(e, isModule(src) ? `export { default } from ${JSON.stringify(src)}\n` : `import ${JSON.stringify(src)}\n`)
|
|
138
|
+
return e
|
|
139
|
+
})
|
|
140
|
+
const res = await Bun.build({
|
|
141
|
+
entrypoints: entries,
|
|
142
|
+
target: 'browser',
|
|
143
|
+
format: 'esm',
|
|
144
|
+
minify,
|
|
145
|
+
naming: { entry: '[name]-[hash].[ext]', chunk: '[name]-[hash].[ext]', asset: '[name]-[hash].[ext]' },
|
|
146
|
+
external: ['/public/*', '/_brust/*'],
|
|
147
|
+
// L21: Tailwind's content detection scans the app (bun-plugin-tailwind reads `root`).
|
|
148
|
+
root: appRoot,
|
|
149
|
+
plugins,
|
|
150
|
+
})
|
|
151
|
+
if (!res.success) throw new BuildError('css', res.logs.map(String).join('\n'))
|
|
152
|
+
// Assets a stylesheet references (fonts, images): `<name>-<hex10>.<ext>`, references rewritten.
|
|
153
|
+
const rename = new Map<string, string>()
|
|
154
|
+
const assets: { name: string; bytes: Uint8Array }[] = []
|
|
155
|
+
const css = new Map<number, string>()
|
|
156
|
+
const js = new Map<number, string>()
|
|
157
|
+
const unserved: string[] = []
|
|
158
|
+
for (const o of res.outputs) {
|
|
159
|
+
const name = basename(o.path)
|
|
160
|
+
const entry = /^s(\d+)-[^-.]+\.(css|js)$/.exec(name)
|
|
161
|
+
if (entry) {
|
|
162
|
+
;(entry[2] === 'css' ? css : js).set(Number(entry[1]), await o.text())
|
|
163
|
+
continue
|
|
164
|
+
}
|
|
165
|
+
const m = /^(.*)-[^-.]+(\.[^.]+)$/.exec(name)
|
|
166
|
+
if (!m) throw new BuildError('css', `unexpected output ${name}`)
|
|
167
|
+
const ext = m[2]!.toLowerCase()
|
|
168
|
+
if (!SERVED_ASSET_EXT.includes(ext)) {
|
|
169
|
+
unserved.push(name)
|
|
170
|
+
continue
|
|
171
|
+
}
|
|
172
|
+
const bytes = new Uint8Array(await o.arrayBuffer())
|
|
173
|
+
const to = `${safeName(m[1]!)}-${hex(bytes)}${ext}`
|
|
174
|
+
rename.set(name, to)
|
|
175
|
+
assets.push({ name: to, bytes })
|
|
176
|
+
}
|
|
177
|
+
// Fail closed (review N1): an asset the server would not serve from /_brust/client would 404.
|
|
178
|
+
if (unserved.length > 0) {
|
|
179
|
+
const lines = unserved.map((name) => {
|
|
180
|
+
const by = sources.filter((_, i) => css.get(i)?.includes(name))
|
|
181
|
+
const [, stem, ext] = /^(.*)-[^-.]+(\.[^.]+)$/.exec(name)!
|
|
182
|
+
return `${stem}${ext} (url() in ${by.join(', ') || 'a stylesheet'})`
|
|
183
|
+
})
|
|
184
|
+
throw new BuildError(
|
|
185
|
+
'css-asset-type',
|
|
186
|
+
`${lines.join('; ')}: brust serves only ${SERVED_ASSET_EXT.join(' ')} from /_brust/client; put the file in public/ and reference it as /public/…`,
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
for (const a of assets) writeFileSync(join(dist, 'client', a.name), a.bytes)
|
|
190
|
+
for (const [i, src] of sources.entries()) {
|
|
191
|
+
let text = css.get(i)
|
|
192
|
+
if (text !== undefined && text.trim() !== '') {
|
|
193
|
+
for (const [from, to] of rename) text = text.split(from).join(to)
|
|
194
|
+
const stem = safeName(basename(src).replace(/\.css$/, ''))
|
|
195
|
+
const out = `${stem}-${hex(text)}.css`
|
|
196
|
+
writeFileSync(join(dist, 'client', out), text)
|
|
197
|
+
file.set(src, `client/${out}`)
|
|
198
|
+
}
|
|
199
|
+
if (!isModule(src)) continue
|
|
200
|
+
const mod = join(stage, `map${i}.mjs`)
|
|
201
|
+
writeFileSync(mod, js.get(i) ?? 'export default {}')
|
|
202
|
+
const map = { ...((await import(mod)).default as Record<string, string>) }
|
|
203
|
+
// R19.4: every class the map hands out exists in the CSS it ships with.
|
|
204
|
+
const have = new Set([...(text ?? '').matchAll(CLASS)].map((x) => x[1]!))
|
|
205
|
+
for (const [k, v] of Object.entries(map))
|
|
206
|
+
for (const cls of v.split(/\s+/).filter(Boolean))
|
|
207
|
+
if (!have.has(cls)) throw new BuildError('css', `${src}: class ${k} maps to ${cls}, which is not in the emitted CSS`)
|
|
208
|
+
maps.set(src, map)
|
|
209
|
+
}
|
|
210
|
+
return { file, maps }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---- Tailwind CSS v4 (gaps round L21, R21.1) ----
|
|
214
|
+
|
|
215
|
+
/** A stylesheet that needs the Tailwind compiler. */
|
|
216
|
+
const TAILWIND_USE = /@import\s+(?:url\(\s*)?["']tailwindcss(?:\/[^"']*)?["']|@apply\b|@reference\b|@theme\b|@plugin\b/
|
|
217
|
+
const TAILWIND_IMPORT = /@import\s+(?:url\(\s*)?["']tailwindcss["']/
|
|
218
|
+
const CSS_IMPORT = /@import\s+(?:url\(\s*)?["'](\.{1,2}\/[^"']+)["']/g
|
|
219
|
+
|
|
220
|
+
/** Which sources (or the relative `@import`s they pull in) use Tailwind, and which `@import "tailwindcss"`. */
|
|
221
|
+
export function tailwindUse(sources: string[]): { using: string[]; imports: string[] } {
|
|
222
|
+
const using: string[] = []
|
|
223
|
+
const imports: string[] = []
|
|
224
|
+
const seen = new Set<string>()
|
|
225
|
+
const visit = (f: string) => {
|
|
226
|
+
if (seen.has(f)) return
|
|
227
|
+
seen.add(f)
|
|
228
|
+
let text = ''
|
|
229
|
+
try {
|
|
230
|
+
text = readFileSync(f, 'utf8')
|
|
231
|
+
} catch {
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
if (TAILWIND_USE.test(text)) using.push(f)
|
|
235
|
+
if (TAILWIND_IMPORT.test(text)) imports.push(f)
|
|
236
|
+
for (const m of text.matchAll(CSS_IMPORT)) visit(resolve(dirname(f), m[1]!))
|
|
237
|
+
}
|
|
238
|
+
for (const s of sources) visit(s)
|
|
239
|
+
return { using, imports }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** `bun-plugin-tailwind`, resolved from the app (R21.1: `tailwindcss@4` and the plugin are the app's own dev
|
|
243
|
+
* dependencies). Missing → `tailwind-missing` with the exact install command. */
|
|
244
|
+
export async function loadTailwind(appRoot: string, usedBy: string): Promise<BunPlugin> {
|
|
245
|
+
const missing = ['tailwindcss', 'bun-plugin-tailwind'].filter((p) => {
|
|
246
|
+
try {
|
|
247
|
+
Bun.resolveSync(`${p}/package.json`, appRoot)
|
|
248
|
+
return false
|
|
249
|
+
} catch {
|
|
250
|
+
return true
|
|
251
|
+
}
|
|
252
|
+
})
|
|
253
|
+
if (missing.length > 0)
|
|
254
|
+
throw new BuildError(
|
|
255
|
+
'tailwind-missing',
|
|
256
|
+
`${relative(appRoot, usedBy)} uses Tailwind CSS, but ${missing.join(' and ')} ${missing.length > 1 ? 'are' : 'is'} not installed in the app: run \`bun add -d tailwindcss@4 bun-plugin-tailwind\``,
|
|
257
|
+
)
|
|
258
|
+
return (await import(Bun.resolveSync('bun-plugin-tailwind', appRoot))).default as BunPlugin
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** R19.4: the one CSS plugin of every JS build. A `.module.css` import is its class map
|
|
262
|
+
* (`export default Object.freeze({…})`); any other `.css` import is an empty module, because the
|
|
263
|
+
* stylesheet itself is linked by the server (R19.3), never bundled into JS. */
|
|
264
|
+
export function cssPlugin(maps: Map<string, Record<string, string>>): BunPlugin {
|
|
265
|
+
return {
|
|
266
|
+
name: 'brust-css',
|
|
267
|
+
setup(b) {
|
|
268
|
+
b.onLoad({ filter: /\.css$/ }, (a) => {
|
|
269
|
+
if (!isModule(a.path)) return { contents: '', loader: 'js' }
|
|
270
|
+
const map = maps.get(real(a.path))
|
|
271
|
+
if (!map) throw new Error(`${a.path} is imported by code the build did not reach from a route; import it from a route's component files`)
|
|
272
|
+
return { contents: `export default Object.freeze(${JSON.stringify(map)})\n`, loader: 'js' }
|
|
273
|
+
})
|
|
274
|
+
},
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Direct `.css` imports of `file` (the route entry: linked to no route, so the build warns). */
|
|
279
|
+
export function directCss(file: string): string[] {
|
|
280
|
+
const src = readFileSync(file, 'utf8')
|
|
281
|
+
const t = new Bun.Transpiler({ loader: file.endsWith('.tsx') ? 'tsx' : 'ts' })
|
|
282
|
+
return t
|
|
283
|
+
.scanImports(src)
|
|
284
|
+
.filter((i) => i.kind === 'import-statement' && i.path.endsWith('.css'))
|
|
285
|
+
.map((i) => i.path)
|
|
286
|
+
}
|