@shop-on/theme-server 0.4.0 → 0.6.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.
@@ -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
@@ -48,6 +48,21 @@ export declare const BadgeTagsFragment: import("gql.tada").TadaDocumentNode<{
48
48
  on: "ShopProduct";
49
49
  masked: false;
50
50
  }>;
51
+ /** `"namespace.key"` names as the card queries' metafield variables. Nothing
52
+ * named ⇒ the selection stays `@include`d out and the variables at their
53
+ * defaults, so a caller naming no keys sends exactly the query it always did.
54
+ * A name without a dot (or with an empty side) names no key and is dropped. */
55
+ export declare function metafieldKeysVariables(keys: readonly string[] | undefined): {
56
+ withMetafields?: true;
57
+ metafieldFirst?: number;
58
+ metafieldKeys?: Array<{
59
+ namespace: string;
60
+ key: string;
61
+ }>;
62
+ };
63
+ /** The cache-key segment for a key list: sorted, so two callers naming the
64
+ * same keys in a different order share one entry. */
65
+ export declare function metafieldKeysKey(keys: readonly string[] | undefined): string;
51
66
  /** Lean fetch for product cards (collection grids, social tiles, shop-the-look).
52
67
  * Skips description and caps media at 5. Used by the `product_list` resolver.
53
68
  * `includeOptions` (default true) gates the variant-options payload — pass
@@ -55,7 +70,7 @@ export declare const BadgeTagsFragment: import("gql.tada").TadaDocumentNode<{
55
70
  export declare function fetchProduct(ref: string, opts?: {
56
71
  includeOptions?: boolean;
57
72
  locale?: string;
58
- metafield?: string;
73
+ metafieldKeys?: readonly string[];
59
74
  }): Promise<ProductData | null>;
60
75
  /** Lean card fetch with a plain-text description — same footprint as
61
76
  * {@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 {
@@ -306,25 +309,38 @@ function toMoney(m) {
306
309
  currencyCode: m.currencyCode
307
310
  };
308
311
  }
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;
312
+ /** `"namespace.key"` names as the card queries' metafield variables. Nothing
313
+ * named ⇒ the selection stays `@include`d out and the variables at their
314
+ * defaults, so a caller naming no keys sends exactly the query it always did.
315
+ * A name without a dot (or with an empty side) names no key and is dropped. */
316
+ function metafieldKeysVariables(keys) {
317
+ const parsed = [];
318
+ for (const ref of keys ?? []) {
319
+ const dot = ref.indexOf(".");
320
+ if (dot <= 0 || dot === ref.length - 1) continue;
321
+ parsed.push({
322
+ namespace: ref.slice(0, dot),
323
+ key: ref.slice(dot + 1)
324
+ });
325
+ }
326
+ if (parsed.length === 0) return {};
314
327
  return {
315
- namespace: ref.slice(0, dot),
316
- key: ref.slice(dot + 1)
328
+ withMetafields: true,
329
+ metafieldFirst: parsed.length,
330
+ metafieldKeys: parsed
317
331
  };
318
332
  }
319
- const fetchProductCardCached = memoTTL(async (ref, includeOptions, locale, metafield) => {
320
- const lookup = metafieldRef(metafield);
333
+ /** The cache-key segment for a key list: sorted, so two callers naming the
334
+ * same keys in a different order share one entry. */
335
+ function metafieldKeysKey(keys) {
336
+ return keys?.length ? [...keys].sort().join(",") : "";
337
+ }
338
+ const fetchProductCardCached = memoTTL(async (ref, includeOptions, locale, metafieldKeys) => {
321
339
  try {
322
340
  const p = (await request(FetchProductCardQuery, {
323
341
  ...parseRef(ref),
324
342
  includeOptions,
325
- includeMetafield: lookup !== null,
326
- metafieldNamespace: lookup?.namespace ?? "",
327
- metafieldKey: lookup?.key ?? ""
343
+ ...metafieldKeysVariables(metafieldKeys)
328
344
  }, locale ? { acceptLanguage: locale } : void 0)).product;
329
345
  if (!p) return null;
330
346
  return {
@@ -343,17 +359,14 @@ const fetchProductCardCached = memoTTL(async (ref, includeOptions, locale, metaf
343
359
  compare_at: p.defaultVariant.compareAtV2 ? toMoney(p.defaultVariant.compareAtV2) : null,
344
360
  media: toMedia(p.media.nodes, 480),
345
361
  ...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
- } } } : {}
362
+ ...p.metafields ? { metafields: toMetafieldMap(p.metafields.nodes) } : {}
350
363
  };
351
364
  } catch (err) {
352
365
  console.warn("[fetchProduct] failed:", err);
353
366
  return null;
354
367
  }
355
368
  }, {
356
- key: (ref, includeOptions, locale, metafield) => `card|${ref}|${includeOptions}|${locale ?? ""}|${metafield ?? ""}`,
369
+ key: (ref, includeOptions, locale, metafieldKeys) => `card|${ref}|${includeOptions}|${locale ?? ""}|${metafieldKeysKey(metafieldKeys)}`,
357
370
  shouldCache: (v) => v !== null,
358
371
  version: getContentVersion
359
372
  });
@@ -362,7 +375,7 @@ const fetchProductCardCached = memoTTL(async (ref, includeOptions, locale, metaf
362
375
  * `includeOptions` (default true) gates the variant-options payload — pass
363
376
  * false when the consuming cards render no swatches. */
364
377
  function fetchProduct(ref, opts) {
365
- return fetchProductCardCached(ref, opts?.includeOptions ?? true, opts?.locale, opts?.metafield);
378
+ return fetchProductCardCached(ref, opts?.includeOptions ?? true, opts?.locale, opts?.metafieldKeys);
366
379
  }
367
380
  const FetchProductShowcaseQuery = graphql(`
368
381
  query FetchProductShowcase($handle: String, $id: ID) {
@@ -464,10 +477,7 @@ async function loadProductFull(handle, locale) {
464
477
  title: p.seo.title,
465
478
  description: p.seo.description
466
479
  },
467
- metafields: Object.fromEntries(p.metafields.nodes.map((m) => [`${m.namespace}.${m.key}`, {
468
- type: m.type,
469
- value: m.value
470
- }]))
480
+ metafields: toMetafieldMap(p.metafields.nodes)
471
481
  };
472
482
  } catch (err) {
473
483
  console.warn("[fetchProductFull] failed:", err);
@@ -657,4 +667,4 @@ const fetchProductShowcaseMedia = memoTTL(async ({ handle, namespace, imageKey,
657
667
  version: getContentVersion
658
668
  });
659
669
  //#endregion
660
- export { BadgeTagsFragment, fetchProduct, fetchProductFull, fetchProductListDetail, fetchProductShowcase, fetchProductShowcaseMedia, fetchVariantMediaMap };
670
+ 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