@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.
- package/dist/account.js +7 -4
- package/dist/analytics-proxy.d.ts +2 -1
- package/dist/analytics-proxy.js +2 -1
- package/dist/blog.d.ts +26 -0
- package/dist/blog.js +65 -1
- package/dist/cart.d.ts +7 -7
- package/dist/checkout.js +1 -1
- package/dist/collection.d.ts +41 -4
- package/dist/collection.js +66 -5
- package/dist/content-version.d.ts +12 -6
- package/dist/content-version.js +25 -4
- package/dist/graphql-env.d.ts +435 -29
- package/dist/graphql.js +8 -2
- package/dist/marketing-cookies.d.ts +6 -4
- package/dist/marketing-cookies.js +35 -14
- package/dist/metafields.d.ts +7 -0
- package/dist/metafields.js +33 -0
- package/dist/page.d.ts +15 -0
- package/dist/page.js +35 -1
- package/dist/product.d.ts +16 -1
- package/dist/product.js +44 -34
- package/dist/recommendations.d.ts +3 -0
- package/dist/recommendations.js +8 -3
- package/dist/schema.storefront.graphql +434 -20
- package/dist/search.d.ts +3 -0
- package/dist/search.js +5 -0
- package/package.json +3 -3
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
return
|
|
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;
|
package/dist/analytics-proxy.js
CHANGED
|
@@ -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.
|
|
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;
|
package/dist/collection.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/collection.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
|
34
|
+
export declare function getContentVersion(): Promise<string>;
|
|
29
35
|
//#endregion
|
package/dist/content-version.js
CHANGED
|
@@ -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
|
|
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
|
|
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 };
|