@brust/core 2.0.0-alpha.1 → 2.0.0-alpha.11
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 +87 -5
- package/native/index.js +61 -54
- package/package.json +22 -9
- package/src/actions/client.ts +22 -5
- package/src/actions/load.ts +2 -1
- package/src/actions/prefix.ts +27 -3
- package/src/actions-bridge.ts +76 -19
- package/src/browser.ts +3 -1
- package/src/build/actions-only.ts +86 -0
- package/src/build/build-eval.ts +330 -0
- package/src/build/build-icons.ts +69 -0
- package/src/build/bundle.ts +341 -43
- package/src/build/cf-assets.ts +83 -0
- package/src/build/cf-core.ts +90 -0
- package/src/build/cf-entry.ts +76 -0
- package/src/build/cf-wrangler.ts +169 -0
- package/src/build/cloudflare.ts +317 -0
- package/src/build/compile.ts +74 -4
- package/src/build/config.ts +274 -15
- package/src/build/css.ts +275 -0
- package/src/build/features.ts +110 -0
- package/src/build/index.ts +224 -20
- package/src/build/manifest.ts +69 -8
- package/src/build/pkg-src.ts +383 -0
- package/src/build/scan.ts +61 -20
- package/src/build/server-only.ts +39 -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 +258 -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/bridge.ts +91 -0
- package/src/cache-adapter/conformance.ts +226 -0
- package/src/cache-adapter/index.ts +10 -0
- package/src/cache-adapter/load.ts +144 -0
- package/src/cache-adapter/memory.ts +229 -0
- package/src/cache-adapter/runtime.ts +124 -0
- package/src/cache-adapter/types.ts +96 -0
- package/src/cache-adapter/workers.ts +118 -0
- package/src/cache.ts +4 -1
- package/src/cf.ts +19 -0
- package/src/cli.ts +70 -2
- package/src/config.ts +127 -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 +93 -0
- package/src/handlers.ts +282 -0
- package/src/index.ts +3 -1
- package/src/native.ts +49 -2
- package/src/routes.ts +20 -1
- package/src/run.ts +61 -14
- package/src/server-handlers.ts +32 -0
- package/src/shadcn/init.ts +213 -0
- package/src/shadcn/jsonc.ts +129 -0
- package/src/worker.ts +129 -245
package/src/build/ssg.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// `brust build --target static` planner (gaps round L9, R9.2-R9.3): which paths to render and the
|
|
2
|
+
// file each one is written to. Pure: no server, no filesystem. `params()` results come in resolved.
|
|
3
|
+
import type { FlatRoute, RouteSsgConfig } from '../routes'
|
|
4
|
+
import { BuildError } from './errors'
|
|
5
|
+
|
|
6
|
+
/** The path the `*` route is rendered at: no route matches it, so the server answers the 404 page. */
|
|
7
|
+
export const NOT_FOUND_PATH = '/__brust_ssg_not_found__'
|
|
8
|
+
|
|
9
|
+
export interface PlannedPage {
|
|
10
|
+
/** The request path (each `{name}` value percent-encoded). */
|
|
11
|
+
path: string
|
|
12
|
+
/** Relative to the static dir: `index.html`, `docs.html`, `docs/x.html`, `404.html`. */
|
|
13
|
+
file: string
|
|
14
|
+
routeId: string
|
|
15
|
+
pattern: string
|
|
16
|
+
/** 200, or 404 for the `*` route. */
|
|
17
|
+
expect: 200 | 404
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface StaticPlan {
|
|
21
|
+
pages: PlannedPage[]
|
|
22
|
+
/** Routes `ssg: false` left out (listed in the report). */
|
|
23
|
+
skipped: { routeId: string; pattern: string }[]
|
|
24
|
+
/** No `*` route: the build writes a default 404.html. */
|
|
25
|
+
defaultNotFound: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A leaf's own `ssg` field (the last node of its chain). */
|
|
29
|
+
export function ssgOf(leaf: FlatRoute): RouteSsgConfig | undefined {
|
|
30
|
+
return leaf.chain[leaf.chain.length - 1]?.ssg
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const SEGMENT = /\{(\*?)([^}]*)\}/g
|
|
34
|
+
|
|
35
|
+
/** The `{name}` / `{*name}` segment names of a pattern, in order. */
|
|
36
|
+
export function patternParams(pattern: string): { name: string; rest: boolean }[] {
|
|
37
|
+
return [...pattern.matchAll(SEGMENT)].map((m) => ({ name: m[2]!, rest: m[1] === '*' }))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The file a request path is written to: `/` → `index.html`, `/a/b` → `a/b.html` (decoded, so a
|
|
41
|
+
* host that decodes the URL finds it). A segment that is not valid percent-encoding (a stray `%`)
|
|
42
|
+
* is a `ssg-param-encoding` error naming `where` (review N1), never a URIError. */
|
|
43
|
+
export function fileFor(path: string, where = path): string {
|
|
44
|
+
if (path === '/') return 'index.html'
|
|
45
|
+
const decoded = path
|
|
46
|
+
.slice(1)
|
|
47
|
+
.split('/')
|
|
48
|
+
.map((s) => {
|
|
49
|
+
try {
|
|
50
|
+
return decodeURIComponent(s)
|
|
51
|
+
} catch {
|
|
52
|
+
throw new BuildError('ssg-param-encoding', `${where}: path segment ${JSON.stringify(s)} is not valid percent-encoding (write a literal % as %25)`)
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
.join('/')
|
|
56
|
+
return `${decoded}.html`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** `encodeURIComponent`, as an `ssg-param-encoding` error naming `where` for a lone surrogate (review N1). */
|
|
60
|
+
function encodeSegment(v: string, where: string): string {
|
|
61
|
+
try {
|
|
62
|
+
return encodeURIComponent(v)
|
|
63
|
+
} catch {
|
|
64
|
+
throw new BuildError('ssg-param-encoding', `${where} = ${JSON.stringify(v)} is not encodable (a lone UTF-16 surrogate)`)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** One `{name}` value: a non-empty string; `/` only in a `{*rest}` value, and never an empty, `.`
|
|
69
|
+
* or `..` segment (the file name would leave its directory). */
|
|
70
|
+
function checkValue(leaf: FlatRoute, i: number, name: string, rest: boolean, v: unknown): string {
|
|
71
|
+
const where = `route ${leaf.id} (${leaf.pattern}) ssg.params()[${i}].${name}`
|
|
72
|
+
if (typeof v !== 'string' || v === '') throw new BuildError('ssg-params', `${where} is ${v === '' ? 'empty' : 'missing'}: every {${name}} needs a non-empty string`)
|
|
73
|
+
if (!rest && v.includes('/')) throw new BuildError('ssg-params', `${where} = ${JSON.stringify(v)} contains '/': a {${name}} value is one path segment`)
|
|
74
|
+
for (const seg of v.split('/'))
|
|
75
|
+
if (seg === '' || seg === '.' || seg === '..') throw new BuildError('ssg-params', `${where} = ${JSON.stringify(v)} has an empty, '.' or '..' segment`)
|
|
76
|
+
return v
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Plans every page. `params` holds each dynamic route's resolved `ssg.params()` by route id. */
|
|
80
|
+
export function planStatic(leaves: FlatRoute[], params: Map<string, unknown>): StaticPlan {
|
|
81
|
+
const pages: PlannedPage[] = []
|
|
82
|
+
const skipped: StaticPlan['skipped'] = []
|
|
83
|
+
let catchAll: FlatRoute | undefined
|
|
84
|
+
for (const leaf of leaves) {
|
|
85
|
+
const ssg = ssgOf(leaf)
|
|
86
|
+
if (ssg === false) {
|
|
87
|
+
skipped.push({ routeId: leaf.id, pattern: leaf.pattern })
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
if (leaf.catchAll) {
|
|
91
|
+
catchAll = leaf
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
const names = patternParams(leaf.pattern)
|
|
95
|
+
if (names.length === 0) {
|
|
96
|
+
pages.push({ path: leaf.pattern, file: fileFor(leaf.pattern, `route ${leaf.id} (${leaf.pattern})`), routeId: leaf.id, pattern: leaf.pattern, expect: 200 })
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
if (!ssg || ssg.params === undefined)
|
|
100
|
+
throw new BuildError(
|
|
101
|
+
'ssg-params-missing',
|
|
102
|
+
`route ${leaf.id} (${leaf.pattern}) is dynamic: add ssg: { params: () => [{ ${names.map((n) => `${n.name}: '…'`).join(', ')} }] } or ssg: false`,
|
|
103
|
+
)
|
|
104
|
+
const recs = params.get(leaf.id)
|
|
105
|
+
if (!Array.isArray(recs)) throw new BuildError('ssg-params', `route ${leaf.id} (${leaf.pattern}) ssg.params() must return an array of records`)
|
|
106
|
+
recs.forEach((rec, i) => {
|
|
107
|
+
if (typeof rec !== 'object' || rec === null || Array.isArray(rec)) throw new BuildError('ssg-params', `route ${leaf.id} (${leaf.pattern}) ssg.params()[${i}] is not a record`)
|
|
108
|
+
const r = rec as Record<string, unknown>
|
|
109
|
+
let k = 0
|
|
110
|
+
const path = leaf.pattern.replace(SEGMENT, () => {
|
|
111
|
+
const { name, rest } = names[k++]!
|
|
112
|
+
const v = checkValue(leaf, i, name, rest, r[name])
|
|
113
|
+
return v
|
|
114
|
+
.split('/')
|
|
115
|
+
.map((seg) => encodeSegment(seg, `route ${leaf.id} (${leaf.pattern}) ssg.params()[${i}].${name}`))
|
|
116
|
+
.join('/')
|
|
117
|
+
})
|
|
118
|
+
pages.push({ path, file: fileFor(path, `route ${leaf.id} (${leaf.pattern})`), routeId: leaf.id, pattern: leaf.pattern, expect: 200 })
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
const seen = new Map<string, PlannedPage>()
|
|
122
|
+
// Keyed case-folded (review N2): macOS and Windows file systems are case-insensitive, so `/A` and
|
|
123
|
+
// `/a` would overwrite each other's file.
|
|
124
|
+
for (const p of pages) {
|
|
125
|
+
const key = p.file.toLowerCase()
|
|
126
|
+
const prev = seen.get(key)
|
|
127
|
+
if (prev && prev.file === p.file) throw new BuildError('ssg-params', `${prev.path} (route ${prev.routeId}) and ${p.path} (route ${p.routeId}) both write ${p.file}`)
|
|
128
|
+
if (prev)
|
|
129
|
+
throw new BuildError(
|
|
130
|
+
'ssg-path-collision',
|
|
131
|
+
`${prev.path} (route ${prev.routeId}, ${prev.file}) and ${p.path} (route ${p.routeId}, ${p.file}) differ only by case: on a case-insensitive file system one file overwrites the other`,
|
|
132
|
+
)
|
|
133
|
+
seen.set(key, p)
|
|
134
|
+
}
|
|
135
|
+
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, file: '404.html', routeId: catchAll.id, pattern: catchAll.pattern, expect: 404 })
|
|
137
|
+
return { pages, skipped, defaultNotFound: catchAll === undefined }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The output paths `ssg.params()` must be resolved for: dynamic leaves with `ssg.params`. */
|
|
141
|
+
export function paramRoutes(leaves: FlatRoute[]): FlatRoute[] {
|
|
142
|
+
return leaves.filter((l) => {
|
|
143
|
+
const ssg = ssgOf(l)
|
|
144
|
+
return !l.catchAll && ssg && ssg.params !== undefined && patternParams(l.pattern).length > 0
|
|
145
|
+
})
|
|
146
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// `brust build --target static` with `[actions]` (gaps round L12, R12.1): the static tree plus a
|
|
2
|
+
// Cloudflare Pages advanced-mode Worker that serves ONLY the action prefix.
|
|
3
|
+
// static/_worker.js/index.js a WorkerEntrypoint: the action prefix -> the Cloudflare core, everything else -> env.ASSETS
|
|
4
|
+
// static/_worker.js/core/ the `--target cloudflare` worker tree (glue + wasm + brust-app.js), verbatim
|
|
5
|
+
// static/_routes.json include: the prefix only, so pages and assets never invoke the Worker
|
|
6
|
+
// wrangler.jsonc the Pages project file (name, compatibility date); sits next to `static/`
|
|
7
|
+
import { cpSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
8
|
+
import { join } from 'node:path'
|
|
9
|
+
import { type CfPrepared, cacheNeeds } from './cloudflare'
|
|
10
|
+
import { workerNameOf } from './cf-wrangler'
|
|
11
|
+
import { BuildError } from './errors'
|
|
12
|
+
import { CF_MIN_COMPAT_DATE } from './config'
|
|
13
|
+
import type { CacheRecord } from './manifest'
|
|
14
|
+
|
|
15
|
+
/** `.assetsignore`: the Worker source and the routes file are deploy inputs, never public assets. */
|
|
16
|
+
export const ASSETS_IGNORE = '_worker.js\n_routes.json\n.assetsignore\n'
|
|
17
|
+
|
|
18
|
+
/** The deploy inputs the Worker answers with a 404 (N1). */
|
|
19
|
+
export const HIDDEN = ['/_worker.js', '/_worker.js/*', '/_routes.json', '/.assetsignore']
|
|
20
|
+
|
|
21
|
+
/** `_routes.json` (R12.1): the prefix itself and everything under it reach the Worker, and so do the
|
|
22
|
+
* deploy inputs (N1): a static host that would otherwise serve `_worker.js/*` as files (the local
|
|
23
|
+
* `wrangler pages dev` does) is answered by the Worker's 404 instead. Pages and assets never do. */
|
|
24
|
+
export function routesJson(prefix: string): string {
|
|
25
|
+
return `${JSON.stringify({ version: 1, include: [prefix, `${prefix}/*`, ...HIDDEN], exclude: [] }, null, 2)}\n`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** `_worker.js/index.js`. Pages' pipeline wants a module object with `fetch`, not the class the core
|
|
29
|
+
* exports (a WorkerEntrypoint whose `fetch` reads `this.env` and `this.ctx`), so the core's method is
|
|
30
|
+
* called with exactly those two. A request that reaches the Worker and is not an action (a path
|
|
31
|
+
* `_routes.json` keeps away, or a probe) falls through to the static files, except the deploy inputs
|
|
32
|
+
* themselves (`_worker.js/*`, `_routes.json`, `.assetsignore`), which are a 404 whatever serves the assets (N1). */
|
|
33
|
+
export function workerSource(main: string, prefix: string): string {
|
|
34
|
+
const q = JSON.stringify
|
|
35
|
+
return `${[
|
|
36
|
+
'// generated by `brust build --target static` (actions on Pages) — do not edit',
|
|
37
|
+
`import Core from ${q(`./core/${main}`)}`,
|
|
38
|
+
`const PREFIX = ${q(prefix)}`,
|
|
39
|
+
'export default {',
|
|
40
|
+
' fetch(request, env, ctx) {',
|
|
41
|
+
' const path = new URL(request.url).pathname',
|
|
42
|
+
' if (path === PREFIX || path.startsWith(`${PREFIX}/`)) return Core.prototype.fetch.call({ env, ctx }, request)',
|
|
43
|
+
' let plain = path',
|
|
44
|
+
' try { plain = decodeURIComponent(path) } catch {}',
|
|
45
|
+
' if (/^\\/(_worker\\.js(\\/|$)|_routes\\.json$|\\.assetsignore$)/i.test(plain)) return new Response("Not Found", { status: 404 })',
|
|
46
|
+
' return env.ASSETS.fetch(request)',
|
|
47
|
+
' },',
|
|
48
|
+
'}',
|
|
49
|
+
].join('\n')}\n`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The Pages project file: bindings and secrets are the user's (`wrangler pages` / the dashboard), never baked. */
|
|
53
|
+
export function pagesConfig(p: CfPrepared): string {
|
|
54
|
+
const name = p.cfg.name ?? (p.pkgName ? workerNameOf(p.pkgName) : undefined)
|
|
55
|
+
const compatibilityDate = p.cfg.compatibilityDate ?? p.core.meta.compatibilityDate
|
|
56
|
+
if (compatibilityDate < CF_MIN_COMPAT_DATE) throw new BuildError('config', `compatibility_date ${compatibilityDate} is older than ${CF_MIN_COMPAT_DATE}`)
|
|
57
|
+
const c = { ...(name !== undefined ? { name } : {}), pages_build_output_dir: './static', compatibility_date: compatibilityDate }
|
|
58
|
+
return `// generated by \`brust build --target static\` — bindings and secrets: \`wrangler pages secret put\` / the dashboard (docs/cloudflare.md §12)\n${JSON.stringify(c, null, 2)}\n`
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** R12: `[cache]` does not apply to static pages, and the Worker serves actions only, so an adapter
|
|
62
|
+
* that needs a Cloudflare binding (KV, Durable Object) has nowhere to be used: refused before any write. */
|
|
63
|
+
export function assertNoCacheBindings(cache: Pick<CacheRecord, 'adapter' | 'broadcast' | 'options' | 'broadcastOptions'> | null | undefined): void {
|
|
64
|
+
const need = cacheNeeds(cache ?? undefined)
|
|
65
|
+
if (need)
|
|
66
|
+
throw new BuildError(
|
|
67
|
+
'ssg-actions-cache',
|
|
68
|
+
`[cache] adapter "${cache!.adapter}" needs a Cloudflare binding (${[need.kv && `KV ${need.kv}`, need.do && `Durable Object ${need.do}`].filter(Boolean).join(', ')}), which the static + actions Worker does not carry (static pages are never cached, the Worker serves actions only); drop [cache], or use --target cloudflare`,
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Turns `<dist>/cf/` (built by `buildCloudflare`) into the Pages Worker inside `<dist>/static/`, then
|
|
73
|
+
* drops `cf/`: the build is `--target static`, its only output is `static/`. */
|
|
74
|
+
export function writeStaticWorker(dist: string, actions: { prefix: string }, p: CfPrepared, log: (s: string) => void): void {
|
|
75
|
+
const staticDir = join(dist, 'static')
|
|
76
|
+
const worker = join(dist, 'cf', 'worker')
|
|
77
|
+
if (!existsSync(join(worker, p.core.meta.main))) throw new BuildError('cf-wasm', `the Cloudflare build wrote no worker/${p.core.meta.main}`)
|
|
78
|
+
const out = join(staticDir, '_worker.js')
|
|
79
|
+
rmSync(out, { recursive: true, force: true })
|
|
80
|
+
cpSync(worker, join(out, 'core'), { recursive: true })
|
|
81
|
+
writeFileSync(join(out, 'index.js'), workerSource(p.core.meta.main, actions.prefix))
|
|
82
|
+
writeFileSync(join(staticDir, '_routes.json'), routesJson(actions.prefix))
|
|
83
|
+
// Workers Static Assets uploads the whole directory as public files unless told otherwise; Pages never serves these two.
|
|
84
|
+
writeFileSync(join(staticDir, '.assetsignore'), ASSETS_IGNORE)
|
|
85
|
+
writeFileSync(join(dist, 'wrangler.jsonc'), pagesConfig(p))
|
|
86
|
+
const report = join(staticDir, 'ssg.json')
|
|
87
|
+
const json = JSON.parse(readFileSync(report, 'utf8')) as Record<string, unknown>
|
|
88
|
+
writeFileSync(report, `${JSON.stringify({ ...json, actions: { prefix: actions.prefix, worker: '_worker.js' } }, null, 2)}\n`)
|
|
89
|
+
rmSync(join(dist, 'cf'), { recursive: true, force: true })
|
|
90
|
+
log(`[brust] static: actions under ${actions.prefix} are served by _worker.js (_routes.json keeps pages and assets off the Worker)`)
|
|
91
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// `brust build --target static` (gaps round L9, R9.1-R9.5): after the normal dist is built, render
|
|
2
|
+
// every planned page through the real server (a child process, `ssg-render.ts`) and write
|
|
3
|
+
// `<out>/static/`: one HTML file per page, `404.html`, the assets the native host serves
|
|
4
|
+
// (`layoutAssets`), `_headers`, `_redirects` and an `ssg.json` report. The layout is what
|
|
5
|
+
// Cloudflare Pages and Workers Static Assets (`not_found_handling = "404-page"`) serve as is.
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
7
|
+
import { dirname, join } from 'node:path'
|
|
8
|
+
import type { FlatRoute } from '../routes'
|
|
9
|
+
import { layoutAssets } from './cf-assets'
|
|
10
|
+
import { BuildError } from './errors'
|
|
11
|
+
import type { Rendered } from './ssg-render'
|
|
12
|
+
import { assertNoCacheBindings } from './static-worker'
|
|
13
|
+
import { NOT_FOUND_PATH, type PlannedPage, type StaticPlan, paramRoutes, planStatic, ssgOf } from './ssg'
|
|
14
|
+
|
|
15
|
+
export interface StaticOptions {
|
|
16
|
+
/** `--strict`: a loader reading the request fails the build instead of warning. */
|
|
17
|
+
strict: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The request reads of one cache key expression (the server's grammar, through the addon). */
|
|
21
|
+
export type ExprReads = (expr: string) => string[]
|
|
22
|
+
|
|
23
|
+
/** R9.3/R9.4: everything that refuses a static build, checked before a byte is written; then the
|
|
24
|
+
* resolved plan. `actions`: whether `[actions]` is configured. */
|
|
25
|
+
export async function prepareStatic(
|
|
26
|
+
leaves: FlatRoute[],
|
|
27
|
+
actions: boolean,
|
|
28
|
+
reads: ExprReads,
|
|
29
|
+
log: (s: string) => void,
|
|
30
|
+
cache?: Parameters<typeof assertNoCacheBindings>[0],
|
|
31
|
+
): Promise<StaticPlan> {
|
|
32
|
+
// L12: `[actions]` makes the output hybrid (static pages + a Pages Worker for the action prefix).
|
|
33
|
+
if (actions) assertNoCacheBindings(cache)
|
|
34
|
+
let cacheInfo = false
|
|
35
|
+
for (const leaf of leaves) {
|
|
36
|
+
const cache = leaf.chain[leaf.chain.length - 1]?.cache
|
|
37
|
+
if (!cache || ssgOf(leaf) === false) continue
|
|
38
|
+
cacheInfo = true
|
|
39
|
+
for (const [field, expr] of [
|
|
40
|
+
['prefix', cache.prefix],
|
|
41
|
+
['bypass', typeof cache.bypass === 'string' ? cache.bypass : undefined],
|
|
42
|
+
] as const) {
|
|
43
|
+
if (expr === undefined) continue
|
|
44
|
+
let r: string[]
|
|
45
|
+
try {
|
|
46
|
+
r = reads(expr)
|
|
47
|
+
} catch (e) {
|
|
48
|
+
throw new BuildError('ssg-varies', `route ${leaf.id} (${leaf.pattern}) cache.${field}: ${(e as Error).message}`)
|
|
49
|
+
}
|
|
50
|
+
if (r.length > 0)
|
|
51
|
+
throw new BuildError(
|
|
52
|
+
'ssg-varies',
|
|
53
|
+
`route ${leaf.id} (${leaf.pattern}) cache.${field} reads ${r.join(', ')}: the page varies per request, and a static file is one page for everyone (set ssg: false on the route, or drop the read)`,
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (cacheInfo) log('[brust] static: cache.ttl_seconds does not apply to static files (ignored)')
|
|
58
|
+
const params = new Map<string, unknown>()
|
|
59
|
+
for (const leaf of paramRoutes(leaves)) {
|
|
60
|
+
const ssg = ssgOf(leaf)
|
|
61
|
+
if (!ssg || !ssg.params) continue
|
|
62
|
+
try {
|
|
63
|
+
params.set(leaf.id, await ssg.params())
|
|
64
|
+
} catch (e) {
|
|
65
|
+
throw new BuildError('ssg-params', `route ${leaf.id} (${leaf.pattern}) ssg.params() threw: ${(e as Error).message ?? String(e)}`)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return planStatic(leaves, params)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface StaticReport {
|
|
72
|
+
pages: { path: string; file: string; routeId: string }[]
|
|
73
|
+
redirects: { from: string; to: string; status: number }[]
|
|
74
|
+
skipped: { routeId: string; pattern: string }[]
|
|
75
|
+
notFound: { file: '404.html'; routeId: string | null }
|
|
76
|
+
warnings: string[]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
type SsgRecord =
|
|
80
|
+
| { kind: 'read'; routeId: string; path: string; field: string; key: string }
|
|
81
|
+
| { kind: 'headers'; routeId: string; path: string; names: string[] }
|
|
82
|
+
|
|
83
|
+
const DEFAULT_404 =
|
|
84
|
+
'<!doctype html>\n<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>404 Not Found</title></head><body><h1>404 Not Found</h1></body></html>\n'
|
|
85
|
+
|
|
86
|
+
/** A `_redirects` source as Cloudflare Pages matches it (review N4): the decoded path, with only what
|
|
87
|
+
* the file format or the decoding needs escaped (whitespace and control characters, and `%`, so a
|
|
88
|
+
* literal `%` is not read as an escape). Non-ASCII stays as UTF-8. */
|
|
89
|
+
export function redirectSource(path: string): string {
|
|
90
|
+
let decoded: string
|
|
91
|
+
try {
|
|
92
|
+
decoded = decodeURIComponent(path)
|
|
93
|
+
} catch {
|
|
94
|
+
decoded = path
|
|
95
|
+
}
|
|
96
|
+
return decoded.replace(/[\s%\u0000-\u001f\u007f]/gu, (c) => encodeURIComponent(c))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const text = (r: Rendered) => Buffer.from(r.body, 'base64').toString('utf8')
|
|
100
|
+
const excerpt = (s: string) => (s.length > 300 ? `${s.slice(0, 300)}…` : s).replace(/\s+/g, ' ').trim()
|
|
101
|
+
|
|
102
|
+
/** Spawns the render child against the dist at `dist`; the rendered responses in plan order and
|
|
103
|
+
* the loader records. `cwd` is the app root (its `brust.toml` is the server's config). */
|
|
104
|
+
async function render(dist: string, entry: string, cwd: string, paths: string[]): Promise<{ addr: string; rendered: Rendered[]; records: SsgRecord[] }> {
|
|
105
|
+
const stage = join(dist, '.ssg')
|
|
106
|
+
mkdirSync(stage, { recursive: true })
|
|
107
|
+
const plan = join(stage, 'plan.json')
|
|
108
|
+
const out = join(stage, 'rendered.json')
|
|
109
|
+
const logFile = join(stage, 'records.jsonl')
|
|
110
|
+
const progress = join(stage, 'progress.txt')
|
|
111
|
+
writeFileSync(plan, JSON.stringify(paths))
|
|
112
|
+
writeFileSync(logFile, '')
|
|
113
|
+
writeFileSync(progress, '')
|
|
114
|
+
const proc = Bun.spawn([process.execPath, join(import.meta.dir, 'ssg-render.ts')], {
|
|
115
|
+
cwd,
|
|
116
|
+
env: {
|
|
117
|
+
...process.env,
|
|
118
|
+
BRUST_DIST_DIR: dist,
|
|
119
|
+
BRUST_APP_ENTRY: entry,
|
|
120
|
+
BRUST_SSG: '1',
|
|
121
|
+
BRUST_SSG_PLAN: plan,
|
|
122
|
+
BRUST_SSG_OUT: out,
|
|
123
|
+
BRUST_SSG_LOG: logFile,
|
|
124
|
+
BRUST_SSG_PROGRESS: progress,
|
|
125
|
+
BRUST_ADDR: '127.0.0.1',
|
|
126
|
+
BRUST_PORT: '',
|
|
127
|
+
},
|
|
128
|
+
stdout: 'pipe',
|
|
129
|
+
stderr: 'pipe',
|
|
130
|
+
})
|
|
131
|
+
// Review N3: the whole render is bounded too; past it the child (our own PID) is killed.
|
|
132
|
+
const totalMs = Number(process.env.BRUST_SSG_TIMEOUT_MS) || 600_000
|
|
133
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
134
|
+
const late = new Promise<'late'>((r) => {
|
|
135
|
+
timer = setTimeout(() => r('late'), totalMs)
|
|
136
|
+
})
|
|
137
|
+
const texts = Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()])
|
|
138
|
+
const first = await Promise.race([proc.exited, late])
|
|
139
|
+
clearTimeout(timer)
|
|
140
|
+
if (first === 'late') {
|
|
141
|
+
proc.kill('SIGKILL')
|
|
142
|
+
await proc.exited
|
|
143
|
+
const last = readFileSync(progress, 'utf8').trim().split('\n').pop() || '(before the first page)'
|
|
144
|
+
throw new BuildError('ssg-timeout', `the render took longer than ${totalMs} ms (BRUST_SSG_TIMEOUT_MS); it was rendering ${last}`)
|
|
145
|
+
}
|
|
146
|
+
const code = first
|
|
147
|
+
const [stdout, stderr] = await texts
|
|
148
|
+
const result = existsSync(out) ? (JSON.parse(readFileSync(out, 'utf8')) as { addr: string; pages?: Rendered[]; timeout?: { path: string; ms: number } }) : null
|
|
149
|
+
if (result?.timeout)
|
|
150
|
+
throw new BuildError('ssg-timeout', `GET ${result.timeout.path} did not answer within ${result.timeout.ms} ms (BRUST_SSG_PAGE_TIMEOUT_MS)`)
|
|
151
|
+
if (code !== 0 || !result?.pages) throw new BuildError('ssg-render', `the render server failed (exit ${code}):\n${(stderr || stdout).trim()}`)
|
|
152
|
+
const { addr, pages: rendered } = result as { addr: string; pages: Rendered[] }
|
|
153
|
+
const records = readFileSync(logFile, 'utf8')
|
|
154
|
+
.split('\n')
|
|
155
|
+
.filter((l) => l !== '')
|
|
156
|
+
.map((l) => JSON.parse(l) as SsgRecord)
|
|
157
|
+
rmSync(stage, { recursive: true, force: true })
|
|
158
|
+
return { addr, rendered, records }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Renders `plan` against the built dist at `dist` and writes `<dist>/static/`. Returns the report. */
|
|
162
|
+
export async function writeStatic(
|
|
163
|
+
dist: string,
|
|
164
|
+
o: { entry: string; appRoot: string; plan: StaticPlan; version: string; strict: boolean; log: (s: string) => void; /** The dir as the user will find it (the build renders into a temp dist). */ shownDir?: string },
|
|
165
|
+
): Promise<StaticReport> {
|
|
166
|
+
const dir = join(dist, 'static')
|
|
167
|
+
rmSync(dir, { recursive: true, force: true })
|
|
168
|
+
const assetsStage = join(dist, '.ssg-assets')
|
|
169
|
+
const assets = layoutAssets(dist, assetsStage, o.version)
|
|
170
|
+
renameSync(join(assetsStage, 'assets'), dir)
|
|
171
|
+
rmSync(assetsStage, { recursive: true, force: true })
|
|
172
|
+
const warnings = [...assets.warnings]
|
|
173
|
+
const warn = (w: string) => {
|
|
174
|
+
warnings.push(w)
|
|
175
|
+
o.log(w)
|
|
176
|
+
}
|
|
177
|
+
for (const w of assets.warnings) o.log(w)
|
|
178
|
+
|
|
179
|
+
// R9.4: [cache] does not apply to static files, so the render must not need its adapter (a Redis
|
|
180
|
+
// that is not running at build time): the render reads a manifest without `cache`, and the dist
|
|
181
|
+
// keeps the real one for `brust start`.
|
|
182
|
+
const manifestFile = join(dist, 'manifest.json')
|
|
183
|
+
const manifest = readFileSync(manifestFile, 'utf8')
|
|
184
|
+
const parsed = JSON.parse(manifest) as { cache?: unknown }
|
|
185
|
+
if (parsed.cache) {
|
|
186
|
+
o.log('[brust] static: [cache] does not apply to static files (ignored while rendering)')
|
|
187
|
+
writeFileSync(manifestFile, `${JSON.stringify({ ...parsed, cache: null }, null, 2)}\n`)
|
|
188
|
+
}
|
|
189
|
+
let r: Awaited<ReturnType<typeof render>>
|
|
190
|
+
try {
|
|
191
|
+
r = await render(dist, o.entry, o.appRoot, o.plan.pages.map((p) => p.path))
|
|
192
|
+
} finally {
|
|
193
|
+
writeFileSync(manifestFile, manifest)
|
|
194
|
+
}
|
|
195
|
+
const { addr, rendered, records } = r
|
|
196
|
+
const report: StaticReport = { pages: [], redirects: [], skipped: o.plan.skipped, notFound: { file: '404.html', routeId: null }, warnings }
|
|
197
|
+
const write = (file: string, body: Buffer) => {
|
|
198
|
+
mkdirSync(dirname(join(dir, file)), { recursive: true })
|
|
199
|
+
writeFileSync(join(dir, file), body)
|
|
200
|
+
}
|
|
201
|
+
rendered.forEach((r, i) => {
|
|
202
|
+
const page: PlannedPage = o.plan.pages[i]!
|
|
203
|
+
const where = `GET ${page.path} (route ${page.routeId} ${page.pattern})`
|
|
204
|
+
const body = Buffer.from(r.body, 'base64')
|
|
205
|
+
if (text(r).includes(addr) || (r.headers.location ?? '').includes(addr))
|
|
206
|
+
throw new BuildError('ssg-render', `${where}: the response names the render server's loopback origin; a static page must not (build absolute URLs from a configured origin)`)
|
|
207
|
+
if (page.expect === 404) {
|
|
208
|
+
if (r.status !== 404) throw new BuildError('ssg-render', `${where}: the * route answered ${r.status}, want 404: ${excerpt(text(r))}`)
|
|
209
|
+
write('404.html', body)
|
|
210
|
+
report.notFound.routeId = page.routeId
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
if (r.status === 200) {
|
|
214
|
+
write(page.file, body)
|
|
215
|
+
report.pages.push({ path: page.path, file: page.file, routeId: page.routeId })
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
if (r.status >= 300 && r.status < 400 && r.headers.location) {
|
|
219
|
+
report.redirects.push({ from: page.path, to: r.headers.location, status: r.status })
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
throw new BuildError('ssg-render', `${where}: status ${r.status}, want 200 (or a redirect()): ${excerpt(text(r))}`)
|
|
223
|
+
})
|
|
224
|
+
if (o.plan.defaultNotFound) {
|
|
225
|
+
write('404.html', Buffer.from(DEFAULT_404))
|
|
226
|
+
warn("[brust] warning ssg-404 no '*' route: wrote a default 404.html (without one, Cloudflare Pages serves every unknown path as the home page)")
|
|
227
|
+
}
|
|
228
|
+
writeFileSync(join(dir, '_redirects'), report.redirects.map((r) => `${redirectSource(r.from)} ${r.to} ${r.status}\n`).join(''))
|
|
229
|
+
|
|
230
|
+
const seen = new Set<string>()
|
|
231
|
+
const reads: string[] = []
|
|
232
|
+
for (const rec of records) {
|
|
233
|
+
const leaf = o.plan.pages.find((p) => p.routeId === rec.routeId)
|
|
234
|
+
const route = `route ${rec.routeId}${leaf ? ` (${leaf.pattern})` : ''}`
|
|
235
|
+
if (rec.kind === 'headers') {
|
|
236
|
+
const key = `h ${rec.routeId} ${rec.names.join(',')}`
|
|
237
|
+
if (seen.has(key)) continue
|
|
238
|
+
seen.add(key)
|
|
239
|
+
warn(`[brust] warning ssg-headers ${route}: the loader's response headers (${rec.names.join(', ')}) are dropped: a static file carries none`)
|
|
240
|
+
continue
|
|
241
|
+
}
|
|
242
|
+
const key = `r ${rec.routeId} ${rec.field} ${rec.key}`
|
|
243
|
+
if (seen.has(key)) continue
|
|
244
|
+
seen.add(key)
|
|
245
|
+
const what = rec.key === '*' ? `lists req.${rec.field}` : `reads req.${rec.field}[${JSON.stringify(rec.key)}]`
|
|
246
|
+
reads.push(`${route} ${what} (at ${rec.path}): the static page is what a visitor with no headers, cookies or query sees`)
|
|
247
|
+
}
|
|
248
|
+
if (reads.length > 0 && o.strict) throw new BuildError('ssg-request-read', reads.join('\n'))
|
|
249
|
+
for (const r of reads) warn(`[brust] warning ssg-request-read ${r}`)
|
|
250
|
+
|
|
251
|
+
writeFileSync(join(dir, 'ssg.json'), `${JSON.stringify(report, null, 2)}\n`)
|
|
252
|
+
o.log(
|
|
253
|
+
`[brust] static: ${report.pages.length} page(s), ${report.redirects.length} redirect(s), 404.html${report.notFound.routeId ? '' : ' (default)'}${report.skipped.length ? `, ${report.skipped.length} route(s) ssg: false` : ''} -> ${o.shownDir ?? dir}`,
|
|
254
|
+
)
|
|
255
|
+
return report
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export { NOT_FOUND_PATH }
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Tailwind CSS v4 for `brust build` (L32, F135, R33.1-R33.4): brust's own Bun plugin over the app's Tailwind.
|
|
2
|
+
// It does what the official integrations (@tailwindcss/vite, @tailwindcss/postcss) do: `compile()` from
|
|
3
|
+
// `@tailwindcss/node`, a `Scanner` from `@tailwindcss/oxide` over the compiler's sources (the app root unless the
|
|
4
|
+
// stylesheet says `source(...)`, plus every `@source` / `@source not`), then `build(candidates)`. Both packages are
|
|
5
|
+
// resolved from the APP, never bundled into or depended on by brust, so the compiler is the app's pinned
|
|
6
|
+
// `tailwindcss` (`@tailwindcss/node` depends on its own exact `tailwindcss`; the app installs all three at one
|
|
7
|
+
// version, and a mismatch is a loud warning with the command that aligns them).
|
|
8
|
+
import { readFileSync } from 'node:fs'
|
|
9
|
+
import { dirname, relative } from 'node:path'
|
|
10
|
+
import type { BunPlugin } from 'bun'
|
|
11
|
+
import { BuildError } from './errors'
|
|
12
|
+
|
|
13
|
+
/** The install set (R33.2): one exact version for all three. */
|
|
14
|
+
export const TAILWIND_PACKAGES = ['tailwindcss', '@tailwindcss/node', '@tailwindcss/oxide'] as const
|
|
15
|
+
|
|
16
|
+
interface SourceEntry {
|
|
17
|
+
base: string
|
|
18
|
+
pattern: string
|
|
19
|
+
negated: boolean
|
|
20
|
+
}
|
|
21
|
+
interface Compiler {
|
|
22
|
+
root: 'none' | { base: string; pattern: string } | null
|
|
23
|
+
sources: SourceEntry[]
|
|
24
|
+
build(candidates: string[]): string
|
|
25
|
+
}
|
|
26
|
+
interface TailwindNode {
|
|
27
|
+
compile(css: string, o: { base: string; from?: string; shouldRewriteUrls?: boolean; onDependency: (path: string) => void }): Promise<Compiler>
|
|
28
|
+
}
|
|
29
|
+
interface TailwindOxide {
|
|
30
|
+
Scanner: new (o: { sources: SourceEntry[] }) => { scan(): string[] }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The `version` of `pkg` as resolved from `from` (a directory), or undefined when it does not resolve. */
|
|
34
|
+
function versionFrom(pkg: string, from: string): string | undefined {
|
|
35
|
+
try {
|
|
36
|
+
return (JSON.parse(readFileSync(Bun.resolveSync(`${pkg}/package.json`, from), 'utf8')) as { version?: string }).version
|
|
37
|
+
} catch {
|
|
38
|
+
return undefined
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The versions that decide what compiles (R33.3): the app's `tailwindcss`, the `tailwindcss` that
|
|
43
|
+
* `@tailwindcss/node` imports (the compiler), and the two integration packages. */
|
|
44
|
+
export function tailwindVersions(appRoot: string): { app?: string; compiler?: string; node?: string; oxide?: string } {
|
|
45
|
+
let compiler: string | undefined
|
|
46
|
+
try {
|
|
47
|
+
compiler = versionFrom('tailwindcss', dirname(Bun.resolveSync('@tailwindcss/node/package.json', appRoot)))
|
|
48
|
+
} catch {}
|
|
49
|
+
return { app: versionFrom('tailwindcss', appRoot), compiler, node: versionFrom('@tailwindcss/node', appRoot), oxide: versionFrom('@tailwindcss/oxide', appRoot) }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The oldest Tailwind this plugin runs (review F1): 4.0's `compile()` has no `sources` (it takes globs), so its build
|
|
53
|
+
* would crash mid-way; 4.1.0 is the first with the API the official integrations use today. */
|
|
54
|
+
export const TAILWIND_FLOOR = '4.1.0'
|
|
55
|
+
/** The version brust is tested with (create-app pins the same); the command that lifts an app off an old Tailwind names it. */
|
|
56
|
+
export const TAILWIND_TESTED = '4.3.3'
|
|
57
|
+
|
|
58
|
+
/** `a < b` for plain `major.minor.patch` versions (a pre-release suffix counts as its base version). */
|
|
59
|
+
export function versionBelow(a: string, b: string): boolean {
|
|
60
|
+
const n = (v: string) => v.split('-')[0]!.split('.').map((x) => Number(x) || 0)
|
|
61
|
+
const [x, y] = [n(a), n(b)]
|
|
62
|
+
for (let i = 0; i < 3; i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) < (y[i] ?? 0)
|
|
63
|
+
return false
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const addCommand = (v: string) => `bun add -d --exact ${TAILWIND_PACKAGES.map((p) => `${p}@${v}`).join(' ')}`
|
|
67
|
+
|
|
68
|
+
/** The Tailwind plugin of the CSS build, resolved from the app. Missing packages → `tailwind-missing` with the
|
|
69
|
+
* exact install command; versions that differ → one warning through `log` with the command that aligns them.
|
|
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
|
+
export async function loadTailwind(appRoot: string, usedBy: string, uses: (file: string) => boolean, log?: (s: string) => void): Promise<BunPlugin> {
|
|
73
|
+
const v = tailwindVersions(appRoot)
|
|
74
|
+
const missing = TAILWIND_PACKAGES.filter((p) => versionFrom(p, appRoot) === undefined)
|
|
75
|
+
if (missing.length > 0)
|
|
76
|
+
throw new BuildError(
|
|
77
|
+
'tailwind-missing',
|
|
78
|
+
`${relative(appRoot, usedBy)} uses Tailwind CSS, but ${missing.join(', ')} ${missing.length > 1 ? 'are' : 'is'} not installed in the app: run \`${v.app ? addCommand(v.app) : `bun add -d ${TAILWIND_PACKAGES.map((p) => `${p}@4`).join(' ')}`}\``,
|
|
79
|
+
)
|
|
80
|
+
const old = (
|
|
81
|
+
[
|
|
82
|
+
['tailwindcss', v.app],
|
|
83
|
+
['the compiler tailwindcss', v.compiler],
|
|
84
|
+
['@tailwindcss/node', v.node],
|
|
85
|
+
['@tailwindcss/oxide', v.oxide],
|
|
86
|
+
] as const
|
|
87
|
+
).filter(([, ver]) => ver !== undefined && versionBelow(ver, TAILWIND_FLOOR))
|
|
88
|
+
if (old.length > 0)
|
|
89
|
+
throw new BuildError(
|
|
90
|
+
'tailwind-version',
|
|
91
|
+
`${relative(appRoot, usedBy)} uses Tailwind CSS, but ${old.map(([p, ver]) => `${p} ${ver}`).join(', ')} ${old.length > 1 ? 'are' : 'is'} older than ${TAILWIND_FLOOR}, the oldest brust builds with: run \`${addCommand(TAILWIND_TESTED)}\``,
|
|
92
|
+
)
|
|
93
|
+
if (new Set([v.app, v.compiler, v.node, v.oxide]).size > 1)
|
|
94
|
+
log?.(
|
|
95
|
+
`[brust] warning tailwind-version the app has tailwindcss ${v.app}, but the compiler is tailwindcss ${v.compiler} (@tailwindcss/node ${v.node}, @tailwindcss/oxide ${v.oxide}): install one version of all three: ${addCommand(v.app!)}`,
|
|
96
|
+
)
|
|
97
|
+
const node = (await import(Bun.resolveSync('@tailwindcss/node', appRoot))) as TailwindNode
|
|
98
|
+
const oxide = (await import(Bun.resolveSync('@tailwindcss/oxide', appRoot))) as TailwindOxide
|
|
99
|
+
return {
|
|
100
|
+
name: 'brust-tailwind',
|
|
101
|
+
setup(b) {
|
|
102
|
+
b.onLoad({ filter: /\.css$/ }, async (a) => {
|
|
103
|
+
if (!uses(a.path)) return undefined // Bun's own loader (CSS Modules included)
|
|
104
|
+
const css = readFileSync(a.path, 'utf8')
|
|
105
|
+
const compiler = await node.compile(css, { base: dirname(a.path), from: a.path, shouldRewriteUrls: true, onDependency: () => {} })
|
|
106
|
+
const root: SourceEntry[] =
|
|
107
|
+
compiler.root === 'none' ? [] : compiler.root === null ? [{ base: appRoot, pattern: '**/*', negated: false }] : [{ ...compiler.root, negated: false }]
|
|
108
|
+
const scanner = new oxide.Scanner({ sources: [...root, ...compiler.sources] })
|
|
109
|
+
let out = compiler.build(scanner.scan())
|
|
110
|
+
// `@reference "./app.css"` pulls in Tailwind's license banner too; only the file that imports Tailwind ships it
|
|
111
|
+
// (one banner per page, as before)
|
|
112
|
+
if (!/@import\s+(?:url\(\s*)?["']tailwindcss["']/.test(css)) out = out.replace(/\/\*! tailwindcss v[^*]*\*\/\s*/g, '')
|
|
113
|
+
return { contents: out, loader: 'css' }
|
|
114
|
+
})
|
|
115
|
+
},
|
|
116
|
+
}
|
|
117
|
+
}
|