@asstio-dev/storefront 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -55,6 +55,26 @@ const { data: page } = await storefront.GET("/v1/products/{slugOrId}/variants",
55
55
  Every example in this file is compiled as part of `npm run check` — the source of them is
56
56
  [`examples/readme.ts`](examples/readme.ts), which also covers product lookup, ETag revalidation and crawling.
57
57
 
58
+ ### Images
59
+
60
+ Products, cards, variants and cart lines carry **media descriptors**, never URLs. Combine a descriptor with the
61
+ `media` block of `/v1/site` to build `src`, `srcset` and `<picture>` sources. The helpers only ever produce a URL
62
+ the CDN actually serves: a width and a format the descriptor announces, and the descriptor's own `recipe`.
63
+
64
+ ```ts
65
+ import { pictureSources, fileUrl } from "@asstio-dev/storefront";
66
+
67
+ const { data: site } = await storefront.GET("/v1/site");
68
+ const { data: product } = await storefront.GET("/v1/products/{slugOrId}", { params: { path: { slugOrId: "acuvue-oasys" } } });
69
+
70
+ if (site && product?.image) {
71
+ const { sources, img } = pictureSources(site.media, product.image, "(min-width: 900px) 33vw, 100vw");
72
+ // <picture>{sources.map(s => <source {...s} />)}<img {...img} alt={product.image.alt["sv"] ?? product.name} /></picture>
73
+ }
74
+ for (const file of product?.files ?? []) console.log(file.filename, fileUrl(site!.media, file));
75
+ // A variant with hasOwnImages: false inherits: variant.hasOwnImages ? variant.images : product.images
76
+ ```
77
+
58
78
  ## Headers
59
79
 
60
80
  Every call the client makes carries these; you never set them by hand.
package/dist/index.d.ts CHANGED
@@ -44,3 +44,4 @@ export type FilterSelection = NonNullable<NonNullable<paths["/v1/products"]["get
44
44
  * The client sends it as `filter[brand]=dior,chanel` (deepObject), which is what the API reads.
45
45
  */
46
46
  export declare function filterQuery(selection: Record<string, string | string[] | undefined>): FilterSelection;
47
+ export { fileUrl, mediaHash, mediaUrl, MediaUrlError, pickWidth, pictureSources, posterUrl, srcSet, videoSources, type MediaConfig, type MediaDescriptor, type PictureSources, type VideoSource, } from "./media.js";
package/dist/index.js CHANGED
@@ -32,3 +32,4 @@ export function filterQuery(selection) {
32
32
  }
33
33
  return out;
34
34
  }
35
+ export { fileUrl, mediaHash, mediaUrl, MediaUrlError, pickWidth, pictureSources, posterUrl, srcSet, videoSources, } from "./media.js";
@@ -0,0 +1,51 @@
1
+ import type { components } from "./schema";
2
+ export type MediaDescriptor = components["schemas"]["MediaDescriptorDto"];
3
+ export type MediaConfig = components["schemas"]["MediaDto"];
4
+ export type PictureSources = {
5
+ sources: {
6
+ type: string;
7
+ srcSet: string;
8
+ sizes: string;
9
+ }[];
10
+ img: {
11
+ src: string;
12
+ srcSet: string;
13
+ sizes: string;
14
+ width?: number;
15
+ height?: number;
16
+ };
17
+ };
18
+ export type VideoSource = {
19
+ src: string;
20
+ type: string;
21
+ width: number;
22
+ height: number;
23
+ name: string;
24
+ };
25
+ /** A descriptor that cannot produce the URL asked for: wrong kind, a format or width it does not carry. */
26
+ export declare class MediaUrlError extends Error {
27
+ constructor(message: string);
28
+ }
29
+ /** The descriptor id without its `sha256:` prefix — the `{hash}` of every template. */
30
+ export declare function mediaHash(d: MediaDescriptor): string;
31
+ /** The smallest rendition at or above `width`, else the largest — only ever a width the descriptor announces. */
32
+ export declare function pickWidth(d: MediaDescriptor, width: number): number;
33
+ /** One image URL. `format` defaults to the last one announced (jpg, or gif for an animated GIF). */
34
+ export declare function mediaUrl(site: MediaConfig, d: MediaDescriptor, opts: {
35
+ width: number;
36
+ format?: string;
37
+ }): string;
38
+ /** `srcset` over every actual width of one format. */
39
+ export declare function srcSet(site: MediaConfig, d: MediaDescriptor, format: string): string;
40
+ /**
41
+ * Everything a `<picture>` needs: one `<source>` per format except the last (avif first, as the feed lists it),
42
+ * and the `<img>` in the last format with the middle width as `src`. One format ⇒ no sources, only the img (T15).
43
+ * `sizes` is mandatory — without it the browser picks the largest candidate.
44
+ */
45
+ export declare function pictureSources(site: MediaConfig, d: MediaDescriptor, sizes: string): PictureSources;
46
+ /** Poster (video, PDF, animated GIF): the poster template over `poster.widths`; always avif. */
47
+ export declare function posterUrl(site: MediaConfig, d: MediaDescriptor, width: number): string;
48
+ /** The download itself: the file template plus the sanitized filename the descriptor carries. */
49
+ export declare function fileUrl(site: MediaConfig, d: MediaDescriptor): string;
50
+ /** Every encoded rendition of a video, for `<source>` elements. Empty for anything that is not a video. */
51
+ export declare function videoSources(site: MediaConfig, d: MediaDescriptor): VideoSource[];
package/dist/media.js ADDED
@@ -0,0 +1,114 @@
1
+ /** A descriptor that cannot produce the URL asked for: wrong kind, a format or width it does not carry. */
2
+ export class MediaUrlError extends Error {
3
+ constructor(message) {
4
+ super(message);
5
+ this.name = "MediaUrlError";
6
+ }
7
+ }
8
+ const MIME = {
9
+ avif: "image/avif", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", png: "image/png",
10
+ mp4: "video/mp4", webm: "video/webm",
11
+ };
12
+ const mime = (format) => MIME[format] ?? `application/octet-stream`;
13
+ // `w`/`h`/`widths`/etc. are typed `number | string` in schema.d.ts (OpenAPI `format: int32` round-trips as
14
+ // either on the wire); every arithmetic/comparison use below normalizes through this first.
15
+ const toNumber = (v) => (typeof v === "number" ? v : Number(v));
16
+ /** The descriptor id without its `sha256:` prefix — the `{hash}` of every template. */
17
+ export function mediaHash(d) {
18
+ if (!d.id.startsWith("sha256:") || d.id.length !== 71)
19
+ throw new MediaUrlError(`media id '${d.id}' is not a sha256: descriptor id`);
20
+ return d.id.slice(7);
21
+ }
22
+ function fill(site, template, vars) {
23
+ const base = site.baseUrl.replace(/\/$/, "");
24
+ const path = template.replace(/\{(\w+)\}/g, (_, key) => {
25
+ const v = vars[key];
26
+ if (v === undefined)
27
+ throw new MediaUrlError(`template '${template}' needs '${key}'`);
28
+ return String(v);
29
+ });
30
+ return base + path;
31
+ }
32
+ function requireImage(d, what) {
33
+ // Branch on kind, never on emptiness: a file descriptor has widths: [] and formats: [] by contract.
34
+ if (d.kind !== "image" && d.kind !== "video")
35
+ throw new MediaUrlError(`${what}: kind '${d.kind}' has no image renditions; use fileUrl or posterUrl`);
36
+ if (d.widths.length === 0 || d.formats.length === 0)
37
+ throw new MediaUrlError(`${what}: descriptor ${d.id} announces no renditions`);
38
+ }
39
+ function pick(widths, width) {
40
+ const sorted = widths.map(toNumber).sort((a, b) => a - b);
41
+ return sorted.find((w) => w >= width) ?? sorted[sorted.length - 1];
42
+ }
43
+ /** The smallest rendition at or above `width`, else the largest — only ever a width the descriptor announces. */
44
+ export function pickWidth(d, width) {
45
+ requireImage(d, "pickWidth");
46
+ return pick(d.widths, width);
47
+ }
48
+ function requireFormat(d, format) {
49
+ const f = format ?? d.formats[d.formats.length - 1];
50
+ if (!d.formats.includes(f))
51
+ throw new MediaUrlError(`format '${f}' is not one of ${d.formats.join(", ")} for ${d.id}`);
52
+ return f;
53
+ }
54
+ /** One image URL. `format` defaults to the last one announced (jpg, or gif for an animated GIF). */
55
+ export function mediaUrl(site, d, opts) {
56
+ requireImage(d, "mediaUrl");
57
+ const format = requireFormat(d, opts.format);
58
+ return fill(site, site.templates.image, { hash: mediaHash(d), recipe: d.recipe, width: pick(d.widths, opts.width), format });
59
+ }
60
+ /** `srcset` over every actual width of one format. */
61
+ export function srcSet(site, d, format) {
62
+ requireImage(d, "srcSet");
63
+ const f = requireFormat(d, format);
64
+ return d.widths
65
+ .map(toNumber)
66
+ .sort((a, b) => a - b)
67
+ .map((w) => `${fill(site, site.templates.image, { hash: mediaHash(d), recipe: d.recipe, width: w, format: f })} ${w}w`)
68
+ .join(", ");
69
+ }
70
+ /**
71
+ * Everything a `<picture>` needs: one `<source>` per format except the last (avif first, as the feed lists it),
72
+ * and the `<img>` in the last format with the middle width as `src`. One format ⇒ no sources, only the img (T15).
73
+ * `sizes` is mandatory — without it the browser picks the largest candidate.
74
+ */
75
+ export function pictureSources(site, d, sizes) {
76
+ requireImage(d, "pictureSources");
77
+ const formats = d.formats;
78
+ const last = formats[formats.length - 1];
79
+ const widths = d.widths.map(toNumber).sort((a, b) => a - b);
80
+ return {
81
+ sources: formats.slice(0, -1).map((f) => ({ type: mime(f), srcSet: srcSet(site, d, f), sizes })),
82
+ img: {
83
+ src: mediaUrl(site, d, { width: widths[Math.floor(widths.length / 2)], format: last }),
84
+ srcSet: srcSet(site, d, last),
85
+ sizes,
86
+ width: d.w == null ? undefined : toNumber(d.w),
87
+ height: d.h == null ? undefined : toNumber(d.h),
88
+ },
89
+ };
90
+ }
91
+ /** Poster (video, PDF, animated GIF): the poster template over `poster.widths`; always avif. */
92
+ export function posterUrl(site, d, width) {
93
+ if (!d.poster || d.poster.widths.length === 0)
94
+ throw new MediaUrlError(`posterUrl: descriptor ${d.id} has no poster`);
95
+ return fill(site, site.templates.poster, { hash: mediaHash(d), recipe: d.recipe, width: pick(d.poster.widths, width) });
96
+ }
97
+ /** The download itself: the file template plus the sanitized filename the descriptor carries. */
98
+ export function fileUrl(site, d) {
99
+ if (d.kind !== "file" || !d.filename)
100
+ throw new MediaUrlError(`fileUrl: descriptor ${d.id} is not a file`);
101
+ return fill(site, site.templates.file, { hash: mediaHash(d), recipe: d.recipe, filename: d.filename });
102
+ }
103
+ /** Every encoded rendition of a video, for `<source>` elements. Empty for anything that is not a video. */
104
+ export function videoSources(site, d) {
105
+ if (d.kind !== "video" || !d.variants)
106
+ return [];
107
+ return d.variants.map((v) => ({
108
+ src: fill(site, site.templates.video, { hash: mediaHash(d), recipe: d.recipe, variant: v.name, format: v.format }),
109
+ type: mime(v.format),
110
+ width: toNumber(v.w),
111
+ height: toNumber(v.h),
112
+ name: v.name,
113
+ }));
114
+ }
package/dist/schema.d.ts CHANGED
@@ -249,6 +249,7 @@ export interface components {
249
249
  };
250
250
  CartLineDto: {
251
251
  id: string;
252
+ image: null | components["schemas"]["MediaDescriptorDto"];
252
253
  price: components["schemas"]["PriceDto"];
253
254
  productId: string;
254
255
  /** Format: int32 */
@@ -339,6 +340,68 @@ export interface components {
339
340
  language: string;
340
341
  urlTemplate: string;
341
342
  };
343
+ MediaDescriptorDto: {
344
+ alt: {
345
+ [key: string]: string;
346
+ };
347
+ /** @description Passed through opaque, exactly as Purchase wrote it (spec 2026-09-18 §3); no fixed shape. */
348
+ attributes: null | {
349
+ [key: string]: unknown;
350
+ };
351
+ bg: null | string;
352
+ blurhash: null | string;
353
+ /** Format: int64 */
354
+ bytes: number | null | string;
355
+ caption: null | {
356
+ [key: string]: string;
357
+ };
358
+ color: null | string;
359
+ /** Format: int32 */
360
+ duration: number | null | string;
361
+ filename: null | string;
362
+ formats: string[];
363
+ /** Format: int32 */
364
+ h: number | null | string;
365
+ id: string;
366
+ kind: string;
367
+ /** Format: int32 */
368
+ order: number | string;
369
+ /** Format: int32 */
370
+ pages: number | null | string;
371
+ poster: null | components["schemas"]["MediaPosterDto"];
372
+ /** Format: int32 */
373
+ recipe: number | string;
374
+ role: string;
375
+ variants: components["schemas"]["MediaVideoVariantDto"][] | null;
376
+ view: null | string;
377
+ /** Format: int32 */
378
+ w: number | null | string;
379
+ widths: (number | string)[];
380
+ };
381
+ MediaDto: {
382
+ baseUrl: string;
383
+ templates: components["schemas"]["MediaTemplatesDto"];
384
+ };
385
+ MediaPosterDto: {
386
+ formats: string[];
387
+ widths: (number | string)[];
388
+ };
389
+ MediaTemplatesDto: {
390
+ file: string;
391
+ image: string;
392
+ poster: string;
393
+ video: string;
394
+ };
395
+ MediaVideoVariantDto: {
396
+ /** Format: int64 */
397
+ bytes: number | string;
398
+ format: string;
399
+ /** Format: int32 */
400
+ h: number | string;
401
+ name: string;
402
+ /** Format: int32 */
403
+ w: number | string;
404
+ };
342
405
  OptionDto: {
343
406
  name: string;
344
407
  values: string[];
@@ -353,6 +416,7 @@ export interface components {
353
416
  ProductCardDto: {
354
417
  collections: string[];
355
418
  id: string;
419
+ image: null | components["schemas"]["MediaDescriptorDto"];
356
420
  inStock: boolean;
357
421
  name: string;
358
422
  price: null | components["schemas"]["PriceDto"];
@@ -369,7 +433,10 @@ export interface components {
369
433
  };
370
434
  categories: components["schemas"]["CategoryRefDto"][];
371
435
  collections: components["schemas"]["CollectionRefDto"][];
436
+ files: components["schemas"]["MediaDescriptorDto"][];
372
437
  id: string;
438
+ image: null | components["schemas"]["MediaDescriptorDto"];
439
+ images: components["schemas"]["MediaDescriptorDto"][];
373
440
  matchedVariantId: null | string;
374
441
  name: string;
375
442
  options: components["schemas"]["OptionDto"][];
@@ -408,6 +475,7 @@ export interface components {
408
475
  configVersion: string;
409
476
  defaultMarket: string;
410
477
  markets: components["schemas"]["MarketDto"][];
478
+ media: components["schemas"]["MediaDto"];
411
479
  name: string;
412
480
  preview: boolean;
413
481
  urlPatterns: components["schemas"]["UrlPatternsDto"];
@@ -466,7 +534,7 @@ export interface components {
466
534
  };
467
535
  SwatchDto: {
468
536
  color: null | string;
469
- imageUrl: null | string;
537
+ image: null | components["schemas"]["MediaDescriptorDto"];
470
538
  };
471
539
  UpdateCartLineRequest: {
472
540
  /** Format: int32 */
@@ -477,7 +545,10 @@ export interface components {
477
545
  product: string;
478
546
  };
479
547
  VariantDto: {
548
+ hasOwnImages: boolean;
480
549
  id: string;
550
+ image: null | components["schemas"]["MediaDescriptorDto"];
551
+ images: components["schemas"]["MediaDescriptorDto"][];
481
552
  inStock: boolean;
482
553
  price: null | components["schemas"]["PriceDto"];
483
554
  selectedOptions: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asstio-dev/storefront",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Typed client for the Asstio Storefront API (generated from OpenAPI).",
5
5
  "repository": {
6
6
  "type": "git",
@@ -17,14 +17,17 @@
17
17
  "scripts": {
18
18
  "generate": "openapi-typescript ../../tests/Storefront.Api.Tests/Snapshots/openapi-v1.json -o src/schema.d.ts",
19
19
  "typecheck-examples": "tsc -p tsconfig.examples.json",
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",
21
- "check": "npm run generate && npm run typecheck-examples && git diff --exit-code -- src/schema.d.ts && test -z \"$(git status --porcelain -- src/schema.d.ts)\""
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
+ "test": "vitest run",
22
+ "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) })\""
22
24
  },
23
25
  "dependencies": {
24
26
  "openapi-fetch": "0.17.0"
25
27
  },
26
28
  "devDependencies": {
27
29
  "openapi-typescript": "7.13.0",
28
- "typescript": "5.9.3"
30
+ "typescript": "5.9.3",
31
+ "vitest": "3.2.7"
29
32
  }
30
33
  }
package/src/schema.d.ts CHANGED
@@ -249,6 +249,7 @@ export interface components {
249
249
  };
250
250
  CartLineDto: {
251
251
  id: string;
252
+ image: null | components["schemas"]["MediaDescriptorDto"];
252
253
  price: components["schemas"]["PriceDto"];
253
254
  productId: string;
254
255
  /** Format: int32 */
@@ -339,6 +340,68 @@ export interface components {
339
340
  language: string;
340
341
  urlTemplate: string;
341
342
  };
343
+ MediaDescriptorDto: {
344
+ alt: {
345
+ [key: string]: string;
346
+ };
347
+ /** @description Passed through opaque, exactly as Purchase wrote it (spec 2026-09-18 §3); no fixed shape. */
348
+ attributes: null | {
349
+ [key: string]: unknown;
350
+ };
351
+ bg: null | string;
352
+ blurhash: null | string;
353
+ /** Format: int64 */
354
+ bytes: number | null | string;
355
+ caption: null | {
356
+ [key: string]: string;
357
+ };
358
+ color: null | string;
359
+ /** Format: int32 */
360
+ duration: number | null | string;
361
+ filename: null | string;
362
+ formats: string[];
363
+ /** Format: int32 */
364
+ h: number | null | string;
365
+ id: string;
366
+ kind: string;
367
+ /** Format: int32 */
368
+ order: number | string;
369
+ /** Format: int32 */
370
+ pages: number | null | string;
371
+ poster: null | components["schemas"]["MediaPosterDto"];
372
+ /** Format: int32 */
373
+ recipe: number | string;
374
+ role: string;
375
+ variants: components["schemas"]["MediaVideoVariantDto"][] | null;
376
+ view: null | string;
377
+ /** Format: int32 */
378
+ w: number | null | string;
379
+ widths: (number | string)[];
380
+ };
381
+ MediaDto: {
382
+ baseUrl: string;
383
+ templates: components["schemas"]["MediaTemplatesDto"];
384
+ };
385
+ MediaPosterDto: {
386
+ formats: string[];
387
+ widths: (number | string)[];
388
+ };
389
+ MediaTemplatesDto: {
390
+ file: string;
391
+ image: string;
392
+ poster: string;
393
+ video: string;
394
+ };
395
+ MediaVideoVariantDto: {
396
+ /** Format: int64 */
397
+ bytes: number | string;
398
+ format: string;
399
+ /** Format: int32 */
400
+ h: number | string;
401
+ name: string;
402
+ /** Format: int32 */
403
+ w: number | string;
404
+ };
342
405
  OptionDto: {
343
406
  name: string;
344
407
  values: string[];
@@ -353,6 +416,7 @@ export interface components {
353
416
  ProductCardDto: {
354
417
  collections: string[];
355
418
  id: string;
419
+ image: null | components["schemas"]["MediaDescriptorDto"];
356
420
  inStock: boolean;
357
421
  name: string;
358
422
  price: null | components["schemas"]["PriceDto"];
@@ -369,7 +433,10 @@ export interface components {
369
433
  };
370
434
  categories: components["schemas"]["CategoryRefDto"][];
371
435
  collections: components["schemas"]["CollectionRefDto"][];
436
+ files: components["schemas"]["MediaDescriptorDto"][];
372
437
  id: string;
438
+ image: null | components["schemas"]["MediaDescriptorDto"];
439
+ images: components["schemas"]["MediaDescriptorDto"][];
373
440
  matchedVariantId: null | string;
374
441
  name: string;
375
442
  options: components["schemas"]["OptionDto"][];
@@ -408,6 +475,7 @@ export interface components {
408
475
  configVersion: string;
409
476
  defaultMarket: string;
410
477
  markets: components["schemas"]["MarketDto"][];
478
+ media: components["schemas"]["MediaDto"];
411
479
  name: string;
412
480
  preview: boolean;
413
481
  urlPatterns: components["schemas"]["UrlPatternsDto"];
@@ -466,7 +534,7 @@ export interface components {
466
534
  };
467
535
  SwatchDto: {
468
536
  color: null | string;
469
- imageUrl: null | string;
537
+ image: null | components["schemas"]["MediaDescriptorDto"];
470
538
  };
471
539
  UpdateCartLineRequest: {
472
540
  /** Format: int32 */
@@ -477,7 +545,10 @@ export interface components {
477
545
  product: string;
478
546
  };
479
547
  VariantDto: {
548
+ hasOwnImages: boolean;
480
549
  id: string;
550
+ image: null | components["schemas"]["MediaDescriptorDto"];
551
+ images: components["schemas"]["MediaDescriptorDto"][];
481
552
  inStock: boolean;
482
553
  price: null | components["schemas"]["PriceDto"];
483
554
  selectedOptions: {