langsys-js-typescript 0.6.4 → 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
@@ -1,10 +1,17 @@
1
1
  # Langsys SDK — TypeScript
2
2
 
3
- Framework-agnostic TypeScript SDK for the [Langsys](https://Langsys.dev/) Translation Manager. Realtime, continuous translations with automatic token discovery — no framework required.
3
+ [![npm](https://img.shields.io/npm/v/langsys-js-typescript.svg?style=flat)](https://www.npmjs.com/package/langsys-js-typescript)
4
+ [![build](https://img.shields.io/github/actions/workflow/status/langsys/langsys-js-typescript/ci.yml?style=flat)](https://github.com/langsys/langsys-js-typescript/actions)
5
+ [![last commit](https://img.shields.io/github/last-commit/langsys/langsys-js-typescript.svg?style=flat)](https://github.com/langsys/langsys-js-typescript/commits)
6
+ [![commit activity](https://img.shields.io/github/commit-activity/m/langsys/langsys-js-typescript.svg?style=flat)](https://github.com/langsys/langsys-js-typescript/pulse)
7
+ [![bundle size](https://img.shields.io/bundlejs/size/langsys-js-typescript?style=flat)](https://bundlejs.com/?q=langsys-js-typescript)
8
+ [![types](https://img.shields.io/npm/types/langsys-js-typescript.svg?style=flat)](https://www.npmjs.com/package/langsys-js-typescript)
9
+ [![downloads](https://img.shields.io/npm/dm/langsys-js-typescript.svg?style=flat)](https://www.npmjs.com/package/langsys-js-typescript)
10
+ [![license](https://img.shields.io/npm/l/langsys-js-typescript.svg?style=flat)](./LICENSE)
4
11
 
5
- This is the base SDK. Framework-specific bindings (`langsys-js-svelte`, and similar packages for React / Vue / Solid that can be built on top of this) are thin wrappers that add idiomatic framework reactivity around the same core.
12
+ Framework-agnostic TypeScript SDK for the [Langsys](https://Langsys.dev/) Translation Manager. Realtime, continuous translations with automatic token discovery — no framework required.
6
13
 
7
- [![NPM License](https://img.shields.io/npm/l/all-contributors.svg?style=flat)](./LICENSE)
14
+ This is the base SDK. Framework-specific bindings (`langsys-js-svelte`, `langsys-js-react`, `langsys-js-vue`) are thin wrappers that add idiomatic framework reactivity around the same core.
8
15
 
9
16
  ## What's inside
10
17
 
@@ -12,9 +19,12 @@ This is the base SDK. Framework-specific bindings (`langsys-js-svelte`, and simi
12
19
  - `t(phrase, category?, params?)` — the everyday translation function. The phrase is both the lookup key *and* the base-language default. Curly-brace placeholders interpolate from `params` and are compile-time type-checked.
13
20
  - `tSignal: Signal<TFunction>` — reactive primitive that re-emits a fresh `t` on every translations/locale change. Framework bindings subscribe to this to drive re-renders.
14
21
  - `Translate` class — wraps a DOM element, walks text + translatable attributes, registers a content block with the Translation Manager, re-translates on locale change.
22
+ - `Phrase` class — wraps one sentence that contains inline markup, so it registers and translates whole.
23
+ - Server rendering — request scopes that keep one visitor's catalog and locale from the next, content blocks rendered without a DOM, hydration seeds and catalog snapshots.
24
+ - `langsys-js-typescript/pure` — the identity rules (content-block ids, locale canonicalization, interpolation) with no DOM, for servers, workers and build scripts.
15
25
  - `Signal<T>` — tiny subscribe/set/update/get primitive. Compatible with Svelte's store contract by design, so the Svelte binding is nearly trivial.
16
26
  - `LangsysAppAPI` — direct HTTP access if you want to bypass everything else.
17
- - Zero runtime dependencies. Works in browsers, Node, any bundler.
27
+ - One runtime dependency (`intl-messageformat`, for ICU plurals and gender selects). About 37 kB minified and gzipped, 46 kB with `intl-messageformat` included. Works in browsers, Node, any bundler.
18
28
 
19
29
  ## Install
20
30
 
@@ -22,6 +32,112 @@ This is the base SDK. Framework-specific bindings (`langsys-js-svelte`, and simi
22
32
  npm install langsys-js-typescript
23
33
  ```
24
34
 
35
+ ### No build step? Use the browser build
36
+
37
+ For a plain HTML page with no bundler, import the self-contained browser build —
38
+ it inlines its one dependency so there is nothing for the browser to resolve:
39
+
40
+ ```html
41
+ <script type="module">
42
+ import { LangsysApp, createSignal } from './node_modules/langsys-js-typescript/dist/langsys.browser.mjs';
43
+ </script>
44
+ ```
45
+
46
+ The same build is the package's `langsys-js-typescript/browser` export. Or, without modules at all — exposes a `Langsys` global:
47
+
48
+ ```html
49
+ <script src="./node_modules/langsys-js-typescript/dist/langsys.browser.global.js"></script>
50
+ <script>
51
+ Langsys.LangsysApp.init({ /* ... */ });
52
+ </script>
53
+ ```
54
+
55
+ Package consumers (Vite, webpack, Next, SvelteKit, Node) should keep importing
56
+ `langsys-js-typescript` normally — the main entry leaves `intl-messageformat`
57
+ external so your bundler dedupes it against its own copy. **Do not load
58
+ `dist/index.mjs` directly in a browser:** it imports the bare specifier
59
+ `intl-messageformat`, which a browser cannot resolve without an import map, and
60
+ the failure is silent — the file returns 200 and the module body simply never
61
+ executes.
62
+
63
+ ### Pointing at a different API host
64
+
65
+ The SDK talks to `https://api.langsys.dev/api` by default. To run it against a
66
+ local instance, a staging host, or a stateful test double, pass `apiUrl` to
67
+ `init`:
68
+
69
+ ```ts
70
+ import { LangsysApp, createSignal } from 'langsys-js-typescript';
71
+
72
+ await LangsysApp.init({
73
+ projectid: '...',
74
+ key: '...',
75
+ UserLocaleStore: createSignal('en-us'),
76
+ apiUrl: 'http://localhost:8000/api', // no trailing slash needed
77
+ });
78
+ ```
79
+
80
+ Same from the browser build, via the global:
81
+
82
+ ```html
83
+ <script src=".../dist/langsys.browser.global.js"></script>
84
+ <script>
85
+ Langsys.LangsysApp.init({
86
+ projectid: '...',
87
+ key: '...',
88
+ UserLocaleStore: Langsys.createSignal('en-us'),
89
+ apiUrl: 'http://localhost:8000/api',
90
+ });
91
+ </script>
92
+ ```
93
+
94
+ This is what integration tests should use to point the SDK at a stateful test
95
+ double rather than the live API. Patching the built artifact is never necessary.
96
+
97
+ > **Prefer `apiUrl` over `LangsysAppAPI.setBaseUrl()`.**
98
+ > `setBaseUrl` still exists and still works, but it has to run **before**
99
+ > `init()`, and running it after leaves the SDK **permanently inert**. `init()`
100
+ > will already have authorized against the default host, and with a key for a
101
+ > different environment that authorization fails; the locale subscription is
102
+ > installed closed over that failed result, so **it no-ops for the life of the
103
+ > app**. Later locale changes won't recover it and neither will fixing the URL
104
+ > afterwards — only calling `init()` again will.
105
+ >
106
+ > Nothing throws, so the symptom is simply that no translations ever arrive.
107
+ > `apiUrl` is applied inside `init()` before authorization, in the same call
108
+ > that consumes it, so the ordering cannot be got wrong.
109
+
110
+ **Always check what `init()` returns.** A failed authorization — wrong key,
111
+ wrong host, unreachable server — is reported there and nowhere else:
112
+
113
+ ```ts
114
+ const res = await LangsysApp.init({ /* ... */ });
115
+ if (!res.status) console.error('Langsys init failed', res.errors);
116
+ ```
117
+
118
+ ### Running without credentials
119
+
120
+ `init()` without a project id, an API key or a locale store does not throw: it
121
+ resolves `{ status: false, errors }` and leaves the client unconfigured, so a
122
+ preview build or a test run without secrets still renders. `t()` shows the
123
+ source text with its params filled and makes no request, and a `<Translate>`
124
+ block's registration resolves skipped with reason `not-configured`. An explicit
125
+ write, such as `LangsysAppAPI.createTranslatableItems`, throws
126
+ `LangsysConfigurationError`. `LangsysApp.isConfigured()` reports the state.
127
+
128
+ To fail loudly instead, pass `requireCredentials: true`: `t()` then throws
129
+ `LangsysConfigurationError` while the client is unconfigured. The core's
130
+ `Phrase` and `Translate` still construct without throwing, and their
131
+ asynchronous render rejects with that error instead, as does the promise
132
+ `registerBlock` returns for a block rendered as a tree. That holds for a
133
+ component mounted before `init()` too: it rejects once `init()` refuses. The
134
+ reference lists fail the same way: each list call rejects with that error, and
135
+ `getLocaleName`, which is synchronous, throws it. The option fails on the first
136
+ `t()` call, registration or reference read, so a block that only renders never
137
+ fails: one with `register: false`, on any path, and one rendered
138
+ through `renderBlock` alone and never passed to `registerBlock`. Both show their
139
+ source text.
140
+
25
141
  ## Quick start
26
142
 
27
143
  ```ts
@@ -29,14 +145,14 @@ import { LangsysApp, createSignal, t } from 'langsys-js-typescript';
29
145
 
30
146
  // 1. A reactive holder for the user's current locale. Use `createSignal` if you
31
147
  // have nothing of your own — any object with subscribe/set/update/get works.
32
- const userLocale = createSignal('en-US');
148
+ const userLocale = createSignal('en-us');
33
149
 
34
150
  // 2. Initialize
35
151
  const response = await LangsysApp.init({
36
152
  projectid: process.env.LANGSYS_PROJECT_ID!,
37
153
  key: process.env.LANGSYS_API_KEY!,
38
154
  UserLocaleStore: userLocale,
39
- baseLocale: 'en-US',
155
+ baseLocale: 'en-us',
40
156
  debug: false,
41
157
  });
42
158
 
@@ -51,12 +167,18 @@ document.querySelector('#greeting')!.textContent =
51
167
  t('Hello, {name}!', 'Greetings', { name: 'Sarah' });
52
168
 
53
169
  // 5. Change locale — all subscribed consumers re-translate
54
- userLocale.set('es-ES');
170
+ userLocale.set('es-es');
55
171
  ```
56
172
 
173
+ ### Locale identifiers
174
+
175
+ Locale identifiers are lowercase `xx-yy` throughout the SDK. Whatever form you pass (`en-US`, `en_US`, `zh-Hant-TW`) is canonicalized where it enters, so `currentlyLoadedLocale`, `localeHeaders()`, `detectPreferredLocale()` and the catalog cache all hold `en-us` and `zh-hant-tw`. `canonicalizeLocale('en-US')` is `en-us`, and it resolves legacy aliases on the way (`iw-IL` is `he-il`). Compare locales in that form, or pass yours through `canonicalizeLocale` first.
176
+
57
177
  ### How `t()` works
58
178
 
59
- The signature is `t(phrase, category?, params?)` — the phrase comes first, the category is optional, params come last. The phrase is the lookup key. The first time `t('Welcome to my app', 'Home')` runs with a write key, Langsys registers the phrase in the Translation Manager under the "Home" category. On subsequent locale changes, the SDK fetches translations and `t()` returns the translated version. If no translation exists yet, you get back the original phrase — your base language stays visible while translations get filled in.
179
+ The signature is `t(phrase, category?, params?)` — the phrase comes first, the category is optional, params come last. The phrase is the lookup key. The first time `t('Welcome to my app', 'Home')` runs in a session that can write (see [Write capability](#write-capability)), Langsys registers the phrase in the Translation Manager under the "Home" category. On subsequent locale changes, the SDK fetches translations and `t()` returns the translated version. If no translation exists yet, you get back the original phrase — your base language stays visible while translations get filled in.
180
+
181
+ In a browser the catalog is cached in `localStorage` under its project and locale (`langsys:translations:<projectid>:<locale>`), so a reload renders that project's catalog for that locale while `init()` authorizes, and the fetched catalog then replaces it. The cache holds translations only; whether the session may write is asked of the server every time. Where there is no `localStorage`, `setPersistStorage(storage)` supplies a synchronous one, such as MMKV or an in-memory mirror of AsyncStorage on React Native.
60
182
 
61
183
  #### Compile-time-checked interpolation
62
184
 
@@ -68,9 +190,15 @@ t('Hello, {name}!', 'Greetings', {}); // ❌ Property '
68
190
  t('Hello, {name}!', 'Greetings', { name: 'x', extra: 'y' }); // ❌ Unknown property 'extra'
69
191
  ```
70
192
 
71
- Allowed value types: `string | number | Date | boolean`. Numbers and Dates format per the active locale's CLDR rules (`1234.5` → `1.234,5` in `de-DE`; Dates use the medium date style). Pass values as strings to opt out — e.g. IDs and codes that must not get grouping separators.
193
+ Allowed value types: `string | number | Date | boolean`. Numbers and Dates format per the active locale's CLDR rules (`1234.5` → `1.234,5` in `de-de`; Dates use the medium date style). Pass values as strings to opt out — e.g. IDs and codes that must not get grouping separators.
72
194
 
73
- > Translations containing ICU MessageFormat syntax — `{count, plural, one {# item} other {# items}}`, `{gender, select, …}`, `{n, number}`, `{d, date}` — are rendered with full CLDR plural rules for the active locale (Arabic's six categories, Russian's four, etc.). Plain `{name}` slots keep working unchanged.
195
+ > Translations containing ICU MessageFormat syntax — `{count, plural, one {# item} other {# items}}`, `{gender, select, …}`, `{n, number}`, `{d, date}` — are rendered with full CLDR plural rules for the active locale (Arabic's six categories, Russian's four, etc.), alongside plain `{name}` slots. Every request carries `X-Langsys-Capabilities: icu`, so the server sends plurals and selects as ICU for `t()` to render, and a select or plural renders its `other` branch when its argument is not passed.
196
+ >
197
+ > In a language with grammatical gender, Langsys can turn a plain `{name}` into `{name_gender, select, …}` in the translation. Pass `name_gender` beside `name` when you know it; it is typed as an optional param for every placeholder, and without it the neutral `other` branch renders:
198
+ >
199
+ > ```ts
200
+ > t('{name} has been invited', 'Team', { name: 'Ana', name_gender: 'female' });
201
+ > ```
74
202
 
75
203
  > **⚠️ Always pass values through `params` — never build the phrase yourself.**
76
204
  >
@@ -79,7 +207,7 @@ Allowed value types: `string | number | Date | boolean`. Numbers and Dates forma
79
207
  > t('Hello, {name}!', 'Greetings', { name }); // ✓
80
208
  > ```
81
209
  >
82
- > The template literal resolves *before* `t()` runs, so with a write key the SDK registers `"Hello, Sarah!"` as a brand-new phrase — and every distinct runtime value mints another one ("Hello, Priya!", "Hello, Ahmed!"). Your Translation Manager fills with untranslatable one-offs, and since projects share one catalog across SDKs, it pollutes what every other binding reads too. The SDK cannot detect this: an interpolated string is indistinguishable from an authored one. The `params` form registers the phrase once, with the placeholder intact for translators.
210
+ > The template literal resolves *before* `t()` runs, so in a session that can write the SDK registers `"Hello, Sarah!"` as a brand-new phrase — and every distinct runtime value mints another one ("Hello, Priya!", "Hello, Ahmed!"). Your Translation Manager fills with untranslatable one-offs, and since projects share one catalog across SDKs, it pollutes what every other binding reads too. The SDK cannot detect this: an interpolated string is indistinguishable from an authored one. The `params` form registers the phrase once, with the placeholder intact for translators.
83
211
 
84
212
  #### Categorization disambiguates context
85
213
 
@@ -90,12 +218,36 @@ t('Home', 'Home repairs'); // 'Hogar' in Spanish
90
218
 
91
219
  The same phrase in different categories can have different translations. Without categorization, "Home" would only have one. Langsys's philosophy: *translate once, use everywhere* — categorize when the same phrase legitimately means different things in different parts of the app.
92
220
 
93
- ### API key permissions
221
+ ### Write capability
222
+
223
+ Whether a session registers what it finds is the server's decision, made per session from the API key, the address the request comes from and any write grant. The server answers `write_enabled` when `init()` authorizes and again with every catalog it serves, so a session follows a change without a re-init. An answer without `write_enabled` is not write-enabled. The SDK never decides from the key's type, and never caches the decision.
224
+
225
+ - **`write` key** — every session can write: phrases `t()` misses and new content blocks are registered as your app runs. Keep it out of code you ship to the public.
226
+ - **`read` key** — fetches translations and registers nothing.
227
+ - **`ip_write` key** — can write only from the addresses on the key's allow-list, and is read-only everywhere else. Ship this one in a public site: visitors' browsers read, while your own allow-listed machines, and Langsys's discovery renderer, register.
94
228
 
95
- - **Write key** (development) — new phrases and content blocks discovered as your app runs are registered automatically.
96
- - **Read-only key** (production) — only fetches existing translations; no token creation, no content-block writes.
229
+ The answer is the `writeEnabled` signal: `undefined` until the server has answered, when misses are held rather than dropped, then `true` or `false`. It is only ever set in a browser, so on a server it stays `undefined`. When a `write` or `ip_write` key's session comes back read-only, the SDK logs one warning saying so, debug or not.
97
230
 
98
- Detected automatically from the validation response — you don't configure this manually.
231
+ #### Write grants
232
+
233
+ An app behind a login, which the discovery renderer cannot reach, makes its own users' sessions write-enabled with a write grant: a short-lived token your backend mints, sent as `X-Write-Grant`. Pass a function, which the SDK calls before every request so it always sends a fresh token, and return `null` until there is one:
234
+
235
+ ```ts
236
+ await LangsysApp.init({
237
+ projectid, key, UserLocaleStore,
238
+ writeGrant: () => session.langsysGrant ?? null, // called before each request, never cached
239
+ });
240
+
241
+ await setWriteGrant(() => fetchGrant()); // replace it later; the session re-authorizes
242
+ ```
243
+
244
+ Configure the provider at `init()` even when the token only exists after login. With no grant configured, a read-only answer means no grant can ever arrive, and what the session missed goes to discovery instead of waiting. A bare string works for a quick start, but grants expire within minutes. While a grant is configured, `ssrTokenStrategy` acts as `'client'`, since a grant makes the decision per user and a server process renders for many.
245
+
246
+ #### Content discovery from read-only sessions
247
+
248
+ A session that cannot write still tells Langsys where its untranslated content is. When `t()` or a content block misses, the SDK reports the page's URL, and nothing else, after a random 5–30 second delay and at most once per URL per browser session. Langsys's discovery renderer then loads that URL from an allow-listed address, where the same page registers what it shows. Reporting happens only where the key permits it, a setting on the key in Langsys that arrives as the `autoDiscovery` signal. Tracking parameters (`utm_*`, `gclid`, `fbclid` and the like) are stripped first, and a URL that carries a credential-shaped parameter (`token`, `password`, `session`, `email` and the like) is never reported. A session that can write registers and never reports, and nothing is reported from a server render.
249
+
250
+ A project can also limit both registering and reporting to pages shown in its base locale, a project setting the server sends with its answer, so a page served already translated never files its translated text as source.
99
251
 
100
252
  ## Reactivity for frameworks
101
253
 
@@ -145,9 +297,27 @@ Use the dedicated [`langsys-js-svelte`](https://github.com/langsys/langsys-js-sv
145
297
 
146
298
  The same pattern: subscribe to `tSignal` for invalidation, call the current `TFunction` for values. ~5-10 lines of binding code per framework.
147
299
 
300
+ ### Route changes: `notifyNavigation()`
301
+
302
+ Call `notifyNavigation()` after every client-side route change. Content that stays mounted across routes — a layout, a header, a sidebar — is otherwise never looked up again, so a phrase missing from it is reported for the first page only, and never for the pages the visitor moves to.
303
+
304
+ The call publishes a fresh `t` through `tSignal`, so anything bound to it re-renders, and each miss is recorded at the new URL. `Translate` and `Phrase` instances re-run their lookup too, but only while their element is still attached to the page. The call sends nothing itself, and a phrase already registered is not registered again.
305
+
306
+ ```ts
307
+ import { notifyNavigation } from 'langsys-js-typescript';
308
+
309
+ router.afterEach(() => notifyNavigation()); // Vue Router
310
+ afterNavigate(() => notifyNavigation()); // SvelteKit
311
+ router.events.subscribe((e) => { // Angular
312
+ if (e instanceof NavigationEnd) notifyNavigation();
313
+ });
314
+ ```
315
+
316
+ Framework bindings wire this for you from their router's after-navigation hook.
317
+
148
318
  ## The `Translate` class
149
319
 
150
- For larger blocks of HTML — articles, help text, multi-sentence markup — use `Translate` to wrap an existing DOM element. It tokenizes text nodes and translatable attributes, registers the block with the Translation Manager (so translators see your styled markup), and re-translates on locale change.
320
+ For larger blocks of HTML — articles, help text, multi-sentence markup — use `Translate` to wrap an existing DOM element. It tokenizes text nodes and translatable attributes, registers the block with the Translation Manager (so translators see your styled markup), and re-translates on locale change. It stamps the block's id on the element as `data-ls-contentblock`.
151
321
 
152
322
  ```ts
153
323
  import { Translate } from 'langsys-js-typescript';
@@ -159,13 +329,48 @@ const handle = new Translate(article, { category: 'Blog', label: 'Welcome post'
159
329
  handle.destroy();
160
330
  ```
161
331
 
332
+ > **Single-token content keeps its markup.**
333
+ > When the wrapped element's content resolves to a *single* text run,
334
+ > `Translate` writes the translation into that one text node and leaves the
335
+ > surrounding structure alone — so a `data-testid`, `id`, or ref on a child
336
+ > element survives:
337
+ >
338
+ > ```html
339
+ > <div> <!-- before -->
340
+ > <p data-testid="greeting">Welcome back.</p>
341
+ > </div>
342
+ > <div> <!-- after -->
343
+ > <p data-testid="greeting">Bentornato.</p>
344
+ > </div>
345
+ > ```
346
+ >
347
+ > Every other shape is translated node by node, in place, and keeps its
348
+ > structure too, including an element whose one token is an attribute, such as
349
+ > `<img alt="…">`. `Phrase` preserves markup in all cases.
350
+
351
+ What a block shows when it mounts is taken as provisional. A framework can mount a block with a placeholder in it, a Suspense fallback or a lazy child's spinner, and swap the real content in a moment later. `Translate` renders at once, but registers only once the element's structure has been quiet for 500ms. That is above React's `FALLBACK_THROTTLE_MS` (300ms), the least time React keeps a Suspense fallback on screen once it shows one, so a fallback React swaps out for its content is never what registers. If the structure changes later, it reads the block again, under the id its new content derives (your own `custom_id` stays), and registers that. Until the window first closes, a text change counts too, since a framework may swap a placeholder's text for its content in the same element; after that, only structure counts, so a framework updating a value's text never re-keys a block. A block whose placeholder outlives the window has already registered under the placeholder's id, and that registration stays; with `debug` on, a notice names both ids when the block re-keys. A list inside a block that grows or shrinks re-keys it, as mounting the block in that state would.
352
+
162
353
  Attributes honored on contained elements:
163
354
 
164
- - `placeholder`, `alt`, `title`, `aria-label`, `aria-placeholder`
165
- - `value` on `<button>`, `<input type="submit">`, `<input type="button">`
355
+ **Visible text**
356
+
357
+ - `placeholder`, `alt`, `title`, `label`
358
+ - `value` on `<button>`, `<input type="submit">`, `<input type="button">` — and *only* those; a text input's value is never rewritten
166
359
  - `<option>` text inside `<select>`
167
- - Several validation-message `data-*` attributes
168
- - `translate="no"` — elements marked this way (and their children) are skipped
360
+
361
+ **Screen-reader text** — `aria-label`, `aria-placeholder`, `aria-description`, `aria-valuetext`, `aria-roledescription`
362
+
363
+ > Leaving these untranslated has **no visible symptom**. Nothing on the page changes, so reviewing the rendered output cannot catch it — the failure is audible only to the users least able to work around it.
364
+
365
+ **Validation and status messages** — `data-error`, `data-error-message`, `data-validation-message`, `data-invalid-message`, `data-required-message`, `data-pattern-message`, `data-success-message`, `data-warning-message`, `data-empty-message`
366
+
367
+ **Tooltips, dialogs and widgets** — `data-confirm`, `data-tooltip`, `data-title`, `data-content`, `data-original-title`, `data-bs-title`, `data-bs-content`, `data-loading-text`, `data-placeholder`
368
+
369
+ **Opting out**
370
+
371
+ - `translate="no"` (case-insensitive), or `data-notrans` — `langsys-php`'s author-facing alias, honored here so an SSR-rendered page opts out consistently in both SDKs. Presence means intent; an explicit `="false"` or `="0"` opts back in.
372
+ - Elements marked this way, and their children, are skipped entirely.
373
+ - `<script>`, `<style>`, `<template>`, `<noscript>` and `<math>` are always skipped: code and notation are not prose. Text inside an `<svg>` is translated like any other.
169
374
 
170
375
  > **`Translate` tokenizes per text node — so inline markup splits a sentence.**
171
376
  >
@@ -175,6 +380,10 @@ Attributes honored on contained elements:
175
380
  >
176
381
  > registers **three** phrases: `"My content"`, `"is the best"`, `"when internationalized."` That's correct for a block of independent runs, and it's what `Translate` is for. But a translator sees three fragments with no context, and neither reordering nor grammatical agreement can cross a fragment boundary — fatal for a sentence whose word order or noun forms change with the language. When inline markup sits *inside a sentence*, reach for [`Phrase`](#the-phrase-class) instead.
177
382
 
383
+ ### Nested blocks
384
+
385
+ An element inside a block that carries `data-ls-contentblock` is a block of its own. The enclosing block leaves it out of its tokens and never writes into it, and the nested element registers and renders as its own unit, under its own `data-ls-category` when it names one. A `Translate` you construct on it manages it; otherwise the enclosing `Translate` does. When the markup does not mark the nested element and its own `Translate` adds the marker, construct that `Translate` before the enclosing block's, since the enclosing block reads its words when it is constructed. A bare, `true`, `1` or `yes` value declares a block, `false` or `0` opts out, and any other value is the block's id: it renders under that id, and registers under it when the catalog lacks it. Inside `data-ls-resolved`, on the element or an ancestor, which a server sets on content it already rendered from the catalog, nothing registers and no miss is recorded, since that text is a translation rather than source. An element marked `data-ls-phrase` is likewise its own [`Phrase`](#the-phrase-class). (`langsys-php`'s `data-langsys-*` spellings of these markers are read the same way.)
386
+
178
387
  ### Interpolation params
179
388
 
180
389
  `Translate` accepts the same single-brace `{key}` placeholders as `t()`. Pass `params` and every translated text node and attribute is interpolated after lookup — unknown keys stay visible as-is, and `Number`/`Date` values are formatted per the active locale's CLDR rules:
@@ -210,7 +419,7 @@ In compiled frameworks, bare `{key}` written in markup never reaches the SDK —
210
419
  </Translate>
211
420
  ```
212
421
 
213
- Both spellings are equivalent: `%key%` is normalized to canonical `{key}` at tokenization, so the Translation Manager, the wire, and translators only ever see `{key}`, and existing `{key}` content in vanilla HTML keeps working unchanged. Keys must be identifiers (`[A-Za-z_][A-Za-z0-9_]*`), so literal `%` signs in prose ("20% off") are never touched. `t()` phrases are plain JS strings with no compiler in the way — they stay `{key}`-only.
422
+ Both spellings are equivalent: `%key%` is normalized to canonical `{key}` at tokenization, so the Translation Manager, the wire, and translators only ever see `{key}`, and `{key}` content in vanilla HTML works as written. A translation that writes `%key%` renders like `{key}` for a key you pass. Keys must be identifiers (`[A-Za-z_][A-Za-z0-9_]*`), so literal `%` signs in prose ("20% off") are never touched. `t()` phrases are plain JS strings with no compiler in the way — they stay `{key}`-only.
214
423
 
215
424
  Writing `{key}` in markup fails *silently* — the base locale still looks right, so the mistake surfaces only once someone switches language. With `debug: true`, the SDK catches it for you: if you pass `params` whose keys have no matching placeholder in the captured content, it warns and names the fix.
216
425
 
@@ -223,6 +432,15 @@ instead.
223
432
 
224
433
  The check runs whenever the params key-set changes, so a ticking `count` won't spam the console, and it's silent in production.
225
434
 
435
+ ### Registering a block yourself
436
+
437
+ A binding that renders its own DOM registers through the same primitives `Translate` uses: `tokenizeElement(element)` gives a block's `tokens` and `content`, `generateCustomId(category, tokens)` its id, and `isContentBlockKnown(category, id)` whether the catalog holds it already. `registerContentBlock({ custom_id, category, content, tokens, label })` sends it, never throws, and resolves `{ status: true }` only once the server has stored the block:
438
+
439
+ - `{ status: false, skipped: true, reason }` when it did not send, on purpose: `not-configured`, `not-write-enabled` (the page is reported for discovery instead), `catalog-unavailable` (the catalog fetch failed, so an unknown block cannot be told from a registered one), `base-locale-gate`, or `deferred` (inside a request scope, which sends it at `close()`).
440
+ - `{ status: false, reason, errors }` when the request was made and did not succeed: `refused` (the server answered with a failure) or `failed` (the request did not complete).
441
+
442
+ Check `status`, not whether a result came back.
443
+
226
444
  ## The `Phrase` class
227
445
 
228
446
  `Phrase` wraps an element whose contents are **one** translatable sentence *even though it contains inline markup*. Where `Translate` splits at tag boundaries, `Phrase` keeps the run whole.
@@ -246,10 +464,82 @@ That registers as a single phrase — `Based on {n} {m0o}reviews{m0c}` — rathe
246
464
 
247
465
  Use it standalone, or nested inside a `Translate` block to protect specific runs — the block tokenizer skips any subtree carrying `data-ls-phrase` (exported as `PHRASE_MARKER_ATTR`), letting the `Phrase` own it. `setParams()` re-renders on a changed count, same as `Translate`.
248
466
 
249
- > **Who sets `data-ls-phrase`.** In the framework bindings it's internal — `<Phrase>` puts it on its own host element and you never write it yourself. In **vanilla usage it is yours to set**: the `Phrase` class does not add it, so a `Phrase` nested inside a `Translate` block needs the attribute present in your HTML *before* the wrapping `Translate` tokenizes, or the block will tokenize straight through the phrase and both will fight over the same nodes. Standalone `Phrase` doesn't need it at all. (Not to be confused with `langsys-php`'s `data-langsys-*` attributes, which are author-facing by design.)
467
+ An element you exclude inside a phrase, with `translate="no"` or `data-notrans`, is one empty pair in the key, whatever it holds: `Signed in as <span translate="no">a@b.c</span> today` is `Signed in as {m0o}{m0c} today` for every user, and the translator never sees the address. Code elements (`<script>`, `<style>` and the rest the tokenizer skips) are the same. The element renders as the page has it, the same node with its own content, and a `{key}` or `%key%` inside an excluded element is filled from the phrase's params as plain text: `Write to <b translate="no">%email%</b> for help` shows the address. A phrase made only of excluded content registers nothing. A phrase keyed with its excluded text by an earlier version, such as `Welcome to {m0o}Langsys{m0c}`, keeps rendering that translation until its current key has one, and only the current key registers.
468
+
469
+ > **Who sets `data-ls-phrase`.** In the framework bindings it's internal: `<Phrase>` puts it on its own host element and you never write it yourself. In **vanilla usage you write it** on the element, and the enclosing `Translate` then treats that element as one phrase and registers it whole, whether or not you construct a `Phrase` for it. Construct one when you need `setParams()`, and it takes over from the `Translate`. A nested `data-ls-contentblock` works the same way; see [Nested blocks](#nested-blocks). (`langsys-php`'s `data-langsys-*` spellings are read the same way.)
250
470
 
251
471
  If a translation drops or unbalances a markup token, rendering degrades to plain text with the markers stripped — the meaning survives even when the markup doesn't.
252
472
 
473
+ ## Migrating from i18n keys
474
+
475
+ An app on vue-i18n or i18next can move to Langsys without rewriting its call sites. Keep the source-language file, delete every other language's file, and hand the kept file to `init`:
476
+
477
+ ```ts
478
+ import en from './locales/en.json';
479
+
480
+ await LangsysApp.init({
481
+ projectid, key, UserLocaleStore,
482
+ legacyKeys: [{ name: 'locales/en.json', format: 'vue-i18n', data: en }],
483
+ });
484
+
485
+ t('checkout.submit'); // → "Pay now", registered under category "checkout"
486
+ t('Welcome back!'); // not a key: literal source text, as always
487
+ ```
488
+
489
+ `t()` resolves its argument as a key first. On a hit the key's value is the phrase — the key itself never reaches Langsys — so a later codemod can inline the English and delete the file without moving an id. The key's first segment is the category unless the call passes one.
490
+
491
+ Values are converted to Langsys syntax as they are read:
492
+
493
+ - **Placeholders**, whatever the format: `{{name}}`, `:name`, `%{name}` and `%(name)s` become `{name}`, and `%%` is a literal `%`.
494
+ - **Plurals**, by the file's `format`:
495
+ - `vue-i18n`: `car | cars` becomes `{count, plural, =1 {car} other {cars}}`, and three forms add `=0`. This keeps vue's selection by exact count.
496
+ - `i18next`: `items_one` / `items_other` become one plural under `items`, by CLDR category. The older `items` / `items_plural` pair works too.
497
+ - `plain`, the default: no plurals, so a `|` is text.
498
+ - **Anything the conversion cannot express**, such as a `|` in a `plain` file, `:Name`, or a formatted `%(amount).2f`, registers exactly as written and logs a warning naming the file and key.
499
+
500
+ A plural's argument is `count`, so pass `{ count }` in params. `name` is what errors report. `namespace` makes a per-namespace file answer only keys under that prefix, with the namespace as their category. With several files, the first that holds a key answers.
501
+
502
+ This core reads `i18next`, `vue-i18n` and `plain`. A file in another format (`laravel`, `rails-i18n`, `gettext`, or a name ending `.php`, `.yml`, `.po` or `.mo`) makes `init` throw `LegacyFormatError`, naming the format and the file. Leave `legacyKeys` unset once the migration is done, and `t()` does no key lookup at all.
503
+
504
+ `convertLegacyValue`, `convertLegacyPluralForms`, `convertLegacyCall` and `createLegacyKeys` are exported from `langsys-js-typescript/pure` too, so a server or a framework bridge converts through the same code and registers the same phrase.
505
+
506
+ ## Server messages
507
+
508
+ A Langsys server SDK leaves your framework's error response exactly as it is and attaches translatable entries beside it. Laravel, for example, puts them under `langsys_errors` next to its own `message` and `errors`. An entry is the framework's own sentence as a `template`, with the field's label written in and `{name}` markers for values such as numbers, plus the `params` that fill it. `message` is that sentence already filled. `field` and `code` are the framework's own, passed through unchanged: Laravel's rule name, Pydantic's error `type`, Django's `code`.
509
+
510
+ ```ts
511
+ import { renderServerMessage, resolveServerMessages } from 'langsys-js-typescript';
512
+
513
+ const res = await fetch('/api/signup', { method: 'POST', body });
514
+ if (!res.ok) {
515
+ for (const entry of resolveServerMessages(await res.json(), { key: 'langsys_errors' })) {
516
+ showError(entry.field, renderServerMessage(entry));
517
+ }
518
+ }
519
+ ```
520
+
521
+ - **`resolveServerMessages(body, { key })`** reads the entries at the path your server attaches them under, which is the same setting as the server's. Nothing else in the body is read or changed. If your entries use other piece names, map them with `pieces: { template: 'sentence', … }`. Pass `{ resolver }` to build entries from the body yourself. With neither `key` nor `resolver` it throws, because it has nowhere to look.
522
+ - **`renderServerMessage(entry, category?)`** renders the entry's `template` through `t()`, filled from its `params`, in the current locale. When the catalog has no translation for the template it shows the entry's `message`, which is the framework's own text. An entry with no `template` shows its `message` and is not looked up. `message` itself is never looked up. A count param drives an ICU plural in the translation with no ICU in your code.
523
+ - **Branch on `code`, never on text.** It is your framework's identifier, so the logic you already have keeps working, whatever language the text is in.
524
+ - **Category.** Templates are registered and rendered under `Errors`. If your server uses another category, set the same one with `messagesCategory` in `LangsysApp.init`, or every lookup misses.
525
+
526
+ `templateMarkers`, `fillTemplate`, `resolveServerMessages` and `toServerMessage` are also exported from `langsys-js-typescript/pure`, with no DOM.
527
+
528
+ ## Catalog snapshots
529
+
530
+ A snapshot is a project's catalog for chosen locales and categories, exported to a file. A mobile bundle, a first paint or an offline session can then render translations without calling the API. Any Langsys SDK's export tool writes the same `langsys-catalog-snapshot` format, and this SDK loads it synchronously:
531
+
532
+ ```ts
533
+ import snapshot from './langsys-snapshot.json';
534
+
535
+ LangsysApp.loadSnapshot(snapshot); // before first render; returns false if it has no catalog for the locale
536
+ t('Save', 'UI'); // → "Guardar", on the next line
537
+ ```
538
+
539
+ The locale is the user's current one, else the snapshot's base locale, or pass it as the second argument. The snapshot is a cache, not the catalog of record. `init()` still fetches the catalog, which replaces it and supplies any phrase it lacked. Registration waits for that live catalog too: nothing is registered, or skipped, on the snapshot's say-so. With no network the snapshot keeps rendering, and anything it lacks shows its source text.
540
+
541
+ Never edit a snapshot by hand; export it again. Its checksum covers the contents, so an edited file is refused with a `SnapshotError`, as are a different format, an unsupported version and a missing member. `buildSnapshot` writes the format for an export tool of your own. It and `parseSnapshot` are also on `langsys-js-typescript/pure`.
542
+
253
543
  ## Server-Side Rendering
254
544
 
255
545
  Pre-fetch translations on the server and seed them through `initialTranslations` to skip the duplicate client fetch on hydration:
@@ -259,9 +549,9 @@ await LangsysApp.init({
259
549
  projectid: env.LANGSYS_PROJECT_ID,
260
550
  key: env.LANGSYS_API_KEY,
261
551
  UserLocaleStore: userLocale,
262
- baseLocale: 'en-US',
552
+ baseLocale: 'en-us',
263
553
  initialTranslations, // fetched on the server
264
- initialTranslationsLocale: 'es-ES',
554
+ initialTranslationsLocale: 'es-es',
265
555
  ssrTokenStrategy: 'client', // 'client' | 'server' | 'auto'
266
556
  });
267
557
  ```
@@ -270,6 +560,130 @@ await LangsysApp.init({
270
560
  - `'server'` — flush tokens immediately from the server.
271
561
  - `'auto'` — small batches (≤5) flush from server, larger batches wait for the client.
272
562
 
563
+ > **Precondition for `'server'`, and for the server half of `'auto'`: allow-list the origin server's address.**
564
+ > Registration during a server render is sent from your server, not from the visitor's browser, so the write
565
+ > decision is computed for your server's own address. If that address is not on the key's allow-list, the server
566
+ > lane is read-only and fails silently: no error, no request, nothing in the catalog, and no discovery report
567
+ > either, since reports are never sent during a server render. Allow-list the address your server makes requests
568
+ > from before choosing `'server'` or `'auto'`, or keep the default `'client'`.
569
+
570
+ ### Rendering on a server: request scopes
571
+
572
+ A server process renders for many visitors at once, and the SDK's catalog, locale and miss queue are module state, so each request renders inside a **request scope** of its own. A scope has its own locale, its own view of the catalog, its own misses and its own hydration seed, and nothing one scope does reaches another.
573
+
574
+ ```ts
575
+ import { AsyncLocalStorage } from 'node:async_hooks';
576
+ import { createRequestScope, setRequestScopeStorage } from 'langsys-js-typescript';
577
+
578
+ setRequestScopeStorage(new AsyncLocalStorage()); // once, at startup
579
+
580
+ // per request, in your framework's request hook:
581
+ const scope = await createRequestScope({ locale: resolvedLocale, url: request.url });
582
+ const html = await scope.run(() => renderApp()); // t(), Translate and Phrase resolve to the scope
583
+ const seed = scope.seed(); // serialise into the page
584
+ // ...send the response, then:
585
+ await scope.close(); // sends what the render missed, after the response
586
+ ```
587
+
588
+ On the client, hydrate with `LangsysApp.seedCatalog(seed.catalog, seed.locale, seed)`, before the DOM classes mount. The seed's blocks give each served block's source tokens, and its blocks and phrases are marked `collected` when the server sends them itself at `close()`; the client never registers those.
589
+
590
+ - **`scopeFromSeed(seed)`** rebuilds the server's scope on the client, synchronously, from the seed the page carries: for a hydration render that must read what the server read, in a provider that cannot await. It sends nothing itself; its render's misses and blocks go to the page, which skips what the server collected.
591
+ - **Wrap `scope.t`, not `tSignal.get()`.** The `t` that `tSignal.get()` returns inside `run()` reads the catalog when it is called, so a component calling it later, outside `run()`, reads the page's catalog. `scope.t` is the scope's wherever it is called.
592
+ - **`createRequestScope({ locale, catalog?, url?, credentials? })`** resolves once the scope's catalog is in hand. That's the `catalog` you pass, or a fetch. A fetch is made at most once per request and shared read-only with scopes rendering the same locale under the same credentials for a minute. A failed fetch renders source text and records nothing.
593
+ - **`credentials: { projectid, key, writeGrant? }`** is for a process that renders for more than one project. The scope's catalog fetch and its `close()` go out under them wherever they run, and so does every request the render makes, however renders interleave, once `setRequestScopeStorage` has an `AsyncLocalStorage`. Without one, only a request made before the render's first `await` sees the scope, and a later one goes out under the credentials `init()` set; pass `{ credentials }` to a `LangsysAppAPI` call you make there. Without `credentials` the scope sends under the credentials `init()` set, and the page's own requests always do. A process can call `init()` without credentials and give every scope its own: its `apiUrl` and `ssrTokenStrategy` still apply. The scope's write capability is its key's, as its catalog fetch reports it, so a scope given both a `catalog` and credentials has no answer for its key, and its `close()` resolves `not-write-enabled` and leaves the misses to the client. Both a project id and a key, or `createRequestScope` rejects with `LangsysConfigurationError`. They are never part of `seed()`.
594
+ - **`setRequestScopeStorage`** is opt-in, and what it guarantees depends on the host's async model. Under zone.js, for one, promises don't carry it across concurrent renders. A binding can instead carry the scope through its own dependency injection and read `scope.t` directly.
595
+ - **`scope.run(fn)`** makes the scope current while `fn` runs, for `t()` and for `currentlyLoadedLocale.get()` and `sTranslations.get()` alike, and returns what `fn` returns, a promise included. Without `setRequestScopeStorage`, only code that finishes synchronously inside `fn` sees the scope; `scope.t` always works.
596
+ - **`scope.enter()`** makes the scope current for the rest of the current async context, for a host that cannot wrap its render in a function. It holds only in the continuation that itself performs the render: called inside a function the host awaits and returns from, such as a Nuxt plugin, a Nitro request hook or an awaited helper, the scope is gone when that function returns. So call `enter()` in the function that renders, use `run()` everywhere else, and hand the scope to the app through the framework's injection when the host awaits you. It needs the storage, and throws without it.
597
+ - **`scope.close()`** runs after the response. It sends the scope's misses when the key may write and the strategy collects on the server (`'server'`, or `'auto'` for a short list), and otherwise leaves them to the client. Its result names what happened. It never throws.
598
+ - `currentRequestScope()` returns the scope the current code runs in.
599
+
600
+ Framework bindings wire this for you through their own request hooks.
601
+
602
+ #### Content blocks without a DOM
603
+
604
+ A server render, or a client render before mount, has no DOM for the `Translate` class to walk, and inside a request scope that class does nothing. Instead, render the block from its content as a tree:
605
+
606
+ ```ts
607
+ import { renderBlock, registerBlock } from 'langsys-js-typescript';
608
+
609
+ const content = [{ tag: 'p', children: [{ text: 'Compare every plan' }] }, { tag: 'p', children: [{ text: 'Cancel anytime' }] }];
610
+ const { nodes, hostAttrs } = renderBlock(content, { category: 'Pricing' }); // synchronous, registers nothing
611
+ registerBlock(content, { category: 'Pricing' }); // discovery, deferred to scope.close()
612
+ ```
613
+
614
+ On a server, `registerBlock` registers only inside a request scope. Outside one there is no scope to hold the block for `close()`, so it registers nothing, resolves at once, and says so in one debug notice.
615
+
616
+ `renderBlock` returns the translated tree, the id, and `hostAttrs` to set on the host element: `data-ls-contentblock` always, and `data-ls-resolved` when the text came from the catalog in a non-base locale. Every element names its input element by `source`, its pre-order index, so a binding can re-attach handlers even where a translation reorders inline markup. `id` registers under your own `custom_id`; `customId` adopts a stamp the server rendered from the catalog. Nested hosts are marked the same way: a nested block gets its id, and a nested block or phrase host rendered from the catalog in a non-base locale gets `data-ls-resolved`. A nested host's element node can carry `params` of its own, `{ tag: 'span', attrs: { 'data-ls-phrase': true }, params: { name: owner.name }, children }`, which it renders with in place of the block's, so each unit names its values on its own and a nested phrase keys as it does standing alone.
617
+
618
+ On the client, `applyRendered(element, rendered)` writes a rendered block into the existing nodes in place: text, translatable attributes and nested hosts' markers. It refuses with `reason: 'structure'` when the markup's shape differs. When the tree was rendered from the host element itself rather than its children, as a standalone phrase host is, pass `{ self: true }` and the element is matched against the tree's root. `blockNodesOf(element)` turns a DOM host's content into the tree.
619
+
620
+ A page served translated holds the translation, and the catalog is keyed by source. Hand the scope's seed to the client with `seedCatalog`, as above, and register each block after `init()`:
621
+
622
+ ```ts
623
+ for (const block of Object.values(seed.blocks)) registerBlock(block); // registers nothing the server collected
624
+ ```
625
+
626
+ That loop is for bindings that mount the DOM classes. A binding that renders trees registers each block from its nodes as it renders, and `registerBlock` skips what the seed marks `collected` there too.
627
+
628
+ A `Translate` over a stamped host in a resolved scope then takes the block's source tokens from the seed, or, without one, from the catalog entry the served text was rendered from. After a locale switch, the host's own resolved marker names the locale it now holds. A `Phrase` over a resolved host takes its source phrase from the catalog the same way. Neither reads the source from the DOM, so a later locale switch renders that locale. A host whose source cannot be recovered keeps the text it was served with, and the core warns once.
629
+
630
+ ## The `/pure` subpath
631
+
632
+ `langsys-js-typescript/pure` holds the rules that decide what is stored and how it renders, with no DOM: for a server, a worker, an edge runtime or a build script. Nothing it exports reads `window`, `document`, `navigator` or storage, at import or on any call, and importing it has no side effects. Code that registers or looks up content outside the browser derives the same ids, keys and output the browser does:
633
+
634
+ ```ts
635
+ import { canonicalizeLocale, generateCustomId, interpolate } from 'langsys-js-typescript/pure';
636
+
637
+ canonicalizeLocale('zh-Hant-TW'); // 'zh-hant-tw'
638
+ generateCustomId('Pricing', ['Compare every plan', 'Cancel anytime']); // the block's custom_id
639
+ interpolate('{n, plural, one {# item} other {# items}}', { n: 3 }, 'en-us'); // '3 items'
640
+ ```
641
+
642
+ What it exports:
643
+
644
+ - **Ids and keys:** `generateCustomId`, `generateLegacyCustomId`, `canonicalContentBlockJson`, `encodeRichPhrase` (the `Phrase` encoder over a node shape of your own), `md5`, `md5Legacy`.
645
+ - **What the tokenizer is built from:** `TRANSLATABLE_ATTRIBUTES`, `NON_TRANSLATABLE_ELEMENTS`, `isTranslationExcluded`, `normalizeTokenText`, `normalizeMarkupPlaceholders`, `stripC0Controls`.
646
+ - **Marker attributes:** `PHRASE_MARKER_ATTR`, `PHRASE_MARKER_ATTR_LEGACY`, `PHRASE_MARKER_ATTRS`, `CONTENT_BLOCK_MARKER_ATTR`, `CONTENT_BLOCK_MARKER_ATTR_LEGACY`, `CONTENT_BLOCK_MARKER_ATTRS`, `RESOLVED_MARKER_ATTR`, `RESOLVED_MARKER_ATTR_LEGACY`, `RESOLVED_MARKER_ATTRS`, `VAR_PARAM_ATTR`.
647
+ - **Locales:** `canonicalizeLocale`, `maximizedLangScript`.
648
+ - **Interpolation and placeholders:** `interpolate`, `defaultedArguments`, `isICU`, `findUnusedParamKeys`, `derivePlaceholderNames`, `snakeCase`.
649
+ - **Legacy keys:** `createLegacyKeys`, `convertLegacyValue`, `convertLegacyPluralForms`, `convertLegacyCall`, `LegacyFormatError`, `LEGACY_FORMATS`, `SUPPORTED_LEGACY_FORMATS`.
650
+ - **Catalog snapshots:** `buildSnapshot`, `parseSnapshot`, `canonicalSnapshotJson`, `snapshotChecksum`, `SnapshotError`, `SNAPSHOT_FORMAT`, `SNAPSHOT_VERSION`.
651
+ - **Server messages:** `resolveServerMessages`, `toServerMessage`, `templateMarkers`, `fillTemplate`, `DEFAULT_SERVER_MESSAGE_CATEGORY`.
652
+ - **Utilities:** `isEmpty`.
653
+
654
+ The DOM walkers (`tokenizeElement`, `encodeRichText`) and everything that sends a request are on the main entry only.
655
+
656
+ ## Variables in registered text
657
+
658
+ A sentence that shows a value from a variable registers once, as a phrase with a `{placeholder}`, for every user. `Hello {name}` is one phrase; `Hello Ana` and `Hello Luis` would be two, and a phrase with no placeholder cannot be given plural or gendered forms.
659
+
660
+ Where you call `t()` yourself, pass the value as a param: `t('Hello {name}', { name })`, or name it in a template tag, ``t`Hello ${{ name }}` ``. A tag names the placeholder by the key in snake_case, so ``t`Hello ${{ userName }}` `` registers `Hello {user_name}`, a different phrase from `t('Hello {userName}', { userName })`. A tag with a value that is not a one-key object returns its text as written: nothing in it is looked up, translated or registered.
661
+
662
+ Where the text is rendered first and read afterwards (`<Translate>`, `<Phrase>`, server-rendered HTML), the emitter marks each printed value, and the SDK reads the mark back as the placeholder:
663
+
664
+ ```html
665
+ <p>Hello <!--ls:name-->Ana<!--/ls-->, welcome back</p>
666
+ <p>Hello <span data-ls-param="name">Ana</span>, welcome back</p> <!-- where comments cannot be written -->
667
+ ```
668
+
669
+ Both register `Hello {name}, welcome back` with `name` = `Ana` as its param, unless you pass `name` yourself. In a tree for `renderBlock`, the same marker is `{ comment: 'ls:name' }` … `{ comment: '/ls' }`. The framework bindings' build-time transforms emit these for you. The value is text only: an element inside the pair makes it ordinary markup. A unit holding nothing but markers registers nothing. The content a block registers with, which the Translation Manager shows, holds `Hello {name}, welcome back` as plain text: the marker and the value are gone, in either form.
670
+
671
+ A translation that keeps each placeholder once and in order is written around the framework's own value node, so the framework's later updates to the value still show. A translation that moves or repeats one is written as a whole sentence with the value in it.
672
+
673
+ Placeholder names come from the source expression (`derivePlaceholderNames`): `user.firstName` → `first_name`, `items.length` → `items_count`, `price.value` → `price`.
674
+
675
+ A `Translate` or `Phrase` created with `register: false`, and `registerBlock(nodes, { register: false })`, renders from the catalog and registers nothing. A binding uses this for content it cannot name without its transform, and one debug notice says so. Such a block is a read on every path: under `requireCredentials` it never rejects.
676
+
677
+ ## Asking your own API for the user's language
678
+
679
+ ```ts
680
+ import { localeHeaders } from 'langsys-js-typescript';
681
+
682
+ await fetch('/api/orders', { headers: { ...localeHeaders() } }); // { 'Accept-Language': 'es-es' }
683
+ ```
684
+
685
+ `localeHeaders()` names the locale the user chose, or the request scope's inside one, so an API that answers in the negotiated language answers in the user's.
686
+
273
687
  ## Detecting the user's preferred locale
274
688
 
275
689
  ```ts
@@ -287,18 +701,20 @@ const locale = LangsysApp.detectPreferredLocale(
287
701
  );
288
702
  ```
289
703
 
290
- The matcher tries an exact match first (`en-US`), then falls back to same-language-and-script matching using CLDR likely-subtags data (`es-MX` matches `es-ES`; `zh-TW` matches `zh-Hant` but never `zh-Hans` — cross-script fallbacks are deliberately refused). All results come back in BCP 47 canonical form (`en-US`, `zh-Hant-TW`). When you pass `supported` and none match, it falls back to the user's top preference (canonicalized); it returns `false` only when no preference can be determined at all.
704
+ The matcher tries an exact match first (`en-us`), then falls back to same-language-and-script matching using CLDR likely-subtags data (`es-mx` matches `es-es`; `zh-tw` matches `zh-hant` but never `zh-hans` — cross-script fallbacks are deliberately refused). Results come back in the core's lowercase form (`en-us`, `zh-hant-tw`). When you pass `supported` and none match, it returns `false`, so `detectPreferredLocale(header, supported) || 'en-us'` reaches your default; without `supported` it returns the user's top preference, and `false` only when no preference can be determined at all.
291
705
 
292
706
  ## Localized country / currency / locale lists
293
707
 
294
708
  ```ts
295
- const countries = await LangsysApp.getCountries(); // [{ code, label }, ...]
296
- const dialCodes = await LangsysApp.getDialCodes(); // [{ country_code, dial_code, name }, ...]
709
+ const countries = await LangsysApp.getCountries(); // [{ code, label, eu }, ...]
710
+ const dialCodes = await LangsysApp.getDialCodes(); // [{ country_code, dial_code, name }, ...] — '34', 'Spain (+34)'
297
711
  const currencies = await LangsysApp.getCurrencies(); // [{ code, name, symbol, ... }, ...]
298
712
  const locales = await LangsysApp.getLocales(); // { LanguageName: [{ code, name }] }
299
- const localeName = await LangsysApp.getLocaleNameWithLookup('es-ES', true, 'fr-FR'); // 'espagnol'
713
+ const localeName = await LangsysApp.getLocaleNameWithLookup('es-es', true, 'fr-fr'); // 'Espagnol'
300
714
  ```
301
715
 
716
+ Each call takes an optional display locale, else the current one as `t()` serves it (inside a request scope, the scope's), normalised as `t()` normalises a locale (`iw-IL` is `he-il`), and answers the list named in it, with locale codes in the API's lowercase `xx-yy` form (`_` read as `-`) whatever format the organization writes them in. A list is read once per display locale and held for the core's lifetime, so a country field on every page of a server costs one request per locale for the process; concurrent calls share one request, and each call answers its own copy, safe to sort or edit. No call throws: a failed read answers an empty list (`{}` for `getLocales`) and logs why, and that list and locale are not asked again under the same credentials until the catalog's failure window has passed (3 seconds, doubling to 5 minutes). A display locale that is not shaped as a locale is refused with nothing sent, and an unconfigured client answers empty lists without a request, or rejects under `requireCredentials`; `getLocaleName` then answers `''`, or throws. `getLocalesData(locale, true)` and `refresh()` read the locale data again.
717
+
302
718
  ## The `Signal<T>` primitive
303
719
 
304
720
  A tiny observable value holder. Used internally by `tSignal` and by user-supplied locale stores. You can use it for your own state too, though that's not its primary purpose.
@@ -340,11 +756,19 @@ import type {
340
756
  ExtractParamKeys,
341
757
  ParamsFor,
342
758
  Signal,
759
+ WriteGrant,
760
+ RegistrationResult,
761
+ RequestScope,
762
+ BlockNode,
763
+ RenderedBlock,
343
764
  } from 'langsys-js-typescript';
344
765
  ```
345
766
 
346
767
  - `LangsysApp.init(config)` — initialize, returns an `iLangsysResponse`.
768
+ - `LangsysApp.isConfigured()` — whether `init()` accepted a project id, an API key and a locale store.
769
+ - `LangsysApp.setWriteGrant(grant)` (also top-level `setWriteGrant`) — supply or replace the write grant after `init()`, and re-authorize.
347
770
  - `LangsysApp.refresh()` — force-refetch translations for the current locale.
771
+ - `LangsysApp.notifyNavigation()` — tell the SDK the route changed, so mounted content is looked up again for the new URL.
348
772
  - `LangsysApp.t` — current `TFunction` (getter; reads fresh state on every call).
349
773
  - `LangsysApp.translationsLoadingPromise` — resolves when the current locale's translations are ready.
350
774
  - `LangsysApp.detectPreferredLocale(header?, supported?)` — locale detection.
@@ -353,7 +777,23 @@ import type {
353
777
  - `LangsysApp.getCurrencies(inLocale?)` / `.getCurrencyName(code, inLocale?)`
354
778
  - `LangsysApp.getLocales(inLocale?)` / `.getLocalesFlat(inLocale?)` / `.getLocalesData(inLocale?, force?)`
355
779
  - `LangsysApp.getLocaleName(code, short?, inLocale?)` / `.getLocaleNameWithLookup(...)`
356
- - Top-level: `t`, `tSignal`, `currentlyLoadedLocale`, `sTranslations`, `LangsysAppAPI`, `Translate`, `createSignal`, `getValue`, `persist`, `interpolate`, `Logger`, `logger`, `md5`, `isEmpty`.
780
+ - `LangsysApp.renderServerMessage(entry, category?)` — render a server message entry: its template through `t()`, or its `message` when there is no translation.
781
+ - `LangsysApp.seedCatalog(catalog, locale, seed?)` — put a catalog in place synchronously, before mount, for hydration.
782
+ - `LangsysApp.localeHeaders()` (also top-level `localeHeaders`) — `{ 'Accept-Language': … }` for the user's locale.
783
+ - `interpolate(template, params?, locale?, options?)` — ICU and `{name}` interpolation; `options.onDefaulted` and `options.onFormatterFailure` hand its notices to a logger of your own. `defaultedArguments(template, params)` names the arguments a render would default.
784
+ - `createRequestScope({ locale, catalog?, url?, credentials? })`, `setRequestScopeStorage(storage)`, `currentRequestScope()`, `scopeFromSeed(seed)` — render on a server with one visitor's state kept from the next (see *Rendering on a server*).
785
+ - `renderBlock(nodes, options?)`, `registerBlock(nodesOrSeededBlock, options?)`, `tokenizeTree(nodes)`, `applyRendered(element, rendered, options?)`, `blockNodesOf(element)` — content blocks as trees, with no DOM (see *Content blocks without a DOM*).
786
+ - `registerContentBlock(block)`, `tokenizeElement(element)`, `generateCustomId(category, tokens)`, `isContentBlockKnown(category, id)` — the content-block primitives `Translate` is built on (see *Registering a block yourself*).
787
+ - `derivePlaceholderNames(expressions)` — placeholder names from source expressions, for a build-time transform.
788
+ - `canonicalizeLocale(locale)` — the SDK's lowercase `xx-yy` form of a locale identifier.
789
+ - `LangsysApp.loadSnapshot(snapshot, locale?)` — load a catalog snapshot synchronously as the preloaded catalog; the fetched catalog still replaces it.
790
+ - `createLegacyKeys(files)` — the legacy-key resolver `t()` uses in migrate mode, for a server or bridge that reads its own files.
791
+ - `resolveServerMessages(body, { key } | { resolver })` — the server message entries attached to a response body at the configured key.
792
+ - `setTeardownSignal(subscribe)` — on a host with no `document` (React Native), supply the "app is going away" signal: `subscribe(fire)` returns an unsubscribe, and the SDK flushes what is queued when `fire` is called.
793
+ - `setPersistStorage(storage)` — a synchronous storage for the catalog cache where there is no `localStorage`.
794
+ - `writeEnabled`, `autoDiscovery` — the server's write decision for this session and whether the key may report pages for discovery (see *Write capability*).
795
+ - `LangsysConfigurationError`, `SnapshotError`, `LegacyFormatError` — what an unconfigured write, a refused snapshot and an unreadable legacy file throw.
796
+ - Top-level: `t`, `tSignal`, `notifyNavigation`, `renderServerMessage`, `resolveServerMessages`, `localeHeaders`, `setWriteGrant`, `currentlyLoadedLocale`, `sTranslations`, `writeEnabled`, `autoDiscovery`, `LangsysAppAPI`, `Translate`, `Phrase`, `createSignal`, `getValue`, `persist`, `interpolate`, `canonicalizeLocale`, `Logger`, `logger`, `md5`, `isEmpty`.
357
797
 
358
798
  ## License
359
799