@shop-on/theme-server 0.5.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.
package/dist/account.js CHANGED
@@ -2,13 +2,16 @@ import { fetchShopInfo } from "./shop.js";
2
2
  import { checkOrigin } from "./csrf.js";
3
3
  import { escapeHtmlAttr } from "./html.js";
4
4
  //#region src/account.ts
5
+ const RETURN_TO_BASE = "http://return-to.invalid";
5
6
  const FORBIDDEN_RETURN_TO = /^(?:\/[^/]+)?\/(?:account|customer_logout)(?:\/|$)/;
6
7
  function normalizeReturnToPath(raw) {
7
8
  if (typeof raw !== "string") return "/";
8
- if (!raw.startsWith("/") || raw.startsWith("//")) return "/";
9
- const pathOnly = raw.split(/[?#]/, 1)[0];
10
- if (FORBIDDEN_RETURN_TO.test(pathOnly)) return "/";
11
- return raw;
9
+ const path = raw.replace(/[\t\n\r]/g, "");
10
+ if (!path.startsWith("/") || path.startsWith("//") || path.startsWith("/\\")) return "/";
11
+ const url = new URL(path, RETURN_TO_BASE);
12
+ if (url.origin !== RETURN_TO_BASE || url.pathname.startsWith("//")) return "/";
13
+ if (FORBIDDEN_RETURN_TO.test(url.pathname)) return "/";
14
+ return url.pathname + url.search + url.hash;
12
15
  }
13
16
  async function getAccountRedirectUrl(data) {
14
17
  const shop = await fetchShopInfo();
@@ -4,7 +4,7 @@ declare const PATHS: {
4
4
  readonly events: "/storefront/analytics/events";
5
5
  };
6
6
  export type AnalyticsEndpoint = keyof typeof PATHS;
7
- export declare function proxyAnalytics({ endpoint, body, visitToken, visitorToken, userAgent, clientIp, fbp, fbc, eventSourceUrl }: {
7
+ export declare function proxyAnalytics({ endpoint, body, visitToken, visitorToken, userAgent, clientIp, fbp, fbc, cookies, eventSourceUrl }: {
8
8
  endpoint: AnalyticsEndpoint;
9
9
  body: unknown;
10
10
  visitToken?: string | null;
@@ -13,6 +13,7 @@ export declare function proxyAnalytics({ endpoint, body, visitToken, visitorToke
13
13
  clientIp?: string | null;
14
14
  fbp?: string | null;
15
15
  fbc?: string | null;
16
+ cookies?: string | null;
16
17
  eventSourceUrl?: string | null;
17
18
  }): Promise<{
18
19
  status: number;
@@ -6,7 +6,7 @@ const PATHS = {
6
6
  visits: "/storefront/analytics/visits",
7
7
  events: "/storefront/analytics/events"
8
8
  };
9
- async function proxyAnalytics({ endpoint, body, visitToken, visitorToken, userAgent, clientIp, fbp, fbc, eventSourceUrl }) {
9
+ async function proxyAnalytics({ endpoint, body, visitToken, visitorToken, userAgent, clientIp, fbp, fbc, cookies, eventSourceUrl }) {
10
10
  const headers = {
11
11
  Authorization: `Bearer ${await getStorefrontToken()}`,
12
12
  "Content-Type": "application/json",
@@ -18,6 +18,7 @@ async function proxyAnalytics({ endpoint, body, visitToken, visitorToken, userAg
18
18
  if (clientIp) headers["X-Buyer-IP"] = clientIp;
19
19
  if (fbp) headers["X-Marketing-Fbp"] = fbp;
20
20
  if (fbc) headers["X-Marketing-Fbc"] = fbc;
21
+ if (cookies) headers["X-Marketing-Cookies"] = cookies;
21
22
  if (eventSourceUrl) headers["X-Event-Source-Url"] = eventSourceUrl;
22
23
  return { status: (await fetch(`${storefrontApiUrl()}${PATHS[endpoint]}`, {
23
24
  method: "POST",
package/dist/blog.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { ImageFileInfo } from "./files.js";
2
+ import { MetafieldMap } from "./metafields.js";
2
3
  //#region src/blog.d.ts
3
4
  export type ArticleAuthor = {
4
5
  name: string;
@@ -105,5 +106,30 @@ export declare const fetchAdjacentArticles: (params: {
105
106
  newer: ArticleCardData | null;
106
107
  older: ArticleCardData | null;
107
108
  }>;
109
+ /** The blog's storefront metafields keyed `"namespace.key"` — the
110
+ * `blog_metafield` dynamic source, read only when a template binds one. `{}`
111
+ * on a miss or a failure, so the literal renders. */
112
+ export declare const fetchBlogMetafields: (ref: string, locale?: string) => Promise<MetafieldMap>;
113
+ /** The article's storefront metafields — the `article_metafield` dynamic
114
+ * source. `ref` is what every article read takes (`parseArticleRef`): the
115
+ * route's `<blog-handle>/<article-handle>` composite, or a node id. */
116
+ export declare const fetchArticleMetafields: (ref: string, locale?: string) => Promise<MetafieldMap>;
117
+ /** The route blog as the theme loader's dynamic sources read it
118
+ * (`DynamicSourceBlog` in `@shop-on/theme-loader`): the title off the blog read
119
+ * the theme already made, and a lazy provider the loader calls only when a
120
+ * template binds a blog field. */
121
+ export declare function routeBlogSource(blog: {
122
+ handle: string;
123
+ title: string;
124
+ }, locale?: string): {
125
+ title: string;
126
+ getMetafields: () => Promise<MetafieldMap>;
127
+ };
128
+ /** The route article as the theme loader's dynamic sources read it
129
+ * (`DynamicSourceArticle`): the same shape as `routeBlogSource`. */
130
+ export declare function routeArticleSource(article: Pick<ArticleData, "blogHandle" | "handle" | "title">, locale?: string): {
131
+ title: string;
132
+ getMetafields: () => Promise<MetafieldMap>;
133
+ };
108
134
  //#endregion
109
135
  export {};
package/dist/blog.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 { toFileInfo } from "./files.js";
5
+ import { metafieldsReader } from "./metafields.js";
5
6
  //#region src/blog.ts
6
7
  const FetchBlogQuery = graphql(`
7
8
  query FetchBlog($handle: String!, $first: Int!) {
@@ -477,5 +478,68 @@ const fetchAdjacentArticles = async (params) => {
477
478
  };
478
479
  }
479
480
  };
481
+ const FetchBlogMetafieldsQuery = graphql(`
482
+ query FetchBlogMetafields($handle: String, $id: ID) {
483
+ blog(handle: $handle, id: $id) {
484
+ metafields(first: 50) {
485
+ nodes {
486
+ namespace
487
+ key
488
+ type
489
+ value
490
+ }
491
+ }
492
+ }
493
+ }
494
+ `);
495
+ const FetchArticleMetafieldsQuery = graphql(`
496
+ query FetchArticleMetafields($blogHandle: String, $handle: String, $id: ID) {
497
+ article(blogHandle: $blogHandle, handle: $handle, id: $id) {
498
+ metafields(first: 50) {
499
+ nodes {
500
+ namespace
501
+ key
502
+ type
503
+ value
504
+ }
505
+ }
506
+ }
507
+ }
508
+ `);
509
+ /** The blog's storefront metafields keyed `"namespace.key"` — the
510
+ * `blog_metafield` dynamic source, read only when a template binds one. `{}`
511
+ * on a miss or a failure, so the literal renders. */
512
+ const fetchBlogMetafields = metafieldsReader("fetchBlogMetafields", async (handle, locale) => {
513
+ return (await request(FetchBlogMetafieldsQuery, handle.startsWith("gid://") ? { id: handle } : { handle }, locale ? { acceptLanguage: locale } : void 0)).blog?.metafields.nodes ?? null;
514
+ });
515
+ /** The article's storefront metafields — the `article_metafield` dynamic
516
+ * source. `ref` is what every article read takes (`parseArticleRef`): the
517
+ * route's `<blog-handle>/<article-handle>` composite, or a node id. */
518
+ const fetchArticleMetafields = metafieldsReader("fetchArticleMetafields", async (ref, locale) => {
519
+ const parts = parseArticleRef(ref);
520
+ return (await request(FetchArticleMetafieldsQuery, {
521
+ blogHandle: parts.blogHandle ?? null,
522
+ handle: parts.handle ?? null,
523
+ id: parts.id ?? null
524
+ }, locale ? { acceptLanguage: locale } : void 0)).article?.metafields.nodes ?? null;
525
+ });
526
+ /** The route blog as the theme loader's dynamic sources read it
527
+ * (`DynamicSourceBlog` in `@shop-on/theme-loader`): the title off the blog read
528
+ * the theme already made, and a lazy provider the loader calls only when a
529
+ * template binds a blog field. */
530
+ function routeBlogSource(blog, locale) {
531
+ return {
532
+ title: blog.title,
533
+ getMetafields: () => fetchBlogMetafields(blog.handle, locale)
534
+ };
535
+ }
536
+ /** The route article as the theme loader's dynamic sources read it
537
+ * (`DynamicSourceArticle`): the same shape as `routeBlogSource`. */
538
+ function routeArticleSource(article, locale) {
539
+ return {
540
+ title: article.title,
541
+ getMetafields: () => fetchArticleMetafields(`${article.blogHandle}/${article.handle}`, locale)
542
+ };
543
+ }
480
544
  //#endregion
481
- export { fetchAdjacentArticles, fetchArticle, fetchArticleByRef, fetchArticleCard, fetchBlog, fetchBlogMeta, fetchBlogPage };
545
+ export { fetchAdjacentArticles, fetchArticle, fetchArticleByRef, fetchArticleCard, fetchArticleMetafields, fetchBlog, fetchBlogMeta, fetchBlogMetafields, fetchBlogPage, routeArticleSource, routeBlogSource };
package/dist/cart.d.ts CHANGED
@@ -92,17 +92,17 @@ export declare const CartFields: import("gql.tada").TadaDocumentNode<{
92
92
  attachedToCurrentCustomer: boolean;
93
93
  discountAmount: {
94
94
  amount: string;
95
- currencyCode: "MNT";
95
+ currencyCode: "CNY" | "EUR" | "GBP" | "JPY" | "KRW" | "MNT" | "RUB" | "USD";
96
96
  };
97
97
  discountApplications: {
98
98
  allocatedAmount: {
99
99
  amount: string;
100
- currencyCode: "MNT";
100
+ currencyCode: "CNY" | "EUR" | "GBP" | "JPY" | "KRW" | "MNT" | "RUB" | "USD";
101
101
  };
102
102
  allocations: {
103
103
  amount: {
104
104
  amount: string;
105
- currencyCode: "MNT";
105
+ currencyCode: "CNY" | "EUR" | "GBP" | "JPY" | "KRW" | "MNT" | "RUB" | "USD";
106
106
  };
107
107
  targetLineItemId: string | null;
108
108
  }[];
@@ -119,7 +119,7 @@ export declare const CartFields: import("gql.tada").TadaDocumentNode<{
119
119
  nodes: {
120
120
  cost: {
121
121
  amount: string;
122
- currencyCode: "MNT";
122
+ currencyCode: "CNY" | "EUR" | "GBP" | "JPY" | "KRW" | "MNT" | "RUB" | "USD";
123
123
  };
124
124
  customAttributes: {
125
125
  name: string;
@@ -132,7 +132,7 @@ export declare const CartFields: import("gql.tada").TadaDocumentNode<{
132
132
  availableForSale: boolean;
133
133
  compareAtV2: {
134
134
  amount: string;
135
- currencyCode: "MNT";
135
+ currencyCode: "CNY" | "EUR" | "GBP" | "JPY" | "KRW" | "MNT" | "RUB" | "USD";
136
136
  } | null;
137
137
  featuredMedia: {
138
138
  __typename: "ShopFont";
@@ -158,7 +158,7 @@ export declare const CartFields: import("gql.tada").TadaDocumentNode<{
158
158
  id: string;
159
159
  priceV2: {
160
160
  amount: string;
161
- currencyCode: "MNT";
161
+ currencyCode: "CNY" | "EUR" | "GBP" | "JPY" | "KRW" | "MNT" | "RUB" | "USD";
162
162
  };
163
163
  product: {
164
164
  featuredMedia: {
@@ -201,7 +201,7 @@ export declare const CartFields: import("gql.tada").TadaDocumentNode<{
201
201
  note: string | null;
202
202
  subtotalAmount: {
203
203
  amount: string;
204
- currencyCode: "MNT";
204
+ currencyCode: "CNY" | "EUR" | "GBP" | "JPY" | "KRW" | "MNT" | "RUB" | "USD";
205
205
  };
206
206
  totalQuantity: number;
207
207
  }, {}, {
package/dist/checkout.js CHANGED
@@ -21,7 +21,7 @@ const readMarketingAttribution = createServerOnlyFn(async () => {
21
21
  try {
22
22
  const { getRequest } = await import("@tanstack/react-start/server");
23
23
  const cookies = parseMarketingCookies(getRequest().headers.get("cookie"));
24
- return cookies.fbp || cookies.fbc ? cookies : null;
24
+ return Object.keys(cookies).length > 0 ? cookies : null;
25
25
  } catch (err) {
26
26
  console.warn("[checkout] marketing attribution unavailable:", err);
27
27
  return null;
@@ -1,4 +1,5 @@
1
1
  import { FileInfo } from "./files.js";
2
+ import { MetafieldMap } from "./metafields.js";
2
3
  import { ResultOf } from "gql.tada";
3
4
  import { Money } from "@shop-on/theme-runtime/money";
4
5
  import { ProductOption } from "@shop-on/theme-runtime/product-variants";
@@ -23,6 +24,10 @@ export type CollectionProductData = {
23
24
  media: FileInfo[];
24
25
  options?: ProductOption[];
25
26
  variantMedia?: FileInfo[];
27
+ metafields?: Record<string, {
28
+ type: string;
29
+ value: string;
30
+ }>;
26
31
  };
27
32
  export type CollectionData = {
28
33
  title: string;
@@ -41,12 +46,12 @@ export declare const CollectionProductNodeFragment: import("gql.tada").TadaDocum
41
46
  availableForSale: boolean;
42
47
  compareAtV2: {
43
48
  amount: string;
44
- currencyCode: "MNT";
49
+ currencyCode: "CNY" | "EUR" | "GBP" | "JPY" | "KRW" | "MNT" | "RUB" | "USD";
45
50
  } | null;
46
51
  id: string;
47
52
  priceV2: {
48
53
  amount: string;
49
- currencyCode: "MNT";
54
+ currencyCode: "CNY" | "EUR" | "GBP" | "JPY" | "KRW" | "MNT" | "RUB" | "USD";
50
55
  };
51
56
  sku: string | null;
52
57
  };
@@ -87,6 +92,14 @@ export declare const CollectionProductNodeFragment: import("gql.tada").TadaDocum
87
92
  width: number | null;
88
93
  })[];
89
94
  };
95
+ metafields?: {
96
+ nodes: {
97
+ key: string;
98
+ namespace: string;
99
+ type: "BOOLEAN" | "COLLECTION_REFERENCE" | "COLOR" | "DATE" | "DATE_TIME" | "DECIMAL" | "FILE_REFERENCE" | "INTEGER" | "JSON" | "LIST_BOOLEAN" | "LIST_COLLECTION_REFERENCE" | "LIST_COLOR" | "LIST_DATE" | "LIST_DATE_TIME" | "LIST_DECIMAL" | "LIST_INTEGER" | "LIST_METAOBJECT_REFERENCE" | "LIST_MULTI_LINE_TEXT" | "LIST_PRODUCT_REFERENCE" | "LIST_SINGLE_LINE_TEXT" | "LIST_TAXONOMY_VALUE_REFERENCE" | "LIST_URL" | "LIST_VARIANT_REFERENCE" | "METAOBJECT_REFERENCE" | "MULTI_LINE_TEXT" | "PAGE_REFERENCE" | "PRODUCT_REFERENCE" | "RICH_TEXT" | "SINGLE_LINE_TEXT" | "TAXONOMY_CATEGORY_REFERENCE" | "TAXONOMY_VALUE_REFERENCE" | "URL" | "VARIANT_REFERENCE";
100
+ value: string;
101
+ }[];
102
+ } | undefined;
90
103
  options?: {
91
104
  id: string;
92
105
  linkedMetafield: {
@@ -100,7 +113,7 @@ export declare const CollectionProductNodeFragment: import("gql.tada").TadaDocum
100
113
  availableForSale: boolean;
101
114
  compareAtV2: {
102
115
  amount: string;
103
- currencyCode: "MNT";
116
+ currencyCode: "CNY" | "EUR" | "GBP" | "JPY" | "KRW" | "MNT" | "RUB" | "USD";
104
117
  } | null;
105
118
  featuredMedia: {
106
119
  __typename?: "ShopFont" | undefined;
@@ -115,7 +128,7 @@ export declare const CollectionProductNodeFragment: import("gql.tada").TadaDocum
115
128
  id: string;
116
129
  priceV2: {
117
130
  amount: string;
118
- currencyCode: "MNT";
131
+ currencyCode: "CNY" | "EUR" | "GBP" | "JPY" | "KRW" | "MNT" | "RUB" | "USD";
119
132
  };
120
133
  sku: string | null;
121
134
  } | null | undefined;
@@ -213,6 +226,9 @@ export declare const fetchCollection: (data: {
213
226
  includeOptions?: boolean;
214
227
  includeVariants?: boolean;
215
228
  locale?: string;
229
+ /** Storefront-visible metafields (`"namespace.key"`) each card carries, for
230
+ * the `closest_product_metafield` sources bound on it. Omitted ⇒ none. */
231
+ metafieldKeys?: readonly string[];
216
232
  }) => Promise<CollectionData | null>;
217
233
  /** The liquid pass's `collection` drop source. The drop is metadata only
218
234
  * (`liquidCollection` maps no products; the grid's own page fetch stays the
@@ -300,6 +316,9 @@ type FetchCollectionPageInput = {
300
316
  includeOptions?: boolean;
301
317
  includeVariants?: boolean;
302
318
  locale?: string;
319
+ /** Storefront-visible metafields (`"namespace.key"`) each card carries, for
320
+ * the `closest_product_metafield` sources bound on it. Omitted ⇒ none. */
321
+ metafieldKeys?: readonly string[];
303
322
  };
304
323
  /** Server-driven collection listing. Always returns `null` on missing
305
324
  * collection / GraphQL error so the route loader can map the failure to
@@ -365,5 +384,23 @@ export declare const fetchCollectionMeta: (h: string, locale?: string | undefine
365
384
  * resolution stay lean; the theme loader calls it only when a template binds the
366
385
  * description. null on a miss, a failure, or an empty description. */
367
386
  export declare function fetchCollectionDescriptionHtml(handle: string, locale?: string): Promise<string | null>;
387
+ /** The collection's storefront metafields keyed `"namespace.key"` — the
388
+ * `collection_metafield` dynamic source. Kept out of `fetchCollectionMeta` for
389
+ * the same reason as the description: that lean read is also what every
390
+ * collection list asks for, so metafields there would be fetched for every
391
+ * listed collection. The theme loader calls this only when a template binds a
392
+ * collection metafield. `{}` on a miss or a failure, so the literal renders. */
393
+ export declare const fetchCollectionMetafields: (ref: string, locale?: string) => Promise<MetafieldMap>;
394
+ /** The route collection as the theme loader's dynamic sources read it
395
+ * (`DynamicSourceCollection` in `@shop-on/theme-loader`): the title off the
396
+ * lean meta read the theme already made, and a lazy provider for each source
397
+ * the loader fetches only when a template binds it. One builder so a theme
398
+ * passes `routeCollectionSource(meta, locale)` and picks up a new provider on
399
+ * upgrade, instead of every theme re-listing them. */
400
+ export declare function routeCollectionSource(meta: Pick<CollectionMeta, "handle" | "title">, locale?: string): {
401
+ title: string;
402
+ getDescriptionHtml: () => Promise<string | null>;
403
+ getMetafields: () => Promise<MetafieldMap>;
404
+ };
368
405
  //#endregion
369
406
  export {};
@@ -2,7 +2,8 @@ import { memoTTL } from "./cache.js";
2
2
  import { graphql, request } from "./graphql.js";
3
3
  import { getContentVersion } from "./content-version.js";
4
4
  import { toFileInfo } from "./files.js";
5
- import { BadgeTagsFragment } from "./product.js";
5
+ import { metafieldsReader, toMetafieldMap } from "./metafields.js";
6
+ import { BadgeTagsFragment, metafieldKeysKey, metafieldKeysVariables } from "./product.js";
6
7
  //#region src/collection.ts
7
8
  const CollectionProductNodeFragment = graphql(`
8
9
  fragment CollectionProductNode on ShopProduct @_unmask {
@@ -124,6 +125,16 @@ const CollectionProductNodeFragment = graphql(`
124
125
  height
125
126
  }
126
127
  }
128
+ # Only the metafields a card's bound sources name (one API filter on top
129
+ # of the storefront-visible gate), and nothing unless some are named.
130
+ metafields(first: $metafieldFirst, keys: $metafieldKeys) @include(if: $withMetafields) {
131
+ nodes {
132
+ namespace
133
+ key
134
+ type
135
+ value
136
+ }
137
+ }
127
138
  }
128
139
  `, [BadgeTagsFragment]);
129
140
  const CollectionImageFragment = graphql(`
@@ -164,6 +175,7 @@ function toCollectionProduct(p) {
164
175
  price: toMoney(p.defaultVariant.priceV2),
165
176
  compare_at: p.defaultVariant.compareAtV2 ? toMoney(p.defaultVariant.compareAtV2) : null,
166
177
  media: p.media.nodes.map((n) => toFileInfo(n, { baseWidth: 480 })).filter((m) => m !== null),
178
+ ...p.metafields ? { metafields: toMetafieldMap(p.metafields.nodes) } : {},
167
179
  ...p.options ? { options: p.options.map((o) => ({
168
180
  id: o.id,
169
181
  name: o.name,
@@ -223,6 +235,9 @@ const FetchCollectionQuery = graphql(`
223
235
  $mediaFirst: Int!
224
236
  $includeOptions: Boolean!
225
237
  $includeVariants: Boolean = false
238
+ $withMetafields: Boolean = false
239
+ $metafieldFirst: Int = 0
240
+ $metafieldKeys: [MetafieldKeyInput!] = []
226
241
  ) {
227
242
  collection(handle: $handle, id: $id) {
228
243
  title
@@ -252,13 +267,14 @@ function toMoney(m) {
252
267
  function parseRef(ref) {
253
268
  return ref.startsWith("gid://") ? { id: ref } : { handle: ref };
254
269
  }
255
- const fetchCollectionCached = memoTTL(async ({ handle, first, mediaFirst, includeOptions, includeVariants, locale }) => {
270
+ const fetchCollectionCached = memoTTL(async ({ handle, first, mediaFirst, includeOptions, includeVariants, locale, metafieldKeys }) => {
256
271
  const c = (await request(FetchCollectionQuery, {
257
272
  ...parseRef(handle),
258
273
  first,
259
274
  mediaFirst,
260
275
  includeOptions,
261
- ...variantsVariable(includeVariants)
276
+ ...variantsVariable(includeVariants),
277
+ ...metafieldKeysVariables(metafieldKeys)
262
278
  }, locale ? { acceptLanguage: locale } : void 0)).collection;
263
279
  if (!c) return null;
264
280
  return {
@@ -270,7 +286,7 @@ const fetchCollectionCached = memoTTL(async ({ handle, first, mediaFirst, includ
270
286
  products: c.products.nodes.map(toCollectionProduct)
271
287
  };
272
288
  }, {
273
- key: ({ handle, first, mediaFirst, includeOptions, includeVariants, locale }) => `${handle}|${first}|${mediaFirst}|${includeOptions}|${includeVariants}|${locale ?? ""}`,
289
+ key: ({ handle, first, mediaFirst, includeOptions, includeVariants, locale, metafieldKeys }) => `${handle}|${first}|${mediaFirst}|${includeOptions}|${includeVariants}|${locale ?? ""}|${metafieldKeysKey(metafieldKeys)}`,
274
290
  version: getContentVersion
275
291
  });
276
292
  const fetchCollection = async (data) => {
@@ -355,6 +371,9 @@ const FetchCollectionPageQuery = graphql(`
355
371
  $mediaFirst: Int!
356
372
  $includeOptions: Boolean!
357
373
  $includeVariants: Boolean = false
374
+ $withMetafields: Boolean = false
375
+ $metafieldFirst: Int = 0
376
+ $metafieldKeys: [MetafieldKeyInput!] = []
358
377
  $after: String
359
378
  $filter: CollectionProductFilterInput
360
379
  $sort: CollectionProductSortEnum
@@ -439,6 +458,7 @@ const fetchCollectionPage = async (data) => {
439
458
  mediaFirst,
440
459
  includeOptions,
441
460
  ...variantsVariable(data.includeVariants),
461
+ ...metafieldKeysVariables(data.metafieldKeys),
442
462
  after: after ?? null,
443
463
  filter: filter ?? null,
444
464
  sort: sort ?? null
@@ -493,6 +513,9 @@ const FetchAllProductsQuery = graphql(`
493
513
  $mediaFirst: Int!
494
514
  $includeOptions: Boolean!
495
515
  $includeVariants: Boolean = false
516
+ $withMetafields: Boolean = false
517
+ $metafieldFirst: Int = 0
518
+ $metafieldKeys: [MetafieldKeyInput!] = []
496
519
  ) {
497
520
  products(first: $first) {
498
521
  nodes {
@@ -655,5 +678,43 @@ const fetchCollectionDescriptionRaw = memoTTL(async (handle, locale) => {
655
678
  async function fetchCollectionDescriptionHtml(handle, locale) {
656
679
  return await fetchCollectionDescriptionRaw(handle, locale) || null;
657
680
  }
681
+ const FetchCollectionMetafieldsQuery = graphql(`
682
+ query FetchCollectionMetafields($handle: String, $id: ID) {
683
+ collection(handle: $handle, id: $id) {
684
+ # Storefront-visible only (the API gates on the storefront scope), so the
685
+ # set is a shop's published collection fields; 50 matches the product read.
686
+ metafields(first: 50) {
687
+ nodes {
688
+ namespace
689
+ key
690
+ type
691
+ value
692
+ }
693
+ }
694
+ }
695
+ }
696
+ `);
697
+ /** The collection's storefront metafields keyed `"namespace.key"` — the
698
+ * `collection_metafield` dynamic source. Kept out of `fetchCollectionMeta` for
699
+ * the same reason as the description: that lean read is also what every
700
+ * collection list asks for, so metafields there would be fetched for every
701
+ * listed collection. The theme loader calls this only when a template binds a
702
+ * collection metafield. `{}` on a miss or a failure, so the literal renders. */
703
+ const fetchCollectionMetafields = metafieldsReader("fetchCollectionMetafields", async (handle, locale) => {
704
+ return (await request(FetchCollectionMetafieldsQuery, parseRef(handle), locale ? { acceptLanguage: locale } : void 0)).collection?.metafields.nodes ?? null;
705
+ });
706
+ /** The route collection as the theme loader's dynamic sources read it
707
+ * (`DynamicSourceCollection` in `@shop-on/theme-loader`): the title off the
708
+ * lean meta read the theme already made, and a lazy provider for each source
709
+ * the loader fetches only when a template binds it. One builder so a theme
710
+ * passes `routeCollectionSource(meta, locale)` and picks up a new provider on
711
+ * upgrade, instead of every theme re-listing them. */
712
+ function routeCollectionSource(meta, locale) {
713
+ return {
714
+ title: meta.title,
715
+ getDescriptionHtml: () => fetchCollectionDescriptionHtml(meta.handle, locale),
716
+ getMetafields: () => fetchCollectionMetafields(meta.handle, locale)
717
+ };
718
+ }
658
719
  //#endregion
659
- export { CollectionImageFragment, CollectionProductNodeFragment, fetchAllProducts, fetchCollection, fetchCollectionCard, fetchCollectionDescriptionHtml, fetchCollectionMeta, fetchCollectionPage, fetchCollectionsPage, fetchLiquidCollection, toCollectionProduct, toPriceBounds, variantsVariable };
720
+ export { CollectionImageFragment, CollectionProductNodeFragment, fetchAllProducts, fetchCollection, fetchCollectionCard, fetchCollectionDescriptionHtml, fetchCollectionMeta, fetchCollectionMetafields, fetchCollectionPage, fetchCollectionsPage, fetchLiquidCollection, routeCollectionSource, toCollectionProduct, toPriceBounds, variantsVariable };
@@ -3,7 +3,8 @@
3
3
  * Per-shop storefront content version. Rails bumps a per-shop counter on any
4
4
  * catalog content write and serves it as the storefront `shop.contentVersion`
5
5
  * field; the catalog loaders prepend it to their `memoTTL` keys so an edit
6
- * busts the per-process cache within ~1s instead of waiting out the 60s TTL.
6
+ * busts the per-process cache within seconds instead of waiting out the 60s
7
+ * TTL.
7
8
  * Stock / availability changes are deliberately NOT covered — they stay on
8
9
  * the TTL.
9
10
  *
@@ -18,12 +19,17 @@ export declare function createContentVersionReader({ read, fallback, onError }:
18
19
  fallback?: string;
19
20
  onError?: (error: unknown) => void;
20
21
  }): () => Promise<string>;
22
+ /** Set by the storefront shard's router on every request it forwards. */
23
+ export declare const CONTENT_VERSION_HEADER = "x-shard-content-version";
24
+ /** In the env of an isolate whose shard router sets or drops that header. */
25
+ export declare const CONTENT_VERSION_FLAG = "SHARD_FORWARDS_CONTENT_VERSION";
21
26
  /**
22
- * Memoized for 1s so a fan-out of loaders in a single render shares one Rails
23
- * round trip, and concurrent renders take the lease holder's result — this
24
- * caps the polling cost at one tiny query per second per isolate, failed reads
25
- * included (the reader never throws, so its fallback is cached for the TTL).
27
+ * The version the shard router forwarded with this request (read from Redis by
28
+ * the orchestrator, at most one resolve cache old: ~5s), else the polled one.
29
+ * The header counts only under CONTENT_VERSION_FLAG: off a shard, such as
30
+ * behind `theme dev`'s tunnel or under `wrangler dev`, nothing strips a
31
+ * caller's copy, and a made-up version would miss every catalog cache entry.
26
32
  * Wired into the catalog loaders via the `version` option of `memoTTL`.
27
33
  */
28
- export declare const getContentVersion: () => Promise<string>;
34
+ export declare function getContentVersion(): Promise<string>;
29
35
  //#endregion
@@ -1,4 +1,5 @@
1
1
  import { memoTTL } from "./cache.js";
2
+ import { currentRequest } from "./request-scope.js";
2
3
  import { graphql, request } from "./graphql.js";
3
4
  //#region src/content-version.ts
4
5
  const CONTENT_VERSION_TTL_MS = 1e3;
@@ -7,7 +8,8 @@ const FALLBACK_VERSION = "0";
7
8
  * Per-shop storefront content version. Rails bumps a per-shop counter on any
8
9
  * catalog content write and serves it as the storefront `shop.contentVersion`
9
10
  * field; the catalog loaders prepend it to their `memoTTL` keys so an edit
10
- * busts the per-process cache within ~1s instead of waiting out the 60s TTL.
11
+ * busts the per-process cache within seconds instead of waiting out the 60s
12
+ * TTL.
11
13
  * Stock / availability changes are deliberately NOT covered — they stay on
12
14
  * the TTL.
13
15
  *
@@ -53,11 +55,30 @@ const readContentVersion = createContentVersionReader({
53
55
  * round trip, and concurrent renders take the lease holder's result — this
54
56
  * caps the polling cost at one tiny query per second per isolate, failed reads
55
57
  * included (the reader never throws, so its fallback is cached for the TTL).
56
- * Wired into the catalog loaders via the `version` option of `memoTTL`.
57
58
  */
58
- const getContentVersion = memoTTL(readContentVersion, {
59
+ const pollContentVersion = memoTTL(readContentVersion, {
59
60
  ttlMs: CONTENT_VERSION_TTL_MS,
60
61
  key: () => "v"
61
62
  });
63
+ /** Set by the storefront shard's router on every request it forwards. */
64
+ const CONTENT_VERSION_HEADER = "x-shard-content-version";
65
+ /** In the env of an isolate whose shard router sets or drops that header. */
66
+ const CONTENT_VERSION_FLAG = "SHARD_FORWARDS_CONTENT_VERSION";
67
+ /**
68
+ * The version the shard router forwarded with this request (read from Redis by
69
+ * the orchestrator, at most one resolve cache old: ~5s), else the polled one.
70
+ * The header counts only under CONTENT_VERSION_FLAG: off a shard, such as
71
+ * behind `theme dev`'s tunnel or under `wrangler dev`, nothing strips a
72
+ * caller's copy, and a made-up version would miss every catalog cache entry.
73
+ * Wired into the catalog loaders via the `version` option of `memoTTL`.
74
+ */
75
+ function getContentVersion() {
76
+ return forwardedHeader() ? readForwarded() : pollContentVersion();
77
+ }
78
+ function forwardedHeader() {
79
+ if (process.env["SHARD_FORWARDS_CONTENT_VERSION"] !== "1") return null;
80
+ return currentRequest()?.headers.get("x-shard-content-version") || null;
81
+ }
82
+ const readForwarded = createContentVersionReader({ read: async () => forwardedHeader() });
62
83
  //#endregion
63
- export { createContentVersionReader, getContentVersion };
84
+ export { CONTENT_VERSION_FLAG, CONTENT_VERSION_HEADER, createContentVersionReader, getContentVersion };