@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/README.md +178 -0
- package/package.json +168 -0
- package/src/analyze.js +534 -0
- package/src/async-state.js +555 -0
- package/src/bundle-runtime.js +35 -0
- package/src/clarity-bundle.js +332 -0
- package/src/clarity-test.js +622 -0
- package/src/cli.js +453 -0
- package/src/codegen.js +1934 -0
- package/src/dev-server.js +362 -0
- package/src/devtools.js +765 -0
- package/src/edge.js +606 -0
- package/src/error-overlay.js +535 -0
- package/src/file-conventions.js +472 -0
- package/src/font.js +513 -0
- package/src/game-loop.js +106 -0
- package/src/head.js +393 -0
- package/src/hydrate.js +292 -0
- package/src/i18n.js +403 -0
- package/src/image.js +352 -0
- package/src/index.js +193 -0
- package/src/islands.js +284 -0
- package/src/isr.js +306 -0
- package/src/layout.js +342 -0
- package/src/lexer.js +572 -0
- package/src/linter.js +547 -0
- package/src/pages-router.js +229 -0
- package/src/parser.js +1108 -0
- package/src/router.js +732 -0
- package/src/runtime.js +1465 -0
- package/src/scoped-css.js +641 -0
- package/src/server-actions.js +439 -0
- package/src/server-data.js +225 -0
- package/src/sourcemap.js +130 -0
- package/src/ssg.js +310 -0
- package/src/ssr.js +621 -0
- package/src/store.js +276 -0
- package/src/transitions.js +438 -0
- package/src/ts-plugin.js +613 -0
- package/src/typegen.js +240 -0
- package/src/vite-plugin.js +447 -0
- package/types/index.d.ts +366 -0
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
|
+
}
|