@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
@@ -1,94 +1,154 @@
1
1
  /**
2
- * Pure helpers for Shopify selling plans (subscriptions). No DOM, easy to test.
2
+ * Shopify selling plans (subscriptions): which plans a product and a variant have, and what each costs. Pure, no DOM,
3
+ * shared by the browser script and the server helpers.
3
4
  */
5
+ /** @import { Money } from './shopify-values.js' */
6
+ /** @import { SellingPlan, SellingPlanGroup, SellingPlanAllocation } from './fragments.js' */
4
7
 
5
- export const GID = {
6
- product: (v) => toGid(v, 'Product'),
7
- variant: (v) => toGid(v, 'ProductVariant'),
8
- sellingPlan: (v) => toGid(v, 'SellingPlan'),
9
- };
8
+ /** @typedef {SellingPlan & { group: SellingPlanGroup }} PlanInGroup A plan with the group it belongs to. */
9
+ /** @typedef {{ sellingPlanGroups?: { nodes: SellingPlanGroup[] } | null }} ProductWithPlans */
10
+ /**
11
+ * A variant as far as plans go. A cart line's variant has no allocations.
12
+ *
13
+ * @typedef {{ price: Money, sellingPlanAllocations?: { nodes: SellingPlanAllocation[] } | null }} VariantWithPlans
14
+ */
15
+ /**
16
+ * @typedef {{ type: 'percentage', value: number } | { type: 'amount', value: Money } | { type: 'price', value: Money }}
17
+ * PlanAdjustment
18
+ */
10
19
 
20
+ /**
21
+ * @param {unknown} value A gid, a numeric id, or anything else.
22
+ * @param {string} type The Shopify type, e.g. `ProductVariant`.
23
+ * @returns {string | null} The gid of a numeric id; a gid or other value as written; `null` when empty.
24
+ */
11
25
  export function toGid(value, type) {
12
- const v = String(value ?? '').trim();
13
- if (!v) return null;
14
- if (v.startsWith('gid://')) return v;
15
- if (/^\d+$/.test(v)) return `gid://shopify/${type}/${v}`;
16
- return v;
26
+ const text = typeof value === 'string' || typeof value === 'number' ? String(value).trim() : '';
27
+ if (!text) return null;
28
+ if (text.startsWith('gid://')) return text;
29
+ if (/^\d+$/.test(text)) return `gid://shopify/${type}/${text}`;
30
+ return text;
17
31
  }
18
32
 
19
- /** All plans of a product, flattened, each with its group attached. */
33
+ export const GID = {
34
+ /** @param {unknown} value */
35
+ product: (value) => toGid(value, 'Product'),
36
+ /** @param {unknown} value */
37
+ variant: (value) => toGid(value, 'ProductVariant'),
38
+ /** @param {unknown} value */
39
+ sellingPlan: (value) => toGid(value, 'SellingPlan'),
40
+ };
41
+
42
+ /**
43
+ * @param {ProductWithPlans | null | undefined} product
44
+ * @returns {PlanInGroup[]} All plans of the product, flattened.
45
+ */
20
46
  export function allPlans(product) {
21
- const groups = product?.sellingPlanGroups?.nodes || [];
22
- return groups.flatMap((g) => (g.sellingPlans?.nodes || []).map((p) => ({ ...p, group: g })));
47
+ const groups = product?.sellingPlanGroups?.nodes ?? [];
48
+ return groups.flatMap((group) => group.sellingPlans.nodes.map((plan) => ({ ...plan, group })));
23
49
  }
24
50
 
51
+ /** @param {ProductWithPlans | null | undefined} product */
25
52
  export function hasPlans(product) {
26
53
  return allPlans(product).length > 0;
27
54
  }
28
55
 
29
- /** The allocation of `planId` on `variant`, or null when the variant isn't in that plan. */
56
+ /**
57
+ * @param {VariantWithPlans | null | undefined} variant
58
+ * @param {string | null | undefined} planId
59
+ * @returns {SellingPlanAllocation | null} The variant's allocation on the plan; `null` when the variant isn't in it.
60
+ */
30
61
  export function allocationFor(variant, planId) {
31
62
  if (!variant || !planId) return null;
32
- return (variant.sellingPlanAllocations?.nodes || []).find((a) => a.sellingPlan?.id === planId) || null;
63
+ const allocations = variant.sellingPlanAllocations?.nodes ?? [];
64
+ return allocations.find((allocation) => allocation.sellingPlan.id === planId) ?? null;
33
65
  }
