locale-ai 0.6.14 → 0.6.15
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 +1 -1
- package/dist/cli.js +1 -1
- package/dist/locales.js +50 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ Download 577 MB? (Y/n)
|
|
|
28
28
|
|
|
29
29
|
## What it handles
|
|
30
30
|
|
|
31
|
-
- **Layouts**: `locales/en/*.json` (nested folders too) → `locales/fr/...`, or `messages/en.json` → `messages/fr.json`. `.json`, `.yaml`, `.yml`, `.po`/`.pot` (gettext), `.arb` (Flutter) and `.strings` (Apple) are all supported, mixed freely in the same project. Common folders (`locales`, `src/locales`, `public/locales`, `src/i18n`, `messages`, `lang`, ...) are found automatically, or pass `--input`.
|
|
31
|
+
- **Layouts**: `locales/en/*.json` (nested folders too) → `locales/fr/...`, or `messages/en.json` → `messages/fr.json`. `.json`, `.yaml`, `.yml`, `.po`/`.pot` (gettext), `.arb` (Flutter) and `.strings` (Apple) are all supported, mixed freely in the same project. Common folders (`locales`, `src/locales`, `public/locales`, `src/i18n`, `messages`, `lang`, `config/locales` (Rails), ...) are found automatically, or pass `--input`. Framework-specific naming is also recognized: `flutter gen-l10n`'s `app_en.arb`/`app_fr.arb` filename prefix, and Apple's `en.lproj`/`fr.lproj` directory-suffix convention (`--input en.lproj` or just the project root both work), on both the source and the generated target side.
|
|
32
32
|
- **Placeholders are never translated or lost**: `{name}`, `{{count}}`, `%s`, `%1$s`, `%(name)s`, `%{name}`, `${name}`, `:attribute`, `$t(key)`, `@:key`, `<b>…</b>`, `<0>…</0>`, ` `, URLs, e-mails.
|
|
33
33
|
- **ICU MessageFormat**: `{count, plural, one {# file} other {# files}}`, `select`, nesting. Only the text is translated.
|
|
34
34
|
- **i18next plurals**: adds the forms the target language needs (`_few`, `_many` for Arabic, Russian, Polish...), so the app doesn't fall back to the English string — each added form copies the `_other` translation verbatim rather than inflecting it, so review and adjust the wording of `_zero`/`_two`/`_few`/`_many` by hand for languages where those forms use a different noun/adjective form. The `_one` form is translated with its `{{count}}` temporarily replaced by a literal "1", since the model otherwise sometimes picks the target's plural noun form despite the English source being grammatically singular. Rails-style nested plurals (`item:` with `one:`/`other:` children instead of `item_one`/`item_other`) get the same missing-forms treatment.
|
package/dist/cli.js
CHANGED
|
@@ -173,7 +173,7 @@ async function cmdTranslate(argv) {
|
|
|
173
173
|
const singularHints = new Set(sources.flatMap(({ doc }) => [...pluralOneStrings(doc.data)]));
|
|
174
174
|
log(`locale-ai ${VERSION}`);
|
|
175
175
|
log(`Found ${sources.length} ${from.tag} file${sources.length === 1 ? "" : "s"} in ${rel(layout.root)}/` +
|
|
176
|
-
(layout.kind === "dir" ? `${from.tag}/` : ""));
|
|
176
|
+
(layout.kind === "dir" ? `${from.tag}${layout.dirSuffix ?? ""}/` : ""));
|
|
177
177
|
let failures = 0;
|
|
178
178
|
const jobs = [];
|
|
179
179
|
for (const locale of targets) {
|
package/dist/locales.js
CHANGED
|
@@ -23,6 +23,7 @@ const CANDIDATES = [
|
|
|
23
23
|
"src/locale",
|
|
24
24
|
"app/i18n",
|
|
25
25
|
"static/locales",
|
|
26
|
+
"config/locales",
|
|
26
27
|
".",
|
|
27
28
|
];
|
|
28
29
|
const isDir = (p) => fs.existsSync(p) && fs.statSync(p).isDirectory();
|
|
@@ -31,25 +32,60 @@ const isFile = (p) => fs.existsSync(p) && fs.statSync(p).isFile();
|
|
|
31
32
|
function fileExtAt(root, from) {
|
|
32
33
|
return EXTENSIONS.find((ext) => isFile(path.join(root, `${from}${ext}`)));
|
|
33
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* A prefixed source-locale file, if `<root>/<prefix>_<from><ext>` exists for one of
|
|
37
|
+
* EXTENSIONS — the convention `flutter gen-l10n` uses by default ("app_en.arb").
|
|
38
|
+
*/
|
|
39
|
+
function prefixedFileAt(root, from) {
|
|
40
|
+
if (!isDir(root))
|
|
41
|
+
return undefined;
|
|
42
|
+
for (const entry of fs.readdirSync(root)) {
|
|
43
|
+
if (!isFile(path.join(root, entry)))
|
|
44
|
+
continue;
|
|
45
|
+
for (const ext of EXTENSIONS) {
|
|
46
|
+
const suffix = `_${from}${ext}`;
|
|
47
|
+
if (entry.endsWith(suffix) && entry.length > suffix.length) {
|
|
48
|
+
return { ext, prefix: entry.slice(0, -suffix.length) };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
34
54
|
function layoutAt(root, from) {
|
|
35
55
|
const dir = path.join(root, from);
|
|
36
56
|
if (isDir(dir) && listLocaleFiles(dir).length > 0)
|
|
37
57
|
return { kind: "dir", root };
|
|
58
|
+
const lprojDir = path.join(root, `${from}.lproj`);
|
|
59
|
+
if (isDir(lprojDir) && listLocaleFiles(lprojDir).length > 0)
|
|
60
|
+
return { kind: "dir", root, dirSuffix: ".lproj" };
|
|
38
61
|
const ext = fileExtAt(root, from);
|
|
39
62
|
if (ext)
|
|
40
63
|
return { kind: "file", root, ext };
|
|
64
|
+
const prefixed = prefixedFileAt(root, from);
|
|
65
|
+
if (prefixed)
|
|
66
|
+
return { kind: "file", root, ext: prefixed.ext, prefix: prefixed.prefix };
|
|
41
67
|
return undefined;
|
|
42
68
|
}
|
|
69
|
+
const dirNameFor = (layout, tag) => `${tag}${layout.dirSuffix ?? ""}`;
|
|
70
|
+
const fileNameFor = (layout, tag, ext) => `${layout.prefix ? `${layout.prefix}_` : ""}${tag}${ext}`;
|
|
43
71
|
export function detectLayout(cwd, from, input) {
|
|
44
72
|
if (input) {
|
|
45
73
|
const abs = path.resolve(cwd, input);
|
|
46
74
|
// Accept pointing at the source locale itself: --input locales/en or locales/en.json
|
|
47
75
|
if (path.basename(abs) === from && isDir(abs))
|
|
48
76
|
return { kind: "dir", root: path.dirname(abs) };
|
|
77
|
+
if (path.basename(abs) === `${from}.lproj` && isDir(abs)) {
|
|
78
|
+
return { kind: "dir", root: path.dirname(abs), dirSuffix: ".lproj" };
|
|
79
|
+
}
|
|
49
80
|
const base = path.basename(abs);
|
|
50
81
|
const directExt = EXTENSIONS.find((ext) => base === `${from}${ext}`);
|
|
51
82
|
if (directExt && isFile(abs))
|
|
52
83
|
return { kind: "file", root: path.dirname(abs), ext: directExt };
|
|
84
|
+
const prefixedExt = EXTENSIONS.find((ext) => base.endsWith(`_${from}${ext}`) && base.length > `_${from}${ext}`.length);
|
|
85
|
+
if (prefixedExt && isFile(abs)) {
|
|
86
|
+
const suffix = `_${from}${prefixedExt}`;
|
|
87
|
+
return { kind: "file", root: path.dirname(abs), ext: prefixedExt, prefix: base.slice(0, -suffix.length) };
|
|
88
|
+
}
|
|
53
89
|
const l = layoutAt(abs, from);
|
|
54
90
|
if (!l)
|
|
55
91
|
throw new Error(`No "${from}" locale found in ${input} (looked for ${from}/* and ${from}${EXTENSIONS.join("/")}).`);
|
|
@@ -76,16 +112,18 @@ function listLocaleFiles(dir) {
|
|
|
76
112
|
return out.sort();
|
|
77
113
|
}
|
|
78
114
|
export function sourceFiles(layout, from) {
|
|
79
|
-
return layout.kind === "dir"
|
|
115
|
+
return layout.kind === "dir"
|
|
116
|
+
? listLocaleFiles(path.join(layout.root, dirNameFor(layout, from)))
|
|
117
|
+
: [path.join(layout.root, fileNameFor(layout, from, layout.ext))];
|
|
80
118
|
}
|
|
81
119
|
/** A translated file is a real PO, never a template, regardless of what the source was. */
|
|
82
120
|
const outputExt = (ext) => (ext === ".pot" ? ".po" : ext);
|
|
83
121
|
export function targetFile(layout, from, to, src) {
|
|
84
122
|
if (layout.kind === "file")
|
|
85
|
-
return path.join(layout.root,
|
|
86
|
-
const rel = path.relative(path.join(layout.root, from), src);
|
|
123
|
+
return path.join(layout.root, fileNameFor(layout, to, outputExt(layout.ext)));
|
|
124
|
+
const rel = path.relative(path.join(layout.root, dirNameFor(layout, from)), src);
|
|
87
125
|
const ext = path.extname(rel);
|
|
88
|
-
return path.join(layout.root, to, ext === ".pot" ? `${rel.slice(0, -ext.length)}.po` : rel);
|
|
126
|
+
return path.join(layout.root, dirNameFor(layout, to), ext === ".pot" ? `${rel.slice(0, -ext.length)}.po` : rel);
|
|
89
127
|
}
|
|
90
128
|
/** True when `tag` parses as a locale (e.g. "fr", "pt-BR") — rejects names like "__mocks__" or "schemas". */
|
|
91
129
|
function looksLikeLocale(tag) {
|
|
@@ -101,19 +139,22 @@ function looksLikeLocale(tag) {
|
|
|
101
139
|
export function existingTargets(layout, from) {
|
|
102
140
|
if (layout.kind === "file") {
|
|
103
141
|
const ext = layout.ext;
|
|
142
|
+
const prefix = layout.prefix ? `${layout.prefix}_` : "";
|
|
104
143
|
return fs
|
|
105
144
|
.readdirSync(layout.root)
|
|
106
|
-
.filter((f) => f.endsWith(ext) && f.slice(
|
|
107
|
-
.map((f) => f.slice(
|
|
145
|
+
.filter((f) => f.startsWith(prefix) && f.endsWith(ext) && f.slice(prefix.length, -ext.length) !== from)
|
|
146
|
+
.map((f) => f.slice(prefix.length, -ext.length))
|
|
108
147
|
.filter(looksLikeLocale)
|
|
109
148
|
.sort();
|
|
110
149
|
}
|
|
150
|
+
const suffix = layout.dirSuffix ?? "";
|
|
111
151
|
return fs
|
|
112
152
|
.readdirSync(layout.root, { withFileTypes: true })
|
|
113
|
-
.filter((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== "node_modules" && e.name
|
|
114
|
-
.map((e) => e.name)
|
|
153
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== "node_modules" && e.name.endsWith(suffix))
|
|
154
|
+
.map((e) => (suffix ? e.name.slice(0, -suffix.length) : e.name))
|
|
155
|
+
.filter((name) => name !== from)
|
|
115
156
|
.filter(looksLikeLocale)
|
|
116
|
-
.filter((name) => listLocaleFiles(path.join(layout.root, name)).length > 0)
|
|
157
|
+
.filter((name) => listLocaleFiles(path.join(layout.root, dirNameFor(layout, name))).length > 0)
|
|
117
158
|
.sort();
|
|
118
159
|
}
|
|
119
160
|
const finalNewline = (text) => text === "" || text.endsWith("\n");
|