@occluze/tether 0.4.0 → 0.5.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/README.md +7 -5
- package/dist/esm/astro.js +2 -0
- package/dist/esm/chunks/chunk-HHMOPDZG.js +1 -0
- package/dist/esm/chunks/chunk-HXKV6QGB.js +4 -0
- package/dist/esm/chunks/{chunk-KYQ5OGBX.js → chunk-RBVRRQIU.js} +1 -1
- package/dist/esm/chunks/chunk-RNZMTQTX.js +1 -0
- package/dist/esm/chunks/chunk-VC46IEJQ.js +1 -0
- package/dist/esm/chunks/diagnose-J667GHGS.js +1 -0
- package/dist/esm/chunks/markets-ui-EGWIEUOZ.js +1 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/server.js +1 -0
- package/dist/tether.diagnose.js +4 -3
- package/dist/tether.diagnose.min.js +1 -1
- package/dist/tether.js +229 -61
- package/dist/tether.markets.js +16 -10
- package/dist/tether.markets.min.js +1 -1
- package/dist/tether.min.js +3 -3
- package/dist/tether.min.js.map +3 -3
- package/package.json +27 -4
- package/src/astro.js +73 -0
- package/src/cart.js +47 -7
- package/src/client.js +25 -15
- package/src/config.js +2 -0
- package/src/diagnose.js +1 -1
- package/src/dom.js +32 -9
- package/src/fragments.js +12 -11
- package/src/index.js +18 -6
- package/src/initial.js +66 -0
- package/src/loader.js +49 -6
- package/src/markets-ui.js +13 -5
- package/src/markets.js +1 -1
- package/src/mounting.js +42 -2
- package/src/product.js +33 -9
- package/src/server.js +179 -0
- package/src/status.js +1 -6
- package/types/astro.d.ts +15 -0
- package/types/index.d.ts +17 -2
- package/types/server.d.ts +94 -0
- package/dist/esm/chunks/chunk-GHIUMD5H.js +0 -1
- package/dist/esm/chunks/chunk-LI3YDMGF.js +0 -4
- package/dist/esm/chunks/diagnose-ZXXXJIQE.js +0 -1
- package/dist/esm/chunks/markets-ui-AG6Y5VUE.js +0 -1
package/src/initial.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the browser script will show first for a product, so a server can render the same
|
|
3
|
+
* (no flash when Tether takes over): the selected variant, price, purchase mode and conditions.
|
|
4
|
+
* Same rules as src/product.js: first available variant (or the one in ?variant=), subscription
|
|
5
|
+
* when the product requires a plan or defaultPurchase is 'subscription', low stock at 5 or fewer.
|
|
6
|
+
*/
|
|
7
|
+
import { GID, allPlans, allocationFor, findPlan, planPrice, planSavings } from './selling-plans.js';
|
|
8
|
+
|
|
9
|
+
export function initialState(product, { variant, sellingPlan, defaultPurchase = 'one-time' } = {}) {
|
|
10
|
+
const vs = product?.variants?.nodes || [];
|
|
11
|
+
const gid = variant ? GID.variant(variant) : null;
|
|
12
|
+
const v = (gid && vs.find((x) => x.id === gid)) || vs.find((x) => x.availableForSale) || vs[0] || null;
|
|
13
|
+
if (!v) return null;
|
|
14
|
+
const selected = Object.fromEntries((v.selectedOptions || []).map((o) => [o.name, o.value]));
|
|
15
|
+
|
|
16
|
+
const plans = allPlans(product);
|
|
17
|
+
const fromUrl = findPlan(product, sellingPlan);
|
|
18
|
+
let plan = plans.length ? (fromUrl || plans[0]) : null;
|
|
19
|
+
if (plan && !allocationFor(v, plan.id)) plan = plans.find((p) => allocationFor(v, p.id)) || plan;
|
|
20
|
+
const wantsSub = product.requiresSellingPlan || fromUrl || defaultPurchase === 'subscription';
|
|
21
|
+
const subscriptionAvailable = plans.some((p) => allocationFor(v, p.id));
|
|
22
|
+
const purchase = plans.length && wantsSub ? 'subscription' : 'one-time';
|
|
23
|
+
const sub = purchase === 'subscription' && subscriptionAvailable;
|
|
24
|
+
|
|
25
|
+
const price = sub ? planPrice(v, plan.id) : v.price;
|
|
26
|
+
const compareAt = sub ? (allocationFor(v, plan.id)?.priceAdjustments?.[0]?.compareAtPrice || v.compareAtPrice) : v.compareAtPrice;
|
|
27
|
+
const onSale = Boolean(compareAt && parseFloat(compareAt.amount) > parseFloat(price.amount));
|
|
28
|
+
const q = v.quantityAvailable;
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
variant: v,
|
|
32
|
+
selected,
|
|
33
|
+
purchase,
|
|
34
|
+
plan: sub ? plan : null,
|
|
35
|
+
/** The plan list (tether-plan-list="all"): selected one gets is-selected, unavailable ones is-unavailable. */
|
|
36
|
+
plans: plans.map((p) => ({
|
|
37
|
+
id: p.id, name: p.name, price: planPrice(v, p.id), savings: planSavings(v, p.id).percentage,
|
|
38
|
+
selected: sub && p.id === plan.id, available: Boolean(allocationFor(v, p.id)),
|
|
39
|
+
})),
|
|
40
|
+
/** Savings of the selected plan in % (tether-bind="subscription-savings"), 0 without one. */
|
|
41
|
+
savings: plan ? planSavings(v, plan.id).percentage : 0,
|
|
42
|
+
price,
|
|
43
|
+
compareAtPrice: onSale ? compareAt : null,
|
|
44
|
+
available: v.availableForSale,
|
|
45
|
+
/** The tether-show conditions, e.g. hidden={!state.show['out-of-stock']}. */
|
|
46
|
+
show: {
|
|
47
|
+
'in-stock': v.availableForSale,
|
|
48
|
+
'out-of-stock': !v.availableForSale,
|
|
49
|
+
backorder: Boolean(v.availableForSale && v.currentlyNotInStock),
|
|
50
|
+
'on-sale': onSale,
|
|
51
|
+
'not-on-sale': !onSale,
|
|
52
|
+
'has-variants': vs.length > 1,
|
|
53
|
+
'single-variant': vs.length === 1,
|
|
54
|
+
'low-stock': q != null && q > 0 && q <= 5,
|
|
55
|
+
'subscription-available': subscriptionAvailable,
|
|
56
|
+
'subscription-unavailable': !subscriptionAvailable,
|
|
57
|
+
'subscription-only': Boolean(product.requiresSellingPlan),
|
|
58
|
+
'one-time-available': !product.requiresSellingPlan,
|
|
59
|
+
'subscription-selected': sub,
|
|
60
|
+
'one-time-selected': !sub,
|
|
61
|
+
},
|
|
62
|
+
/** Option values with no available variant (given the other selected options): class is-unavailable. */
|
|
63
|
+
isUnavailable: (name, value) => !vs.some((x) => x.availableForSale && x.selectedOptions.every((o) =>
|
|
64
|
+
(o.name === name ? o.value === value : selected[o.name] === o.value))),
|
|
65
|
+
};
|
|
66
|
+
}
|
package/src/loader.js
CHANGED
|
@@ -90,7 +90,11 @@ async function flush() {
|
|
|
90
90
|
handles.forEach((h, i) => { if (data[`h${i}`]) got.set(h, data[`h${i}`]); });
|
|
91
91
|
|
|
92
92
|
keys.forEach((k) => settle(k, (r) => {
|
|
93
|
-
if (got.has(k)) {
|
|
93
|
+
if (got.has(k)) {
|
|
94
|
+
const p = got.get(k);
|
|
95
|
+
if (gen === generation) { primeProduct(p); freshAt.set(p.handle, Date.now()); freshAt.set(p.id, Date.now()); }
|
|
96
|
+
r.resolve(p);
|
|
97
|
+
} else {
|
|
94
98
|
if (gen === generation) productCache.delete(k); // don't cache misses: a fix in Shopify works on next load
|
|
95
99
|
r.reject(new TetherError(`Product not found: ${k}`, { code: 'NOT_FOUND' }));
|
|
96
100
|
}
|
|
@@ -100,7 +104,44 @@ async function flush() {
|
|
|
100
104
|
}
|
|
101
105
|
}
|
|
102
106
|
|
|
103
|
-
export function clearProductCache() { productCache.clear(); generation++; }
|
|
107
|
+
export function clearProductCache() { productCache.clear(); generation++; stale.clear(); freshAt.clear(); }
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Hydration: products rendered on the server (tether/server hydrate()) are used at once and
|
|
111
|
+
// refreshed once in the background, so prices and stock are live without a loading state.
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
const stale = new Set(); // keys of products that came from the server and weren't refreshed yet
|
|
115
|
+
const freshAt = new Map(); // key -> when it was last loaded live (this page session)
|
|
116
|
+
const FRESH_MS = 60000;
|
|
117
|
+
|
|
118
|
+
export function hydrateProducts(products) {
|
|
119
|
+
(products || []).forEach((p) => {
|
|
120
|
+
if (!p?.id || !p.handle) return;
|
|
121
|
+
// Loaded live in the last minute (an earlier page of a View Transition visit): that copy is newer.
|
|
122
|
+
if (Date.now() - (freshAt.get(p.handle) || 0) < FRESH_MS && productCache.has(p.handle)) return;
|
|
123
|
+
primeProduct(p);
|
|
124
|
+
stale.add(p.handle);
|
|
125
|
+
stale.add(p.id);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** True once per server-provided product: the caller refreshes it. */
|
|
130
|
+
export function takeStale(key) {
|
|
131
|
+
const k = normalizeProductKey(key);
|
|
132
|
+
if (!stale.has(k)) return false;
|
|
133
|
+
const p = productCache.get(k);
|
|
134
|
+
stale.delete(k);
|
|
135
|
+
// Both keys point to the same product: refresh it once.
|
|
136
|
+
p?.then?.((x) => { stale.delete(x.handle); stale.delete(x.id); });
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Fetch these products again (bypassing the cache). */
|
|
141
|
+
export function refreshProducts(keys) {
|
|
142
|
+
keys.forEach((k) => productCache.delete(normalizeProductKey(k)));
|
|
143
|
+
return keys.map((k) => getProduct(k));
|
|
144
|
+
}
|
|
104
145
|
|
|
105
146
|
/**
|
|
106
147
|
* A page of products from a collection (by handle) or from a search query.
|
|
@@ -141,11 +182,13 @@ ${fragment}`, { first, after, query: query || undefined, sortKey: sortKey || und
|
|
|
141
182
|
return { collection: null, products: data.products.nodes, pageInfo: data.products.pageInfo };
|
|
142
183
|
}
|
|
143
184
|
|
|
144
|
-
export async function getRecommendations(
|
|
145
|
-
const
|
|
146
|
-
|
|
185
|
+
export async function getRecommendations(product, intent = 'RELATED') {
|
|
186
|
+
const key = normalizeProductKey(typeof product === 'object' ? product?.id : product);
|
|
187
|
+
const byId = key?.startsWith('gid://');
|
|
188
|
+
const data = await gql(`query TetherRecs(${byId ? '$id: ID!' : '$handle: String!'}, $intent: ProductRecommendationIntent) {
|
|
189
|
+
productRecommendations(${byId ? 'productId: $id' : 'productHandle: $handle'}, intent: $intent) { ...TetherProduct }
|
|
147
190
|
}
|
|
148
|
-
${productFragment()}`, { id:
|
|
191
|
+
${productFragment()}`, byId ? { id: key, intent } : { handle: key, intent });
|
|
149
192
|
const list = data.productRecommendations || [];
|
|
150
193
|
list.forEach(primeProduct);
|
|
151
194
|
return list;
|
package/src/markets-ui.js
CHANGED
|
@@ -30,11 +30,14 @@ function renderCountrySelects(root = document) {
|
|
|
30
30
|
.replace(/\{currency\}/g, c.currency?.isoCode || '').replace(/\{symbol\}/g, c.currency?.symbol || '');
|
|
31
31
|
selects.forEach((s) => {
|
|
32
32
|
const tpl = s.getAttribute(attr('country-label')) || '{name} ({currency})';
|
|
33
|
+
// tether-countries="NL,DE,BE": only these (in your order); otherwise every country of the store.
|
|
34
|
+
const only = (s.getAttribute(attr('countries')) || '').toUpperCase().split(/[\s,]+/).filter(Boolean);
|
|
35
|
+
const list = only.length ? only.map((c) => countries.find((x) => x.isoCode === c)).filter(Boolean) : countries;
|
|
33
36
|
s.innerHTML = '';
|
|
34
|
-
|
|
37
|
+
list.forEach((c) => s.appendChild(new Option(fill(tpl, c), c.isoCode)));
|
|
35
38
|
s.value = current;
|
|
36
39
|
if (s.value !== current) s.selectedIndex = -1; // a country the store doesn't sell to
|
|
37
|
-
s.disabled =
|
|
40
|
+
s.disabled = list.length < 2 && !s.hasAttribute(attr('always-enabled'));
|
|
38
41
|
});
|
|
39
42
|
binds.forEach((el) => {
|
|
40
43
|
const k = get(el, 'country-bind') || 'name';
|
|
@@ -49,14 +52,19 @@ function renderCountrySelects(root = document) {
|
|
|
49
52
|
* buyerIdentity.countryCode is updated, and every product and list on the page re-renders.
|
|
50
53
|
* Tether.setCountry('DE') Tether.setCountry('BE', { language: 'FR' })
|
|
51
54
|
*/
|
|
52
|
-
export async function setCountry(code, { language, remember = true } = {}) {
|
|
55
|
+
export async function setCountry(code, { language, locale, remember = true } = {}) {
|
|
53
56
|
const country = toCountry(code);
|
|
54
57
|
if (!country) throw new TetherError(`Not a country code: ${code}`, { code: 'INVALID_COUNTRY' });
|
|
58
|
+
// Money format: the option, else a markets.locales entry for this country, else unchanged.
|
|
59
|
+
const entry = Object.values(cfg.markets?.locales || {}).find((m) => toCountry(m?.country) === country);
|
|
60
|
+
const own = locale || entry?.locale;
|
|
61
|
+
const loc = own || cfg.locale;
|
|
55
62
|
const lang = language === undefined ? cfg.language : toLanguage(language);
|
|
56
|
-
const changed = country !== String(cfg.country || '').toUpperCase() || lang !== cfg.language;
|
|
63
|
+
const changed = country !== String(cfg.country || '').toUpperCase() || lang !== cfg.language || loc !== cfg.locale;
|
|
57
64
|
cfg.country = country;
|
|
58
65
|
cfg.language = lang || undefined;
|
|
59
|
-
|
|
66
|
+
cfg.locale = loc;
|
|
67
|
+
if (remember) rememberMarket({ country, language: lang, locale: own });
|
|
60
68
|
if (!changed) { renderCountrySelects(); return Cart.state; }
|
|
61
69
|
|
|
62
70
|
clearProductCache();
|
package/src/markets.js
CHANGED
|
@@ -112,7 +112,7 @@ export function detectMarket(markets = cfg.markets, env = defaultEnv()) {
|
|
|
112
112
|
|
|
113
113
|
export function rememberMarket(market) {
|
|
114
114
|
if (cfg.markets?.remember === false) return;
|
|
115
|
-
try { localStorage.setItem(storageKey(), JSON.stringify({ country: market.country, language: market.language || undefined })); } catch { /* private mode */ }
|
|
115
|
+
try { localStorage.setItem(storageKey(), JSON.stringify({ country: market.country, language: market.language || undefined, locale: market.locale || undefined })); } catch { /* private mode */ }
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
let localizationPromise = null;
|
package/src/mounting.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/** Product scopes and lists that are mounted, shared by index.js and the optional markets part. */
|
|
2
2
|
import { cfg, log, warn, reportError } from './config.js';
|
|
3
|
-
import { ProductScope, scopes } from './product.js';
|
|
3
|
+
import { ProductScope, scopes, rememberOptionNames } from './product.js';
|
|
4
4
|
import { listSelector } from './collection.js';
|
|
5
|
-
import { getProduct } from './loader.js';
|
|
5
|
+
import { getProduct, takeStale, refreshProducts, hydrateProducts } from './loader.js';
|
|
6
6
|
import { resolveProductKey, setScopeStatus, isNotFound } from './status.js';
|
|
7
7
|
import { attr, get, qsa, emit } from './dom.js';
|
|
8
8
|
|
|
@@ -53,6 +53,7 @@ function mountProduct(el) {
|
|
|
53
53
|
// eslint-disable-next-line no-new
|
|
54
54
|
else new ProductScope(el, p);
|
|
55
55
|
applyPageTitle(el, p);
|
|
56
|
+
if (cfg.revalidate !== false && takeStale(key)) queueRefresh(key);
|
|
56
57
|
})
|
|
57
58
|
.catch((err) => {
|
|
58
59
|
if (!current()) return;
|
|
@@ -87,3 +88,42 @@ export function retryFailed() {
|
|
|
87
88
|
mountProduct(el);
|
|
88
89
|
});
|
|
89
90
|
}
|
|
91
|
+
|
|
92
|
+
// Server-rendered products: one batched background request refreshes them all.
|
|
93
|
+
let refreshQueue = new Set();
|
|
94
|
+
function queueRefresh(key) {
|
|
95
|
+
if (!refreshQueue.size) setTimeout(flushRefresh, 0);
|
|
96
|
+
refreshQueue.add(key);
|
|
97
|
+
}
|
|
98
|
+
function flushRefresh() {
|
|
99
|
+
const keys = [...refreshQueue];
|
|
100
|
+
refreshQueue = new Set();
|
|
101
|
+
refreshProducts(keys).forEach((promise, i) => promise.then((fresh) => {
|
|
102
|
+
liveProducts.forEach((el) => {
|
|
103
|
+
const scope = scopes.get(el);
|
|
104
|
+
if (!scope || !el.isConnected) return;
|
|
105
|
+
const k = resolveProductKey(el).key;
|
|
106
|
+
if (k !== keys[i] && k !== fresh.handle && k !== fresh.id) return;
|
|
107
|
+
if (JSON.stringify(scope.product) !== JSON.stringify(fresh)) scope.update(fresh);
|
|
108
|
+
});
|
|
109
|
+
}).catch((err) => reportError(err, `refresh ${keys[i]}`)));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* <script type="application/json" data-tether-hydrate> tags written by tether/server's hydrate():
|
|
114
|
+
* their products are used without a request. Ignored when they were made for another market.
|
|
115
|
+
*/
|
|
116
|
+
const readScripts = new WeakSet();
|
|
117
|
+
export function hydrateFromPage(root = document) {
|
|
118
|
+
qsa(root.ownerDocument || root, 'script[type="application/json"][data-tether-hydrate]').forEach((s) => {
|
|
119
|
+
if (readScripts.has(s)) return;
|
|
120
|
+
readScripts.add(s);
|
|
121
|
+
try {
|
|
122
|
+
const data = JSON.parse(s.textContent);
|
|
123
|
+
rememberOptionNames(data.products); // the build's option names, also for other languages
|
|
124
|
+
const same = (a, b) => String(a || '').toUpperCase() === String(b || '').toUpperCase();
|
|
125
|
+
if (same(data.country, cfg.country) && same(data.language, cfg.language)) hydrateProducts(data.products);
|
|
126
|
+
else log('hydration skipped: other market');
|
|
127
|
+
} catch (err) { warn('bad data-tether-hydrate', err); }
|
|
128
|
+
});
|
|
129
|
+
}
|
package/src/product.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { cfg, reportError } from './config.js';
|
|
6
6
|
import {
|
|
7
7
|
attr, sel, get, has, qsa, scoped, emit, formatMoney, formatDate, setText, setImage, show,
|
|
8
|
-
setSelected, repeat, productUrl, notifyLayoutChanged, watchImages, busy, parseJSONAttr, toAttributes,
|
|
8
|
+
setSelected, repeat, productUrl, notifyLayoutChanged, watchImages, busy, parseJSONAttr, toAttributes, sameImage, hasTemplate,
|
|
9
9
|
} from './dom.js';
|
|
10
10
|
import { Cart } from './cart.js';
|
|
11
11
|
import { STATUS_KEYS, ownedBy } from './status.js';
|
|
@@ -25,6 +25,13 @@ function metafieldValue(product, ref) {
|
|
|
25
25
|
return mf || null;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// Option names as written in the markup (the build's language), by product handle. With another
|
|
29
|
+
// language Shopify translates them (Size -> Größe), so attributes are matched by position instead.
|
|
30
|
+
const knownOptionNames = new Map();
|
|
31
|
+
export function rememberOptionNames(products) {
|
|
32
|
+
(products || []).forEach((p) => { if (p?.handle && !knownOptionNames.has(p.handle)) knownOptionNames.set(p.handle, (p.options || []).map((o) => o.name)); });
|
|
33
|
+
}
|
|
34
|
+
|
|
28
35
|
export class ProductScope {
|
|
29
36
|
constructor(root, product) {
|
|
30
37
|
this.root = root;
|
|
@@ -76,6 +83,14 @@ export class ProductScope {
|
|
|
76
83
|
|
|
77
84
|
// ------------------------------------------------------------ state
|
|
78
85
|
|
|
86
|
+
/** The product's option name for a name used in the markup (same name, or same position). */
|
|
87
|
+
optName(authored) {
|
|
88
|
+
const opts = this.product?.options || [];
|
|
89
|
+
if (!authored || opts.some((o) => o.name === authored)) return authored;
|
|
90
|
+
const i = (knownOptionNames.get(this.product.handle) || []).indexOf(authored);
|
|
91
|
+
return i >= 0 && opts[i] ? opts[i].name : authored;
|
|
92
|
+
}
|
|
93
|
+
|
|
79
94
|
initSelection() {
|
|
80
95
|
const vs = this.product.variants.nodes;
|
|
81
96
|
const first = vs.find((v) => v.availableForSale) || vs[0];
|
|
@@ -208,7 +223,7 @@ export class ProductScope {
|
|
|
208
223
|
const opt = t.closest(sel('option-value'));
|
|
209
224
|
if (own(opt) && opt.closest(sel('option-list'))) {
|
|
210
225
|
e.preventDefault();
|
|
211
|
-
this.select(get(opt.closest(sel('option-list')), 'option-list'), get(opt, 'option-value'));
|
|
226
|
+
this.select(this.optName(get(opt.closest(sel('option-list')), 'option-list')), get(opt, 'option-value'));
|
|
212
227
|
return;
|
|
213
228
|
}
|
|
214
229
|
|
|
@@ -234,7 +249,7 @@ export class ProductScope {
|
|
|
234
249
|
this.root.addEventListener('change', (e) => {
|
|
235
250
|
const t = e.target;
|
|
236
251
|
if (!own(t)) return;
|
|
237
|
-
if (t.matches(`select${sel('option')}`)) this.select(get(t, 'option'), t.value);
|
|
252
|
+
if (t.matches(`select${sel('option')}`)) this.select(this.optName(get(t, 'option')), t.value);
|
|
238
253
|
else if (t.matches(`select${sel('variant-select')}`)) this.selectVariant(t.value);
|
|
239
254
|
else if (t.matches(`select${sel('plan')}`)) {
|
|
240
255
|
if (t.value) this.selectPlan(t.value); else this.setPurchase('one-time');
|
|
@@ -266,6 +281,11 @@ export class ProductScope {
|
|
|
266
281
|
// ------------------------------------------------------------ render
|
|
267
282
|
|
|
268
283
|
renderProduct() {
|
|
284
|
+
const p0 = this.product;
|
|
285
|
+
if (!knownOptionNames.has(p0.handle)) {
|
|
286
|
+
const authored = this.$(`${sel('option-list')},select${sel('option')}`).map((e) => get(e, 'option-list') || get(e, 'option'));
|
|
287
|
+
if (authored.some((n) => p0.options.some((o) => o.name === n))) rememberOptionNames([p0]);
|
|
288
|
+
}
|
|
269
289
|
const p = this.product;
|
|
270
290
|
|
|
271
291
|
this.$(sel('bind')).forEach((el) => {
|
|
@@ -289,7 +309,11 @@ export class ProductScope {
|
|
|
289
309
|
el.setAttribute('href', productUrl(p.handle, p.id));
|
|
290
310
|
if (key === 'url' && !el.firstElementChild && !el.textContent.trim()) setText(el, p.title);
|
|
291
311
|
return undefined;
|
|
292
|
-
case 'images':
|
|
312
|
+
case 'images': {
|
|
313
|
+
// Server-rendered gallery with the same images: keep it as it is.
|
|
314
|
+
const imgs = [...el.children].map((n) => (n.tagName === 'IMG' ? n : n.querySelector('img')));
|
|
315
|
+
if (!hasTemplate(el) && imgs.length === p.images.nodes.length && imgs.every((im, i) => sameImage(im?.getAttribute('src'), p.images.nodes[i].url))) return undefined;
|
|
316
|
+
}
|
|
293
317
|
return repeat(el, p.images.nodes, (node, img) => {
|
|
294
318
|
const target = node.matches(`img,${sel('bind', 'image')}`) ? node : node.querySelector('img') || node;
|
|
295
319
|
setImage(target, img.url, img.altText || p.title);
|
|
@@ -316,7 +340,7 @@ export class ProductScope {
|
|
|
316
340
|
});
|
|
317
341
|
|
|
318
342
|
this.$(sel('option-list')).forEach((container) => {
|
|
319
|
-
const name = get(container, 'option-list');
|
|
343
|
+
const name = this.optName(get(container, 'option-list'));
|
|
320
344
|
const opt = p.options.find((o) => o.name === name);
|
|
321
345
|
if (!opt || (opt.optionValues.length <= 1 && has(container, 'hide-single'))) return show(container, false);
|
|
322
346
|
return repeat(container, opt.optionValues, (node, v) => {
|
|
@@ -330,7 +354,7 @@ export class ProductScope {
|
|
|
330
354
|
});
|
|
331
355
|
|
|
332
356
|
this.$(`select${sel('option')}`).forEach((s) => {
|
|
333
|
-
const opt = p.options.find((o) => o.name === get(s, 'option'));
|
|
357
|
+
const opt = p.options.find((o) => o.name === this.optName(get(s, 'option')));
|
|
334
358
|
if (!opt) return show(s, false);
|
|
335
359
|
s.innerHTML = '';
|
|
336
360
|
opt.optionValues.forEach((v) => s.appendChild(new Option(v.name, v.name)));
|
|
@@ -425,10 +449,10 @@ export class ProductScope {
|
|
|
425
449
|
else if (text[k]) setText(el, text[k]());
|
|
426
450
|
});
|
|
427
451
|
|
|
428
|
-
this.$(sel('option-selected')).forEach((el) => setText(el, this.selected[get(el, 'option-selected')] || ''));
|
|
452
|
+
this.$(sel('option-selected')).forEach((el) => setText(el, this.selected[this.optName(get(el, 'option-selected'))] || ''));
|
|
429
453
|
|
|
430
454
|
this.$(sel('option-list')).forEach((container) => {
|
|
431
|
-
const name = get(container, 'option-list');
|
|
455
|
+
const name = this.optName(get(container, 'option-list'));
|
|
432
456
|
qsa(container, sel('option-value')).forEach((node) => {
|
|
433
457
|
const value = get(node, 'option-value');
|
|
434
458
|
setSelected(node, this.selected[name] === value);
|
|
@@ -438,7 +462,7 @@ export class ProductScope {
|
|
|
438
462
|
});
|
|
439
463
|
});
|
|
440
464
|
|
|
441
|
-
this.$(`select${sel('option')}`).forEach((s) => { s.value = this.selected[get(s, 'option')] || ''; });
|
|
465
|
+
this.$(`select${sel('option')}`).forEach((s) => { s.value = this.selected[this.optName(get(s, 'option'))] || ''; });
|
|
442
466
|
this.$(`select${sel('variant-select')}`).forEach((s) => { s.value = v.id; });
|
|
443
467
|
|
|
444
468
|
// Subscription UI
|
package/src/server.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @occluze/tether/server: Shopify product data at build time or on the server (Astro, Eleventy,
|
|
3
|
+
* Node, Cloudflare Workers). No browser APIs. Uses the same queries as the browser script, so
|
|
4
|
+
* products handed over with hydrate() are exactly what Tether expects and need no extra request.
|
|
5
|
+
*
|
|
6
|
+
* import { createStorefront } from '@occluze/tether/server';
|
|
7
|
+
* const shop = createStorefront({ domain, token, country: 'NL' });
|
|
8
|
+
* const product = await shop.getProduct('coffee-box');
|
|
9
|
+
* const all = await shop.allProducts();
|
|
10
|
+
*/
|
|
11
|
+
import { DEFAULTS } from './config.js';
|
|
12
|
+
import { request, withContext, TetherError } from './client.js';
|
|
13
|
+
import { productFragment, collectionFields } from './fragments.js';
|
|
14
|
+
import * as plans from './selling-plans.js';
|
|
15
|
+
import { imageUrl } from './dom.js';
|
|
16
|
+
import { initialState } from './initial.js';
|
|
17
|
+
|
|
18
|
+
export { imageUrl, initialState };
|
|
19
|
+
|
|
20
|
+
export { TetherError, plans, withContext };
|
|
21
|
+
|
|
22
|
+
const isGid = (v) => typeof v === 'string' && v.startsWith('gid://');
|
|
23
|
+
|
|
24
|
+
/** Handle, numeric id, gid or product URL -> handle or gid. */
|
|
25
|
+
export function productKey(raw) {
|
|
26
|
+
const v = String(raw ?? '').trim();
|
|
27
|
+
if (!v) return null;
|
|
28
|
+
if (isGid(v)) return v;
|
|
29
|
+
if (/^\d+$/.test(v)) return `gid://shopify/Product/${v}`;
|
|
30
|
+
const m = v.match(/\/products\/([^/?#]+)/);
|
|
31
|
+
return m ? m[1] : v;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Money as text, e.g. formatMoney({ amount: '30.0', currencyCode: 'EUR' }, 'nl-NL') -> "€ 30,00". */
|
|
35
|
+
export function formatMoney(money, locale) {
|
|
36
|
+
if (!money) return '';
|
|
37
|
+
const amount = parseFloat(money.amount);
|
|
38
|
+
if (Number.isNaN(amount)) return '';
|
|
39
|
+
try {
|
|
40
|
+
return new Intl.NumberFormat(locale || undefined, { style: 'currency', currency: money.currencyCode }).format(amount);
|
|
41
|
+
} catch {
|
|
42
|
+
return `${money.currencyCode} ${amount.toFixed(2)}`;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** productPath '/products' -> /products/<handle>; templates '/p?handle={handle}' and '{id}' work too. */
|
|
47
|
+
export function productUrl(handle, { productPath = '/product', id } = {}) {
|
|
48
|
+
if (!/\{(handle|id)\}/.test(productPath)) return `${productPath.replace(/\/$/, '')}/${handle}`;
|
|
49
|
+
return productPath.replace(/\{handle\}/g, encodeURIComponent(handle || '')).replace(/\{id\}/g, encodeURIComponent(id ? String(id).split('/').pop() : ''));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** JSON for a <script> tag: escapes < so product text can never close the tag. */
|
|
53
|
+
const safeJson = (v) => JSON.stringify(v).replace(/</g, '\\u003c').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
|
|
54
|
+
|
|
55
|
+
// createStorefront(import.meta.env) works too: these env names map to domain and token.
|
|
56
|
+
const ENV = {
|
|
57
|
+
domain: ['PUBLIC_SHOPIFY_DOMAIN', 'PUBLIC_SHOPIFY_STORE_DOMAIN', 'SHOPIFY_DOMAIN'],
|
|
58
|
+
token: ['PUBLIC_SHOPIFY_STOREFRONT_TOKEN', 'PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN', 'SHOPIFY_STOREFRONT_TOKEN'],
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export function createStorefront(options = {}) {
|
|
62
|
+
const known = Object.fromEntries(Object.entries(options).filter(([k]) => k in DEFAULTS || k === 'fetch' || k === 'pageSize'));
|
|
63
|
+
const conf = { ...DEFAULTS, ...known };
|
|
64
|
+
for (const [key, names] of Object.entries(ENV)) if (!conf[key]) conf[key] = names.map((n) => options[n]).find(Boolean) || '';
|
|
65
|
+
conf.domain = String(conf.domain || '').replace(/^https?:\/\//, '').replace(/\/.*$/, '');
|
|
66
|
+
if (!conf.domain || !conf.token) {
|
|
67
|
+
throw new TetherError('createStorefront needs domain and token: pass them, or import.meta.env with PUBLIC_SHOPIFY_DOMAIN and PUBLIC_SHOPIFY_STOREFRONT_TOKEN', { code: 'NOT_CONFIGURED' });
|
|
68
|
+
}
|
|
69
|
+
const pageSize = options.pageSize || 25;
|
|
70
|
+
const q = (query, variables, opts) => request(conf, query, variables, opts);
|
|
71
|
+
const fragment = () => productFragment(conf);
|
|
72
|
+
|
|
73
|
+
async function getProduct(raw) {
|
|
74
|
+
const key = productKey(raw);
|
|
75
|
+
if (!key) return null;
|
|
76
|
+
const data = isGid(key)
|
|
77
|
+
? await q(`query TetherServerProductById($id: ID!) { node(id: $id) { ... on Product { ...TetherProduct } } }\n${fragment()}`, { id: key })
|
|
78
|
+
: await q(`query TetherServerProduct($handle: String!) { product(handle: $handle) { ...TetherProduct } }\n${fragment()}`, { handle: key });
|
|
79
|
+
const p = isGid(key) ? data.node : data.product;
|
|
80
|
+
return p && p.id ? p : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function getProducts({ collection, query, first = pageSize, after = null, sortKey, reverse = false, filters } = {}) {
|
|
84
|
+
if (collection) {
|
|
85
|
+
const data = await q(`query TetherServerCollection($handle: String!, $first: Int!, $after: String, $sortKey: ProductCollectionSortKeys, $reverse: Boolean, $filters: [ProductFilter!]) {
|
|
86
|
+
collection(handle: $handle) {
|
|
87
|
+
${collectionFields()}
|
|
88
|
+
products(first: $first, after: $after, sortKey: $sortKey, reverse: $reverse, filters: $filters) {
|
|
89
|
+
nodes { ...TetherProduct }
|
|
90
|
+
pageInfo { hasNextPage endCursor }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
${fragment()}`, { handle: collection, first, after, sortKey: sortKey || undefined, reverse, filters: filters || undefined });
|
|
95
|
+
if (!data.collection) return { collection: null, products: [], pageInfo: { hasNextPage: false, endCursor: null } };
|
|
96
|
+
const { products, ...rest } = data.collection;
|
|
97
|
+
return { collection: rest, products: products.nodes, pageInfo: products.pageInfo };
|
|
98
|
+
}
|
|
99
|
+
const data = await q(`query TetherServerProducts($first: Int!, $after: String, $query: String, $sortKey: ProductSortKeys, $reverse: Boolean) {
|
|
100
|
+
products(first: $first, after: $after, query: $query, sortKey: $sortKey, reverse: $reverse) {
|
|
101
|
+
nodes { ...TetherProduct }
|
|
102
|
+
pageInfo { hasNextPage endCursor }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
${fragment()}`, { first, after, query: query || undefined, sortKey: sortKey || undefined, reverse });
|
|
106
|
+
return { collection: null, products: data.products.nodes, pageInfo: data.products.pageInfo };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Every product (of a collection or a search), page by page. For getStaticPaths(). */
|
|
110
|
+
async function allProducts(opts = {}) {
|
|
111
|
+
const out = [];
|
|
112
|
+
let after = null;
|
|
113
|
+
for (let i = 0; i < 500; i++) {
|
|
114
|
+
const page = await getProducts({ ...opts, first: pageSize, after });
|
|
115
|
+
out.push(...page.products);
|
|
116
|
+
if (!page.pageInfo.hasNextPage) break;
|
|
117
|
+
after = page.pageInfo.endCursor;
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function getCollection(handle, opts = {}) {
|
|
123
|
+
const page = await getProducts({ ...opts, collection: handle });
|
|
124
|
+
return page.collection ? { ...page.collection, products: page.products, pageInfo: page.pageInfo } : null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function allCollections() {
|
|
128
|
+
const out = [];
|
|
129
|
+
let after = null;
|
|
130
|
+
for (let i = 0; i < 100; i++) {
|
|
131
|
+
const data = await q(`query TetherServerCollections($first: Int!, $after: String) {
|
|
132
|
+
collections(first: $first, after: $after) { nodes { ${collectionFields()} } pageInfo { hasNextPage endCursor } }
|
|
133
|
+
}`, { first: 100, after });
|
|
134
|
+
out.push(...data.collections.nodes);
|
|
135
|
+
if (!data.collections.pageInfo.hasNextPage) break;
|
|
136
|
+
after = data.collections.pageInfo.endCursor;
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** By product id (gid or numeric) or handle, so it can run next to getProduct(handle). */
|
|
142
|
+
async function getRecommendations(product, intent = 'RELATED') {
|
|
143
|
+
const key = productKey(typeof product === 'object' ? product?.id : product);
|
|
144
|
+
const byId = key?.startsWith('gid://');
|
|
145
|
+
const data = await q(`query TetherServerRecs(${byId ? '$id: ID!' : '$handle: String!'}, $intent: ProductRecommendationIntent) {
|
|
146
|
+
productRecommendations(${byId ? 'productId: $id' : 'productHandle: $handle'}, intent: $intent) { ...TetherProduct }
|
|
147
|
+
}
|
|
148
|
+
${fragment()}`, byId ? { id: key, intent } : { handle: key, intent });
|
|
149
|
+
return data.productRecommendations || [];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* A <script> tag that hands products to the browser script: Tether renders them at once,
|
|
154
|
+
* without its own request, then refreshes prices and stock in the background.
|
|
155
|
+
* Put it anywhere in the page (Astro: <Fragment set:html={shop.hydrate(products)} />).
|
|
156
|
+
*/
|
|
157
|
+
function hydrate(products) {
|
|
158
|
+
const list = (Array.isArray(products) ? products : [products]).filter(Boolean);
|
|
159
|
+
const payload = { v: 1, at: new Date().toISOString(), country: conf.country || null, language: conf.language || null, products: list };
|
|
160
|
+
return `<script type="application/json" data-tether-hydrate>${safeJson(payload)}</script>`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
config: conf,
|
|
165
|
+
query: q,
|
|
166
|
+
getProduct,
|
|
167
|
+
getProducts,
|
|
168
|
+
allProducts,
|
|
169
|
+
getCollection,
|
|
170
|
+
allCollections,
|
|
171
|
+
getRecommendations,
|
|
172
|
+
hydrate,
|
|
173
|
+
formatMoney: (money, locale = conf.locale) => formatMoney(money, locale),
|
|
174
|
+
imageUrl,
|
|
175
|
+
initialState: (product, opts = {}) => initialState(product, { defaultPurchase: conf.defaultPurchase, ...opts }),
|
|
176
|
+
productUrl: (handle, opts = {}) => productUrl(handle, { productPath: conf.productPath, ...opts }),
|
|
177
|
+
plans,
|
|
178
|
+
};
|
|
179
|
+
}
|
package/src/status.js
CHANGED
|
@@ -53,12 +53,7 @@ export function setScopeStatus(root, status, err) {
|
|
|
53
53
|
scoped(root, sel('show'), boundary()).forEach((el) => {
|
|
54
54
|
const k = get(el, 'show');
|
|
55
55
|
if (!STATUS_KEYS.includes(k)) return;
|
|
56
|
-
|
|
57
|
-
show(el, visible);
|
|
58
|
-
// Blocks hidden by a Webflow class (display: none) still need to appear.
|
|
59
|
-
if (visible && typeof getComputedStyle === 'function' && getComputedStyle(el).display === 'none') {
|
|
60
|
-
el.style.display = el.getAttribute(attr('display')) || 'block';
|
|
61
|
-
}
|
|
56
|
+
show(el, k === status);
|
|
62
57
|
});
|
|
63
58
|
|
|
64
59
|
if (status === 'error' || notFound) {
|
package/types/astro.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { TetherConfig } from './index.js';
|
|
2
|
+
|
|
3
|
+
type Serializable<T> = { [K in keyof T as T[K] extends ((...args: any[]) => any) | null | undefined ? (Exclude<T[K], null | undefined> extends (...args: any[]) => any ? never : K) : K]: T[K] };
|
|
4
|
+
|
|
5
|
+
export interface TetherAstroOptions extends Partial<Serializable<TetherConfig>> {
|
|
6
|
+
/** Add the Tether script to every page (default true). False: import and init it yourself. */
|
|
7
|
+
inject?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Astro integration: Tether on every page, store and token from .env
|
|
12
|
+
* (PUBLIC_SHOPIFY_DOMAIN, PUBLIC_SHOPIFY_STOREFRONT_TOKEN). Functions (onError, hooks) go in
|
|
13
|
+
* window.TETHER_CONFIG in your layout.
|
|
14
|
+
*/
|
|
15
|
+
export default function tether(options?: TetherAstroOptions): { name: string; hooks: Record<string, (...args: any[]) => unknown> };
|
package/types/index.d.ts
CHANGED
|
@@ -60,7 +60,7 @@ export interface ShopifyCartLine {
|
|
|
60
60
|
merchandise: ShopifyVariant & { product: Pick<ShopifyProduct, 'id' | 'handle' | 'title'> & Record<string, unknown> };
|
|
61
61
|
sellingPlanAllocation?: { sellingPlan: { id: string; name: string } } | null;
|
|
62
62
|
attributes?: { key: string; value: string }[];
|
|
63
|
-
cost:
|
|
63
|
+
cost: { totalAmount: Money; subtotalAmount: Money; amountPerQuantity: Money; compareAtAmountPerQuantity?: Money | null };
|
|
64
64
|
[k: string]: unknown;
|
|
65
65
|
}
|
|
66
66
|
|
|
@@ -117,6 +117,10 @@ export interface TetherConfig {
|
|
|
117
117
|
sellingPlansFirst?: number;
|
|
118
118
|
collectionFirst?: number;
|
|
119
119
|
retries?: number;
|
|
120
|
+
/** Products handed over by the server (tether/server hydrate()): refresh once in the background (default true). */
|
|
121
|
+
revalidate?: boolean;
|
|
122
|
+
/** Mount on Astro View Transitions (astro:page-load) and close the cart on navigation (default true). */
|
|
123
|
+
astro?: boolean;
|
|
120
124
|
storageKey?: string | null;
|
|
121
125
|
cartDrawerOpenClass?: string;
|
|
122
126
|
openCartOnAdd?: boolean;
|
|
@@ -163,11 +167,17 @@ export interface LineInput {
|
|
|
163
167
|
attributes?: Attributes;
|
|
164
168
|
}
|
|
165
169
|
|
|
170
|
+
export interface CartWarning { code: string; message: string }
|
|
171
|
+
|
|
166
172
|
export interface Cart {
|
|
167
173
|
readonly state: ShopifyCart | null;
|
|
168
174
|
readonly lines: ShopifyCartLine[];
|
|
169
175
|
readonly count: number;
|
|
170
176
|
lastError: Error | null;
|
|
177
|
+
/** Shopify's notes on the last change, e.g. less stock than asked (code MERCHANDISE_NOT_ENOUGH_STOCK). */
|
|
178
|
+
readonly warnings: CartWarning[];
|
|
179
|
+
/** true once the saved cart (or the absence of one) is known. */
|
|
180
|
+
readonly loaded: boolean;
|
|
171
181
|
id(): string | null;
|
|
172
182
|
init(): Promise<ShopifyCart | null>;
|
|
173
183
|
/** add(variantId, qty) · add({ variantId, quantity, sellingPlanId, attributes }) · add([line, line]) */
|
|
@@ -189,6 +199,8 @@ export interface Cart {
|
|
|
189
199
|
isOpen(): boolean;
|
|
190
200
|
drawers(): HTMLElement[];
|
|
191
201
|
render(): void;
|
|
202
|
+
/** For framework islands: fn(cart, meta) now, when the saved cart has loaded and after every change. Returns unsubscribe. */
|
|
203
|
+
subscribe(fn: (cart: ShopifyCart | null, meta: { open: boolean; count: number; loaded: boolean; warnings: CartWarning[] }) => void): () => void;
|
|
192
204
|
}
|
|
193
205
|
|
|
194
206
|
export interface ProductScope {
|
|
@@ -262,6 +274,7 @@ export interface TetherEvents {
|
|
|
262
274
|
'product-added': { product: ShopifyProduct; variant: ShopifyVariant; line: LineInput };
|
|
263
275
|
added: { cart: ShopifyCart; lines: LineInput[] };
|
|
264
276
|
'group-added': { group: string | null; lines: LineInput[] };
|
|
277
|
+
'cart-warning': { warnings: CartWarning[] };
|
|
265
278
|
'cart-updated': { cart: ShopifyCart | null; reason: string };
|
|
266
279
|
'cart-toggled': { open: boolean };
|
|
267
280
|
'cart-error': { error: Error };
|
|
@@ -290,13 +303,15 @@ export interface Tether {
|
|
|
290
303
|
cart: Cart;
|
|
291
304
|
getProduct(handleOrId: string): Promise<ShopifyProduct>;
|
|
292
305
|
getProducts(opts?: { collection?: string; query?: string; first?: number; after?: string | null; sortKey?: string; reverse?: boolean; filters?: unknown[] }): Promise<ProductPage>;
|
|
293
|
-
getRecommendations(
|
|
306
|
+
getRecommendations(product: string | Pick<ShopifyProduct, 'id'>, intent?: 'RELATED' | 'COMPLEMENTARY'): Promise<ShopifyProduct[]>;
|
|
294
307
|
getScope(el: Element | null | undefined): ProductScope | null;
|
|
295
308
|
addGroup(button: Element): Promise<ShopifyCart | null>;
|
|
296
309
|
primeProduct(product: ShopifyProduct): void;
|
|
297
310
|
normalizeProductKey(raw: string | number): string | null;
|
|
298
311
|
clearProductCache(): void;
|
|
299
312
|
formatMoney(money: Money | null | undefined): string;
|
|
313
|
+
/** Shopify CDN image at a size: imageUrl(line.merchandise.image?.url, { width: 160 }). */
|
|
314
|
+
imageUrl(url: string | null | undefined, size?: { width?: number; height?: number; crop?: string }): string;
|
|
300
315
|
plans: SellingPlanHelpers;
|
|
301
316
|
TetherError: typeof TetherError;
|
|
302
317
|
resolveProductKey(el: Element, location?: Location | URL): { key: string | null; fromUrl: boolean; raw: string };
|