34
66
 
35
- /** Plans that can actually be bought with this variant. */
67
+ /**
68
+ * @param {ProductWithPlans | null | undefined} product
69
+ * @param {VariantWithPlans | null | undefined} variant
70
+ * @returns {PlanInGroup[]} The plans this variant can be bought on.
71
+ */
36
72
  export function plansForVariant(product, variant) {
37
- return allPlans(product).filter((p) => allocationFor(variant, p.id));
73
+ return allPlans(product).filter((plan) => allocationFor(variant, plan.id));
38
74
  }
39
75
 
40
- /** Price per delivery for a variant on a plan (falls back to the one-time price). */
76
+ /**
77
+ * @param {VariantWithPlans | null | undefined} variant
78
+ * @param {string | null | undefined} planId
79
+ * @returns {Money | null} The price per delivery on the plan; the one-time price when the variant isn't on it.
80
+ */
41
81
  export function planPrice(variant, planId) {
42
- const a = allocationFor(variant, planId);
43
- const adj = a?.priceAdjustments?.[0];
44
- return adj?.perDeliveryPrice || adj?.price || variant?.price || null;
82
+ const adjustment = allocationFor(variant, planId)?.priceAdjustments[0];
83
+ return adjustment?.perDeliveryPrice ?? adjustment?.price ?? variant?.price ?? null;
45
84
  }
46
85
 
47
- /** What the customer pays at checkout for this plan (prepaid plans charge more up front). */
86
+ /**
87
+ * @param {VariantWithPlans | null | undefined} variant
88
+ * @param {string | null | undefined} planId
89
+ * @returns {Money | null} What the customer pays at checkout on the plan (prepaid plans charge more up front).
90
+ */
48
91
  export function planCheckoutCharge(variant, planId) {
49
- const a = allocationFor(variant, planId);
50
- return a?.checkoutChargeAmount || a?.priceAdjustments?.[0]?.price || variant?.price || null;
92
+ const allocation = allocationFor(variant, planId);
93
+ return allocation?.checkoutChargeAmount ?? allocation?.priceAdjustments[0]?.price ?? variant?.price ?? null;
51
94
  }
52
95
 
