@asstio-dev/storefront 0.28.0 → 0.31.1

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,44 @@
1
+ import type { Client } from "openapi-fetch";
2
+ import type { StorefrontPaths } from "../index.js";
3
+ export type SitemapType = "product" | "collection" | "brand";
4
+ export type SitemapUrl = {
5
+ url: string;
6
+ lastModified?: string;
7
+ alternates: {
8
+ hreflang: string;
9
+ href: string;
10
+ }[];
11
+ };
12
+ /** `complete: false` = the API was unavailable part-way (transport failure, a 5xx, `INDEX_UNAVAILABLE`,
13
+ * `SITE_REGISTRY_WARMING`); `entries` is what was read before that. `complete: true` means "walked to exhaustion
14
+ * without an error" — a cursor walk is an eventually consistent crawl, not a snapshot. */
15
+ export type SitemapResult = {
16
+ entries: SitemapUrl[];
17
+ complete: boolean;
18
+ };
19
+ /** Next.js `MetadataRoute.Sitemap` entry, structurally typed (no `next` dependency, like `toNextMetadata`). */
20
+ export type NextSitemapEntry = {
21
+ url: string;
22
+ lastModified?: string;
23
+ alternates?: {
24
+ languages: Record<string, string>;
25
+ };
26
+ };
27
+ export interface SitemapOptions {
28
+ /** The market the client was created with (the same `X-Market`). Required: `/v1/site` answers the site's default market,
29
+ * not the request's, and a wrong market here would pair one market's URLs with another's hreflang. */
30
+ market: string;
31
+ /** Default: all three. */
32
+ types?: SitemapType[];
33
+ /** Items per request, 1–1000 (default 1000). */
34
+ pageSize?: number;
35
+ }
36
+ /**
37
+ * Every sitemap URL of one market: `GET /v1/site` once, then for each type a cursor walk of `GET /v1/sitemap` from
38
+ * `cursor=start` along `nextCursor`. hreflang tags are real BCP 47 tags (`sv-SE`, never the market code `se`), with a
39
+ * self-reference, and `x-default` only when the site's default market has the page. An epoch `updatedAt` (collections)
40
+ * is left out rather than printed as 1970.
41
+ */
42
+ export declare function sitemapEntries(client: Client<StorefrontPaths>, opts: SitemapOptions): Promise<SitemapResult>;
43
+ /** Next.js `app/sitemap.ts` shape: `alternates` → `{ languages: { [hreflang]: href } }`, `x-default` included. */
44
+ export declare function toNextSitemap(r: SitemapResult): NextSitemapEntry[];
@@ -0,0 +1,91 @@
1
+ import { alternateSet } from "./shared.js";
2
+ import { SeoError } from "./types.js";
3
+ const ALL_TYPES = ["product", "collection", "brand"];
4
+ const RETRYABLE = new Set(["INDEX_UNAVAILABLE", "SITE_REGISTRY_WARMING"]);
5
+ /** The walk stops here, partial: the API is unavailable, not wrong. */
6
+ const UNAVAILABLE = Symbol("unavailable");
7
+ /**
8
+ * Every sitemap URL of one market: `GET /v1/site` once, then for each type a cursor walk of `GET /v1/sitemap` from
9
+ * `cursor=start` along `nextCursor`. hreflang tags are real BCP 47 tags (`sv-SE`, never the market code `se`), with a
10
+ * self-reference, and `x-default` only when the site's default market has the page. An epoch `updatedAt` (collections)
11
+ * is left out rather than printed as 1970.
12
+ */
13
+ export async function sitemapEntries(client, opts) {
14
+ const entries = [];
15
+ const pageSize = opts.pageSize ?? 1000;
16
+ const site = await read(() => client.GET("/v1/site", {}));
17
+ if (site === UNAVAILABLE)
18
+ return { entries, complete: false };
19
+ // The API matches market codes case-insensitively; alternateSet does not, so the configured spelling is passed on.
20
+ const wanted = opts.market.toLowerCase();
21
+ const market = site.markets.find((m) => m.code.toLowerCase() === wanted)?.code;
22
+ if (market === undefined)
23
+ throw new SeoError(`market '${opts.market}' is not one of the site's markets (${site.markets.map((m) => m.code).join(", ")})`);
24
+ for (const type of opts.types ?? ALL_TYPES) {
25
+ let cursor = "start";
26
+ for (;;) {
27
+ const page = await read(() => client.GET("/v1/sitemap", { params: { query: { type, cursor, pageSize } } }));
28
+ if (page === UNAVAILABLE)
29
+ return { entries, complete: false };
30
+ // An API older than cursor paging ignores ?cursor= and answers one page without the field: that must never read as
31
+ // the end of the walk, or the sitemap would silently shrink to its first page.
32
+ if (!Object.hasOwn(page, "nextCursor"))
33
+ throw new Error("the Storefront API does not support sitemap cursors; deploy the API first");
34
+ for (const item of page.items)
35
+ entries.push(toSitemapUrl(site, market, item));
36
+ if (page.nextCursor === null)
37
+ break;
38
+ if (page.nextCursor === cursor)
39
+ throw new Error(`the Storefront API returned the same sitemap cursor twice (type=${type})`);
40
+ cursor = page.nextCursor;
41
+ }
42
+ }
43
+ return { entries, complete: true };
44
+ }
45
+ /** Next.js `app/sitemap.ts` shape: `alternates` → `{ languages: { [hreflang]: href } }`, `x-default` included. */
46
+ export function toNextSitemap(r) {
47
+ return r.entries.map((e) => {
48
+ const out = { url: e.url };
49
+ if (e.lastModified !== undefined)
50
+ out.lastModified = e.lastModified;
51
+ if (e.alternates.length > 0)
52
+ out.alternates = { languages: Object.fromEntries(e.alternates.map((a) => [a.hreflang, a.href])) };
53
+ return out;
54
+ });
55
+ }
56
+ function toSitemapUrl(site, market, item) {
57
+ let alternates = [];
58
+ try {
59
+ alternates = alternateSet(site, market, item.url, item.alternateUrls).alternates;
60
+ }
61
+ catch (e) {
62
+ // Two markets mapping to one hreflang with different URLs is a site config fault: drop this entry's alternates, keep
63
+ // the URL. A config fault must not empty the sitemap.
64
+ if (!(e instanceof SeoError))
65
+ throw e;
66
+ }
67
+ const time = Date.parse(item.updatedAt);
68
+ return Number.isNaN(time) || time === 0 ? { url: item.url, alternates } : { url: item.url, lastModified: item.updatedAt, alternates };
69
+ }
70
+ /**
71
+ * One request. Decided on the status first: an empty-body 500 has no `error`, and an HTML 502 has a string one. A thrown
72
+ * request is a transport failure; the catch wraps the request only, never the mapping after it.
73
+ */
74
+ async function read(request) {
75
+ let result;
76
+ try {
77
+ result = await request();
78
+ }
79
+ catch {
80
+ return UNAVAILABLE;
81
+ }
82
+ const { data, error, response } = result;
83
+ if (response.status >= 500)
84
+ return UNAVAILABLE;
85
+ const code = typeof error === "object" && error !== null && "code" in error && typeof error.code === "string" ? error.code : undefined;
86
+ if (code !== undefined && RETRYABLE.has(code))
87
+ return UNAVAILABLE;
88
+ if (!response.ok || data === undefined)
89
+ throw new Error(`Storefront API ${response.url} answered ${response.status}${code ? ` ${code}` : ""}`);
90
+ return data;
91
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asstio-dev/storefront",
3
- "version": "0.28.0",
3
+ "version": "0.31.1",
4
4
  "description": "Typed client for the Asstio Storefront API (generated from OpenAPI).",
5
5
  "repository": {
6
6
  "type": "git",
@@ -10,6 +10,25 @@
10
10
  "type": "module",
11
11
  "main": "dist/index.js",
12
12
  "types": "dist/index.d.ts",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "default": "./dist/index.js"
17
+ },
18
+ "./account": {
19
+ "types": "./dist/account/index.d.ts",
20
+ "default": "./dist/account/index.js"
21
+ },
22
+ "./account/browser": {
23
+ "types": "./dist/account/browser.d.ts",
24
+ "default": "./dist/account/browser.js"
25
+ },
26
+ "./account/next": {
27
+ "types": "./dist/account/next.d.ts",
28
+ "default": "./dist/account/next.js"
29
+ },
30
+ "./package.json": "./package.json"
31
+ },
13
32
  "files": [
14
33
  "dist",
15
34
  "src/schema.d.ts"
@@ -21,11 +40,20 @@
21
40
  "test": "vitest run",
22
41
  "test:browser": "playwright test -c browser/playwright.config.ts",
23
42
  "check": "npm run generate && npm run typecheck-examples && npm test && git diff --exit-code -- src/schema.d.ts && test -z \"$(git status --porcelain -- src/schema.d.ts)\"",
24
- "smoke": "node --input-type=module -e \"import(\\\"./dist/index.js\\\").then(m => { for (const f of [\\\"createStorefrontClient\\\", \\\"filterQuery\\\", \\\"encodeFilterValues\\\", \\\"decodeFilterValues\\\", \\\"pictureSources\\\", \\\"mediaUrl\\\", \\\"formatImage\\\", \\\"styleToCss\\\", \\\"startCheckout\\\", \\\"syncCheckout\\\", \\\"completeCheckout\\\", \\\"getCheckoutStatus\\\", \\\"liveShippingFor\\\", \\\"getOrder\\\", \\\"awaitCompletion\\\", \\\"mountCheckout\\\", \\\"productJsonLd\\\", \\\"gtinJsonLd\\\", \\\"breadcrumbJsonLd\\\", \\\"productHead\\\", \\\"collectionHead\\\", \\\"brandHead\\\", \\\"toNextMetadata\\\", \\\"jsonLdScript\\\", \\\"SeoError\\\", \\\"productFacts\\\", \\\"toGa4Item\\\", \\\"toGa4Ecommerce\\\", \\\"longestCategory\\\", \\\"AnalyticsError\\\", \\\"trackSearchEvents\\\", \\\"variantUrl\\\", \\\"readVariantParam\\\", \\\"variantQueryForApi\\\", \\\"redirectTarget\\\"]) if (typeof m[f] !== \\\"function\\\") throw new Error(\\\"missing export \\\" + f) })\""
43
+ "smoke": "node --input-type=module -e \"import(\\\"./dist/index.js\\\").then(m => { for (const f of [\\\"createStorefrontClient\\\", \\\"filterQuery\\\", \\\"encodeFilterValues\\\", \\\"decodeFilterValues\\\", \\\"pictureSources\\\", \\\"mediaUrl\\\", \\\"formatImage\\\", \\\"styleToCss\\\", \\\"startCheckout\\\", \\\"syncCheckout\\\", \\\"completeCheckout\\\", \\\"getCheckoutStatus\\\", \\\"liveShippingFor\\\", \\\"getOrder\\\", \\\"awaitCompletion\\\", \\\"mountCheckout\\\", \\\"productJsonLd\\\", \\\"gtinJsonLd\\\", \\\"breadcrumbJsonLd\\\", \\\"productHead\\\", \\\"collectionHead\\\", \\\"brandHead\\\", \\\"toNextMetadata\\\", \\\"sitemapEntries\\\", \\\"toNextSitemap\\\", \\\"jsonLdScript\\\", \\\"SeoError\\\", \\\"productFacts\\\", \\\"toGa4Item\\\", \\\"toGa4Ecommerce\\\", \\\"longestCategory\\\", \\\"AnalyticsError\\\", \\\"trackSearchEvents\\\", \\\"variantUrl\\\", \\\"readVariantParam\\\", \\\"variantQueryForApi\\\", \\\"redirectTarget\\\"]) if (typeof m[f] !== \\\"function\\\") throw new Error(\\\"missing export \\\" + f) })\" && npm run smoke:account",
44
+ "smoke:account": "node --input-type=module -e \"const checks = { \\\"./dist/account/index.js\\\": [\\\"createShopperClient\\\", \\\"startSignIn\\\", \\\"verifySignIn\\\", \\\"signOut\\\", \\\"switchPrincipal\\\", \\\"getSession\\\", \\\"getMe\\\", \\\"getMyCompany\\\", \\\"linkChallengeId\\\", \\\"linkToken\\\", \\\"sessionCookie\\\", \\\"signInCookie\\\", \\\"cartCookie\\\", \\\"browserCartCookie\\\", \\\"copyBrowserCart\\\", \\\"CopyBrowserCartError\\\", \\\"cookieSecurity\\\", \\\"readSignInCookie\\\", \\\"serializeCookie\\\", \\\"shopperIpFrom\\\", \\\"assertSameOrigin\\\", \\\"safeReturnPath\\\", \\\"CrossOriginError\\\"], \\\"./dist/account/browser.js\\\": [\\\"readLinkFragment\\\"], \\\"./dist/account/next.js\\\": [\\\"startSignInAction\\\", \\\"verifySignInAction\\\", \\\"signOutAction\\\", \\\"switchPrincipalAction\\\", \\\"shopperClient\\\", \\\"copyBrowserCartAction\\\"] }; for (const [file, names] of Object.entries(checks)) { const m = await import(file); for (const f of names) if (typeof m[f] !== \\\"function\\\") throw new Error(\\\"missing export \\\" + f + \\\" in \\\" + file); }\""
25
45
  },
26
46
  "dependencies": {
27
47
  "openapi-fetch": "0.17.0"
28
48
  },
49
+ "peerDependencies": {
50
+ "next": ">=15"
51
+ },
52
+ "peerDependenciesMeta": {
53
+ "next": {
54
+ "optional": true
55
+ }
56
+ },
29
57
  "devDependencies": {
30
58
  "@playwright/test": "1.63.0",
31
59
  "@types/react": "19.3.0",