@occluze/tether 0.6.6 → 0.6.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/README.md +3 -3
  2. package/dist/esm/astro.js +2 -2
  3. package/dist/esm/chunks/account-C3NP6HFB.js +1 -0
  4. package/dist/esm/chunks/account-pages-WLK374FO.js +1 -0
  5. package/dist/esm/chunks/analytics-G3SAWMNL.js +1 -0
  6. package/dist/esm/chunks/chunk-27PJJZGC.js +1 -0
  7. package/dist/esm/chunks/chunk-2FSXS55U.js +1 -0
  8. package/dist/esm/chunks/chunk-FCSWQDVV.js +1 -0
  9. package/dist/esm/chunks/chunk-FOMSMIAM.js +1 -0
  10. package/dist/esm/chunks/chunk-GZJTCLLW.js +1 -0
  11. package/dist/esm/chunks/chunk-HEQILZVR.js +1 -0
  12. package/dist/esm/chunks/chunk-JOOEXYF3.js +4 -0
  13. package/dist/esm/chunks/chunk-MPTQYLSM.js +1 -0
  14. package/dist/esm/chunks/chunk-NEHZ37TU.js +3 -0
  15. package/dist/esm/chunks/chunk-V74KGEJF.js +1 -0
  16. package/dist/esm/chunks/chunk-VT4CDUJJ.js +1 -0
  17. package/dist/esm/chunks/chunk-YJLWX2HK.js +1 -0
  18. package/dist/esm/chunks/diagnose-6VVGZNTY.js +14 -0
  19. package/dist/esm/chunks/extras-GJVRDFM3.js +1 -0
  20. package/dist/esm/chunks/markets-ui-UNB4PTA7.js +1 -0
  21. package/dist/esm/chunks/search-OU75RNQL.js +1 -0
  22. package/dist/esm/index.js +1 -1
  23. package/dist/esm/server.js +2 -1
  24. package/dist/presets/barba.esm.js +56 -36
  25. package/dist/tether.account-pages.js +776 -432
  26. package/dist/tether.account-pages.min.js +1 -1
  27. package/dist/tether.account.js +655 -321
  28. package/dist/tether.account.min.js +1 -1
  29. package/dist/tether.analytics.js +318 -191
  30. package/dist/tether.analytics.min.js +1 -1
  31. package/dist/tether.diagnose.js +334 -243
  32. package/dist/tether.diagnose.min.js +2 -2
  33. package/dist/tether.extras.js +78 -33
  34. package/dist/tether.extras.min.js +1 -1
  35. package/dist/tether.js +3171 -1998
  36. package/dist/tether.markets.js +231 -126
  37. package/dist/tether.markets.min.js +1 -1
  38. package/dist/tether.min.js +0 -0
  39. package/dist/tether.min.js.map +4 -4
  40. package/dist/tether.search.js +221 -126
  41. package/dist/tether.search.min.js +1 -1
  42. package/package.json +23 -4
  43. package/src/account/addresses-view.js +92 -0
  44. package/src/account/browser-session.js +359 -0
  45. package/src/account/markup.js +48 -0
  46. package/src/account/operations.js +180 -0
  47. package/src/account/order-lines.js +96 -0
  48. package/src/account/order-list.js +91 -0
  49. package/src/account/order-page.js +192 -0
  50. package/src/account/page-events.js +288 -0
  51. package/src/account/server-client.js +172 -0
  52. package/src/account/shared.js +111 -0
  53. package/src/account/shopify-auth.js +150 -0
  54. package/src/account/subscriptions-view.js +158 -0
  55. package/src/account-api.js +118 -0
  56. package/src/account-links.js +35 -0
  57. package/src/account-pages.js +68 -284
  58. package/src/account.js +281 -317
  59. package/src/amounts.js +36 -0
  60. package/src/analytics-shopify.js +241 -0
  61. package/src/analytics.js +304 -184
  62. package/src/api-version.js +75 -25
  63. package/src/astro.js +233 -68
  64. package/src/cart/checkout.js +97 -0
  65. package/src/cart/drawer.js +138 -0
  66. package/src/cart/mutations.js +234 -0
  67. package/src/cart/render.js +294 -0
  68. package/src/cart/store.js +326 -0
  69. package/src/cart.js +322 -481
  70. package/src/client.js +214 -78
  71. package/src/collection.js +126 -57
  72. package/src/config.js +323 -59
  73. package/src/controls/cart-controls.js +155 -0
  74. package/src/controls/discount.js +96 -0
  75. package/src/controls/extras.js +63 -0
  76. package/src/controls/navigation.js +83 -0
  77. package/src/diagnose-markup.js +333 -0
  78. package/src/diagnose.js +251 -202
  79. package/src/dom.js +219 -220
  80. package/src/extras.js +119 -37
  81. package/src/fragments.js +115 -22
  82. package/src/images.js +104 -0
  83. package/src/index.js +245 -285
  84. package/src/initial.js +78 -66
  85. package/src/lazy-iife.js +92 -22
  86. package/src/lazy.js +25 -4
  87. package/src/loader.js +263 -124
  88. package/src/market-codes.js +35 -0
  89. package/src/markets-ui.js +169 -55
  90. package/src/markets.js +286 -85
  91. package/src/markup.js +64 -0
  92. package/src/mounting.js +214 -91
  93. package/src/page-market.js +116 -0
  94. package/src/parts/account-pages.js +4 -1
  95. package/src/parts/account.js +4 -1
  96. package/src/parts/analytics.js +4 -1
  97. package/src/parts/diagnose.js +4 -1
  98. package/src/parts/extras.js +4 -1
  99. package/src/parts/markets.js +4 -1
  100. package/src/parts/search.js +4 -1
  101. package/src/presets/barba.js +120 -30
  102. package/src/product-events.js +130 -0
  103. package/src/product-render.js +257 -0
  104. package/src/product-state.js +318 -0
  105. package/src/product.js +233 -459
  106. package/src/repeaters.js +173 -0
  107. package/src/search.js +277 -107
  108. package/src/selling-plans.js +107 -47
  109. package/src/server.js +168 -205
  110. package/src/shopify-values.js +89 -0
  111. package/src/status.js +99 -39
  112. package/src/store-html.js +0 -0
  113. package/src/storefront/catalog.js +199 -0
  114. package/src/storefront/json-ld.js +167 -0
  115. package/src/variant-render.js +165 -0
  116. package/types/astro.d.ts +13 -2
  117. package/types/index.d.ts +178 -27
  118. package/types/server.d.ts +89 -17
  119. package/dist/esm/chunks/account-QUBHQG3C.js +0 -1
  120. package/dist/esm/chunks/account-pages-VWDHF6WA.js +0 -1
  121. package/dist/esm/chunks/analytics-SLATWPLC.js +0 -1
  122. package/dist/esm/chunks/chunk-64TUD3QR.js +0 -1
  123. package/dist/esm/chunks/chunk-AJLPU2JC.js +0 -4
  124. package/dist/esm/chunks/chunk-FIOZPPC5.js +0 -1
  125. package/dist/esm/chunks/chunk-K2YEQAIH.js +0 -1
  126. package/dist/esm/chunks/chunk-L2TRRKYV.js +0 -1
  127. package/dist/esm/chunks/chunk-RREHGH4A.js +0 -1
  128. package/dist/esm/chunks/chunk-VC46IEJQ.js +0 -1
  129. package/dist/esm/chunks/chunk-VCPI7MJ2.js +0 -1
  130. package/dist/esm/chunks/chunk-VPVJPNB4.js +0 -1
  131. package/dist/esm/chunks/chunk-ZIUIWEON.js +0 -1
  132. package/dist/esm/chunks/diagnose-VXQBSD3A.js +0 -14
  133. package/dist/esm/chunks/extras-R4X34VQL.js +0 -1
  134. package/dist/esm/chunks/markets-ui-F3R5T5EL.js +0 -1
  135. package/dist/esm/chunks/search-NILMGYXG.js +0 -1
