@occluze/tether 0.6.6 → 0.6.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/README.md +3 -3
  2. package/dist/esm/astro.js +2 -2
  3. package/dist/esm/chunks/account-C3NP6HFB.js +1 -0
  4. package/dist/esm/chunks/account-pages-WLK374FO.js +1 -0
  5. package/dist/esm/chunks/analytics-G3SAWMNL.js +1 -0
  6. package/dist/esm/chunks/chunk-27PJJZGC.js +1 -0
  7. package/dist/esm/chunks/chunk-2FSXS55U.js +1 -0
  8. package/dist/esm/chunks/chunk-FCSWQDVV.js +1 -0
  9. package/dist/esm/chunks/chunk-FOMSMIAM.js +1 -0
  10. package/dist/esm/chunks/chunk-GZJTCLLW.js +1 -0
  11. package/dist/esm/chunks/chunk-HEQILZVR.js +1 -0
  12. package/dist/esm/chunks/chunk-JOOEXYF3.js +4 -0
  13. package/dist/esm/chunks/chunk-MPTQYLSM.js +1 -0
  14. package/dist/esm/chunks/chunk-NEHZ37TU.js +3 -0
  15. package/dist/esm/chunks/chunk-V74KGEJF.js +1 -0
  16. package/dist/esm/chunks/chunk-VT4CDUJJ.js +1 -0
  17. package/dist/esm/chunks/chunk-YJLWX2HK.js +1 -0
  18. package/dist/esm/chunks/diagnose-6VVGZNTY.js +14 -0
  19. package/dist/esm/chunks/extras-GJVRDFM3.js +1 -0
  20. package/dist/esm/chunks/markets-ui-UNB4PTA7.js +1 -0
  21. package/dist/esm/chunks/search-OU75RNQL.js +1 -0
  22. package/dist/esm/index.js +1 -1
  23. package/dist/esm/server.js +2 -1
  24. package/dist/presets/barba.esm.js +56 -36
  25. package/dist/tether.account-pages.js +776 -432
  26. package/dist/tether.account-pages.min.js +1 -1
  27. package/dist/tether.account.js +655 -321
  28. package/dist/tether.account.min.js +1 -1
  29. package/dist/tether.analytics.js +318 -191
  30. package/dist/tether.analytics.min.js +1 -1
  31. package/dist/tether.diagnose.js +334 -243
  32. package/dist/tether.diagnose.min.js +2 -2
  33. package/dist/tether.extras.js +78 -33
  34. package/dist/tether.extras.min.js +1 -1
  35. package/dist/tether.js +3171 -1998
  36. package/dist/tether.markets.js +231 -126
  37. package/dist/tether.markets.min.js +1 -1
  38. package/dist/tether.min.js +0 -0
  39. package/dist/tether.min.js.map +4 -4
  40. package/dist/tether.search.js +221 -126
  41. package/dist/tether.search.min.js +1 -1
  42. package/package.json +23 -4
  43. package/src/account/addresses-view.js +92 -0
  44. package/src/account/browser-session.js +359 -0
  45. package/src/account/markup.js +48 -0
  46. package/src/account/operations.js +180 -0
  47. package/src/account/order-lines.js +96 -0
  48. package/src/account/order-list.js +91 -0
  49. package/src/account/order-page.js +192 -0
  50. package/src/account/page-events.js +288 -0
  51. package/src/account/server-client.js +172 -0
  52. package/src/account/shared.js +111 -0
  53. package/src/account/shopify-auth.js +150 -0
  54. package/src/account/subscriptions-view.js +158 -0
  55. package/src/account-api.js +118 -0
  56. package/src/account-links.js +35 -0
  57. package/src/account-pages.js +68 -284
  58. package/src/account.js +281 -317
  59. package/src/amounts.js +36 -0
  60. package/src/analytics-shopify.js +241 -0
  61. package/src/analytics.js +304 -184
  62. package/src/api-version.js +75 -25
  63. package/src/astro.js +233 -68
  64. package/src/cart/checkout.js +97 -0
  65. package/src/cart/drawer.js +138 -0
  66. package/src/cart/mutations.js +234 -0
  67. package/src/cart/render.js +294 -0
  68. package/src/cart/store.js +326 -0
  69. package/src/cart.js +322 -481
  70. package/src/client.js +214 -78
  71. package/src/collection.js +126 -57
  72. package/src/config.js +323 -59
  73. package/src/controls/cart-controls.js +155 -0
  74. package/src/controls/discount.js +96 -0
  75. package/src/controls/extras.js +63 -0
  76. package/src/controls/navigation.js +83 -0
  77. package/src/diagnose-markup.js +333 -0
  78. package/src/diagnose.js +251 -202
  79. package/src/dom.js +219 -220
  80. package/src/extras.js +119 -37
  81. package/src/fragments.js +115 -22
  82. package/src/images.js +104 -0
  83. package/src/index.js +245 -285
  84. package/src/initial.js +78 -66
  85. package/src/lazy-iife.js +92 -22
  86. package/src/lazy.js +25 -4
  87. package/src/loader.js +263 -124
  88. package/src/market-codes.js +35 -0
  89. package/src/markets-ui.js +169 -55
  90. package/src/markets.js +286 -85
  91. package/src/markup.js +64 -0
  92. package/src/mounting.js +214 -91
  93. package/src/page-market.js +116 -0
  94. package/src/parts/account-pages.js +4 -1
  95. package/src/parts/account.js +4 -1
  96. package/src/parts/analytics.js +4 -1
  97. package/src/parts/diagnose.js +4 -1
  98. package/src/parts/extras.js +4 -1
  99. package/src/parts/markets.js +4 -1
  100. package/src/parts/search.js +4 -1
  101. package/src/presets/barba.js +120 -30
  102. package/src/product-events.js +130 -0
  103. package/src/product-render.js +257 -0
  104. package/src/product-state.js +318 -0
  105. package/src/product.js +233 -459
  106. package/src/repeaters.js +173 -0
  107. package/src/search.js +277 -107
  108. package/src/selling-plans.js +107 -47
  109. package/src/server.js +168 -205
  110. package/src/shopify-values.js +89 -0
  111. package/src/status.js +99 -39
  112. package/src/store-html.js +0 -0
  113. package/src/storefront/catalog.js +199 -0
  114. package/src/storefront/json-ld.js +167 -0
  115. package/src/variant-render.js +165 -0
  116. package/types/astro.d.ts +13 -2
  117. package/types/index.d.ts +178 -27
  118. package/types/server.d.ts +89 -17
  119. package/dist/esm/chunks/account-QUBHQG3C.js +0 -1
  120. package/dist/esm/chunks/account-pages-VWDHF6WA.js +0 -1
  121. package/dist/esm/chunks/analytics-SLATWPLC.js +0 -1
  122. package/dist/esm/chunks/chunk-64TUD3QR.js +0 -1
  123. package/dist/esm/chunks/chunk-AJLPU2JC.js +0 -4
  124. package/dist/esm/chunks/chunk-FIOZPPC5.js +0 -1
  125. package/dist/esm/chunks/chunk-K2YEQAIH.js +0 -1
  126. package/dist/esm/chunks/chunk-L2TRRKYV.js +0 -1
  127. package/dist/esm/chunks/chunk-RREHGH4A.js +0 -1
  128. package/dist/esm/chunks/chunk-VC46IEJQ.js +0 -1
  129. package/dist/esm/chunks/chunk-VCPI7MJ2.js +0 -1
  130. package/dist/esm/chunks/chunk-VPVJPNB4.js +0 -1
  131. package/dist/esm/chunks/chunk-ZIUIWEON.js +0 -1
  132. package/dist/esm/chunks/diagnose-VXQBSD3A.js +0 -14
  133. package/dist/esm/chunks/extras-R4X34VQL.js +0 -1
  134. package/dist/esm/chunks/markets-ui-F3R5T5EL.js +0 -1
  135. package/dist/esm/chunks/search-NILMGYXG.js +0 -1
