langsys-js-react 0.6.6 → 0.7.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
@@ -33,7 +33,7 @@ If you need the SDK outside React (a Node script, a non-React web app), import f
33
33
  npm install langsys-js-react
34
34
  ```
35
35
 
36
- `langsys-js-typescript` is installed automatically as a transitive dependency. `react` is a peer dependency you already have.
36
+ `langsys-js-typescript` (`^0.7.3`) is installed automatically as a dependency. `react` is a peer dependency you already have.
37
37
 
38
38
  ## Creating a Langsys project
39
39
 
@@ -67,7 +67,6 @@ export function LangsysGate({ children }: { children: ReactNode }) {
67
67
  UserLocaleStore: localeStore,
68
68
  baseLocale: 'en-US',
69
69
  debug: false,
70
- ssrTokenStrategy: 'client',
71
70
  }).then((res) => {
72
71
  if (res.status) setReady(true);
73
72
  else setError(res.errors?.join(', ') ?? 'Init failed');
@@ -82,15 +81,29 @@ export function LangsysGate({ children }: { children: ReactNode }) {
82
81
 
83
82
  `UserLocaleStore` is a `Signal<string>` — switch it with `setLocale(...)` (from the same `useLocaleStore` call) or `localeStore.set('fr-FR')`, and the SDK reacts. If you'd rather keep the locale store at module scope, `const localeStore = createLocaleStore('en-US')` works too.
84
83
 
85
- Locale identifiers are canonicalized to BCP 47 by the base SDK (v0.3.0+): lowercase input like `'en-us'` still works, but `useCurrentLocale()` and `detectPreferredLocale()` always return the canonical form (`'en-US'`) — compare against that, or normalize your own values with the re-exported `canonicalizeLocale()`.
84
+ Locale identifiers are canonicalized by the base SDK: `'en-US'`, `'EN-US'` and `'en-us'` all normalize to **lowercase** `'en-us'`. `useCurrentLocale()` and `detectPreferredLocale()` return that lowercase form, so compare against `'en-us'` — a test for `'en-US'` never matches. Normalize your own values with the re-exported `canonicalizeLocale()` rather than casing by hand.
85
+
86
+ ### Running without credentials
87
+
88
+ `init()` without a project id, an API key or a locale store does not throw: it resolves `{ status: false, errors }` and leaves the client unconfigured, so a preview build or a test run without secrets still renders. `t()` shows the source text with its params filled, `<Translate>` and `<Phrase>` show their content as written, and nothing is requested or registered: a registration resolves skipped with the `RegistrationReason` `'not-configured'`. An explicit write, such as `LangsysAppAPI.createTranslatableItems`, throws `LangsysConfigurationError`. `LangsysApp.isConfigured()` reports the state.
89
+
90
+ To fail loudly instead, pass `requireCredentials: true`. While the client is unconfigured:
91
+
92
+ - `t()` throws `LangsysConfigurationError`, so a component that calls it throws during render, and the error reaches your nearest error boundary.
93
+ - `<Translate>` and `<Phrase>` still show their content as written. Their registration rejects with the same error, including a block that mounted before `init()` answered, left unhandled so it appears as an unhandled rejection in the console, and fails a test runner that treats those as errors. A block holding a value the build transform did not name registers nothing, so it only reads and stays silent.
94
+ - On a server, the request's `t()` throws and a block's registration never rejects.
95
+
96
+ `LangsysConfigurationError` is exported from `langsys-js-react` and from `langsys-js-react/server`.
86
97
 
87
98
  ### SSR token strategy
88
99
 
89
- `ssrTokenStrategy` (default `'client'`) controls when missing tokens are sent during server rendering:
100
+ `ssrTokenStrategy` (default `'client'`) decides what a request scope's `close()` does with the phrases and blocks its [server render](#server-side-rendering-nextjs-remix) missed. The scope reads it from `LangsysApp.init()` in the rendering process, so it applies only where the server calls `init()` too. A client-only app like the one above has no server render, and a server that never calls `init()` sends nothing from the server.
90
101
 
91
- - `'client'` — tokens collected on the server are flushed from the client after hydration. Best for performance.
92
- - `'server'` — tokens are sent immediately during SSR. Best for reliability and immediate registration.
93
- - `'auto'` — small batches (≤5) sent from server, larger queued for client.
102
+ - `'client'` (default) — the server sends nothing. The browser registers the misses after hydration when its session may write, and otherwise reports the page for discovery.
103
+ - `'server'` — `close()` sends the misses from the server, from your server's IP rather than a browser's.
104
+ - `'auto'` — `close()` sends a list of fewer than five from the server and leaves a longer one to the browser.
105
+
106
+ The server sends only when its own key may write; otherwise its misses go to the browser as under `'client'`. With a `writeGrant` configured the server sends nothing under any strategy, because a grant makes write capability per user, and the SDK warns that the strategy is degraded to `'client'`. [README-SSR.md](./README-SSR.md#what-a-server-render-translates-and-discovers) lists what a server render translates and what it discovers.
94
107
 
95
108
  ## Using translations
96
109
 
@@ -141,9 +154,39 @@ t('You have {count} new messages', 'Notifications', { count: 3, extra: 'x' });
141
154
  // ❌ Object literal may only specify known properties, and 'extra' does not exist
142
155
  ```
143
156
 
144
- Allowed value types: `string | number | Date | boolean`. Since base SDK 0.3.0, values are locale-formatted: numbers go through `Intl.NumberFormat` (`1234.5` → `1.234,5` in `de-DE`) and `Date` values through `Intl.DateTimeFormat` with the medium date style (previously ISO 8601). Pass a string to opt out of formatting. Formatting always uses the catalog locale (falling back to `en`), never the host's default locale, so server and client render identically.
157
+ Allowed value types: `string | number | Date | boolean`. Values are locale-formatted: numbers go through `Intl.NumberFormat` (`1234.5` → `1.234,5` in `de-DE`) and `Date` values through `Intl.DateTimeFormat` with the medium date style. Pass a string to opt out of formatting. Formatting always uses the catalog locale (falling back to `en`), never the host's default locale, so server and client render identically. The same holds inside a translation's plural or gender form.
158
+
159
+ #### Plurals and gender: write the sentence flat
160
+
161
+ **Never write ICU.** Write the sentence as it reads, with the count or the name as a `{placeholder}`, and pass the value as a param. Langsys makes each language's plural and gender forms, the base language included:
145
162
 