package/src/mounting.js CHANGED
@@ -1,87 +1,144 @@
1
- /** Product scopes and lists that are mounted, shared by index.js and the optional markets part. */
1
+ /**
2
+ * The product scopes and lists on the page: mounting a product root, the live roots a country switch re-renders, and
3
+ * the products the server rendered into the page. Shared by `index.js` and the optional markets part.
4
+ */
2
5
  import { cfg, log, warn, reportError } from './config.js';
3
6
  import { ProductScope, scopes, rememberOptionNames } from './product.js';
4
7
  import { listSelector } from './collection.js';
5
8
  import { getProduct, takeStale, refreshProducts, hydrateProducts } from './loader.js';
6
9
  import { resolveProductKey, setScopeStatus, isNotFound } from './status.js';
7
10
  import { attr, get, qsa, emit } from './dom.js';
11
+ /** @import { Product } from './fragments.js' */
12
+
13
+ /** @typedef {{ root: Element, load: (more?: boolean) => Promise<unknown> }} MountedList A mounted `ListScope`. */
14
+
15
+ /**
16
+ * The marker on the `<script type="application/json">` tags `tether/server`'s `hydrate()` writes. It is fixed, not
17
+ * `<prefix>-hydrate`, because the server writes it without knowing the browser's prefix.
18
+ */
19
+ const HYDRATE_SELECTOR = 'script[type="application/json"][data-tether-hydrate]';
8
20
 
