locale-ai 0.6.14 → 0.6.16
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 +4 -3
- package/dist/cli.js +3 -2
- package/dist/locales.js +202 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -28,14 +28,15 @@ 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
|
+
- **Android**: `res/values/strings.xml` → `res/values-fr/strings.xml` (or `values-pt-rBR` for a region). `<string>`, `<string-array>` and `<plurals>` are all translated; `translatable="false"` strings and `<!-- ... -->` comments are kept as-is. Resource-qualifier scripts/variants (`values-b+sr+Latn`) aren't supported — only a plain language or language-region qualifier.
|
|
32
33
|
- **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
34
|
- **ICU MessageFormat**: `{count, plural, one {# file} other {# files}}`, `select`, nesting. Only the text is translated.
|
|
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.
|
|
35
|
+
- **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 — Android's `<plurals>`/`<item quantity="...">` groups are the same shape and get it too.
|
|
35
36
|
- **Incremental, and edit-aware**: only missing or changed keys are translated; untouched translations are kept, so re-runs are no-ops. A small `locale-ai.lock.json` (commit it, like a package lock) remembers a hash of each source string and of the output last written for it per target locale — so editing a source string gets it re-translated automatically (it isn't enough for the target to already hold *some* text), while editing a target string by hand is detected and reported for review instead of being silently overwritten on the next run. `--overwrite` bypasses the lock and re-translates everything.
|
|
36
37
|
- **Structure**: key order, numbers, booleans, arrays, indentation and trailing newline are preserved.
|
|
37
38
|
|
|
38
|
-
**Format notes**: for `.yaml`/`.yml`, `#` comments (a translator note, an inline `# ...` after a value) survive a translation run — the target file is edited in place rather than rebuilt from scratch, so an existing comment's own value is updated without disturbing the comment, and a brand-new key is appended with no comment of its own. A newly added plural-category key (see above) is likewise appended rather than inserted between existing ones, so its position in the file won't always match the canonical `zero`/`one`/`two`/`few`/`many`/`other` order. For `.po`/`.pot`, `msgid` is the stable key (never changed) and `msgstr` is the translated value, as gettext expects — the English source's `msgstr` holds the source text when it's filled in (most generators do), and falls back to `msgid` itself when it's empty, which is normal for a `.pot` template or a project that just keeps the English text in `msgid`. That fallback only ever applies to the source file: an empty `msgstr` in an *existing target* still means "not translated yet", never "same as msgid". `msgid_plural`/`msgstr[n]` plural entries are translated too (the singular and plural source forms), then expanded to however many `msgstr[n]` the target language's plural rules need (6 for Arabic, 1 for Japanese, ...) — not full grammatical inflection for in-between categories (`few`/`many`/...), which get the plural-sourced translation verbatim, the same simplification `addPluralForms` makes for i18next. `Plural-Forms` in the header is computed for the target language, not copied from the source; an existing target file's header, and any entry's own comments (a translator's note, a hand-added `#, fuzzy`), are otherwise left exactly as last edited instead of being regenerated from the source on every rerun. `--fuzzy` flags every entry a run actually (re)translated with gettext's standard `#, fuzzy` "needs review" marker, merging onto an existing `#,` line rather than adding a second one. A `.pot` source always writes a `.po` target, never a `.pot`. For `.arb`, `@key` metadata and `@@locale` are never translated; `@@locale` is set to the target locale on output. For `.strings`, a file saved as UTF-16 (Xcode's traditional default) is read like a UTF-8 one, an unquoted key (`title = "Settings";`, without quotes around `title`) is read the same as a quoted one, and `\U00e9`-style escapes are decoded — output is always written as UTF-8 with quoted keys, regardless of how the source was saved.
|
|
39
|
+
**Format notes**: for `.yaml`/`.yml`, `#` comments (a translator note, an inline `# ...` after a value) survive a translation run — the target file is edited in place rather than rebuilt from scratch, so an existing comment's own value is updated without disturbing the comment, and a brand-new key is appended with no comment of its own. A newly added plural-category key (see above) is likewise appended rather than inserted between existing ones, so its position in the file won't always match the canonical `zero`/`one`/`two`/`few`/`many`/`other` order. For `.po`/`.pot`, `msgid` is the stable key (never changed) and `msgstr` is the translated value, as gettext expects — the English source's `msgstr` holds the source text when it's filled in (most generators do), and falls back to `msgid` itself when it's empty, which is normal for a `.pot` template or a project that just keeps the English text in `msgid`. That fallback only ever applies to the source file: an empty `msgstr` in an *existing target* still means "not translated yet", never "same as msgid". `msgid_plural`/`msgstr[n]` plural entries are translated too (the singular and plural source forms), then expanded to however many `msgstr[n]` the target language's plural rules need (6 for Arabic, 1 for Japanese, ...) — not full grammatical inflection for in-between categories (`few`/`many`/...), which get the plural-sourced translation verbatim, the same simplification `addPluralForms` makes for i18next. `Plural-Forms` in the header is computed for the target language, not copied from the source; an existing target file's header, and any entry's own comments (a translator's note, a hand-added `#, fuzzy`), are otherwise left exactly as last edited instead of being regenerated from the source on every rerun. `--fuzzy` flags every entry a run actually (re)translated with gettext's standard `#, fuzzy` "needs review" marker, merging onto an existing `#,` line rather than adding a second one. A `.pot` source always writes a `.po` target, never a `.pot`. For `.arb`, `@key` metadata and `@@locale` are never translated; `@@locale` is set to the target locale on output. For `.strings`, a file saved as UTF-16 (Xcode's traditional default) is read like a UTF-8 one, an unquoted key (`title = "Settings";`, without quotes around `title`) is read the same as a quoted one, and `\U00e9`-style escapes are decoded — output is always written as UTF-8 with quoted keys, regardless of how the source was saved. For Android `strings.xml`, `translatable="false"` strings and each element's own `<!-- ... -->` comment are kept on first write, but — unlike YAML/`.po`/`.strings` — a rerun regenerates the file from scratch rather than editing an existing target in place, so a hand-edited comment in an already-translated file won't survive a rerun.
|
|
39
40
|
|
|
40
41
|
## Why locale-ai?
|
|
41
42
|
|
package/dist/cli.js
CHANGED
|
@@ -33,7 +33,8 @@ Usage:
|
|
|
33
33
|
Translate options:
|
|
34
34
|
--from <locale> Source locale (default: en)
|
|
35
35
|
--to <locales> Target locale(s), comma separated: fr,es,pt-BR
|
|
36
|
-
--input <dir> Folder containing <from>/ or <from>.json/.yaml/.yml/.po/.pot/.arb/.strings
|
|
36
|
+
--input <dir> Folder containing <from>/ or <from>.json/.yaml/.yml/.po/.pot/.arb/.strings,
|
|
37
|
+
or an Android res/ folder (values/strings.xml) (auto-detected)
|
|
37
38
|
--overwrite Re-translate strings that already have a translation
|
|
38
39
|
--dry-run Show what would be translated without downloading or writing
|
|
39
40
|
--fast Greedy decoding: ~2x faster, slightly lower quality
|
|
@@ -173,7 +174,7 @@ async function cmdTranslate(argv) {
|
|
|
173
174
|
const singularHints = new Set(sources.flatMap(({ doc }) => [...pluralOneStrings(doc.data)]));
|
|
174
175
|
log(`locale-ai ${VERSION}`);
|
|
175
176
|
log(`Found ${sources.length} ${from.tag} file${sources.length === 1 ? "" : "s"} in ${rel(layout.root)}/` +
|
|
176
|
-
(layout.kind === "dir" ? `${from.tag}/` : ""));
|
|
177
|
+
(layout.kind === "dir" ? `${from.tag}${layout.dirSuffix ?? ""}/` : ""));
|
|
177
178
|
let failures = 0;
|
|
178
179
|
const jobs = [];
|
|
179
180
|
for (const locale of targets) {
|
package/dist/locales.js
CHANGED
|
@@ -23,33 +23,94 @@ const CANDIDATES = [
|
|
|
23
23
|
"src/locale",
|
|
24
24
|
"app/i18n",
|
|
25
25
|
"static/locales",
|
|
26
|
+
"config/locales",
|
|
27
|
+
"app/src/main/res",
|
|
28
|
+
"src/main/res",
|
|
29
|
+
"res",
|
|
26
30
|
".",
|
|
27
31
|
];
|
|
32
|
+
/** Android's `values-<qualifier>` folder suffix for a locale tag: "fr" -> "fr", "pt-BR" -> "pt-rBR". */
|
|
33
|
+
function androidQualifier(tag) {
|
|
34
|
+
const l = parseLocale(tag);
|
|
35
|
+
if (l.script)
|
|
36
|
+
throw new Error(`Android resource qualifiers for scripts (got "${tag}") aren't supported; use a plain language or language-region tag.`);
|
|
37
|
+
return l.region ? `${l.lang}-r${l.region}` : l.lang;
|
|
38
|
+
}
|
|
39
|
+
/** Reverse of androidQualifier: "fr" -> "fr", "pt-rBR" -> "pt-BR". Undefined for a qualifier it can't parse (script/BCP-47 "b+..." variants). */
|
|
40
|
+
function tagFromAndroidQualifier(qualifier) {
|
|
41
|
+
const m = /^([a-z]{2,3})(?:-r([A-Za-z]{2}))?$/.exec(qualifier);
|
|
42
|
+
return m ? (m[2] ? `${m[1]}-${m[2].toUpperCase()}` : m[1]) : undefined;
|
|
43
|
+
}
|
|
28
44
|
const isDir = (p) => fs.existsSync(p) && fs.statSync(p).isDirectory();
|
|
29
45
|
const isFile = (p) => fs.existsSync(p) && fs.statSync(p).isFile();
|
|
30
46
|
/** The source-locale file's extension, if `<root>/<from><ext>` exists for one of EXTENSIONS. */
|
|
31
47
|
function fileExtAt(root, from) {
|
|
32
48
|
return EXTENSIONS.find((ext) => isFile(path.join(root, `${from}${ext}`)));
|
|
33
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* A prefixed source-locale file, if `<root>/<prefix>_<from><ext>` exists for one of
|
|
52
|
+
* EXTENSIONS — the convention `flutter gen-l10n` uses by default ("app_en.arb").
|
|
53
|
+
*/
|
|
54
|
+
function prefixedFileAt(root, from) {
|
|
55
|
+
if (!isDir(root))
|
|
56
|
+
return undefined;
|
|
57
|
+
for (const entry of fs.readdirSync(root)) {
|
|
58
|
+
if (!isFile(path.join(root, entry)))
|
|
59
|
+
continue;
|
|
60
|
+
for (const ext of EXTENSIONS) {
|
|
61
|
+
const suffix = `_${from}${ext}`;
|
|
62
|
+
if (entry.endsWith(suffix) && entry.length > suffix.length) {
|
|
63
|
+
return { ext, prefix: entry.slice(0, -suffix.length) };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
/** Android's default (untagged) `values/strings.xml` resource folder, if present. */
|
|
70
|
+
function androidLayoutAt(root) {
|
|
71
|
+
return isFile(path.join(root, "values", "strings.xml")) ? { kind: "android", root } : undefined;
|
|
72
|
+
}
|
|
34
73
|
function layoutAt(root, from) {
|
|
35
74
|
const dir = path.join(root, from);
|
|
36
75
|
if (isDir(dir) && listLocaleFiles(dir).length > 0)
|
|
37
76
|
return { kind: "dir", root };
|
|
77
|
+
const lprojDir = path.join(root, `${from}.lproj`);
|
|
78
|
+
if (isDir(lprojDir) && listLocaleFiles(lprojDir).length > 0)
|
|
79
|
+
return { kind: "dir", root, dirSuffix: ".lproj" };
|
|
80
|
+
const android = androidLayoutAt(root);
|
|
81
|
+
if (android)
|
|
82
|
+
return android;
|
|
38
83
|
const ext = fileExtAt(root, from);
|
|
39
84
|
if (ext)
|
|
40
85
|
return { kind: "file", root, ext };
|
|
86
|
+
const prefixed = prefixedFileAt(root, from);
|
|
87
|
+
if (prefixed)
|
|
88
|
+
return { kind: "file", root, ext: prefixed.ext, prefix: prefixed.prefix };
|
|
41
89
|
return undefined;
|
|
42
90
|
}
|
|
91
|
+
const dirNameFor = (layout, tag) => `${tag}${layout.dirSuffix ?? ""}`;
|
|
92
|
+
const fileNameFor = (layout, tag, ext) => `${layout.prefix ? `${layout.prefix}_` : ""}${tag}${ext}`;
|
|
43
93
|
export function detectLayout(cwd, from, input) {
|
|
44
94
|
if (input) {
|
|
45
95
|
const abs = path.resolve(cwd, input);
|
|
46
96
|
// Accept pointing at the source locale itself: --input locales/en or locales/en.json
|
|
47
97
|
if (path.basename(abs) === from && isDir(abs))
|
|
48
98
|
return { kind: "dir", root: path.dirname(abs) };
|
|
99
|
+
if (path.basename(abs) === `${from}.lproj` && isDir(abs)) {
|
|
100
|
+
return { kind: "dir", root: path.dirname(abs), dirSuffix: ".lproj" };
|
|
101
|
+
}
|
|
102
|
+
if (path.basename(abs) === "values" && isFile(path.join(abs, "strings.xml"))) {
|
|
103
|
+
return { kind: "android", root: path.dirname(abs) };
|
|
104
|
+
}
|
|
49
105
|
const base = path.basename(abs);
|
|
50
106
|
const directExt = EXTENSIONS.find((ext) => base === `${from}${ext}`);
|
|
51
107
|
if (directExt && isFile(abs))
|
|
52
108
|
return { kind: "file", root: path.dirname(abs), ext: directExt };
|
|
109
|
+
const prefixedExt = EXTENSIONS.find((ext) => base.endsWith(`_${from}${ext}`) && base.length > `_${from}${ext}`.length);
|
|
110
|
+
if (prefixedExt && isFile(abs)) {
|
|
111
|
+
const suffix = `_${from}${prefixedExt}`;
|
|
112
|
+
return { kind: "file", root: path.dirname(abs), ext: prefixedExt, prefix: base.slice(0, -suffix.length) };
|
|
113
|
+
}
|
|
53
114
|
const l = layoutAt(abs, from);
|
|
54
115
|
if (!l)
|
|
55
116
|
throw new Error(`No "${from}" locale found in ${input} (looked for ${from}/* and ${from}${EXTENSIONS.join("/")}).`);
|
|
@@ -76,16 +137,22 @@ function listLocaleFiles(dir) {
|
|
|
76
137
|
return out.sort();
|
|
77
138
|
}
|
|
78
139
|
export function sourceFiles(layout, from) {
|
|
79
|
-
|
|
140
|
+
if (layout.kind === "android")
|
|
141
|
+
return [path.join(layout.root, "values", "strings.xml")];
|
|
142
|
+
return layout.kind === "dir"
|
|
143
|
+
? listLocaleFiles(path.join(layout.root, dirNameFor(layout, from)))
|
|
144
|
+
: [path.join(layout.root, fileNameFor(layout, from, layout.ext))];
|
|
80
145
|
}
|
|
81
146
|
/** A translated file is a real PO, never a template, regardless of what the source was. */
|
|
82
147
|
const outputExt = (ext) => (ext === ".pot" ? ".po" : ext);
|
|
83
148
|
export function targetFile(layout, from, to, src) {
|
|
149
|
+
if (layout.kind === "android")
|
|
150
|
+
return path.join(layout.root, `values-${androidQualifier(to)}`, "strings.xml");
|
|
84
151
|
if (layout.kind === "file")
|
|
85
|
-
return path.join(layout.root,
|
|
86
|
-
const rel = path.relative(path.join(layout.root, from), src);
|
|
152
|
+
return path.join(layout.root, fileNameFor(layout, to, outputExt(layout.ext)));
|
|
153
|
+
const rel = path.relative(path.join(layout.root, dirNameFor(layout, from)), src);
|
|
87
154
|
const ext = path.extname(rel);
|
|
88
|
-
return path.join(layout.root, to, ext === ".pot" ? `${rel.slice(0, -ext.length)}.po` : rel);
|
|
155
|
+
return path.join(layout.root, dirNameFor(layout, to), ext === ".pot" ? `${rel.slice(0, -ext.length)}.po` : rel);
|
|
89
156
|
}
|
|
90
157
|
/** True when `tag` parses as a locale (e.g. "fr", "pt-BR") — rejects names like "__mocks__" or "schemas". */
|
|
91
158
|
function looksLikeLocale(tag) {
|
|
@@ -99,21 +166,32 @@ function looksLikeLocale(tag) {
|
|
|
99
166
|
}
|
|
100
167
|
/** Locale tags already translated alongside `from` in this layout (for `locale-ai check`). */
|
|
101
168
|
export function existingTargets(layout, from) {
|
|
169
|
+
if (layout.kind === "android") {
|
|
170
|
+
return fs
|
|
171
|
+
.readdirSync(layout.root, { withFileTypes: true })
|
|
172
|
+
.filter((e) => e.isDirectory() && /^values-/.test(e.name) && isFile(path.join(layout.root, e.name, "strings.xml")))
|
|
173
|
+
.map((e) => tagFromAndroidQualifier(e.name.slice("values-".length)))
|
|
174
|
+
.filter((tag) => tag !== undefined && tag !== from)
|
|
175
|
+
.sort();
|
|
176
|
+
}
|
|
102
177
|
if (layout.kind === "file") {
|
|
103
178
|
const ext = layout.ext;
|
|
179
|
+
const prefix = layout.prefix ? `${layout.prefix}_` : "";
|
|
104
180
|
return fs
|
|
105
181
|
.readdirSync(layout.root)
|
|
106
|
-
.filter((f) => f.endsWith(ext) && f.slice(
|
|
107
|
-
.map((f) => f.slice(
|
|
182
|
+
.filter((f) => f.startsWith(prefix) && f.endsWith(ext) && f.slice(prefix.length, -ext.length) !== from)
|
|
183
|
+
.map((f) => f.slice(prefix.length, -ext.length))
|
|
108
184
|
.filter(looksLikeLocale)
|
|
109
185
|
.sort();
|
|
110
186
|
}
|
|
187
|
+
const suffix = layout.dirSuffix ?? "";
|
|
111
188
|
return fs
|
|
112
189
|
.readdirSync(layout.root, { withFileTypes: true })
|
|
113
|
-
.filter((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== "node_modules" && e.name
|
|
114
|
-
.map((e) => e.name)
|
|
190
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== "node_modules" && e.name.endsWith(suffix))
|
|
191
|
+
.map((e) => (suffix ? e.name.slice(0, -suffix.length) : e.name))
|
|
192
|
+
.filter((name) => name !== from)
|
|
115
193
|
.filter(looksLikeLocale)
|
|
116
|
-
.filter((name) => listLocaleFiles(path.join(layout.root, name)).length > 0)
|
|
194
|
+
.filter((name) => listLocaleFiles(path.join(layout.root, dirNameFor(layout, name))).length > 0)
|
|
117
195
|
.sort();
|
|
118
196
|
}
|
|
119
197
|
const finalNewline = (text) => text === "" || text.endsWith("\n");
|
|
@@ -252,6 +330,119 @@ function readStringsDoc(file, text) {
|
|
|
252
330
|
};
|
|
253
331
|
return { data, serialize };
|
|
254
332
|
}
|
|
333
|
+
/** Android's `&entity;` plus its own backslash escapes (`\'`, `\"`, `\n`, `\t`) and `<![CDATA[...]]>`. */
|
|
334
|
+
function unescapeAndroidXml(s) {
|
|
335
|
+
return s
|
|
336
|
+
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, (_m, inner) => inner)
|
|
337
|
+
.replace(/</g, "<")
|
|
338
|
+
.replace(/>/g, ">")
|
|
339
|
+
.replace(/"/g, '"')
|
|
340
|
+
.replace(/'/g, "'")
|
|
341
|
+
.replace(/&/g, "&")
|
|
342
|
+
.replace(/\\(.)/g, (_m, c) => ({ n: "\n", t: "\t" })[c] ?? c);
|
|
343
|
+
}
|
|
344
|
+
function escapeAndroidXml(s) {
|
|
345
|
+
return s
|
|
346
|
+
.replace(/&/g, "&")
|
|
347
|
+
.replace(/</g, "<")
|
|
348
|
+
.replace(/>/g, ">")
|
|
349
|
+
.replace(/'/g, "\\'")
|
|
350
|
+
.replace(/"/g, '\\"')
|
|
351
|
+
.replace(/\n/g, "\\n")
|
|
352
|
+
.replace(/\t/g, "\\t");
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Android `strings.xml`: `<string>`/`<string-array>`/`<plurals>` elements under a
|
|
356
|
+
* single `<resources>` root, each optionally preceded by an `<!-- ... -->` translator
|
|
357
|
+
* comment and marked `translatable="false"` for text that must stay verbatim (an app ID,
|
|
358
|
+
* a format string nobody localizes). A `<plurals>` element's `quantity="one|other|..."`
|
|
359
|
+
* items become a nested one/other/... object — the same shape Rails-style nested
|
|
360
|
+
* plurals use, so `addNestedPluralForms` fills in whatever categories the target
|
|
361
|
+
* language needs with zero format-specific code.
|
|
362
|
+
*
|
|
363
|
+
* This is a simpler round-trip than YAML/.po/.strings: comments are kept from the
|
|
364
|
+
* source on first write, but a rerun regenerates the whole file from `data` rather
|
|
365
|
+
* than editing an existing target in place (so a translator's own XML comment edits in
|
|
366
|
+
* an already-translated file won't survive a rerun) — the same level of fidelity ARB
|
|
367
|
+
* already has in this project.
|
|
368
|
+
*/
|
|
369
|
+
function readAndroidXmlDoc(file, text) {
|
|
370
|
+
const openMatch = /<resources\b[^>]*>/.exec(text);
|
|
371
|
+
const closeMatch = /<\/resources>/.exec(text);
|
|
372
|
+
if (!openMatch || !closeMatch)
|
|
373
|
+
throw new Error(`Invalid Android strings.xml in ${file}: missing a <resources> element.`);
|
|
374
|
+
const before = text.slice(0, openMatch.index + openMatch[0].length);
|
|
375
|
+
const after = text.slice(closeMatch.index);
|
|
376
|
+
const body = text.slice(openMatch.index + openMatch[0].length, closeMatch.index);
|
|
377
|
+
const data = newObj();
|
|
378
|
+
const entries = [];
|
|
379
|
+
const elementRe = /(?:(<!--[\s\S]*?-->)\s*\r?\n\s*)?<(string-array|string|plurals)\b([^>]*)(?:\/>|>([\s\S]*?)<\/\2>)/g;
|
|
380
|
+
for (const m of body.matchAll(elementRe)) {
|
|
381
|
+
const [, comment, tag, attrsRaw, inner] = m;
|
|
382
|
+
const nameMatch = /\bname="([^"]*)"/.exec(attrsRaw);
|
|
383
|
+
if (!nameMatch)
|
|
384
|
+
continue;
|
|
385
|
+
const name = unescapeAndroidXml(nameMatch[1]);
|
|
386
|
+
const translatable = !/\btranslatable\s*=\s*"false"/.test(attrsRaw);
|
|
387
|
+
if (tag === "string") {
|
|
388
|
+
entries.push({ comment, name, translatable, kind: "string" });
|
|
389
|
+
if (translatable)
|
|
390
|
+
data[name] = unescapeAndroidXml((inner ?? "").trim());
|
|
391
|
+
}
|
|
392
|
+
else if (tag === "string-array") {
|
|
393
|
+
entries.push({ comment, name, translatable, kind: "array" });
|
|
394
|
+
const items = [...(inner ?? "").matchAll(/<item\b[^>]*>([\s\S]*?)<\/item>/g)].map((im) => unescapeAndroidXml(im[1].trim()));
|
|
395
|
+
if (translatable)
|
|
396
|
+
data[name] = items;
|
|
397
|
+
}
|
|
398
|
+
else {
|
|
399
|
+
entries.push({ comment, name, translatable, kind: "plural" });
|
|
400
|
+
if (translatable) {
|
|
401
|
+
const plural = newObj();
|
|
402
|
+
for (const im of (inner ?? "").matchAll(/<item\s+quantity="(\w+)"[^>]*>([\s\S]*?)<\/item>/g)) {
|
|
403
|
+
plural[im[1]] = unescapeAndroidXml(im[2].trim());
|
|
404
|
+
}
|
|
405
|
+
data[name] = plural;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (entries.length === 0 && body.trim() !== "") {
|
|
410
|
+
throw new Error(`No <string>/<string-array>/<plurals> entries found in ${file}`);
|
|
411
|
+
}
|
|
412
|
+
const indent = /\r?\n([ \t]+)</.exec(body)?.[1] ?? " ";
|
|
413
|
+
const keepNewline = finalNewline(text);
|
|
414
|
+
const raw = newObj();
|
|
415
|
+
for (const m of body.matchAll(elementRe))
|
|
416
|
+
raw[/\bname="([^"]*)"/.exec(m[3])[1]] = m[0];
|
|
417
|
+
const serialize = (d) => {
|
|
418
|
+
const obj = isObj(d) ? d : newObj();
|
|
419
|
+
const blocks = [];
|
|
420
|
+
for (const e of entries) {
|
|
421
|
+
const attr = e.translatable ? "" : ' translatable="false"';
|
|
422
|
+
let tag;
|
|
423
|
+
if (!e.translatable) {
|
|
424
|
+
tag = raw[e.name].replace(/^<!--[\s\S]*?-->\s*\r?\n\s*/, "");
|
|
425
|
+
}
|
|
426
|
+
else if (e.kind === "string") {
|
|
427
|
+
const v = typeof obj[e.name] === "string" ? obj[e.name] : "";
|
|
428
|
+
tag = `<string name="${escapeAndroidXml(e.name)}"${attr}>${escapeAndroidXml(v)}</string>`;
|
|
429
|
+
}
|
|
430
|
+
else if (e.kind === "array") {
|
|
431
|
+
const items = Array.isArray(obj[e.name]) ? obj[e.name] : [];
|
|
432
|
+
const lines = items.map((v) => `${indent}${indent}<item>${escapeAndroidXml(typeof v === "string" ? v : "")}</item>`);
|
|
433
|
+
tag = `<string-array name="${escapeAndroidXml(e.name)}"${attr}>\n${lines.join("\n")}\n${indent}</string-array>`;
|
|
434
|
+
}
|
|
435
|
+
else {
|
|
436
|
+
const plural = isObj(obj[e.name]) ? obj[e.name] : newObj();
|
|
437
|
+
const lines = CATEGORIES.filter((c) => typeof plural[c] === "string").map((c) => `${indent}${indent}<item quantity="${c}">${escapeAndroidXml(plural[c])}</item>`);
|
|
438
|
+
tag = `<plurals name="${escapeAndroidXml(e.name)}"${attr}>\n${lines.join("\n")}\n${indent}</plurals>`;
|
|
439
|
+
}
|
|
440
|
+
blocks.push(e.comment ? `${indent}${e.comment}\n${indent}${tag}` : `${indent}${tag}`);
|
|
441
|
+
}
|
|
442
|
+
return withNewline(`${before}\n${blocks.join("\n")}\n${after}`, keepNewline);
|
|
443
|
+
};
|
|
444
|
+
return { data, serialize };
|
|
445
|
+
}
|
|
255
446
|
/** ARB (Flutter): plain JSON, but "@key" / "@@locale" entries are metadata, not translatable content. */
|
|
256
447
|
function readArbDoc(file, text) {
|
|
257
448
|
let raw;
|
|
@@ -565,6 +756,8 @@ export function readLocaleDoc(file, tag, isSource = false, fuzzy = false) {
|
|
|
565
756
|
return readArbDoc(file, text);
|
|
566
757
|
if (ext === ".strings")
|
|
567
758
|
return readStringsDoc(file, text);
|
|
759
|
+
if (ext === ".xml")
|
|
760
|
+
return readAndroidXmlDoc(file, text);
|
|
568
761
|
return readJsonDoc(file, text);
|
|
569
762
|
}
|
|
570
763
|
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|