@wocart/storefront 1.19.0 → 1.21.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/CHANGELOG.md +91 -0
- package/USAGE.md +317 -4
- package/dist/abandoned-checkout.d.ts +45 -0
- package/dist/abandoned-checkout.js +81 -3
- package/dist/answer-readers.d.ts +11 -4
- package/dist/answer-readers.js +114 -0
- package/dist/astro.d.ts +4 -0
- package/dist/astro.js +2 -0
- package/dist/basket.d.ts +11 -1
- package/dist/basket.js +17 -0
- package/dist/catalog.d.ts +24 -0
- package/dist/catalog.js +33 -0
- package/dist/client/orders.d.ts +10 -1
- package/dist/client/orders.js +8 -1
- package/dist/client/products.d.ts +13 -3
- package/dist/client/products.js +35 -12
- package/dist/coupon.d.ts +16 -1
- package/dist/coupon.js +37 -0
- package/dist/endpoints/storefront-routes.d.ts +8 -2
- package/dist/endpoints/storefront-routes.js +9 -2
- package/dist/index.d.ts +15 -7
- package/dist/index.js +9 -4
- package/dist/operations.d.ts +4 -0
- package/dist/operations.js +4 -0
- package/dist/order-lookup.d.ts +51 -0
- package/dist/order-lookup.js +44 -0
- package/dist/order-number.d.ts +9 -0
- package/dist/order-number.js +11 -0
- package/dist/product-view.d.ts +8 -0
- package/dist/product-view.js +26 -4
- package/dist/quote.d.ts +27 -0
- package/dist/quote.js +43 -0
- package/dist/review-capture.d.ts +77 -0
- package/dist/review-capture.js +172 -0
- package/dist/review-media.d.ts +8 -75
- package/dist/review-media.js +7 -170
- package/dist/review-session.d.ts +97 -0
- package/dist/review-session.js +157 -0
- package/dist/reviews.d.ts +33 -0
- package/dist/reviews.js +41 -0
- package/dist/routes.d.ts +2 -0
- package/dist/routes.js +3 -0
- package/dist/schemas.d.ts +98 -0
- package/dist/schemas.js +31 -0
- package/dist/seo.d.ts +47 -0
- package/dist/seo.js +87 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +1 -0
- package/dist/sitemap.d.ts +22 -0
- package/dist/sitemap.js +51 -0
- package/dist/store-request.d.ts +96 -0
- package/dist/store-request.js +102 -0
- package/dist/types.d.ts +50 -2
- package/dist/utils/image.d.ts +48 -0
- package/dist/utils/image.js +51 -0
- package/package.json +1 -1
package/dist/schemas.d.ts
CHANGED
|
@@ -109,6 +109,8 @@ export declare const ProductListSchema: z.ZodObject<{
|
|
|
109
109
|
createdAt: z.ZodString;
|
|
110
110
|
updatedAt: z.ZodString;
|
|
111
111
|
shippingProfileId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
112
|
+
metaTitle: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
113
|
+
metaDescription: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
112
114
|
variantOptions: z.ZodNullable<z.ZodString>;
|
|
113
115
|
tags: z.ZodNullable<z.ZodString>;
|
|
114
116
|
coverImage: z.ZodNullable<z.ZodObject<{
|
|
@@ -162,6 +164,8 @@ export declare const ProductSchema: z.ZodObject<{
|
|
|
162
164
|
createdAt: z.ZodString;
|
|
163
165
|
updatedAt: z.ZodString;
|
|
164
166
|
shippingProfileId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
167
|
+
metaTitle: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
168
|
+
metaDescription: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
165
169
|
variantOptions: z.ZodNullable<z.ZodArray<z.ZodObject<{
|
|
166
170
|
name: z.ZodString;
|
|
167
171
|
values: z.ZodArray<z.ZodObject<{
|
|
@@ -276,6 +280,16 @@ export declare const StoreCollectionSchema: z.ZodObject<{
|
|
|
276
280
|
handle: z.ZodString;
|
|
277
281
|
title: z.ZodString;
|
|
278
282
|
description: z.ZodNullable<z.ZodString>;
|
|
283
|
+
productCount: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
|
284
|
+
image: z.ZodOptional<z.ZodNullable<z.ZodObject<{
|
|
285
|
+
id: z.ZodString;
|
|
286
|
+
src: z.ZodString;
|
|
287
|
+
srcSm: z.ZodNullable<z.ZodString>;
|
|
288
|
+
srcMd: z.ZodNullable<z.ZodString>;
|
|
289
|
+
srcLg: z.ZodNullable<z.ZodString>;
|
|
290
|
+
altText: z.ZodNullable<z.ZodString>;
|
|
291
|
+
position: z.ZodNumber;
|
|
292
|
+
}, z.core.$strip>>>;
|
|
279
293
|
}, z.core.$strip>;
|
|
280
294
|
export declare const StoreMenuSchema: z.ZodObject<{
|
|
281
295
|
handle: z.ZodString;
|
|
@@ -600,6 +614,8 @@ export declare const LandingPageSchema: z.ZodObject<{
|
|
|
600
614
|
createdAt: z.ZodString;
|
|
601
615
|
updatedAt: z.ZodString;
|
|
602
616
|
shippingProfileId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
617
|
+
metaTitle: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
618
|
+
metaDescription: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
603
619
|
variantOptions: z.ZodNullable<z.ZodArray<z.ZodObject<{
|
|
604
620
|
name: z.ZodString;
|
|
605
621
|
values: z.ZodArray<z.ZodObject<{
|
|
@@ -821,6 +837,88 @@ export declare const OrderTrackingSchema: z.ZodObject<{
|
|
|
821
837
|
Purchase: "Purchase";
|
|
822
838
|
}>>;
|
|
823
839
|
}, z.core.$strip>;
|
|
840
|
+
/**
|
|
841
|
+
* Where a shopper's order is — POST /store/orders/status. `stage` is an open
|
|
842
|
+
* string: a newer server may name a step this release does not know.
|
|
843
|
+
*/
|
|
844
|
+
export declare const ShopperOrderStatusSchema: z.ZodObject<{
|
|
845
|
+
orderNumber: z.ZodString;
|
|
846
|
+
placedAt: z.ZodString;
|
|
847
|
+
stage: z.ZodString;
|
|
848
|
+
deliveryType: z.ZodEnum<{
|
|
849
|
+
home: "home";
|
|
850
|
+
stop_desk: "stop_desk";
|
|
851
|
+
}>;
|
|
852
|
+
lines: z.ZodArray<z.ZodObject<{
|
|
853
|
+
name: z.ZodString;
|
|
854
|
+
variantLabel: z.ZodNullable<z.ZodString>;
|
|
855
|
+
quantity: z.ZodNumber;
|
|
856
|
+
}, z.core.$strip>>;
|
|
857
|
+
total: z.ZodNumber;
|
|
858
|
+
tracking: z.ZodNullable<z.ZodObject<{
|
|
859
|
+
number: z.ZodString;
|
|
860
|
+
carrier: z.ZodNullable<z.ZodString>;
|
|
861
|
+
}, z.core.$strip>>;
|
|
862
|
+
}, z.core.$strip>;
|
|
863
|
+
/** A page of products with the whole listing's size; `total` is absent from a server that predates it. */
|
|
864
|
+
export declare const ProductPageEnvelopeSchema: z.ZodObject<{
|
|
865
|
+
data: z.ZodArray<z.ZodObject<{
|
|
866
|
+
id: z.ZodString;
|
|
867
|
+
name: z.ZodString;
|
|
868
|
+
description: z.ZodNullable<z.ZodString>;
|
|
869
|
+
descriptionFormat: z.ZodEnum<{
|
|
870
|
+
html: "html";
|
|
871
|
+
text: "text";
|
|
872
|
+
}>;
|
|
873
|
+
descriptionPlain: z.ZodNullable<z.ZodString>;
|
|
874
|
+
handle: z.ZodString;
|
|
875
|
+
currency: z.ZodString;
|
|
876
|
+
price: z.ZodNumber;
|
|
877
|
+
compareAtPrice: z.ZodNullable<z.ZodNumber>;
|
|
878
|
+
costPrice: z.ZodNullable<z.ZodNumber>;
|
|
879
|
+
type: z.ZodEnum<{
|
|
880
|
+
DIGITAL: "DIGITAL";
|
|
881
|
+
PHYSICAL: "PHYSICAL";
|
|
882
|
+
}>;
|
|
883
|
+
hasVariants: z.ZodBoolean;
|
|
884
|
+
sku: z.ZodNullable<z.ZodString>;
|
|
885
|
+
inventory: z.ZodNumber;
|
|
886
|
+
trackInventory: z.ZodBoolean;
|
|
887
|
+
lowStockThreshold: z.ZodNumber;
|
|
888
|
+
categoryId: z.ZodNullable<z.ZodString>;
|
|
889
|
+
visibility: z.ZodBoolean;
|
|
890
|
+
status: z.ZodEnum<{
|
|
891
|
+
ACTIVE: "ACTIVE";
|
|
892
|
+
ARCHIVED: "ARCHIVED";
|
|
893
|
+
DRAFT: "DRAFT";
|
|
894
|
+
}>;
|
|
895
|
+
showInStore: z.ZodBoolean;
|
|
896
|
+
storeFeatured: z.ZodBoolean;
|
|
897
|
+
deletedAt: z.ZodOptional<z.ZodNull>;
|
|
898
|
+
publishedAt: z.ZodNullable<z.ZodString>;
|
|
899
|
+
createdAt: z.ZodString;
|
|
900
|
+
updatedAt: z.ZodString;
|
|
901
|
+
shippingProfileId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
902
|
+
metaTitle: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
903
|
+
metaDescription: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
904
|
+
variantOptions: z.ZodNullable<z.ZodString>;
|
|
905
|
+
tags: z.ZodNullable<z.ZodString>;
|
|
906
|
+
coverImage: z.ZodNullable<z.ZodObject<{
|
|
907
|
+
id: z.ZodString;
|
|
908
|
+
src: z.ZodString;
|
|
909
|
+
srcSm: z.ZodNullable<z.ZodString>;
|
|
910
|
+
srcMd: z.ZodNullable<z.ZodString>;
|
|
911
|
+
srcLg: z.ZodNullable<z.ZodString>;
|
|
912
|
+
altText: z.ZodNullable<z.ZodString>;
|
|
913
|
+
position: z.ZodNumber;
|
|
914
|
+
}, z.core.$strip>>;
|
|
915
|
+
reviewStats: z.ZodNullable<z.ZodObject<{
|
|
916
|
+
avgRating: z.ZodNumber;
|
|
917
|
+
reviewCount: z.ZodNumber;
|
|
918
|
+
}, z.core.$strip>>;
|
|
919
|
+
}, z.core.$strip>>;
|
|
920
|
+
total: z.ZodOptional<z.ZodNumber>;
|
|
921
|
+
}, z.core.$strip>;
|
|
824
922
|
/**
|
|
825
923
|
* The `{ id }` success payload of the write endpoints that only mint a row:
|
|
826
924
|
* POST /store/reviews and POST /store/abandoned.
|
package/dist/schemas.js
CHANGED
|
@@ -86,6 +86,10 @@ const productBaseFields = {
|
|
|
86
86
|
currency: z.string(),
|
|
87
87
|
price: z.number(),
|
|
88
88
|
compareAtPrice: z.number().nullable(),
|
|
89
|
+
/**
|
|
90
|
+
* @deprecated Always null: what the merchant paid is never served to a
|
|
91
|
+
* storefront. Older servers sent it; a later major drops it. Do not read it.
|
|
92
|
+
*/
|
|
89
93
|
costPrice: z.number().nullable(),
|
|
90
94
|
type: z.enum(["PHYSICAL", "DIGITAL"]),
|
|
91
95
|
hasVariants: z.boolean(),
|
|
@@ -109,6 +113,10 @@ const productBaseFields = {
|
|
|
109
113
|
createdAt: z.string().datetime(),
|
|
110
114
|
updatedAt: z.string().datetime(),
|
|
111
115
|
shippingProfileId: z.string().nullable().optional(),
|
|
116
|
+
/** The title search engines and shared links show, in the client's language; null or absent: use `productMeta(product)`, which falls back to the name. */
|
|
117
|
+
metaTitle: z.string().nullish(),
|
|
118
|
+
/** The description search engines and shared links show; null or absent: `productMeta(product)` shortens the product's own. */
|
|
119
|
+
metaDescription: z.string().nullish(),
|
|
112
120
|
};
|
|
113
121
|
const variantOptionSchema = z.object({
|
|
114
122
|
name: z.string(),
|
|
@@ -124,6 +132,7 @@ const variantOptionSchema = z.object({
|
|
|
124
132
|
const rawProductFields = {
|
|
125
133
|
...productBaseFields,
|
|
126
134
|
variantOptions: z.string().nullable(),
|
|
135
|
+
/** The tags as stored JSON text; read them with `productTags(product)`, which a product page's list also goes through. */
|
|
127
136
|
tags: z.string().nullable(),
|
|
128
137
|
coverImage: ProductImageSchema.nullable(),
|
|
129
138
|
reviewStats: reviewStatsSchema.nullable(),
|
|
@@ -170,6 +179,10 @@ export const StoreCollectionSchema = z.object({
|
|
|
170
179
|
handle: z.string(),
|
|
171
180
|
title: z.string(),
|
|
172
181
|
description: z.string().nullable(),
|
|
182
|
+
/** How many products a shopper sees in it; absent from a server that predates it. */
|
|
183
|
+
productCount: z.number().int().min(0).nullish(),
|
|
184
|
+
/** Its first listed product's first photo; absent from a server that predates it. */
|
|
185
|
+
image: ProductImageSchema.nullish(),
|
|
173
186
|
});
|
|
174
187
|
export const StoreMenuSchema = z.object({
|
|
175
188
|
handle: z.string(),
|
|
@@ -413,6 +426,24 @@ export const OrderTrackingSchema = z.object({
|
|
|
413
426
|
pixelId: z.string().nullable(),
|
|
414
427
|
event: z.enum(["Purchase", "Lead"]).nullable(),
|
|
415
428
|
});
|
|
429
|
+
/**
|
|
430
|
+
* Where a shopper's order is — POST /store/orders/status. `stage` is an open
|
|
431
|
+
* string: a newer server may name a step this release does not know.
|
|
432
|
+
*/
|
|
433
|
+
export const ShopperOrderStatusSchema = z.object({
|
|
434
|
+
orderNumber: z.string(),
|
|
435
|
+
placedAt: z.string(),
|
|
436
|
+
stage: z.string(),
|
|
437
|
+
deliveryType: z.enum(["home", "stop_desk"]),
|
|
438
|
+
lines: z.array(z.object({ name: z.string(), variantLabel: z.string().nullable(), quantity: z.number().int() })),
|
|
439
|
+
total: z.number(),
|
|
440
|
+
tracking: z.object({ number: z.string(), carrier: z.string().nullable() }).nullable(),
|
|
441
|
+
});
|
|
442
|
+
/** A page of products with the whole listing's size; `total` is absent from a server that predates it. */
|
|
443
|
+
export const ProductPageEnvelopeSchema = z.object({
|
|
444
|
+
data: z.array(ProductListSchema),
|
|
445
|
+
total: z.number().int().min(0).optional(),
|
|
446
|
+
});
|
|
416
447
|
/**
|
|
417
448
|
* The `{ id }` success payload of the write endpoints that only mint a row:
|
|
418
449
|
* POST /store/reviews and POST /store/abandoned.
|
package/dist/seo.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Product, ProductList } from "./types.js";
|
|
2
|
+
/** The lengths a search-engine title and description may have: about what a search result shows. */
|
|
3
|
+
export declare const SEO_TEXT_LIMITS: {
|
|
4
|
+
readonly titleMax: 60;
|
|
5
|
+
readonly descriptionMax: 160;
|
|
6
|
+
};
|
|
7
|
+
/** A page's `<title>` text and `<meta name="description">`; description null when there is nothing to say. */
|
|
8
|
+
export interface PageMeta {
|
|
9
|
+
title: string;
|
|
10
|
+
description: string | null;
|
|
11
|
+
}
|
|
12
|
+
type MetaSource = Pick<ProductList, "name" | "description" | "descriptionFormat" | "descriptionPlain"> & {
|
|
13
|
+
metaTitle?: string | null | undefined;
|
|
14
|
+
metaDescription?: string | null | undefined;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* A product's title and description for search engines and shared links:
|
|
18
|
+
* what the merchant wrote for them, else its name and the start of its own
|
|
19
|
+
* description as plain text — never markup — within `SEO_TEXT_LIMITS`.
|
|
20
|
+
* The store's name is the theme's to add to the title.
|
|
21
|
+
*/
|
|
22
|
+
export declare function productMeta(product: MetaSource): PageMeta;
|
|
23
|
+
export interface ProductStructuredDataOptions {
|
|
24
|
+
/** The page's absolute URL. */
|
|
25
|
+
url: string;
|
|
26
|
+
/** The store's name, as the brand when the product has no other. */
|
|
27
|
+
storeName?: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The schema.org `Product` of a product page, for a
|
|
31
|
+
* `<script type="application/ld+json">` written with `jsonLd()`: name,
|
|
32
|
+
* description, photos, SKU, its price and stock as an `Offer` — an
|
|
33
|
+
* `AggregateOffer` with the lowest and highest price when its variants cost
|
|
34
|
+
* different amounts — and its rating once it has reviews.
|
|
35
|
+
*/
|
|
36
|
+
export declare function productStructuredData(product: Product, options: ProductStructuredDataOptions): Record<string, unknown>;
|
|
37
|
+
/**
|
|
38
|
+
* Structured data as the text of a `<script type="application/ld+json">`:
|
|
39
|
+
* JSON with `<`, `>`, `&` and the two line separators escaped, so no value — a
|
|
40
|
+
* product name holding `</script>` — can end the script or start markup.
|
|
41
|
+
*
|
|
42
|
+
* ```astro
|
|
43
|
+
* <script type="application/ld+json" set:html={jsonLd(productStructuredData(product, { url }))} />
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export declare function jsonLd(value: unknown): string;
|
|
47
|
+
export {};
|
package/dist/seo.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a product page tells search engines and shared links: its title and
|
|
3
|
+
* description, and its structured data — decided once, so every theme ranks
|
|
4
|
+
* the same products the same way.
|
|
5
|
+
*/
|
|
6
|
+
import { getProductView } from "./product-view.js";
|
|
7
|
+
import { initialVariant } from "./variant-picker.js";
|
|
8
|
+
/** The lengths a search-engine title and description may have: about what a search result shows. */
|
|
9
|
+
export const SEO_TEXT_LIMITS = { titleMax: 60, descriptionMax: 160 };
|
|
10
|
+
const collapse = (text) => text.replace(/\s+/g, " ").trim();
|
|
11
|
+
/** `text` within `max` characters, cut at a word with an ellipsis when it is longer. */
|
|
12
|
+
function shorten(text, max) {
|
|
13
|
+
if (text.length <= max)
|
|
14
|
+
return text;
|
|
15
|
+
const cut = text.slice(0, max - 1);
|
|
16
|
+
const space = cut.lastIndexOf(" ");
|
|
17
|
+
return `${(space > max / 2 ? cut.slice(0, space) : cut).trimEnd()}…`;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* A product's title and description for search engines and shared links:
|
|
21
|
+
* what the merchant wrote for them, else its name and the start of its own
|
|
22
|
+
* description as plain text — never markup — within `SEO_TEXT_LIMITS`.
|
|
23
|
+
* The store's name is the theme's to add to the title.
|
|
24
|
+
*/
|
|
25
|
+
export function productMeta(product) {
|
|
26
|
+
const title = collapse(product.metaTitle ?? "") || collapse(product.name);
|
|
27
|
+
const written = collapse(product.metaDescription ?? "");
|
|
28
|
+
if (written)
|
|
29
|
+
return { title, description: written };
|
|
30
|
+
const plain = product.descriptionPlain ?? (product.descriptionFormat === "text" ? product.description : null);
|
|
31
|
+
const text = collapse(plain ?? "");
|
|
32
|
+
return { title, description: text ? shorten(text, SEO_TEXT_LIMITS.descriptionMax) : null };
|
|
33
|
+
}
|
|
34
|
+
const AVAILABILITY = { inStock: "https://schema.org/InStock", outOfStock: "https://schema.org/OutOfStock" };
|
|
35
|
+
/**
|
|
36
|
+
* The schema.org `Product` of a product page, for a
|
|
37
|
+
* `<script type="application/ld+json">` written with `jsonLd()`: name,
|
|
38
|
+
* description, photos, SKU, its price and stock as an `Offer` — an
|
|
39
|
+
* `AggregateOffer` with the lowest and highest price when its variants cost
|
|
40
|
+
* different amounts — and its rating once it has reviews.
|
|
41
|
+
*/
|
|
42
|
+
export function productStructuredData(product, options) {
|
|
43
|
+
const meta = productMeta(product);
|
|
44
|
+
const view = getProductView(product);
|
|
45
|
+
const variants = product.hasVariants ? product.variants.filter((variant) => variant.active) : [];
|
|
46
|
+
const prices = variants.map((variant) => variant.price);
|
|
47
|
+
const sellable = product.hasVariants
|
|
48
|
+
? variants.some((variant) => !product.trackInventory || variant.inventory > 0)
|
|
49
|
+
: !view.isOutOfStock;
|
|
50
|
+
const availability = sellable ? AVAILABILITY.inStock : AVAILABILITY.outOfStock;
|
|
51
|
+
const ranged = prices.length > 1 && Math.min(...prices) !== Math.max(...prices);
|
|
52
|
+
const offers = ranged
|
|
53
|
+
? { "@type": "AggregateOffer", url: options.url, priceCurrency: product.currency, lowPrice: Math.min(...prices), highPrice: Math.max(...prices), offerCount: prices.length, availability }
|
|
54
|
+
: { "@type": "Offer", url: options.url, priceCurrency: product.currency, price: view.basePrice, availability, itemCondition: "https://schema.org/NewCondition" };
|
|
55
|
+
const sku = product.sku ?? (product.hasVariants ? initialVariant(product)?.sku : null) ?? null;
|
|
56
|
+
const images = [...product.images].sort((a, b) => a.position - b.position).map((image) => image.src);
|
|
57
|
+
const rating = product.reviewStats && product.reviewStats.reviewCount > 0 ? product.reviewStats : null;
|
|
58
|
+
return {
|
|
59
|
+
"@context": "https://schema.org",
|
|
60
|
+
"@type": "Product",
|
|
61
|
+
name: collapse(product.name),
|
|
62
|
+
...(meta.description ? { description: meta.description } : {}),
|
|
63
|
+
...(images.length > 0 ? { image: images } : {}),
|
|
64
|
+
...(sku ? { sku } : {}),
|
|
65
|
+
url: options.url,
|
|
66
|
+
...(options.storeName ? { brand: { "@type": "Brand", name: options.storeName } } : {}),
|
|
67
|
+
offers,
|
|
68
|
+
...(rating ? { aggregateRating: { "@type": "AggregateRating", ratingValue: rating.avgRating, reviewCount: rating.reviewCount } } : {}),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Structured data as the text of a `<script type="application/ld+json">`:
|
|
73
|
+
* JSON with `<`, `>`, `&` and the two line separators escaped, so no value — a
|
|
74
|
+
* product name holding `</script>` — can end the script or start markup.
|
|
75
|
+
*
|
|
76
|
+
* ```astro
|
|
77
|
+
* <script type="application/ld+json" set:html={jsonLd(productStructuredData(product, { url }))} />
|
|
78
|
+
* ```
|
|
79
|
+
*/
|
|
80
|
+
export function jsonLd(value) {
|
|
81
|
+
return JSON.stringify(value)
|
|
82
|
+
.replace(/</g, "\\u003c")
|
|
83
|
+
.replace(/>/g, "\\u003e")
|
|
84
|
+
.replace(/&/g, "\\u0026")
|
|
85
|
+
.replace(/\u2028/g, "\\u2028")
|
|
86
|
+
.replace(/\u2029/g, "\\u2029");
|
|
87
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -6,3 +6,4 @@ export { previewConfig, previewResponseHeaders, readPreviewRequest, verifyPrevie
|
|
|
6
6
|
export { REVALIDATE_PATH, readRevalidateRequest } from "./revalidate.js";
|
|
7
7
|
export type { RevalidateClaims, RevalidateRequest, RevalidateRequestOptions } from "./revalidate.js";
|
|
8
8
|
export type { PreviewClaims, PreviewDraft, PreviewRequest, PreviewRequestOptions, PreviewTokenCheck, PreviewTokenOptions, PreviewTokenRefusal, } from "./preview.js";
|
|
9
|
+
export { catalogPaths, sitemapXml } from "./sitemap.js";
|
package/dist/server.js
CHANGED
|
@@ -2,3 +2,4 @@ export { createStoreClient } from "./client/index.js";
|
|
|
2
2
|
export { StorefrontError, isStorefrontError } from "./client/errors.js";
|
|
3
3
|
export { previewConfig, previewResponseHeaders, readPreviewRequest, verifyPreviewToken } from "./preview.js";
|
|
4
4
|
export { REVALIDATE_PATH, readRevalidateRequest } from "./revalidate.js";
|
|
5
|
+
export { catalogPaths, sitemapXml } from "./sitemap.js";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The storefront's sitemap: every page a shopper can land on, in every
|
|
3
|
+
* published language, as search engines read multilingual sites — one
|
|
4
|
+
* `<url>` per page per language, each naming all its language versions and
|
|
5
|
+
* `x-default`. Landing pages are left out: they are ad destinations, often
|
|
6
|
+
* unlisted from the store.
|
|
7
|
+
*/
|
|
8
|
+
import type { StoreClient } from "./client/index.js";
|
|
9
|
+
import { type LocaleRouting } from "./locale-routing.js";
|
|
10
|
+
import type { StoreConfig } from "./types.js";
|
|
11
|
+
/**
|
|
12
|
+
* The sitemap of `paths` — unprefixed page paths (`/products/robe`) — on
|
|
13
|
+
* `origin`, in every language `routing` publishes. A path given twice is
|
|
14
|
+
* listed once.
|
|
15
|
+
*/
|
|
16
|
+
export declare function sitemapXml(paths: readonly string[], routing: LocaleRouting, origin: string): string;
|
|
17
|
+
/**
|
|
18
|
+
* Every page of the store a sitemap lists, unprefixed: home, the catalog,
|
|
19
|
+
* each category, each product (the whole catalog, read page by page) and each
|
|
20
|
+
* published text page.
|
|
21
|
+
*/
|
|
22
|
+
export declare function catalogPaths(client: StoreClient, config: Pick<StoreConfig, "pages">): Promise<string[]>;
|
package/dist/sitemap.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { PRODUCT_PAGE_MAX } from "./client/products.js";
|
|
2
|
+
import { alternateLinks, localizeHref } from "./locale-routing.js";
|
|
3
|
+
import { storefrontPath } from "./routes.js";
|
|
4
|
+
/** The most catalog pages `catalogPaths` reads: a sitemap holds 50,000 addresses at most. */
|
|
5
|
+
const MAX_CATALOG_PAGES = 500;
|
|
6
|
+
const escapeXml = (value) => value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
7
|
+
/**
|
|
8
|
+
* The sitemap of `paths` — unprefixed page paths (`/products/robe`) — on
|
|
9
|
+
* `origin`, in every language `routing` publishes. A path given twice is
|
|
10
|
+
* listed once.
|
|
11
|
+
*/
|
|
12
|
+
export function sitemapXml(paths, routing, origin) {
|
|
13
|
+
const base = origin.replace(/\/+$/, "");
|
|
14
|
+
const multilingual = routing.published.length > 1;
|
|
15
|
+
const urls = [...new Set(paths)].flatMap((path) => {
|
|
16
|
+
const alternates = multilingual
|
|
17
|
+
? alternateLinks(path, routing, base).map((link) => ` <xhtml:link rel="alternate" hreflang="${escapeXml(link.hreflang)}" href="${escapeXml(link.href)}"/>`)
|
|
18
|
+
: [];
|
|
19
|
+
return routing.published.map((locale) => [" <url>", ` <loc>${escapeXml(`${base}${localizeHref(path, locale, routing)}`)}</loc>`, ...alternates, " </url>"].join("\n"));
|
|
20
|
+
});
|
|
21
|
+
return [
|
|
22
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
23
|
+
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">',
|
|
24
|
+
...urls,
|
|
25
|
+
"</urlset>",
|
|
26
|
+
"",
|
|
27
|
+
].join("\n");
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Every page of the store a sitemap lists, unprefixed: home, the catalog,
|
|
31
|
+
* each category, each product (the whole catalog, read page by page) and each
|
|
32
|
+
* published text page.
|
|
33
|
+
*/
|
|
34
|
+
export async function catalogPaths(client, config) {
|
|
35
|
+
const products = [];
|
|
36
|
+
const categories = client.categories.list();
|
|
37
|
+
for (let page = 0, offset = 0; page < MAX_CATALOG_PAGES; page++) {
|
|
38
|
+
const result = await client.products.page({ limit: PRODUCT_PAGE_MAX, offset });
|
|
39
|
+
products.push(...result.items.map((product) => storefrontPath("product", { handle: product.handle })));
|
|
40
|
+
if (!result.hasNext || result.items.length === 0)
|
|
41
|
+
break;
|
|
42
|
+
offset += result.items.length;
|
|
43
|
+
}
|
|
44
|
+
return [
|
|
45
|
+
storefrontPath("home"),
|
|
46
|
+
storefrontPath("catalog"),
|
|
47
|
+
...(await categories).map((category) => storefrontPath("category", { slug: category.slug })),
|
|
48
|
+
...products,
|
|
49
|
+
...config.pages.map((page) => storefrontPath("page", { slug: page.slug })),
|
|
50
|
+
];
|
|
51
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which store a request renders, in which language — the rules every theme's
|
|
3
|
+
* middleware needs, decided once:
|
|
4
|
+
*
|
|
5
|
+
* - A page takes its language from its path (`/fr/products/robe`). The
|
|
6
|
+
* primary's own prefix redirects permanently to its one address (`/ar/x` →
|
|
7
|
+
* `/x`); a language the store stopped publishing redirects temporarily to the
|
|
8
|
+
* primary page, since it may publish it again.
|
|
9
|
+
* - An endpoint (`/api/…`) or a server island (`/_server-islands/…`) serves part
|
|
10
|
+
* of a page in a request with no language in its URL: it takes the language
|
|
11
|
+
* of the page that asked, from its same-origin `Referer`, and is never
|
|
12
|
+
* redirected or rewritten.
|
|
13
|
+
* - An editor's preview renders the store as the draft would leave it, in the
|
|
14
|
+
* language the draft asks for when the store serves it.
|
|
15
|
+
*
|
|
16
|
+
* The theme keeps what is its own: the client it creates (one per request),
|
|
17
|
+
* the config it falls back to when the store cannot be read, and the context
|
|
18
|
+
* it builds from what these return.
|
|
19
|
+
*/
|
|
20
|
+
import type { StoreClient } from "./client/index.js";
|
|
21
|
+
import { type LocaleRouting } from "./locale-routing.js";
|
|
22
|
+
import { type PreviewDraft } from "./preview.js";
|
|
23
|
+
import type { StoreConfig } from "./types.js";
|
|
24
|
+
export interface StoreRequestOptions {
|
|
25
|
+
/** The request's client in `locale`, or in the store's primary language when absent. */
|
|
26
|
+
client: (locale?: string) => StoreClient;
|
|
27
|
+
/** What renders when the store's config cannot be read: a shell with the checkout switched off. */
|
|
28
|
+
fallback: StoreConfig;
|
|
29
|
+
/** Whether a path serves part of a page rather than a page. Default: `/api/` and `/_server-islands/`. */
|
|
30
|
+
embedded?: (pathname: string) => boolean;
|
|
31
|
+
}
|
|
32
|
+
/** The store a request renders. */
|
|
33
|
+
export interface ResolvedStore {
|
|
34
|
+
/** The client in this request's language; every read of the page goes through it. */
|
|
35
|
+
client: StoreClient;
|
|
36
|
+
config: StoreConfig;
|
|
37
|
+
/** The language this request renders in. */
|
|
38
|
+
locale: string;
|
|
39
|
+
/** The store's languages, as links need them. */
|
|
40
|
+
routing: LocaleRouting;
|
|
41
|
+
/** The path without its language prefix: the route the page renders. */
|
|
42
|
+
pathname: string;
|
|
43
|
+
}
|
|
44
|
+
export type StoreRequestResolution = ({
|
|
45
|
+
kind: "page";
|
|
46
|
+
/** An endpoint or server island: never redirected, never rewritten. */
|
|
47
|
+
embedded: boolean;
|
|
48
|
+
/** The path and query to render instead of the request's (`/fr/x` renders `/x`); null to render it as it is. */
|
|
49
|
+
rewrite: string | null;
|
|
50
|
+
} & ResolvedStore) | {
|
|
51
|
+
kind: "redirect";
|
|
52
|
+
/** Where the shopper goes, the request's query kept. */
|
|
53
|
+
location: string;
|
|
54
|
+
status: 301 | 302;
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* The store a request renders and the language it renders in, or the redirect
|
|
58
|
+
* it answers:
|
|
59
|
+
*
|
|
60
|
+
* ```ts
|
|
61
|
+
* const request = await resolveStoreRequest(
|
|
62
|
+
* { url: context.url, referer: context.request.headers.get("Referer") },
|
|
63
|
+
* { client: createStore, fallback: FALLBACK_CONFIG },
|
|
64
|
+
* );
|
|
65
|
+
* if (request.kind === "redirect") return context.redirect(request.location, request.status);
|
|
66
|
+
* context.locals.store = buildContext(request);
|
|
67
|
+
* return request.rewrite ? next(request.rewrite) : next();
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
export declare function resolveStoreRequest(request: {
|
|
71
|
+
url: URL;
|
|
72
|
+
referer: string | null;
|
|
73
|
+
}, options: StoreRequestOptions): Promise<StoreRequestResolution>;
|
|
74
|
+
/**
|
|
75
|
+
* The store an editor's draft previews: its theme settings and text edits
|
|
76
|
+
* over the saved ones, conversion tracking off, in the language the draft
|
|
77
|
+
* asks for when the store serves it — else the primary. `pathname` is the
|
|
78
|
+
* route the draft previews (`draft.path`), else the home page.
|
|
79
|
+
*/
|
|
80
|
+
export declare function resolvePreviewStore(draft: PreviewDraft, options: Omit<StoreRequestOptions, "embedded">): Promise<ResolvedStore>;
|
|
81
|
+
/** What `notFound` uses of a page's `Astro` global. */
|
|
82
|
+
export interface NotFoundContext {
|
|
83
|
+
cache?: {
|
|
84
|
+
set(options: false): void;
|
|
85
|
+
};
|
|
86
|
+
response: {
|
|
87
|
+
status?: number | undefined;
|
|
88
|
+
};
|
|
89
|
+
rewrite(path: string): Promise<Response>;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Answers a page whose resource does not exist — `return notFound(Astro)` —
|
|
93
|
+
* with the theme's not-found page (`/404`) and status 404, and never caches
|
|
94
|
+
* it: a product published a minute later must show, not the cached refusal.
|
|
95
|
+
*/
|
|
96
|
+
export declare function notFound(context: NotFoundContext, page?: string): Promise<Response>;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { localeRouting, resolveLocalePath } from "./locale-routing.js";
|
|
2
|
+
import { previewConfig } from "./preview.js";
|
|
3
|
+
const defaultEmbedded = (pathname) => pathname.startsWith("/api/") || pathname.startsWith("/_server-islands/");
|
|
4
|
+
async function primaryStore(options) {
|
|
5
|
+
const client = options.client();
|
|
6
|
+
const config = (await client.config.get()) ?? options.fallback;
|
|
7
|
+
return { client, config, routing: localeRouting(config) };
|
|
8
|
+
}
|
|
9
|
+
async function resolvePath(path, primary, options) {
|
|
10
|
+
const { routing } = primary;
|
|
11
|
+
const resolved = resolveLocalePath(path, routing);
|
|
12
|
+
if (resolved.kind === "redirect")
|
|
13
|
+
return { kind: "redirect", location: resolved.location, status: 301 };
|
|
14
|
+
if (resolved.locale === routing.primary)
|
|
15
|
+
return { kind: "page", client: primary.client, config: primary.config, locale: resolved.locale, routing, pathname: resolved.pathname };
|
|
16
|
+
const client = options.client(resolved.locale);
|
|
17
|
+
const config = (await client.config.get()) ?? primary.config;
|
|
18
|
+
if (config.locale !== resolved.locale)
|
|
19
|
+
return { kind: "redirect", location: resolved.pathname, status: 302 };
|
|
20
|
+
return { kind: "page", client, config, locale: resolved.locale, routing, pathname: resolved.pathname };
|
|
21
|
+
}
|
|
22
|
+
/** The page an embedded request serves part of: its same-origin `Referer`'s path, else the home page. */
|
|
23
|
+
function askingPage(url, referer) {
|
|
24
|
+
if (referer === null)
|
|
25
|
+
return "/";
|
|
26
|
+
try {
|
|
27
|
+
const page = new URL(referer);
|
|
28
|
+
return page.origin === url.origin ? page.pathname : "/";
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return "/";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The store a request renders and the language it renders in, or the redirect
|
|
36
|
+
* it answers:
|
|
37
|
+
*
|
|
38
|
+
* ```ts
|
|
39
|
+
* const request = await resolveStoreRequest(
|
|
40
|
+
* { url: context.url, referer: context.request.headers.get("Referer") },
|
|
41
|
+
* { client: createStore, fallback: FALLBACK_CONFIG },
|
|
42
|
+
* );
|
|
43
|
+
* if (request.kind === "redirect") return context.redirect(request.location, request.status);
|
|
44
|
+
* context.locals.store = buildContext(request);
|
|
45
|
+
* return request.rewrite ? next(request.rewrite) : next();
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export async function resolveStoreRequest(request, options) {
|
|
49
|
+
const { url } = request;
|
|
50
|
+
const embedded = (options.embedded ?? defaultEmbedded)(url.pathname);
|
|
51
|
+
const primary = await primaryStore(options);
|
|
52
|
+
let resolved = await resolvePath(embedded ? askingPage(url, request.referer) : url.pathname, primary, options);
|
|
53
|
+
if (resolved.kind === "redirect") {
|
|
54
|
+
if (!embedded)
|
|
55
|
+
return { kind: "redirect", location: `${resolved.location}${url.search}`, status: resolved.status };
|
|
56
|
+
// The asking page's own address redirects: serve the part as its target would.
|
|
57
|
+
for (let hops = 0; resolved.kind === "redirect" && hops < 3; hops++)
|
|
58
|
+
resolved = await resolvePath(resolved.location, primary, options);
|
|
59
|
+
if (resolved.kind === "redirect")
|
|
60
|
+
resolved = { kind: "page", client: primary.client, config: primary.config, locale: primary.routing.primary, routing: primary.routing, pathname: "/" };
|
|
61
|
+
}
|
|
62
|
+
const { client, config, locale, routing, pathname } = resolved;
|
|
63
|
+
return {
|
|
64
|
+
kind: "page",
|
|
65
|
+
embedded,
|
|
66
|
+
rewrite: embedded || pathname === url.pathname ? null : `${pathname}${url.search}`,
|
|
67
|
+
client,
|
|
68
|
+
config,
|
|
69
|
+
locale,
|
|
70
|
+
routing,
|
|
71
|
+
pathname,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The store an editor's draft previews: its theme settings and text edits
|
|
76
|
+
* over the saved ones, conversion tracking off, in the language the draft
|
|
77
|
+
* asks for when the store serves it — else the primary. `pathname` is the
|
|
78
|
+
* route the draft previews (`draft.path`), else the home page.
|
|
79
|
+
*/
|
|
80
|
+
export async function resolvePreviewStore(draft, options) {
|
|
81
|
+
const primary = await primaryStore(options);
|
|
82
|
+
const { routing } = primary;
|
|
83
|
+
const pathname = draft.path ?? "/";
|
|
84
|
+
const asked = draft.locale;
|
|
85
|
+
if (asked !== undefined && asked !== routing.primary && routing.published.includes(asked)) {
|
|
86
|
+
const client = options.client(asked);
|
|
87
|
+
const config = await client.config.get();
|
|
88
|
+
if (config !== null && config.locale === asked)
|
|
89
|
+
return { client, config: previewConfig(config, draft), locale: asked, routing, pathname };
|
|
90
|
+
}
|
|
91
|
+
return { client: primary.client, config: previewConfig(primary.config, draft), locale: routing.primary, routing, pathname };
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Answers a page whose resource does not exist — `return notFound(Astro)` —
|
|
95
|
+
* with the theme's not-found page (`/404`) and status 404, and never caches
|
|
96
|
+
* it: a product published a minute later must show, not the cached refusal.
|
|
97
|
+
*/
|
|
98
|
+
export function notFound(context, page = "/404") {
|
|
99
|
+
context.cache?.set(false);
|
|
100
|
+
context.response.status = 404;
|
|
101
|
+
return context.rewrite(page);
|
|
102
|
+
}
|