21
+ /** @type {WeakSet<Element>} */
9
22
  export const mountedProducts = new WeakSet();
23
+ /** @type {WeakSet<Element>} */
10
24
  export const mountedLists = new WeakSet();
11
- // Live roots, for re-rendering after a country switch (pruned when disconnected).
25
+ /** @type {Set<HTMLElement>} Product roots to re-render after a country switch; pruned once disconnected. */
12
26
  export const liveProducts = new Set();
27
+ /** @type {Set<MountedList>} Lists to reload after a country switch; pruned once disconnected. */
13
28
  export const liveLists = new Set();
14
- const requestToken = new WeakMap(); // product root -> latest request, so a slow old response can't win
29
+ /** @type {WeakMap<Element, object>} Each product root's latest request, so a slow older response can't win. */
30
+ const latestRequest = new WeakMap();
15
31
 
32
+ /** Forgets roots a page transition removed, so they aren't kept alive. */
16
33
  export function pruneLive() {
17
- liveProducts.forEach((el) => { if (!el.isConnected) liveProducts.delete(el); });
18
- liveLists.forEach((l) => { if (!l.root.isConnected) liveLists.delete(l); });
34
+ liveProducts.forEach((el) => {
35
+ if (!el.isConnected) liveProducts.delete(el);
36
+ });
37
+ liveLists.forEach((list) => {
38
+ if (!list.root.isConnected) liveLists.delete(list);
39
+ });
19
40
  }
41
+
42
+ /**
43
+ * @param {ParentNode & Partial<Pick<Element, 'matches'>>} root
44
+ * @param {string} selector
45
+ * @returns {HTMLElement[]} `root` itself when it matches, then the matches inside it.
46
+ */
20
47
  export function within(root, selector) {
21
- return [...(root.matches?.(selector) ? [root] : []), ...qsa(root, selector)];
48
+ const self = root.matches?.(selector) ? [/** @type {HTMLElement} */ (root)] : [];
49
+ return [...self, ...qsa(root, selector)];
50
+ }
51
+
52
+ /**
53
+ * `tether-page-title="{title} | My shop"` on a product root sets `document.title`, for dynamic product pages.
54
+ *
55
+ * @param {Element} el
56
+ * @param {Product} product
57
+ */
58
+ function applyPageTitle(el, product) {
59
+ const template = get(el, 'page-title');
60
+ if (!template || typeof document === 'undefined') return;
61
+ document.title = template
62
+ .replace(/\{title\}/g, product.title)
63
+ .replace(/\{vendor\}/g, product.vendor || '')
64
+ .replace(/\{type\}/g, product.productType || '');
22
65
  }
23
66
 
