@genrwork/laravel-i18next 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # laravel-i18next
2
2
 
3
- Use your **Laravel** translation files with [i18next](https://www.i18next.com) in **React**, **Vue 3** or **Svelte** (4 or 5), translated exactly like [Laravel Localization](https://laravel.com/docs/localization) does.
3
+ Use your **Laravel** translation files with [i18next](https://www.i18next.com) in **React**, **Vue 3** or **Svelte** (4 or 5). PHP files keep [Laravel Localization](https://laravel.com/docs/localization)'s conventions and are converted to i18next JSON at build time, so the browser only ever sees, and your frontend code only ever writes, plain i18next.
4
4
 
5
5
  Each Laravel language file becomes its own **i18next namespace**: `lang/{locale}.json` is the default namespace, and each `lang/{locale}/{namespace}.php` file (auth, validation, your own...) becomes a namespace of the same name — nested any number of levels deep, too (`lang/{locale}/{ns1}/{ns2}.php` → namespace `ns1/ns2`). Nothing is merged into one big bundle, so components only load and depend on the namespaces they actually use.
6
6
 
@@ -8,9 +8,42 @@ The Vite plugin can also read from **more than one directory**, in a fixed prior
8
8
 
9
9
  Components use their framework's own i18next bindings: `useTranslation()` (React, Vue) or the store-based `useTranslation()` this package provides for Svelte. This package supplies:
10
10
 
11
- - `LaravelBackend` and `LaravelFormat`, the i18next plugins doing the work: loading the namespaced language files (merging several sources per key when configured), and translating like Laravel (`:name` replacements, `trans_choice()` pluralization).
12
- - A provider per framework — `LaravelReactI18nProvider` (`/react`), `laravelVueI18n` (`/vue`), `setLaravelI18nContext`/`useTranslation` (`/svelte`) — built on top of the two plugins above, SSR and hydration ready.
13
- - A Vite plugin (`/vite`) turning PHP translations into JSON, and serving one or more sources of language files to the provider through a virtual module.
11
+ - `LaravelBackend`, the i18next backend loading the namespaced language files (merging several sources per key when configured), plus `laravelOptions` and `registerCaseFormats()`, the few i18next options and formats Laravel-style translations need.
12
+ - A provider per framework — `LaravelReactI18nProvider` (`/react`), `laravelVueI18n` (`/vue`), `setLaravelI18nContext`/`useTranslation` (`/svelte`) — built on top of them, SSR and hydration ready.
13
+ - A Vite plugin (`/vite`) converting PHP translations into i18next JSON, and serving one or more sources of language files to the provider through a virtual module.
14
+
15
+ ## Two formats, one meeting point
16
+
17
+ Everything PHP is written the **Laravel** way, and converted for you. Everything JSON is written the **i18next** way, and read as is:
18
+
19
+ | | PHP (`lang/{locale}/*.php`) | JSON (hand-written) |
20
+ | --- | --- | --- |
21
+ | placeholder | `:name` | `{{name}}` |
22
+ | upper / capitalized | `:NAME` / `:Name` | `{{name, uppercase}}` / `{{name, capitalize}}` |
23
+ | plural forms | `'one apple\|:count apples'` | `"apples_one"`, `"apples_other"` (`_zero`, `_two`, `_few`, `_many` where the language has them) |
24
+ | a count of 0 | `'{0} none\|...'` | `"apples_zero"` |
25
+ | nesting a key | nested arrays, flattened to `a.b.c` | flat keys, or nested objects |
26
+
27
+ The converted file is what the app loads, so for the message above the browser gets:
28
+
29
+ ```json
30
+ {
31
+ "greeting": "Hello {{name}}",
32
+ "apples": "one apple|{{count}} apples",
33
+ "apples_one": "one apple",
34
+ "apples_other": "{{count}} apples"
35
+ }
36
+ ```
37
+
38
+ and the frontend calls `t('greeting', { name })` and `t('apples', { count })`, exactly like any other i18next project. Laravel keeps using the PHP file, untouched, through `trans()` and `trans_choice()`.
39
+
40
+ How a PHP message is converted, and what it cannot do:
41
+
42
+ - A placeholder is a colon followed by a name that does not hang on a word before it: `:name`, `(:count)`, but not `https://`, `10:30` or `Note:this`. Nothing is left to a regex at runtime, so `:to` never touches `:total`.
43
+ - `:NAME` and `:Name` are read as the upper-cased and capitalized forms of `name`, as Laravel does. A placeholder that is *itself* written in capitals (`:ID`) therefore becomes `{{id, uppercase}}`.
44
+ - Every message containing a `|` is a plural message. The raw text stays under the plain key too, which is what `trans()` gives without a count; the plural forms come from asking Laravel's own rules for whole counts and grouping them by the plural category of the language of the file (`lang/{locale}/`). A message Laravel answers differently *within* one category (`[2,19]` next to `[20,*]` in English) cannot be written as i18next plural keys: the build fails and names the key instead of translating it wrongly.
45
+ - `{{` in a PHP message is i18next interpolation syntax; write it in a hand-written JSON source if you need the text.
46
+ - Laravel's own `lang/{locale}.json` files are not converted -- they are read as i18next JSON like every other `.json` file. Keep frontend strings in a source of their own (see [Sources and precedence](#sources-and-precedence)).
14
47
 
15
48
  ## Requirements
16
49
 
@@ -47,26 +80,28 @@ export default defineConfig({
47
80
  });
48
81
  ```
49
82
 
50
- While Vite runs, the plugin converts every `.php` file found under a source directory (at any depth) into a sibling `.json` file (regenerated when the PHP file changes, deleted when the build ends). Keep them out of git:
83
+ While Vite runs, the plugin converts every `.php` file found under a source directory (at any depth) into a sibling i18next `.json` file (regenerated when the PHP file changes, deleted when the build ends). Keep them out of git:
51
84
 
52
85
  ```
53
86
  lang/*/*.json
54
- Modules/*/lang/*/*.json
87
+ # and, for a wildcard source such as packages/*/lang (see below):
88
+ packages/*/lang/*/*.json
55
89
  ```
56
90
 
57
- The `sources` option is an ordered list of directories, **highest priority first**; each may hold hand-written `.json` files, `.php` files, or both (but not both for the same namespace — a hand-written file always wins where present, so don't shadow one with a same-named PHP file in the same directory). One `*` wildcard segment is allowed per entry, expanded against every matching directory that exists, e.g. `Modules/*/lang`. Defaults to `['lang']`, matching a single Laravel app with no other sources.
91
+ The `sources` option is an ordered list of directories, **highest priority first**; each may hold hand-written `.json` files, `.php` files, or both (but not both for the same namespace — a hand-written file always wins where present, so don't shadow one with a same-named PHP file in the same directory). One `*` wildcard segment is allowed per entry, expanded against every matching directory that exists, e.g. `packages/*/lang` for an app split into modules. Defaults to `['lang']`, matching a single Laravel app with no other sources.
58
92
 
59
93
  ```ts
60
94
  i18n({
61
95
  sources: [
62
96
  'resources/js/lang', // hand-written, frontend-only strings -- highest priority
63
- 'Modules/*/lang', // this app's own modules' PHP translations
64
97
  'lang', // the app's own PHP translations
65
98
  ],
66
99
  });
67
100
  ```
68
101
 
69
- The plugin also serves the language files as `virtual:laravel-i18next/files`: an array, one entry per configured source in the same order, each eagerly loaded in server code (so SSR renders translated) and lazily loaded in client code, one chunk per namespace. Declare its type next to Vite's, e.g. in `resources/js/types/vite-env.d.ts`:
102
+ The plugin also serves the language files as `virtual:laravel-i18next/files`: an array, one entry per configured source in the same order, each eagerly loaded in server code (so SSR renders translated) and lazily loaded in client code, one chunk per namespace. Two more options: `ssr` (`'eager'` by default, or `'lazy'` for large projects -- see [Large projects](#large-projects-lazy-on-the-server-too)) and `detectNamespaces` (`true` by default: tells [`preloadI18n()`](#loading-without-gaps) which namespaces each module uses).
103
+
104
+ Declare its type next to Vite's, e.g. in `resources/js/types/vite-env.d.ts`:
70
105
 
71
106
  ```ts
72
107
  /// <reference types="vite/client" />
@@ -220,6 +255,20 @@ In any child component:
220
255
 
221
256
  </details>
222
257
 
258
+ ### i18next options
259
+
260
+ The providers initialize i18next with `laravelOptions`: no key or namespace separator (Laravel keys are flat and contain dots and colons), an empty message counts as missing and falls back, and interpolated values are not escaped (React, Vue and Svelte already escape what they render; Laravel's `:name` never escaped either). They also register the `uppercase`, `lowercase` and `capitalize` formats the converted `:NAME` / `:Name` need. Setting i18next up by hand:
261
+
262
+ ```ts
263
+ import i18next from 'i18next';
264
+ import { LaravelBackend, laravelOptions, registerCaseFormats } from '@genrwork/laravel-i18next';
265
+
266
+ const i18n = i18next.createInstance();
267
+
268
+ await i18n.use(LaravelBackend).init({ ...laravelOptions, lng: 'pt', backend: { files } });
269
+ registerCaseFormats(i18n);
270
+ ```
271
+
223
272
  ### Following locale changes
224
273
 
225
274
  The framework setup above only runs once, when the page loads. When Laravel shares another locale after a visit (e.g. the user switched language), change the language from a layout component. React:
@@ -243,6 +292,46 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
243
292
 
244
293
  Vue and Svelte follow the same idea: watch the shared `locale` prop and call `i18next.changeLanguage(locale)` (Vue: `const { i18next } = useTranslation()`; Svelte: the `i18next` instance returned by `setLaravelI18nContext()`/`useTranslation()`).
245
294
 
295
+ ## Loading without gaps
296
+
297
+ Translations are chunks, like pages: with lazy files a namespace is downloaded the first time something needs it. If that happens while rendering, the framework suspends and shows a fallback -- a blank gap on every visit to a page whose namespaces are not there yet, exactly what Inertia avoids for the page component itself by loading it *before* swapping to it. Do the same for translations:
298
+
299
+ ```ts
300
+ import { withI18nPreload } from '@genrwork/laravel-i18next';
301
+ import files from 'virtual:laravel-i18next/files';
302
+
303
+ createInertiaApp({
304
+ resolve: withI18nPreload((name) => resolvePageComponent(`./pages/${name}.tsx`, pages), {
305
+ files,
306
+ fallbackLocale: 'en',
307
+ // The language the page is shown in: whatever your server shares with every page.
308
+ locale: (page) => page.props.locale as string,
309
+ }),
310
+ // ...
311
+ });
312
+ ```
313
+
314
+ `withI18nPreload()` resolves the page, then loads -- for the language `locale(page)` returns and the fallback language -- every namespace the page uses, and only then hands the page back. Which namespaces? The Vite plugin adds a tiny declaration to every module calling `useTranslation('ns')` (also `withTranslation`, `<Trans ns="ns">`, `{ ns: 'ns' }`); evaluating a page evaluates everything it statically imports, so by the time it is resolved the declarations of the page, its layout and its components are all in, however deeply nested and with nothing to write by hand. A namespace requested through a variable is invisible to the plugin: call `declareNamespaces(['ns'])` for it, or pass `namespaces: ['ns']` (or `'*'` for every namespace) to `withI18nPreload()`. Resolving the page that is already on screen in another language (a language setting saved, a language chosen through a link) loads, by default, everything declared, like any other page. Pass `onLanguageSwitch: 'rendered'` to load only the namespaces that page has rendered with (react-i18next records them) and to stop i18next from re-loading every namespace it ever loaded: a session that visited many pages then downloads a few catalogs for the switch instead of all of them, and a namespace left out is loaded, before the page renders, when a page that needs it is visited. It is only right when a page renders the same namespaces in every language and nothing it opens later needs one it has not shown yet -- otherwise that namespace suspends until it arrives. Outside react-i18next it has no effect. A component lazy-loaded *by* the page (`React.lazy`) is its own chunk with its own declarations: its namespaces load when it does, and, like the component itself, it shows its own loading state (`fallback` prop of `<LaravelReactI18nProvider>`).
315
+
316
+ What this gives you, in the browser and on the server alike:
317
+
318
+ - the first render is translated -- the provider finds everything in memory and is ready synchronously, with or without SSR;
319
+ - a client-side visit never blanks: the previous page stays until the next one, translations included, can be shown whole;
320
+ - switching language costs the same: the new language is loaded while the page that switches to it resolves, so it appears with the page;
321
+ - only what a page needs is downloaded, never every namespace of every language.
322
+
323
+ `preloadI18n({ files, locale, fallbackLocale, namespaces })` is the function underneath, for a router that is not Inertia's. It returns once the modules are loaded, and already-loaded ones cost nothing.
324
+
325
+ ### Large projects: lazy on the server too
326
+
327
+ By default the server bundle holds every language file (`ssr: 'eager'`), which keeps SSR free of any step of its own and is right for a few languages. With many languages or namespaces, make the server lazy as well:
328
+
329
+ ```ts
330
+ i18n({ ssr: 'lazy', sources: [/* ... */] });
331
+ ```
332
+
333
+ Each catalog becomes a chunk of the server build, imported the first time a request needs it and kept by Node afterwards; the server bundle does not grow with the number of languages. This needs `withI18nPreload()` (or `preloadI18n()`) in front of the render, as above: SSR cannot wait for a chunk while rendering, so it must be there before. Without it, a lazy server renders untranslated keys.
334
+
246
335
  ## Sources and precedence
247
336
 
248
337
  With more than one `sources` entry, the same `(locale, namespace)` pair can come from several places at once. They are merged **per key**, highest-priority source winning, rather than one source replacing another wholesale:
@@ -258,7 +347,7 @@ lang/es/settings.php ['Save' => 'Guardar (antiguo)', 'profile_up
258
347
 
259
348
  This is what lets a namespace migrate from PHP to hand-written JSON one string at a time: move a key, and every key not yet moved keeps resolving from PHP.
260
349
 
261
- The merge is a **shallow** merge over already-flattened, dotted keys (`sub_level1.text`, not `{ sub_level1: { text: ... } }`) — since a PHP file is flattened before it ever becomes JSON, this shallow merge is exactly the deep merge Laravel itself performs over the original nested arrays; no separate deep-merge step is needed.
350
+ The merge is a **shallow** merge over already-flattened, dotted keys (`sub_level1.text`, not `{ sub_level1: { text: ... } }`) — since a PHP file is flattened before it ever becomes JSON, this shallow merge is exactly the deep merge Laravel itself performs over the original nested arrays; no separate deep-merge step is needed. A hand-written JSON source overriding a plural message overrides it by its i18next keys (`apples_one`, `apples_other`).
262
351
 
263
352
  Two things worth knowing:
264
353
 
@@ -299,18 +388,18 @@ Namespaces are not merged with each other: `t('failed')` inside the `auth` names
299
388
  - `locale` _(optional)_: defaults to the `<html lang="">` attribute, or `en`.
300
389
  - `fallbackLocale` _(optional)_: used when a translation is missing, or when a namespace has no file for `locale`. Defaults to the `<html lang="">` attribute, or `en`.
301
390
 
302
- Every provider/plugin instance creates its own i18next instance, so concurrent SSR requests never share a language. Eagerness is all-or-nothing **across every non-empty source**: with every source eager, every known namespace is preloaded and translated on the first render, synchronously this is what the Vite plugin always emits for SSR, and what a hand-rolled config should match to keep SSR synchronous. With any source lazy, a namespace is fetched the first time a component requests it (merging still waits for every source's candidate to resolve) in React, requesting components suspend meanwhile (wrap them in `<Suspense>`, which the provider already does around its children); in Vue and Svelte, use the `ready` value (`i18next-vue` re-renders once loaded; the Svelte `useTranslation()` returns a `ready` store) to show a loading state instead. The `<html lang="">` attribute is set immediately and kept in sync with the language.
391
+ Every provider/plugin instance creates its own i18next instance, so concurrent SSR requests never share a language. Eagerness is all-or-nothing **across every non-empty source**: with every source eager, every known namespace is preloaded and translated on the first render, synchronously -- what the Vite plugin emits for SSR by default (`ssr: 'eager'`). With lazy files, only the namespaces declared by the modules evaluated so far are loaded up front, and any other one when it is first needed (merging still waits for every source's candidate to resolve): in React the requesting components suspend meanwhile, showing the provider's `fallback` prop (nothing by default; the provider already wraps its children in `<Suspense>`); in Vue and Svelte, use the `ready` value (`i18next-vue` re-renders once loaded; the Svelte `useTranslation()` returns a `ready` store) to show a loading state instead. Preload them and nothing waits at all -- see [Loading without gaps](#loading-without-gaps). The `<html lang="">` attribute is set immediately and kept in sync with the language.
303
392
 
304
393
  ## Usage
305
394
 
306
395
  ### Translating
307
396
 
308
- `lang/pt.json` (default namespace):
397
+ `lang/pt.json` (default namespace), i18next JSON:
309
398
 
310
399
  ```json
311
400
  {
312
401
  "Welcome!": "Bem-vindo!",
313
- "Welcome, :name!": "Bem-vindo, :name!"
402
+ "welcome_name": "Bem-vindo, {{name}}!"
314
403
  }
315
404
  ```
316
405
 
@@ -318,26 +407,24 @@ Every provider/plugin instance creates its own i18next instance, so concurrent S
318
407
  const { t } = useTranslation();
319
408
 
320
409
  t('Welcome!'); // Bem-vindo!
321
- t('Welcome, :name!', { name: 'Francisco' }); // Bem-vindo, Francisco!
322
- t('Welcome, :NAME!', { name: 'Francisco' }); // Bem-vindo, FRANCISCO!
410
+ t('welcome_name', { name: 'Francisco' }); // Bem-vindo, Francisco!
323
411
  t('Some untranslated'); // Some untranslated
324
412
  ```
325
413
 
326
- `lang/pt/auth.php` → `auth` namespace:
414
+ `lang/pt/auth.php` → `auth` namespace, converted from PHP:
327
415
 
328
416
  ```tsx
329
417
  const { t } = useTranslation('auth');
330
418
 
331
419
  t('failed'); // PHP translation from lang/pt/auth.php's 'failed' key
420
+ t('throttle', { seconds: 5 }); // its ':seconds' placeholder
332
421
  ```
333
422
 
334
- A missing (or empty) translation is looked up in the fallback language, then the key itself is returned.
335
-
336
- i18next `{{name}}` interpolation and `$t()` nesting are not applied. Replacements named like an i18next option (`lng`, `ns`, `context`, `defaultValue`...) must be given in `replace`: `t('Hello :lng', { replace: { lng: 'PHP' } })`.
423
+ A missing (or empty) translation is looked up in the fallback language, then the key itself is returned. The `{{name}}` interpolation, `$t()` nesting, `context` and every other i18next feature work as documented by i18next.
337
424
 
338
425
  ### Pluralization
339
426
 
340
- Pass the number as `count`:
427
+ Pass the number as `count`; i18next picks the `_one` / `_other` (...) key by the plural rules of the language the message resolved in — the current language, or the fallback language when the message itself came from there. That holds with several `sources` too: a namespace can have SOME content for a language while the specific message still comes from the fallback, and pluralization follows the message, not the namespace.
341
428
 
342
429
  `lang/pt/fruits.php`:
343
430
 
@@ -345,7 +432,7 @@ Pass the number as `count`:
345
432
  <?php
346
433
  return [
347
434
  'apple_count' => ':count apple|:count apples',
348
- 'none_some_many' => '{0} There are none|[1,19] There are some|[20,*] There are many',
435
+ 'none_some_many' => '{0} There are none|{1} There is one|[2,*] There are many',
349
436
  ];
350
437
  ```
351
438
 
@@ -353,15 +440,10 @@ return [
353
440
  const { t } = useTranslation('fruits');
354
441
 
355
442
  t('apple_count', { count: 1 }); // 1 apple
356
- t('none_some_many', { count: 19 }); // There are some
443
+ t('none_some_many', { count: 0 }); // There are none
444
+ t('none_some_many', { count: 7 }); // There are many
357
445
  ```
358
446
 
359
- Segments without an explicit range are picked with the plural rules of the language the message actually resolved in — the current language, or the fallback language when the message itself came from there, like Laravel does. This is deliberately not just "does the namespace have a file for the current language": with several `sources`, a namespace can end up with SOME content for a language (enough to make `hasResourceBundle()` true) while the specific message being pluralized still came from the fallback — pluralization follows the message, not the namespace. `_one` / `_other` suffixed keys are not used.
360
-
361
- ## Credits
362
-
363
- - The translation behavior (replacements, pluralization) mirrors Laravel's own [`__()`](https://laravel.com/docs/localization) and `trans_choice()`.
364
-
365
447
  ## License
366
448
 
367
449
  MIT &copy; GenrWork
package/dist/index.cjs CHANGED
@@ -1,12 +1,134 @@
1
1
  'use strict';
2
2
 
3
- var createI18n = require('./shared/create-i18n-WsDK4Z8L.cjs');
3
+ var createI18n = require('./shared/create-i18n-DI7Eog6O.cjs');
4
+ var demand = require('./shared/demand-sbjTXVnl.cjs');
4
5
  require('i18next');
5
6
 
6
-
7
+ function unique(values) {
8
+ return Array.from(new Set(values.filter((value)=>Boolean(value))));
9
+ }
10
+ /**
11
+ * The language and its base language: `es-MX` is looked up as `es-MX`, then `es`.
12
+ */ function languagesOf(locale, fallbackLocale) {
13
+ return unique([
14
+ locale,
15
+ locale.split(/[-_]/)[0],
16
+ fallbackLocale,
17
+ fallbackLocale === null || fallbackLocale === void 0 ? void 0 : fallbackLocale.split(/[-_]/)[0]
18
+ ]);
19
+ }
20
+ /**
21
+ * Load, before anything renders, the translations a page needs.
22
+ *
23
+ * With lazy files (the browser, or the server with `ssr: 'lazy'`) a namespace
24
+ * is a chunk to download, and rendering something that needs a chunk not yet
25
+ * there means waiting for it: the framework suspends and shows a fallback --
26
+ * a blank gap, where Inertia would have kept the old page on screen. Inertia
27
+ * avoids that by loading the next page's component before swapping to it.
28
+ * This does the same for its translations: call it (see `withI18nPreload()`)
29
+ * while the page is resolved, and by the time it renders every namespace it
30
+ * translates with is already in memory, so nothing suspends.
31
+ *
32
+ * It loads, for the language and its fallback, the namespaces declared by the
33
+ * modules evaluated so far -- the page component and everything it statically
34
+ * imports, which the Vite plugin records -- plus the ones given in
35
+ * `namespaces`. Loaded modules are cached, so calling it again costs nothing
36
+ * for what is already there. Live browser instances get the namespaces added,
37
+ * and the new language too when the page switched it.
38
+ *
39
+ * With eager files there is nothing to wait for and it returns at once.
40
+ */ async function preloadI18n({ files, locale, fallbackLocale, namespaces, declared = true }) {
41
+ if (!locale) return;
42
+ const sources = createI18n.toSources(files);
43
+ const index = createI18n.recognizer(sources);
44
+ const known = index.getAllNamespaces();
45
+ const wanted = namespaces === '*' ? known : unique([
46
+ createI18n.DEFAULT_NAMESPACE,
47
+ ...declared ? demand.getDeclaredNamespaces() : [],
48
+ ...namespaces !== null && namespaces !== void 0 ? namespaces : []
49
+ ]);
50
+ const needed = wanted.filter((namespace)=>known.includes(namespace));
51
+ const languages = languagesOf(locale, fallbackLocale);
52
+ // What a page needs, an instance created next (SSR) must start with too.
53
+ demand.declareNamespaces(needed);
54
+ await Promise.all(languages.flatMap((language)=>needed.flatMap((namespace)=>createI18n.resolver(sources, language, namespace))));
55
+ await Promise.all(demand.getLiveInstances().map((instance)=>loadInto(instance, locale, needed)));
56
+ }
57
+ /**
58
+ * Give a running instance the namespaces (and the language) a page needs. The
59
+ * modules are cached by now, so its backend reads them without waiting.
60
+ */ async function loadInto(instance, locale, namespaces) {
61
+ if (!instance.isInitialized || namespaces.length === 0) return;
62
+ await instance.loadNamespaces(namespaces);
63
+ if (locale !== instance.language) {
64
+ const missing = namespaces.filter((namespace)=>!instance.hasResourceBundle(locale, namespace));
65
+ if (missing.length > 0) await instance.reloadResources(languagesOf(locale), missing);
66
+ }
67
+ }
68
+ /**
69
+ * Wrap a page resolver so a page never renders before its translations are in
70
+ * memory -- the same guarantee Inertia gives the page component itself, and
71
+ * for the same reason: nothing is swapped in until it can be shown whole.
72
+ *
73
+ * ```ts
74
+ * createInertiaApp({
75
+ * resolve: withI18nPreload((name) => resolvePageComponent(...), {
76
+ * files,
77
+ * fallbackLocale: "en",
78
+ * locale: (page) => page.props.locale as string,
79
+ * }),
80
+ * });
81
+ * ```
82
+ *
83
+ * The page component is resolved first, which evaluates it and everything it
84
+ * imports and so declares their namespaces; then those are loaded. That order
85
+ * is what makes the namespaces exact, at the price of one more round of
86
+ * downloads after the page's own -- it happens in parallel with nothing, so
87
+ * keep catalogs small (one per area) rather than one per language.
88
+ */ function withI18nPreload(resolve, options) {
89
+ const { locale: pickLocale, onLanguageSwitch = "declared", ...preload } = options;
90
+ let current;
91
+ return async (name, page)=>{
92
+ const component = await resolve(name, page);
93
+ const locale = page ? pickLocale(page) : undefined;
94
+ if (typeof locale === "string") {
95
+ const rendered = onLanguageSwitch === "rendered" && name === current ? renderedForSwitchTo(locale) : undefined;
96
+ if (rendered) demand.keepOnlyNamespaces([
97
+ createI18n.DEFAULT_NAMESPACE,
98
+ ...rendered
99
+ ]);
100
+ await preloadI18n(rendered ? {
101
+ ...preload,
102
+ locale,
103
+ declared: false,
104
+ namespaces: rendered
105
+ } : {
106
+ ...preload,
107
+ locale
108
+ });
109
+ }
110
+ // A new page starts a new record of what it renders with.
111
+ if (onLanguageSwitch === "rendered" && name !== current) demand.forgetRenderedNamespaces();
112
+ current = name;
113
+ return component;
114
+ };
115
+ }
116
+ /**
117
+ * What the page on screen has rendered with, when a live instance is about to
118
+ * change language; nothing (so the caller loads everything declared) when it
119
+ * is not a language switch or nothing has been recorded.
120
+ */ function renderedForSwitchTo(locale) {
121
+ const switching = demand.getLiveInstances().some((instance)=>instance.isInitialized && instance.language !== locale);
122
+ const rendered = demand.getRenderedNamespaces();
123
+ return switching && rendered.length > 0 ? rendered : undefined;
124
+ }
7
125
 
8
126
  exports.LaravelBackend = createI18n.LaravelBackend;
9
- exports.LaravelFormat = createI18n.LaravelFormat;
10
127
  exports.createI18n = createI18n.createI18n;
11
128
  exports.documentLocale = createI18n.documentLocale;
129
+ exports.laravelOptions = createI18n.laravelOptions;
130
+ exports.registerCaseFormats = createI18n.registerCaseFormats;
12
131
  exports.syncDocumentLang = createI18n.syncDocumentLang;
132
+ exports.declareNamespaces = demand.declareNamespaces;
133
+ exports.preloadI18n = preloadI18n;
134
+ exports.withI18nPreload = withI18nPreload;
package/dist/index.mjs CHANGED
@@ -1,2 +1,125 @@
1
- export { L as LaravelBackend, a as LaravelFormat, c as createI18n, d as documentLocale, s as syncDocumentLang } from './shared/create-i18n-BSEwKsCX.mjs';
1
+ import { t as toSources, r as recognizer, D as DEFAULT_NAMESPACE, a as resolver } from './shared/create-i18n-DaZzs1DJ.mjs';
2
+ export { L as LaravelBackend, c as createI18n, d as documentLocale, l as laravelOptions, b as registerCaseFormats, s as syncDocumentLang } from './shared/create-i18n-DaZzs1DJ.mjs';
3
+ import { g as getDeclaredNamespaces, d as declareNamespaces, a as getLiveInstances, k as keepOnlyNamespaces, f as forgetRenderedNamespaces, b as getRenderedNamespaces } from './shared/demand-6YnTqSM4.mjs';
2
4
  import 'i18next';
5
+
6
+ function unique(values) {
7
+ return Array.from(new Set(values.filter((value)=>Boolean(value))));
8
+ }
9
+ /**
10
+ * The language and its base language: `es-MX` is looked up as `es-MX`, then `es`.
11
+ */ function languagesOf(locale, fallbackLocale) {
12
+ return unique([
13
+ locale,
14
+ locale.split(/[-_]/)[0],
15
+ fallbackLocale,
16
+ fallbackLocale === null || fallbackLocale === void 0 ? void 0 : fallbackLocale.split(/[-_]/)[0]
17
+ ]);
18
+ }
19
+ /**
20
+ * Load, before anything renders, the translations a page needs.
21
+ *
22
+ * With lazy files (the browser, or the server with `ssr: 'lazy'`) a namespace
23
+ * is a chunk to download, and rendering something that needs a chunk not yet
24
+ * there means waiting for it: the framework suspends and shows a fallback --
25
+ * a blank gap, where Inertia would have kept the old page on screen. Inertia
26
+ * avoids that by loading the next page's component before swapping to it.
27
+ * This does the same for its translations: call it (see `withI18nPreload()`)
28
+ * while the page is resolved, and by the time it renders every namespace it
29
+ * translates with is already in memory, so nothing suspends.
30
+ *
31
+ * It loads, for the language and its fallback, the namespaces declared by the
32
+ * modules evaluated so far -- the page component and everything it statically
33
+ * imports, which the Vite plugin records -- plus the ones given in
34
+ * `namespaces`. Loaded modules are cached, so calling it again costs nothing
35
+ * for what is already there. Live browser instances get the namespaces added,
36
+ * and the new language too when the page switched it.
37
+ *
38
+ * With eager files there is nothing to wait for and it returns at once.
39
+ */ async function preloadI18n({ files, locale, fallbackLocale, namespaces, declared = true }) {
40
+ if (!locale) return;
41
+ const sources = toSources(files);
42
+ const index = recognizer(sources);
43
+ const known = index.getAllNamespaces();
44
+ const wanted = namespaces === '*' ? known : unique([
45
+ DEFAULT_NAMESPACE,
46
+ ...declared ? getDeclaredNamespaces() : [],
47
+ ...namespaces !== null && namespaces !== void 0 ? namespaces : []
48
+ ]);
49
+ const needed = wanted.filter((namespace)=>known.includes(namespace));
50
+ const languages = languagesOf(locale, fallbackLocale);
51
+ // What a page needs, an instance created next (SSR) must start with too.
52
+ declareNamespaces(needed);
53
+ await Promise.all(languages.flatMap((language)=>needed.flatMap((namespace)=>resolver(sources, language, namespace))));
54
+ await Promise.all(getLiveInstances().map((instance)=>loadInto(instance, locale, needed)));
55
+ }
56
+ /**
57
+ * Give a running instance the namespaces (and the language) a page needs. The
58
+ * modules are cached by now, so its backend reads them without waiting.
59
+ */ async function loadInto(instance, locale, namespaces) {
60
+ if (!instance.isInitialized || namespaces.length === 0) return;
61
+ await instance.loadNamespaces(namespaces);
62
+ if (locale !== instance.language) {
63
+ const missing = namespaces.filter((namespace)=>!instance.hasResourceBundle(locale, namespace));
64
+ if (missing.length > 0) await instance.reloadResources(languagesOf(locale), missing);
65
+ }
66
+ }
67
+ /**
68
+ * Wrap a page resolver so a page never renders before its translations are in
69
+ * memory -- the same guarantee Inertia gives the page component itself, and
70
+ * for the same reason: nothing is swapped in until it can be shown whole.
71
+ *
72
+ * ```ts
73
+ * createInertiaApp({
74
+ * resolve: withI18nPreload((name) => resolvePageComponent(...), {
75
+ * files,
76
+ * fallbackLocale: "en",
77
+ * locale: (page) => page.props.locale as string,
78
+ * }),
79
+ * });
80
+ * ```
81
+ *
82
+ * The page component is resolved first, which evaluates it and everything it
83
+ * imports and so declares their namespaces; then those are loaded. That order
84
+ * is what makes the namespaces exact, at the price of one more round of
85
+ * downloads after the page's own -- it happens in parallel with nothing, so
86
+ * keep catalogs small (one per area) rather than one per language.
87
+ */ function withI18nPreload(resolve, options) {
88
+ const { locale: pickLocale, onLanguageSwitch = "declared", ...preload } = options;
89
+ let current;
90
+ return async (name, page)=>{
91
+ const component = await resolve(name, page);
92
+ const locale = page ? pickLocale(page) : undefined;
93
+ if (typeof locale === "string") {
94
+ const rendered = onLanguageSwitch === "rendered" && name === current ? renderedForSwitchTo(locale) : undefined;
95
+ if (rendered) keepOnlyNamespaces([
96
+ DEFAULT_NAMESPACE,
97
+ ...rendered
98
+ ]);
99
+ await preloadI18n(rendered ? {
100
+ ...preload,
101
+ locale,
102
+ declared: false,
103
+ namespaces: rendered
104
+ } : {
105
+ ...preload,
106
+ locale
107
+ });
108
+ }
109
+ // A new page starts a new record of what it renders with.
110
+ if (onLanguageSwitch === "rendered" && name !== current) forgetRenderedNamespaces();
111
+ current = name;
112
+ return component;
113
+ };
114
+ }
115
+ /**
116
+ * What the page on screen has rendered with, when a live instance is about to
117
+ * change language; nothing (so the caller loads everything declared) when it
118
+ * is not a language switch or nothing has been recorded.
119
+ */ function renderedForSwitchTo(locale) {
120
+ const switching = getLiveInstances().some((instance)=>instance.isInitialized && instance.language !== locale);
121
+ const rendered = getRenderedNamespaces();
122
+ return switching && rendered.length > 0 ? rendered : undefined;
123
+ }
124
+
125
+ export { declareNamespaces, preloadI18n, withI18nPreload };
package/dist/react.cjs CHANGED
@@ -3,17 +3,19 @@
3
3
  var jsxRuntime = require('react/jsx-runtime');
4
4
  var react = require('react');
5
5
  var reactI18next = require('react-i18next');
6
- var createI18n = require('./shared/create-i18n-WsDK4Z8L.cjs');
6
+ var createI18n = require('./shared/create-i18n-DI7Eog6O.cjs');
7
7
  require('i18next');
8
+ require('./shared/demand-sbjTXVnl.cjs');
8
9
 
9
10
  /**
10
11
  * Provides an i18next instance translating Laravel language files to react-i18next.
11
12
  *
12
13
  * Every provider owns its instance, so concurrent SSR requests do not share a language.
13
14
  * With eager files, every namespace is preloaded and translated on the first render.
14
- * While a lazy namespace loads, the children suspend, which keeps server-rendered
15
- * HTML in place until it can be hydrated.
16
- */ function LaravelReactI18nProvider({ children, files, locale, fallbackLocale }) {
15
+ * While a lazy namespace that was not preloaded loads, the children suspend, which
16
+ * keeps server-rendered HTML in place until it can be hydrated. Preload them (see
17
+ * `withI18nPreload()`) and nothing ever suspends.
18
+ */ function LaravelReactI18nProvider({ children, files, locale, fallbackLocale, fallback = null }) {
17
19
  const [i18n] = react.useState(()=>createI18n.createI18n({
18
20
  files,
19
21
  locale,
@@ -31,7 +33,7 @@ require('i18next');
31
33
  return jsxRuntime.jsx(reactI18next.I18nextProvider, {
32
34
  i18n: i18n,
33
35
  children: jsxRuntime.jsx(react.Suspense, {
34
- fallback: null,
36
+ fallback: fallback,
35
37
  children: children
36
38
  })
37
39
  });
package/dist/react.mjs CHANGED
@@ -1,17 +1,19 @@
1
1
  import { jsx } from 'react/jsx-runtime';
2
2
  import { useState, useEffect, Suspense } from 'react';
3
3
  import { I18nextProvider } from 'react-i18next';
4
- import { c as createI18n, s as syncDocumentLang } from './shared/create-i18n-BSEwKsCX.mjs';
4
+ import { c as createI18n, s as syncDocumentLang } from './shared/create-i18n-DaZzs1DJ.mjs';
5
5
  import 'i18next';
6
+ import './shared/demand-6YnTqSM4.mjs';
6
7
 
7
8
  /**
8
9
  * Provides an i18next instance translating Laravel language files to react-i18next.
9
10
  *
10
11
  * Every provider owns its instance, so concurrent SSR requests do not share a language.
11
12
  * With eager files, every namespace is preloaded and translated on the first render.
12
- * While a lazy namespace loads, the children suspend, which keeps server-rendered
13
- * HTML in place until it can be hydrated.
14
- */ function LaravelReactI18nProvider({ children, files, locale, fallbackLocale }) {
13
+ * While a lazy namespace that was not preloaded loads, the children suspend, which
14
+ * keeps server-rendered HTML in place until it can be hydrated. Preload them (see
15
+ * `withI18nPreload()`) and nothing ever suspends.
16
+ */ function LaravelReactI18nProvider({ children, files, locale, fallbackLocale, fallback = null }) {
15
17
  const [i18n] = useState(()=>createI18n({
16
18
  files,
17
19
  locale,
@@ -29,7 +31,7 @@ import 'i18next';
29
31
  return jsx(I18nextProvider, {
30
32
  i18n: i18n,
31
33
  children: jsx(Suspense, {
32
- fallback: null,
34
+ fallback: fallback,
33
35
  children: children
34
36
  })
35
37
  });