@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
@@ -0,0 +1,89 @@
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 CHANGED
@@ -1,66 +1,126 @@
1
1
  /**
2
- * Where a product scope gets its key from, and the loading / ready / error /
3
- * not-found state of a scope (so a failing API never leaves a broken page).
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
4
  */
5
5
  import { attr, sel, get, scoped, show } from './dom.js';
6
6
 
7
7
  /**
8
- * tether-product values:
9
- * "coffee-box" handle, numeric id, gid or product URL (as before)
10
- * "url:handle" ?handle=... from the page URL (any param name: "url:p" -> ?p=)
11
- * "url:path" the last segment of the page path (/shop/coffee-box -> coffee-box)
12
- * Returns { key, fromUrl, raw }. key is null when the URL has no value.
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}
13
42
  */
14
43
  export function resolveProductKey(el, location = window.location, name = 'product') {
15
- const raw = (get(el, name) || '').trim();
16
- if (!raw.toLowerCase().startsWith('url:')) return { key: raw || null, fromUrl: false, raw };
17
- const what = raw.slice(4).trim();
18
- let key = null;
19
- if (what.toLowerCase() === 'path') {
20
- const parts = location.pathname.split('/').filter(Boolean);
21
- key = parts.length ? safeDecode(parts[parts.length - 1]) : null;
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);
22
52
  } else {
23
- key = new URLSearchParams(location.search).get(what || 'handle');
53
+ key = new URLSearchParams(location.search).get(source || 'handle');
24
54
  }
25
- key = key && key.trim() ? key.trim() : null;
26
- return { key, fromUrl: true, raw };
55
+ return { key: key?.trim() || null, fromUrl: true, raw };
27
56
  }
28
57
 
29
- function safeDecode(s) { try { return decodeURIComponent(s); } catch { return s; } }
30
-
31
- /** Values of tether-show that follow the scope state. */
58
+ /** @type {readonly ScopeStatus[]} The values of `tether-show` that follow the scope's state. */
32
59
  export const STATUS_KEYS = ['loading', 'ready', 'error', 'not-found'];
33
60
 
34
- export const isNotFound = (err) => err?.code === 'NOT_FOUND';
61
+ /**
62
+ * @param {unknown} value
63
+ * @returns {value is ScopeStatus}
64
+ */
65
+ const isScopeStatus = (value) => STATUS_KEYS.some((status) => status === value);
35
66
 
36
- /** Product scopes and lists both own their state elements; the nearest one wins. */
37
- export const boundary = () => ['product', 'collection', 'products', 'recommendations'].map((n) => sel(n)).join(',');
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';
38
78
 
39
- /** True when `el` belongs to the scope `root` (not to a nested product or list). */
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
+ */
40
88
  export const ownedBy = (el, root) => el === root || el.parentElement?.closest(boundary()) === root;
41
89
 
42
90
  /**
43
- * Sets tether-state (loading | ready | error) and tether-error (not-found or an
44
- * error code) on `root`, toggles tether-show="loading|ready|error|not-found"
45
- * inside it, and disables add-to-cart buttons when there's no product.
91
+ * @param {unknown} error
92
+ * @returns {string} The error's code for `tether-error`, `error` without one.
46
93
  */
47
- export function setScopeStatus(root, status, err) {
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) {
48
109
  const notFound = status === 'not-found';
110
+ const failed = status === 'error' || notFound;
49
111
  root.setAttribute(attr('state'), notFound ? 'error' : status);
50
- if (status === 'error' || notFound) root.setAttribute(attr('error'), notFound ? 'not-found' : (err?.code || 'error'));
112
+ if (failed) root.setAttribute(attr('error'), notFound ? 'not-found' : errorCode(error));
51
113
  else root.removeAttribute(attr('error'));
52
114
 
53
115
  scoped(root, sel('show'), boundary()).forEach((el) => {
54
- const k = get(el, 'show');
55
- if (!STATUS_KEYS.includes(k)) return;
56
- show(el, k === status);
116
+ const shows = get(el, 'show');
117
+ if (isScopeStatus(shows)) show(el, shows === status);
57
118
  });
58
119
 
59
- if (status === 'error' || notFound) {
60
- scoped(root, sel('add-to-cart'), boundary()).forEach((btn) => {
61
- btn.classList.add('is-disabled');
62
- btn.setAttribute('aria-disabled', 'true');
63
- if ('disabled' in btn) btn.disabled = true;
64
- });
65
- }
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
+ });
66
126
  }
Binary file
@@ -0,0 +1,199 @@
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
+ }
@@ -0,0 +1,167 @@
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
+ }