@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-env.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// BRAINLLM lifecycle and environment helpers (INTERNALS §41, #169). Each one cleans up with the component that called
|
|
2
|
+
// it (the owner's effects are disposed on unmount), and none of them runs timers, observers or listeners while the
|
|
3
|
+
// server renders (server.mjs SSR or the build's prerender: globalThis.__brainSSR / __brainRoute are set), so a render
|
|
4
|
+
// never keeps Node alive. Values meant for markup (`media`, `visible`, `debounce`) are read-only nodes read with `.v`.
|
|
5
|
+
import { _rt, sig, fx, untrack } from './brain.js'
|
|
6
|
+
|
|
7
|
+
const G = globalThis, I = _rt()
|
|
8
|
+
const onServer = () => !!(G.__brainSSR || G.__brainRoute)
|
|
9
|
+
const ro = n => ({ get v() { return n.v } })
|
|
10
|
+
const ms = t => {
|
|
11
|
+
if (typeof t === 'number') return t
|
|
12
|
+
const m = /^(\d+(?:\.\d+)?)(ms|s|m|h)$/.exec(String(t).trim())
|
|
13
|
+
if (!m) throw Error(`brain-env: "${t}" is not a duration (300ms, 30s, 5m, 1h)`)
|
|
14
|
+
return m[1] * { ms: 1, s: 1e3, m: 6e4, h: 36e5 }[m[2]]
|
|
15
|
+
}
|
|
16
|
+
// runs `f` when the component unmounts (an fx with no reads: its cleanup is the owner's disposal)
|
|
17
|
+
const onDispose = f => fx(() => f)
|
|
18
|
+
|
|
19
|
+
// mounted(f): after the component's nodes are in the document. A returned function runs on unmount.
|
|
20
|
+
export function mounted(f) {
|
|
21
|
+
if (onServer()) return
|
|
22
|
+
let alive = true, c
|
|
23
|
+
queueMicrotask(() => alive && (c = untrack(f)))
|
|
24
|
+
onDispose(() => ((alive = false), typeof c === 'function' && c()))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// watch(() => src, (v, old) => …): runs the callback when the source changes, not on the first read; the callback is
|
|
28
|
+
// not tracked. A returned function is its cleanup (before the next run and on unmount).
|
|
29
|
+
export function watch(src, cb) {
|
|
30
|
+
let first = true, old
|
|
31
|
+
fx(() => {
|
|
32
|
+
const v = src()
|
|
33
|
+
if (first) return void ((first = false), (old = v))
|
|
34
|
+
const o = old
|
|
35
|
+
old = v
|
|
36
|
+
return untrack(() => cb(v, o))
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// tick(): resolves once pending updates have reached the DOM
|
|
41
|
+
export const tick = () => new Promise(r => setTimeout(r, 0))
|
|
42
|
+
|
|
43
|
+
// every('30s', f): an interval, paused while the tab is hidden
|
|
44
|
+
export function every(t, f) {
|
|
45
|
+
if (onServer()) return
|
|
46
|
+
const n = ms(t), d = G.document
|
|
47
|
+
let id = 0
|
|
48
|
+
const sync = () => (clearInterval(id), (id = d && d.hidden ? 0 : setInterval(() => untrack(f), n)))
|
|
49
|
+
sync()
|
|
50
|
+
if (d && d.addEventListener) d.addEventListener('visibilitychange', sync)
|
|
51
|
+
onDispose(() => (clearInterval(id), d && d.removeEventListener && d.removeEventListener('visibilitychange', sync)))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// media('(max-width: 600px)') → .v true/false, following the viewport
|
|
55
|
+
export function media(q) {
|
|
56
|
+
const mq = !onServer() && G.matchMedia ? G.matchMedia(q) : null
|
|
57
|
+
const n = sig(!!(mq && mq.matches))
|
|
58
|
+
if (mq) {
|
|
59
|
+
const on = e => (n.v = e.matches)
|
|
60
|
+
mq.addEventListener('change', on)
|
|
61
|
+
onDispose(() => mq.removeEventListener('change', on))
|
|
62
|
+
}
|
|
63
|
+
return ro(n)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// visible(ref) → .v true while the element is in the viewport (IntersectionObserver); a ref may still be empty
|
|
67
|
+
export function visible(r, o) {
|
|
68
|
+
const n = sig(false)
|
|
69
|
+
if (onServer() || !G.IntersectionObserver) return ro(n)
|
|
70
|
+
fx(() => {
|
|
71
|
+
const el = r && r.nodeType ? r : r && r.v
|
|
72
|
+
if (!el) return
|
|
73
|
+
const io = new IntersectionObserver(es => (n.v = es[es.length - 1].isIntersecting), o)
|
|
74
|
+
io.observe(el)
|
|
75
|
+
return () => io.disconnect()
|
|
76
|
+
})
|
|
77
|
+
return ro(n)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// debounce(() => value, '300ms') → .v follows the value once it has been still for that long
|
|
81
|
+
export function debounce(f, t) {
|
|
82
|
+
const n = sig(untrack(f)), wait = ms(t)
|
|
83
|
+
let first = true
|
|
84
|
+
fx(() => {
|
|
85
|
+
const v = f()
|
|
86
|
+
if (first) return void (first = false)
|
|
87
|
+
if (onServer()) return void (n.v = v)
|
|
88
|
+
const id = setTimeout(() => (n.v = v), wait)
|
|
89
|
+
return () => clearTimeout(id)
|
|
90
|
+
})
|
|
91
|
+
return ro(n)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// <component is={C} …props> → dyn(parent, () => C, $p, anchor): mounts C before the anchor and remounts only when the
|
|
95
|
+
// component itself changes (a null C renders nothing). The mounted component belongs to the caller's owner.
|
|
96
|
+
export function dyn(p, c, $p, end) {
|
|
97
|
+
const own = I.owner()
|
|
98
|
+
let cur, nodes = [], d = null
|
|
99
|
+
const clear = () => (d && d(), (d = null), I.rm(nodes), (nodes = []))
|
|
100
|
+
fx(() => {
|
|
101
|
+
const C = c()
|
|
102
|
+
if (C === cur) return
|
|
103
|
+
cur = C
|
|
104
|
+
untrack(() => {
|
|
105
|
+
clear()
|
|
106
|
+
if (C) I.root(x => ((d = x), (nodes = I.nodes(C($p || {})))), own)
|
|
107
|
+
const par = (end && end.parentNode) || p
|
|
108
|
+
for (const n of nodes) par.insertBefore(n, end || null)
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
onDispose(clear)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// <Portal to="#layer"> → portal(target, () => children): renders the children into another element (a selector or an
|
|
115
|
+
// element; document.body by default) and removes them on unmount. Returns what to insert in place: nothing in the
|
|
116
|
+
// browser; while the server renders, the children themselves (they stay where they are written).
|
|
117
|
+
export function portal(target, f) {
|
|
118
|
+
if (onServer() || !G.document) return f()
|
|
119
|
+
const t = (typeof target === 'string' ? document.querySelector(target) : target) || document.body
|
|
120
|
+
let nodes = [], d = null
|
|
121
|
+
I.root(x => ((d = x), (nodes = I.nodes(f()))), I.owner())
|
|
122
|
+
for (const n of nodes) t.appendChild(n)
|
|
123
|
+
onDispose(() => (d && d(), I.rm(nodes)))
|
|
124
|
+
return []
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// route() → { path, params, query }, read-only and reactive (each a signal read): path without the base path
|
|
128
|
+
// (<meta name="brain-base">), query from the URL, params from the page's <meta name="brain-params" content='{"id":"7"}'>
|
|
129
|
+
// (the server knows the route's pattern; the client does not). brain-router calls globalThis.__brainRouted after every
|
|
130
|
+
// client navigation. While the server renders, the values come from globalThis.__brainRoute (server.mjs, prerender).
|
|
131
|
+
let cur = null
|
|
132
|
+
const paramsOf = () => {
|
|
133
|
+
try {
|
|
134
|
+
const m = G.document && document.querySelector('meta[name=brain-params]')
|
|
135
|
+
return (m && JSON.parse(m.getAttribute('content'))) || {}
|
|
136
|
+
} catch {
|
|
137
|
+
return {}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const strip = p => {
|
|
141
|
+
const m = G.document && document.querySelector('meta[name=brain-base]'), b = (m && m.getAttribute('content')) || '/'
|
|
142
|
+
return b !== '/' && (p + '/').startsWith(b) ? '/' + p.slice(b.length) : p
|
|
143
|
+
}
|
|
144
|
+
const read = () => {
|
|
145
|
+
const u = new URL(location.href)
|
|
146
|
+
return [strip(u.pathname), paramsOf(), Object.fromEntries(u.searchParams)]
|
|
147
|
+
}
|
|
148
|
+
export function route() {
|
|
149
|
+
const s = G.__brainRoute
|
|
150
|
+
if (s) return { path: s.path, params: s.params || {}, query: s.query || {} }
|
|
151
|
+
if (!cur) {
|
|
152
|
+
const S = read().map(v => sig(v))
|
|
153
|
+
cur = { get path() { return S[0].v }, get params() { return S[1].v }, get query() { return S[2].v } }
|
|
154
|
+
G.__brainRouted = () => {
|
|
155
|
+
const v = read()
|
|
156
|
+
S.forEach((s, i) => JSON.stringify(untrack(() => s.v)) !== JSON.stringify(v[i]) && (s.v = v[i])) // only what changed
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return cur
|
|
160
|
+
}
|
package/brain-err.js
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// BRAINLLM native error capture (INTERNALS §18), no third parties. The core already captures fx, drv, handlers and render
|
|
2
|
+
// (and cuts B0902 loops); this module gives each error a fingerprint, component and parent chain, stack, breadcrumbs
|
|
3
|
+
// (last 30 actions), build and browser; it dedupes and sends via sendBeacon with a cap.
|
|
4
|
+
// Usage: prod `capture({ report: '/path', build })` (brain build --report) · in dev brain-inspect installs it with `who`.
|
|
5
|
+
// `fallback(C, fb)` (codegen of `<meta fallback="…">`: `dev(fallback(C, fb))`) lives here: importing the module
|
|
6
|
+
// installs the core hook, so an app without fallbacks or --report does not pay for this code.
|
|
7
|
+
// Never includes input values, cookies, query strings or signal values.
|
|
8
|
+
import { _err, sig } from './brain.js'
|
|
9
|
+
|
|
10
|
+
const G = globalThis
|
|
11
|
+
const RT = new URL('.', import.meta.url).href
|
|
12
|
+
// Core hook: records (capture active) or console.error, and falls back to the nearest boundary's fallback (not for
|
|
13
|
+
// event handlers).
|
|
14
|
+
const I = _err((e, x, w) => {
|
|
15
|
+
on ? record(e, x, w) : G.console && console.error(e)
|
|
16
|
+
// §42: a failing event handler never destroys its component (it is reported; brain-guard shows the toast)
|
|
17
|
+
if (String(w).startsWith('on:')) return
|
|
18
|
+
for (; x; x = x.p || x.parentNode) {
|
|
19
|
+
const b = BW.get(x)
|
|
20
|
+
if (b) return fall(b)
|
|
21
|
+
}
|
|
22
|
+
})
|
|
23
|
+
// Boundaries: owner N and its root elements → { fb, n: nodes, r: owner, f: already fell }.
|
|
24
|
+
const BW = new WeakMap()
|
|
25
|
+
const fbn = fb => {
|
|
26
|
+
if (typeof fb === 'function') return I.nodes(fb())
|
|
27
|
+
const p = document.createElement('p')
|
|
28
|
+
p.setAttribute('role', 'alert')
|
|
29
|
+
p.textContent = fb
|
|
30
|
+
return [p]
|
|
31
|
+
}
|
|
32
|
+
function fall(b) {
|
|
33
|
+
if (b.f) return
|
|
34
|
+
b.f = 1
|
|
35
|
+
const a = fbn(b.fb), f = b.n[0], p = f && f.parentNode
|
|
36
|
+
if (p) for (const x of a) p.insertBefore(x, f)
|
|
37
|
+
b.r.q() // stops fx and handlers of the fallen component
|
|
38
|
+
I.rm(b.n)
|
|
39
|
+
b.n = a
|
|
40
|
+
}
|
|
41
|
+
// An error in the component's render, fx or handler swaps its nodes for the fallback (text → <p role=alert>,
|
|
42
|
+
// function → nodes); the rest of the app keeps running.
|
|
43
|
+
export const fallback = (C, fb) =>
|
|
44
|
+
Object.assign(
|
|
45
|
+
$p =>
|
|
46
|
+
I.root(() => {
|
|
47
|
+
const r = I.owner(), b = { fb, n: [], r, f: 0 }
|
|
48
|
+
BW.set(r, b)
|
|
49
|
+
let a
|
|
50
|
+
try {
|
|
51
|
+
a = I.nodes(C($p))
|
|
52
|
+
} catch (e) {
|
|
53
|
+
on ? record(e, r, 'render') : G.console && console.error(e)
|
|
54
|
+
r.q()
|
|
55
|
+
;(b.f = 1), (a = fbn(fb))
|
|
56
|
+
}
|
|
57
|
+
for (const x of (b.n = a)) BW.set(x, b)
|
|
58
|
+
return a
|
|
59
|
+
}, I.owner()),
|
|
60
|
+
{ C, h: C.h, f: C.f, p: C.p, form: C.form }
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
const MSG = { B0902: 'fx re-ran >100 times in one flush: sig→fx→sig cycle; cut off so the tab does not hang' }
|
|
64
|
+
export const errors = [] // deduplicated records, most recent last (Errors view, MCP brain_errors)
|
|
65
|
+
export const sub = new Set() // fn(record) on each new or repeated error
|
|
66
|
+
const crumbs = []
|
|
67
|
+
let opts = {}, on = false, sent = 0, F0 = null
|
|
68
|
+
|
|
69
|
+
export function crumb(k, d) {
|
|
70
|
+
const l = crumbs[crumbs.length - 1]
|
|
71
|
+
if (l && l.k === k && l.d === d) return void l.n++ // the same repeated action is counted, not stacked
|
|
72
|
+
crumbs.push({ k, d, n: 1, at: Date.now() })
|
|
73
|
+
if (crumbs.length > 30) crumbs.shift()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const hash = s => {
|
|
77
|
+
let h = 5381
|
|
78
|
+
for (let i = 0; i < s.length; i++) h = ((h * 33) ^ s.charCodeAt(i)) >>> 0
|
|
79
|
+
return h.toString(36)
|
|
80
|
+
}
|
|
81
|
+
const path = u => {
|
|
82
|
+
try {
|
|
83
|
+
return new URL(u, G.location && G.location.href).pathname // no query: it may carry sensitive data
|
|
84
|
+
} catch {
|
|
85
|
+
return String(u).split('?')[0]
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// "url:line:col" of each frame; the first one outside the runtime is the app line.
|
|
89
|
+
function frames(e) {
|
|
90
|
+
const out = []
|
|
91
|
+
for (const l of String((e && e.stack) || '').split('\n')) {
|
|
92
|
+
const m = l.match(/((?:https?|file):\/\/[^\s)]+):(\d+):(\d+)/)
|
|
93
|
+
if (m) out.push({ url: m[1], line: +m[2], col: +m[3], rt: m[1].startsWith(RT) && !m[1].slice(RT.length).includes('/') })
|
|
94
|
+
}
|
|
95
|
+
return out
|
|
96
|
+
}
|
|
97
|
+
// Component via the DOM: roots with data-b="<hash>" (prod) → parent chain via ancestors with data-b.
|
|
98
|
+
function byDom(el) {
|
|
99
|
+
const chain = []
|
|
100
|
+
for (; el; el = el.parentNode) if (el.getAttribute && el.getAttribute('data-b')) chain.push({ h: el.getAttribute('data-b') })
|
|
101
|
+
return chain.length ? { ...chain[0], chain: chain.map(c => c.h) } : null
|
|
102
|
+
}
|
|
103
|
+
// Prod chunk /_b/<hash>.brain → component hash.
|
|
104
|
+
const byStack = f => {
|
|
105
|
+
const m = f && f.url.match(/\/_b\/([a-z0-9]+)/)
|
|
106
|
+
return m ? { h: m[1], chain: [m[1]] } : null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// .brain lines (§18, dev): each module compiled in dev ends with an inline Source Map v3 that maps every generated
|
|
110
|
+
// line to its .brain line. With `maps`, a recorded error is enriched asynchronously: `src` ('views/home.brain:12', the
|
|
111
|
+
// first app frame) and `brainStack`; subscribers (brain_errors, overlay, doctor) are notified again. The file path
|
|
112
|
+
// comes from the module URL (two `index.brain` in different folders never mix); prod chunks carry no map: no-op.
|
|
113
|
+
const MAPS = new Map()
|
|
114
|
+
const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
|
115
|
+
export function decode(m) {
|
|
116
|
+
const lines = []
|
|
117
|
+
let src = 0
|
|
118
|
+
for (const seg of m.split(';')) {
|
|
119
|
+
let line = null
|
|
120
|
+
for (const part of seg.split(',')) {
|
|
121
|
+
if (!part) continue
|
|
122
|
+
const v = []
|
|
123
|
+
for (let i = 0, n = 0, sh = 0; i < part.length; i++) {
|
|
124
|
+
const d = B64.indexOf(part[i])
|
|
125
|
+
n += (d & 31) << sh
|
|
126
|
+
if (d & 32) sh += 5
|
|
127
|
+
else (v.push(n & 1 ? -(n >> 1) : n >> 1), (n = 0), (sh = 0))
|
|
128
|
+
}
|
|
129
|
+
if (v.length >= 3) (src += v[2]), line === null && (line = src + 1)
|
|
130
|
+
}
|
|
131
|
+
lines.push(line)
|
|
132
|
+
}
|
|
133
|
+
return lines
|
|
134
|
+
}
|
|
135
|
+
const mapOf = url => {
|
|
136
|
+
if (!MAPS.has(url))
|
|
137
|
+
MAPS.set(
|
|
138
|
+
url,
|
|
139
|
+
Promise.resolve(opts.load ? opts.load(url) : F0(url).then(r => r.text()))
|
|
140
|
+
.then(t => {
|
|
141
|
+
const m = /sourceMappingURL=data:application\/json;base64,([A-Za-z0-9+/=]+)/.exec(t)
|
|
142
|
+
return m ? decode(JSON.parse(atob(m[1])).mappings) : null
|
|
143
|
+
})
|
|
144
|
+
.catch(() => null)
|
|
145
|
+
)
|
|
146
|
+
return MAPS.get(url)
|
|
147
|
+
}
|
|
148
|
+
async function toBrain(r, f) {
|
|
149
|
+
const out = []
|
|
150
|
+
for (const t of f.slice(0, 12)) {
|
|
151
|
+
const lines = t.rt ? null : await mapOf(t.url)
|
|
152
|
+
const l = lines && lines[t.line - 1]
|
|
153
|
+
if (l) out.push(`${path(t.url).replace(/^\//, '')}:${l}`)
|
|
154
|
+
}
|
|
155
|
+
if (!out.length) return
|
|
156
|
+
r.src = out[0]
|
|
157
|
+
r.brainStack = out
|
|
158
|
+
for (const s of sub) s(r)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function record(e, x, w) {
|
|
162
|
+
if (e && e.kind === 'aborted') return // a cancelled request (brain-data ApiError): not an error
|
|
163
|
+
const f = frames(e), top = f.find(t => !t.rt) || null
|
|
164
|
+
const c = (opts.who && opts.who(x)) || (x && x.nodeType ? byDom(x) : null) || byStack(top)
|
|
165
|
+
const type = (e && e.code) || (e && e.name) || typeof e
|
|
166
|
+
const line = top ? `${path(top.url)}:${top.line}` : null
|
|
167
|
+
const fp = hash([type, c && c.h, line, w].join('|'))
|
|
168
|
+
let r = errors.find(r => r.fp === fp)
|
|
169
|
+
if (r) (r.n++, (r.last = Date.now()))
|
|
170
|
+
else {
|
|
171
|
+
r = {
|
|
172
|
+
fp, code: (e && e.code) || null, type, msg: MSG[e && e.code] || String((e && e.message) || e).slice(0, 500), where: w || null, comp: c, line,
|
|
173
|
+
stack: f.slice(0, 12).map(t => `${path(t.url)}:${t.line}:${t.col}`), crumbs: crumbs.map(c => ({ ...c })),
|
|
174
|
+
build: opts.build || null, ua: G.navigator ? navigator.userAgent : null, at: Date.now(), last: Date.now(), n: 1,
|
|
175
|
+
}
|
|
176
|
+
errors.push(r)
|
|
177
|
+
if (errors.length > 100) errors.shift()
|
|
178
|
+
if (opts.maps) toBrain(r, f)
|
|
179
|
+
if (opts.report && G.navigator && navigator.sendBeacon && sent < (opts.max || 20)) sent++, navigator.sendBeacon(opts.report, JSON.stringify(r))
|
|
180
|
+
}
|
|
181
|
+
for (const s of sub) s(r)
|
|
182
|
+
if (opts.log !== false && G.console) console.error(e)
|
|
183
|
+
return r
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Readable element label without values: aria-label, name or button/link text (40 chars).
|
|
187
|
+
const label = el => {
|
|
188
|
+
if (!el || !el.tagName) return '?'
|
|
189
|
+
const t = el.tagName.toLowerCase(), a = el.getAttribute('aria-label') || el.getAttribute('name')
|
|
190
|
+
const txt = /^(input|textarea|select)$/.test(t) ? '' : String(el.textContent || '').trim().slice(0, 40)
|
|
191
|
+
return `${t}${a ? `[${a}]` : ''}${txt ? ` "${txt}"` : ''}`
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function capture(o = {}) {
|
|
195
|
+
if (on) return (Object.assign(opts, o), errors)
|
|
196
|
+
on = true
|
|
197
|
+
opts = o
|
|
198
|
+
F0 = G.fetch && G.fetch.bind(G) // the unwrapped fetch: reading source maps leaves no breadcrumbs
|
|
199
|
+
if (G.addEventListener) {
|
|
200
|
+
G.addEventListener('error', ev => record(ev.error || ev.message, null, 'onerror'))
|
|
201
|
+
G.addEventListener('unhandledrejection', ev => record(ev.reason, null, 'promise'))
|
|
202
|
+
G.addEventListener('popstate', () => crumb('nav', path(location.href)))
|
|
203
|
+
G.addEventListener('hashchange', () => crumb('nav', path(location.href) + location.hash))
|
|
204
|
+
}
|
|
205
|
+
const d = G.document
|
|
206
|
+
if (d && d.addEventListener) {
|
|
207
|
+
for (const t of ['click', 'submit', 'change']) d.addEventListener(t, e => crumb(t, label(e.target)), true)
|
|
208
|
+
d.addEventListener('input', e => crumb('input', label(e.target)), true) // only which field, never the value
|
|
209
|
+
}
|
|
210
|
+
if (G.history)
|
|
211
|
+
for (const k of ['pushState', 'replaceState']) {
|
|
212
|
+
const f = history[k]
|
|
213
|
+
history[k] = function (s, t, u) {
|
|
214
|
+
const r = f.apply(this, arguments)
|
|
215
|
+
crumb('nav', path(u == null ? location.href : u))
|
|
216
|
+
return r
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (G.fetch) {
|
|
220
|
+
const f = G.fetch
|
|
221
|
+
G.fetch = function (i, init) {
|
|
222
|
+
const m = (init && init.method) || (i && i.method) || 'GET', u = path((i && i.url) || i)
|
|
223
|
+
crumb('fetch', `${m} ${u}`)
|
|
224
|
+
return f.apply(this, arguments).then(
|
|
225
|
+
r => (r.status >= 400 && crumb('fetch', `${m} ${u} → ${r.status}`), r),
|
|
226
|
+
e => (crumb('fetch', `${m} ${u} → failed`), Promise.reject(e))
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
// Signal writes: name (dev, opts.sigName) or 'sig'; never the value.
|
|
231
|
+
const P = Object.getPrototypeOf(sig(0)), v = Object.getOwnPropertyDescriptor(P, 'v')
|
|
232
|
+
Object.defineProperty(P, 'v', {
|
|
233
|
+
configurable: true,
|
|
234
|
+
get: v.get,
|
|
235
|
+
set(x) {
|
|
236
|
+
const b = this._
|
|
237
|
+
v.set.call(this, x)
|
|
238
|
+
if (this._ !== b) crumb('sig', (opts.sigName && opts.sigName(this)) || 'sig')
|
|
239
|
+
},
|
|
240
|
+
})
|
|
241
|
+
return errors
|
|
242
|
+
}
|
package/brain-form.js
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// BRAINLLM forms (INTERNALS §14). `let f = form(schema, { submit: async (v) => … })` →
|
|
2
|
+
// bind:value={f.v.email} · {f.err.email} · f.busy · f.error (form-level) · f.ok · on:submit={f.submit}
|
|
3
|
+
// Schema mini-language per field: `str num bool email url date` + `!` required + `<=N >=N` (length for str and lists,
|
|
4
|
+
// value for num) + `[T]` list of T, or a nested schema object (a type from types.jsbrain).
|
|
5
|
+
// Validation: a field is validated when it loses focus and every field on submit; errors show only after that.
|
|
6
|
+
// The element of each field is found without ids: the control that has focus while its value is written (the
|
|
7
|
+
// `bind:` input handler), or on submit `form.elements[name]`. On it the runtime keeps `aria-invalid` and, when the
|
|
8
|
+
// error element exists (`<Field>` or any element with id `f.id(name)`), `aria-describedby`.
|
|
9
|
+
// Submit: validates, focuses the first invalid control, ignores re-entry while `busy`, converts values (num → Number),
|
|
10
|
+
// awaits `submit(values)`. A returned or thrown `{ errors: { field: msg } }` marks fields; any other failure → f.error
|
|
11
|
+
// (a cancelled request, kind 'aborted', is not an error to show).
|
|
12
|
+
// Async checks (§41.6): `check: { email: async (v) => (await users.exists({ email: v })) ? 'Taken' : '' }` run after
|
|
13
|
+
// the field's own rule passes, when it loses focus and before submit; `f.checking` while any runs; a stale answer
|
|
14
|
+
// (the value changed meanwhile) is ignored.
|
|
15
|
+
// Unsaved changes (§41.5): `f.dirty` is true while values differ from the last saved ones (init, reset() or a successful
|
|
16
|
+
// submit); leaving the page (client navigation or closing the tab) then asks first. No API.
|
|
17
|
+
import { sig, fx, batch, untrack, _rt } from './brain.js'
|
|
18
|
+
import { rule, check, msg } from './brain-schema.js'
|
|
19
|
+
|
|
20
|
+
export { rule, check } // server.mjs validates posted forms with the same rules (§38)
|
|
21
|
+
|
|
22
|
+
const I = _rt()
|
|
23
|
+
|
|
24
|
+
const D = globalThis.document
|
|
25
|
+
let seq = 0
|
|
26
|
+
const GENERIC = 'Something went wrong. Try again.' // the text callForm (server.mjs) shows without JS (key form.error)
|
|
27
|
+
// navigate through the client router when it is loaded (no reload, base path applied), else a normal load
|
|
28
|
+
const go = u => {
|
|
29
|
+
const G = globalThis
|
|
30
|
+
if (G.__brainNavigate) return G.__brainNavigate(u)
|
|
31
|
+
const m = D && D.querySelector('meta[name=brain-base]'), b = (m && m.getAttribute('content')) || '/'
|
|
32
|
+
G.location && G.location.assign(b !== '/' && !(u + '/').startsWith(b) ? b + u.slice(1) : u)
|
|
33
|
+
}
|
|
34
|
+
// forms with unsaved changes: brain-router asks before a client navigation, the browser before unload. A form being
|
|
35
|
+
// submitted does not ask (#299): the user chose to save, and a submit() that navigates once the server accepted the
|
|
36
|
+
// data (`navigate('/products/' + p.id)`) leaves before submit() returns and marks the values saved.
|
|
37
|
+
const DIRTY = (globalThis.__brainDirty ||= new Set())
|
|
38
|
+
if (globalThis.addEventListener && !globalThis.__brainDirtyHook) {
|
|
39
|
+
globalThis.__brainDirtyHook = 1
|
|
40
|
+
globalThis.addEventListener('beforeunload', e => {
|
|
41
|
+
for (const f of DIRTY) if (f.dirty && !f.busy) return void (e.preventDefault(), (e.returnValue = ''))
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
const empty = v => v == null || v === '' || (Array.isArray(v) && !v.length)
|
|
45
|
+
const init0 = r => (r.list ? [] : r.t === 'bool' ? false : '')
|
|
46
|
+
const out = (r, v) => (r.list ? v || [] : r.t === 'num' ? (empty(v) ? null : Number(v)) : r.t === 'bool' ? !!v : v)
|
|
47
|
+
|
|
48
|
+
// state left by the server: globalThis.__brainForms while it renders, or the page's #brain-forms JSON
|
|
49
|
+
let SEEN
|
|
50
|
+
function posted(id) {
|
|
51
|
+
if (!id) return null
|
|
52
|
+
const g = globalThis.__brainForms
|
|
53
|
+
if (g) return g[id] || null
|
|
54
|
+
if (SEEN === undefined)
|
|
55
|
+
try {
|
|
56
|
+
const el = D && D.getElementById('brain-forms')
|
|
57
|
+
SEEN = el ? JSON.parse(el.textContent) : null
|
|
58
|
+
} catch {
|
|
59
|
+
SEEN = null
|
|
60
|
+
}
|
|
61
|
+
return (SEEN && SEEN[id]) || null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function form(schema, o = {}) {
|
|
65
|
+
const id = 'f' + ++seq, owner = I.owner() // aria effects are created later (in handlers) but live with the component
|
|
66
|
+
const busy = sig(false), error = sig(''), shown = sig(0) // shown: bumped when touched set changes
|
|
67
|
+
const checking = sig(0), saved = sig('') // async checks running · JSON of the last saved values
|
|
68
|
+
const F = [] // flat fields: { path, r, s (value sig), e (error sig), el, touched }
|
|
69
|
+
const byPath = {}
|
|
70
|
+
|
|
71
|
+
// Reactive getters/setters, nested for sub-schemas: f.v.address.city
|
|
72
|
+
function build(sch, base, init) {
|
|
73
|
+
const v = {}, err = {}
|
|
74
|
+
for (const k of Object.keys(sch)) {
|
|
75
|
+
const r = rule(sch[k]), path = base ? base + '.' + k : k
|
|
76
|
+
if (r.t === 'obj') {
|
|
77
|
+
const sub = build(r.s, path, (init && init[k]) || {})
|
|
78
|
+
Object.defineProperty(v, k, { enumerable: true, value: sub.v })
|
|
79
|
+
Object.defineProperty(err, k, { enumerable: true, value: sub.err })
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
const x = { path, r, s: sig(init && k in init ? init[k] : init0(r)), e: sig(''), el: null, touched: false }
|
|
83
|
+
F.push(x)
|
|
84
|
+
byPath[path] = x
|
|
85
|
+
Object.defineProperty(v, k, {
|
|
86
|
+
enumerable: true,
|
|
87
|
+
get: () => x.s.v,
|
|
88
|
+
set: val => {
|
|
89
|
+
x.s.v = val
|
|
90
|
+
track(x, D && D.activeElement)
|
|
91
|
+
if (x.touched) validate(x) // already shown: keep the message live while fixing it
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
Object.defineProperty(err, k, { enumerable: true, get: () => (shown.v, x.touched ? x.e.v : '') })
|
|
95
|
+
}
|
|
96
|
+
return { v, err }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function track(x, el) {
|
|
100
|
+
// the first field written while a control has focus claims it: logic in that control's handler that writes
|
|
101
|
+
// other fields does not steal it
|
|
102
|
+
if (!el || x.el || el.$ff || !el.isConnected || !/^(INPUT|SELECT|TEXTAREA)$/.test(el.tagName || '')) return
|
|
103
|
+
x.el = el
|
|
104
|
+
el.$ff = x
|
|
105
|
+
el.addEventListener('focusout', () => touch(x))
|
|
106
|
+
I.root(() => fx(() => {
|
|
107
|
+
const e = shown.v >= 0 && x.touched && x.e.v // reads shown first: re-runs when the field gets touched
|
|
108
|
+
if (e) el.setAttribute('aria-invalid', 'true')
|
|
109
|
+
else el.removeAttribute('aria-invalid')
|
|
110
|
+
const d = D && D.getElementById(fid(x.path))
|
|
111
|
+
if (e && d) el.setAttribute('aria-describedby', fid(x.path))
|
|
112
|
+
else if (el.getAttribute('aria-describedby') === fid(x.path)) el.removeAttribute('aria-describedby')
|
|
113
|
+
}), owner)
|
|
114
|
+
}
|
|
115
|
+
const touch = x => {
|
|
116
|
+
validate(x)
|
|
117
|
+
if (!x.touched) (x.touched = true), shown.v++
|
|
118
|
+
remote(x)
|
|
119
|
+
}
|
|
120
|
+
// async check of one field (only when its own rule passes); resolves to its message
|
|
121
|
+
const remote = x => {
|
|
122
|
+
const c = o.check && o.check[x.path], v = untrack(() => x.s.v)
|
|
123
|
+
if (!c || untrack(() => x.e.v)) return Promise.resolve(untrack(() => x.e.v))
|
|
124
|
+
checking.v++
|
|
125
|
+
return Promise.resolve()
|
|
126
|
+
.then(() => c(v))
|
|
127
|
+
.then(m => m || '', e => (e && e.kind === 'aborted' ? '' : (e && e.message) || msg('form.check_failed', 'Could not check this value.')))
|
|
128
|
+
.then(m => {
|
|
129
|
+
if (untrack(() => x.s.v) === v) x.e.v = m // a stale answer (the value changed meanwhile) is ignored
|
|
130
|
+
return untrack(() => x.e.v)
|
|
131
|
+
})
|
|
132
|
+
.finally(() => checking.v--)
|
|
133
|
+
}
|
|
134
|
+
const validate = x => (x.e.v = untrack(() => check(x.r, x.s.v)))
|
|
135
|
+
const fid = p => `${id}-${p.replace(/\./g, '-')}-err`
|
|
136
|
+
|
|
137
|
+
// A form posted without JS (§38) comes back from the server with its values and messages: start from them, already
|
|
138
|
+
// shown, both while the server renders the page and when the page hydrates
|
|
139
|
+
const st = posted(o.submit && o.submit.id)
|
|
140
|
+
const { v, err } = build(schema, '', st ? st.values : o.init)
|
|
141
|
+
if (st) {
|
|
142
|
+
for (const p in st.errors || {}) if (byPath[p]) (byPath[p].e.v = String(st.errors[p])), (byPath[p].touched = true)
|
|
143
|
+
error.v = st.error || ''
|
|
144
|
+
}
|
|
145
|
+
const now = () => JSON.stringify(F.map(x => x.s.v))
|
|
146
|
+
saved.v = untrack(now)
|
|
147
|
+
|
|
148
|
+
function values() {
|
|
149
|
+
const r = {}
|
|
150
|
+
for (const x of F) {
|
|
151
|
+
const ks = x.path.split('.')
|
|
152
|
+
let t = r
|
|
153
|
+
for (const k of ks.slice(0, -1)) t = t[k] || (t[k] = {})
|
|
154
|
+
t[ks.at(-1)] = out(x.r, untrack(() => x.s.v))
|
|
155
|
+
}
|
|
156
|
+
return r
|
|
157
|
+
}
|
|
158
|
+
function mark(errors) {
|
|
159
|
+
batch(() => {
|
|
160
|
+
for (const p in errors) {
|
|
161
|
+
const x = byPath[p]
|
|
162
|
+
if (x) (x.e.v = String(errors[p])), (x.touched = true)
|
|
163
|
+
}
|
|
164
|
+
shown.v++
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// `f.v` and `f.err` are the form's live fields: replacing the object (`f.v = { … }`) would leave the inputs bound to
|
|
169
|
+
// signals the form no longer reads (submit then sees every field empty), so it fails loudly instead (§14, B0133)
|
|
170
|
+
const fixed = k => {
|
|
171
|
+
throw Error(`form: \`${k}\` cannot be replaced; start from values with form(schema, { init }) or load them later with f.reset(values)`)
|
|
172
|
+
}
|
|
173
|
+
const f = {
|
|
174
|
+
get v() {
|
|
175
|
+
return v
|
|
176
|
+
},
|
|
177
|
+
set v(_) {
|
|
178
|
+
fixed('f.v')
|
|
179
|
+
},
|
|
180
|
+
get err() {
|
|
181
|
+
return err
|
|
182
|
+
},
|
|
183
|
+
set err(_) {
|
|
184
|
+
fixed('f.err')
|
|
185
|
+
},
|
|
186
|
+
get busy() {
|
|
187
|
+
return busy.v
|
|
188
|
+
},
|
|
189
|
+
get error() {
|
|
190
|
+
return error.v
|
|
191
|
+
},
|
|
192
|
+
get dirty() {
|
|
193
|
+
return now() !== saved.v
|
|
194
|
+
},
|
|
195
|
+
get checking() {
|
|
196
|
+
return checking.v > 0
|
|
197
|
+
},
|
|
198
|
+
get ok() {
|
|
199
|
+
for (const x of F) if (check(x.r, x.s.v)) return false
|
|
200
|
+
return true
|
|
201
|
+
},
|
|
202
|
+
id: path => fid(path),
|
|
203
|
+
values,
|
|
204
|
+
touch: path => byPath[path] && touch(byPath[path]),
|
|
205
|
+
errors: mark,
|
|
206
|
+
reset(init) {
|
|
207
|
+
batch(() => {
|
|
208
|
+
for (const x of F) {
|
|
209
|
+
let val = init
|
|
210
|
+
for (const k of x.path.split('.')) val = val == null ? undefined : val[k]
|
|
211
|
+
x.s.v = val === undefined ? init0(x.r) : val
|
|
212
|
+
x.e.v = ''
|
|
213
|
+
x.touched = false
|
|
214
|
+
}
|
|
215
|
+
error.v = ''
|
|
216
|
+
shown.v++
|
|
217
|
+
saved.v = untrack(now)
|
|
218
|
+
})
|
|
219
|
+
},
|
|
220
|
+
async submit(e) {
|
|
221
|
+
if (e && e.preventDefault) e.preventDefault()
|
|
222
|
+
if (untrack(() => busy.v)) return // no re-entry: a double click sends once
|
|
223
|
+
const fm = e && e.target && e.target.elements ? e.target : null
|
|
224
|
+
let first = null
|
|
225
|
+
batch(() => {
|
|
226
|
+
for (const x of F) {
|
|
227
|
+
if (!x.el && fm) track(x, fm.elements.namedItem && fm.elements.namedItem(x.path))
|
|
228
|
+
validate(x)
|
|
229
|
+
x.touched = true
|
|
230
|
+
if (!first && x.e.v) first = x
|
|
231
|
+
}
|
|
232
|
+
error.v = ''
|
|
233
|
+
shown.v++
|
|
234
|
+
})
|
|
235
|
+
if (o.validate && !first) {
|
|
236
|
+
const cross = o.validate(values()) // cross-field rules: { field: msg }
|
|
237
|
+
if (cross && Object.keys(cross).length) {
|
|
238
|
+
mark(cross)
|
|
239
|
+
first = byPath[Object.keys(cross)[0]] || null
|
|
240
|
+
if (!first) return
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (first) return void (first.el && first.el.focus && first.el.focus())
|
|
244
|
+
busy.v = true
|
|
245
|
+
if (fm) fm.setAttribute('aria-busy', 'true') // assistive tech hears the form is working
|
|
246
|
+
try {
|
|
247
|
+
if (o.check) {
|
|
248
|
+
await Promise.all(F.map(remote))
|
|
249
|
+
first = F.find(x => untrack(() => x.e.v))
|
|
250
|
+
if (first) return void (shown.v++, first.el && first.el.focus && first.el.focus())
|
|
251
|
+
}
|
|
252
|
+
if (!o.submit) return
|
|
253
|
+
const r = await o.submit(values())
|
|
254
|
+
if (r && r.errors) mark(r.errors)
|
|
255
|
+
else {
|
|
256
|
+
saved.v = untrack(now) // saved: no longer dirty
|
|
257
|
+
// `return { redirect: '/x' }` from the server function: the same page the 303 reaches without JS (§38)
|
|
258
|
+
if (r && typeof r.redirect === 'string' && r.redirect[0] === '/' && r.redirect[1] !== '/') go(r.redirect)
|
|
259
|
+
}
|
|
260
|
+
return r
|
|
261
|
+
} catch (x) {
|
|
262
|
+
if (x && x.errors) mark(x.errors)
|
|
263
|
+
// a server failure reads the same as without JS: 4xx and the author's own errors keep their message; 5xx and a
|
|
264
|
+
// TypeError (the network dropped, or a bug) get the generic one
|
|
265
|
+
else if (!(x && x.kind === 'aborted')) error.v = (x && x.message && !(x.status >= 500) && x.name !== 'TypeError' && x.message) || msg('form.error', GENERIC)
|
|
266
|
+
} finally {
|
|
267
|
+
busy.v = false
|
|
268
|
+
if (fm) fm.removeAttribute('aria-busy')
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
}
|
|
272
|
+
DIRTY.add(f)
|
|
273
|
+
if (owner) I.root(() => fx(() => () => DIRTY.delete(f)), owner)
|
|
274
|
+
return f
|
|
275
|
+
}
|