brustjs 0.1.64-alpha → 0.1.65-alpha

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.
@@ -0,0 +1,87 @@
1
+ import { onBeforeNavigate } from '../navigation/store.ts'
2
+
3
+ export type Ref = `e${number}`
4
+ export type ActionTarget = Ref | string
5
+
6
+ export interface BrustError {
7
+ code:
8
+ | 'stale-ref'
9
+ | 'not-found'
10
+ | 'ambiguous'
11
+ | 'timeout'
12
+ | 'disabled'
13
+ | 'nav-failed'
14
+ | 'bad-input'
15
+ message: string
16
+ hint?: string
17
+ }
18
+
19
+ export interface ErrorResult {
20
+ ok: false
21
+ error: BrustError
22
+ }
23
+
24
+ const elementRefs = new WeakMap<Element, Ref>()
25
+ let refElements = new Map<Ref, WeakRef<Element>>()
26
+ let nextRef = 1
27
+ let generation = 0
28
+
29
+ export function mintRef(element: Element): Ref {
30
+ const existing = elementRefs.get(element)
31
+ if (existing && refElements.get(existing)?.deref() === element) return existing
32
+ const ref = `e${nextRef++}` as Ref
33
+ elementRefs.set(element, ref)
34
+ refElements.set(ref, new WeakRef(element))
35
+ return ref
36
+ }
37
+
38
+ export function bumpRefGeneration(): void {
39
+ generation += 1
40
+ refElements = new Map()
41
+ }
42
+
43
+ export function refGeneration(): number {
44
+ return generation
45
+ }
46
+
47
+ function error(code: BrustError['code'], message: string, hint?: string): ErrorResult {
48
+ return { ok: false, error: { code, message, ...(hint ? { hint } : {}) } }
49
+ }
50
+
51
+ export function resolveTarget(
52
+ target: ActionTarget,
53
+ root: ParentNode = document,
54
+ ): Element | ErrorResult {
55
+ if (typeof target !== 'string' || target.length === 0) {
56
+ return error('bad-input', 'target must be a non-empty ref or CSS selector')
57
+ }
58
+ if (/^e\d+$/.test(target)) {
59
+ const element = refElements.get(target as Ref)?.deref()
60
+ if (!element?.isConnected) {
61
+ return error('stale-ref', `ref ${target} is no longer valid`, 're-run Brust.struct()')
62
+ }
63
+ return element
64
+ }
65
+ let matches: Element[]
66
+ try {
67
+ matches = Array.from(root.querySelectorAll(target))
68
+ } catch {
69
+ return error('bad-input', `invalid CSS selector: ${target}`)
70
+ }
71
+ if (matches.length === 0) return error('not-found', `no element matches selector: ${target}`)
72
+ if (matches.length > 1) {
73
+ return error('ambiguous', `selector matches ${matches.length} elements: ${target}`)
74
+ }
75
+ return matches[0]!
76
+ }
77
+
78
+ // Invalidate snapshot refs as soon as a navigation begins. A failed navigation
79
+ // may leave the same DOM in place, but the public contract deliberately makes
80
+ // every navigation attempt a generation boundary.
81
+ onBeforeNavigate(() => bumpRefGeneration())
82
+
83
+ export function __resetRefsForTest(): void {
84
+ bumpRefGeneration()
85
+ generation = 0
86
+ nextRef = 1
87
+ }
@@ -0,0 +1,168 @@
1
+ import { getNavState, type NavPhase } from '../navigation/store.ts'
2
+ import { mintRef, resolveTarget, type ErrorResult, type Ref } from './refs.ts'
3
+
4
+ export interface Field {
5
+ ref: Ref
6
+ name: string
7
+ type: string
8
+ value: string | null
9
+ required: boolean
10
+ options?: Array<{ value: string; label: string }>
11
+ }
12
+
13
+ export interface PageStruct {
14
+ url: string
15
+ path: string
16
+ title: string
17
+ shellId: string
18
+ nav: { phase: NavPhase }
19
+ outline: Array<{ level: number; text: string }>
20
+ links: Array<{ ref: Ref; href: string; text: string; external: boolean; current: boolean }>
21
+ buttons: Array<{
22
+ ref: Ref
23
+ text: string
24
+ disabled: boolean
25
+ kind: 'button' | 'submit' | 'x-on-click'
26
+ }>
27
+ forms: Array<{
28
+ ref: Ref
29
+ name: string | null
30
+ action: string | null
31
+ method: string
32
+ fields: Field[]
33
+ }>
34
+ inputs: Field[]
35
+ islands: Array<{ ref: Ref; name: string; hydrated: boolean }>
36
+ behaviors: Array<{ ref: Ref; name: string }>
37
+ }
38
+
39
+ export interface StructOptions {
40
+ within?: Ref | string
41
+ maxText?: number
42
+ }
43
+
44
+ type FieldElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
45
+
46
+ function isIgnored(element: Element): boolean {
47
+ return element.closest('[data-ai-ignore]') !== null
48
+ }
49
+
50
+ function all<T extends Element>(root: ParentNode, selector: string): T[] {
51
+ const found = Array.from(root.querySelectorAll<T>(selector))
52
+ if (root instanceof Element && root.matches(selector)) found.unshift(root as T)
53
+ return found.filter((element) => !isIgnored(element))
54
+ }
55
+
56
+ function label(element: Element, maxText: number): string {
57
+ const raw =
58
+ element.getAttribute('aria-label') ?? element.getAttribute('title') ?? element.textContent ?? ''
59
+ const text = raw.replace(/\s+/g, ' ').trim()
60
+ return text.length > maxText ? text.slice(0, maxText) : text
61
+ }
62
+
63
+ function field(element: FieldElement): Field {
64
+ const redacted =
65
+ (element instanceof HTMLInputElement && element.type === 'password') ||
66
+ element.hasAttribute('data-ai-redact')
67
+ const value = redacted
68
+ ? null
69
+ : element instanceof HTMLSelectElement && element.multiple
70
+ ? Array.from(element.selectedOptions)
71
+ .map((option) => option.value)
72
+ .join(',')
73
+ : element.value
74
+ const options =
75
+ element instanceof HTMLSelectElement
76
+ ? Array.from(element.options).map((option) => ({ value: option.value, label: option.text }))
77
+ : undefined
78
+ return {
79
+ ref: mintRef(element),
80
+ name: element.name || element.getAttribute('x-model') || '',
81
+ type:
82
+ element instanceof HTMLInputElement
83
+ ? element.type
84
+ : element instanceof HTMLSelectElement
85
+ ? 'select'
86
+ : 'textarea',
87
+ value,
88
+ required: element.required,
89
+ ...(options ? { options } : {}),
90
+ }
91
+ }
92
+
93
+ export async function struct(options: StructOptions = {}): Promise<PageStruct | ErrorResult> {
94
+ let root: ParentNode = document
95
+ if (options.within) {
96
+ const resolved = resolveTarget(options.within)
97
+ if (!(resolved instanceof Element)) return resolved
98
+ root = resolved
99
+ }
100
+ const maxText = Math.max(0, options.maxText ?? 200)
101
+ const origin = location.origin
102
+ const forms = all<HTMLFormElement>(root, 'form')
103
+ const links = all<HTMLAnchorElement>(root, 'a[href]').map((anchor) => {
104
+ const url = new URL(anchor.href, location.href)
105
+ return {
106
+ ref: mintRef(anchor),
107
+ href: anchor.href,
108
+ text: label(anchor, maxText),
109
+ external: url.origin !== origin,
110
+ current: anchor.getAttribute('aria-current') === 'page',
111
+ }
112
+ })
113
+ const buttonElements = all<HTMLElement>(
114
+ root,
115
+ 'button, input[type="button"], input[type="submit"], [x-on-click]',
116
+ )
117
+ const buttons = [...new Set(buttonElements)].map((button) => ({
118
+ ref: mintRef(button),
119
+ text:
120
+ button instanceof HTMLInputElement
121
+ ? button.value || label(button, maxText)
122
+ : label(button, maxText),
123
+ disabled: 'disabled' in button && Boolean((button as HTMLButtonElement).disabled),
124
+ kind: ((button.matches('button') && (button as HTMLButtonElement).type === 'submit') ||
125
+ button.matches('input[type="submit"]')
126
+ ? 'submit'
127
+ : button.matches('button, input[type="button"]')
128
+ ? 'button'
129
+ : 'x-on-click') as 'button' | 'submit' | 'x-on-click',
130
+ }))
131
+ const standalone = all<FieldElement>(root, 'input, textarea, select').filter(
132
+ (element) => !element.form,
133
+ )
134
+ return {
135
+ url: location.href,
136
+ path: location.pathname,
137
+ title: document.title,
138
+ shellId: document.querySelector('meta[name="brust-shell"]')?.getAttribute('content') ?? '',
139
+ nav: { phase: getNavState().phase },
140
+ outline: all<HTMLHeadingElement>(root, 'h1, h2, h3').map((heading) => ({
141
+ level: Number(heading.tagName.slice(1)),
142
+ text: label(heading, maxText),
143
+ })),
144
+ links,
145
+ buttons,
146
+ forms: forms.map((formElement) => ({
147
+ ref: mintRef(formElement),
148
+ name:
149
+ formElement.getAttribute('data-ai-name') ||
150
+ formElement.getAttribute('name') ||
151
+ formElement.id ||
152
+ null,
153
+ action: formElement.getAttribute('action'),
154
+ method: (formElement.getAttribute('method') || 'get').toLowerCase(),
155
+ fields: all<FieldElement>(formElement, 'input, textarea, select').map(field),
156
+ })),
157
+ inputs: standalone.map(field),
158
+ islands: all<HTMLElement>(root, '[data-brust-island]').map((island) => ({
159
+ ref: mintRef(island),
160
+ name: island.getAttribute('data-brust-island') ?? '',
161
+ hydrated: island.hasAttribute('data-brust-hydrated'),
162
+ })),
163
+ behaviors: all<HTMLElement>(root, '[x-data]').map((behavior) => ({
164
+ ref: mintRef(behavior),
165
+ name: behavior.getAttribute('x-data') ?? '',
166
+ })),
167
+ }
168
+ }
@@ -4,6 +4,7 @@ import { createRequire } from 'node:module'
4
4
  import { tmpdir } from 'node:os'
