@occluze/tether 0.6.7 → 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 +773 -442
  26. package/dist/tether.account-pages.min.js +1 -1
  27. package/dist/tether.account.js +648 -318
  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 +331 -242
  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 +3176 -2015
  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 +69 -298
  58. package/src/account.js +280 -323
  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 +320 -488
  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 -287
  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-Q6E2TFEV.js +0 -1
  120. package/dist/esm/chunks/account-pages-UZXR4R5F.js +0 -1
  121. package/dist/esm/chunks/analytics-IJBKATUP.js +0 -1
  122. package/dist/esm/chunks/chunk-4QJKLPYR.js +0 -1
  123. package/dist/esm/chunks/chunk-64TUD3QR.js +0 -1
  124. package/dist/esm/chunks/chunk-AJLPU2JC.js +0 -4
  125. package/dist/esm/chunks/chunk-FIOZPPC5.js +0 -1
  126. package/dist/esm/chunks/chunk-K2YEQAIH.js +0 -1
  127. package/dist/esm/chunks/chunk-KIDH76B7.js +0 -1
  128. package/dist/esm/chunks/chunk-PAYT5ZD7.js +0 -1
  129. package/dist/esm/chunks/chunk-RREHGH4A.js +0 -1
  130. package/dist/esm/chunks/chunk-SSJT43CJ.js +0 -1
  131. package/dist/esm/chunks/chunk-VC46IEJQ.js +0 -1
  132. package/dist/esm/chunks/diagnose-3WVAWNTX.js +0 -14
  133. package/dist/esm/chunks/extras-7I7WJX35.js +0 -1
  134. package/dist/esm/chunks/markets-ui-545XLFTE.js +0 -1
  135. package/dist/esm/chunks/search-NILMGYXG.js +0 -1
package/src/loader.js CHANGED
@@ -1,162 +1,257 @@
1
1
  /**
2
- * Data loading. Products are batched per tick into one request and cached for
3
- * the lifetime of the page (survives Barba/Swup page swaps).
2
+ * Product data for the browser script. Products asked for in the same tick go out as one request, and every product
3
+ * is cached for the life of the page, which survives Barba/Swup page swaps. Products the server rendered
4
+ * (`tether/server` `hydrate()`) are used at once and refreshed once in the background.
4
5
  */
5
6
  import { cfg, log } from './config.js';
6
7
  import { gql, TetherError } from './client.js';
7
8
  import { productFragment, collectionFields } from './fragments.js';
9
+ import { productKey } from './shopify-values.js';
10
+ /** @import { Product, Image } from './fragments.js' */
8
11
 
9
- const productCache = new Map(); // key -> Promise<product>
10
- let pending = [];
11
- const resolvers = new Map();
12
- let scheduled = false;
13
- // Bumped by clearProductCache(): responses to requests sent before that (e.g. in the old
14
- // market after a country switch) still reach their callers but never refill the cache.
12
+ /**
13
+ * A product request that missed is remembered this long, so one page asking twice sends one request, and a product
14
+ * published in Shopify meanwhile shows up on the next visit.
15
+ */
16
+ const MISS_CACHE_MS = 10_000;
17
+ /** A product loaded live this recently is newer than a server-rendered copy of it, which is then ignored. */
18
+ const FRESH_MS = 60_000;
19
+
20
+ /** @type {Map<string, Promise<Product>>} Handle or gid to the product, loaded or loading. */
21
+ const productCache = new Map();
22
+ /** @type {Map<string, { resolve: (product: Product) => void, reject: (error: unknown) => void }>} */
23
+ const waiting = new Map();
24
+ /** @type {Set<string>} Keys of products that came from the server and weren't refreshed yet. */
25
+ const stale = new Set();
26
+ /** @type {Map<string, number>} Handle or gid to when it last loaded live, in ms since the epoch. */
27
+ const freshAt = new Map();
28
+ /** @type {string[]} Keys for the next batch. */
29
+ const pending = [];
30
+ let flushScheduled = false;
31
+ /**
32
+ * Bumped by `clearProductCache()`: responses to requests sent before that (in the old market, after a country
33
+ * switch) still reach their callers but never refill the cache.
34
+ */
15
35
  let generation = 0;
16
36
 