package/src/diagnose.js CHANGED
@@ -1,246 +1,295 @@
1
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.
2
+ * `Tether.diagnose()`: one call in the browser console that explains what's wrong on a page. Logs a readable report
3
+ * and returns it as an object. The markup checks are in diagnose-markup.js.
4
4
  *
5
5
  * await Tether.diagnose()
6
6
  * await Tether.diagnose({ silent: true }) // just the object
7
7
  */
8
8
  import { cfg, isConfigured } from './config.js';
9
9
  import { gql, endpoint } from './client.js';
10
- import { attr, sel, get, qsa, hasTemplate } from './dom.js';
10
+ import { sel, get, qsa } from './dom.js';
11
11
  import { getProduct } from './loader.js';
12
12
  import { allPlans } from './selling-plans.js';
13
- import { resolveProductKey, boundary } from './status.js';
13
+ import { resolveProductKey } from './status.js';
14
14
  import { apiVersionStatus } from './api-version.js';
15
- import { detectMarket } from './markets.js';
15
+ import { loadPart } from './lazy.js';
16
+ import { checkMarkup, didYouMean } from './diagnose-markup.js';
17
+ /** @import { ApiVersionStatus } from './api-version.js' */
18
+ /** @import { DetectedMarket } from './markets.js' */
19
+ /** @import { Problem } from './diagnose-markup.js' */
20
+ /** @import { Product } from './fragments.js' */
16
21
 
