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