@docyrus/i18n 0.0.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.
package/README.md ADDED
@@ -0,0 +1,283 @@
1
+ # @docyrus/i18n
2
+
3
+ Internationalization provider for Docyrus apps. Provides `DocyrusI18nProvider` + `t()` function with API-based and static translation support, cookie-based locale persistence, and `{{variable}}` interpolation.
4
+
5
+ Supports **React**, **Vue**, and **SSR** (Next.js Server Components) via separate entrypoints.
6
+
7
+ ## Features
8
+
9
+ - **Translation Provider**: Wrap your app with `DocyrusI18nProvider` and use `t()` anywhere
10
+ - **API + Static Merge**: Fetch translations from API (`GET /tenant/translations`) and/or provide static JSON
11
+ - **Cookie-based Locale**: Persists locale in a cookie (`docyrus-locale`), survives page reloads
12
+ - **Locale Switching**: `setLocale('en')` writes cookie + refetches translations automatically
13
+ - **Interpolation**: Supports `{{name}}`, `{name}`, and `{0}` placeholder formats
14
+ - **SSR + CSR**: `'use client'` directive for Next.js; static translations available immediately (no loading flash)
15
+ - **Auth Integration**: Accepts `RestApiClient` from `@docyrus/api-client` (auto Authorization header)
16
+ - **React Hooks**: `useDocyrusI18n()` (full context) and `useTranslation()` (lightweight)
17
+ - **Vue Composables**: `useDocyrusI18n()` and `useTranslation()` for Vue 3
18
+ - **Framework-Agnostic Core**: `I18nManager` class for custom integrations
19
+ - **TypeScript**: Full type definitions included
20
+
21
+ ## Installation
22
+
23
+ ```bash
24
+ npm install @docyrus/i18n @docyrus/api-client
25
+ ```
26
+
27
+ ```bash
28
+ pnpm add @docyrus/i18n @docyrus/api-client
29
+ ```
30
+
31
+ ### Peer Dependencies
32
+
33
+ - `@docyrus/api-client` >= 0.0.9 (required)
34
+ - `react` >= 19.2.0 (optional — required for React/Next.js entrypoint)
35
+ - `vue` >= 3.5.0 (optional — required for Vue entrypoint)
36
+
37
+ ## Entrypoints
38
+
39
+ | Import Path | Description | Framework |
40
+ |-------------|-------------|-----------|
41
+ | `@docyrus/i18n` | React provider, hooks | React, Next.js |
42
+ | `@docyrus/i18n/vue` | Vue provider, composables | Vue 3 |
43
+ | `@docyrus/i18n/core` | Pure TypeScript core (no framework dependency) | Any |
44
+
45
+ ## Quick Start (React)
46
+
47
+ ```tsx
48
+ import { DocyrusI18nProvider, useTranslation, useDocyrusI18n } from '@docyrus/i18n';
49
+ import { useDocyrusClient } from '@docyrus/app-auth-ui';
50
+
51
+ function App() {
52
+ const client = useDocyrusClient();
53
+
54
+ return (
55
+ <DocyrusI18nProvider
56
+ client={client}
57
+ staticTranslations={{ 'common.loading': 'Yükleniyor...' }}
58
+ >
59
+ <Dashboard />
60
+ </DocyrusI18nProvider>
61
+ );
62
+ }
63
+
64
+ function Dashboard() {
65
+ const { t, isLoading } = useTranslation();
66
+
67
+ if (isLoading) return <div>{t('common.loading')}</div>;
68
+
69
+ return <h1>{t('dashboard.title')}</h1>;
70
+ }
71
+ ```
72
+
73
+ ## Quick Start (Next.js SSR)
74
+
75
+ ```tsx
76
+ // app/layout.tsx (Server Component)
77
+ import { I18nClientProvider } from './i18n-client-provider';
78
+
79
+ async function getTranslations() {
80
+ const res = await fetch(`${API_URL}/tenant/translations`, {
81
+ headers: { Authorization: `Bearer ${serverToken}` }
82
+ });
83
+ return res.json();
84
+ }
85
+
86
+ export default async function Layout({ children }: { children: React.ReactNode }) {
87
+ const { data: translations } = await getTranslations();
88
+
89
+ return (
90
+ <html>
91
+ <body>
92
+ <I18nClientProvider staticTranslations={translations}>
93
+ {children}
94
+ </I18nClientProvider>
95
+ </body>
96
+ </html>
97
+ );
98
+ }
99
+ ```
100
+
101
+ ```tsx
102
+ // components/i18n-client-provider.tsx (Client Component)
103
+ 'use client';
104
+
105
+ import { DocyrusI18nProvider, type TranslationDictionary } from '@docyrus/i18n';
106
+ import { useDocyrusClient } from '@docyrus/app-auth-ui';
107
+
108
+ export function I18nClientProvider({
109
+ children,
110
+ staticTranslations
111
+ }: {
112
+ children: React.ReactNode;
113
+ staticTranslations: TranslationDictionary;
114
+ }) {
115
+ const client = useDocyrusClient();
116
+
117
+ return (
118
+ <DocyrusI18nProvider
119
+ client={client}
120
+ staticTranslations={staticTranslations}
121
+ >
122
+ {children}
123
+ </DocyrusI18nProvider>
124
+ );
125
+ }
126
+ ```
127
+
128
+ ## Quick Start (Vue)
129
+
130
+ ```vue
131
+ <!-- App.vue -->
132
+ <script setup lang="ts">
133
+ import { RouterView } from 'vue-router';
134
+ import { DocyrusI18nProvider } from '@docyrus/i18n/vue';
135
+ import { useDocyrusClient } from '@docyrus/app-auth-ui/vue';
136
+
137
+ const client = useDocyrusClient();
138
+ </script>
139
+
140
+ <template>
141
+ <DocyrusI18nProvider :client="client">
142
+ <RouterView />
143
+ </DocyrusI18nProvider>
144
+ </template>
145
+ ```
146
+
147
+ ```vue
148
+ <!-- Dashboard.vue -->
149
+ <script setup lang="ts">
150
+ import { useDocyrusI18n } from '@docyrus/i18n/vue';
151
+
152
+ const { t, locale, setLocale, isLoading } = useDocyrusI18n();
153
+ </script>
154
+
155
+ <template>
156
+ <div v-if="isLoading">Loading...</div>
157
+ <div v-else>
158
+ <h1>{{ t('dashboard.title') }}</h1>
159
+ <select :value="locale" @change="setLocale(($event.target as HTMLSelectElement).value)">
160
+ <option value="tr">Turkce</option>
161
+ <option value="en">English</option>
162
+ </select>
163
+ </div>
164
+ </template>
165
+ ```
166
+
167
+ ## Configuration
168
+
169
+ | Prop | Type | Default | Description |
170
+ |------|------|---------|-------------|
171
+ | `client` | `RestApiClient \| null` | `null` | Pre-configured API client with auth tokens |
172
+ | `getAccessToken` | `() => Promise<string \| null>` | `undefined` | Alternative auth callback (for SSR or custom setups) |
173
+ | `apiUrl` | `string` | `undefined` | API base URL (required with `getAccessToken`) |
174
+ | `translationsEndpoint` | `string` | `'tenant/translations'` | API endpoint path |
175
+ | `staticTranslations` | `Record<string, string>` | `{}` | Pre-loaded translations (available immediately) |
176
+ | `cookieKey` | `string` | `'docyrus-locale'` | Cookie name for locale persistence |
177
+ | `mergeStrategy` | `'api-first' \| 'static-first'` | `'api-first'` | How to merge static and API translations |
178
+ | `fallback` | `'key' \| 'empty' \| (key) => string` | `'key'` | What to return when a key is not found |
179
+ | `disableApiFetch` | `boolean` | `false` | Skip API fetch entirely (use only static translations) |
180
+ | `userLanguageEndpoint` | `string \| false` | `'users/me'` | PATCH endpoint to persist user language. Set `false` to disable |
181
+
182
+ ## Hooks / Composables
183
+
184
+ ### useDocyrusI18n()
185
+
186
+ Full i18n context. Available in both React and Vue (`@docyrus/i18n/vue`).
187
+
188
+ ```tsx
189
+ const {
190
+ t, // (key: string, params?: Record<string, string | number>) => string
191
+ locale, // current locale string | null
192
+ setLocale, // (locale: string) => void — writes cookie + refetches
193
+ status, // 'loading' | 'ready' | 'error'
194
+ isLoading, // boolean
195
+ translations, // Record<string, string>
196
+ error, // Error | null
197
+ refetch // () => Promise<void>
198
+ } = useDocyrusI18n();
199
+ ```
200
+
201
+ ### useTranslation()
202
+
203
+ Lightweight hook for components that only need `t()`:
204
+
205
+ ```tsx
206
+ const { t, isLoading } = useTranslation();
207
+ ```
208
+
209
+ ## Merge Strategy
210
+
211
+ - **`'api-first'`** (default): `{ ...static, ...api }` — API translations override static. Use this when static translations are fallbacks.
212
+ - **`'static-first'`**: `{ ...api, ...static }` — Static translations override API. Use this for hardcoded overrides.
213
+
214
+ Static translations are available immediately on mount (no loading flash). API translations are fetched asynchronously and merged in.
215
+
216
+ ## Locale Management
217
+
218
+ 1. On mount: read locale from cookie (`docyrus-locale`). If not set, locale is `null` — API resolves from user profile.
219
+ 2. `setLocale('en_US')`: write to cookie + `PATCH users/me { language: 'en_US' }` + refetch translations.
220
+ 3. API returns translations based on user's `language` field — no `?locale=` param needed.
221
+ 4. Next page load: cookie is read automatically — user stays on the same locale.
222
+ 5. SSR: cookie can be read server-side via Next.js `cookies()` API.
223
+
224
+ ## Interpolation
225
+
226
+ Three placeholder formats are supported:
227
+
228
+ ```tsx
229
+ // Double-brace (static translations convention)
230
+ t('greeting', { name: 'Ali' }) // "Merhaba, {{name}}!" → "Merhaba, Ali!"
231
+
232
+ // Single-brace named (API convention)
233
+ t('ai.chat', { name: 'Docyrus' }) // "Talk to {name} AI" → "Talk to Docyrus AI"
234
+
235
+ // Single-brace positional (API convention)
236
+ t('add.new', { '0': 'User' }) // "Add New {0}" → "Add New User"
237
+ ```
238
+
239
+ ## API Response Format
240
+
241
+ The API endpoint (`GET /tenant/translations`) must return:
242
+
243
+ ```json
244
+ {
245
+ "success": true,
246
+ "data": {
247
+ "Show Expand Detail Button": "Detayı Genişlet Butonunu Göster",
248
+ "Add New {0}": "Yeni {0} Ekle",
249
+ "Talk to {name} AI": "{name} AI ile Konuş"
250
+ }
251
+ }
252
+ ```
253
+
254
+ Translation keys are flat strings (not dot-notation). Values may contain `{0}` or `{name}` placeholders.
255
+
256
+ ## Core (Framework-Agnostic)
257
+
258
+ ```tsx
259
+ import { I18nManager, interpolate, getLocale, setLocale } from '@docyrus/i18n/core';
260
+
261
+ const manager = new I18nManager({
262
+ staticTranslations: { 'hello': 'Merhaba, {{name}}!' }
263
+ });
264
+
265
+ manager.subscribe((state) => console.log(state.status, state.translations));
266
+ await manager.initialize();
267
+
268
+ console.log(manager.t('hello', { name: 'Ali' })); // "Merhaba, Ali!"
269
+ ```
270
+
271
+ ## Development
272
+
273
+ ```bash
274
+ pnpm install
275
+ pnpm dev # Watch mode
276
+ pnpm build # Build ESM + dts
277
+ pnpm lint # ESLint
278
+ pnpm typecheck # ESLint + tsc --noEmit
279
+ ```
280
+
281
+ ## License
282
+
283
+ MIT
@@ -0,0 +1,107 @@
1
+ import { D as DocyrusI18nConfig, I as I18nStatus, T as TranslationDictionary } from './types-DQtgewNG.js';
2
+ export { a as DocyrusI18nContextValue, F as FallbackStrategy, b as TranslationsResponse } from './types-DQtgewNG.js';
3
+ import '@docyrus/api-client';
4
+
5
+ type I18nStateListener = (state: {
6
+ status: I18nStatus;
7
+ locale: string | null;
8
+ translations: TranslationDictionary;
9
+ error: Error | null;
10
+ }) => void;
11
+ /**
12
+ * Framework-agnostic translation engine.
13
+ *
14
+ * Manages locale persistence (cookie), translation fetching (API + static merge),
15
+ * and provides the `t()` function for translation lookup with interpolation.
16
+ *
17
+ * Pattern follows AuthManager from @docyrus/app-auth-ui.
18
+ */
19
+ declare class I18nManager {
20
+ private status;
21
+ private locale;
22
+ private translations;
23
+ private apiTranslations;
24
+ private error;
25
+ private listeners;
26
+ private abortController;
27
+ private client;
28
+ private getAccessToken;
29
+ private apiUrl;
30
+ private translationsEndpoint;
31
+ private staticTranslations;
32
+ private cookieKey;
33
+ private mergeStrategy;
34
+ private fallback;
35
+ private disableApiFetch;
36
+ private userLanguageEndpoint;
37
+ constructor(config: DocyrusI18nConfig);
38
+ getStatus(): I18nStatus;
39
+ getLocale(): string | null;
40
+ getTranslations(): TranslationDictionary;
41
+ getError(): Error | null;
42
+ subscribe(listener: I18nStateListener): () => void;
43
+ private notify;
44
+ /**
45
+ * Initialize the manager.
46
+ * 1. Apply static translations immediately (no loading flash).
47
+ * 2. Fetch from API if enabled and auth is available.
48
+ */
49
+ initialize(): Promise<void>;
50
+ /**
51
+ * Fetch translations from the API.
52
+ * Uses RestApiClient.get() if client is available, otherwise raw fetch with getAccessToken.
53
+ */
54
+ fetchTranslations(): Promise<void>;
55
+ /**
56
+ * Translate a key with optional interpolation.
57
+ * Falls back based on the configured strategy.
58
+ */
59
+ t(key: string, params?: Record<string, string | number>): string;
60
+ /**
61
+ * Change the active locale.
62
+ * Writes to cookie, persists to API (PATCH users/me), and triggers translation refetch.
63
+ */
64
+ setLocale(locale: string): Promise<void>;
65
+ /**
66
+ * Persist user language preference to API.
67
+ * PATCH {userLanguageEndpoint} { language: locale }
68
+ */
69
+ private persistUserLanguage;
70
+ /**
71
+ * Update config at runtime.
72
+ * Useful when client transitions from null → RestApiClient (auth loading complete).
73
+ */
74
+ updateConfig(updates: Partial<Pick<DocyrusI18nConfig, 'client' | 'staticTranslations'>>): void;
75
+ /** Cleanup: abort in-flight requests + clear listeners. */
76
+ destroy(): void;
77
+ private mergeTranslations;
78
+ }
79
+
80
+ /**
81
+ * Interpolate placeholders in a translation string.
82
+ *
83
+ * Supports three formats (checked in order):
84
+ * 1. {{variable}} — double-brace (static translations convention)
85
+ * 2. {variable} — single-brace named (API convention: "Talk to {name} AI")
86
+ * 3. {0}, {1} — single-brace positional (API convention: "Add New {0}")
87
+ *
88
+ * Positional placeholders use numeric keys: params = { '0': 'User' }
89
+ *
90
+ * Examples:
91
+ * interpolate('Hello, {{name}}!', { name: 'Ali' }) → 'Hello, Ali!'
92
+ * interpolate('Talk to {name} AI', { name: 'Docyrus' }) → 'Talk to Docyrus AI'
93
+ * interpolate('Add New {0}', { '0': 'User' }) → 'Add New User'
94
+ */
95
+ declare function interpolate(template: string, params?: Record<string, string | number>): string;
96
+
97
+ /**
98
+ * Read the locale from a cookie. SSR-safe.
99
+ * Returns null if the cookie is not set or not in a browser environment.
100
+ */
101
+ declare function getLocale(cookieKey: string): string | null;
102
+ /**
103
+ * Write the locale to a cookie. SSR-safe (no-op on server).
104
+ */
105
+ declare function setLocale(cookieKey: string, locale: string): void;
106
+
107
+ export { DocyrusI18nConfig, I18nManager, type I18nStateListener, I18nStatus, TranslationDictionary, getLocale, interpolate, setLocale };
@@ -0,0 +1,246 @@
1
+ // src/core/interpolation.ts
2
+ function interpolate(template, params) {
3
+ if (!params) return template;
4
+ return template.replace(
5
+ /\{\{(\w+)\}\}|\{(\w+)\}/g,
6
+ (match, doubleBraceKey, singleBraceKey) => {
7
+ const key = doubleBraceKey ?? singleBraceKey;
8
+ if (key === void 0) return match;
9
+ const value = params[key];
10
+ return value !== void 0 ? String(value) : match;
11
+ }
12
+ );
13
+ }
14
+
15
+ // src/core/locale-storage.ts
16
+ var DEFAULT_MAX_AGE = 365 * 24 * 60 * 60;
17
+ function getLocale(cookieKey) {
18
+ if (typeof document === "undefined") return null;
19
+ const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${escapeRegex(cookieKey)}=([^;]*)`));
20
+ return match ? decodeURIComponent(match[1]) : null;
21
+ }
22
+ function setLocale(cookieKey, locale) {
23
+ if (typeof document === "undefined") return;
24
+ document.cookie = `${cookieKey}=${encodeURIComponent(locale)};path=/;max-age=${DEFAULT_MAX_AGE};SameSite=Lax`;
25
+ }
26
+ function escapeRegex(str) {
27
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
28
+ }
29
+
30
+ // src/core/i18n-manager.ts
31
+ var I18nManager = class {
32
+ status = "loading";
33
+ locale;
34
+ translations = {};
35
+ apiTranslations = {};
36
+ error = null;
37
+ listeners = /* @__PURE__ */ new Set();
38
+ abortController = null;
39
+ client;
40
+ getAccessToken;
41
+ apiUrl;
42
+ translationsEndpoint;
43
+ staticTranslations;
44
+ cookieKey;
45
+ mergeStrategy;
46
+ fallback;
47
+ disableApiFetch;
48
+ userLanguageEndpoint;
49
+ constructor(config) {
50
+ this.client = config.client ?? null;
51
+ this.getAccessToken = config.getAccessToken;
52
+ this.apiUrl = config.apiUrl;
53
+ this.translationsEndpoint = config.translationsEndpoint ?? "tenant/translations";
54
+ this.staticTranslations = config.staticTranslations ?? {};
55
+ this.cookieKey = config.cookieKey ?? "docyrus-locale";
56
+ this.mergeStrategy = config.mergeStrategy ?? "api-first";
57
+ this.fallback = config.fallback ?? "key";
58
+ this.disableApiFetch = config.disableApiFetch ?? false;
59
+ this.userLanguageEndpoint = config.userLanguageEndpoint ?? "users/me";
60
+ this.locale = getLocale(this.cookieKey);
61
+ }
62
+ getStatus() {
63
+ return this.status;
64
+ }
65
+ getLocale() {
66
+ return this.locale;
67
+ }
68
+ getTranslations() {
69
+ return this.translations;
70
+ }
71
+ getError() {
72
+ return this.error;
73
+ }
74
+ subscribe(listener) {
75
+ this.listeners.add(listener);
76
+ return () => this.listeners.delete(listener);
77
+ }
78
+ notify() {
79
+ for (const listener of this.listeners) {
80
+ listener({
81
+ status: this.status,
82
+ locale: this.locale,
83
+ translations: this.translations,
84
+ error: this.error
85
+ });
86
+ }
87
+ }
88
+ /**
89
+ * Initialize the manager.
90
+ * 1. Apply static translations immediately (no loading flash).
91
+ * 2. Fetch from API if enabled and auth is available.
92
+ */
93
+ async initialize() {
94
+ if (Object.keys(this.staticTranslations).length > 0) {
95
+ this.translations = { ...this.staticTranslations };
96
+ this.status = "ready";
97
+ this.notify();
98
+ }
99
+ if (!this.disableApiFetch && (this.client || this.getAccessToken)) {
100
+ await this.fetchTranslations();
101
+ } else if (this.status !== "ready") {
102
+ this.status = "ready";
103
+ this.notify();
104
+ }
105
+ }
106
+ /**
107
+ * Fetch translations from the API.
108
+ * Uses RestApiClient.get() if client is available, otherwise raw fetch with getAccessToken.
109
+ */
110
+ async fetchTranslations() {
111
+ this.abortController?.abort();
112
+ this.abortController = new AbortController();
113
+ try {
114
+ let apiData;
115
+ if (this.client) {
116
+ apiData = await this.client.get(
117
+ this.translationsEndpoint
118
+ );
119
+ } else if (this.getAccessToken && this.apiUrl) {
120
+ const token = await this.getAccessToken();
121
+ if (!token) {
122
+ throw new Error("getAccessToken returned null \u2014 cannot fetch translations");
123
+ }
124
+ const url = `${this.apiUrl.replace(/\/$/, "")}/${this.translationsEndpoint}`;
125
+ const res = await fetch(url, {
126
+ headers: { Authorization: `Bearer ${token}` },
127
+ signal: this.abortController.signal
128
+ });
129
+ if (!res.ok) {
130
+ throw new Error(`Translation fetch failed: ${res.status} ${res.statusText}`);
131
+ }
132
+ apiData = await res.json();
133
+ } else {
134
+ return;
135
+ }
136
+ this.apiTranslations = apiData.data;
137
+ this.mergeTranslations();
138
+ this.error = null;
139
+ this.status = "ready";
140
+ this.notify();
141
+ } catch (err) {
142
+ if (err.name === "AbortError") return;
143
+ this.error = err instanceof Error ? err : new Error(String(err));
144
+ if (Object.keys(this.translations).length > 0) {
145
+ this.notify();
146
+ } else {
147
+ this.status = "error";
148
+ this.notify();
149
+ }
150
+ }
151
+ }
152
+ /**
153
+ * Translate a key with optional interpolation.
154
+ * Falls back based on the configured strategy.
155
+ */
156
+ t(key, params) {
157
+ const value = this.translations[key];
158
+ if (value !== void 0) {
159
+ return interpolate(value, params);
160
+ }
161
+ if (this.fallback === "key") return key;
162
+ if (this.fallback === "empty") return "";
163
+ return this.fallback(key);
164
+ }
165
+ /**
166
+ * Change the active locale.
167
+ * Writes to cookie, persists to API (PATCH users/me), and triggers translation refetch.
168
+ */
169
+ async setLocale(locale) {
170
+ if (locale === this.locale) return;
171
+ this.locale = locale;
172
+ setLocale(this.cookieKey, locale);
173
+ this.notify();
174
+ const promises = [];
175
+ if (this.userLanguageEndpoint !== false && (this.client || this.getAccessToken)) {
176
+ promises.push(this.persistUserLanguage(locale));
177
+ }
178
+ if (!this.disableApiFetch && (this.client || this.getAccessToken)) {
179
+ promises.push(this.fetchTranslations());
180
+ }
181
+ await Promise.all(promises);
182
+ }
183
+ /**
184
+ * Persist user language preference to API.
185
+ * PATCH {userLanguageEndpoint} { language: locale }
186
+ */
187
+ async persistUserLanguage(locale) {
188
+ if (this.userLanguageEndpoint === false) return;
189
+ try {
190
+ if (this.client) {
191
+ await this.client.patch(this.userLanguageEndpoint, { language: locale });
192
+ } else if (this.getAccessToken && this.apiUrl) {
193
+ const token = await this.getAccessToken();
194
+ if (!token) return;
195
+ const url = `${this.apiUrl.replace(/\/$/, "")}/${this.userLanguageEndpoint}`;
196
+ await fetch(url, {
197
+ method: "PATCH",
198
+ headers: {
199
+ Authorization: `Bearer ${token}`,
200
+ "Content-Type": "application/json"
201
+ },
202
+ body: JSON.stringify({ language: locale })
203
+ });
204
+ }
205
+ } catch {
206
+ }
207
+ }
208
+ /**
209
+ * Update config at runtime.
210
+ * Useful when client transitions from null → RestApiClient (auth loading complete).
211
+ */
212
+ updateConfig(updates) {
213
+ let shouldFetch = false;
214
+ if (updates.client !== void 0 && updates.client !== this.client) {
215
+ const hadNoClient = !this.client;
216
+ this.client = updates.client;
217
+ if (hadNoClient && this.client && !this.disableApiFetch) {
218
+ shouldFetch = true;
219
+ }
220
+ }
221
+ if (updates.staticTranslations !== void 0) {
222
+ this.staticTranslations = updates.staticTranslations;
223
+ this.mergeTranslations();
224
+ this.notify();
225
+ }
226
+ if (shouldFetch) {
227
+ this.fetchTranslations();
228
+ }
229
+ }
230
+ /** Cleanup: abort in-flight requests + clear listeners. */
231
+ destroy() {
232
+ this.abortController?.abort();
233
+ this.listeners.clear();
234
+ }
235
+ mergeTranslations() {
236
+ if (this.mergeStrategy === "api-first") {
237
+ this.translations = { ...this.staticTranslations, ...this.apiTranslations };
238
+ } else {
239
+ this.translations = { ...this.apiTranslations, ...this.staticTranslations };
240
+ }
241
+ }
242
+ };
243
+
244
+ export { I18nManager, getLocale, interpolate, setLocale };
245
+ //# sourceMappingURL=core-index.js.map
246
+ //# sourceMappingURL=core-index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/core/interpolation.ts","../src/core/locale-storage.ts","../src/core/i18n-manager.ts"],"names":[],"mappings":"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","file":"core-index.js","sourcesContent":["/**\n * Interpolate placeholders in a translation string.\n *\n * Supports three formats (checked in order):\n * 1. {{variable}} — double-brace (static translations convention)\n * 2. {variable} — single-brace named (API convention: \"Talk to {name} AI\")\n * 3. {0}, {1} — single-brace positional (API convention: \"Add New {0}\")\n *\n * Positional placeholders use numeric keys: params = { '0': 'User' }\n *\n * Examples:\n * interpolate('Hello, {{name}}!', { name: 'Ali' }) → 'Hello, Ali!'\n * interpolate('Talk to {name} AI', { name: 'Docyrus' }) → 'Talk to Docyrus AI'\n * interpolate('Add New {0}', { '0': 'User' }) → 'Add New User'\n */\nexport function interpolate(\n template: string,\n params?: Record<string, string | number>\n): string {\n if (!params) return template;\n\n // Match both {{key}} and {key} — double-brace first, then single-brace\n return template.replace(\n /\\{\\{(\\w+)\\}\\}|\\{(\\w+)\\}/g,\n (match, doubleBraceKey: string | undefined, singleBraceKey: string | undefined) => {\n const key = doubleBraceKey ?? singleBraceKey;\n\n if (key === undefined) return match;\n\n const value = params[key];\n\n return value !== undefined ? String(value) : match;\n }\n );\n}\n","const DEFAULT_MAX_AGE = 365 * 24 * 60 * 60; // 1 year in seconds\n\n/**\n * Read the locale from a cookie. SSR-safe.\n * Returns null if the cookie is not set or not in a browser environment.\n */\nexport function getLocale(cookieKey: string): string | null {\n if (typeof document === 'undefined') return null;\n\n const match = document.cookie.match(new RegExp(`(?:^|;\\\\s*)${escapeRegex(cookieKey)}=([^;]*)`));\n\n return match ? decodeURIComponent(match[1]) : null;\n}\n\n/**\n * Write the locale to a cookie. SSR-safe (no-op on server).\n */\nexport function setLocale(cookieKey: string, locale: string): void {\n if (typeof document === 'undefined') return;\n\n document.cookie = `${cookieKey}=${encodeURIComponent(locale)};path=/;max-age=${DEFAULT_MAX_AGE};SameSite=Lax`;\n}\n\nfunction escapeRegex(str: string): string {\n return str.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n","import { type RestApiClient } from '@docyrus/api-client';\n\nimport {\n type DocyrusI18nConfig,\n type FallbackStrategy,\n type I18nStatus,\n type TranslationDictionary,\n type TranslationsResponse\n} from '../types';\n\nimport { interpolate } from './interpolation';\nimport { getLocale, setLocale as writeLocaleCookie } from './locale-storage';\n\nexport type I18nStateListener = (state: {\n status: I18nStatus;\n locale: string | null;\n translations: TranslationDictionary;\n error: Error | null;\n}) => void;\n\n/**\n * Framework-agnostic translation engine.\n *\n * Manages locale persistence (cookie), translation fetching (API + static merge),\n * and provides the `t()` function for translation lookup with interpolation.\n *\n * Pattern follows AuthManager from @docyrus/app-auth-ui.\n */\nexport class I18nManager {\n private status: I18nStatus = 'loading';\n private locale: string | null;\n private translations: TranslationDictionary = {};\n private apiTranslations: TranslationDictionary = {};\n private error: Error | null = null;\n private listeners: Set<I18nStateListener> = new Set();\n private abortController: AbortController | null = null;\n private client: RestApiClient | null;\n private getAccessToken: (() => Promise<string | null> | string | null) | undefined;\n private apiUrl: string | undefined;\n private translationsEndpoint: string;\n private staticTranslations: TranslationDictionary;\n private cookieKey: string;\n private mergeStrategy: 'api-first' | 'static-first';\n private fallback: FallbackStrategy;\n private disableApiFetch: boolean;\n private userLanguageEndpoint: string | false;\n constructor(config: DocyrusI18nConfig) {\n this.client = config.client ?? null;\n this.getAccessToken = config.getAccessToken;\n this.apiUrl = config.apiUrl;\n this.translationsEndpoint = config.translationsEndpoint ?? 'tenant/translations';\n this.staticTranslations = config.staticTranslations ?? {};\n this.cookieKey = config.cookieKey ?? 'docyrus-locale';\n this.mergeStrategy = config.mergeStrategy ?? 'api-first';\n this.fallback = config.fallback ?? 'key';\n this.disableApiFetch = config.disableApiFetch ?? false;\n this.userLanguageEndpoint = config.userLanguageEndpoint ?? 'users/me';\n\n // Read locale from cookie (null if not set — API resolves from user profile)\n this.locale = getLocale(this.cookieKey);\n }\n getStatus(): I18nStatus {\n return this.status;\n }\n getLocale(): string | null {\n return this.locale;\n }\n getTranslations(): TranslationDictionary {\n return this.translations;\n }\n getError(): Error | null {\n return this.error;\n }\n subscribe(listener: I18nStateListener): () => void {\n this.listeners.add(listener);\n\n return () => this.listeners.delete(listener);\n }\n private notify(): void {\n for (const listener of this.listeners) {\n listener({\n status: this.status,\n locale: this.locale,\n translations: this.translations,\n error: this.error\n });\n }\n }\n /**\n * Initialize the manager.\n * 1. Apply static translations immediately (no loading flash).\n * 2. Fetch from API if enabled and auth is available.\n */\n async initialize(): Promise<void> {\n // Apply static translations immediately\n if (Object.keys(this.staticTranslations).length > 0) {\n this.translations = { ...this.staticTranslations };\n this.status = 'ready';\n this.notify();\n }\n\n // Fetch from API\n if (!this.disableApiFetch && (this.client || this.getAccessToken)) {\n await this.fetchTranslations();\n } else if (this.status !== 'ready') {\n // No static translations and no API fetch — mark ready with empty translations\n this.status = 'ready';\n this.notify();\n }\n }\n /**\n * Fetch translations from the API.\n * Uses RestApiClient.get() if client is available, otherwise raw fetch with getAccessToken.\n */\n async fetchTranslations(): Promise<void> {\n // Abort any in-flight request\n this.abortController?.abort();\n this.abortController = new AbortController();\n\n try {\n let apiData: TranslationsResponse;\n\n if (this.client) {\n apiData = await this.client.get<TranslationsResponse>(\n this.translationsEndpoint\n );\n } else if (this.getAccessToken && this.apiUrl) {\n const token = await this.getAccessToken();\n\n if (!token) {\n throw new Error('getAccessToken returned null — cannot fetch translations');\n }\n\n const url = `${this.apiUrl.replace(/\\/$/, '')}/${this.translationsEndpoint}`;\n const res = await fetch(url, {\n headers: { Authorization: `Bearer ${token}` },\n signal: this.abortController.signal\n });\n\n if (!res.ok) {\n throw new Error(`Translation fetch failed: ${res.status} ${res.statusText}`);\n }\n\n apiData = await res.json() as TranslationsResponse;\n } else {\n // No auth available yet — skip silently\n return;\n }\n\n this.apiTranslations = apiData.data;\n this.mergeTranslations();\n this.error = null;\n this.status = 'ready';\n this.notify();\n } catch (err) {\n if ((err as Error).name === 'AbortError') return;\n\n this.error = err instanceof Error ? err : new Error(String(err));\n\n // If we already have static translations, stay 'ready' with error\n if (Object.keys(this.translations).length > 0) {\n this.notify();\n } else {\n this.status = 'error';\n this.notify();\n }\n }\n }\n /**\n * Translate a key with optional interpolation.\n * Falls back based on the configured strategy.\n */\n t(key: string, params?: Record<string, string | number>): string {\n const value = this.translations[key];\n\n if (value !== undefined) {\n return interpolate(value, params);\n }\n\n // Fallback\n if (this.fallback === 'key') return key;\n if (this.fallback === 'empty') return '';\n\n return this.fallback(key);\n }\n /**\n * Change the active locale.\n * Writes to cookie, persists to API (PATCH users/me), and triggers translation refetch.\n */\n async setLocale(locale: string): Promise<void> {\n if (locale === this.locale) return;\n\n this.locale = locale;\n writeLocaleCookie(this.cookieKey, locale);\n this.notify();\n\n // Persist language preference to API + refetch translations in parallel\n const promises: Promise<void>[] = [];\n\n if (this.userLanguageEndpoint !== false && (this.client || this.getAccessToken)) {\n promises.push(this.persistUserLanguage(locale));\n }\n\n if (!this.disableApiFetch && (this.client || this.getAccessToken)) {\n promises.push(this.fetchTranslations());\n }\n\n await Promise.all(promises);\n }\n /**\n * Persist user language preference to API.\n * PATCH {userLanguageEndpoint} { language: locale }\n */\n private async persistUserLanguage(locale: string): Promise<void> {\n if (this.userLanguageEndpoint === false) return;\n\n try {\n if (this.client) {\n await this.client.patch(this.userLanguageEndpoint, { language: locale });\n } else if (this.getAccessToken && this.apiUrl) {\n const token = await this.getAccessToken();\n\n if (!token) return;\n\n const url = `${this.apiUrl.replace(/\\/$/, '')}/${this.userLanguageEndpoint}`;\n\n await fetch(url, {\n method: 'PATCH',\n headers: {\n Authorization: `Bearer ${token}`,\n 'Content-Type': 'application/json'\n },\n body: JSON.stringify({ language: locale })\n });\n }\n } catch {\n // Language persist is best-effort — don't block locale switch on failure\n }\n }\n /**\n * Update config at runtime.\n * Useful when client transitions from null → RestApiClient (auth loading complete).\n */\n updateConfig(updates: Partial<Pick<DocyrusI18nConfig, 'client' | 'staticTranslations'>>): void {\n let shouldFetch = false;\n\n if (updates.client !== undefined && updates.client !== this.client) {\n const hadNoClient = !this.client;\n\n this.client = updates.client;\n\n // Client became available — fetch translations\n if (hadNoClient && this.client && !this.disableApiFetch) {\n shouldFetch = true;\n }\n }\n\n if (updates.staticTranslations !== undefined) {\n this.staticTranslations = updates.staticTranslations;\n this.mergeTranslations();\n this.notify();\n }\n\n if (shouldFetch) {\n this.fetchTranslations();\n }\n }\n /** Cleanup: abort in-flight requests + clear listeners. */\n destroy(): void {\n this.abortController?.abort();\n this.listeners.clear();\n }\n private mergeTranslations(): void {\n if (this.mergeStrategy === 'api-first') {\n this.translations = { ...this.staticTranslations, ...this.apiTranslations };\n } else {\n this.translations = { ...this.apiTranslations, ...this.staticTranslations };\n }\n }\n}\n"]}