@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.
@@ -0,0 +1,95 @@
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
+ const codes = (lines) => new Set(lines.join(" ").split(" "));
11
+ export const ISO_639_1 = codes([
12
+ "aa ab ae af ak am an ar as av ay az",
13
+ "ba be bg bi bm bn bo br bs",
14
+ "ca ce ch co cr cs cu cv cy",
15
+ "da de dv dz",
16
+ "ee el en eo es et eu",
17
+ "fa ff fi fj fo fr fy",
18
+ "ga gd gl gn gu gv",
19
+ "ha he hi ho hr ht hu hy hz",
20
+ "ia id ie ig ii ik io is it iu",
21
+ "ja jv",
22
+ "ka kg ki kj kk kl km kn ko kr ks ku kv kw ky",
23
+ "la lb lg li ln lo lt lu lv",
24
+ "mg mh mi mk ml mn mr ms mt my",
25
+ "na nb nd ne ng nl nn no nr nv ny",
26
+ "oc oj om or os",
27
+ "pa pi pl ps pt",
28
+ "qu",
29
+ "rm rn ro ru rw",
30
+ "sa sc sd se sg si sk sl sm sn so sq sr ss st su sv sw",
31
+ "ta te tg th ti tk tl tn to tr ts tt tw ty",
32
+ "ug uk ur uz",
33
+ "ve vi vo",
34
+ "wa wo",
35
+ "xh",
36
+ "yi yo",
37
+ "za zh zu",
38
+ ]);
39
+ export const ISO_3166_1 = codes([
40
+ "AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ",
41
+ "BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ",
42
+ "CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ",
43
+ "DE DJ DK DM DO DZ",
44
+ "EC EE EG EH ER ES ET",
45
+ "FI FJ FK FM FO FR",
46
+ "GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY",
47
+ "HK HM HN HR HT HU",
48
+ "ID IE IL IM IN IO IQ IR IS IT",
49
+ "JE JM JO JP",
50
+ "KE KG KH KI KM KN KP KR KW KY KZ",
51
+ "LA LB LC LI LK LR LS LT LU LV LY",
52
+ "MA MC MD ME MF MG MH MK ML MM MN MO MP MQ MR MS MT MU MV MW MX MY MZ",
53
+ "NA NC NE NF NG NI NL NO NP NR NU NZ",
54
+ "OM",
55
+ "PA PE PF PG PH PK PL PM PN PR PS PT PW PY",
56
+ "QA",
57
+ "RE RO RS RU RW",
58
+ "SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ",
59
+ "TC TD TF TG TH TJ TK TL TM TN TO TR TT TV TW TZ",
60
+ "UA UG UM US UY UZ",
61
+ "VA VC VE VG VI VN VU",
62
+ "WF WS",
63
+ "YE YT",
64
+ "ZA ZM ZW",
65
+ ]);
66
+ const SCRIPTS = new Set(["Hans", "Hant", "Latn", "Cyrl", "Arab"]);
67
+ /** `null` = the market has no valid locale and is left out of everything language-dependent. No alias
68
+ * canonicalization: `iw`, `in`, `UK` are simply not in the tables. */
69
+ export function normalizeLocale(market) {
70
+ const [primary, ...rest] = market.language.split(/[-_]/);
71
+ const language = primary.toLowerCase();
72
+ if (!ISO_639_1.has(language))
73
+ return null;
74
+ let i = 0;
75
+ let script;
76
+ if (rest[i] !== undefined && /^[a-z]{4}$/i.test(rest[i])) {
77
+ script = rest[i][0].toUpperCase() + rest[i].slice(1).toLowerCase();
78
+ if (!SCRIPTS.has(script))
79
+ return null;
80
+ i++;
81
+ }
82
+ // A region in `language` is dropped: the market's own country is the market (spec §4.19).
83
+ if (rest[i] !== undefined && /^([a-z]{2}|\d{3})$/i.test(rest[i]))
84
+ i++;
85
+ if (i !== rest.length)
86
+ return null;
87
+ const country = market.country.toUpperCase();
88
+ if (!ISO_3166_1.has(country))
89
+ return null;
90
+ return script ? { language, script, country } : { language, country };
91
+ }
92
+ /** `language[-Script]-COUNTRY`: `sv-SE`, `zh-Hant-TW`. */
93
+ export const hreflang = (l) => (l.script ? `${l.language}-${l.script}-${l.country}` : `${l.language}-${l.country}`);
94
+ /** Open Graph `language_TERRITORY`, script dropped: `zh_TW`. Never derived from `hreflang` by separator replacement. */
95
+ export const ogLocale = (l) => `${l.language}_${l.country}`;
@@ -0,0 +1,6 @@
1
+ import { type JsonLd, type ProductDto, type ProductJsonLdOptions } from "./types.js";
2
+ /**
3
+ * schema.org `Product` / `ProductGroup` for a product page (spec §6.2). Decision order: no offer → single (only for a
4
+ * proven singleton) → group. Never an `AggregateOffer`, never a guessed price.
5
+ */
6
+ export declare function productJsonLd(p: ProductDto, ctx: ProductJsonLdOptions): JsonLd;
@@ -0,0 +1,134 @@
1
+ import { gtinJsonLd } from "../gtin.js";
2
+ import { jsonLdDescription, pickImages, productDescription, rejectRedirect, requireMarket } from "./shared.js";
3
+ import { SeoError } from "./types.js";
4
+ const PROPERTIES = ["size", "color", "material", "pattern"];
5
+ const IN_STOCK = "https://schema.org/InStock";
6
+ const OUT_OF_STOCK = "https://schema.org/OutOfStock";
7
+ const brandNode = (label) => ({ "@type": "Brand", name: label });
8
+ /** Spec §4.12: absolute http(s), distinct per variant; checked for every emitted variant, single path included. */
9
+ function checkVariantUrls(variants, variantUrl) {
10
+ const seen = new Set();
11
+ return variants.map((v) => {
12
+ const u = variantUrl(v);
13
+ let parsed = null;
14
+ if (typeof u === "string") {
15
+ try {
16
+ parsed = new URL(u);
17
+ }
18
+ catch {
19
+ parsed = null;
20
+ }
21
+ }
22
+ if (parsed === null || (parsed.protocol !== "http:" && parsed.protocol !== "https:"))
23
+ throw new SeoError(`variantUrl must return an absolute http(s) URL; got ${JSON.stringify(u)} for variant ${v.sku}`);
24
+ // Crawlers drop the #fragment, so URLs that differ only there are one URL.
25
+ parsed.hash = "";
26
+ if (seen.has(parsed.href))
27
+ throw new SeoError(`variantUrl returns ${parsed.href} for more than one variant (a #fragment does not count); each needs its own URL`);
28
+ seen.add(parsed.href);
29
+ return u;
30
+ });
31
+ }
32
+ const optionValue = (v, name) => Object.hasOwn(v.selectedOptions, name) && v.selectedOptions[name] !== "" ? v.selectedOptions[name] : undefined;
33
+ /** Spec §4.3: the product name, " – ", every option value in `p.options` order; the SKU when there is none. */
34
+ function variantName(p, v) {
35
+ const values = p.options.map((o) => optionValue(v, o.name)).filter((x) => x !== undefined);
36
+ return `${p.name} – ${values.length > 0 ? values.join(", ") : v.sku}`;
37
+ }
38
+ function offerExtras(ctx) {
39
+ const out = {};
40
+ if (ctx.seller !== undefined)
41
+ out.seller = ctx.seller;
42
+ if (ctx.itemCondition !== undefined)
43
+ out.itemCondition = ctx.itemCondition;
44
+ if (ctx.shippingDetails !== undefined)
45
+ out.shippingDetails = ctx.shippingDetails;
46
+ if (ctx.hasMerchantReturnPolicy !== undefined)
47
+ out.hasMerchantReturnPolicy = ctx.hasMerchantReturnPolicy;
48
+ return out;
49
+ }
50
+ /**
51
+ * schema.org `Product` / `ProductGroup` for a product page (spec §6.2). Decision order: no offer → single (only for a
52
+ * proven singleton) → group. Never an `AggregateOffer`, never a guessed price.
53
+ */
54
+ export function productJsonLd(p, ctx) {
55
+ rejectRedirect(p, `product ${p.sku}`);
56
+ const market = requireMarket(ctx.site, ctx.market);
57
+ const variesBy = ctx.variesBy ?? {};
58
+ for (const [axis, prop] of Object.entries(variesBy))
59
+ if (!PROPERTIES.includes(prop))
60
+ throw new SeoError(`variesBy['${axis}'] = '${prop}' is not one of ${PROPERTIES.join(", ")}`);
61
+ if (typeof ctx.variantUrl !== "function")
62
+ throw new SeoError("variantUrl is required: Google needs a distinct URL that preselects each variant");
63
+ const list = p.variants ?? ctx.variants ?? null;
64
+ const complete = p.variants !== null;
65
+ const sellable = (list ?? []).filter((v) => v.price !== null);
66
+ const description = jsonLdDescription(productDescription(p));
67
+ const images = pickImages(ctx.site, p.images, market.language, p.name).map((i) => i.url);
68
+ const common = (type) => ({
69
+ "@context": "https://schema.org",
70
+ "@type": type,
71
+ name: p.name,
72
+ ...(description !== null ? { description } : {}),
73
+ ...(images.length > 0 ? { image: images } : {}),
74
+ url: p.url,
75
+ });
76
+ const offer = (v, url) => ({
77
+ "@type": "Offer",
78
+ price: v.price.amount,
79
+ priceCurrency: v.price.currency,
80
+ availability: v.inStock ? IN_STOCK : OUT_OF_STOCK,
81
+ url,
82
+ ...offerExtras(ctx),
83
+ });
84
+ const gtin = (v) => {
85
+ // GTIN spec §13.3 (plan P14): the hook wins when given, and may omit; otherwise the variant's own gtin. A value of
86
+ // 8, 12, 13 or 14 digits becomes gtin8/12/13/14; any other hook value keeps the generic property.
87
+ const g = ctx.gtin ? ctx.gtin(v) : (v.gtin ?? undefined);
88
+ if (!g)
89
+ return {};
90
+ const keyed = gtinJsonLd(g);
91
+ return Object.keys(keyed).length > 0 ? keyed : { gtin: g };
92
+ };
93
+ if (list === null || sellable.length === 0)
94
+ return { ...common("Product"), sku: p.sku, ...(p.brand ? { brand: brandNode(p.brand.label) } : {}) };
95
+ if (list.length === 1 && p.options.length === 0 && sellable.length === 1 && (complete || Number(p.variantCount) === 1)) {
96
+ const v = sellable[0];
97
+ const [url] = checkVariantUrls([v], ctx.variantUrl);
98
+ return { ...common("Product"), sku: v.sku, ...gtin(v), ...(v.brand ? { brand: brandNode(v.brand.label) } : {}), offers: offer(v, url) };
99
+ }
100
+ const urls = checkVariantUrls(sellable, ctx.variantUrl);
101
+ // Spec §4.14: hoist only from the DTO's own list, and only when every emitted variant has the same non-null label.
102
+ const first = sellable[0].brand?.label;
103
+ const hoisted = complete && first != null && sellable.every((v) => v.brand?.label === first) ? first : null;
104
+ const axes = p.options.filter((o) => Object.hasOwn(variesBy, o.name)).map((o) => ({ name: o.name, prop: variesBy[o.name] }));
105
+ const props = [...new Set(axes.map((a) => a.prop))];
106
+ return {
107
+ ...common("ProductGroup"),
108
+ productGroupID: p.sku,
109
+ ...(props.length > 0 ? { variesBy: props.map((x) => `https://schema.org/${x}`) } : {}),
110
+ ...(hoisted !== null ? { brand: brandNode(hoisted) } : {}),
111
+ hasVariant: sellable.map((v, i) => {
112
+ const name = variantName(p, v);
113
+ const own = {};
114
+ for (const a of axes) {
115
+ const value = optionValue(v, a.name);
116
+ if (value !== undefined && !Object.hasOwn(own, a.prop))
117
+ own[a.prop] = value;
118
+ }
119
+ const vImages = v.hasOwnImages ? pickImages(ctx.site, v.images, market.language, name).map((x) => x.url) : [];
120
+ return {
121
+ "@type": "Product",
122
+ sku: v.sku,
123
+ ...gtin(v),
124
+ name,
125
+ inProductGroupWithID: p.sku,
126
+ ...own,
127
+ ...(vImages.length > 0 ? { image: vImages } : {}),
128
+ ...(hoisted === null && v.brand ? { brand: brandNode(v.brand.label) } : {}),
129
+ url: urls[i],
130
+ offers: offer(v, urls[i]),
131
+ };
132
+ }),
133
+ };
134
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The inner text of a `<script type="application/ld+json">` (spec §4.16): `JSON.stringify` with every `<`, `>`, `&`,
3
+ * U+2028 and U+2029 written as a six-character backslash-u escape, so merchant text cannot end the script element.
4
+ * Safe for HTML templates and React's `dangerouslySetInnerHTML`; the frontend writes the element itself.
5
+ */
6
+ export declare function jsonLdScript(obj: object): string;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The inner text of a `<script type="application/ld+json">` (spec §4.16): `JSON.stringify` with every `<`, `>`, `&`,
3
+ * U+2028 and U+2029 written as a six-character backslash-u escape, so merchant text cannot end the script element.
4
+ * Safe for HTML templates and React's `dangerouslySetInnerHTML`; the frontend writes the element itself.
5
+ */
6
+ export function jsonLdScript(obj) {
7
+ return JSON.stringify(obj).replace(/[<>&\u2028\u2029]/g, (c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0"));
8
+ }
@@ -0,0 +1,38 @@
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;
4
+ /** Spec §4.17: a page with `redirectTo` should answer 301, not render. */
5
+ export declare function rejectRedirect(entity: {
6
+ redirectTo: string | null;
7
+ }, what: string): void;
8
+ export declare function collapse(s: string | null | undefined): string | null;
9
+ /** Spec §4.9: `seo.description` → `shortDescription` → `description.text`, whitespace collapsed. */
10
+ export declare function productDescription(p: ProductDto): string | null;
11
+ /** At most `max` characters including the `…`; never ends inside a word unless one word is longer than the limit. */
12
+ export declare function truncate(text: string, max: number): string;
13
+ export declare const headDescription: (t: string | null) => string | null;
14
+ export declare const jsonLdDescription: (t: string | null) => string | null;
15
+ export type PickedImage = {
16
+ url: string;
17
+ width: number;
18
+ height?: number;
19
+ alt: string;
20
+ };
21
+ /** `alt[language] ?? alt[primary subtag] ?? alt.sv ?? fallback` (own, non-empty properties only). */
22
+ export declare function altText(d: MediaDescriptor, language: string, fallback: string): string;
23
+ /** Spec §4.13: images in API order, `kind === "image"` only, at most `limit`; a descriptor that cannot render is skipped. */
24
+ export declare function pickImages(site: SiteDto, descriptors: readonly MediaDescriptor[], language: string, fallbackAlt: string, limit?: number): PickedImage[];
25
+ export type AlternateSet = {
26
+ alternates: {
27
+ hreflang: string;
28
+ href: string;
29
+ }[];
30
+ locale: string | null;
31
+ alternateLocales: string[];
32
+ };
33
+ /**
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.
37
+ */
38
+ export declare function alternateSet(site: SiteDto, market: string, url: string, alternateUrls: Record<string, string>, xDefault?: string | false): AlternateSet;
@@ -0,0 +1,106 @@
1
+ import { mediaUrl, MediaUrlError, pickWidth } from "../media.js";
2
+ import { hreflang, normalizeLocale, ogLocale } from "./locales.js";
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;
9
+ }
10
+ /** Spec §4.17: a page with `redirectTo` should answer 301, not render. */
11
+ export function rejectRedirect(entity, what) {
12
+ if (entity.redirectTo)
13
+ throw new SeoError(`${what} has redirectTo '${entity.redirectTo}': answer 301 instead of rendering it`);
14
+ }
15
+ export function collapse(s) {
16
+ if (s == null)
17
+ return null;
18
+ const c = s.replace(/\s+/g, " ").trim();
19
+ return c === "" ? null : c;
20
+ }
21
+ /** Spec §4.9: `seo.description` → `shortDescription` → `description.text`, whitespace collapsed. */
22
+ export function productDescription(p) {
23
+ return collapse(p.seo.description) ?? collapse(p.shortDescription) ?? collapse(p.description?.text);
24
+ }
25
+ /** At most `max` characters including the `…`; never ends inside a word unless one word is longer than the limit. */
26
+ export function truncate(text, max) {
27
+ if (text.length <= max)
28
+ return text;
29
+ const room = text.slice(0, max - 1);
30
+ const lastSpace = room.lastIndexOf(" ");
31
+ const cut = text[max - 1] === " " || lastSpace <= 0 ? room : room.slice(0, lastSpace);
32
+ return cut.trimEnd() + "…";
33
+ }
34
+ export const headDescription = (t) => (t === null ? null : truncate(t, 160));
35
+ export const jsonLdDescription = (t) => (t === null ? null : truncate(t, 5000));
36
+ const IMAGE_WIDTH = 1200;
37
+ /** `alt[language] ?? alt[primary subtag] ?? alt.sv ?? fallback` (own, non-empty properties only). */
38
+ export function altText(d, language, fallback) {
39
+ const alt = d.alt ?? {};
40
+ const own = (k) => (Object.hasOwn(alt, k) && alt[k] ? alt[k] : undefined);
41
+ return own(language) ?? own(language.split(/[-_]/)[0].toLowerCase()) ?? own("sv") ?? fallback;
42
+ }
43
+ /** Spec §4.13: images in API order, `kind === "image"` only, at most `limit`; a descriptor that cannot render is skipped. */
44
+ export function pickImages(site, descriptors, language, fallbackAlt, limit = 10) {
45
+ const out = [];
46
+ for (const d of descriptors) {
47
+ if (out.length >= limit)
48
+ break;
49
+ if (d.kind !== "image")
50
+ continue;
51
+ let url;
52
+ let width;
53
+ try {
54
+ url = mediaUrl(site.media, d, { width: IMAGE_WIDTH });
55
+ width = pickWidth(d, IMAGE_WIDTH);
56
+ }
57
+ catch (e) {
58
+ if (e instanceof MediaUrlError)
59
+ continue;
60
+ throw e;
61
+ }
62
+ const w = d.w == null ? NaN : Number(d.w);
63
+ const h = d.h == null ? NaN : Number(d.h);
64
+ const alt = altText(d, language, fallbackAlt);
65
+ out.push(w > 0 && h > 0 ? { url, width, height: Math.round((width * h) / w), alt } : { url, width, alt });
66
+ }
67
+ return out;
68
+ }
69
+ /**
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.
73
+ */
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 });
79
+ 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);
86
+ 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`);
90
+ }
91
+ 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;
99
+ const alternateLocales = [];
100
+ for (const [, e] of sorted) {
101
+ const l = ogLocale(e.loc);
102
+ if (e.code !== market && l !== locale && !alternateLocales.includes(l))
103
+ alternateLocales.push(l);
104
+ }
105
+ return { alternates, locale, alternateLocales };
106
+ }
@@ -0,0 +1,101 @@
1
+ import type { components } from "../schema";
2
+ type Schemas = components["schemas"];
3
+ export type SiteDto = Schemas["SiteDto"];
4
+ export type MarketDto = Schemas["MarketDto"];
5
+ export type ProductDto = Schemas["ProductDto"];
6
+ export type VariantDto = Schemas["VariantDto"];
7
+ export type CollectionListingResponse = Schemas["CollectionListingResponse"];
8
+ export type CollectionCrumbDto = Schemas["CollectionCrumbDto"];
9
+ export type BrandDetailDto = Schemas["BrandDetailDto"];
10
+ /** Every helper's context. `market` is the code the client sends as X-Market and must be in `site.markets`. */
11
+ export type SeoContext = {
12
+ site: SiteDto;
13
+ market: string;
14
+ /** `x-default` target: a market code, or `false` for none. Default: `site.defaultMarket`. */
15
+ xDefault?: string | false;
16
+ };
17
+ /** The `variesBy` targets Google accepts as plain strings (spec §4.3, review #5). */
18
+ export type SchemaProperty = "size" | "color" | "material" | "pattern";
19
+ export type SeoMeta = {
20
+ name: string;
21
+ content: string;
22
+ } | {
23
+ property: string;
24
+ content: string;
25
+ };
26
+ export type SeoHead = {
27
+ title: string;
28
+ description: string | null;
29
+ canonical: string;
30
+ /** Includes `x-default` (last); otherwise sorted by hreflang. */
31
+ alternates: {
32
+ hreflang: string;
33
+ href: string;
34
+ }[];
35
+ meta: SeoMeta[];
36
+ };
37
+ export type ProductJsonLdOptions = SeoContext & {
38
+ /** Required: a distinct absolute http(s) URL that opens the page with this variant preselected (spec §4.12). */
39
+ variantUrl: (v: VariantDto) => string;
40
+ /** Option name (`options[].name`) → schema.org property, e.g. `{ Storlek: "size", Färg: "color" }`. */
41
+ variesBy?: Record<string, SchemaProperty>;
42
+ /** Used only when `p.variants` is `null` (more than 100 variants), e.g. the first page of `/variants`. */
43
+ variants?: VariantDto[];
44
+ /** Overrides the GTIN per variant; return `undefined` to omit. Defaults to the variant's `gtin`. */
45
+ gtin?: (v: VariantDto) => string | undefined;
46
+ seller?: {
47
+ name: string;
48
+ };
49
+ itemCondition?: string;
50
+ shippingDetails?: unknown;
51
+ hasMerchantReturnPolicy?: unknown;
52
+ };
53
+ export type BreadcrumbLink = {
54
+ name: string;
55
+ url: string;
56
+ };
57
+ export type HeadOptions = SeoContext & {
58
+ titleTemplate?: string;
59
+ };
60
+ export type CollectionHeadOptions = HeadOptions & {
61
+ description?: string;
62
+ };
63
+ export type JsonLd = Record<string, unknown>;
64
+ type AbsoluteTitle = {
65
+ absolute: string;
66
+ };
67
+ /** The subset of Next.js `Metadata` that `toNextMetadata` fills; assignable to `Metadata` (checked in examples). */
68
+ export type NextMetadataLike = {
69
+ title: AbsoluteTitle;
70
+ description?: string;
71
+ alternates: {
72
+ canonical: string;
73
+ languages?: Record<string, string>;
74
+ };
75
+ openGraph: {
76
+ type: "website";
77
+ title: AbsoluteTitle;
78
+ description?: string;
79
+ url: string;
80
+ siteName?: string;
81
+ locale?: string;
82
+ alternateLocale?: string[];
83
+ images?: {
84
+ url: string;
85
+ width?: number;
86
+ height?: number;
87
+ alt?: string;
88
+ }[];
89
+ };
90
+ twitter: {
91
+ card: "summary" | "summary_large_image";
92
+ title: AbsoluteTitle;
93
+ description?: string;
94
+ images?: string[];
95
+ };
96
+ };
97
+ /** A caller mistake or a configuration error (spec §7). Holes in the data never throw. */
98
+ export declare class SeoError extends Error {
99
+ constructor(message: string);
100
+ }
101
+ export {};
@@ -0,0 +1,7 @@
1
+ /** A caller mistake or a configuration error (spec §7). Holes in the data never throw. */
2
+ export class SeoError extends Error {
3
+ constructor(message) {
4
+ super(message);
5
+ this.name = "SeoError";
6
+ }
7
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asstio-dev/storefront",
3
- "version": "0.9.2",
3
+ "version": "0.12.0",
4
4
  "description": "Typed client for the Asstio Storefront API (generated from OpenAPI).",
5
5
  "repository": {
6
6
  "type": "git",
@@ -21,14 +21,18 @@
21
21
  "test": "vitest run",
22
22
  "test:browser": "playwright test -c browser/playwright.config.ts",
23
23
  "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)\"",
24
- "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\\\", \\\"getOrder\\\", \\\"awaitCompletion\\\", \\\"mountCheckout\\\"]) if (typeof m[f] !== \\\"function\\\") throw new Error(\\\"missing export \\\" + f) })\""
24
+ "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\\\", \\\"getOrder\\\", \\\"awaitCompletion\\\", \\\"mountCheckout\\\", \\\"productJsonLd\\\", \\\"gtinJsonLd\\\", \\\"breadcrumbJsonLd\\\", \\\"productHead\\\", \\\"collectionHead\\\", \\\"brandHead\\\", \\\"toNextMetadata\\\", \\\"jsonLdScript\\\", \\\"SeoError\\\"]) if (typeof m[f] !== \\\"function\\\") throw new Error(\\\"missing export \\\" + f) })\""
25
25
  },
26
26
  "dependencies": {
27
27
  "openapi-fetch": "0.17.0"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@playwright/test": "1.63.0",
31
+ "@types/react": "19.3.0",
32
+ "next": "16.3.5",
31
33
  "openapi-typescript": "7.13.0",
34
+ "react": "19.2.8",
35
+ "react-dom": "19.2.8",
32
36
  "typescript": "5.9.3",
33
37
  "vitest": "3.2.7"
34
38
  }