@christianirack/brainllm-runtime 0.1.0 → 0.4.0

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/brain-router.js CHANGED
@@ -13,6 +13,7 @@
13
13
  // `false` and redirects with `redirect('/login')` (thrown or returned) or a string. The server runs the same guard,
14
14
  // so a redirect it answers (fetch ends on another URL) is reflected in the address bar. A guard that throws anything
15
15
  // else → full page load: the server decides.
16
+ import { prefetchPage } from './brain-prefetch-shared.js'
16
17
  const G = globalThis
17
18
  // Base path (<meta name="brain-base" content="/app/">, §23 #148): links in the HTML already carry it; guards and
18
19
  // routes work with app paths (strip), and app paths given to go()/navigate()/redirect() get it (app). '/' when absent.
@@ -33,7 +34,9 @@ const app = u => {
33
34
  // guards run); without a router it is a normal page load.
34
35
  let active = null
35
36
  export function navigate(to, o = {}) {
36
- const u = app(String(to))
37
+ const raw = String(to)
38
+ if (/[\\\x00-\x20\x7f]/.test(raw)) throw Error('Invalid navigation target')
39
+ const u = app(raw)
37
40
  if (active) return active.go(u, o.replace ? 'replace' : true)
38
41
  o.replace ? location.replace(u) : location.assign(u)
39
42
  }
@@ -89,7 +92,9 @@ const outcome = v =>
89
92
  export function router(o = {}) {
90
93
  const region = o.region || 'main'
91
94
  const parse = o.parse || (h => new DOMParser().parseFromString(h, 'text/html'))
95
+ const fullLoad = new Set(Array.isArray(o.fullLoad) ? o.fullLoad.map(p => { try { return new URL(p, location.href).pathname.replace(/\/+$/, '') || '/' } catch { return null } }).filter(Boolean) : [])
92
96
  let seq = 0, through = null, stale = false
97
+ const ahead = predict(o, u => same(u, true))
93
98
  // Build signature (§35.7): the page's <meta name="brain-build">. A navigation response from another build, or the
94
99
  // service worker announcing one, turns client navigation into full loads so old and new code never mix.
95
100
  const build = () => {
@@ -97,7 +102,7 @@ export function router(o = {}) {
97
102
  return m ? m.getAttribute('content') : null
98
103
  }
99
104
  const sw = G.navigator && navigator.serviceWorker
100
- const onMsg = e => typeof e.data === 'string' && e.data.startsWith('brain:build ') && build() && e.data.slice(12) !== build() && (stale = true)
105
+ const onMsg = e => typeof e.data === 'string' && e.data.startsWith('brain:build ') && build() && e.data.slice(12) !== build() && (stale = true, ahead.stop())
101
106
  if (sw && sw.addEventListener) sw.addEventListener('message', onMsg)
102
107
  // a guard is a function, or the URL of its `_guard.jsbrain` chunk (BUILD #220): loaded the first time a navigation
103
108
  // reaches that folder, then cached by the module map; its default export runs with the same ctx
@@ -110,24 +115,30 @@ export function router(o = {}) {
110
115
  const url = new URL(u, location.href), path = strip(url.pathname) // guards see app paths (no base)
111
116
  for (const [pre, g] of gs) {
112
117
  if (!(path === pre || path.startsWith(pre.endsWith('/') ? pre : pre + '/'))) continue
113
- let v
118
+ // The browser has no session (the cookie is HttpOnly): a guard that reads ctx.user cannot decide here. If it would
119
+ // stop the navigation, the server decides on a full load instead (F12b: `ctx.user ? true : redirect('/login')`
120
+ // used to send a signed-in user to /login on every client navigation).
121
+ let v, asked = false
122
+ const ctx = { url, path, query: Object.fromEntries(url.searchParams) }
123
+ Object.defineProperty(ctx, 'user', { enumerable: true, get: () => ((asked = true), undefined) })
114
124
  try {
115
- v = await g({ url, path, query: Object.fromEntries(url.searchParams) })
125
+ v = await g(ctx)
116
126
  } catch (e) {
127
+ if (asked) return null
117
128
  return e && typeof e === 'object' && (R in e || 'redirect' in e) ? outcome(e) : null
118
129
  }
119
130
  v = outcome(v)
120
- if (v !== true) return v
131
+ if (v !== true) return asked ? null : v
121
132
  }
122
133
  return true
123
134
  }
124
135
 
125
136
  // _loading.brain (#235): when the next page takes longer than 150 ms, the nearest loading view of the target route
126
- // (`loading: [{ rx, view }]`, most specific first, emitted by the build with the `core` URL) is mounted in the
137
+ // (`loading: [{ rx: string | string[], view }]`, most specific first, emitted by the build with the `core` URL) is mounted in the
127
138
  // region until the page arrives; fast navigations never show it, so nothing flickers.
128
139
  const loads = o.loading || []
129
140
  function waiting(url, my) {
130
- const path = strip(new URL(url, location.href).pathname), l = loads.find(x => new RegExp(x.rx).test(path))
141
+ const path = strip(new URL(url, location.href).pathname), l = loads.find(x => (Array.isArray(x.rx) ? x.rx : [x.rx]).some(rx => new RegExp(rx).test(path)))
131
142
  const w = { done: false, t: 0 }
132
143
  if (!l || !o.core) return w
133
144
  w.t = setTimeout(() => {
@@ -154,7 +165,7 @@ export function router(o = {}) {
154
165
  G.dispatchEvent && G.dispatchEvent(new Event('brain:navigate'))
155
166
  } catch {}
156
167
  try {
157
- r = await fetch(url, { headers: { 'x-brain-nav': '1' } })
168
+ r = (await ahead.take(url)) || (await fetch(url, { headers: { 'x-brain-nav': '1' } }))
158
169
  const b = r.headers && r.headers.get && r.headers.get('x-brain-build')
159
170
  if (b && build() && b !== build()) return void (my === seq && hard(url)) // a new deploy: load it whole
160
171
  const ct = (r.headers && r.headers.get && r.headers.get('content-type')) || 'text/html'
@@ -216,6 +227,10 @@ export function router(o = {}) {
216
227
  if (pb && document.head) document.head.appendChild(document.adoptNode ? document.adoptNode(pb) : pb)
217
228
  routed()
218
229
  current()
230
+ const oldPlan = document.getElementById('brain-next'), newPlan = doc.querySelector('#brain-next')
231
+ if (oldPlan) oldPlan.remove()
232
+ if (newPlan) document.body.appendChild(document.adoptNode ? document.adoptNode(newPlan) : newPlan)
233
+ ahead.scan()
219
234
  }
220
235
  if (document.startViewTransition) {
221
236
  const vt = document.startViewTransition(swap)
@@ -225,11 +240,11 @@ export function router(o = {}) {
225
240
  }
226
241
  // a full load must not be intercepted again (Navigation API): `through` lets that one navigation pass
227
242
  const hard = url => ((through = new URL(url, location.href).href), (o.hard || (u => location.assign(u)))(url))
228
- const same = u => {
243
+ const same = (u, prefetch = false) => {
229
244
  try {
230
245
  const x = new URL(u, location.href), b = base()
231
246
  // same origin and inside the app's base path (§23 #148); anything else is a full load
232
- return x.origin === location.origin && (x.pathname + '/').startsWith(b) && !(o.skip && o.skip(x))
247
+ return x.origin === location.origin && (x.pathname + '/').startsWith(b) && !(o.skip && o.skip(x)) && (prefetch || !fullLoad.has(x.pathname.replace(/\/+$/, '') || '/'))
233
248
  } catch {
234
249
  return false
235
250
  }
@@ -243,6 +258,7 @@ export function router(o = {}) {
243
258
  if (!e.canIntercept || e.hashChange || e.downloadRequest || e.formData || !same(e.destination.url)) return
244
259
  if (e.cancelable && !leave()) return e.preventDefault()
245
260
  const to = e.destination.url, from = nav.currentEntry && nav.currentEntry.url
261
+ if (G.__brainURLOnly?.(from || location.href, to)) return e.intercept({ handler: () => G.__brainURLRestore?.() })
246
262
  if (!gs.length) return e.intercept({ handler: () => load(to) }) // scroll + focus reset handled by the browser
247
263
  let g
248
264
  e.intercept({
@@ -261,10 +277,11 @@ export function router(o = {}) {
261
277
  })
262
278
  }
263
279
  nav.addEventListener('navigate', h)
280
+ ahead.scan()
264
281
  return (active = {
265
282
  go: (u, how) => nav.navigate(app(u), how === 'replace' ? { history: 'replace' } : undefined),
266
283
  check,
267
- stop: () => ((active = null), nav.removeEventListener('navigate', h), sw && sw.removeEventListener && sw.removeEventListener('message', onMsg)),
284
+ stop: () => ((active = null), ahead.stop(), nav.removeEventListener('navigate', h), sw && sw.removeEventListener && sw.removeEventListener('message', onMsg)),
268
285
  })
269
286
  }
270
287
 
@@ -273,6 +290,7 @@ export function router(o = {}) {
273
290
  let here = location.href
274
291
  const go = async (u, push = true, hops = 0) => {
275
292
  if (!hops && !leave()) return push || history.pushState(history.state, '', here) // stay (back/forward: restore the URL)
293
+ if (!same(u)) return hard(u)
276
294
  const g = gs.length ? await check(u) : true
277
295
  if (g === null || hops > 5) return hard(u)
278
296
  if (g === false) return push || history.pushState(history.state, '', here) // back/forward blocked: stay
@@ -295,7 +313,10 @@ export function router(o = {}) {
295
313
  e.preventDefault()
296
314
  go(u.href)
297
315
  }
298
- const pop = () => go(location.href, false)
316
+ const pop = () => {
317
+ if (G.__brainURLOnly?.(here, location.href)) { here = location.href; G.__brainURLRestore?.(); return }
318
+ return go(location.href, false)
319
+ }
299
320
  const focusMain = () => {
300
321
  const m = document.querySelector(region), h = m && (m.querySelector('h1') || m)
301
322
  if (h && h.focus) {
@@ -305,13 +326,208 @@ export function router(o = {}) {
305
326
  }
306
327
  document.addEventListener('click', click)
307
328
  G.addEventListener && G.addEventListener('popstate', pop)
329
+ ahead.scan()
308
330
  return (active = {
309
331
  go: (u, push = true) => go(app(u), push),
310
332
  check,
311
- stop: () => ((active = null), document.removeEventListener('click', click), G.removeEventListener && G.removeEventListener('popstate', pop)),
333
+ stop: () => ((active = null), ahead.stop(), document.removeEventListener('click', click), G.removeEventListener && G.removeEventListener('popstate', pop)),
312
334
  })
313
335
  }
314
336
 
337
+ // Predictive prefetch (§19, 2026-10-29): no dead time during a visit. Every internal link that scrolls into view is
338
+ // fetched in the background while the browser is idle (at most 2 at a time, 12 per page), and hovering, focusing or
339
+ // touching a link moves it to the front of the queue. The fetched page is kept in memory and the next navigation to it
340
+ // swaps without waiting for the network; its module chunks get `<link rel=modulepreload>` so the view mounts at once.
341
+ // The current view never waits: requests run at low priority and only when idle. Skipped: Save-Data and 2G (only
342
+ // intent prefetches there, and never with Save-Data), `data-prefetch="false"` on the link or an ancestor, download or
343
+ // target links, files (`.pdf`, `.zip`…), `/api/` and `/auth/`, action-style paths (log out, unsubscribe, confirm,
344
+ // verify), URLs carrying a token in the query, and everything with `router({ prefetch: false })` or
345
+ // `<meta name="brain-prefetch" content="off">`. Private/no-store and dynamic no-cache HTML is never reused; static
346
+ // pages and explicitly cacheable public HTML stay in bounded tab memory for at most 5 min,
347
+ // and any write empties the memory first: a form submit, a `brain:write` (brain-data writes, every server function
348
+ // call, log-out included), so the next view never shows data from before the write or another session. Each fetch says
349
+ // `x-brain-prefetch: 1` (`ctx.prefetch` in the server's `load`), so a server can tell it apart from a click.
350
+ const FRESH = 300000, MAX_PAGE = 12, MAX_KEPT = 24, PARALLEL = 2
351
+ const NOPE = /(^|\/)(log-?out|sign-?out|logoff|unsubscribe|confirm|verify|api|auth|_brain|_r)(\.html?)?(\/|$)/i
352
+ const FILE = /\.(?!html?$)[a-z0-9]{2,5}$/i
353
+ const SECRET = /(^|[?&])(token|code|key|sig|signature|auth|otp|state)=/i
354
+ export function predict(o = {}, same = () => false) {
355
+ const off = { scan() {}, stop() {}, take: async () => null }
356
+ const meta = G.document && document.querySelector && document.querySelector('meta[name=brain-prefetch]')
357
+ if (o.prefetch === false || (meta && /^(off|false|0)$/i.test(meta.getAttribute('content') || '')) || !G.document || !G.fetch) return off
358
+ const net = G.navigator && navigator.connection
359
+ const thin = () => !!net && (net.saveData || /(^|-)2g$/.test(net.effectiveType || ''))
360
+ const cache = new Map(), queue = [], pre = new Map(), running = new Map(), pending = new Map(), planned = new Map()
361
+ let seen = new WeakSet(), busy = 0, count = 0, io = null, idle = 0, epoch = 0, dead = false, bytes = 0, worker = null, serial = 0
362
+ const key = h => {
363
+ try { const u = new URL(h, location.href); u.hash = ''; return u.href } catch { return null }
364
+ }
365
+ const safe = k => {
366
+ if (!k || !same(k) || k === key(location.href)) return false
367
+ const u = new URL(k)
368
+ return !NOPE.test(u.pathname) && !FILE.test(u.pathname) && !SECRET.test(u.search)
369
+ }
370
+ const ok = a => {
371
+ if (!a || !a.getAttribute || !a.getAttribute('href') || a.getAttribute('target') || a.hasAttribute('download')) return null
372
+ for (let n = a; n && n.getAttribute; n = n.parentNode) if (n.getAttribute('data-prefetch') === 'false') return null
373
+ const k = key(a.href || a.getAttribute('href'))
374
+ return safe(k) ? k : null
375
+ }
376
+ const warm = list => {
377
+ if (!document.head || dead) return
378
+ let n = 0
379
+ for (const href of list) {
380
+ const u = key(href)
381
+ if (!u || pre.has(u) || !same(u) || !new URL(u).pathname.includes('/_b/') || ++n > 8) continue
382
+ const l = document.createElement('link')
383
+ l.setAttribute('rel', /\.(js|jsbrain)(\?|$)/.test(u) ? 'modulepreload' : 'prefetch')
384
+ l.setAttribute('href', u)
385
+ l.setAttribute('fetchpriority', 'low')
386
+ document.head.appendChild(l)
387
+ pre.set(u, l)
388
+ while (pre.size > 48) { const [k, node] = pre.entries().next().value; node.remove(); pre.delete(k) }
389
+ }
390
+ }
391
+ const remove = k => { const c = cache.get(k); if (c) bytes -= c.bytes || 0; cache.delete(k) }
392
+ const prune = () => {
393
+ for (const [k, c] of cache) if (Date.now() - c.at > FRESH) remove(k)
394
+ while (cache.size > MAX_KEPT || bytes > 4 * 1024 * 1024) remove(cache.keys().next().value)
395
+ }
396
+ const crashed = () => {
397
+ worker && worker.terminate(); worker = null
398
+ for (const p of pending.values()) { clearTimeout(p.timer); p.resolve(null) }
399
+ pending.clear()
400
+ }
401
+ if (G.Worker && typeof o.prefetchWorker === 'string') {
402
+ try {
403
+ const url = new URL(o.prefetchWorker, location.href)
404
+ if (url.origin === location.origin) {
405
+ worker = new Worker(url.href, { type: 'module' })
406
+ worker.onmessage = e => {
407
+ const m = e.data, p = m && pending.get(m.id)
408
+ if (!p) return
409
+ clearTimeout(p.timer); pending.delete(m.id); p.resolve(m.ok ? m : null)
410
+ }
411
+ worker.onerror = crashed
412
+ worker.onmessageerror = crashed
413
+ }
414
+ } catch { crashed() }
415
+ }
416
+ const background = (k, controller) => {
417
+ if (worker) return new Promise(resolve => {
418
+ const id = ++serial
419
+ const done = () => { const p = pending.get(id); if (p) { clearTimeout(p.timer); pending.delete(id); resolve(null); worker && worker.postMessage({ id, cancel: true }) } }
420
+ const timer = setTimeout(done, 6500)
421
+ pending.set(id, { resolve, timer })
422
+ controller.signal.addEventListener('abort', done, { once: true })
423
+ try { worker.postMessage({ id, url: k }) } catch { done(); crashed() }
424
+ })
425
+ return prefetchPage(k, controller.signal)
426
+ }
427
+
428
+ const get = k => {
429
+ const own = epoch, controller = new AbortController(), at = Date.now()
430
+ running.set(k, controller)
431
+ const timer = setTimeout(() => controller.abort(), 6000)
432
+ const c = { at, epoch: own, bytes: 0, p: null }
433
+ c.p = background(k, controller).then(m => {
434
+ if (!m || dead || own !== epoch || controller.signal.aborted) { if (cache.get(k) === c) remove(k); return null }
435
+ const headers = Array.isArray(m.headers) ? new Headers(m.headers) : m.headers
436
+ const cc = headers.get('cache-control') || '', freshness = /(?:^|,)\s*(?:s-maxage|max-age)=(\d+)/i.exec(cc)
437
+ if (m.assets) warm(m.assets)
438
+ if (planned.has(k)) warm(planned.get(k))
439
+ // Personalized and dynamically revalidated HTML always goes back through the server on navigation.
440
+ // Speculation still warms its immutable code/assets; it never reuses another tab's prior session data.
441
+ if (/private|no-store/i.test(cc) || /no-cache/i.test(cc) && headers.get('x-brain-static') !== '1') { if (cache.get(k) === c) remove(k); return null }
442
+ // Transient page memory belongs to this tab and write epoch, never to CacheStorage or the worker.
443
+ c.bytes = m.text.length * 2
444
+ if (cache.get(k) === c) bytes += c.bytes
445
+ prune()
446
+ return { ok: m.ok, status: m.status, redirected: false, url: m.url, headers, text: async () => m.text, ttl: Math.min(FRESH, freshness ? +freshness[1] * 1000 : FRESH) }
447
+ }).catch(() => null).finally(() => { clearTimeout(timer); if (running.get(k) === controller) running.delete(k) })
448
+ cache.set(k, c); prune()
449
+ return c.p
450
+ }
451
+ const pump = () => {
452
+ if (dead || document.hidden || net && net.saveData) return
453
+ while (busy < PARALLEL && queue.length) {
454
+ const k = queue.shift()
455
+ if (cache.has(k)) continue
456
+ busy++
457
+ get(k).finally(() => { busy--; pump() })
458
+ }
459
+ }
460
+ const later = f => G.requestIdleCallback ? G.requestIdleCallback(f, { timeout: 2000 }) : setTimeout(f, 200)
461
+ const cancelIdle = () => { if (idle) { G.cancelIdleCallback ? G.cancelIdleCallback(idle) : clearTimeout(idle); idle = 0 } }
462
+ const want = (k, now) => {
463
+ if (!k || dead || cache.has(k) || count >= (now ? MAX_KEPT : MAX_PAGE)) return
464
+ const i = queue.indexOf(k)
465
+ if (i >= 0) queue.splice(i, 1); else count++
466
+ if (now) { queue.unshift(k); pump() }
467
+ else { queue.push(k); if (!idle) idle = later(() => { idle = 0; pump() }) }
468
+ }
469
+ const intent = e => {
470
+ if (net && net.saveData) return
471
+ let a = e.target
472
+ while (a && a.tagName !== 'A') a = a.parentNode
473
+ want(ok(a), true)
474
+ }
475
+ const evs = ['pointerover', 'focusin', 'touchstart']
476
+ for (const t of evs) document.addEventListener(t, intent, { passive: true, capture: true })
477
+ const forget = () => {
478
+ epoch++; cancelIdle(); queue.length = 0; cache.clear(); bytes = 0; count = 0
479
+ for (const c of running.values()) c.abort()
480
+ running.clear()
481
+ }
482
+ document.addEventListener('submit', forget, { capture: true })
483
+ if (G.addEventListener) G.addEventListener('brain:write', forget)
484
+ const visible = () => { if (!document.hidden) pump() }
485
+ document.addEventListener('visibilitychange', visible)
486
+ if (G.IntersectionObserver) io = new IntersectionObserver(es => {
487
+ for (const e of es) if (e.isIntersecting) { io.unobserve(e.target); if (!thin()) want(ok(e.target), false) }
488
+ }, { rootMargin: '200px' })
489
+ return {
490
+ scan() {
491
+ if (dead) return
492
+ count = 0; cancelIdle(); queue.length = 0; planned.clear(); seen = new WeakSet(); io && io.disconnect()
493
+ const anchors = document.querySelectorAll('a[href]'), allowed = new Set()
494
+ for (const a of anchors) { const k = ok(a); if (k) allowed.add(k); if (io && !seen.has(a)) { seen.add(a); io.observe(a) } }
495
+ // Compiler-generated graph, validated against the current DOM: hidden/opted-out links cannot sneak in.
496
+ const plan = document.getElementById && document.getElementById('brain-next')
497
+ if (plan && !thin()) {
498
+ try {
499
+ const map = JSON.parse(plan.textContent)
500
+ if (map.v === 1 && Array.isArray(map.next)) for (const item of map.next.slice(0, MAX_PAGE)) {
501
+ const k = key(item.url)
502
+ if (allowed.has(k) && safe(k)) { if (Array.isArray(item.assets)) planned.set(k, item.assets.slice(0, 16)); want(k, false) }
503
+ }
504
+ } catch {}
505
+ }
506
+ },
507
+ stop() {
508
+ if (dead) return
509
+ forget(); dead = true; crashed(); io && io.disconnect()
510
+ for (const t of evs) document.removeEventListener(t, intent, { capture: true })
511
+ document.removeEventListener('submit', forget, { capture: true })
512
+ document.removeEventListener('visibilitychange', visible)
513
+ G.removeEventListener && G.removeEventListener('brain:write', forget)
514
+ for (const l of pre.values()) l.remove()
515
+ pre.clear()
516
+ },
517
+ async take(u) {
518
+ const k = key(u), c = k && cache.get(k)
519
+ if (!c) return null
520
+ remove(k)
521
+ // A click must not inherit a slow speculative request's six-second deadline.
522
+ let timer
523
+ const r = await Promise.race([c.p, new Promise(resolve => { timer = setTimeout(() => resolve(null), 150) })])
524
+ clearTimeout(timer)
525
+ if (!r) { const controller = running.get(k); controller && controller.abort() }
526
+ return r && !dead && c.epoch === epoch && Date.now() - c.at < r.ttl ? r : null
527
+ },
528
+ }
529
+ }
530
+
315
531
  const HOLD = ['click', 'submit']
316
532
  function hold(el) {
317
533
  if (el.$hold) el.$hold() // a newer navigation drops what the previous page held
package/brain-rpc.js CHANGED
@@ -10,6 +10,9 @@ export async function rpc(id, a, oops, base) {
10
10
  j = t ? JSON.parse(t) : undefined
11
11
  } catch (e) {
12
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'))
13
16
  }
14
17
  if (r.ok) return j
15
18
  const e = Object.assign(Error((j && j.error) || 'rpc ' + id + ': ' + r.status), { code: 'rpc', status: r.status, id, errors: j && j.errors })
package/brain-schema.js CHANGED
@@ -3,67 +3,142 @@
3
3
  // Per field: `str num bool email url date` + `!` required + `<=N >=N` (length for str and lists, value for num) + `[T]`
4
4
  // list of T, or a nested schema object.
5
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.text form.boolean 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
+ }
6
30
 
7
31
  // 'str!<=80' → { t: 'str', req: true, min, max, list: false }
8
- export function rule(s) {
9
- if (typeof s === 'object') return { t: 'obj', s, req: false }
10
- const req = s.includes('!'), r = { t: '', req, list: false, min: null, max: null }
11
- let x = s.replace(/!/g, '')
12
- const m = /^(\[?)(\w+)\]?((?:[<>]=-?\d+(?:\.\d+)?)*)$/.exec(x)
13
- if (!m) throw Error(`form: invalid rule '${s}' (use str num bool email url date, ! required, <=N >=N, [T])`)
14
- r.list = !!m[1]
15
- r.t = m[2]
16
- for (const [, op, n] of m[3].matchAll(/([<>])=(-?\d+(?:\.\d+)?)/g)) r[op === '<' ? 'max' : 'min'] = +n
32
+ const TYPES = new Set(['str', 'num', 'bool', 'email', 'url', 'date'])
33
+ function scalarRule(s) {
34
+ if (typeof s !== 'string' || s.length > 256) throw Error('form: invalid rule (use str num bool email url date, ! required, <=N >=N, [T])')
35
+ const m = /^(?:([a-z]+)(!)?|\[([a-z]+)(!)?\](!)?)((?:[<>]=-?\d+(?:\.\d+)?)*)$/.exec(s)
36
+ if (!m || m[4] && m[5]) throw Error(`form: invalid rule '${s}' (use str num bool email url date, ! required, <=N >=N, [T])`)
37
+ const r = { t: m[1] || m[3], req: !!(m[2] || m[4] || m[5]), list: !!m[3], min: null, max: null }
38
+ if (!TYPES.has(r.t)) throw Error(`form: unknown type '${r.t}'`)
39
+ for (const [, op, n] of m[6].matchAll(/([<>])=(-?\d+(?:\.\d+)?)/g)) {
40
+ const key = op === '<' ? 'max' : 'min', value = Number(n)
41
+ if (r[key] != null || !Number.isFinite(value) || (!r.list && r.t === 'bool') || ((r.list || r.t !== 'num') && (!Number.isSafeInteger(value) || value < 0))) throw Error(`form: invalid bounds in rule '${s}'`)
42
+ r[key] = value
43
+ }
44
+ if (r.min != null && r.max != null && r.min > r.max) throw Error(`form: invalid bounds in rule '${s}'`)
17
45
  return r
18
46
  }
47
+ function schemaRules(schema, seen, budget, depth) {
48
+ if (!schema || Array.isArray(schema) || typeof schema !== 'object' || ![null, Object.prototype].includes(Object.getPrototypeOf(schema)) || seen.has(schema) || depth > 16) throw Error('form: invalid nested schema')
49
+ seen.add(schema)
50
+ for (const key of Object.keys(schema)) {
51
+ const field = Object.getOwnPropertyDescriptor(schema, key)
52
+ if (++budget.n > 4096 || ['__proto__', 'prototype', 'constructor'].includes(key) || !field || !('value' in field)) throw Error('form: invalid schema field')
53
+ if (field.value && typeof field.value === 'object') schemaRules(field.value, seen, budget, depth + 1)
54
+ else scalarRule(field.value)
55
+ }
56
+ seen.delete(schema)
57
+ }
58
+ export function rule(s) {
59
+ if (s && typeof s === 'object') {
60
+ schemaRules(s, new Set(), { n: 0 }, 0)
61
+ return { t: 'obj', s, req: false }
62
+ }
63
+ return scalarRule(s)
64
+ }
65
+ const empty = v => v == null || typeof v === 'string' && v.trim() === '' || Array.isArray(v) && !v.length
66
+ const NUMBER = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/
67
+ // Check calendar fields directly; Date.parse may normalize impossible days or accept locale-specific formats.
68
+ function calendarDate(s) {
69
+ const m = /^(\d{4})-(\d{2})-(\d{2})(?:T([01]\d|2[0-3]):([0-5]\d)(?::([0-5]\d)(?:\.\d{1,9})?)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)?)?$/.exec(s)
70
+ if (!m) return false
71
+ const year = +m[1], month = +m[2], day = +m[3]
72
+ const days = [31, year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
73
+ return year > 0 && month > 0 && month <= 12 && day > 0 && day <= days[month - 1]
74
+ }
19
75
 
20
- const empty = v => v == null || v === '' || (Array.isArray(v) && !v.length)
21
76
  const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`
22
77
 
78
+ // Compiler-only validators for proven unbounded scalars. Separate entry points let
79
+ // text-only forms omit numeric syntax and boolean validation from their dependency graph.
80
+ export function checkText(r, value) {
81
+ if (Array.isArray(value)) return msg('form.text', 'Enter text')
82
+ if (empty(value)) return r.req ? msg('form.required', 'Required') : ''
83
+ if (r.t === 'email' && (typeof value !== 'string' || !RX_EMAIL.test(value))) return msg('form.email', 'Enter a valid email')
84
+ return typeof value === 'string' ? '' : msg('form.text', 'Enter text')
85
+ }
86
+ export function checkScalar(r, value) {
87
+ if (r.t !== 'bool' && r.t !== 'num') return checkText(r, value)
88
+ if (Array.isArray(value)) return msg(r.t === 'num' ? 'form.number' : 'form.boolean', r.t === 'num' ? 'Enter a number' : 'Choose true or false')
89
+ if (empty(value)) return r.req ? msg('form.required', 'Required') : ''
90
+ if (r.t === 'bool') return typeof value !== 'boolean' ? msg('form.boolean', 'Choose true or false') : r.req && !value ? msg('form.required', 'Required') : ''
91
+ return Number.isFinite(typeof value === 'number' ? value : typeof value === 'string' && NUMBER.test(value.trim()) ? Number(value) : NaN) ? '' : msg('form.number', 'Enter a number')
92
+ }
93
+
23
94
  // Message for one value, or '' when valid.
24
95
  export function check(r, v) {
25
96
  if (r.list) {
26
- if (empty(v)) return r.req || r.min > 0 ? (r.req && !r.min ? 'Required' : `Choose at least ${r.min}`) : ''
27
- if (!Array.isArray(v)) return 'Must be a list'
28
- if (r.min != null && v.length < r.min) return `Choose at least ${r.min}`
29
- if (r.max != null && v.length > r.max) return `At most ${plural(r.max, 'item')}`
97
+ if (v != null && !Array.isArray(v)) return msg('form.list', 'Must be a list')
98
+ 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 })) : ''
99
+ if (!Array.isArray(v)) return msg('form.list', 'Must be a list')
100
+ if (r.min != null && v.length < r.min) return msg('form.min_items', `Choose at least ${r.min}`, { n: r.min })
101
+ if (r.max != null && v.length > r.max) return msg('form.max_items', `At most ${plural(r.max, 'item')}`, { n: r.max })
30
102
  const one = { ...r, list: false, req: true, min: null, max: null }
31
103
  for (let i = 0; i < v.length; i++) {
32
- const e = check(one, v[i])
33
- if (e) return `Item ${i + 1}: ${e}`
104
+ const e = r.t === 'bool' ? typeof v[i] === 'boolean' ? '' : msg('form.boolean', 'Choose true or false') : check(one, v[i])
105
+ if (e) return msg('form.item', `Item ${i + 1}: ${e}`, { n: i + 1, error: e })
34
106
  }
35
107
  return ''
36
108
  }
37
- if (r.t === 'bool') return r.req && v !== true ? 'Required' : ''
38
- if (empty(v)) return r.req ? 'Required' : ''
39
- const s = String(v)
109
+ if (Array.isArray(v)) return msg(r.t === 'num' ? 'form.number' : r.t === 'bool' ? 'form.boolean' : 'form.text', r.t === 'num' ? 'Enter a number' : r.t === 'bool' ? 'Choose true or false' : 'Enter text')
110
+ if (empty(v)) return r.req ? msg('form.required', 'Required') : ''
111
+ if (r.t === 'bool') return typeof v !== 'boolean' ? msg('form.boolean', 'Choose true or false') : r.req && !v ? msg('form.required', 'Required') : ''
112
+ const s = typeof v === 'string' ? v : ''
40
113
  switch (r.t) {
41
114
  case 'num': {
42
- const n = typeof v === 'number' ? v : s.trim() === '' ? NaN : Number(s)
43
- if (!Number.isFinite(n)) return 'Enter a number'
44
- if (r.min != null && n < r.min) return `Must be at least ${r.min}`
45
- if (r.max != null && n > r.max) return `Must be at most ${r.max}`
115
+ const n = typeof v === 'number' ? v : typeof v === 'string' && NUMBER.test(s.trim()) ? Number(s) : NaN
116
+ if (!Number.isFinite(n)) return msg('form.number', 'Enter a number')
117
+ if (r.min != null && n < r.min) return msg('form.min', `Must be at least ${r.min}`, { n: r.min })
118
+ if (r.max != null && n > r.max) return msg('form.max', `Must be at most ${r.max}`, { n: r.max })
46
119
  return ''
47
120
  }
48
121
  case 'email':
49
- if (!RX_EMAIL.test(s)) return 'Enter a valid email'
122
+ if (typeof v !== 'string' || !RX_EMAIL.test(s)) return msg('form.email', 'Enter a valid email')
50
123
  break
51
124
  case 'url':
125
+ if (typeof v !== 'string') return msg('form.url', 'Enter a valid URL')
52
126
  try {
53
- if (!/^https?:$/.test(new URL(s).protocol)) return 'Enter a valid URL'
127
+ if (!/^https?:$/.test(new URL(s).protocol)) return msg('form.url', 'Enter a valid URL')
54
128
  } catch {
55
- return 'Enter a valid URL'
129
+ return msg('form.url', 'Enter a valid URL')
56
130
  }
57
131
  break
58
132
  case 'date':
59
- if (Number.isNaN(Date.parse(s))) return 'Enter a valid date'
60
- return ''
133
+ if (typeof v !== 'string' || !calendarDate(s)) return msg('form.date', 'Enter a valid date')
134
+ break
61
135
  case 'str':
136
+ if (typeof v !== 'string') return msg('form.text', 'Enter text')
62
137
  break
63
138
  default:
64
139
  throw Error(`form: unknown type '${r.t}'`)
65
140
  }
66
- if (r.min != null && s.length < r.min) return `At least ${plural(r.min, 'character')}`
67
- if (r.max != null && s.length > r.max) return `At most ${plural(r.max, 'character')}`
141
+ if (r.min != null && s.length < r.min) return msg('form.min_chars', `At least ${plural(r.min, 'character')}`, { n: r.min })
142
+ if (r.max != null && s.length > r.max) return msg('form.max_chars', `At most ${plural(r.max, 'character')}`, { n: r.max })
68
143
  return ''
69
144
  }