@occluze/tether 0.3.6 → 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.
Files changed (45) hide show
  1. package/README.md +12 -7
  2. package/dist/esm/astro.js +2 -0
  3. package/dist/esm/chunks/chunk-HHMOPDZG.js +1 -0
  4. package/dist/esm/chunks/chunk-HXKV6QGB.js +4 -0
  5. package/dist/esm/chunks/chunk-RBVRRQIU.js +1 -0
  6. package/dist/esm/chunks/chunk-RNZMTQTX.js +1 -0
  7. package/dist/esm/chunks/chunk-VC46IEJQ.js +1 -0
  8. package/dist/esm/chunks/diagnose-J667GHGS.js +1 -0
  9. package/dist/esm/chunks/markets-ui-EGWIEUOZ.js +1 -0
  10. package/dist/esm/index.js +1 -0
  11. package/dist/esm/server.js +1 -0
  12. package/dist/presets/barba.esm.js +4 -5
  13. package/dist/tether.diagnose.js +320 -0
  14. package/dist/tether.diagnose.min.js +1 -0
  15. package/dist/tether.esm.js +2 -2608
  16. package/dist/tether.js +637 -833
  17. package/dist/tether.markets.js +213 -0
  18. package/dist/tether.markets.min.js +1 -0
  19. package/dist/tether.min.js +3 -132
  20. package/dist/tether.min.js.map +4 -4
  21. package/package.json +52 -11
  22. package/src/astro.js +73 -0
  23. package/src/cart.js +79 -7
  24. package/src/client.js +25 -15
  25. package/src/config.js +4 -0
  26. package/src/diagnose.js +1 -1
  27. package/src/dom.js +32 -9
  28. package/src/fragments.js +12 -11
  29. package/src/index.js +80 -160
  30. package/src/initial.js +66 -0
  31. package/src/lazy-iife.js +43 -0
  32. package/src/lazy.js +9 -0
  33. package/src/loader.js +50 -7
  34. package/src/markets-ui.js +89 -0
  35. package/src/markets.js +1 -1
  36. package/src/mounting.js +129 -0
  37. package/src/parts/diagnose.js +3 -0
  38. package/src/parts/markets.js +3 -0
  39. package/src/product.js +86 -61
  40. package/src/server.js +179 -0
  41. package/src/status.js +2 -6
  42. package/types/astro.d.ts +15 -0
  43. package/types/barba.d.ts +16 -0
  44. package/types/index.d.ts +347 -0
  45. package/types/server.d.ts +94 -0
package/package.json CHANGED
@@ -1,32 +1,59 @@
1
1
  {
2
2
  "name": "@occluze/tether",
3
- "version": "0.3.6",
3
+ "version": "0.5.0",
4
4
  "description": "Free, open-source alternative to Smootify and Storesynk (Shopyflow): Shopify products, cart and subscriptions in Webflow with HTML attributes. Optional Shopify to Webflow CMS sync.",
5
5
  "type": "module",
6
- "main": "dist/tether.esm.js",
7
- "module": "dist/tether.esm.js",
6
+ "main": "dist/esm/index.js",
7
+ "module": "dist/esm/index.js",
8
8
  "exports": {
9
- ".": "./dist/tether.esm.js",
10
- "./presets/barba": "./dist/presets/barba.esm.js"
9
+ ".": {
10
+ "types": "./types/index.d.ts",
11
+ "default": "./dist/esm/index.js"
12
+ },
13
+ "./server": {
14
+ "types": "./types/server.d.ts",
15
+ "default": "./dist/esm/server.js"
16
+ },
17
+ "./astro": {
18
+ "types": "./types/astro.d.ts",
19
+ "default": "./dist/esm/astro.js"
20
+ },
21
+ "./presets/barba": {
22
+ "types": "./types/barba.d.ts",
23
+ "default": "./dist/presets/barba.esm.js"
24
+ },
25
+ "./dist/*": "./dist/*"
11
26
  },
12
27
  "files": [
13
28
  "dist",
14
29
  "src",
15
30
  "LICENSE",
16
- "README.md"
31
+ "README.md",
32
+ "types/index.d.ts",
33
+ "types/barba.d.ts",
34
+ "types/server.d.ts",
35
+ "types/astro.d.ts"
17
36
  ],
18
37
  "scripts": {
19
38
  "build": "node scripts/build.mjs",
20
39
  "dev": "node scripts/build.mjs --watch",
21
- "test": "node --test test/*.test.js server/test/*.test.js",
22
- "smoke": "node scripts/smoke.mjs"
40
+ "test": "node --test test/*.test.js server/test/*.test.js && npm run test:types",
41
+ "smoke": "node scripts/smoke.mjs",
42
+ "pretest": "node scripts/build.mjs",
43
+ "presmoke": "node scripts/build.mjs",
44
+ "test:types": "tsc -p types/tsconfig.json",
45
+ "release": "node scripts/release.mjs"
23
46
  },
24
47
  "devDependencies": {
25
48
  "esbuild": "^0.25.0",
26
- "jsdom": "^26.0.0"
49
+ "jsdom": "^26.0.0",
50
+ "typescript": "^5.9.3"
27
51
  },
28
52
  "license": "MIT",
29
- "author": "Ravi Klaassens",
53
+ "author": {
54
+ "name": "Ravi Klaassens",
55
+ "url": "https://occluze.com"
56
+ },
30
57
  "repository": {
31
58
  "type": "git",
32
59
  "url": "git+https://github.com/ravi-klaassens/tether.git"
@@ -36,24 +63,38 @@
36
63
  "url": "https://github.com/ravi-klaassens/tether/issues"
37
64
  },
38
65
  "keywords": [
66
+ "astro",
67
+ "astro-integration",
39
68
  "cart",
40
69
  "ecommerce",
41
70
  "headless",
71
+ "headless-commerce",
42
72
  "headless-shopify",
43
73
  "selling-plans",
44
74
  "shopify",
45
75
  "shopify-subscriptions",
46
76
  "shopyflow-alternative",
47
77
  "smootify-alternative",
78
+ "ssr",
48
79
  "storefront-api",
49
80
  "storesynk-alternative",
50
81
  "subscriptions",
51
82
  "webflow",
52
83
  "webflow-cms",
53
84
  "webflow-ecommerce",
54
- "webflow-shopify"
85
+ "webflow-shopify",
86
+ "withastro"
55
87
  ],
56
88
  "publishConfig": {
57
89
  "access": "public"
90
+ },
91
+ "types": "types/index.d.ts",
92
+ "peerDependencies": {
93
+ "astro": ">=4"
94
+ },
95
+ "peerDependenciesMeta": {
96
+ "astro": {
97
+ "optional": true
98
+ }
58
99
  }
59
100
  }
