@occluze/tether 0.6.7 → 0.7.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 (250) hide show
  1. package/README.md +11 -11
  2. package/dist/esm/astro.js +2 -2
  3. package/dist/esm/chunks/account-UIPWJQ3Y.js +1 -0
  4. package/dist/esm/chunks/account-pages-6ROA6Y4V.js +1 -0
  5. package/dist/esm/chunks/analytics-2V632IRV.js +1 -0
  6. package/dist/esm/chunks/chunk-4GCXM764.js +1 -0
  7. package/dist/esm/chunks/chunk-56FCLJJD.js +1 -0
  8. package/dist/esm/chunks/chunk-5Z5O3767.js +1 -0
  9. package/dist/esm/chunks/chunk-6OZJCAGZ.js +1 -0
  10. package/dist/esm/chunks/chunk-A5SYCVQM.js +1 -0
  11. package/dist/esm/chunks/chunk-BA4LQJJQ.js +1 -0
  12. package/dist/esm/chunks/chunk-BFTZR4OB.js +4 -0
  13. package/dist/esm/chunks/chunk-C5JYQCWH.js +1 -0
  14. package/dist/esm/chunks/chunk-E5YO6274.js +1 -0
  15. package/dist/esm/chunks/chunk-MPDWM2NN.js +1 -0
  16. package/dist/esm/chunks/chunk-T44MUL4Y.js +1 -0
  17. package/dist/esm/chunks/chunk-YLUULATY.js +1 -0
  18. package/dist/esm/chunks/diagnose-3FEKRIEK.js +14 -0
  19. package/dist/esm/chunks/extras-6POFBNOP.js +1 -0
  20. package/dist/esm/chunks/markets-ui-FD6NI2OG.js +1 -0
  21. package/dist/esm/chunks/search-UJ73YBQ5.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 +55 -39
  25. package/dist/tether.account-pages.js +781 -495
  26. package/dist/tether.account-pages.min.js +1 -1
  27. package/dist/tether.account.js +462 -381
  28. package/dist/tether.account.min.js +1 -1
  29. package/dist/tether.analytics.js +299 -194
  30. package/dist/tether.analytics.min.js +1 -1
  31. package/dist/tether.diagnose.js +354 -262
  32. package/dist/tether.diagnose.min.js +2 -2
  33. package/dist/tether.extras.js +82 -45
  34. package/dist/tether.extras.min.js +1 -1
  35. package/dist/tether.js +3653 -2116
  36. package/dist/tether.markets.js +238 -150
  37. package/dist/tether.markets.min.js +1 -1
  38. package/dist/tether.min.js +3 -3
  39. package/dist/tether.min.js.map +4 -4
  40. package/dist/tether.search.js +207 -136
  41. package/dist/tether.search.min.js +1 -1
  42. package/dist/types/server/src/shared/account-orders.d.ts +44 -0
  43. package/dist/types/server/src/shared/account-routes.d.ts +153 -0
  44. package/dist/types/server/src/shared/api-versions.d.ts +13 -0
  45. package/dist/types/server/src/shared/error-message.d.ts +2 -0
  46. package/dist/types/server/src/shared/json.d.ts +25 -0
  47. package/dist/types/server/src/shared/market-codes.d.ts +16 -0
  48. package/dist/types/src/account/addresses-view.d.ts +5 -0
  49. package/dist/types/src/account/answers.d.ts +40 -0
  50. package/dist/types/src/account/markup.d.ts +19 -0
  51. package/dist/types/src/account/operations.d.ts +41 -0
  52. package/dist/types/src/account/order-lines.d.ts +13 -0
  53. package/dist/types/src/account/order-list.d.ts +12 -0
  54. package/dist/types/src/account/order-page.d.ts +6 -0
  55. package/dist/types/src/account/page-answers.d.ts +182 -0
  56. package/dist/types/src/account/page-events.d.ts +2 -0
  57. package/dist/types/src/account/server-client.d.ts +60 -0
  58. package/dist/types/src/account/subscriptions-view.d.ts +2 -0
  59. package/dist/types/src/account-api.d.ts +18 -0
  60. package/dist/types/src/account-marks.d.ts +15 -0
  61. package/dist/types/src/account-pages.d.ts +8 -0
  62. package/dist/types/src/account.d.ts +64 -0
  63. package/dist/types/src/amounts.d.ts +12 -0
  64. package/dist/types/src/analytics-shopify.d.ts +41 -0
  65. package/dist/types/src/analytics.d.ts +2 -0
  66. package/dist/types/src/api-version.d.ts +28 -0
  67. package/dist/types/src/astro.d.ts +42 -0
  68. package/dist/types/src/cart/cart-shape.d.ts +43 -0
  69. package/dist/types/src/cart/checkout.d.ts +16 -0
  70. package/dist/types/src/cart/drawer.d.ts +18 -0
  71. package/dist/types/src/cart/mutations.d.ts +79 -0
  72. package/dist/types/src/cart/render.d.ts +17 -0
  73. package/dist/types/src/cart/store.d.ts +182 -0
  74. package/dist/types/src/cart.d.ts +106 -0
  75. package/dist/types/src/client.d.ts +100 -0
  76. package/dist/types/src/collection.d.ts +25 -0
  77. package/dist/types/src/config-sections.d.ts +40 -0
  78. package/dist/types/src/config.d.ts +185 -0
  79. package/dist/types/src/controls/cart-controls.d.ts +2 -0
  80. package/dist/types/src/controls/discount.d.ts +2 -0
  81. package/dist/types/src/controls/extras.d.ts +5 -0
  82. package/dist/types/src/controls/navigation.d.ts +12 -0
  83. package/dist/types/src/diagnose-markup.d.ts +19 -0
  84. package/dist/types/src/diagnose.d.ts +60 -0
  85. package/dist/types/src/dom.d.ts +75 -0
  86. package/dist/types/src/extras.d.ts +21 -0
  87. package/dist/types/src/fragments.d.ts +158 -0
  88. package/dist/types/src/globals.d.ts +15 -0
  89. package/dist/types/src/images.d.ts +20 -0
  90. package/dist/types/src/index.d.ts +167 -0
  91. package/dist/types/src/initial.d.ts +56 -0
  92. package/dist/types/src/lazy.d.ts +28 -0
  93. package/dist/types/src/loader.d.ts +84 -0
  94. package/dist/types/src/market-codes.d.ts +6 -0
  95. package/dist/types/src/markets-ui.d.ts +32 -0
  96. package/dist/types/src/markets.d.ts +72 -0
  97. package/dist/types/src/markup.d.ts +131 -0
  98. package/dist/types/src/mounting.d.ts +46 -0
  99. package/dist/types/src/page-market.d.ts +27 -0
  100. package/dist/types/src/presets/barba.d.ts +56 -0
  101. package/dist/types/src/product-events.d.ts +2 -0
  102. package/dist/types/src/product-render.d.ts +17 -0
  103. package/dist/types/src/product-shape.d.ts +88 -0
  104. package/dist/types/src/product-state.d.ts +110 -0
  105. package/dist/types/src/product.d.ts +66 -0
  106. package/dist/types/src/repeaters.d.ts +16 -0
  107. package/dist/types/src/search.d.ts +33 -0
  108. package/dist/types/src/selling-plans.d.ts +64 -0
  109. package/dist/types/src/server.d.ts +88 -0
  110. package/dist/types/src/shopify-values.d.ts +46 -0
  111. package/dist/types/src/status.d.ts +33 -0
  112. package/dist/types/src/storage.d.ts +12 -0
  113. package/dist/types/src/store-html.d.ts +39 -0
  114. package/dist/types/src/storefront/catalog.d.ts +38 -0
  115. package/dist/types/src/storefront/json-ld.d.ts +20 -0
  116. package/dist/types/src/variant-render.d.ts +14 -0
  117. package/package.json +42 -22
  118. package/src/account/addresses-view.ts +58 -0
  119. package/src/account/answers.ts +79 -0
  120. package/src/account/markup.ts +36 -0
  121. package/src/account/operations.ts +145 -0
  122. package/src/account/order-lines.ts +75 -0
  123. package/src/account/order-list.ts +107 -0
  124. package/src/account/order-page.ts +137 -0
  125. package/src/account/page-answers.ts +317 -0
  126. package/src/account/page-events.ts +253 -0
  127. package/src/account/server-client.ts +183 -0
  128. package/src/account/subscriptions-view.ts +115 -0
  129. package/src/account-api.ts +98 -0
  130. package/src/account-marks.ts +35 -0
  131. package/src/account-pages.ts +81 -0
  132. package/src/account.ts +284 -0
  133. package/src/amounts.ts +64 -0
  134. package/src/analytics-shopify.ts +246 -0
  135. package/src/analytics.ts +315 -0
  136. package/src/api-version.ts +79 -0
  137. package/src/astro.ts +272 -0
  138. package/src/cart/cart-shape.ts +25 -0
  139. package/src/cart/checkout.ts +101 -0
  140. package/src/cart/drawer.ts +125 -0
  141. package/src/cart/mutations.ts +237 -0
  142. package/src/cart/render.ts +266 -0
  143. package/src/cart/store.ts +279 -0
  144. package/src/cart.ts +345 -0
  145. package/src/client.ts +391 -0
  146. package/src/collection.ts +175 -0
  147. package/src/config-sections.ts +284 -0
  148. package/src/config.ts +480 -0
  149. package/src/controls/cart-controls.ts +123 -0
  150. package/src/controls/discount.ts +75 -0
  151. package/src/controls/extras.ts +54 -0
  152. package/src/controls/navigation.ts +77 -0
  153. package/src/diagnose-markup.ts +298 -0
  154. package/src/diagnose.ts +294 -0
  155. package/src/dom.ts +268 -0
  156. package/src/extras.ts +136 -0
  157. package/src/fragments.ts +261 -0
  158. package/src/globals.d.ts +15 -0
  159. package/src/images.ts +91 -0
  160. package/src/index.ts +415 -0
  161. package/src/initial.ts +101 -0
  162. package/src/lazy-iife.ts +197 -0
  163. package/src/lazy.ts +42 -0
  164. package/src/loader.ts +363 -0
  165. package/src/market-codes.ts +6 -0
  166. package/src/markets-ui.ts +227 -0
  167. package/src/markets.ts +246 -0
  168. package/src/markup.ts +127 -0
  169. package/src/mounting.ts +264 -0
  170. package/src/page-market.ts +110 -0
  171. package/src/part-integrity.ts +7 -0
  172. package/src/parts/account-pages.ts +7 -0
  173. package/src/parts/account.ts +7 -0
  174. package/src/parts/analytics.ts +7 -0
  175. package/src/parts/diagnose.ts +7 -0
  176. package/src/parts/extras.ts +7 -0
  177. package/src/parts/markets.ts +7 -0
  178. package/src/parts/search.ts +7 -0
  179. package/src/presets/barba.ts +194 -0
  180. package/src/product-events.ts +115 -0
  181. package/src/product-render.ts +218 -0
  182. package/src/product-shape.ts +49 -0
  183. package/src/product-state.ts +318 -0
  184. package/src/product.ts +308 -0
  185. package/src/repeaters.ts +161 -0
  186. package/src/search.ts +305 -0
  187. package/src/selling-plans.ts +145 -0
  188. package/src/server.ts +211 -0
  189. package/src/shopify-values.ts +91 -0
  190. package/src/status.ts +106 -0
  191. package/src/storage.ts +34 -0
  192. package/src/store-html.ts +359 -0
  193. package/src/storefront/catalog.ts +196 -0
  194. package/src/storefront/json-ld.ts +144 -0
  195. package/src/variant-render.ts +139 -0
  196. package/dist/esm/chunks/account-Q6E2TFEV.js +0 -1
  197. package/dist/esm/chunks/account-pages-UZXR4R5F.js +0 -1
  198. package/dist/esm/chunks/analytics-IJBKATUP.js +0 -1
  199. package/dist/esm/chunks/chunk-4QJKLPYR.js +0 -1
  200. package/dist/esm/chunks/chunk-64TUD3QR.js +0 -1
  201. package/dist/esm/chunks/chunk-AJLPU2JC.js +0 -4
  202. package/dist/esm/chunks/chunk-FIOZPPC5.js +0 -1
  203. package/dist/esm/chunks/chunk-K2YEQAIH.js +0 -1
  204. package/dist/esm/chunks/chunk-KIDH76B7.js +0 -1
  205. package/dist/esm/chunks/chunk-PAYT5ZD7.js +0 -1
  206. package/dist/esm/chunks/chunk-RREHGH4A.js +0 -1
  207. package/dist/esm/chunks/chunk-SSJT43CJ.js +0 -1
  208. package/dist/esm/chunks/chunk-VC46IEJQ.js +0 -1
  209. package/dist/esm/chunks/diagnose-3WVAWNTX.js +0 -14
  210. package/dist/esm/chunks/extras-7I7WJX35.js +0 -1
  211. package/dist/esm/chunks/markets-ui-545XLFTE.js +0 -1
  212. package/dist/esm/chunks/search-NILMGYXG.js +0 -1
  213. package/src/account-pages.js +0 -318
  214. package/src/account.js +0 -385
  215. package/src/analytics.js +0 -232
  216. package/src/api-version.js +0 -35
  217. package/src/astro.js +0 -101
  218. package/src/cart.js +0 -559
  219. package/src/client.js +0 -131
  220. package/src/collection.js +0 -114
  221. package/src/config.js +0 -85
  222. package/src/diagnose.js +0 -246
  223. package/src/dom.js +0 -288
  224. package/src/extras.js +0 -63
  225. package/src/fragments.js +0 -144
  226. package/src/index.js +0 -395
  227. package/src/initial.js +0 -75
  228. package/src/lazy-iife.js +0 -46
  229. package/src/lazy.js +0 -18
  230. package/src/loader.js +0 -198
  231. package/src/markets-ui.js +0 -90
  232. package/src/markets.js +0 -133
  233. package/src/mounting.js +0 -154
  234. package/src/parts/account-pages.js +0 -3
  235. package/src/parts/account.js +0 -3
  236. package/src/parts/analytics.js +0 -3
  237. package/src/parts/diagnose.js +0 -3
  238. package/src/parts/extras.js +0 -3
  239. package/src/parts/markets.js +0 -3
  240. package/src/parts/search.js +0 -3
  241. package/src/presets/barba.js +0 -100
  242. package/src/product.js +0 -570
  243. package/src/search.js +0 -153
  244. package/src/selling-plans.js +0 -94
  245. package/src/server.js +0 -252
  246. package/src/status.js +0 -66
  247. package/types/astro.d.ts +0 -17
  248. package/types/barba.d.ts +0 -16
  249. package/types/index.d.ts +0 -426
  250. package/types/server.d.ts +0 -110
