@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.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +4 -2
  3. package/brain-ai.js +129 -0
  4. package/brain-anim.js +21 -0
  5. package/brain-ch.js +254 -0
  6. package/brain-consent.js +38 -0
  7. package/brain-data.js +549 -0
  8. package/brain-dev.js +100 -0
  9. package/brain-env.js +160 -0
  10. package/brain-err.js +242 -0
  11. package/brain-form.js +275 -0
  12. package/brain-guard.js +242 -0
  13. package/brain-hydrate.js +72 -0
  14. package/brain-i18n.js +111 -0
  15. package/brain-inspect.js +745 -0
  16. package/brain-island.js +106 -0
  17. package/brain-lazy.js +88 -0
  18. package/brain-mcp.js +73 -0
  19. package/brain-mock.js +80 -0
  20. package/brain-overlay.js +123 -0
  21. package/brain-persist.js +65 -0
  22. package/brain-router.js +503 -0
  23. package/brain-rpc.js +20 -0
  24. package/brain-schema.js +93 -0
  25. package/brain-wc.js +69 -0
  26. package/brain-why.js +198 -0
  27. package/brain-worker.js +66 -0
  28. package/brain.js +421 -0
  29. package/package.json +20 -4
  30. package/server/server.mjs +1046 -0
  31. package/shim/dom.js +448 -0
  32. package/shim/probe.js +111 -0
  33. package/sw/brain-sw.js +103 -0
  34. package/themes/contrast.cssbrain +39 -0
  35. package/themes/dark.cssbrain +39 -0
  36. package/themes/glass.cssbrain +39 -0
  37. package/themes/paper.cssbrain +39 -0
  38. package/ui/button.brain +31 -0
  39. package/ui/center.brain +11 -0
  40. package/ui/checkbox.brain +13 -0
  41. package/ui/container.brain +11 -0
  42. package/ui/dialog.brain +29 -0
  43. package/ui/error-state.brain +53 -0
  44. package/ui/field.brain +34 -0
  45. package/ui/grid.brain +11 -0
  46. package/ui/input.brain +23 -0
  47. package/ui/lang-switch.brain +38 -0
  48. package/ui/menu.brain +64 -0
  49. package/ui/row.brain +12 -0
  50. package/ui/select.brain +29 -0
  51. package/ui/sidebar.brain +17 -0
  52. package/ui/spacer.brain +12 -0
  53. package/ui/stack.brain +10 -0
  54. package/ui/table.brain +73 -0
  55. package/ui/tabs.brain +33 -0
  56. package/ui/toast.brain +45 -0
