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.
- package/README.md +6 -0
- package/package.json +7 -7
- package/runtime/ai/actions.ts +271 -0
- package/runtime/ai/index.ts +90 -0
- package/runtime/ai/manifest.ts +68 -0
- package/runtime/ai/navigate.ts +157 -0
- package/runtime/ai/pages.ts +49 -0
- package/runtime/ai/refs.ts +87 -0
- package/runtime/ai/struct.ts +168 -0
- package/runtime/cli/build.ts +43 -4
- package/runtime/cli/dev.ts +6 -1
- package/runtime/cli/help.ts +8 -0
- package/runtime/cli/native-routes-emit.ts +33 -9
- package/runtime/config.ts +3 -0
- package/runtime/generator.ts +14 -0
- package/runtime/index.js +52 -52
- package/runtime/index.ts +88 -3
- package/runtime/islands/build.ts +42 -1
- package/runtime/md/emit.ts +5 -1
- package/runtime/render/inject-ai-client.ts +38 -0
- package/runtime/render/stream.ts +9 -3
- package/types/ai/manifest.d.ts +17 -0
- package/types/cli/native-routes-emit.d.ts +5 -1
- package/types/config.d.ts +2 -0
- package/types/generator.d.ts +5 -0
- package/types/index.d.ts +2 -0
- package/types/islands/build.d.ts +9 -0
- package/types/md/emit.d.ts +3 -0
- package/types/render/inject-ai-client.d.ts +6 -0
|
@@ -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
|
+
}
|
package/runtime/cli/build.ts
CHANGED
|
@@ -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 {
|
|
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],
|
package/runtime/cli/dev.ts
CHANGED
|
@@ -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
|
package/runtime/cli/help.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
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'
|
|
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,
|
package/runtime/generator.ts
CHANGED
|
@@ -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. */
|