17
- export { apiVersionStatus };
22
+ export { apiVersionStatus, checkMarkup, didYouMean };
18
23
 
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', 'subscriptions-loading', 'subscriptions-empty',
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));
24
+ /**
25
+ * @typedef {object} MarketReport The market in use (a visitor's choice included), and what detection would pick.
26
+ * @property {string | null} country
27
+ * @property {string | null} language
28
+ * @property {string | null} locale
29
+ * @property {DetectedMarket | null} detected `null` without `markets.detect`.
30
+ */
31
+ /** @typedef {{ name: string, url: string, currency: string }} ShopReport */
32
+ /**
33
+ * @typedef {{ key: string, handle: string, title: string, plans: number, requiresSellingPlan: boolean }
34
+ * | { key: string, error: string | undefined }} ProductReport
35
+ */
36
+ /** @typedef {{ product: string, group: string, plan: string, id: string }} SellingPlanReport */
37
+ /**
38
+ * @typedef {object} DiagnoseReport
39
+ * @property {string | undefined} version
40
+ * @property {Record<string, unknown>} config The config, with the token masked.
41
+ * @property {ApiVersionStatus} apiVersion
42
+ * @property {MarketReport} market
43
+ * @property {boolean} reachable
44
+ * @property {ShopReport | null} shop
45
+ * @property {number | null} latencyMs
46
+ * @property {ProductReport[]} products
47
+ * @property {SellingPlanReport[]} sellingPlans
48
+ * @property {Problem[]} problems
49
+ */
50
+ /**
51
+ * @typedef {{ kind: 'not-configured' }
52
+ * | { kind: 'reachable', shop: ShopReport, latencyMs: number }
53
+ * | { kind: 'unreachable', error: unknown, latencyMs: number }} StorefrontCheck
54
+ */
55
+ /**
56
+ * @typedef {object} DiagnoseOptions
57
+ * @property {ParentNode} [root]
58
+ * @property {boolean} [silent] Return the report without logging it.
59
+ * @property {string} [version] Tether's version, for the report.
60
+ * @property {Date} [now] The date the API version's support is measured against.
61
+ */
49
62
 
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
- }
63
+ /** Products checked at most, so a long CMS page doesn't send a request per card. */
64
+ const MAX_PRODUCTS_CHECKED = 20;
65
+ /** Matches the warning window of `apiVersionStatus`. */
66
+ const API_UPGRADE_WARNING_MONTHS = 3;
67
+ const TOKEN_EDGE_CHARACTERS = 4;
67
68
 