53
- /** Savings of a plan vs the one-time price, as { amount, percentage }. */
96
+ /**
97
+ * @param {VariantWithPlans | null | undefined} variant
98
+ * @param {string | null | undefined} planId
99
+ * @returns {{ amount: Money | null, percentage: number }} What the plan saves on the one-time price; no amount and
100
+ * 0% when it saves nothing.
101
+ */
54
102
  export function planSavings(variant, planId) {
55
- const base = parseFloat(variant?.price?.amount);
56
- const sub = parseFloat(planPrice(variant, planId)?.amount);
57
- if (!Number.isFinite(base) || !Number.isFinite(sub) || base <= 0 || sub >= base) {
103
+ const oneTime = parseFloat(variant?.price.amount ?? '');
104
+ const onPlan = parseFloat(planPrice(variant, planId)?.amount ?? '');
105
+ if (!variant || !Number.isFinite(oneTime) || !Number.isFinite(onPlan) || oneTime <= 0 || onPlan >= oneTime) {
58
106
  return { amount: null, percentage: 0 };
59
107
  }
60
108
  return {
61
- amount: { amount: (base - sub).toFixed(2), currencyCode: variant.price.currencyCode },
62
- percentage: Math.round((1 - sub / base) * 100),
109
+ amount: { amount: (oneTime - onPlan).toFixed(2), currencyCode: variant.price.currencyCode },
110
+ percentage: Math.round((1 - onPlan / oneTime) * 100),
63
111
  };
64
112
  }
65
113
 
66
114
  /**
67
- * The plan's own discount description, independent of a variant:
68
- * '10%', or a fixed amount / fixed price.
115
+ * @param {Pick<SellingPlan, 'priceAdjustments'> | null | undefined} plan
116
+ * @returns {PlanAdjustment | null} The plan's own discount, whatever the variant: a percentage, a fixed amount off or
117
+ * a fixed price.
69
118
  */
70
119
  export function planAdjustment(plan) {
71
- const v = plan?.priceAdjustments?.[0]?.adjustmentValue;
72
- if (!v) return null;
73
- switch (v.__typename) {
74
- case 'SellingPlanPercentagePriceAdjustment': return { type: 'percentage', value: v.adjustmentPercentage };
75
- case 'SellingPlanFixedAmountPriceAdjustment': return { type: 'amount', value: v.adjustmentAmount };
76
- case 'SellingPlanFixedPriceAdjustment': return { type: 'price', value: v.price };
77
- default: return null;
120
+ const value = plan?.priceAdjustments[0]?.adjustmentValue;
121
+ if (!value) return null;
122
+ switch (value.__typename) {
123
+ case 'SellingPlanPercentagePriceAdjustment':
124
+ return { type: 'percentage', value: value.adjustmentPercentage };
125
+ case 'SellingPlanFixedAmountPriceAdjustment':
126
+ return { type: 'amount', value: value.adjustmentAmount };
127
+ case 'SellingPlanFixedPriceAdjustment':
128
+ return { type: 'price', value: value.price };
129
+ default:
130
+ // An adjustment type newer than the API version Tether was written for.
131
+ return null;
78
132
  }
79
133
  }
80
134
 
81
135
  /**
82
- * Finds a plan by id, numeric id, name or option value (case-insensitive),
83
- * so Webflow editors can write tether-default-plan="Every month".
136
+ * Finds a plan by id, numeric id, name or option value (case-insensitive), so Webflow editors can write
137
+ * `tether-default-plan="Every month"`.
138
+ *
139
+ * @param {ProductWithPlans | null | undefined} product
140
+ * @param {string | null | undefined} ref
141
+ * @returns {PlanInGroup | null}
84
142
  */
85
143
  export function findPlan(product, ref) {
86
144
  if (!ref) return null;
87
145
  const plans = allPlans(product);
88
146
  const gid = GID.sellingPlan(ref);
89
- const needle = String(ref).trim().toLowerCase();
90
- return plans.find((p) => p.id === gid)
91
- || plans.find((p) => p.name.toLowerCase() === needle)
92
- || plans.find((p) => (p.options || []).some((o) => String(o.value).toLowerCase() === needle))
93
- || null;
147
+ const needle = ref.trim().toLowerCase();
148
+ return (
149
+ plans.find((plan) => plan.id === gid) ??
150
+ plans.find((plan) => plan.name.toLowerCase() === needle) ??
151
+ plans.find((plan) => plan.options.some((option) => option.value.toLowerCase() === needle)) ??
152
+ null
153
+ );
94
154
  }
package/src/server.js CHANGED
@@ -1,252 +1,215 @@
1
1
  /**
2
- * @occluze/tether/server: Shopify product data at build time or on the server (Astro, Eleventy,
3
- * Node, Cloudflare Workers). No browser APIs. Uses the same queries as the browser script, so
4
- * products handed over with hydrate() are exactly what Tether expects and need no extra request.
2
+ * `@occluze/tether/server`: Shopify product data at build time or on the server (Astro, Eleventy, Node, Cloudflare
3
+ * Workers). No browser APIs. It sends the browser script's own queries, so products handed over with `hydrate()` are
4
+ * exactly what the script expects and need no request of their own.
5
5
  *
6
6
  * import { createStorefront } from '@occluze/tether/server';
7
7
  * const shop = createStorefront({ domain, token, country: 'NL' });
8
8
  * const product = await shop.getProduct('coffee-box');
9
9
  * const all = await shop.allProducts();
10
10
  */
11
- import { DEFAULTS } from './config.js';
11
+ import { DEFAULTS, resolveConfig } from './config.js';
12
12
  import { request, withContext, TetherError } from './client.js';
13
- import { productFragment, collectionFields } from './fragments.js';
14
13
  import * as plans from './selling-plans.js';
15
14
  import { imageUrl, formatPercent } from './dom.js';
16
15
  import { initialState } from './initial.js';
16
+ import { STORE_ENV_NAMES, formatMoney, productKey, productUrl as productUrlFor } from './shopify-values.js';
17
+ import { createCatalog } from './storefront/catalog.js';
18
+ import { jsonLdData, scriptJson } from './storefront/json-ld.js';
19
+ /** @import { TetherConfig, TetherOptions } from './config.js' */
20
+ /** @import { Product } from './fragments.js' */
21
+ /** @import { Money } from './shopify-values.js' */
22
+ /** @import { InitialOptions } from './initial.js' */
23
+ /** @import { JsonLdOptions } from './storefront/json-ld.js' */
17
24
 
18
- export { imageUrl, initialState, formatPercent };
25
+ export { imageUrl, initialState, formatPercent, formatMoney, productKey };
19
26
 
20
27
  export { TetherError, plans, withContext };
21
28
 
22
- const isGid = (v) => typeof v === 'string' && v.startsWith('gid://');
23
-
24
- /** Handle, numeric id, gid or product URL -> handle or gid. */
25
- export function productKey(raw) {
26
- const v = String(raw ?? '').trim();
27
- if (!v) return null;
28
- if (isGid(v)) return v;
29
- if (/^\d+$/.test(v)) return `gid://shopify/Product/${v}`;
30
- const m = v.match(/\/products\/([^/?#]+)/);
31
- return m ? m[1] : v;
32
- }
29
+ /**
30
+ * @typedef {object} ServerOptions The options only a storefront has.
31
+ * @property {typeof fetch} [fetch] Replaces the global `fetch` (tests, caching).
32
+ * @property {number} [pageSize] Products per request for `allProducts()` (default 25).
33
+ * @property {string} [siteUrl] The site's address, for product URLs in `jsonLd()` without `url`.
34
+ */
33
35
 
34
- /** Money as text, e.g. formatMoney({ amount: '30.0', currencyCode: 'EUR' }, 'nl-NL') -> "€ 30,00". */
35
- export function formatMoney(money, locale) {
36
- if (!money) return '';
37
- const amount = parseFloat(money.amount);
38
- if (Number.isNaN(amount)) return '';
39
- try {
40
- return new Intl.NumberFormat(locale || undefined, { style: 'currency', currency: money.currencyCode }).format(amount);
41
- } catch {
42
- return `${money.currencyCode} ${amount.toFixed(2)}`;
43
- }
44
- }
36
+ /**
37
+ * @typedef {TetherOptions & ServerOptions & Record<string, unknown>} StorefrontOptions The config options, or an
38
+ * `import.meta.env` spread into them: its `PUBLIC_SHOPIFY_*` names give the domain and token, its other keys are
39
+ * ignored.
40
+ */
45
41
 
46
- /** productPath '/products' -> /products/<handle>; templates '/p?handle={handle}' and '{id}' work too. */
47
- export function productUrl(handle, { productPath = '/product', id } = {}) {
48
- if (!/\{(handle|id)\}/.test(productPath)) return `${productPath.replace(/\/$/, '')}/${handle}`;
49
- return productPath.replace(/\{handle\}/g, encodeURIComponent(handle || '')).replace(/\{id\}/g, encodeURIComponent(id ? String(id).split('/').pop() : ''));
50
- }
42
+ /** @typedef {TetherConfig & ServerOptions} ServerConfig */
51
43
 
52
- /** JSON for a <script> tag: escapes < so product text can never close the tag. */
53
- const safeJson = (v) => JSON.stringify(v).replace(/</g, '\\u003c').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
44
+ /** @typedef {{ card?: boolean, images?: number }} HydrateOptions */
54
45
 
55
- // createStorefront(import.meta.env) works too: these env names map to domain and token.
56
- const ENV = {
57
- domain: ['PUBLIC_SHOPIFY_DOMAIN', 'PUBLIC_SHOPIFY_STORE_DOMAIN', 'SHOPIFY_DOMAIN'],
58
- token: ['PUBLIC_SHOPIFY_STOREFRONT_TOKEN', 'PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN', 'SHOPIFY_STOREFRONT_TOKEN'],
59
- };
46
+ const DEFAULT_PAGE_SIZE = 25;
47
+ /** The images a card keeps: the main one and a hover image (`tether-bind="image:2"`). */
48
+ const DEFAULT_CARD_IMAGES = 2;
49
+ /** The version of the `data-tether-hydrate` payload, for the browser script reading it. */
50
+ const HYDRATE_VERSION = 1;
60
51
 
61
- export function createStorefront(options = {}) {
62
- const known = Object.fromEntries(Object.entries(options).filter(([k]) => k in DEFAULTS || k === 'fetch' || k === 'pageSize' || k === 'siteUrl'));
63
- const conf = { ...DEFAULTS, ...known };
64
- for (const [key, names] of Object.entries(ENV)) if (!conf[key]) conf[key] = names.map((n) => options[n]).find(Boolean) || '';
65
- conf.domain = String(conf.domain || '').replace(/^https?:\/\//, '').replace(/\/.*$/, '');
66
- if (!conf.domain || !conf.token) {
67
- throw new TetherError('createStorefront needs domain and token: pass them, or import.meta.env with PUBLIC_SHOPIFY_DOMAIN and PUBLIC_SHOPIFY_STOREFRONT_TOKEN', { code: 'NOT_CONFIGURED' });
68
- }
69
- const pageSize = options.pageSize || 25;
70
- const q = (query, variables, opts) => request(conf, query, variables, opts);
71
- const fragment = () => productFragment(conf);
72
-
73
- async function getProduct(raw) {
74
- const key = productKey(raw);
75
- if (!key) return null;
76
- const data = isGid(key)
77
- ? await q(`query TetherServerProductById($id: ID!) { node(id: $id) { ... on Product { ...TetherProduct } } }\n${fragment()}`, { id: key })
78
- : await q(`query TetherServerProduct($handle: String!) { product(handle: $handle) { ...TetherProduct } }\n${fragment()}`, { handle: key });
79
- const p = isGid(key) ? data.node : data.product;
80
- return p && p.id ? p : null;
81
- }
82
-
83
- async function getProducts({ collection, query, first = pageSize, after = null, sortKey, reverse = false, filters } = {}) {
84
- if (collection) {
85
- const data = await q(`query TetherServerCollection($handle: String!, $first: Int!, $after: String, $sortKey: ProductCollectionSortKeys, $reverse: Boolean, $filters: [ProductFilter!]) {
86
- collection(handle: $handle) {
87
- ${collectionFields()}
88
- products(first: $first, after: $after, sortKey: $sortKey, reverse: $reverse, filters: $filters) {
89
- nodes { ...TetherProduct }
90
- pageInfo { hasNextPage endCursor }
91
- }
92
- }
93
- }
94
- ${fragment()}`, { handle: collection, first, after, sortKey: sortKey || undefined, reverse, filters: filters || undefined });
95
- if (!data.collection) return { collection: null, products: [], pageInfo: { hasNextPage: false, endCursor: null } };
96
- const { products, ...rest } = data.collection;
97
- return { collection: rest, products: products.nodes, pageInfo: products.pageInfo };
98
- }
99
- const data = await q(`query TetherServerProducts($first: Int!, $after: String, $query: String, $sortKey: ProductSortKeys, $reverse: Boolean) {
100
- products(first: $first, after: $after, query: $query, sortKey: $sortKey, reverse: $reverse) {
101
- nodes { ...TetherProduct }
102
- pageInfo { hasNextPage endCursor }
103
- }
52
+ /**
53
+ * @param {string} handle
54
+ * @param {{ productPath?: string, id?: string }} [options]
55
+ * @returns {string} `productPath` `/products` → `/products/<handle>`; templates with `{handle}` and `{id}` work too.
56
+ */
57
+ export function productUrl(handle, { productPath = DEFAULTS.productPath, id } = {}) {
58
+ return productUrlFor(productPath, handle, id);
104
59
  }
105
- ${fragment()}`, { first, after, query: query || undefined, sortKey: sortKey || undefined, reverse });
106
- return { collection: null, products: data.products.nodes, pageInfo: data.products.pageInfo };
107
- }
108
60
 
109
- /** Every product (of a collection or a search), page by page. For getStaticPaths(). */
110
- async function allProducts(opts = {}) {
111
- const out = [];
112
- let after = null;
113
- for (let i = 0; i < 500; i++) {
114
- const page = await getProducts({ ...opts, first: pageSize, after });
115
- out.push(...page.products);
116
- if (!page.pageInfo.hasNextPage) break;
117
- after = page.pageInfo.endCursor;
118
- }
119
- return out;
61
+ /**
62
+ * @param {Record<string, unknown>} options
63
+ * @param {readonly string[]} names
64
+ * @returns {string} The first of the `names` set in `options`, `''` without one.
65
+ */
66
+ function fromEnv(options, names) {
67
+ for (const name of names) {
68
+ const value = options[name];
69
+ if (typeof value === 'string' && value) return value;
120
70
  }
71
+ return '';
72
+ }
121
73
 
122
- async function getCollection(handle, opts = {}) {
123
- const page = await getProducts({ ...opts, collection: handle });
124
- return page.collection ? { ...page.collection, products: page.products, pageInfo: page.pageInfo } : null;
74
+ /**
75
+ * @param {StorefrontOptions} options
76
+ * @returns {ServerConfig} The config options of `options`, resolved and validated as the browser's are, with the
77
+ * domain and token from the environment names when they aren't set.
78
+ */
79
+ function storefrontConfig(options) {
80
+ /** @type {Record<string, unknown>} */
81
+ const known = {};
82
+ for (const [key, value] of Object.entries(options)) {
83
+ if (key in DEFAULTS) known[key] = value;
125
84
  }
85
+ const domain = typeof options.domain === 'string' && options.domain ? options.domain : '';
86
+ const token = typeof options.token === 'string' && options.token ? options.token : '';
87
+ /** @type {ServerConfig} */
88
+ const conf = resolveConfig({
89
+ // Only config keys: `resolveConfig` checks each value.
90
+ .../** @type {TetherOptions} */ (known),
91
+ domain: domain || fromEnv(options, STORE_ENV_NAMES.domain),
92
+ token: token || fromEnv(options, STORE_ENV_NAMES.token),
93
+ });
94
+ if (options.fetch) conf.fetch = options.fetch;
95
+ if (options.pageSize !== undefined) conf.pageSize = options.pageSize;
96
+ if (options.siteUrl !== undefined) conf.siteUrl = options.siteUrl;
97
+ return conf;
98
+ }
126
99
 
127
- async function allCollections() {
128
- const out = [];
129
- let after = null;
130
- for (let i = 0; i < 100; i++) {
131
- const data = await q(`query TetherServerCollections($first: Int!, $after: String) {
132
- collections(first: $first, after: $after) { nodes { ${collectionFields()} } pageInfo { hasNextPage endCursor } }
133
- }`, { first: 100, after });
134
- out.push(...data.collections.nodes);
135
- if (!data.collections.pageInfo.hasNextPage) break;
136
- after = data.collections.pageInfo.endCursor;
137
- }
138
- return out;
139
- }
100
+ /**
101
+ * @param {number | undefined} pageSize
102
+ * @returns {number} `pageSize` when it is a positive integer, else the default.
103
+ */
104
+ const validPageSize = (pageSize) =>
105
+ typeof pageSize === 'number' && Number.isInteger(pageSize) && pageSize > 0 ? pageSize : DEFAULT_PAGE_SIZE;
140
106
 
141
- /** By product id (gid or numeric) or handle, so it can run next to getProduct(handle). */
142
- async function getRecommendations(product, intent = 'RELATED') {
143
- const key = productKey(typeof product === 'object' ? product?.id : product);
144
- const byId = key?.startsWith('gid://');
145
- const data = await q(`query TetherServerRecs(${byId ? '$id: ID!' : '$handle: String!'}, $intent: ProductRecommendationIntent) {
146
- productRecommendations(${byId ? 'productId: $id' : 'productHandle: $handle'}, intent: $intent) { ...TetherProduct }
107
+ /**
108
+ * A product for a list (home, collections, recommendations): without descriptions and variant images, and only its
109
+ * first `images` images. Prices, stock and plans per variant stay.
110
+ *
111
+ * @param {Product} product
112
+ * @param {number} images
113
+ * @returns {Product}
114
+ */
115
+ function cardOf(product, images) {
116
+ return {
117
+ ...product,
118
+ description: '',
119
+ descriptionHtml: '',
120
+ images: { nodes: product.images.nodes.slice(0, Math.max(1, images)) },
121
+ variants: { nodes: product.variants.nodes.map((variant) => ({ ...variant, image: null })) },
122
+ };
147
123
  }
148
- ${fragment()}`, byId ? { id: key, intent } : { handle: key, intent });
149
- return data.productRecommendations || [];
124
+
125
+ /**
126
+ * A storefront: the store's products and collections for the server, and the tags that hand them to the page.
127
+ *
128
+ * @param {StorefrontOptions} [options]
129
+ * @throws {TetherError} `NOT_CONFIGURED` without a domain and a token.
130
+ */
131
+ export function createStorefront(options = {}) {
132
+ const conf = storefrontConfig(options);
133
+ if (!conf.domain || !conf.token) {
134
+ throw new TetherError(
135
+ 'createStorefront needs domain and token: pass them, or import.meta.env with PUBLIC_SHOPIFY_DOMAIN and PUBLIC_SHOPIFY_STOREFRONT_TOKEN',
136
+ { code: 'NOT_CONFIGURED' },
137
+ );
150
138
  }
139
+ const catalog = createCatalog(conf, validPageSize(conf.pageSize));
140
+ const site = { productPath: conf.productPath, siteUrl: conf.siteUrl };
151
141
 
152
142
  /**
153
- * A <script> tag that hands products to the browser script: Tether renders them at once,
154
- * without its own request, then refreshes prices and stock in the background.
155
- * Put it anywhere in the page (Astro: <Fragment set:html={shop.hydrate(products)} />).
143
+ * A `<script type="application/json" data-tether-hydrate>` tag that hands products to the browser script: it renders
144
+ * them at once, without a request of its own, then refreshes prices and stock once in the background. Astro:
145
+ * `<Fragment set:html={shop.hydrate(products)} />`.
146
+ *
147
+ * @param {Product | null | undefined | (Product | null | undefined)[]} products
148
+ * @param {HydrateOptions} [options] `card: true` for lists: about 40% smaller (see `cardOf`). The full product
149
+ * comes with the background refresh.
156
150
  */
157
- /**
158
- * { card: true } for lists (home, collections, recommendations): about 40% smaller, without
159
- * descriptions, variant images and images after the first two (a hover image, tether-bind="image:2";
160
- * more with { card: true, images: 3 }). Price, stock and plans per variant stay. The full
161
- * product comes with the background refresh.
162
- */
163
- function hydrate(products, { card = false, images = 2 } = {}) {
164
- let list = (Array.isArray(products) ? products : [products]).filter(Boolean);
165
- if (card) list = list.map((p) => slim(p, images));
166
- const payload = { v: 1, at: new Date().toISOString(), country: conf.country || null, language: conf.language || null, products: list };
167
- return `<script type="application/json" data-tether-hydrate>${safeJson(payload)}</script>`;
151
+ function hydrate(products, { card = false, images = DEFAULT_CARD_IMAGES } = {}) {
152
+ const present = (Array.isArray(products) ? products : [products]).filter((product) => product != null);
153
+ const payload = {
154
+ v: HYDRATE_VERSION,
155
+ at: new Date().toISOString(),
156
+ country: conf.country || null,
157
+ language: conf.language || null,
158
+ products: card ? present.map((product) => cardOf(product, images)) : present,
159
+ };
160
+ return `<script type="application/json" data-tether-hydrate>${scriptJson(payload)}</script>`;
168
161
  }
169
162
 
170
163
  /**
171
- * schema.org data for search engines (Google's product rich results): a Product, or a ProductGroup
172
- * with one Product per variant (price, currency, stock, sku, image, link to ?variant=).
173
- * jsonLd(product, { url: 'https://shop.example/products/x' }) -> the <script> tag; jsonLdData() -> the object.
164
+ * @param {Product | null | undefined} product
165
+ * @param {JsonLdOptions} [jsonLdOptions]
166
+ * @returns {string} The `<script type="application/ld+json">` tag, `''` without a product.
174
167
  */
175
- function jsonLdData(p, { url, brand } = {}) {
176
- if (!p) return null;
177
- const pageUrl = url || (conf.siteUrl ? new URL(productUrl(p.handle, { productPath: conf.productPath, id: p.id }), conf.siteUrl).toString() : undefined);
178
- const vid = (v) => String(v.id).split('/').pop();
179
- const withVariant = (v) => (pageUrl ? `${pageUrl}${pageUrl.includes('?') ? '&' : '?'}variant=${vid(v)}` : undefined);
180
- const text = (p.description || '').trim() || undefined;
181
- const images = (p.images?.nodes || []).map((i) => i.url).filter(Boolean);
182
- const brandName = brand || p.vendor;
183
- // Only buyable on a subscription: the plan price is the price (the first plan the variant has).
184
- const priceOf = (v) => {
185
- if (!p.requiresSellingPlan) return v.price;
186
- const plan = plans.allPlans(p).find((pl) => plans.allocationFor(v, pl.id));
187
- return plan ? plans.planPrice(v, plan.id) : v.price;
188
- };
189
- const offer = (v) => ({
190
- '@type': 'Offer',
191
- price: priceOf(v)?.amount,
192
- priceCurrency: priceOf(v)?.currencyCode,
193
- availability: `https://schema.org/${v.availableForSale ? (v.currentlyNotInStock ? 'BackOrder' : 'InStock') : 'OutOfStock'}`,
194
- url: withVariant(v),
195
- });
196
- const vs = p.variants?.nodes || [];
197
- const clean = (o) => JSON.parse(JSON.stringify(o)); // drops undefined
198
- if (vs.length <= 1) {
199
- const v = vs[0];
200
- return clean({
201
- '@context': 'https://schema.org', '@type': 'Product', name: p.title, description: text, url: pageUrl,
202
- image: images, sku: v?.sku || undefined, brand: brandName ? { '@type': 'Brand', name: brandName } : undefined,
203
- offers: v ? offer(v) : undefined,
204
- });
205
- }
206
- const varies = { size: 'https://schema.org/size', color: 'https://schema.org/color', colour: 'https://schema.org/color', material: 'https://schema.org/material' };
207
- return clean({
208
- '@context': 'https://schema.org', '@type': 'ProductGroup', name: p.title, description: text, url: pageUrl,
209
- productGroupID: String(p.id).split('/').pop(), image: images,
210
- brand: brandName ? { '@type': 'Brand', name: brandName } : undefined,
211
- variesBy: [...new Set((p.options || []).map((o) => varies[o.name.toLowerCase()]).filter(Boolean))],
212
- hasVariant: vs.map((v) => ({
213
- '@type': 'Product',
214
- name: `${p.title} - ${v.title}`,
215
- sku: v.sku || undefined,
216
- image: v.image?.url || images[0],
217
- ...Object.fromEntries((v.selectedOptions || []).filter((o) => varies[o.name.toLowerCase()]).map((o) => [varies[o.name.toLowerCase()].split('/').pop(), o.value])),
218
- offers: offer(v),
219
- })),
220
- });
221
- }
222
- const jsonLd = (p, opts) => (p ? `<script type="application/ld+json">${safeJson(jsonLdData(p, opts))}</script>` : '');
223
-
224
- function slim(p, keep = 2) {
225
- return {
226
- ...p,
227
- description: '', descriptionHtml: '',
228
- images: { nodes: (p.images?.nodes || []).slice(0, Math.max(1, keep)) },
229
- variants: { nodes: (p.variants?.nodes || []).map((v) => ({ ...v, image: null })) },
230
- };
168
+ function jsonLd(product, jsonLdOptions = {}) {
169
+ if (!product) return '';
170
+ return `<script type="application/ld+json">${scriptJson(jsonLdData(product, jsonLdOptions, site))}</script>`;
231
171
  }
232
172
 
233
173
  return {
234
174
  config: conf,
235
- query: q,
236
- getProduct,
237
- getProducts,
238
- allProducts,
239
- getCollection,
240
- allCollections,
241
- getRecommendations,
175
+ /**
176
+ * @template T
177
+ * @param {string} query
178
+ * @param {Record<string, unknown>} [variables]
179
+ * @param {{ context?: boolean }} [requestOptions]
180
+ * @returns {Promise<T>}
181
+ */
182
+ query: (query, variables, requestOptions) => request(conf, query, variables, requestOptions),
183
+ ...catalog,
242
184
  hydrate,
185
+ /**
186
+ * @param {Money | null | undefined} money
187
+ * @param {string | undefined} [locale]
188
+ */
243
189
  formatMoney: (money, locale = conf.locale) => formatMoney(money, locale),
244
- formatPercent: (n, locale = conf.locale) => formatPercent(n, locale),
190
+ /**
191
+ * @param {number} percentage
192
+ * @param {string | undefined} [locale]
193
+ */
194
+ formatPercent: (percentage, locale = conf.locale) => formatPercent(percentage, locale),
245
195
  imageUrl,
246
196
  jsonLd,
247
- jsonLdData,
248
- initialState: (product, opts = {}) => initialState(product, { defaultPurchase: conf.defaultPurchase, lowStock: conf.lowStock, ...opts }),
249
- productUrl: (handle, opts = {}) => productUrl(handle, { productPath: conf.productPath, ...opts }),
197
+ /**
198
+ * @param {Product | null | undefined} product
199
+ * @param {JsonLdOptions} [jsonLdOptions]
200
+ */
201
+ jsonLdData: (product, jsonLdOptions = {}) => jsonLdData(product, jsonLdOptions, site),
202
+ /**
203
+ * @param {Product | null | undefined} product
204
+ * @param {InitialOptions} [initialOptions]
205
+ */
206
+ initialState: (product, initialOptions = {}) =>
207
+ initialState(product, { defaultPurchase: conf.defaultPurchase, lowStock: conf.lowStock, ...initialOptions }),
208
+ /**
209
+ * @param {string} handle
210
+ * @param {{ productPath?: string, id?: string }} [urlOptions]
211
+ */
212
+ productUrl: (handle, urlOptions = {}) => productUrl(handle, { productPath: conf.productPath, ...urlOptions }),
250
213
  plans,
251
214
  };
252
215
  }