@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.
Files changed (56) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/USAGE.md +317 -4
  3. package/dist/abandoned-checkout.d.ts +45 -0
  4. package/dist/abandoned-checkout.js +81 -3
  5. package/dist/answer-readers.d.ts +11 -4
  6. package/dist/answer-readers.js +114 -0
  7. package/dist/astro.d.ts +4 -0
  8. package/dist/astro.js +2 -0
  9. package/dist/basket.d.ts +11 -1
  10. package/dist/basket.js +17 -0
  11. package/dist/catalog.d.ts +24 -0
  12. package/dist/catalog.js +33 -0
  13. package/dist/client/orders.d.ts +10 -1
  14. package/dist/client/orders.js +8 -1
  15. package/dist/client/products.d.ts +13 -3
  16. package/dist/client/products.js +35 -12
  17. package/dist/coupon.d.ts +16 -1
  18. package/dist/coupon.js +37 -0
  19. package/dist/endpoints/storefront-routes.d.ts +8 -2
  20. package/dist/endpoints/storefront-routes.js +9 -2
  21. package/dist/index.d.ts +15 -7
  22. package/dist/index.js +9 -4
  23. package/dist/operations.d.ts +4 -0
  24. package/dist/operations.js +4 -0
  25. package/dist/order-lookup.d.ts +51 -0
  26. package/dist/order-lookup.js +44 -0
  27. package/dist/order-number.d.ts +9 -0
  28. package/dist/order-number.js +11 -0
  29. package/dist/product-view.d.ts +8 -0
  30. package/dist/product-view.js +26 -4
  31. package/dist/quote.d.ts +27 -0
  32. package/dist/quote.js +43 -0
  33. package/dist/review-capture.d.ts +77 -0
  34. package/dist/review-capture.js +172 -0
  35. package/dist/review-media.d.ts +8 -75
  36. package/dist/review-media.js +7 -170
  37. package/dist/review-session.d.ts +97 -0
  38. package/dist/review-session.js +157 -0
  39. package/dist/reviews.d.ts +33 -0
  40. package/dist/reviews.js +41 -0
  41. package/dist/routes.d.ts +2 -0
  42. package/dist/routes.js +3 -0
  43. package/dist/schemas.d.ts +98 -0
  44. package/dist/schemas.js +31 -0
  45. package/dist/seo.d.ts +47 -0
  46. package/dist/seo.js +87 -0
  47. package/dist/server.d.ts +1 -0
  48. package/dist/server.js +1 -0
  49. package/dist/sitemap.d.ts +22 -0
  50. package/dist/sitemap.js +51 -0
  51. package/dist/store-request.d.ts +96 -0
  52. package/dist/store-request.js +102 -0
  53. package/dist/types.d.ts +50 -2
  54. package/dist/utils/image.d.ts +48 -0
  55. package/dist/utils/image.js +51 -0
  56. 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[]>;
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
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
+ }