68
- const mask = (t) => (t ? `${String(t).slice(0, 4)}…${String(t).slice(-4)} (${String(t).length} chars)` : '(missing)');
69
+ /** @param {string} token */
70
+ function maskToken(token) {
71
+ if (!token) return '(missing)';
72
+ return `${token.slice(0, TOKEN_EDGE_CHARACTERS)}…${token.slice(-TOKEN_EDGE_CHARACTERS)} (${token.length} chars)`;
73
+ }
69
74
 
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}>`;
75
+ /**
76
+ * @param {unknown} error
77
+ * @param {string} fallback
78
+ * @returns {string} The error's message, else its code (a `TetherError` has one), else `fallback`.
79
+ */
80
+ function errorText(error, fallback) {
81
+ if (error instanceof Error && error.message) return error.message;
82
+ /** @type {unknown} */
83
+ const code = typeof error === 'object' && error !== null ? Reflect.get(error, 'code') : undefined;
84
+ return typeof code === 'string' && code ? code : fallback;
74
85
  }
75
86
 
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')}`;
87
+ /** @returns {Record<string, unknown>} */
88
+ function configSummary() {
89
+ return {
90
+ domain: cfg.domain || '(missing)',
91
+ token: maskToken(cfg.token),
92
+ apiVersion: cfg.apiVersion,
93
+ endpoint: cfg.domain ? endpoint() : null,
94
+ country: cfg.country,
95
+ language: cfg.language,
96
+ locale: cfg.locale,
97
+ markets: cfg.markets,
98
+ prefix: cfg.prefix,
99
+ productPath: cfg.productPath,
100
+ metafields: cfg.metafields,
101
+ autoMount: cfg.autoMount,
102
+ observe: cfg.observe,
103
+ onError: typeof cfg.onError === 'function',
104
+ };
105
+ }
82
106
 
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
- });
107
+ /**
108
+ * Detection runs in the markets part, which init has already loaded whenever `markets.detect` is set.
109
+ *
110
+ * @returns {Promise<MarketReport>}
111
+ */
112
+ async function marketSummary() {
113
+ return {
114
+ country: cfg.country || null,
115
+ language: cfg.language || null,
116
+ locale: cfg.locale || null,
117
+ detected: cfg.markets?.detect ? (await loadPart('markets')).detectMarket(cfg.markets) : null,
118
+ };
119
+ }
89
120
 
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
- });
121
+ /**
122
+ * @param {ApiVersionStatus} status
123
+ * @returns {Problem | null} An error for an unsupported version, a warning when support ends soon.
124
+ */
125
+ function apiVersionProblem({ ok, monthsLeft, note }) {
126
+ if (ok && (monthsLeft === undefined || monthsLeft >= API_UPGRADE_WARNING_MONTHS)) return null;
127
+ return { level: ok ? 'warn' : 'error', message: `apiVersion ${cfg.apiVersion}: ${note}` };
128
+ }
98
129
 
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
- });
130
+ /** @returns {Promise<StorefrontCheck>} */
131
+ async function checkStorefront() {
132
+ if (!isConfigured()) return { kind: 'not-configured' };
133
+ const startedAt = Date.now();
134
+ try {
135
+ /** @type {{ shop: { name: string, primaryDomain: { url: string }, paymentSettings: { currencyCode: string } } }} */
136
+ const data = await gql(
137
+ 'query TetherDiagnose { shop { name primaryDomain { url } paymentSettings { currencyCode } } }',
138
+ {},
139
+ { context: false },
140
+ );
141
+ const shop = {
142
+ name: data.shop.name,
143
+ url: data.shop.primaryDomain.url,
144
+ currency: data.shop.paymentSettings.currencyCode,
145
+ };
146
+ return { kind: 'reachable', shop, latencyMs: Date.now() - startedAt };
147
+ } catch (error) {
148
+ return { kind: 'unreachable', error, latencyMs: Date.now() - startedAt };
149
+ }
150
+ }
102
151
 
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);
152
+ /**
153
+ * Options a visitor can't choose on this page get the first available variant's value.
154
+ *
155
+ * @param {ParentNode} root
156
+ * @param {string} key
157
+ * @param {Product} product
158
+ * @returns {Problem[]}
159
+ */
160
+ function checkOptions(root, key, product) {
161
+ /** @type {Problem[]} */
162
+ const problems = [];
163
+ const names = product.options.map((option) => option.name);
164
+ const roots = qsa(root, sel('product')).filter(
165
+ (el) => resolveProductKey(el).key === key && el.querySelector(sel('add-to-cart')),
166
+ );
167
+ for (const el of roots) {
168
+ const shown = new Set(
169
+ qsa(el, `${sel('option-list')},select${sel('option')}`).map(
170
+ (list) => get(list, 'option-list') || get(list, 'option'),
171
+ ),
172
+ );
173
+ if (!shown.size && qsa(el, `select${sel('variant-select')}`).length) continue;
174
+ // Translated option names (a language in the market) don't match the names in the markup.
175
+ if (!cfg.language) {
176
+ for (const name of shown) {
177
+ if (!name || names.includes(name)) continue;
178
+ problems.push({
179
+ level: 'warn',
180
+ message: `Product "${product.handle}" has no option "${name}" (its options: ${names.join(', ') || 'none'}).${didYouMean(name, names)}`,
181
+ });
182
+ }
106
183
  }
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);
184
+ for (const option of product.options) {
185
+ if (option.optionValues.length <= 1 || shown.has(option.name)) continue;
186
+ problems.push({
187
+ level: 'warn',
188
+ message: `Product "${product.handle}": option "${option.name}" (${option.optionValues.length} values) has no tether-option-list, so visitors can't choose it.`,
189
+ });
134
190
  }
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
191
  }
148
- if (qsa(root, sel('cart-drawer')).length > 1) add('warn', `More than one ${attr('cart-drawer')}.`);
192
+ return problems;
193
+ }
149
194
 