package/src/client.js DELETED
@@ -1,131 +0,0 @@
1
- /**
2
- * Storefront API client: one fetch wrapper with @inContext, retries and
3
- * readable errors. Exposed as Tether.query() for anything Tether doesn't cover.
4
- */
5
- import { cfg, log } from './config.js';
6
-
7
- export class TetherError extends Error {
8
- constructor(message, { status, errors, userErrors, code } = {}) {
9
- super(message);
10
- this.name = 'TetherError';
11
- this.code = code || (status ? `HTTP_${status}` : 'ERROR');
12
- this.status = status;
13
- this.errors = errors;
14
- this.userErrors = userErrors;
15
- }
16
- }
17
-
18
- export function endpoint(conf = cfg) {
19
- return `https://${conf.domain}/api/${conf.apiVersion}/graphql.json`;
20
- }
21
-
22
- /**
23
- * Adds @inContext(country, language) to the first operation in the document,
24
- * so prices, currency and translations follow the configured market.
25
- * Operations are always written first, fragments after, so the first match is
26
- * the operation.
27
- */
28
- export function withContext(query, ctx = cfg) {
29
- const args = [];
30
- if (ctx.country) args.push(`country: ${String(ctx.country).toUpperCase()}`);
31
- if (ctx.language) args.push(`language: ${String(ctx.language).toUpperCase().replace('-', '_')}`);
32
- if (!args.length || /@inContext/.test(query)) return query;
33
-
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
- }
39
-
40
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
41
-
42
- /** What a status means, for errors Shopify sends without a message (401/403 have none). */
43
- function statusText(status, conf) {
44
- if (status === 401 || status === 403) {
45
- return `Shopify rejected the Storefront token for ${conf.domain} (${status}): use the public Storefront API token (Headless channel)${/^shpat_/.test(conf.token) ? ', not an Admin API token (shpat_…)' : ''}`;
46
- }
47
- return status === 404 ? `Store or API version not found (404): check domain ${conf.domain} and apiVersion ${conf.apiVersion}` : `HTTP ${status} from ${conf.domain}`;
48
- }
49
-
50
- /**
51
- * One Storefront API request with retries, for any config object (the browser's shared config,
52
- * or the server module's own). gql() below is the browser version.
53
- */
54
- export async function request(conf, query, variables = {}, { context = true } = {}) {
55
- if (!conf.domain || !conf.token) {
56
- throw new TetherError('Tether is not configured: domain and token are required', { code: 'NOT_CONFIGURED' });
57
- }
58
-
59
- // Shopify caches the response of an identical query text (prices included, for hours after a
60
- // change). A time bucket in a comment keeps answers at most maxAge seconds old (default 60).
61
- let text = context ? withContext(query, conf) : query;
62
- const age = conf.maxAge ?? 60;
63
- if (age > 0 && !/^\s*mutation\b/.test(text)) text += ` #${Math.floor(Date.now() / (age * 1000))}`;
64
- const body = JSON.stringify({ query: text, variables });
65
- let attempt = 0;
66
-
67
- for (;;) {
68
- let res;
69
- try {
70
- res = await (conf.fetch || fetch)(endpoint(conf), {
71
- method: 'POST',
72
- headers: {
73
- 'Content-Type': 'application/json',
74
- 'X-Shopify-Storefront-Access-Token': conf.token,
75
- },
76
- body,
77
- });
78
- } catch (err) {
79
- if (attempt++ < conf.retries) { await sleep(300 * 2 ** attempt); continue; }
80
- throw new TetherError(`Network error: ${err.message}`, { code: 'NETWORK' });
81
- }
82
-
83
- if (res.status === 430) throw new TetherError('Shopify security rejection (430)', { status: 430 });
84
-
85
- const retryable = res.status === 429 || res.status >= 500;
86
- if (retryable && attempt++ < conf.retries) {
87
- log('retry', res.status, attempt);
88
- await sleep(300 * 2 ** attempt);
89
- continue;
90
- }
91
-
92
- let json;
93
- try { json = await res.json(); } catch {
94
- throw new TetherError(res.ok ? `Invalid response (${res.status})` : statusText(res.status, conf), { status: res.status });
95
- }
96
-
97
- if (json.errors?.length) {
98
- // Token without unauthenticated_read_product_inventory: drop the field and retry.
99
- const inventoryDenied = json.errors.some((e) => /quantityAvailable/.test(e.message || ''));
100
- if (inventoryDenied && /\bquantityAvailable\b/.test(body)) {
101
- log('no inventory permission, retrying without quantityAvailable');
102
- return request(conf, query.replace(/\bquantityAvailable\b/g, ''), variables, { context });
103
- }
104
- // An older apiVersion without cart warnings (before 2024-07): retry without them.
105
- if (json.errors.some((e) => /'warnings'/.test(e.message || '')) && /warnings \{ code message target \}/.test(query)) {
106
- return request(conf, query.replace(/ warnings \{ code message target \}/g, ''), variables, { context });
107
- }
108
- const throttled = json.errors.some((e) => e.extensions?.code === 'THROTTLED');
109
- if (throttled && attempt++ < conf.retries) { await sleep(500 * 2 ** attempt); continue; }
110
- const message = json.errors.map((e) => e.message).filter(Boolean).join(' | ');
111
- throw new TetherError(res.ok ? message || 'Storefront API error' : (res.status === 401 || res.status === 403 || !message ? statusText(res.status, conf) : message), {
112
- status: res.status, errors: json.errors,
113
- });
114
- }
115
- if (!res.ok) throw new TetherError(statusText(res.status, conf), { status: res.status });
116
-
117
- return json.data;
118
- }
119
- }
120
-
121
- export const gql = (query, variables, opts) => request(cfg, query, variables, opts);
122
-
123
- /** Throws on mutation userErrors, returns the payload otherwise. */
124
- export function unwrap(payload, name) {
125
- if (!payload) throw new TetherError(`${name}: empty response`);
126
- const errs = payload.userErrors || [];
127
- if (errs.length) {
128
- throw new TetherError(errs.map((e) => e.message).join(' | '), { userErrors: errs });
129
- }
130
- return payload;
131
- }
package/src/collection.js DELETED
@@ -1,114 +0,0 @@
1
- /**
2
- * Product lists: [tether-collection="handle"] or [tether-products="search query"].
3
- * The first child of [tether-list] is the card template; every card becomes a
4
- * product scope, so all product attributes work inside cards (including
5
- * quick-add with a subscription toggle).
6
- */
7
- import { reportError } from './config.js';
8
- import { TetherError } from './client.js';
9
- import { attr, sel, get, has, scoped, emit, setText, setImage, repeat, notifyLayoutChanged, busy, show } from './dom.js';
10
- import { getProducts, getRecommendations } from './loader.js';
11
- import { setScopeStatus, isNotFound, resolveProductKey } from './status.js';
12
-
13
- export const listSelector = () => `${sel('collection')},${sel('products')},${sel('recommendations')}`;
14
-
15
- export class ListScope {
16
- constructor(root) {
17
- this.root = root;
18
- this.products = [];
19
- this.pageInfo = null;
20
- this.collection = null;
21
- setScopeStatus(root, 'loading');
22
- root.addEventListener('click', (e) => {
23
- const more = e.target.closest?.(sel('load-more'));
24
- if (more && more.closest(listSelector()) === root) {
25
- e.preventDefault();
26
- busy(more, () => this.load(true));
27
- }
28
- });
29
- }
30
-
31
- $(s) { return scoped(this.root, s, listSelector()); }
32
-
33
- options() {
34
- const r = this.root;
35
- const opts = {
36
- first: parseInt(get(r, 'limit') || '', 10) || undefined,
37
- sortKey: get(r, 'sort') || undefined,
38
- reverse: get(r, 'reverse') === 'true',
39
- };
40
- if (has(r, 'collection')) opts.collection = get(r, 'collection');
41
- else {
42
- // url:q = the search in the page's ?q= (a results page on a static site).
43
- const raw = get(r, 'products') || '';
44
- opts.query = raw.startsWith('url:') ? new URL(window.location.href).searchParams.get(raw.slice(4)) || '' : raw;
45
- }
46
- return opts;
47
- }
48
-
49
- async load(more = false) {
50
- const token = (this.loadToken = (this.loadToken || 0) + 1);
51
- const stale = () => token !== this.loadToken;
52
- try {
53
- if (has(this.root, 'recommendations')) {
54
- // A handle, url:handle (one dynamic product page), or empty inside a product: that product.
55
- let { key } = resolveProductKey(this.root, window.location, 'recommendations');
56
- const scope = this.root.closest(sel('product'));
57
- if (!key && scope) ({ key } = resolveProductKey(scope));
58
- if (!key) throw new TetherError('No product for recommendations', { code: 'NOT_FOUND' });
59
- const recs = await getRecommendations(key, get(this.root, 'intent') || 'RELATED');
60
- if (stale()) return;
61
- const limit = parseInt(get(this.root, 'limit') || '', 10);
62
- this.products = limit ? recs.slice(0, limit) : recs;
63
- } else {
64
- const res = await getProducts({ ...this.options(), after: more ? this.pageInfo?.endCursor : null });
65
- if (stale()) return;
66
- this.collection = res.collection;
67
- this.pageInfo = res.pageInfo;
68
- this.products = more ? [...this.products, ...res.products] : res.products;
69
- }
70
- this.render();
71
- setScopeStatus(this.root, 'ready');
72
- emit('list-ready', { products: this.products, collection: this.collection }, this.root);
73
- } catch (err) {
74
- if (stale()) return;
75
- // Keep what's already rendered on a failed "load more"; otherwise show the error state.
76
- if (!more) setScopeStatus(this.root, isNotFound(err) ? 'not-found' : 'error', err);
77
- // A missing product for recommendations is a page detail, not an error to report.
78
- if (!(isNotFound(err) && has(this.root, 'recommendations'))) reportError(err, 'list load failed');
79
- emit('error', { error: err, context: 'list' }, this.root);
80
- }
81
- }
82
-
83
- render() {
84
- const c = this.collection;
85
- this.$(sel('collection-bind')).forEach((el) => {
86
- switch (get(el, 'collection-bind')) {
87
- case 'title': return setText(el, c?.title || '');
88
- case 'description': el.innerHTML = c?.descriptionHtml || ''; return undefined;
89
- case 'image': return setImage(el, c?.image?.url, c?.image?.altText || c?.title);
90
- case 'count': return setText(el, this.products.length);
91
- default: return undefined;
92
- }
93
- });
94
-
95
- this.$(sel('list')).forEach((container) => {
96
- repeat(container, this.products, (node, p) => {
97
- node.setAttribute(attr('product'), p.handle);
98
- node.removeAttribute(attr('state'));
99
- });
100
- // Cards are new elements: mount them (product data is already cached).
101
- emit('list-rendered', { container }, this.root);
102
- });
103
-
104
- const empty = this.products.length === 0;
105
- this.$(sel('show')).forEach((el) => {
106
- const k = get(el, 'show');
107
- if (k === 'list-empty') show(el, empty);
108
- if (k === 'list-filled') show(el, !empty);
109
- });
110
- this.$(sel('load-more')).forEach((el) => show(el, Boolean(this.pageInfo?.hasNextPage)));
111
- notifyLayoutChanged('list');
112
- }
113
- }
114
-
package/src/config.js DELETED
@@ -1,85 +0,0 @@
1
- /**
2
- * Runtime config. One shared object, mutated by configure(), read everywhere.
3
- */
4
-
5
- export const DEFAULTS = {
6
- // Shopify Storefront API
7
- domain: '', // 'your-store.myshopify.com'
8
- token: '', // public Storefront API access token
9
- apiVersion: '2026-07',
10
- country: undefined, // ISO code, e.g. 'NL' -> @inContext(country: NL)
11
- language: undefined, // e.g. 'NL' -> @inContext(language: NL)
12
- locale: undefined, // Intl locale for money/dates, e.g. 'nl-NL'
13
- markets: null, // { detect: true, locales: { de: { country: 'DE', language: 'DE' } }, cookie, remember } (markets.js)
14
-
15
- // Markup
16
- prefix: 'tether', // attribute prefix: tether-product, tether-bind, ...
17
- productPath: '/product', // Webflow CMS template path for products
18
- collectionPath: '/collection',
19
-
20
- // Fetching
21
- metafields: [], // ['custom.material', { namespace, key }]
22
- imagesFirst: 25,
23
- variantsFirst: 100,
24
- sellingPlanGroupsFirst: 10,
25
- sellingPlansFirst: 25,
26
- collectionFirst: 24,
27
- retries: 2,
28
- revalidate: true, // products from the server (tether/server hydrate()): refresh once in the background
29
-
30
- // Cart
31
- storageKey: null, // defaults to `tether:cart:<domain>`
32
- cartDrawerOpenClass: 'is-open',
33
- openCartOnAdd: true,
34
- cartFocus: true, // move focus into the open cart, keep Tab inside it, restore it on close
35
- checkoutTarget: '_self', // '_self' or '_blank'
36
-
37
- // Subscriptions
38
- defaultPurchase: 'one-time', // 'one-time' | 'subscription'
39
- variantUrl: false, // true: ?variant= follows the visitor's choice on the main product (shareable links)
40
- maxAge: 60, // seconds a Shopify answer may be cached (0 = Shopify's own caching, can be hours)
41
- lowStock: 5, // tether-show="low-stock" at this many or fewer (needs inventory access)
42
-
43
- // Mounting
44
- autoMount: true, // mount document on DOMContentLoaded
45
- observe: false, // auto-mount nodes added later (CMS load-more, filters)
46
- astro: true, // mount on Astro View Transitions (astro:page-load); false if you do it yourself
47
- partsUrl: null, // folder of tether.markets/diagnose(.min).js for the <script> build (default: next to it)
48
-
49
- // Formatting
50
- formatMoney: null, // (money, locale) => string
51
-
52
- // Hooks (all optional)
53
- onLayoutChange: null, // (reason) => void. Default refreshes ScrollTrigger if present
54
- onCartOpen: null, // (drawers) => void, e.g. lock scroll
55
- onCartClose: null, // (drawers) => void
56
- onLineEnter: null, // (node) => void, animate a new cart line in
57
- onLineLeave: null, // (node, done) => void, animate out, then call done()
58
- onValueChange: null, // (el, previousText) => void, e.g. text scramble
59
- onError: null, // (error, context) => void
60
-
61
- debug: false,
62
- };
63
-
64
- export const cfg = { ...DEFAULTS };
65
-
66
- export function configure(overrides = {}) {
67
- Object.keys(cfg).forEach((k) => delete cfg[k]);
68
- Object.assign(cfg, DEFAULTS, overrides);
69
- if (cfg.domain) cfg.domain = String(cfg.domain).replace(/^https?:\/\//, '').replace(/\/.*$/, '');
70
- if (!cfg.storageKey) cfg.storageKey = `${cfg.prefix}:cart:${cfg.domain}`;
71
- if (cfg.apiVersion === 'unstable') warn('apiVersion "unstable" changes without notice. Pin a dated version (e.g. 2026-07).');
72
- return cfg;
73
- }
74
-
75
- export const isConfigured = () => Boolean(cfg.domain && cfg.token);
76
-
77
- export function log(...a) { if (cfg.debug) console.log(`[${cfg.prefix}]`, ...a); }
78
- export function warn(...a) { console.warn(`[${cfg.prefix}]`, ...a); }
79
-
80
- export function reportError(err, context) {
81
- warn(context, err && (err.message || err.code) ? err.message || err.code : err);
82
- if (typeof cfg.onError === 'function') {
83
- try { cfg.onError(err, context); } catch { /* never let a hook break us */ }
84
- }
85
- }
package/src/diagnose.js DELETED
@@ -1,246 +0,0 @@
1
- /**
2
- * Tether.diagnose(): one call in the browser console that explains what's
3
- * wrong on a page. Logs a readable report and returns it as an object.
4
- *
5
- * await Tether.diagnose()
6
- * await Tether.diagnose({ silent: true }) // just the object
7
- */
8
- import { cfg, isConfigured } from './config.js';
9
- import { gql, endpoint } from './client.js';
10
- import { attr, sel, get, qsa, hasTemplate } from './dom.js';
11
- import { getProduct } from './loader.js';
12
- import { allPlans } from './selling-plans.js';
13
- import { resolveProductKey, boundary } from './status.js';
14
- import { apiVersionStatus } from './api-version.js';
15
- import { detectMarket } from './markets.js';
16
-
17
- export { apiVersionStatus };
18
-
19
- // Everything Tether reads, to tell valid markup from typos.
20
- const SHOW = new Set([
21
- 'in-stock', 'out-of-stock', 'backorder', 'low-stock', 'plenty-in-stock', 'on-sale', 'not-on-sale', 'has-variants', 'single-variant',
22
- 'subscription-available', 'subscription-unavailable', 'subscription-only', 'one-time-available',
23
- 'subscription-selected', 'one-time-selected', 'has-savings', 'loading', 'ready', 'error', 'not-found',
24
- 'cart-empty', 'cart-filled', 'cart-has-subscription', 'cart-no-subscription', 'cart-has-discount', 'cart-error', 'cart-warning',
25
- 'list-empty', 'list-filled', 'search-empty', 'search-results', 'logged-in', 'logged-out', 'orders-empty', 'orders-loading',
26
- 'addresses-loading', 'addresses-empty', 'addresses-error', 'subscriptions-loading', 'subscriptions-empty', 'subscriptions-error',
27
- ]);
28
- const BIND = new Set([
29
- 'title', 'description', 'description-text', 'vendor', 'type', 'handle', 'published-at', 'price', 'total-price',
30
- 'compare-at-price', 'unit-price', 'min-price', 'max-price', 'discount-percentage', 'discount-amount', 'variant-title',
31
- 'variant-id', 'sku', 'barcode', 'stock', 'quantity', 'featured-image', 'variant-image', 'images', 'tags', 'link', 'url',
32
- 'one-time-price', 'subscription-price', 'subscription-savings', 'subscription-savings-amount', 'checkout-charge',
33
- 'plan-name', 'plan-description', 'plan-group', 'option-value', 'swatch', 'image',
34
- ]);
35
- const ATTRS = new Set(`account account-bind account-link account-login account-logout add-label add-to-cart adding-text
36
- always-enabled bind cart-checkout-charge cart-clear cart-close cart-count cart-discounts cart-drawer cart-label cart-lines
37
- cart-note cart-open cart-subtotal cart-total cart-warning checkout code collection collection-bind countries country
38
- country-bind country-label country-select default-plan default-purchase discount discount-code discount-form discount-input
39
- discount-remove display error group group-add hide-single language limit line line-attribute line-attributes line-id
40
- line-minus line-plan line-plus line-remove line-show list load-more market-pending one-time-label option option-list
41
- option-selected option-value order order-bind order-line order-lines order-line-show order-show order-state order-fulfillments
42
- fulfillment-bind order-return return-quantity return-reason return-note orders profile-form address-form addresses address-bind
43
- address-show address-edit address-new address-delete address-default subscriptions subscription-bind subscription-lines
44
- subscription-line subscription-show subscription-action subscription-status subscription-state confirm form-state form-error page-country page-language page-locale page-title plan plan-bind plan-list
45
- plan-value product products purchase purchase-mode qty qty-minus qty-plus quantity recommendations reverse search
46
- search-bind search-collections search-input search-limit search-min search-products search-queries search-results
47
- search-state show soldout-text sort state subscribe-text variant variant-select variant-url max-width max-height crop
48
- sig label hydrated busy`.split(/\s+/).filter(Boolean));
49
-
50
- /** Levenshtein distance, for "did you mean". */
51
- function distance(a, b) {
52
- const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
53
- for (let j = 1; j <= b.length; j++) d[0][j] = j;
54
- for (let i = 1; i <= a.length; i++) {
55
- for (let j = 1; j <= b.length; j++) d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
56
- }
57
- return d[a.length][b.length];
58
- }
59
- export function didYouMean(value, options, prefix = '') {
60
- let best = null; let score = Infinity;
61
- for (const o of options) {
62
- const n = distance(String(value), o);
63
- if (n < score) { score = n; best = o; }
64
- }
65
- return best && score <= Math.max(2, Math.floor(String(value).length / 4)) ? ` Did you mean "${prefix}${best}"?` : '';
66
- }
67
-
68
- const mask = (t) => (t ? `${String(t).slice(0, 4)}…${String(t).slice(-4)} (${String(t).length} chars)` : '(missing)');
69
-
70
- function describe(el) {
71
- const id = el.id ? `#${el.id}` : '';
72
- const cls = el.className && typeof el.className === 'string' ? `.${el.className.trim().split(/\s+/).slice(0, 2).join('.')}` : '';
73
- return `<${el.tagName.toLowerCase()}${id}${cls}>`;
74
- }
75
-
76
- /** Markup problems that don't need the network. */
77
- export function checkMarkup(root = document) {
78
- const problems = [];
79
- const add = (level, message, el) => problems.push({ level, message, element: el ? describe(el) : undefined, el });
80
- const inProduct = (el) => Boolean(el.closest(sel('product')));
81
- const listSel = `${sel('collection')},${sel('products')},${sel('recommendations')}`;
82
-
83
- qsa(root, sel('product')).forEach((el) => {
84
- if (el.closest(listSel) && el.closest(sel('list'))) return; // list cards get their handle at render time
85
- const { key, fromUrl, raw } = resolveProductKey(el);
86
- if (!raw) add('error', `${attr('product')} has no value. Bind it to the Shopify handle (CMS field), or use "url:handle".`, el);
87
- else if (fromUrl && !key) add('warn', `${attr('product')}="${raw}" but this URL has no value for it (e.g. ?handle=my-product).`, el);
88
- });
89
-
90
- const productOnly = ['bind', 'option-list', 'option', 'variant-select', 'add-to-cart', 'purchase', 'plan-list', 'plan', 'qty', 'line-attribute'];
91
- productOnly.forEach((name) => {
92
- qsa(root, sel(name)).forEach((el) => {
93
- if (inProduct(el)) return;
94
- if (name === 'line-attribute' && el.closest(sel('group'))) return;
95
- add('warn', `${attr(name)} outside any ${attr('product')}: it does nothing here.`, el);
96
- });
97
- });
98
-
99
- qsa(root, sel('group-add')).forEach((el) => {
100
- if (!el.closest(sel('group'))) add('warn', `${attr('group-add')} outside a ${attr('group')}.`, el);
101
- });
102
-
103
- ['option-list', 'plan-list', 'cart-lines', 'list'].forEach((name) => qsa(root, sel(name)).forEach((el) => {
104
- if (!el.firstElementChild && !hasTemplate(el)) {
105
- add('warn', `${attr(name)} without a template child: put one example element inside it.`, el);
106
- }
107
- }));
108
-
109
- qsa(root, listSel).forEach((el) => {
110
- if (!qsa(el, sel('list')).length) add('error', `List without a ${attr('list')} child: products have nowhere to render.`, el);
111
- });
112
-
113
- qsa(root, sel('show')).forEach((el) => {
114
- const k = get(el, 'show');
115
- if (!SHOW.has(k)) add('warn', `Unknown ${attr('show')}="${k}".${didYouMean(k, SHOW)}`, el);
116
- });
117
-
118
- qsa(root, sel('bind')).forEach((el) => {
119
- const k = get(el, 'bind') || '';
120
- if (!k || BIND.has(k) || /^image:\d+$/.test(k) || k.startsWith('metafield:')) return;
121
- add('warn', `Unknown ${attr('bind')}="${k}": it stays empty.${didYouMean(k, BIND)}`, el);
122
- });
123
-
124
- // Typos in attribute names (tether-bnd, tether-add-to-card) do nothing, silently.
125
- const prefix = `${cfg.prefix}-`;
126
- const seen = new Set();
127
- qsa(root, '*').forEach((el) => {
128
- for (const a of el.attributes) {
129
- if (!a.name.startsWith(prefix)) continue;
130
- const name = a.name.slice(prefix.length);
131
- if (ATTRS.has(name) || seen.has(name)) continue;
132
- seen.add(name);
133
- add('warn', `Unknown attribute ${a.name}: Tether ignores it.${didYouMean(name, ATTRS, prefix)}`, el);
134
- }
135
- });
136
-
137
- qsa(root, sel('bind')).forEach((el) => {
138
- const k = get(el, 'bind') || '';
139
- if (!k.startsWith('metafield:')) return;
140
- const ref = k.slice(10);
141
- const listed = (cfg.metafields || []).some((m) => (typeof m === 'string' ? m : `${m.namespace}.${m.key}`) === ref);
142
- if (!listed) add('error', `${attr('bind')}="${k}" but "${ref}" is not in config.metafields, so it stays empty.`, el);
143
- });
144
-
145
- if (qsa(root, sel('cart-open')).length && !qsa(root, sel('cart-drawer')).length) {
146
- add('warn', `${attr('cart-open')} exists but there is no ${attr('cart-drawer')} on this page.`);
147
- }
148
- if (qsa(root, sel('cart-drawer')).length > 1) add('warn', `More than one ${attr('cart-drawer')}.`);
149
-
150
- qsa(root, `[${attr('state')}="error"]`).forEach((el) => {
151
- if (el.matches(boundary())) add('error', `Scope failed to load (${get(el, 'error') || 'error'}).`, el);
152
- });
153
-
154
- return problems;
155
- }
156
-
157
- export async function diagnose({ root = document, silent = false, version, now = new Date() } = {}) {
158
- const report = {
159
- version,
160
- config: {
161
- domain: cfg.domain || '(missing)',
162
- token: mask(cfg.token),
163
- apiVersion: cfg.apiVersion,
164
- endpoint: cfg.domain ? endpoint() : null,
165
- country: cfg.country, language: cfg.language, locale: cfg.locale, markets: cfg.markets || null,
166
- prefix: cfg.prefix, productPath: cfg.productPath, metafields: cfg.metafields,
167
- autoMount: cfg.autoMount, observe: cfg.observe, onError: typeof cfg.onError === 'function',
168
- },
169
- apiVersion: apiVersionStatus(cfg.apiVersion, now),
170
- // The market in use (a visitor's choice included), plus what detection would pick.
171
- market: { country: cfg.country || null, language: cfg.language || null, locale: cfg.locale || null, detected: cfg.markets?.detect ? detectMarket(cfg.markets) : null },
172
- reachable: false,
173
- shop: null,
174
- latencyMs: null,
175
- products: [],
176
- sellingPlans: [],
177
- problems: [],
178
- };
179
-
180
- if (!isConfigured()) {
181
- report.problems.push({ level: 'error', message: 'TETHER_CONFIG needs domain and token.' });
182
- } else {
183
- const t0 = Date.now();
184
- try {
185
- const data = await gql('query TetherDiagnose { shop { name primaryDomain { url } paymentSettings { currencyCode } } }', {}, { context: false });
186
- report.reachable = true;
187
- report.shop = { name: data.shop?.name, url: data.shop?.primaryDomain?.url, currency: data.shop?.paymentSettings?.currencyCode };
188
- } catch (err) {
189
- report.problems.push({ level: 'error', message: `Storefront API not reachable: ${err.message || err.code || 'unknown error'}` });
190
- }
191
- report.latencyMs = Date.now() - t0;
192
-
193
- if (report.reachable) {
194
- const keys = [...new Set(qsa(root, sel('product')).map((el) => resolveProductKey(el).key).filter(Boolean))].slice(0, 20);
195
- const results = await Promise.allSettled(keys.map((k) => getProduct(k)));
196
- results.forEach((r, i) => {
197
- if (r.status === 'fulfilled') {
198
- const p = r.value;
199
- const plans = allPlans(p);
200
- report.products.push({ key: keys[i], handle: p.handle, title: p.title, plans: plans.length, requiresSellingPlan: p.requiresSellingPlan });
201
- plans.forEach((pl) => report.sellingPlans.push({ product: p.handle, group: pl.group?.name, plan: pl.name, id: pl.id }));
202
- // Options a visitor can't choose on this page (they get the first available variant's value).
203
- qsa(root, sel('product')).filter((el) => resolveProductKey(el).key === keys[i] && el.querySelector(sel('add-to-cart'))).forEach((el) => {
204
- const shown = new Set(qsa(el, `${sel('option-list')},select${sel('option')}`).map((x) => get(x, 'option-list') || get(x, 'option')));
205
- if (!shown.size && qsa(el, `select${sel('variant-select')}`).length) return;
206
- const names = (p.options || []).map((o) => o.name);
207
- if (!cfg.language) {
208
- [...shown].filter((n) => n && !names.includes(n)).forEach((n) => {
209
- report.problems.push({ level: 'warn', message: `Product "${p.handle}" has no option "${n}" (its options: ${names.join(', ') || 'none'}).${didYouMean(n, names)}` });
210
- });
211
- }
212
- (p.options || []).filter((o) => o.optionValues.length > 1 && !shown.has(o.name)).forEach((o) => {
213
- report.problems.push({ level: 'warn', message: `Product "${p.handle}": option "${o.name}" (${o.optionValues.length} values) has no tether-option-list, so visitors can't choose it.` });
214
- });
215
- });
216
- } else {
217
- report.products.push({ key: keys[i], error: r.reason?.message });
218
- report.problems.push({ level: 'error', message: `Product "${keys[i]}": ${r.reason?.message || r.reason?.code || 'not loaded'}. Is it published to the Headless channel?` });
219
- }
220
- });
221
- }
222
- }
223
-
224
- if (!report.apiVersion.ok || (report.apiVersion.monthsLeft ?? 99) < 3) {
225
- report.problems.push({ level: report.apiVersion.ok ? 'warn' : 'error', message: `apiVersion ${cfg.apiVersion}: ${report.apiVersion.note}` });
226
- }
227
- if (typeof document !== 'undefined') report.problems.push(...checkMarkup(root));
228
-
229
- if (!silent) print(report);
230
- return report;
231
- }
232
-
233
- function print(r) {
234
- const c = console;
235
- const errors = r.problems.filter((p) => p.level === 'error').length;
236
- c.group?.(`[${cfg.prefix}] diagnose: ${errors ? `${errors} error(s)` : 'no errors'}, ${r.problems.length - errors} warning(s)`);
237
- c.log('version', r.version, '| API', r.apiVersion.version, `(${r.apiVersion.note})`);
238
- c.log('config', r.config);
239
- if (r.market) c.log('market', `in use: ${[r.market.country, r.market.language].filter(Boolean).join(' / ') || 'the store default'}`, r.market.detected ? `(detection picks ${JSON.stringify(r.market.detected)})` : '');
240
- c.log(r.reachable ? `Storefront API reachable: ${r.shop?.name} (${r.latencyMs} ms)` : 'Storefront API NOT reachable');
241
- if (r.products.length) (c.table || c.log)(r.products);
242
- if (r.sellingPlans.length) (c.table || c.log)(r.sellingPlans);
243
- else if (r.reachable) c.log('No selling plans on the products of this page.');
244
- r.problems.forEach((p) => (p.level === 'error' ? c.error : c.warn)(p.message, p.el || ''));
245
- c.groupEnd?.();
246
- }