17
- const isGid = (v) => typeof v === 'string' && v.startsWith('gid://');
37
+ /** @param {string} key */
38
+ const isGid = (key) => key.startsWith('gid://');
18
39
 
19
40
  /** Accepts a handle, numeric id, gid or a full product URL. */
20
- export function normalizeProductKey(raw) {
21
- const v = String(raw ?? '').trim();
22
- if (!v) return null;
23
- if (isGid(v)) return v;
24
- if (/^\d+$/.test(v)) return `gid://shopify/Product/${v}`;
25
- const m = v.match(/\/products\/([^/?#]+)/);
26
- return m ? m[1] : v;
27
- }
41
+ export const normalizeProductKey = productKey;
28
42
 
29
- /** Put a product we already have (from a collection query) in the cache. */
43
+ /**
44
+ * Puts a product the page already has (from a collection query) in the cache, under its handle and its id.
45
+ *
46
+ * @param {Product | null | undefined} product
47
+ */
30
48
  export function primeProduct(product) {
31
49
  if (!product) return;
32
- const p = Promise.resolve(product);
33
- productCache.set(product.handle, p);
34
- productCache.set(product.id, p);
50
+ const loaded = Promise.resolve(product);
51
+ productCache.set(product.handle, loaded);
52
+ productCache.set(product.id, loaded);
35
53
  }
36
54
 
55
+ /**
56
+ * @param {string | number | null | undefined} rawKey A handle, numeric id, gid or product URL.
57
+ * @returns {Promise<Product>}
58
+ * @throws {TetherError} `NOT_FOUND` (rejected) for an empty key or a product the store doesn't have.
59
+ */
37
60
  export function getProduct(rawKey) {
38
61
  const key = normalizeProductKey(rawKey);
39
62
  if (!key) return Promise.reject(new TetherError('empty product key', { code: 'NOT_FOUND' }));
40
- if (productCache.has(key)) return productCache.get(key);
63
+ const cached = productCache.get(key);
64
+ if (cached) return cached;
41
65
 
42
- const p = new Promise((resolve, reject) => {
43
- resolvers.set(key, { resolve, reject });
66
+ /** @type {Promise<Product>} */
67
+ const loading = new Promise((resolve, reject) => {
68
+ waiting.set(key, { resolve, reject });
44
69
  pending.push(key);
45
- if (!scheduled) {
46
- scheduled = true;
47
- setTimeout(flush, 0);
70
+ if (!flushScheduled) {
71
+ flushScheduled = true;
72
+ setTimeout(() => void flush(), 0);
48
73
  }
49
74
  });
50
- productCache.set(key, p);
51
- return p;
75
+ productCache.set(key, loading);
76
+ return loading;
52
77
  }
53
78
 
54
- async function flush() {
55
- scheduled = false;
56
- const gen = generation;
57
- const keys = pending.splice(0);
58
- if (!keys.length) return;
59
-
79
+ /**
80
+ * @param {string[]} keys Handles and gids.
81
+ * @returns {{ query: string, variables: Record<string, unknown>, handles: string[] }} One query for all of them: gids
82
+ * through `nodes(ids:)` as `byId`, each handle as its own alias `h<index>`.
83
+ */
84
+ function batchQuery(keys) {
60
85
  const ids = keys.filter(isGid);
61
- const handles = keys.filter((k) => !isGid(k));
62
- const defs = [];
63
- const parts = [];
64
- const vars = {};
65
-
86
+ const handles = keys.filter((key) => !isGid(key));
87
+ const definitions = [];
88
+ const selections = [];
89
+ /** @type {Record<string, unknown>} */
90
+ const variables = {};
66
91
  if (ids.length) {
67
- defs.push('$ids: [ID!]!');
68
- vars.ids = ids;
69
- parts.push('byId: nodes(ids: $ids) { ... on Product { ...TetherProduct } }');
92
+ definitions.push('$ids: [ID!]!');
93
+ variables['ids'] = ids;
94
+ selections.push('byId: nodes(ids: $ids) { ... on Product { ...TetherProduct } }');
70
95
  }
71
- handles.forEach((h, i) => {
72
- defs.push(`$h${i}: String!`);
73
- vars[`h${i}`] = h;
74
- parts.push(`h${i}: product(handle: $h${i}) { ...TetherProduct }`);
96
+ handles.forEach((handle, index) => {
97
+ definitions.push(`$h${index}: String!`);
98
+ variables[`h${index}`] = handle;
99
+ selections.push(`h${index}: product(handle: $h${index}) { ...TetherProduct }`);
75
100
  });
101
+ const query = `query TetherProducts(${definitions.join(',')}) { ${selections.join(' ')} } ${productFragment()}`;
102
+ return { query, variables, handles };
103
+ }
76
104
 
77
- const query = `query TetherProducts(${defs.join(',')}) { ${parts.join(' ')} } ${productFragment()}`;
78
- log('fetch products', keys);
105
+ /**
106
+ * @param {string} key
107
+ * @param {(waiter: { resolve: (product: Product) => void, reject: (error: unknown) => void }) => void} settle
108
+ */
109
+ function settleWaiting(key, settle) {
110
+ const waiter = waiting.get(key);
111
+ waiting.delete(key);
112
+ if (waiter) settle(waiter);
113
+ }
114
+
115
+ /**
116
+ * @param {string} key
117
+ * @param {number} batchGeneration
118
+ */
119
+ function forgetMissLater(key, batchGeneration) {
120
+ if (batchGeneration !== generation) return;
121
+ const miss = productCache.get(key);
122
+ setTimeout(() => {
123
+ if (productCache.get(key) === miss) productCache.delete(key);
124
+ }, MISS_CACHE_MS);
125
+ }
79
126
 
80
- const settle = (k, fn) => {
81
- const r = resolvers.get(k);
82
- resolvers.delete(k);
83
- if (r) fn(r);
84
- };
127
+ async function flush() {
128
+ flushScheduled = false;
129
+ const batchGeneration = generation;
130
+ const keys = pending.splice(0);
131
+ if (!keys.length) return;
132
+ const { query, variables, handles } = batchQuery(keys);
133
+ log('fetch products', keys);
85
134
 
135
+ /** @type {Record<string, Product | null> & { byId?: (Product | null)[] }} */
136
+ let data;
86
137
  try {
87
- const data = await gql(query, vars);
88
- const got = new Map();
89
- (data.byId || []).forEach((p) => { if (p) got.set(p.id, p); });
90
- handles.forEach((h, i) => { if (data[`h${i}`]) got.set(h, data[`h${i}`]); });
91
-
92
- keys.forEach((k) => settle(k, (r) => {
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 {
98
- // A miss is remembered for a few seconds (one page load asking twice makes one request),
99
- // not longer: a product published in Shopify shows up on the next visit.
100
- const miss = productCache.get(k);
101
- if (gen === generation) setTimeout(() => { if (productCache.get(k) === miss) productCache.delete(k); }, 10000);
102
- r.reject(new TetherError(`Product not found: ${k}`, { code: 'NOT_FOUND' }));
103
- }
104
- }));
105
- } catch (err) {
106
- keys.forEach((k) => settle(k, (r) => { if (gen === generation) productCache.delete(k); r.reject(err); }));
138
+ data = await gql(query, variables);
139
+ } catch (error) {
140
+ keys.forEach((key) => {
141
+ settleWaiting(key, (waiter) => {
142
+ if (batchGeneration === generation) productCache.delete(key);
143
+ waiter.reject(error);
144
+ });
145
+ });
146
+ return;
107
147
  }
108
- }
109
148
 
110
- export function clearProductCache() { productCache.clear(); generation++; stale.clear(); freshAt.clear(); }
149
+ /** @type {Map<string, Product>} */
150
+ const found = new Map();
151
+ for (const product of data.byId ?? []) if (product) found.set(product.id, product);
152
+ handles.forEach((handle, index) => {
153
+ const product = data[`h${index}`];
154
+ if (product) found.set(handle, product);
155
+ });
111
156
 
112
- // ---------------------------------------------------------------------------
113
- // Hydration: products rendered on the server (tether/server hydrate()) are used at once and
114
- // refreshed once in the background, so prices and stock are live without a loading state.
115
- // ---------------------------------------------------------------------------
157
+ const now = Date.now();
158
+ keys.forEach((key) => {
159
+ settleWaiting(key, (waiter) => {
160
+ const product = found.get(key);
161
+ if (!product) {
162
+ forgetMissLater(key, batchGeneration);
163
+ waiter.reject(new TetherError(`Product not found: ${key}`, { code: 'NOT_FOUND' }));
164
+ return;
165
+ }
166
+ if (batchGeneration === generation) {
167
+ primeProduct(product);
168
+ freshAt.set(product.handle, now);
169
+ freshAt.set(product.id, now);
170
+ }
171
+ waiter.resolve(product);
172
+ });
173
+ });
174
+ }
116
175
 
117
- const stale = new Set(); // keys of products that came from the server and weren't refreshed yet
118
- const freshAt = new Map(); // key -> when it was last loaded live (this page session)
119
- const FRESH_MS = 60000;
176
+ /** Forgets every product, for a new market: requests already sent no longer fill the cache. */
177
+ export function clearProductCache() {
178
+ productCache.clear();
179
+ generation += 1;
180
+ stale.clear();
181
+ freshAt.clear();
182
+ }
120
183
 
184
+ /**
185
+ * Uses products the server rendered, unless the page loaded the same product live in the last `FRESH_MS` (an earlier
186
+ * page of a View Transition visit), whose copy is newer.
187
+ *
188
+ * @param {readonly Product[] | null | undefined} products
189
+ */
121
190
  export function hydrateProducts(products) {
122
- (products || []).forEach((p) => {
123
- if (!p?.id || !p.handle) return;
124
- // Loaded live in the last minute (an earlier page of a View Transition visit): that copy is newer.
125
- if (Date.now() - (freshAt.get(p.handle) || 0) < FRESH_MS && productCache.has(p.handle)) return;
126
- primeProduct(p);
127
- stale.add(p.handle);
128
- stale.add(p.id);
129
- });
191
+ for (const product of products ?? []) {
192
+ if (!product.id || !product.handle) continue;
193
+ const loadedAgo = Date.now() - (freshAt.get(product.handle) ?? 0);
194
+ if (loadedAgo < FRESH_MS && productCache.has(product.handle)) continue;
195
+ primeProduct(product);
196
+ stale.add(product.handle);
197
+ stale.add(product.id);
198
+ }
130
199
  }
131
200
 
132
- /** True once per server-provided product: the caller refreshes it. */
133
- export function takeStale(key) {
134
- const k = normalizeProductKey(key);
135
- if (!stale.has(k)) return false;
136
- const p = productCache.get(k);
137
- stale.delete(k);
138
- // Both keys point to the same product: refresh it once.
139
- p?.then?.((x) => { stale.delete(x.handle); stale.delete(x.id); });
201
+ /**
202
+ * @param {string | number | null | undefined} rawKey
203
+ * @returns {boolean} True once per server-rendered product: the caller then refreshes it.
204
+ */
205
+ export function takeStale(rawKey) {
206
+ const key = normalizeProductKey(rawKey);
207
+ if (!key || !stale.has(key)) return false;
208
+ stale.delete(key);
209
+ // The handle and the id point to the same product: one refresh covers both.
210
+ void productCache.get(key)?.then((product) => {
211
+ stale.delete(product.handle);
212
+ stale.delete(product.id);
213
+ });
140
214
  return true;
141
215
  }
142
216
 
143
- /** Fetch these products again (bypassing the cache). */
217
+ /**
218
+ * Loads these products again, past the cache.
219
+ *
220
+ * @param {readonly (string | number)[]} keys
221
+ * @returns {Promise<Product>[]}
222
+ */
144
223
  export function refreshProducts(keys) {
145
- keys.forEach((k) => productCache.delete(normalizeProductKey(k)));
146
- return keys.map((k) => getProduct(k));
224
+ for (const key of keys) {
225
+ const normalized = normalizeProductKey(key);
226
+ if (normalized) productCache.delete(normalized);
227
+ }
228
+ return keys.map((key) => getProduct(key));
147
229
  }
148
230
 
231
+ /** @typedef {{ hasNextPage: boolean, endCursor: string | null }} PageInfo */
149
232
  /**
150
- * A page of products from a collection (by handle) or from a search query.
151
- * Returns { collection, products, pageInfo }.
233
+ * @typedef {object} Collection
234
+ * @property {string} id
235
+ * @property {string} handle
236
+ * @property {string} title
237
+ * @property {string} description
238
+ * @property {string} descriptionHtml
239
+ * @property {Image | null} image
240
+ * @property {{ title: string | null, description: string | null }} seo
241
+ */
242
+ /** @typedef {{ collection: Collection | null, products: Product[], pageInfo: PageInfo }} ProductPage */
243
+ /**
244
+ * @typedef {object} ProductQuery
245
+ * @property {string} [collection] A collection handle; without one, a search.
246
+ * @property {string} [query] Storefront search syntax, for a search.
247
+ * @property {number} [first]
248
+ * @property {string | null} [after] The cursor of the page before.
249
+ * @property {string} [sortKey] `ProductCollectionSortKeys` for a collection, `ProductSortKeys` for a search.
250
+ * @property {boolean} [reverse]
251
+ * @property {unknown[]} [filters] `ProductFilter` values, for a collection.
152
252
  */
153
- export async function getProducts({
154
- collection, query, first = cfg.collectionFirst, after = null, sortKey, reverse = false, filters,
155
- } = {}) {
156
- const fragment = productFragment();
157
253
 
158
- if (collection) {
159
- const data = await gql(`query TetherCollection($handle: String!, $first: Int!, $after: String, $sortKey: ProductCollectionSortKeys, $reverse: Boolean, $filters: [ProductFilter!]) {
254
+ const COLLECTION_QUERY = `query TetherCollection($handle: String!, $first: Int!, $after: String, $sortKey: ProductCollectionSortKeys, $reverse: Boolean, $filters: [ProductFilter!]) {
160
255
  collection(handle: $handle) {
161
256
  ${collectionFields()}
162
257
  products(first: $first, after: $after, sortKey: $sortKey, reverse: $reverse, filters: $filters) {
@@ -164,9 +259,41 @@ export async function getProducts({
164
259
  pageInfo { hasNextPage endCursor }
165
260
  }
166
261
  }
167
- }
168
- ${fragment}`, {
169
- handle: collection, first, after, sortKey: sortKey || undefined, reverse, filters: filters || undefined,
262
+ }`;
263
+
264
+ const SEARCH_QUERY = `query TetherSearch($first: Int!, $after: String, $query: String, $sortKey: ProductSortKeys, $reverse: Boolean) {
265
+ products(first: $first, after: $after, query: $query, sortKey: $sortKey, reverse: $reverse) {
266
+ nodes { ...TetherProduct }
267
+ pageInfo { hasNextPage endCursor }
268
+ }
269
+ }`;
270
+
271
+ /**
272
+ * A page of products from a collection (by handle) or from a search. Every product on it is cached.
273
+ *
274
+ * @param {ProductQuery} [options]
275
+ * @returns {Promise<ProductPage>}
276
+ * @throws {TetherError} `NOT_FOUND` for a collection the store doesn't have.
277
+ */
278
+ export async function getProducts({
279
+ collection,
280
+ query,
281
+ first = cfg.collectionFirst,
282
+ after = null,
283
+ sortKey,
284
+ reverse = false,
285
+ filters,
286
+ } = {}) {
287
+ const fragment = productFragment();
288
+ if (collection) {
289
+ /** @type {{ collection: (Collection & { products: { nodes: Product[], pageInfo: PageInfo } }) | null }} */
290
+ const data = await gql(`${COLLECTION_QUERY}\n${fragment}`, {
291
+ handle: collection,
292
+ first,
293
+ after,
294
+ sortKey: sortKey || undefined,
295
+ reverse,
296
+ filters: filters ?? undefined,
170
297
  });
171
298
  if (!data.collection) throw new TetherError(`Collection not found: ${collection}`, { code: 'NOT_FOUND' });
172
299
  const { products, ...rest } = data.collection;
@@ -174,25 +301,37 @@ ${fragment}`, {
174
301
  return { collection: rest, products: products.nodes, pageInfo: products.pageInfo };
175
302
  }
176
303
 
177
- const data = await gql(`query TetherSearch($first: Int!, $after: String, $query: String, $sortKey: ProductSortKeys, $reverse: Boolean) {
178
- products(first: $first, after: $after, query: $query, sortKey: $sortKey, reverse: $reverse) {
179
- nodes { ...TetherProduct }
180
- pageInfo { hasNextPage endCursor }
181
- }
182
- }
183
- ${fragment}`, { first, after, query: query || undefined, sortKey: sortKey || undefined, reverse });
304
+ /** @type {{ products: { nodes: Product[], pageInfo: PageInfo } }} */
305
+ const data = await gql(`${SEARCH_QUERY}\n${fragment}`, {
306
+ first,
307
+ after,
308
+ query: query || undefined,
309
+ sortKey: sortKey || undefined,
310
+ reverse,
311
+ });
184
312
  data.products.nodes.forEach(primeProduct);
185
313
  return { collection: null, products: data.products.nodes, pageInfo: data.products.pageInfo };
186
314
  }
187
315
 
316
+ /**
317
+ * Shopify's recommendations for a product. Every product in them is cached.
318
+ *
319
+ * @param {string | number | { id: string } | null | undefined} product A product, or its handle, id or gid.
320
+ * @param {'RELATED' | 'COMPLEMENTARY'} [intent]
321
+ * @returns {Promise<Product[]>}
322
+ */
188
323
  export async function getRecommendations(product, intent = 'RELATED') {
189
324
  const key = normalizeProductKey(typeof product === 'object' ? product?.id : product);
190
- const byId = key?.startsWith('gid://');
191
- const data = await gql(`query TetherRecs(${byId ? '$id: ID!' : '$handle: String!'}, $intent: ProductRecommendationIntent) {
325
+ const byId = key?.startsWith('gid://') ?? false;
326
+ /** @type {{ productRecommendations: Product[] | null }} */
327
+ const data = await gql(
328
+ `query TetherRecs(${byId ? '$id: ID!' : '$handle: String!'}, $intent: ProductRecommendationIntent) {
192
329
  productRecommendations(${byId ? 'productId: $id' : 'productHandle: $handle'}, intent: $intent) { ...TetherProduct }
193
330
  }
194
- ${productFragment()}`, byId ? { id: key, intent } : { handle: key, intent });
195
- const list = data.productRecommendations || [];
331
+ ${productFragment()}`,
332
+ byId ? { id: key, intent } : { handle: key, intent },
333
+ );
334
+ const list = data.productRecommendations ?? [];
196
335
  list.forEach(primeProduct);
197
336
  return list;
198
337
  }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Country and language codes as Shopify's `@inContext` takes them. Every market value that reaches a query passes
3
+ * through here: they are written into the query text, so anything else is dropped rather than sent.
4
+ */
5
+
6
+ /** @typedef {string & { readonly __brand: 'CountryCode' }} CountryCode An ISO 3166-1 alpha-2 code, upper case. */
7
+ /** @typedef {string & { readonly __brand: 'LanguageCode' }} LanguageCode A Shopify `LanguageCode` value. */
8
+
9
+ const COUNTRY = /^[A-Z]{2}$/;
10
+ /** Cloudflare's codes for an unknown country (XX) and Tor (T1), which a `cf-ipcountry` cookie can carry. */
11
+ const NOT_A_COUNTRY = new Set(['XX', 'T1']);
12
+ /** Shopify `LanguageCode` values with a region. Every other language is its plain ISO 639 code. */
13
+ const REGIONAL_LANGUAGES = new Set(['PT_BR', 'PT_PT', 'ZH_CN', 'ZH_TW']);
14
+ const LANGUAGE = /^[A-Z]{2,3}$/;
15
+
16
+ /**
17
+ * @param {unknown} value
18
+ * @returns {CountryCode | null} `'de'` → `'DE'`; `null` for anything that is not a country code.
19
+ */
20
+ export function toCountry(value) {
21
+ const code = (typeof value === 'string' ? value : '').trim().toUpperCase();
22
+ if (!COUNTRY.test(code) || NOT_A_COUNTRY.has(code)) return null;
23
+ return /** @type {CountryCode} */ (code);
24
+ }
25
+
26
+ /**
27
+ * @param {unknown} value
28
+ * @returns {LanguageCode | null} `'de-DE'` → `'DE'`, `'pt-BR'` → `'PT_BR'`, `'en'` → `'EN'`; `null` otherwise.
29
+ */
30
+ export function toLanguage(value) {
31
+ const raw = (typeof value === 'string' ? value : '').trim().replace('-', '_').toUpperCase();
32
+ if (REGIONAL_LANGUAGES.has(raw)) return /** @type {LanguageCode} */ (raw);
33
+ const primary = raw.split('_')[0] ?? '';
34
+ return LANGUAGE.test(primary) ? /** @type {LanguageCode} */ (primary) : null;
35
+ }