@asstio-dev/storefront 0.31.1 → 0.40.0-market.1
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/README.md +134 -43
- package/dist/account/cart.d.ts +32 -1
- package/dist/account/cart.js +20 -2
- package/dist/account/index.d.ts +5 -8
- package/dist/account/index.js +6 -5
- package/dist/account/next.d.ts +32 -14
- package/dist/account/next.js +17 -14
- package/dist/analytics/decimal.d.ts +6 -2
- package/dist/analytics/decimal.js +10 -2
- package/dist/analytics/ga4.d.ts +6 -4
- package/dist/analytics/ga4.js +38 -18
- package/dist/analytics/types.d.ts +1 -1
- package/dist/checkout/types.d.ts +12 -1
- package/dist/checkout/types.js +3 -0
- package/dist/context.d.ts +23 -0
- package/dist/context.js +6 -0
- package/dist/filters.d.ts +10 -0
- package/dist/filters.js +8 -0
- package/dist/index.d.ts +12 -10
- package/dist/index.js +11 -7
- package/dist/offer.d.ts +16 -0
- package/dist/offer.js +17 -0
- package/dist/schema.d.ts +510 -279
- package/dist/seo/head.js +16 -12
- package/dist/seo/locales.d.ts +6 -1
- package/dist/seo/locales.js +9 -1
- package/dist/seo/product.d.ts +4 -0
- package/dist/seo/product.js +25 -12
- package/dist/seo/shared.d.ts +8 -6
- package/dist/seo/shared.js +33 -34
- package/dist/seo/sitemap.d.ts +27 -9
- package/dist/seo/sitemap.js +52 -21
- package/dist/seo/types.d.ts +10 -4
- package/package.json +2 -2
- package/src/schema.d.ts +510 -279
package/dist/seo/head.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import { alternateSet, collapse, headDescription, pickImages, productDescription, rejectRedirect,
|
|
1
|
+
import { alternateSet, collapse, headDescription, pickImages, productDescription, rejectRedirect, requireBinding } from "./shared.js";
|
|
2
2
|
const applyTemplate = (template, title) => (template === undefined ? title : template.replace("%s", () => title));
|
|
3
|
-
/** Spec §6.4. Meta order is fixed; a tag whose value is null is left out.
|
|
3
|
+
/** Spec §6.4, market model §16.3. Meta order is fixed; a tag whose value is null is left out. The canonical is the page's own URL on
|
|
4
|
+
* the request's binding (origin, base path and prefix included: the API built it); a secondary binding is `noindex,follow`. */
|
|
4
5
|
function buildHead(ctx, input) {
|
|
5
|
-
const
|
|
6
|
+
const binding = requireBinding(ctx.site, ctx.binding);
|
|
7
|
+
const set = alternateSet(ctx.site, binding, input.alternates, ctx.xDefault);
|
|
8
|
+
const robots = binding.indexable ? null : "noindex,follow";
|
|
6
9
|
const meta = [];
|
|
7
10
|
const name = (n, content) => {
|
|
8
11
|
if (content !== null)
|
|
@@ -13,6 +16,7 @@ function buildHead(ctx, input) {
|
|
|
13
16
|
meta.push({ property: p, content });
|
|
14
17
|
};
|
|
15
18
|
const img = input.image;
|
|
19
|
+
name("robots", robots);
|
|
16
20
|
name("description", input.description);
|
|
17
21
|
// Always `website`: Next.js has no `product` Open Graph type and its renderer throws on one (review #2).
|
|
18
22
|
prop("og:type", "website");
|
|
@@ -35,24 +39,23 @@ function buildHead(ctx, input) {
|
|
|
35
39
|
name("twitter:title", input.title);
|
|
36
40
|
name("twitter:description", input.description);
|
|
37
41
|
name("twitter:image", img ? img.url : null);
|
|
38
|
-
return { title: applyTemplate(ctx.titleTemplate, input.title), description: input.description, canonical: input.url, alternates: set.alternates, meta };
|
|
42
|
+
return { title: applyTemplate(ctx.titleTemplate, input.title), description: input.description, canonical: input.url, robots, alternates: set.alternates, meta };
|
|
39
43
|
}
|
|
40
44
|
export function productHead(p, ctx) {
|
|
41
45
|
rejectRedirect(p, `product ${p.sku}`);
|
|
42
|
-
const
|
|
43
|
-
const [image] = pickImages(ctx.site, p.images,
|
|
44
|
-
return buildHead(ctx, { title: p.seo.title, description: headDescription(productDescription(p)), url: p.url,
|
|
46
|
+
const binding = requireBinding(ctx.site, ctx.binding);
|
|
47
|
+
const [image] = pickImages(ctx.site, p.images, binding.locale, p.name, 1);
|
|
48
|
+
return buildHead(ctx, { title: p.seo.title, description: headDescription(productDescription(p)), url: p.url, alternates: p.alternates, image: image ?? null });
|
|
45
49
|
}
|
|
46
50
|
/** A collection has no description or image in the API; pass `description` yourself if the page has one. */
|
|
47
51
|
export function collectionHead(r, ctx) {
|
|
48
|
-
|
|
49
|
-
return buildHead(ctx, { title: r.collection.name, description: headDescription(collapse(ctx.description)), url: r.collection.url, alternateUrls: r.alternateUrls, image: null });
|
|
52
|
+
return buildHead(ctx, { title: r.collection.name, description: headDescription(collapse(ctx.description)), url: r.collection.url, alternates: r.alternates, image: null });
|
|
50
53
|
}
|
|
51
54
|
export function brandHead(b, ctx) {
|
|
52
55
|
rejectRedirect(b, `brand ${b.slug}`);
|
|
53
|
-
const
|
|
54
|
-
const [image] = b.logo ? pickImages(ctx.site, [b.logo],
|
|
55
|
-
return buildHead(ctx, { title: b.name, description: headDescription(collapse(b.description?.text)), url: b.url,
|
|
56
|
+
const binding = requireBinding(ctx.site, ctx.binding);
|
|
57
|
+
const [image] = b.logo ? pickImages(ctx.site, [b.logo], binding.locale, b.name, 1) : [];
|
|
58
|
+
return buildHead(ctx, { title: b.name, description: headDescription(collapse(b.description?.text)), url: b.url, alternates: b.alternates, image: image ?? null });
|
|
56
59
|
}
|
|
57
60
|
const metaKey = (m) => ("property" in m ? m.property : m.name);
|
|
58
61
|
/** `{ [k]: v }` when `v` is defined, else `{}` — so a missing value is an absent key, never `null`/`undefined`. */
|
|
@@ -72,6 +75,7 @@ export function toNextMetadata(h) {
|
|
|
72
75
|
return {
|
|
73
76
|
title: { absolute: h.title },
|
|
74
77
|
...opt("description", h.description ?? undefined),
|
|
78
|
+
...opt("robots", h.robots ?? undefined),
|
|
75
79
|
alternates: {
|
|
76
80
|
canonical: h.canonical,
|
|
77
81
|
...opt("languages", h.alternates.length > 0 ? Object.fromEntries(h.alternates.map((a) => [a.hreflang, a.href])) : undefined),
|
package/dist/seo/locales.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Binding → locale for hreflang and Open Graph (spec 2026-10-06 §4.19; market model §16.2: the content locale's language and script
|
|
3
|
+
* plus the binding's destination country).
|
|
3
4
|
*
|
|
4
5
|
* Embedded tables, read 2026-10-07:
|
|
5
6
|
* - ISO 639-1: the two-letter column of the ISO 639-2 Registration Authority list (Library of Congress,
|
|
@@ -24,3 +25,7 @@ export declare function normalizeLocale(market: {
|
|
|
24
25
|
export declare const hreflang: (l: MarketLocale) => string;
|
|
25
26
|
/** Open Graph `language_TERRITORY`, script dropped: `zh_TW`. Never derived from `hreflang` by separator replacement. */
|
|
26
27
|
export declare const ogLocale: (l: MarketLocale) => string;
|
|
28
|
+
/** Market model §16.2: the hreflang of a binding — the content locale's language (+script) and the binding's destination region, never
|
|
29
|
+
* the content locale's own region (`en-GB` bound to DE → `en-DE`); `null` when either is not a valid code. The API publishes the
|
|
30
|
+
* compiled value on every binding and alternate; this is the same rule for a caller that has only a locale and a country. */
|
|
31
|
+
export declare function hreflangFor(locale: string, country: string): string | null;
|
package/dist/seo/locales.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Binding → locale for hreflang and Open Graph (spec 2026-10-06 §4.19; market model §16.2: the content locale's language and script
|
|
3
|
+
* plus the binding's destination country).
|
|
3
4
|
*
|
|
4
5
|
* Embedded tables, read 2026-10-07:
|
|
5
6
|
* - ISO 639-1: the two-letter column of the ISO 639-2 Registration Authority list (Library of Congress,
|
|
@@ -93,3 +94,10 @@ export function normalizeLocale(market) {
|
|
|
93
94
|
export const hreflang = (l) => (l.script ? `${l.language}-${l.script}-${l.country}` : `${l.language}-${l.country}`);
|
|
94
95
|
/** Open Graph `language_TERRITORY`, script dropped: `zh_TW`. Never derived from `hreflang` by separator replacement. */
|
|
95
96
|
export const ogLocale = (l) => `${l.language}_${l.country}`;
|
|
97
|
+
/** Market model §16.2: the hreflang of a binding — the content locale's language (+script) and the binding's destination region, never
|
|
98
|
+
* the content locale's own region (`en-GB` bound to DE → `en-DE`); `null` when either is not a valid code. The API publishes the
|
|
99
|
+
* compiled value on every binding and alternate; this is the same rule for a caller that has only a locale and a country. */
|
|
100
|
+
export function hreflangFor(locale, country) {
|
|
101
|
+
const l = normalizeLocale({ language: locale, country });
|
|
102
|
+
return l && hreflang(l);
|
|
103
|
+
}
|
package/dist/seo/product.d.ts
CHANGED
|
@@ -3,5 +3,9 @@ import { type JsonLd, type ProductDto, type ProductJsonLdOptions } from "./types
|
|
|
3
3
|
* schema.org `Product` / `ProductGroup` for a product page (spec §6.2). Decision order: no offer → single (only for a
|
|
4
4
|
* proven singleton) → group. Never an `AggregateOffer`, never a guessed price. A priced `p.selectedVariant` is always
|
|
5
5
|
* in the group, appended when the list does not hold it (variant URLs spec §6).
|
|
6
|
+
*
|
|
7
|
+
* Provenance (market model §7.4, plans review 1 #7): an `Offer` is a public offer, so a variant gets one only when the page was
|
|
8
|
+
* answered for an anonymous context (`p.context.customerKind === "anonymous"`) and its own offer is `available`. A dealer's
|
|
9
|
+
* price, a `loginRequired` page or a response without `context` emits the product without offers.
|
|
6
10
|
*/
|
|
7
11
|
export declare function productJsonLd(p: ProductDto, ctx: ProductJsonLdOptions): JsonLd;
|
package/dist/seo/product.js
CHANGED
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
import { gtinJsonLd } from "../gtin.js";
|
|
2
|
-
import { jsonLdDescription, pickImages, productDescription, rejectRedirect,
|
|
2
|
+
import { jsonLdDescription, pickImages, productDescription, rejectRedirect, requireBinding } from "./shared.js";
|
|
3
3
|
import { SeoError } from "./types.js";
|
|
4
4
|
const PROPERTIES = ["size", "color", "material", "pattern"];
|
|
5
5
|
const IN_STOCK = "https://schema.org/InStock";
|
|
6
6
|
const BACK_ORDER = "https://schema.org/BackOrder";
|
|
7
7
|
const OUT_OF_STOCK = "https://schema.org/OutOfStock";
|
|
8
8
|
const SCHEMA_AVAILABILITY = { inStock: IN_STOCK, backorder: BACK_ORDER, unavailable: OUT_OF_STOCK };
|
|
9
|
-
/**
|
|
10
|
-
|
|
9
|
+
/** The offer's stock status as an availability, for a variant whose `availability` is null. */
|
|
10
|
+
const FROM_STOCK = { inStock: "inStock", backorder: "backorder" };
|
|
11
|
+
/** Sellability C1 §6: the caller's answer, else the variant's own `availability` (null wherever stock is hidden), else its offer's
|
|
12
|
+
* stock status (market model §8.2). */
|
|
11
13
|
function availabilityOf(v, ctx) {
|
|
12
|
-
const state = ctx.availability?.(v) ?? v.availability ??
|
|
14
|
+
const state = ctx.availability?.(v) ?? v.availability ?? FROM_STOCK[v.offer.stock.status] ?? "unavailable";
|
|
13
15
|
return SCHEMA_AVAILABILITY[state] ?? OUT_OF_STOCK;
|
|
14
16
|
}
|
|
17
|
+
/** The variant's gross price (`inc`), whatever the page's display basis: schema.org offers carry the consumer price (SEO helper spec,
|
|
18
|
+
* market model Google = inc-only). Null when the pair has no gross — the variant then gets no offer. */
|
|
19
|
+
function offerAmount(v) {
|
|
20
|
+
return v.offer.price?.inc ?? null;
|
|
21
|
+
}
|
|
15
22
|
const brandNode = (label) => ({ "@type": "Brand", name: label });
|
|
16
23
|
/** Spec §4.12: absolute http(s), distinct per variant; checked for every emitted variant, single path included. The
|
|
17
24
|
* caller's hook wins, else the variant's own `url` (variant URLs spec §6); neither is an error. */
|
|
@@ -62,10 +69,14 @@ function offerExtras(ctx) {
|
|
|
62
69
|
* schema.org `Product` / `ProductGroup` for a product page (spec §6.2). Decision order: no offer → single (only for a
|
|
63
70
|
* proven singleton) → group. Never an `AggregateOffer`, never a guessed price. A priced `p.selectedVariant` is always
|
|
64
71
|
* in the group, appended when the list does not hold it (variant URLs spec §6).
|
|
72
|
+
*
|
|
73
|
+
* Provenance (market model §7.4, plans review 1 #7): an `Offer` is a public offer, so a variant gets one only when the page was
|
|
74
|
+
* answered for an anonymous context (`p.context.customerKind === "anonymous"`) and its own offer is `available`. A dealer's
|
|
75
|
+
* price, a `loginRequired` page or a response without `context` emits the product without offers.
|
|
65
76
|
*/
|
|
66
77
|
export function productJsonLd(p, ctx) {
|
|
67
78
|
rejectRedirect(p, `product ${p.sku}`);
|
|
68
|
-
const
|
|
79
|
+
const binding = requireBinding(ctx.site, ctx.binding);
|
|
69
80
|
const variesBy = ctx.variesBy ?? {};
|
|
70
81
|
for (const [axis, prop] of Object.entries(variesBy))
|
|
71
82
|
if (!PROPERTIES.includes(prop))
|
|
@@ -74,14 +85,16 @@ export function productJsonLd(p, ctx) {
|
|
|
74
85
|
throw new SeoError("variantUrl must be a function");
|
|
75
86
|
const list = p.variants ?? ctx.variants ?? null;
|
|
76
87
|
const complete = p.variants !== null;
|
|
88
|
+
const publicOffers = p.context?.customerKind === "anonymous";
|
|
89
|
+
const offered = (v) => publicOffers && v.offer.offerStatus === "available" && offerAmount(v) !== null;
|
|
77
90
|
// Variant URLs spec §6: a priced selection is always emitted, exactly once (deduplicated by id against the list).
|
|
78
|
-
const sel = p.selectedVariant
|
|
91
|
+
const sel = p.selectedVariant != null && offered(p.selectedVariant) ? p.selectedVariant : null;
|
|
79
92
|
const merged = sel !== null && !(list ?? []).some((v) => v.id === sel.id) ? [...(list ?? []), sel] : (list ?? []);
|
|
80
|
-
const sellable = merged.filter(
|
|
93
|
+
const sellable = merged.filter(offered);
|
|
81
94
|
const description = jsonLdDescription(productDescription(p));
|
|
82
|
-
const images = pickImages(ctx.site, p.images,
|
|
95
|
+
const images = pickImages(ctx.site, p.images, binding.locale, p.name).map((i) => i.url);
|
|
83
96
|
// Variant URLs spec §5.4: effective images = the variant's own set when it owns one (an owned empty set stays empty).
|
|
84
|
-
const effectiveImages = (v, alt) => v.hasOwnImages ? pickImages(ctx.site, v.images,
|
|
97
|
+
const effectiveImages = (v, alt) => v.hasOwnImages ? pickImages(ctx.site, v.images, binding.locale, alt).map((x) => x.url) : images;
|
|
85
98
|
const common = (type, image = images) => ({
|
|
86
99
|
"@context": "https://schema.org",
|
|
87
100
|
"@type": type,
|
|
@@ -92,8 +105,8 @@ export function productJsonLd(p, ctx) {
|
|
|
92
105
|
});
|
|
93
106
|
const offer = (v, url) => ({
|
|
94
107
|
"@type": "Offer",
|
|
95
|
-
price: v
|
|
96
|
-
priceCurrency: v.price.currency,
|
|
108
|
+
price: offerAmount(v),
|
|
109
|
+
priceCurrency: v.offer.price.currency,
|
|
97
110
|
availability: availabilityOf(v, ctx),
|
|
98
111
|
url,
|
|
99
112
|
...offerExtras(ctx),
|
|
@@ -110,7 +123,7 @@ export function productJsonLd(p, ctx) {
|
|
|
110
123
|
if (sellable.length === 0)
|
|
111
124
|
return { ...common("Product"), sku: p.sku, ...(p.brand ? { brand: brandNode(p.brand.label) } : {}) };
|
|
112
125
|
// The singleton is decided on the list alone: a selection never turns a proven singleton into a group (§6).
|
|
113
|
-
const listSellable = (list ?? []).filter(
|
|
126
|
+
const listSellable = (list ?? []).filter(offered);
|
|
114
127
|
if (list !== null && list.length === 1 && p.options.length === 0 && listSellable.length === 1 && (complete || Number(p.variantCount) === 1)) {
|
|
115
128
|
const v = listSellable[0];
|
|
116
129
|
const [url] = checkVariantUrls([v], ctx);
|
package/dist/seo/shared.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type MediaDescriptor } from "../media.js";
|
|
2
|
-
import { type
|
|
3
|
-
|
|
2
|
+
import { type AlternateDto, type BindingDto, type ProductDto, type SiteDto } from "./types.js";
|
|
3
|
+
/** The page binding a helper renders for (market model §16.2): `id` must be one of `site.bindings`. */
|
|
4
|
+
export declare function requireBinding(site: SiteDto, id: string): BindingDto;
|
|
4
5
|
/** Spec §4.17: a page with `redirectTo` should answer 301, not render. */
|
|
5
6
|
export declare function rejectRedirect(entity: {
|
|
6
7
|
redirectTo: string | null;
|
|
@@ -31,8 +32,9 @@ export type AlternateSet = {
|
|
|
31
32
|
alternateLocales: string[];
|
|
32
33
|
};
|
|
33
34
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
35
|
+
* Market model §16.3, one pass: the API's `alternates` (the indexable bindings where the page exists publicly, the current one
|
|
36
|
+
* included when it is indexable) as hreflang links, sorted by hreflang; identical pairs are deduplicated and the same hreflang with
|
|
37
|
+
* different URLs throws. `x-default` is the alternate of `xDefault` (a binding id; default `site.defaultBindingId`, `false` for none)
|
|
38
|
+
* when the page exists there. A secondary binding (`indexable: false`) is in no cluster: no links, only its own Open Graph locale.
|
|
37
39
|
*/
|
|
38
|
-
export declare function alternateSet(site: SiteDto,
|
|
40
|
+
export declare function alternateSet(site: SiteDto, current: BindingDto, alternates: readonly AlternateDto[], xDefault?: string | false): AlternateSet;
|
package/dist/seo/shared.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { mediaUrl, MediaUrlError, pickWidth } from "../media.js";
|
|
2
|
-
import {
|
|
2
|
+
import { normalizeLocale, ogLocale } from "./locales.js";
|
|
3
3
|
import { SeoError } from "./types.js";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
4
|
+
/** The page binding a helper renders for (market model §16.2): `id` must be one of `site.bindings`. */
|
|
5
|
+
export function requireBinding(site, id) {
|
|
6
|
+
const b = site.bindings.find((x) => x.id === id);
|
|
7
|
+
if (!b)
|
|
8
|
+
throw new SeoError(`binding '${id}' is not one of the site's bindings (${site.bindings.map((x) => x.id).join(", ")})`);
|
|
9
|
+
return b;
|
|
9
10
|
}
|
|
10
11
|
/** Spec §4.17: a page with `redirectTo` should answer 301, not render. */
|
|
11
12
|
export function rejectRedirect(entity, what) {
|
|
@@ -66,41 +67,39 @@ export function pickImages(site, descriptors, language, fallbackAlt, limit = 10)
|
|
|
66
67
|
}
|
|
67
68
|
return out;
|
|
68
69
|
}
|
|
70
|
+
const ogOf = (locale, country) => {
|
|
71
|
+
const l = normalizeLocale({ language: locale, country });
|
|
72
|
+
return l && ogLocale(l);
|
|
73
|
+
};
|
|
69
74
|
/**
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
75
|
+
* Market model §16.3, one pass: the API's `alternates` (the indexable bindings where the page exists publicly, the current one
|
|
76
|
+
* included when it is indexable) as hreflang links, sorted by hreflang; identical pairs are deduplicated and the same hreflang with
|
|
77
|
+
* different URLs throws. `x-default` is the alternate of `xDefault` (a binding id; default `site.defaultBindingId`, `false` for none)
|
|
78
|
+
* when the page exists there. A secondary binding (`indexable: false`) is in no cluster: no links, only its own Open Graph locale.
|
|
73
79
|
*/
|
|
74
|
-
export function alternateSet(site,
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
candidates.push({ code: market, href: url });
|
|
80
|
+
export function alternateSet(site, current, alternates, xDefault) {
|
|
81
|
+
const locale = ogOf(current.locale, current.country);
|
|
82
|
+
if (!current.indexable)
|
|
83
|
+
return { alternates: [], locale, alternateLocales: [] };
|
|
79
84
|
const entries = new Map();
|
|
80
|
-
for (const
|
|
81
|
-
const
|
|
82
|
-
if (!loc)
|
|
83
|
-
continue;
|
|
84
|
-
const tag = hreflang(loc);
|
|
85
|
-
const seen = entries.get(tag);
|
|
85
|
+
for (const a of alternates) {
|
|
86
|
+
const seen = entries.get(a.hreflang);
|
|
86
87
|
if (seen === undefined)
|
|
87
|
-
entries.set(
|
|
88
|
-
else if (seen.
|
|
89
|
-
throw new SeoError(`
|
|
88
|
+
entries.set(a.hreflang, a);
|
|
89
|
+
else if (seen.url !== a.url)
|
|
90
|
+
throw new SeoError(`bindings '${seen.bindingId}' and '${a.bindingId}' both give hreflang '${a.hreflang}' with different URLs; fix the site's bindings`);
|
|
90
91
|
}
|
|
91
92
|
const sorted = [...entries].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
92
|
-
const
|
|
93
|
-
const target = xDefault === false ? null : (xDefault ?? site.
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const selfLoc = self ? normalizeLocale(self) : null;
|
|
98
|
-
const locale = selfLoc ? ogLocale(selfLoc) : null;
|
|
93
|
+
const links = sorted.map(([tag, a]) => ({ hreflang: tag, href: a.url }));
|
|
94
|
+
const target = xDefault === false ? null : (xDefault ?? site.defaultBindingId);
|
|
95
|
+
const fallback = target == null ? undefined : alternates.find((a) => a.bindingId === target);
|
|
96
|
+
if (fallback !== undefined)
|
|
97
|
+
links.push({ hreflang: "x-default", href: fallback.url });
|
|
99
98
|
const alternateLocales = [];
|
|
100
|
-
for (const [,
|
|
101
|
-
const l =
|
|
102
|
-
if (
|
|
99
|
+
for (const [, a] of sorted) {
|
|
100
|
+
const l = ogOf(a.locale, a.country);
|
|
101
|
+
if (l !== null && a.bindingId !== current.id && l !== locale && !alternateLocales.includes(l))
|
|
103
102
|
alternateLocales.push(l);
|
|
104
103
|
}
|
|
105
|
-
return { alternates, locale, alternateLocales };
|
|
104
|
+
return { alternates: links, locale, alternateLocales };
|
|
106
105
|
}
|
package/dist/seo/sitemap.d.ts
CHANGED
|
@@ -9,9 +9,11 @@ export type SitemapUrl = {
|
|
|
9
9
|
href: string;
|
|
10
10
|
}[];
|
|
11
11
|
};
|
|
12
|
-
/** `complete:
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
/** `complete: true`: every type's walk reached its end without an error, each on its own catalog generation, and the config version did
|
|
13
|
+
* not change between types. It is no promise of one generation for the whole result, and no snapshot: see `sitemapEntries`.
|
|
14
|
+
* `complete: false`: the API was unavailable part-way (transport failure, a 5xx, `INDEX_UNAVAILABLE`, `SITE_REGISTRY_WARMING`), or the
|
|
15
|
+
* walk could not meet that promise after its restart. `entries` is what the last walk read, so a restart that stopped early can hold less
|
|
16
|
+
* than the first walk did; only a first walk that finished replaces such a restart. */
|
|
15
17
|
export type SitemapResult = {
|
|
16
18
|
entries: SitemapUrl[];
|
|
17
19
|
complete: boolean;
|
|
@@ -25,19 +27,35 @@ export type NextSitemapEntry = {
|
|
|
25
27
|
};
|
|
26
28
|
};
|
|
27
29
|
export interface SitemapOptions {
|
|
28
|
-
/** The market
|
|
29
|
-
*
|
|
30
|
-
|
|
30
|
+
/** The binding id (`{market}/{COUNTRY}/{locale}`, one of `site.bindings`) whose sitemap is walked; sent as `binding`. Required: the
|
|
31
|
+
* sitemap is per binding (market model §16.4), and a wrong binding here would pair one binding's URLs with another's hreflang. */
|
|
32
|
+
binding: string;
|
|
31
33
|
/** Default: all three. */
|
|
32
34
|
types?: SitemapType[];
|
|
33
35
|
/** Items per request, 1–1000 (default 1000). */
|
|
34
36
|
pageSize?: number;
|
|
37
|
+
/** Inclusive ISO-8601 timestamp: only entries changed at or after it (`since`, selective revalidation). Sent on every page; a cursor is
|
|
38
|
+
* bound to it. */
|
|
39
|
+
since?: string;
|
|
35
40
|
}
|
|
36
41
|
/**
|
|
37
|
-
* Every sitemap URL of one
|
|
38
|
-
* `cursor=start` along `nextCursor
|
|
39
|
-
*
|
|
42
|
+
* Every sitemap URL of one binding: `GET /v1/site`, then for each type a cursor walk of `GET /v1/sitemap?binding=` from
|
|
43
|
+
* `cursor=start` along `nextCursor` (each cursor is bound to the binding, type and `since` it was issued for, and to the catalog
|
|
44
|
+
* generation the walk started on). hreflang tags are the bindings' compiled BCP 47 tags (`sv-SE`, never the market code `se`),
|
|
45
|
+
* with a self-reference, and `x-default` only when the site's default binding has the page. An epoch `updatedAt` (collections)
|
|
40
46
|
* is left out rather than printed as 1970.
|
|
47
|
+
*
|
|
48
|
+
* A walk restarts once, whole (the site and every type), and the new walk's entries replace the old ones. A `409 CURSOR_EXPIRED` (the
|
|
49
|
+
* cursor's generation is gone, or the cursor is over an hour old) stops it at once. Responses that name more than one `X-Config-Version`
|
|
50
|
+
* (a publish between two types, which no cursor sees) let it read on to the end, so a list is never cut short for that: a second such
|
|
51
|
+
* walk gives every entry with `complete: false`, and so does a restart that stops early, with the first walk's entries.
|
|
52
|
+
*
|
|
53
|
+
* So `complete: true` proves this much: each type finished on its own generation, and the config version did not change between types. The
|
|
54
|
+
* API names no generation in a response, so a rebuild at the same config version between two types is not detected. That is acceptable
|
|
55
|
+
* for a sitemap: two generations at one config version carry the same URL model, and the next render reads the newer one.
|
|
56
|
+
*
|
|
57
|
+
* `400 INVALID_CURSOR` is no restart: a cursor of another scope is a fault, so it throws. A preview key meets it when the walk's generation
|
|
58
|
+
* kind flips (its draft is created or published mid-walk), which is another scope, not an expiry: run the walk again.
|
|
41
59
|
*/
|
|
42
60
|
export declare function sitemapEntries(client: Client<StorefrontPaths>, opts: SitemapOptions): Promise<SitemapResult>;
|
|
43
61
|
/** Next.js `app/sitemap.ts` shape: `alternates` → `{ languages: { [hreflang]: href } }`, `x-default` included. */
|
package/dist/seo/sitemap.js
CHANGED
|
@@ -1,38 +1,65 @@
|
|
|
1
|
-
import { alternateSet } from "./shared.js";
|
|
1
|
+
import { alternateSet, requireBinding } from "./shared.js";
|
|
2
2
|
import { SeoError } from "./types.js";
|
|
3
3
|
const ALL_TYPES = ["product", "collection", "brand"];
|
|
4
4
|
const RETRYABLE = new Set(["INDEX_UNAVAILABLE", "SITE_REGISTRY_WARMING"]);
|
|
5
5
|
/** The walk stops here, partial: the API is unavailable, not wrong. */
|
|
6
6
|
const UNAVAILABLE = Symbol("unavailable");
|
|
7
|
+
/** The walk stops here and may start over: it outlived its catalog generation. */
|
|
8
|
+
const EXPIRED = Symbol("expired");
|
|
7
9
|
/**
|
|
8
|
-
* Every sitemap URL of one
|
|
9
|
-
* `cursor=start` along `nextCursor
|
|
10
|
-
*
|
|
10
|
+
* Every sitemap URL of one binding: `GET /v1/site`, then for each type a cursor walk of `GET /v1/sitemap?binding=` from
|
|
11
|
+
* `cursor=start` along `nextCursor` (each cursor is bound to the binding, type and `since` it was issued for, and to the catalog
|
|
12
|
+
* generation the walk started on). hreflang tags are the bindings' compiled BCP 47 tags (`sv-SE`, never the market code `se`),
|
|
13
|
+
* with a self-reference, and `x-default` only when the site's default binding has the page. An epoch `updatedAt` (collections)
|
|
11
14
|
* is left out rather than printed as 1970.
|
|
15
|
+
*
|
|
16
|
+
* A walk restarts once, whole (the site and every type), and the new walk's entries replace the old ones. A `409 CURSOR_EXPIRED` (the
|
|
17
|
+
* cursor's generation is gone, or the cursor is over an hour old) stops it at once. Responses that name more than one `X-Config-Version`
|
|
18
|
+
* (a publish between two types, which no cursor sees) let it read on to the end, so a list is never cut short for that: a second such
|
|
19
|
+
* walk gives every entry with `complete: false`, and so does a restart that stops early, with the first walk's entries.
|
|
20
|
+
*
|
|
21
|
+
* So `complete: true` proves this much: each type finished on its own generation, and the config version did not change between types. The
|
|
22
|
+
* API names no generation in a response, so a rebuild at the same config version between two types is not detected. That is acceptable
|
|
23
|
+
* for a sitemap: two generations at one config version carry the same URL model, and the next render reads the newer one.
|
|
24
|
+
*
|
|
25
|
+
* `400 INVALID_CURSOR` is no restart: a cursor of another scope is a fault, so it throws. A preview key meets it when the walk's generation
|
|
26
|
+
* kind flips (its draft is created or published mid-walk), which is another scope, not an expiry: run the walk again.
|
|
12
27
|
*/
|
|
13
28
|
export async function sitemapEntries(client, opts) {
|
|
29
|
+
const first = await walk(client, opts);
|
|
30
|
+
const second = first.expired ? await walk(client, opts) : first;
|
|
31
|
+
// The restart's result stands, unless it stopped early and the first walk finished. A first walk a 409 stopped is no finished list, however
|
|
32
|
+
// much it read, so a restart cut short after one is returned as it is.
|
|
33
|
+
const { entries, complete } = first.finished && !second.finished ? first : second;
|
|
34
|
+
return { entries, complete };
|
|
35
|
+
}
|
|
36
|
+
async function walk(client, opts) {
|
|
14
37
|
const entries = [];
|
|
15
38
|
const pageSize = opts.pageSize ?? 1000;
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
39
|
+
const versions = new Set();
|
|
40
|
+
const seen = (response) => {
|
|
41
|
+
const version = response.headers.get("X-Config-Version");
|
|
42
|
+
if (version !== null)
|
|
43
|
+
versions.add(version);
|
|
44
|
+
};
|
|
45
|
+
const stopped = (reason) => ({ entries, complete: false, finished: false, expired: reason === EXPIRED });
|
|
46
|
+
const site = await read(() => client.GET("/v1/site", {}), seen);
|
|
47
|
+
if (site === UNAVAILABLE || site === EXPIRED)
|
|
48
|
+
return stopped(site);
|
|
49
|
+
const binding = requireBinding(site, opts.binding);
|
|
24
50
|
for (const type of opts.types ?? ALL_TYPES) {
|
|
25
51
|
let cursor = "start";
|
|
26
52
|
for (;;) {
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
53
|
+
const query = { type, cursor, pageSize, binding: binding.id, ...(opts.since === undefined ? {} : { since: opts.since }) };
|
|
54
|
+
const page = await read(() => client.GET("/v1/sitemap", { params: { query } }), seen);
|
|
55
|
+
if (page === UNAVAILABLE || page === EXPIRED)
|
|
56
|
+
return stopped(page);
|
|
30
57
|
// An API older than cursor paging ignores ?cursor= and answers one page without the field: that must never read as
|
|
31
58
|
// the end of the walk, or the sitemap would silently shrink to its first page.
|
|
32
59
|
if (!Object.hasOwn(page, "nextCursor"))
|
|
33
60
|
throw new Error("the Storefront API does not support sitemap cursors; deploy the API first");
|
|
34
61
|
for (const item of page.items)
|
|
35
|
-
entries.push(toSitemapUrl(site,
|
|
62
|
+
entries.push(toSitemapUrl(site, binding, item));
|
|
36
63
|
if (page.nextCursor === null)
|
|
37
64
|
break;
|
|
38
65
|
if (page.nextCursor === cursor)
|
|
@@ -40,7 +67,8 @@ export async function sitemapEntries(client, opts) {
|
|
|
40
67
|
cursor = page.nextCursor;
|
|
41
68
|
}
|
|
42
69
|
}
|
|
43
|
-
|
|
70
|
+
const oneVersion = versions.size <= 1;
|
|
71
|
+
return { entries, complete: oneVersion, finished: true, expired: !oneVersion };
|
|
44
72
|
}
|
|
45
73
|
/** Next.js `app/sitemap.ts` shape: `alternates` → `{ languages: { [hreflang]: href } }`, `x-default` included. */
|
|
46
74
|
export function toNextSitemap(r) {
|
|
@@ -53,13 +81,13 @@ export function toNextSitemap(r) {
|
|
|
53
81
|
return out;
|
|
54
82
|
});
|
|
55
83
|
}
|
|
56
|
-
function toSitemapUrl(site,
|
|
84
|
+
function toSitemapUrl(site, binding, item) {
|
|
57
85
|
let alternates = [];
|
|
58
86
|
try {
|
|
59
|
-
alternates = alternateSet(site,
|
|
87
|
+
alternates = alternateSet(site, binding, item.alternates).alternates;
|
|
60
88
|
}
|
|
61
89
|
catch (e) {
|
|
62
|
-
// Two
|
|
90
|
+
// Two bindings mapping to one hreflang with different URLs is a site config fault: drop this entry's alternates, keep
|
|
63
91
|
// the URL. A config fault must not empty the sitemap.
|
|
64
92
|
if (!(e instanceof SeoError))
|
|
65
93
|
throw e;
|
|
@@ -71,7 +99,7 @@ function toSitemapUrl(site, market, item) {
|
|
|
71
99
|
* One request. Decided on the status first: an empty-body 500 has no `error`, and an HTML 502 has a string one. A thrown
|
|
72
100
|
* request is a transport failure; the catch wraps the request only, never the mapping after it.
|
|
73
101
|
*/
|
|
74
|
-
async function read(request) {
|
|
102
|
+
async function read(request, seen) {
|
|
75
103
|
let result;
|
|
76
104
|
try {
|
|
77
105
|
result = await request();
|
|
@@ -85,7 +113,10 @@ async function read(request) {
|
|
|
85
113
|
const code = typeof error === "object" && error !== null && "code" in error && typeof error.code === "string" ? error.code : undefined;
|
|
86
114
|
if (code !== undefined && RETRYABLE.has(code))
|
|
87
115
|
return UNAVAILABLE;
|
|
116
|
+
if (response.status === 409 && code === "CURSOR_EXPIRED")
|
|
117
|
+
return EXPIRED;
|
|
88
118
|
if (!response.ok || data === undefined)
|
|
89
119
|
throw new Error(`Storefront API ${response.url} answered ${response.status}${code ? ` ${code}` : ""}`);
|
|
120
|
+
seen(response);
|
|
90
121
|
return data;
|
|
91
122
|
}
|
package/dist/seo/types.d.ts
CHANGED
|
@@ -2,17 +2,20 @@ import type { components } from "../schema";
|
|
|
2
2
|
type Schemas = components["schemas"];
|
|
3
3
|
export type SiteDto = Schemas["SiteDto"];
|
|
4
4
|
export type MarketDto = Schemas["MarketDto"];
|
|
5
|
+
export type BindingDto = Schemas["BindingDto"];
|
|
6
|
+
export type AlternateDto = Schemas["AlternateDto"];
|
|
5
7
|
export type ProductDto = Schemas["ProductDto"];
|
|
6
8
|
export type VariantDto = Schemas["VariantDto"];
|
|
7
9
|
export type VariantAvailability = Schemas["VariantAvailability"];
|
|
8
10
|
export type CollectionListingResponse = Schemas["CollectionListingResponse"];
|
|
9
11
|
export type CollectionCrumbDto = Schemas["CollectionCrumbDto"];
|
|
10
12
|
export type BrandDetailDto = Schemas["BrandDetailDto"];
|
|
11
|
-
/** Every helper's context. `
|
|
13
|
+
/** Every helper's context. `binding` is the id (`{market}/{COUNTRY}/{locale}`) of the page binding the client requested the page
|
|
14
|
+
* on — the one its `X-Market`, `X-Country` and `X-Locale` resolve to — and must be in `site.bindings` (market model §16.2). */
|
|
12
15
|
export type SeoContext = {
|
|
13
16
|
site: SiteDto;
|
|
14
|
-
|
|
15
|
-
/** `x-default` target: a
|
|
17
|
+
binding: string;
|
|
18
|
+
/** `x-default` target: a binding id, or `false` for none. Default: `site.defaultBindingId`. */
|
|
16
19
|
xDefault?: string | false;
|
|
17
20
|
};
|
|
18
21
|
/** The `variesBy` targets Google accepts as plain strings (spec §4.3, review #5). */
|
|
@@ -28,7 +31,9 @@ export type SeoHead = {
|
|
|
28
31
|
title: string;
|
|
29
32
|
description: string | null;
|
|
30
33
|
canonical: string;
|
|
31
|
-
/**
|
|
34
|
+
/** `"noindex,follow"` on a secondary (non-indexable) binding, else `null` (market model §16.3). */
|
|
35
|
+
robots: string | null;
|
|
36
|
+
/** Includes `x-default` (last); otherwise sorted by hreflang. Empty on a secondary binding: it is in no hreflang cluster. */
|
|
32
37
|
alternates: {
|
|
33
38
|
hreflang: string;
|
|
34
39
|
href: string;
|
|
@@ -73,6 +78,7 @@ type AbsoluteTitle = {
|
|
|
73
78
|
export type NextMetadataLike = {
|
|
74
79
|
title: AbsoluteTitle;
|
|
75
80
|
description?: string;
|
|
81
|
+
robots?: string;
|
|
76
82
|
alternates: {
|
|
77
83
|
canonical: string;
|
|
78
84
|
languages?: Record<string, string>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@asstio-dev/storefront",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.40.0-market.1",
|
|
4
4
|
"description": "Typed client for the Asstio Storefront API (generated from OpenAPI).",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"test": "vitest run",
|
|
41
41
|
"test:browser": "playwright test -c browser/playwright.config.ts",
|
|
42
42
|
"check": "npm run generate && npm run typecheck-examples && npm test && git diff --exit-code -- src/schema.d.ts && test -z \"$(git status --porcelain -- src/schema.d.ts)\"",
|
|
43
|
-
"smoke": "node --input-type=module -e \"import(\\\"./dist/index.js\\\").then(m => { for (const f of [\\\"createStorefrontClient\\\", \\\"filterQuery\\\", \\\"encodeFilterValues\\\", \\\"decodeFilterValues\\\", \\\"pictureSources\\\", \\\"mediaUrl\\\", \\\"formatImage\\\", \\\"styleToCss\\\", \\\"startCheckout\\\", \\\"syncCheckout\\\", \\\"completeCheckout\\\", \\\"getCheckoutStatus\\\", \\\"liveShippingFor\\\", \\\"getOrder\\\", \\\"awaitCompletion\\\", \\\"mountCheckout\\\", \\\"productJsonLd\\\", \\\"gtinJsonLd\\\", \\\"breadcrumbJsonLd\\\", \\\"productHead\\\", \\\"collectionHead\\\", \\\"brandHead\\\", \\\"toNextMetadata\\\", \\\"sitemapEntries\\\", \\\"toNextSitemap\\\", \\\"jsonLdScript\\\", \\\"SeoError\\\", \\\"productFacts\\\", \\\"toGa4Item\\\", \\\"toGa4Ecommerce\\\", \\\"longestCategory\\\", \\\"AnalyticsError\\\", \\\"trackSearchEvents\\\", \\\"variantUrl\\\", \\\"readVariantParam\\\", \\\"variantQueryForApi\\\", \\\"redirectTarget\\\"]) if (typeof m[f] !== \\\"function\\\") throw new Error(\\\"missing export \\\" + f) })\" && npm run smoke:account",
|
|
43
|
+
"smoke": "node --input-type=module -e \"import(\\\"./dist/index.js\\\").then(m => { for (const f of [\\\"createStorefrontClient\\\", \\\"filterQuery\\\", \\\"encodeFilterValues\\\", \\\"decodeFilterValues\\\", \\\"pictureSources\\\", \\\"mediaUrl\\\", \\\"formatImage\\\", \\\"styleToCss\\\", \\\"startCheckout\\\", \\\"syncCheckout\\\", \\\"completeCheckout\\\", \\\"getCheckoutStatus\\\", \\\"liveShippingFor\\\", \\\"getOrder\\\", \\\"awaitCompletion\\\", \\\"mountCheckout\\\", \\\"productJsonLd\\\", \\\"gtinJsonLd\\\", \\\"breadcrumbJsonLd\\\", \\\"productHead\\\", \\\"collectionHead\\\", \\\"brandHead\\\", \\\"toNextMetadata\\\", \\\"sitemapEntries\\\", \\\"toNextSitemap\\\", \\\"jsonLdScript\\\", \\\"SeoError\\\", \\\"productFacts\\\", \\\"toGa4Item\\\", \\\"toGa4Ecommerce\\\", \\\"longestCategory\\\", \\\"AnalyticsError\\\", \\\"trackSearchEvents\\\", \\\"variantUrl\\\", \\\"readVariantParam\\\", \\\"variantQueryForApi\\\", \\\"redirectTarget\\\", \\\"displayPrice\\\", \\\"isPriceVisible\\\", \\\"isBuyable\\\", \\\"requireBinding\\\", \\\"hreflangFor\\\", \\\"isCheckoutContextMismatch\\\"]) if (typeof m[f] !== \\\"function\\\") throw new Error(\\\"missing export \\\" + f) })\" && npm run smoke:account",
|
|
44
44
|
"smoke:account": "node --input-type=module -e \"const checks = { \\\"./dist/account/index.js\\\": [\\\"createShopperClient\\\", \\\"startSignIn\\\", \\\"verifySignIn\\\", \\\"signOut\\\", \\\"switchPrincipal\\\", \\\"getSession\\\", \\\"getMe\\\", \\\"getMyCompany\\\", \\\"linkChallengeId\\\", \\\"linkToken\\\", \\\"sessionCookie\\\", \\\"signInCookie\\\", \\\"cartCookie\\\", \\\"browserCartCookie\\\", \\\"copyBrowserCart\\\", \\\"CopyBrowserCartError\\\", \\\"cookieSecurity\\\", \\\"readSignInCookie\\\", \\\"serializeCookie\\\", \\\"shopperIpFrom\\\", \\\"assertSameOrigin\\\", \\\"safeReturnPath\\\", \\\"CrossOriginError\\\"], \\\"./dist/account/browser.js\\\": [\\\"readLinkFragment\\\"], \\\"./dist/account/next.js\\\": [\\\"startSignInAction\\\", \\\"verifySignInAction\\\", \\\"signOutAction\\\", \\\"switchPrincipalAction\\\", \\\"shopperClient\\\", \\\"copyBrowserCartAction\\\"] }; for (const [file, names] of Object.entries(checks)) { const m = await import(file); for (const f of names) if (typeof m[f] !== \\\"function\\\") throw new Error(\\\"missing export \\\" + f + \\\" in \\\" + file); }\""
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|