@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/dist/seo/head.js CHANGED
@@ -1,8 +1,11 @@
1
- import { alternateSet, collapse, headDescription, pickImages, productDescription, rejectRedirect, requireMarket } from "./shared.js";
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 set = alternateSet(ctx.site, ctx.market, input.url, input.alternateUrls, ctx.xDefault);
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 market = requireMarket(ctx.site, ctx.market);
43
- const [image] = pickImages(ctx.site, p.images, market.language, p.name, 1);
44
- return buildHead(ctx, { title: p.seo.title, description: headDescription(productDescription(p)), url: p.url, alternateUrls: p.alternateUrls, image: image ?? null });
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
- requireMarket(ctx.site, ctx.market);
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 market = requireMarket(ctx.site, ctx.market);
54
- const [image] = b.logo ? pickImages(ctx.site, [b.logo], market.language, b.name, 1) : [];
55
- return buildHead(ctx, { title: b.name, description: headDescription(collapse(b.description?.text)), url: b.url, alternateUrls: b.alternateUrls, image: image ?? null });
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),
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Market → locale for hreflang and Open Graph (spec 2026-10-06 §4.19).
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;
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Market → locale for hreflang and Open Graph (spec 2026-10-06 §4.19).
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
+ }
@@ -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;
@@ -1,17 +1,24 @@
1
1
  import { gtinJsonLd } from "../gtin.js";
2
- import { jsonLdDescription, pickImages, productDescription, rejectRedirect, requireMarket } from "./shared.js";
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
- /** Sellability C1 §6: the caller's answer, else the variant's own; a response from an API older than 0.14 has no
10
- * `availability` and falls back to `inStock`. */
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 ?? (v.inStock ? "inStock" : "unavailable");
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 market = requireMarket(ctx.site, ctx.market);
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?.price != null ? p.selectedVariant : null;
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((v) => v.price !== null);
93
+ const sellable = merged.filter(offered);
81
94
  const description = jsonLdDescription(productDescription(p));
82
- const images = pickImages(ctx.site, p.images, market.language, p.name).map((i) => i.url);
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, market.language, alt).map((x) => x.url) : 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.price.amount,
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((v) => v.price !== null);
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);
@@ -1,6 +1,7 @@
1
1
  import { type MediaDescriptor } from "../media.js";
2
- import { type MarketDto, type ProductDto, type SiteDto } from "./types.js";
3
- export declare function requireMarket(site: SiteDto, code: string): MarketDto;
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
- * Spec §6.4, one pass (review #16): candidates = `alternateUrls` entries for the site's markets + the current market
35
- * with `url` when missing; markets without a valid locale are skipped; over the final set identical pairs are
36
- * deduplicated and the same hreflang with different URLs throws. `x-default` is decided on the original map.
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, market: string, url: string, alternateUrls: Record<string, string>, xDefault?: string | false): AlternateSet;
40
+ export declare function alternateSet(site: SiteDto, current: BindingDto, alternates: readonly AlternateDto[], xDefault?: string | false): AlternateSet;
@@ -1,11 +1,12 @@
1
1
  import { mediaUrl, MediaUrlError, pickWidth } from "../media.js";
2
- import { hreflang, normalizeLocale, ogLocale } from "./locales.js";
2
+ import { normalizeLocale, ogLocale } from "./locales.js";
3
3
  import { SeoError } from "./types.js";
