@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 +76 -3
- package/dist/gtin.d.ts +3 -0
- package/dist/gtin.js +13 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/schema.d.ts +269 -1
- package/dist/seo/breadcrumb.d.ts +9 -0
- package/dist/seo/breadcrumb.js +18 -0
- package/dist/seo/head.d.ts +10 -0
- package/dist/seo/head.js +101 -0
- package/dist/seo/locales.d.ts +26 -0
- package/dist/seo/locales.js +95 -0
- package/dist/seo/product.d.ts +6 -0
- package/dist/seo/product.js +134 -0
- package/dist/seo/serialize.d.ts +6 -0
- package/dist/seo/serialize.js +8 -0
- package/dist/seo/shared.d.ts +38 -0
- package/dist/seo/shared.js +106 -0
- package/dist/seo/types.d.ts +101 -0
- package/dist/seo/types.js +7 -0
- package/package.json +6 -2
- package/src/schema.d.ts +269 -1
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
|
|
150
|
-
|
|
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;
|
package/dist/seo/head.js
ADDED
|
@@ -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;
|