@christianirack/brainllm-runtime 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +4 -2
- package/brain-anim.js +21 -0
- package/brain-ch.js +254 -0
- package/brain-consent.js +38 -0
- package/brain-data.js +547 -0
- package/brain-dev.js +100 -0
- package/brain-env.js +160 -0
- package/brain-err.js +242 -0
- package/brain-form.js +260 -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 +58 -0
- package/brain-mock.js +80 -0
- package/brain-overlay.js +123 -0
- package/brain-persist.js +65 -0
- package/brain-router.js +353 -0
- package/brain-rpc.js +17 -0
- package/brain-schema.js +69 -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 +882 -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,353 @@
|
|
|
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
|
+
// Build signature (§35.7): the page's <meta name="brain-build">. A navigation response from another build, or the
|
|
94
|
+
// service worker announcing one, turns client navigation into full loads so old and new code never mix.
|
|
95
|
+
const build = () => {
|
|
96
|
+
const m = document.querySelector('meta[name=brain-build]')
|
|
97
|
+
return m ? m.getAttribute('content') : null
|
|
98
|
+
}
|
|
99
|
+
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)
|
|
101
|
+
if (sw && sw.addEventListener) sw.addEventListener('message', onMsg)
|
|
102
|
+
// a guard is a function, or the URL of its `_guard.jsbrain` chunk (BUILD #220): loaded the first time a navigation
|
|
103
|
+
// reaches that folder, then cached by the module map; its default export runs with the same ctx
|
|
104
|
+
const gs = Object.entries(o.guards || {})
|
|
105
|
+
.map(([pre, g]) => [pre, typeof g === 'string' ? c => import(g).then(m => m.default(c)) : g])
|
|
106
|
+
.sort((a, b) => a[0].length - b[0].length)
|
|
107
|
+
current()
|
|
108
|
+
// → true (go on) · false (cancel) · string (redirect) · null (guard failed: full load)
|
|
109
|
+
async function check(u) {
|
|
110
|
+
const url = new URL(u, location.href), path = strip(url.pathname) // guards see app paths (no base)
|
|
111
|
+
for (const [pre, g] of gs) {
|
|
112
|
+
if (!(path === pre || path.startsWith(pre.endsWith('/') ? pre : pre + '/'))) continue
|
|
113
|
+
let v
|
|
114
|
+
try {
|
|
115
|
+
v = await g({ url, path, query: Object.fromEntries(url.searchParams) })
|
|
116
|
+
} catch (e) {
|
|
117
|
+
return e && typeof e === 'object' && (R in e || 'redirect' in e) ? outcome(e) : null
|
|
118
|
+
}
|
|
119
|
+
v = outcome(v)
|
|
120
|
+
if (v !== true) return v
|
|
121
|
+
}
|
|
122
|
+
return true
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// _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
|
|
127
|
+
// region until the page arrives; fast navigations never show it, so nothing flickers.
|
|
128
|
+
const loads = o.loading || []
|
|
129
|
+
function waiting(url, my) {
|
|
130
|
+
const path = strip(new URL(url, location.href).pathname), l = loads.find(x => new RegExp(x.rx).test(path))
|
|
131
|
+
const w = { done: false, t: 0 }
|
|
132
|
+
if (!l || !o.core) return w
|
|
133
|
+
w.t = setTimeout(() => {
|
|
134
|
+
Promise.all([import(o.core), import(l.view)])
|
|
135
|
+
.then(([c, v]) => {
|
|
136
|
+
const el = document.querySelector(region), mount = c.m || c.mount
|
|
137
|
+
if (w.done || my !== seq || !el || !mount) return // the page arrived first
|
|
138
|
+
for (const e of [el, ...walkAll(el)]) if (e.$d) e.$d(), (e.$d = null)
|
|
139
|
+
for (const n of [...el.childNodes]) if (!/^(STYLE|LINK)$/.test(n.nodeName)) n.remove() // the view's CSS stays (BUILD #35)
|
|
140
|
+
el.$d = mount(v.default, el, {})
|
|
141
|
+
})
|
|
142
|
+
.catch(() => {})
|
|
143
|
+
}, 150)
|
|
144
|
+
return w
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function load(url) {
|
|
148
|
+
const my = ++seq
|
|
149
|
+
let doc, r
|
|
150
|
+
if (stale) return hard(url)
|
|
151
|
+
const slow = waiting(url, my)
|
|
152
|
+
// the previous route's background requests (prefetch) are dropped by brain-data's queue (§41.4)
|
|
153
|
+
try {
|
|
154
|
+
G.dispatchEvent && G.dispatchEvent(new Event('brain:navigate'))
|
|
155
|
+
} catch {}
|
|
156
|
+
try {
|
|
157
|
+
r = await fetch(url, { headers: { 'x-brain-nav': '1' } })
|
|
158
|
+
const b = r.headers && r.headers.get && r.headers.get('x-brain-build')
|
|
159
|
+
if (b && build() && b !== build()) return void (my === seq && hard(url)) // a new deploy: load it whole
|
|
160
|
+
const ct = (r.headers && r.headers.get && r.headers.get('content-type')) || 'text/html'
|
|
161
|
+
if (!r.ok || !/html/.test(ct)) throw Error(r.status)
|
|
162
|
+
doc = parse(await r.text())
|
|
163
|
+
} catch {
|
|
164
|
+
return void ((slow.done = true), clearTimeout(slow.t), my === seq && hard(url))
|
|
165
|
+
}
|
|
166
|
+
slow.done = true
|
|
167
|
+
clearTimeout(slow.t)
|
|
168
|
+
if (my !== seq) return // a newer navigation won
|
|
169
|
+
// another language (§37): a full load brings its own dictionaries, `lang` and `dir`; locales never mix in memory
|
|
170
|
+
const lang = d => d && d.documentElement && d.documentElement.getAttribute('lang')
|
|
171
|
+
if (lang(doc) && lang(document) && lang(doc) !== lang(document)) return hard(url)
|
|
172
|
+
if (r.redirected && r.url && G.history && r.url !== new URL(url, location.href).href) history.replaceState(history.state, '', r.url)
|
|
173
|
+
const cur = document.querySelector(region), next = doc.querySelector(region)
|
|
174
|
+
if (!cur || !next) return hard(url)
|
|
175
|
+
const swap = () => {
|
|
176
|
+
// read before the swap: afterwards the fetched page's scripts are in the document too
|
|
177
|
+
const own = document.querySelector('script[nonce]'), nonce = own && (own.nonce || own.getAttribute('nonce'))
|
|
178
|
+
for (const el of [cur, ...walkAll(cur)]) if (el.$d) el.$d(), (el.$d = null)
|
|
179
|
+
cur.replaceChildren(...[...next.childNodes].map(n => (document.adoptNode ? document.adoptNode(n) : n)))
|
|
180
|
+
// Scripts parsed by DOMParser never run: the region's own scripts (the view's mount, `e.$d = mount(...)`) are
|
|
181
|
+
// recreated so they execute. Already-imported modules come from the module map: their state survives.
|
|
182
|
+
// The page's CSP only trusts its own nonce: a recreated script carries the current page's nonce, never the one
|
|
183
|
+
// that came with the fetched page (each server response has a fresh one).
|
|
184
|
+
for (const sc of cur.querySelectorAll('script')) {
|
|
185
|
+
if (sc.type && !/^(module|text\/javascript)$/.test(sc.type)) continue
|
|
186
|
+
const n = document.createElement('script')
|
|
187
|
+
for (const a of sc.attributes) if (a.name !== 'nonce') n.setAttribute(a.name, a.value)
|
|
188
|
+
if (nonce) (n.nonce = nonce), n.setAttribute('nonce', nonce)
|
|
189
|
+
n.textContent = sc.textContent
|
|
190
|
+
sc.replaceWith(n)
|
|
191
|
+
}
|
|
192
|
+
// QA #291: the view's mount script stores its dispose (`e.$d = mount(...)`) after the swap, since module scripts
|
|
193
|
+
// run later; until then the region shows the server's HTML with no handlers. Clicks and submits there are held and
|
|
194
|
+
// replayed on the mounted view, by position, so the first click after a navigation is never lost.
|
|
195
|
+
if (!cur.$d && [...cur.querySelectorAll('script[type=module]')].some(sc => /\$d\s*=/.test(sc.textContent))) hold(cur)
|
|
196
|
+
for (const a of next.attributes || []) cur.setAttribute(a.name, a.value)
|
|
197
|
+
if (doc.title) document.title = doc.title
|
|
198
|
+
for (const s of ['meta[name=description]', 'link[rel=canonical]']) {
|
|
199
|
+
const a = document.head && document.head.querySelector(s), b = doc.querySelector(s)
|
|
200
|
+
if (a && b) a.replaceWith(document.adoptNode ? document.adoptNode(b) : b)
|
|
201
|
+
}
|
|
202
|
+
// SEO (§32): og:*, twitter:*, robots and JSON-LD follow the page; the region's own tags stay where they are
|
|
203
|
+
const tags = (r, skip) =>
|
|
204
|
+
[...((r && r.querySelectorAll && r.querySelectorAll('meta,script')) || [])].filter(e => {
|
|
205
|
+
for (let p = e.parentNode; p; p = p.parentNode) if (p === skip) return false
|
|
206
|
+
const k = e.getAttribute('property') || e.getAttribute('name') || ''
|
|
207
|
+
return e.tagName === 'SCRIPT' ? e.getAttribute('type') === 'application/ld+json' : /^(og:|twitter:|robots$)/.test(k)
|
|
208
|
+
})
|
|
209
|
+
if (document.head) {
|
|
210
|
+
for (const e of tags(document.head)) e.remove()
|
|
211
|
+
for (const e of tags(doc.head || doc, next)) document.head.appendChild(document.adoptNode ? document.adoptNode(e) : e)
|
|
212
|
+
}
|
|
213
|
+
// the next page's route params (or none): route() reads them
|
|
214
|
+
const pa = document.head && document.head.querySelector('meta[name=brain-params]'), pb = doc.querySelector('meta[name=brain-params]')
|
|
215
|
+
if (pa) pa.remove()
|
|
216
|
+
if (pb && document.head) document.head.appendChild(document.adoptNode ? document.adoptNode(pb) : pb)
|
|
217
|
+
routed()
|
|
218
|
+
current()
|
|
219
|
+
}
|
|
220
|
+
if (document.startViewTransition) {
|
|
221
|
+
const vt = document.startViewTransition(swap)
|
|
222
|
+
vt.ready.catch(() => {}) // rejects when a faster navigation skips this transition: expected, not an error
|
|
223
|
+
await vt.updateCallbackDone
|
|
224
|
+
} else swap()
|
|
225
|
+
}
|
|
226
|
+
// a full load must not be intercepted again (Navigation API): `through` lets that one navigation pass
|
|
227
|
+
const hard = url => ((through = new URL(url, location.href).href), (o.hard || (u => location.assign(u)))(url))
|
|
228
|
+
const same = u => {
|
|
229
|
+
try {
|
|
230
|
+
const x = new URL(u, location.href), b = base()
|
|
231
|
+
// 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))
|
|
233
|
+
} catch {
|
|
234
|
+
return false
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const nav = G.navigation
|
|
239
|
+
if (nav && nav.addEventListener) {
|
|
240
|
+
const h = e => {
|
|
241
|
+
if (through && e.destination.url === through) return void (through = null)
|
|
242
|
+
if (e.navigationType === 'reload') return // a reload is always a real full load (e.g. after a deploy deleted a chunk)
|
|
243
|
+
if (!e.canIntercept || e.hashChange || e.downloadRequest || e.formData || !same(e.destination.url)) return
|
|
244
|
+
if (e.cancelable && !leave()) return e.preventDefault()
|
|
245
|
+
const to = e.destination.url, from = nav.currentEntry && nav.currentEntry.url
|
|
246
|
+
if (!gs.length) return e.intercept({ handler: () => load(to) }) // scroll + focus reset handled by the browser
|
|
247
|
+
let g
|
|
248
|
+
e.intercept({
|
|
249
|
+
// Chrome 141+: decided before the URL commits (cancel = the address bar never changes; native redirect).
|
|
250
|
+
async precommitHandler(c) {
|
|
251
|
+
g = await check(to)
|
|
252
|
+
if (typeof g === 'string' && c.redirect) c.redirect(new URL(app(g), location.href).href), (g = true)
|
|
253
|
+
else if (g === false) throw new DOMException('guard', 'AbortError')
|
|
254
|
+
},
|
|
255
|
+
async handler() {
|
|
256
|
+
if (g === undefined) g = await check(to) // no precommit support: the URL already committed
|
|
257
|
+
if (g === true) return load(nav.currentEntry ? nav.currentEntry.url : to)
|
|
258
|
+
if (g === null) return hard(to)
|
|
259
|
+
nav.navigate(g === false ? from : new URL(app(g), location.href).href, { history: 'replace' })
|
|
260
|
+
},
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
nav.addEventListener('navigate', h)
|
|
264
|
+
return (active = {
|
|
265
|
+
go: (u, how) => nav.navigate(app(u), how === 'replace' ? { history: 'replace' } : undefined),
|
|
266
|
+
check,
|
|
267
|
+
stop: () => ((active = null), nav.removeEventListener('navigate', h), sw && sw.removeEventListener && sw.removeEventListener('message', onMsg)),
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Fallback: History API. Scroll position is kept in history.state so back/forward restores it.
|
|
272
|
+
const save = () => history.replaceState({ ...(history.state || {}), y: G.scrollY || 0 }, '')
|
|
273
|
+
let here = location.href
|
|
274
|
+
const go = async (u, push = true, hops = 0) => {
|
|
275
|
+
if (!hops && !leave()) return push || history.pushState(history.state, '', here) // stay (back/forward: restore the URL)
|
|
276
|
+
const g = gs.length ? await check(u) : true
|
|
277
|
+
if (g === null || hops > 5) return hard(u)
|
|
278
|
+
if (g === false) return push || history.pushState(history.state, '', here) // back/forward blocked: stay
|
|
279
|
+
if (g !== true) return go(new URL(app(g), location.href).href, push, hops + 1)
|
|
280
|
+
if (push) save(), history[push === 'replace' ? 'replaceState' : 'pushState']({ y: 0 }, '', u) // push: true | 'replace' | false (back/forward)
|
|
281
|
+
await load(u)
|
|
282
|
+
here = location.href
|
|
283
|
+
const hash = new URL(u, location.href).hash, t = hash && document.getElementById(hash.slice(1))
|
|
284
|
+
if (t) t.scrollIntoView()
|
|
285
|
+
else G.scrollTo && G.scrollTo(0, push ? 0 : (history.state && history.state.y) || 0)
|
|
286
|
+
focusMain()
|
|
287
|
+
}
|
|
288
|
+
const click = e => {
|
|
289
|
+
if (e.defaultPrevented || e.button || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
|
|
290
|
+
let a = e.target
|
|
291
|
+
while (a && a.tagName !== 'A') a = a.parentNode
|
|
292
|
+
if (!a || !a.getAttribute('href') || a.getAttribute('target') || a.hasAttribute('download') || !same(a.href || a.getAttribute('href'))) return
|
|
293
|
+
const u = new URL(a.href || a.getAttribute('href'), location.href)
|
|
294
|
+
if (u.pathname === location.pathname && u.hash) return // in-page anchor: native
|
|
295
|
+
e.preventDefault()
|
|
296
|
+
go(u.href)
|
|
297
|
+
}
|
|
298
|
+
const pop = () => go(location.href, false)
|
|
299
|
+
const focusMain = () => {
|
|
300
|
+
const m = document.querySelector(region), h = m && (m.querySelector('h1') || m)
|
|
301
|
+
if (h && h.focus) {
|
|
302
|
+
if (!h.hasAttribute('tabindex')) h.setAttribute('tabindex', '-1')
|
|
303
|
+
h.focus({ preventScroll: true }) // screen readers announce the new view
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
document.addEventListener('click', click)
|
|
307
|
+
G.addEventListener && G.addEventListener('popstate', pop)
|
|
308
|
+
return (active = {
|
|
309
|
+
go: (u, push = true) => go(app(u), push),
|
|
310
|
+
check,
|
|
311
|
+
stop: () => ((active = null), document.removeEventListener('click', click), G.removeEventListener && G.removeEventListener('popstate', pop)),
|
|
312
|
+
})
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const HOLD = ['click', 'submit']
|
|
316
|
+
function hold(el) {
|
|
317
|
+
if (el.$hold) el.$hold() // a newer navigation drops what the previous page held
|
|
318
|
+
const q = []
|
|
319
|
+
const keep = e => {
|
|
320
|
+
const path = []
|
|
321
|
+
for (let n = e.target; n && n !== el; n = n.parentNode) path.unshift([].indexOf.call(n.parentNode.childNodes, n))
|
|
322
|
+
q.push([e, path])
|
|
323
|
+
e.preventDefault()
|
|
324
|
+
e.stopImmediatePropagation()
|
|
325
|
+
}
|
|
326
|
+
const done = () => {
|
|
327
|
+
clearTimeout(t)
|
|
328
|
+
for (const k of HOLD) el.removeEventListener(k, keep, true)
|
|
329
|
+
delete el.$d
|
|
330
|
+
el.$hold = null
|
|
331
|
+
}
|
|
332
|
+
// a page whose script never mounts (it failed to load) stops holding after 5 s: later clicks act on the HTML as is
|
|
333
|
+
const t = setTimeout(done, 5000)
|
|
334
|
+
for (const k of HOLD) el.addEventListener(k, keep, true)
|
|
335
|
+
el.$hold = done
|
|
336
|
+
Object.defineProperty(el, '$d', {
|
|
337
|
+
configurable: true,
|
|
338
|
+
get: () => undefined,
|
|
339
|
+
set(v) {
|
|
340
|
+
done()
|
|
341
|
+
el.$d = v
|
|
342
|
+
for (const [e, path] of q) {
|
|
343
|
+
let n = el
|
|
344
|
+
for (const i of path) n = n && n.childNodes[i]
|
|
345
|
+
if (n) n.dispatchEvent(new e.constructor(e.type, e))
|
|
346
|
+
}
|
|
347
|
+
},
|
|
348
|
+
})
|
|
349
|
+
}
|
|
350
|
+
function walkAll(n, out = []) {
|
|
351
|
+
for (const c of n.childNodes) if (c.nodeType === 1) out.push(c), walkAll(c, out)
|
|
352
|
+
return out
|
|
353
|
+
}
|
package/brain-rpc.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
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
|
+
}
|
|
14
|
+
if (r.ok) return j
|
|
15
|
+
const e = Object.assign(Error((j && j.error) || 'rpc ' + id + ': ' + r.status), { code: 'rpc', status: r.status, id, errors: j && j.errors })
|
|
16
|
+
throw (r.status == 422 || oops(e, null, 'rpc'), e)
|
|
17
|
+
}
|
package/brain-schema.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
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
|
+
|
|
7
|
+
// '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
|
|
17
|
+
return r
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const empty = v => v == null || v === '' || (Array.isArray(v) && !v.length)
|
|
21
|
+
const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`
|
|
22
|
+
|
|
23
|
+
// Message for one value, or '' when valid.
|
|
24
|
+
export function check(r, v) {
|
|
25
|
+
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')}`
|
|
30
|
+
const one = { ...r, list: false, req: true, min: null, max: null }
|
|
31
|
+
for (let i = 0; i < v.length; i++) {
|
|
32
|
+
const e = check(one, v[i])
|
|
33
|
+
if (e) return `Item ${i + 1}: ${e}`
|
|
34
|
+
}
|
|
35
|
+
return ''
|
|
36
|
+
}
|
|
37
|
+
if (r.t === 'bool') return r.req && v !== true ? 'Required' : ''
|
|
38
|
+
if (empty(v)) return r.req ? 'Required' : ''
|
|
39
|
+
const s = String(v)
|
|
40
|
+
switch (r.t) {
|
|
41
|
+
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}`
|
|
46
|
+
return ''
|
|
47
|
+
}
|
|
48
|
+
case 'email':
|
|
49
|
+
if (!RX_EMAIL.test(s)) return 'Enter a valid email'
|
|
50
|
+
break
|
|
51
|
+
case 'url':
|
|
52
|
+
try {
|
|
53
|
+
if (!/^https?:$/.test(new URL(s).protocol)) return 'Enter a valid URL'
|
|
54
|
+
} catch {
|
|
55
|
+
return 'Enter a valid URL'
|
|
56
|
+
}
|
|
57
|
+
break
|
|
58
|
+
case 'date':
|
|
59
|
+
if (Number.isNaN(Date.parse(s))) return 'Enter a valid date'
|
|
60
|
+
return ''
|
|
61
|
+
case 'str':
|
|
62
|
+
break
|
|
63
|
+
default:
|
|
64
|
+
throw Error(`form: unknown type '${r.t}'`)
|
|
65
|
+
}
|
|
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')}`
|
|
68
|
+
return ''
|
|
69
|
+
}
|
package/brain-wc.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// BRAINLLM Web Components (SPEC §6.2): define(tag, Comp, css) exports a component as a Custom Element.
|
|
2
|
+
// Imports ./brain.js relatively. Props = attributes from Comp.p (string) or JS properties (any value).
|
|
3
|
+
import { sig, mount, _ev } from './brain.js'
|
|
4
|
+
|
|
5
|
+
const sheets = new Map()
|
|
6
|
+
// Events that do not cross the shadow root (composed: false): also delegated on each shadow root.
|
|
7
|
+
const LOCAL = ['change', 'submit', 'reset', 'select', 'toggle', 'invalid']
|
|
8
|
+
|
|
9
|
+
export function define(tag, C, css = '') {
|
|
10
|
+
const P = C.p || []
|
|
11
|
+
class E extends HTMLElement {
|
|
12
|
+
static observedAttributes = P
|
|
13
|
+
static formAssociated = !!C.form
|
|
14
|
+
constructor() {
|
|
15
|
+
super()
|
|
16
|
+
this.s = {}
|
|
17
|
+
for (const k of P) this.s[k] = sig(this.getAttribute(k))
|
|
18
|
+
this.i = C.form ? this.attachInternals() : null
|
|
19
|
+
this.x = null
|
|
20
|
+
}
|
|
21
|
+
attributeChangedCallback(k, _, v) {
|
|
22
|
+
this.s[k].v = v
|
|
23
|
+
}
|
|
24
|
+
connectedCallback() {
|
|
25
|
+
if (this.x) return
|
|
26
|
+
const r = this.shadowRoot || this.attachShadow({ mode: 'open' }) // reuses Declarative Shadow DOM
|
|
27
|
+
let sh = sheets.get(tag)
|
|
28
|
+
if (!sh) sheets.set(tag, (sh = new CSSStyleSheet())), sh.replaceSync(css)
|
|
29
|
+
r.adoptedStyleSheets = [sh]
|
|
30
|
+
if (!r.$w) {
|
|
31
|
+
r.$w = 1
|
|
32
|
+
for (const t of LOCAL) r.addEventListener(t, _ev)
|
|
33
|
+
}
|
|
34
|
+
r.textContent = '' // v0: DSD content is re-rendered, same as hydrate
|
|
35
|
+
const el = this
|
|
36
|
+
const $p = new Proxy(
|
|
37
|
+
{},
|
|
38
|
+
{
|
|
39
|
+
get: (_, k) =>
|
|
40
|
+
k in el.s
|
|
41
|
+
? el.s[k].v
|
|
42
|
+
: k === '$internals'
|
|
43
|
+
? el.i
|
|
44
|
+
: typeof k === 'string' && k.startsWith('on:')
|
|
45
|
+
? d => el.dispatchEvent(new CustomEvent(k.slice(3), { detail: d, bubbles: true, composed: true }))
|
|
46
|
+
: undefined,
|
|
47
|
+
}
|
|
48
|
+
)
|
|
49
|
+
this.x = mount(C, r, $p)
|
|
50
|
+
}
|
|
51
|
+
disconnectedCallback() {
|
|
52
|
+
// in a microtask: moving the element (disconnect and reconnect) does not destroy it
|
|
53
|
+
queueMicrotask(() => {
|
|
54
|
+
if (!this.isConnected && this.x) this.x(), (this.x = null)
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
for (const k of P)
|
|
59
|
+
Object.defineProperty(E.prototype, k, {
|
|
60
|
+
get() {
|
|
61
|
+
return this.s[k].v
|
|
62
|
+
},
|
|
63
|
+
set(v) {
|
|
64
|
+
this.s[k].v = v
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
customElements.define(tag, E)
|
|
68
|
+
return E
|
|
69
|
+
}
|