@christianirack/brainllm-runtime 0.8.12 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/brain-analytics.js +29 -5
- package/brain-consent.js +78 -17
- package/brain-google.js +113 -0
- package/brain-lazy.js +4 -1
- package/brain-links.js +4 -2
- package/brain-list.js +9 -0
- package/brain-native-vitals.js +78 -0
- package/brain-prefetch-shared.js +1 -1
- package/brain-router.js +11 -4
- package/brain-sentry.js +71 -0
- package/brain-text.js +56 -0
- package/brain-url.js +36 -3
- package/brain-vitals.js +8 -2
- package/brain.js +17 -10
- package/package.json +9 -2
- package/server/redis-cache.mjs +173 -0
- package/server/sentry.mjs +156 -0
- package/server/server.mjs +427 -139
- package/server/warmup-watchdog.mjs +9 -0
- package/server/warmup.mjs +134 -0
- package/shim/dom.js +8 -17
- package/sw/brain-sw.js +35 -2
- package/ui/dialog.brain +2 -2
package/brain-analytics.js
CHANGED
|
@@ -37,7 +37,8 @@ export function start(config = {}, env = globalThis) {
|
|
|
37
37
|
const win = env.window || env
|
|
38
38
|
const nav = env.navigator || {}
|
|
39
39
|
const loc = () => env.location || { pathname: '/', search: '', href: '/', origin: '' }
|
|
40
|
-
|
|
40
|
+
let store
|
|
41
|
+
try { store = env.localStorage } catch {} // some browsers block access to the storage object itself
|
|
41
42
|
const now = () => (env.performance ? env.performance.now() : Date.now())
|
|
42
43
|
if (!doc || config.enabled === false) return null
|
|
43
44
|
if (nav.globalPrivacyControl === true || nav.doNotTrack === '1' || win.doNotTrack === '1' || nav.webdriver) return null
|
|
@@ -45,7 +46,9 @@ export function start(config = {}, env = globalThis) {
|
|
|
45
46
|
const consented = () => {
|
|
46
47
|
if (config.consent !== 'required') return true
|
|
47
48
|
try {
|
|
48
|
-
|
|
49
|
+
const memory = env[Symbol.for('brain:consent-state')]
|
|
50
|
+
if (memory && Object.hasOwn(memory, 'analytics')) return memory.analytics === true
|
|
51
|
+
return (JSON.parse(store.getItem('brain:consent')) || {}).analytics === true
|
|
49
52
|
} catch {
|
|
50
53
|
return false
|
|
51
54
|
}
|
|
@@ -193,9 +196,11 @@ export function start(config = {}, env = globalThis) {
|
|
|
193
196
|
let maxScroll = 0
|
|
194
197
|
let path = loc().pathname
|
|
195
198
|
let stopped = false
|
|
199
|
+
let consentEpoch = 0
|
|
196
200
|
const send = () => {
|
|
197
201
|
clearTimeout(timer)
|
|
198
202
|
timer = null
|
|
203
|
+
if (!consented()) { discardDenied(); return }
|
|
199
204
|
if (!queue.length) return
|
|
200
205
|
const vid = ids()
|
|
201
206
|
while (queue.length) {
|
|
@@ -267,9 +272,25 @@ export function start(config = {}, env = globalThis) {
|
|
|
267
272
|
const listen = (t, f, target = doc) => {
|
|
268
273
|
if (!target.addEventListener) return
|
|
269
274
|
const o = { capture: true, passive: true }
|
|
270
|
-
|
|
271
|
-
|
|
275
|
+
const listener = /^(brain:consent|storage|popstate|visibilitychange|pagehide)$/.test(t) ? f : event => { if (consented()) f(event) }
|
|
276
|
+
target.addEventListener(t, listener, o)
|
|
277
|
+
off.push(() => target.removeEventListener(t, listener, o))
|
|
272
278
|
}
|
|
279
|
+
const discardDenied = () => {
|
|
280
|
+
if (!consented()) {
|
|
281
|
+
queue.length = 0; clearTimeout(timer); timer = null; pv = 0; consentEpoch++
|
|
282
|
+
clicks.length = 0; focused.clear(); typed.clear()
|
|
283
|
+
if (hover) clearTimeout(hover.t)
|
|
284
|
+
hover = null
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
listen('brain:consent', discardDenied, win)
|
|
288
|
+
listen('storage', event => {
|
|
289
|
+
if (event.key === 'brain:consent' || event.key === null) {
|
|
290
|
+
delete env[Symbol.for('brain:consent-state')]
|
|
291
|
+
discardDenied()
|
|
292
|
+
}
|
|
293
|
+
}, win)
|
|
273
294
|
const clicks = []
|
|
274
295
|
let mutations = 0
|
|
275
296
|
const Observer = env.MutationObserver
|
|
@@ -291,7 +312,9 @@ export function start(config = {}, env = globalThis) {
|
|
|
291
312
|
const seen = changes()
|
|
292
313
|
const href = loc().href
|
|
293
314
|
const active = doc.activeElement
|
|
315
|
+
const epoch = consentEpoch
|
|
294
316
|
setTimeout(() => {
|
|
317
|
+
if (epoch !== consentEpoch) return
|
|
295
318
|
if (changes() === seen && loc().href === href && doc.activeElement === active) record('dead', el)
|
|
296
319
|
}, DEAD_MS)
|
|
297
320
|
}
|
|
@@ -357,7 +380,8 @@ export function start(config = {}, env = globalThis) {
|
|
|
357
380
|
const el = actionable(e.target)
|
|
358
381
|
if (!el || hovered.has(el) || (hover && hover.el === el)) return
|
|
359
382
|
if (hover) clearTimeout(hover.t)
|
|
360
|
-
|
|
383
|
+
const epoch = consentEpoch
|
|
384
|
+
hover = { el, t: setTimeout(() => { if (epoch === consentEpoch) { hovered.add(el); hover = null; record('hover', el, HOVER_MS) } }, HOVER_MS) }
|
|
361
385
|
})
|
|
362
386
|
listen('pointerout', e => {
|
|
363
387
|
if (hover && actionable(e.target) === hover.el) clearTimeout(hover.t), (hover = null)
|
package/brain-consent.js
CHANGED
|
@@ -10,29 +10,90 @@ const TYPES = {
|
|
|
10
10
|
functional: ['functionality_storage'],
|
|
11
11
|
personal: ['personalization_storage'],
|
|
12
12
|
}
|
|
13
|
-
const
|
|
13
|
+
const MEMORY = Symbol.for('brain:consent-state')
|
|
14
|
+
const read = (env = G) => {
|
|
15
|
+
const state = {}
|
|
14
16
|
try {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
const stored = JSON.parse(env.localStorage.getItem(KEY))
|
|
18
|
+
for (const kind of Object.keys(TYPES)) if (typeof stored?.[kind] === 'boolean') state[kind] = stored[kind]
|
|
19
|
+
} catch {}
|
|
20
|
+
const memory = env[MEMORY]
|
|
21
|
+
for (const kind of Object.keys(TYPES)) if (typeof memory?.[kind] === 'boolean') state[kind] = memory[kind]
|
|
22
|
+
return state
|
|
19
23
|
}
|
|
20
|
-
const apply = state => {
|
|
24
|
+
const apply = (state, env = G) => {
|
|
21
25
|
const u = {}
|
|
22
|
-
for (const k in state) for (const t of TYPES[k] || []) u[t] = state[k] ? 'granted' : 'denied'
|
|
26
|
+
for (const k in state) for (const t of TYPES[k] || []) if (typeof state[k] === 'boolean') u[t] = state[k] ? 'granted' : 'denied'
|
|
23
27
|
if (!Object.keys(u).length) return
|
|
24
|
-
|
|
25
|
-
|
|
28
|
+
try {
|
|
29
|
+
if (typeof env.gtag === 'function') env.gtag('consent', 'update', u)
|
|
30
|
+
else if (Array.isArray(env.dataLayer)) env.dataLayer.push((function () { return arguments })('consent', 'update', u))
|
|
31
|
+
} catch {} // a blocked or broken external tag must not prevent a local privacy decision
|
|
26
32
|
}
|
|
27
|
-
|
|
28
|
-
|
|
33
|
+
const notify = env => {
|
|
34
|
+
const target = env.window || env
|
|
35
|
+
const EventType = env.Event || G.Event
|
|
36
|
+
if (target.dispatchEvent && EventType) target.dispatchEvent(new EventType('brain:consent'))
|
|
37
|
+
}
|
|
38
|
+
export function consent(kind, granted = true, env = G) {
|
|
29
39
|
if (!TYPES[kind]) throw Error(`consent: unknown kind "${kind}" (${Object.keys(TYPES).join(', ')})`)
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
40
|
+
if (typeof granted !== 'boolean') throw Error('consent: granted must be true or false')
|
|
41
|
+
if (env.__brainSSR || env.__brainRoute) return
|
|
42
|
+
const s = { ...read(env), [kind]: granted }
|
|
43
|
+
env[MEMORY] = s // a current-page decision still works when browser storage is blocked
|
|
44
|
+
try { env.localStorage.setItem(KEY, JSON.stringify(s)) } catch {}
|
|
45
|
+
// Disable the bridge/Google tag before a denied update can be processed by an already-loaded tag.
|
|
46
|
+
if (!granted) notify(env)
|
|
47
|
+
apply({ [kind]: granted }, env)
|
|
48
|
+
if (granted) notify(env)
|
|
49
|
+
}
|
|
50
|
+
export const consented = (kind, env = G) => !env.__brainSSR && !env.__brainRoute && read(env)[kind] === true
|
|
51
|
+
|
|
52
|
+
// Opt-in, non-modal controls: native buttons work with keyboard and screen readers; no custom key trap.
|
|
53
|
+
// App policy wording and any additional jurisdiction-specific requirements remain the site's responsibility.
|
|
54
|
+
export function consentBanner(options = {}, env = G) {
|
|
55
|
+
const doc = env.document
|
|
56
|
+
if (env.__brainSSR || env.__brainRoute || !doc?.body || options.enabled === false) return null
|
|
57
|
+
const region = doc.createElement('section'), choices = doc.createElement('div')
|
|
58
|
+
region.setAttribute('role', 'region'); region.setAttribute('aria-label', 'Privacy preferences')
|
|
59
|
+
region.setAttribute('data-brain-consent', '')
|
|
60
|
+
const message = doc.createElement('p')
|
|
61
|
+
message.textContent = 'Optional Google analytics stays off until you accept. You can change your choice here.'
|
|
62
|
+
choices.appendChild(message)
|
|
63
|
+
const button = (label, fn, parent) => {
|
|
64
|
+
const el = doc.createElement('button'); el.setAttribute('type', 'button'); el.textContent = label
|
|
65
|
+
el.addEventListener('click', fn); parent.appendChild(el); return el
|
|
66
|
+
}
|
|
67
|
+
const refresh = () => { choices.hidden = Object.hasOwn(read(env), 'analytics'); revoke.hidden = !consented('analytics', env) }
|
|
68
|
+
const choose = value => { consent('analytics', value, env); refresh(); manage.focus?.() }
|
|
69
|
+
button('Deny analytics', () => choose(false), choices)
|
|
70
|
+
button('Accept analytics', () => choose(true), choices)
|
|
71
|
+
region.appendChild(choices)
|
|
72
|
+
const manage = button('Manage privacy preferences', () => { choices.hidden = false; choices.querySelector('button')?.focus?.() }, region)
|
|
73
|
+
const revoke = button('Revoke analytics consent', () => choose(false), region)
|
|
74
|
+
const target = env.window || env
|
|
75
|
+
const storageChanged = event => { if (event.key === KEY || event.key === null) { delete env[MEMORY]; notify(env); refresh() } }
|
|
76
|
+
target.addEventListener?.('brain:consent', refresh)
|
|
77
|
+
target.addEventListener?.('storage', storageChanged)
|
|
78
|
+
refresh(); doc.body.appendChild(region)
|
|
79
|
+
return { element: region, stop() { target.removeEventListener?.('brain:consent', refresh); target.removeEventListener?.('storage', storageChanged); region.parentNode?.removeChild(region) } }
|
|
35
80
|
}
|
|
36
|
-
export const consented = kind => !!read()[kind]
|
|
37
81
|
|
|
38
82
|
apply(read()) // a returning visitor: their earlier answer, before any tag reads the defaults
|
|
83
|
+
|
|
84
|
+
// The public consent API also invalidates page-local decisions across tabs when no collector or banner is mounted.
|
|
85
|
+
// One page-lifetime listener is shared by the generated bridge and separately imported consent module.
|
|
86
|
+
const STORAGE_SYNC = Symbol.for('brain:consent-storage-sync')
|
|
87
|
+
const consentWindow = G.window || G
|
|
88
|
+
if (!G.__brainSSR && !G.__brainRoute && consentWindow.addEventListener && !G[STORAGE_SYNC]) {
|
|
89
|
+
G[STORAGE_SYNC] = true
|
|
90
|
+
consentWindow.addEventListener('storage', event => {
|
|
91
|
+
if (event.key !== KEY && event.key !== null) return
|
|
92
|
+
delete G[MEMORY]
|
|
93
|
+
notify(G)
|
|
94
|
+
const state = read()
|
|
95
|
+
const deniedMissing = {}
|
|
96
|
+
for (const kind of Object.keys(TYPES)) deniedMissing[kind] = state[kind] === true
|
|
97
|
+
apply(deniedMissing)
|
|
98
|
+
})
|
|
99
|
+
}
|
package/brain-google.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
const TOKEN = /^[\w.:-]{1,64}$/
|
|
2
|
+
|
|
3
|
+
// Explicit basic-mode Google bridge. It runs without the first-party collector or a native backend.
|
|
4
|
+
// Only declared event names cross the boundary: props, DOM text, field values, URLs and visitor ids never do.
|
|
5
|
+
export function startGoogle(config = {}, env = globalThis) {
|
|
6
|
+
const doc = env.document, win = env.window || env, nav = env.navigator || {}
|
|
7
|
+
const id = config.analytics || config.tagManager
|
|
8
|
+
if (env.__brainSSR || env.__brainRoute || !doc || config.mode !== 'basic' || !id || (config.analytics && config.tagManager)) return null
|
|
9
|
+
if (config.analytics ? !/^G-[A-Z0-9]{1,32}$/.test(id) : !/^GTM-[A-Z0-9]{1,32}$/.test(id)) return null
|
|
10
|
+
if (nav.globalPrivacyControl === true || nav.doNotTrack === '1' || win.doNotTrack === '1' || nav.webdriver) return null
|
|
11
|
+
const eventName = name => typeof name === 'string' && /^[A-Za-z][A-Za-z0-9_]{0,39}$/.test(name) && !/^(google_|ga_|firebase_)/i.test(name)
|
|
12
|
+
const events = Object.create(null)
|
|
13
|
+
for (const [source, name] of Object.entries(config.events || {}).slice(0, 100)) if (TOKEN.test(source) && eventName(name)) events[source] = name
|
|
14
|
+
const funnels = Object.entries(config.funnels || {}).slice(0, 20).filter(([name, steps]) => eventName(name) && Array.isArray(steps) && steps.length >= 2 && steps.length <= 20)
|
|
15
|
+
let stopped = false, initialized = false, loaded = false, lastGranted = false, loader = null
|
|
16
|
+
const pending = new Set(), off = []
|
|
17
|
+
const allowed = () => {
|
|
18
|
+
if (stopped || nav.globalPrivacyControl === true || nav.doNotTrack === '1' || win.doNotTrack === '1') return false
|
|
19
|
+
try {
|
|
20
|
+
const memory = env[Symbol.for('brain:consent-state')]
|
|
21
|
+
if (memory && Object.hasOwn(memory, 'analytics')) return memory.analytics === true
|
|
22
|
+
return JSON.parse(env.localStorage.getItem('brain:consent'))?.analytics === true
|
|
23
|
+
} catch { return false }
|
|
24
|
+
}
|
|
25
|
+
const listen = (target, type, fn) => {
|
|
26
|
+
if (!target?.addEventListener) return
|
|
27
|
+
target.addEventListener(type, fn, true)
|
|
28
|
+
off.push(() => target.removeEventListener(type, fn, true))
|
|
29
|
+
}
|
|
30
|
+
const clearPending = () => {
|
|
31
|
+
if (Array.isArray(env.dataLayer)) for (let i = env.dataLayer.length - 1; i >= 0; i--) if (pending.has(env.dataLayer[i])) env.dataLayer.splice(i, 1)
|
|
32
|
+
pending.clear()
|
|
33
|
+
}
|
|
34
|
+
const privacy = () => {
|
|
35
|
+
let origin = 'https://example.invalid/'
|
|
36
|
+
try { const url = new URL(env.location?.origin); if (/^https?:$/.test(url.protocol)) origin = url.origin + '/' } catch {}
|
|
37
|
+
return { page_location: origin, page_referrer: '', page_title: '[redacted]', send_page_view: false, allow_google_signals: false, allow_ad_personalization_signals: false }
|
|
38
|
+
}
|
|
39
|
+
const sync = () => {
|
|
40
|
+
try {
|
|
41
|
+
const granted = allowed()
|
|
42
|
+
if (config.analytics) env['ga-disable-' + id] = !granted
|
|
43
|
+
if (!granted) {
|
|
44
|
+
clearPending()
|
|
45
|
+
if (initialized && lastGranted) env.gtag('consent', 'update', { analytics_storage: 'denied' })
|
|
46
|
+
lastGranted = false
|
|
47
|
+
return false
|
|
48
|
+
}
|
|
49
|
+
if (initialized) {
|
|
50
|
+
if (!lastGranted) env.gtag('consent', 'update', { analytics_storage: 'granted' })
|
|
51
|
+
lastGranted = true
|
|
52
|
+
return true
|
|
53
|
+
}
|
|
54
|
+
// Do not adopt a malformed/preconfigured data layer. Never overwrite another integration's queue.
|
|
55
|
+
if (env.dataLayer !== undefined && !Array.isArray(env.dataLayer)) return false
|
|
56
|
+
env.dataLayer ||= []
|
|
57
|
+
if (env.gtag !== undefined && typeof env.gtag !== 'function') return false
|
|
58
|
+
env.gtag ||= function () { env.dataLayer.push(arguments) }
|
|
59
|
+
env.gtag('consent', 'default', { analytics_storage: 'denied', ad_storage: 'denied', ad_user_data: 'denied', ad_personalization: 'denied' })
|
|
60
|
+
env.gtag('consent', 'update', { analytics_storage: 'granted', ad_storage: 'denied', ad_user_data: 'denied', ad_personalization: 'denied' })
|
|
61
|
+
if (config.analytics) { env.gtag('js', new Date()); env.gtag('config', id, privacy()) }
|
|
62
|
+
else env.dataLayer.push({ 'gtm.start': Date.now(), event: 'gtm.js' })
|
|
63
|
+
loader = doc.createElement('script')
|
|
64
|
+
loader.async = true
|
|
65
|
+
loader.onload = () => { loaded = true; pending.clear() }
|
|
66
|
+
loader.src = 'https://www.googletagmanager.com/' + (config.analytics ? 'gtag/js' : 'gtm.js') + '?id=' + id
|
|
67
|
+
const parent = doc.head || doc.documentElement
|
|
68
|
+
if (!parent) return false
|
|
69
|
+
parent.appendChild(loader)
|
|
70
|
+
initialized = lastGranted = true
|
|
71
|
+
return true
|
|
72
|
+
} catch { return false }
|
|
73
|
+
}
|
|
74
|
+
const track = source => {
|
|
75
|
+
if (typeof source !== 'string' || !Object.hasOwn(events, source) || !sync() || (!loaded && pending.size >= 128)) return false
|
|
76
|
+
const name = events[source], params = privacy()
|
|
77
|
+
delete params.send_page_view
|
|
78
|
+
// A declared funnel is a list of named steps, not an inferred conversion or a separate extra event.
|
|
79
|
+
for (const [funnel, steps] of funnels) {
|
|
80
|
+
const step = Array.isArray(steps) ? steps.indexOf(name) : -1
|
|
81
|
+
if (eventName(funnel) && step >= 0 && step < 20) { params.brain_funnel = funnel; params.brain_step = step + 1; break }
|
|
82
|
+
}
|
|
83
|
+
let message
|
|
84
|
+
if (config.analytics) {
|
|
85
|
+
params.send_to = id
|
|
86
|
+
// Arguments objects are the official gtag queue shape. Keep references so revocation drops our pending events.
|
|
87
|
+
message = (function () { return arguments })('event', name, params)
|
|
88
|
+
} else message = { event: name, ...params }
|
|
89
|
+
if (!loaded) pending.add(message)
|
|
90
|
+
try { env.dataLayer.push(message); return true } catch { pending.delete(message); return false }
|
|
91
|
+
}
|
|
92
|
+
const tracked = target => {
|
|
93
|
+
let found = null
|
|
94
|
+
for (let el = target; el?.nodeType === 1; el = el.parentNode) {
|
|
95
|
+
if (el.hasAttribute('data-private')) return null
|
|
96
|
+
if (!found && el.hasAttribute('data-track')) found = el
|
|
97
|
+
}
|
|
98
|
+
return found
|
|
99
|
+
}
|
|
100
|
+
listen(doc, 'click', e => {
|
|
101
|
+
const el = tracked(e.target)
|
|
102
|
+
if (el && el.tagName !== 'FORM') track(el.getAttribute('data-track'))
|
|
103
|
+
})
|
|
104
|
+
listen(doc, 'submit', e => {
|
|
105
|
+
const el = e.target
|
|
106
|
+
if (el?.tagName === 'FORM' && tracked(el) === el) track(el.getAttribute('data-track'))
|
|
107
|
+
})
|
|
108
|
+
listen(win, 'brain:track', e => { if (e.detail && typeof e.detail.name === 'string') track(e.detail.name) })
|
|
109
|
+
listen(win, 'brain:consent', sync)
|
|
110
|
+
listen(win, 'storage', e => { if (e.key === 'brain:consent' || e.key === null) { delete env[Symbol.for('brain:consent-state')]; sync() } })
|
|
111
|
+
sync()
|
|
112
|
+
return { track, stop() { stopped = true; clearPending(); if (config.analytics) env['ga-disable-' + id] = true; for (const fn of off) fn(); if (loader?.parentNode) loader.parentNode.removeChild(loader) } }
|
|
113
|
+
}
|
package/brain-lazy.js
CHANGED
|
@@ -29,8 +29,11 @@ function reloadOnce() {
|
|
|
29
29
|
function visible(el, go) {
|
|
30
30
|
const box = { go }
|
|
31
31
|
const io = new IntersectionObserver(es => es.some(e => e.isIntersecting) && box.go?.(), { rootMargin: '200px' })
|
|
32
|
+
const doc = el.ownerDocument || G.document
|
|
33
|
+
const hidden = () => { if (doc?.visibilityState === 'hidden') box.go?.() }
|
|
34
|
+
doc?.addEventListener('visibilitychange', hidden)
|
|
32
35
|
io.observe(el)
|
|
33
|
-
return () => { box.go = null; io.disconnect() }
|
|
36
|
+
return () => { box.go = null; io.disconnect(); doc?.removeEventListener('visibilitychange', hidden) }
|
|
34
37
|
}
|
|
35
38
|
|
|
36
39
|
export function lazy(el, imp, mode, props = {}) {
|
package/brain-links.js
CHANGED
|
@@ -14,8 +14,10 @@ export default function links(lang, b, p, re, s) {
|
|
|
14
14
|
const t = tables[document.documentElement.getAttribute('lang')]
|
|
15
15
|
if (!t || !h || h[0] !== '/' || h[1] === '/') return h
|
|
16
16
|
const a = t.b && (h + '/').startsWith(t.b + '/') ? h.slice(t.b.length) || '/' : h
|
|
17
|
-
const m = /^[^?#]*/.exec(a)[0]
|
|
18
|
-
|
|
17
|
+
const m = /^[^?#]*/.exec(a)[0], key = m.replace(/\/+$/, '') || '/'
|
|
18
|
+
if (key === t.p || key.startsWith(t.p + '/')) return h
|
|
19
|
+
let n = t.s[key] || (t.re.test(key) && (key === '/' ? t.p : t.p + key))
|
|
20
|
+
if (n && key !== '/' && m.endsWith('/') && !n.endsWith('/')) n += '/'
|
|
19
21
|
return n ? t.b + n + a.slice(m.length) : h
|
|
20
22
|
}
|
|
21
23
|
G.__brainHref = to
|
package/brain-list.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Pure bounded integer iteration, outside the runtime core.
|
|
2
|
+
export function range(from, to) {
|
|
3
|
+
if (!Number.isSafeInteger(from) || !Number.isSafeInteger(to)) throw new RangeError('range requires two safe integer endpoints')
|
|
4
|
+
const length = Math.max(0, to - from)
|
|
5
|
+
if (length > 10000) throw new RangeError('range cannot emit more than 10000 integers')
|
|
6
|
+
const values = new Array(length)
|
|
7
|
+
for (let i = 0; i < length; i++) values[i] = from + i
|
|
8
|
+
return values
|
|
9
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Optional initial-document performance estimates. Only declared patterns leave the browser.
|
|
2
|
+
import { consented } from './brain-consent.js'
|
|
3
|
+
import { vitals } from './brain-vitals.js'
|
|
4
|
+
const STATE = Symbol.for('brain:native-vitals')
|
|
5
|
+
const ROUTE = /^(?:\/|(?:\/(?:[A-Za-z0-9_-]+|:[a-z][a-z0-9_]*))+)$/
|
|
6
|
+
const MAX = { LCP: 3600000, CLS: 1000, INP: 3600000, FCP: 3600000, TTFB: 3600000 }
|
|
7
|
+
export function startNativeVitals(config, env = globalThis) {
|
|
8
|
+
if (env.__brainSSR || env.__brainRoute || !env.window || !env.document || !env.location || env[STATE]) return env[STATE]
|
|
9
|
+
if (!config || typeof config.base !== 'string' || !/^(?:\/[A-Za-z0-9_.-]+)*$/.test(config.base) || config.base.split('/').some(p => p === '.' || p === '..') || !Array.isArray(config.routes) || !config.routes.length || config.routes.length > 512 || config.routes.some(p => typeof p !== 'string' || p.length > 160 || !ROUTE.test(p)) || new Set(config.routes).size !== config.routes.length) return
|
|
10
|
+
let origin
|
|
11
|
+
try { const url = new URL(env.location.href); if (!/^https?:$/.test(url.protocol)) return; origin = url.origin } catch { return }
|
|
12
|
+
const initialPath = env.location.pathname
|
|
13
|
+
if (config.base && !initialPath.startsWith(config.base + '/')) return
|
|
14
|
+
const local = config.base ? initialPath.slice(config.base.length) : initialPath
|
|
15
|
+
const pathname = local.length > 1 && local.endsWith('/') ? local.slice(0, -1) : local
|
|
16
|
+
const parts = pathname.split('/')
|
|
17
|
+
let route, score = -1
|
|
18
|
+
for (const pattern of config.routes) {
|
|
19
|
+
const wanted = pattern.split('/')
|
|
20
|
+
if (wanted.length !== parts.length) continue
|
|
21
|
+
let matches = true, literal = 0
|
|
22
|
+
for (let i = 0; i < wanted.length; i++) {
|
|
23
|
+
if (wanted[i].startsWith(':')) { if (!parts[i]) { matches = false; break } }
|
|
24
|
+
else if (wanted[i] !== parts[i]) { matches = false; break }
|
|
25
|
+
else literal++
|
|
26
|
+
}
|
|
27
|
+
if (matches && literal > score) { route = pattern; score = literal }
|
|
28
|
+
}
|
|
29
|
+
if (!route) return
|
|
30
|
+
const target = env.window, doc = env.document, fresh = new Map()
|
|
31
|
+
let active = false, disposed = false, navigated = false, off, pending, timer, generation = 0
|
|
32
|
+
const dnt = value => value === '1' || value === 'yes' || value === true
|
|
33
|
+
const permitted = () => consented('analytics', env) && !dnt(env.navigator?.doNotTrack) && !dnt(env.navigator?.msDoNotTrack) && !dnt(env.doNotTrack) && env.navigator?.globalPrivacyControl !== true
|
|
34
|
+
const deactivate = () => {
|
|
35
|
+
active = false; generation++; off?.(); off = undefined; fresh.clear()
|
|
36
|
+
doc.removeEventListener('visibilitychange', flush); target.removeEventListener('pagehide', flush)
|
|
37
|
+
pending?.abort(); pending = undefined; env.clearTimeout?.(timer); timer = undefined
|
|
38
|
+
}
|
|
39
|
+
const navigation = () => { navigated = true; deactivate() }
|
|
40
|
+
const flush = event => {
|
|
41
|
+
if (!active || !permitted()) return deactivate()
|
|
42
|
+
if (env.location.pathname !== initialPath) return navigation()
|
|
43
|
+
if (event?.type !== 'pagehide' && doc.visibilityState !== 'hidden' || !fresh.size || pending) return
|
|
44
|
+
const body = JSON.stringify({ route, consent: true, metrics: [...fresh.values()] })
|
|
45
|
+
fresh.clear()
|
|
46
|
+
if (body.length > 4096) return
|
|
47
|
+
const controller = new env.AbortController(), epoch = generation
|
|
48
|
+
pending = controller
|
|
49
|
+
timer = env.setTimeout(() => controller.abort(), 3000)
|
|
50
|
+
Promise.resolve().then(() => active && epoch === generation && permitted() ? env.fetch(origin + config.base + '/_brain/vitals', {
|
|
51
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body,
|
|
52
|
+
mode: 'same-origin', credentials: 'omit', referrerPolicy: 'no-referrer', redirect: 'error', cache: 'no-store', keepalive: true, signal: controller.signal,
|
|
53
|
+
}) : undefined).then(response => { if (response?.status === 404) state.stop() }).catch(() => {}).finally(() => {
|
|
54
|
+
if (pending === controller) { pending = undefined; env.clearTimeout(timer); timer = undefined }
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
const update = () => {
|
|
58
|
+
if (disposed) return
|
|
59
|
+
if (!permitted()) return deactivate()
|
|
60
|
+
if (active || navigated || env.location.pathname !== initialPath || typeof env.fetch !== 'function' || !env.AbortController) return
|
|
61
|
+
active = true
|
|
62
|
+
const epoch = generation
|
|
63
|
+
off = vitals(metric => {
|
|
64
|
+
if (!active || epoch !== generation || !permitted()) return
|
|
65
|
+
if (Object.hasOwn(MAX, metric.name) && Number.isFinite(metric.value) && metric.value >= 0 && metric.value <= MAX[metric.name]) fresh.set(metric.name, { name: metric.name, value: metric.value })
|
|
66
|
+
}, env)
|
|
67
|
+
doc.addEventListener('visibilitychange', flush); target.addEventListener('pagehide', flush)
|
|
68
|
+
}
|
|
69
|
+
const storage = event => { if (event.key === 'brain:consent' || event.key === null) { delete env[Symbol.for('brain:consent-state')]; update() } }
|
|
70
|
+
const state = { stop() { disposed = true; deactivate(); target.removeEventListener('brain:navigate', navigation); target.removeEventListener('popstate', navigation); target.removeEventListener('brain:consent', update); target.removeEventListener('storage', storage); delete env[STATE] } }
|
|
71
|
+
env[STATE] = state
|
|
72
|
+
target.addEventListener('brain:consent', update); target.addEventListener('storage', storage)
|
|
73
|
+
// Lifetime events only: no performance access or collection occurs before consent. Revocation must not
|
|
74
|
+
// erase knowledge that the original document has navigated away and returned to the same pathname.
|
|
75
|
+
target.addEventListener('brain:navigate', navigation); target.addEventListener('popstate', navigation)
|
|
76
|
+
update()
|
|
77
|
+
return state
|
|
78
|
+
}
|
package/brain-prefetch-shared.js
CHANGED
|
@@ -29,7 +29,7 @@ export async function prefetchPage(url, signal) {
|
|
|
29
29
|
let hit
|
|
30
30
|
while ((hit = pattern.exec(text)) && assets.length < 16) {
|
|
31
31
|
const asset = new URL(hit[1] || hit[2] || hit[3], base)
|
|
32
|
-
if (asset.origin === base.origin && asset.pathname.includes('/_b/')) assets.push(asset.href)
|
|
32
|
+
if (asset.origin === base.origin && (asset.pathname.includes('/_b/') || /\/[a-z0-9]{8}\.(?:js|mjs|jsbrain|css|cssbrain)$/.test(asset.pathname))) assets.push(asset.href)
|
|
33
33
|
}
|
|
34
34
|
return { ok: true, status: r.status, url: r.url, headers: r.headers, text, bytes, assets }
|
|
35
35
|
} catch { return null }
|
package/brain-router.js
CHANGED
|
@@ -96,6 +96,10 @@ export function router(o = {}) {
|
|
|
96
96
|
const region = o.region || 'main'
|
|
97
97
|
const parse = o.parse || (h => new DOMParser().parseFromString(h, 'text/html'))
|
|
98
98
|
const fullLoad = new Set((o.fullLoad || []).map(p => new URL(p, location.href).pathname.replace(/\/+$/, '') || '/'))
|
|
99
|
+
// COOP belongs to the document, not the swapped view. Only real head markers opt into provider popups.
|
|
100
|
+
const popup = d => [...((d.head || d.querySelector('head'))?.querySelectorAll('meta') || [])].some(m =>
|
|
101
|
+
/^(brain-google-signin|brain-apple-signin)$/i.test(m.getAttribute('name') || '') && m.getAttribute('content') === '1')
|
|
102
|
+
const popupDocument = popup(document)
|
|
99
103
|
let seq = 0, through = null, stale = false
|
|
100
104
|
const ahead = predict(o, (u, p = true) => same(u, p))
|
|
101
105
|
// Build signature (§35.7): the page's <meta name="brain-build">. A navigation response from another build, or the
|
|
@@ -205,6 +209,7 @@ export function router(o = {}) {
|
|
|
205
209
|
if (!r.ok || !/html/.test(hd('content-type') || 'html')) throw Error(r.status)
|
|
206
210
|
const doc = parse(await r.text())
|
|
207
211
|
if (my !== seq) return // a newer navigation won
|
|
212
|
+
if (popup(doc) !== popupDocument) return hard(url) // entering or leaving a popup page needs fresh HTTP headers
|
|
208
213
|
// another language (§37): a full load brings its own dictionaries, `lang` and `dir`; locales never mix in memory
|
|
209
214
|
const lang = d => d?.documentElement?.getAttribute('lang')
|
|
210
215
|
if (lang(doc) && lang(document) && lang(doc) !== lang(document)) return hard(url)
|
|
@@ -308,7 +313,7 @@ export function router(o = {}) {
|
|
|
308
313
|
const h = e => {
|
|
309
314
|
if (through && e.destination.url === through) return void (through = null)
|
|
310
315
|
if (e.navigationType === 'reload') return // a reload is always a real full load (e.g. after a deploy deleted a chunk)
|
|
311
|
-
if (!e.canIntercept || e.hashChange || e.downloadRequest || e.formData || !same(e.destination.url)) return
|
|
316
|
+
if (!e.canIntercept || e.hashChange || e.downloadRequest || e.formData || externalLink(e.sourceElement) || !same(e.destination.url)) return
|
|
312
317
|
if (e.cancelable && !leave()) return e.preventDefault()
|
|
313
318
|
const to = e.destination.url, from = nav.currentEntry?.url
|
|
314
319
|
if (G.__brainURLOnly?.(from || location.href, to)) return e.intercept({ focusReset: 'manual', handler: () => G.__brainURLRestore?.() })
|
|
@@ -362,7 +367,7 @@ export function router(o = {}) {
|
|
|
362
367
|
if (e.defaultPrevented || e.button || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
|
|
363
368
|
let a = e.target
|
|
364
369
|
while (a && a.tagName !== 'A') a = a.parentNode
|
|
365
|
-
if (!a || !a.getAttribute('href') || a.getAttribute('target') || a.hasAttribute('download') || !same(a.href || a.getAttribute('href'))) return
|
|
370
|
+
if (!a || !a.getAttribute('href') || a.getAttribute('target') || a.hasAttribute('download') || externalLink(a) || !same(a.href || a.getAttribute('href'))) return
|
|
366
371
|
const u = new URL(a.href || a.getAttribute('href'), location.href)
|
|
367
372
|
if (u.pathname === location.pathname && u.hash) return // in-page anchor: native
|
|
368
373
|
e.preventDefault()
|
|
@@ -416,7 +421,7 @@ export function predict(o = {}, same = () => false) {
|
|
|
416
421
|
return !NOPE.test(u.pathname) && !FILE.test(u.pathname) && !SECRET.test(u.search)
|
|
417
422
|
}
|
|
418
423
|
const ok = a => {
|
|
419
|
-
if (!a?.getAttribute?.('href') || a.getAttribute('target') || a.hasAttribute('download')) return null
|
|
424
|
+
if (!a?.getAttribute?.('href') || a.getAttribute('target') || a.hasAttribute('download') || externalLink(a)) return null
|
|
420
425
|
for (let n = a; n?.getAttribute; n = n.parentNode) if (n.getAttribute('data-prefetch') === 'false') return null
|
|
421
426
|
const k = key(a.href || a.getAttribute('href'))
|
|
422
427
|
return safe(k) ? k : null
|
|
@@ -426,7 +431,7 @@ export function predict(o = {}, same = () => false) {
|
|
|
426
431
|
let n = 0
|
|
427
432
|
for (const href of list) {
|
|
428
433
|
const u = key(href)
|
|
429
|
-
if (!u || pre.has(u) || pre.size > 47 || !same(u) || !new URL(u).pathname.includes('/_b/') || ++n > 8) continue
|
|
434
|
+
if (!u || pre.has(u) || pre.size > 47 || !same(u) || !(new URL(u).pathname.includes('/_b/') || /\/[a-z0-9]{8}\.(?:js|mjs|jsbrain|css|cssbrain)$/.test(new URL(u).pathname)) || ++n > 8) continue
|
|
430
435
|
const l = document.createElement('link')
|
|
431
436
|
l.setAttribute('rel', /\.(js|jsbrain)(\?|$)/.test(u) ? 'modulepreload' : 'prefetch')
|
|
432
437
|
l.setAttribute('href', u)
|
|
@@ -619,3 +624,5 @@ function hold(el) {
|
|
|
619
624
|
},
|
|
620
625
|
})
|
|
621
626
|
}
|
|
627
|
+
// An absolute navigation can intentionally belong to another site on the same origin.
|
|
628
|
+
const externalLink = a => (a?.getAttribute?.('rel') || '').split(/\s+/).some(token => token.toLowerCase() === 'external') && /^https?:\/\//i.test((a?.getAttribute?.('href') || '').trim())
|
package/brain-sentry.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Optional error reporting. Consent owns the entire capture lifetime; raw values never leave this function.
|
|
2
|
+
import { _rt } from './brain.js'
|
|
3
|
+
import { consented } from './brain-consent.js'
|
|
4
|
+
|
|
5
|
+
const TYPES = new Set(['Error', 'TypeError', 'RangeError', 'ReferenceError', 'SyntaxError', 'URIError', 'EvalError', 'AggregateError'])
|
|
6
|
+
const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/
|
|
7
|
+
const STATE = Symbol.for('brain:sentry')
|
|
8
|
+
export function startSentry(config, env = globalThis, subscribe) {
|
|
9
|
+
if (env.__brainSSR || env.__brainRoute || !env.window || !env.location || env[STATE]) return env[STATE]
|
|
10
|
+
if (!config || !config.assets || typeof config.base !== 'string' || !/^(?:\/[a-zA-Z0-9_.-]+)*$/.test(config.base) || config.base.split('/').some(p => p === '.' || p === '..')) return
|
|
11
|
+
const assets = new Map()
|
|
12
|
+
for (const [path, id] of Object.entries(config.assets)) if (path.length <= 511 && /^(?:[a-zA-Z0-9_.-]+\/)*[a-z0-9]{8}\.(?:js|jsbrain)$/.test(path) && UUID.test(id)) assets.set('/' + path, id)
|
|
13
|
+
const target = env.window, pending = new Set(), seen = new Set()
|
|
14
|
+
let active = false, disposed = false, off, windowStart = 0, count = 0, generation = 0
|
|
15
|
+
const dnt = value => value === '1' || value === 'yes' || value === true
|
|
16
|
+
const permitted = () => consented('analytics', env) && !dnt(env.navigator?.doNotTrack) && !dnt(env.navigator?.msDoNotTrack) && !dnt(env.doNotTrack) && env.navigator?.globalPrivacyControl !== true
|
|
17
|
+
const capture = error => {
|
|
18
|
+
try {
|
|
19
|
+
if (!active || !permitted() || pending.size >= 4 || !error) return
|
|
20
|
+
const stack = error.stack
|
|
21
|
+
if (typeof stack !== 'string') return
|
|
22
|
+
const frames = []
|
|
23
|
+
for (const line of stack.slice(0, 8192).split('\n').slice(0, 41)) {
|
|
24
|
+
const m = /^\s+at (?:[^\r\n()]{1,160} \()?(https?:\/\/[^\s()]+):(\d{1,7}):(\d{1,7})\)?$/.exec(line) || /^[^\s@()]{0,160}@(https?:\/\/[^\s()]+):(\d{1,7}):(\d{1,7})$/.exec(line)
|
|
25
|
+
if (!m) continue
|
|
26
|
+
const url = new URL(m[1]), id = assets.get(url.pathname)
|
|
27
|
+
if (url.origin !== env.location.origin || url.search || url.hash || !id || +m[2] < 1 || +m[3] < 1) continue
|
|
28
|
+
frames.push({ asset: url.pathname, line: +m[2], column: +m[3], debugId: id })
|
|
29
|
+
if (frames.length >= 20) break
|
|
30
|
+
}
|
|
31
|
+
if (!frames.length) return
|
|
32
|
+
const name = error.name
|
|
33
|
+
const type = TYPES.has(name) ? name : 'Error'
|
|
34
|
+
const payload = JSON.stringify({ schemaVersion: 1, type, frames })
|
|
35
|
+
const now = Date.now()
|
|
36
|
+
if (now - windowStart >= 60000) { windowStart = now; count = 0; seen.clear() }
|
|
37
|
+
if (count >= 8 || seen.has(payload) || seen.size >= 32) return
|
|
38
|
+
count++; seen.add(payload)
|
|
39
|
+
const controller = new env.AbortController()
|
|
40
|
+
const epoch = generation
|
|
41
|
+
pending.add(controller)
|
|
42
|
+
const timer = env.setTimeout(() => controller.abort(), 3000)
|
|
43
|
+
Promise.resolve().then(() => active && epoch === generation && permitted() ? env.fetch(env.location.origin + config.base + '/_brain/sentry', {
|
|
44
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body: payload,
|
|
45
|
+
mode: 'same-origin', credentials: 'omit', referrerPolicy: 'no-referrer', redirect: 'error', cache: 'no-store', signal: controller.signal,
|
|
46
|
+
}) : undefined).catch(() => {}).finally(() => { env.clearTimeout(timer); pending.delete(controller) })
|
|
47
|
+
} catch {} // reporting, including hostile accessors, never enters the app's error routing
|
|
48
|
+
}
|
|
49
|
+
const onError = event => capture(event.error)
|
|
50
|
+
const onRejection = event => capture(event.reason)
|
|
51
|
+
const deactivate = () => {
|
|
52
|
+
active = false; generation++; off?.(); off = undefined
|
|
53
|
+
target.removeEventListener('error', onError); target.removeEventListener('unhandledrejection', onRejection)
|
|
54
|
+
for (const request of pending) request.abort()
|
|
55
|
+
pending.clear(); seen.clear(); count = 0; windowStart = 0
|
|
56
|
+
}
|
|
57
|
+
const update = () => {
|
|
58
|
+
if (disposed) return
|
|
59
|
+
if (!permitted()) return deactivate()
|
|
60
|
+
if (active || typeof env.fetch !== 'function' || !env.AbortController) return
|
|
61
|
+
active = true
|
|
62
|
+
off = (subscribe || _rt().listenErrors)(capture)
|
|
63
|
+
target.addEventListener('error', onError); target.addEventListener('unhandledrejection', onRejection)
|
|
64
|
+
}
|
|
65
|
+
const storage = event => { if (event.key === 'brain:consent' || event.key === null) update() }
|
|
66
|
+
target.addEventListener('brain:consent', update); target.addEventListener('storage', storage)
|
|
67
|
+
const state = { stop() { disposed = true; deactivate(); target.removeEventListener('brain:consent', update); target.removeEventListener('storage', storage); delete env[STATE] } }
|
|
68
|
+
env[STATE] = state
|
|
69
|
+
update()
|
|
70
|
+
return state
|
|
71
|
+
}
|
package/brain-text.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Pure bounded Unicode matching; no locale, network, DOM or persistent state.
|
|
2
|
+
const letters = value => {
|
|
3
|
+
if (typeof value !== 'string' || value.length > 8192) throw new RangeError('text helpers require strings of at most 4096 Unicode characters')
|
|
4
|
+
const result = Array.from(value)
|
|
5
|
+
if (result.length > 4096) throw new RangeError('text helpers require strings of at most 4096 Unicode characters')
|
|
6
|
+
return result
|
|
7
|
+
}
|
|
8
|
+
const fold = value => {
|
|
9
|
+
letters(value)
|
|
10
|
+
const result = value.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
|
|
11
|
+
letters(result)
|
|
12
|
+
return result
|
|
13
|
+
}
|
|
14
|
+
// Optimal string alignment: adjacent transpositions count as one edit; the band and row minimum bound work.
|
|
15
|
+
const distance = (left, right, max = 2) => {
|
|
16
|
+
if (!Number.isInteger(max) || max < 0 || max > 64) throw new RangeError('text.distance max must be an integer from 0 to 64')
|
|
17
|
+
const a = letters(left), b = letters(right), n = a.length, m = b.length, cap = max + 1
|
|
18
|
+
if (Math.abs(n - m) > max) return cap
|
|
19
|
+
if (!n) return Math.min(m, cap)
|
|
20
|
+
if (!m) return Math.min(n, cap)
|
|
21
|
+
let before = new Uint16Array(m + 1).fill(cap)
|
|
22
|
+
let previous = new Uint16Array(m + 1).fill(cap)
|
|
23
|
+
let current = new Uint16Array(m + 1).fill(cap)
|
|
24
|
+
for (let j = 0; j <= Math.min(m, max); j++) previous[j] = j
|
|
25
|
+
for (let i = 1; i <= n; i++) {
|
|
26
|
+
const low = Math.max(1, i - max), high = Math.min(m, i + max)
|
|
27
|
+
current[0] = Math.min(i, cap)
|
|
28
|
+
if (low > 1) current[low - 1] = cap
|
|
29
|
+
if (high < m) current[high + 1] = cap
|
|
30
|
+
let minimum = current[0]
|
|
31
|
+
for (let j = low; j <= high; j++) {
|
|
32
|
+
let value = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1), cap)
|
|
33
|
+
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) value = Math.min(value, before[j - 2] + 1)
|
|
34
|
+
current[j] = value
|
|
35
|
+
minimum = Math.min(minimum, value)
|
|
36
|
+
}
|
|
37
|
+
if (minimum > max) return cap
|
|
38
|
+
const spare = before; before = previous; previous = current; current = spare
|
|
39
|
+
}
|
|
40
|
+
return Math.min(previous[m], cap)
|
|
41
|
+
}
|
|
42
|
+
const fuzzy = (word, list) => {
|
|
43
|
+
const needle = fold(word), size = letters(needle).length
|
|
44
|
+
if (!Array.isArray(list) || list.length > 10000) throw new RangeError('text.fuzzy requires at most 10000 candidate strings')
|
|
45
|
+
const count = list.length, max = size < 4 ? 0 : size < 8 ? 1 : 2
|
|
46
|
+
const found = []
|
|
47
|
+
let units = 0
|
|
48
|
+
for (let i = 0; i < count; i++) {
|
|
49
|
+
const candidate = list[i]
|
|
50
|
+
if (typeof candidate !== 'string' || candidate.length > 1048576 - units) throw new RangeError('text.fuzzy candidate text exceeds 1048576 UTF-16 code units or contains a non-string')
|
|
51
|
+
units += candidate.length
|
|
52
|
+
if (distance(needle, fold(candidate), max) <= max) found.push(candidate)
|
|
53
|
+
}
|
|
54
|
+
return found
|
|
55
|
+
}
|
|
56
|
+
export const text = Object.freeze({ fold, distance, fuzzy })
|