@christianirack/brainllm-runtime 0.0.0-stage → 0.3.1
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/LICENSE +21 -0
- package/README.md +4 -2
- package/brain-ai.js +129 -0
- package/brain-anim.js +21 -0
- package/brain-ch.js +254 -0
- package/brain-consent.js +38 -0
- package/brain-data.js +549 -0
- package/brain-dev.js +100 -0
- package/brain-env.js +160 -0
- package/brain-err.js +242 -0
- package/brain-form.js +275 -0
- package/brain-guard.js +242 -0
- package/brain-hydrate.js +72 -0
- package/brain-i18n.js +111 -0
- package/brain-inspect.js +745 -0
- package/brain-island.js +106 -0
- package/brain-lazy.js +88 -0
- package/brain-mcp.js +73 -0
- package/brain-mock.js +80 -0
- package/brain-overlay.js +123 -0
- package/brain-persist.js +65 -0
- package/brain-router.js +503 -0
- package/brain-rpc.js +20 -0
- package/brain-schema.js +93 -0
- package/brain-wc.js +69 -0
- package/brain-why.js +198 -0
- package/brain-worker.js +66 -0
- package/brain.js +421 -0
- package/package.json +20 -4
- package/server/server.mjs +1046 -0
- package/shim/dom.js +448 -0
- package/shim/probe.js +111 -0
- package/sw/brain-sw.js +103 -0
- package/themes/contrast.cssbrain +39 -0
- package/themes/dark.cssbrain +39 -0
- package/themes/glass.cssbrain +39 -0
- package/themes/paper.cssbrain +39 -0
- package/ui/button.brain +31 -0
- package/ui/center.brain +11 -0
- package/ui/checkbox.brain +13 -0
- package/ui/container.brain +11 -0
- package/ui/dialog.brain +29 -0
- package/ui/error-state.brain +53 -0
- package/ui/field.brain +34 -0
- package/ui/grid.brain +11 -0
- package/ui/input.brain +23 -0
- package/ui/lang-switch.brain +38 -0
- package/ui/menu.brain +64 -0
- package/ui/row.brain +12 -0
- package/ui/select.brain +29 -0
- package/ui/sidebar.brain +17 -0
- package/ui/spacer.brain +12 -0
- package/ui/stack.brain +10 -0
- package/ui/table.brain +73 -0
- package/ui/tabs.brain +33 -0
- package/ui/toast.brain +45 -0
package/brain-router.js
ADDED
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
// BRAINLLM client router (INTERNALS §19). Views are prerendered pages, so navigating = fetch the next page, swap its
|
|
2
|
+
// region (`main` by default), title and SEO meta, inside a View Transition when available. Islands keep working: their
|
|
3
|
+
// resume listeners are delegated on document; islands swapped out are disposed (`$d`, set by the core's resume).
|
|
4
|
+
// Navigation API (Baseline 2026) intercepts link navigations and back/forward and restores scroll and
|
|
5
|
+
// focus; without it, a click listener + History API do the same. Any failure falls back to a full page load.
|
|
6
|
+
// Page contract (dev server and build): the region holds the view and the script that mounts it, which stores the
|
|
7
|
+
// dispose on the region (`e.$d = mount(V, e, p)`); the router disposes it before swapping and runs the new one.
|
|
8
|
+
// Usage (app entry, emitted by the toolchain): `router()` · options: { region, skip(url) → bool, parse(html) → doc,
|
|
9
|
+
// guards }.
|
|
10
|
+
// Guards (INTERNALS §22): `guards: { '/admin': ctx => … }` or `guards: { '/admin': '/_b/<h>.js' }`, one per folder with
|
|
11
|
+
// `_guard.jsbrain` (the build emits the chunk URL; the router imports it the first time it is needed). Before navigating, every guard whose folder contains
|
|
12
|
+
// the target runs, outer to inner, with ctx = { url, path, query }. It allows by returning nothing/true, cancels with
|
|
13
|
+
// `false` and redirects with `redirect('/login')` (thrown or returned) or a string. The server runs the same guard,
|
|
14
|
+
// so a redirect it answers (fetch ends on another URL) is reflected in the address bar. A guard that throws anything
|
|
15
|
+
// else → full page load: the server decides.
|
|
16
|
+
const G = globalThis
|
|
17
|
+
// Base path (<meta name="brain-base" content="/app/">, §23 #148): links in the HTML already carry it; guards and
|
|
18
|
+
// routes work with app paths (strip), and app paths given to go()/navigate()/redirect() get it (app). '/' when absent.
|
|
19
|
+
const base = () => {
|
|
20
|
+
const m = G.document && document.querySelector('meta[name=brain-base]')
|
|
21
|
+
return (m && m.getAttribute('content')) || '/'
|
|
22
|
+
}
|
|
23
|
+
const strip = p => {
|
|
24
|
+
const b = base()
|
|
25
|
+
return b !== '/' && (p + '/').startsWith(b) ? '/' + p.slice(b.length) : p
|
|
26
|
+
}
|
|
27
|
+
const app = u => {
|
|
28
|
+
const b = base()
|
|
29
|
+
return typeof u === 'string' && b !== '/' && u.startsWith('/') && !u.startsWith('//') && !(u + '/').startsWith(b) ? b + u.slice(1) : u
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// navigate('/account', { replace }) (§41.5): an app path (the base is added), through the running router (no reload,
|
|
33
|
+
// guards run); without a router it is a normal page load.
|
|
34
|
+
let active = null
|
|
35
|
+
export function navigate(to, o = {}) {
|
|
36
|
+
const u = app(String(to))
|
|
37
|
+
if (active) return active.go(u, o.replace ? 'replace' : true)
|
|
38
|
+
o.replace ? location.replace(u) : location.assign(u)
|
|
39
|
+
}
|
|
40
|
+
G.__brainNavigate = navigate // brain-form follows a server function's { redirect } without importing the router
|
|
41
|
+
|
|
42
|
+
// route() lives in brain-env.js (it needs the core's signals; a page without islands downloads only this router):
|
|
43
|
+
// after every client navigation the router calls the hook it installs.
|
|
44
|
+
const routed = () => G.__brainRouted && G.__brainRouted()
|
|
45
|
+
|
|
46
|
+
// Active links (§41.5): every internal <a href> to the current page gets aria-current="page" (the CSS hook is
|
|
47
|
+
// [aria-current=page]); the others lose it. On start and after every client navigation.
|
|
48
|
+
const current = () => {
|
|
49
|
+
if (!G.document || !G.location) return
|
|
50
|
+
for (const a of document.querySelectorAll('a[href]')) {
|
|
51
|
+
let u
|
|
52
|
+
try {
|
|
53
|
+
u = new URL(a.getAttribute('href'), location.href)
|
|
54
|
+
} catch {
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
if (u.origin === location.origin && u.pathname.replace(/\/$/, '') === location.pathname.replace(/\/$/, '')) a.setAttribute('aria-current', 'page')
|
|
58
|
+
else if (a.getAttribute('aria-current') === 'page') a.removeAttribute('aria-current')
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// A form() with unsaved changes (brain-form keeps them in globalThis.__brainDirty) asks before leaving (§41.5); one
|
|
63
|
+
// being submitted does not (#299): its submit() may navigate on success before it returns
|
|
64
|
+
const leave = () => {
|
|
65
|
+
for (const f of G.__brainDirty || []) if (f.dirty && !f.busy) return !G.confirm || G.confirm('Leave this page? Changes you made will not be saved.')
|
|
66
|
+
return true
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Same shapes as server.mjs, so one _guard.jsbrain works on both sides: { redirect, status } and { deny }
|
|
70
|
+
const R = Symbol('redirect') // older compiled guards
|
|
71
|
+
export function redirect(url, status = 303) {
|
|
72
|
+
throw { redirect: String(url), status }
|
|
73
|
+
}
|
|
74
|
+
export const deny = (reason = 'Forbidden') => ({ deny: String(reason) })
|
|
75
|
+
// guard result → true (go on) · false (cancel) · string (redirect) · null (full load: the server decides, e.g. 403)
|
|
76
|
+
const outcome = v =>
|
|
77
|
+
v && typeof v === 'object'
|
|
78
|
+
? R in v
|
|
79
|
+
? v[R]
|
|
80
|
+
: typeof v.redirect === 'string'
|
|
81
|
+
? v.redirect
|
|
82
|
+
: v.deny !== undefined
|
|
83
|
+
? null
|
|
84
|
+
: true
|
|
85
|
+
: v === false || typeof v === 'string'
|
|
86
|
+
? v
|
|
87
|
+
: true
|
|
88
|
+
|
|
89
|
+
export function router(o = {}) {
|
|
90
|
+
const region = o.region || 'main'
|
|
91
|
+
const parse = o.parse || (h => new DOMParser().parseFromString(h, 'text/html'))
|
|
92
|
+
let seq = 0, through = null, stale = false
|
|
93
|
+
const ahead = predict(o, u => same(u))
|
|
94
|
+
// Build signature (§35.7): the page's <meta name="brain-build">. A navigation response from another build, or the
|
|
95
|
+
// service worker announcing one, turns client navigation into full loads so old and new code never mix.
|
|
96
|
+
const build = () => {
|
|
97
|
+
const m = document.querySelector('meta[name=brain-build]')
|
|
98
|
+
return m ? m.getAttribute('content') : null
|
|
99
|
+
}
|
|
100
|
+
const sw = G.navigator && navigator.serviceWorker
|
|
101
|
+
const onMsg = e => typeof e.data === 'string' && e.data.startsWith('brain:build ') && build() && e.data.slice(12) !== build() && (stale = true)
|
|
102
|
+
if (sw && sw.addEventListener) sw.addEventListener('message', onMsg)
|
|
103
|
+
// a guard is a function, or the URL of its `_guard.jsbrain` chunk (BUILD #220): loaded the first time a navigation
|
|
104
|
+
// reaches that folder, then cached by the module map; its default export runs with the same ctx
|
|
105
|
+
const gs = Object.entries(o.guards || {})
|
|
106
|
+
.map(([pre, g]) => [pre, typeof g === 'string' ? c => import(g).then(m => m.default(c)) : g])
|
|
107
|
+
.sort((a, b) => a[0].length - b[0].length)
|
|
108
|
+
current()
|
|
109
|
+
// → true (go on) · false (cancel) · string (redirect) · null (guard failed: full load)
|
|
110
|
+
async function check(u) {
|
|
111
|
+
const url = new URL(u, location.href), path = strip(url.pathname) // guards see app paths (no base)
|
|
112
|
+
for (const [pre, g] of gs) {
|
|
113
|
+
if (!(path === pre || path.startsWith(pre.endsWith('/') ? pre : pre + '/'))) continue
|
|
114
|
+
// The browser has no session (the cookie is HttpOnly): a guard that reads ctx.user cannot decide here. If it would
|
|
115
|
+
// stop the navigation, the server decides on a full load instead (F12b: `ctx.user ? true : redirect('/login')`
|
|
116
|
+
// used to send a signed-in user to /login on every client navigation).
|
|
117
|
+
let v, asked = false
|
|
118
|
+
const ctx = { url, path, query: Object.fromEntries(url.searchParams) }
|
|
119
|
+
Object.defineProperty(ctx, 'user', { enumerable: true, get: () => ((asked = true), undefined) })
|
|
120
|
+
try {
|
|
121
|
+
v = await g(ctx)
|
|
122
|
+
} catch (e) {
|
|
123
|
+
if (asked) return null
|
|
124
|
+
return e && typeof e === 'object' && (R in e || 'redirect' in e) ? outcome(e) : null
|
|
125
|
+
}
|
|
126
|
+
v = outcome(v)
|
|
127
|
+
if (v !== true) return asked ? null : v
|
|
128
|
+
}
|
|
129
|
+
return true
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// _loading.brain (#235): when the next page takes longer than 150 ms, the nearest loading view of the target route
|
|
133
|
+
// (`loading: [{ rx, view }]`, most specific first, emitted by the build with the `core` URL) is mounted in the
|
|
134
|
+
// region until the page arrives; fast navigations never show it, so nothing flickers.
|
|
135
|
+
const loads = o.loading || []
|
|
136
|
+
function waiting(url, my) {
|
|
137
|
+
const path = strip(new URL(url, location.href).pathname), l = loads.find(x => new RegExp(x.rx).test(path))
|
|
138
|
+
const w = { done: false, t: 0 }
|
|
139
|
+
if (!l || !o.core) return w
|
|
140
|
+
w.t = setTimeout(() => {
|
|
141
|
+
Promise.all([import(o.core), import(l.view)])
|
|
142
|
+
.then(([c, v]) => {
|
|
143
|
+
const el = document.querySelector(region), mount = c.m || c.mount
|
|
144
|
+
if (w.done || my !== seq || !el || !mount) return // the page arrived first
|
|
145
|
+
for (const e of [el, ...walkAll(el)]) if (e.$d) e.$d(), (e.$d = null)
|
|
146
|
+
for (const n of [...el.childNodes]) if (!/^(STYLE|LINK)$/.test(n.nodeName)) n.remove() // the view's CSS stays (BUILD #35)
|
|
147
|
+
el.$d = mount(v.default, el, {})
|
|
148
|
+
})
|
|
149
|
+
.catch(() => {})
|
|
150
|
+
}, 150)
|
|
151
|
+
return w
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function load(url) {
|
|
155
|
+
const my = ++seq
|
|
156
|
+
let doc, r
|
|
157
|
+
if (stale) return hard(url)
|
|
158
|
+
const slow = waiting(url, my)
|
|
159
|
+
// the previous route's background requests (prefetch) are dropped by brain-data's queue (§41.4)
|
|
160
|
+
try {
|
|
161
|
+
G.dispatchEvent && G.dispatchEvent(new Event('brain:navigate'))
|
|
162
|
+
} catch {}
|
|
163
|
+
try {
|
|
164
|
+
r = (await ahead.take(url)) || (await fetch(url, { headers: { 'x-brain-nav': '1' } }))
|
|
165
|
+
const b = r.headers && r.headers.get && r.headers.get('x-brain-build')
|
|
166
|
+
if (b && build() && b !== build()) return void (my === seq && hard(url)) // a new deploy: load it whole
|
|
167
|
+
const ct = (r.headers && r.headers.get && r.headers.get('content-type')) || 'text/html'
|
|
168
|
+
if (!r.ok || !/html/.test(ct)) throw Error(r.status)
|
|
169
|
+
doc = parse(await r.text())
|
|
170
|
+
} catch {
|
|
171
|
+
return void ((slow.done = true), clearTimeout(slow.t), my === seq && hard(url))
|
|
172
|
+
}
|
|
173
|
+
slow.done = true
|
|
174
|
+
clearTimeout(slow.t)
|
|
175
|
+
if (my !== seq) return // a newer navigation won
|
|
176
|
+
// another language (§37): a full load brings its own dictionaries, `lang` and `dir`; locales never mix in memory
|
|
177
|
+
const lang = d => d && d.documentElement && d.documentElement.getAttribute('lang')
|
|
178
|
+
if (lang(doc) && lang(document) && lang(doc) !== lang(document)) return hard(url)
|
|
179
|
+
if (r.redirected && r.url && G.history && r.url !== new URL(url, location.href).href) history.replaceState(history.state, '', r.url)
|
|
180
|
+
const cur = document.querySelector(region), next = doc.querySelector(region)
|
|
181
|
+
if (!cur || !next) return hard(url)
|
|
182
|
+
const swap = () => {
|
|
183
|
+
// read before the swap: afterwards the fetched page's scripts are in the document too
|
|
184
|
+
const own = document.querySelector('script[nonce]'), nonce = own && (own.nonce || own.getAttribute('nonce'))
|
|
185
|
+
for (const el of [cur, ...walkAll(cur)]) if (el.$d) el.$d(), (el.$d = null)
|
|
186
|
+
cur.replaceChildren(...[...next.childNodes].map(n => (document.adoptNode ? document.adoptNode(n) : n)))
|
|
187
|
+
// Scripts parsed by DOMParser never run: the region's own scripts (the view's mount, `e.$d = mount(...)`) are
|
|
188
|
+
// recreated so they execute. Already-imported modules come from the module map: their state survives.
|
|
189
|
+
// The page's CSP only trusts its own nonce: a recreated script carries the current page's nonce, never the one
|
|
190
|
+
// that came with the fetched page (each server response has a fresh one).
|
|
191
|
+
for (const sc of cur.querySelectorAll('script')) {
|
|
192
|
+
if (sc.type && !/^(module|text\/javascript)$/.test(sc.type)) continue
|
|
193
|
+
const n = document.createElement('script')
|
|
194
|
+
for (const a of sc.attributes) if (a.name !== 'nonce') n.setAttribute(a.name, a.value)
|
|
195
|
+
if (nonce) (n.nonce = nonce), n.setAttribute('nonce', nonce)
|
|
196
|
+
n.textContent = sc.textContent
|
|
197
|
+
sc.replaceWith(n)
|
|
198
|
+
}
|
|
199
|
+
// QA #291: the view's mount script stores its dispose (`e.$d = mount(...)`) after the swap, since module scripts
|
|
200
|
+
// run later; until then the region shows the server's HTML with no handlers. Clicks and submits there are held and
|
|
201
|
+
// replayed on the mounted view, by position, so the first click after a navigation is never lost.
|
|
202
|
+
if (!cur.$d && [...cur.querySelectorAll('script[type=module]')].some(sc => /\$d\s*=/.test(sc.textContent))) hold(cur)
|
|
203
|
+
for (const a of next.attributes || []) cur.setAttribute(a.name, a.value)
|
|
204
|
+
if (doc.title) document.title = doc.title
|
|
205
|
+
for (const s of ['meta[name=description]', 'link[rel=canonical]']) {
|
|
206
|
+
const a = document.head && document.head.querySelector(s), b = doc.querySelector(s)
|
|
207
|
+
if (a && b) a.replaceWith(document.adoptNode ? document.adoptNode(b) : b)
|
|
208
|
+
}
|
|
209
|
+
// SEO (§32): og:*, twitter:*, robots and JSON-LD follow the page; the region's own tags stay where they are
|
|
210
|
+
const tags = (r, skip) =>
|
|
211
|
+
[...((r && r.querySelectorAll && r.querySelectorAll('meta,script')) || [])].filter(e => {
|
|
212
|
+
for (let p = e.parentNode; p; p = p.parentNode) if (p === skip) return false
|
|
213
|
+
const k = e.getAttribute('property') || e.getAttribute('name') || ''
|
|
214
|
+
return e.tagName === 'SCRIPT' ? e.getAttribute('type') === 'application/ld+json' : /^(og:|twitter:|robots$)/.test(k)
|
|
215
|
+
})
|
|
216
|
+
if (document.head) {
|
|
217
|
+
for (const e of tags(document.head)) e.remove()
|
|
218
|
+
for (const e of tags(doc.head || doc, next)) document.head.appendChild(document.adoptNode ? document.adoptNode(e) : e)
|
|
219
|
+
}
|
|
220
|
+
// the next page's route params (or none): route() reads them
|
|
221
|
+
const pa = document.head && document.head.querySelector('meta[name=brain-params]'), pb = doc.querySelector('meta[name=brain-params]')
|
|
222
|
+
if (pa) pa.remove()
|
|
223
|
+
if (pb && document.head) document.head.appendChild(document.adoptNode ? document.adoptNode(pb) : pb)
|
|
224
|
+
routed()
|
|
225
|
+
current()
|
|
226
|
+
ahead.scan()
|
|
227
|
+
}
|
|
228
|
+
if (document.startViewTransition) {
|
|
229
|
+
const vt = document.startViewTransition(swap)
|
|
230
|
+
vt.ready.catch(() => {}) // rejects when a faster navigation skips this transition: expected, not an error
|
|
231
|
+
await vt.updateCallbackDone
|
|
232
|
+
} else swap()
|
|
233
|
+
}
|
|
234
|
+
// a full load must not be intercepted again (Navigation API): `through` lets that one navigation pass
|
|
235
|
+
const hard = url => ((through = new URL(url, location.href).href), (o.hard || (u => location.assign(u)))(url))
|
|
236
|
+
const same = u => {
|
|
237
|
+
try {
|
|
238
|
+
const x = new URL(u, location.href), b = base()
|
|
239
|
+
// same origin and inside the app's base path (§23 #148); anything else is a full load
|
|
240
|
+
return x.origin === location.origin && (x.pathname + '/').startsWith(b) && !(o.skip && o.skip(x))
|
|
241
|
+
} catch {
|
|
242
|
+
return false
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const nav = G.navigation
|
|
247
|
+
if (nav && nav.addEventListener) {
|
|
248
|
+
const h = e => {
|
|
249
|
+
if (through && e.destination.url === through) return void (through = null)
|
|
250
|
+
if (e.navigationType === 'reload') return // a reload is always a real full load (e.g. after a deploy deleted a chunk)
|
|
251
|
+
if (!e.canIntercept || e.hashChange || e.downloadRequest || e.formData || !same(e.destination.url)) return
|
|
252
|
+
if (e.cancelable && !leave()) return e.preventDefault()
|
|
253
|
+
const to = e.destination.url, from = nav.currentEntry && nav.currentEntry.url
|
|
254
|
+
if (!gs.length) return e.intercept({ handler: () => load(to) }) // scroll + focus reset handled by the browser
|
|
255
|
+
let g
|
|
256
|
+
e.intercept({
|
|
257
|
+
// Chrome 141+: decided before the URL commits (cancel = the address bar never changes; native redirect).
|
|
258
|
+
async precommitHandler(c) {
|
|
259
|
+
g = await check(to)
|
|
260
|
+
if (typeof g === 'string' && c.redirect) c.redirect(new URL(app(g), location.href).href), (g = true)
|
|
261
|
+
else if (g === false) throw new DOMException('guard', 'AbortError')
|
|
262
|
+
},
|
|
263
|
+
async handler() {
|
|
264
|
+
if (g === undefined) g = await check(to) // no precommit support: the URL already committed
|
|
265
|
+
if (g === true) return load(nav.currentEntry ? nav.currentEntry.url : to)
|
|
266
|
+
if (g === null) return hard(to)
|
|
267
|
+
nav.navigate(g === false ? from : new URL(app(g), location.href).href, { history: 'replace' })
|
|
268
|
+
},
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
nav.addEventListener('navigate', h)
|
|
272
|
+
ahead.scan()
|
|
273
|
+
return (active = {
|
|
274
|
+
go: (u, how) => nav.navigate(app(u), how === 'replace' ? { history: 'replace' } : undefined),
|
|
275
|
+
check,
|
|
276
|
+
stop: () => ((active = null), ahead.stop(), nav.removeEventListener('navigate', h), sw && sw.removeEventListener && sw.removeEventListener('message', onMsg)),
|
|
277
|
+
})
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Fallback: History API. Scroll position is kept in history.state so back/forward restores it.
|
|
281
|
+
const save = () => history.replaceState({ ...(history.state || {}), y: G.scrollY || 0 }, '')
|
|
282
|
+
let here = location.href
|
|
283
|
+
const go = async (u, push = true, hops = 0) => {
|
|
284
|
+
if (!hops && !leave()) return push || history.pushState(history.state, '', here) // stay (back/forward: restore the URL)
|
|
285
|
+
const g = gs.length ? await check(u) : true
|
|
286
|
+
if (g === null || hops > 5) return hard(u)
|
|
287
|
+
if (g === false) return push || history.pushState(history.state, '', here) // back/forward blocked: stay
|
|
288
|
+
if (g !== true) return go(new URL(app(g), location.href).href, push, hops + 1)
|
|
289
|
+
if (push) save(), history[push === 'replace' ? 'replaceState' : 'pushState']({ y: 0 }, '', u) // push: true | 'replace' | false (back/forward)
|
|
290
|
+
await load(u)
|
|
291
|
+
here = location.href
|
|
292
|
+
const hash = new URL(u, location.href).hash, t = hash && document.getElementById(hash.slice(1))
|
|
293
|
+
if (t) t.scrollIntoView()
|
|
294
|
+
else G.scrollTo && G.scrollTo(0, push ? 0 : (history.state && history.state.y) || 0)
|
|
295
|
+
focusMain()
|
|
296
|
+
}
|
|
297
|
+
const click = e => {
|
|
298
|
+
if (e.defaultPrevented || e.button || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
|
|
299
|
+
let a = e.target
|
|
300
|
+
while (a && a.tagName !== 'A') a = a.parentNode
|
|
301
|
+
if (!a || !a.getAttribute('href') || a.getAttribute('target') || a.hasAttribute('download') || !same(a.href || a.getAttribute('href'))) return
|
|
302
|
+
const u = new URL(a.href || a.getAttribute('href'), location.href)
|
|
303
|
+
if (u.pathname === location.pathname && u.hash) return // in-page anchor: native
|
|
304
|
+
e.preventDefault()
|
|
305
|
+
go(u.href)
|
|
306
|
+
}
|
|
307
|
+
const pop = () => go(location.href, false)
|
|
308
|
+
const focusMain = () => {
|
|
309
|
+
const m = document.querySelector(region), h = m && (m.querySelector('h1') || m)
|
|
310
|
+
if (h && h.focus) {
|
|
311
|
+
if (!h.hasAttribute('tabindex')) h.setAttribute('tabindex', '-1')
|
|
312
|
+
h.focus({ preventScroll: true }) // screen readers announce the new view
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
document.addEventListener('click', click)
|
|
316
|
+
G.addEventListener && G.addEventListener('popstate', pop)
|
|
317
|
+
ahead.scan()
|
|
318
|
+
return (active = {
|
|
319
|
+
go: (u, push = true) => go(app(u), push),
|
|
320
|
+
check,
|
|
321
|
+
stop: () => ((active = null), ahead.stop(), document.removeEventListener('click', click), G.removeEventListener && G.removeEventListener('popstate', pop)),
|
|
322
|
+
})
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Predictive prefetch (§19, 2026-10-29): no dead time during a visit. Every internal link that scrolls into view is
|
|
326
|
+
// fetched in the background while the browser is idle (at most 2 at a time, 12 per page), and hovering, focusing or
|
|
327
|
+
// touching a link moves it to the front of the queue. The fetched page is kept in memory and the next navigation to it
|
|
328
|
+
// swaps without waiting for the network; its module chunks get `<link rel=modulepreload>` so the view mounts at once.
|
|
329
|
+
// The current view never waits: requests run at low priority and only when idle. Skipped: Save-Data and 2G (only
|
|
330
|
+
// intent prefetches there, and never with Save-Data), `data-prefetch="false"` on the link or an ancestor, download or
|
|
331
|
+
// target links, files (`.pdf`, `.zip`…), `/api/` and `/auth/`, action-style paths (log out, unsubscribe, confirm,
|
|
332
|
+
// verify), URLs carrying a token in the query, and everything with `router({ prefetch: false })` or
|
|
333
|
+
// `<meta name="brain-prefetch" content="off">`. A page served `private`/`no-store` is reused for 30 s, others for 5 min,
|
|
334
|
+
// and any write empties the memory first: a form submit, a `brain:write` (brain-data writes, every server function
|
|
335
|
+
// call, log-out included), so the next view never shows data from before the write or another session. Each fetch says
|
|
336
|
+
// `x-brain-prefetch: 1` (`ctx.prefetch` in the server's `load`), so a server can tell it apart from a click.
|
|
337
|
+
const FRESH = 300000, FRESH_PRIVATE = 30000, MAX_PAGE = 12, MAX_KEPT = 24, PARALLEL = 2
|
|
338
|
+
const NOPE = /(^|\/)(log-?out|sign-?out|logoff|unsubscribe|confirm|verify|api|auth)(\.html?)?(\/|$)/i
|
|
339
|
+
const FILE = /\.(?!html?$)[a-z0-9]{2,5}$/i
|
|
340
|
+
const SECRET = /(^|[?&])(token|code|key|sig|signature|auth|otp|state)=/i
|
|
341
|
+
export function predict(o = {}, same = () => false) {
|
|
342
|
+
const off = { scan() {}, stop() {}, take: async () => null }
|
|
343
|
+
const meta = G.document && document.querySelector && document.querySelector('meta[name=brain-prefetch]')
|
|
344
|
+
if (o.prefetch === false || (meta && /^(off|false|0)$/i.test(meta.getAttribute('content') || '')) || !G.document || !G.fetch) return off
|
|
345
|
+
const net = G.navigator && navigator.connection
|
|
346
|
+
const thin = () => !!net && (net.saveData || /(^|-)2g$/.test(net.effectiveType || ''))
|
|
347
|
+
const cache = new Map(), queue = [], seen = new WeakSet(), pre = new Set()
|
|
348
|
+
let busy = 0, count = 0, io = null, idle = 0
|
|
349
|
+
const key = h => {
|
|
350
|
+
try {
|
|
351
|
+
const u = new URL(h, location.href)
|
|
352
|
+
u.hash = ''
|
|
353
|
+
return u.href
|
|
354
|
+
} catch {
|
|
355
|
+
return null
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
const ok = a => {
|
|
359
|
+
if (!a || !a.getAttribute || !a.getAttribute('href') || a.getAttribute('target') || a.hasAttribute('download')) return null
|
|
360
|
+
for (let n = a; n && n.getAttribute; n = n.parentNode) if (n.getAttribute('data-prefetch') === 'false') return null
|
|
361
|
+
const k = key(a.href || a.getAttribute('href'))
|
|
362
|
+
if (!k || !same(k) || k === key(location.href)) return null
|
|
363
|
+
const u = new URL(k)
|
|
364
|
+
if (NOPE.test(u.pathname) || FILE.test(u.pathname) || SECRET.test(u.search)) return null
|
|
365
|
+
return k
|
|
366
|
+
}
|
|
367
|
+
// the next page's chunks: module scripts and static imports in its HTML
|
|
368
|
+
const chunks = html => {
|
|
369
|
+
if (!document.head) return
|
|
370
|
+
const re = /<script[^>]+type="module"[^>]+src="([^"]+)"|\bfrom\s*"([^"]+\.js)"|\bimport\(\s*"([^"]+\.js)"\s*\)/g
|
|
371
|
+
let m, n = 0
|
|
372
|
+
while ((m = re.exec(html)) && n < 8) {
|
|
373
|
+
const u = key(m[1] || m[2] || m[3])
|
|
374
|
+
if (!u || pre.has(u) || !same(u)) continue
|
|
375
|
+
pre.add(u), n++
|
|
376
|
+
const l = document.createElement('link')
|
|
377
|
+
l.setAttribute('rel', 'modulepreload')
|
|
378
|
+
l.setAttribute('href', u)
|
|
379
|
+
document.head.appendChild(l)
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
// drop what expired, then the oldest past MAX_KEPT (a Map keeps insertion order)
|
|
383
|
+
const prune = () => {
|
|
384
|
+
const now = Date.now()
|
|
385
|
+
for (const [k, c] of cache) if (now - c.at > FRESH) cache.delete(k)
|
|
386
|
+
for (const k of cache.keys()) if (cache.size >= MAX_KEPT) cache.delete(k)
|
|
387
|
+
}
|
|
388
|
+
const get = k => {
|
|
389
|
+
prune()
|
|
390
|
+
const at = Date.now()
|
|
391
|
+
const p = fetch(k, { headers: { 'x-brain-nav': '1', 'x-brain-prefetch': '1' }, priority: 'low', credentials: 'same-origin' })
|
|
392
|
+
.then(async r => {
|
|
393
|
+
const ct = (r.headers && r.headers.get && r.headers.get('content-type')) || 'text/html'
|
|
394
|
+
if (!r.ok || !/html/.test(ct)) return null
|
|
395
|
+
const text = await r.text()
|
|
396
|
+
chunks(text)
|
|
397
|
+
const cc = (r.headers.get && r.headers.get('cache-control')) || ''
|
|
398
|
+
// a stand-in for the Response that load() reads: headers, status, redirect and the body once per use
|
|
399
|
+
return { ok: r.ok, status: r.status, redirected: r.redirected, url: r.url, headers: r.headers, text: async () => text, ttl: /private|no-store/i.test(cc) ? FRESH_PRIVATE : FRESH }
|
|
400
|
+
})
|
|
401
|
+
.catch(() => null)
|
|
402
|
+
cache.set(k, { at, p })
|
|
403
|
+
return p
|
|
404
|
+
}
|
|
405
|
+
const pump = () => {
|
|
406
|
+
while (busy < PARALLEL && queue.length) {
|
|
407
|
+
const k = queue.shift()
|
|
408
|
+
if (cache.has(k)) continue
|
|
409
|
+
busy++
|
|
410
|
+
get(k).finally(() => (busy--, pump()))
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
const later = f => (G.requestIdleCallback ? G.requestIdleCallback(f, { timeout: 2000 }) : setTimeout(f, 200))
|
|
414
|
+
const want = (k, now) => {
|
|
415
|
+
if (!k || cache.has(k)) return
|
|
416
|
+
const i = queue.indexOf(k)
|
|
417
|
+
if (i >= 0) queue.splice(i, 1)
|
|
418
|
+
if (now) queue.unshift(k), pump()
|
|
419
|
+
else if (count < MAX_PAGE) count++, queue.push(k), idle || (idle = 1) && later(() => ((idle = 0), pump()))
|
|
420
|
+
}
|
|
421
|
+
const intent = e => {
|
|
422
|
+
if (net && net.saveData) return
|
|
423
|
+
let a = e.target
|
|
424
|
+
while (a && a.tagName !== 'A') a = a.parentNode
|
|
425
|
+
want(ok(a), true)
|
|
426
|
+
}
|
|
427
|
+
const evs = ['pointerover', 'focusin', 'touchstart']
|
|
428
|
+
for (const t of evs) document.addEventListener(t, intent, { passive: true, capture: true })
|
|
429
|
+
// a write makes every fetched page possibly stale (or another session's after log-out): forget them all
|
|
430
|
+
const forget = () => ((queue.length = 0), cache.clear())
|
|
431
|
+
document.addEventListener('submit', forget, { capture: true })
|
|
432
|
+
if (G.addEventListener) G.addEventListener('brain:write', forget)
|
|
433
|
+
if (G.IntersectionObserver)
|
|
434
|
+
io = new IntersectionObserver(
|
|
435
|
+
es => {
|
|
436
|
+
for (const e of es) if (e.isIntersecting) io.unobserve(e.target), thin() || want(ok(e.target), false)
|
|
437
|
+
},
|
|
438
|
+
{ rootMargin: '200px' }
|
|
439
|
+
)
|
|
440
|
+
return {
|
|
441
|
+
scan() {
|
|
442
|
+
count = 0
|
|
443
|
+
queue.length = 0 // the previous view's links are no longer the likely next step
|
|
444
|
+
if (!io) return
|
|
445
|
+
for (const a of document.querySelectorAll('a[href]')) if (!seen.has(a)) seen.add(a), io.observe(a)
|
|
446
|
+
},
|
|
447
|
+
stop() {
|
|
448
|
+
io && io.disconnect()
|
|
449
|
+
for (const t of evs) document.removeEventListener(t, intent, { capture: true })
|
|
450
|
+
document.removeEventListener('submit', forget, { capture: true })
|
|
451
|
+
G.removeEventListener && G.removeEventListener('brain:write', forget)
|
|
452
|
+
cache.clear()
|
|
453
|
+
},
|
|
454
|
+
// the prefetched page for this URL when it is still fresh (used once: a later visit fetches again)
|
|
455
|
+
async take(u) {
|
|
456
|
+
const k = key(u), c = k && cache.get(k)
|
|
457
|
+
if (!c) return null
|
|
458
|
+
cache.delete(k)
|
|
459
|
+
const r = await c.p
|
|
460
|
+
return r && Date.now() - c.at < r.ttl ? r : null
|
|
461
|
+
},
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const HOLD = ['click', 'submit']
|
|
466
|
+
function hold(el) {
|
|
467
|
+
if (el.$hold) el.$hold() // a newer navigation drops what the previous page held
|
|
468
|
+
const q = []
|
|
469
|
+
const keep = e => {
|
|
470
|
+
const path = []
|
|
471
|
+
for (let n = e.target; n && n !== el; n = n.parentNode) path.unshift([].indexOf.call(n.parentNode.childNodes, n))
|
|
472
|
+
q.push([e, path])
|
|
473
|
+
e.preventDefault()
|
|
474
|
+
e.stopImmediatePropagation()
|
|
475
|
+
}
|
|
476
|
+
const done = () => {
|
|
477
|
+
clearTimeout(t)
|
|
478
|
+
for (const k of HOLD) el.removeEventListener(k, keep, true)
|
|
479
|
+
delete el.$d
|
|
480
|
+
el.$hold = null
|
|
481
|
+
}
|
|
482
|
+
// a page whose script never mounts (it failed to load) stops holding after 5 s: later clicks act on the HTML as is
|
|
483
|
+
const t = setTimeout(done, 5000)
|
|
484
|
+
for (const k of HOLD) el.addEventListener(k, keep, true)
|
|
485
|
+
el.$hold = done
|
|
486
|
+
Object.defineProperty(el, '$d', {
|
|
487
|
+
configurable: true,
|
|
488
|
+
get: () => undefined,
|
|
489
|
+
set(v) {
|
|
490
|
+
done()
|
|
491
|
+
el.$d = v
|
|
492
|
+
for (const [e, path] of q) {
|
|
493
|
+
let n = el
|
|
494
|
+
for (const i of path) n = n && n.childNodes[i]
|
|
495
|
+
if (n) n.dispatchEvent(new e.constructor(e.type, e))
|
|
496
|
+
}
|
|
497
|
+
},
|
|
498
|
+
})
|
|
499
|
+
}
|
|
500
|
+
function walkAll(n, out = []) {
|
|
501
|
+
for (const c of n.childNodes) if (c.nodeType === 1) out.push(c), walkAll(c, out)
|
|
502
|
+
return out
|
|
503
|
+
}
|
package/brain-rpc.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// BRAINLLM RPC client (INTERNALS §11.1), loaded by the core's `rpc()` on the first call (the core stays small: #226).
|
|
2
|
+
// Same-origin POST with an anti-CSRF header; returns the response JSON. A failure (network or status ≥400) is reported
|
|
3
|
+
// to the catcher and rethrown to the caller; 422 (validation, `{ errors: { field: msg } }` that form() marks) is
|
|
4
|
+
// expected, not reported. It never imports the core (that would be a hash cycle): the core hands it `oops` and the base.
|
|
5
|
+
export async function rpc(id, a, oops, base) {
|
|
6
|
+
let r, j
|
|
7
|
+
try {
|
|
8
|
+
r = await fetch(base + '_r/' + id, { method: 'POST', headers: { 'content-type': 'application/json', 'x-brain-rpc': '1' }, body: JSON.stringify(a) })
|
|
9
|
+
const t = await r.text()
|
|
10
|
+
j = t ? JSON.parse(t) : undefined
|
|
11
|
+
} catch (e) {
|
|
12
|
+
throw (oops(e, null, 'rpc'), e)
|
|
13
|
+
} finally {
|
|
14
|
+
// a server function may write or change the session: pages the router prefetched may be stale (§19)
|
|
15
|
+
if (globalThis.dispatchEvent && globalThis.Event) globalThis.dispatchEvent(new Event('brain:write'))
|
|
16
|
+
}
|
|
17
|
+
if (r.ok) return j
|
|
18
|
+
const e = Object.assign(Error((j && j.error) || 'rpc ' + id + ': ' + r.status), { code: 'rpc', status: r.status, id, errors: j && j.errors })
|
|
19
|
+
throw (r.status == 422 || oops(e, null, 'rpc'), e)
|
|
20
|
+
}
|
package/brain-schema.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// BRAINLLM schema mini-language (INTERNALS §14, §15): shared by form() (brain-form.js), api() validation
|
|
2
|
+
// (brain-data.js) and server.mjs, so a page that only fetches data does not load the form module.
|
|
3
|
+
// Per field: `str num bool email url date` + `!` required + `<=N >=N` (length for str and lists, value for num) + `[T]`
|
|
4
|
+
// list of T, or a nested schema object.
|
|
5
|
+
const RX_EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
6
|
+
const G = globalThis
|
|
7
|
+
|
|
8
|
+
// Validation messages (§14, §37): the app's dictionary wins when it has the key (`"form.required": "Obligatorio"` in
|
|
9
|
+
// i18n/<locale>.json; plural objects allowed for keys with `{n}`), else the English text. The table is read directly
|
|
10
|
+
// (globalThis.__brainDict, filled by brain-i18n.js `add`) so a page that only validates does not load brain-i18n.js.
|
|
11
|
+
// Keys: form.required form.list form.min_items form.max_items form.item form.number form.min form.max form.email
|
|
12
|
+
// form.url form.date form.min_chars form.max_chars.
|
|
13
|
+
export function msg(key, en, vars) {
|
|
14
|
+
const D = G.__brainDict
|
|
15
|
+
if (D) {
|
|
16
|
+
const h = G.document && G.document.documentElement
|
|
17
|
+
const loc = G.__brainLocale || (h && h.getAttribute('lang')) || 'en'
|
|
18
|
+
const def = (h && h.getAttribute('data-default-lang')) || 'en'
|
|
19
|
+
let m = (D[loc] && D[loc][key]) ?? (D[def] && D[def][key])
|
|
20
|
+
if (m != null) {
|
|
21
|
+
if (typeof m === 'object') {
|
|
22
|
+
const n = vars && vars.n
|
|
23
|
+
m = m['=' + n] ?? m[new Intl.PluralRules(loc).select(+n || 0)] ?? m.other ?? en
|
|
24
|
+
}
|
|
25
|
+
return vars ? String(m).replace(/\{(\w+)\}/g, (s, k) => (k in vars ? String(vars[k]) : s)) : String(m)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return en
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 'str!<=80' → { t: 'str', req: true, min, max, list: false }
|
|
32
|
+
export function rule(s) {
|
|
33
|
+
if (typeof s === 'object') return { t: 'obj', s, req: false }
|
|
34
|
+
const req = s.includes('!'), r = { t: '', req, list: false, min: null, max: null }
|
|
35
|
+
let x = s.replace(/!/g, '')
|
|
36
|
+
const m = /^(\[?)(\w+)\]?((?:[<>]=-?\d+(?:\.\d+)?)*)$/.exec(x)
|
|
37
|
+
if (!m) throw Error(`form: invalid rule '${s}' (use str num bool email url date, ! required, <=N >=N, [T])`)
|
|
38
|
+
r.list = !!m[1]
|
|
39
|
+
r.t = m[2]
|
|
40
|
+
for (const [, op, n] of m[3].matchAll(/([<>])=(-?\d+(?:\.\d+)?)/g)) r[op === '<' ? 'max' : 'min'] = +n
|
|
41
|
+
return r
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const empty = v => v == null || v === '' || (Array.isArray(v) && !v.length)
|
|
45
|
+
const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`
|
|
46
|
+
|
|
47
|
+
// Message for one value, or '' when valid.
|
|
48
|
+
export function check(r, v) {
|
|
49
|
+
if (r.list) {
|
|
50
|
+
if (empty(v)) return r.req || r.min > 0 ? (r.req && !r.min ? msg('form.required', 'Required') : msg('form.min_items', `Choose at least ${r.min}`, { n: r.min })) : ''
|
|
51
|
+
if (!Array.isArray(v)) return msg('form.list', 'Must be a list')
|
|
52
|
+
if (r.min != null && v.length < r.min) return msg('form.min_items', `Choose at least ${r.min}`, { n: r.min })
|
|
53
|
+
if (r.max != null && v.length > r.max) return msg('form.max_items', `At most ${plural(r.max, 'item')}`, { n: r.max })
|
|
54
|
+
const one = { ...r, list: false, req: true, min: null, max: null }
|
|
55
|
+
for (let i = 0; i < v.length; i++) {
|
|
56
|
+
const e = check(one, v[i])
|
|
57
|
+
if (e) return msg('form.item', `Item ${i + 1}: ${e}`, { n: i + 1, error: e })
|
|
58
|
+
}
|
|
59
|
+
return ''
|
|
60
|
+
}
|
|
61
|
+
if (r.t === 'bool') return r.req && v !== true ? msg('form.required', 'Required') : ''
|
|
62
|
+
if (empty(v)) return r.req ? msg('form.required', 'Required') : ''
|
|
63
|
+
const s = String(v)
|
|
64
|
+
switch (r.t) {
|
|
65
|
+
case 'num': {
|
|
66
|
+
const n = typeof v === 'number' ? v : s.trim() === '' ? NaN : Number(s)
|
|
67
|
+
if (!Number.isFinite(n)) return msg('form.number', 'Enter a number')
|
|
68
|
+
if (r.min != null && n < r.min) return msg('form.min', `Must be at least ${r.min}`, { n: r.min })
|
|
69
|
+
if (r.max != null && n > r.max) return msg('form.max', `Must be at most ${r.max}`, { n: r.max })
|
|
70
|
+
return ''
|
|
71
|
+
}
|
|
72
|
+
case 'email':
|
|
73
|
+
if (!RX_EMAIL.test(s)) return msg('form.email', 'Enter a valid email')
|
|
74
|
+
break
|
|
75
|
+
case 'url':
|
|
76
|
+
try {
|
|
77
|
+
if (!/^https?:$/.test(new URL(s).protocol)) return msg('form.url', 'Enter a valid URL')
|
|
78
|
+
} catch {
|
|
79
|
+
return msg('form.url', 'Enter a valid URL')
|
|
80
|
+
}
|
|
81
|
+
break
|
|
82
|
+
case 'date':
|
|
83
|
+
if (Number.isNaN(Date.parse(s))) return msg('form.date', 'Enter a valid date')
|
|
84
|
+
return ''
|
|
85
|
+
case 'str':
|
|
86
|
+
break
|
|
87
|
+
default:
|
|
88
|
+
throw Error(`form: unknown type '${r.t}'`)
|
|
89
|
+
}
|
|
90
|
+
if (r.min != null && s.length < r.min) return msg('form.min_chars', `At least ${plural(r.min, 'character')}`, { n: r.min })
|
|
91
|
+
if (r.max != null && s.length > r.max) return msg('form.max_chars', `At most ${plural(r.max, 'character')}`, { n: r.max })
|
|
92
|
+
return ''
|
|
93
|
+
}
|