@asstio-dev/storefront 0.5.0 → 0.7.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 +73 -2
- package/dist/filters.d.ts +12 -0
- package/dist/filters.js +39 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.js +7 -4
- package/dist/media.d.ts +80 -0
- package/dist/media.js +142 -0
- package/dist/schema.d.ts +23 -0
- package/package.json +4 -2
- package/src/schema.d.ts +23 -0
package/README.md
CHANGED
|
@@ -53,7 +53,7 @@ const { data: page } = await storefront.GET("/v1/products/{slugOrId}/variants",
|
|
|
53
53
|
```
|
|
54
54
|
|
|
55
55
|
Every example in this file is compiled as part of `npm run check` — the source of them is
|
|
56
|
-
[`examples/readme.ts`](examples/readme.ts), which also covers product lookup, ETag revalidation and
|
|
56
|
+
[`examples/readme.ts`](examples/readme.ts), which also covers product lookup, ETag revalidation, crawling, and image formats.
|
|
57
57
|
|
|
58
58
|
### Images
|
|
59
59
|
|
|
@@ -78,6 +78,70 @@ for (const file of product?.files ?? []) console.log(file.filename, fileUrl(site
|
|
|
78
78
|
`videoSources` returns `[]` for anything that is not a video, while `mediaUrl`/`pictureSources`/`fileUrl`/`posterUrl`
|
|
79
79
|
throw `MediaUrlError` when the descriptor cannot produce that URL — branch on `kind` before calling.
|
|
80
80
|
|
|
81
|
+
### Image formats
|
|
82
|
+
|
|
83
|
+
A site's **formats** (`site.media.formats`: `card`, `gallery`, `thumb`, `cart`, `swatch`, `default`, plus custom
|
|
84
|
+
keys) say how an image is framed: aspect, fit (`contain` · `cover` · `natural`), padding and a background per
|
|
85
|
+
`bg` class. They are published with the site, so the look changes without a frontend deploy. `formatImage` turns
|
|
86
|
+
a descriptor and a format key into everything a `<picture>` needs:
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
import { formatImage } from "@asstio-dev/storefront";
|
|
90
|
+
|
|
91
|
+
const f = formatImage(site.media, product.image, "card", [
|
|
92
|
+
{ media: "(min-width: 1080px)", size: "25vw" }, // your slot's width per breakpoint
|
|
93
|
+
{ size: "100vw" }, // the last one without a media condition
|
|
94
|
+
]);
|
|
95
|
+
// React:
|
|
96
|
+
// <div className={styles.cardImage}> ← your slot: your class, may be a flex/grid item
|
|
97
|
+
// <div style={f.box}> ← the aspect frame, the slot's only child
|
|
98
|
+
// <canvas ref={blurhashCanvas} style={f.placeholder} /> ← optional placeholder, same rectangle as the image
|
|
99
|
+
// <picture>
|
|
100
|
+
// {f.sources.map((s) => <source key={s.type} {...s} />)}
|
|
101
|
+
// <img {...f.fallback} style={f.img} alt={alt} loading="lazy" />
|
|
102
|
+
// </picture>
|
|
103
|
+
// </div>
|
|
104
|
+
// </div>
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Plain HTML (templating):
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
const f = formatImage(site.media, product.image, "card", [
|
|
111
|
+
{ media: "(min-width: 1080px)", size: "25vw" },
|
|
112
|
+
{ media: "(min-width: 640px)", size: "50vw" },
|
|
113
|
+
{ size: "100vw" },
|
|
114
|
+
]);
|
|
115
|
+
// alt is merchant text, not SDK output: escape it for the attribute.
|
|
116
|
+
const escapeAttr = (s: string) => s.replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<").replaceAll(">", ">");
|
|
117
|
+
const alt = escapeAttr(product.image.alt["sv"] ?? product.name);
|
|
118
|
+
// Your slot owns the width; box is its only child in every fit:
|
|
119
|
+
const picture =
|
|
120
|
+
`<picture>${f.sources.map((s) => `<source type="${s.type}" srcset="${s.srcSet}" sizes="${s.sizes}">`).join("")}` +
|
|
121
|
+
`<img src="${f.fallback.src}" srcset="${f.fallback.srcSet}" sizes="${f.fallback.sizes}" width="${f.fallback.width}" height="${f.fallback.height}" alt="${alt}" loading="lazy" style="${styleToCss(f.img)}"></picture>`;
|
|
122
|
+
const placeholder = `<canvas width="32" height="32" style="${styleToCss(f.placeholder)}"></canvas>`;
|
|
123
|
+
const html = `<div class="card-image"><div style="${styleToCss(f.box)}">${placeholder}${picture}</div></div>`;
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
- **Element contract.** Your slot element owns the width (grid, flex, `className`); it may be stretched. Put the
|
|
127
|
+
`box` inside it as its only child and give the box **no** padding, border or height of its own — its inline
|
|
128
|
+
styles pin its geometry, so a stretched slot never distorts the format's aspect.
|
|
129
|
+
- **One rectangle.** `img` and `placeholder` are the same absolutely positioned rectangle with `object-fit: fill`,
|
|
130
|
+
computed from the descriptor: a blurhash canvas of any decode size is stretched to exactly where the image will
|
|
131
|
+
paint. The box's `backgroundColor` shows around a `contain` image and through transparent AVIF/WebP.
|
|
132
|
+
- **Natural** has a box too, at the image's own aspect and without a colour; `img` and `placeholder` fill it. So
|
|
133
|
+
the layout is reserved before load in every fit, and the placeholder sits under the image the same way.
|
|
134
|
+
- **`sizes`** is your slots scaled by the image's drawn fraction with `calc()`; `srcset` keeps the real widths, so
|
|
135
|
+
the browser downloads a sharp enough rendition for a cover crop and not a wasted one for a padded packshot.
|
|
136
|
+
- `formats: null` (a site without formats) or an unknown key falls back to `default`, then to `natural`; your own
|
|
137
|
+
CSS keeps working. `focal` (set per placement in Asstio) positions a `cover` crop; `showWhole` renders a cover
|
|
138
|
+
format as contain for that image. `formatImage` throws `MediaUrlError` for video descriptors, file descriptors,
|
|
139
|
+
a descriptor without `w`/`h`, a descriptor without renditions, or an empty `slots` array.
|
|
140
|
+
- Plain HTML: see the templating example above, or the compiled function `productCard` in [`examples/readme.ts`](examples/readme.ts).
|
|
141
|
+
`styleToCss(f.box)` gives the `style` attribute value for any element. Escape anything that is not SDK output
|
|
142
|
+
(`alt`, product names) before it goes into markup; the React example needs no escaping because React escapes
|
|
143
|
+
attribute values.
|
|
144
|
+
|
|
81
145
|
### Product content
|
|
82
146
|
|
|
83
147
|
`description` is **already sanitized** on the server: render `description.html` as-is — do not sanitize, escape or
|
|
@@ -190,7 +254,14 @@ goes blank because of a bad query string:
|
|
|
190
254
|
|
|
191
255
|
`q`, `page`, `pageSize`, `sort` and `filter` are all in the contract, so they are typed and autocompleted.
|
|
192
256
|
`filter` is a `deepObject`: `{ brand: "dior,chanel" }` goes out as `filter[brand]=dior,chanel`, which is the
|
|
193
|
-
form the API reads.
|
|
257
|
+
form the API reads. In a list, swatch or hierarchy filter `,` separates values, `\,` is a comma inside a value and
|
|
258
|
+
`\\` a backslash — value keys are raw values, and an option such as `14,2` is the key `14,2`. Never join or split a
|
|
259
|
+
filter parameter yourself: `encodeFilterValues(values)` builds one and `decodeFilterValues(param)` reads one (for
|
|
260
|
+
the selected state of a URL you parse), for every facet alike. `filterQuery()` uses `encodeFilterValues` for
|
|
261
|
+
multi-select arrays, passes a single string (a range `100-500`, a toggle `true`) as given and drops `undefined` ones.
|
|
262
|
+
A range is `min-max`: either bound may be left out and either may be negative — `-10--2`, `-10-2`, `-10-` (at least
|
|
263
|
+
−10), `--2` (at most −2) — and a leading dash with nothing before it keeps its meaning, `-500` is "at most 500". A
|
|
264
|
+
range whose lower bound is above its upper bound is ignored with a warning.
|
|
194
265
|
|
|
195
266
|
`/v1/sitemap` takes `type` (`product` | `collection`), `since` (ISO-8601, inclusive), `page` and `pageSize`
|
|
196
267
|
(max 1000, default 500).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The list grammar of a `filter[<key>]` parameter (list, swatch and hierarchy facets): `,` separates values, `\,` is a
|
|
3
|
+
* comma inside a value and `\\` a backslash. A value key is the raw value — an option such as "14,2" is the key "14,2" —
|
|
4
|
+
* so a frontend never joins or splits a filter parameter by hand: it builds and toggles filter URLs with these two, for
|
|
5
|
+
* every facet alike. Range (`min-max`) and toggle (`true`) parameters are single strings and need neither.
|
|
6
|
+
*/
|
|
7
|
+
export declare function encodeFilterValues(values: readonly string[]): string;
|
|
8
|
+
/**
|
|
9
|
+
* The values a `filter[<key>]` parameter selects, read the way the API reads it: `\,` and `\\` unescaped, a backslash
|
|
10
|
+
* before anything else (or at the end) kept as it is, values trimmed, empty ones and repeats dropped.
|
|
11
|
+
*/
|
|
12
|
+
export declare function decodeFilterValues(param: string): string[];
|
package/dist/filters.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The list grammar of a `filter[<key>]` parameter (list, swatch and hierarchy facets): `,` separates values, `\,` is a
|
|
3
|
+
* comma inside a value and `\\` a backslash. A value key is the raw value — an option such as "14,2" is the key "14,2" —
|
|
4
|
+
* so a frontend never joins or splits a filter parameter by hand: it builds and toggles filter URLs with these two, for
|
|
5
|
+
* every facet alike. Range (`min-max`) and toggle (`true`) parameters are single strings and need neither.
|
|
6
|
+
*/
|
|
7
|
+
export function encodeFilterValues(values) {
|
|
8
|
+
return values.map((v) => v.replace(/\\/g, "\\\\").replace(/,/g, "\\,")).join(",");
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The values a `filter[<key>]` parameter selects, read the way the API reads it: `\,` and `\\` unescaped, a backslash
|
|
12
|
+
* before anything else (or at the end) kept as it is, values trimmed, empty ones and repeats dropped.
|
|
13
|
+
*/
|
|
14
|
+
export function decodeFilterValues(param) {
|
|
15
|
+
const values = [];
|
|
16
|
+
let current = "";
|
|
17
|
+
const push = () => {
|
|
18
|
+
const value = current.trim();
|
|
19
|
+
current = "";
|
|
20
|
+
if (value.length > 0 && !values.includes(value))
|
|
21
|
+
values.push(value);
|
|
22
|
+
};
|
|
23
|
+
for (let i = 0; i < param.length; i++) {
|
|
24
|
+
const c = param[i];
|
|
25
|
+
const next = param[i + 1];
|
|
26
|
+
if (c === "\\" && (next === "," || next === "\\")) {
|
|
27
|
+
current += next;
|
|
28
|
+
i++;
|
|
29
|
+
}
|
|
30
|
+
else if (c === ",") {
|
|
31
|
+
push();
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
current += c;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
push();
|
|
38
|
+
return values;
|
|
39
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -38,10 +38,12 @@ export declare function createStorefrontClient(opts: StorefrontClientOptions): C
|
|
|
38
38
|
/** The `filter` query parameter of a listing call: one facet key to one value, as the generated types spell it. */
|
|
39
39
|
export type FilterSelection = NonNullable<NonNullable<paths["/v1/products"]["get"]["parameters"]["query"]>["filter"]>;
|
|
40
40
|
/**
|
|
41
|
-
* Collapse a facet selection into the typed `filter` object: several values for one facet are
|
|
42
|
-
*
|
|
41
|
+
* Collapse a facet selection into the typed `filter` object: several values for one facet are encoded with
|
|
42
|
+
* `encodeFilterValues` (so a value containing a comma or a backslash survives), a single string is sent as given (a
|
|
43
|
+
* range "100-500", a toggle "true"), and `undefined` facets drop out — an unchecked checkbox needs no special case.
|
|
43
44
|
* `{ brand: ["dior", "chanel"], price: "100-500", size: undefined }` → `{ brand: "dior,chanel", price: "100-500" }`.
|
|
44
45
|
* The client sends it as `filter[brand]=dior,chanel` (deepObject), which is what the API reads.
|
|
45
46
|
*/
|
|
46
47
|
export declare function filterQuery(selection: Record<string, string | string[] | undefined>): FilterSelection;
|
|
47
|
-
export {
|
|
48
|
+
export { decodeFilterValues, encodeFilterValues } from "./filters.js";
|
|
49
|
+
export { coverOffset, fileUrl, fillFactor, formatFactor, formatImage, imageRect, mediaHash, mediaUrl, MediaUrlError, pickWidth, pictureSources, posterUrl, scaledSizes, srcSet, styleToCss, videoSources, type FormatFit, type FormattedImage, type MediaConfig, type MediaDescriptor, type MediaFormat, type PictureSources, type SlotSize, type VideoSource, } from "./media.js";
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import createClient from "openapi-fetch";
|
|
2
|
+
import { encodeFilterValues } from "./filters.js";
|
|
2
3
|
/** Thin typed client: every call carries X-Site-Key / X-Market / Accept-Language. Works in browsers and on the server. */
|
|
3
4
|
export function createStorefrontClient(opts) {
|
|
4
5
|
const client = createClient({ baseUrl: opts.baseUrl.replace(/\/$/, ""), fetch: opts.fetch });
|
|
@@ -18,8 +19,9 @@ export function createStorefrontClient(opts) {
|
|
|
18
19
|
return client;
|
|
19
20
|
}
|
|
20
21
|
/**
|
|
21
|
-
* Collapse a facet selection into the typed `filter` object: several values for one facet are
|
|
22
|
-
*
|
|
22
|
+
* Collapse a facet selection into the typed `filter` object: several values for one facet are encoded with
|
|
23
|
+
* `encodeFilterValues` (so a value containing a comma or a backslash survives), a single string is sent as given (a
|
|
24
|
+
* range "100-500", a toggle "true"), and `undefined` facets drop out — an unchecked checkbox needs no special case.
|
|
23
25
|
* `{ brand: ["dior", "chanel"], price: "100-500", size: undefined }` → `{ brand: "dior,chanel", price: "100-500" }`.
|
|
24
26
|
* The client sends it as `filter[brand]=dior,chanel` (deepObject), which is what the API reads.
|
|
25
27
|
*/
|
|
@@ -28,8 +30,9 @@ export function filterQuery(selection) {
|
|
|
28
30
|
for (const [key, value] of Object.entries(selection)) {
|
|
29
31
|
if (value === undefined)
|
|
30
32
|
continue;
|
|
31
|
-
out[key] = Array.isArray(value) ? value
|
|
33
|
+
out[key] = Array.isArray(value) ? encodeFilterValues(value) : value;
|
|
32
34
|
}
|
|
33
35
|
return out;
|
|
34
36
|
}
|
|
35
|
-
export {
|
|
37
|
+
export { decodeFilterValues, encodeFilterValues } from "./filters.js";
|
|
38
|
+
export { coverOffset, fileUrl, fillFactor, formatFactor, formatImage, imageRect, mediaHash, mediaUrl, MediaUrlError, pickWidth, pictureSources, posterUrl, scaledSizes, srcSet, styleToCss, videoSources, } from "./media.js";
|
package/dist/media.d.ts
CHANGED
|
@@ -49,3 +49,83 @@ export declare function posterUrl(site: MediaConfig, d: MediaDescriptor, width:
|
|
|
49
49
|
export declare function fileUrl(site: MediaConfig, d: MediaDescriptor): string;
|
|
50
50
|
/** Every encoded rendition of a video, for `<source>` elements. Empty for anything that is not a video. */
|
|
51
51
|
export declare function videoSources(site: MediaConfig, d: MediaDescriptor): VideoSource[];
|
|
52
|
+
export type FormatFit = "contain" | "cover" | "natural";
|
|
53
|
+
/** One slot size of the frontend's layout (F6): `{ media: "(min-width: 1080px)", size: "25vw" }`; the last entry
|
|
54
|
+
* has no media condition. The SDK turns these into the `sizes` attribute. */
|
|
55
|
+
export type SlotSize = {
|
|
56
|
+
media?: string;
|
|
57
|
+
size: string;
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* The image's drawn width as a fraction of the slot (spec §6 rule 4). `rBox` = format W/H, `rImg` = image w/h,
|
|
61
|
+
* `padding` a fraction of the box's width (contain only).
|
|
62
|
+
*/
|
|
63
|
+
export declare function fillFactor(fit: FormatFit, rBox: number, rImg: number, padding: number): number;
|
|
64
|
+
/**
|
|
65
|
+
* Where a cover image sits (spec §3.3): the fractions `ox`/`oy` of the overflow placed before the box, so the
|
|
66
|
+
* focal point is as centred as possible without showing an edge. Visible fraction `v = min(rBox/rImg, rImg/rBox)`;
|
|
67
|
+
* along the overflowing axis `clamp((f − v/2) / (1 − v), 0, 1)`, the other axis 0.5. Unset focal ⇒ centre.
|
|
68
|
+
*/
|
|
69
|
+
export declare function coverOffset(focal: {
|
|
70
|
+
x: number | string;
|
|
71
|
+
y: number | string;
|
|
72
|
+
} | null | undefined, rBox: number, rImg: number): {
|
|
73
|
+
ox: number;
|
|
74
|
+
oy: number;
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* The rectangle the image occupies, in percent of the box (spec §6 rule 3): `width = fill·100`,
|
|
78
|
+
* `height = fill·rBox/rImg·100`; contain centred (the padding inset is inside `fill`), cover placed by
|
|
79
|
+
* `coverOffset` (negative offsets; the box clips), natural the whole box (its box has the image's aspect).
|
|
80
|
+
* The `<img>` and the placeholder both use it.
|
|
81
|
+
*/
|
|
82
|
+
export declare function imageRect(fit: FormatFit, rBox: number, rImg: number, fill: number, offset: {
|
|
83
|
+
ox: number;
|
|
84
|
+
oy: number;
|
|
85
|
+
}): {
|
|
86
|
+
left: number;
|
|
87
|
+
top: number;
|
|
88
|
+
width: number;
|
|
89
|
+
height: number;
|
|
90
|
+
};
|
|
91
|
+
/** `fill` with 4 significant digits: a positive factor never rounds to 0 (review 2 #12). */
|
|
92
|
+
export declare function formatFactor(fill: number): string;
|
|
93
|
+
/** The `sizes` attribute: every slot's size scaled by `fill` with `calc()`, unchanged when `fill` is 1 (F10). */
|
|
94
|
+
export declare function scaledSizes(slots: SlotSize[], fill: number): string;
|
|
95
|
+
export type MediaFormat = components["schemas"]["MediaFormatDto"];
|
|
96
|
+
export type FormattedImage = {
|
|
97
|
+
/** The format key actually used, after fallback ("default" when the site has no formats). */
|
|
98
|
+
format: string;
|
|
99
|
+
/** The effective fit, after `showWhole`. */
|
|
100
|
+
fit: FormatFit;
|
|
101
|
+
/** The aspect frame's style (camelCase CSS): the slot's only child in every fit. For natural it takes the
|
|
102
|
+
* image's own aspect and has no colour. */
|
|
103
|
+
box: Record<string, string>;
|
|
104
|
+
/** The `<img>` style: the image's rectangle in the box. */
|
|
105
|
+
img: Record<string, string>;
|
|
106
|
+
/** The placeholder layer's style (e.g. a blurhash canvas): the same rectangle as `img`. */
|
|
107
|
+
placeholder: Record<string, string>;
|
|
108
|
+
sources: {
|
|
109
|
+
type: string;
|
|
110
|
+
srcSet: string;
|
|
111
|
+
sizes: string;
|
|
112
|
+
}[];
|
|
113
|
+
fallback: {
|
|
114
|
+
src: string;
|
|
115
|
+
srcSet: string;
|
|
116
|
+
sizes: string;
|
|
117
|
+
width: number;
|
|
118
|
+
height: number;
|
|
119
|
+
};
|
|
120
|
+
};
|
|
121
|
+
/**
|
|
122
|
+
* A complete, standards-conforming `<picture>` for one descriptor in one of the site's formats (spec 2026-09-23 §6).
|
|
123
|
+
* Render: your **slot** element (your className, may be a flex/grid item) → `box` (the slot's only child, in every
|
|
124
|
+
* fit) → the placeholder layer and `<picture>` with `sources` and an `<img>` carrying `fallback` and `img`.
|
|
125
|
+
* `slots` describes your layout's widths for the slot (F6); the SDK scales them into `sizes`.
|
|
126
|
+
*/
|
|
127
|
+
export declare function formatImage(site: MediaConfig, d: MediaDescriptor, formatKey: string, slots: SlotSize[]): FormattedImage;
|
|
128
|
+
/** A camelCase style object as a `style` attribute value, for plain HTML and templating engines. Meant for
|
|
129
|
+
* `formatImage` output, whose values are SDK-generated or a server-validated `#rrggbb`: it does not escape, so
|
|
130
|
+
* do not pass it values of your own that could hold `"`, `;` or `<`. */
|
|
131
|
+
export declare function styleToCss(style: Record<string, string>): string;
|
package/dist/media.js
CHANGED
|
@@ -112,3 +112,145 @@ export function videoSources(site, d) {
|
|
|
112
112
|
name: v.name,
|
|
113
113
|
}));
|
|
114
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* The image's drawn width as a fraction of the slot (spec §6 rule 4). `rBox` = format W/H, `rImg` = image w/h,
|
|
117
|
+
* `padding` a fraction of the box's width (contain only).
|
|
118
|
+
*/
|
|
119
|
+
export function fillFactor(fit, rBox, rImg, padding) {
|
|
120
|
+
if (fit === "natural")
|
|
121
|
+
return 1;
|
|
122
|
+
if (fit === "cover")
|
|
123
|
+
return Math.max(1, rImg / rBox);
|
|
124
|
+
return Math.min(1 - 2 * padding, (1 / rBox - 2 * padding) * rImg);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Where a cover image sits (spec §3.3): the fractions `ox`/`oy` of the overflow placed before the box, so the
|
|
128
|
+
* focal point is as centred as possible without showing an edge. Visible fraction `v = min(rBox/rImg, rImg/rBox)`;
|
|
129
|
+
* along the overflowing axis `clamp((f − v/2) / (1 − v), 0, 1)`, the other axis 0.5. Unset focal ⇒ centre.
|
|
130
|
+
*/
|
|
131
|
+
export function coverOffset(focal, rBox, rImg) {
|
|
132
|
+
const v = Math.min(rBox / rImg, rImg / rBox);
|
|
133
|
+
if (v >= 1)
|
|
134
|
+
return { ox: 0.5, oy: 0.5 };
|
|
135
|
+
const pos = (f) => Math.min(1, Math.max(0, (f - v / 2) / (1 - v)));
|
|
136
|
+
const fx = focal == null ? 0.5 : toNumber(focal.x);
|
|
137
|
+
const fy = focal == null ? 0.5 : toNumber(focal.y);
|
|
138
|
+
return rImg > rBox ? { ox: pos(fx), oy: 0.5 } : { ox: 0.5, oy: pos(fy) };
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* The rectangle the image occupies, in percent of the box (spec §6 rule 3): `width = fill·100`,
|
|
142
|
+
* `height = fill·rBox/rImg·100`; contain centred (the padding inset is inside `fill`), cover placed by
|
|
143
|
+
* `coverOffset` (negative offsets; the box clips), natural the whole box (its box has the image's aspect).
|
|
144
|
+
* The `<img>` and the placeholder both use it.
|
|
145
|
+
*/
|
|
146
|
+
export function imageRect(fit, rBox, rImg, fill, offset) {
|
|
147
|
+
if (fit === "natural")
|
|
148
|
+
return { left: 0, top: 0, width: 100, height: 100 };
|
|
149
|
+
const width = fill * 100;
|
|
150
|
+
const height = ((fill * rBox) / rImg) * 100;
|
|
151
|
+
if (fit === "contain")
|
|
152
|
+
return { left: (100 - width) / 2, top: (100 - height) / 2, width, height };
|
|
153
|
+
return { left: offset.ox * (100 - width), top: offset.oy * (100 - height), width, height };
|
|
154
|
+
}
|
|
155
|
+
/** `fill` with 4 significant digits: a positive factor never rounds to 0 (review 2 #12). */
|
|
156
|
+
export function formatFactor(fill) {
|
|
157
|
+
return String(Number(fill.toPrecision(4)));
|
|
158
|
+
}
|
|
159
|
+
/** The `sizes` attribute: every slot's size scaled by `fill` with `calc()`, unchanged when `fill` is 1 (F10). */
|
|
160
|
+
export function scaledSizes(slots, fill) {
|
|
161
|
+
if (slots.length === 0)
|
|
162
|
+
throw new MediaUrlError("formatImage: slots must not be empty");
|
|
163
|
+
return slots
|
|
164
|
+
.map((s) => {
|
|
165
|
+
const size = fill === 1 ? s.size : `calc(${s.size} * ${formatFactor(fill)})`;
|
|
166
|
+
return s.media ? `${s.media} ${size}` : size;
|
|
167
|
+
})
|
|
168
|
+
.join(", ");
|
|
169
|
+
}
|
|
170
|
+
const NATURAL = { aspect: null, fit: "natural", padding: 0, background: null };
|
|
171
|
+
const pct = (v) => `${Number(v.toFixed(4))}%`;
|
|
172
|
+
/** Rule 1: `formats[key] ?? formats.default ?? natural`. Own-property lookups: a key like "constructor" must fall
|
|
173
|
+
* through to `default`/natural, never resolve on `Object.prototype`. */
|
|
174
|
+
function resolveFormat(site, key) {
|
|
175
|
+
const formats = site.formats ?? null;
|
|
176
|
+
if (formats && Object.hasOwn(formats, key))
|
|
177
|
+
return { key, format: formats[key] };
|
|
178
|
+
if (formats && Object.hasOwn(formats, "default"))
|
|
179
|
+
return { key: "default", format: formats.default };
|
|
180
|
+
return { key: "default", format: NATURAL };
|
|
181
|
+
}
|
|
182
|
+
function parseAspect(aspect, key) {
|
|
183
|
+
const m = aspect == null ? null : /^(\d{1,3})\/(\d{1,3})$/.exec(aspect);
|
|
184
|
+
if (!m)
|
|
185
|
+
throw new MediaUrlError(`formatImage: format '${key}' aspect '${aspect}' is not "W/H"`);
|
|
186
|
+
return { w: Number(m[1]), h: Number(m[2]) };
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* A complete, standards-conforming `<picture>` for one descriptor in one of the site's formats (spec 2026-09-23 §6).
|
|
190
|
+
* Render: your **slot** element (your className, may be a flex/grid item) → `box` (the slot's only child, in every
|
|
191
|
+
* fit) → the placeholder layer and `<picture>` with `sources` and an `<img>` carrying `fallback` and `img`.
|
|
192
|
+
* `slots` describes your layout's widths for the slot (F6); the SDK scales them into `sizes`.
|
|
193
|
+
*/
|
|
194
|
+
export function formatImage(site, d, formatKey, slots) {
|
|
195
|
+
if (d.kind !== "image")
|
|
196
|
+
throw new MediaUrlError(`formatImage: kind '${d.kind}' is not an image`);
|
|
197
|
+
requireImage(d, "formatImage");
|
|
198
|
+
if (d.w == null || d.h == null)
|
|
199
|
+
throw new MediaUrlError(`formatImage: descriptor ${d.id} has no w/h`);
|
|
200
|
+
const w = toNumber(d.w);
|
|
201
|
+
const h = toNumber(d.h);
|
|
202
|
+
const { key, format } = resolveFormat(site, formatKey);
|
|
203
|
+
const rawFit = format.fit;
|
|
204
|
+
// An unknown fit falls back to natural (spec §8), so `FormattedImage.fit` is always a real FormatFit.
|
|
205
|
+
let fit = rawFit === "contain" || rawFit === "cover" ? rawFit : "natural";
|
|
206
|
+
if (fit === "cover" && d.showWhole)
|
|
207
|
+
fit = "contain";
|
|
208
|
+
// Natural gets a box too (spec §6 rule 3): the descriptor's own aspect, no colour, the image fills it.
|
|
209
|
+
const a = fit === "natural" ? { w, h } : parseAspect(format.aspect, key);
|
|
210
|
+
const rBox = a.w / a.h;
|
|
211
|
+
const rImg = w / h;
|
|
212
|
+
const padding = fit === "contain" ? toNumber(format.padding) : 0;
|
|
213
|
+
const fill = fillFactor(fit, rBox, rImg, padding);
|
|
214
|
+
const rect = imageRect(fit, rBox, rImg, fill, coverOffset(d.focal, rBox, rImg));
|
|
215
|
+
const bgClass = d.bg ?? "default";
|
|
216
|
+
const bg = fit !== "natural" && format.background
|
|
217
|
+
? Object.hasOwn(format.background, bgClass)
|
|
218
|
+
? format.background[bgClass]
|
|
219
|
+
: Object.hasOwn(format.background, "default")
|
|
220
|
+
? format.background.default
|
|
221
|
+
: null
|
|
222
|
+
: null;
|
|
223
|
+
const box = {
|
|
224
|
+
display: "block", position: "relative", overflow: "hidden", width: "100%", height: "auto", aspectRatio: `${a.w} / ${a.h}`,
|
|
225
|
+
minHeight: "0", maxHeight: "none", padding: "0", border: "0", boxSizing: "content-box",
|
|
226
|
+
alignSelf: "flex-start", justifySelf: "stretch", flex: "none",
|
|
227
|
+
...(bg ? { backgroundColor: bg } : {}),
|
|
228
|
+
};
|
|
229
|
+
const layer = {
|
|
230
|
+
position: "absolute", left: pct(rect.left), top: pct(rect.top), width: pct(rect.width), height: pct(rect.height),
|
|
231
|
+
display: "block", objectFit: "fill",
|
|
232
|
+
// A global `img { max-width: 100%; height: auto }` reset must not shrink a cover rectangle wider than the box.
|
|
233
|
+
maxWidth: "none", maxHeight: "none", minWidth: "0", minHeight: "0", margin: "0", padding: "0", border: "0",
|
|
234
|
+
};
|
|
235
|
+
const sizes = scaledSizes(slots, fill);
|
|
236
|
+
// Same rendition logic as pictureSources (sources except the last format, middle-width fallback src): delegate
|
|
237
|
+
// rather than duplicate it; formatImage keeps the real w/h it already validated for the fallback dimensions.
|
|
238
|
+
const p = pictureSources(site, d, sizes);
|
|
239
|
+
return {
|
|
240
|
+
format: key,
|
|
241
|
+
fit,
|
|
242
|
+
box,
|
|
243
|
+
img: layer,
|
|
244
|
+
placeholder: { ...layer },
|
|
245
|
+
sources: p.sources,
|
|
246
|
+
fallback: { src: p.img.src, srcSet: p.img.srcSet, sizes, width: w, height: h },
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
/** A camelCase style object as a `style` attribute value, for plain HTML and templating engines. Meant for
|
|
250
|
+
* `formatImage` output, whose values are SDK-generated or a server-validated `#rrggbb`: it does not escape, so
|
|
251
|
+
* do not pass it values of your own that could hold `"`, `;` or `<`. */
|
|
252
|
+
export function styleToCss(style) {
|
|
253
|
+
return Object.entries(style)
|
|
254
|
+
.map(([k, v]) => `${k.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase())}: ${v}`)
|
|
255
|
+
.join("; ");
|
|
256
|
+
}
|
package/dist/schema.d.ts
CHANGED
|
@@ -370,6 +370,8 @@ export interface components {
|
|
|
370
370
|
text: string;
|
|
371
371
|
};
|
|
372
372
|
FacetDto: {
|
|
373
|
+
collapsed: boolean;
|
|
374
|
+
help: null | string;
|
|
373
375
|
key: string;
|
|
374
376
|
label: string;
|
|
375
377
|
match: string;
|
|
@@ -387,6 +389,7 @@ export interface components {
|
|
|
387
389
|
children: components["schemas"]["FacetValueDto"][] | null;
|
|
388
390
|
/** Format: int64 */
|
|
389
391
|
count: number | string;
|
|
392
|
+
help: null | string;
|
|
390
393
|
key: string;
|
|
391
394
|
label: string;
|
|
392
395
|
pinned: boolean;
|
|
@@ -438,6 +441,7 @@ export interface components {
|
|
|
438
441
|
/** Format: int32 */
|
|
439
442
|
duration: number | null | string;
|
|
440
443
|
filename: null | string;
|
|
444
|
+
focal: null | components["schemas"]["MediaFocalDto"];
|
|
441
445
|
formats: string[];
|
|
442
446
|
/** Format: int32 */
|
|
443
447
|
h: number | null | string;
|
|
@@ -451,6 +455,7 @@ export interface components {
|
|
|
451
455
|
/** Format: int32 */
|
|
452
456
|
recipe: number | string;
|
|
453
457
|
role: string;
|
|
458
|
+
showWhole: boolean;
|
|
454
459
|
variants: components["schemas"]["MediaVideoVariantDto"][] | null;
|
|
455
460
|
view: null | string;
|
|
456
461
|
/** Format: int32 */
|
|
@@ -459,8 +464,26 @@ export interface components {
|
|
|
459
464
|
};
|
|
460
465
|
MediaDto: {
|
|
461
466
|
baseUrl: string;
|
|
467
|
+
formats: null | {
|
|
468
|
+
[key: string]: components["schemas"]["MediaFormatDto"];
|
|
469
|
+
};
|
|
462
470
|
templates: components["schemas"]["MediaTemplatesDto"];
|
|
463
471
|
};
|
|
472
|
+
MediaFocalDto: {
|
|
473
|
+
/** Format: double */
|
|
474
|
+
x: number | string;
|
|
475
|
+
/** Format: double */
|
|
476
|
+
y: number | string;
|
|
477
|
+
};
|
|
478
|
+
MediaFormatDto: {
|
|
479
|
+
aspect: null | string;
|
|
480
|
+
background: null | {
|
|
481
|
+
[key: string]: string;
|
|
482
|
+
};
|
|
483
|
+
fit: string;
|
|
484
|
+
/** Format: double */
|
|
485
|
+
padding: number | string;
|
|
486
|
+
};
|
|
464
487
|
MediaPosterDto: {
|
|
465
488
|
formats: string[];
|
|
466
489
|
widths: (number | string)[];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@asstio-dev/storefront",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Typed client for the Asstio Storefront API (generated from OpenAPI).",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -19,13 +19,15 @@
|
|
|
19
19
|
"typecheck-examples": "tsc -p tsconfig.examples.json",
|
|
20
20
|
"build": "npm run generate && tsc -p tsconfig.json && node -e \"require('node:fs').copyFileSync('src/schema.d.ts','dist/schema.d.ts')\" && npm run typecheck-examples && npm run smoke",
|
|
21
21
|
"test": "vitest run",
|
|
22
|
+
"test:browser": "playwright test -c browser/playwright.config.ts",
|
|
22
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)\"",
|
|
23
|
-
"smoke": "node --input-type=module -e \"import(\\\"./dist/index.js\\\").then(m => { for (const f of [\\\"createStorefrontClient\\\", \\\"filterQuery\\\", \\\"pictureSources\\\", \\\"mediaUrl\\\"]) 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\\\"]) if (typeof m[f] !== \\\"function\\\") throw new Error(\\\"missing export \\\" + f) })\""
|
|
24
25
|
},
|
|
25
26
|
"dependencies": {
|
|
26
27
|
"openapi-fetch": "0.17.0"
|
|
27
28
|
},
|
|
28
29
|
"devDependencies": {
|
|
30
|
+
"@playwright/test": "1.63.0",
|
|
29
31
|
"openapi-typescript": "7.13.0",
|
|
30
32
|
"typescript": "5.9.3",
|
|
31
33
|
"vitest": "3.2.7"
|
package/src/schema.d.ts
CHANGED
|
@@ -370,6 +370,8 @@ export interface components {
|
|
|
370
370
|
text: string;
|
|
371
371
|
};
|
|
372
372
|
FacetDto: {
|
|
373
|
+
collapsed: boolean;
|
|
374
|
+
help: null | string;
|
|
373
375
|
key: string;
|
|
374
376
|
label: string;
|
|
375
377
|
match: string;
|
|
@@ -387,6 +389,7 @@ export interface components {
|
|
|
387
389
|
children: components["schemas"]["FacetValueDto"][] | null;
|
|
388
390
|
/** Format: int64 */
|
|
389
391
|
count: number | string;
|
|
392
|
+
help: null | string;
|
|
390
393
|
key: string;
|
|
391
394
|
label: string;
|
|
392
395
|
pinned: boolean;
|
|
@@ -438,6 +441,7 @@ export interface components {
|
|
|
438
441
|
/** Format: int32 */
|
|
439
442
|
duration: number | null | string;
|
|
440
443
|
filename: null | string;
|
|
444
|
+
focal: null | components["schemas"]["MediaFocalDto"];
|
|
441
445
|
formats: string[];
|
|
442
446
|
/** Format: int32 */
|
|
443
447
|
h: number | null | string;
|
|
@@ -451,6 +455,7 @@ export interface components {
|
|
|
451
455
|
/** Format: int32 */
|
|
452
456
|
recipe: number | string;
|
|
453
457
|
role: string;
|
|
458
|
+
showWhole: boolean;
|
|
454
459
|
variants: components["schemas"]["MediaVideoVariantDto"][] | null;
|
|
455
460
|
view: null | string;
|
|
456
461
|
/** Format: int32 */
|
|
@@ -459,8 +464,26 @@ export interface components {
|
|
|
459
464
|
};
|
|
460
465
|
MediaDto: {
|
|
461
466
|
baseUrl: string;
|
|
467
|
+
formats: null | {
|
|
468
|
+
[key: string]: components["schemas"]["MediaFormatDto"];
|
|
469
|
+
};
|
|
462
470
|
templates: components["schemas"]["MediaTemplatesDto"];
|
|
463
471
|
};
|
|
472
|
+
MediaFocalDto: {
|
|
473
|
+
/** Format: double */
|
|
474
|
+
x: number | string;
|
|
475
|
+
/** Format: double */
|
|
476
|
+
y: number | string;
|
|
477
|
+
};
|
|
478
|
+
MediaFormatDto: {
|
|
479
|
+
aspect: null | string;
|
|
480
|
+
background: null | {
|
|
481
|
+
[key: string]: string;
|
|
482
|
+
};
|
|
483
|
+
fit: string;
|
|
484
|
+
/** Format: double */
|
|
485
|
+
padding: number | string;
|
|
486
|
+
};
|
|
464
487
|
MediaPosterDto: {
|
|
465
488
|
formats: string[];
|
|
466
489
|
widths: (number | string)[];
|