@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/dist/vite.cjs CHANGED
@@ -5,6 +5,7 @@ Object.defineProperty(exports, '__esModule', { value: true });
5
5
  var fs = require('fs');
6
6
  var path = require('path');
7
7
  var vite = require('vite');
8
+ var demand = require('./shared/demand-sbjTXVnl.cjs');
8
9
  var phpArrayReader = require('php-array-reader');
9
10
 
10
11
  /**
@@ -46,10 +47,379 @@ var locale = {
46
47
  }
47
48
  };
48
49
 
50
+ /**
51
+ * Finds the i18next namespaces a module translates with, by reading its code.
52
+ * Deliberately a text scan and not a parse: it runs on every module of the
53
+ * app, on the code of any framework (JSX, Vue and Svelte output alike), and a
54
+ * namespace it over-reports is harmless -- `preloadI18n()` ignores names that
55
+ * have no language file.
56
+ *
57
+ * Recognized, with string literals only:
58
+ *
59
+ * - `useTranslation('auth')`, `useTranslation(['auth', 'teams'])`
60
+ * (react-i18next, i18next-vue, and this package's Svelte `useTranslation`);
61
+ * - `withTranslation('auth')`;
62
+ * - `ns="auth"` / `ns={'auth'}` (`<Trans>`, `<Translation>`);
63
+ * - `{ ns: 'auth' }` (`t()` options, `getFixedT`).
64
+ *
65
+ * Comments are ignored, so documentation that quotes a call (this package's
66
+ * own, or commented-out code) declares nothing. A namespace requested through
67
+ * a variable is invisible to it: declare it with `declareNamespaces()`.
68
+ */ const CALL = /\b(?:useTranslation|withTranslation)\s*\(\s*(\[[^\]]*\]|"[^"]*"|'[^']*'|`[^`$]*`)/g;
69
+ const JSX_PROP = /\bns\s*=\s*(?:"([^"]+)"|'([^']+)'|\{\s*(?:"([^"]+)"|'([^']+)')\s*\})/g;
70
+ const OPTION = /\bns\s*:\s*(?:"([^"]+)"|'([^']+)')/g;
71
+ /**
72
+ * A string literal, or a comment: strings are kept, comments dropped. The
73
+ * `//` inside a string (a URL) must not start a comment, which is why both
74
+ * are matched by one pattern, strings first.
75
+ */ const STRING_OR_COMMENT = /("(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'|`(?:\\.|[^`\\])*`)|\/\/[^\n]*|\/\*[\s\S]*?\*\//g;
76
+ const QUOTED = /["'`]([^"'`]+)["'`]/g;
77
+ function detectNamespaces(source) {
78
+ const found = new Set();
79
+ if (!/useTranslation|withTranslation|\bns\b/.test(source)) return [];
80
+ const code = source.replace(STRING_OR_COMMENT, (_match, string)=>string !== null && string !== void 0 ? string : ' ');
81
+ for (const [, argument] of code.matchAll(CALL)){
82
+ for (const [, name] of argument.matchAll(QUOTED))found.add(name);
83
+ }
84
+ for (const pattern of [
85
+ JSX_PROP,
86
+ OPTION
87
+ ]){
88
+ for (const match of code.matchAll(pattern)){
89
+ const name = match.slice(1).find(Boolean);
90
+ if (name) found.add(name);
91
+ }
92
+ }
93
+ return Array.from(found).sort();
94
+ }
95
+
96
+ /* eslint-disable */ /**
97
+ * Get the index to use for pluralization.
98
+ * The plural rules are derived from code of the Zend Framework.
99
+ *
100
+ * @category Zend
101
+ * @package Zend_Locale
102
+ * @public https://github.com/zendframework/zf1/blob/master/library/Zend/Translate/Plural.php
103
+ * @copyright 2005-2015 Zend Technologies USA Inc. http://www.zend.com
104
+ * @license http://framework.zend.com/license New BSD License
105
+ *
106
+ * @param {String} locale
107
+ * @param {Number} number
108
+ * @return {Number}
109
+ */ function getPluralIndex(locale, number) {
110
+ locale = locale.replace('-', '_');
111
+ if (locale === 'pt_BR') {
112
+ // temporary set a locale for brazilian
113
+ locale = 'xbr';
114
+ }
115
+ if (locale.length > 3) {
116
+ locale = locale.substring(0, locale.lastIndexOf('_'));
117
+ }
118
+ switch(locale){
119
+ case 'az':
120
+ case 'bo':
121
+ case 'dz':
122
+ case 'id':
123
+ case 'ja':
124
+ case 'jv':
125
+ case 'ka':
126
+ case 'km':
127
+ case 'kn':
128
+ case 'ko':
129
+ case 'ms':
130
+ case 'th':
131
+ case 'tr':
132
+ case 'vi':
133
+ case 'zh':
134
+ return 0;
135
+ case 'af':
136
+ case 'bn':
137
+ case 'bg':
138
+ case 'ca':
139
+ case 'da':
140
+ case 'de':
141
+ case 'el':
142
+ case 'en':
143
+ case 'eo':
144
+ case 'es':
145
+ case 'et':
146
+ case 'eu':
147
+ case 'fa':
148
+ case 'fi':
149
+ case 'fo':
150
+ case 'fur':
151
+ case 'fy':
152
+ case 'gl':
153
+ case 'gu':
154
+ case 'ha':
155
+ case 'he':
156
+ case 'hu':
157
+ case 'is':
158
+ case 'it':
159
+ case 'ku':
160
+ case 'lb':
161
+ case 'ml':
162
+ case 'mn':
163
+ case 'mr':
164
+ case 'nah':
165
+ case 'nb':
166
+ case 'ne':
167
+ case 'nl':
168
+ case 'nn':
169
+ case 'no':
170
+ case 'om':
171
+ case 'or':
172
+ case 'pa':
173
+ case 'pap':
174
+ case 'ps':
175
+ case 'pt':
176
+ case 'so':
177
+ case 'sq':
178
+ case 'sv':
179
+ case 'sw':
180
+ case 'ta':
181
+ case 'te':
182
+ case 'tk':
183
+ case 'ur':
184
+ case 'zu':
185
+ return number === 1 ? 0 : 1;
186
+ case 'am':
187
+ case 'bh':
188
+ case 'fil':
189
+ case 'fr':
190
+ case 'gun':
191
+ case 'hi':
192
+ case 'ln':
193
+ case 'mg':
194
+ case 'nso':
195
+ case 'xbr':
196
+ case 'ti':
197
+ case 'wa':
198
+ return number === 0 || number === 1 ? 0 : 1;
199
+ case 'be':
200
+ case 'bs':
201
+ case 'hr':
202
+ case 'ru':
203
+ case 'sr':
204
+ case 'uk':
205
+ return number % 10 === 1 && number % 100 !== 11 ? 0 : number % 10 >= 2 && number % 10 <= 4 && (number % 100 < 10 || number % 100 >= 20) ? 1 : 2;
206
+ case 'cs':
207
+ case 'sk':
208
+ return number === 1 ? 0 : number >= 2 && number <= 4 ? 1 : 2;
209
+ case 'ga':
210
+ return number === 1 ? 0 : number === 2 ? 1 : 2;
211
+ case 'lt':
212
+ return number % 10 === 1 && number % 100 !== 11 ? 0 : number % 10 >= 2 && (number % 100 < 10 || number % 100 >= 20) ? 1 : 2;
213
+ case 'sl':
214
+ return number % 100 === 1 ? 0 : number % 100 === 2 ? 1 : number % 100 === 3 || number % 100 === 4 ? 2 : 3;
215
+ case 'mk':
216
+ return number % 10 === 1 ? 0 : 1;
217
+ case 'mt':
218
+ return number === 1 ? 0 : number === 0 || number % 100 > 1 && number % 100 < 11 ? 1 : number % 100 > 10 && number % 100 < 20 ? 2 : 3;
219
+ case 'lv':
220
+ return number === 0 ? 0 : number % 10 === 1 && number % 100 !== 11 ? 1 : 2;
221
+ case 'pl':
222
+ return number === 1 ? 0 : number % 10 >= 2 && number % 10 <= 4 && (number % 100 < 12 || number % 100 > 14) ? 1 : 2;
223
+ case 'cy':
224
+ return number === 1 ? 0 : number === 2 ? 1 : number === 8 || number === 11 ? 2 : 3;
225
+ case 'ro':
226
+ return number === 1 ? 0 : number === 0 || number % 100 > 0 && number % 100 < 20 ? 1 : 2;
227
+ case 'ar':
228
+ return number === 0 ? 0 : number === 1 ? 1 : number === 2 ? 2 : number >= 3 && number <= 10 ? 3 : number >= 11 && number <= 99 ? 4 : 5;
229
+ default:
230
+ return 0;
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Select a proper translation string based on the given number,
236
+ * using the same rules as Laravel's `trans_choice()`.
237
+ *
238
+ * @param message
239
+ * @param number
240
+ * @param locale
241
+ */ function pluralization(message, number, locale) {
242
+ let segments = message.split('|');
243
+ const extracted = extract(segments, number);
244
+ if (extracted !== null) {
245
+ return extracted.trim();
246
+ }
247
+ segments = stripConditions(segments);
248
+ const pluralIndex = getPluralIndex(locale, number);
249
+ return segments.length === 1 || !segments[pluralIndex] ? segments[0] : segments[pluralIndex];
250
+ }
251
+ /**
252
+ * Extract a translation string using inline conditions.
253
+ *
254
+ * @param segments
255
+ * @param number
256
+ */ function extract(segments, number) {
257
+ for (const segment of segments){
258
+ const result = extractFromString(segment, number);
259
+ if (result !== null) return result;
260
+ }
261
+ return null;
262
+ }
263
+ /**
264
+ * Get the translation string if the condition matches.
265
+ *
266
+ * @param part
267
+ * @param number
268
+ */ function extractFromString(part, number) {
269
+ const matches = part.match(/^[{[]([^,{}[\]]*),?([^{}[\]]*)[}\]]([\s\S]*)/);
270
+ if (!matches) return null;
271
+ const [, from, to, value] = matches;
272
+ if ((from === '*' || number >= parseFloat(from)) && (to === '*' || number <= parseFloat(to))) {
273
+ return value;
274
+ }
275
+ return from && parseFloat(from) === number ? value : null;
276
+ }
277
+ /**
278
+ * Strip the inline conditions from each segment, just leaving the text.
279
+ *
280
+ * @param segments
281
+ */ function stripConditions(segments) {
282
+ return segments.map((part)=>part.replace(/^[{[]([^[\]{}]*)[}\]]/, ''));
283
+ }
284
+
285
+ /**
286
+ * Converts one Laravel translation message into the i18next way, at build
287
+ * time, so the browser only ever sees i18next JSON:
288
+ *
289
+ * - `:name` becomes `{{name}}`, `:NAME` becomes `{{name, uppercase}}` and
290
+ * `:Name` becomes `{{name, capitalize}}` (formats registered by
291
+ * `registerCaseFormats()`);
292
+ * - a `|` separated message (Laravel's `trans_choice()` forms, with or
293
+ * without `{0}` / `[1,19]` conditions) becomes i18next's plural keys
294
+ * (`key_one`, `key_other`, ...) for the language of the file, plus the raw
295
+ * message under the plain key, which is what Laravel's `trans()` returns
296
+ * when no count is given.
297
+ */ /**
298
+ * A Laravel placeholder: a colon followed by a name, not glued to a word before
299
+ * it, so `https://`, `10:30` and `Note:this` are text.
300
+ */ const PLACEHOLDER = /(?<!\w):([A-Za-z_][A-Za-z0-9_]*)/g;
301
+ /**
302
+ * i18next's plural categories, in its own order (see `getSuffixes()`).
303
+ */ const CATEGORY_ORDER = [
304
+ 'zero',
305
+ 'one',
306
+ 'two',
307
+ 'few',
308
+ 'many',
309
+ 'other'
310
+ ];
311
+ /**
312
+ * Whole counts a message is checked against: every count that matters for
313
+ * plural rules, plus a few large ones for the categories that only start there
314
+ * (`many` of Spanish, French and Italian begins at a million).
315
+ */ const SAMPLES = [
316
+ ...Array.from({
317
+ length: 1001
318
+ }, (_, count)=>count),
319
+ 1e6,
320
+ 2e6,
321
+ 1e9
322
+ ];
323
+ /**
324
+ * Rewrite Laravel placeholders as i18next interpolations.
325
+ */ function toInterpolation(message) {
326
+ return message.replace(PLACEHOLDER, (_match, name)=>{
327
+ const hasLetters = name !== name.toLowerCase() || name !== name.toUpperCase();
328
+ if (hasLetters && name === name.toUpperCase()) return `{{${name.toLowerCase()}, uppercase}}`;
329
+ if (name[0] !== name[0].toLowerCase()) return `{{${name[0].toLowerCase()}${name.slice(1)}, capitalize}}`;
330
+ return `{{${name}}}`;
331
+ });
332
+ }
333
+ /**
334
+ * The plural categories a language distinguishes, in i18next's order.
335
+ */ function categoriesOf(rules) {
336
+ return rules.resolvedOptions().pluralCategories.slice().sort((left, right)=>CATEGORY_ORDER.indexOf(left) - CATEGORY_ORDER.indexOf(right));
337
+ }
338
+ function pluralRulesOf(locale) {
339
+ try {
340
+ return new Intl.PluralRules(locale.replace(/_/g, '-'));
341
+ } catch {
342
+ return new Intl.PluralRules('en');
343
+ }
344
+ }
345
+ /**
346
+ * Split a pluralized Laravel message into one message per i18next plural
347
+ * category by asking Laravel's own rules (`pluralization()`) what they answer
348
+ * for whole counts, then checking that every count of a category agrees.
349
+ * A message Laravel answers differently inside one category -- `[2,19]` next
350
+ * to `[20,*]` in English -- cannot be written as i18next plural keys and is
351
+ * refused, rather than silently translated wrongly.
352
+ *
353
+ * Count `0` may differ from its category (`{0} none|...`): it is written as
354
+ * `_zero`, which i18next tries first for a count of 0 in every language.
355
+ */ function pluralForms(key, message, locale) {
356
+ var _a;
357
+ const rules = pluralRulesOf(locale);
358
+ const categories = categoriesOf(rules);
359
+ const forms = new Map();
360
+ SAMPLES.forEach((count)=>{
361
+ if (count === 0) return;
362
+ const category = rules.select(count);
363
+ const text = pluralization(message, count, locale);
364
+ const known = forms.get(category);
365
+ if (known && known.text !== text) {
366
+ throw new Error(`Cannot convert "${key}" (${locale}) to i18next plural forms: Laravel answers "${known.text}" for a count of ` + `${known.count} and "${text}" for ${count}, which i18next treats as the same plural category ("${category}"). ` + 'Split it into separate keys, or write it as i18next JSON.');
367
+ }
368
+ if (!known) forms.set(category, {
369
+ text,
370
+ count
371
+ });
372
+ });
373
+ const zero = pluralization(message, 0, locale);
374
+ const zeroCategory = rules.select(0);
375
+ const result = {};
376
+ // A category no whole count reaches (`other` in Russian) reads like the largest category.
377
+ const fallback = (_a = [
378
+ ...categories
379
+ ].reverse().map((category)=>{
380
+ var _a;
381
+ return (_a = forms.get(category)) === null || _a === void 0 ? void 0 : _a.text;
382
+ }).find(Boolean)) !== null && _a !== void 0 ? _a : zero;
383
+ categories.forEach((category)=>{
384
+ var _a, _b;
385
+ result[category] = (_b = (_a = forms.get(category)) === null || _a === void 0 ? void 0 : _a.text) !== null && _b !== void 0 ? _b : category === zeroCategory ? zero : fallback;
386
+ });
387
+ if (zero !== result[zeroCategory]) result.zero = zero;
388
+ return result;
389
+ }
390
+ /**
391
+ * Convert one entry, returning the i18next entries it becomes (one for a
392
+ * plain message, several for a pluralized one). A list has its messages
393
+ * converted, anything else that is not a string is returned untouched.
394
+ */ function convertEntry(key, value, locale) {
395
+ if (Array.isArray(value)) {
396
+ return {
397
+ [key]: value.map((item)=>typeof item === 'string' ? toInterpolation(item) : item)
398
+ };
399
+ }
400
+ if (typeof value !== 'string') return {
401
+ [key]: value
402
+ };
403
+ if (!value.includes('|')) return {
404
+ [key]: toInterpolation(value)
405
+ };
406
+ const entries = {
407
+ [key]: toInterpolation(value)
408
+ };
409
+ const forms = pluralForms(key, value, locale);
410
+ CATEGORY_ORDER.filter((category)=>category in forms).forEach((category)=>{
411
+ entries[`${key}_${category}`] = toInterpolation(forms[category]);
412
+ });
413
+ return entries;
414
+ }
415
+
49
416
  /**
50
417
  * Convert every `lang/{locale}/{namespace}.php` file into a sibling
51
418
  * `lang/{locale}/{namespace}.json` file, one per PHP file, so each PHP file
52
419
  * becomes its own i18next namespace instead of being merged with the others.
420
+ * The PHP file keeps Laravel's conventions (`:name` placeholders, `one|many`
421
+ * plural forms); the JSON is written the i18next way (`{{name}}`,
422
+ * `key_one` / `key_other`), see `src/plugin/convert.ts`.
53
423
  * A PHP file may sit at any depth under the locale directory --
54
424
  * `lang/{locale}/{ns1}/{ns2}.php` and deeper -- and gets its sibling `.json`
55
425
  * written at that same nested path; the resulting namespace name itself is
@@ -78,7 +448,8 @@ var locale = {
78
448
  */ function generateNamespaceFiles(localeDirname) {
79
449
  return walkPhpFiles(localeDirname).sort().map((phpPath)=>{
80
450
  const content = fs.readFileSync(phpPath, 'utf-8');
81
- const translations = convertToDottedKey(phpArrayReader.fromString(content));
451
+ const locale = path.basename(localeDirname);
452
+ const translations = convertToI18next(convertToDottedKey(phpArrayReader.fromString(content)), locale);
82
453
  const outputPath = phpPath.replace(/\.php$/, '.json');
83
454
  fs.writeFileSync(outputPath, JSON.stringify(translations));
84
455
  return {
@@ -102,6 +473,14 @@ var locale = {
102
473
  ] : [];
103
474
  });
104
475
  }
476
+ /**
477
+ * Rewrite every flattened Laravel message the i18next way for a language.
478
+ *
479
+ * @param source
480
+ * @param locale
481
+ */ function convertToI18next(source, locale) {
482
+ return Object.entries(source).reduce((target, [key, value])=>Object.assign(target, convertEntry(key, value, locale)), {});
483
+ }
105
484
  /**
106
485
  * Flatten nested translations into dotted keys, e.g. `sub_level1.sub_level2.text`.
107
486
  *
@@ -133,7 +512,7 @@ function countWildcardSegments(normalizedPattern) {
133
512
  * Every directory an ordered source `pattern` currently expands to, relative
134
513
  * to `root`. A pattern with no `*` is a single literal directory (existing or
135
514
  * not -- callers decide what an absent literal directory means). A pattern
136
- * with exactly one `*` segment (e.g. `Modules/*\/lang`) is expanded against
515
+ * with exactly one `*` segment (e.g. `packages/*\/lang`) is expanded against
137
516
  * the filesystem: every existing subdirectory of the prefix before the `*`,
138
517
  * with the suffix after it appended, kept only when the full path also
139
518
  * exists, sorted. Two or more `*` segments throw -- unsupported.
@@ -166,8 +545,8 @@ function countWildcardSegments(normalizedPattern) {
166
545
  * so this is always root-relative (a leading `/`). A `*` segment in the
167
546
  * pattern passes through `path.resolve`/`path.relative`/`normalizePath`
168
547
  * completely untouched -- none of them treat it specially -- which is why
169
- * the emitted glob needs no separate wildcard handling: `Modules/*\/lang`
170
- * becomes `/Modules/*\/lang/**\/*.json` and `import.meta.glob()` expands the
548
+ * the emitted glob needs no separate wildcard handling: `packages/*\/lang`
549
+ * becomes `/packages/*\/lang/**\/*.json` and `import.meta.glob()` expands the
171
550
  * `*` itself, natively, at both dev and build time.
172
551
  *
173
552
  * @param pattern
@@ -187,6 +566,10 @@ function countWildcardSegments(normalizedPattern) {
187
566
  return dirnames.some((dir)=>normalizedFile.startsWith(`${vite.normalizePath(dir)}/`));
188
567
  }
189
568
 
569
+ /**
570
+ * The modules the namespace declaration is added to: the app's own scripts and
571
+ * single-file components, never dependencies.
572
+ */ const SCRIPT_MODULE = /\.(?:[cm]?[jt]sx?|vue|svelte)$/;
190
573
  /**
191
574
  * Language files, one namespace per language file: loaded eagerly by server
192
575
  * code (SSR), so it renders translated, and lazily by client code. The
@@ -198,13 +581,15 @@ const RESOLVED_FILES_MODULE_ID = `\0${FILES_MODULE_ID}`;
198
581
  * Makes Laravel PHP translations available to i18next by generating a sibling
199
582
  * `.json` file next to each `.php` file, across one or more ordered sources.
200
583
  */ function i18n(config) {
201
- var _a;
584
+ var _a, _b, _c;
202
585
  const logger = vite.createLogger('info', {
203
586
  prefix: '[laravel-i18next]'
204
587
  });
205
588
  const sourcePatterns = (_a = config === null || config === void 0 ? void 0 : config.sources) !== null && _a !== void 0 ? _a : [
206
589
  'lang'
207
590
  ];
591
+ const eagerOnServer = ((_b = config === null || config === void 0 ? void 0 : config.ssr) !== null && _b !== void 0 ? _b : 'eager') === 'eager';
592
+ const detect = (_c = config === null || config === void 0 ? void 0 : config.detectNamespaces) !== null && _c !== void 0 ? _c : true;
208
593
  let root = process.cwd();
209
594
  let generated = [];
210
595
  let exitHandlersBound = false;
@@ -267,9 +652,26 @@ const RESOLVED_FILES_MODULE_ID = `\0${FILES_MODULE_ID}`;
267
652
  if (id !== RESOLVED_FILES_MODULE_ID) return undefined;
268
653
  // Globs of virtual modules must be relative to the root. Recursive, so
269
654
  // each also matches namespaces nested any number of levels deep.
270
- const entries = sourcePatterns.map((pattern)=>JSON.stringify(toGlob(pattern, root))).map((pattern)=>(options === null || options === void 0 ? void 0 : options.ssr) ? `import.meta.glob(${pattern}, { eager: true })` : `import.meta.glob(${pattern})`).join(', ');
655
+ const entries = sourcePatterns.map((pattern)=>JSON.stringify(toGlob(pattern, root))).map((pattern)=>(options === null || options === void 0 ? void 0 : options.ssr) && eagerOnServer ? `import.meta.glob(${pattern}, { eager: true })` : `import.meta.glob(${pattern})`).join(', ');
271
656
  return `export default [${entries}];`;
272
657
  },
658
+ transform (code, id) {
659
+ const file = id.split('?')[0];
660
+ if (!detect || id.charCodeAt(0) === 0 || file.includes('/node_modules/') || !SCRIPT_MODULE.test(file)) {
661
+ return undefined;
662
+ }
663
+ const namespaces = detectNamespaces(code);
664
+ if (namespaces.length === 0) return undefined;
665
+ // Self-contained (see `src/demand.ts` for why it imports nothing), and
666
+ // on the first line so no line of the module moves.
667
+ const registry = `globalThis[Symbol.for(${JSON.stringify(demand.DECLARED_NAMESPACES_KEY)})]`;
668
+ const adds = namespaces.map((namespace)=>`.add(${JSON.stringify(namespace)})`).join('');
669
+ const declaration = `(${registry}||(${registry}=new Set))${adds};`;
670
+ return {
671
+ code: declaration + code,
672
+ map: null
673
+ };
674
+ },
273
675
  config (userConfig) {
274
676
  var _a;
275
677
  // `config()` runs before `configResolved()`, so `root` is not yet the