@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
package/src/auth.ts ADDED
@@ -0,0 +1,223 @@
1
+ import * as AuthSession from 'expo-auth-session';
2
+ import * as SecureStore from 'expo-secure-store';
3
+
4
+ import type { LaconiusAuthConfig, LaconiusConfig } from './config';
5
+ import { singleFlight } from './single-flight';
6
+ import { selectUserId, useSessionStore } from './stores';
7
+
8
+ const REFRESH_TOKEN_KEY = 'laconius.refreshToken';
9
+
10
+ /** Refresh-token storage failed. Treated as "no stored session", never as a crash. */
11
+ export class SecureStorageError extends Error {
12
+ constructor(operation: string, cause: unknown) {
13
+ super(`[laconius] Secure storage ${operation} failed.`);
14
+ this.name = 'SecureStorageError';
15
+ this.cause = cause;
16
+ }
17
+ }
18
+
19
+ export type TokenSet = {
20
+ accessToken: string;
21
+ refreshToken?: string;
22
+ /** Epoch milliseconds. */
23
+ expiresAt?: number;
24
+ };
25
+
26
+ /**
27
+ * Built from config, never from the server's discovery document: that document is wrong about
28
+ * its own server (it never advertises `none` for token endpoint auth, which demonstrably works).
29
+ */
30
+ export function createDiscovery(
31
+ authServerUrl: string,
32
+ ): AuthSession.DiscoveryDocument {
33
+ const base = authServerUrl.replace(/\/$/, '');
34
+ return {
35
+ authorizationEndpoint: `${base}/oauth/authorize`,
36
+ tokenEndpoint: `${base}/oauth/token`,
37
+ revocationEndpoint: `${base}/oauth/revoke`,
38
+ };
39
+ }
40
+
41
+ export type AuthClient = {
42
+ /** Authorization Code + PKCE. Returns the token set, or `undefined` when the user cancelled. */
43
+ login: () => Promise<TokenSet | undefined>;
44
+ logout: () => Promise<void>;
45
+ /** Single-flight: concurrent callers share one token request. */
46
+ refresh: () => Promise<TokenSet | undefined>;
47
+ /** Cold start: read the stored refresh token and exchange it once. */
48
+ hydrate: () => Promise<TokenSet | undefined>;
49
+ /** Refreshes proactively when the access token is within 60s of expiry. */
50
+ getAccessToken: () => Promise<string | undefined>;
51
+ getUserId: () => string;
52
+ };
53
+
54
+ const EXPIRY_SKEW_MS = 60_000;
55
+
56
+ export function createAuthClient(config: LaconiusConfig): AuthClient {
57
+ const auth: LaconiusAuthConfig = config.auth;
58
+ const discovery = createDiscovery(auth.authServerUrl);
59
+ const report = (error: unknown) => config.onError?.(error, {});
60
+
61
+ const storeRefreshToken = async (token: string | undefined) => {
62
+ try {
63
+ if (token === undefined) {
64
+ await SecureStore.deleteItemAsync(REFRESH_TOKEN_KEY);
65
+ } else {
66
+ await SecureStore.setItemAsync(REFRESH_TOKEN_KEY, token, {
67
+ requireAuthentication: auth.requireBiometricUnlock ?? false,
68
+ });
69
+ }
70
+ } catch (error) {
71
+ report(new SecureStorageError('write', error));
72
+ }
73
+ };
74
+
75
+ const readRefreshToken = async (): Promise<string | undefined> => {
76
+ try {
77
+ return (await SecureStore.getItemAsync(REFRESH_TOKEN_KEY)) ?? undefined;
78
+ } catch (error) {
79
+ // Biometric re-enrolment invalidates the key: that is "no stored session".
80
+ report(new SecureStorageError('read', error));
81
+ return undefined;
82
+ }
83
+ };
84
+
85
+ const adopt = async (tokens: TokenSet): Promise<TokenSet> => {
86
+ useSessionStore.getState().setSession({
87
+ accessToken: tokens.accessToken,
88
+ refreshToken: tokens.refreshToken,
89
+ expiresAt: tokens.expiresAt,
90
+ });
91
+ if (tokens.refreshToken) await storeRefreshToken(tokens.refreshToken);
92
+ return tokens;
93
+ };
94
+
95
+ const fromTokenResponse = (response: AuthSession.TokenResponse): TokenSet => ({
96
+ accessToken: response.accessToken,
97
+ refreshToken: response.refreshToken,
98
+ expiresAt: response.expiresIn
99
+ ? (response.issuedAt + response.expiresIn) * 1000
100
+ : undefined,
101
+ });
102
+
103
+ const fromTokenProvider = async (): Promise<TokenSet | undefined> => {
104
+ if (!auth.tokenProvider) return undefined;
105
+ const token = await auth.tokenProvider();
106
+ return {
107
+ accessToken: token.access_token,
108
+ refreshToken: token.refresh_token,
109
+ expiresAt: Date.now() + token.expires_in * 1000,
110
+ };
111
+ };
112
+
113
+ const expire = async () => {
114
+ useSessionStore.getState().clearSession();
115
+ await storeRefreshToken(undefined);
116
+ auth.onAuthExpired?.();
117
+ };
118
+
119
+ const refresh = singleFlight(async (): Promise<TokenSet | undefined> => {
120
+ const refreshToken = useSessionStore.getState().refreshToken;
121
+ try {
122
+ if (refreshToken) {
123
+ const response = await AuthSession.refreshAsync(
124
+ { clientId: auth.clientId, refreshToken },
125
+ discovery,
126
+ );
127
+ return await adopt(fromTokenResponse(response));
128
+ }
129
+ const provided = await fromTokenProvider();
130
+ if (provided) return await adopt(provided);
131
+ } catch (error) {
132
+ report(error);
133
+ }
134
+ await expire();
135
+ return undefined;
136
+ });
137
+
138
+ return {
139
+ refresh,
140
+
141
+ async login() {
142
+ const provided = await fromTokenProvider();
143
+ if (provided) return adopt(provided);
144
+
145
+ // Imperative API, never `useAuthRequest`: refresh must be callable outside React.
146
+ const request = await AuthSession.loadAsync(
147
+ {
148
+ clientId: auth.clientId,
149
+ redirectUri: auth.redirectUri,
150
+ responseType: AuthSession.ResponseType.Code,
151
+ usePKCE: true,
152
+ scopes: [],
153
+ },
154
+ discovery,
155
+ );
156
+ const result = await request.promptAsync(discovery);
157
+ if (result.type !== 'success') return undefined;
158
+
159
+ const response = await AuthSession.exchangeCodeAsync(
160
+ {
161
+ clientId: auth.clientId,
162
+ redirectUri: auth.redirectUri,
163
+ code: result.params['code'] ?? '',
164
+ extraParams: request.codeVerifier
165
+ ? { code_verifier: request.codeVerifier }
166
+ : {},
167
+ },
168
+ discovery,
169
+ );
170
+ return adopt(fromTokenResponse(response));
171
+ },
172
+
173
+ async logout() {
174
+ const { accessToken, refreshToken } = useSessionStore.getState();
175
+ const token = refreshToken ?? accessToken;
176
+ if (token && discovery.revocationEndpoint) {
177
+ try {
178
+ // No Authorization header: the rebuilt authorization server reads one as *client*
179
+ // authentication, and a PKCE public client has no secret — the request comes back
180
+ // `invalid_client` and the token stays alive. Unauthenticated, revoke returns 200 and
181
+ // the refresh token is dead (verified live, ticket 23).
182
+ await fetch(discovery.revocationEndpoint, {
183
+ method: 'POST',
184
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
185
+ body: `token=${encodeURIComponent(token)}&token_type_hint=${
186
+ refreshToken ? 'refresh_token' : 'access_token'
187
+ }&client_id=${encodeURIComponent(auth.clientId)}`,
188
+ });
189
+ } catch (error) {
190
+ // A failed revoke still logs out locally.
191
+ report(error);
192
+ }
193
+ }
194
+ useSessionStore.getState().clearSession();
195
+ await storeRefreshToken(undefined);
196
+ },
197
+
198
+ async hydrate() {
199
+ const stored = await readRefreshToken();
200
+ if (!stored) return undefined;
201
+ useSessionStore.getState().setSession({ refreshToken: stored });
202
+ return refresh();
203
+ },
204
+
205
+ async getAccessToken() {
206
+ const { accessToken, expiresAt } = useSessionStore.getState();
207
+ if (accessToken && (!expiresAt || Date.now() < expiresAt - EXPIRY_SKEW_MS)) {
208
+ return accessToken;
209
+ }
210
+ if (!accessToken && !useSessionStore.getState().refreshToken) {
211
+ return undefined;
212
+ }
213
+ return (await refresh())?.accessToken;
214
+ },
215
+
216
+ getUserId() {
217
+ const userId = selectUserId(useSessionStore.getState());
218
+ return (
219
+ auth.decorateUserId?.(userId, config.backend.occ.baseSite) ?? userId
220
+ );
221
+ },
222
+ };
223
+ }
package/src/client.ts ADDED
@@ -0,0 +1,133 @@
1
+ import type { AuthClient } from './auth';
2
+ import type { LaconiusConfig } from './config';
3
+ import { createEndpointResolver, type EndpointResolver } from './endpoints';
4
+ import { LaconiusHttpError, parseOccErrors } from './errors';
5
+ import { resolveCurrency, resolveLanguage } from './stores';
6
+
7
+ /** Endpoints that must never carry an `Authorization` header. */
8
+ const ANONYMOUS_ENDPOINTS = new Set<string>(['baseSites']);
9
+
10
+ export type LaconiusRequest = {
11
+ /** Endpoint key, resolved through `backend.occ.endpoints`. */
12
+ endpoint: string;
13
+ scope?: string;
14
+ method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
15
+ urlParams?: Record<string, string | number>;
16
+ queryParams?: Record<string, string | number | boolean | undefined>;
17
+ /** Sent as JSON. */
18
+ body?: unknown;
19
+ /** Sent as `application/x-www-form-urlencoded` (OCC wants this for product reviews). */
20
+ form?: Record<string, string>;
21
+ headers?: Record<string, string>;
22
+ /** Skip the `Authorization` header even when a session exists. */
23
+ anonymous?: boolean;
24
+ signal?: AbortSignal;
25
+ };
26
+
27
+ export type LaconiusHttpClient = {
28
+ request<T>(options: LaconiusRequest): Promise<T>;
29
+ endpoints: EndpointResolver;
30
+ };
31
+
32
+ /**
33
+ * Lives outside React so refresh and session reads work from anywhere.
34
+ *
35
+ * `lang` and `curr` are appended here rather than by adapters — the same division Spartacus
36
+ * draws with an interceptor.
37
+ */
38
+ export function createHttpClient(
39
+ config: LaconiusConfig,
40
+ auth: AuthClient,
41
+ ): LaconiusHttpClient {
42
+ const endpoints = createEndpointResolver(config);
43
+
44
+ async function send<T>(options: LaconiusRequest, retrying: boolean): Promise<T> {
45
+ const anonymous =
46
+ options.anonymous || ANONYMOUS_ENDPOINTS.has(options.endpoint);
47
+
48
+ const url = endpoints.getEndpoint(
49
+ options.endpoint,
50
+ {
51
+ urlParams: { userId: auth.getUserId(), ...options.urlParams },
52
+ queryParams: {
53
+ lang: resolveLanguage(config),
54
+ curr: resolveCurrency(config),
55
+ ...options.queryParams,
56
+ },
57
+ },
58
+ options.scope,
59
+ );
60
+
61
+ const headers: Record<string, string> = { Accept: 'application/json' };
62
+ if (options.form) {
63
+ headers['Content-Type'] = 'application/x-www-form-urlencoded';
64
+ } else if (options.body !== undefined) {
65
+ headers['Content-Type'] = 'application/json';
66
+ }
67
+ if (!anonymous) {
68
+ const token = await auth.getAccessToken();
69
+ if (token) headers['Authorization'] = `Bearer ${token}`;
70
+ }
71
+ // Never overwrite a header the caller set.
72
+ Object.assign(headers, options.headers);
73
+
74
+ let response: Response;
75
+ try {
76
+ response = await fetch(url, {
77
+ method: options.method ?? 'GET',
78
+ headers,
79
+ body: options.form
80
+ ? new URLSearchParams(options.form).toString()
81
+ : options.body !== undefined
82
+ ? JSON.stringify(options.body)
83
+ : undefined,
84
+ signal: options.signal,
85
+ });
86
+ } catch (cause) {
87
+ const error = new LaconiusHttpError({
88
+ status: 0,
89
+ url,
90
+ message: cause instanceof Error ? cause.message : 'Network request failed',
91
+ });
92
+ config.onError?.(error, { url });
93
+ throw error;
94
+ }
95
+
96
+ const payload = await readPayload(response);
97
+
98
+ if (!response.ok) {
99
+ if (response.status === 401 && !anonymous && !retrying) {
100
+ const refreshed = await auth.refresh();
101
+ if (refreshed) return send<T>(options, true);
102
+ }
103
+ const error = new LaconiusHttpError({
104
+ status: response.status,
105
+ url,
106
+ occErrors: parseOccErrors(payload),
107
+ body: payload,
108
+ });
109
+ config.onError?.(error, { url });
110
+ throw error;
111
+ }
112
+
113
+ return payload as T;
114
+ }
115
+
116
+ return {
117
+ endpoints,
118
+ request(options) {
119
+ return send(options, false);
120
+ },
121
+ };
122
+ }
123
+
124
+ async function readPayload(response: Response): Promise<unknown> {
125
+ if (response.status === 204) return undefined;
126
+ const text = await response.text();
127
+ if (!text) return undefined;
128
+ try {
129
+ return JSON.parse(text);
130
+ } catch {
131
+ return text;
132
+ }
133
+ }
package/src/config.ts ADDED
@@ -0,0 +1,191 @@
1
+ import type { ComponentType } from 'react';
2
+ import type { QueryClient } from '@tanstack/react-query';
3
+
4
+ import type { SiteContextAdapter } from './site-context';
5
+
6
+ /**
7
+ * An endpoint value is a URL template, optionally split into named scopes.
8
+ * Values carry their own query string (`fields=…`); the builder merges caller params into it.
9
+ */
10
+ export type EndpointValue = string | Record<string, string>;
11
+
12
+ /**
13
+ * Endpoint keys. Feature packages add their own by declaration merging:
14
+ *
15
+ * ```ts
16
+ * declare module '@laconius/core' {
17
+ * interface LaconiusEndpoints { productSearch: string | Record<'default' | 'carousel', string> }
18
+ * }
19
+ * ```
20
+ */
21
+ export interface LaconiusEndpoints {
22
+ baseSites: string;
23
+ languages: string;
24
+ currencies: string;
25
+ countries: string;
26
+ regions: string;
27
+ }
28
+
29
+ /** Converter keys, extended by declaration merging exactly like endpoint keys. */
30
+ export interface LaconiusConverters {
31
+ baseSite: unknown;
32
+ language: unknown;
33
+ currency: unknown;
34
+ country: unknown;
35
+ region: unknown;
36
+ }
37
+
38
+ /** Adapter slots, extended by declaration merging. Replacing one is the route to a non-OCC backend. */
39
+ export interface LaconiusAdapters {
40
+ siteContext: SiteContextAdapter;
41
+ }
42
+
43
+ export type TranslationTree = { [key: string]: string | TranslationTree };
44
+
45
+ /** Extended by `@laconius/ui` with themes, components and colour scheme. */
46
+ export interface LaconiusUiConfig {
47
+ media?: {
48
+ /** Format code to width in pixels. Widths, not device classes. */
49
+ formats?: Record<string, number>;
50
+ /** Role to the largest format code that role may use. */
51
+ roles?: Record<string, string>;
52
+ };
53
+ }
54
+
55
+ /** Extended by `@laconius/cms` with the component registry and layout config. */
56
+ export interface LaconiusCmsConfig {
57
+ htmlRenderer?: ComponentType<{ html: string }>;
58
+ }
59
+
60
+ export interface LaconiusAuthConfig {
61
+ clientId: string;
62
+ redirectUri: string;
63
+ authServerUrl: string;
64
+ /** Passed to `expo-secure-store` as `requireAuthentication`. Off by default. */
65
+ requireBiometricUnlock?: boolean;
66
+ /** Identity-provider slot. Laconius stores and refreshes whatever it is handed. */
67
+ tokenProvider?: () => Promise<{
68
+ access_token: string;
69
+ refresh_token?: string;
70
+ expires_in: number;
71
+ }>;
72
+ /** Multisite isolation hook, replacing Spartacus' `AuthMultisiteIsolationService`. */
73
+ decorateUserId?: (userId: string, baseSite: string) => string;
74
+ /** Called when the session dies. Core never navigates. */
75
+ onAuthExpired?: () => void;
76
+ }
77
+
78
+ export interface LaconiusConfig {
79
+ backend: {
80
+ occ: {
81
+ baseUrl: string;
82
+ baseSite: string;
83
+ /** Defaults to `/occ/v2`. */
84
+ prefix?: string;
85
+ endpoints?: Partial<LaconiusEndpoints>;
86
+ };
87
+ media?: { baseUrl?: string };
88
+ adapters?: Partial<LaconiusAdapters>;
89
+ };
90
+ auth: LaconiusAuthConfig;
91
+ context: {
92
+ languages: string[];
93
+ currencies: string[];
94
+ defaultLanguage?: string;
95
+ defaultCurrency?: string;
96
+ };
97
+ /** The three known version-gated endpoints. Defaults are the 221121.x baseline. */
98
+ capabilities?: {
99
+ jsonVouchers?: boolean;
100
+ productAvailabilities?: boolean;
101
+ userScopedCms?: boolean;
102
+ };
103
+ converters?: Partial<Record<keyof LaconiusConverters, unknown[]>>;
104
+ cms?: LaconiusCmsConfig;
105
+ ui?: LaconiusUiConfig;
106
+ i18n?: {
107
+ fallbackLanguage?: string;
108
+ translations?: Record<string, TranslationTree>;
109
+ /** Set this and Laconius resolves nothing itself. */
110
+ translate?: (key: string, params?: Record<string, unknown>) => string;
111
+ };
112
+ query?: {
113
+ queryClient?: QueryClient;
114
+ wireOnlineManager?: boolean;
115
+ wireFocusManager?: boolean;
116
+ };
117
+ /** Observation only: it does not render. */
118
+ onError?: (error: unknown, ctx: { url?: string }) => void;
119
+ }
120
+
121
+ type Primitive = string | number | boolean | symbol | bigint | null | undefined;
122
+
123
+ export type DeepPartial<T> = T extends Primitive
124
+ ? T
125
+ : T extends (...args: never[]) => unknown
126
+ ? T
127
+ : T extends readonly (infer _U)[]
128
+ ? T
129
+ : { [K in keyof T]?: DeepPartial<T[K]> };
130
+
131
+ /** What a feature package exports as `defaultProductConfig` and friends. */
132
+ export type LaconiusConfigChunk = DeepPartial<LaconiusConfig>;
133
+
134
+ /** The app's own chunk. `backend.occ` and `auth` are what the provider refuses to start without. */
135
+ export type LaconiusAppConfig = LaconiusConfigChunk & {
136
+ backend: DeepPartial<LaconiusConfig['backend']> & {
137
+ occ: { baseUrl: string; baseSite: string };
138
+ };
139
+ auth: LaconiusAuthConfig;
140
+ };
141
+
142
+ export const DEFAULT_OCC_PREFIX = '/occ/v2';
143
+
144
+ export const defaultCapabilities = {
145
+ jsonVouchers: true,
146
+ productAvailabilities: true,
147
+ userScopedCms: true,
148
+ };
149
+
150
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
151
+ return (
152
+ typeof value === 'object' &&
153
+ value !== null &&
154
+ !Array.isArray(value) &&
155
+ Object.getPrototypeOf(value) === Object.prototype
156
+ );
157
+ }
158
+
159
+ /**
160
+ * Objects deep-merge, arrays are replaced, `undefined` is ignored.
161
+ *
162
+ * Arrays are never concatenated: a default you cannot remove is a bug. Everything that would
163
+ * want concatenation exports a `default*` array the app spreads explicitly.
164
+ */
165
+ export function deepMerge<T>(base: T, override: unknown): T {
166
+ if (override === undefined) return base;
167
+ if (!isPlainObject(base) || !isPlainObject(override)) return override as T;
168
+
169
+ const result: Record<string, unknown> = { ...base };
170
+ for (const key of Object.keys(override)) {
171
+ const next = override[key];
172
+ if (next === undefined) continue;
173
+ result[key] = isPlainObject(result[key])
174
+ ? deepMerge(result[key], next)
175
+ : next;
176
+ }
177
+ return result as T;
178
+ }
179
+
180
+ /** @internal Use `createLaconiusConfig`, which seeds core's own defaults first. */
181
+ export function createLaconiusConfigInternal(
182
+ app: LaconiusAppConfig,
183
+ chunks: LaconiusConfigChunk[],
184
+ ): LaconiusConfig {
185
+ const merged = chunks.reduce<unknown>((acc, chunk) => deepMerge(acc, chunk), {});
186
+ const config = deepMerge(merged, app) as LaconiusConfig;
187
+ return {
188
+ ...config,
189
+ capabilities: { ...defaultCapabilities, ...config.capabilities },
190
+ };
191
+ }
@@ -0,0 +1,47 @@
1
+ import type { LaconiusConfig, LaconiusConverters } from './config';
2
+
3
+ /** Spartacus' populator pattern with dependency injection removed. */
4
+ export type Converter<S, T> = (source: S, target?: T) => T;
5
+
6
+ /**
7
+ * Reduces a converter stack so each converter enriches the previous one's partial target.
8
+ *
9
+ * The seed is explicit because it is easy to re-derive wrong: an empty stack returns the source
10
+ * unchanged, and otherwise the first converter is called with `target === undefined`.
11
+ */
12
+ export function applyConverters<S, T>(
13
+ source: S,
14
+ converters: readonly Converter<S, T>[] | undefined,
15
+ ): T {
16
+ if (!converters || converters.length === 0) {
17
+ return source as unknown as T;
18
+ }
19
+ return converters.reduce<T | undefined>(
20
+ (target, convert) => convert(source, target),
21
+ undefined,
22
+ ) as T;
23
+ }
24
+
25
+ export type ConverterRegistry = {
26
+ convert<S, T>(source: S, key: keyof LaconiusConverters): T;
27
+ convertAll<S, T>(sources: S[] | undefined, key: keyof LaconiusConverters): T[];
28
+ };
29
+
30
+ /** Converters are registered by explicit spread of a feature's exported defaults. */
31
+ export function createConverterRegistry(
32
+ config: LaconiusConfig,
33
+ ): ConverterRegistry {
34
+ const registered = config.converters ?? {};
35
+ const stackFor = <S, T>(key: keyof LaconiusConverters) =>
36
+ registered[key] as readonly Converter<S, T>[] | undefined;
37
+
38
+ return {
39
+ convert<S, T>(source: S, key: keyof LaconiusConverters) {
40
+ return applyConverters<S, T>(source, stackFor<S, T>(key));
41
+ },
42
+ convertAll<S, T>(sources: S[] | undefined, key: keyof LaconiusConverters) {
43
+ const converters = stackFor<S, T>(key);
44
+ return (sources ?? []).map((source) => applyConverters<S, T>(source, converters));
45
+ },
46
+ };
47
+ }
@@ -0,0 +1,29 @@
1
+ import {
2
+ createLaconiusConfigInternal,
3
+ type LaconiusAppConfig,
4
+ type LaconiusConfig,
5
+ type LaconiusConfigChunk,
6
+ } from './config';
7
+ import { defaultCoreEndpoints } from './endpoints';
8
+ import { defaultMediaFormats, defaultMediaRoles } from './media';
9
+ import { defaultCoreTranslations } from './translations';
10
+
11
+ /** What core contributes before any feature chunk: its endpoints, media table and strings. */
12
+ export const coreDefaults: LaconiusConfigChunk = {
13
+ backend: { occ: { endpoints: defaultCoreEndpoints } },
14
+ ui: { media: { formats: defaultMediaFormats, roles: defaultMediaRoles } },
15
+ i18n: { fallbackLanguage: 'en', translations: defaultCoreTranslations },
16
+ };
17
+
18
+ /**
19
+ * Deep-merges core's defaults, then the feature chunks in order, then the app chunk last, so an
20
+ * override is written where the reader expects it.
21
+ *
22
+ * Objects deep-merge, arrays are replaced, `undefined` is ignored.
23
+ */
24
+ export function createLaconiusConfig(
25
+ app: LaconiusAppConfig,
26
+ featureChunks: LaconiusConfigChunk[] = [],
27
+ ): LaconiusConfig {
28
+ return createLaconiusConfigInternal(app, [coreDefaults, ...featureChunks]);
29
+ }