@shop-on/theme-server 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/graphql.js CHANGED
@@ -16,6 +16,7 @@ let mintHoldError = null;
16
16
  const MINT_FAILURE_HOLD_MS = 2e3;
17
17
  const MINT_THROTTLE_FALLBACK_HOLD_MS = 15e3;
18
18
  const MINT_HOLD_MAX_MS = 6e4;
19
+ const GRAPHQL_TIMEOUT_MS = 8e3;
19
20
  var MintError = class extends Error {
20
21
  holdMs;
21
22
  constructor(message, holdMs) {
@@ -157,7 +158,11 @@ function invalidateToken(used) {
157
158
  if (cached?.accessToken === used) cached = null;
158
159
  }
159
160
  async function executeSingle(document, variables, options, trace) {
160
- const send = async (token) => await buildClient(bearerHeaders(token, options, trace)).request(document, variables);
161
+ const send = async (token) => await buildClient(bearerHeaders(token, options, trace)).request({
162
+ document,
163
+ variables,
164
+ signal: AbortSignal.timeout(GRAPHQL_TIMEOUT_MS)
165
+ });
161
166
  const token = await getToken();
162
167
  try {
163
168
  return await send(token);
@@ -236,7 +241,8 @@ async function executeBatchChunk(chunk, options, trace) {
236
241
  "Content-Type": "application/json",
237
242
  ...bearerHeaders(token, options, trace)
238
243
  },
239
- body
244
+ body,
245
+ signal: AbortSignal.timeout(GRAPHQL_TIMEOUT_MS)
240
246
  }));
241
247
  if (!res.ok) throw batchClientError({
242
248
  status: res.status,
@@ -1,13 +1,15 @@
1
1
  //#region src/marketing-cookies.d.ts
2
- /** Attribution ids read off the request cookies. A key is absent, never empty. */
2
+ /** Attribution read off the request cookies. A key is absent, never empty. */
3
3
  export interface MarketingCookies {
4
4
  fbp?: string;
5
5
  fbc?: string;
6
+ /** Every allowlisted cookie as `name=value; …`, for Rails to parse. */
7
+ cookies?: string;
6
8
  }
7
9
  /**
8
- * Parse `_fbp` / `_fbc` out of a raw `Cookie` header. Returns `{}` when the
9
- * header is missing or carries neither — the caller then sends no attribution at
10
- * all rather than a hash of nulls.
10
+ * Parse the attribution cookies out of a raw `Cookie` header. Returns `{}` when
11
+ * the header is missing or carries none — the caller then sends no attribution
12
+ * at all rather than a hash of nulls.
11
13
  */
12
14
  export declare function parseMarketingCookies(cookie: string | null | undefined): MarketingCookies;
13
15
  //#endregion
@@ -1,26 +1,47 @@
1
1
  //#region src/marketing-cookies.ts
2
2
  const MAX_LENGTH = 256;
3
- const COOKIE_NAMES = {
4
- fbp: "_fbp",
5
- fbc: "_fbc"
6
- };
7
- function readCookie(cookie, name) {
8
- const value = new RegExp(`(?:^|;\\s*)${name}=([^;]*)`).exec(cookie)?.[1]?.trim();
9
- if (!value || value.length > MAX_LENGTH) return void 0;
10
- return value;
3
+ const MAX_COOKIES_LENGTH = 2048;
4
+ const ALLOWED_NAMES = /* @__PURE__ */ new Set([
5
+ "_fbp",
6
+ "_fbc",
7
+ "_ga",
8
+ "_gcl_aw",
9
+ "_gcl_au",
10
+ "_ttp"
11
+ ]);
12
+ const GA_STREAM_NAME = /^_ga_[A-Za-z0-9]+$/;
13
+ function isAllowed(name) {
14
+ return ALLOWED_NAMES.has(name) || GA_STREAM_NAME.test(name);
11
15
  }
12
16
  /**
13
- * Parse `_fbp` / `_fbc` out of a raw `Cookie` header. Returns `{}` when the
14
- * header is missing or carries neither — the caller then sends no attribution at
15
- * all rather than a hash of nulls.
17
+ * Parse the attribution cookies out of a raw `Cookie` header. Returns `{}` when
18
+ * the header is missing or carries none — the caller then sends no attribution
19
+ * at all rather than a hash of nulls.
16
20
  */
17
21
  function parseMarketingCookies(cookie) {
18
22
  if (!cookie) return {};
23
+ const found = /* @__PURE__ */ new Map();
24
+ for (const pair of cookie.split(";")) {
25
+ const separator = pair.indexOf("=");
26
+ if (separator === -1) continue;
27
+ const name = pair.slice(0, separator).trim();
28
+ const value = pair.slice(separator + 1).trim();
29
+ if (!isAllowed(name) || found.has(name)) continue;
30
+ if (!value || value.length > MAX_LENGTH) continue;
31
+ found.set(name, value);
32
+ }
19
33
  const result = {};
20
- for (const [key, name] of Object.entries(COOKIE_NAMES)) {
21
- const value = readCookie(cookie, name);
22
- if (value) result[key] = value;
34
+ const fbp = found.get("_fbp");
35
+ const fbc = found.get("_fbc");
36
+ if (fbp) result.fbp = fbp;
37
+ if (fbc) result.fbc = fbc;
38
+ let cookies = "";
39
+ for (const [name, value] of found) {
40
+ const next = cookies ? `${cookies}; ${name}=${value}` : `${name}=${value}`;
41
+ if (next.length > MAX_COOKIES_LENGTH) break;
42
+ cookies = next;
23
43
  }
44
+ if (cookies) result.cookies = cookies;
24
45
  return result;
25
46
  }
26
47
  //#endregion
@@ -0,0 +1,7 @@
1
+ //#region src/metafields.d.ts
2
+ /** A resource's storefront metafields keyed `"namespace.key"`. */
3
+ export type MetafieldMap = Record<string, {
4
+ type: string;
5
+ value: string;
6
+ }>;
7
+ //#endregion
@@ -0,0 +1,33 @@
1
+ import { memoTTL } from "./cache.js";
2
+ import { getContentVersion } from "./content-version.js";
3
+ //#region src/metafields.ts
4
+ /** Metafield nodes as the `"namespace.key"`-keyed map every reader uses. */
5
+ function toMetafieldMap(nodes) {
6
+ return Object.fromEntries(nodes.map((m) => [`${m.namespace}.${m.key}`, {
7
+ type: m.type,
8
+ value: m.value
9
+ }]));
10
+ }
11
+ /** The cached metafield read behind each route resource's `*_metafield`
12
+ * dynamic source. `read` returns the resource's metafield nodes, or `null`
13
+ * when the resource is missing. A resource with no published fields is `{}`
14
+ * and IS cached; a missing resource or a failed read is retried next time.
15
+ * Either way the caller gets `{}`, so the template renders its literal. */
16
+ function metafieldsReader(name, read) {
17
+ const cached = memoTTL(async (ref, locale) => {
18
+ try {
19
+ const nodes = await read(ref, locale);
20
+ return nodes ? toMetafieldMap(nodes) : null;
21
+ } catch (err) {
22
+ console.warn(`[${name}] failed:`, err);
23
+ return null;
24
+ }
25
+ }, {
26
+ key: (ref, locale) => `${name}|${ref}|${locale ?? ""}`,
27
+ shouldCache: (v) => v !== null,
28
+ version: getContentVersion
29
+ });
30
+ return async (ref, locale) => await cached(ref, locale) ?? {};
31
+ }
32
+ //#endregion
33
+ export { metafieldsReader, toMetafieldMap };
package/dist/page.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { MetafieldMap } from "./metafields.js";
1
2
  //#region src/page.d.ts
2
3
  export type PageData = {
3
4
  id: string;
@@ -25,4 +26,18 @@ export type PageRef = {
25
26
  * `page(id:)` applies the same `public_visible` gate as the handle path, so a
26
27
  * missing/draft/cross-shop page returns null — the caller falls back. */
27
28
  export declare const fetchPageById: (id: string, locale?: string) => Promise<PageRef | null>;
29
+ /** The page's storefront metafields keyed `"namespace.key"` — the
30
+ * `page_metafield` dynamic source. Its own read, not a field on `fetchPage`,
31
+ * so a page whose template binds none never asks for them. `{}` on a miss or
32
+ * a failure, so the literal renders. */
33
+ export declare const fetchPageMetafields: (ref: string, locale?: string) => Promise<MetafieldMap>;
34
+ /** The route CMS page as the theme loader's dynamic sources read it
35
+ * (`DynamicSourcePage` in `@shop-on/theme-loader`): the title off the page
36
+ * read the theme already made, and a lazy provider the loader calls only when
37
+ * a template binds a page field. One builder so a theme picks up a new
38
+ * provider on upgrade instead of re-listing them. */
39
+ export declare function routePageSource(page: Pick<PageData, "handle" | "title">, locale?: string): {
40
+ title: string;
41
+ getMetafields: () => Promise<MetafieldMap>;
42
+ };
28
43
  //#endregion
package/dist/page.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { memoTTL } from "./cache.js";
2
2
  import { graphql, request } from "./graphql.js";
3
3
  import { getContentVersion } from "./content-version.js";
4
+ import { metafieldsReader } from "./metafields.js";
4
5
  //#region src/page.ts
5
6
  function parseRef(ref) {
6
7
  return ref.startsWith("gid://") ? { id: ref } : { handle: ref };
@@ -90,5 +91,38 @@ const fetchPageById = async (id, locale) => {
90
91
  return null;
91
92
  }
92
93
  };
94
+ const FetchPageMetafieldsQuery = graphql(`
95
+ query FetchPageMetafields($handle: String, $id: ID) {
96
+ page(handle: $handle, id: $id) {
97
+ # Storefront-visible only (the API gates on the storefront scope).
98
+ metafields(first: 50) {
99
+ nodes {
100
+ namespace
101
+ key
102
+ type
103
+ value
104
+ }
105
+ }
106
+ }
107
+ }
108
+ `);
109
+ /** The page's storefront metafields keyed `"namespace.key"` — the
110
+ * `page_metafield` dynamic source. Its own read, not a field on `fetchPage`,
111
+ * so a page whose template binds none never asks for them. `{}` on a miss or
112
+ * a failure, so the literal renders. */
113
+ const fetchPageMetafields = metafieldsReader("fetchPageMetafields", async (handle, locale) => {
114
+ return (await request(FetchPageMetafieldsQuery, parseRef(handle), locale ? { acceptLanguage: locale } : void 0)).page?.metafields.nodes ?? null;
115
+ });
116
+ /** The route CMS page as the theme loader's dynamic sources read it
117
+ * (`DynamicSourcePage` in `@shop-on/theme-loader`): the title off the page
118
+ * read the theme already made, and a lazy provider the loader calls only when
119
+ * a template binds a page field. One builder so a theme picks up a new
120
+ * provider on upgrade instead of re-listing them. */
121
+ function routePageSource(page, locale) {
122
+ return {
123
+ title: page.title,
124
+ getMetafields: () => fetchPageMetafields(page.handle, locale)
125
+ };
126
+ }
93
127
  //#endregion
94
- export { fetchPage, fetchPageById };
128
+ export { fetchPage, fetchPageById, fetchPageMetafields, routePageSource };
package/dist/product.d.ts CHANGED
@@ -40,6 +40,8 @@ export type ProductData = {
40
40
  type: string;
41
41
  value: string;
42
42
  }>;
43
+ finalSale?: boolean;
44
+ gtin?: string | null;
43
45
  };
44
46
  export declare const BadgeTagsFragment: import("gql.tada").TadaDocumentNode<{
45
47
  tags: string[];
@@ -48,6 +50,21 @@ export declare const BadgeTagsFragment: import("gql.tada").TadaDocumentNode<{
48
50
  on: "ShopProduct";
49
51
  masked: false;
50
52
  }>;
53
+ /** `"namespace.key"` names as the card queries' metafield variables. Nothing
54
+ * named ⇒ the selection stays `@include`d out and the variables at their
55
+ * defaults, so a caller naming no keys sends exactly the query it always did.
56
+ * A name without a dot (or with an empty side) names no key and is dropped. */
57
+ export declare function metafieldKeysVariables(keys: readonly string[] | undefined): {
58
+ withMetafields?: true;
59
+ metafieldFirst?: number;
60
+ metafieldKeys?: Array<{
61
+ namespace: string;
62
+ key: string;
63
+ }>;
64
+ };
65
+ /** The cache-key segment for a key list: sorted, so two callers naming the
66
+ * same keys in a different order share one entry. */
67
+ export declare function metafieldKeysKey(keys: readonly string[] | undefined): string;
51
68
  /** Lean fetch for product cards (collection grids, social tiles, shop-the-look).
52
69
  * Skips description and caps media at 5. Used by the `product_list` resolver.
53
70
  * `includeOptions` (default true) gates the variant-options payload — pass
@@ -55,7 +72,7 @@ export declare const BadgeTagsFragment: import("gql.tada").TadaDocumentNode<{
55
72
  export declare function fetchProduct(ref: string, opts?: {
56
73
  includeOptions?: boolean;
57
74
  locale?: string;
58
- metafield?: string;
75
+ metafieldKeys?: readonly string[];
59
76
  }): Promise<ProductData | null>;
60
77
  /** Lean card fetch with a plain-text description — same footprint as
61
78
  * {@link fetchProduct}, plus `descriptionPlain`; no options/variants. For cards
package/dist/product.js CHANGED
@@ -2,6 +2,7 @@ import { memoTTL } from "./cache.js";
2
2
  import { graphql, request } from "./graphql.js";
3
3
  import { getContentVersion } from "./content-version.js";
4
4
  import { fetchFileById, toFileInfo } from "./files.js";
5
+ import { toMetafieldMap } from "./metafields.js";
5
6
  //#region src/product.ts
6
7
  const BadgeTagsFragment = graphql(`
7
8
  fragment BadgeTags on ShopProduct @_unmask {
@@ -127,9 +128,9 @@ const FetchProductCardQuery = graphql(`
127
128
  $handle: String
128
129
  $id: ID
129
130
  $includeOptions: Boolean!
130
- $includeMetafield: Boolean!
131
- $metafieldNamespace: String!
132
- $metafieldKey: String!
131
+ $withMetafields: Boolean = false
132
+ $metafieldFirst: Int = 0
133
+ $metafieldKeys: [MetafieldKeyInput!] = []
133
134
  ) {
134
135
  product(handle: $handle, id: $id) {
135
136
  id
@@ -162,14 +163,16 @@ const FetchProductCardQuery = graphql(`
162
163
  options @include(if: $includeOptions) {
163
164
  ...ProductOptionNode
164
165
  }
165
- # One storefront-visible metafield a card names, for a caller that
166
- # labels each product by it (a theme's product-variations swatches).
167
- metafield(namespace: $metafieldNamespace, key: $metafieldKey)
168
- @include(if: $includeMetafield) {
169
- namespace
170
- key
171
- type
172
- value
166
+ # The storefront-visible metafields a card names and no more (a
167
+ # theme's product-variations swatches name one; a closest product
168
+ # metafield source bound in the picker's scope names each it reads).
169
+ metafields(first: $metafieldFirst, keys: $metafieldKeys) @include(if: $withMetafields) {
170
+ nodes {
171
+ namespace
172
+ key
173
+ type
174
+ value
175
+ }
173
176
  }
174
177
  media(first: 5) {
175
178
  nodes {
@@ -193,6 +196,7 @@ const FetchProductFullQuery = graphql(`
193
196
  themeTemplate
194
197
  ...BadgeTags
195
198
  giftCard
199
+ finalSale
196
200
  descriptionHtml
197
201
  # Server-side plain-text projection of descriptionHtml — Rails
198
202
  # strips tags AND decodes HTML entities, which a client-side regex
@@ -204,6 +208,7 @@ const FetchProductFullQuery = graphql(`
204
208
  }
205
209
  defaultVariant {
206
210
  ...ProductVariantNode
211
+ gtin
207
212
  }
208
213
  options {
209
214
  ...ProductOptionNode
@@ -306,25 +311,38 @@ function toMoney(m) {
306
311
  currencyCode: m.currencyCode
307
312
  };
308
313
  }
309
- /** `namespace.key` as the metafield lookup's two arguments, or null when it
310
- * names no key. */
311
- function metafieldRef(ref) {
312
- const dot = ref?.indexOf(".") ?? -1;
313
- if (!ref || dot <= 0 || dot === ref.length - 1) return null;
314
+ /** `"namespace.key"` names as the card queries' metafield variables. Nothing
315
+ * named ⇒ the selection stays `@include`d out and the variables at their
316
+ * defaults, so a caller naming no keys sends exactly the query it always did.
317
+ * A name without a dot (or with an empty side) names no key and is dropped. */
318
+ function metafieldKeysVariables(keys) {
319
+ const parsed = [];
320
+ for (const ref of keys ?? []) {
321
+ const dot = ref.indexOf(".");
322
+ if (dot <= 0 || dot === ref.length - 1) continue;
323
+ parsed.push({
324
+ namespace: ref.slice(0, dot),
325
+ key: ref.slice(dot + 1)
326
+ });
327
+ }
328
+ if (parsed.length === 0) return {};
314
329
  return {
315
- namespace: ref.slice(0, dot),
316
- key: ref.slice(dot + 1)
330
+ withMetafields: true,
331
+ metafieldFirst: parsed.length,
332
+ metafieldKeys: parsed
317
333
  };
318
334
  }
319
- const fetchProductCardCached = memoTTL(async (ref, includeOptions, locale, metafield) => {
320
- const lookup = metafieldRef(metafield);
335
+ /** The cache-key segment for a key list: sorted, so two callers naming the
336
+ * same keys in a different order share one entry. */
337
+ function metafieldKeysKey(keys) {
338
+ return keys?.length ? [...keys].sort().join(",") : "";
339
+ }
340
+ const fetchProductCardCached = memoTTL(async (ref, includeOptions, locale, metafieldKeys) => {
321
341
  try {
322
342
  const p = (await request(FetchProductCardQuery, {
323
343
  ...parseRef(ref),
324
344
  includeOptions,
325
- includeMetafield: lookup !== null,
326
- metafieldNamespace: lookup?.namespace ?? "",
327
- metafieldKey: lookup?.key ?? ""
345
+ ...metafieldKeysVariables(metafieldKeys)
328
346
  }, locale ? { acceptLanguage: locale } : void 0)).product;
329
347
  if (!p) return null;
330
348
  return {
@@ -343,17 +361,14 @@ const fetchProductCardCached = memoTTL(async (ref, includeOptions, locale, metaf
343
361
  compare_at: p.defaultVariant.compareAtV2 ? toMoney(p.defaultVariant.compareAtV2) : null,
344
362
  media: toMedia(p.media.nodes, 480),
345
363
  ...p.options ? { options: p.options.map(toOption) } : {},
346
- ...p.metafield ? { metafields: { [`${p.metafield.namespace}.${p.metafield.key}`]: {
347
- type: p.metafield.type,
348
- value: p.metafield.value
349
- } } } : {}
364
+ ...p.metafields ? { metafields: toMetafieldMap(p.metafields.nodes) } : {}
350
365
  };
351
366
  } catch (err) {
352
367
  console.warn("[fetchProduct] failed:", err);
353
368
  return null;
354
369
  }
355
370
  }, {
356
- key: (ref, includeOptions, locale, metafield) => `card|${ref}|${includeOptions}|${locale ?? ""}|${metafield ?? ""}`,
371
+ key: (ref, includeOptions, locale, metafieldKeys) => `card|${ref}|${includeOptions}|${locale ?? ""}|${metafieldKeysKey(metafieldKeys)}`,
357
372
  shouldCache: (v) => v !== null,
358
373
  version: getContentVersion
359
374
  });
@@ -362,7 +377,7 @@ const fetchProductCardCached = memoTTL(async (ref, includeOptions, locale, metaf
362
377
  * `includeOptions` (default true) gates the variant-options payload — pass
363
378
  * false when the consuming cards render no swatches. */
364
379
  function fetchProduct(ref, opts) {
365
- return fetchProductCardCached(ref, opts?.includeOptions ?? true, opts?.locale, opts?.metafield);
380
+ return fetchProductCardCached(ref, opts?.includeOptions ?? true, opts?.locale, opts?.metafieldKeys);
366
381
  }
367
382
  const FetchProductShowcaseQuery = graphql(`
368
383
  query FetchProductShowcase($handle: String, $id: ID) {
@@ -464,10 +479,9 @@ async function loadProductFull(handle, locale) {
464
479
  title: p.seo.title,
465
480
  description: p.seo.description
466
481
  },
467
- metafields: Object.fromEntries(p.metafields.nodes.map((m) => [`${m.namespace}.${m.key}`, {
468
- type: m.type,
469
- value: m.value
470
- }]))
482
+ metafields: toMetafieldMap(p.metafields.nodes),
483
+ finalSale: p.finalSale,
484
+ gtin: p.defaultVariant.gtin ?? null
471
485
  };
472
486
  } catch (err) {
473
487
  console.warn("[fetchProductFull] failed:", err);
@@ -657,4 +671,4 @@ const fetchProductShowcaseMedia = memoTTL(async ({ handle, namespace, imageKey,
657
671
  version: getContentVersion
658
672
  });
659
673
  //#endregion
660
- export { BadgeTagsFragment, fetchProduct, fetchProductFull, fetchProductListDetail, fetchProductShowcase, fetchProductShowcaseMedia, fetchVariantMediaMap };
674
+ export { BadgeTagsFragment, fetchProduct, fetchProductFull, fetchProductListDetail, fetchProductShowcase, fetchProductShowcaseMedia, fetchVariantMediaMap, metafieldKeysKey, metafieldKeysVariables };
@@ -22,5 +22,8 @@ export declare const fetchProductRecommendations: (data: {
22
22
  * swatches switch the card to it. Needs `includeOptions`. */
23
23
  includeVariants?: boolean;
24
24
  locale?: string;
25
+ /** Storefront-visible metafields (`"namespace.key"`) each card carries, for
26
+ * the `closest_product_metafield` sources bound on it. Omitted ⇒ none. */
27
+ metafieldKeys?: readonly string[];
25
28
  }) => Promise<RecommendationsData>;
26
29
  //#endregion
@@ -1,6 +1,7 @@
1
1
  import { memoTTL } from "./cache.js";
2
2
  import { graphql, request } from "./graphql.js";
3
3
  import { getContentVersion } from "./content-version.js";
4
+ import { metafieldKeysKey, metafieldKeysVariables } from "./product.js";
4
5
  import { CollectionProductNodeFragment, toCollectionProduct } from "./collection.js";
5
6
  //#region src/recommendations.ts
6
7
  const FetchProductRecommendationsQuery = graphql(`
@@ -10,6 +11,9 @@ const FetchProductRecommendationsQuery = graphql(`
10
11
  $includeOptions: Boolean!
11
12
  $includeVariants: Boolean!
12
13
  $mediaFirst: Int!
14
+ $withMetafields: Boolean = false
15
+ $metafieldFirst: Int = 0
16
+ $metafieldKeys: [MetafieldKeyInput!] = []
13
17
  ) {
14
18
  reference: product(handle: $productHandle) {
15
19
  id
@@ -21,13 +25,14 @@ const FetchProductRecommendationsQuery = graphql(`
21
25
  `, [CollectionProductNodeFragment]);
22
26
  /** A recommendation card's media: its image and the ones a hover gallery shows. */
23
27
  const MEDIA_FIRST = 5;
24
- const fetchRecommendationsCached = memoTTL(async ({ productHandle, intent, includeOptions, includeVariants, locale }) => {
28
+ const fetchRecommendationsCached = memoTTL(async ({ productHandle, intent, includeOptions, includeVariants, locale, metafieldKeys }) => {
25
29
  const res = await request(FetchProductRecommendationsQuery, {
26
30
  productHandle,
27
31
  intent,
28
32
  includeOptions,
29
33
  includeVariants,
30
- mediaFirst: MEDIA_FIRST
34
+ mediaFirst: MEDIA_FIRST,
35
+ ...metafieldKeysVariables(metafieldKeys)
31
36
  }, locale ? { acceptLanguage: locale } : void 0);
32
37
  const products = res.productRecommendations.map(toCollectionProduct);
33
38
  return {
@@ -37,7 +42,7 @@ const fetchRecommendationsCached = memoTTL(async ({ productHandle, intent, inclu
37
42
  products
38
43
  };
39
44
  }, {
40
- key: ({ productHandle, intent, includeOptions, includeVariants, locale }) => `${intent}|${productHandle}|${includeOptions}|${includeVariants}|${locale ?? ""}`,
45
+ key: ({ productHandle, intent, includeOptions, includeVariants, locale, metafieldKeys }) => `${intent}|${productHandle}|${includeOptions}|${includeVariants}|${locale ?? ""}|${metafieldKeysKey(metafieldKeys)}`,
41
46
  version: getContentVersion
42
47
  });
43
48
  /** Server fetch for the storefront `productRecommendations` field. Returns an