@asstio-dev/storefront 0.1.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
@@ -9,6 +9,11 @@ deliberate commit, so a change here always shows up in a diff.
9
9
  npm install @asstio-dev/storefront
10
10
  ```
11
11
 
12
+ Setting a shop up in the first place — the Asstio-side MCP calls, the API key kinds, CORS, the filter and
13
+ price model — is one document: [`docs/PUBLIC_STOREFRONT_ONBOARDING.md`](../../docs/PUBLIC_STOREFRONT_ONBOARDING.md),
14
+ served publicly at <https://api.storefront.asstio.com/docs/onboarding>. This file is only about using the
15
+ package.
16
+
12
17
  ```ts
13
18
  import { createStorefrontClient, filterQuery } from "@asstio-dev/storefront";
14
19
 
@@ -50,6 +55,26 @@ const { data: page } = await storefront.GET("/v1/products/{slugOrId}/variants",
50
55
  Every example in this file is compiled as part of `npm run check` — the source of them is
51
56
  [`examples/readme.ts`](examples/readme.ts), which also covers product lookup, ETag revalidation and crawling.
52
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
+
53
78
  ## Headers
54
79
 
55
80
  Every call the client makes carries these; you never set them by hand.
@@ -132,12 +157,46 @@ Errors are never stored (`Cache-Control: no-store`) and never carry an ETag.
132
157
 
133
158
  ## Development
134
159
 
160
+ From `sdk/typescript`:
161
+
135
162
  ```bash
136
- npm install
163
+ npm ci
137
164
  npm run build # regenerates src/schema.d.ts from the snapshot, compiles, then typechecks the examples
138
165
  npm run check # fails if the committed schema.d.ts is stale, modified or untracked, or an example broke
139
166
  ```
140
167
 
141
168
  `src/schema.d.ts` is generated **and committed**: it is reviewed in pull requests exactly like the
142
- OpenAPI snapshot it comes from. After changing the API, regenerate the snapshot
143
- (`UPDATE_SNAPSHOTS=1 dotnet test --project tests/Storefront.Api.Tests`), then `npm run build` here.
169
+ OpenAPI snapshot it comes from. After changing the public API, regenerate the snapshot from the repo
170
+ root, then build and check the SDK:
171
+
172
+ ```bash
173
+ UPDATE_SNAPSHOTS=1 dotnet test --project tests/Storefront.Api.Tests -- --filter-query "/*/*/OpenApiSnapshotTests/*"
174
+ cd sdk/typescript
175
+ npm ci
176
+ npm run build
177
+ npm run check
178
+ ```
179
+
180
+ Review and commit `tests/Storefront.Api.Tests/Snapshots/openapi-v1.json` and
181
+ `sdk/typescript/src/schema.d.ts` together. An API or worker implementation change that leaves the
182
+ OpenAPI snapshot unchanged does not require a generated SDK change.
183
+
184
+ ## Publishing
185
+
186
+ The SDK and backend share a release version. Do not edit the placeholder version in `package.json`
187
+ and do not run `npm publish` locally. Pushing a repository tag such as `v1.2.3` runs
188
+ `publish-npm.yml`, which derives version `1.2.3` from the tag, installs and checks the package, runs a
189
+ publish dry run, and creates a pending staged package through npm trusted publishing. No long-lived
190
+ `NPM_TOKEN` is used.
191
+
192
+ The tag does not make the package public. A maintainer must inspect and approve it with npm 2FA, on
193
+ the npm website under **Staged Packages** or with npm 11.19 or newer:
194
+
195
+ ```bash
196
+ npm stage list @asstio-dev/storefront
197
+ npm stage view <stage-id>
198
+ npm stage approve <stage-id>
199
+ ```
200
+
201
+ After approval, verify the registry version and a clean consumer install. The full API, worker, and
202
+ npm release checklist is in [`docs/ops/deploy.md`](../../docs/ops/deploy.md).
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
+ }