150
- qsa(root, `[${attr('state')}="error"]`).forEach((el) => {
151
- if (el.matches(boundary())) add('error', `Scope failed to load (${get(el, 'error') || 'error'}).`, el);
195
+ /**
196
+ * Loads the page's products and adds what it finds about each to `report`.
197
+ *
198
+ * @param {ParentNode} root
199
+ * @param {DiagnoseReport} report
200
+ */
201
+ async function checkProducts(root, report) {
202
+ const allKeys = qsa(root, sel('product')).map((el) => resolveProductKey(el).key);
203
+ const keys = [...new Set(allKeys.filter((key) => key !== null && key !== ''))].slice(0, MAX_PRODUCTS_CHECKED);
204
+ const results = await Promise.allSettled(keys.map((key) => getProduct(key)));
205
+ results.forEach((result, index) => {
206
+ const key = keys[index] ?? '';
207
+ if (result.status === 'rejected') {
208
+ const reason = errorText(result.reason, 'not loaded');
209
+ report.products.push({ key, error: result.reason instanceof Error ? result.reason.message : undefined });
210
+ report.problems.push({
211
+ level: 'error',
212
+ message: `Product "${key}": ${reason}. Is it published to the Headless channel?`,
213
+ });
214
+ return;
215
+ }
216
+ const product = result.value;
217
+ const plans = allPlans(product);
218
+ report.products.push({
219
+ key,
220
+ handle: product.handle,
221
+ title: product.title,
222
+ plans: plans.length,
223
+ requiresSellingPlan: product.requiresSellingPlan,
224
+ });
225
+ for (const plan of plans) {
226
+ report.sellingPlans.push({ product: product.handle, group: plan.group.name, plan: plan.name, id: plan.id });
227
+ }
228
+ report.problems.push(...checkOptions(root, key, product));
152
229
  });
153
-
154
- return problems;
155
230
  }
156
231
 
232
+ /**
233
+ * @param {DiagnoseOptions} [options]
234
+ * @returns {Promise<DiagnoseReport>} What's wrong on the page: config, token, API version, products, plans, markup.
235
+ */
157
236
  export async function diagnose({ root = document, silent = false, version, now = new Date() } = {}) {
237
+ const storefront = await checkStorefront();
238
+ /** @type {DiagnoseReport} */
158
239
  const report = {
159
240
  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
- },
241
+ config: configSummary(),
169
242
  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,
243
+ market: await marketSummary(),
244
+ reachable: storefront.kind === 'reachable',
245
+ shop: storefront.kind === 'reachable' ? storefront.shop : null,
246
+ latencyMs: storefront.kind === 'not-configured' ? null : storefront.latencyMs,
175
247
  products: [],
176
248
  sellingPlans: [],
177
249
  problems: [],
178
250
  };
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
- }
251
+ switch (storefront.kind) {
252
+ case 'not-configured':
253
+ report.problems.push({ level: 'error', message: 'TETHER_CONFIG needs domain and token.' });
254
+ break;
255
+ case 'unreachable':
256
+ report.problems.push({
257
+ level: 'error',
258
+ message: `Storefront API not reachable: ${errorText(storefront.error, 'unknown error')}`,
220
259
  });
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}` });
260
+ break;
261
+ case 'reachable':
262
+ await checkProducts(root, report);
263
+ break;
226
264
  }
265
+ const versionProblem = apiVersionProblem(report.apiVersion);
266
+ if (versionProblem) report.problems.push(versionProblem);
227
267
  if (typeof document !== 'undefined') report.problems.push(...checkMarkup(root));
228
-
229
268
  if (!silent) print(report);
230
269
  return report;
231
270
  }
232
271
 
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?.();
272
+ /** @param {DiagnoseReport} report */
273
+ function print(report) {
274
+ const errors = report.problems.filter((problem) => problem.level === 'error').length;
275
+ const warnings = report.problems.length - errors;
276
+ console.group(`[${cfg.prefix}] diagnose: ${errors ? `${errors} error(s)` : 'no errors'}, ${warnings} warning(s)`);
277
+ console.log('version', report.version, '| API', report.apiVersion.version, `(${report.apiVersion.note})`);
278
+ console.log('config', report.config);
279
+ const { country, language, detected } = report.market;
280
+ const inUse = [country, language].filter(Boolean).join(' / ') || 'the store default';
281
+ console.log('market', `in use: ${inUse}`, detected ? `(detection picks ${JSON.stringify(detected)})` : '');
282
+ console.log(
283
+ report.shop
284
+ ? `Storefront API reachable: ${report.shop.name} (${report.latencyMs ?? 0} ms)`
285
+ : 'Storefront API NOT reachable',
286
+ );
287
+ if (report.products.length) console.table(report.products);
288
+ if (report.sellingPlans.length) console.table(report.sellingPlans);
289
+ else if (report.reachable) console.log('No selling plans on the products of this page.');
290
+ for (const problem of report.problems) {
291
+ if (problem.level === 'error') console.error(problem.message, problem.el ?? '');
292
+ else console.warn(problem.message, problem.el ?? '');
293
+ }
294
+ console.groupEnd();
246
295
  }