@asstio-dev/storefront 0.7.0 → 0.11.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,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;
@@ -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,128 @@
1
+ import { jsonLdDescription, pickImages, productDescription, rejectRedirect, requireMarket } from "./shared.js";
2
+ import { SeoError } from "./types.js";
3
+ const PROPERTIES = ["size", "color", "material", "pattern"];
4
+ const IN_STOCK = "https://schema.org/InStock";
5
+ const OUT_OF_STOCK = "https://schema.org/OutOfStock";
6
+ const brandNode = (label) => ({ "@type": "Brand", name: label });
7
+ /** Spec §4.12: absolute http(s), distinct per variant; checked for every emitted variant, single path included. */
8
+ function checkVariantUrls(variants, variantUrl) {
9
+ const seen = new Set();
10
+ return variants.map((v) => {
11
+ const u = variantUrl(v);
12
+ let parsed = null;
13
+ if (typeof u === "string") {
14
+ try {
15
+ parsed = new URL(u);
16
+ }
17
+ catch {
18
+ parsed = null;
19
+ }
20
+ }
21
+ if (parsed === null || (parsed.protocol !== "http:" && parsed.protocol !== "https:"))
22
+ throw new SeoError(`variantUrl must return an absolute http(s) URL; got ${JSON.stringify(u)} for variant ${v.sku}`);
23
+ // Crawlers drop the #fragment, so URLs that differ only there are one URL.
24
+ parsed.hash = "";
25
+ if (seen.has(parsed.href))
26
+ throw new SeoError(`variantUrl returns ${parsed.href} for more than one variant (a #fragment does not count); each needs its own URL`);
27
+ seen.add(parsed.href);
28
+ return u;
29
+ });
30
+ }
31
+ const optionValue = (v, name) => Object.hasOwn(v.selectedOptions, name) && v.selectedOptions[name] !== "" ? v.selectedOptions[name] : undefined;
32
+ /** Spec §4.3: the product name, " – ", every option value in `p.options` order; the SKU when there is none. */
33
+ function variantName(p, v) {
34
+ const values = p.options.map((o) => optionValue(v, o.name)).filter((x) => x !== undefined);
35
+ return `${p.name} – ${values.length > 0 ? values.join(", ") : v.sku}`;
36
+ }
37
+ function offerExtras(ctx) {
38
+ const out = {};
39
+ if (ctx.seller !== undefined)
40
+ out.seller = ctx.seller;
41
+ if (ctx.itemCondition !== undefined)
42
+ out.itemCondition = ctx.itemCondition;
43
+ if (ctx.shippingDetails !== undefined)
44
+ out.shippingDetails = ctx.shippingDetails;
45
+ if (ctx.hasMerchantReturnPolicy !== undefined)
46
+ out.hasMerchantReturnPolicy = ctx.hasMerchantReturnPolicy;
47
+ return out;
48
+ }
49
+ /**
50
+ * schema.org `Product` / `ProductGroup` for a product page (spec §6.2). Decision order: no offer → single (only for a
51
+ * proven singleton) → group. Never an `AggregateOffer`, never a guessed price.
52
+ */
53
+ export function productJsonLd(p, ctx) {
54
+ rejectRedirect(p, `product ${p.sku}`);
55
+ const market = requireMarket(ctx.site, ctx.market);
56
+ const variesBy = ctx.variesBy ?? {};
57
+ for (const [axis, prop] of Object.entries(variesBy))
58
+ if (!PROPERTIES.includes(prop))
59
+ throw new SeoError(`variesBy['${axis}'] = '${prop}' is not one of ${PROPERTIES.join(", ")}`);
60
+ if (typeof ctx.variantUrl !== "function")
61
+ throw new SeoError("variantUrl is required: Google needs a distinct URL that preselects each variant");
62
+ const list = p.variants ?? ctx.variants ?? null;
63
+ const complete = p.variants !== null;
64
+ const sellable = (list ?? []).filter((v) => v.price !== null);
65
+ const description = jsonLdDescription(productDescription(p));
66
+ const images = pickImages(ctx.site, p.images, market.language, p.name).map((i) => i.url);
67
+ const common = (type) => ({
68
+ "@context": "https://schema.org",
69
+ "@type": type,
70
+ name: p.name,
71
+ ...(description !== null ? { description } : {}),
72
+ ...(images.length > 0 ? { image: images } : {}),
73
+ url: p.url,
74
+ });
75
+ const offer = (v, url) => ({
76
+ "@type": "Offer",
77
+ price: v.price.amount,
78
+ priceCurrency: v.price.currency,
79
+ availability: v.inStock ? IN_STOCK : OUT_OF_STOCK,
80
+ url,
81
+ ...offerExtras(ctx),
82
+ });
83
+ const gtin = (v) => {
84
+ const g = ctx.gtin?.(v);
85
+ return g ? { gtin: g } : {};
86
+ };
87
+ if (list === null || sellable.length === 0)
88
+ return { ...common("Product"), sku: p.sku, ...(p.brand ? { brand: brandNode(p.brand.label) } : {}) };
89
+ if (list.length === 1 && p.options.length === 0 && sellable.length === 1 && (complete || Number(p.variantCount) === 1)) {
90
+ const v = sellable[0];
91
+ const [url] = checkVariantUrls([v], ctx.variantUrl);
92
+ return { ...common("Product"), sku: v.sku, ...gtin(v), ...(v.brand ? { brand: brandNode(v.brand.label) } : {}), offers: offer(v, url) };
93
+ }
94
+ const urls = checkVariantUrls(sellable, ctx.variantUrl);
95
+ // Spec §4.14: hoist only from the DTO's own list, and only when every emitted variant has the same non-null label.
96
+ const first = sellable[0].brand?.label;
97
+ const hoisted = complete && first != null && sellable.every((v) => v.brand?.label === first) ? first : null;
98
+ const axes = p.options.filter((o) => Object.hasOwn(variesBy, o.name)).map((o) => ({ name: o.name, prop: variesBy[o.name] }));
99
+ const props = [...new Set(axes.map((a) => a.prop))];
100
+ return {
101
+ ...common("ProductGroup"),
102
+ productGroupID: p.sku,
103
+ ...(props.length > 0 ? { variesBy: props.map((x) => `https://schema.org/${x}`) } : {}),
104
+ ...(hoisted !== null ? { brand: brandNode(hoisted) } : {}),
105
+ hasVariant: sellable.map((v, i) => {
106
+ const name = variantName(p, v);
107
+ const own = {};
108
+ for (const a of axes) {
109
+ const value = optionValue(v, a.name);
110
+ if (value !== undefined && !Object.hasOwn(own, a.prop))
111
+ own[a.prop] = value;
112
+ }
113
+ const vImages = v.hasOwnImages ? pickImages(ctx.site, v.images, market.language, name).map((x) => x.url) : [];
114
+ return {
115
+ "@type": "Product",
116
+ sku: v.sku,
117
+ ...gtin(v),
118
+ name,
119
+ inProductGroupWithID: p.sku,
120
+ ...own,
121
+ ...(vImages.length > 0 ? { image: vImages } : {}),
122
+ ...(hoisted === null && v.brand ? { brand: brandNode(v.brand.label) } : {}),
123
+ url: urls[i],
124
+ offers: offer(v, urls[i]),
125
+ };
126
+ }),
127
+ };
128
+ }
@@ -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,100 @@
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
+ gtin?: (v: VariantDto) => string | undefined;
45
+ seller?: {
46
+ name: string;
47
+ };
48
+ itemCondition?: string;
49
+ shippingDetails?: unknown;
50
+ hasMerchantReturnPolicy?: unknown;
51
+ };
52
+ export type BreadcrumbLink = {
53
+ name: string;
54
+ url: string;
55
+ };
56
+ export type HeadOptions = SeoContext & {
57
+ titleTemplate?: string;
58
+ };
59
+ export type CollectionHeadOptions = HeadOptions & {
60
+ description?: string;
61
+ };
62
+ export type JsonLd = Record<string, unknown>;
63
+ type AbsoluteTitle = {
64
+ absolute: string;
65
+ };
66
+ /** The subset of Next.js `Metadata` that `toNextMetadata` fills; assignable to `Metadata` (checked in examples). */
67
+ export type NextMetadataLike = {
68
+ title: AbsoluteTitle;
69
+ description?: string;
70
+ alternates: {
71
+ canonical: string;
72
+ languages?: Record<string, string>;
73
+ };
74
+ openGraph: {
75
+ type: "website";
76
+ title: AbsoluteTitle;
77
+ description?: string;
78
+ url: string;
79
+ siteName?: string;
80
+ locale?: string;
81
+ alternateLocale?: string[];
82
+ images?: {
83
+ url: string;
84
+ width?: number;
85
+ height?: number;
86
+ alt?: string;
87
+ }[];
88
+ };
89
+ twitter: {
90
+ card: "summary" | "summary_large_image";
91
+ title: AbsoluteTitle;
92
+ description?: string;
93
+ images?: string[];
94
+ };
95
+ };
96
+ /** A caller mistake or a configuration error (spec §7). Holes in the data never throw. */
97
+ export declare class SeoError extends Error {
98
+ constructor(message: string);
99
+ }
100
+ export {};