24
- export /** Mounts one product root. Returns true when a product request was started. */
25
- function mountProduct(el) {
67
+ /**
68
+ * A product root without a key: on a dynamic page opened without `?handle=` (or with an empty path) it is a product
69
+ * that isn't there; inside a list it gets its handle when the list renders.
70
+ *
71
+ * @param {HTMLElement} el
72
+ * @param {{ fromUrl: boolean, raw: string }} source
73
+ */
74
+ function mountWithoutKey(el, { fromUrl, raw }) {
75
+ if (fromUrl) {
76
+ mountedProducts.add(el);
77
+ setScopeStatus(el, 'not-found');
78
+ emit('product-not-found', { key: null, source: raw }, el);
79
+ } else if (!el.closest(listSelector())) {
80
+ warn(`[${attr('product')}] without a value. Bind it to the Shopify handle field, or use "url:handle".`, el);
81
+ }
82
+ }
83
+
84
+ /**
85
+ * @param {HTMLElement} el
86
+ * @param {string} key
87
+ * @param {{ fromUrl: boolean, raw: string }} source
88
+ * @param {unknown} error
89
+ */
90
+ function showLoadFailure(el, key, { fromUrl, raw }, error) {
91
+ const notFound = isNotFound(error);
92
+ setScopeStatus(el, notFound ? 'not-found' : 'error', error);
93
+ if (!notFound) {
94
+ reportError(error, `product ${key}`);
95
+ emit('error', { error, context: 'product', key }, el);
96
+ return;
97
+ }
98
+ emit('product-not-found', { key, source: raw }, el);
99
+ // A typo in a URL isn't worth an alert; a CMS item pointing at a deleted product is.
100
+ if (fromUrl) log(`product not found: ${key}`);
101
+ else reportError(error, `product ${key}`);
102
+ }
103
+
104
+ /**
105
+ * Mounts one product root.
106
+ *
107
+ * @param {HTMLElement} el
108
+ * @returns {boolean} True when a product request was started.
109
+ */
110
+ export function mountProduct(el) {
26
111
  if (mountedProducts.has(el)) return false;
27
- const { key, fromUrl, raw } = resolveProductKey(el);
112
+ const { key, ...source } = resolveProductKey(el);
28
113
  if (!key) {
29
- if (fromUrl) {
30
- // Dynamic product page opened without ?handle= (or an empty path).
31
- mountedProducts.add(el);
32
- setScopeStatus(el, 'not-found');
33
- emit('product-not-found', { key: null, source: raw }, el);
34
- } else if (!el.closest(listSelector())) {
35
- // List cards get their handle when the list renders.
36
- warn(`[${attr('product')}] without a value. Bind it to the Shopify handle field, or use "url:handle".`, el);
37
- }
114
+ mountWithoutKey(el, source);
38
115
  return false;
39
116
  }
40
117
  mountedProducts.add(el);
41
118
  liveProducts.add(el);
42
- const token = {};
43
- requestToken.set(el, token);
44
- const current = () => requestToken.get(el) === token;
45
- // A rendered scope keeps showing its content while it refreshes (e.g. country switch).
46
- if (!scopes.get(el)) setScopeStatus(el, 'loading');
47
- else el.setAttribute(attr('state'), 'updating');
48
- const loading = getProduct(key);
49
- marketPending?.add(loading);
119
+ const request = {};
120
+ latestRequest.set(el, request);
121
+ const isLatest = () => latestRequest.get(el) === request;
122
+ // A rendered scope keeps showing its content while it refreshes (a country switch).
123
+ if (scopes.get(el)) el.setAttribute(attr('state'), 'updating');
124
+ else setScopeStatus(el, 'loading');
125
+ const loading = trackMarket(getProduct(key));
50
126
  loading
51
- .then((p) => {
52
- if (!current()) return;
127
+ .then((product) => {
128
+ if (!isLatest()) return;
53
129
  const existing = scopes.get(el);
54
- if (existing) existing.update(p);
55
- // eslint-disable-next-line no-new
56
- else new ProductScope(el, p);
57
- applyPageTitle(el, p);
58
- if (cfg.revalidate !== false && takeStale(key)) queueRefresh(key);
130
+ if (existing) existing.update(product);
131
+ else new ProductScope(el, product);
132
+ applyPageTitle(el, product);
133
+ if (cfg.revalidate && takeStale(key)) queueRefresh(key);
59
134
  })
60
- .catch((err) => {
61
- if (!current()) return;
62
- const notFound = isNotFound(err);
63
- setScopeStatus(el, notFound ? 'not-found' : 'error', err);
64
- if (notFound) {
65
- emit('product-not-found', { key, source: raw }, el);
66
- // A typo in a URL isn't worth an alert; a CMS item pointing at a deleted product is.
67
- if (fromUrl) log(`product not found: ${key}`); else reportError(err, `product ${key}`);
68
- } else {
69
- reportError(err, `product ${key}`);
70
- emit('error', { error: err, context: 'product', key }, el);
71
- }
135
+ .catch((/** @type {unknown} */ error) => {
136
+ if (isLatest()) showLoadFailure(el, key, source, error);
72
137
  });
73
138
  return true;
74
139
  }
75
140
 
76
- /** tether-page-title="{title} | My shop" on a product root sets document.title (dynamic product pages). */
77
- function applyPageTitle(el, p) {
78
- const tpl = get(el, 'page-title');
79
- if (!tpl || typeof document === 'undefined') return;
80
- document.title = tpl.replace(/\{title\}/g, p.title).replace(/\{vendor\}/g, p.vendor || '').replace(/\{type\}/g, p.productType || '');
81
- }
82
-
83
-
84
- /** Product scopes that failed on a network/API error: loaded again when the browser is back online. */
141
+ /** Loads again the product scopes that failed on a network or API error, for when the browser is back online. */
85
142
  export function retryFailed() {
86
143
  pruneLive();
87
144
  liveProducts.forEach((el) => {
@@ -91,64 +148,130 @@ export function retryFailed() {
91
148
  });
92
149
  }
93
150
 
94
- // Server-rendered products: one batched background request refreshes them all.
151
+ /** @type {Set<string>} Server-rendered products to refresh in the next batch. */
95
152
  let refreshQueue = new Set();
153
+
154
+ /** @param {string} key */
96
155
  function queueRefresh(key) {
97
156
  if (!refreshQueue.size) setTimeout(flushRefresh, 0);
98
157
  refreshQueue.add(key);
99
158
  }
159
+
160
+ /**
161
+ * @param {string} key The key the refresh was asked for.
162
+ * @param {Product} fresh
163
+ */
164
+ function applyRefresh(key, fresh) {
165
+ liveProducts.forEach((el) => {
166
+ const scope = scopes.get(el);
167
+ if (!scope || !el.isConnected) return;
168
+ const rootKey = resolveProductKey(el).key;
169
+ if (rootKey !== key && rootKey !== fresh.handle && rootKey !== fresh.id) return;
170
+ if (JSON.stringify(scope.product) !== JSON.stringify(fresh)) scope.update(fresh);
171
+ });
172
+ }
173
+
174
+ /** One batched background request refreshes every server-rendered product queued. */
100
175
  function flushRefresh() {
101
176
  const keys = [...refreshQueue];
102
177
  refreshQueue = new Set();
103
- refreshProducts(keys).forEach((promise, i) => promise.then((fresh) => {
104
- liveProducts.forEach((el) => {
105
- const scope = scopes.get(el);
106
- if (!scope || !el.isConnected) return;
107
- const k = resolveProductKey(el).key;
108
- if (k !== keys[i] && k !== fresh.handle && k !== fresh.id) return;
109
- if (JSON.stringify(scope.product) !== JSON.stringify(fresh)) scope.update(fresh);
110
- });
111
- }).catch((err) => reportError(err, `refresh ${keys[i]}`)));
178
+ refreshProducts(keys).forEach((promise, index) => {
179
+ const key = /** @type {string} */ (keys[index]);
180
+ promise
181
+ .then((fresh) => {
182
+ applyRefresh(key, fresh);
183
+ })
184
+ .catch((/** @type {unknown} */ error) => {
185
+ reportError(error, `refresh ${key}`);
186
+ });
187
+ });
112
188
  }
113
189
 
114
190
  /**
115
- * <script type="application/json" data-tether-hydrate> tags written by tether/server's hydrate():
116
- * their products are used without a request. Ignored when they were made for another market.
191
+ * @type {Set<Promise<unknown>> | null} While the page's server-rendered prices are another market's: the loads to
192
+ * wait for before `html.tether-market-pending` goes. `null` when no market is pending.
117
193
  */
118
- const readScripts = new WeakSet();
119
- let marketPending = null; // product loads to wait for before html.tether-market-pending goes
194
+ let marketPending = null;
120
195
 
121
- /** Lists (and anything else that loads prices) wait too. */
122
- export const trackMarket = (p) => { marketPending?.add(p); return p; };
196
+ /**
197
+ * Lists, and anything else that loads prices, hold `html.tether-market-pending` until they are in.
198
+ *
199
+ * @template T
200
+ * @param {Promise<T>} loading
201
+ * @returns {Promise<T>} `loading` itself.
202
+ */
203
+ export const trackMarket = (loading) => {
204
+ marketPending?.add(loading);
205
+ return loading;
206
+ };
123
207
 
124
- /** After a mount: drop html.tether-market-pending once this page's products are live. */
208
+ /** After a mount: drops `html.tether-market-pending` once this page's products are live. */
125
209
  export function settleMarket() {
126
210
  if (!marketPending) return;
127
211
  const waits = [...marketPending];
128
- Promise.allSettled(waits).then(() => {
129
- if (!marketPending || waits.some((w) => !marketPending.has(w))) return;
130
- if (waits.length === marketPending.size) {
131
- marketPending = null;
132
- document.documentElement.classList.remove(`${cfg.prefix}-market-pending`);
133
- } else settleMarket(); // more products came in meanwhile
212
+ void Promise.allSettled(waits).then(() => {
213
+ if (!marketPending || waits.some((wait) => !marketPending?.has(wait))) return;
214
+ if (waits.length !== marketPending.size) {
215
+ settleMarket(); // more loads came in meanwhile
216
+ return;
217
+ }
218
+ marketPending = null;
219
+ document.documentElement.classList.remove(`${cfg.prefix}-market-pending`);
134
220
  });
135
221
  }
136
222
 
223
+ /**
224
+ * What `tether/server`'s `hydrate()` writes. The tag is the site's own markup, so its shape is trusted; a product
225
+ * without an id or a handle is skipped.
226
+ *
227
+ * @typedef {{ country?: string | null, language?: string | null, products?: (Product | null)[] }} HydratePayload
228
+ */
229
+
230
+ /**
231
+ * @param {Product | null} product
232
+ * @returns {product is Product}
233
+ */
234
+ const isUsable = (product) => Boolean(product?.id && product.handle);
235
+
236
+ /**
237
+ * @param {string | null | undefined} a
238
+ * @param {string | null | undefined} b
239
+ */
240
+ const sameCode = (a, b) => (a ?? '').toUpperCase() === (b ?? '').toUpperCase();
241
+
242
+ /** @type {WeakSet<Element>} Hydrate tags already read. */
243
+ const readScripts = new WeakSet();
244
+
245
+ /**
246
+ * Uses the products in the page's hydrate tags (`tether/server` `hydrate()`) without a request. A tag made for another
247
+ * market is skipped, and marks the page (`html.tether-market-pending`) until the live prices are in, so CSS can hide
248
+ * prices meanwhile.
249
+ *
250
+ * @param {Document | Element} [root]
251
+ */
137
252
  export function hydrateFromPage(root = document) {
138
- qsa(root.ownerDocument || root, 'script[type="application/json"][data-tether-hydrate]').forEach((s) => {
139
- if (readScripts.has(s)) return;
140
- readScripts.add(s);
253
+ qsa(root.ownerDocument ?? root, HYDRATE_SELECTOR).forEach((script) => {
254
+ if (readScripts.has(script)) return;
255
+ readScripts.add(script);
256
+ /** @type {unknown} */
257
+ let json;
141
258
  try {
142
- const data = JSON.parse(s.textContent);
143
- rememberOptionNames(data.products); // the build's option names, also for other languages
144
- const same = (a, b) => String(a || '').toUpperCase() === String(b || '').toUpperCase();
145
- if (same(data.country, cfg.country) && same(data.language, cfg.language)) hydrateProducts(data.products);
146
- else {
147
- // The page was built for another market: its prices aren't this visitor's. Mark the page
148
- // (html.tether-market-pending) until the live ones are in, so CSS can hide prices meanwhile.
149
- log('hydration skipped: other market');
150
- if (!marketPending) { marketPending = new Set(); document.documentElement.classList.add(`${cfg.prefix}-market-pending`); }
151
- }
152
- } catch (err) { warn('bad data-tether-hydrate', err); }
259
+ json = JSON.parse(script.textContent);
260
+ } catch (error) {
261
+ warn('bad data-tether-hydrate', error);
262
+ return;
263
+ }
264
+ const { country, language, products } = /** @type {HydratePayload | null} */ (json) ?? {};
265
+ const usable = Array.isArray(products) ? products.filter(isUsable) : [];
266
+ rememberOptionNames(usable); // the build's option names, also for other languages
267
+ if (sameCode(country, cfg.country) && sameCode(language, cfg.language)) {
268
+ hydrateProducts(usable);
269
+ return;
270
+ }
271
+ log('hydration skipped: other market');
272
+ if (!marketPending) {
273
+ marketPending = new Set();
274
+ document.documentElement.classList.add(`${cfg.prefix}-market-pending`);
275
+ }
153
276
  });
154
277
  }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * The market the page uses: a page built for one market (`<meta name="tether-page-country">`) wins, then the visitor's
3
+ * saved or detected market, then the configured one. Init applies it to `cfg` once. Astro's ClientRouter keeps the
4
+ * script running across pages, so each arrival asks again: the next page may be built for another market, or for none.
5
+ */
6
+ import { cfg, log, reportError } from './config.js';
7
+ import { loadPart } from './lazy.js';
8
+ import { toCountry, toLanguage } from './market-codes.js';
9
+ import { readSavedMarket } from './markets.js';
10
+ /** @import { TetherOptions } from './config.js' */
11
+ /** @import { Market, DetectedMarket } from './markets.js' */
12
+ /** @import { SetCountryOptions } from './markets-ui.js' */
13
+ /** @import { ShopifyCart } from './cart/store.js' */
14
+
15
+ /**
16
+ * @typedef {object} ConfiguredMarket The market of the config, before a page's or visitor's market applied.
17
+ * @property {string | undefined} country
18
+ * @property {string | undefined} language
19
+ * @property {string | undefined} locale
20
+ */
21
+
22
+ /** @type {ConfiguredMarket} */
23
+ let configuredMarket = { country: undefined, language: undefined, locale: undefined };
24
+
25
+ /** Whether the page arrived at last was built for its own market, which the next page without one switches back from. */
26
+ let lastPageHadOwnMarket = false;
27
+
28
+ /**
29
+ * `<meta name="tether-page-country" content="DE">`, with `tether-page-language` and `tether-page-locale`. Never
30
+ * remembered: it belongs to the page, not to the visitor.
31
+ *
32
+ * @returns {Market | null} `null` when the page has no valid country of its own.
33
+ */
34
+ function pageMarket() {
35
+ /** @param {string} name */
36
+ const meta = (name) =>
37
+ document.querySelector(`meta[name="${cfg.prefix}-page-${name}"]`)?.getAttribute('content') || undefined;
38
+ const country = toCountry(meta('country'));
39
+ if (!country) return null;
40
+ return { country, language: toLanguage(meta('language')) ?? undefined, locale: meta('locale') };
41
+ }
42
+
43
+ /**
44
+ * Without `markets.detect` only the visitor's saved choice applies, read without loading the markets part.
45
+ *
46
+ * @returns {Promise<Market | DetectedMarket | null>}
47
+ */
48
+ async function visitorMarket() {
49
+ const markets = cfg.markets ?? {};
50
+ if (markets.detect === false) return null;
51
+ if (!markets.detect) return markets.remember === false ? null : readSavedMarket();
52
+ try {
53
+ return (await loadPart('markets')).detectMarket(markets);
54
+ } catch (error) {
55
+ reportError(error, 'markets');
56
+ return null;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Sets `cfg`'s market for the first page, over the configured one.
62
+ *
63
+ * @param {TetherOptions} overrides What `init()` was called with: a `locale` there wins over the visitor's.
64
+ * @returns {Promise<void>}
65
+ */
66
+ export async function applyStartMarket(overrides) {
67
+ configuredMarket = { country: cfg.country, language: cfg.language, locale: cfg.locale };
68
+ const page = pageMarket();
69
+ lastPageHadOwnMarket = page !== null;
70
+ if (page) {
71
+ cfg.country = page.country;
72
+ cfg.language = page.language;
73
+ if (page.locale) cfg.locale = page.locale;
74
+ return;
75
+ }
76
+ const market = await visitorMarket();
77
+ if (!market) return;
78
+ if (market.country) cfg.country = market.country;
79
+ if (market.language) cfg.language = market.language;
80
+ if (market.locale && !overrides.locale) cfg.locale = market.locale;
81
+ log('market', market);
82
+ }
83
+
84
+ /**
85
+ * @param {string | undefined} a
86
+ * @param {string | undefined} b
87
+ */
88
+ const sameCode = (a, b) => (a ?? '').toUpperCase() === (b ?? '').toUpperCase();
89
+
90
+ /**
91
+ * The market to switch to on arriving at the current page. A page built for a market switches to it; the first page
92
+ * without one after such a page switches back to the visitor's own choice, else to the configured market.
93
+ *
94
+ * @returns {{ country: string, language: string | undefined, locale: string | undefined } | null} `null` when the page
95
+ * stays in the market in use.
96
+ */
97
+ export function marketForArrival() {
98
+ const page = pageMarket();
99
+ const leftOwnMarket = page === null && lastPageHadOwnMarket;
100
+ lastPageHadOwnMarket = page !== null;
101
+ const target = page ?? (leftOwnMarket ? (readSavedMarket() ?? configuredMarket) : null);
102
+ if (!target?.country) return null;
103
+ if (sameCode(target.country, cfg.country) && sameCode(target.language, cfg.language)) return null;
104
+ return { country: target.country, language: target.language, locale: target.locale };
105
+ }
106
+
107
+ /**
108
+ * `Tether.setCountry('DE')`: loads the markets part on first use.
109
+ *
110
+ * @param {string} code
111
+ * @param {SetCountryOptions} [options]
112
+ * @returns {Promise<ShopifyCart | null>} The cart once everything is in the new market.
113
+ */
114
+ export function setCountry(code, options) {
115
+ return loadPart('markets').then((part) => part.setCountry(code, options));
116
+ }
@@ -1,3 +1,6 @@
1
+ // The <script> build's tether.account-pages.js: registers the part where the core script's loader (lazy-iife.js) finds it.
1
2
  import * as part from '../account-pages.js';
2
3
 
3
- window.Tether.__parts['account-pages'] = part;
4
+ const { Tether } = /** @type {Window & { Tether?: { __parts: Record<string, unknown> } }} */ (window);
5
+ if (!Tether) throw new Error('tether.account-pages.js runs after the Tether script, which loads it');
6
+ Tether.__parts['account-pages'] = part;
@@ -1,3 +1,6 @@
1
+ // The <script> build's tether.account.js: registers the part where the core script's loader (lazy-iife.js) finds it.
1
2
  import * as part from '../account.js';
2
3
 
3
- window.Tether.__parts.account = part;
4
+ const { Tether } = /** @type {Window & { Tether?: { __parts: Record<string, unknown> } }} */ (window);
5
+ if (!Tether) throw new Error('tether.account.js runs after the Tether script, which loads it');
6
+ Tether.__parts['account'] = part;
@@ -1,3 +1,6 @@
1
+ // The <script> build's tether.analytics.js: registers the part where the core script's loader (lazy-iife.js) finds it.
1
2
  import * as part from '../analytics.js';
2
3
 
3
- window.Tether.__parts.analytics = part;
4
+ const { Tether } = /** @type {Window & { Tether?: { __parts: Record<string, unknown> } }} */ (window);
5
+ if (!Tether) throw new Error('tether.analytics.js runs after the Tether script, which loads it');
6
+ Tether.__parts['analytics'] = part;
@@ -1,3 +1,6 @@
1
+ // The <script> build's tether.diagnose.js: registers the part where the core script's loader (lazy-iife.js) finds it.
1
2
  import * as part from '../diagnose.js';
2
3
 
3
- window.Tether.__parts.diagnose = part;
4
+ const { Tether } = /** @type {Window & { Tether?: { __parts: Record<string, unknown> } }} */ (window);
5
+ if (!Tether) throw new Error('tether.diagnose.js runs after the Tether script, which loads it');
6
+ Tether.__parts['diagnose'] = part;
@@ -1,3 +1,6 @@
1
+ // The <script> build's tether.extras.js: registers the part where the core script's loader (lazy-iife.js) finds it.
1
2
  import * as part from '../extras.js';
2
3
 
3
- window.Tether.__parts.extras = part;
4
+ const { Tether } = /** @type {Window & { Tether?: { __parts: Record<string, unknown> } }} */ (window);
5
+ if (!Tether) throw new Error('tether.extras.js runs after the Tether script, which loads it');
6
+ Tether.__parts['extras'] = part;
@@ -1,3 +1,6 @@
1
+ // The <script> build's tether.markets.js: registers the part where the core script's loader (lazy-iife.js) finds it.
1
2
  import * as part from '../markets-ui.js';
2
3
 
3
- window.Tether.__parts.markets = part;
4
+ const { Tether } = /** @type {Window & { Tether?: { __parts: Record<string, unknown> } }} */ (window);
5
+ if (!Tether) throw new Error('tether.markets.js runs after the Tether script, which loads it');
6
+ Tether.__parts['markets'] = part;
@@ -1,3 +1,6 @@
1
+ // The <script> build's tether.search.js: registers the part where the core script's loader (lazy-iife.js) finds it.
1
2
  import * as part from '../search.js';
2
3
 
3
- window.Tether.__parts.search = part;
4
+ const { Tether } = /** @type {Window & { Tether?: { __parts: Record<string, unknown> } }} */ (window);
5
+ if (!Tether) throw new Error('tether.search.js runs after the Tether script, which loads it');
6
+ Tether.__parts['search'] = part;