@luigisbox/luma 0.4.0 → 0.6.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/CHANGELOG.md +152 -0
- package/README.md +76 -9
- package/build/baseline.mjs +2 -2
- package/build/vite.mjs +255 -20
- package/docs/ACCESSIBILITY.md +31 -5
- package/package.json +3 -2
- package/src/analytics/chain.ts +1 -1
- package/src/analytics/pageview.ts +1 -1
- package/src/analytics/start.ts +3 -4
- package/src/analytics/wire.ts +1 -1
- package/src/api/ai.ts +32 -20
- package/src/api/token.ts +7 -7
- package/src/core/a11y.ts +6 -35
- package/src/core/context.ts +2 -0
- package/src/core/contrast-toggle.ts +32 -0
- package/src/core/default-button.ts +66 -0
- package/src/core/early-rules.ts +3 -2
- package/src/core/features.ts +109 -0
- package/src/core/frameworks-check.ts +32 -0
- package/src/core/frameworks.ts +35 -0
- package/src/core/image-load.ts +0 -26
- package/src/core/image-preload.ts +29 -0
- package/src/core/image-tags.ts +7 -1
- package/src/core/images.ts +3 -2
- package/src/core/integration.ts +42 -26
- package/src/core/listings.ts +9 -5
- package/src/core/locales.ts +2 -1
- package/src/core/own.ts +7 -3
- package/src/core/recommenders.ts +2 -2
- package/src/core/reconciler.ts +32 -23
- package/src/core/routes.ts +7 -6
- package/src/core/scheduler.ts +39 -5
- package/src/core/speculation.ts +2 -0
- package/src/core/stash.ts +15 -6
- package/src/core/styles.ts +6 -5
- package/src/core/support.ts +1 -1
- package/src/core/takeover.ts +6 -5
- package/src/core/templates.ts +5 -2
- package/src/core/tiles.ts +33 -10
- package/src/core/url.ts +6 -3
- package/src/core/widget-inits.ts +4 -1
- package/src/lit.ts +2 -1
- package/src/locales/cs.json +1 -1
- package/src/locales/de.json +1 -1
- package/src/locales/en.json +1 -1
- package/src/main.ts +70 -53
- package/src/types.ts +48 -14
- package/src/widgets/autocomplete/index.ts +167 -34
- package/src/widgets/autocomplete/style-autocomplete.css +16 -8
- package/src/widgets/autocomplete/template-autocomplete-empty.ts +1 -1
- package/src/widgets/autocomplete/template-autocomplete-sheet.ts +13 -9
- package/src/widgets/listing/applied.ts +87 -0
- package/src/widgets/listing/element.ts +279 -78
- package/src/widgets/listing/intent.ts +15 -0
- package/src/widgets/listing/query.ts +6 -3
- package/src/widgets/listing/stash.ts +33 -16
- package/src/widgets/listing/state.ts +12 -0
- package/src/widgets/listing/view.ts +2 -1
- package/src/widgets/plp/route.ts +3 -2
- package/src/widgets/recommender/index.ts +3 -2
- package/src/widgets/search/direct.ts +39 -0
- package/src/widgets/search/form-url.ts +38 -34
- package/src/widgets/search/in-place.ts +43 -108
- package/src/widgets/search/index.ts +7 -2
- package/src/widgets/search/speculate.ts +48 -27
- package/src/widgets/search/submit.ts +136 -0
package/build/vite.mjs
CHANGED
|
@@ -17,17 +17,21 @@ const FRAMEWORK = resolve(dirname(fileURLToPath(import.meta.url)), '../src');
|
|
|
17
17
|
// One lazy chunk per widget plus what they share, so a page loads few files:
|
|
18
18
|
// lbx-core lit, the template registry, a11y, core/support (i18n,
|
|
19
19
|
// images, speculation rules) and the integration's shared
|
|
20
|
-
// _global templates (the product tile): every widget's
|
|
20
|
+
// _global templates (the product tile): every widget's;
|
|
21
|
+
// a core module one chunk alone reaches goes with it
|
|
22
|
+
// (soleUser: the listing's contrast toggle, fold, …)
|
|
21
23
|
// lbx-listing search results and category pages (one listing: plp is
|
|
22
24
|
// a route of it) with the integration's search/ templates
|
|
23
25
|
// lbx-autocomplete lbx-recommender each widget with its templates
|
|
24
|
-
// lbx-plp lbx-place lbx-search lbx-speculate lbx-token
|
|
25
|
-
//
|
|
26
|
-
// search
|
|
26
|
+
// lbx-plp lbx-place lbx-search lbx-search-in-place lbx-speculate lbx-token
|
|
27
|
+
// the plp widget and its templates, placement and
|
|
28
|
+
// recommender triggers, the search page's own, a new query
|
|
29
|
+
// in place, search while typing (and Enter straight to the
|
|
30
|
+
// query's page), minting the token (chunkOf)
|
|
27
31
|
// lbx-nav what search while typing (lbx-speculate) needs, a
|
|
28
32
|
// search page's first request (widgets/search/first.ts)
|
|
29
33
|
// and what they share with the listing (the route, query
|
|
30
|
-
// building, stash,
|
|
34
|
+
// building, stash, a form's URL, URL writing): typing on
|
|
31
35
|
// any page and the listing need it, a page with only a
|
|
32
36
|
// recommender does not
|
|
33
37
|
// lbx-analytics pageviews, our tiles' clicks and add-to-carts, the
|
|
@@ -82,17 +86,21 @@ export const chunkOf = (id) => {
|
|
|
82
86
|
// Hydration of server-rendered listings: only those pages load it.
|
|
83
87
|
// lit-html's private SSR support is the hydrator's alone (not every widget page's lbx-core).
|
|
84
88
|
if (/\/node_modules\/(@lit-labs\/ssr-client\/|lit-html\/private-ssr-support\.js)/.test(id)) return 'lbx-hydrate';
|
|
85
|
-
// Helpers one widget alone uses go with it, not into lbx-core (every widget's).
|
|
86
|
-
if (/\/src\/core\/color\.ts$/.test(id)) return 'lbx-autocomplete';
|
|
87
|
-
if (/\/src\/core\/a11y-keys\.ts$/.test(id)) return 'lbx-listing';
|
|
88
89
|
// Placing our elements (core/reconciler.ts) and the recommenders' triggers:
|
|
89
90
|
// pages with a listing or a recommender, in parallel with their other chunks.
|
|
90
91
|
if (/\/src\/core\/(reconciler|recommenders)\.ts$/.test(id)) return 'lbx-place';
|
|
91
|
-
// The
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
92
|
+
// The listing's high-contrast toggle: only a site that turns it on loads it (widgets/listing/element.ts).
|
|
93
|
+
if (/\/src\/core\/contrast-toggle\.ts$/.test(id)) return 'lbx-contrast';
|
|
94
|
+
// An Apply's cue in a staged drawer: asked for when such a drawer opens (widgets/listing/element.ts).
|
|
95
|
+
if (/\/src\/widgets\/listing\/applied\.ts$/.test(id)) return 'lbx-applied';
|
|
96
|
+
// The search page's own: not in lbx-listing, which a category page loads too
|
|
97
|
+
// (2.1 kB gzip it never ran).
|
|
98
|
+
if (/\/src\/widgets\/search\/index\.ts$/.test(id)) return 'lbx-search';
|
|
99
|
+
// A new query in place, and taking a submit of the site's search form (submit.ts, direct.ts's too: a
|
|
100
|
+
// category page never takes one): asked for as lbx-search runs, not waited for (widgets/search/index.ts).
|
|
101
|
+
if (/\/src\/widgets\/search\/(in-place|submit)\.ts$/.test(id)) return 'lbx-search-in-place';
|
|
102
|
+
// Search while typing, and Enter straight to the query's page (main.ts loads both once the shopper types): not in lbx-nav, which a listing page loads at once.
|
|
103
|
+
if (/\/src\/widgets\/search\/(speculate|direct)\.ts$/.test(id)) return 'lbx-speculate';
|
|
96
104
|
// Analytics (src/analytics): every page once loaded (main.ts), widgets import it for their tiles' clicks.
|
|
97
105
|
if (/\/src\/analytics\//.test(id)) return 'lbx-analytics';
|
|
98
106
|
// Minting the browser token: a session's first page loads it at boot (main.ts), lbx-core imports it.
|
|
@@ -108,7 +116,31 @@ export const chunkOf = (id) => {
|
|
|
108
116
|
// The entry's own modules are named into the entry chunk (rollupOptions.input's
|
|
109
117
|
// key) rather than left out: Rollup would pull a module a manual chunk depends
|
|
110
118
|
// on into that chunk, entry or not.
|
|
111
|
-
|
|
119
|
+
//
|
|
120
|
+
// A module bound for lbx-core (every widget's) that one other chunk alone
|
|
121
|
+
// reaches (directly, or through other lbx-core modules) goes with that chunk:
|
|
122
|
+
// a listing's helper is not on an autocomplete page (core/fold.ts, a11y-keys,
|
|
123
|
+
// color, the contrast toggle).
|
|
124
|
+
const soleUser = (id, getModuleInfo, entry, seen = new Set([id])) => {
|
|
125
|
+
const users = new Set();
|
|
126
|
+
for (const by of [...(getModuleInfo(id)?.importers ?? []), ...(getModuleInfo(id)?.dynamicImporters ?? [])]) {
|
|
127
|
+
if (seen.has(by)) continue;
|
|
128
|
+
seen.add(by);
|
|
129
|
+
const c = entry.has(by) ? 'integration' : chunkOf(by);
|
|
130
|
+
if (c === 'lbx-core') for (const u of soleUser(by, getModuleInfo, entry, seen)) users.add(u);
|
|
131
|
+
else users.add(c);
|
|
132
|
+
}
|
|
133
|
+
return users;
|
|
134
|
+
};
|
|
135
|
+
const manualChunks = (id, { getModuleInfo, getModuleIds }) => {
|
|
136
|
+
const entry = entryGraph(getModuleInfo, getModuleIds);
|
|
137
|
+
if (entry.has(id)) return 'integration';
|
|
138
|
+
const c = chunkOf(id);
|
|
139
|
+
if (c !== 'lbx-core') return c;
|
|
140
|
+
const users = soleUser(id, getModuleInfo, entry);
|
|
141
|
+
const [only] = users;
|
|
142
|
+
return users.size === 1 && only && only !== 'integration' ? only : c;
|
|
143
|
+
};
|
|
112
144
|
|
|
113
145
|
/**
|
|
114
146
|
* Template scope members Luma no longer has, and what replaces each. A
|
|
@@ -356,7 +388,7 @@ export const dedentTemplates = (ts, code, file) => {
|
|
|
356
388
|
for (const [a, b, s] of edits.sort((x, y) => y[0] - x[0])) out = out.slice(0, a) + s + out.slice(b);
|
|
357
389
|
return out;
|
|
358
390
|
};
|
|
359
|
-
const templateText = (root) => {
|
|
391
|
+
export const templateText = (root) => {
|
|
360
392
|
let ts;
|
|
361
393
|
// Module ids are real paths: an integration/ reached through a symlink too.
|
|
362
394
|
const dir = resolve(root, 'integration');
|
|
@@ -377,6 +409,50 @@ const templateText = (root) => {
|
|
|
377
409
|
};
|
|
378
410
|
};
|
|
379
411
|
|
|
412
|
+
/**
|
|
413
|
+
* lit-html's directives (`@/lit`) as free of side effects: each module only
|
|
414
|
+
* defines its directive, and one no template uses is left out. Without this
|
|
415
|
+
* one assigning its class's static fields (unsafeHTML: `directiveName`,
|
|
416
|
+
* `resultType`) stays in lbx-core though no template uses it (0.6 kB, found
|
|
417
|
+
* 2026-10-17); lit-html's package.json declares no `sideEffects`.
|
|
418
|
+
*/
|
|
419
|
+
const pureDirectives = () => ({
|
|
420
|
+
name: 'lbx-pure-directives',
|
|
421
|
+
apply: 'build',
|
|
422
|
+
transform(code, id) {
|
|
423
|
+
if (!/\/node_modules\/lit-html\/directives\/[\w-]+\.js$/.test(id.split('?')[0])) return null;
|
|
424
|
+
return { code, map: null, moduleSideEffects: false };
|
|
425
|
+
},
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Chunks nothing loads, left out: a widget the integration has no folder for
|
|
430
|
+
* (src/core/features.ts) leaves its modules tree-shaken to nothing, and the
|
|
431
|
+
* chunk they are named into (manualChunks) would still be emitted, empty
|
|
432
|
+
* but for imports. A chunk is loaded when another one imports it, statically
|
|
433
|
+
* or dynamically, or names its file (a preload list); the entry always.
|
|
434
|
+
*/
|
|
435
|
+
const unreachable = () => ({
|
|
436
|
+
name: 'lbx-unreachable-chunks',
|
|
437
|
+
apply: 'build',
|
|
438
|
+
enforce: 'post',
|
|
439
|
+
generateBundle(_, bundle) {
|
|
440
|
+
for (let pruned = true; pruned; ) {
|
|
441
|
+
pruned = false;
|
|
442
|
+
const chunks = Object.values(bundle).filter((c) => c.type === 'chunk');
|
|
443
|
+
for (const c of chunks) {
|
|
444
|
+
if (c.isEntry) continue;
|
|
445
|
+
const base = c.fileName.split('/').pop();
|
|
446
|
+
const used = chunks.some((o) => o !== c && (o.imports.includes(c.fileName) || o.dynamicImports.includes(c.fileName) || o.code.includes(base)));
|
|
447
|
+
if (!used) {
|
|
448
|
+
delete bundle[c.fileName];
|
|
449
|
+
pruned = true;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
},
|
|
454
|
+
});
|
|
455
|
+
|
|
380
456
|
/**
|
|
381
457
|
* A second minifier after Vite's esbuild pass: terser (two compress passes,
|
|
382
458
|
* mangled top-level names in each module) finds what esbuild leaves (~3 %
|
|
@@ -419,6 +495,14 @@ const terse = () => ({
|
|
|
419
495
|
* handles CSS dependencies, a <meta> nonce, a `preload` fallback and a
|
|
420
496
|
* vite:preloadError event, none of which a Luma build uses: ~1 kB gzip of the
|
|
421
497
|
* entry. A failed preload is the import's failure (it fetches the same file).
|
|
498
|
+
*
|
|
499
|
+
* `deps` starts with the imported chunk itself, even when it is the only one
|
|
500
|
+
* (resolveDependencies below; Vite's is [] then), so that it is marked: no
|
|
501
|
+
* later import's link preloads a chunk already imported or in flight. WebKit
|
|
502
|
+
* fetches such a link's chunk again and never uses the response (lbx-token
|
|
503
|
+
* and lbx-place, imported by the entry, then a listing's dependencies: twice
|
|
504
|
+
* per document, 2026-10-15). Its own link stays: Firefox holds the page's
|
|
505
|
+
* load event for a chunk an import() fetches, not for one a link does.
|
|
422
506
|
*/
|
|
423
507
|
export const PRELOAD_HELPER = `const seen = {};
|
|
424
508
|
export const __vitePreload = (load, deps, base) => {
|
|
@@ -444,10 +528,159 @@ const preloadHelper = () => ({
|
|
|
444
528
|
},
|
|
445
529
|
});
|
|
446
530
|
|
|
531
|
+
/**
|
|
532
|
+
* The widgets the integration has (src/core/features.ts): a widget is its
|
|
533
|
+
* folder of integration/, analytics an _analytics.ts in any. Defined for a
|
|
534
|
+
* build from the folders (the entry leaves out a missing widget's triggers);
|
|
535
|
+
* the dev server sees folders come and go: everything there.
|
|
536
|
+
*/
|
|
537
|
+
/** Options on by default whose reads fold where no file of the integration names them (src/core/features.ts NAMED_*). */
|
|
538
|
+
export const ON_BY_DEFAULT = ['preconnectImages', 'prefetchFilters', 'preload', 'fade', 'restoreOnBack', 'prefetchNextPage', 'inPlaceQuery', 'prefetchWhileTyping', 'earlyHold', 'speculation'];
|
|
539
|
+
|
|
540
|
+
export const featureDefines = (integrationDir) => {
|
|
541
|
+
const folders = existsSync(integrationDir) ? readdirSync(integrationDir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name) : null;
|
|
542
|
+
// A recommender folder is `recommender <name>` (src/core/integration.ts slot: checked there in development only).
|
|
543
|
+
for (const f of folders ?? []) if (f.trim() === 'recommender') throw new Error(`integration/${f}: a recommender folder needs a name, e.g. "recommender home"`);
|
|
544
|
+
const has = (f) => String(!folders || f(folders));
|
|
545
|
+
// The integration repository's code and markup (integration/ and anything
|
|
546
|
+
// it imports from beside it; not node_modules, builds or hidden folders),
|
|
547
|
+
// read once: a feature only its own files can ask for (a template's
|
|
548
|
+
// attribute, a hook _init.ts sets).
|
|
549
|
+
let text;
|
|
550
|
+
const read = (dir) =>
|
|
551
|
+
readdirSync(dir, { withFileTypes: true }).flatMap((e) =>
|
|
552
|
+
e.name.startsWith('.') || e.name === 'node_modules' || e.name === 'dist' ? [] : e.isDirectory() ? read(resolve(dir, e.name)) : /\.(ts|tsx|js|jsx|mts|mjs|cts|cjs|html)$/.test(e.name) ? [readFileSync(resolve(dir, e.name), 'utf8')] : [],
|
|
553
|
+
);
|
|
554
|
+
const inFiles = (...needles) => ((text ??= read(dirname(integrationDir)).join('\n')), needles.some((n) => text.includes(n)));
|
|
555
|
+
return {
|
|
556
|
+
__LBX_AUTOCOMPLETE__: has((d) => d.includes('autocomplete')),
|
|
557
|
+
__LBX_SEARCH__: has((d) => d.includes('search')),
|
|
558
|
+
__LBX_PLP__: has((d) => d.includes('plp')),
|
|
559
|
+
__LBX_RECOMMENDER__: has((d) => d.some((f) => f.split(/\s+/)[0] === 'recommender')),
|
|
560
|
+
__LBX_ANALYTICS__: has((d) => d.some((f) => existsSync(resolve(integrationDir, f, '_analytics.ts')))),
|
|
561
|
+
__LBX_WIDGET_INITS__: has((d) => d.some((f) => existsSync(resolve(integrationDir, f, '_widget.ts')))),
|
|
562
|
+
__LBX_HOST_STYLES__: has((d) => d.some((f) => existsSync(resolve(integrationDir, f, 'style-host.css')))),
|
|
563
|
+
__LBX_TRANSLATIONS__: has((d) => d.some((f) => existsSync(resolve(integrationDir, f, '_translations.ts')))),
|
|
564
|
+
__LBX_INITS__: has((d) => d.some((f) => existsSync(resolve(integrationDir, f, '_init.ts')))),
|
|
565
|
+
// Only an integration's templates make a cart button, a filter drawer or a menu (Luma's own have none).
|
|
566
|
+
__LBX_CART__: has(() => inFiles('data-lbx-cart')),
|
|
567
|
+
__LBX_DRAWER__: has(() => inFiles('data-lbx-drawer')),
|
|
568
|
+
__LBX_MENU__: has(() => inFiles('data-lbx-menu')),
|
|
569
|
+
__LBX_DROPDOWN__: has(() => inFiles('data-lbx-dropdown')),
|
|
570
|
+
// Hooks only the integration's code sets (_init.ts, _widget.ts: functions, never in _config.ts).
|
|
571
|
+
__LBX_AC_HOOKS__: has(() => inFiles('onOpen', 'onClose', 'onSelect', 'onReady')),
|
|
572
|
+
__LBX_ON_RENDER__: has(() => inFiles('onRender')),
|
|
573
|
+
__LBX_READ_QUERY__: has(() => inFiles('readQuery')),
|
|
574
|
+
__LBX_FACETS_FOR__: has(() => inFiles('facetsFor')),
|
|
575
|
+
__LBX_QUERY_URL__: has(() => inFiles('queryUrl')),
|
|
576
|
+
// Options off by default: a key no file names keeps its default.
|
|
577
|
+
__LBX_EMPTY_VALUES__: has(() => inFiles('emptyValues')),
|
|
578
|
+
__LBX_PRERENDER_TYPING__: has(() => inFiles('prerenderWhileTyping', 'pageWhileTyping')),
|
|
579
|
+
__LBX_CONTENT_VISIBILITY__: has(() => inFiles('contentVisibility')),
|
|
580
|
+
// Options on by default: one no file names is on.
|
|
581
|
+
...Object.fromEntries(ON_BY_DEFAULT.map((k) => [`__LBX_NAMED_${k.replace(/[A-Z]/g, (c) => `_${c}`).toUpperCase()}__`, has(() => inFiles(k))])),
|
|
582
|
+
};
|
|
583
|
+
};
|
|
584
|
+
/** Luma's dictionaries' namespaces (src/locales/*.json) a build without the widget leaves out: plp reads search's too. */
|
|
585
|
+
export const LOCALE_NAMESPACES = { autocomplete: ['AUTOCOMPLETE'], search: ['SEARCH', 'PLP'], plp: ['PLP'], recommender: ['RECOMMENDER'] };
|
|
586
|
+
export const pruneDictionary = (json, defines) => {
|
|
587
|
+
const dict = JSON.parse(json);
|
|
588
|
+
for (const [ns, flags] of Object.entries(LOCALE_NAMESPACES)) if (flags.every((f) => defines[`__LBX_${f}__`] === 'false')) delete dict[ns];
|
|
589
|
+
return JSON.stringify(dict);
|
|
590
|
+
};
|
|
591
|
+
const features = (root) => {
|
|
592
|
+
let defines = null;
|
|
593
|
+
return {
|
|
594
|
+
name: 'lbx-features',
|
|
595
|
+
enforce: 'pre',
|
|
596
|
+
config(_, { command }) {
|
|
597
|
+
if (command !== 'build') return undefined;
|
|
598
|
+
defines = featureDefines(resolve(root, 'integration'));
|
|
599
|
+
return { define: defines };
|
|
600
|
+
},
|
|
601
|
+
// Before Vite's JSON plugin: the dictionary as JSON.
|
|
602
|
+
transform(code, id) {
|
|
603
|
+
if (!defines || !id.startsWith(FRAMEWORK) || !/\/locales\/[\w-]+\.json$/.test(id.split('?')[0])) return null;
|
|
604
|
+
return { code: pruneDictionary(code, defines), map: null };
|
|
605
|
+
},
|
|
606
|
+
};
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* The locales the site uses (`locales` of clientConfig: a list), so the
|
|
611
|
+
* entry's map of Luma's dictionaries (src/core/locales.ts, a glob) has those
|
|
612
|
+
* and English only: ~20 B gzip on every page per locale left out. A locale not
|
|
613
|
+
* listed reads English (loadDictionary's fallback). Not declared: every one.
|
|
614
|
+
*/
|
|
615
|
+
export const LOCALES = readdirSync(resolve(FRAMEWORK, 'locales')).filter((f) => f.endsWith('.json')).map((f) => f.slice(0, -5));
|
|
616
|
+
export const localeGlob = (locales) => {
|
|
617
|
+
if (locales === undefined) return null;
|
|
618
|
+
if (!Array.isArray(locales) || !locales.length) throw new Error(`clientConfig: locales is a list of locales (${LOCALES.join(', ')}), not ${JSON.stringify(locales)}`);
|
|
619
|
+
for (const l of locales) if (!LOCALES.includes(l)) throw new Error(`clientConfig: Luma has no dictionary for locale ${JSON.stringify(l)}: one of ${LOCALES.join(', ')}`);
|
|
620
|
+
return [...new Set(['en', ...locales])].sort().map((l) => `../locales/${l}.json`);
|
|
621
|
+
};
|
|
622
|
+
const onlyLocales = (locales) => {
|
|
623
|
+
const glob = localeGlob(locales);
|
|
624
|
+
return {
|
|
625
|
+
name: 'lbx-only-locales',
|
|
626
|
+
enforce: 'pre',
|
|
627
|
+
transform(code, id) {
|
|
628
|
+
if (!glob || id.split('?')[0] !== resolve(FRAMEWORK, 'core/locales.ts')) return null;
|
|
629
|
+
const from = "import.meta.glob<Dictionary>('../locales/*.json',";
|
|
630
|
+
if (!code.includes(from)) return this.error('src/core/locales.ts: its dictionaries glob changed; update onlyLocales');
|
|
631
|
+
return { code: code.replace(from, `import.meta.glob<Dictionary>(${JSON.stringify(glob)},`), map: null };
|
|
632
|
+
},
|
|
633
|
+
};
|
|
634
|
+
};
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Luma's support for the site's framework, in parts (src/core/frameworks.ts):
|
|
638
|
+
* react React roots and islands (hydration, Suspense)
|
|
639
|
+
* documentReact React hydrating the whole document (the entry's checks)
|
|
640
|
+
* next Next.js pages router (its router, its hydration measure)
|
|
641
|
+
* vue Vue 2 / 3 and Nuxt (mount, router)
|
|
642
|
+
* angular Angular hydration
|
|
643
|
+
* sveltekit SvelteKit (its root, its history entries)
|
|
644
|
+
* astro Astro (islands, ClientRouter history entries)
|
|
645
|
+
* turbo Turbo Drive / Turbolinks (snapshots, history, prefetch)
|
|
646
|
+
* A build has all of them unless the integration declares its framework(s)
|
|
647
|
+
* (`framework` of clientConfig: a name or a list): then only theirs, the rest
|
|
648
|
+
* left out of every chunk. Measured on the test fixture 2026-10-17: see
|
|
649
|
+
* test/browser/specs/budgets.spec.ts.
|
|
650
|
+
*/
|
|
651
|
+
export const SUPPORTS = ['react', 'documentReact', 'next', 'vue', 'angular', 'sveltekit', 'astro', 'turbo'];
|
|
652
|
+
export const FRAMEWORKS = {
|
|
653
|
+
next: ['react', 'documentReact', 'next'],
|
|
654
|
+
remix: ['react', 'documentReact'],
|
|
655
|
+
'react-router': ['react', 'documentReact'],
|
|
656
|
+
react: ['react'],
|
|
657
|
+
vue: ['vue'],
|
|
658
|
+
nuxt: ['vue'],
|
|
659
|
+
angular: ['angular'],
|
|
660
|
+
svelte: [],
|
|
661
|
+
sveltekit: ['sveltekit'],
|
|
662
|
+
astro: ['astro'],
|
|
663
|
+
turbo: ['turbo'],
|
|
664
|
+
none: [],
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
/** The defines of a declaration (undefined: everything). */
|
|
668
|
+
export const frameworkDefines = (framework) => {
|
|
669
|
+
const names = framework === undefined ? null : [framework].flat();
|
|
670
|
+
for (const f of names ?? [])
|
|
671
|
+
if (typeof f !== 'string' || !Object.hasOwn(FRAMEWORKS, f))
|
|
672
|
+
throw new Error(`clientConfig: unknown framework ${JSON.stringify(f)}: one of ${Object.keys(FRAMEWORKS).join(', ')}, a list of them, or leave it out`);
|
|
673
|
+
if (names && !names.length) throw new Error('clientConfig: framework is an empty list: name the site\'s framework(s), none, or leave it out');
|
|
674
|
+
const has = new Set(names ? names.flatMap((f) => FRAMEWORKS[f]) : SUPPORTS);
|
|
675
|
+
return Object.fromEntries(SUPPORTS.map((k) => [`__LBX_${k.replace(/[A-Z]/g, (c) => `_${c}`).toUpperCase()}__`, String(has.has(k))]));
|
|
676
|
+
};
|
|
677
|
+
|
|
447
678
|
/**
|
|
448
679
|
* `overrides` are merged into the config; `plugins` are appended and `server`
|
|
449
680
|
* is merged, so a client repo can add e.g. HTTPS for the loader's development
|
|
450
|
-
* mode without losing ours.
|
|
681
|
+
* mode without losing ours. `framework` (FRAMEWORKS), `serverRendering`
|
|
682
|
+
* (false: the site has no server-rendered listings, src/core/features.ts SSR)
|
|
683
|
+
* and `locales` (onlyLocales) are Luma's, not Vite's.
|
|
451
684
|
*/
|
|
452
685
|
export const clientConfig = (configUrl, overrides = {}) => {
|
|
453
686
|
const root = dirname(fileURLToPath(configUrl));
|
|
@@ -456,11 +689,12 @@ export const clientConfig = (configUrl, overrides = {}) => {
|
|
|
456
689
|
// may read it. Unset: any origin (convenient, but any page open in the
|
|
457
690
|
// developer's browser could read the source while the server runs).
|
|
458
691
|
const devOrigins = process.env.LBX_DEV_ORIGINS?.split(',').map((o) => o.trim()).filter(Boolean);
|
|
459
|
-
const { plugins = [], server = {}, ...rest } = overrides;
|
|
692
|
+
const { plugins = [], server = {}, framework, serverRendering, locales, ...rest } = overrides;
|
|
693
|
+
if (serverRendering !== undefined && typeof serverRendering !== 'boolean') throw new Error(`clientConfig: serverRendering is true or false, not ${JSON.stringify(serverRendering)}`);
|
|
460
694
|
return {
|
|
461
695
|
root,
|
|
462
696
|
resolve: { alias: { '@': FRAMEWORK } },
|
|
463
|
-
define: { __VERSION__: JSON.stringify(new Date().toISOString()) },
|
|
697
|
+
define: { __VERSION__: JSON.stringify(new Date().toISOString()), ...frameworkDefines(framework), __LBX_SSR__: String(serverRendering !== false) },
|
|
464
698
|
// css.postcss: {} stops Vite from picking up a PostCSS config from parent dirs.
|
|
465
699
|
css: { postcss: {} },
|
|
466
700
|
// Class fields are fields (tsconfig.json's ES2022), wherever Luma is: Vite
|
|
@@ -471,7 +705,7 @@ export const clientConfig = (configUrl, overrides = {}) => {
|
|
|
471
705
|
// pre-bundled as a dependency: installed under node_modules/, Vite would.
|
|
472
706
|
optimizeDeps: { exclude: ['@'] },
|
|
473
707
|
base: './',
|
|
474
|
-
plugins: [preloadHelper(), scopeCheck(), unusedBuiltIns(root), rawCss(), templateText(root), terse(), ...plugins],
|
|
708
|
+
plugins: [preloadHelper(), scopeCheck(), features(root), onlyLocales(locales), unusedBuiltIns(root), rawCss(), templateText(root), pureDirectives(), terse(), unreachable(), ...plugins],
|
|
475
709
|
server: {
|
|
476
710
|
port: 8069,
|
|
477
711
|
strictPort: true,
|
|
@@ -488,7 +722,8 @@ export const clientConfig = (configUrl, overrides = {}) => {
|
|
|
488
722
|
// Syntax only: APIs are not polyfilled, which `yarn luma baseline` checks.
|
|
489
723
|
target: 'baseline-widely-available',
|
|
490
724
|
// Preload a lazy chunk's own dependencies in parallel with it.
|
|
491
|
-
|
|
725
|
+
// Each import's own chunk first, even alone (Vite's []): the helper marks it.
|
|
726
|
+
modulePreload: { polyfill: false, resolveDependencies: (file, deps) => [file, ...deps.filter((d) => d !== file)] },
|
|
492
727
|
rollupOptions: {
|
|
493
728
|
input: { integration: resolve(root, 'integration/__integration.ts') },
|
|
494
729
|
output: {
|
package/docs/ACCESSIBILITY.md
CHANGED
|
@@ -105,7 +105,8 @@ guessed alt text, and forced colours and the OS setting are honoured.
|
|
|
105
105
|
the fallback products another, named by their heading and the note that
|
|
106
106
|
they are not results; the help box's phone link is a plain link. "Show all
|
|
107
107
|
products" is a real link (`href`: the
|
|
108
|
-
search page the site's form would submit to
|
|
108
|
+
search page the site's form would submit to, or with `search.queryUrl` the
|
|
109
|
+
query's page, the one Enter loads) and the open dropdown's one
|
|
109
110
|
tab stop: Tab from the input reaches it (the options' selection cleared:
|
|
110
111
|
one thing lit), Shift+Tab goes back, Escape closes and goes back, Tab moves
|
|
111
112
|
on to the page's next control after the input. It is also the arrow keys'
|
|
@@ -133,7 +134,11 @@ guessed alt text, and forced colours and the OS setting are honoured.
|
|
|
133
134
|
`aria-activedescendant` the selected option: the same shadow root); the
|
|
134
135
|
site's input is left as it was. The back button is named "Close search",
|
|
135
136
|
the clear button "Clear"; the type tabs are a `tablist` (arrow keys move
|
|
136
|
-
between them)
|
|
137
|
+
between them), shown for two types or more (one type: no tabs, its
|
|
138
|
+
listbox named by the type, "Products"); a finger's vertical swipe
|
|
139
|
+
through the list puts the on-screen keyboard away: focus moves from the
|
|
140
|
+
input to the sheet (`tabindex="-1"`), Escape still closes it, a tap on the
|
|
141
|
+
input brings the keyboard back; Tab is the browser's; targets are 44 px; Escape and the
|
|
137
142
|
browser's Back close it.
|
|
138
143
|
- **Forced colours**: the selection in `Highlight`/`HighlightText` with an
|
|
139
144
|
outline, the panel (and the sheet) keep a `CanvasText` border, pills,
|
|
@@ -165,7 +170,10 @@ guessed alt text, and forced colours and the OS setting are honoured.
|
|
|
165
170
|
opened from the keyboard, focus goes to its selected item
|
|
166
171
|
(`aria-selected`/`aria-current`/`aria-checked`), else the first; arrows move
|
|
167
172
|
through its items; Escape closes it and focus returns to the button; picking
|
|
168
|
-
an item returns focus to the button; Tab out of it closes it.
|
|
173
|
+
an item returns focus to the button; Tab out of it closes it. The build
|
|
174
|
+
includes menus only where a file of the integration's repository (a script or `.html` file, not in `node_modules`, `dist` or a hidden folder) has `data-lbx-menu` (written literally, as in a template). A
|
|
175
|
+
`<details data-lbx-dropdown>` menu closes on a press outside it and on
|
|
176
|
+
picking an item; the same holds for it with `data-lbx-dropdown`.
|
|
169
177
|
- **The drawer** (`[data-lbx-drawer="open|close"]`): `aria-expanded` on its
|
|
170
178
|
openers. With a panel marked `[data-lbx-drawer-panel]`, while open, the panel
|
|
171
179
|
is a modal dialog (`role="dialog"`, `aria-modal="true"`, named "Filters",
|
|
@@ -176,6 +184,21 @@ guessed alt text, and forced colours and the OS setting are honoured.
|
|
|
176
184
|
focus staying on the control. With `facetsApply: 'staged'` its
|
|
177
185
|
changes wait for `[data-lbx-drawer="apply"]`; closing it otherwise discards
|
|
178
186
|
them (the scope's `pending` says whether there is anything to apply).
|
|
187
|
+
Apply's result is said in Luma's status region as "Filters applied" and
|
|
188
|
+
the count (translation `search.applied`, WCAG 4.1.3), and shown for 2.5 s
|
|
189
|
+
in a pill at the listing's top with the same words (hidden from assistive
|
|
190
|
+
technology: it repeats the status region; white on dark, at least 10:1
|
|
191
|
+
over any page behind it; opaque in the high-contrast mode and in forced
|
|
192
|
+
colours, with a border there; taps go through it; it wraps rather than
|
|
193
|
+
cuts its words at 320 px or 200% text, and follows a larger default font
|
|
194
|
+
size; it moves no content). While the applied results load, the
|
|
195
|
+
old list is dimmed as a busy one is: not at all in the high-contrast
|
|
196
|
+
mode, to 60% in forced colours. The new tiles fade in; they also rise
|
|
197
|
+
unless reduced motion is asked for. The pill may overlap the top of the
|
|
198
|
+
drawer's opener, where focus returns, for its 2.5 s; it never hides it.
|
|
199
|
+
The build includes the drawer only where a file of the integration's
|
|
200
|
+
repository (a script or `.html` file, not in `node_modules`, `dist` or a hidden folder) has `data-lbx-drawer` (written
|
|
201
|
+
literally, as in a template).
|
|
179
202
|
- **Facet groups** (built-in template): each group's title is a `<button
|
|
180
203
|
aria-expanded aria-controls>` controlling its values (WAI-ARIA APG
|
|
181
204
|
accordion), focus staying on it. A group's search box
|
|
@@ -206,7 +229,8 @@ guessed alt text, and forced colours and the OS setting are honoured.
|
|
|
206
229
|
- Built-in templates: the pager is a native "Show more" button with a
|
|
207
230
|
"Showing N of M" line (no numbered pages); tile pictures are decorative
|
|
208
231
|
(`alt=""`: the link reads as its title and price once); targets of at least
|
|
209
|
-
24 px; no fade under reduced motion
|
|
232
|
+
24 px; no fade under reduced motion (except a staged drawer's Apply: its
|
|
233
|
+
new tiles fade in, without rising); tiles reflow to one column at 320 px.
|
|
210
234
|
|
|
211
235
|
### Recommenders (`widgets/recommender/`)
|
|
212
236
|
|
|
@@ -225,7 +249,9 @@ guessed alt text, and forced colours and the OS setting are honoured.
|
|
|
225
249
|
reset cannot remove it there).
|
|
226
250
|
- **Reduced motion**: the hold's loading bar stands still, the listing's
|
|
227
251
|
dim while a re-render loads comes without its fade and the new results
|
|
228
|
-
without their fade-in, no smooth scrolling of ours.
|
|
252
|
+
without their fade-in, no smooth scrolling of ours. One exception: after
|
|
253
|
+
a staged drawer's Apply the new tiles still fade in (opacity only, nothing
|
|
254
|
+
moves), so the swap the shopper confirmed is seen.
|
|
229
255
|
- `.lbx-sr-only`: a class for text that only screen readers read (e.g. "Was"
|
|
230
256
|
before an old price), defined for every widget and in server-rendered CSS.
|
|
231
257
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@luigisbox/luma",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Luma: Luigi's Box frontend integrations (autocomplete, search, category listings, recommenders) on the Luigi's Box AI API. Pre-release.",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
"snippet/early-hold.min.js",
|
|
30
30
|
"docs/ACCESSIBILITY.md",
|
|
31
31
|
"docs/INTEGRATION-PERFORMANCE.md",
|
|
32
|
-
"docs/PARITY.md"
|
|
32
|
+
"docs/PARITY.md",
|
|
33
|
+
"CHANGELOG.md"
|
|
33
34
|
],
|
|
34
35
|
"publishConfig": {
|
|
35
36
|
"access": "public"
|
package/src/analytics/chain.ts
CHANGED
|
@@ -71,7 +71,7 @@ export const reader = <E extends EventName, C>(event: E, f: (ctx: C) => Reads[E]
|
|
|
71
71
|
/** `.read(r)` on event `name`: `r`, or an error when it is a reader of another event. */
|
|
72
72
|
export const checked = (name: string, r: { event?: string }) => {
|
|
73
73
|
const e = name.startsWith('recommender:') ? 'recommender' : name;
|
|
74
|
-
if (r.event && r.event !== e) throw
|
|
74
|
+
if (r.event && r.event !== e) throw Error(`analytics: collect.${e}.read(): a reader of ${r.event}`);
|
|
75
75
|
};
|
|
76
76
|
|
|
77
77
|
export interface Chain<E extends EventName, C> {
|
|
@@ -42,7 +42,7 @@ export const pageView = {
|
|
|
42
42
|
},
|
|
43
43
|
read(r) {
|
|
44
44
|
checked('pageView', r);
|
|
45
|
-
if (chained) throw
|
|
45
|
+
if (chained) throw Error('analytics: one collect.pageView chain per setup');
|
|
46
46
|
chained = true;
|
|
47
47
|
const ref = (ctx: C) => {
|
|
48
48
|
try {
|
package/src/analytics/start.ts
CHANGED
|
@@ -33,10 +33,9 @@ export const start = async (o: StartOptions) => {
|
|
|
33
33
|
if (started) return;
|
|
34
34
|
started = true;
|
|
35
35
|
state.g = o.config._global;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
env
|
|
39
|
-
env.kept = o.kept;
|
|
36
|
+
// One call, not a read per field: a setup that uses none of them leaves
|
|
37
|
+
// Rollup no property reads to keep (it keeps a read: it may be a getter).
|
|
38
|
+
Object.assign(env, o);
|
|
40
39
|
// The setup (`_global` first). Its chains register as it runs.
|
|
41
40
|
for (const [p, f] of Object.entries(o.files).sort(([a], [b]) => (a.includes('/_global/') ? -1 : b.includes('/_global/') ? 1 : 0)))
|
|
42
41
|
try {
|
package/src/analytics/wire.ts
CHANGED
|
@@ -51,7 +51,7 @@ export const readDl = (x: any): DlEntry | null => {
|
|
|
51
51
|
return null;
|
|
52
52
|
};
|
|
53
53
|
|
|
54
|
-
const IGNORE =
|
|
54
|
+
const IGNORE = RegExp(`${TRACKING}|^${P.replace('.', '\\.')}`);
|
|
55
55
|
/**
|
|
56
56
|
* The page a URL shows, for pageviews: without tracking params, the hash and
|
|
57
57
|
* our own `lbx.*` (a filter, a sort on our listing is the same page). A
|
package/src/api/ai.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// Also runs in Node (the edge render service): there the
|
|
9
9
|
// storefront origin is sent explicitly and nothing touches the DOM.
|
|
10
10
|
|
|
11
|
+
import { PLP, RECOMMENDER, SEARCH } from '../core/features';
|
|
11
12
|
import { health } from '../core/health';
|
|
12
13
|
import type { GlobalConfigType } from '../types';
|
|
13
14
|
import type { Api, FacetData, Hit, Page } from '.';
|
|
@@ -15,6 +16,11 @@ import { fetchJson, TIMEOUT_MS, tokensFor, untilAborted, visitorId } from './tok
|
|
|
15
16
|
|
|
16
17
|
export { untilAborted };
|
|
17
18
|
|
|
19
|
+
/** A method of a widget the build has no folder for (never called). */
|
|
20
|
+
const unbuilt = (): never => {
|
|
21
|
+
throw Error('not in this build');
|
|
22
|
+
};
|
|
23
|
+
|
|
18
24
|
export const aiApi = (g: GlobalConfigType, origin?: string): Api => {
|
|
19
25
|
const tk = tokensFor(g, origin);
|
|
20
26
|
const { API, originHeaders } = tk;
|
|
@@ -48,8 +54,8 @@ export const aiApi = (g: GlobalConfigType, origin?: string): Api => {
|
|
|
48
54
|
return post(path, params, body, signal, true);
|
|
49
55
|
}
|
|
50
56
|
}
|
|
51
|
-
if (!r.ok) throw
|
|
52
|
-
if (r.json == null) throw
|
|
57
|
+
if (!r.ok) throw Error(`${path}: HTTP ${r.status} ${r.body.slice(0, 300)}`);
|
|
58
|
+
if (r.json == null) throw Error(`${path}: not JSON`);
|
|
53
59
|
return r.json;
|
|
54
60
|
};
|
|
55
61
|
|
|
@@ -75,24 +81,30 @@ export const aiApi = (g: GlobalConfigType, origin?: string): Api => {
|
|
|
75
81
|
return { hits: j.hits ?? [], total: j.total ?? j.total_approx ?? 0, guid: j.guid, cursor: j.next_page_cursor ?? null, status: j.pagination_status ?? 'exhausted' };
|
|
76
82
|
},
|
|
77
83
|
|
|
78
|
-
|
|
79
|
-
|
|
84
|
+
// A listing's and a recommender's (core/features.ts): not in a build without one.
|
|
85
|
+
facets:
|
|
86
|
+
SEARCH || PLP
|
|
87
|
+
? async (guid, facets, signal): Promise<Record<string, FacetData>> =>
|
|
88
|
+
(await post('facets', { channel_id: g.channelId }, { guid, facets: facets.map((f) => compact(f)) }, signal)).facets ?? {}
|
|
89
|
+
: unbuilt,
|
|
80
90
|
|
|
81
|
-
recommend:
|
|
82
|
-
(
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
91
|
+
recommend: RECOMMENDER
|
|
92
|
+
? async (r, signal): Promise<Hit[]> =>
|
|
93
|
+
(
|
|
94
|
+
await post(
|
|
95
|
+
'recommender',
|
|
96
|
+
{ channel_id: g.channelId, surface_id: r.surface },
|
|
97
|
+
compact({
|
|
98
|
+
type: r.type,
|
|
99
|
+
size: r.size,
|
|
100
|
+
anchor_ids: r.anchors.length ? r.anchors.slice(0, 50) : undefined,
|
|
101
|
+
exclude_ids: r.exclude?.length ? r.exclude.slice(0, 50) : undefined,
|
|
102
|
+
filters: nonEmpty(r.filters),
|
|
103
|
+
return_fields: r.fields?.length ? r.fields : undefined,
|
|
104
|
+
}),
|
|
105
|
+
signal,
|
|
106
|
+
)
|
|
107
|
+
).hits ?? []
|
|
108
|
+
: unbuilt,
|
|
97
109
|
};
|
|
98
110
|
};
|
package/src/api/token.ts
CHANGED
|
@@ -99,7 +99,7 @@ export async function solveHere(nonce: string, difficulty: number, until: number
|
|
|
99
99
|
let t0 = Date.now();
|
|
100
100
|
for (let n = 0; ; n++) {
|
|
101
101
|
if ((n & 1023) === 0 && Date.now() - t0 > (slice || 50)) {
|
|
102
|
-
if (Date.now() > until) throw
|
|
102
|
+
if (Date.now() > until) throw Error('proof of work: no answer in time');
|
|
103
103
|
if (slice) await new Promise((r) => setTimeout(r));
|
|
104
104
|
t0 = Date.now();
|
|
105
105
|
}
|
|
@@ -142,8 +142,8 @@ const solve = (nonce: string, difficulty: number): Promise<number> => {
|
|
|
142
142
|
return here(); // CSP worker-src
|
|
143
143
|
}
|
|
144
144
|
// A worker that never answers is stopped.
|
|
145
|
-
timer = window.setTimeout(() => (done(), reject(
|
|
146
|
-
w.onmessage = (e) => (done(), e.data == null ? reject(
|
|
145
|
+
timer = window.setTimeout(() => (done(), reject(Error('proof of work: no answer in time'))), POW_TIMEOUT_MS);
|
|
146
|
+
w.onmessage = (e) => (done(), e.data == null ? reject(Error('proof of work failed')) : resolve(e.data));
|
|
147
147
|
w.onerror = here;
|
|
148
148
|
w.postMessage([nonce, difficulty, until]);
|
|
149
149
|
});
|
|
@@ -236,9 +236,9 @@ const tokens = (g: GlobalConfigType, origin?: string): Tokens => {
|
|
|
236
236
|
const url = challengeUrl(g);
|
|
237
237
|
// The one the entry asked while this chunk loaded (the first try only: a retry needs a new one).
|
|
238
238
|
const ch = await fetchJson(url, { headers: originHeaders }, TIMEOUT_MS, undefined, browser && retries ? takeChallenge(url) : undefined);
|
|
239
|
-
if (!ch.ok) throw
|
|
239
|
+
if (!ch.ok) throw Error(`browser-challenge: HTTP ${ch.status}`);
|
|
240
240
|
const { nonce, difficulty } = ch.json ?? {};
|
|
241
|
-
if (typeof nonce !== 'string' || !(difficulty >= 0 && difficulty <= MAX_DIFFICULTY)) throw
|
|
241
|
+
if (typeof nonce !== 'string' || !(difficulty >= 0 && difficulty <= MAX_DIFFICULTY)) throw Error('browser-challenge: unexpected challenge');
|
|
242
242
|
const solution = await solve(nonce, difficulty);
|
|
243
243
|
const r = await fetchJson(
|
|
244
244
|
`${AUTH}/browser-token`,
|
|
@@ -260,10 +260,10 @@ const tokens = (g: GlobalConfigType, origin?: string): Tokens => {
|
|
|
260
260
|
if (!r.ok) {
|
|
261
261
|
const detail = r.json?.detail;
|
|
262
262
|
if (retries > 0 && detail === 'Nonce expired') return mint(retries - 1);
|
|
263
|
-
throw
|
|
263
|
+
throw Error(`browser-token: HTTP ${r.status} ${typeof detail === 'string' ? detail : ''}`);
|
|
264
264
|
}
|
|
265
265
|
const { access_token, expires_in } = r.json ?? {};
|
|
266
|
-
if (typeof access_token !== 'string' || !(expires_in > 30)) throw
|
|
266
|
+
if (typeof access_token !== 'string' || !(expires_in > 30)) throw Error('browser-token: unexpected response');
|
|
267
267
|
return { token: access_token, exp: Date.now() + (expires_in - 30) * 1000 };
|
|
268
268
|
};
|
|
269
269
|
|