@asstio-dev/storefront 0.9.2 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -146,9 +146,11 @@ const html = `<div class="card-image"><div style="${styleToCss(f.box)}">${placeh
146
146
 
147
147
  `description` is **already sanitized** on the server: render `description.html` as-is — do not sanitize, escape or
148
148
  re-parse it — and use `description.text` wherever you need plain text (a meta description, a snippet, an
149
- `aria-label`). `shortDescription` is plain text. `brand.facet` is set when the brand shares a facet's source, so it
150
- can link straight to a filtered listing; when it is `null`, render `brand.label` as plain text and never guess a
151
- filter.
149
+ `aria-label`). `shortDescription` is plain text. `brand` is the product's brand (the main product's); every variant in `variants[]` and on `/variants` carries its own
150
+ `brand` and `responsible` — render the **selected variant's**. `brand.facet` links a filtered listing
151
+ (`filter[facet.key]=facet.valueKey`) when the shop has a brand filter. `brand.url` is the brand page on this market and
152
+ is `null` whenever that page would not exist here (brand pages off, or nothing of the brand sold on this market) — then
153
+ render the label (and `brand.logo`) without a link; never build a brand URL yourself.
152
154
 
153
155
  `attributes[]` arrives ordered and labelled for display. Branch on `type` (`text` | `html` | `number` | `bool` |
154
156
  `list`): a list fills `values`, everything else fills `value`, and `html` fills `html` too — sanitized exactly like
@@ -170,6 +172,77 @@ value has no key of its own: a list is either fully linkable or not linkable at
170
172
  `related[]` holds groups of cards already resolved for this market, and `bundle.components[]` the cards a bundle is
171
173
  made of; both leave out anything that no longer exists or is not sold here.
172
174
 
175
+ ### Brands and responsible parties
176
+
177
+ `responsible` is `{ manufacturer, importer, euResponsiblePerson }`, each a party or `null`. Show the selected variant's
178
+ on the product page (EU GPSR manufacturer and responsible-person contact data). `null` means nothing applies for this
179
+ SKU: do not fall back to the product's or the brand's. `phone` is only present when the shop publishes it.
180
+
181
+ Brand pages: `GET /v1/brands` lists the brands with a page on this market (paged, by name); `GET /v1/brands/{slugOrId}`
182
+ returns the brand's name, description (sanitized like product descriptions), logo, website, its default parties and
183
+ `facet` — render the listing with `GET /v1/products?filter[<facet.key>]=<facet.valueKey>`. A `redirectTo` means 301 to
184
+ that slug. `GET /v1/sitemap?type=brand` lists them for `sitemap.xml`.
185
+
186
+ ```ts
187
+ const { data: brand } = await storefront.GET("/v1/brands/{slugOrId}", { params: { path: { slugOrId: "hugo-boss" } } });
188
+ if (brand?.redirectTo) redirect(301, `/varumarken/${brand.redirectTo}`);
189
+ const { data: listing } = await storefront.GET("/v1/products", {
190
+ params: { query: { filter: filterQuery({ [brand!.facet!.key]: [brand!.facet!.valueKey] }) } },
191
+ });
192
+ ```
193
+
194
+ ### SEO
195
+
196
+ Pure helpers that compute schema.org JSON-LD and `<head>` data from the DTOs. They render nothing: your frontend
197
+ writes the markup. No backend call, no DOM, no dependency.
198
+
199
+ **Product JSON-LD.** `productJsonLd` returns a `Product` (one variant) or a `ProductGroup` with one `Product` and
200
+ `Offer` per variant sold on the market. `variantUrl` is required: Google needs a distinct URL per variant, and opening
201
+ that URL **must preselect the variant** on your page. The format is yours; `?variant=` below is only an example.
202
+ `variesBy` maps your option names to `size`, `color`, `material` or `pattern`; unmapped options still name the
203
+ variants. A product with more than 100 variants arrives with `variants: null`: pass the first `/variants` page as
204
+ `variants`, or the product gets no offer (valid schema.org, but not eligible for product rich results).
205
+
206
+ ```tsx
207
+ const ld = productJsonLd(product, {
208
+ site, market: "se",
209
+ variantUrl: (v) => `${product.url}?variant=${v.id}`,
210
+ variesBy: { Storlek: "size", Färg: "color" },
211
+ });
212
+ <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLdScript(ld) }} />
213
+ ```
214
+
215
+ In a plain template: `<script type="application/ld+json">${jsonLdScript(ld)}</script>`. `jsonLdScript` escapes
216
+ `<`, `>`, `&`, U+2028 and U+2029, so merchant text cannot end the script element; never use plain `JSON.stringify` there.
217
+
218
+ **Breadcrumbs.** On a collection page, `r.breadcrumb` holds the ancestors only. Add the collection itself as `current`:
219
+
220
+ ```ts
221
+ const crumbs = breadcrumbJsonLd(r.breadcrumb, { home: { name: "Hem", url: "https://moory.se/" }, current: r.collection });
222
+ // null below two items (Google's minimum): render nothing then.
223
+ ```
224
+
225
+ **Head.** `productHead`, `collectionHead` (takes your own `description`; the API has none for collections) and
226
+ `brandHead` return a framework-neutral `SeoHead`: title, description (cut at 160 characters on a word boundary),
227
+ canonical, hreflang alternates (keyed `language-COUNTRY`, `x-default` from the site's default market; override with
228
+ `xDefault: "<market>"` or `false`), and Open Graph / Twitter meta. `titleTemplate: "%s | Moory"` applies to `<title>`
229
+ only, never to the social titles. In Next.js:
230
+
231
+ ```ts
232
+ export async function generateMetadata(): Promise<Metadata> {
233
+ return toNextMetadata(productHead(product, { site, market: "se", titleTemplate: "%s | Moory" }));
234
+ }
235
+ ```
236
+
237
+ Titles come out as `{ absolute }` on purpose, so a layout's `title.template` does not apply a second time.
238
+
239
+ **Errors.** `SeoError` means a caller or configuration error: `redirectTo` is set (answer 301 instead), an unknown
240
+ market, a bad `variesBy` value, a `variantUrl` that is not an absolute http(s) URL or repeats for two variants, or
241
+ two markets with the same `language-COUNTRY` and different URLs. Catch it at your SEO boundary and fix the market
242
+ config. Holes in the data never throw: a missing image, description or price only leaves that part out.
243
+
244
+ hreflang sets are only as reciprocal as the API's `alternateUrls`; the SDK renders what the API gives.
245
+
173
246
  ### Search-as-you-type and search-in-facet
174
247
 
175
248
  `/v1/search/suggest` answers three short lists for what the shopper has typed so far: up to 5 product cards
package/dist/gtin.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ /** Schema.org's GTIN property for a `gtin` value from the API (GTIN spec §13.3): gtin8, gtin12, gtin13 or gtin14 by
2
+ * length. The API sends the canonical form, so the length is the format. Spread it into a Product or Offer object. */
3
+ export declare function gtinJsonLd(gtin: string | null | undefined): Record<string, string>;
package/dist/gtin.js ADDED
@@ -0,0 +1,13 @@
1
+ /** Schema.org's GTIN property for a `gtin` value from the API (GTIN spec §13.3): gtin8, gtin12, gtin13 or gtin14 by
2
+ * length. The API sends the canonical form, so the length is the format. Spread it into a Product or Offer object. */
3
+ export function gtinJsonLd(gtin) {
4
+ if (!gtin || !/^\d+$/.test(gtin))
5
+ return {};
6
+ switch (gtin.length) {
7
+ case 8: return { gtin8: gtin };
8
+ case 12: return { gtin12: gtin };
9
+ case 13: return { gtin13: gtin };
10
+ case 14: return { gtin14: gtin };
11
+ default: return {};
12
+ }
13
+ }
package/dist/index.d.ts CHANGED
@@ -51,3 +51,9 @@ export { awaitCompletion, completeCheckout, getCheckoutStatus, getOrder, startCh
51
51
  export { retryAfterSeconds, type CheckoutDto, type CheckoutOrderRefDto, type CheckoutResult, type CheckoutStatusDto, type CheckoutWidgetDto, type OrderDto, type OrderResult, type StatusResult, type StorefrontProblem } from "./checkout/types.js";
52
52
  export { mountCheckout, type CheckoutHandle, type MountOptions, type MinimalDocument, type MinimalElement, type MinimalScript } from "./checkout/browser.js";
53
53
  export { controllers, type CheckoutController, type ProviderWindow } from "./checkout/controllers.js";
54
+ export { breadcrumbJsonLd } from "./seo/breadcrumb.js";
55
+ export { brandHead, collectionHead, productHead, toNextMetadata } from "./seo/head.js";
56
+ export { gtinJsonLd } from "./gtin.js";
57
+ export { productJsonLd } from "./seo/product.js";
58
+ export { jsonLdScript } from "./seo/serialize.js";
59
+ export { SeoError, type BreadcrumbLink, type CollectionHeadOptions, type HeadOptions, type JsonLd, type NextMetadataLike, type ProductJsonLdOptions, type SchemaProperty, type SeoContext, type SeoHead, type SeoMeta, } from "./seo/types.js";
package/dist/index.js CHANGED
@@ -40,3 +40,9 @@ export { awaitCompletion, completeCheckout, getCheckoutStatus, getOrder, startCh
40
40
  export { retryAfterSeconds } from "./checkout/types.js";
41
41
  export { mountCheckout } from "./checkout/browser.js";
42
42
  export { controllers } from "./checkout/controllers.js";
43
+ export { breadcrumbJsonLd } from "./seo/breadcrumb.js";
44
+ export { brandHead, collectionHead, productHead, toNextMetadata } from "./seo/head.js";
45
+ export { gtinJsonLd } from "./gtin.js";
46
+ export { productJsonLd } from "./seo/product.js";
47
+ export { jsonLdScript } from "./seo/serialize.js";
48
+ export { SeoError, } from "./seo/types.js";
package/dist/schema.d.ts CHANGED
@@ -4,6 +4,40 @@
4
4
  */
5
5
 
6
6
  export interface paths {
7
+ "/v1/brands": {
8
+ parameters: {
9
+ query?: never;
10
+ header?: never;
11
+ path?: never;
12
+ cookie?: never;
13
+ };
14
+ /** Brands with a page on this market (some product of the brand is sold here), by name. Empty when the site has no brand facet or the market no brand URL pattern. productCount counts product families with a variant of the brand sold here. */
15
+ get: operations["ListBrands"];
16
+ put?: never;
17
+ post?: never;
18
+ delete?: never;
19
+ options?: never;
20
+ head?: never;
21
+ patch?: never;
22
+ trace?: never;
23
+ };
24
+ "/v1/brands/{slugOrId}": {
25
+ parameters: {
26
+ query?: never;
27
+ header?: never;
28
+ path?: never;
29
+ cookie?: never;
30
+ };
31
+ /** A brand page's data by current slug, historical slug or id: name, description, logo, website, the brand's default responsible parties and the facet link for its listing. redirectTo is set for a historical slug. */
32
+ get: operations["GetBrand"];
33
+ put?: never;
34
+ post?: never;
35
+ delete?: never;
36
+ options?: never;
37
+ head?: never;
38
+ patch?: never;
39
+ trace?: never;
40
+ };
7
41
  "/v1/cart": {
8
42
  parameters: {
9
43
  query?: never;
@@ -325,7 +359,7 @@ export interface paths {
325
359
  path?: never;
326
360
  cookie?: never;
327
361
  };
328
- /** Ids, slugs, URLs and lastmod for sitemap.xml and selective revalidation. type=product|collection, ?since= is inclusive. */
362
+ /** Ids, slugs, URLs and lastmod for sitemap.xml and selective revalidation. type=product|collection|brand, ?since= is inclusive. */
329
363
  get: operations["GetSitemap"];
330
364
  put?: never;
331
365
  post?: never;
@@ -368,9 +402,50 @@ export interface components {
368
402
  valueKeys: string[] | null;
369
403
  values: string[] | null;
370
404
  };
405
+ BrandDetailDto: {
406
+ alternateUrls: {
407
+ [key: string]: string;
408
+ };
409
+ description: null | components["schemas"]["DescriptionDto"];
410
+ facet: null | components["schemas"]["FacetLinkDto"];
411
+ id: string;
412
+ logo: null | components["schemas"]["MediaDescriptorDto"];
413
+ name: string;
414
+ /** Format: int64 */
415
+ productCount: number | string;
416
+ redirectTo: null | string;
417
+ responsible: components["schemas"]["ResponsibleDto"];
418
+ slug: string;
419
+ /** Format: date-time */
420
+ updatedAt: string;
421
+ url: string;
422
+ websiteUrl: null | string;
423
+ };
371
424
  BrandDto: {
372
425
  facet: null | components["schemas"]["FacetLinkDto"];
426
+ id?: null | string;
373
427
  label: string;
428
+ logo?: null | components["schemas"]["MediaDescriptorDto"];
429
+ slug?: null | string;
430
+ url?: null | string;
431
+ };
432
+ BrandListItemDto: {
433
+ id: string;
434
+ logo: null | components["schemas"]["MediaDescriptorDto"];
435
+ name: string;
436
+ /** Format: int64 */
437
+ productCount: number | string;
438
+ slug: string;
439
+ url: string;
440
+ };
441
+ BrandPageDto: {
442
+ items: components["schemas"]["BrandListItemDto"][];
443
+ /** Format: int32 */
444
+ page: number | string;
445
+ /** Format: int32 */
446
+ pageSize: number | string;
447
+ /** Format: int64 */
448
+ total: number | string;
374
449
  };
375
450
  BundleComponentDto: {
376
451
  product: components["schemas"]["ProductCardDto"];
@@ -731,6 +806,18 @@ export interface components {
731
806
  gross: string;
732
807
  vat: components["schemas"]["VatAmountDto"][];
733
808
  };
809
+ PartyDto: {
810
+ city: null | string;
811
+ countryCode: string;
812
+ email: null | string;
813
+ legalName: string;
814
+ name: string;
815
+ phone: null | string;
816
+ postalCode: null | string;
817
+ region: null | string;
818
+ street: null | string;
819
+ url: null | string;
820
+ };
734
821
  PriceDto: {
735
822
  amount: string;
736
823
  compareAt: null | string;
@@ -765,6 +852,8 @@ export interface components {
765
852
  collections: components["schemas"]["CollectionRefDto"][];
766
853
  description: null | components["schemas"]["DescriptionDto"];
767
854
  files: components["schemas"]["MediaDescriptorDto"][];
855
+ gtin: null | string;
856
+ gtins: string[];
768
857
  id: string;
769
858
  image: null | components["schemas"]["MediaDescriptorDto"];
770
859
  images: components["schemas"]["MediaDescriptorDto"][];
@@ -773,6 +862,7 @@ export interface components {
773
862
  options: components["schemas"]["OptionDto"][];
774
863
  redirectTo: null | string;
775
864
  related: components["schemas"]["RelatedGroupDto"][];
865
+ responsible: components["schemas"]["ResponsibleDto"];
776
866
  seo: components["schemas"]["SeoDto"];
777
867
  shortDescription: null | string;
778
868
  sku: string;
@@ -802,6 +892,11 @@ export interface components {
802
892
  label: string;
803
893
  products: components["schemas"]["ProductCardDto"][];
804
894
  };
895
+ ResponsibleDto: {
896
+ euResponsiblePerson: null | components["schemas"]["PartyDto"];
897
+ importer: null | components["schemas"]["PartyDto"];
898
+ manufacturer: null | components["schemas"]["PartyDto"];
899
+ };
805
900
  SeoDto: {
806
901
  description: null | string;
807
902
  title: string;
@@ -909,12 +1004,16 @@ export interface components {
909
1004
  product: string;
910
1005
  };
911
1006
  VariantDto: {
1007
+ brand: null | components["schemas"]["BrandDto"];
1008
+ gtin: null | string;
1009
+ gtins: string[];
912
1010
  hasOwnImages: boolean;
913
1011
  id: string;
914
1012
  image: null | components["schemas"]["MediaDescriptorDto"];
915
1013
  images: components["schemas"]["MediaDescriptorDto"][];
916
1014
  inStock: boolean;
917
1015
  price: null | components["schemas"]["PriceDto"];
1016
+ responsible: components["schemas"]["ResponsibleDto"];
918
1017
  selectedOptions: {
919
1018
  [key: string]: string;
920
1019
  };
@@ -958,6 +1057,175 @@ export interface components {
958
1057
  }
959
1058
  export type $defs = Record<string, never>;
960
1059
  export interface operations {
1060
+ ListBrands: {
1061
+ parameters: {
1062
+ query?: {
1063
+ /** @description 1-based page. page × pageSize must not exceed 10000 (default pageSize 100, max 1000). */
1064
+ page?: number;
1065
+ /** @description Items per page, 1–1000. Out of range is a 400 with code INVALID_PAGE. */
1066
+ pageSize?: number;
1067
+ };
1068
+ header: {
1069
+ /** @description Publishable, preview or secret site key. Required on every request; secret keys are server-side only. */
1070
+ "X-Site-Key": string;
1071
+ /** @description Market code (e.g. "se"). Omit for the site's default market. */
1072
+ "X-Market"?: string;
1073
+ /** @description BCP-47 language tag. Omit to use the market's language. */
1074
+ "Accept-Language"?: string;
1075
+ };
1076
+ path?: never;
1077
+ cookie?: never;
1078
+ };
1079
+ requestBody?: never;
1080
+ responses: {
1081
+ /** @description OK */
1082
+ 200: {
1083
+ headers: {
1084
+ [name: string]: unknown;
1085
+ };
1086
+ content: {
1087
+ "application/json": components["schemas"]["BrandPageDto"];
1088
+ };
1089
+ };
1090
+ /** @description problem+json. MARKET_UNKNOWN (no such market on this site) or INVALID_PAGE. */
1091
+ 400: {
1092
+ headers: {
1093
+ [name: string]: unknown;
1094
+ };
1095
+ content: {
1096
+ "application/problem+json": components["schemas"]["StorefrontProblem"];
1097
+ };
1098
+ };
1099
+ /** @description problem+json. SITE_KEY_MISSING or SITE_KEY_INVALID. */
1100
+ 401: {
1101
+ headers: {
1102
+ [name: string]: unknown;
1103
+ };
1104
+ content: {
1105
+ "application/problem+json": components["schemas"]["StorefrontProblem"];
1106
+ };
1107
+ };
1108
+ /** @description problem+json. SECRET_KEY_FROM_BROWSER: a secret key was sent from a browser. */
1109
+ 403: {
1110
+ headers: {
1111
+ [name: string]: unknown;
1112
+ };
1113
+ content: {
1114
+ "application/problem+json": components["schemas"]["StorefrontProblem"];
1115
+ };
1116
+ };
1117
+ /** @description problem+json. SITE_INACTIVE, or NOT_FOUND for an unrouted path. */
1118
+ 404: {
1119
+ headers: {
1120
+ [name: string]: unknown;
1121
+ };
1122
+ content: {
1123
+ "application/problem+json": components["schemas"]["StorefrontProblem"];
1124
+ };
1125
+ };
1126
+ /** @description problem+json. RATE_LIMITED; retryAfterSeconds and the Retry-After header say how long to wait. */
1127
+ 429: {
1128
+ headers: {
1129
+ [name: string]: unknown;
1130
+ };
1131
+ content: {
1132
+ "application/problem+json": components["schemas"]["StorefrontProblem"];
1133
+ };
1134
+ };
1135
+ /** @description problem+json. SITE_REGISTRY_WARMING or INDEX_UNAVAILABLE — retry, the request was not wrong. */
1136
+ 503: {
1137
+ headers: {
1138
+ [name: string]: unknown;
1139
+ };
1140
+ content: {
1141
+ "application/problem+json": components["schemas"]["StorefrontProblem"];
1142
+ };
1143
+ };
1144
+ };
1145
+ };
1146
+ GetBrand: {
1147
+ parameters: {
1148
+ query?: never;
1149
+ header: {
1150
+ /** @description Publishable, preview or secret site key. Required on every request; secret keys are server-side only. */
1151
+ "X-Site-Key": string;
1152
+ /** @description Market code (e.g. "se"). Omit for the site's default market. */
1153
+ "X-Market"?: string;
1154
+ /** @description BCP-47 language tag. Omit to use the market's language. */
1155
+ "Accept-Language"?: string;
1156
+ };
1157
+ path: {
1158
+ slugOrId: string;
1159
+ };
1160
+ cookie?: never;
1161
+ };
1162
+ requestBody?: never;
1163
+ responses: {
1164
+ /** @description OK */
1165
+ 200: {
1166
+ headers: {
1167
+ [name: string]: unknown;
1168
+ };
1169
+ content: {
1170
+ "application/json": components["schemas"]["BrandDetailDto"];
1171
+ };
1172
+ };
1173
+ /** @description problem+json. MARKET_UNKNOWN (no such market on this site) or INVALID_PAGE. */
1174
+ 400: {
1175
+ headers: {
1176
+ [name: string]: unknown;
1177
+ };
1178
+ content: {
1179
+ "application/problem+json": components["schemas"]["StorefrontProblem"];
1180
+ };
1181
+ };
1182
+ /** @description problem+json. SITE_KEY_MISSING or SITE_KEY_INVALID. */
1183
+ 401: {
1184
+ headers: {
1185
+ [name: string]: unknown;
1186
+ };
1187
+ content: {
1188
+ "application/problem+json": components["schemas"]["StorefrontProblem"];
1189
+ };
1190
+ };
1191
+ /** @description problem+json. SECRET_KEY_FROM_BROWSER: a secret key was sent from a browser. */
1192
+ 403: {
1193
+ headers: {
1194
+ [name: string]: unknown;
1195
+ };
1196
+ content: {
1197
+ "application/problem+json": components["schemas"]["StorefrontProblem"];
1198
+ };
1199
+ };
1200
+ /** @description problem+json. BRAND_NOT_FOUND, or SITE_INACTIVE when the site itself is not live. */
1201
+ 404: {
1202
+ headers: {
1203
+ [name: string]: unknown;
1204
+ };
1205
+ content: {
1206
+ "application/problem+json": components["schemas"]["StorefrontProblem"];
1207
+ };
1208
+ };
1209
+ /** @description problem+json. RATE_LIMITED; retryAfterSeconds and the Retry-After header say how long to wait. */
1210
+ 429: {
1211
+ headers: {
1212
+ [name: string]: unknown;
1213
+ };
1214
+ content: {
1215
+ "application/problem+json": components["schemas"]["StorefrontProblem"];
1216
+ };
1217
+ };
1218
+ /** @description problem+json. SITE_REGISTRY_WARMING or INDEX_UNAVAILABLE — retry, the request was not wrong. */
1219
+ 503: {
1220
+ headers: {
1221
+ [name: string]: unknown;
1222
+ };
1223
+ content: {
1224
+ "application/problem+json": components["schemas"]["StorefrontProblem"];
1225
+ };
1226
+ };
1227
+ };
1228
+ };
961
1229
  GetCart: {
962
1230
  parameters: {
963
1231
  query?: never;
@@ -0,0 +1,9 @@
1
+ import type { BreadcrumbLink, CollectionCrumbDto, JsonLd } from "./types.js";
2
+ /**
3
+ * schema.org `BreadcrumbList` (spec §4.18): optional `home`, the API's ancestor crumbs root first, optional `current`
4
+ * (on a collection page `{ name: r.collection.name, url: r.collection.url }`). `null` below two items (Google's minimum).
5
+ */
6
+ export declare function breadcrumbJsonLd(crumbs: CollectionCrumbDto[], opts?: {
7
+ home?: BreadcrumbLink;
8
+ current?: BreadcrumbLink;
9
+ }): JsonLd | null;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * schema.org `BreadcrumbList` (spec §4.18): optional `home`, the API's ancestor crumbs root first, optional `current`
3
+ * (on a collection page `{ name: r.collection.name, url: r.collection.url }`). `null` below two items (Google's minimum).
4
+ */
5
+ export function breadcrumbJsonLd(crumbs, opts = {}) {
6
+ const trail = [
7
+ ...(opts.home ? [opts.home] : []),
8
+ ...crumbs.map((c) => ({ name: c.name, url: c.url })),
9
+ ...(opts.current ? [opts.current] : []),
10
+ ];
11
+ if (trail.length < 2)
12
+ return null;
13
+ return {
14
+ "@context": "https://schema.org",
15
+ "@type": "BreadcrumbList",
16
+ itemListElement: trail.map((t, i) => ({ "@type": "ListItem", position: i + 1, name: t.name, item: t.url })),
17
+ };
18
+ }
@@ -0,0 +1,10 @@
1
+ import type { BrandDetailDto, CollectionHeadOptions, CollectionListingResponse, HeadOptions, NextMetadataLike, ProductDto, SeoHead } from "./types.js";
2
+ export declare function productHead(p: ProductDto, ctx: HeadOptions): SeoHead;
3
+ /** A collection has no description or image in the API; pass `description` yourself if the page has one. */
4
+ export declare function collectionHead(r: CollectionListingResponse, ctx: CollectionHeadOptions): SeoHead;
5
+ export declare function brandHead(b: BrandDetailDto, ctx: HeadOptions): SeoHead;
6
+ /**
7
+ * `SeoHead` → the Next.js `Metadata` shape (spec §6.5), built from the head alone so the two never disagree. Titles are
8
+ * `{ absolute }` so a parent layout's `title.template` does not apply twice (review #17). Missing values are omitted.
9
+ */
10
+ export declare function toNextMetadata(h: SeoHead): NextMetadataLike;
@@ -0,0 +1,101 @@
1
+ import { alternateSet, collapse, headDescription, pickImages, productDescription, rejectRedirect, requireMarket } from "./shared.js";
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. */
4
+ function buildHead(ctx, input) {
5
+ const set = alternateSet(ctx.site, ctx.market, input.url, input.alternateUrls, ctx.xDefault);
6
+ const meta = [];
7
+ const name = (n, content) => {
8
+ if (content !== null)
9
+ meta.push({ name: n, content });
10
+ };
11
+ const prop = (p, content) => {
12
+ if (content !== null)
13
+ meta.push({ property: p, content });
14
+ };
15
+ const img = input.image;
16
+ name("description", input.description);
17
+ // Always `website`: Next.js has no `product` Open Graph type and its renderer throws on one (review #2).
18
+ prop("og:type", "website");
19
+ prop("og:title", input.title);
20
+ prop("og:description", input.description);
21
+ prop("og:url", input.url);
22
+ prop("og:site_name", ctx.site.name);
23
+ prop("og:locale", set.locale);
24
+ for (const l of set.alternateLocales)
25
+ prop("og:locale:alternate", l);
26
+ if (img) {
27
+ prop("og:image", img.url);
28
+ if (img.height !== undefined) {
29
+ prop("og:image:width", String(img.width));
30
+ prop("og:image:height", String(img.height));
31
+ }
32
+ prop("og:image:alt", img.alt);
33
+ }
34
+ name("twitter:card", img ? "summary_large_image" : "summary");
35
+ name("twitter:title", input.title);
36
+ name("twitter:description", input.description);
37
+ 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 };
39
+ }
40
+ export function productHead(p, ctx) {
41
+ 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 });
45
+ }
46
+ /** A collection has no description or image in the API; pass `description` yourself if the page has one. */
47
+ 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 });
50
+ }
51
+ export function brandHead(b, ctx) {
52
+ 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
+ }
57
+ const metaKey = (m) => ("property" in m ? m.property : m.name);
58
+ /** `{ [k]: v }` when `v` is defined, else `{}` — so a missing value is an absent key, never `null`/`undefined`. */
59
+ const opt = (k, v) => (v === undefined ? {} : { [k]: v });
60
+ /**
61
+ * `SeoHead` → the Next.js `Metadata` shape (spec §6.5), built from the head alone so the two never disagree. Titles are
62
+ * `{ absolute }` so a parent layout's `title.template` does not apply twice (review #17). Missing values are omitted.
63
+ */
64
+ export function toNextMetadata(h) {
65
+ const get = (k) => h.meta.find((m) => metaKey(m) === k)?.content;
66
+ const all = (k) => h.meta.filter((m) => metaKey(m) === k).map((m) => m.content);
67
+ const ogImage = get("og:image");
68
+ const width = get("og:image:width");
69
+ const height = get("og:image:height");
70
+ const alternateLocale = all("og:locale:alternate");
71
+ const twitterImage = get("twitter:image");
72
+ return {
73
+ title: { absolute: h.title },
74
+ ...opt("description", h.description ?? undefined),
75
+ alternates: {
76
+ canonical: h.canonical,
77
+ ...opt("languages", h.alternates.length > 0 ? Object.fromEntries(h.alternates.map((a) => [a.hreflang, a.href])) : undefined),
78
+ },
79
+ openGraph: {
80
+ type: "website",
81
+ title: { absolute: get("og:title") ?? h.title },
82
+ ...opt("description", get("og:description")),
83
+ url: get("og:url") ?? h.canonical,
84
+ ...opt("siteName", get("og:site_name")),
85
+ ...opt("locale", get("og:locale")),
86
+ ...opt("alternateLocale", alternateLocale.length > 0 ? alternateLocale : undefined),
87
+ ...opt("images", ogImage === undefined ? undefined : [{
88
+ url: ogImage,
89
+ ...opt("width", width === undefined ? undefined : Number(width)),
90
+ ...opt("height", height === undefined ? undefined : Number(height)),
91
+ ...opt("alt", get("og:image:alt")),
92
+ }]),
93
+ },
94
+ twitter: {
95
+ card: get("twitter:card") === "summary_large_image" ? "summary_large_image" : "summary",
96
+ title: { absolute: get("twitter:title") ?? h.title },
97
+ ...opt("description", get("twitter:description")),
98
+ ...opt("images", twitterImage === undefined ? undefined : [twitterImage]),
99
+ },
100
+ };
101
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Market → locale for hreflang and Open Graph (spec 2026-10-06 §4.19).
3
+ *
4
+ * Embedded tables, read 2026-10-07:
5
+ * - ISO 639-1: the two-letter column of the ISO 639-2 Registration Authority list (Library of Congress,
6
+ * https://www.loc.gov/standards/iso639-2/php/code_list.php), 183 codes; `bh` (withdrawn) and `sh` (deprecated) left out.
7
+ * - ISO 3166-1 alpha-2: the 249 officially assigned codes (ISO Online Browsing Platform). Reserved codes (UK, EU, AC,
8
+ * XK, …) are not assigned and are absent on purpose.
9
+ */
10
+ export declare const ISO_639_1: ReadonlySet<string>;
11
+ export declare const ISO_3166_1: ReadonlySet<string>;
12
+ export type MarketLocale = {
13
+ language: string;
14
+ script?: string;
15
+ country: string;
16
+ };
17
+ /** `null` = the market has no valid locale and is left out of everything language-dependent. No alias
18
+ * canonicalization: `iw`, `in`, `UK` are simply not in the tables. */
19
+ export declare function normalizeLocale(market: {
20
+ language: string;
21
+ country: string;
22
+ }): MarketLocale | null;
23
+ /** `language[-Script]-COUNTRY`: `sv-SE`, `zh-Hant-TW`. */
24
+ export declare const hreflang: (l: MarketLocale) => string;
25
+ /** Open Graph `language_TERRITORY`, script dropped: `zh_TW`. Never derived from `hreflang` by separator replacement. */
26
+ export declare const ogLocale: (l: MarketLocale) => string;