4
- export function requireMarket(site, code) {
5
- const m = site.markets.find((x) => x.code === code);
6
- if (!m)
7
- throw new SeoError(`market '${code}' is not one of the site's markets (${site.markets.map((x) => x.code).join(", ")})`);
8
- return m;
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
- * Spec §6.4, one pass (review #16): candidates = `alternateUrls` entries for the site's markets + the current market
71
- * with `url` when missing; markets without a valid locale are skipped; over the final set identical pairs are
72
- * deduplicated and the same hreflang with different URLs throws. `x-default` is decided on the original map.
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, market, url, alternateUrls, xDefault) {
75
- const byCode = new Map(site.markets.map((m) => [m.code, m]));
76
- const candidates = Object.entries(alternateUrls).filter(([code]) => byCode.has(code)).map(([code, href]) => ({ code, href }));
77
- if (!Object.hasOwn(alternateUrls, market))
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 c of candidates) {
81
- const loc = normalizeLocale(byCode.get(c.code));
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(tag, { code: c.code, href: c.href, loc });
88
- else if (seen.href !== c.href)
89
- throw new SeoError(`markets '${seen.code}' and '${c.code}' both give hreflang '${tag}' with different URLs; fix their language/country`);
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 alternates = sorted.map(([tag, e]) => ({ hreflang: tag, href: e.href }));
93
- const target = xDefault === false ? null : (xDefault ?? site.defaultMarket);
94
- if (target !== null && Object.hasOwn(alternateUrls, target))
95
- alternates.push({ hreflang: "x-default", href: alternateUrls[target] });
96
- const self = byCode.get(market);
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 [, e] of sorted) {
101
- const l = ogLocale(e.loc);
102
- if (e.code !== market && l !== locale && !alternateLocales.includes(l))
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
  }
@@ -9,9 +9,11 @@ export type SitemapUrl = {
9
9
  href: string;
10
10
  }[];
11
11
  };
12
- /** `complete: false` = the API was unavailable part-way (transport failure, a 5xx, `INDEX_UNAVAILABLE`,
13
- * `SITE_REGISTRY_WARMING`); `entries` is what was read before that. `complete: true` means "walked to exhaustion
14
- * without an error" — a cursor walk is an eventually consistent crawl, not a snapshot. */
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 the client was created with (the same `X-Market`). Required: `/v1/site` answers the site's default market,
29
- * not the request's, and a wrong market here would pair one market's URLs with another's hreflang. */
30
- market: string;
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 market: `GET /v1/site` once, then for each type a cursor walk of `GET /v1/sitemap` from
38
- * `cursor=start` along `nextCursor`. hreflang tags are real BCP 47 tags (`sv-SE`, never the market code `se`), with a
39
- * self-reference, and `x-default` only when the site's default market has the page. An epoch `updatedAt` (collections)
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. */
@@ -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 market: `GET /v1/site` once, then for each type a cursor walk of `GET /v1/sitemap` from
9
- * `cursor=start` along `nextCursor`. hreflang tags are real BCP 47 tags (`sv-SE`, never the market code `se`), with a
10
- * self-reference, and `x-default` only when the site's default market has the page. An epoch `updatedAt` (collections)
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 site = await read(() => client.GET("/v1/site", {}));
17
- if (site === UNAVAILABLE)
18
- return { entries, complete: false };
19
- // The API matches market codes case-insensitively; alternateSet does not, so the configured spelling is passed on.
20
- const wanted = opts.market.toLowerCase();
21
- const market = site.markets.find((m) => m.code.toLowerCase() === wanted)?.code;
22
- if (market === undefined)
23
- throw new SeoError(`market '${opts.market}' is not one of the site's markets (${site.markets.map((m) => m.code).join(", ")})`);
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 page = await read(() => client.GET("/v1/sitemap", { params: { query: { type, cursor, pageSize } } }));
28
- if (page === UNAVAILABLE)
29
- return { entries, complete: false };
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, market, item));
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
- return { entries, complete: true };
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, market, item) {
84
+ function toSitemapUrl(site, binding, item) {
57
85
  let alternates = [];
58
86
  try {
59
- alternates = alternateSet(site, market, item.url, item.alternateUrls).alternates;
87
+ alternates = alternateSet(site, binding, item.alternates).alternates;
60
88
  }
61
89
  catch (e) {
62
- // Two markets mapping to one hreflang with different URLs is a site config fault: drop this entry's alternates, keep
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
  }
@@ -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. `market` is the code the client sends as X-Market and must be in `site.markets`. */
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
- market: string;
15
- /** `x-default` target: a market code, or `false` for none. Default: `site.defaultMarket`. */
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
- /** Includes `x-default` (last); otherwise sorted by hreflang. */
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.31.1",
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": {