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 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
- - `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
63
64
 
64
- `react-native-localize` is used for accurate device language detection on iOS and Android.
65
- It is not installed automatically — add it to your app if you want native locale detection:
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
- # or
70
- npm install react-native-localize
73
+ cd ios && pod install
71
74
  ```
72
75
 
73
- 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.
74
82
 
75
83
  ```bash
76
- cd ios && pod install
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={{ en, de }}
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. Use `react-native-localize` (if installed) to detect device locale on iOS/Android
144
- 2. Match the detected locale (including region) or just language code against your `assets`
145
- 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
146
194
  4. Otherwise, fall back to `defaultLocale`
147
195
 
148
- 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.
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
- {/* Localised value by key */}
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
- Initial locale. If provided, it overrides auto-detection.
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. Try to find a key in the **project locale** whose **value** equals
271
- `children`, then use that key in the current locale.
272
- 2. If nothing is found in the project locale, try **directly** using
273
- `children` as a key in the current locale.
274
- 3. If still nothing is found, fall back to the **default locale**.
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` (CJS)
317
- - `dist/index.mjs` (ESM)
318
- - `dist/index.d.ts` (types)
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
- So you can import from `locon` in both JS and TS projects and get type
321
- support for `Locon`, `useLocon`, and `LText`.
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