@stapel/core 0.2.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/LICENSE +21 -0
- package/README.md +118 -0
- package/dist/analytics/context.d.ts +9 -0
- package/dist/analytics/context.d.ts.map +1 -0
- package/dist/analytics/context.js +14 -0
- package/dist/analytics/context.js.map +1 -0
- package/dist/analytics/createAnalytics.d.ts +10 -0
- package/dist/analytics/createAnalytics.d.ts.map +1 -0
- package/dist/analytics/createAnalytics.js +273 -0
- package/dist/analytics/createAnalytics.js.map +1 -0
- package/dist/analytics/flow.d.ts +10 -0
- package/dist/analytics/flow.d.ts.map +1 -0
- package/dist/analytics/flow.js +10 -0
- package/dist/analytics/flow.js.map +1 -0
- package/dist/analytics/hash.d.ts +3 -0
- package/dist/analytics/hash.d.ts.map +1 -0
- package/dist/analytics/hash.js +12 -0
- package/dist/analytics/hash.js.map +1 -0
- package/dist/analytics/pii.d.ts +9 -0
- package/dist/analytics/pii.d.ts.map +1 -0
- package/dist/analytics/pii.js +52 -0
- package/dist/analytics/pii.js.map +1 -0
- package/dist/analytics/providers.d.ts +28 -0
- package/dist/analytics/providers.d.ts.map +1 -0
- package/dist/analytics/providers.js +82 -0
- package/dist/analytics/providers.js.map +1 -0
- package/dist/analytics/types.d.ts +94 -0
- package/dist/analytics/types.d.ts.map +1 -0
- package/dist/analytics/types.js +7 -0
- package/dist/analytics/types.js.map +1 -0
- package/dist/client.d.ts +49 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +135 -0
- package/dist/client.js.map +1 -0
- package/dist/config.d.ts +32 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +28 -0
- package/dist/config.js.map +1 -0
- package/dist/errors.d.ts +33 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +46 -0
- package/dist/errors.js.map +1 -0
- package/dist/i18n.d.ts +51 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/i18n.js +90 -0
- package/dist/i18n.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/query.d.ts +42 -0
- package/dist/query.d.ts.map +1 -0
- package/dist/query.js +95 -0
- package/dist/query.js.map +1 -0
- package/dist/storage.d.ts +16 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +65 -0
- package/dist/storage.js.map +1 -0
- package/dist/useBreakpoint.d.ts +8 -0
- package/dist/useBreakpoint.d.ts.map +1 -0
- package/dist/useBreakpoint.js +22 -0
- package/dist/useBreakpoint.js.map +1 -0
- package/dist/verification.d.ts +31 -0
- package/dist/verification.d.ts.map +1 -0
- package/dist/verification.js +20 -0
- package/dist/verification.js.map +1 -0
- package/package.json +68 -0
- package/src/analytics/context.ts +20 -0
- package/src/analytics/createAnalytics.ts +310 -0
- package/src/analytics/flow.ts +19 -0
- package/src/analytics/hash.ts +16 -0
- package/src/analytics/pii.ts +66 -0
- package/src/analytics/providers.ts +108 -0
- package/src/analytics/types.ts +105 -0
- package/src/client.ts +206 -0
- package/src/config.tsx +62 -0
- package/src/errors.ts +70 -0
- package/src/i18n.tsx +147 -0
- package/src/index.ts +72 -0
- package/src/query.ts +151 -0
- package/src/storage.ts +76 -0
- package/src/useBreakpoint.ts +27 -0
- package/src/verification.ts +48 -0
- package/tsconfig.json +26 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { parseErrorEnvelope } from "./errors.js";
|
|
2
|
+
import {
|
|
3
|
+
extractVerificationChallenge,
|
|
4
|
+
VERIFICATION_TOKEN_HEADER,
|
|
5
|
+
type VerificationChallengeHandler,
|
|
6
|
+
} from "./verification.js";
|
|
7
|
+
|
|
8
|
+
export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
9
|
+
|
|
10
|
+
export interface StapelRequestOptions {
|
|
11
|
+
readonly method?: HttpMethod;
|
|
12
|
+
/** JSON-serialized unless it is a `BodyInit` (FormData, Blob, string…). */
|
|
13
|
+
readonly body?: unknown;
|
|
14
|
+
readonly headers?: Record<string, string>;
|
|
15
|
+
/** Appended to the URL; `undefined` values are skipped. */
|
|
16
|
+
readonly query?: Record<string, string | number | boolean | undefined>;
|
|
17
|
+
readonly signal?: AbortSignal;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface StapelClient {
|
|
21
|
+
readonly baseUrl: string;
|
|
22
|
+
request<T>(path: string, options?: StapelRequestOptions): Promise<T>;
|
|
23
|
+
get<T>(path: string, options?: Omit<StapelRequestOptions, "method" | "body">): Promise<T>;
|
|
24
|
+
post<T>(path: string, body?: unknown, options?: Omit<StapelRequestOptions, "method" | "body">): Promise<T>;
|
|
25
|
+
put<T>(path: string, body?: unknown, options?: Omit<StapelRequestOptions, "method" | "body">): Promise<T>;
|
|
26
|
+
patch<T>(path: string, body?: unknown, options?: Omit<StapelRequestOptions, "method" | "body">): Promise<T>;
|
|
27
|
+
delete<T>(path: string, options?: Omit<StapelRequestOptions, "method" | "body">): Promise<T>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface StapelClientOptions {
|
|
31
|
+
/** e.g. `https://api.example.com` or `/api`. */
|
|
32
|
+
readonly baseUrl: string;
|
|
33
|
+
/** Auth seam: current access token (attached as `Authorization: Bearer`). */
|
|
34
|
+
readonly getToken?: () =>
|
|
35
|
+
| string
|
|
36
|
+
| null
|
|
37
|
+
| undefined
|
|
38
|
+
| Promise<string | null | undefined>;
|
|
39
|
+
/**
|
|
40
|
+
* Refresh seam: called once per request on a 401. Return the new access
|
|
41
|
+
* token to retry the request with it; return null/undefined to give up
|
|
42
|
+
* (the 401 is then thrown as `StapelApiError`).
|
|
43
|
+
*/
|
|
44
|
+
readonly onAuthRefresh?: () => Promise<string | null | undefined>;
|
|
45
|
+
/**
|
|
46
|
+
* Step-up verification seam: called when a 403 body carries a
|
|
47
|
+
* `verification` challenge. On `{retry: true}` the original request is
|
|
48
|
+
* retried exactly once, with `X-Verification-Token` when a token is given.
|
|
49
|
+
*/
|
|
50
|
+
readonly onVerificationChallenge?: VerificationChallengeHandler;
|
|
51
|
+
/** Merged into every request (overridable per request). */
|
|
52
|
+
readonly defaultHeaders?: Record<string, string>;
|
|
53
|
+
/** Injectable fetch (tests, SSR, instrumentation). Default: global fetch. */
|
|
54
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isBodyInit(body: unknown): body is BodyInit {
|
|
58
|
+
return (
|
|
59
|
+
typeof body === "string" ||
|
|
60
|
+
(typeof Blob !== "undefined" && body instanceof Blob) ||
|
|
61
|
+
(typeof FormData !== "undefined" && body instanceof FormData) ||
|
|
62
|
+
(typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams) ||
|
|
63
|
+
(typeof ArrayBuffer !== "undefined" && body instanceof ArrayBuffer) ||
|
|
64
|
+
(typeof ReadableStream !== "undefined" && body instanceof ReadableStream)
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function buildUrl(
|
|
69
|
+
baseUrl: string,
|
|
70
|
+
path: string,
|
|
71
|
+
query?: StapelRequestOptions["query"]
|
|
72
|
+
): string {
|
|
73
|
+
const base = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
|
|
74
|
+
const suffix = path.startsWith("/") ? path : `/${path}`;
|
|
75
|
+
let url = `${base}${suffix}`;
|
|
76
|
+
if (query) {
|
|
77
|
+
const search = new URLSearchParams();
|
|
78
|
+
for (const [key, value] of Object.entries(query)) {
|
|
79
|
+
if (value !== undefined) search.set(key, String(value));
|
|
80
|
+
}
|
|
81
|
+
const qs = search.toString();
|
|
82
|
+
if (qs.length > 0) url += `?${qs}`;
|
|
83
|
+
}
|
|
84
|
+
return url;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function parseBody(response: Response): Promise<unknown> {
|
|
88
|
+
if (response.status === 204 || response.status === 205) return undefined;
|
|
89
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
90
|
+
const text = await response.text();
|
|
91
|
+
if (text.length === 0) return undefined;
|
|
92
|
+
if (contentType.includes("json")) {
|
|
93
|
+
try {
|
|
94
|
+
return JSON.parse(text) as unknown;
|
|
95
|
+
} catch {
|
|
96
|
+
return text;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
return JSON.parse(text) as unknown;
|
|
101
|
+
} catch {
|
|
102
|
+
return text;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Typed fetch wrapper around the Stapel API conventions: JSON in/out, bearer
|
|
108
|
+
* auth with a refresh seam, the `{localizable_error, error, params}` error
|
|
109
|
+
* envelope, and verification-403 interception (see `StapelClientOptions`).
|
|
110
|
+
*/
|
|
111
|
+
export function createStapelClient(options: StapelClientOptions): StapelClient {
|
|
112
|
+
const fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
113
|
+
|
|
114
|
+
async function request<T>(
|
|
115
|
+
path: string,
|
|
116
|
+
requestOptions: StapelRequestOptions = {}
|
|
117
|
+
): Promise<T> {
|
|
118
|
+
const method = requestOptions.method ?? "GET";
|
|
119
|
+
const url = buildUrl(options.baseUrl, path, requestOptions.query);
|
|
120
|
+
|
|
121
|
+
let overrideToken: string | undefined;
|
|
122
|
+
let verificationToken: string | undefined;
|
|
123
|
+
let triedRefresh = false;
|
|
124
|
+
let triedVerification = false;
|
|
125
|
+
|
|
126
|
+
for (;;) {
|
|
127
|
+
const headers = new Headers(options.defaultHeaders);
|
|
128
|
+
if (requestOptions.headers) {
|
|
129
|
+
for (const [key, value] of Object.entries(requestOptions.headers)) {
|
|
130
|
+
headers.set(key, value);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const token = overrideToken ?? (await options.getToken?.());
|
|
134
|
+
if (token != null && token.length > 0 && !headers.has("authorization")) {
|
|
135
|
+
headers.set("Authorization", `Bearer ${token}`);
|
|
136
|
+
}
|
|
137
|
+
if (verificationToken !== undefined) {
|
|
138
|
+
headers.set(VERIFICATION_TOKEN_HEADER, verificationToken);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let body: BodyInit | undefined;
|
|
142
|
+
if (requestOptions.body !== undefined) {
|
|
143
|
+
if (isBodyInit(requestOptions.body)) {
|
|
144
|
+
body = requestOptions.body;
|
|
145
|
+
} else {
|
|
146
|
+
body = JSON.stringify(requestOptions.body);
|
|
147
|
+
if (!headers.has("content-type")) {
|
|
148
|
+
headers.set("Content-Type", "application/json");
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const requestInit: RequestInit = { method, headers };
|
|
154
|
+
if (body !== undefined) requestInit.body = body;
|
|
155
|
+
if (requestOptions.signal) requestInit.signal = requestOptions.signal;
|
|
156
|
+
const response = await fetchImpl(url, requestInit);
|
|
157
|
+
|
|
158
|
+
if (response.ok) {
|
|
159
|
+
return (await parseBody(response)) as T;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const errorBody = await parseBody(response);
|
|
163
|
+
|
|
164
|
+
if (response.status === 401 && options.onAuthRefresh && !triedRefresh) {
|
|
165
|
+
triedRefresh = true;
|
|
166
|
+
const refreshed = await options.onAuthRefresh();
|
|
167
|
+
if (refreshed != null && refreshed.length > 0) {
|
|
168
|
+
overrideToken = refreshed;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (
|
|
174
|
+
response.status === 403 &&
|
|
175
|
+
options.onVerificationChallenge &&
|
|
176
|
+
!triedVerification
|
|
177
|
+
) {
|
|
178
|
+
const challenge = extractVerificationChallenge(errorBody);
|
|
179
|
+
if (challenge) {
|
|
180
|
+
triedVerification = true;
|
|
181
|
+
const outcome = await options.onVerificationChallenge(challenge);
|
|
182
|
+
if (outcome.retry) {
|
|
183
|
+
if (outcome.token !== undefined) {
|
|
184
|
+
verificationToken = outcome.token;
|
|
185
|
+
}
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
throw parseErrorEnvelope(response.status, errorBody);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
baseUrl: options.baseUrl,
|
|
197
|
+
request,
|
|
198
|
+
get: (path, opts) => request(path, { ...opts, method: "GET" }),
|
|
199
|
+
post: (path, body, opts) =>
|
|
200
|
+
request(path, { ...opts, method: "POST", body }),
|
|
201
|
+
put: (path, body, opts) => request(path, { ...opts, method: "PUT", body }),
|
|
202
|
+
patch: (path, body, opts) =>
|
|
203
|
+
request(path, { ...opts, method: "PATCH", body }),
|
|
204
|
+
delete: (path, opts) => request(path, { ...opts, method: "DELETE" }),
|
|
205
|
+
};
|
|
206
|
+
}
|
package/src/config.tsx
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
2
|
+
import type { ReactElement, ReactNode } from "react";
|
|
3
|
+
import type { StapelClient } from "./client.js";
|
|
4
|
+
import { AnalyticsContext } from "./analytics/context.js";
|
|
5
|
+
import type { Analytics } from "./analytics/types.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* App-level Stapel configuration. `clients` allows per-module client
|
|
9
|
+
* overrides — the client-injection fork-resolution seam of
|
|
10
|
+
* frontend-standard §7.2: a divergent backend gets its own generated client
|
|
11
|
+
* injected into the same package machines.
|
|
12
|
+
*/
|
|
13
|
+
export interface StapelConfig {
|
|
14
|
+
/** Default API client used by all `@stapel/<module>-react` packages. */
|
|
15
|
+
readonly client: StapelClient;
|
|
16
|
+
/** Per-module overrides, keyed by module name (e.g. `"auth"`). */
|
|
17
|
+
readonly clients?: Readonly<Record<string, StapelClient>>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const StapelConfigContext = createContext<StapelConfig | null>(null);
|
|
21
|
+
|
|
22
|
+
export function StapelConfigProvider(props: {
|
|
23
|
+
config: StapelConfig;
|
|
24
|
+
/**
|
|
25
|
+
* Optional analytics facade (analytics-standard §2); when provided,
|
|
26
|
+
* `useAnalytics()` works anywhere below. Omitting it keeps the previous
|
|
27
|
+
* behaviour.
|
|
28
|
+
*/
|
|
29
|
+
analytics?: Analytics;
|
|
30
|
+
children: ReactNode;
|
|
31
|
+
}): ReactElement {
|
|
32
|
+
return (
|
|
33
|
+
<StapelConfigContext.Provider value={props.config}>
|
|
34
|
+
<AnalyticsContext.Provider value={props.analytics ?? null}>
|
|
35
|
+
{props.children}
|
|
36
|
+
</AnalyticsContext.Provider>
|
|
37
|
+
</StapelConfigContext.Provider>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function useStapelConfig(): StapelConfig {
|
|
42
|
+
const config = useContext(StapelConfigContext);
|
|
43
|
+
if (config === null) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
"useStapelConfig must be used within a <StapelConfigProvider>"
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return config;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Resolve the API client for a module: the per-module override when
|
|
53
|
+
* configured, otherwise the default client.
|
|
54
|
+
*/
|
|
55
|
+
export function useStapelClient(module?: string): StapelClient {
|
|
56
|
+
const config = useStapelConfig();
|
|
57
|
+
if (module !== undefined) {
|
|
58
|
+
const override = config.clients?.[module];
|
|
59
|
+
if (override) return override;
|
|
60
|
+
}
|
|
61
|
+
return config.client;
|
|
62
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Stapel backend error envelope:
|
|
3
|
+
* `{ localizable_error: "auth.otp.invalid", error: "Invalid OTP", params: {...} }`
|
|
4
|
+
* `localizable_error` is an i18n key; `params` feed `{param}` interpolation.
|
|
5
|
+
*/
|
|
6
|
+
export interface StapelErrorEnvelope {
|
|
7
|
+
readonly localizable_error?: string;
|
|
8
|
+
readonly error?: string;
|
|
9
|
+
readonly params?: Record<string, unknown>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class StapelApiError extends Error {
|
|
13
|
+
/** i18n key from `localizable_error` (fallback: `stapel.http.<status>`). */
|
|
14
|
+
readonly code: string;
|
|
15
|
+
/** Interpolation params for the i18n key. */
|
|
16
|
+
readonly params: Readonly<Record<string, unknown>>;
|
|
17
|
+
/** HTTP status code. */
|
|
18
|
+
readonly status: number;
|
|
19
|
+
/** Raw (parsed) response body, for diagnostics and extensions. */
|
|
20
|
+
readonly body: unknown;
|
|
21
|
+
|
|
22
|
+
constructor(args: {
|
|
23
|
+
code: string;
|
|
24
|
+
message: string;
|
|
25
|
+
params?: Record<string, unknown>;
|
|
26
|
+
status: number;
|
|
27
|
+
body?: unknown;
|
|
28
|
+
}) {
|
|
29
|
+
super(args.message);
|
|
30
|
+
this.name = "StapelApiError";
|
|
31
|
+
this.code = args.code;
|
|
32
|
+
this.params = args.params ?? {};
|
|
33
|
+
this.status = args.status;
|
|
34
|
+
this.body = args.body;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
39
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Parse a failed response body (already JSON-decoded; may be anything) into
|
|
44
|
+
* a `StapelApiError`. Tolerant of non-envelope bodies.
|
|
45
|
+
*/
|
|
46
|
+
export function parseErrorEnvelope(
|
|
47
|
+
status: number,
|
|
48
|
+
body: unknown
|
|
49
|
+
): StapelApiError {
|
|
50
|
+
const fallbackCode = `stapel.http.${String(status)}`;
|
|
51
|
+
if (!isRecord(body)) {
|
|
52
|
+
return new StapelApiError({
|
|
53
|
+
code: fallbackCode,
|
|
54
|
+
message: `Request failed with status ${String(status)}`,
|
|
55
|
+
status,
|
|
56
|
+
body,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
const code =
|
|
60
|
+
typeof body["localizable_error"] === "string" &&
|
|
61
|
+
body["localizable_error"].length > 0
|
|
62
|
+
? body["localizable_error"]
|
|
63
|
+
: fallbackCode;
|
|
64
|
+
const message =
|
|
65
|
+
typeof body["error"] === "string" && body["error"].length > 0
|
|
66
|
+
? body["error"]
|
|
67
|
+
: code;
|
|
68
|
+
const params = isRecord(body["params"]) ? body["params"] : {};
|
|
69
|
+
return new StapelApiError({ code, message, params, status, body });
|
|
70
|
+
}
|
package/src/i18n.tsx
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { createContext, useContext, useSyncExternalStore } from "react";
|
|
2
|
+
import type { ReactElement, ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
/** Flat key → string dictionary, e.g. `{"auth.otp.invalid": "Invalid code"}`. */
|
|
5
|
+
export type I18nDictionary = Record<string, string>;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Async locale loader seam. Point it at the stapel-translate pair:
|
|
9
|
+
* `loadLocale: (locale) => translateClient.resolve(locale)` — the engine
|
|
10
|
+
* calls it once per locale and caches the result as a bundle.
|
|
11
|
+
*/
|
|
12
|
+
export type LocaleLoader = (locale: string) => Promise<I18nDictionary>;
|
|
13
|
+
|
|
14
|
+
export type TranslateFn = (
|
|
15
|
+
key: string,
|
|
16
|
+
params?: Record<string, unknown>
|
|
17
|
+
) => string;
|
|
18
|
+
|
|
19
|
+
export interface I18nEngine {
|
|
20
|
+
/** Current locale. */
|
|
21
|
+
readonly locale: string;
|
|
22
|
+
/** Translate a key; missing keys fall back to the key itself. */
|
|
23
|
+
t: TranslateFn;
|
|
24
|
+
/** Switch locale; loads it via `loadLocale` when not already registered. */
|
|
25
|
+
setLocale(locale: string): Promise<void>;
|
|
26
|
+
/** Register a static bundle (packages register their keys this way). */
|
|
27
|
+
registerBundle(locale: string, bundle: I18nDictionary): void;
|
|
28
|
+
/** Subscribe to engine changes (locale switches, bundle registration). */
|
|
29
|
+
subscribe(listener: () => void): () => void;
|
|
30
|
+
/** Monotonic change counter (for useSyncExternalStore). */
|
|
31
|
+
getVersion(): number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** `{param}` interpolation. Unknown params are left as-is. */
|
|
35
|
+
export function interpolate(
|
|
36
|
+
template: string,
|
|
37
|
+
params?: Record<string, unknown>
|
|
38
|
+
): string {
|
|
39
|
+
if (!params) return template;
|
|
40
|
+
return template.replace(/\{([\w.]+)\}/g, (match, name: string) =>
|
|
41
|
+
Object.prototype.hasOwnProperty.call(params, name)
|
|
42
|
+
? String(params[name])
|
|
43
|
+
: match
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface CreateI18nOptions {
|
|
48
|
+
/** Initial locale. */
|
|
49
|
+
readonly locale: string;
|
|
50
|
+
/** Static bundles, keyed by locale. */
|
|
51
|
+
readonly bundles?: Readonly<Record<string, I18nDictionary>>;
|
|
52
|
+
/** Async loader for locales not covered by static bundles. */
|
|
53
|
+
readonly loadLocale?: LocaleLoader;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Minimal i18n engine: dictionaries per locale, `{param}` interpolation,
|
|
58
|
+
* static bundles + async loader, missing-key fallback to the key itself
|
|
59
|
+
* (frontend-standard §4.2 — user-facing strings are always keys).
|
|
60
|
+
*/
|
|
61
|
+
export function createI18n(options: CreateI18nOptions): I18nEngine {
|
|
62
|
+
const dictionaries = new Map<string, I18nDictionary>();
|
|
63
|
+
const loadedLocales = new Set<string>();
|
|
64
|
+
const listeners = new Set<() => void>();
|
|
65
|
+
let locale = options.locale;
|
|
66
|
+
let version = 0;
|
|
67
|
+
|
|
68
|
+
if (options.bundles) {
|
|
69
|
+
for (const [bundleLocale, bundle] of Object.entries(options.bundles)) {
|
|
70
|
+
dictionaries.set(bundleLocale, { ...bundle });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function notify(): void {
|
|
75
|
+
version += 1;
|
|
76
|
+
for (const listener of listeners) listener();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function ensureLoaded(nextLocale: string): Promise<void> {
|
|
80
|
+
if (!options.loadLocale || loadedLocales.has(nextLocale)) return;
|
|
81
|
+
loadedLocales.add(nextLocale);
|
|
82
|
+
const bundle = await options.loadLocale(nextLocale);
|
|
83
|
+
const existing = dictionaries.get(nextLocale) ?? {};
|
|
84
|
+
dictionaries.set(nextLocale, { ...existing, ...bundle });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const engine: I18nEngine = {
|
|
88
|
+
get locale(): string {
|
|
89
|
+
return locale;
|
|
90
|
+
},
|
|
91
|
+
t: (key, params) => {
|
|
92
|
+
const template = dictionaries.get(locale)?.[key];
|
|
93
|
+
if (template === undefined) return key;
|
|
94
|
+
return interpolate(template, params);
|
|
95
|
+
},
|
|
96
|
+
setLocale: async (nextLocale) => {
|
|
97
|
+
await ensureLoaded(nextLocale);
|
|
98
|
+
locale = nextLocale;
|
|
99
|
+
notify();
|
|
100
|
+
},
|
|
101
|
+
registerBundle: (bundleLocale, bundle) => {
|
|
102
|
+
const existing = dictionaries.get(bundleLocale) ?? {};
|
|
103
|
+
dictionaries.set(bundleLocale, { ...existing, ...bundle });
|
|
104
|
+
notify();
|
|
105
|
+
},
|
|
106
|
+
subscribe: (listener) => {
|
|
107
|
+
listeners.add(listener);
|
|
108
|
+
return () => {
|
|
109
|
+
listeners.delete(listener);
|
|
110
|
+
};
|
|
111
|
+
},
|
|
112
|
+
getVersion: () => version,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
return engine;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const I18nContext = createContext<I18nEngine | null>(null);
|
|
119
|
+
|
|
120
|
+
export function I18nProvider(props: {
|
|
121
|
+
i18n: I18nEngine;
|
|
122
|
+
children: ReactNode;
|
|
123
|
+
}): ReactElement {
|
|
124
|
+
return (
|
|
125
|
+
<I18nContext.Provider value={props.i18n}>
|
|
126
|
+
{props.children}
|
|
127
|
+
</I18nContext.Provider>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function useI18n(): I18nEngine {
|
|
132
|
+
const engine = useContext(I18nContext);
|
|
133
|
+
if (engine === null) {
|
|
134
|
+
throw new Error("useI18n must be used within an <I18nProvider>");
|
|
135
|
+
}
|
|
136
|
+
return engine;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Reactive translate function: re-renders on locale switches and bundle
|
|
141
|
+
* registration.
|
|
142
|
+
*/
|
|
143
|
+
export function useT(): TranslateFn {
|
|
144
|
+
const engine = useI18n();
|
|
145
|
+
useSyncExternalStore(engine.subscribe, engine.getVersion, engine.getVersion);
|
|
146
|
+
return engine.t;
|
|
147
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// fetch + error envelope
|
|
2
|
+
export { createStapelClient } from "./client.js";
|
|
3
|
+
export type {
|
|
4
|
+
StapelClient,
|
|
5
|
+
StapelClientOptions,
|
|
6
|
+
StapelRequestOptions,
|
|
7
|
+
HttpMethod,
|
|
8
|
+
} from "./client.js";
|
|
9
|
+
export { StapelApiError, parseErrorEnvelope } from "./errors.js";
|
|
10
|
+
export type { StapelErrorEnvelope } from "./errors.js";
|
|
11
|
+
|
|
12
|
+
// verification-403 interception seam
|
|
13
|
+
export {
|
|
14
|
+
extractVerificationChallenge,
|
|
15
|
+
VERIFICATION_TOKEN_HEADER,
|
|
16
|
+
} from "./verification.js";
|
|
17
|
+
export type {
|
|
18
|
+
VerificationChallenge,
|
|
19
|
+
VerificationOutcome,
|
|
20
|
+
VerificationChallengeHandler,
|
|
21
|
+
} from "./verification.js";
|
|
22
|
+
|
|
23
|
+
// config provider + client injection
|
|
24
|
+
export {
|
|
25
|
+
StapelConfigProvider,
|
|
26
|
+
useStapelConfig,
|
|
27
|
+
useStapelClient,
|
|
28
|
+
} from "./config.js";
|
|
29
|
+
export type { StapelConfig } from "./config.js";
|
|
30
|
+
|
|
31
|
+
// query layer + persistence
|
|
32
|
+
export { createStapelQueryClient } from "./query.js";
|
|
33
|
+
export type {
|
|
34
|
+
StapelQueryRuntime,
|
|
35
|
+
StapelQueryClientOptions,
|
|
36
|
+
PersistStorage,
|
|
37
|
+
} from "./query.js";
|
|
38
|
+
|
|
39
|
+
// i18n engine
|
|
40
|
+
export { createI18n, interpolate, I18nProvider, useI18n, useT } from "./i18n.js";
|
|
41
|
+
export type {
|
|
42
|
+
I18nEngine,
|
|
43
|
+
I18nDictionary,
|
|
44
|
+
LocaleLoader,
|
|
45
|
+
TranslateFn,
|
|
46
|
+
CreateI18nOptions,
|
|
47
|
+
} from "./i18n.js";
|
|
48
|
+
|
|
49
|
+
// analytics facade (analytics-standard §2)
|
|
50
|
+
export { createAnalytics } from "./analytics/createAnalytics.js";
|
|
51
|
+
export {
|
|
52
|
+
consoleProvider,
|
|
53
|
+
stapelCollectorProvider,
|
|
54
|
+
} from "./analytics/providers.js";
|
|
55
|
+
export type { StapelCollectorOptions } from "./analytics/providers.js";
|
|
56
|
+
export { trackFlowStep } from "./analytics/flow.js";
|
|
57
|
+
export type { FlowStepPhase } from "./analytics/flow.js";
|
|
58
|
+
export { AnalyticsContext, useAnalytics } from "./analytics/context.js";
|
|
59
|
+
export type {
|
|
60
|
+
Analytics,
|
|
61
|
+
AnalyticsEvent,
|
|
62
|
+
AnalyticsEventKind,
|
|
63
|
+
AnalyticsProvider,
|
|
64
|
+
AnalyticsOptions,
|
|
65
|
+
AnalyticsBatchOptions,
|
|
66
|
+
ConsentState,
|
|
67
|
+
PiiGuardMode,
|
|
68
|
+
} from "./analytics/types.js";
|
|
69
|
+
|
|
70
|
+
// breakpoints
|
|
71
|
+
export { useBreakpoint } from "./useBreakpoint.js";
|
|
72
|
+
export type { Breakpoint } from "@stapel/tokens";
|