146
- > Future versions will swap the simple `{name}` runtime for full ICU MessageFormat — adding plural / select — without changing the public signature. Style-less ICU arguments (`{n, number}`, `{d, date}`, `{t, time}`) already format as of base SDK 0.3.0. Today's `t('{count} items', 'Cart', { count })` will evolve to `t('{count, plural, one {# item} other {# items}}', 'Cart', { count })`.
163
+ ```tsx
164
+ t('You have {count} new messages', 'Inbox', { count }); // ✅
165
+ t('{count, plural, one {# new message} other {# new messages}}', 'Inbox', { count }); // ❌ ICU written by hand
166
+ ```
167
+
168
+ Langsys makes the forms once it has checked the phrase: a translation run checks its own phrases, and an hourly check covers any phrase a run missed. Until then the base language shows the sentence as written. The catalog holds the forms as ICU MessageFormat. Every request carries `X-Langsys-Capabilities: icu`, and `t()` renders the form the count selects with the CLDR plural rules of the loaded catalog's locale (Russian's four categories, Arabic's six). A plural or select renders its `other` branch when its argument isn't passed. Your code passes the same params in every language. A count and the noun it inflects belong in one phrase, or no plural form can reach the noun.
169
+
170
+ A sentence that says something different at zero is its own phrase, chosen in code, and so is a sentence that differs in the source language itself:
171
+
172
+ ```tsx
173
+ count === 0 ? t('Your inbox is empty.', 'Inbox') : t('You have {count} new messages', 'Inbox', { count });
174
+ ```
175
+
176
+ In a language with grammatical gender, Langsys can turn a plain `{name}` into `{name_gender, select, …}` in the translation. Pass `name_gender` (`'female'`, `'male'` or `'other'`) beside `name` when you know it. `t()` types it as an optional param for every placeholder, `<Phrase params>` and `<Translate params>` take it as it is, and without it the neutral `other` form renders:
177
+
178
+ ```tsx
179
+ t('{name} has been invited', 'Team', { name: 'Ana', name_gender: 'female' });
180
+ ```
181
+
182
+ A plain `{n}` formats a number or a date, and nothing else needs writing. That holds inside a translation that carries a plural or gender form too: the count, written `#` or `{count}`, and every other number format for the loaded catalog's locale. A value passed as a string is written as given, so pass a time of day, or a number in a format of your own, as a string formatted with the loaded locale:
183
+
184
+ ```tsx
185
+ const locale = useCurrentLocale() || 'en'; // '' until the first catalog loads, and Intl throws on ''
186
+ const total = new Intl.NumberFormat(locale, { style: 'currency', currency: 'EUR' }).format(amount);
187
+ const at = new Intl.DateTimeFormat(locale, { timeStyle: 'short' }).format(date);
188
+ t('{count} items, {total}, ready at {at}', 'Orders', { count, total, at });
189
+ ```
147
190
 
148
191
  #### Categorization disambiguates context
149
192
 
@@ -158,6 +201,41 @@ Without categorization, "Home" would only have one translation — which can't w
158
201
 
159
202
  A good rule for category names: the module or feature the phrase lives in (`Account`, `Errors`, `Checkout`, `UI`).
160
203
 
204
+ #### Moving content to another category
205
+
206
+ The category is part of what identifies a phrase or block, so changing it registers the content as new, and it would be translated, and charged for, again. Name the category it was in before, and its translations follow it instead:
207
+
208
+ ```tsx
209
+ import { inCategory, Phrase, Translate } from 'langsys-js-react';
210
+
211
+ t('Buy now', inCategory('Checkout', { previousCategory: 'Header' }));
212
+
213
+ <Phrase category="Checkout" previousCategory="Header">Buy <b>now</b></Phrase>
214
+
215
+ <Translate category="Checkout" previousCategory="Header">
216
+ <p>Free shipping on every order.</p>
217
+ </Translate>
218
+ ```
219
+
220
+ `t()`'s second argument is a category when it's a string and params when it's an object, so a move goes in the category position, wrapped by `inCategory()`. Use `previousCategory: ''` if the content had no category before.
221
+
222
+ - **It moves every use.** Langsys keeps one item per text and category, so a move retires the old item for every call that registers that text in that category. Move them all in one release.
223
+ - **Nothing to clean up on a schedule.** The previous category is sent once, on the registration the new code triggers. Once the catalog has the content under its new category, the option does nothing and you can delete it whenever convenient.
224
+ - **No flash on the first deploy.** Until the catalog has the new category, the previous category's translation renders, and the host says where the text came from, `data-ls-previous-contentblock` when it was filled from the old category's block and `data-ls-previous-category` when it was filled from the old category's phrase, so hydration renders the same text.
225
+ - **A block with its own `custom_id` moves in place.** Its id names it whatever its category, so the API moves the block itself, and an older release still registering it under the old category moves it back. Move every use in one release.
226
+ - **On a server entry** (`langsys-js-react/server`), import `inCategory` from there: it is the same function the main entry exports, and `langsys-js-typescript` is a dependency of this package, so importing it directly breaks under pnpm and Yarn PnP.
227
+ - **A nested unit that names no category moves with its block.** A `<Phrase>` or `<Translate>` inside a `<Translate>` (or `<Phrase>`) that holds a component takes the block's `category` and its `previousCategory`, since it is part of the same content. Leave `category` off to inherit; `category=""` names no category and takes neither. A nested unit that names its own `category` takes neither, and moves only if it names its own `previousCategory`. One that names no category but a `previousCategory` of its own takes the block's category and its own previous one. A unit that is not inside a block names what its props say. Markup does the same: a host nested in a block takes the block's category and previous category unless it carries its own `data-ls-category`, and `data-ls-previous-category` beside it names its own previous category, the way the core stamps a host rendered from the previous category.
228
+
229
+ ```tsx
230
+ <Translate category="Checkout" previousCategory="Header">
231
+ <p>Welcome, <UserName /></p>
232
+ <Phrase>Free shipping</Phrase> {/* Checkout, moved from Header */}
233
+ <Phrase category="Cart">Your cart</Phrase> {/* Cart, no move */}
234
+ <Phrase category="Cart" previousCategory="Basket">Your cart</Phrase> {/* Cart, moved from Basket */}
235
+ </Translate>
236
+ ```
237
+ - **Needs base SDK `langsys-js-typescript@^0.7.3`**, which carries the move.
238
+
161
239
  ### `<Translate>` — HTML content blocks
162
240
 
163
241
  For larger blocks of HTML where the structure should be preserved for the translator:
@@ -182,11 +260,13 @@ The component:
182
260
  - **Screen-reader text:** `aria-label`, `aria-placeholder`, `aria-description`, `aria-valuetext`, `aria-roledescription` — worth knowing these are covered, since untranslated ARIA strings are invisible on the page and only surface to someone using a screen reader
183
261
  - **Validation messages:** `data-error`, `data-error-message`, `data-validation-message`, `data-invalid-message`, `data-required-message`, `data-pattern-message`
184
262
  - Translates the `value` attribute **only where it is a label rather than data**: on `<button>`, and on `<input type="submit">` / `<input type="button">`. Every other input type is left alone, so a text field's value is never rewritten. This is a separate mechanism from the attribute list above — `value` is deliberately *not* in the SDK's `TRANSLATABLE_ATTRIBUTES`; it's gated by `VALUE_TRANSLATABLE_ELEMENTS` / `VALUE_TRANSLATABLE_INPUT_TYPES`.
185
- - Captures semantic CSS so translators see the styled appearance in the Translation Manager.
263
+ - Registers a snapshot of the block's markup, so translators see its structure in the Translation Manager.
186
264
  - Registers the whole thing as a **content block** that translators handle as one unit while still translating the individual phrases inside.
187
265
  - Auto re-translates on locale change.
188
266
 
189
- `<Translate>` mounts the SDK's DOM walker on its host element and lets it mutate the rendered output in place, so **keep its children static** — prose, marketing copy, CMS-rendered articles, forms with placeholders. For dynamic per-string values that React owns, use `useT()`.
267
+ `<Translate>` renders its content through the core: the translated text comes back as data, and the component renders it as React elements — your own elements, with their handlers, refs and keys, in the places the translation puts them. It renders the same on a server and in the browser, so a server-rendered page arrives translated and hydrates without a mismatch, and a locale change re-renders it. Keep its children to markup and text — prose, marketing copy, forms with placeholders. For dynamic per-string values that React owns, use `useT()`.
268
+
269
+ A block whose children hold a component, `lazy`, `Suspense` or `dangerouslySetInnerHTML` cannot be rendered that way, because its content isn't known until React renders it. Such a block renders its children as they are, and the SDK translates it in the browser after it mounts; its `custom_id`, when you give one, is on the element from the first render. The SDK registers such a block once its content has stopped changing for 500 ms, so a `Suspense` fallback that resolves within that time is never registered as the block. A fallback still showing then is registered, because it is what the page shows; if it resolves later, the SDK says so in a debug notice naming both block ids. On a server the SDK warns once that it served such a block in the source language. Until it mounts, such a block shows its children as the build transform left them, `%name%` placeholders included, so keep components out of a block that interpolates values when its server HTML matters: put the component outside the `<Translate>`, or the values in a `<Phrase>` of their own.
190
270
 
191
271
  ```tsx
192
272
  {/* CMS content goes through Translate as-is */}
@@ -195,7 +275,15 @@ The component:
195
275
  </Translate>
196
276
  ```
197
277
 
198
- `<Translate>` also accepts `params` for placeholder interpolation. Write placeholders as **`%key%`** directly in the markup — applied to the resolved text of content-block nodes, translatable attributes, `<option>` text, and single-token content (untranslated fallbacks included). Number/Date values get CLDR locale formatting. Change `params` after mount and the block re-renders:
278
+ Values in `<Translate>` and `<Phrase>` markup are placeholders. With the build transform enabled (one line of configuration, see [Variables in translated text](#variables-in-translated-text)), write them as you would any JSX:
279
+
280
+ ```tsx
281
+ <Translate category="Cart">You have {items.length} items in your cart.</Translate>
282
+ ```
283
+
284
+ The transform turns `{items.length}` into the placeholder `{items_count}` and passes the value as its param, so the sentence registers once — `You have {items_count} items in your cart.` — for every user and every count, and a translator can give it plural forms.
285
+
286
+ Without the transform, write the placeholder yourself as **`%key%`** and pass the value in `params`:
199
287
 
200
288
  ```tsx
201
289
  <Translate category="Cart" params={{ count: itemCount }}>
@@ -203,11 +291,11 @@ The component:
203
291
  </Translate>
204
292
  ```
205
293
 
206
- > **Use `%key%` in `<Translate>`/`<Phrase>` markup, not bare `{key}`.** In JSX a literal `{count}` is a JavaScript expression that React evaluates *before* the SDK's DOM walker sees the text — the braces vanish and interpolation silently breaks (it still looks fine in the base locale, which hides it). `%count%` passes through JSX as plain text; the SDK normalizes it to canonical `{count}` at capture, so **translators and the catalog only ever see `{count}`**, and both spellings hash to the same content-block id. Keys are identifier-shaped (`%[A-Za-z_][A-Za-z0-9_]*%`), so a stray `%` in prose ("50% off") is left untouched. An unknown `%key%` with no matching param renders as `{key}` — matching `t()`'s behavior for unknown keys. (`t()` and the hooks keep `{key}`: JS strings reach the SDK literally, so there's no collision.)
294
+ `%key%` passes through JSX as plain text and the SDK reads it as `{key}`; keys are identifier-shaped (`%[A-Za-z_][A-Za-z0-9_]*%`), so a stray `%` in prose ("50% off") is left alone. `params` apply to text, translatable attributes, `<option>` text and single-token content; numbers and dates get the locale's formatting, inside a plural or gender form too, and changing `params` re-renders the block.
207
295
 
208
- > Since base SDK 0.4.2, running with `debug: true` catches this mistake for you: if you pass `params` whose keys match no placeholder in the captured content, the SDK warns and names the fix (`… received params with no matching placeholder … write %count% instead`). It's silent in production, treats ICU slots as legitimate, and only re-warns when the set of param keys changes.
296
+ Without the transform, a bare `{count}` is evaluated by React before the SDK sees the text. Where it sits inside other text — `You have {count} items` — the component can tell a value was there but not what to call it, so it registers nothing for that block, renders any translation the catalog already holds, and says so once as a debug notice. In a `<Phrase>`, a value inside a part that is not translated, or inside notation such as `<math>`, doesn't count: the phrase's key leaves that part out, so the phrase still registers. Where the value is the only text of its element (`<b>{name}</b>`), it cannot be told from literal text, and the block registers the text it renders. The transform leaves a conditional (`{signedIn ? name : 'guest'}`) as written, so one inside other text registers nothing either. Raw HTML is content, not a variable: a block whose only dynamic part is `dangerouslySetInnerHTML` (a CMS field, say) registers that HTML's text; one that also interpolates a value registers nothing.
209
297
 
210
- `<Translate>` props: `category?`, `custom_id?`, `label?`, `params?`, `tag?` (defaults to `translate`), `className?`, `children`.
298
+ `<Translate>` props: `category?`, `previousCategory?` (see [Moving content to another category](#moving-content-to-another-category)), `custom_id?`, `label?`, `params?`, `tag?` (defaults to `translate`), `className?`, `children`.
211
299
 
212
300
  ### `<Phrase>` — markup-bearing phrases (pluralization)
213
301
 
@@ -225,7 +313,7 @@ The inline elements never reach the translator — they're replaced with neutral
225
313
 
226
314
  > Note: write placeholders as `%n%` in `<Phrase>` markup (see the `<Translate>` note above) — a bare `{n}` in JSX is an expression and never reaches the SDK.
227
315
 
228
- `<Phrase>` props: `category?`, `params?`, `tag?` (defaults to `span`), `className?`, `children`.
316
+ `<Phrase>` props: `category?`, `previousCategory?`, `params?`, `tag?` (defaults to `span`), `className?`, `children`.
229
317
 
230
318
  ### `<DontTranslate>` — never-translated regions
231
319
 
@@ -237,10 +325,59 @@ import { DontTranslate } from 'langsys-js-react';
237
325
  Built with <DontTranslate>Kangen®</DontTranslate> on <DontTranslate>langsys.dev</DontTranslate>
238
326
  ```
239
327
 
240
- Renders the host with `translate="no"`, which the base SDK's tokenizer and renderer already honor — the content is never tokenized, registered, or replaced.
328
+ Renders the host with `translate="no"`, which the base SDK's tokenizer and renderer already honor — the content is never tokenized, registered, or replaced. Inside `<Translate>` and `<Phrase>` it is that host element, so the block around it renders on the server like any other markup. A value written inside it is shown as it is; see [Variables in translated text](#variables-in-translated-text) for how the build transform keeps it out of what Langsys stores.
241
329
 
242
330
  `<DontTranslate>` props: `tag?` (defaults to `span`), `className?`, `children`.
243
331
 
332
+ ## Variables in translated text
333
+
334
+ A sentence that shows a value — a name, a count, a date — is one phrase however many values it is shown with. The build transform makes that automatic: inside `<Translate>`, `<Phrase>` and `t()` calls it turns each interpolated value into a named placeholder and passes the value as its param.
335
+
336
+ ```tsx
337
+ <Translate>Hello {user.firstName}, welcome back</Translate>
338
+ // registers "Hello {first_name}, welcome back"
339
+
340
+ <Phrase>Signed in as <b>{user.name}</b></Phrase>
341
+ // registers "Signed in as {m0o}{name}{m0c}"
342
+
343
+ t(`You have ${inbox.count} new messages`)
344
+ // registers "You have {inbox_count} new messages"
345
+ ```
346
+
347
+ Enable it with one line, for your build tool:
348
+
349
+ ```ts
350
+ // vite.config.ts
351
+ import langsys from 'langsys-js-react/vite';
352
+ export default defineConfig({ plugins: [langsys(), react()] });
353
+ ```
354
+
355
+ ```js
356
+ // next.config.mjs — Turbopack and webpack; Next's own compiler stays on
357
+ import { withLangsys } from 'langsys-js-react/next';
358
+ export default withLangsys({ /* your config */ });
359
+ ```
360
+
361
+ ```json
362
+ // babel.config.json — any other Babel setup
363
+ { "plugins": ["langsys-js-react/babel"] }
364
+ ```
365
+
366
+ It only touches files that import `langsys-js-react`, and reads them with TypeScript's or the standard decorators and, in a `.js` file, Flow; a file it still can't read is left as written, with a build warning, for your compiler to report, and only `<Translate>` and `<Phrase>` imported from it and the `t` a `useT()` call returns; another function named `t` in the same file is left alone. In a `t()` call, the values join the params where `t()` reads them, whichever form you wrote (`t(text, params)` or `t(text, category, params)`); a second argument the build can't see into, such as a variable, is read when the call runs, as `t()` reads it. `+` joins as JavaScript joins it (`'Page ' + (page + 1)` is one value), a name you wrote (`%name%` anywhere in a block, a key in a params object you wrote out, or `{name}` in a `t()` text whose params come from a variable or a spread) is never given to another value, and an argument whose own text has no space (`t(`errors.${code}`)`, `t(`status_${status}`)`) is a key built in the call and is left as it is, so legacy-key mode looks it up. String, number and plain template literals stay text, a fragment's content joins the text around it, and each run of text reaches React as one string, with JSX's whitespace rules applied as written, whether the plugin runs in your Babel config or ahead of another compiler. JSX, conditionals and components inside a block are left as they are, and so is any expression holding JSX (`{items.map((i) => <li key={i}>{i}</li>)}`), which renders elements rather than a value's text. `{children}` that is all the text between two elements is content: a wrapper like `<Translate tag="h1">{children}</Translate>` translates what it is given. Beside text, as in `Read {children}`, or inside a part that is not translated, it is a value like any other. When the page renders, a value that is text, a number, a boolean or a date is the placeholder's param; a boolean stays a boolean, so a select in the translation can branch on it, as it can in `t()`. A bigint is passed as its digits, and `null` or `undefined` renders nothing, as React would render it. On React 19 a promise or a context is read as React reads one, a context left in the content too. Anything else, such as an element, a list or a function, is rendered in its place exactly as React renders it (`Click {icon} to go`).
367
+
368
+ In a `<Translate>`, the attributes the SDK translates (`alt`, `title`, `placeholder`, `aria-label`, `label` and the rest of its list, and the `value` of a button or a submit button) are part of the block, so a value in one is a placeholder too: `<img alt={`Photo of ${user.name}`} />` registers `Photo of {name}`. A value that appears only inside attributes' text is written as JavaScript writes it into a string. A value that is the whole attribute (`alt={product.name}`), or that also appears in the text, fills its placeholder as a value in the text does: `null` or `undefined` fills it with nothing (`alt=""`), a number or a date is formatted for the locale, and a list or an object is written as React writes an attribute, or, inside an attribute's text, as JavaScript writes it into a string. On an element whose `translate`, `data-notrans` or input `type` is an expression, only the page knows whether the attribute is translated, so the value is left as written and that block registers nothing. A `<Phrase>` doesn't translate its elements' attributes, so they are left as written there. A value right after a `%` and a word (`%s{x}`) is written as `{x}`, the form the SDK stores, since `%s%x%` would read as the placeholder `%s%`.
369
+
370
+ A value inside a part that is not translated — `<DontTranslate>`, an element with `translate="no"` or `data-notrans`, or code such as `<template>` and `<noscript>` — is shown exactly as written, so the transform leaves it where it is and marks it as a value (`<span data-ls-param="email">`) instead of replacing it. A `translate` or `data-notrans` whose value is an expression is only known when the page renders. In a `<Translate>` a value under it is marked too; a marked value reads as its placeholder wherever it ends up. In a `<Phrase>` it is a placeholder like any other, filled whether the element turns out translated or not. A `<Phrase>` holding a marker you wrote yourself (`<span data-ls-param="count">{count}</span>`) has its other values marked too, so it reads correctly before a translation exists; a value in its translated text that stands where no marker can (in a translated `<svg>`, `<option>`, `<title>` or `<textarea>`) is left as written, and that phrase registers nothing. A name a marker of yours carries is never given to another value. Langsys never stores a marked value: a block's stored content holds the placeholder `{email}` in its place, and a `<Phrase>` keys the whole part as one empty piece of markup (`Signed in as {m0o}{m0c} today`), whatever it holds. Under such an expression, a value that turns out to be translated is filled from its param, formatted like any other. Some parts can't take the marker: inside `<math>`, and inside an `<svg>` that is not translated, a `<span>` draws nothing, and `<textarea>`, `<title>` and `<option>` hold text only. A value in such a part is left as written. A `<Translate>` holding one registers nothing, since its stored content would hold the value. A `<Phrase>` keys that part as one empty piece of markup and registers as usual. In translated text inside an `<svg>`, a value is a placeholder like anywhere else. `<script>`, `<style>` and `<plaintext>` hold raw text, so a value there is left as written, and a `<Translate>` holding one registers nothing. An opt-out set through a spread (`<span {...props}>`) is invisible to the build: write `translate` or `data-notrans` on the element itself.
371
+
372
+ ```tsx
373
+ <Translate>
374
+ <p>Signed in as <DontTranslate>{user.email}</DontTranslate></p>
375
+ </Translate>
376
+ // registers the tokens "Signed in as"; the page shows the address
377
+ ```
378
+
379
+ **Names** are the same in every Langsys SDK, derived from the expression in snake_case: `user.firstName` → `first_name`, `items.length` → `items_count`, `price.value` → `price`, `formatDate(order.date)` → `date`. Two values that would share a name are told apart by the segment before it (`a.name`, `b.name` → `a_name`, `b_name`), and the same named expression written twice is one placeholder. An expression no name can be derived from — `a + b`, a ternary, a call with several arguments — is named `value`, `value_2`, …, one placeholder each time it appears, even when the same expression repeats, and the build warns about it; give it a name yourself with `%name%` and `params`, which always wins.
380
+
244
381
  ## Hooks & reactive primitives
245
382
 
246
383
  | Export | Type | Notes |
@@ -252,13 +389,166 @@ Renders the host with `translate="no"`, which the base SDK's tokenizer and rende
252
389
  | `useSignal(signal)` | `<T>(s: Signal<T>) => T` | Low-level: subscribe a component to any base-SDK signal. |
253
390
  | `createLocaleStore(initial?)` | `(s?: string) => Signal<string>` | Make a user-locale store outside React (module scope). |
254
391
  | `t` / `currentlyLoadedLocale` / `sTranslations` | `Signal<…>` | Raw signals for direct subscription outside React. In components, prefer the hooks. |
255
- | `canonicalizeLocale(locale)` | `(s: string) => string` | Normalize a locale identifier to canonical BCP 47 (`'en-us'` → `'en-US'`) — the same normalization the SDK applies internally. |
392
+ | `canonicalizeLocale(locale)` | `(s: string) => string` | Normalize a locale identifier to the lowercase wire form (`'en-US'` → `'en-us'`) — the same normalization the SDK applies internally. |
393
+ | `useNotifyNavigation(location)` | `(location: unknown) => void` | Tells the SDK the route changed whenever `location` changes. See [Route changes](#route-changes). |
394
+ | `useRenderServerMessage()` | `() => (entry, category?) => string` | Renders server message entries, re-rendering when the locale changes. See [Server messages](#server-messages). |
395
+ | `useWriteEnabled()` | `() => boolean \| undefined` | Whether this session may register content, as decided by the server. See [Write gating](#write-gating) — the `undefined` state is meaningful. |
396
+
397
+ ## Route changes
398
+
399
+ Call `useNotifyNavigation` once, anywhere under your router, with the router's current location:
400
+
401
+ ```tsx
402
+ import { useNotifyNavigation } from 'langsys-js-react';
403
+
404
+ // React Router
405
+ function NavigationNotifier() {
406
+ useNotifyNavigation(useLocation().key);
407
+ return null;
408
+ }
409
+
410
+ // Next.js App Router
411
+ function NavigationNotifier() {
412
+ useNotifyNavigation(usePathname() + '?' + useSearchParams());
413
+ return null;
414
+ }
415
+ ```
416
+
417
+ Content that stays mounted across a route change — a header, a sidebar, a persistent layout — is not re-rendered by React when only the route changes, so without this the SDK never learns that its phrases now appear on the new page, and discovery never credits that page with them. The hook tells the SDK the route changed; every mounted translated node then looks itself up again at the new URL. Everything else — what is reported, when, and how often — is decided by the SDK.
418
+
419
+ If your router exposes its own after-navigation callback, calling `notifyNavigation()` there is equivalent.
420
+
421
+ ## Server messages
422
+
423
+ A Langsys server SDK leaves your framework's own error response exactly as it is and attaches a list of entries beside it — for Laravel, under `langsys_errors` next to the usual `errors` map in the 422 body. Each entry carries `template`, the source sentence with `{markers}`; `params`, the values that fill them; and `message`, the same sentence already filled. It also passes through what the framework reports about the failure, unchanged: `field`, in the framework's own path format, and `code`, the framework's own identifier for the failure (Laravel's `Min`, Pydantic's `string_too_short`) — use it for your logic, never to choose text.
424
+
425
+ `resolveServerMessages(body, { key })` reads the entries from where the server attached them; `useRenderServerMessage()` renders them in the current locale:
426
+
427
+ ```tsx
428
+ import { resolveServerMessages, useRenderServerMessage } from 'langsys-js-react';
429
+
430
+ function FormErrors({ body }: { body: unknown }) {
431
+ const render = useRenderServerMessage();
432
+ return (
433
+ <ul>
434
+ {resolveServerMessages(body, { key: 'langsys_errors' }).map((entry, i) => (
435
+ <li key={i}>{render(entry)}</li>
436
+ ))}
437
+ </ul>
438
+ );
439
+ }
440
+ ```
441
+
442
+ Pass the same `key` the server SDK is configured with — a dotted path such as `meta.langsys` works too — or a `resolver` that maps the body to entries yourself. One of the two is required: the body is never searched by shape. If the server renames the pieces, pass `pieces`, e.g. `{ template: 'sentence', message: 'text' }`. An entry needs a `template` or a `message`.
443
+
444
+ An entry renders as the translation of its `template`, filled from `params`, when the catalog has one, and as its `message` otherwise — so a message nobody has translated yet still reads correctly. An entry with no `template` shows its `message`, and `message` is never used as a lookup key. Templates are looked up under one category, `Errors` unless `messagesCategory` is set in `init`, and it has to match the category the server registers them under. An app that renames that category sets `messagesPreviousCategory` to the old name, on the server and in `init` alike: a template with no translation under the new category then renders the old category's translation, and its `message` only when neither has one. The rendering function changes identity whenever the catalog or locale does, so it can be passed to memoised children.
445
+
446
+ ### Inertia
447
+
448
+ After a failed form, the server SDK flashes the entries and shares them with the page it redirects to as a prop under the same key (`langsys_errors` in the Laravel package, unless configured otherwise). Resolve them from the page's props:
449
+
450
+ ```tsx
451
+ import { usePage } from '@inertiajs/react';
452
+
453
+ function CardsNew() {
454
+ const render = useRenderServerMessage();
455
+ return resolveServerMessages(usePage().props, { key: 'langsys_errors' }).map((entry, i) => (
456
+ <p key={i}>{render(entry)}</p>
457
+ ));
458
+ }
459
+ ```
460
+
461
+ A page that follows no failure has no such prop, and renders nothing.
462
+
463
+ ## Migrating from key-based i18n
464
+
465
+ An app moving from i18next or vue-i18n can keep calling `t()` with its existing keys. Give `init` the source-language files it already has:
466
+
467
+ ```tsx
468
+ import en from './locales/en.json';
469
+
470
+ LangsysApp.init({
471
+ projectid,
472
+ key,
473
+ UserLocaleStore: store,
474
+ legacyKeys: [{ name: 'en.json', format: 'i18next', data: en }],
475
+ });
476
+
477
+ const t = useT();
478
+ t('checkout.title', undefined, { name: 'Ada' }); // "Checkout for Ada" — registered as "Checkout for {name}" under "checkout"
479
+ ```
480
+
481
+ `t()` then resolves its argument as a key first. A key the files hold becomes its value, converted to Langsys placeholders and ICU plurals, and that value — never the key — is the phrase Langsys registers and translates; the key's first segment is its category unless the call passes one. An argument the files do not hold is literal source text. Formats are `i18next`, `vue-i18n` and `plain` (the default); any other makes `init` throw a `LegacyFormatError` naming the file. Leave `legacyKeys` unset and `t()` does no key lookup at all. `LangsysApp.Translations.setLegacyKeys(files | null)` turns the mode on or off later.
482
+
483
+ ## Catalog snapshots
484
+
485
+ A snapshot is a project's catalog exported to a file, so a first paint or an offline session renders translations with no API call. Load it before the first render:
486
+
487
+ ```tsx
488
+ import snapshot from './langsys-snapshot.json';
489
+
490
+ LangsysApp.loadSnapshot(snapshot, 'es-es'); // synchronous; false if it holds no catalog for the locale
491
+ createRoot(el).render(<App />); // the first render already has its translations
492
+ ```
493
+
494
+ The snapshot is a cache, not the catalog of record: `init()` still fetches the catalog, which replaces it and supplies any phrase it lacked, and with no network anything it lacks shows its source text. Never edit a snapshot by hand — export it again. An edited file, a different format, an unsupported version or a missing member is refused with a `SnapshotError` whose `reason` says which.
495
+
496
+ ## Write gating
497
+
498
+ A write-capable key shipped in public JavaScript is extractable, so **public keys are read-only**. The server decides per session whether that session may register newly-discovered phrases, and the SDK follows that decision — you never compute it client-side, because the same key can be write-enabled from one IP and read-only from another.
499
+
500
+ When a session is read-only, nothing is lost: the SDK reports the *page URL only* (never phrase content) so Langsys can visit that page from an allow-listed address and register what it finds.
501
+
502
+ ### `useWriteEnabled()`
503
+
504
+ ```tsx
505
+ const writeEnabled = useWriteEnabled(); // boolean | undefined
506
+ ```
507
+
508
+ Three distinct states — **don't collapse them to a boolean**:
509
+
510
+ | Value | Meaning |
511
+ |---|---|
512
+ | `undefined` | Authorization hasn't landed yet. Not the same as read-only. Also the value throughout SSR, since this is decided in the browser. |
513
+ | `false` | Read-only session. The SDK may report the page URL instead, subject to the key's auto-discovery permission. |
514
+ | `true` | This session registers content directly. |
515
+
516
+ Defaulting `undefined` to `false` will render a read-only state during every first paint and, under SSR, cause a hydration mismatch.
517
+
518
+ This package deliberately does **not** re-export the raw `writeEnabled` signal. It is `undefined` for the whole of a server render, so reading it directly during render defeats the guard `useWriteEnabled()` provides. If you genuinely need it outside React's render cycle, import it from `langsys-js-typescript` directly — the capability is available, it just isn't blessed under this binding's name.
519
+
520
+ ### Write grants — for login-walled apps
521
+
522
+ Content behind a login can't be reached by an external visit, so pass a short-lived JWT your backend mints at login. The server then treats the session as write-enabled.
523
+
524
+ ```tsx
525
+ LangsysApp.init({
526
+ projectid,
527
+ key,
528
+ UserLocaleStore: store,
529
+ // Prefer the function form: grants are short-lived (~5 min) and the SDK
530
+ // resolves it fresh before each request, so your auth layer decides when to
531
+ // mint. A static string expires while the app is still mounted.
532
+ writeGrant: () => auth.getLangsysGrant(),
533
+ });
534
+ ```
535
+
536
+ If the token only exists *after* `init()` — the usual case — supply it when the user logs in:
537
+
538
+ ```tsx
539
+ import { setWriteGrant } from 'langsys-js-react';
540
+
541
+ await setWriteGrant(token); // re-authorizes; resolves once the server has answered
542
+ await setWriteGrant(undefined); // clear on logout — session returns to read-only
543
+ ```
544
+
545
+ `setWriteGrant()` re-authorizes rather than merely storing the token, so `useWriteEnabled()` reflects the new decision once it resolves. Misses occurring *after* the grant lands register directly; earlier ones were already reported through the URL-reporting path.
256
546
 
257
547
  ## Server-Side Rendering (Next.js, Remix)
258
548
 
259
- The SDK is SSR-compatible. The main pattern is to pre-fetch translations server-side and seed them through `initialTranslations` / `initialTranslationsLocale` so the client doesn't refetch on hydration. `useT` and friends are built on `useSyncExternalStore` with a server snapshot, so they hydrate without a flash of untranslated content when seeded.
549
+ Each server request renders inside a **request scope** of its own, with its own locale, catalog and misses, so concurrent visitors never see each other's language. `renderInRequestScope` from `langsys-js-react/server` opens one around a render. Once the server process has called `LangsysApp.init()`, `useT()`, `<Translate>` and `<Phrase>` render the request's locale on the server, so the served HTML is translated, apart from the blocks [README-SSR](./README-SSR.md#what-a-server-render-translates-and-discovers) lists as translated in the browser. The scope's seed, handed to the browser before hydration (`LangsysApp.seedCatalog`, or `<LangsysProvider seed>` in the Next App Router), makes the first client render match it.
260
550
 
261
- 📖 **See [README-SSR.md](./README-SSR.md)** for a complete Next.js (App Router & Pages Router) walkthrough.
551
+ 📖 **See [README-SSR.md](./README-SSR.md)** for the walkthrough, including the Next.js App Router.
262
552
 
263
553
  ## Utilities
264
554
 
@@ -289,20 +579,46 @@ const supportedLocales = (await LangsysApp.getLocalesFlat()).map((l) => l.code);
289
579
  const locale = LangsysApp.detectPreferredLocale(request.headers.get('Accept-Language'), supportedLocales);
290
580
  ```
291
581
 
292
- The matcher tries exact match first (e.g. `en-US`), then language-only (`en` matches `en-GB`), and is script-aware via CLDR likely-subtags (base SDK 0.3.0+): `zh-TW` matches `zh-Hant` and never falls back to `zh-Hans`. Results are always canonical BCP 47; it returns `false` if no match.
582
+ The matcher tries exact match first (e.g. `en-US`), then language-only (`en` matches `en-GB`), and is script-aware via CLDR likely-subtags (base SDK 0.3.0+): `zh-TW` matches `zh-Hant` and never falls back to `zh-Hans`. Results are returned in the SDK's canonical form, which is **lowercase** (`en-us`, `zh-hant`) — see the note under [Initialization](#initialization); it returns `false` if no match.
583
+
584
+ ### Asking your own API for the user's language
585
+
586
+ `localeHeaders()` returns the request header for the locale the user chose, so your app's calls to its own API answer in that language:
587
+
588
+ ```ts
589
+ import { localeHeaders } from 'langsys-js-react';
590
+
591
+ fetch('/api/orders', { headers: { ...localeHeaders() } }); // Accept-Language: es-es
592
+ ```
293
593
 
294
594
  ### Waiting for translations to load
295
595
 
296
- When changing locale mid-session, you may want to re-run dependent code after the new translations arrive:
596
+ When changing locale mid-session, you may want to re-run dependent code once the fetch ends:
297
597
 
298
598
  ```tsx
299
599
  useEffect(() => {
300
600
  LangsysApp.translationsLoadingPromise.then(() => {
301
- // re-render content / regenerate UI here
601
+ // re-run dependent code here
302
602
  });
303
603
  }, [locale]);
304
604
  ```
305
605
 
606
+ `translationsLoadingPromise` settles when the catalog fetch **ends**, whether it succeeded or failed. It never rejects.
607
+
608
+ - **On success**, the new catalog is in place when it settles, so `t()` already returns the new locale's text. `useCurrentLocale()` follows about 100 ms later, so read it from a later render, not inside `.then()`.
609
+ - **On failure**, the page renders the source text, or the catalog stored for that locale from an earlier load. `useCurrentLocale()` keeps naming the last locale that loaded (`''` before any has) and never reaches the requested one, so a component that waits only for `useCurrentLocale() === requested` waits forever.
610
+
611
+ Treat a settled promise **and** `useCurrentLocale() === requested` as the only sign that the switch worked:
612
+
613
+ ```tsx
614
+ // loaded -> settled && currentLocale === locale
615
+ // not yet -> anything else
616
+ ```
617
+
618
+ A mismatch is not a failure test: right after a successful fetch settles, the locale has not been written yet. Nothing public tells a failed fetch apart from one that succeeded a moment ago, so if you need an error state, use your own timeout. The SDK logs a failed fetch to the console as an error.
619
+
620
+ After a failure, the SDK does not fetch that locale again for a backoff window (3 s, doubling with each consecutive failure up to 5 minutes); switching back to it inside the window renders what the failure did. `LangsysApp.refresh()` fetches at once; await the promise it returns, since it does not replace `translationsLoadingPromise`. Setting the locale store to the value it already holds does nothing.
621
+
306
622
  ## License
307
623
 
308
624
  MIT © Langsys
@@ -0,0 +1,61 @@
1
+ import { LangsysApp as LangsysApp$1, iLangsysInitConfig as iLangsysInitConfig$1, Signal, iLangsysResponse } from 'langsys-js-typescript';
2
+
3
+ /**
4
+ * The entry point and its locale store, in a module that imports no React, so the main entry and
5
+ * `langsys-js-react/server` export the same `LangsysApp` and `createLocaleStore`. A server
6
+ * process, a Next Server Component included, initializes the SDK through the server entry
7
+ * without loading React's hooks.
8
+ */
9
+
10
+ /**
11
+ * React-flavored init config. Identical to the base SDK's config except
12
+ * `UserLocaleStore` is typed as a `Signal<string>` — create one with
13
+ * `createLocaleStore()` (or get one from the `useLocaleStore` hook). The base
14
+ * SDK only reads and subscribes to it.
15
+ */
16
+ interface iLangsysInitConfig extends Omit<iLangsysInitConfig$1, 'UserLocaleStore'> {
17
+ UserLocaleStore: Signal<string>;
18
+ }
19
+ /**
20
+ * React SDK entry point — the core singleton itself, re-exported **by
21
+ * reference**, with `init` typed to take the React-flavoured config.
22
+ *
23
+ * There is no wrapper and no list of delegated methods, so every public member
24
+ * of the core is reachable here the moment it exists, and identity is
25
+ * preserved: `LangsysApp` here **is** the core's `LangsysApp`. This binding
26
+ * overrides no behaviour, so the only thing it needs to express is a type.
27
+ *
28
+ * Destructuring behaves exactly as on the core. `const { t } = LangsysApp` is
29
+ * safe — `t` is a getter returning a closure. Prototype methods are not:
30
+ * `const { refresh } = LangsysApp` detaches the receiver and the call throws.
31
+ * Call methods on `LangsysApp` itself.
32
+ *
33
+ * The type narrows `init` to require a `Signal<string>` for `UserLocaleStore`.
34
+ * The core accepts the broader `LocaleSource`, which `Signal<string>`
35
+ * satisfies. Because the type is `Omit<typeof _LangsysApp, 'init'> & {…}` —
36
+ * keyof-mapped — it does not expose the core's private members: calling one is
37
+ * a compile error.
38
+ *
39
+ * Pinned by `src/surface.test.ts`.
40
+ */
41
+ declare const LangsysApp: Omit<typeof LangsysApp$1, 'init'> & {
42
+ init(config: iLangsysInitConfig): Promise<iLangsysResponse>;
43
+ };
44
+ /**
45
+ * Create a reactive `Signal<string>` to hold the user's selected locale — the
46
+ * React analog of Svelte's `writable('en-US')`.
47
+ *
48
+ * Pass the result as `UserLocaleStore` to `LangsysApp.init`, read it reactively
49
+ * with `useSignal(store)` (or use the all-in-one `useLocaleStore` hook), and
50
+ * switch locale with `store.set('fr-FR')`. The base SDK only ever reads and
51
+ * subscribes to it — it never writes.
52
+ *
53
+ * Locale identifiers are canonicalized to BCP 47 by the base SDK (v0.3.0+), so
54
+ * `'en-us'` still works on input — but `currentlyLoadedLocale` always emits the
55
+ * canonical form (`'en-us'` — lowercase, per WIRE-3), so compare against the
56
+ * lowercase form. Verified against the core, not assumed: `canonicalizeLocale`
57
+ * lowercases both subtags, so a consumer testing `=== 'en-US'` never matches.
58
+ */
59
+ declare function createLocaleStore(initial?: string): Signal<string>;
60
+
61
+ export { LangsysApp as L, createLocaleStore as c, type iLangsysInitConfig as i };
@@ -0,0 +1,61 @@
1
+ import { LangsysApp as LangsysApp$1, iLangsysInitConfig as iLangsysInitConfig$1, Signal, iLangsysResponse } from 'langsys-js-typescript';
2
+
3
+ /**
4
+ * The entry point and its locale store, in a module that imports no React, so the main entry and
5
+ * `langsys-js-react/server` export the same `LangsysApp` and `createLocaleStore`. A server
6
+ * process, a Next Server Component included, initializes the SDK through the server entry
7
+ * without loading React's hooks.
8
+ */
9
+
10
+ /**
11
+ * React-flavored init config. Identical to the base SDK's config except
12
+ * `UserLocaleStore` is typed as a `Signal<string>` — create one with
13
+ * `createLocaleStore()` (or get one from the `useLocaleStore` hook). The base
14
+ * SDK only reads and subscribes to it.
15
+ */
16
+ interface iLangsysInitConfig extends Omit<iLangsysInitConfig$1, 'UserLocaleStore'> {
17
+ UserLocaleStore: Signal<string>;
18
+ }
19
+ /**
20
+ * React SDK entry point — the core singleton itself, re-exported **by
21
+ * reference**, with `init` typed to take the React-flavoured config.
22
+ *
23
+ * There is no wrapper and no list of delegated methods, so every public member
24
+ * of the core is reachable here the moment it exists, and identity is
25
+ * preserved: `LangsysApp` here **is** the core's `LangsysApp`. This binding
26
+ * overrides no behaviour, so the only thing it needs to express is a type.
27
+ *
28
+ * Destructuring behaves exactly as on the core. `const { t } = LangsysApp` is
29
+ * safe — `t` is a getter returning a closure. Prototype methods are not:
30
+ * `const { refresh } = LangsysApp` detaches the receiver and the call throws.
31
+ * Call methods on `LangsysApp` itself.
32
+ *
33
+ * The type narrows `init` to require a `Signal<string>` for `UserLocaleStore`.
34
+ * The core accepts the broader `LocaleSource`, which `Signal<string>`
35
+ * satisfies. Because the type is `Omit<typeof _LangsysApp, 'init'> & {…}` —
36
+ * keyof-mapped — it does not expose the core's private members: calling one is
37
+ * a compile error.
38
+ *
39
+ * Pinned by `src/surface.test.ts`.
40
+ */
41
+ declare const LangsysApp: Omit<typeof LangsysApp$1, 'init'> & {
42
+ init(config: iLangsysInitConfig): Promise<iLangsysResponse>;
43
+ };
44
+ /**
45
+ * Create a reactive `Signal<string>` to hold the user's selected locale — the
46
+ * React analog of Svelte's `writable('en-US')`.
47
+ *
48
+ * Pass the result as `UserLocaleStore` to `LangsysApp.init`, read it reactively
49
+ * with `useSignal(store)` (or use the all-in-one `useLocaleStore` hook), and
50
+ * switch locale with `store.set('fr-FR')`. The base SDK only ever reads and
51
+ * subscribes to it — it never writes.
52
+ *
53
+ * Locale identifiers are canonicalized to BCP 47 by the base SDK (v0.3.0+), so
54
+ * `'en-us'` still works on input — but `currentlyLoadedLocale` always emits the
55
+ * canonical form (`'en-us'` — lowercase, per WIRE-3), so compare against the
56
+ * lowercase form. Verified against the core, not assumed: `canonicalizeLocale`
57
+ * lowercases both subtags, so a consumer testing `=== 'en-US'` never matches.
58
+ */
59
+ declare function createLocaleStore(initial?: string): Signal<string>;
60
+
61
+ export { LangsysApp as L, createLocaleStore as c, type iLangsysInitConfig as i };