locon 1.4.6 → 1.5.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 +497 -41
- package/dist/index.d.ts +187 -10
- package/dist/index.js +632 -75
- package/package.json +27 -10
- package/skills/locon-sync/SKILL.md +158 -0
- package/skills/locon-sync/agents/openai.yaml +4 -0
- package/skills/locon-sync/scripts/check-locon-assets.mjs +594 -0
- package/skills/locon-sync/scripts/write_locale.py +229 -0
- package/dist/components/LText.d.mts +0 -10
- package/dist/components/LText.d.ts +0 -10
- package/dist/components/LText.js +0 -41
- package/dist/components/LText.mjs +0 -11
- package/dist/hooks/useLocon.d.mts +0 -6
- package/dist/hooks/useLocon.d.ts +0 -6
- package/dist/hooks/useLocon.js +0 -30
- package/dist/hooks/useLocon.mjs +0 -10
- package/dist/index.d.mts +0 -19
- package/dist/index.mjs +0 -104
- package/dist/types/Assets.d.mts +0 -3
- package/dist/types/Assets.d.ts +0 -3
- package/dist/types/Assets.js +0 -16
- package/dist/types/Assets.mjs +0 -0
- package/dist/types/LoconContext.d.mts +0 -12
- package/dist/types/LoconContext.d.ts +0 -12
- package/dist/types/LoconContext.js +0 -16
- package/dist/types/LoconContext.mjs +0 -0
- package/dist/utils/getSystemLanguage.d.mts +0 -7
- package/dist/utils/getSystemLanguage.d.ts +0 -7
- package/dist/utils/getSystemLanguage.js +0 -125
- package/dist/utils/getSystemLanguage.mjs +0 -105
package/README.md
CHANGED
|
@@ -25,10 +25,15 @@ function Example() {
|
|
|
25
25
|
### Features
|
|
26
26
|
|
|
27
27
|
- **React Native–first**: tiny API, no global singletons
|
|
28
|
+
- **Expo-ready**: detects the device language through `expo-localization`, no native linking
|
|
28
29
|
- **Context provider** with pluggable assets (per-locale JSON)
|
|
29
30
|
- **Device language auto-detection** (optional)
|
|
30
31
|
- **`useLocon()` hook** for accessing translations and changing locale
|
|
31
32
|
- **`<LText>` component** as a drop-in localized `<Text>`
|
|
33
|
+
- **Interpolation and plurals** via `{token}` params and CLDR plural categories
|
|
34
|
+
- **Locale-scoped lookups** with `lIn()` / `createTranslator()` — render a PDF in one language while the UI is in another
|
|
35
|
+
- **RTL aware**: `isRTL` on the context, plus an explicit `applyRTL()` helper
|
|
36
|
+
- **Locale utilities** — `resolveLocale()`, `getSystemLanguage()` and `intlLocale()`, usable without a provider
|
|
32
37
|
- Fully typed TypeScript build (`dist/index.d.ts`)
|
|
33
38
|
|
|
34
39
|
---
|
|
@@ -49,33 +54,69 @@ yarn add locon
|
|
|
49
54
|
bun add locon
|
|
50
55
|
```
|
|
51
56
|
|
|
52
|
-
**Important:** After installation, run pod install for iOS:
|
|
53
|
-
|
|
54
|
-
```bash
|
|
55
|
-
cd ios && pod install
|
|
56
|
-
```
|
|
57
|
-
|
|
58
57
|
**Peer dependencies**
|
|
59
58
|
|
|
60
59
|
- `react >= 19.0.0`
|
|
61
60
|
- `react-native >= 0.70.0`
|
|
62
|
-
-
|
|
61
|
+
- **one** of the following, if you want native device-language detection:
|
|
62
|
+
- `expo-localization >= 14.0.0` — for Expo apps
|
|
63
|
+
- `react-native-localize 3.x` — for bare React Native apps
|
|
63
64
|
|
|
64
|
-
|
|
65
|
-
|
|
65
|
+
Both are optional. Without either, `locon` falls back to React Native's own
|
|
66
|
+
`NativeModules` and then to the `Intl` API, which is usually enough to get the
|
|
67
|
+
language right but ignores the user's ordered language preferences.
|
|
68
|
+
|
|
69
|
+
In a bare React Native app, install `react-native-localize` and run pods:
|
|
66
70
|
|
|
67
71
|
```bash
|
|
68
72
|
yarn add react-native-localize
|
|
69
|
-
|
|
70
|
-
npm install react-native-localize
|
|
73
|
+
cd ios && pod install
|
|
71
74
|
```
|
|
72
75
|
|
|
73
|
-
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
### Use with Expo
|
|
79
|
+
|
|
80
|
+
`locon` needs no native module of its own, so it works in the managed
|
|
81
|
+
workflow, in a dev client, and in Expo Go.
|
|
74
82
|
|
|
75
83
|
```bash
|
|
76
|
-
|
|
84
|
+
npx expo install expo-localization
|
|
77
85
|
```
|
|
78
86
|
|
|
87
|
+
That is the whole integration — detection picks `expo-localization` up
|
|
88
|
+
automatically when it is installed.
|
|
89
|
+
|
|
90
|
+
To make the languages you ship visible to the operating system and the stores,
|
|
91
|
+
declare them in `app.json`. This is what puts your app in the App Store's
|
|
92
|
+
"Languages" list and in Android 13+'s per-app language picker:
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
{
|
|
96
|
+
"expo": {
|
|
97
|
+
"plugins": [
|
|
98
|
+
[
|
|
99
|
+
"expo-localization",
|
|
100
|
+
{
|
|
101
|
+
"supportedLocales": {
|
|
102
|
+
"ios": ["de", "en", "fr", "ar"],
|
|
103
|
+
"android": ["de", "en", "fr", "ar"]
|
|
104
|
+
},
|
|
105
|
+
"supportsRTL": true,
|
|
106
|
+
"allowDynamicLocaleChangesAndroid": true
|
|
107
|
+
}
|
|
108
|
+
]
|
|
109
|
+
]
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Then run `npx expo prebuild` so the config lands in the native projects.
|
|
115
|
+
|
|
116
|
+
> The locales you list here are independent of the store listing languages
|
|
117
|
+
> Apple and Google support: shipping a bundled translation is always allowed,
|
|
118
|
+
> even for a language whose store page you cannot localize.
|
|
119
|
+
|
|
79
120
|
---
|
|
80
121
|
|
|
81
122
|
### Quick start
|
|
@@ -113,6 +154,8 @@ import en from './assets/i18n/en.json'
|
|
|
113
154
|
import de from './assets/i18n/de.json'
|
|
114
155
|
import RootNavigator from './RootNavigator'
|
|
115
156
|
|
|
157
|
+
const assets = { en, de }
|
|
158
|
+
|
|
116
159
|
function App() {
|
|
117
160
|
const theme = {
|
|
118
161
|
...DefaultTheme,
|
|
@@ -124,7 +167,7 @@ function App() {
|
|
|
124
167
|
|
|
125
168
|
return (
|
|
126
169
|
<Locon
|
|
127
|
-
assets={
|
|
170
|
+
assets={assets}
|
|
128
171
|
projectLocale='de'
|
|
129
172
|
>
|
|
130
173
|
<NavigationContainer theme={theme}>
|
|
@@ -140,12 +183,18 @@ export default App
|
|
|
140
183
|
If `currentLocale` is not provided and `autodetect` is `true` (default),
|
|
141
184
|
`locon` will:
|
|
142
185
|
|
|
143
|
-
1.
|
|
144
|
-
|
|
145
|
-
|
|
186
|
+
1. Read the device's preferred languages from these sources in order —
|
|
187
|
+
`expo-localization`, then `react-native-localize`, then React Native's
|
|
188
|
+
`NativeModules`, then `Intl`
|
|
189
|
+
2. Match each one against your `assets`, widening from the normalized exact
|
|
190
|
+
tag (`pt_BR` → `pt-BR`), to the same script — explicit or inferred from
|
|
191
|
+
region (`zh-Hant-TW` / `zh-TW` → `zh-Hant`) — to a bare language
|
|
192
|
+
(`de-AT` → `de`), and finally to any locale in the same language
|
|
193
|
+
3. If a match is found, use it as the initial locale
|
|
146
194
|
4. Otherwise, fall back to `defaultLocale`
|
|
147
195
|
|
|
148
|
-
|
|
196
|
+
Device order is preference order, so a user whose second language you ship
|
|
197
|
+
gets that rather than your default.
|
|
149
198
|
|
|
150
199
|
#### 3. Use the `useLocon()` hook
|
|
151
200
|
|
|
@@ -159,11 +208,7 @@ function LanguageSwitcher() {
|
|
|
159
208
|
|
|
160
209
|
return (
|
|
161
210
|
<View>
|
|
162
|
-
{/*
|
|
163
|
-
<Text>
|
|
164
|
-
{l('current_language')}: {currentLocale}
|
|
165
|
-
</Text>
|
|
166
|
-
{/* or by value in project- or default language */}
|
|
211
|
+
{/* Resolve by the value written in the project language. */}
|
|
167
212
|
<Text>
|
|
168
213
|
{l('Current language')}: {currentLocale}
|
|
169
214
|
</Text>
|
|
@@ -209,7 +254,7 @@ function Welcome() {
|
|
|
209
254
|
```ts
|
|
210
255
|
interface LoconProps extends PropsWithChildren {
|
|
211
256
|
assets?: Record<string, Record<string, string>>
|
|
212
|
-
currentLocale?: string
|
|
257
|
+
currentLocale?: string | null
|
|
213
258
|
defaultLocale?: string
|
|
214
259
|
projectLocale?: string
|
|
215
260
|
autodetect?: boolean
|
|
@@ -217,10 +262,27 @@ interface LoconProps extends PropsWithChildren {
|
|
|
217
262
|
```
|
|
218
263
|
|
|
219
264
|
- **`assets`**
|
|
220
|
-
Map of locale → flat translations, e.g. `{ en: { hello: 'Hello' }, de: { hello: 'Hallo' } }`
|
|
265
|
+
Map of locale → flat translations, e.g. `{ en: { hello: 'Hello' }, de: { hello: 'Hallo' } }`
|
|
266
|
+
Keep this object stable (module constant or `useMemo`) — `locon` caches its
|
|
267
|
+
value→key index against the object's identity.
|
|
221
268
|
|
|
222
269
|
- **`currentLocale`**
|
|
223
|
-
|
|
270
|
+
Which locale to show:
|
|
271
|
+
|
|
272
|
+
- a **string** selects that locale and overrides auto-detection; if the prop
|
|
273
|
+
later changes, the provider follows the new value
|
|
274
|
+
- **`null`** explicitly follows the device language
|
|
275
|
+
- **omitted** leaves the locale uncontrolled after the initial detection
|
|
276
|
+
|
|
277
|
+
The `null` form is what apps with a "System" option in their language picker
|
|
278
|
+
want: persist the user's choice as `string | null` and pass it straight
|
|
279
|
+
through, and switching back to "System" re-detects immediately instead of
|
|
280
|
+
waiting for a restart.
|
|
281
|
+
|
|
282
|
+
`currentLocale` is not a fully controlled React value: `setLocale()` can
|
|
283
|
+
still change the provider after the initial prop is applied. A later change
|
|
284
|
+
to the prop takes precedence again. This preserves the imperative API used
|
|
285
|
+
by existing apps.
|
|
224
286
|
|
|
225
287
|
- **`defaultLocale`** (default `'en'`)
|
|
226
288
|
Used when key is missing in the current locale.
|
|
@@ -239,21 +301,117 @@ interface LoconProps extends PropsWithChildren {
|
|
|
239
301
|
import { useLocon } from 'locon'
|
|
240
302
|
|
|
241
303
|
const {
|
|
242
|
-
l, // (key/value: string) => string
|
|
304
|
+
l, // (key/value: string, options?: TranslateOptions) => string
|
|
305
|
+
lIn, // (locale: string, key/value: string, options?) => string
|
|
243
306
|
assets, // all assets map
|
|
307
|
+
locales, // every locale present in assets
|
|
244
308
|
currentLocale, // current language code
|
|
245
309
|
defaultLocale, // default language code
|
|
310
|
+
projectLocale, // language your source strings are written in
|
|
311
|
+
systemLocale, // locale detected from the device, or null
|
|
312
|
+
isRTL, // whether currentLocale is written right-to-left
|
|
246
313
|
autodetect, // boolean
|
|
247
|
-
setLocale, // (locale: string) => void
|
|
314
|
+
setLocale, // (locale: string | null) => void
|
|
248
315
|
} = useLocon()
|
|
249
316
|
```
|
|
250
317
|
|
|
251
|
-
- `l(key)`
|
|
318
|
+
- `l(key, options?)`
|
|
252
319
|
Returns the localized string or the key itself if nothing was found.
|
|
253
320
|
|
|
321
|
+
- `lIn(locale, key, options?)`
|
|
322
|
+
Same resolution, forced into `locale`. Handy for rendering one screen
|
|
323
|
+
section — or one exported document — in another language.
|
|
324
|
+
|
|
254
325
|
- `setLocale(locale)`
|
|
255
326
|
Switches to `locale` if it exists in `assets`, otherwise logs a warning
|
|
256
|
-
in development.
|
|
327
|
+
in development. Pass `null` to go back to following the device language.
|
|
328
|
+
|
|
329
|
+
#### `TranslateOptions`
|
|
330
|
+
|
|
331
|
+
```ts
|
|
332
|
+
interface TranslateOptions {
|
|
333
|
+
params?: Record<string, string | number>
|
|
334
|
+
count?: number
|
|
335
|
+
locale?: string
|
|
336
|
+
fallback?: string
|
|
337
|
+
}
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
**Interpolation** — `{token}` placeholders are replaced from `params`.
|
|
341
|
+
Unknown tokens are left as-is rather than blanked:
|
|
342
|
+
|
|
343
|
+
```json
|
|
344
|
+
{ "saved_radius": "{m} m radius · saved" }
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
```tsx
|
|
348
|
+
l('{m} m radius · saved', { params: { m: 150 } }) // → '150 m radius · saved'
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
**Fallback** — `fallback` is returned only when no locale resolves the input.
|
|
352
|
+
`<LText assetKey='…'>children</LText>` uses this internally so a missing key
|
|
353
|
+
shows its human-readable children rather than the raw key.
|
|
354
|
+
|
|
355
|
+
**Plurals** — pass `count` and suffix your keys with the CLDR plural
|
|
356
|
+
_category_, which is exactly what `Intl.PluralRules` returns for that locale.
|
|
357
|
+
`{count}` is interpolated for free.
|
|
358
|
+
When `params` also contains a `count` key, the dedicated `count` option wins so
|
|
359
|
+
the rendered number always agrees with the selected plural form.
|
|
360
|
+
|
|
361
|
+
Locon delegates category selection to the runtime's `Intl.PluralRules`:
|
|
362
|
+
|
|
363
|
+
```ts
|
|
364
|
+
const ruPlural = new Intl.PluralRules('ru')
|
|
365
|
+
|
|
366
|
+
ruPlural.select(21) // → 'one'
|
|
367
|
+
ruPlural.select(22) // → 'few'
|
|
368
|
+
ruPlural.select(25) // → 'many'
|
|
369
|
+
ruPlural.select(1.5) // → 'other'
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
See the [ECMA-402 `Intl.PluralRules.select()` specification](https://tc39.es/ecma402/#sec-intl.pluralrules.prototype.select)
|
|
373
|
+
and [Unicode CLDR's Russian plural rules](https://unicode.org/cldr/charts/49/verify/numbers/ru.html)
|
|
374
|
+
for the exact language-specific rules and examples.
|
|
375
|
+
|
|
376
|
+
These are all six possible suffixes. The count examples are locale-specific,
|
|
377
|
+
not universal rules:
|
|
378
|
+
|
|
379
|
+
| Suffix | Example selection | Example value |
|
|
380
|
+
| -------- | ---------------------------------------- | ---------------- |
|
|
381
|
+
| `_zero` | Arabic `0` | `"لا أيام"` |
|
|
382
|
+
| `_one` | English `1` | `"{count} day"` |
|
|
383
|
+
| `_two` | Arabic `2` | `"يومان"` |
|
|
384
|
+
| `_few` | Russian `2–4`, except `12–14` | `"{count} дня"` |
|
|
385
|
+
| `_many` | Russian `0`, `5–20`, `25–30`, and so on | `"{count} дней"` |
|
|
386
|
+
| `_other` | English `0` and `2+`; decimals; fallback | `"{count} days"` |
|
|
387
|
+
|
|
388
|
+
Always provide `_other`. It is CLDR's catch-all category and Locon's first
|
|
389
|
+
fallback when a locale-specific category is absent.
|
|
390
|
+
|
|
391
|
+
```json
|
|
392
|
+
// en.json — two forms
|
|
393
|
+
{ "day_one": "{count} day", "day_other": "{count} days" }
|
|
394
|
+
|
|
395
|
+
// ru.json — four categories (three for integers, plus _other for decimals)
|
|
396
|
+
{ "day_one": "{count} день", "day_few": "{count} дня", "day_many": "{count} дней", "day_other": "{count} дня" }
|
|
397
|
+
|
|
398
|
+
// ar.json — all six
|
|
399
|
+
{
|
|
400
|
+
"day_zero": "لا أيام",
|
|
401
|
+
"day_one": "يوم واحد",
|
|
402
|
+
"day_two": "يومان",
|
|
403
|
+
"day_few": "{count} أيام",
|
|
404
|
+
"day_many": "{count} يومًا",
|
|
405
|
+
"day_other": "{count} يوم"
|
|
406
|
+
}
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
```tsx
|
|
410
|
+
l('{count} days', { count: 5 }) // en → '5 days' · ru → '5 дней'
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
There is deliberately no generic `_plural` suffix: most languages do not have
|
|
414
|
+
a single plural form. Categories fall back to `_other`, then to the bare key.
|
|
257
415
|
|
|
258
416
|
#### `<LText />`
|
|
259
417
|
|
|
@@ -261,19 +419,293 @@ const {
|
|
|
261
419
|
interface LTextProps extends ComponentProps<typeof Text> {
|
|
262
420
|
children: string
|
|
263
421
|
assetKey?: string
|
|
422
|
+
params?: Record<string, string | number>
|
|
423
|
+
count?: number
|
|
424
|
+
locale?: string
|
|
264
425
|
}
|
|
265
426
|
```
|
|
266
427
|
|
|
267
|
-
- If `assetKey` is provided, `LText` uses it directly as the lookup key
|
|
428
|
+
- If `assetKey` is provided, `LText` uses it directly as the lookup key and
|
|
429
|
+
renders `children` when that key is missing in every fallback locale.
|
|
268
430
|
- Otherwise, it uses `children` as an input to the same resolution logic
|
|
269
431
|
as `l()`:
|
|
270
|
-
1.
|
|
271
|
-
|
|
272
|
-
2.
|
|
273
|
-
|
|
274
|
-
3.
|
|
432
|
+
1. If `children` is a key in the target, default, or project locale, use it
|
|
433
|
+
directly (the backward-compatible key-based form).
|
|
434
|
+
2. Otherwise, find a key whose value equals `children`, first in the
|
|
435
|
+
**project locale**, then in the **default locale**.
|
|
436
|
+
3. Resolve that key in the target locale, then the default locale, then the
|
|
437
|
+
project locale.
|
|
275
438
|
4. If there is no match anywhere, render `children` as-is.
|
|
276
439
|
|
|
440
|
+
Fallback happens **per key**, not per locale: a string missing from the current
|
|
441
|
+
locale falls back on its own, so a half-finished translation stays readable
|
|
442
|
+
instead of rendering blank.
|
|
443
|
+
|
|
444
|
+
#### `createTranslator()` — localization outside React
|
|
445
|
+
|
|
446
|
+
The resolver the provider uses, available on its own. Use it wherever there is
|
|
447
|
+
no React tree: generating a PDF or spreadsheet, composing an e-mail, or
|
|
448
|
+
building a notification from a background task.
|
|
449
|
+
|
|
450
|
+
```ts
|
|
451
|
+
import { createTranslator } from 'locon'
|
|
452
|
+
import de from './assets/i18n/de.json'
|
|
453
|
+
import tr from './assets/i18n/tr.json'
|
|
454
|
+
|
|
455
|
+
const l = createTranslator({
|
|
456
|
+
assets: { de, tr },
|
|
457
|
+
locale: 'tr', // the document's language …
|
|
458
|
+
defaultLocale: 'de',
|
|
459
|
+
projectLocale: 'de', // … regardless of what the UI is showing
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
l('Arbeitszeit') // → 'Çalışma süresi'
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
This is what lets an app export a report in a language the interface is not
|
|
466
|
+
currently in — a user browsing in German can still produce a Turkish report.
|
|
467
|
+
|
|
468
|
+
#### `resolveLocale()` — the provider's locale precedence
|
|
469
|
+
|
|
470
|
+
`createTranslator()` needs a locale, and outside React nothing has picked one
|
|
471
|
+
for you. `resolveLocale()` answers that question with the exact precedence
|
|
472
|
+
`<Locon />` uses internally: an explicit choice, else the device language, else
|
|
473
|
+
the default.
|
|
474
|
+
|
|
475
|
+
```ts
|
|
476
|
+
import { resolveLocale } from 'locon'
|
|
477
|
+
import de from './assets/i18n/de.json'
|
|
478
|
+
import en from './assets/i18n/en.json'
|
|
479
|
+
|
|
480
|
+
const assets = { de, en }
|
|
481
|
+
|
|
482
|
+
resolveLocale({ assets, currentLocale: 'de' }) // → 'de' — an explicit choice wins
|
|
483
|
+
resolveLocale({ assets, currentLocale: null }) // → device language, else 'en'
|
|
484
|
+
resolveLocale({ assets, autodetect: false, defaultLocale: 'de' }) // → 'de'
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
The config it takes:
|
|
488
|
+
|
|
489
|
+
```ts
|
|
490
|
+
interface ResolveLocaleConfig {
|
|
491
|
+
assets: Assets
|
|
492
|
+
/** An explicit choice, `null` to follow the device, `undefined` for neither. */
|
|
493
|
+
currentLocale?: string | null
|
|
494
|
+
defaultLocale?: string // default 'en'
|
|
495
|
+
autodetect?: boolean // default true
|
|
496
|
+
/** A device locale you already detected — pass it to skip a second detection pass. */
|
|
497
|
+
systemLocale?: string | null
|
|
498
|
+
}
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
Reach for it in a background task, an export pipeline, or a native bridge —
|
|
502
|
+
anywhere that has to agree with the UI about what language the app is in. An
|
|
503
|
+
app that re-derives the rule by hand eventually disagrees with the provider.
|
|
504
|
+
|
|
505
|
+
`getSystemLanguage(availableLocales)` is the detection step on its own. It
|
|
506
|
+
returns the best match among the locales you ship, or `null`, trying
|
|
507
|
+
`expo-localization`, then `react-native-localize`, then React Native's
|
|
508
|
+
`NativeModules`, then `Intl` — skipping silently past whichever are not
|
|
509
|
+
installed.
|
|
510
|
+
|
|
511
|
+
```ts
|
|
512
|
+
import { getSystemLanguage } from 'locon'
|
|
513
|
+
|
|
514
|
+
getSystemLanguage(['de', 'en', 'pt-BR']) // device asks for pt → 'pt-BR'
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
Matching widens progressively — normalized exact tag, same script (explicit or
|
|
518
|
+
inferred from a region), bare language, then any locale in the same language.
|
|
519
|
+
Thus both `zh-Hant-TW` and `zh-TW` choose `zh-Hant`, not an earlier `zh-Hans`.
|
|
520
|
+
Device order is preference order, so the user's first choice beats a closer
|
|
521
|
+
match further down their list.
|
|
522
|
+
|
|
523
|
+
#### `intlLocale()` — tags for `Intl`
|
|
524
|
+
|
|
525
|
+
`locon` has no opinion on which calendar or numbering system your app should
|
|
526
|
+
use: a Persian journal app wants Jalali dates, a Persian payroll export usually
|
|
527
|
+
does not. That is a product decision. What it can do is get the tag syntax
|
|
528
|
+
right, which naive concatenation does not — appending `-u-ca-gregory` to a
|
|
529
|
+
locale that already reads `de-u-nu-latn` produces two `-u-` singletons, and
|
|
530
|
+
`Intl` throws on it.
|
|
531
|
+
|
|
532
|
+
```ts
|
|
533
|
+
import { intlLocale } from 'locon'
|
|
534
|
+
|
|
535
|
+
intlLocale('fa', { calendar: 'persian' }) // → 'fa-u-ca-persian'
|
|
536
|
+
intlLocale('pt_BR') // → 'pt-BR'
|
|
537
|
+
intlLocale('de-u-nu-latn', { calendar: 'gregory' }) // → 'de-u-nu-latn-ca-gregory'
|
|
538
|
+
intlLocale('en-x-private', { calendar: 'gregory' }) // → 'en-u-ca-gregory-x-private'
|
|
539
|
+
intlLocale('fa-u-ca-gregory', { calendar: 'persian' }) // → unchanged: the tag already decided
|
|
540
|
+
intlLocale('de') // → 'de'
|
|
541
|
+
|
|
542
|
+
new Intl.DateTimeFormat(intlLocale('fa', { calendar: 'persian' })).format(date)
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
Extensions already present in the tag are left alone, so an explicit choice by
|
|
546
|
+
the caller — or by the user's own device settings — wins over your default.
|
|
547
|
+
|
|
548
|
+
#### RTL
|
|
549
|
+
|
|
550
|
+
```ts
|
|
551
|
+
import { applyRTL, isRtlLocale, useLocon } from 'locon'
|
|
552
|
+
|
|
553
|
+
isRtlLocale('ar') // true
|
|
554
|
+
isRtlLocale('ar-EG') // true — region subtags are ignored
|
|
555
|
+
isRtlLocale('pa-Arab') // true — an explicit script subtag wins
|
|
556
|
+
isRtlLocale('ha') // false — CLDR treats Hausa as Latin-script
|
|
557
|
+
|
|
558
|
+
const { isRTL } = useLocon() // direction of the current locale
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
`applyRTL(locale)` aligns React Native's `I18nManager` with the locale and
|
|
562
|
+
returns `true` when the direction actually changed — which means the app has
|
|
563
|
+
to restart before the new direction renders. `locon` never restarts your app
|
|
564
|
+
for you; prompt the user, or reload with `expo-updates`:
|
|
565
|
+
|
|
566
|
+
```tsx
|
|
567
|
+
const needsRestart = applyRTL('ar')
|
|
568
|
+
|
|
569
|
+
if (needsRestart) {
|
|
570
|
+
Alert.alert('Restart required', 'Reopen the app to apply the new direction.')
|
|
571
|
+
}
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
Calling it again with the current direction before restarting cancels a queued
|
|
575
|
+
opposite change (for example, Arabic then English), so a dismissed language
|
|
576
|
+
choice cannot leave the next launch in the wrong direction.
|
|
577
|
+
|
|
578
|
+
On Expo, set `supportsRTL: true` in the `expo-localization` plugin config
|
|
579
|
+
(see [Use with Expo](#use-with-expo)) or iOS will ignore the direction change.
|
|
580
|
+
|
|
581
|
+
---
|
|
582
|
+
|
|
583
|
+
### Keeping translations in sync
|
|
584
|
+
|
|
585
|
+
Value-based lookup makes the assets easy to write and easy to break quietly: a
|
|
586
|
+
typo in an `<LText>` child is not a compile error, it just renders your source
|
|
587
|
+
language to someone who cannot read it.
|
|
588
|
+
|
|
589
|
+
The package ships a skill and two scripts for that. Audit a project's assets
|
|
590
|
+
against its code:
|
|
591
|
+
|
|
592
|
+
```bash
|
|
593
|
+
node node_modules/locon/skills/locon-sync/scripts/check-locon-assets.mjs \
|
|
594
|
+
--source de \
|
|
595
|
+
--locales de,en,fr
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
It checks that every statically written phrase in the code resolves, that all
|
|
599
|
+
declared locales have files, that all locale files carry the same keys, that
|
|
600
|
+
placeholders line up, that duplicate JSON keys cannot be silently overwritten,
|
|
601
|
+
that no locale has picked up characters from another script, and that nothing
|
|
602
|
+
addresses a key where a source phrase belongs. It exits non-zero, so it works
|
|
603
|
+
as a CI step.
|
|
604
|
+
|
|
605
|
+
Orphaned keys are reported but are advisory by default: a lightweight source
|
|
606
|
+
scan cannot prove that a dynamically selected string is unused. In a project
|
|
607
|
+
that keeps lookups static, make them fatal too:
|
|
608
|
+
|
|
609
|
+
```bash
|
|
610
|
+
node node_modules/locon/skills/locon-sync/scripts/check-locon-assets.mjs --source de --strict-orphans
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
Missing CLDR categories are also advisory because locon deliberately falls
|
|
614
|
+
back to `_other`; use `--strict-plurals` when every selectable category must be
|
|
615
|
+
written explicitly. If two source values intentionally differ only by context,
|
|
616
|
+
allow their direct keys with `--allow-keys key_a,key_b`.
|
|
617
|
+
|
|
618
|
+
`{count}` must match by default. Allow an exact translation to omit it only by
|
|
619
|
+
locale and key, for example
|
|
620
|
+
`--allow-implicit-count ar:day_one,ar:day_two`. This is never inferred from the
|
|
621
|
+
suffix: Russian 21 selects `_one`, so an unparameterized `day_one: "день"`
|
|
622
|
+
would lose the number.
|
|
623
|
+
|
|
624
|
+
`write_locale.py` validates before atomically replacing a locale file,
|
|
625
|
+
preserves source key order and grouping, and accepts target-specific plural
|
|
626
|
+
categories:
|
|
627
|
+
|
|
628
|
+
```bash
|
|
629
|
+
python3 node_modules/locon/skills/locon-sync/scripts/write_locale.py \
|
|
630
|
+
--locale fr \
|
|
631
|
+
--input /tmp/fr.json \
|
|
632
|
+
--assets src/i18n/assets \
|
|
633
|
+
--source de
|
|
634
|
+
```
|
|
635
|
+
|
|
636
|
+
If you use an agentic coding tool that supports skills, point it at
|
|
637
|
+
`node_modules/locon/skills/locon-sync/` (or copy the folder into your own
|
|
638
|
+
skills directory) and it will follow the same workflow, including the parts
|
|
639
|
+
that are not about strings at all — fonts for new scripts, calendars and
|
|
640
|
+
numbering systems, RTL, and store metadata.
|
|
641
|
+
|
|
642
|
+
#### Use the skill with Claude Code or Codex
|
|
643
|
+
|
|
644
|
+
Claude Code and Codex both use the open Agent Skills format but scan different
|
|
645
|
+
project directories. Keep one canonical npm copy and expose it to both from
|
|
646
|
+
the app root:
|
|
647
|
+
|
|
648
|
+
```bash
|
|
649
|
+
mkdir -p .claude/skills .agents/skills
|
|
650
|
+
ln -s ../../node_modules/locon/skills/locon-sync .claude/skills/locon-sync
|
|
651
|
+
ln -s ../../node_modules/locon/skills/locon-sync .agents/skills/locon-sync
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
Commit these two links so everyone opening the repository gets the same skill.
|
|
655
|
+
If the commands do not appear in an already open session, reopen the project.
|
|
656
|
+
Copy the folder instead on systems where project symlinks are unavailable, and
|
|
657
|
+
refresh both copies whenever `locon` is updated.
|
|
658
|
+
|
|
659
|
+
Invoke the skill explicitly at the beginning of a chat message. Claude Code
|
|
660
|
+
uses `/locon-sync`; Codex uses `$locon-sync`. The rest of the message is the
|
|
661
|
+
task and any limits you want the agent to observe.
|
|
662
|
+
|
|
663
|
+
Audit without changing files:
|
|
664
|
+
|
|
665
|
+
```text
|
|
666
|
+
# Claude Code
|
|
667
|
+
/locon-sync Audit every declared locale in this project. Do not change files; report missing assets, unresolved strings, placeholder or plural problems, and integration gaps.
|
|
668
|
+
|
|
669
|
+
# Codex
|
|
670
|
+
$locon-sync Audit every declared locale in this project. Do not change files; report missing assets, unresolved strings, placeholder or plural problems, and integration gaps.
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
Synchronize existing languages after UI copy changes:
|
|
674
|
+
|
|
675
|
+
```text
|
|
676
|
+
# Claude Code
|
|
677
|
+
/locon-sync Synchronize all existing locales after my source-language UI changes. Preserve product meaning, run the bundled checker and relevant tests, and list anything that needs my decision.
|
|
678
|
+
|
|
679
|
+
# Codex
|
|
680
|
+
$locon-sync Synchronize all existing locales after my source-language UI changes. Preserve product meaning, run the bundled checker and relevant tests, and list anything that needs my decision.
|
|
681
|
+
```
|
|
682
|
+
|
|
683
|
+
Add a language end to end:
|
|
684
|
+
|
|
685
|
+
```text
|
|
686
|
+
# Claude Code
|
|
687
|
+
/locon-sync Add Polish (pl) to this app. Inspect the project before editing, translate every source asset, update the declared language list and picker, check fonts, RTL, Intl and native/store metadata, then run validation. Do not publish.
|
|
688
|
+
|
|
689
|
+
# Codex
|
|
690
|
+
$locon-sync Add Polish (pl) to this app. Inspect the project before editing, translate every source asset, update the declared language list and picker, check fonts, RTL, Intl and native/store metadata, then run validation. Do not publish.
|
|
691
|
+
```
|
|
692
|
+
|
|
693
|
+
Both tools can also select the skill automatically from a plain request such
|
|
694
|
+
as “audit the Locon translations” because its description declares those
|
|
695
|
+
tasks. The explicit command is useful when you want to guarantee that the
|
|
696
|
+
workflow and bundled scripts are loaded. You can inspect the available skills
|
|
697
|
+
with `/skills` in either tool. See the official
|
|
698
|
+
[Claude Code skills documentation](https://code.claude.com/docs/en/slash-commands)
|
|
699
|
+
and [Codex skills documentation](https://learn.chatgpt.com/docs/build-skills).
|
|
700
|
+
|
|
701
|
+
The agent first discovers the source locale, asset directory, and complete
|
|
702
|
+
declared language list. It then runs the checker, makes only the requested
|
|
703
|
+
changes, uses the safe writer when generating a locale, and verifies JSON,
|
|
704
|
+
placeholders, plurals, scripts, fonts, RTL, locale-sensitive formatting, and
|
|
705
|
+
native/store declarations. A skill supplies a workflow, not extra permissions:
|
|
706
|
+
publishing, pushing, or destructive changes still require the authority you
|
|
707
|
+
give the agent in the chat.
|
|
708
|
+
|
|
277
709
|
---
|
|
278
710
|
|
|
279
711
|
### ESLint integration (`react-native/no-raw-text`)
|
|
@@ -313,12 +745,36 @@ raw text inside `<LText>`.
|
|
|
313
745
|
|
|
314
746
|
The published package contains:
|
|
315
747
|
|
|
316
|
-
- `dist/index.js`
|
|
317
|
-
- `dist/index.
|
|
318
|
-
|
|
748
|
+
- `dist/index.js` — one bundled CommonJS file
|
|
749
|
+
- `dist/index.d.ts` — types
|
|
750
|
+
|
|
751
|
+
So you can import from `locon` in both JS and TS projects and get type support
|
|
752
|
+
for every export. Public component/configuration types are importable too:
|
|
753
|
+
|
|
754
|
+
```ts
|
|
755
|
+
import type {
|
|
756
|
+
Assets,
|
|
757
|
+
IntlExtensions,
|
|
758
|
+
LTextProps,
|
|
759
|
+
LoconContextValue,
|
|
760
|
+
LoconProps,
|
|
761
|
+
ResolveLocaleConfig,
|
|
762
|
+
TranslateOptions,
|
|
763
|
+
Translator,
|
|
764
|
+
TranslatorConfig,
|
|
765
|
+
} from 'locon'
|
|
766
|
+
```
|
|
319
767
|
|
|
320
|
-
|
|
321
|
-
|
|
768
|
+
The build is deliberately **CommonJS only, and bundled**. Metro finds optional
|
|
769
|
+
dependencies by statically reading literal `require()` calls, and esbuild
|
|
770
|
+
rewrites those into its own shim when emitting ESM — so an ESM build registered
|
|
771
|
+
nothing and even `react-native` failed to resolve at runtime. Bundling matters
|
|
772
|
+
for the same reason: an unbundled tree emitted extensionless relative imports,
|
|
773
|
+
which Node resolves to `.js` but Metro tries `.mjs` for first, loading the
|
|
774
|
+
library twice. Two instances mean two contexts — the provider fills one, every
|
|
775
|
+
`useLocon()` reads the other, and consumers get untranslated screens with no
|
|
776
|
+
error at all. Metro consumes CJS natively, so a second format buys a React
|
|
777
|
+
Native library nothing.
|
|
322
778
|
|
|
323
779
|
---
|
|
324
780
|
|