package/src/astro.js ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Astro integration: Tether on every page, configured from .env.
3
+ *
4
+ * // astro.config.mjs
5
+ * import { defineConfig } from 'astro/config';
6
+ * import tether from '@occluze/tether/astro';
7
+ * export default defineConfig({ integrations: [tether({ country: 'NL', locale: 'nl-NL' })] });
8
+ *
9
+ * // .env
10
+ * PUBLIC_SHOPIFY_DOMAIN=your-store.myshopify.com
11
+ * PUBLIC_SHOPIFY_STOREFRONT_TOKEN=your-public-storefront-token
12
+ *
13
+ * Options are Tether's config (domain and token override .env). Functions (onError, formatMoney,
14
+ * hooks) can't be passed here: set them in window.TETHER_CONFIG in your layout, it is merged in.
15
+ * View Transitions (<ClientRouter />) work without extra code.
16
+ */
17
+
18
+ const ENV_KEYS = {
19
+ domain: ['PUBLIC_SHOPIFY_DOMAIN', 'PUBLIC_SHOPIFY_STORE_DOMAIN', 'SHOPIFY_DOMAIN'],
20
+ token: ['PUBLIC_SHOPIFY_STOREFRONT_TOKEN', 'PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN', 'SHOPIFY_STOREFRONT_TOKEN'],
21
+ };
22
+
23
+ /** .env files the way Vite reads them (later files win), plus process.env on top. */
24
+ async function readEnv(command, root) {
25
+ const mode = command === 'dev' ? 'development' : 'production';
26
+ const out = {};
27
+ try {
28
+ const { readFile } = await import('node:fs/promises');
29
+ const { join } = await import('node:path');
30
+ for (const name of ['.env', '.env.local', `.env.${mode}`, `.env.${mode}.local`]) {
31
+ let text;
32
+ try { text = await readFile(join(root, name), 'utf8'); } catch { continue; }
33
+ for (const line of text.split(/\r?\n/)) {
34
+ const m = /^\s*(?:export\s+)?([\w.-]+)\s*=\s*(.*?)\s*$/.exec(line);
35
+ if (!m || line.trim().startsWith('#')) continue;
36
+ let v = m[2];
37
+ const q = /^(["'])(.*?)\1/.exec(v); // "quoted" (a # comment may follow)
38
+ v = q ? q[2] : v.replace(/\s+#.*$/, '');
39
+ out[m[1]] = v;
40
+ }
41
+ }
42
+ } catch { /* not in Node: nothing to read */ }
43
+ return { ...out, ...process.env };
44
+ }
45
+
46
+ export default function tether(options = {}) {
47
+ const { inject = true, ...config } = options;
48
+ return {
49
+ name: '@occluze/tether',
50
+ hooks: {
51
+ 'astro:config:setup': async ({ injectScript, command, config: astroConfig, logger }) => {
52
+ const root = astroConfig?.root ? decodeURIComponent(new URL(astroConfig.root).pathname) : process.cwd();
53
+ const env = await readEnv(command, root);
54
+ for (const [key, names] of Object.entries(ENV_KEYS)) {
55
+ if (!config[key]) config[key] = names.map((n) => env[n]).find(Boolean);
56
+ }
57
+ const bad = Object.entries(config).filter(([, v]) => typeof v === 'function').map(([k]) => k);
58
+ bad.forEach((k) => delete config[k]);
59
+ if (bad.length) logger.warn(`${bad.join(', ')}: functions can't be passed here. Set them in window.TETHER_CONFIG in your layout.`);
60
+ if (!config.domain || !config.token) {
61
+ logger.warn('No Shopify store configured: set PUBLIC_SHOPIFY_DOMAIN and PUBLIC_SHOPIFY_STOREFRONT_TOKEN in .env (the public Storefront API token).');
62
+ }
63
+ if (!inject) return;
64
+ // Before any module runs: window.TETHER_CONFIG in the layout doesn't auto-start a second init.
65
+ injectScript('head-inline', 'window.TETHER_INTEGRATION="astro";');
66
+ injectScript('page', [
67
+ "import Tether from '@occluze/tether';",
68
+ `Tether.init({ ...${JSON.stringify({ autoMount: false, ...config })}, ...(window.TETHER_CONFIG || {}) }).then(() => Tether.mount(document));`,
69
+ ].join('\n'));
70
+ },
71
+ },
72
+ };
73
+ }
package/src/cart.js CHANGED
@@ -29,11 +29,28 @@ export function toLineInput(input, quantity = 1, opts = {}) {
29
29
 
30
30
  function mutation(name, args, inner) {
31
31
  return `mutation Tether_${name}(${args}) {
32
- ${name}(${inner}) { cart { ...TetherCart } userErrors { field message } }
32
+ ${name}(${inner}) { cart { ...TetherCart } userErrors { field message } warnings { code message } }
33
33
  }
34
34
  ${cartFragment()}`;
35
35
  }
36
36
 
37
+ const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
38
+ let returnFocus = null;
39
+
40
+ /** Focusable elements inside an open drawer, in order (visible ones only). */
41
+ export const focusables = (d) => qsa(d, FOCUSABLE).filter((el) => el.offsetParent !== null || el === document.activeElement);
42
+
43
+ /** aria-expanded on the open buttons; dialog semantics on drawers (unless the markup has its own). */
44
+ function ariaState(open) {
45
+ qsa(document, sel('cart-open')).forEach((b) => b.setAttribute('aria-expanded', String(open)));
46
+ qsa(document, sel('cart-drawer')).forEach((d) => {
47
+ if (!d.hasAttribute('role')) d.setAttribute('role', 'dialog');
48
+ if (!d.hasAttribute('aria-label') && !d.hasAttribute('aria-labelledby')) d.setAttribute('aria-label', get(d, 'cart-label') || 'Cart');
49
+ d.setAttribute('aria-modal', String(open));
50
+ if (!d.classList.contains(cfg.cartDrawerOpenClass)) d.setAttribute('aria-hidden', 'true');
51
+ });
52
+ }
53
+
37
54
  const isMissingCart = (err) => /does not exist|not found|invalid id/i.test(err?.message || '');
38
55
 
39
56
  export const Cart = {
@@ -52,6 +69,17 @@ export const Cart = {
52
69
  get count() { return this.state?.totalQuantity || 0; },
53
70
 
54
71
  lastError: null,
72
+ /** Shopify's notes on the last change, e.g. less stock than asked: [{ code, message }]. */
73
+ warnings: [],
74
+ /** true once the saved cart (or the absence of one) is known. */
75
+ loaded: false,
76
+
77
+ _take(payload, name) {
78
+ const p = unwrap(payload, name);
79
+ this.warnings = p.warnings || [];
80
+ if (this.warnings.length) emit('cart-warning', { warnings: this.warnings });
81
+ return p.cart;
82
+ },
55
83
 
56
84
  _queue(fn) {
57
85
  const run = this._chain.then(fn, fn).then((value) => {
@@ -78,7 +106,7 @@ export const Cart = {
78
106
 
79
107
  async init() {
80
108
  const id = this.id();
81
- if (!id) { this.state = null; this.render(); return null; }
109
+ if (!id) { this.state = null; this.loaded = true; this.render(); emit('cart-updated', { cart: null, reason: 'load' }); return null; }
82
110
  try {
83
111
  const data = await gql(`query TetherCart($id: ID!) { cart(id: $id) { ...TetherCart } }\n${cartFragment()}`, { id });
84
112
  if (!data.cart) { this._setId(null); this.state = null; } else this.state = data.cart;
@@ -89,7 +117,9 @@ export const Cart = {
89
117
  if (isMissingCart(err)) this._setId(null);
90
118
  this.state = null;
91
119
  }
120
+ this.loaded = true;
92
121
  this.render();
122
+ emit('cart-updated', { cart: this.state, reason: 'load' }); // islands (cart.subscribe) hear the loaded cart
93
123
  return this.state;
94
124
  },
95
125
 
@@ -99,7 +129,7 @@ export const Cart = {
99
129
  mutation('cartCreate', '$input: CartInput!', 'input: $input'),
100
130
  { input: { buyerIdentity, ...input } },
101
131
  );
102
- return unwrap(data.cartCreate, 'cartCreate').cart;
132
+ return this._take(data.cartCreate, 'cartCreate');
103
133
  },
104
134
 
105
135
  /** Runs a cart mutation; creates a fresh cart if ours expired. */
@@ -108,7 +138,7 @@ export const Cart = {
108
138
  if (!cartId) return createInput ? this._create(createInput) : null;
109
139
  try {
110
140
  const data = await gql(mutation(name, `$cartId: ID!, ${args}`, `cartId: $cartId, ${inner}`), { ...variables, cartId });
111
- return unwrap(data[name], name).cart;
141
+ return this._take(data[name], name);
112
142
  } catch (err) {
113
143
  if (createInput && isMissingCart(err)) {
114
144
  log('cart expired, creating a new one');
@@ -203,7 +233,8 @@ export const Cart = {
203
233
  },
204
234
 
205
235
  removeDiscount(code) {
206
- return this.setDiscountCodes((this.state?.discountCodes || []).map((d) => d.code).filter((c) => c !== code));
236
+ const k = String(code).toLowerCase();
237
+ return this.setDiscountCodes((this.state?.discountCodes || []).map((d) => d.code).filter((c) => c.toLowerCase() !== k));
207
238
  },
208
239
 
209
240
  /** { countryCode, email, phone } — changes currency/prices for that market. */
@@ -226,7 +257,7 @@ export const Cart = {
226
257
  */
227
258
  async checkout(target = cfg.checkoutTarget) {
228
259
  const id = this.id();
229
- if (!id) return null;
260
+ if (!id || (this.state && !this.count)) return null; // nothing to check out
230
261
  const tab = target === '_blank' ? window.open('', '_blank') : null;
231
262
  if (tab) { try { tab.opener = null; } catch { /* ignore */ } }
232
263
 
@@ -255,6 +286,16 @@ export const Cart = {
255
286
  if (this.isOpen()) return;
256
287
  const ds = this.drawers().filter((d) => get(d, 'cart-drawer') !== 'manual');
257
288
  ds.forEach((d) => { d.classList.add(cfg.cartDrawerOpenClass); d.setAttribute('aria-hidden', 'false'); });
289
+ ariaState(true);
290
+ if (cfg.cartFocus !== false && ds[0]) {
291
+ // Move focus into the cart (after the open transition has made it visible).
292
+ returnFocus = document.activeElement;
293
+ setTimeout(() => {
294
+ const target = focusables(ds[0])[0] || ds[0];
295
+ if (target === ds[0] && !ds[0].hasAttribute('tabindex')) ds[0].tabIndex = -1;
296
+ target.focus?.({ preventScroll: true });
297
+ }, 60);
298
+ }
258
299
  if (typeof cfg.onCartOpen === 'function') cfg.onCartOpen(ds);
259
300
  emit('cart-toggled', { open: true });
260
301
  },
@@ -262,13 +303,29 @@ export const Cart = {
262
303
  close() {
263
304
  if (!this.isOpen()) return;
264
305
  const ds = this.drawers();
306
+ const hadFocus = ds.some((d) => d.contains(document.activeElement));
265
307
  ds.forEach((d) => { d.classList.remove(cfg.cartDrawerOpenClass); d.setAttribute('aria-hidden', 'true'); });
308
+ ariaState(false);
309
+ if (hadFocus && returnFocus?.isConnected) returnFocus.focus({ preventScroll: true });
310
+ returnFocus = null;
266
311
  if (typeof cfg.onCartClose === 'function') cfg.onCartClose(ds);
267
312
  emit('cart-toggled', { open: false });
268
313
  },
269
314
 
270
315
  toggle() { if (this.isOpen()) this.close(); else this.open(); },
271
316
 
317
+ /**
318
+ * For framework islands (React, Svelte, Vue): fn(cart, { open }) now and on every change.
319
+ * Returns a function that stops listening.
320
+ */
321
+ subscribe(fn) {
322
+ const call = () => fn(this.state, { open: this.isOpen(), count: this.count, loaded: this.loaded, warnings: this.warnings });
323
+ const h = () => call();
324
+ ['cart-updated', 'cart-toggled'].forEach((e) => document.addEventListener(`${cfg.prefix}:${e}`, h));
325
+ call();
326
+ return () => ['cart-updated', 'cart-toggled'].forEach((e) => document.removeEventListener(`${cfg.prefix}:${e}`, h));
327
+ },
328
+
272
329
  // -------------------------------------------------------------- render
273
330
 
274
331
  render() {
@@ -276,6 +333,7 @@ export const Cart = {
276
333
  const count = cart ? cart.totalQuantity : 0;
277
334
  const money = (k) => (cart ? formatMoney(cart.cost?.[k]) : '');
278
335
 
336
+ ariaState(this.isOpen());
279
337
  qsa(document, sel('cart-count')).forEach((el) => setTextTracked(el, count));
280
338
  qsa(document, sel('cart-subtotal')).forEach((el) => setTextTracked(el, money('subtotalAmount')));
281
339
  qsa(document, sel('cart-total')).forEach((el) => setTextTracked(el, money('totalAmount')));
@@ -289,11 +347,17 @@ export const Cart = {
289
347
  'cart-no-subscription': !hasSub,
290
348
  'cart-has-discount': (cart?.discountCodes || []).some((d) => d.applicable),
291
349
  'cart-error': Boolean(this.lastError),
350
+ 'cart-warning': this.warnings.length > 0,
292
351
  };
352
+ qsa(document, sel('cart-warning')).forEach((el) => setTextTracked(el, [...new Set(this.warnings.map((w) => w.message))].join(' ')));
293
353
  qsa(document, sel('show')).forEach((el) => {
294
354
  const k = get(el, 'show');
295
355
  if (k in conds && !el.closest(sel('product'))) show(el, conds[k]);
296
356
  });
357
+ qsa(document, sel('checkout')).forEach((el) => {
358
+ el.classList.toggle('is-disabled', !count);
359
+ el.setAttribute('aria-disabled', String(!count));
360
+ });
297
361
 
298
362
  qsa(document, `textarea${sel('cart-note')},input${sel('cart-note')}`).forEach((el) => {
299
363
  if (document.activeElement !== el) el.value = cart?.note || '';
@@ -356,7 +420,15 @@ function renderLine(node, line) {
356
420
  qsa(node, sel('line')).forEach((el) => {
357
421
  const key = get(el, 'line');
358
422
  if (key === 'image') return setImage(el, m.image?.url || m.product?.featuredImage?.url, m.product?.title);
359
- if (key === 'url') return el.setAttribute('href', productUrl(m.product?.handle, m.product?.id));
423
+ if (key === 'url') {
424
+ const u = productUrl(m.product?.handle, m.product?.id);
425
+ const q = new URLSearchParams();
426
+ if (m.id) q.set('variant', String(m.id).split('/').pop());
427
+ const plan = line.sellingPlanAllocation?.sellingPlan?.id;
428
+ if (plan) q.set('selling_plan', plan.split('/').pop());
429
+ const qs = q.toString();
430
+ return el.setAttribute('href', qs ? `${u}${u.includes('?') ? '&' : '?'}${qs}` : u);
431
+ }
360
432
  const v = lineValue(line, key);
361
433
  if (v !== undefined) setTextTracked(el, v);
362
434
  return undefined;
package/src/client.js CHANGED
@@ -15,8 +15,8 @@ export class TetherError extends Error {
15
15
  }
16
16
  }
17
17
 
18
- export function endpoint() {
19
- return `https://${cfg.domain}/api/${cfg.apiVersion}/graphql.json`;
18
+ export function endpoint(conf = cfg) {
19
+ return `https://${conf.domain}/api/${conf.apiVersion}/graphql.json`;
20
20
  }
21
21
 
22
22
  /**
@@ -31,42 +31,46 @@ export function withContext(query, ctx = cfg) {
31
31
  if (ctx.language) args.push(`language: ${String(ctx.language).toUpperCase().replace('-', '_')}`);
32
32
  if (!args.length || /@inContext/.test(query)) return query;
33
33
 
34
- return query.replace(
35
- /^(\s*(?:query|mutation)\s+\w+\s*(?:\([^)]*\))?)/,
36
- `$1 @inContext(${args.join(', ')})`,
37
- );
34
+ const tag = `@inContext(${args.join(', ')})`;
35
+ // Named (query Name($x: T)), anonymous (query { … }) and shorthand ({ … }) queries.
36
+ if (/^\s*\{/.test(query)) return `query ${tag} ${query.trimStart()}`;
37
+ return query.replace(/^(\s*(?:query|mutation)(?:\s+\w+)?\s*(?:\([^)]*\))?)/, `$1 ${tag}`);
38
38
  }
39
39
 
40
40
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
41
41
 
42
- export async function gql(query, variables = {}, { context = true } = {}) {
43
- if (!cfg.domain || !cfg.token) {
42
+ /**
43
+ * One Storefront API request with retries, for any config object (the browser's shared config,
44
+ * or the server module's own). gql() below is the browser version.
45
+ */
46
+ export async function request(conf, query, variables = {}, { context = true } = {}) {
47
+ if (!conf.domain || !conf.token) {
44
48
  throw new TetherError('Tether is not configured: domain and token are required', { code: 'NOT_CONFIGURED' });
45
49
  }
46
50
 
47
- const body = JSON.stringify({ query: context ? withContext(query) : query, variables });
51
+ const body = JSON.stringify({ query: context ? withContext(query, conf) : query, variables });
48
52
  let attempt = 0;
49
53
 
50
54
  for (;;) {
51
55
  let res;
52
56
  try {
53
- res = await fetch(endpoint(), {
57
+ res = await (conf.fetch || fetch)(endpoint(conf), {
54
58
  method: 'POST',
55
59
  headers: {
56
60
  'Content-Type': 'application/json',
57
- 'X-Shopify-Storefront-Access-Token': cfg.token,
61
+ 'X-Shopify-Storefront-Access-Token': conf.token,
58
62
  },
59
63
  body,
60
64
  });
61
65
  } catch (err) {
62
- if (attempt++ < cfg.retries) { await sleep(300 * 2 ** attempt); continue; }
66
+ if (attempt++ < conf.retries) { await sleep(300 * 2 ** attempt); continue; }
63
67
  throw new TetherError(`Network error: ${err.message}`, { code: 'NETWORK' });
64
68
  }
65
69
 
66
70
  if (res.status === 430) throw new TetherError('Shopify security rejection (430)', { status: 430 });
67
71
 
68
72
  const retryable = res.status === 429 || res.status >= 500;
69
- if (retryable && attempt++ < cfg.retries) {
73
+ if (retryable && attempt++ < conf.retries) {
70
74
  log('retry', res.status, attempt);
71
75
  await sleep(300 * 2 ** attempt);
72
76
  continue;
@@ -82,10 +86,14 @@ export async function gql(query, variables = {}, { context = true } = {}) {
82
86
  const inventoryDenied = json.errors.some((e) => /quantityAvailable/.test(e.message || ''));
83
87
  if (inventoryDenied && /\bquantityAvailable\b/.test(body)) {
84
88
  log('no inventory permission, retrying without quantityAvailable');
85
- return gql(query.replace(/\bquantityAvailable\b/g, ''), variables, { context });
89
+ return request(conf, query.replace(/\bquantityAvailable\b/g, ''), variables, { context });
90
+ }
91
+ // An older apiVersion without cart warnings (before 2024-07): retry without them.
92
+ if (json.errors.some((e) => /'warnings'/.test(e.message || '')) && /warnings \{ code message \}/.test(query)) {
93
+ return request(conf, query.replace(/ warnings \{ code message \}/g, ''), variables, { context });
86
94
  }
87
95
  const throttled = json.errors.some((e) => e.extensions?.code === 'THROTTLED');
88
- if (throttled && attempt++ < cfg.retries) { await sleep(500 * 2 ** attempt); continue; }
96
+ if (throttled && attempt++ < conf.retries) { await sleep(500 * 2 ** attempt); continue; }
89
97
  throw new TetherError(json.errors.map((e) => e.message).join(' | '), {
90
98
  status: res.status, errors: json.errors,
91
99
  });
@@ -96,6 +104,8 @@ export async function gql(query, variables = {}, { context = true } = {}) {
96
104
  }
97
105
  }
98
106
 
107
+ export const gql = (query, variables, opts) => request(cfg, query, variables, opts);
108
+
99
109
  /** Throws on mutation userErrors, returns the payload otherwise. */
100
110
  export function unwrap(payload, name) {
101
111
  if (!payload) throw new TetherError(`${name}: empty response`);
package/src/config.js CHANGED
@@ -25,11 +25,13 @@ export const DEFAULTS = {
25
25
  sellingPlansFirst: 25,
26
26
  collectionFirst: 24,
27
27
  retries: 2,
28
+ revalidate: true, // products from the server (tether/server hydrate()): refresh once in the background
28
29
 
29
30
  // Cart
30
31
  storageKey: null, // defaults to `tether:cart:<domain>`
31
32
  cartDrawerOpenClass: 'is-open',
32
33
  openCartOnAdd: true,
34
+ cartFocus: true, // move focus into the open cart, keep Tab inside it, restore it on close
33
35
  checkoutTarget: '_self', // '_self' or '_blank'
34
36
 
35
37
  // Subscriptions
@@ -38,6 +40,8 @@ export const DEFAULTS = {
38
40
  // Mounting
39
41
  autoMount: true, // mount document on DOMContentLoaded
40
42
  observe: false, // auto-mount nodes added later (CMS load-more, filters)
43
+ astro: true, // mount on Astro View Transitions (astro:page-load); false if you do it yourself
44
+ partsUrl: null, // folder of tether.markets/diagnose(.min).js for the <script> build (default: next to it)
41
45
 
42
46
  // Formatting
43
47
  formatMoney: null, // (money, locale) => string
package/src/diagnose.js CHANGED
@@ -66,7 +66,7 @@ export function checkMarkup(root = document) {
66
66
  'in-stock', 'out-of-stock', 'backorder', 'low-stock', 'on-sale', 'not-on-sale', 'has-variants', 'single-variant',
67
67
  'subscription-available', 'subscription-unavailable', 'subscription-only', 'one-time-available',
68
68
  'subscription-selected', 'one-time-selected', 'has-savings', 'loading', 'ready', 'error', 'not-found',
69
- 'cart-empty', 'cart-filled', 'cart-has-subscription', 'cart-no-subscription', 'cart-has-discount', 'cart-error',
69
+ 'cart-empty', 'cart-filled', 'cart-has-subscription', 'cart-no-subscription', 'cart-has-discount', 'cart-error', 'cart-warning',
70
70
  'list-empty', 'list-filled',
71
71
  ]);
72
72
  qsa(root, sel('show')).forEach((el) => {
package/src/dom.js CHANGED
@@ -53,24 +53,37 @@ export function formatDate(iso) {
53
53
  }
54
54
 
55
55
  /** Shopify CDN resize via max-width / max-height / crop attributes on the element. */
56
- export function cdnUrl(url, el) {
57
- if (!url || !el) return url;
58
- const w = el.getAttribute('max-width');
59
- const h = el.getAttribute('max-height');
60
- const crop = el.getAttribute('crop');
61
- if (!w && !h) return url;
56
+ /**
57
+ * Shopify CDN image at a size: imageUrl(url, { width: 800 }). Use the same width as the element's
58
+ * max-width attribute, so the browser script keeps the image instead of loading it again.
59
+ */
60
+ export function imageUrl(url, { width, height, crop } = {}) {
61
+ if (!url) return url;
62
62
  try {
63
63
  const u = new URL(url);
64
- if (w) u.searchParams.set('width', w);
65
- if (h) u.searchParams.set('height', h);
64
+ if (width) u.searchParams.set('width', String(width));
65
+ if (height) u.searchParams.set('height', String(height));
66
66
  if (crop) u.searchParams.set('crop', crop);
67
67
  return u.toString();
68
68
  } catch { return url; }
69
69
  }
70
70
 
71
+ export function cdnUrl(url, el) {
72
+ if (!url || !el) return url;
73
+ const g = (k) => el.getAttribute(k);
74
+ return imageUrl(url, { width: g('max-width'), height: g('max-height'), crop: g('max-width') || g('max-height') ? g('crop') : null });
75
+ }
76
+
77
+ /** Same image, whatever size parameters: the server-rendered one is kept (no second download). */
78
+ export const sameImage = (a, b) => Boolean(a && b) && a.split('?')[0] === b.split('?')[0];
79
+
71
80
  export function setImage(el, url, alt) {
72
81
  if (!url) return;
73
82
  const src = cdnUrl(url, el);
83
+ if (el.tagName === 'IMG' && sameImage(el.getAttribute('src'), src)) {
84
+ if (alt != null && !el.getAttribute('alt')) el.setAttribute('alt', alt);
85
+ return;
86
+ }
74
87
  if (el.tagName === 'IMG') {
75
88
  el.removeAttribute('srcset');
76
89
  el.removeAttribute('sizes');
@@ -104,14 +117,24 @@ export function setTextTracked(el, v) {
104
117
  export function show(el, visible) {
105
118
  const k = 'tetherPrevDisplay';
106
119
  if (visible) {
120
+ if (el.dataset.tetherShown !== undefined) return; // already visible, nothing to check
121
+ el.dataset.tetherShown = '';
122
+ el.removeAttribute('hidden');
107
123
  if (el.dataset[k] !== undefined) {
108
124
  el.style.display = el.dataset[k];
109
125
  delete el.dataset[k];
110
126
  } else if (el.style.display === 'none') {
111
127
  el.style.display = '';
112
128
  }
129
+ // Hidden before the script ran (a class, an inline display:none, a hidden attribute): show it
130
+ // as block or the value of tether-display.
131
+ if (typeof getComputedStyle === 'function' && getComputedStyle(el).display === 'none') {
132
+ el.style.display = el.getAttribute(`${cfg.prefix}-display`) || 'block';
133
+ }
113
134
  } else {
114
- if (el.dataset[k] === undefined) el.dataset[k] = el.style.display || '';
135
+ delete el.dataset.tetherShown;
136
+ // Remember a real display value to restore, never 'none'.
137
+ if (el.dataset[k] === undefined && el.style.display !== 'none') el.dataset[k] = el.style.display || '';
115
138
  el.style.display = 'none';
116
139
  }
117
140
  }
package/src/fragments.js CHANGED
@@ -7,8 +7,8 @@ import { cfg } from './config.js';
7
7
  const MONEY = 'amount currencyCode';
8
8
  const IMAGE = 'url altText width height';
9
9
 
10
- export function metafieldIdentifiers() {
11
- return (cfg.metafields || []).map((m) => {
10
+ export function metafieldIdentifiers(o = cfg) {
11
+ return (o.metafields || []).map((m) => {
12
12
  if (typeof m === 'string') {
13
13
  const i = m.indexOf('.');
14
14
  return { namespace: m.slice(0, i), key: m.slice(i + 1) };
@@ -17,8 +17,8 @@ export function metafieldIdentifiers() {
17
17
  }).filter((m) => m.namespace && m.key);
18
18
  }
19
19
 
20
- function metafieldsField() {
21
- const ids = metafieldIdentifiers();
20
+ function metafieldsField(o = cfg) {
21
+ const ids = metafieldIdentifiers(o);
22
22
  if (!ids.length) return '';
23
23
  const list = ids
24
24
  .map((m) => `{namespace: ${JSON.stringify(m.namespace)}, key: ${JSON.stringify(m.key)}}`)
@@ -53,26 +53,27 @@ export const ALLOCATION_FIELDS = `
53
53
  unitPrice { ${MONEY} }
54
54
  }`;
55
55
 
56
- export function productFragment() {
56
+ /** The product fields Tether renders. `o` = config (counts, metafields); the server module passes its own. */
57
+ export function productFragment(o = cfg) {
57
58
  return `
58
59
  fragment TetherProduct on Product {
59
60
  id handle title description descriptionHtml productType vendor tags
60
61
  availableForSale publishedAt onlineStoreUrl requiresSellingPlan
61
62
  seo { title description }
62
63
  featuredImage { ${IMAGE} }
63
- images(first: ${cfg.imagesFirst}) { nodes { ${IMAGE} } }
64
+ images(first: ${o.imagesFirst}) { nodes { ${IMAGE} } }
64
65
  options { name optionValues { name swatch { color image { previewImage { ${IMAGE} } } } } }
65
66
  priceRange { minVariantPrice { ${MONEY} } maxVariantPrice { ${MONEY} } }
66
67
  compareAtPriceRange { minVariantPrice { ${MONEY} } maxVariantPrice { ${MONEY} } }
67
- ${metafieldsField()}
68
- sellingPlanGroups(first: ${cfg.sellingPlanGroupsFirst}) {
68
+ ${metafieldsField(o)}
69
+ sellingPlanGroups(first: ${o.sellingPlanGroupsFirst}) {
69
70
  nodes {
70
71
  name appName
71
72
  options { name values }
72
- sellingPlans(first: ${cfg.sellingPlansFirst}) { nodes { ${SELLING_PLAN_FIELDS} } }
73
+ sellingPlans(first: ${o.sellingPlansFirst}) { nodes { ${SELLING_PLAN_FIELDS} } }
73
74
  }
74
75
  }
75
- variants(first: ${cfg.variantsFirst}) {
76
+ variants(first: ${o.variantsFirst}) {
76
77
  nodes {
77
78
  id title availableForSale currentlyNotInStock quantityAvailable sku barcode
78
79
  selectedOptions { name value }
@@ -80,7 +81,7 @@ fragment TetherProduct on Product {
80
81
  price { ${MONEY} }
81
82
  compareAtPrice { ${MONEY} }
82
83
  unitPrice { ${MONEY} }
83
- sellingPlanAllocations(first: ${cfg.sellingPlansFirst}) { nodes { ${ALLOCATION_FIELDS} } }
84
+ sellingPlanAllocations(first: ${o.sellingPlansFirst}) { nodes { ${ALLOCATION_FIELDS} } }
84
85
  }
85
86
  }
86
87
  }`;