@ozsarman/clarityjs 0.6.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/src/i18n.js ADDED
@@ -0,0 +1,403 @@
1
+ /**
2
+ * Clarity.js — Internationalization (i18n)
3
+ *
4
+ * Lightweight, signal-based i18n with first-class RTL support.
5
+ *
6
+ * Features:
7
+ * - Reactive locale / translation signals
8
+ * - Pluralisation (CLDR-style: zero | one | two | few | many | other)
9
+ * - Nested key paths: t('user.profile.title')
10
+ * - Interpolation: t('greeting', { name: 'Özdemir' }) → "Hello, Özdemir!"
11
+ * - RTL detection + automatic <html dir="rtl|ltr"> update
12
+ * - isRTL() signal — drive CSS logical properties in components
13
+ * - Currency / date / number formatters via Intl
14
+ * - Lazy locale loading: loadLocale(code, () => import('./locales/ar.js'))
15
+ * - SSR-safe: no DOM dependency at module level
16
+ *
17
+ * Usage:
18
+ * import { createI18n, useI18n, t, locale, isRTL } from '@ozsarman/clarityjs/i18n';
19
+ *
20
+ * const i18n = createI18n({
21
+ * locale: 'en',
22
+ * messages: {
23
+ * en: { greeting: 'Hello, {name}!', items: { one: '{count} item', other: '{count} items' } },
24
+ * ar: { greeting: 'مرحباً، {name}!', items: { one: '{count} عنصر', other: '{count} عناصر' } },
25
+ * },
26
+ * });
27
+ *
28
+ * // In a component:
29
+ * const { t, locale, isRTL } = useI18n();
30
+ * t('greeting', { name: 'Özdemir' }); // "Hello, Özdemir!"
31
+ * locale.set('ar'); // switches to Arabic → sets dir="rtl"
32
+ * isRTL.get(); // true
33
+ *
34
+ * Author: Claude (Anthropic) + Özdemir Sarman
35
+ */
36
+
37
+ import { signal, effect } from './runtime.js';
38
+
39
+ // ─── RTL locale list (ISO 639-1 + regional codes) ─────────────────────────────
40
+
41
+ /**
42
+ * Languages written right-to-left.
43
+ * Includes all major RTL scripts: Arabic, Hebrew, Persian, Urdu, …
44
+ */
45
+ export const RTL_LOCALES = new Set([
46
+ // Arabic
47
+ 'ar', 'ar-AE', 'ar-BH', 'ar-DZ', 'ar-EG', 'ar-IQ', 'ar-JO', 'ar-KW',
48
+ 'ar-LB', 'ar-LY', 'ar-MA', 'ar-OM', 'ar-QA', 'ar-SA', 'ar-SY', 'ar-TN',
49
+ 'ar-YE',
50
+ // Hebrew
51
+ 'he', 'he-IL',
52
+ // Persian / Farsi
53
+ 'fa', 'fa-IR', 'fa-AF',
54
+ // Urdu
55
+ 'ur', 'ur-PK', 'ur-IN',
56
+ // Sindhi
57
+ 'sd',
58
+ // Pashto
59
+ 'ps', 'ps-AF',
60
+ // Kurdish (Sorani)
61
+ 'ckb', 'ku',
62
+ // Dhivehi (Maldivian)
63
+ 'dv',
64
+ // Yiddish
65
+ 'yi',
66
+ // Azerbaijani (Arabic script variant)
67
+ 'az-Arab',
68
+ // Kashmiri
69
+ 'ks',
70
+ // Uyghur
71
+ 'ug',
72
+ ]);
73
+
74
+ /**
75
+ * Returns true if the given locale code is a known RTL language.
76
+ *
77
+ * @param {string} localeCode e.g. 'ar', 'he-IL', 'en-US'
78
+ * @returns {boolean}
79
+ */
80
+ export function isRTLLocale(localeCode) {
81
+ if (!localeCode) return false;
82
+ if (RTL_LOCALES.has(localeCode)) return true;
83
+ // Match on language subtag: 'ar-EG' → 'ar'
84
+ const lang = localeCode.split('-')[0].toLowerCase();
85
+ return RTL_LOCALES.has(lang);
86
+ }
87
+
88
+ // ─── Global i18n singleton ────────────────────────────────────────────────────
89
+
90
+ let _globalI18n = null;
91
+
92
+ /**
93
+ * Create (and register globally) an i18n instance.
94
+ *
95
+ * @param {object} config
96
+ * @param {string} config.locale - Initial locale code (e.g. 'en')
97
+ * @param {object} config.messages - { [locale]: { [key]: string | object } }
98
+ * @param {string} [config.fallbackLocale] - Fallback when a key is missing
99
+ * @param {boolean} [config.updateHtmlDir=true] - Auto-set <html dir> on locale change
100
+ * @param {boolean} [config.ssr=false] - Disable DOM side-effects (for SSR)
101
+ * @returns {I18nInstance}
102
+ */
103
+ export function createI18n(config = {}) {
104
+ const instance = new I18nInstance(config);
105
+ _globalI18n = instance;
106
+ return instance;
107
+ }
108
+
109
+ /**
110
+ * Access the global i18n instance (must call createI18n first).
111
+ *
112
+ * @returns {I18nInstance}
113
+ */
114
+ export function useI18n() {
115
+ if (!_globalI18n) {
116
+ throw new Error(
117
+ '[Clarity i18n] No i18n instance found.\n' +
118
+ 'Call createI18n({ locale, messages }) before using useI18n().'
119
+ );
120
+ }
121
+ return _globalI18n;
122
+ }
123
+
124
+ // ─── Convenience top-level exports ───────────────────────────────────────────
125
+
126
+ /** Reactive translation function (delegates to global instance). */
127
+ export function t(key, vars) {
128
+ return _globalI18n?.t(key, vars) ?? key;
129
+ }
130
+
131
+ /** Reactive locale signal (read: locale.get(), write: locale.set('fr')). */
132
+ export function getLocale() {
133
+ return _globalI18n?.locale ?? signal('en');
134
+ }
135
+
136
+ /** Reactive isRTL signal — true when current locale is RTL. */
137
+ export function getIsRTL() {
138
+ return _globalI18n?.isRTL ?? signal(false);
139
+ }
140
+
141
+ // ─── I18nInstance ─────────────────────────────────────────────────────────────
142
+
143
+ class I18nInstance {
144
+ constructor({
145
+ locale: initialLocale = 'en',
146
+ messages = {},
147
+ fallbackLocale,
148
+ updateHtmlDir = true,
149
+ ssr = false,
150
+ } = {}) {
151
+ this._messages = { ...messages };
152
+ this._fallback = fallbackLocale;
153
+ this._ssr = ssr;
154
+ this._loaders = new Map(); // locale → () => Promise<messages>
155
+ this._loaded = new Set(Object.keys(messages));
156
+
157
+ /** @type {Signal<string>} */
158
+ this.locale = signal(initialLocale);
159
+
160
+ /** @type {Signal<boolean>} */
161
+ this.isRTL = signal(isRTLLocale(initialLocale));
162
+
163
+ /** @type {Signal<string>} Convenience alias: 'ltr' | 'rtl' */
164
+ this.dir = signal(isRTLLocale(initialLocale) ? 'rtl' : 'ltr');
165
+
166
+ // Keep isRTL and dir in sync with locale
167
+ effect(() => {
168
+ const code = this.locale.get();
169
+ const rtl = isRTLLocale(code);
170
+ this.isRTL.set(rtl);
171
+ this.dir.set(rtl ? 'rtl' : 'ltr');
172
+
173
+ // Update <html dir="…"> automatically
174
+ if (updateHtmlDir && !ssr && typeof document !== 'undefined') {
175
+ document.documentElement.setAttribute('dir', rtl ? 'rtl' : 'ltr');
176
+ document.documentElement.setAttribute('lang', code);
177
+ }
178
+ });
179
+ }
180
+
181
+ // ── Core translation ────────────────────────────────────────────────────────
182
+
183
+ /**
184
+ * Translate a key in the current locale.
185
+ *
186
+ * @param {string} key Dot-separated key path, e.g. 'user.profile.title'
187
+ * @param {object} [vars] Interpolation variables + optional `count` for pluralisation
188
+ * @returns {string}
189
+ */
190
+ t(key, vars = {}) {
191
+ const code = this.locale.get(); // reactive dependency
192
+ const msg = this._resolve(code, key, vars)
193
+ ?? (this._fallback ? this._resolve(this._fallback, key, vars) : null)
194
+ ?? key;
195
+ return msg;
196
+ }
197
+
198
+ /**
199
+ * Translate in a specific locale (non-reactive).
200
+ */
201
+ tIn(localeCode, key, vars = {}) {
202
+ return this._resolve(localeCode, key, vars)
203
+ ?? (this._fallback ? this._resolve(this._fallback, key, vars) : null)
204
+ ?? key;
205
+ }
206
+
207
+ // ── Locale management ───────────────────────────────────────────────────────
208
+
209
+ /**
210
+ * Change the active locale, loading it lazily if needed.
211
+ *
212
+ * @param {string} code e.g. 'fr', 'ar', 'zh-TW'
213
+ * @returns {Promise<void>}
214
+ */
215
+ async setLocale(code) {
216
+ if (!this._loaded.has(code) && this._loaders.has(code)) {
217
+ await this._lazyLoad(code);
218
+ }
219
+ this.locale.set(code);
220
+ }
221
+
222
+ /**
223
+ * Register a lazy locale loader.
224
+ *
225
+ * @param {string} code Locale code
226
+ * @param {function} loader () => Promise<{ default: messages } | messages>
227
+ */
228
+ loadLocale(code, loader) {
229
+ this._loaders.set(code, loader);
230
+ return this;
231
+ }
232
+
233
+ /**
234
+ * Add or merge message keys for a locale.
235
+ */
236
+ addMessages(code, msgs) {
237
+ this._messages[code] = _deepMerge(this._messages[code] ?? {}, msgs);
238
+ this._loaded.add(code);
239
+ return this;
240
+ }
241
+
242
+ /**
243
+ * Returns all registered locale codes.
244
+ */
245
+ availableLocales() {
246
+ return [...new Set([...Object.keys(this._messages), ...this._loaders.keys()])];
247
+ }
248
+
249
+ // ── Intl formatters ─────────────────────────────────────────────────────────
250
+
251
+ /**
252
+ * Format a number using Intl.NumberFormat.
253
+ *
254
+ * @param {number} value
255
+ * @param {object} [opts] Intl.NumberFormat options
256
+ * @returns {string}
257
+ */
258
+ n(value, opts = {}) {
259
+ const code = this.locale.get();
260
+ return new Intl.NumberFormat(code, opts).format(value);
261
+ }
262
+
263
+ /**
264
+ * Format a currency amount.
265
+ *
266
+ * @param {number} value
267
+ * @param {string} currency ISO 4217 code, e.g. 'USD', 'EUR', 'TRY'
268
+ * @param {object} [opts] Additional Intl.NumberFormat options
269
+ * @returns {string}
270
+ */
271
+ currency(value, currency, opts = {}) {
272
+ const code = this.locale.get();
273
+ return new Intl.NumberFormat(code, { style: 'currency', currency, ...opts }).format(value);
274
+ }
275
+
276
+ /**
277
+ * Format a date.
278
+ *
279
+ * @param {Date|number|string} value
280
+ * @param {object} [opts] Intl.DateTimeFormat options
281
+ * @returns {string}
282
+ */
283
+ d(value, opts = {}) {
284
+ const code = this.locale.get();
285
+ return new Intl.DateTimeFormat(code, opts).format(new Date(value));
286
+ }
287
+
288
+ /**
289
+ * Format a relative time (e.g. "3 hours ago").
290
+ *
291
+ * @param {number} value Numeric value (negative = past, positive = future)
292
+ * @param {string} unit 'second'|'minute'|'hour'|'day'|'week'|'month'|'year'
293
+ * @param {object} [opts] Intl.RelativeTimeFormat options
294
+ * @returns {string}
295
+ */
296
+ relative(value, unit = 'day', opts = {}) {
297
+ const code = this.locale.get();
298
+ return new Intl.RelativeTimeFormat(code, { numeric: 'auto', ...opts }).format(value, unit);
299
+ }
300
+
301
+ // ── Private ─────────────────────────────────────────────────────────────────
302
+
303
+ _resolve(code, key, vars) {
304
+ const messages = this._messages[code];
305
+ if (!messages) return null;
306
+
307
+ // Traverse dot-separated key path
308
+ const parts = key.split('.');
309
+ let node = messages;
310
+ for (const part of parts) {
311
+ if (node == null || typeof node !== 'object') return null;
312
+ node = node[part];
313
+ }
314
+
315
+ if (node == null) return null;
316
+
317
+ // Pluralisation: if node is an object with plural keys
318
+ if (typeof node === 'object') {
319
+ node = this._pluralize(code, node, vars.count);
320
+ if (node == null) return null;
321
+ }
322
+
323
+ // Interpolation: replace {varName} placeholders
324
+ return _interpolate(String(node), vars);
325
+ }
326
+
327
+ _pluralize(code, forms, count) {
328
+ if (count === undefined || count === null) {
329
+ return forms.other ?? forms.one ?? Object.values(forms)[0] ?? null;
330
+ }
331
+
332
+ // Use CLDR plural rules via Intl.PluralRules
333
+ try {
334
+ const rule = new Intl.PluralRules(code).select(count);
335
+ return forms[rule] ?? forms.other ?? Object.values(forms)[0] ?? null;
336
+ } catch {
337
+ // Fallback: simple singular/plural
338
+ return count === 1 ? (forms.one ?? forms.other) : forms.other;
339
+ }
340
+ }
341
+
342
+ async _lazyLoad(code) {
343
+ const loader = this._loaders.get(code);
344
+ if (!loader) return;
345
+ try {
346
+ let msgs = await loader();
347
+ // Support both { default: msgs } and direct object
348
+ if (msgs?.default && typeof msgs.default === 'object') msgs = msgs.default;
349
+ this.addMessages(code, msgs);
350
+ } catch (e) {
351
+ console.error(`[Clarity i18n] Failed to load locale "${code}":`, e);
352
+ }
353
+ }
354
+ }
355
+
356
+ // ─── RTL utilities ────────────────────────────────────────────────────────────
357
+
358
+ /**
359
+ * Apply dir="rtl|ltr" to any element (useful for portals / shadow roots).
360
+ *
361
+ * @param {Element} el
362
+ * @param {boolean} rtl
363
+ */
364
+ export function applyDir(el, rtl) {
365
+ if (!el) return;
366
+ el.setAttribute('dir', rtl ? 'rtl' : 'ltr');
367
+ }
368
+
369
+ /**
370
+ * A Clarity component that wraps children in a dir-aware container.
371
+ * Use for mixed-direction content.
372
+ *
373
+ * @param {object} props
374
+ * @param {boolean} props.rtl - Force RTL direction
375
+ * @param {*} props.children
376
+ * @returns {HTMLElement}
377
+ */
378
+ export function BiDiWrapper({ rtl, children }) {
379
+ const el = document.createElement('div');
380
+ el.setAttribute('dir', rtl ? 'rtl' : 'ltr');
381
+ const nodes = Array.isArray(children) ? children : [children];
382
+ nodes.forEach(child => child && el.appendChild(child));
383
+ return el;
384
+ }
385
+
386
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
387
+
388
+ function _interpolate(template, vars) {
389
+ return template.replace(/\{(\w+)\}/g, (_, key) => {
390
+ const val = vars[key];
391
+ return val !== undefined ? String(val) : `{${key}}`;
392
+ });
393
+ }
394
+
395
+ function _deepMerge(target, source) {
396
+ const result = { ...target };
397
+ for (const [k, v] of Object.entries(source)) {
398
+ result[k] = (v && typeof v === 'object' && !Array.isArray(v) && typeof result[k] === 'object')
399
+ ? _deepMerge(result[k], v)
400
+ : v;
401
+ }
402
+ return result;
403
+ }