@laconius/core 0.1.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.
Files changed (90) hide show
  1. package/LICENSE +73 -0
  2. package/README.md +31 -0
  3. package/lib/module/auth.js +182 -0
  4. package/lib/module/auth.js.map +1 -0
  5. package/lib/module/client.js +99 -0
  6. package/lib/module/client.js.map +1 -0
  7. package/lib/module/config.js +72 -0
  8. package/lib/module/config.js.map +1 -0
  9. package/lib/module/converters.js +31 -0
  10. package/lib/module/converters.js.map +1 -0
  11. package/lib/module/create-config.js +36 -0
  12. package/lib/module/create-config.js.map +1 -0
  13. package/lib/module/endpoints.js +89 -0
  14. package/lib/module/endpoints.js.map +1 -0
  15. package/lib/module/errors.js +76 -0
  16. package/lib/module/errors.js.map +1 -0
  17. package/lib/module/i18n.js +84 -0
  18. package/lib/module/i18n.js.map +1 -0
  19. package/lib/module/index.js +18 -0
  20. package/lib/module/index.js.map +1 -0
  21. package/lib/module/media.js +182 -0
  22. package/lib/module/media.js.map +1 -0
  23. package/lib/module/models.js +2 -0
  24. package/lib/module/models.js.map +1 -0
  25. package/lib/module/package.json +1 -0
  26. package/lib/module/provider.js +182 -0
  27. package/lib/module/provider.js.map +1 -0
  28. package/lib/module/runtime.js +43 -0
  29. package/lib/module/runtime.js.map +1 -0
  30. package/lib/module/single-flight.js +23 -0
  31. package/lib/module/single-flight.js.map +1 -0
  32. package/lib/module/site-context.js +74 -0
  33. package/lib/module/site-context.js.map +1 -0
  34. package/lib/module/stores.js +66 -0
  35. package/lib/module/stores.js.map +1 -0
  36. package/lib/module/translations.js +42 -0
  37. package/lib/module/translations.js.map +1 -0
  38. package/lib/typescript/package.json +1 -0
  39. package/lib/typescript/src/auth.d.ts +31 -0
  40. package/lib/typescript/src/auth.d.ts.map +1 -0
  41. package/lib/typescript/src/client.d.ts +31 -0
  42. package/lib/typescript/src/client.d.ts.map +1 -0
  43. package/lib/typescript/src/config.d.ts +150 -0
  44. package/lib/typescript/src/config.d.ts.map +1 -0
  45. package/lib/typescript/src/converters.d.ts +17 -0
  46. package/lib/typescript/src/converters.d.ts.map +1 -0
  47. package/lib/typescript/src/create-config.d.ts +11 -0
  48. package/lib/typescript/src/create-config.d.ts.map +1 -0
  49. package/lib/typescript/src/endpoints.d.ts +36 -0
  50. package/lib/typescript/src/endpoints.d.ts.map +1 -0
  51. package/lib/typescript/src/errors.d.ts +43 -0
  52. package/lib/typescript/src/errors.d.ts.map +1 -0
  53. package/lib/typescript/src/i18n.d.ts +20 -0
  54. package/lib/typescript/src/i18n.d.ts.map +1 -0
  55. package/lib/typescript/src/index.d.ts +17 -0
  56. package/lib/typescript/src/index.d.ts.map +1 -0
  57. package/lib/typescript/src/media.d.ts +81 -0
  58. package/lib/typescript/src/media.d.ts.map +1 -0
  59. package/lib/typescript/src/models.d.ts +327 -0
  60. package/lib/typescript/src/models.d.ts.map +1 -0
  61. package/lib/typescript/src/provider.d.ts +37 -0
  62. package/lib/typescript/src/provider.d.ts.map +1 -0
  63. package/lib/typescript/src/runtime.d.ts +26 -0
  64. package/lib/typescript/src/runtime.d.ts.map +1 -0
  65. package/lib/typescript/src/single-flight.d.ts +7 -0
  66. package/lib/typescript/src/single-flight.d.ts.map +1 -0
  67. package/lib/typescript/src/site-context.d.ts +28 -0
  68. package/lib/typescript/src/site-context.d.ts.map +1 -0
  69. package/lib/typescript/src/stores.d.ts +54 -0
  70. package/lib/typescript/src/stores.d.ts.map +1 -0
  71. package/lib/typescript/src/translations.d.ts +40 -0
  72. package/lib/typescript/src/translations.d.ts.map +1 -0
  73. package/package.json +77 -0
  74. package/src/auth.ts +223 -0
  75. package/src/client.ts +133 -0
  76. package/src/config.ts +191 -0
  77. package/src/converters.ts +47 -0
  78. package/src/create-config.ts +29 -0
  79. package/src/endpoints.ts +133 -0
  80. package/src/errors.ts +104 -0
  81. package/src/i18n.ts +108 -0
  82. package/src/index.ts +123 -0
  83. package/src/media.ts +209 -0
  84. package/src/models.ts +384 -0
  85. package/src/provider.tsx +229 -0
  86. package/src/runtime.ts +57 -0
  87. package/src/single-flight.ts +23 -0
  88. package/src/site-context.ts +77 -0
  89. package/src/stores.ts +94 -0
  90. package/src/translations.ts +40 -0