5
5
  import path, { isAbsolute, resolve } from 'node:path'
6
6
  import type { BunPlugin } from 'bun'
7
+ import { extractAiManifest, writeManifest as writeAiManifest } from '../ai/manifest.ts'
7
8
  import { emitNativeTemplates } from './native-routes-emit.ts'
8
9
  import { nativeShimPlugin } from './native-shim-plugin.ts'
9
10
 
@@ -146,6 +147,7 @@ export interface ParsedArgs {
146
147
  ssg: boolean // --ssg — prerender static routes after the build
147
148
  ssgOut: string | null // --ssg-out value (absolute); null → <outDir>/static computed later
148
149
  generatorVersion: boolean // false ⇔ --no-generator-version (name-only generator tag)
150
+ ai: boolean // --ai — enable the AI runtime + document injection
149
151
  }
150
152
 
151
153
  /** Parse `brust build` argv. Pure (no fs access, no process.exit) so it's
@@ -157,6 +159,7 @@ export function parseArgs(args: string[]): ParsedArgs {
157
159
  let ssg = false
158
160
  let ssgOut: string | undefined
159
161
  let generatorVersion = true
162
+ let ai = false
160
163
 
161
164
  for (let i = 0; i < args.length; i++) {
162
165
  const a = args[i]
@@ -181,6 +184,8 @@ export function parseArgs(args: string[]): ParsedArgs {
181
184
  ssg = true
182
185
  } else if (a === '--no-generator-version') {
183
186
  generatorVersion = false
187
+ } else if (a === '--ai') {
188
+ ai = true
184
189
  } else if (a === '--ssg-out') {
185
190
  ssgOut = args[++i]
186
191
  if (!ssgOut) {
@@ -218,7 +223,23 @@ export function parseArgs(args: string[]): ParsedArgs {
218
223
  }
219
224
  const ssgOutPath = ssgOut ? (isAbsolute(ssgOut) ? ssgOut : resolve(cwd, ssgOut)) : null
220
225
 
221
- return { entry: entryPath, outDir: outPath, target, ssg, ssgOut: ssgOutPath, generatorVersion }
226
+ return {
227
+ entry: entryPath,
228
+ outDir: outPath,
229
+ target,
230
+ ssg,
231
+ ssgOut: ssgOutPath,
232
+ generatorVersion,
233
+ ai,
234
+ }
235
+ }
236
+
237
+ export function buildBanner(aiEnabled: boolean): string {
238
+ return (
239
+ `process.env.BRUST_PREBUILT = '1';\n` +
240
+ `process.env.BRUST_DIST_DIR = import.meta.dir;\n` +
241
+ (aiEnabled ? `process.env.BRUST_AI ??= '1';\n` : '')
242
+ )
222
243
  }
223
244
 
224
245
  export async function runBuild(args: string[]): Promise<void> {
@@ -230,6 +251,8 @@ export async function runBuild(args: string[]): Promise<void> {
230
251
  process.exit(1)
231
252
  }
232
253
  const { entry, outDir, target } = parsed
254
+ const aiEnabled = parsed.ai || process.env.BRUST_AI === '1'
255
+ if (aiEnabled) process.env.BRUST_AI = '1'
233
256
 
234
257
  // Entry existence is a runBuild concern (parseArgs stays fs-free/pure).
235
258
  if (!existsSync(entry)) {
@@ -319,6 +342,22 @@ export async function runBuild(args: string[]): Promise<void> {
319
342
  loadedRoutes = routes
320
343
  }
321
344
 
345
+ // 2.85. AI manifest — extract the route metadata alongside the MCP manifest
346
+ // and persist it in both dist/ and cwd/.brust/ so dev/source and prebuilt
347
+ // runtime paths can read the same artifact.
348
+ {
349
+ const routes = (loadedRoutes ?? []) as Parameters<typeof extractAiManifest>[0]
350
+ const manifest = extractAiManifest(routes)
351
+ const manifestPath = path.join(outDir, '.brust', 'ai-manifest.json')
352
+ await writeAiManifest(outDir, manifest)
353
+ console.log(`[brust build] ai: ${manifest.pages.length} page(s) → ${manifestPath}`)
354
+ const localManifestDir = path.join(process.cwd(), '.brust')
355
+ if (path.resolve(localManifestDir) !== path.resolve(outDir)) {
356
+ await mkdir(localManifestDir, { recursive: true })
357
+ await writeAiManifest(localManifestDir, manifest)
358
+ }
359
+ }
360
+
322
361
  // 2.9. md routes — emit `Md_*.jinja` into <outDir>/jinja BEFORE the island
323
362
  // build so islands used only from md content join the chunk scan, and write
324
363
  // the frozen `md-manifest.json` next to BOTH jinja dirs (dist root for the
@@ -350,6 +389,7 @@ export async function runBuild(args: string[]): Promise<void> {
350
389
  flatRoutes: loadedRoutes,
351
390
  outDir: jinjaDir,
352
391
  withDevClient: false,
392
+ aiEnabled,
353
393
  manifestDirs: [outDir, path.join(process.cwd(), '.brust')],
354
394
  // Build is fatal on a deleted md file — a silent skip would ship a dist
355
395
  // with the route registered but its template missing (dev paths default
@@ -376,7 +416,7 @@ export async function runBuild(args: string[]): Promise<void> {
376
416
  ((loadedRoutes ?? []) as { chain?: Array<{ ssg?: { fallback?: string } }> }[]).some(
377
417
  (r) => r.chain?.at(-1)?.ssg?.fallback === 'client',
378
418
  )
379
- if (islandMap.size > 0 || needsFallbackRuntime) {
419
+ if (islandMap.size > 0 || needsFallbackRuntime || aiEnabled) {
380
420
  const islandsOutDir = path.join(outDir, 'islands')
381
421
  const result = await buildIslands(islandMap, {
382
422
  outDir: islandsOutDir,
@@ -532,8 +572,7 @@ export async function runBuild(args: string[]): Promise<void> {
532
572
  // actions codegen: `defineActions(...)` actions register via the app entry's
533
573
  // `import { actions } from './actions'` → `brust.run({ actions })` path, which
534
574
  // the bundled entry already carries.
535
- const banner =
536
- `process.env.BRUST_PREBUILT = '1';\n` + `process.env.BRUST_DIST_DIR = import.meta.dir;\n`
575
+ const banner = buildBanner(aiEnabled)
537
576
 
538
577
  const result = await Bun.build({
539
578
  entrypoints: [entry],
@@ -11,12 +11,14 @@ interface ParsedArgs {
11
11
  entry: string
12
12
  port: number | undefined
13
13
  generatorVersion: boolean
14
+ ai: boolean
14
15
  }
15
16
 
16
17
  function parseArgs(args: string[]): ParsedArgs {
17
18
  let entry: string | undefined
18
19
  let port: number | undefined
19
20
  let generatorVersion = true
21
+ let ai = false
20
22
  for (let i = 0; i < args.length; i++) {
21
23
  const a = args[i]
22
24
  if (a === '--port') {
@@ -32,6 +34,8 @@ function parseArgs(args: string[]): ParsedArgs {
32
34
  }
33
35
  } else if (a === '--no-generator-version') {
34
36
  generatorVersion = false
37
+ } else if (a === '--ai') {
38
+ ai = true
35
39
  } else if (a.startsWith('--port=')) {
36
40
  port = parseInt(a.slice('--port='.length), 10)
37
41
  } else if (a.startsWith('-')) {
@@ -54,12 +58,13 @@ function parseArgs(args: string[]): ParsedArgs {
54
58
  console.error(`brust dev: no entry file at ${entryPath}; pass a path or create ./index.ts`)
55
59
  process.exit(1)
56
60
  }
57
- return { entry: entryPath, port, generatorVersion }
61
+ return { entry: entryPath, port, generatorVersion, ai }
58
62
  }
59
63
 
60
64
  export async function runDev(args: string[]): Promise<void> {
61
65
  const { entry, port, generatorVersion } = parseArgs(args)
62
66
  process.env.BRUST_DEV = '1'
67
+ process.env.BRUST_AI = '1'
63
68
  if (port !== undefined) process.env.BRUST_PORT = String(port)
64
69
 
65
70
  // Bake the generator decision BEFORE the first emit — emitters and the boot
@@ -65,6 +65,10 @@ export const COMMANDS: CommandDef[] = [
65
65
  flag: '--no-generator-version',
66
66
  desc: 'Drop the version from the generator meta tag + X-Powered-By header (the name stays)',
67
67
  },
68
+ {
69
+ flag: '--ai',
70
+ desc: 'Enable the AI runtime chunk + manifest wiring in production builds',
71
+ },
68
72
  ],
69
73
  notes: [
70
74
  'Markdown pages: routes mounted with mdRoutes(<contentDir>) compile to native',
@@ -83,6 +87,10 @@ export const COMMANDS: CommandDef[] = [
83
87
  flag: '--no-generator-version',
84
88
  desc: 'Drop the version from the generator meta tag + X-Powered-By header (the name stays)',
85
89
  },
90
+ {
91
+ flag: '--ai',
92
+ desc: 'Enable the AI runtime wiring explicitly (dev already enables it)',
93
+ },
86
94
  ],
87
95
  },
88
96
  {
@@ -3,7 +3,12 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync
3
3
  import { createRequire } from 'node:module'
4
4
  import { dirname, relative, resolve } from 'node:path'
5
5
  import { buildDevClientTag } from '../dev/client.ts'
6
- import { insertGeneratorMeta, insertShellMeta, resolveGenerator } from '../generator.ts'
6
+ import {
7
+ aiScriptTag,
8
+ insertGeneratorMeta,
9
+ insertShellMeta,
10
+ resolveGenerator,
11
+ } from '../generator.ts'
7
12
  import { islandChunkBasename } from '../islands/chunk-id.ts'
8
13
  import { emitBehaviorSsrModule } from '../islands/behavior-ssr-loader.ts'
9
14
  import { DIRECTIVES_BOOTSTRAP, ISLANDS_IMPORTMAP_AND_BOOTSTRAP } from '../islands/importmap.ts'
@@ -254,15 +259,32 @@ export function countMainTags(template: string): number {
254
259
  *
255
260
  * Exported for the md emit step (runtime/md/emit.ts), which bakes the same tag
256
261
  * under its `withDevClient` option — md pages render Rust-side too, so without
257
- * it they never auto-reload in dev. */
262
+ * it they never auto-reload in dev.
263
+ *
264
+ * The AI runtime script is injected here as well when BRUST_AI=1. The tag is
265
+ * document-only: the compiler emits a head anchor for full documents, while
266
+ * fragment templates (no head) are left unchanged. */
258
267
  export function injectDevClientIntoTemplate(template: string): string {
259
- const tag = buildDevClientTag()
260
- if (template.includes(tag)) return template // idempotent across re-emits
261
- const headClose = template.indexOf('</head>')
262
- if (headClose !== -1) {
263
- return template.slice(0, headClose) + tag + template.slice(headClose)
268
+ const devTag = buildDevClientTag()
269
+ let out = template
270
+ if (!out.includes(devTag)) {
271
+ const headClose = out.indexOf('</head>')
272
+ if (headClose !== -1) {
273
+ out = out.slice(0, headClose) + devTag + out.slice(headClose)
274
+ } else {
275
+ out += devTag
276
+ }
264
277
  }
265
- return template + tag
278
+ if (process.env.BRUST_AI === '1') {
279
+ const tag = aiScriptTag()
280
+ if (!out.includes(tag)) {
281
+ const headClose = out.indexOf('</head>')
282
+ if (headClose !== -1) {
283
+ out = out.slice(0, headClose) + tag + out.slice(headClose)
284
+ }
285
+ }
286
+ }
287
+ return out
266
288
  }
267
289
 
268
290
  /** Bake the directive runtime loader into a native template iff it uses any
@@ -981,7 +1003,9 @@ export async function emitNativeTemplates(opts: NativeRouteEmitOpts): Promise<Na
981
1003
  // carry the identical signature. Empty/missing → no-op.
982
1004
  const withShell = insertShellMeta(withGenerator, r.shellId ?? '')
983
1005
  const template =
984
- process.env.BRUST_DEV === '1' ? injectDevClientIntoTemplate(withShell) : withShell
1006
+ process.env.BRUST_DEV === '1' || process.env.BRUST_AI === '1'
1007
+ ? injectDevClientIntoTemplate(withShell)
1008
+ : withShell
985
1009
  writeFileSync(outPath, template)
986
1010
  built.push(name)
987
1011
  stats.compiled++
package/runtime/config.ts CHANGED
@@ -14,6 +14,8 @@ export interface BrustConfig {
14
14
  cacheMaxEntries?: number
15
15
  /** L2 page-cache capacity (entries). Undefined → Rust default of 1000. */
16
16
  cachePageMaxEntries?: number
17
+ /** AI runtime toggle from BRUST_AI. Dev mode enables it separately. */
18
+ ai?: boolean
17
19
  /** R9 cross-process cache invalidation: redis/dragonfly URL. Absent →
18
20
  * feature disabled (current single-process behavior). */
19
21
  cacheSyncUrl?: string
@@ -89,6 +91,7 @@ export async function loadConfig(
89
91
  host,
90
92
  port,
91
93
  workers,
94
+ ai: process.env.BRUST_AI === '1',
92
95
  cacheMaxEntries: fromToml.cacheMaxEntries,
93
96
  cachePageMaxEntries: fromToml.cachePageMaxEntries,
94
97
  cacheSyncUrl: fromEnv.cacheSyncUrl ?? fromToml.cacheSyncUrl,
@@ -15,6 +15,20 @@ export interface GeneratorStrings {
15
15
  header: string
16
16
  }
17
17
 
18
+ /** Full browser entry tag for the AI runtime chunk. */
19
+ export function aiScriptTag(): string {
20
+ return '<script type="module" src="/_brust/ai.js"></script>'
21
+ }
22
+
23
+ /** Insert the AI runtime tag immediately before the first `</head>`.
24
+ * Document-only: fragment templates with no head are left unchanged. */
25
+ export function injectAiScriptIntoTemplate(template: string): string {
26
+ const at = template.indexOf('</head>')
27
+ if (at === -1) return template
28
+ const tag = aiScriptTag()
29
+ return template.slice(0, at) + tag + template.slice(at)
30
+ }
31
+
18
32
  /** Build the resolved strings. Version comes from the brustjs package.json
19
33
  * (readVersion never throws — "unknown" degrades to name-only, never a crash).
20
34
  * The version is sanitized to attr/header-safe bytes; semver chars only. */