@occluze/tether 0.6.8 → 0.7.1

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 (215) hide show
  1. package/README.md +28 -28
  2. package/dist/esm/astro.js +2 -2
  3. package/dist/esm/chunks/account-CXMA5RPZ.js +1 -0
  4. package/dist/esm/chunks/account-pages-IZO7FEOZ.js +1 -0
  5. package/dist/esm/chunks/analytics-PG5FPRDN.js +1 -0
  6. package/dist/esm/chunks/chunk-4HD4J5JC.js +1 -0
  7. package/dist/esm/chunks/chunk-6TFMLT4A.js +4 -0
  8. package/dist/esm/chunks/chunk-BBVUVPMO.js +1 -0
  9. package/dist/esm/chunks/chunk-E5YO6274.js +1 -0
  10. package/dist/esm/chunks/chunk-EW5SYO6L.js +1 -0
  11. package/dist/esm/chunks/chunk-HJY2JSNB.js +1 -0
  12. package/dist/esm/chunks/chunk-MQYB6BH4.js +1 -0
  13. package/dist/esm/chunks/chunk-N3V45JPS.js +1 -0
  14. package/dist/esm/chunks/chunk-RPMBCAGS.js +1 -0
  15. package/dist/esm/chunks/chunk-VFNHIGCS.js +1 -0
  16. package/dist/esm/chunks/chunk-WT7RGGJU.js +1 -0
  17. package/dist/esm/chunks/chunk-YN6LEBEN.js +1 -0
  18. package/dist/esm/chunks/chunk-Z6HS3H7K.js +1 -0
  19. package/dist/esm/chunks/diagnose-XCEBQKBO.js +14 -0
  20. package/dist/esm/chunks/extras-4HU3VSBQ.js +1 -0
  21. package/dist/esm/chunks/markets-ui-M4IQ62CT.js +1 -0
  22. package/dist/esm/chunks/search-A5DS2IOS.js +1 -0
  23. package/dist/esm/index.js +1 -1
  24. package/dist/esm/server.js +2 -2
  25. package/dist/presets/barba.esm.js +19 -23
  26. package/dist/tether.account-pages.js +332 -367
  27. package/dist/tether.account-pages.min.js +1 -1
  28. package/dist/tether.account.js +253 -492
  29. package/dist/tether.account.min.js +1 -1
  30. package/dist/tether.analytics.js +82 -89
  31. package/dist/tether.analytics.min.js +1 -1
  32. package/dist/tether.diagnose.js +52 -34
  33. package/dist/tether.diagnose.min.js +2 -2
  34. package/dist/tether.extras.js +42 -37
  35. package/dist/tether.extras.min.js +1 -1
  36. package/dist/tether.js +1361 -935
  37. package/dist/tether.markets.js +89 -91
  38. package/dist/tether.markets.min.js +1 -1
  39. package/dist/tether.min.js +0 -0
  40. package/dist/tether.min.js.map +4 -4
  41. package/dist/tether.search.js +60 -69
  42. package/dist/tether.search.min.js +1 -1
  43. package/dist/types/server/src/shared/account-orders.d.ts +44 -0
  44. package/dist/types/server/src/shared/account-routes.d.ts +153 -0
  45. package/dist/types/server/src/shared/api-versions.d.ts +13 -0
  46. package/dist/types/server/src/shared/cart-id.d.ts +2 -0
  47. package/dist/types/server/src/shared/error-message.d.ts +2 -0
  48. package/dist/types/server/src/shared/json.d.ts +25 -0
  49. package/dist/types/server/src/shared/market-codes.d.ts +16 -0
  50. package/dist/types/src/account/addresses-view.d.ts +5 -0
  51. package/dist/types/src/account/answers.d.ts +40 -0
  52. package/dist/types/src/account/markup.d.ts +19 -0
  53. package/dist/types/src/account/operations.d.ts +41 -0
  54. package/dist/types/src/account/order-lines.d.ts +13 -0
  55. package/dist/types/src/account/order-list.d.ts +12 -0
  56. package/dist/types/src/account/order-page.d.ts +6 -0
  57. package/dist/types/src/account/page-answers.d.ts +182 -0
  58. package/dist/types/src/account/page-events.d.ts +2 -0
  59. package/dist/types/src/account/server-client.d.ts +60 -0
  60. package/dist/types/src/account/subscriptions-view.d.ts +2 -0
  61. package/dist/types/src/account-api.d.ts +18 -0
  62. package/dist/types/src/account-marks.d.ts +15 -0
  63. package/dist/types/src/account-pages.d.ts +8 -0
  64. package/dist/types/src/account.d.ts +64 -0
  65. package/dist/types/src/amounts.d.ts +12 -0
  66. package/dist/types/src/analytics-shopify.d.ts +41 -0
  67. package/dist/types/src/analytics.d.ts +2 -0
  68. package/dist/types/src/api-version.d.ts +28 -0
  69. package/dist/types/src/astro.d.ts +42 -0
  70. package/dist/types/src/cart/cart-shape.d.ts +43 -0
  71. package/dist/types/src/cart/checkout.d.ts +16 -0
  72. package/dist/types/src/cart/drawer.d.ts +18 -0
  73. package/dist/types/src/cart/mutations.d.ts +85 -0
  74. package/dist/types/src/cart/render.d.ts +17 -0
  75. package/dist/types/src/cart/store.d.ts +185 -0
  76. package/dist/types/src/cart.d.ts +106 -0
  77. package/dist/types/src/client.d.ts +134 -0
  78. package/dist/types/src/collection.d.ts +25 -0
  79. package/dist/types/src/config-sections.d.ts +40 -0
  80. package/dist/types/src/config.d.ts +190 -0
  81. package/dist/types/src/controls/cart-controls.d.ts +2 -0
  82. package/dist/types/src/controls/discount.d.ts +2 -0
  83. package/dist/types/src/controls/extras.d.ts +5 -0
  84. package/dist/types/src/controls/navigation.d.ts +12 -0
  85. package/dist/types/src/diagnose-markup.d.ts +19 -0
  86. package/dist/types/src/diagnose.d.ts +60 -0
  87. package/dist/types/src/dom.d.ts +75 -0
  88. package/dist/types/src/extras.d.ts +21 -0
  89. package/dist/types/src/fragments.d.ts +158 -0
  90. package/dist/types/src/globals.d.ts +15 -0
  91. package/dist/types/src/images.d.ts +20 -0
  92. package/dist/types/src/index.d.ts +167 -0
  93. package/dist/types/src/initial.d.ts +56 -0
  94. package/dist/types/src/lazy.d.ts +28 -0
  95. package/dist/types/src/loader.d.ts +84 -0
  96. package/dist/types/src/market-codes.d.ts +6 -0
  97. package/dist/types/src/markets-ui.d.ts +32 -0
  98. package/dist/types/src/markets.d.ts +72 -0
  99. package/dist/types/src/markup.d.ts +131 -0
  100. package/dist/types/src/mounting.d.ts +46 -0
  101. package/dist/types/src/page-market.d.ts +27 -0
  102. package/dist/types/src/presets/barba.d.ts +56 -0
  103. package/dist/types/src/product-events.d.ts +2 -0
  104. package/dist/types/src/product-render.d.ts +17 -0
  105. package/dist/types/src/product-shape.d.ts +90 -0
  106. package/dist/types/src/product-state.d.ts +110 -0
  107. package/dist/types/src/product.d.ts +66 -0
  108. package/dist/types/src/repeaters.d.ts +16 -0
  109. package/dist/types/src/search.d.ts +33 -0
  110. package/dist/types/src/selling-plans.d.ts +64 -0
  111. package/dist/types/src/server.d.ts +88 -0
  112. package/dist/types/src/shopify-values.d.ts +46 -0
  113. package/dist/types/src/status.d.ts +33 -0
  114. package/dist/types/src/storage.d.ts +12 -0
  115. package/dist/types/src/store-html.d.ts +39 -0
  116. package/dist/types/src/storefront/catalog.d.ts +38 -0
  117. package/dist/types/src/storefront/json-ld.d.ts +20 -0
  118. package/dist/types/src/variant-render.d.ts +14 -0
  119. package/package.json +24 -24
  120. package/dist/esm/chunks/account-C3NP6HFB.js +0 -1
  121. package/dist/esm/chunks/account-pages-WLK374FO.js +0 -1
  122. package/dist/esm/chunks/analytics-G3SAWMNL.js +0 -1
  123. package/dist/esm/chunks/chunk-27PJJZGC.js +0 -1
  124. package/dist/esm/chunks/chunk-2FSXS55U.js +0 -1
  125. package/dist/esm/chunks/chunk-FCSWQDVV.js +0 -1
  126. package/dist/esm/chunks/chunk-FOMSMIAM.js +0 -1
  127. package/dist/esm/chunks/chunk-GZJTCLLW.js +0 -1
  128. package/dist/esm/chunks/chunk-HEQILZVR.js +0 -1
  129. package/dist/esm/chunks/chunk-JOOEXYF3.js +0 -4
  130. package/dist/esm/chunks/chunk-MPTQYLSM.js +0 -1
  131. package/dist/esm/chunks/chunk-NEHZ37TU.js +0 -3
  132. package/dist/esm/chunks/chunk-V74KGEJF.js +0 -1
  133. package/dist/esm/chunks/chunk-VT4CDUJJ.js +0 -1
  134. package/dist/esm/chunks/chunk-YJLWX2HK.js +0 -1
  135. package/dist/esm/chunks/diagnose-6VVGZNTY.js +0 -14
  136. package/dist/esm/chunks/extras-GJVRDFM3.js +0 -1
  137. package/dist/esm/chunks/markets-ui-UNB4PTA7.js +0 -1
  138. package/dist/esm/chunks/search-OU75RNQL.js +0 -1
  139. package/src/account/addresses-view.js +0 -92
  140. package/src/account/browser-session.js +0 -359
  141. package/src/account/markup.js +0 -48
  142. package/src/account/operations.js +0 -180
  143. package/src/account/order-lines.js +0 -96
  144. package/src/account/order-list.js +0 -91
  145. package/src/account/order-page.js +0 -192
  146. package/src/account/page-events.js +0 -288
  147. package/src/account/server-client.js +0 -172
  148. package/src/account/shared.js +0 -111
  149. package/src/account/shopify-auth.js +0 -150
  150. package/src/account/subscriptions-view.js +0 -158
  151. package/src/account-api.js +0 -118
  152. package/src/account-links.js +0 -35
  153. package/src/account-pages.js +0 -89
  154. package/src/account.js +0 -342
  155. package/src/amounts.js +0 -36
  156. package/src/analytics-shopify.js +0 -241
  157. package/src/analytics.js +0 -352
  158. package/src/api-version.js +0 -85
  159. package/src/astro.js +0 -266
  160. package/src/cart/checkout.js +0 -97
  161. package/src/cart/drawer.js +0 -138
  162. package/src/cart/mutations.js +0 -234
  163. package/src/cart/render.js +0 -294
  164. package/src/cart/store.js +0 -326
  165. package/src/cart.js +0 -391
  166. package/src/client.js +0 -267
  167. package/src/collection.js +0 -183
  168. package/src/config.js +0 -349
  169. package/src/controls/cart-controls.js +0 -155
  170. package/src/controls/discount.js +0 -96
  171. package/src/controls/extras.js +0 -63
  172. package/src/controls/navigation.js +0 -83
  173. package/src/diagnose-markup.js +0 -333
  174. package/src/diagnose.js +0 -295
  175. package/src/dom.js +0 -287
  176. package/src/extras.js +0 -145
  177. package/src/fragments.js +0 -237
  178. package/src/images.js +0 -104
  179. package/src/index.js +0 -353
  180. package/src/initial.js +0 -87
  181. package/src/lazy-iife.js +0 -116
  182. package/src/lazy.js +0 -39
  183. package/src/loader.js +0 -337
  184. package/src/market-codes.js +0 -35
  185. package/src/markets-ui.js +0 -204
  186. package/src/markets.js +0 -334
  187. package/src/markup.js +0 -64
  188. package/src/mounting.js +0 -277
  189. package/src/page-market.js +0 -116
  190. package/src/parts/account-pages.js +0 -6
  191. package/src/parts/account.js +0 -6
  192. package/src/parts/analytics.js +0 -6
  193. package/src/parts/diagnose.js +0 -6
  194. package/src/parts/extras.js +0 -6
  195. package/src/parts/markets.js +0 -6
  196. package/src/parts/search.js +0 -6
  197. package/src/presets/barba.js +0 -190
  198. package/src/product-events.js +0 -130
  199. package/src/product-render.js +0 -257
  200. package/src/product-state.js +0 -318
  201. package/src/product.js +0 -344
  202. package/src/repeaters.js +0 -173
  203. package/src/search.js +0 -323
  204. package/src/selling-plans.js +0 -154
  205. package/src/server.js +0 -215
  206. package/src/shopify-values.js +0 -89
  207. package/src/status.js +0 -126
  208. package/src/store-html.js +0 -0
  209. package/src/storefront/catalog.js +0 -199
  210. package/src/storefront/json-ld.js +0 -167
  211. package/src/variant-render.js +0 -165
  212. package/types/astro.d.ts +0 -28
  213. package/types/barba.d.ts +0 -16
  214. package/types/index.d.ts +0 -577
  215. package/types/server.d.ts +0 -182
