@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.
Files changed (65) hide show
  1. package/README.md +66 -6
  2. package/native/index.d.ts +87 -5
  3. package/native/index.js +61 -54
  4. package/package.json +22 -9
  5. package/src/actions/client.ts +22 -5
  6. package/src/actions/load.ts +2 -1
  7. package/src/actions/prefix.ts +27 -3
  8. package/src/actions-bridge.ts +76 -19
  9. package/src/browser.ts +3 -1
  10. package/src/build/actions-only.ts +86 -0
  11. package/src/build/build-eval.ts +330 -0
  12. package/src/build/build-icons.ts +69 -0
  13. package/src/build/bundle.ts +341 -43
  14. package/src/build/cf-assets.ts +83 -0
  15. package/src/build/cf-core.ts +90 -0
  16. package/src/build/cf-entry.ts +76 -0
  17. package/src/build/cf-wrangler.ts +169 -0
  18. package/src/build/cloudflare.ts +317 -0
  19. package/src/build/compile.ts +74 -4
  20. package/src/build/config.ts +274 -15
  21. package/src/build/css.ts +275 -0
  22. package/src/build/features.ts +110 -0
  23. package/src/build/index.ts +224 -20
  24. package/src/build/manifest.ts +69 -8
  25. package/src/build/pkg-src.ts +383 -0
  26. package/src/build/scan.ts +61 -20
  27. package/src/build/server-only.ts +39 -0
  28. package/src/build/ssg-render.ts +65 -0
  29. package/src/build/ssg.ts +146 -0
  30. package/src/build/static-worker.ts +91 -0
  31. package/src/build/static.ts +258 -0
  32. package/src/build/tailwind.ts +117 -0
  33. package/src/build/tsconfig-paths.ts +67 -0
  34. package/src/build-time.ts +10 -0
  35. package/src/cache-adapter/bridge.ts +91 -0
  36. package/src/cache-adapter/conformance.ts +226 -0
  37. package/src/cache-adapter/index.ts +10 -0
  38. package/src/cache-adapter/load.ts +144 -0
  39. package/src/cache-adapter/memory.ts +229 -0
  40. package/src/cache-adapter/runtime.ts +124 -0
  41. package/src/cache-adapter/types.ts +96 -0
  42. package/src/cache-adapter/workers.ts +118 -0
  43. package/src/cache.ts +4 -1
  44. package/src/cf.ts +19 -0
  45. package/src/cli.ts +70 -2
  46. package/src/config.ts +127 -25
  47. package/src/css-modules.d.ts +8 -0
  48. package/src/dev/bun.ts +365 -0
  49. package/src/dev/client.ts +92 -0
  50. package/src/dev/devtools-data.ts +23 -0
  51. package/src/dev/devtools.ts +356 -0
  52. package/src/dev/diag.ts +30 -0
  53. package/src/dev/proxy.ts +236 -0
  54. package/src/dev/tui.ts +144 -0
  55. package/src/dev/watch.ts +81 -0
  56. package/src/dev.ts +93 -0
  57. package/src/handlers.ts +282 -0
  58. package/src/index.ts +3 -1
  59. package/src/native.ts +49 -2
  60. package/src/routes.ts +20 -1
  61. package/src/run.ts +61 -14
  62. package/src/server-handlers.ts +32 -0
  63. package/src/shadcn/init.ts +213 -0
  64. package/src/shadcn/jsonc.ts +129 -0
  65. package/src/worker.ts +129 -245