package/brain-guard.js ADDED
@@ -0,0 +1,242 @@
1
+ // BRAINLLM component boundaries (INTERNALS §42). The compiler wraps every comp/, views/ and app.brain component as
2
+ // `guard(Comp, '<meta fallback text>')`; nothing for the author to write. The core routes each error to the nearest
3
+ // boundary (owner chain for fx/drv/list rows, DOM ancestors for handlers):
4
+ // - render, fx, drv, list rows: the component's effects stop and its nodes are replaced by the fallback — a short
5
+ // notice with a Retry button that remounts only this component (3 failures in 10 s → no button, no retry loop);
6
+ // - event handlers (sync, or an async one whose await rejects): the component stays as it was and the user sees a
7
+ // short toast (the error's message for HTTP errors, a generic one otherwise).
8
+ // The error itself is reported first by the core (brain-err when present, else the console). During SSR the failing
9
+ // component renders its fallback into the HTML and the error is collected in globalThis.__brainSSR for the server log.
10
+ import { _rt, fx, sig } from './brain.js'
11
+
12
+ const G = globalThis, I = _rt(), LIMIT = 3, WINDOW = 10000
13
+ const TEXT = 'This section could not load.'
14
+
15
+ // ---------- circuit breaker (§30.3) ----------
16
+ // Per instance budgets, checked as its effects run: runs of one effect per second, continuous work per flush (over budget in
17
+ // 3 flushes in a row), and, once per flush that touched it, its DOM nodes (a native count) and list rows (a walk, only when it has
18
+ // more nodes than the rows budget, at most once a second unless it grew by a quarter: QA #302, counting every 64 runs cost 250 ms per
19
+ // jsfb operation). Crossing one trips THAT instance: its effects stop, a neutral notice replaces it
20
+ // (no Retry: it would trip again), B0908 is reported with the budget, the value and the component; the rest of the
21
+ // page keeps running. Defaults below; `guard(C, fb, { rows: 200000 })` (from `<meta budget="rows:200000">`) overrides.
22
+ // nodes 200 000 and rows 100 000 (QA #302/#303): a legitimate 10,000-row table is ~80 000 nodes, measured without the
23
+ // rows walk
24
+ const BUDGET = { nodes: 200000, rows: 100000, runs: 1000, ms: 200 }
25
+ const STOPPED = 'This part stopped to protect the page.'
26
+ const OWN = new WeakMap() // root owner → instance
27
+ const now = () => (G.performance ? performance.now() : Date.now())
28
+ // work per flush: the time between two effect runs belongs to the earlier one (the hook fires as a run starts); the
29
+ // last run of a flush is settled by a microtask queued at the flush's first run
30
+ let LX = null, LT = 0, inFlush = false
31
+ const touched = new Set()
32
+ // elements under a root, and the most element children any one of them has (the rows of a list)
33
+ const count = (e, c) => {
34
+ if (e.nodeType !== 1) return
35
+ c.nodes++
36
+ let k = 0
37
+ for (const n of e.childNodes) if (n.nodeType === 1) k++, count(n, c)
38
+ if (k > c.rows) c.rows = k
39
+ }
40
+ // elements under a root: native when the DOM has it (the shim walks)
41
+ const elems = r => {
42
+ if (r.nodeType !== 1) return 0
43
+ if (r.getElementsByTagName) return 1 + r.getElementsByTagName('*').length
44
+ const c = { nodes: 0, rows: 0 }
45
+ count(r, c)
46
+ return c.nodes
47
+ }
48
+ const size = (x, t) => {
49
+ if (x.off) return
50
+ let n = 0
51
+ for (const r of x.nodes()) n += elems(r)
52
+ if (n > x.b.nodes) return x.trip('nodes', n)
53
+ if (n > x.b.rows && (t - x.rt > 1000 || n > 1.25 * x.rn)) {
54
+ x.rt = t
55
+ x.rn = n
56
+ const c = { nodes: 0, rows: 0 }
57
+ for (const r of x.nodes()) count(r, c)
58
+ if (c.rows > x.b.rows) x.trip('rows', c.rows)
59
+ }
60
+ }
61
+ const settle = t => {
62
+ if (LX && !LX.off) LX.work += t - LT
63
+ }
64
+ // a busy device can make one flush slow: only an instance over budget in 3 flushes in a row is runaway work
65
+ const close = x => {
66
+ if (x.off) return
67
+ x.slow = x.work > x.b.ms ? (x.slow || 0) + 1 : 0
68
+ if (x.slow >= 3) x.trip('ms', Math.round(x.work))
69
+ }
70
+ // the hook fires as an effect starts running: N.prototype.r is wrapped (the core has no hook for it, #288). A first run
71
+ // (no sources yet) cannot be a loop: it only closes the work of the run before it (QA #302: creating 1,000 rows is 3,000
72
+ // first runs, and the full check on each one was the slowdown).
73
+ const NP = sig(0).constructor.prototype, run = NP.r
74
+ NP.r = function () {
75
+ if (this.e)
76
+ try {
77
+ this.S ? check(this) : born()
78
+ } catch {} // the breaker never breaks a page
79
+ return run.call(this)
80
+ }
81
+ function born() {
82
+ const t = now()
83
+ settle(t)
84
+ LX = null
85
+ LT = t
86
+ }
87
+ function check(n) {
88
+ let o = n
89
+ while (o && !OWN.has(o)) o = o.p
90
+ const x = o && OWN.get(o), t = now()
91
+ settle(t)
92
+ LX = x && !x.off ? x : null
93
+ LT = t
94
+ if (!inFlush) {
95
+ inFlush = true
96
+ queueMicrotask(() => {
97
+ settle(now())
98
+ LX = null
99
+ inFlush = false
100
+ const t = now()
101
+ for (const y of touched) close(y), size(y, t), (y.work = 0)
102
+ touched.clear()
103
+ })
104
+ }
105
+ if (!LX) return
106
+ touched.add(x)
107
+ // runs of ONE effect per second (QA #290): creating or updating 1,000 rows runs 1,000 different effects once each,
108
+ // which is legitimate; a runaway loop re-runs the same effect. Runaway = over budget in 2 consecutive seconds, or 10×
109
+ // the budget inside one second.
110
+ if (t - x.t0 > 1000) (x.hot = x.max > x.b.runs && t - x.t0 < 2000 ? (x.hot || 0) + 1 : 0), (x.t0 = t), (x.max = 0), (x.per = new WeakMap())
111
+ const k = (x.per.get(n) || 0) + 1
112
+ x.per.set(n, k)
113
+ if (k > x.max) x.max = k
114
+ if (k > x.b.runs && (x.hot >= 1 || k > 10 * x.b.runs)) return x.trip('runs', k)
115
+ }
116
+
117
+ let tEl = null, tT = 0
118
+ function toast(msg) {
119
+ const d = G.document
120
+ if (!d || !d.body) return
121
+ if (!tEl) {
122
+ tEl = d.createElement('div')
123
+ tEl.setAttribute('role', 'status')
124
+ tEl.setAttribute('aria-live', 'polite')
125
+ tEl.setAttribute('class', 'brain-toast')
126
+ tEl.setAttribute('style',
127
+ 'position:fixed;left:50%;bottom:16px;transform:translateX(-50%);max-width:90vw;z-index:2147483647;padding:10px 14px;' +
128
+ 'border-radius:var(--radius-sm);border:1px solid var(--line);background:var(--surface);color:var(--fg);box-shadow:var(--shadow);font:14px/1.4 var(--font)')
129
+ d.body.appendChild(tEl)
130
+ }
131
+ tEl.textContent = msg
132
+ tEl.removeAttribute('hidden')
133
+ clearTimeout(tT)
134
+ tT = setTimeout(() => tEl.setAttribute('hidden', ''), 4000)
135
+ }
136
+ // HTTP/API errors carry a message meant for people (ApiError, rpc); anything else gets a generic line
137
+ const human = e => (e && (e.kind || e.status || e.code === 'rpc' || e.code === 'api') && e.message) || 'Something went wrong. Try again.'
138
+
139
+ export function guard(C, fb, budget) {
140
+ const W = $p => {
141
+ const own = I.owner(), end = G.document.createComment('')
142
+ let nodes = [], dispose = null, fails = []
143
+ const notice = (retry, text) => {
144
+ if (typeof fb === 'function' && !text) return I.nodes(fb())
145
+ const box = G.document.createElement('div')
146
+ box.setAttribute('role', 'alert')
147
+ box.setAttribute('class', 'brain-fallback')
148
+ box.textContent = text || fb || TEXT
149
+ if (retry) {
150
+ const b = G.document.createElement('button')
151
+ b.setAttribute('type', 'button')
152
+ b.textContent = 'Retry'
153
+ b.addEventListener('click', () => again())
154
+ box.appendChild(G.document.createTextNode(' '))
155
+ box.appendChild(b)
156
+ }
157
+ return [box]
158
+ }
159
+ const report = (e, w) => {
160
+ if (G.__brainSSR) G.__brainSSR.push({ e, w, comp: W.f || C.f || C.name || null })
161
+ }
162
+ let building = false, pending = null
163
+ const fall = (e, w, text) => {
164
+ if (building) return void (pending = pending || [e, w]) // a child effect failed while this builds: settled by build()
165
+ const now = Date.now()
166
+ fails = [...fails.filter(t => now - t < WINDOW), now]
167
+ if (dispose) dispose(), (dispose = null) // stops its fx, drv and handlers
168
+ const p = end.parentNode
169
+ I.rm(nodes)
170
+ nodes = notice(!text && fails.length < LIMIT, text)
171
+ if (p) for (const n of nodes) p.insertBefore(n, end)
172
+ report(e, w)
173
+ }
174
+ // breaker state of this instance (see NP.r above)
175
+ const x = {
176
+ b: budget ? { ...BUDGET, ...budget } : BUDGET,
177
+ t0: now(), // the first runs window belongs to this instance, not the process clock epoch
178
+ rt: -1e9, // last rows walk (performance.now() starts near 0 with the page)
179
+ rn: 0, // nodes at that walk: a list that grew by a quarter is walked again sooner
180
+ max: 0, // most runs of one effect in this second
181
+ per: new WeakMap(), // effect → its runs in this second
182
+ work: 0,
183
+ off: false,
184
+ nodes: () => nodes,
185
+ trip(k, v) {
186
+ x.off = true
187
+ const e = Object.assign(Error(`B0908: ${W.f || C.f || C.name || 'a component'} crossed its ${k} budget (${v} > ${x.b[k]}); it was stopped to protect the page`), { code: 'B0908', budget: k, value: v, limit: x.b[k], comp: W.f || C.f || null })
188
+ I.oops(e, null, 'B0908') // reported (brain-err, dev overlay, doctor); the boundary handles it below
189
+ queueMicrotask(() => fall(e, 'B0908', STOPPED)) // after the run that tripped it
190
+ },
191
+ }
192
+ const handler = (e, w) => (String(w).startsWith('on:') ? (toast(human(e)), report(e, w)) : fall(e, w))
193
+ // requests started from this component's handlers (brain-data registers them in `$c`): cancelled with it
194
+ const reqs = new Set()
195
+ const build = () =>
196
+ I.root(d => {
197
+ dispose = d
198
+ if (!G.__brainSSR) OWN.set(I.owner(), x)
199
+ x.off = false
200
+ I.guard(I.owner(), handler)
201
+ fx(() => () => reqs.forEach(c => c.abort()))
202
+ building = true
203
+ pending = null
204
+ try {
205
+ nodes = I.nodes(C($p))
206
+ } catch (e) {
207
+ I.oops(e, null, 'render') // report only (no owner: the boundary itself handles it below)
208
+ pending = [e, 'render']
209
+ } finally {
210
+ building = false
211
+ }
212
+ // the render threw, or one of its effects failed during it: the half-built nodes never reach the page
213
+ if (pending) {
214
+ d()
215
+ dispose = null
216
+ const now = Date.now()
217
+ fails = [...fails.filter(t => now - t < WINDOW), now]
218
+ nodes = notice(fails.length < LIMIT)
219
+ report(pending[0], pending[1])
220
+ pending = null
221
+ }
222
+ for (const n of nodes) if (n.nodeType === 1) (n.$g = handler), (n.$c = reqs)
223
+ }, own)
224
+ function again() {
225
+ const p = end.parentNode
226
+ I.rm(nodes)
227
+ build()
228
+ if (p) for (const n of nodes) p.insertBefore(n, end)
229
+ }
230
+ build()
231
+ // when the parent unmounts this component, whatever it shows now (content or fallback) goes with it
232
+ if (own)
233
+ I.root(() => fx(() => () => {
234
+ if (dispose) dispose()
235
+ for (const n of nodes) if (n.nodeType === 1) (n.$g = null), (n.$c = null) // see brain-dev: no node → instance links
236
+ I.rm(nodes)
237
+ }), own)
238
+ return [...nodes, end]
239
+ }
240
+ // every static the compiler puts on the component (h, f, p, form, seo, head…) stays visible on the wrapper
241
+ return Object.assign(W, C, { C })
242
+ }
@@ -0,0 +1,72 @@
1
+ // BRAINLLM hydration by adoption (#285, with COMPILER #143). An island resumes on the server's own nodes instead of
2
+ // replacing them, so internal scroll, a playing <video>, focus and CSS animations survive. No imports: the core and
3
+ // brain-island hand it what it needs (a static import of the core would make a hash cycle).
4
+ // How: while the server renders (stamp), every template clone gets `data-hk="<n>"` in render order and every dynamic
5
+ // region is wrapped as <!--[--> … <!--]--> (its anchor comment becomes `]`). While hydrating (adopter), the same render
6
+ // order claims the stamped node instead of cloning, and each region reuses what the server put there.
7
+ // Codegen (COMPILER) navigates with fc/nx instead of .firstChild/.nextSibling: on a fresh clone they are the same, on
8
+ // adopted server HTML they step over a whole region to its anchor.
9
+
10
+ // first child / next sibling, stepping over a server-rendered dynamic region to its `]` anchor
11
+ const sk = (y, d = 0) => {
12
+ for (; y; y = y.nextSibling)
13
+ if (y.data == '[') d++
14
+ else if (y.data == ']' ? !--d : !d) return y
15
+ }
16
+ export const fc = e => sk(e.firstChild)
17
+ export const nx = n => sk(n.nextSibling)
18
+
19
+ // Server render (server.mjs, the build's prerender): `const end = stamp(_rt())` before mounting, `end()` after.
20
+ export function stamp(I, doc = globalThis.document) {
21
+ let c = 0
22
+ I.adopt({
23
+ t(n) {
24
+ const x = n.cloneNode(true)
25
+ if (x.nodeType === 1) x.setAttribute('data-hk', String(++c))
26
+ return x
27
+ },
28
+ p(end, a) {
29
+ const p = end.parentNode
30
+ if (!end.$m) (end.$m = 1), (end.data = ']'), p.insertBefore(doc.createComment('['), end)
31
+ for (const n of a) p.insertBefore(n, end)
32
+ return a
33
+ },
34
+ })
35
+ return () => I.adopt(null)
36
+ }
37
+
38
+ // Hydration of the server's subtree `root` (brain-island): claims stamped nodes in render order; throws on any
39
+ // mismatch so the caller falls back to rendering fresh (a fragment template, another tag, a missing stamp).
40
+ export function adopter(root) {
41
+ const els = [...root.querySelectorAll('[data-hk]')]
42
+ if (root.getAttribute('data-hk')) els.unshift(root)
43
+ const by = new Map(els.map(e => [+e.getAttribute('data-hk'), e]))
44
+ if (!by.size) throw Error('adopt: nothing stamped')
45
+ const base = Math.min(...by.keys())
46
+ let c = 0
47
+ return {
48
+ t(n) {
49
+ const e = by.get(base + c++)
50
+ if (!e || n.nodeType !== 1 || e.tagName !== n.tagName) throw Error('adopt: the server rendered something else')
51
+ return e
52
+ },
53
+ p(end, a) {
54
+ // the region the server rendered before this anchor: from its matching `[` to `]`
55
+ const ex = []
56
+ if (end.data == ']')
57
+ for (let y = end.previousSibling, d = 0; y; y = y.previousSibling) {
58
+ if (y.data == ']') d++
59
+ else if (y.data == '[' && !d--) break
60
+ ex.push(y)
61
+ }
62
+ ex.reverse()
63
+ // texts reuse the server's text node; adopted elements are already there; anything else goes in
64
+ const out = a.map((n, i) => (n.nodeType === 3 && ex[i] && ex[i].nodeType === 3 ? (ex[i].data !== n.data && (ex[i].data = n.data), ex[i]) : n))
65
+ const keep = new Set(out)
66
+ for (const y of ex) if (!keep.has(y)) y.remove()
67
+ const p = end.parentNode
68
+ for (const n of out) if (n.parentNode !== p) p.insertBefore(n, end) // never move a node already in place
69
+ return out
70
+ },
71
+ }
72
+ }
package/brain-i18n.js ADDED
@@ -0,0 +1,111 @@
1
+ // BRAINLLM languages (INTERNALS §37): our own small standard, no library. Static `t('key')` calls are resolved by the
2
+ // compiler; this module serves what is left at runtime (plurals, dynamic keys, islands) plus formatting and the switcher.
3
+ // Dictionaries arrive per view and locale as chunks that call `add(locale, { key: text | { one, other, … } })` before
4
+ // the page mounts; only the current locale is ever loaded (switching language is a full load of the other URL).
5
+ const G = globalThis
6
+ // locale → { key: text | plural object }. One table for the whole process: the server and the prerender may load this
7
+ // module twice (their own copy and the build's), and a dictionary added through either must be seen by both (BUILD)
8
+ const D = (G.__brainDict ||= {})
9
+ const RTL = /^(ar|he|fa|ur|ps|sd|ug|yi|dv)(-|$)/i
10
+ let warned = null
11
+
12
+ export function add(loc, dict) {
13
+ Object.assign((D[loc] ||= {}), dict)
14
+ }
15
+ // The page's language: the server sets __brainLocale while it renders; in the browser it is <html lang>.
16
+ export function locale() {
17
+ return G.__brainLocale || (G.document && document.documentElement && document.documentElement.getAttribute('lang')) || 'en'
18
+ }
19
+ const def = () => (G.document && document.documentElement && document.documentElement.getAttribute('data-default-lang')) || 'en'
20
+ export const dirOf = loc => (RTL.test(loc) ? 'rtl' : 'ltr')
21
+
22
+ // t('posts.count', { n: 3 }) → '3 posts'. A plural object is chosen by Intl.PluralRules on `n` (or `count`); `=0`-style
23
+ // exact keys win. `{var}` placeholders: numbers are formatted for the locale. A missing key falls back to the default
24
+ // locale, then to the key itself (reported once in dev).
25
+ export function t(key, vars) {
26
+ const loc = locale()
27
+ let m = (D[loc] && D[loc][key]) ?? (D[def()] && D[def()][key])
28
+ if (m == null) {
29
+ if (G.__brainDev && (warned ||= new Set()) && !warned.has(key)) warned.add(key), console.warn(`i18n: missing key "${key}" for ${loc}`)
30
+ return key
31
+ }
32
+ if (typeof m === 'object') {
33
+ const n = vars && (vars.n ?? vars.count)
34
+ m = m['=' + n] ?? m[pr(loc).select(+n || 0)] ?? m.other ?? ''
35
+ if (vars && vars.n == null) vars = { ...vars, n } // `{n}` works with either name
36
+ }
37
+ return vars ? m.replace(/\{(\w+)\}/g, (s, k) => (k in vars ? (typeof vars[k] === 'number' ? fmt.number(vars[k]) : String(vars[k])) : s)) : m
38
+ }
39
+
40
+ const cache = new Map()
41
+ const memo = (k, f) => (cache.has(k) ? cache.get(k) : (cache.set(k, f()), cache.get(k)))
42
+ const pr = loc => memo('p' + loc, () => new Intl.PluralRules(loc))
43
+ const nf = (loc, o) => memo('n' + loc + JSON.stringify(o), () => new Intl.NumberFormat(loc, o))
44
+ const df = (loc, o) => memo('d' + loc + JSON.stringify(o), () => new Intl.DateTimeFormat(loc, o))
45
+ const rf = loc => memo('r' + loc, () => new Intl.RelativeTimeFormat(loc, { numeric: 'auto' }))
46
+ const UNITS = [['year', 31536e6], ['month', 2592e6], ['week', 6048e5], ['day', 864e5], ['hour', 36e5], ['minute', 6e4], ['second', 1e3]]
47
+
48
+ // Formatting for the current locale, over Intl (cached formatters).
49
+ export const fmt = {
50
+ number: (n, o) => nf(locale(), o).format(n),
51
+ money: (n, currency = 'USD', o) => nf(locale(), { style: 'currency', currency, ...o }).format(n),
52
+ date: (d, o = { dateStyle: 'medium' }) => df(locale(), o).format(new Date(d)),
53
+ // "3 days ago", "in 2 hours", "yesterday": the largest unit that fits
54
+ relative(d, now = Date.now()) {
55
+ const ms = new Date(d) - now
56
+ for (const [u, size] of UNITS) if (Math.abs(ms) >= size || u === 'second') return rf(locale()).format(Math.round(ms / size), u)
57
+ },
58
+ }
59
+
60
+ // The same page in every language of the site, from the <link rel="alternate" hreflang> tags the build emits; keeps the
61
+ // current query and hash; each language named in itself ("Deutsch", "Nederlands"). One language → [] (switcher hidden).
62
+ // While the server renders (SSR or prerender) the links come from globalThis.__brainAlternates ([{ locale, href }], set
63
+ // per route from the manifest), so the switcher is in the HTML before any JS runs (§38).
64
+ export function alternates() {
65
+ const d = G.document, cur = locale(), out = []
66
+ const links = G.__brainAlternates || (d && d.head ? [...d.head.querySelectorAll('link[rel=alternate][hreflang]')].map(l => ({ locale: l.getAttribute('hreflang'), href: l.getAttribute('href') })) : [])
67
+ const here = G.location && G.location.href
68
+ for (const l of links) {
69
+ if (l.locale === 'x-default') continue
70
+ let href = l.href
71
+ if (here) {
72
+ const u = new URL(l.href, here)
73
+ u.search = G.location.search
74
+ u.hash = G.location.hash
75
+ href = u.pathname + u.search + u.hash
76
+ }
77
+ out.push({ locale: l.locale, href, name: nameOf(l.locale), current: l.locale === cur })
78
+ }
79
+ return out.length > 1 ? out : []
80
+ }
81
+ const nameOf = loc => {
82
+ try {
83
+ const n = new Intl.DisplayNames([loc], { type: 'language' }).of(loc)
84
+ return n.charAt(0).toLocaleUpperCase(loc) + n.slice(1)
85
+ } catch {
86
+ return loc
87
+ }
88
+ }
89
+
90
+ // One-time suggestion: the browser prefers another language this site has (never an automatic redirect, §37).
91
+ // Returns { locale, href, name } once per browser, or null; `dismiss()` remembers the answer.
92
+ const SEEN = 'brain:lang-suggested'
93
+ export function suggest() {
94
+ try {
95
+ if (G.localStorage && localStorage.getItem(SEEN)) return null
96
+ } catch {}
97
+ const alts = alternates(), cur = locale()
98
+ if (!alts.length || !G.navigator) return null
99
+ for (const want of navigator.languages || [navigator.language]) {
100
+ const base = String(want).toLowerCase().split('-')[0]
101
+ if (cur.toLowerCase().split('-')[0] === base) return null // already in a language the user reads
102
+ const a = alts.find(x => x.locale.toLowerCase() === String(want).toLowerCase() || x.locale.toLowerCase().split('-')[0] === base)
103
+ if (a) return { locale: a.locale, href: a.href, name: a.name }
104
+ }
105
+ return null
106
+ }
107
+ export function dismiss() {
108
+ try {
109
+ G.localStorage && localStorage.setItem(SEEN, '1')
110
+ } catch {}
111
+ }