@@ -1,89 +0,0 @@
1
- /**
2
- * Conversions of Shopify values that the browser script and the server helpers (`src/server.js`, `src/astro.js`)
3
- * share, so a server-rendered page and the script that takes it over agree. Pure: no config, no DOM.
4
- */
5
-
6
- /** @typedef {{ amount: string, currencyCode: string }} Money A Storefront API `MoneyV2`. */
7
-
8
- /** The `.env` names a store's domain and public Storefront token are read from, first match wins. */
9
- export const STORE_ENV_NAMES = {
10
- domain: ['PUBLIC_SHOPIFY_DOMAIN', 'PUBLIC_SHOPIFY_STORE_DOMAIN', 'SHOPIFY_DOMAIN'],
11
- token: ['PUBLIC_SHOPIFY_STOREFRONT_TOKEN', 'PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN', 'SHOPIFY_STOREFRONT_TOKEN'],
12
- };
13
-
14
- /**
15
- * True for an absolute https URL. Links and redirects that come from Shopify (checkout, status pages,
16
- * tracking) are only used when this holds.
17
- *
18
- * @param {unknown} url
19
- */
20
- export function isHttpsUrl(url) {
21
- if (typeof url !== 'string') return false;
22
- try {
23
- return new URL(url).protocol === 'https:';
24
- } catch {
25
- return false;
26
- }
27
- }
28
-
29
- /**
30
- * True for a token Shopify issues as a secret (`shpat_` Admin, `shpss_` shared secret, `shpca_` client credentials,
31
- * `shppa_` private app, `shpua_` user). The public Storefront token has no such prefix. A secret token must never
32
- * reach a page.
33
- *
34
- * @param {string} token
35
- */
36
- export function isSecretShopifyToken(token) {
37
- return /^shp[a-z]{2}_/.test(token.trim());
38
- }
39
-
40
- /**
41
- * Money as text in `locale`, e.g. `{ amount: '30.0', currencyCode: 'EUR' }` in `nl-NL` → `€ 30,00`.
42
- *
43
- * @param {Money | null | undefined} money
44
- * @param {string | undefined} locale An `Intl` locale; the runtime's own when absent.
45
- * @returns {string} `''` without money or with an amount that isn't a number.
46
- */
47
- export function formatMoney(money, locale) {
48
- if (!money) return '';
49
- const amount = parseFloat(money.amount);
50
- if (Number.isNaN(amount)) return '';
51
- try {
52
- return new Intl.NumberFormat(locale || undefined, { style: 'currency', currency: money.currencyCode }).format(
53
- amount,
54
- );
55
- } catch {
56
- // A currency code Intl doesn't know.
57
- return `${money.currencyCode} ${amount.toFixed(2)}`;
58
- }
59
- }
60
-
61
- /**
62
- * A product page's path. `productPath` is a folder (`/product` → `/product/<handle>`) or a template with `{handle}`
63
- * and `{id}` (the numeric product id), e.g. `/product?handle={handle}`.
64
- *
65
- * @param {string} productPath
66
- * @param {string | null | undefined} handle
67
- * @param {string | null | undefined} [id] A product gid or numeric id.
68
- */
69
- export function productUrl(productPath, handle, id) {
70
- if (!/\{(handle|id)\}/.test(productPath)) return `${productPath.replace(/\/$/, '')}/${handle ?? String(handle)}`;
71
- const numericId = id ? (id.split('/').pop() ?? '') : '';
72
- return productPath
73
- .replace(/\{handle\}/g, encodeURIComponent(handle ?? ''))
74
- .replace(/\{id\}/g, encodeURIComponent(numericId));
75
- }
76
-
77
- /**
78
- * The key Tether loads a product by: a handle, or a product gid.
79
- *
80
- * @param {string | number | null | undefined} raw A handle, a numeric id, a gid or a product URL (`…/products/<handle>`).
81
- * @returns {string | null}
82
- */
83
- export function productKey(raw) {
84
- const value = String(raw ?? '').trim();
85
- if (!value) return null;
86
- if (value.startsWith('gid://')) return value;
87
- if (/^\d+$/.test(value)) return `gid://shopify/Product/${value}`;
88
- return /\/products\/([^/?#]+)/.exec(value)?.[1] ?? value;
89
- }
package/src/status.js DELETED
@@ -1,126 +0,0 @@
1
- /**
2
- * Where a product scope gets its key from, and the state of a scope (loading, ready, error, not found), so a failing
3
- * API never leaves a broken page.
4
- */
5
- import { attr, sel, get, scoped, show } from './dom.js';
6
-
7
- /**
8
- * @typedef {object} ProductKeySource
9
- * @property {string | null} key The product to load; `null` when the attribute or the page URL has none.
10
- * @property {boolean} fromUrl The key comes from the page URL (`url:…`).
11
- * @property {string} raw The attribute's value as written.
12
- */
13
-
14
- /** @typedef {'loading' | 'ready' | 'error' | 'not-found'} ScopeStatus */
15
-
16
- const URL_SOURCE = 'url:';
17
-
18
- /**
19
- * A path segment as text. A malformed escape (`%E0%A4%A`) is kept as written, which is how the browser shows it.
20
- *
21
- * @param {string} segment
22
- */
23
- function decodePathSegment(segment) {
24
- try {
25
- return decodeURIComponent(segment);
26
- } catch (error) {
27
- if (error instanceof URIError) return segment;
28
- throw error;
29
- }
30
- }
31
-
32
- /**
33
- * Reads a `tether-product` value:
34
- * - `coffee-box`: a handle, numeric id, gid or product URL;
35
- * - `url:handle`: `?handle=` from the page URL (any parameter: `url:p` reads `?p=`);
36
- * - `url:path`: the last segment of the page path (`/shop/coffee-box` → `coffee-box`).
37
- *
38
- * @param {Element} el
39
- * @param {Pick<Location, 'pathname' | 'search'>} [location]
40
- * @param {string} [name] The attribute, without prefix.
41
- * @returns {ProductKeySource}
42
- */
43
- export function resolveProductKey(el, location = window.location, name = 'product') {
44
- const raw = (get(el, name) ?? '').trim();
45
- if (!raw.toLowerCase().startsWith(URL_SOURCE)) return { key: raw || null, fromUrl: false, raw };
46
- const source = raw.slice(URL_SOURCE.length).trim();
47
- let key;
48
- if (source.toLowerCase() === 'path') {
49
- const segments = location.pathname.split('/').filter(Boolean);
50
- const segment = segments[segments.length - 1];
51
- key = segment === undefined ? null : decodePathSegment(segment);
52
- } else {
53
- key = new URLSearchParams(location.search).get(source || 'handle');
54
- }
55
- return { key: key?.trim() || null, fromUrl: true, raw };
56
- }
57
-
58
- /** @type {readonly ScopeStatus[]} The values of `tether-show` that follow the scope's state. */
59
- export const STATUS_KEYS = ['loading', 'ready', 'error', 'not-found'];
60
-
61
- /**
62
- * @param {unknown} value
63
- * @returns {value is ScopeStatus}
64
- */
65
- const isScopeStatus = (value) => STATUS_KEYS.some((status) => status === value);
66
-
67
- /**
68
- * @param {unknown} error
69
- * @returns {unknown} The error's `code` (a `TetherError` has one).
70
- */
71
- const codeOf = (error) => (typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined);
72
-
73
- /**
74
- * @param {unknown} error
75
- * @returns {boolean} True for a `TetherError` with code `NOT_FOUND`.
76
- */
77
- export const isNotFound = (error) => codeOf(error) === 'NOT_FOUND';
78
-
79
- /** @returns {string} The selector of every scope that owns its state elements; the nearest one wins. */
80
- export const boundary = () =>
81
- ['product', 'collection', 'products', 'recommendations'].map((name) => sel(name)).join(',');
82
-
83
- /**
84
- * @param {Element} el
85
- * @param {Element} root
86
- * @returns {boolean} True when `el` belongs to the scope `root`, not to a scope nested in it.
87
- */
88
- export const ownedBy = (el, root) => el === root || el.parentElement?.closest(boundary()) === root;
89
-
90
- /**
91
- * @param {unknown} error
92
- * @returns {string} The error's code for `tether-error`, `error` without one.
93
- */
94
- function errorCode(error) {
95
- const code = codeOf(error);
96
- return typeof code === 'string' && code ? code : 'error';
97
- }
98
-
99
- /**
100
- * Sets `tether-state` (loading | ready | error) and `tether-error` (not-found or an error code) on `root`, toggles
101
- * `tether-show="loading|ready|error|not-found"` inside it, and disables its add-to-cart buttons when there's no
102
- * product.
103
- *
104
- * @param {Element} root
105
- * @param {ScopeStatus} status
106
- * @param {unknown} [error]
107
- */
108
- export function setScopeStatus(root, status, error) {
109
- const notFound = status === 'not-found';
110
- const failed = status === 'error' || notFound;
111
- root.setAttribute(attr('state'), notFound ? 'error' : status);
112
- if (failed) root.setAttribute(attr('error'), notFound ? 'not-found' : errorCode(error));
113
- else root.removeAttribute(attr('error'));
114
-
115
- scoped(root, sel('show'), boundary()).forEach((el) => {
116
- const shows = get(el, 'show');
117
- if (isScopeStatus(shows)) show(el, shows === status);
118
- });
119
-
120
- if (!failed) return;
121
- scoped(root, sel('add-to-cart'), boundary()).forEach((button) => {
122
- button.classList.add('is-disabled');
123
- button.setAttribute('aria-disabled', 'true');
124
- if ('disabled' in button) button.disabled = true;
125
- });
126
- }
package/src/store-html.js DELETED
Binary file
@@ -1,199 +0,0 @@
1
- /**
2
- * The catalog reads of `createStorefront()`: products, collections and recommendations, with the browser script's own
3
- * product fragment, so what the server renders and hands over has the shape the script expects. Runs in Node and
4
- * Workers: no DOM, no shared config.
5
- */
6
- import { request } from '../client.js';
7
- import { productFragment, collectionFields } from '../fragments.js';
8
- import { productKey } from '../shopify-values.js';
9
- /** @import { StorefrontConfig } from '../client.js' */
10
- /** @import { ProductFragmentConfig, Product } from '../fragments.js' */
11
- /** @import { Collection, PageInfo, ProductPage } from '../loader.js' */
12
-
13
- /**
14
- * @typedef {StorefrontConfig & ProductFragmentConfig} CatalogConfig What the catalog needs of a storefront's config.
15
- */
16
-
17
- /**
18
- * @typedef {object} CatalogQuery A page of a collection's products, or of a search.
19
- * @property {string} [collection] A collection handle; without one, a search.
20
- * @property {string | null} [query] Storefront search syntax; `null` or `''` is every product.
21
- * @property {number} [first] Default the storefront's `pageSize`.
22
- * @property {string | null} [after] The cursor of the page before.
23
- * @property {string} [sortKey] `ProductCollectionSortKeys` for a collection, `ProductSortKeys` for a search.
24
- * @property {boolean} [reverse]
25
- * @property {unknown[]} [filters] `ProductFilter` values, for a collection.
26
- */
27
-
28
- /** @typedef {Collection & { products: Product[], pageInfo: PageInfo }} CollectionWithProducts */
29
- /** @typedef {'RELATED' | 'COMPLEMENTARY'} RecommendationIntent */
30
-
31
- /** Stops a paging loop on a store that keeps answering `hasNextPage` (500 pages of 25 is 12,500 products). */
32
- const MAX_PRODUCT_PAGES = 500;
33
- const COLLECTIONS_PER_PAGE = 100;
34
- const MAX_COLLECTION_PAGES = 100;
35
-
36
- const PRODUCT_BY_ID = `query TetherServerProductById($id: ID!) { node(id: $id) { ... on Product { ...TetherProduct } } }`;
37
- const PRODUCT_BY_HANDLE = `query TetherServerProduct($handle: String!) { product(handle: $handle) { ...TetherProduct } }`;
38
-
39
- const collectionQuery =
40
- () => `query TetherServerCollection($handle: String!, $first: Int!, $after: String, $sortKey: ProductCollectionSortKeys, $reverse: Boolean, $filters: [ProductFilter!]) {
41
- collection(handle: $handle) {
42
- ${collectionFields()}
43
- products(first: $first, after: $after, sortKey: $sortKey, reverse: $reverse, filters: $filters) {
44
- nodes { ...TetherProduct }
45
- pageInfo { hasNextPage endCursor }
46
- }
47
- }
48
- }`;
49
-
50
- const SEARCH_QUERY = `query TetherServerProducts($first: Int!, $after: String, $query: String, $sortKey: ProductSortKeys, $reverse: Boolean) {
51
- products(first: $first, after: $after, query: $query, sortKey: $sortKey, reverse: $reverse) {
52
- nodes { ...TetherProduct }
53
- pageInfo { hasNextPage endCursor }
54
- }
55
- }`;
56
-
57
- const collectionsQuery = () => `query TetherServerCollections($first: Int!, $after: String) {
58
- collections(first: $first, after: $after) { nodes { ${collectionFields()} } pageInfo { hasNextPage endCursor } }
59
- }`;
60
-
61
- /** @param {boolean} byId */
62
- const recommendationsQuery = (
63
- byId,
64
- ) => `query TetherServerRecs(${byId ? '$id: ID!' : '$handle: String!'}, $intent: ProductRecommendationIntent) {
65
- productRecommendations(${byId ? 'productId: $id' : 'productHandle: $handle'}, intent: $intent) { ...TetherProduct }
66
- }`;
67
-
68
- /**
69
- * @param {CatalogConfig} conf
70
- * @param {number} pageSize Products per page when a query doesn't say.
71
- */
72
- export function createCatalog(conf, pageSize) {
73
- /**
74
- * @template T The shape of the query's `data`.
75
- * @param {string} operation The query, without the product fragment.
76
- * @param {Record<string, unknown>} variables
77
- * @returns {Promise<T>}
78
- */
79
- const send = (operation, variables) => request(conf, `${operation}\n${productFragment(conf)}`, variables);
80
-
81
- /**
82
- * @param {string | number | null | undefined} raw A handle, numeric id, gid or product URL.
83
- * @returns {Promise<Product | null>} `null` when the store has no such product.
84
- */
85
- async function getProduct(raw) {
86
- const key = productKey(raw);
87
- if (!key) return null;
88
- if (key.startsWith('gid://')) {
89
- /** @type {{ node: Partial<Product> | null }} */
90
- const data = await send(PRODUCT_BY_ID, { id: key });
91
- // A gid of another type answers with a node that isn't a product: no fields of the fragment.
92
- return data.node?.id ? /** @type {Product} */ (data.node) : null;
93
- }
94
- /** @type {{ product: Product | null }} */
95
- const data = await send(PRODUCT_BY_HANDLE, { handle: key });
96
- return data.product;
97
- }
98
-
99
- /**
100
- * @param {string} handle
101
- * @param {Omit<CatalogQuery, 'collection' | 'query'>} options
102
- * @returns {Promise<ProductPage>} No collection and no products for a handle the store doesn't have.
103
- */
104
- async function getCollectionPage(handle, { first = pageSize, after = null, sortKey, reverse = false, filters }) {
105
- /** @type {{ collection: (Collection & { products: { nodes: Product[], pageInfo: PageInfo } }) | null }} */
106
- const data = await send(collectionQuery(), {
107
- handle,
108
- first,
109
- after,
110
- sortKey: sortKey || undefined,
111
- reverse,
112
- filters: filters ?? undefined,
113
- });
114
- if (!data.collection) return { collection: null, products: [], pageInfo: { hasNextPage: false, endCursor: null } };
115
- const { products, ...collection } = data.collection;
116
- return { collection, products: products.nodes, pageInfo: products.pageInfo };
117
- }
118
-
119
- /**
120
- * @param {CatalogQuery} [options]
121
- * @returns {Promise<ProductPage>}
122
- */
123
- async function getProducts(options = {}) {
124
- const { collection, query, first = pageSize, after = null, sortKey, reverse = false } = options;
125
- if (collection) return getCollectionPage(collection, options);
126
- /** @type {{ products: { nodes: Product[], pageInfo: PageInfo } }} */
127
- const data = await send(SEARCH_QUERY, {
128
- first,
129
- after,
130
- query: query || undefined,
131
- sortKey: sortKey || undefined,
132
- reverse,
133
- });
134
- return { collection: null, products: data.products.nodes, pageInfo: data.products.pageInfo };
135
- }
136
-
137
- /**
138
- * Every product of a collection or a search, page by page: for `getStaticPaths()`.
139
- *
140
- * @param {Omit<CatalogQuery, 'first' | 'after'>} [options]
141
- * @returns {Promise<Product[]>}
142
- */
143
- async function allProducts(options = {}) {
144
- /** @type {Product[]} */
145
- const products = [];
146
- /** @type {string | null} */
147
- let after = null;
148
- for (let page = 0; page < MAX_PRODUCT_PAGES; page++) {
149
- const result = await getProducts({ ...options, first: pageSize, after });
150
- products.push(...result.products);
151
- if (!result.pageInfo.hasNextPage) break;
152
- after = result.pageInfo.endCursor;
153
- }
154
- return products;
155
- }
156
-
157
- /**
158
- * @param {string} handle
159
- * @param {Omit<CatalogQuery, 'collection'>} [options]
160
- * @returns {Promise<CollectionWithProducts | null>} `null` for a handle the store doesn't have.
161
- */
162
- async function getCollection(handle, options = {}) {
163
- const page = await getProducts({ ...options, collection: handle });
164
- return page.collection ? { ...page.collection, products: page.products, pageInfo: page.pageInfo } : null;
165
- }
166
-
167
- /** @returns {Promise<Collection[]>} */
168
- async function allCollections() {
169
- /** @type {Collection[]} */
170
- const collections = [];
171
- /** @type {string | null} */
172
- let after = null;
173
- for (let page = 0; page < MAX_COLLECTION_PAGES; page++) {
174
- /** @type {{ collections: { nodes: Collection[], pageInfo: PageInfo } }} */
175
- const data = await request(conf, collectionsQuery(), { first: COLLECTIONS_PER_PAGE, after });
176
- collections.push(...data.collections.nodes);
177
- if (!data.collections.pageInfo.hasNextPage) break;
178
- after = data.collections.pageInfo.endCursor;
179
- }
180
- return collections;
181
- }
182
-
183
- /**
184
- * By product id (gid or numeric) or handle, so it can run next to `getProduct(handle)`.
185
- *
186
- * @param {string | number | { id: string } | null | undefined} product
187
- * @param {RecommendationIntent} [intent]
188
- * @returns {Promise<Product[]>}
189
- */
190
- async function getRecommendations(product, intent = 'RELATED') {
191
- const key = productKey(typeof product === 'object' ? product?.id : product);
192
- const byId = key?.startsWith('gid://') ?? false;
193
- /** @type {{ productRecommendations: Product[] | null }} */
194
- const data = await send(recommendationsQuery(byId), byId ? { id: key, intent } : { handle: key, intent });
195
- return data.productRecommendations ?? [];
196
- }
197
-
198
- return { getProduct, getProducts, allProducts, getCollection, allCollections, getRecommendations };
199
- }
@@ -1,167 +0,0 @@
1
- /**
2
- * schema.org data for search engines (Google's product rich results): a `Product`, or a `ProductGroup` with one
3
- * `Product` per variant, each with its price, currency, stock, sku, image and `?variant=` link. Products that are only
4
- * sold on a subscription are priced at their first plan.
5
- */
6
- import { allPlans, allocationFor, planPrice } from '../selling-plans.js';
7
- import { productUrl } from '../shopify-values.js';
8
- /** @import { Product, ProductVariant } from '../fragments.js' */
9
- /** @import { Money } from '../shopify-values.js' */
10
-
11
- /**
12
- * @typedef {object} JsonLdOptions
13
- * @property {string} [url] The product page's absolute URL; default from the storefront's `siteUrl`.
14
- * @property {string} [brand] Default the product's vendor.
15
- */
16
-
17
- /** @typedef {{ productPath: string, siteUrl: string | undefined }} JsonLdSite Where the product pages are. */
18
-
19
- /** The option names schema.org has a property for: a `ProductGroup` varies by them. */
20
- /** @type {Readonly<Record<string, string>>} */
21
- const VARIES_BY = {
22
- size: 'https://schema.org/size',
23
- color: 'https://schema.org/color',
24
- colour: 'https://schema.org/color',
25
- material: 'https://schema.org/material',
26
- };
27
-
28
- /**
29
- * Text for a `<script>` tag: `<` is escaped so product text can never close the tag, and U+2028 / U+2029 so older
30
- * JavaScript parsers read it.
31
- *
32
- * @param {unknown} value
33
- * @returns {string}
34
- */
35
- export const scriptJson = (value) =>
36
- JSON.stringify(value)
37
- .replace(/</g, '\\u003c')
38
- .replace(/\u2028/g, '\\u2028')
39
- .replace(/\u2029/g, '\\u2029');
40
-
41
- /**
42
- * @param {Record<string, unknown>} value
43
- * @returns {Record<string, unknown>} `value` without its `undefined` fields, nested ones too.
44
- */
45
- function withoutUndefined(value) {
46
- /** @type {unknown} */
47
- const copy = JSON.parse(JSON.stringify(value));
48
- // The JSON of an object is an object.
49
- return /** @type {Record<string, unknown>} */ (copy);
50
- }
51
-
52
- /** @param {string} gid */
53
- const numericId = (gid) => gid.split('/').pop() ?? gid;
54
-
55
- /**
56
- * @param {ProductVariant} variant
57
- * @returns {string} The schema.org availability.
58
- */
59
- function availability(variant) {
60
- if (!variant.availableForSale) return 'https://schema.org/OutOfStock';
61
- return variant.currentlyNotInStock ? 'https://schema.org/BackOrder' : 'https://schema.org/InStock';
62
- }
63
-
64
- /**
65
- * @param {Product} product
66
- * @param {ProductVariant} variant
67
- * @returns {Money} The variant's price, or its first plan's for a product sold only on a subscription.
68
- */
69
- function offeredPrice(product, variant) {
70
- if (!product.requiresSellingPlan) return variant.price;
71
- const plan = allPlans(product).find((candidate) => allocationFor(variant, candidate.id));
72
- return (plan && planPrice(variant, plan.id)) ?? variant.price;
73
- }
74
-
75
- /**
76
- * @param {Product} product
77
- * @param {ProductVariant} variant
78
- * @param {string | undefined} pageUrl
79
- */
80
- function offer(product, variant, pageUrl) {
81
- const price = offeredPrice(product, variant);
82
- const separator = pageUrl?.includes('?') ? '&' : '?';
83
- return {
84
- '@type': 'Offer',
85
- price: price.amount,
86
- priceCurrency: price.currencyCode,
87
- availability: availability(variant),
88
- url: pageUrl ? `${pageUrl}${separator}variant=${numericId(variant.id)}` : undefined,
89
- };
90
- }
91
-
92
- /**
93
- * @param {Product} product
94
- * @param {ProductVariant} variant
95
- * @param {string | undefined} pageUrl
96
- * @param {readonly string[]} images
97
- */
98
- function variantProduct(product, variant, pageUrl, images) {
99
- /** @type {[string, string][]} schema.org property name → the variant's value. */
100
- const properties = variant.selectedOptions.flatMap((option) => {
101
- const property = VARIES_BY[option.name.toLowerCase()];
102
- return property ? [[property.split('/').pop() ?? property, option.value]] : [];
103
- });
104
- return {
105
- '@type': 'Product',
106
- name: `${product.title} - ${variant.title}`,
107
- sku: variant.sku || undefined,
108
- image: variant.image?.url || images[0],
109
- ...Object.fromEntries(properties),
110
- offers: offer(product, variant, pageUrl),
111
- };
112
- }
113
-
114
- /**
115
- * @param {Product} product
116
- * @param {JsonLdOptions} options
117
- * @param {JsonLdSite} site
118
- * @returns {string | undefined}
119
- */
120
- function pageUrlOf(product, { url }, site) {
121
- if (url) return url;
122
- if (!site.siteUrl) return undefined;
123
- return new URL(productUrl(site.productPath, product.handle, product.id), site.siteUrl).toString();
124
- }
125
-
126
- /**
127
- * @param {Product | null | undefined} product
128
- * @param {JsonLdOptions} options
129
- * @param {JsonLdSite} site
130
- * @returns {Record<string, unknown> | null} The schema.org object, `null` without a product.
131
- */
132
- export function jsonLdData(product, options, site) {
133
- if (!product) return null;
134
- const pageUrl = pageUrlOf(product, options, site);
135
- const images = product.images.nodes.map((image) => image.url).filter(Boolean);
136
- const brandName = options.brand || product.vendor;
137
- const brand = brandName ? { '@type': 'Brand', name: brandName } : undefined;
138
- const description = product.description.trim() || undefined;
139
- const variants = product.variants.nodes;
140
- if (variants.length <= 1) {
141
- const [variant] = variants;
142
- return withoutUndefined({
143
- '@context': 'https://schema.org',
144
- '@type': 'Product',
145
- name: product.title,
146
- description,
147
- url: pageUrl,
148
- image: images,
149
- sku: variant?.sku || undefined,
150
- brand,
151
- offers: variant ? offer(product, variant, pageUrl) : undefined,
152
- });
153
- }
154
- const variesBy = product.options.map((option) => VARIES_BY[option.name.toLowerCase()]).filter(Boolean);
155
- return withoutUndefined({
156
- '@context': 'https://schema.org',
157
- '@type': 'ProductGroup',
158
- name: product.title,
159
- description,
160
- url: pageUrl,
161
- productGroupID: numericId(product.id),
162
- image: images,
163
- brand,
164
- variesBy: [...new Set(variesBy)],
165
- hasVariant: variants.map((variant) => variantProduct(product, variant, pageUrl, images)),
166
- });
167
- }