@@ -0,0 +1,133 @@
1
+ import {
2
+ DEFAULT_OCC_PREFIX,
3
+ type EndpointValue,
4
+ type LaconiusConfig,
5
+ type LaconiusEndpoints,
6
+ } from './config';
7
+
8
+ /**
9
+ * Core's own endpoints: site context. Every value is a URL with its query string, never a bare
10
+ * path, so field selection lives in the template rather than at the call site.
11
+ *
12
+ * `baseSites` is the one endpoint built without the base-site segment.
13
+ */
14
+ export const defaultCoreEndpoints: Pick<
15
+ LaconiusEndpoints,
16
+ 'baseSites' | 'languages' | 'currencies' | 'countries' | 'regions'
17
+ > = {
18
+ baseSites: 'basesites?fields=FULL',
19
+ languages: 'languages',
20
+ currencies: 'currencies',
21
+ countries: 'countries',
22
+ regions: 'countries/${isoCode}/regions?fields=regions(name,isocode,isocodeShort)',
23
+ };
24
+
25
+ /** Endpoints that are addressed outside the base-site segment. */
26
+ const SITE_LESS_ENDPOINTS = new Set<string>(['baseSites']);
27
+
28
+ export type EndpointParams = {
29
+ /** Values for `${placeholder}` segments in the template. URI-encoded on interpolation. */
30
+ urlParams?: Record<string, string | number>;
31
+ /** Merged into the query string the template already carries. Caller params win. */
32
+ queryParams?: Record<string, string | number | boolean | undefined>;
33
+ };
34
+
35
+ /**
36
+ * Named scope -> `default` scope -> plain string -> `undefined`.
37
+ *
38
+ * Spartacus' final fallback — using the endpoint key itself as the path — is deliberately
39
+ * dropped: it turns a configuration mistake into a mysterious 404.
40
+ */
41
+ export function resolveEndpointValue(
42
+ endpoints: Partial<Record<string, EndpointValue>>,
43
+ key: string,
44
+ scope?: string,
45
+ ): string | undefined {
46
+ const value = endpoints[key];
47
+ if (value === undefined) return undefined;
48
+ if (typeof value === 'string') return value;
49
+ if (scope && value[scope] !== undefined) return value[scope];
50
+ return value['default'];
51
+ }
52
+
53
+ function interpolate(
54
+ template: string,
55
+ urlParams: Record<string, string | number> = {},
56
+ ): string {
57
+ return template.replace(/\$\{(\w+)\}/g, (_match, name: string) => {
58
+ const value = urlParams[name];
59
+ if (value === undefined) {
60
+ throw new Error(
61
+ `[laconius] Missing url parameter "${name}" for endpoint template "${template}".`,
62
+ );
63
+ }
64
+ return encodeURIComponent(String(value));
65
+ });
66
+ }
67
+
68
+ /**
69
+ * Merges `queryParams` into the query string the template already carries, caller wins.
70
+ *
71
+ * Template values are kept verbatim — an OCC `fields` expression is already in the shape the
72
+ * backend wants, and re-encoding its parentheses breaks it.
73
+ */
74
+ export function mergeQueryString(
75
+ template: string,
76
+ queryParams: Record<string, string | number | boolean | undefined> = {},
77
+ ): string {
78
+ const separator = template.indexOf('?');
79
+ const path = separator === -1 ? template : template.slice(0, separator);
80
+ const existing = separator === -1 ? '' : template.slice(separator + 1);
81
+
82
+ const params = new Map<string, string>();
83
+ for (const pair of existing.split('&')) {
84
+ if (!pair) continue;
85
+ const equals = pair.indexOf('=');
86
+ if (equals === -1) params.set(pair, '');
87
+ else params.set(pair.slice(0, equals), pair.slice(equals + 1));
88
+ }
89
+ for (const [key, value] of Object.entries(queryParams)) {
90
+ if (value === undefined) continue;
91
+ params.set(encodeURIComponent(key), encodeURIComponent(String(value)));
92
+ }
93
+
94
+ const query = [...params]
95
+ .map(([key, value]) => (value === '' ? key : `${key}=${value}`))
96
+ .join('&');
97
+ return query ? `${path}?${query}` : path;
98
+ }
99
+
100
+ export type EndpointResolver = {
101
+ /** Full URL for an endpoint key. Throws an error naming the key when it is not configured. */
102
+ getEndpoint(key: string, params?: EndpointParams, scope?: string): string;
103
+ /** Lets an adapter branch on presence instead of crashing. */
104
+ isConfigured(key: string, scope?: string): boolean;
105
+ };
106
+
107
+ export function createEndpointResolver(config: LaconiusConfig): EndpointResolver {
108
+ const endpoints = config.backend.occ.endpoints ?? {};
109
+ const { baseUrl, baseSite } = config.backend.occ;
110
+ const prefix = config.backend.occ.prefix ?? DEFAULT_OCC_PREFIX;
111
+
112
+ return {
113
+ isConfigured(key, scope) {
114
+ return resolveEndpointValue(endpoints, key, scope) !== undefined;
115
+ },
116
+ getEndpoint(key, params = {}, scope) {
117
+ const template = resolveEndpointValue(endpoints, key, scope);
118
+ if (template === undefined) {
119
+ throw new Error(
120
+ `[laconius] No endpoint configured for "${key}"${
121
+ scope ? ` (scope "${scope}")` : ''
122
+ }. Add it to backend.occ.endpoints.`,
123
+ );
124
+ }
125
+ const resolved = mergeQueryString(
126
+ interpolate(template, params.urlParams),
127
+ params.queryParams,
128
+ );
129
+ const site = SITE_LESS_ENDPOINTS.has(key) ? '' : `/${baseSite}`;
130
+ return `${baseUrl.replace(/\/$/, '')}${prefix}${site}/${resolved}`;
131
+ },
132
+ };
133
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,104 @@
1
+ /** One entry of OCC's `errors[]` envelope. `type` is open-ended and customer-extensible. */
2
+ export type OccError = {
3
+ type?: string;
4
+ reason?: string;
5
+ subject?: string;
6
+ subjectType?: string;
7
+ message?: string;
8
+ };
9
+
10
+ /**
11
+ * The one error type Laconius throws for a failed request.
12
+ *
13
+ * An OCC business error is data, not a status code: `InsufficientStockError` and a network
14
+ * timeout are the same `status` to a shopper who needs to be told different things.
15
+ */
16
+ export class LaconiusHttpError extends Error {
17
+ /** 0 when the request never reached the server. */
18
+ readonly status: number;
19
+ readonly url: string;
20
+ /** Parsed `errors[]`, empty when the payload carried none. */
21
+ readonly occErrors: OccError[];
22
+ /** Raw payload, for the app's telemetry. */
23
+ readonly body?: unknown;
24
+
25
+ constructor(init: {
26
+ status: number;
27
+ url: string;
28
+ occErrors?: OccError[];
29
+ body?: unknown;
30
+ message?: string;
31
+ }) {
32
+ const occErrors = init.occErrors ?? [];
33
+ super(
34
+ init.message ??
35
+ occErrors[0]?.message ??
36
+ `Request to ${init.url} failed with status ${init.status}`,
37
+ );
38
+ this.name = 'LaconiusHttpError';
39
+ this.status = init.status;
40
+ this.url = init.url;
41
+ this.occErrors = occErrors;
42
+ this.body = init.body;
43
+ }
44
+ }
45
+
46
+ /** Parses OCC's `errors[]` envelope out of an arbitrary payload. */
47
+ export function parseOccErrors(body: unknown): OccError[] {
48
+ if (typeof body !== 'object' || body === null) return [];
49
+ const errors = (body as { errors?: unknown }).errors;
50
+ if (!Array.isArray(errors)) return [];
51
+ return errors.filter(
52
+ (entry): entry is OccError => typeof entry === 'object' && entry !== null,
53
+ );
54
+ }
55
+
56
+ /** `getOccError(error)` for the first one, `getOccError(error, 'InsufficientStockError')` to match. */
57
+ export function getOccError(
58
+ error: unknown,
59
+ type?: string,
60
+ ): OccError | undefined {
61
+ if (!(error instanceof LaconiusHttpError)) return undefined;
62
+ if (type === undefined) return error.occErrors[0];
63
+ return error.occErrors.find((occError) => occError.type === type);
64
+ }
65
+
66
+ const STATUS_KEYS: Record<number, string> = {
67
+ 400: 'badRequest',
68
+ 401: 'unauthorized',
69
+ 403: 'forbidden',
70
+ 404: 'notFound',
71
+ 409: 'conflict',
72
+ };
73
+
74
+ /**
75
+ * A translated sentence for a shopper:
76
+ * OCC `type`/`reason` -> OCC `type` -> status class -> unknown.
77
+ *
78
+ * Never falls through to a raw backend string: that leaks internals and is untranslated by
79
+ * definition.
80
+ */
81
+ export function getErrorMessage(
82
+ error: unknown,
83
+ t: (key: string, params?: Record<string, unknown>) => string,
84
+ ): string {
85
+ const unknown = 'errors.http.unknown';
86
+ if (!(error instanceof LaconiusHttpError)) return t(unknown);
87
+
88
+ const occError = error.occErrors[0];
89
+ if (occError?.type) {
90
+ if (occError.reason) {
91
+ const key = `errors.occ.${occError.type}.${occError.reason}`;
92
+ const message = t(key);
93
+ if (message !== key) return message;
94
+ }
95
+ const key = `errors.occ.${occError.type}`;
96
+ const message = t(key);
97
+ if (message !== key) return message;
98
+ }
99
+
100
+ if (error.status === 0) return t('errors.http.offline');
101
+ const statusKey =
102
+ STATUS_KEYS[error.status] ?? (error.status >= 500 ? 'serverError' : undefined);
103
+ return statusKey ? t(`errors.http.${statusKey}`) : t(unknown);
104
+ }
package/src/i18n.ts ADDED
@@ -0,0 +1,108 @@
1
+ import type { TranslationTree } from './config';
2
+
3
+ const warned = new Set<string>();
4
+
5
+ /**
6
+ * Hermes ships `Intl` on Android only, so on iOS `Intl.PluralRules` is undefined and constructing
7
+ * it throws — the one place where "the platform already does it" was not true.
8
+ *
9
+ * ponytail: English one/other fallback. The translations Laconius ships are English, and an app
10
+ * with a language whose plural rules are richer either sets `i18n.translate` or polyfills
11
+ * `Intl.PluralRules` (`@formatjs/intl-pluralrules`), which this picks up on its own.
12
+ */
13
+ function pluralCategory(count: number, language: string): string {
14
+ const PluralRules = globalThis.Intl?.PluralRules;
15
+ if (!PluralRules) return count === 1 ? 'one' : 'other';
16
+ return new PluralRules(language.replace('_', '-')).select(count);
17
+ }
18
+
19
+ function isDev(): boolean {
20
+ return process.env.NODE_ENV !== 'production';
21
+ }
22
+
23
+ /** Dotted lookup into a nested translation chunk. */
24
+ export function lookupKey(
25
+ tree: TranslationTree | undefined,
26
+ key: string,
27
+ ): string | undefined {
28
+ if (!tree) return undefined;
29
+ let node: string | TranslationTree | undefined = tree;
30
+ for (const segment of key.split('.')) {
31
+ if (typeof node !== 'object' || node === null) return undefined;
32
+ node = node[segment];
33
+ }
34
+ return typeof node === 'string' ? node : undefined;
35
+ }
36
+
37
+ /** `pt_BR` and `pt-BR` both fall back to `pt`, matching OCC's isoCode shapes. */
38
+ function languageChain(language: string, fallbackLanguage?: string): string[] {
39
+ const chain = [language];
40
+ const base = language.split(/[-_]/)[0];
41
+ if (base && base !== language) chain.push(base);
42
+ if (fallbackLanguage && !chain.includes(fallbackLanguage)) {
43
+ chain.push(fallbackLanguage);
44
+ }
45
+ return chain;
46
+ }
47
+
48
+ export function interpolate(
49
+ template: string,
50
+ params: Record<string, unknown> = {},
51
+ ): string {
52
+ return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, name: string) => {
53
+ const value = params[name];
54
+ return value === undefined ? match : String(value);
55
+ });
56
+ }
57
+
58
+ export type Translator = (
59
+ key: string,
60
+ params?: Record<string, unknown>,
61
+ ) => string;
62
+
63
+ /**
64
+ * Key lookup, `{{param}}` interpolation and `Intl.PluralRules` — the three things Laconius
65
+ * needs from an i18n runtime, in place of taking one as a peer dependency.
66
+ *
67
+ * A missing key returns the key itself: warned once in development, silent in production.
68
+ * A blank label is invisible; a printed key is a bug report.
69
+ */
70
+ export function createTranslator(options: {
71
+ translations?: Record<string, TranslationTree>;
72
+ language: string;
73
+ fallbackLanguage?: string;
74
+ /** An app that already runs an i18n runtime sets this, and Laconius resolves nothing itself. */
75
+ translate?: Translator;
76
+ }): Translator {
77
+ if (options.translate) return options.translate;
78
+
79
+ const chain = languageChain(options.language, options.fallbackLanguage);
80
+
81
+ const resolve = (key: string): string | undefined => {
82
+ for (const language of chain) {
83
+ const value = lookupKey(options.translations?.[language], key);
84
+ if (value !== undefined) return value;
85
+ }
86
+ return undefined;
87
+ };
88
+
89
+ return (key, params) => {
90
+ let template: string | undefined;
91
+
92
+ const count = params?.['count'];
93
+ if (typeof count === 'number') {
94
+ const category = pluralCategory(count, options.language);
95
+ template = resolve(`${key}_${category}`) ?? resolve(`${key}_other`);
96
+ }
97
+ template ??= resolve(key);
98
+
99
+ if (template === undefined) {
100
+ if (isDev() && !warned.has(key)) {
101
+ warned.add(key);
102
+ console.warn(`[laconius] Missing translation key "${key}".`);
103
+ }
104
+ return key;
105
+ }
106
+ return interpolate(template, params);
107
+ };
108
+ }
package/src/index.ts ADDED
@@ -0,0 +1,123 @@
1
+ export {
2
+ DEFAULT_OCC_PREFIX,
3
+ deepMerge,
4
+ defaultCapabilities,
5
+ type DeepPartial,
6
+ type EndpointValue,
7
+ type LaconiusAdapters,
8
+ type LaconiusAppConfig,
9
+ type LaconiusAuthConfig,
10
+ type LaconiusCmsConfig,
11
+ type LaconiusConfig,
12
+ type LaconiusConfigChunk,
13
+ type LaconiusConverters,
14
+ type LaconiusEndpoints,
15
+ type LaconiusUiConfig,
16
+ type TranslationTree,
17
+ } from './config';
18
+ export { coreDefaults, createLaconiusConfig } from './create-config';
19
+
20
+ export {
21
+ createEndpointResolver,
22
+ defaultCoreEndpoints,
23
+ mergeQueryString,
24
+ resolveEndpointValue,
25
+ type EndpointParams,
26
+ type EndpointResolver,
27
+ } from './endpoints';
28
+
29
+ export {
30
+ createHttpClient,
31
+ type LaconiusHttpClient,
32
+ type LaconiusRequest,
33
+ } from './client';
34
+
35
+ export {
36
+ LaconiusHttpError,
37
+ getErrorMessage,
38
+ getOccError,
39
+ parseOccErrors,
40
+ type OccError,
41
+ } from './errors';
42
+
43
+ export {
44
+ SecureStorageError,
45
+ createAuthClient,
46
+ createDiscovery,
47
+ type AuthClient,
48
+ type TokenSet,
49
+ } from './auth';
50
+
51
+ export { singleFlight } from './single-flight';
52
+
53
+ export {
54
+ resolveCurrency,
55
+ resolveLanguage,
56
+ selectUserId,
57
+ useSessionStore,
58
+ useSiteContextStore,
59
+ type SessionState,
60
+ type SiteContextState,
61
+ } from './stores';
62
+
63
+ export {
64
+ applyConverters,
65
+ createConverterRegistry,
66
+ type Converter,
67
+ type ConverterRegistry,
68
+ } from './converters';
69
+
70
+ export {
71
+ absolutizeMedia,
72
+ defaultMediaFormats,
73
+ defaultMediaRoles,
74
+ getFormatsUpToMaxFormat,
75
+ getGalleryImages,
76
+ getMediaBaseUrl,
77
+ getPrimaryImage,
78
+ normalizeImages,
79
+ resolveMediaCandidates,
80
+ resolveMediaUrl,
81
+ sortFormats,
82
+ type MediaContainer,
83
+ type MediaSource,
84
+ } from './media';
85
+
86
+ export {
87
+ createTranslator,
88
+ interpolate,
89
+ lookupKey,
90
+ type Translator,
91
+ } from './i18n';
92
+ export { defaultCoreTranslations } from './translations';
93
+
94
+ export {
95
+ getRuntime,
96
+ laconiusQueryKey,
97
+ setActiveRuntime,
98
+ type LaconiusRuntime,
99
+ } from './runtime';
100
+
101
+ export {
102
+ defaultSiteContextAdapter,
103
+ siteContextQueries,
104
+ useBaseSites,
105
+ useCurrencies,
106
+ useLanguages,
107
+ type SiteContextAdapter,
108
+ } from './site-context';
109
+
110
+ export {
111
+ LaconiusProvider,
112
+ createDefaultQueryClient,
113
+ useIsLoggedIn,
114
+ useIsOnline,
115
+ useLaconius,
116
+ useLaconiusConfig,
117
+ useLogout,
118
+ useSiteContext,
119
+ useTranslation,
120
+ type LaconiusProviderProps,
121
+ } from './provider';
122
+
123
+ export type * from './models';
package/src/media.ts ADDED
@@ -0,0 +1,209 @@
1
+ import type { LaconiusConfig } from './config';
2
+ import type { Image, ImageGroup, Images } from './models';
3
+
4
+ /**
5
+ * Format code to width in pixels, ported from Spartacus'
6
+ * `core-libs/storefront/recipes/config/default-media.config.ts`.
7
+ *
8
+ * The codes are widths, not device classes. Mapping a phone to `mobile` (400px) would upscale a
9
+ * hero 3x on a @3x screen, so nothing here maps device to format.
10
+ */
11
+ export const defaultMediaFormats: Record<string, number> = {
12
+ cartIcon: 65,
13
+ thumbnail: 96,
14
+ product: 284,
15
+ mobile: 400,
16
+ zoom: 515,
17
+ tablet: 770,
18
+ desktop: 1140,
19
+ widescreen: 1400,
20
+ };
21
+
22
+ /** A role caps the candidate set; it does not select a single asset. No role means no cap. */
23
+ export const defaultMediaRoles: Record<string, string> = {
24
+ cartIcon: 'cartIcon',
25
+ thumbnail: 'thumbnail',
26
+ product: 'product',
27
+ };
28
+
29
+ /** What `expo-image` accepts as one entry of an `ImageSource[]`. */
30
+ export type MediaSource = { uri: string; width?: number; height?: number };
31
+
32
+ export type MediaContainer = ImageGroup | Image | undefined;
33
+
34
+ export function getMediaBaseUrl(config: LaconiusConfig): string {
35
+ return config.backend.media?.baseUrl ?? config.backend.occ.baseUrl;
36
+ }
37
+
38
+ /**
39
+ * Absolutises an OCC media path. Absolute URLs, protocol-relative URLs and `data:` URIs are
40
+ * returned untouched — Spartacus absolutises `data:` URIs in one of its two paths and breaks
41
+ * them there.
42
+ */
43
+ export function resolveMediaUrl(
44
+ media: Image | undefined,
45
+ baseUrl: string,
46
+ ): string | undefined {
47
+ const url = media?.url;
48
+ if (!url) return undefined;
49
+ if (/^(https?:)?\/\//.test(url) || url.startsWith('data:')) return url;
50
+ return `${baseUrl.replace(/\/$/, '')}${url.startsWith('/') ? '' : '/'}${url}`;
51
+ }
52
+
53
+ /** Format codes ascending by width, unknown widths last in declaration order. */
54
+ export function sortFormats(
55
+ formats: Record<string, number>,
56
+ ): { code: string; width: number }[] {
57
+ return Object.entries(formats)
58
+ .map(([code, width]) => ({ code, width }))
59
+ .sort((a, b) => a.width - b.width);
60
+ }
61
+
62
+ /**
63
+ * Formats up to and including `maxFormat`. An unknown `maxFormat` caps nothing, which is
64
+ * Spartacus' behaviour and the safe one: a typo shows a big image, not no image.
65
+ */
66
+ export function getFormatsUpToMaxFormat<T extends { code: string }>(
67
+ formats: T[],
68
+ maxFormat?: string,
69
+ ): T[] {
70
+ const maxIndex = formats.findIndex((format) => format.code === maxFormat);
71
+ return maxIndex === -1 ? formats : formats.slice(0, maxIndex + 1);
72
+ }
73
+
74
+ /**
75
+ * The candidate set for one media container, widths attached.
76
+ *
77
+ * `expo-image` picks the source that best fits the measured container at the screen scale, so
78
+ * the caller never decides "which format" and never passes the container size.
79
+ */
80
+ export function resolveMediaCandidates(
81
+ media: MediaContainer,
82
+ options: {
83
+ baseUrl: string;
84
+ role?: string;
85
+ /** Explicit cap, overriding the one the role implies. */
86
+ maxFormat?: string;
87
+ formats?: Record<string, number>;
88
+ roles?: Record<string, string>;
89
+ },
90
+ ): MediaSource[] {
91
+ if (!media) return [];
92
+
93
+ const formats = options.formats ?? defaultMediaFormats;
94
+ const roles = options.roles ?? defaultMediaRoles;
95
+ const cap = options.maxFormat ?? (options.role ? roles[options.role] : undefined);
96
+
97
+ const container = media as ImageGroup;
98
+ const known = getFormatsUpToMaxFormat(sortFormats(formats), cap).filter(
99
+ (format) => isImage(container[format.code]),
100
+ );
101
+
102
+ if (known.length > 0) {
103
+ return known.flatMap(({ code, width }) => {
104
+ const uri = resolveMediaUrl(container[code], options.baseUrl);
105
+ return uri ? [{ uri, width }] : [];
106
+ });
107
+ }
108
+
109
+ // A bare `Image` (CMS media often is one), or a container whose format codes are unknown.
110
+ const single = isImage(media) ? (media as Image) : firstImage(container);
111
+ const uri = resolveMediaUrl(single, options.baseUrl);
112
+ return uri ? [{ uri }] : [];
113
+ }
114
+
115
+ function isImage(value: unknown): value is Image {
116
+ return (
117
+ typeof value === 'object' &&
118
+ value !== null &&
119
+ typeof (value as Image).url === 'string'
120
+ );
121
+ }
122
+
123
+ function firstImage(container: ImageGroup): Image | undefined {
124
+ for (const value of Object.values(container)) {
125
+ if (isImage(value)) return value;
126
+ }
127
+ return undefined;
128
+ }
129
+
130
+ /**
131
+ * Absolutises every media URL inside a payload, on the way in.
132
+ *
133
+ * One mechanism for CMS media and product images alike ([chapter 08](../../../docs/spec/08-media.md)),
134
+ * where Spartacus has two unrelated ones. The walk is shape-agnostic because media reaches the
135
+ * client as an `Image`, as a group keyed by format code, and as arrays of either.
136
+ */
137
+ export function absolutizeMedia<T>(value: T, baseUrl: string): T {
138
+ if (Array.isArray(value)) {
139
+ return value.map((item) => absolutizeMedia(item, baseUrl)) as T;
140
+ }
141
+ if (typeof value !== 'object' || value === null) return value;
142
+
143
+ const entries = Object.entries(value as Record<string, unknown>).map(([key, item]) =>
144
+ isImage(item)
145
+ ? [key, { ...item, url: resolveMediaUrl(item, baseUrl) }]
146
+ : [key, absolutizeMedia(item, baseUrl)],
147
+ );
148
+ return Object.fromEntries(entries) as T;
149
+ }
150
+
151
+ /**
152
+ * The `PRIMARY` group of a normalized `Images`, which is what a card, a thumbnail and a gallery's
153
+ * first slide all want. OCC only ever sends one primary image, so an array here is a payload
154
+ * oddity rather than a case to support.
155
+ */
156
+ export function getPrimaryImage(images: Images | undefined): ImageGroup | undefined {
157
+ const primary = images?.['PRIMARY'];
158
+ return Array.isArray(primary) ? primary[0] : primary;
159
+ }
160
+
161
+ /** The `GALLERY` groups in `galleryIndex` order, with the primary image first when there is one. */
162
+ export function getGalleryImages(images: Images | undefined): ImageGroup[] {
163
+ const gallery = images?.['GALLERY'];
164
+ const groups = Array.isArray(gallery) ? gallery.filter(Boolean) : gallery ? [gallery] : [];
165
+ if (groups.length > 0) return groups;
166
+ const primary = getPrimaryImage(images);
167
+ return primary ? [primary] : [];
168
+ }
169
+
170
+ /**
171
+ * Reshapes OCC's flat `Image[]` into `images.PRIMARY.zoom` / `images.GALLERY[i].thumbnail`,
172
+ * ported from `core-libs/core/src/occ/adapters/product/converters/product-image-normalizer.ts:36-100`.
173
+ *
174
+ * Lives in core because product payloads and cart entry products carry the same flat list —
175
+ * one mechanism ([chapter 08](../../../docs/spec/08-media.md)), and no `cart -> product` edge
176
+ * exists to borrow it over ([chapter 01](../../../docs/spec/01-packages.md)).
177
+ *
178
+ * URLs are **not** absolutised here: a converter is a pure function with no access to config, so
179
+ * adapters absolutise the whole payload afterwards through `absolutizeMedia`.
180
+ */
181
+ export function normalizeImages(source: Image[] | undefined): Images {
182
+ const images: Images = {};
183
+ for (const image of source ?? []) {
184
+ if (!image.imageType || !image.format) continue;
185
+
186
+ const group = groupFor(images, image);
187
+ group[image.format] = image;
188
+ }
189
+ return images;
190
+ }
191
+
192
+ /** `galleryIndex` is what makes an image type a list, not the type code. */
193
+ function groupFor(images: Images, image: Image): ImageGroup {
194
+ const type = image.imageType as string;
195
+ if (image.galleryIndex === undefined) {
196
+ const existing = images[type];
197
+ if (existing && !Array.isArray(existing)) return existing;
198
+ const group: ImageGroup = {};
199
+ images[type] = group;
200
+ return group;
201
+ }
202
+
203
+ const existing = images[type];
204
+ const groups: ImageGroup[] = Array.isArray(existing) ? existing : [];
205
+ images[type] = groups;
206
+ const group = groups[image.galleryIndex] ?? {};
207
+ groups[image.galleryIndex] = group;
208
+ return group;
209
+ }