@@ -0,0 +1,275 @@
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
+ import { loadTailwind } from './tailwind'
18
+
19
+ const hex = (b: string | Uint8Array) => createHash('sha256').update(b).digest('hex').slice(0, 10)
20
+ const real = (p: string) => {
21
+ try {
22
+ return realpathSync(p)
23
+ } catch {
24
+ return p
25
+ }
26
+ }
27
+ const isCss = (p: string) => p.endsWith('.css')
28
+ const isModule = (p: string) => p.endsWith('.module.css')
29
+ /** A name the static rules serve (`[A-Za-z0-9_.-]`, no leading dot). */
30
+ const safeName = (s: string) => s.replace(/[^A-Za-z0-9_.-]/g, '_').replace(/^\.+/, '_')
31
+
32
+ /** The stylesheets one source file reaches through static and dynamic imports, in order, real paths. Source
33
+ * files outside node_modules are followed; a `.css` import (an app file or a package's CSS) is
34
+ * collected, never followed (its `@import`s are the CSS bundler's). */
35
+ export class CssGraph {
36
+ private memo = new Map<string, string[]>()
37
+ private active = new Set<string>()
38
+ private transpilers = new Map<string, InstanceType<typeof Bun.Transpiler>>()
39
+
40
+ of(file: string): string[] {
41
+ const f = real(file)
42
+ const hit = this.memo.get(f)
43
+ if (hit) return hit
44
+ if (this.active.has(f)) return [] // an import cycle: the outer visit already lists what follows
45
+ this.active.add(f)
46
+ const out: string[] = []
47
+ const add = (c: string) => {
48
+ if (!out.includes(c)) out.push(c)
49
+ }
50
+ const loader = /\.(tsx|ts|jsx|js|mjs)$/.exec(f)?.[1]
51
+ let src = ''
52
+ try {
53
+ src = loader ? readFileSync(f, 'utf8') : ''
54
+ } catch {}
55
+ if (loader && src) {
56
+ const l = loader === 'mjs' ? 'js' : (loader as 'tsx' | 'ts' | 'jsx' | 'js')
57
+ let t = this.transpilers.get(l)
58
+ if (!t) this.transpilers.set(l, (t = new Bun.Transpiler({ loader: l })))
59
+ for (const imp of t.scanImports(src)) {
60
+ // A dynamic import() is walked too (review N4): its CSS is linked eagerly for the route, so a
61
+ // lazily loaded component is styled on first paint and its CSS Modules are built.
62
+ if (imp.kind !== 'import-statement' && imp.kind !== 'dynamic-import') continue
63
+ let dep: string
64
+ try {
65
+ dep = real(Bun.resolveSync(imp.path, dirname(f)))
66
+ } catch {
67
+ continue // unresolvable: the JS build reports it
68
+ }
69
+ if (isCss(dep)) add(dep)
70
+ else if (!dep.split(sep).includes('node_modules')) for (const c of this.of(dep)) add(c)
71
+ }
72
+ }
73
+ this.active.delete(f)
74
+ this.memo.set(f, out)
75
+ return out
76
+ }
77
+ }
78
+
79
+ /** R19.1: every route's ordered source stylesheets (real paths), by route id. `chainFiles` maps a leaf to
80
+ * its chain's component files, root layout first. */
81
+ export function routeCssSources(leaves: FlatRoute[], chainFiles: (l: FlatRoute) => string[], globals: string[], graph = new CssGraph()): Map<string, string[]> {
82
+ const out = new Map<string, string[]>()
83
+ for (const leaf of leaves) {
84
+ const list: string[] = [...globals]
85
+ for (const file of chainFiles(leaf)) for (const c of graph.of(file)) if (!list.includes(c)) list.push(c)
86
+ out.set(leaf.id, list)
87
+ }
88
+ return out
89
+ }
90
+
91
+ /** `[css] global` resolved: each a `.css` file inside the app. */
92
+ export function resolveGlobals(appRoot: string, globals: string[]): string[] {
93
+ return globals.map((g) => {
94
+ const abs = resolve(appRoot, g)
95
+ const rel = relative(appRoot, abs)
96
+ if (isAbsolute(g) || rel.startsWith('..') || isAbsolute(rel) || !existsSync(abs)) throw new BuildError('css', `css.global ${g} is not a file inside the app`)
97
+ return real(abs)
98
+ })
99
+ }
100
+
101
+ export interface CssBuild {
102
+ /** source (real path) → `client/<stem>-<hex10>.css`; absent when the file emits no CSS. */
103
+ file: Map<string, string>
104
+ /** `.module.css` source (real path) → its class map. */
105
+ maps: Map<string, Record<string, string>>
106
+ }
107
+
108
+ const CLASS = /\.(-?[_a-zA-Z][_a-zA-Z0-9-]*)/g
109
+
110
+ /** The asset types `static_rules::is_safe_client_asset_filename` serves from `/_brust/client/` (review N1). */
111
+ export const SERVED_ASSET_EXT = ['.woff2', '.woff', '.ttf', '.otf', '.eot', '.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif', '.svg', '.ico']
112
+
113
+ /** R19.2/R19.4: builds every source stylesheet (one JS entry each) into `<dist>/client/`. `minify`
114
+ * is off only for a development build (`brust dev`, review N3). */
115
+ export async function buildCss(
116
+ dist: string,
117
+ sources: string[],
118
+ minify = true,
119
+ o: { appRoot?: string; log?: (s: string) => void } = {},
120
+ ): Promise<CssBuild> {
121
+ const file = new Map<string, string>()
122
+ const maps = new Map<string, Record<string, string>>()
123
+ if (sources.length === 0) return { file, maps }
124
+ const appRoot = o.appRoot ?? process.cwd()
125
+ const tw = tailwindUse(sources)
126
+ const plugins: BunPlugin[] = []
127
+ if (tw.using.length > 0) {
128
+ // a stylesheet compiles with Tailwind when it, or a relative @import of it, uses Tailwind
129
+ const via = new Map<string, boolean>()
130
+ const uses = (f: string) => {
131
+ const k = real(f)
132
+ let hit = via.get(k)
133
+ if (hit === undefined) via.set(k, (hit = tailwindUse([k]).using.length > 0))
134
+ return hit
135
+ }
136
+ plugins.push(await loadTailwind(appRoot, tw.using[0]!, uses, o.log))
137
+ if (tw.imports.length > 1)
138
+ o.log?.(
139
+ `[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`,
140
+ )
141
+ }
142
+ const stage = join(dist, '.stage', 'css')
143
+ mkdirSync(stage, { recursive: true })
144
+ const entries = sources.map((src, i) => {
145
+ const e = join(stage, `s${i}.js`)
146
+ writeFileSync(e, isModule(src) ? `export { default } from ${JSON.stringify(src)}\n` : `import ${JSON.stringify(src)}\n`)
147
+ return e
148
+ })
149
+ const res = await Bun.build({
150
+ entrypoints: entries,
151
+ target: 'browser',
152
+ format: 'esm',
153
+ minify,
154
+ naming: { entry: '[name]-[hash].[ext]', chunk: '[name]-[hash].[ext]', asset: '[name]-[hash].[ext]' },
155
+ external: ['/public/*', '/_brust/*'],
156
+ root: appRoot,
157
+ plugins,
158
+ })
159
+ if (!res.success) throw new BuildError('css', res.logs.map(String).join('\n'))
160
+ // Assets a stylesheet references (fonts, images): `<name>-<hex10>.<ext>`, references rewritten.
161
+ const rename = new Map<string, string>()
162
+ const assets: { name: string; bytes: Uint8Array }[] = []
163
+ const css = new Map<number, string>()
164
+ const js = new Map<number, string>()
165
+ const unserved: string[] = []
166
+ for (const o of res.outputs) {
167
+ const name = basename(o.path)
168
+ const entry = /^s(\d+)-[^-.]+\.(css|js)$/.exec(name)
169
+ if (entry) {
170
+ ;(entry[2] === 'css' ? css : js).set(Number(entry[1]), await o.text())
171
+ continue
172
+ }
173
+ const m = /^(.*)-[^-.]+(\.[^.]+)$/.exec(name)
174
+ if (!m) throw new BuildError('css', `unexpected output ${name}`)
175
+ const ext = m[2]!.toLowerCase()
176
+ if (!SERVED_ASSET_EXT.includes(ext)) {
177
+ unserved.push(name)
178
+ continue
179
+ }
180
+ const bytes = new Uint8Array(await o.arrayBuffer())
181
+ const to = `${safeName(m[1]!)}-${hex(bytes)}${ext}`
182
+ rename.set(name, to)
183
+ assets.push({ name: to, bytes })
184
+ }
185
+ // Fail closed (review N1): an asset the server would not serve from /_brust/client would 404.
186
+ if (unserved.length > 0) {
187
+ const lines = unserved.map((name) => {
188
+ const by = sources.filter((_, i) => css.get(i)?.includes(name))
189
+ const [, stem, ext] = /^(.*)-[^-.]+(\.[^.]+)$/.exec(name)!
190
+ return `${stem}${ext} (url() in ${by.join(', ') || 'a stylesheet'})`
191
+ })
192
+ throw new BuildError(
193
+ 'css-asset-type',
194
+ `${lines.join('; ')}: brust serves only ${SERVED_ASSET_EXT.join(' ')} from /_brust/client; put the file in public/ and reference it as /public/…`,
195
+ )
196
+ }
197
+ for (const a of assets) writeFileSync(join(dist, 'client', a.name), a.bytes)
198
+ for (const [i, src] of sources.entries()) {
199
+ let text = css.get(i)
200
+ if (text !== undefined && text.trim() !== '') {
201
+ for (const [from, to] of rename) text = text.split(from).join(to)
202
+ const stem = safeName(basename(src).replace(/\.css$/, ''))
203
+ const out = `${stem}-${hex(text)}.css`
204
+ writeFileSync(join(dist, 'client', out), text)
205
+ file.set(src, `client/${out}`)
206
+ }
207
+ if (!isModule(src)) continue
208
+ const mod = join(stage, `map${i}.mjs`)
209
+ writeFileSync(mod, js.get(i) ?? 'export default {}')
210
+ const map = { ...((await import(mod)).default as Record<string, string>) }
211
+ // R19.4: every class the map hands out exists in the CSS it ships with.
212
+ const have = new Set([...(text ?? '').matchAll(CLASS)].map((x) => x[1]!))
213
+ for (const [k, v] of Object.entries(map))
214
+ for (const cls of v.split(/\s+/).filter(Boolean))
215
+ if (!have.has(cls)) throw new BuildError('css', `${src}: class ${k} maps to ${cls}, which is not in the emitted CSS`)
216
+ maps.set(src, map)
217
+ }
218
+ return { file, maps }
219
+ }
220
+
221
+ // ---- Tailwind CSS v4 (gaps round L21, R21.1) ----
222
+
223
+ /** A stylesheet that needs the Tailwind compiler. */
224
+ const TAILWIND_USE = /@import\s+(?:url\(\s*)?["']tailwindcss(?:\/[^"']*)?["']|@apply\b|@reference\b|@theme\b|@plugin\b/
225
+ const TAILWIND_IMPORT = /@import\s+(?:url\(\s*)?["']tailwindcss["']/
226
+ const CSS_IMPORT = /@import\s+(?:url\(\s*)?["'](\.{1,2}\/[^"']+)["']/g
227
+
228
+ /** Which sources (or the relative `@import`s they pull in) use Tailwind, and which `@import "tailwindcss"`. */
229
+ export function tailwindUse(sources: string[]): { using: string[]; imports: string[] } {
230
+ const using: string[] = []
231
+ const imports: string[] = []
232
+ const seen = new Set<string>()
233
+ const visit = (f: string) => {
234
+ if (seen.has(f)) return
235
+ seen.add(f)
236
+ let text = ''
237
+ try {
238
+ text = readFileSync(f, 'utf8')
239
+ } catch {
240
+ return
241
+ }
242
+ if (TAILWIND_USE.test(text)) using.push(f)
243
+ if (TAILWIND_IMPORT.test(text)) imports.push(f)
244
+ for (const m of text.matchAll(CSS_IMPORT)) visit(resolve(dirname(f), m[1]!))
245
+ }
246
+ for (const s of sources) visit(s)
247
+ return { using, imports }
248
+ }
249
+
250
+ /** R19.4: the one CSS plugin of every JS build. A `.module.css` import is its class map
251
+ * (`export default Object.freeze({…})`); any other `.css` import is an empty module, because the
252
+ * stylesheet itself is linked by the server (R19.3), never bundled into JS. */
253
+ export function cssPlugin(maps: Map<string, Record<string, string>>): BunPlugin {
254
+ return {
255
+ name: 'brust-css',
256
+ setup(b) {
257
+ b.onLoad({ filter: /\.css$/ }, (a) => {
258
+ if (!isModule(a.path)) return { contents: '', loader: 'js' }
259
+ const map = maps.get(real(a.path))
260
+ 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`)
261
+ return { contents: `export default Object.freeze(${JSON.stringify(map)})\n`, loader: 'js' }
262
+ })
263
+ },
264
+ }
265
+ }
266
+
267
+ /** Direct `.css` imports of `file` (the route entry: linked to no route, so the build warns). */
268
+ export function directCss(file: string): string[] {
269
+ const src = readFileSync(file, 'utf8')
270
+ const t = new Bun.Transpiler({ loader: file.endsWith('.tsx') ? 'tsx' : 'ts' })
271
+ return t
272
+ .scanImports(src)
273
+ .filter((i) => i.kind === 'import-statement' && i.path.endsWith('.css'))
274
+ .map((i) => i.path)
275
+ }
@@ -0,0 +1,110 @@
1
+ // Which client-runtime feature modules a component needs (L33, R35.2). The compiler's output is the source of truth:
2
+ // a native component's template carries one `x-*` attribute per directive it uses, a react-tier component is an island.
3
+ // The set is written per component into the manifest; the server unions it over a route's components and links the
4
+ // core plus exactly those modules. `packages/runtime-dom/README.md` ("Feature modules") owns the module list and the rule.
5
+ import { BuildError } from './errors'
6
+
7
+ /** Feature modules of `@brust/runtime-dom/features/*` the browser build ships, in load order. */
8
+ export const RUNTIME_FEATURES = ['basics', 'if', 'for', 'model', 'html', 'island'] as const
9
+ export type RuntimeFeature = (typeof RUNTIME_FEATURES)[number]
10
+
11
+ /** Directive attribute → the module that binds it. `null`: handled by the core (host, props, inert-markup marker). */
12
+ export function featureOfAttr(attr: string): RuntimeFeature | null | undefined {
13
+ switch (attr) {
14
+ case 'x-data':
15
+ case 'x-props':
16
+ case 'x-props-bind':
17
+ case 'x-raw':
18
+ return null
19
+ case 'x-text':
20
+ case 'x-show':
21
+ case 'x-ref':
22
+ return 'basics'
23
+ case 'x-if':
24
+ case 'x-for':
25
+ case 'x-model':
26
+ case 'x-html':
27
+ return attr.slice(2) as RuntimeFeature
28
+ default:
29
+ return /^x-(bind|on)-/.test(attr) ? 'basics' : undefined
30
+ }
31
+ }
32
+
33
+ /** The template with every jinja tag (`{{ … }}`, `{% … %}`, `{# … #}`) replaced by a space, string literals inside them
34
+ * read as strings: a printed value such as shadcn's `has-[>svg]:px-3` (a folded class, F149) holds a `>` or a quote that
35
+ * would otherwise end the element's tag early and hide the directives after it. A directive is never inside a jinja tag. */
36
+ export function withoutJinja(template: string): string {
37
+ let out = ''
38
+ let i = 0
39
+ while (i < template.length) {
40
+ const open = template.indexOf('{', i)
41
+ const kind = open < 0 ? '' : template[open + 1]
42
+ if (open < 0) {
43
+ out += template.slice(i)
44
+ break
45
+ }
46
+ if (kind !== '{' && kind !== '%' && kind !== '#') {
47
+ out += template.slice(i, open + 1)
48
+ i = open + 1
49
+ continue
50
+ }
51
+ out += template.slice(i, open)
52
+ const close = kind === '{' ? '}}' : `${kind}}`
53
+ let j = open + 2
54
+ let quote = ''
55
+ for (; j < template.length; j++) {
56
+ const ch = template[j]!
57
+ if (quote) {
58
+ if (ch === '\\') j++
59
+ else if (ch === quote) quote = ''
60
+ } else if (kind !== '#' && (ch === '"' || ch === "'")) quote = ch
61
+ else if (template.startsWith(close, j)) break
62
+ }
63
+ out += ' '
64
+ i = j + close.length
65
+ }
66
+ return out
67
+ }
68
+
69
+ /** The `x-*` attribute names on the elements of a template (tags only: prose that mentions `x-for` binds nothing). */
70
+ export function directiveAttrs(template: string): Set<string> {
71
+ const out = new Set<string>()
72
+ template = withoutJinja(template)
73
+ for (const tag of template.matchAll(/<[a-zA-Z][\w-]*((?:[^>"']|"[^"]*"|'[^']*')*)>/g))
74
+ for (const a of tag[1]!.matchAll(/(?:^|\s)(x-[a-z][a-z0-9-]*)(?==|\s|$|\/)/g)) out.add(a[1]!)
75
+ return out
76
+ }
77
+
78
+ /**
79
+ * The feature modules `template` needs, sorted in load order (`react` = the component is an island: `island`).
80
+ * An `x-*` attribute no module binds is a build error (a compiler that learned a directive before the runtime did):
81
+ * never a silent no-op in the browser.
82
+ */
83
+ export function featuresOf(id: string, template: string, react: boolean): RuntimeFeature[] {
84
+ const need = new Set<RuntimeFeature>()
85
+ if (react) need.add('island')
86
+ for (const attr of directiveAttrs(template)) {
87
+ const f = featureOfAttr(attr)
88
+ if (f === undefined) throw new BuildError('runtime-feature-missing', `${id}: the template uses ${attr}, which no client runtime feature binds`)
89
+ if (f !== null) need.add(f)
90
+ }
91
+ return RUNTIME_FEATURES.filter((f) => need.has(f))
92
+ }
93
+
94
+ /** Matches the `/_brust/<file>` URL of `file` in a built chunk's text. */
95
+ const mentions = (text: string, file: string) => text.includes(`/_brust/${file}`)
96
+
97
+ /**
98
+ * Whether the built client file `entry` (`client/<file>`) imports the store module `storeFile`, directly or through the
99
+ * shared `chunk-*.js` files it imports. A component whose chunk does gets `store` in its features: the page preloads
100
+ * the module (the chunk imports it itself, so it has no script tag).
101
+ */
102
+ export function importsStore(read: (rel: string) => string | undefined, entry: string, storeFile: string, seen = new Set<string>()): boolean {
103
+ if (seen.has(entry)) return false
104
+ seen.add(entry)
105
+ const text = read(entry)
106
+ if (text === undefined) return false
107
+ if (mentions(text, storeFile)) return true
108
+ for (const m of text.matchAll(/(?:from|import)\s*"\.\/([^"]+\.js)"/g)) if (importsStore(read, `client/${m[1]}`, storeFile, seen)) return true
109
+ return false
110
+ }