@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-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
|
+
}
|
package/brain-hydrate.js
ADDED
|
@@ -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
|
+
}
|