lino-i18n 0.0.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/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ # lino-i18n Changelog
2
+
3
+ ## 0.0.1
4
+
5
+ Initial release of the JavaScript `lino-i18n` package.
6
+
7
+ - Runtime i18n API with `.lino` catalogue loading.
8
+ - Converter CLI for i18next, i18n-js, and react-intl catalogues.
9
+ - Node.js, Bun, and Deno test coverage.
10
+ - Automated npm publishing, GitHub release creation, and generated docs
11
+ deployment through `.github/workflows/js.yml`.
package/LICENSE ADDED
@@ -0,0 +1,24 @@
1
+ This is free and unencumbered software released into the public domain.
2
+
3
+ Anyone is free to copy, modify, publish, use, compile, sell, or
4
+ distribute this software, either in source code form or as a compiled
5
+ binary, for any purpose, commercial or non-commercial, and by any
6
+ means.
7
+
8
+ In jurisdictions that recognize copyright laws, the author or authors
9
+ of this software dedicate any and all copyright interest in the
10
+ software to the public domain. We make this dedication for the benefit
11
+ of the public at large and to the detriment of our heirs and
12
+ successors. We intend this dedication to be an overt act of
13
+ relinquishment in perpetuity of all present and future rights to this
14
+ software under copyright law.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
19
+ IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
20
+ OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
21
+ ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22
+ OTHER DEALINGS IN THE SOFTWARE.
23
+
24
+ For more information, please refer to <https://unlicense.org>
package/README.md ADDED
@@ -0,0 +1,109 @@
1
+ # lino-i18n
2
+
3
+ [![JavaScript CI/CD](https://github.com/link-foundation/lino-i18n/actions/workflows/js.yml/badge.svg?branch=main)](https://github.com/link-foundation/lino-i18n/actions/workflows/js.yml)
4
+ [![npm](https://img.shields.io/npm/v/lino-i18n?label=npm)](https://www.npmjs.com/package/lino-i18n)
5
+ [![GitHub Release](https://img.shields.io/github/v/release/link-foundation/lino-i18n?include_prereleases&label=release)](https://github.com/link-foundation/lino-i18n/releases)
6
+
7
+ Universal internationalization for JavaScript with translations stored in
8
+ [Links Notation](https://github.com/linksplatform/Protocols.Lino) (`.lino`).
9
+
10
+ Runs on Node.js (≥ 20), Bun, Deno, and bundlers like Vite/Webpack/esbuild.
11
+
12
+ ```bash
13
+ npm install lino-i18n
14
+ ```
15
+
16
+ ## Usage
17
+
18
+ ```js
19
+ import { createI18n } from 'lino-i18n';
20
+ import { loadLocalesFromDirectory } from 'lino-i18n/loaders';
21
+
22
+ const catalogues = await loadLocalesFromDirectory('./locales');
23
+ const i18n = createI18n({
24
+ locales: catalogues,
25
+ defaultLocale: 'en',
26
+ fallback: ['en'],
27
+ });
28
+
29
+ i18n.t('greeting', { name: 'World' }); // → "Hello, World!"
30
+ i18n.t('cart.items', { count: 0 }); // → "Your cart is empty"
31
+ i18n.t('cart.items', { count: 3 }, { locale: 'ru' }); // → "3 товара"
32
+ i18n.t('role', { context: 'female' }); // → "She is a developer"
33
+ ```
34
+
35
+ A sample `.lino` catalogue looks like this:
36
+
37
+ ```lino
38
+ en
39
+ greeting "Hello, {{name}}!"
40
+ hero
41
+ description """
42
+ Keep each language in its own block, nest related messages together,
43
+ and still resolve the same runtime keys.
44
+ """
45
+ cart
46
+ title "Your cart"
47
+ items
48
+ zero "Your cart is empty"
49
+ one "{{count}} item"
50
+ other "{{count}} items"
51
+ role
52
+ male "He is a developer"
53
+ female "She is a developer"
54
+ other "They are a developer"
55
+ ```
56
+
57
+ Nested plural and context groups flatten to the runtime suffix keys
58
+ `cart.items_one`, `cart.items_other`, and `role_female`. A single file may also
59
+ contain several top-level locale blocks, for example `en` followed by `ru`.
60
+
61
+ ## CLI
62
+
63
+ The package ships a converter that turns popular i18n formats into
64
+ `.lino`:
65
+
66
+ ```bash
67
+ # i18next JSON → .lino
68
+ npx lino-i18n convert --from i18next \
69
+ --in locales/en.json --out locales --locale en
70
+
71
+ # i18n-js JSON → .lino
72
+ npx lino-i18n convert --from i18n-js \
73
+ --in config/locales/en.json --out locales
74
+
75
+ # react-intl bundle (AST or string) → .lino
76
+ npx lino-i18n convert --from react-intl \
77
+ --in messages/en.json --out locales --locale en
78
+
79
+ # Bundle all converted locales into one .lino file
80
+ npx lino-i18n convert --from i18next \
81
+ --in locales-json --out locales --single-file all.lino
82
+ ```
83
+
84
+ Run `npx lino-i18n --help` for every option.
85
+
86
+ ## Features
87
+
88
+ - CLDR plural categories via `Intl.PluralRules`.
89
+ - Nested `.lino` authoring with multiline quoted values.
90
+ - `{{var}}` and `{var}` placeholder syntax for compatibility with i18next
91
+ and `react-intl`.
92
+ - Context (gender) suffixes: `role_male`, `role_female`, `role_other`.
93
+ - Namespace prefixes via `:` (`navigation:home`) and `.` (`cart.title`).
94
+ - Configurable fallback chain.
95
+ - Bundled multi-locale `.lino` files and per-language directories.
96
+ - Optional missing-key handler.
97
+ - Converter CLI for `i18next`, `i18n-js`, and `react-intl`.
98
+ - JSON config files via `--config`.
99
+
100
+ ## Scripts
101
+
102
+ ```bash
103
+ npm test # node --test --test-timeout=30000 tests/*.test.js
104
+ ```
105
+
106
+ ## License
107
+
108
+ Released into the public domain under the
109
+ [Unlicense](https://unlicense.org/).
@@ -0,0 +1,377 @@
1
+ #!/usr/bin/env node
2
+
3
+ // `lino-i18n` command-line entry point.
4
+ //
5
+ // Built on top of `lino-arguments` so configuration follows the same
6
+ // priority chain as the rest of the Link Foundation stack: CLI flags
7
+ // > environment variables > config file > defaults.
8
+
9
+ import { promises as fs } from 'node:fs';
10
+ import path from 'node:path';
11
+ import { fileURLToPath, URL } from 'node:url';
12
+
13
+ import { createI18n } from '../src/i18n.js';
14
+ import {
15
+ formatLinoCatalog,
16
+ formatLinoCatalogs,
17
+ loadLocalesFromDirectory,
18
+ } from '../src/loaders.js';
19
+ import {
20
+ detectFormat,
21
+ fromI18next,
22
+ fromI18nJs,
23
+ fromReactIntl,
24
+ SUPPORTED_FORMATS,
25
+ } from '../src/converters/index.js';
26
+
27
+ function usage() {
28
+ return [
29
+ 'Usage: lino-i18n <command> [options]',
30
+ '',
31
+ 'Commands:',
32
+ ' convert Convert i18next / i18n-js / react-intl JSON files to .lino',
33
+ ' check Compare locales against a reference locale and report missing keys',
34
+ ' t Translate a key from the CLI for quick debugging',
35
+ '',
36
+ 'Run `lino-i18n <command> --help` for command-specific options.',
37
+ ].join('\n');
38
+ }
39
+
40
+ function commandHelp(command) {
41
+ if (command === 'convert') {
42
+ return [
43
+ 'Usage: lino-i18n convert --in <path> --out <dir> [options]',
44
+ '',
45
+ 'Options:',
46
+ ' --in <path> File or directory with source translations (required)',
47
+ ' --out <dir> Destination directory for .lino files (required)',
48
+ ` --from <format> Source format: ${SUPPORTED_FORMATS.join(', ')} (default: auto-detect)`,
49
+ ' --locale <code> Override locale name when the input is a single-locale file',
50
+ ' --default <code> Default locale when not detectable (default: en)',
51
+ ' --single-file <f> Write all converted locales to one bundled .lino file',
52
+ ' --config <path> Read command defaults from a JSON config file',
53
+ ].join('\n');
54
+ }
55
+ if (command === 'check') {
56
+ return [
57
+ 'Usage: lino-i18n check --dir <dir> [--reference <locale>]',
58
+ '',
59
+ 'Options:',
60
+ ' --dir <dir> Directory containing .lino files',
61
+ ' --reference <locale> Reference locale to diff against (default: en)',
62
+ ' --config <path> Read command defaults from a JSON config file',
63
+ ].join('\n');
64
+ }
65
+ if (command === 't') {
66
+ return [
67
+ 'Usage: lino-i18n t --dir <dir> --locale <code> <key> [k=v ...]',
68
+ '',
69
+ 'Options:',
70
+ ' --dir <dir> Directory containing .lino files',
71
+ ' --locale <code> Locale to use when translating',
72
+ ' --fallback <c> Fallback locale (default: en)',
73
+ ' --config <path> Read command defaults from a JSON config file',
74
+ ].join('\n');
75
+ }
76
+ return usage();
77
+ }
78
+
79
+ function parseFlags(argv) {
80
+ const flags = {};
81
+ const rest = [];
82
+ for (let i = 0; i < argv.length; i++) {
83
+ const arg = argv[i];
84
+ if (arg === '--help' || arg === '-h') {
85
+ flags.help = true;
86
+ continue;
87
+ }
88
+ if (arg.startsWith('--')) {
89
+ const eq = arg.indexOf('=');
90
+ if (eq !== -1) {
91
+ flags[arg.slice(2, eq)] = arg.slice(eq + 1);
92
+ continue;
93
+ }
94
+ const name = arg.slice(2);
95
+ const next = argv[i + 1];
96
+ if (next !== undefined && !next.startsWith('--')) {
97
+ flags[name] = next;
98
+ i += 1;
99
+ } else {
100
+ flags[name] = true;
101
+ }
102
+ continue;
103
+ }
104
+ rest.push(arg);
105
+ }
106
+ return { flags, rest };
107
+ }
108
+
109
+ async function withConfig(command, flags) {
110
+ if (!flags.config) {
111
+ return flags;
112
+ }
113
+ const configPath =
114
+ flags.config === true ? 'lino-i18n.config.json' : String(flags.config);
115
+ const text = await fs.readFile(configPath, 'utf8');
116
+ const parsed = JSON.parse(text);
117
+ const commandConfig =
118
+ parsed && typeof parsed === 'object' && parsed[command]
119
+ ? parsed[command]
120
+ : parsed;
121
+ if (!commandConfig || typeof commandConfig !== 'object') {
122
+ return flags;
123
+ }
124
+ return { ...commandConfig, ...flags };
125
+ }
126
+
127
+ function flagValue(flags, kebabName, camelName) {
128
+ return flags[kebabName] ?? flags[camelName];
129
+ }
130
+
131
+ function pickConverter(format) {
132
+ switch (format) {
133
+ case 'i18next':
134
+ return fromI18next;
135
+ case 'i18n-js':
136
+ return fromI18nJs;
137
+ case 'react-intl':
138
+ return fromReactIntl;
139
+ default:
140
+ return null;
141
+ }
142
+ }
143
+
144
+ async function readJsonInput(inputPath) {
145
+ const stat = await fs.stat(inputPath);
146
+ if (stat.isFile()) {
147
+ const text = await fs.readFile(inputPath, 'utf8');
148
+ return [
149
+ {
150
+ filePath: inputPath,
151
+ data: JSON.parse(text),
152
+ baseName: path.basename(inputPath, path.extname(inputPath)),
153
+ },
154
+ ];
155
+ }
156
+ const entries = (await fs.readdir(inputPath, { withFileTypes: true })).sort(
157
+ (left, right) => left.name.localeCompare(right.name)
158
+ );
159
+ const out = [];
160
+ for (const entry of entries) {
161
+ if (!entry.isFile()) {
162
+ continue;
163
+ }
164
+ if (!entry.name.endsWith('.json')) {
165
+ continue;
166
+ }
167
+ const filePath = path.join(inputPath, entry.name);
168
+ const text = await fs.readFile(filePath, 'utf8');
169
+ out.push({
170
+ filePath,
171
+ data: JSON.parse(text),
172
+ baseName: path.basename(entry.name, '.json'),
173
+ });
174
+ }
175
+ return out;
176
+ }
177
+
178
+ async function commandConvert(flags, log = console.log, err = console.error) {
179
+ if (!flags.in || !flags.out) {
180
+ err('lino-i18n convert: --in and --out are required');
181
+ err(commandHelp('convert'));
182
+ return 1;
183
+ }
184
+ await fs.mkdir(flags.out, { recursive: true });
185
+
186
+ const requestedFormat = flags.from;
187
+ if (requestedFormat && !SUPPORTED_FORMATS.includes(requestedFormat)) {
188
+ err(`lino-i18n convert: unsupported --from value '${requestedFormat}'.`);
189
+ err(`Supported formats: ${SUPPORTED_FORMATS.join(', ')}`);
190
+ return 1;
191
+ }
192
+
193
+ const inputs = await readJsonInput(flags.in);
194
+ if (inputs.length === 0) {
195
+ err(`lino-i18n convert: no JSON inputs found at ${flags.in}`);
196
+ return 1;
197
+ }
198
+
199
+ const aggregated = {};
200
+ for (const { data, baseName } of inputs) {
201
+ const format = requestedFormat || detectFormat(data);
202
+ const convert = pickConverter(format);
203
+ if (!convert) {
204
+ err(`lino-i18n convert: no converter for format '${format}'`);
205
+ return 1;
206
+ }
207
+ const result = convert(data, {
208
+ locale: flags.locale || baseName,
209
+ defaultLocale: flags.default || 'en',
210
+ });
211
+ for (const [locale, table] of Object.entries(result)) {
212
+ aggregated[locale] = { ...(aggregated[locale] || {}), ...table };
213
+ }
214
+ }
215
+
216
+ const singleFile = flagValue(flags, 'single-file', 'singleFile');
217
+ if (singleFile) {
218
+ const outputPath = path.isAbsolute(String(singleFile))
219
+ ? String(singleFile)
220
+ : path.join(flags.out, String(singleFile));
221
+ const text = `${formatLinoCatalogs(aggregated)}\n`;
222
+ await fs.writeFile(outputPath, text, 'utf8');
223
+ log(`wrote ${outputPath} (${Object.keys(aggregated).length} locales)`);
224
+ return 0;
225
+ }
226
+
227
+ for (const [locale, table] of Object.entries(aggregated)) {
228
+ const text = `${formatLinoCatalog(locale, table)}\n`;
229
+ const outputPath = path.join(flags.out, `${locale}.lino`);
230
+ await fs.writeFile(outputPath, text, 'utf8');
231
+ log(`wrote ${outputPath} (${Object.keys(table).length} keys)`);
232
+ }
233
+ return 0;
234
+ }
235
+
236
+ async function commandCheck(flags, log = console.log, err = console.error) {
237
+ if (!flags.dir) {
238
+ err('lino-i18n check: --dir is required');
239
+ err(commandHelp('check'));
240
+ return 1;
241
+ }
242
+ const reference = flags.reference || 'en';
243
+ const catalogues = await loadLocalesFromDirectory(flags.dir);
244
+ if (!catalogues[reference]) {
245
+ err(
246
+ `lino-i18n check: reference locale '${reference}' not found in ${flags.dir}`
247
+ );
248
+ return 1;
249
+ }
250
+ const referenceKeys = new Set(Object.keys(catalogues[reference]));
251
+ let issues = 0;
252
+ for (const [locale, table] of Object.entries(catalogues)) {
253
+ if (locale === reference) {
254
+ continue;
255
+ }
256
+ const missing = [];
257
+ const unknown = [];
258
+ for (const key of referenceKeys) {
259
+ if (!Object.prototype.hasOwnProperty.call(table, key)) {
260
+ missing.push(key);
261
+ }
262
+ }
263
+ for (const key of Object.keys(table)) {
264
+ if (!referenceKeys.has(key)) {
265
+ unknown.push(key);
266
+ }
267
+ }
268
+ if (missing.length === 0 && unknown.length === 0) {
269
+ continue;
270
+ }
271
+ issues += missing.length + unknown.length;
272
+ log(`# ${locale}`);
273
+ for (const key of missing) {
274
+ log(` missing: ${key}`);
275
+ }
276
+ for (const key of unknown) {
277
+ log(` unknown: ${key}`);
278
+ }
279
+ }
280
+ if (issues === 0) {
281
+ log('All catalogues are aligned with the reference locale.');
282
+ return 0;
283
+ }
284
+ return 2;
285
+ }
286
+
287
+ async function commandTranslate(
288
+ flags,
289
+ rest,
290
+ log = console.log,
291
+ err = console.error
292
+ ) {
293
+ if (!flags.dir || !flags.locale || rest.length === 0) {
294
+ err('lino-i18n t: --dir, --locale, and a key are required');
295
+ err(commandHelp('t'));
296
+ return 1;
297
+ }
298
+ const [key, ...params] = rest;
299
+ const catalogues = await loadLocalesFromDirectory(flags.dir);
300
+ const i18n = createI18n({
301
+ locales: catalogues,
302
+ defaultLocale: flags.locale,
303
+ fallback: flags.fallback ? [flags.fallback] : ['en'],
304
+ });
305
+ const values = {};
306
+ for (const entry of params) {
307
+ const eq = entry.indexOf('=');
308
+ if (eq === -1) {
309
+ continue;
310
+ }
311
+ const name = entry.slice(0, eq);
312
+ const value = entry.slice(eq + 1);
313
+ values[name] = /^-?\d+(\.\d+)?$/.test(value) ? Number(value) : value;
314
+ }
315
+ const translated = i18n.t(key, values);
316
+ log(translated);
317
+ return 0;
318
+ }
319
+
320
+ export async function runCli(argv, io = {}) {
321
+ const log = io.log || console.log;
322
+ const err = io.err || console.error;
323
+ if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
324
+ log(usage());
325
+ return 0;
326
+ }
327
+ if (argv[0] === '--version' || argv[0] === '-v') {
328
+ const pkg = JSON.parse(
329
+ await fs.readFile(new URL('../package.json', import.meta.url), 'utf8')
330
+ );
331
+ log(pkg.version);
332
+ return 0;
333
+ }
334
+ const [command, ...rest] = argv;
335
+ const { flags, rest: positional } = parseFlags(rest);
336
+ if (flags.help) {
337
+ log(commandHelp(command));
338
+ return 0;
339
+ }
340
+ const configuredFlags = await withConfig(command, flags);
341
+ switch (command) {
342
+ case 'convert':
343
+ return commandConvert(configuredFlags, log, err);
344
+ case 'check':
345
+ return commandCheck(configuredFlags, log, err);
346
+ case 't':
347
+ case 'translate':
348
+ return commandTranslate(configuredFlags, positional, log, err);
349
+ default:
350
+ err(`Unknown command: ${command}`);
351
+ err(usage());
352
+ return 1;
353
+ }
354
+ }
355
+
356
+ function isCliEntryPoint() {
357
+ if (!process.argv[1]) {
358
+ return false;
359
+ }
360
+ try {
361
+ return process.argv[1] === fileURLToPath(import.meta.url);
362
+ } catch {
363
+ return false;
364
+ }
365
+ }
366
+
367
+ if (isCliEntryPoint()) {
368
+ runCli(process.argv.slice(2)).then(
369
+ (code) => {
370
+ process.exitCode = code;
371
+ },
372
+ (error) => {
373
+ console.error(error?.stack || error?.message || error);
374
+ process.exitCode = 1;
375
+ }
376
+ );
377
+ }
package/package.json ADDED
@@ -0,0 +1,79 @@
1
+ {
2
+ "name": "lino-i18n",
3
+ "version": "0.0.1",
4
+ "description": "Universal i18n library that stores translations in Links Notation (.lino) instead of JSON.",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "types": "./src/index.d.ts",
8
+ "bin": {
9
+ "lino-i18n": "bin/lino-i18n.js"
10
+ },
11
+ "files": [
12
+ "bin/",
13
+ "src/",
14
+ "CHANGELOG.md",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "exports": {
19
+ ".": {
20
+ "types": "./src/index.d.ts",
21
+ "import": "./src/index.js"
22
+ },
23
+ "./converters": "./src/converters/index.js",
24
+ "./loaders": "./src/loaders.js"
25
+ },
26
+ "scripts": {
27
+ "test": "node --test --test-timeout=30000 tests/*.test.js",
28
+ "lint": "eslint .",
29
+ "lint:fix": "eslint . --fix",
30
+ "format": "prettier --write .",
31
+ "format:check": "prettier --check .",
32
+ "check:duplication": "jscpd .",
33
+ "check": "npm run lint && npm run format:check && npm run check:duplication",
34
+ "changeset": "changeset",
35
+ "changeset:version": "changeset version",
36
+ "changeset:publish": "changeset publish",
37
+ "pack:dry-run": "npm pack --dry-run"
38
+ },
39
+ "keywords": [
40
+ "i18n",
41
+ "internationalization",
42
+ "translation",
43
+ "links-notation",
44
+ "lino",
45
+ "localization"
46
+ ],
47
+ "author": "Link Foundation",
48
+ "license": "Unlicense",
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "git+https://github.com/link-foundation/lino-i18n.git"
52
+ },
53
+ "homepage": "https://github.com/link-foundation/lino-i18n#readme",
54
+ "bugs": {
55
+ "url": "https://github.com/link-foundation/lino-i18n/issues"
56
+ },
57
+ "publishConfig": {
58
+ "access": "public",
59
+ "provenance": true
60
+ },
61
+ "engines": {
62
+ "node": ">=20.0.0"
63
+ },
64
+ "dependencies": {
65
+ "links-notation": "^0.13.0",
66
+ "lino-arguments": "^0.3.0",
67
+ "lino-objects-codec": "^0.4.0"
68
+ },
69
+ "devDependencies": {
70
+ "@changesets/cli": "^2.31.0",
71
+ "@eslint/js": "^10.0.1",
72
+ "eslint": "^10.4.0",
73
+ "eslint-config-prettier": "^10.1.8",
74
+ "eslint-plugin-prettier": "^5.5.5",
75
+ "jscpd": "^4.2.2",
76
+ "prettier": "^3.8.3",
77
+ "test-anywhere": "^0.9.1"
78
+ }
79
+ }
@@ -0,0 +1,60 @@
1
+ // Heuristic format detection for incoming JSON catalogues so the CLI can
2
+ // auto-detect when `--from` is omitted.
3
+
4
+ export const SUPPORTED_FORMATS = ['i18next', 'i18n-js', 'react-intl'];
5
+
6
+ function isPlainObject(value) {
7
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
8
+ }
9
+
10
+ function flatStringValues(input) {
11
+ if (!isPlainObject(input)) {
12
+ return false;
13
+ }
14
+ return Object.values(input).every((value) => typeof value === 'string');
15
+ }
16
+
17
+ function hasIcuPlaceholders(input) {
18
+ if (!isPlainObject(input)) {
19
+ return false;
20
+ }
21
+ return Object.values(input).some(
22
+ (value) => typeof value === 'string' && /\{\s*\w+\s*[,}]/.test(value)
23
+ );
24
+ }
25
+
26
+ function hasMustachePlaceholders(input) {
27
+ if (!isPlainObject(input)) {
28
+ return false;
29
+ }
30
+ return Object.values(input).some(
31
+ (value) => typeof value === 'string' && /\{\{\s*\w+\s*\}\}/.test(value)
32
+ );
33
+ }
34
+
35
+ function hasPercentPlaceholders(input) {
36
+ if (!isPlainObject(input)) {
37
+ return false;
38
+ }
39
+ return Object.values(input).some(
40
+ (value) => typeof value === 'string' && /%\{\s*\w+\s*\}/.test(value)
41
+ );
42
+ }
43
+
44
+ export function detectFormat(input) {
45
+ if (!isPlainObject(input)) {
46
+ return 'i18next';
47
+ }
48
+ if (
49
+ flatStringValues(input) &&
50
+ (hasIcuPlaceholders(input) || hasMustachePlaceholders(input) === false)
51
+ ) {
52
+ if (hasIcuPlaceholders(input) && !hasMustachePlaceholders(input)) {
53
+ return 'react-intl';
54
+ }
55
+ }
56
+ if (hasPercentPlaceholders(input)) {
57
+ return 'i18n-js';
58
+ }
59
+ return 'i18next';
60
+ }