champollion 0.3.4 → 0.4.0
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 +41 -26
- package/bin/cli.js +53 -5
- package/index.js +63 -2
- package/lib/api-key.js +17 -4
- package/lib/autofix.js +83 -36
- package/lib/bridge/method_bridge.py +15 -3
- package/lib/cards/reader.js +34 -0
- package/lib/cards/remote.js +15 -0
- package/lib/cards/search-names.js +178 -0
- package/lib/command-help.js +286 -85
- package/lib/commands/audit.js +10 -3
- package/lib/commands/card.js +583 -226
- package/lib/commands/doctor.js +54 -18
- package/lib/commands/help.js +37 -32
- package/lib/commands/init.js +1689 -87
- package/lib/commands/integrity.js +127 -40
- package/lib/commands/leaderboard.js +187 -67
- package/lib/commands/models.js +9 -2
- package/lib/commands/provenance.js +7 -2
- package/lib/commands/recommend.js +43 -14
- package/lib/commands/register-corpus.js +632 -125
- package/lib/commands/seal-corpus.js +1 -1
- package/lib/commands/status.js +564 -27
- package/lib/commands/submit.js +17 -12
- package/lib/commands/sync.js +31 -7
- package/lib/commands/tm.js +15 -9
- package/lib/commands/verify.js +27 -3
- package/lib/commands/wrap.js +63 -5
- package/lib/commands/xliff.js +135 -64
- package/lib/commercial-eligibility.js +1 -1
- package/lib/config.js +196 -14
- package/lib/content-estimate.js +96 -0
- package/lib/content-refusals.js +270 -0
- package/lib/content-review.js +372 -0
- package/lib/content-sync.js +1127 -344
- package/lib/content.js +94 -7
- package/lib/corpus-registration.mjs +194 -35
- package/lib/cost-label.js +29 -0
- package/lib/cost-report.js +726 -78
- package/lib/diff.js +38 -4
- package/lib/docusaurus-sync.js +965 -253
- package/lib/edit-distance.js +31 -0
- package/lib/fallback.js +964 -0
- package/lib/file-scope.js +106 -0
- package/lib/flatten.js +80 -3
- package/lib/flutter-locales.js +124 -0
- package/lib/format.js +266 -12
- package/lib/hash.js +146 -21
- package/lib/icu-structure.js +929 -0
- package/lib/integrity.js +223 -75
- package/lib/language-pair.js +157 -0
- package/lib/lint.js +78 -16
- package/lib/local-only-marks.js +106 -0
- package/lib/locale-layout.js +1103 -0
- package/lib/locale-state.js +571 -0
- package/lib/methods/anthropic.js +5 -0
- package/lib/methods/apertium.js +6 -3
- package/lib/methods/api.js +138 -25
- package/lib/methods/base.js +17 -0
- package/lib/methods/coaching-data.js +153 -0
- package/lib/methods/content-separator.js +43 -0
- package/lib/methods/deepl.js +1 -1
- package/lib/methods/direct-llm.js +252 -103
- package/lib/methods/external.js +146 -63
- package/lib/methods/gemini.js +1 -0
- package/lib/methods/google-translate.js +1 -0
- package/lib/methods/http-utils.js +41 -0
- package/lib/methods/libretranslate.js +7 -2
- package/lib/methods/llm-coached.js +68 -128
- package/lib/methods/llm.js +80 -31
- package/lib/methods/local.js +93 -10
- package/lib/methods/microsoft-translator.js +1 -2
- package/lib/methods/openai.js +4 -2
- package/lib/methods/openrouter-client.js +20 -19
- package/lib/methods/openrouter-pricing.js +150 -13
- package/lib/methods/prompt-methods.js +20 -0
- package/lib/methods/provider-pricing.js +42 -1
- package/lib/methods/request-capture.js +104 -0
- package/lib/methods/tilde.js +1 -1
- package/lib/methods/translated.js +1 -2
- package/lib/missing-key.js +93 -0
- package/lib/models.js +11 -0
- package/lib/name-rules.js +32 -0
- package/lib/named-keys.js +172 -0
- package/lib/no-translate.js +4 -3
- package/lib/output.js +160 -19
- package/lib/pairs.js +586 -30
- package/lib/placeholders.js +394 -0
- package/lib/plugins.js +8 -0
- package/lib/plural-gap-redo.js +109 -0
- package/lib/plurals.js +323 -0
- package/lib/po.js +1187 -0
- package/lib/public-catalogue.js +74 -0
- package/lib/recommend.js +527 -32
- package/lib/redo.js +95 -0
- package/lib/refusal-category.js +44 -0
- package/lib/registers.js +255 -11
- package/lib/repair-script.js +20 -13
- package/lib/scripts.js +6 -1
- package/lib/seal.mjs +4 -3
- package/lib/sealed-qualifier.mjs +1 -1
- package/lib/segment.js +2 -1
- package/lib/seo.js +19 -9
- package/lib/serve.js +43 -6
- package/lib/shared-output-seed.js +164 -0
- package/lib/source-contexts.js +39 -0
- package/lib/submit.mjs +57 -5
- package/lib/sync.js +2923 -474
- package/lib/terminology.js +13 -4
- package/lib/tm-evict.js +179 -0
- package/lib/tm-seed.js +5 -2
- package/lib/tm.js +818 -36
- package/lib/translate-pair.js +639 -34
- package/lib/translate.js +78 -5
- package/lib/types.js +22 -3
- package/lib/validate.js +880 -17
- package/lib/verify.js +1296 -104
- package/lib/watch.js +32 -13
- package/lib/xliff.js +44 -3
- package/package.json +1 -1
- package/shared/CORPORA-CARDS.md +2 -0
- package/shared/cards-fallback.json +1 -1
- package/shared/curated-orthography-conventions.json +26 -8
- package/shared/gettext-plural-forms.json +45 -0
- package/shared/method-registry.json +2 -0
- package/shared/metric-registry.json +96 -18
- package/shared/schemas/champollion-plugin.schema.json +4 -0
- package/shared/schemas/corpora-card.schema.json +8 -2
- package/shared/schemas/method-index-record.schema.json +67 -0
- package/shared/schemas/method-registry.schema.json +4 -0
- package/shared/schemas/metric-registry.schema.json +55 -1
- package/shared/docent/corpus.json +0 -11739
|
@@ -0,0 +1,1103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locale layout — WHERE a project's key-value locale files live, for every
|
|
3
|
+
* locale, in one place.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS: every command used to rebuild `<localesDir>/<code><ext>`
|
|
6
|
+
* by hand (sync, the cost estimate, verify, integrity, xliff, watch, lint,
|
|
7
|
+
* repair-script, wrap). That single shape is right for Hugo, vue-i18n and
|
|
8
|
+
* next-intl, and wrong for i18next — `public/locales/en/common.json`, one
|
|
9
|
+
* folder per language, one file per namespace — which is the most common
|
|
10
|
+
* layout in the React world. Users of those projects were told to write
|
|
11
|
+
* their own flatten/unflatten wrapper. This module is the one answer to
|
|
12
|
+
* "which files make up locale X?", so every caller agrees by construction.
|
|
13
|
+
*
|
|
14
|
+
* LAYOUTS
|
|
15
|
+
* flat <localesDir>/<code><ext> (today's behaviour; one file)
|
|
16
|
+
* dir <localesDir>/<code>/**\/<ns><ext> (one folder per locale; each
|
|
17
|
+
* file is a namespace, which
|
|
18
|
+
* may be a nested path such
|
|
19
|
+
* as "admin/users")
|
|
20
|
+
* pattern config `localesPattern` with {lang} and optional {ns}, e.g.
|
|
21
|
+
* public/locales/{lang}/{ns}.json
|
|
22
|
+
* lib/l10n/app_{lang}.arb
|
|
23
|
+
* locale/{lang}/LC_MESSAGES/{ns}.po
|
|
24
|
+
*
|
|
25
|
+
* AUTO-DETECTION (no localesPattern, no localesLayout): if
|
|
26
|
+
* `<localesDir>/<inputLocale>/` is a directory holding locale files the
|
|
27
|
+
* layout is `dir`; otherwise `flat`. When BOTH `<localesDir>/en.json` and a
|
|
28
|
+
* populated `<localesDir>/en/` exist the answer is ambiguous and we refuse
|
|
29
|
+
* to guess — silently picking one would translate the wrong files.
|
|
30
|
+
*
|
|
31
|
+
* FORMAT IS PASSED THROUGH, NEVER ASSUMED. Each file entry carries the format
|
|
32
|
+
* implied by its real extension (.yml stays .yml — the old getExtension()
|
|
33
|
+
* round trip turned a `.yml` source into a search for `.yaml`). A format the
|
|
34
|
+
* reader does not implement is refused loudly, never parsed as JSON.
|
|
35
|
+
*
|
|
36
|
+
* DOCUMENT FORMATS (.po, .arb). A gettext catalog or an ARB file is a
|
|
37
|
+
* document around the strings — headers, comments, plural forms, metadata.
|
|
38
|
+
* Every target file therefore knows its SOURCE file (`sourcePath`), and the
|
|
39
|
+
* writer rebuilds the target from the source's structure plus the target's
|
|
40
|
+
* own untouched parts (lib/po.js, lib/format.js serializeARB). Each file
|
|
41
|
+
* also knows its `role`: a gettext SOURCE reads msgid where msgstr is empty,
|
|
42
|
+
* a target reads only translated, non-fuzzy entries.
|
|
43
|
+
*
|
|
44
|
+
* GETTEXT TEMPLATES. When the source language has no .po of its own, the
|
|
45
|
+
* source is the template (.pot):
|
|
46
|
+
* flat the one .pot in localesDir (po/fr.po + po/messages.pot);
|
|
47
|
+
* pattern `<name>.pot` beside the source's would-be file, in the
|
|
48
|
+
* pattern's base folder or in the folder above it — <name>
|
|
49
|
+
* is the {ns} (one template per namespace) or the pattern's
|
|
50
|
+
* file name without {lang} ("messages.po" → messages.pot;
|
|
51
|
+
* a file name that is only {lang} takes the one .pot there);
|
|
52
|
+
* dir not searched — a folder-per-language project keeps its
|
|
53
|
+
* source catalog in <localesDir>/<source>/ (Django:
|
|
54
|
+
* `django-admin makemessages -l en`).
|
|
55
|
+
* Two candidate templates where one is expected is an error, never a guess.
|
|
56
|
+
*
|
|
57
|
+
* NAMESPACED KEYS. Wherever two files' keys share one space (the lock
|
|
58
|
+
* manifest, XLIFF unit ids, dry-run key lists, --force-keys) a key is
|
|
59
|
+
* written `<ns>::<key>`. Flat layouts (and a pattern without {ns}) have a
|
|
60
|
+
* single file and keep bare keys, so their lock files are byte-for-byte what
|
|
61
|
+
* they were. The Translation Memory is NOT namespaced: it is keyed by source
|
|
62
|
+
* TEXT, so an identical string in two files is translated once and reused
|
|
63
|
+
* for free (founder cost-first rule).
|
|
64
|
+
*
|
|
65
|
+
* Docusaurus keeps its own lane (lib/docusaurus-sync.js) — its JSON files
|
|
66
|
+
* carry {message, description} objects and a plugin directory structure —
|
|
67
|
+
* so this module refuses format 'docusaurus' rather than half-handling it.
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
import fs from 'node:fs';
|
|
71
|
+
import path from 'node:path';
|
|
72
|
+
import {
|
|
73
|
+
detectFormatFromDir, readLocaleFile, writeLocaleFile, detectYAMLStyle, LOCALE_FILE_FORMATS,
|
|
74
|
+
readLocaleContext, emptyDocumentContent,
|
|
75
|
+
} from './format.js';
|
|
76
|
+
import { flattenKeys } from './flatten.js';
|
|
77
|
+
import { isUnsafeKey } from './security.js';
|
|
78
|
+
import { findPluralGroups, expandPluralsForLocale } from './plurals.js';
|
|
79
|
+
|
|
80
|
+
/** Separator between namespace and key in every shared key space. */
|
|
81
|
+
const NS_SEPARATOR = '::';
|
|
82
|
+
|
|
83
|
+
/** Values accepted by the `localesLayout` config field (pattern = localesPattern). */
|
|
84
|
+
const LAYOUT_KINDS = ['flat', 'dir'];
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Extension → format. Ordered: the first extension listed for a format is
|
|
88
|
+
* the one written when a project has no file to copy it from. A gettext
|
|
89
|
+
* template (.pot) is not a locale file — it is found separately, as a
|
|
90
|
+
* source (see "GETTEXT TEMPLATES" above).
|
|
91
|
+
*/
|
|
92
|
+
const FORMAT_BY_EXT = Object.freeze({
|
|
93
|
+
'.json': 'json',
|
|
94
|
+
'.toml': 'toml',
|
|
95
|
+
'.yaml': 'yaml',
|
|
96
|
+
'.yml': 'yaml',
|
|
97
|
+
'.po': 'po',
|
|
98
|
+
'.arb': 'arb',
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
/** Directory names never walked when discovering locale files. */
|
|
102
|
+
const WALK_SKIP = new Set(['node_modules', '.git']);
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Map a file extension (".yml") to its locale format ("yaml").
|
|
106
|
+
*
|
|
107
|
+
* @param {string} ext - Extension including the dot
|
|
108
|
+
* @returns {string|null} Format name, or null for an unrecognised extension
|
|
109
|
+
*/
|
|
110
|
+
function formatForExtension(ext) {
|
|
111
|
+
return FORMAT_BY_EXT[String(ext).toLowerCase()] || null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Every extension that carries a given format, preferred first.
|
|
116
|
+
*
|
|
117
|
+
* @param {string} format - 'json' | 'toml' | 'yaml' | 'po' | 'arb'
|
|
118
|
+
* @returns {string[]} Extensions including the dot (empty for unknown formats)
|
|
119
|
+
*/
|
|
120
|
+
function extensionsForFormat(format) {
|
|
121
|
+
return Object.entries(FORMAT_BY_EXT).filter(([, f]) => f === format).map(([e]) => e);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Path → forward-slash form, so namespaces and patterns read the same on every OS. */
|
|
125
|
+
function toPosix(p) {
|
|
126
|
+
return p.split(path.sep).join('/');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function isDirectory(p) {
|
|
130
|
+
try { return fs.statSync(p).isDirectory(); } catch { return false; }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function isFile(p) {
|
|
134
|
+
try { return fs.statSync(p).isFile(); } catch { return false; }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Recursively list the files under `dir` that `accept` keeps — the ONE
|
|
139
|
+
* folder walk behind every per-locale folder in the CLI: this module's
|
|
140
|
+
* `dir` and `pattern` layouts, and the Docusaurus lane's i18n/<locale>/
|
|
141
|
+
* JSON discovery (lib/docusaurus-sync.js discoverDocusaurusJSONFiles).
|
|
142
|
+
*
|
|
143
|
+
* @param {string} dir - Absolute directory (missing → [])
|
|
144
|
+
* @param {(name: string) => boolean} accept - File-name filter
|
|
145
|
+
* @param {{ skipHidden?: boolean, maxDepth?: number }} [options] -
|
|
146
|
+
* skipHidden (default true) also skips node_modules/.git; maxDepth 1 =
|
|
147
|
+
* only `dir` itself
|
|
148
|
+
* @returns {string[]} Absolute paths, sorted
|
|
149
|
+
*/
|
|
150
|
+
function walkFiles(dir, accept, { skipHidden = true, maxDepth = Infinity } = {}) {
|
|
151
|
+
const out = [];
|
|
152
|
+
function walk(d, depth) {
|
|
153
|
+
let entries;
|
|
154
|
+
try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
|
|
155
|
+
for (const entry of entries) {
|
|
156
|
+
if (skipHidden && (entry.name.startsWith('.') || WALK_SKIP.has(entry.name))) continue;
|
|
157
|
+
const full = path.join(d, entry.name);
|
|
158
|
+
if (entry.isDirectory()) {
|
|
159
|
+
if (depth < maxDepth) walk(full, depth + 1);
|
|
160
|
+
} else if (entry.isFile() && accept(entry.name)) {
|
|
161
|
+
out.push(full);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
walk(dir, 1);
|
|
166
|
+
return out.sort();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Locale files (any known locale extension) under `dir`.
|
|
171
|
+
*
|
|
172
|
+
* @param {string} dir - Absolute directory
|
|
173
|
+
* @param {number} [maxDepth=Infinity]
|
|
174
|
+
* @returns {string[]} Absolute paths, sorted
|
|
175
|
+
*/
|
|
176
|
+
function walkLocaleFiles(dir, maxDepth = Infinity) {
|
|
177
|
+
return walkFiles(dir, name => !!formatForExtension(path.extname(name)), { maxDepth });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Throw a config error with the code resolveConfig() and the CLI map to a
|
|
182
|
+
* clean, unwrapped message.
|
|
183
|
+
*/
|
|
184
|
+
function layoutError(message) {
|
|
185
|
+
const e = new Error(message);
|
|
186
|
+
e.code = 'CHAMPOLLION_CONFIG_INVALID';
|
|
187
|
+
return e;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// -----------------------------------------------------------------
|
|
191
|
+
// localesPattern
|
|
192
|
+
// -----------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Compile a `localesPattern` ("public/locales/{lang}/{ns}.json") into the
|
|
196
|
+
* pieces discovery and rendering need.
|
|
197
|
+
*
|
|
198
|
+
* Rules (each violation fails loud — a pattern that silently matched
|
|
199
|
+
* nothing would make sync report "fully synced" on a project it never read):
|
|
200
|
+
* - {lang} is required; {ns} is optional and may appear at most once.
|
|
201
|
+
* - No other {placeholder}.
|
|
202
|
+
* - The file name must end in an extension that is not a placeholder.
|
|
203
|
+
* - {lang} may repeat (e.g. "{lang}/app_{lang}.arb"); every occurrence
|
|
204
|
+
* must name the same locale.
|
|
205
|
+
*
|
|
206
|
+
* @param {string} pattern - Absolute (or cwd-relative) pattern
|
|
207
|
+
* @returns {{ pattern: string, base: string, ext: string, hasNs: boolean,
|
|
208
|
+
* regex: RegExp, maxDepth: number, render: (lang: string, ns?: string) => string }}
|
|
209
|
+
*/
|
|
210
|
+
function compileLocalesPattern(pattern) {
|
|
211
|
+
if (typeof pattern !== 'string' || pattern.trim() === '') {
|
|
212
|
+
throw layoutError('"localesPattern" must be a non-empty string such as "public/locales/{lang}/{ns}.json".');
|
|
213
|
+
}
|
|
214
|
+
const abs = path.resolve(pattern);
|
|
215
|
+
const posix = toPosix(abs);
|
|
216
|
+
const placeholders = [...posix.matchAll(/\{([^}]*)\}/g)].map(m => m[1]);
|
|
217
|
+
const unknown = placeholders.filter(p => p !== 'lang' && p !== 'ns');
|
|
218
|
+
if (unknown.length > 0) {
|
|
219
|
+
throw layoutError(
|
|
220
|
+
`"localesPattern" has unknown placeholder(s) ${unknown.map(u => `{${u}}`).join(', ')} — `
|
|
221
|
+
+ 'only {lang} and {ns} are supported.');
|
|
222
|
+
}
|
|
223
|
+
if (!placeholders.includes('lang')) {
|
|
224
|
+
throw layoutError(`"localesPattern" must contain {lang} (got "${pattern}").`);
|
|
225
|
+
}
|
|
226
|
+
if (placeholders.filter(p => p === 'ns').length > 1) {
|
|
227
|
+
throw layoutError(`"localesPattern" may contain {ns} at most once (got "${pattern}").`);
|
|
228
|
+
}
|
|
229
|
+
const fileName = posix.slice(posix.lastIndexOf('/') + 1);
|
|
230
|
+
const ext = path.extname(fileName.replace(/\{[^}]*\}/g, 'X'));
|
|
231
|
+
if (!ext || /\{/.test(ext) || fileName.endsWith('}')) {
|
|
232
|
+
throw layoutError(
|
|
233
|
+
`"localesPattern" must end in a file extension, e.g. "{lang}/{ns}.json" (got "${pattern}").`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Static base directory: everything before the segment holding the first
|
|
237
|
+
// placeholder. Discovery walks only this tree.
|
|
238
|
+
const firstPh = posix.indexOf('{');
|
|
239
|
+
const base = posix.slice(0, posix.lastIndexOf('/', firstPh)) || '/';
|
|
240
|
+
|
|
241
|
+
// Build an anchored regex. {lang} is one path segment fragment (never a
|
|
242
|
+
// slash); {ns} may span directories ("admin/users").
|
|
243
|
+
let seenLang = false;
|
|
244
|
+
let src = '';
|
|
245
|
+
let last = 0;
|
|
246
|
+
for (const m of posix.matchAll(/\{(lang|ns)\}/g)) {
|
|
247
|
+
src += escapeRegex(posix.slice(last, m.index));
|
|
248
|
+
if (m[1] === 'lang') {
|
|
249
|
+
src += seenLang ? '\\k<lang>' : '(?<lang>[^/]+?)';
|
|
250
|
+
seenLang = true;
|
|
251
|
+
} else {
|
|
252
|
+
src += '(?<ns>.+?)';
|
|
253
|
+
}
|
|
254
|
+
last = m.index + m[0].length;
|
|
255
|
+
}
|
|
256
|
+
src += escapeRegex(posix.slice(last));
|
|
257
|
+
const regex = new RegExp(`^${src}$`);
|
|
258
|
+
|
|
259
|
+
const hasNs = placeholders.includes('ns');
|
|
260
|
+
// Without {ns} the depth below `base` is fixed by the pattern; with {ns}
|
|
261
|
+
// namespaces may nest arbitrarily deep.
|
|
262
|
+
const maxDepth = hasNs ? Infinity : posix.slice(base.length + 1).split('/').length;
|
|
263
|
+
|
|
264
|
+
const render = (lang, ns = '') => {
|
|
265
|
+
const out = posix.replace(/\{lang\}/g, lang).replace(/\{ns\}/g, ns);
|
|
266
|
+
return path.normalize(out.split('/').join(path.sep));
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
return { pattern, base: path.normalize(base), ext, hasNs, regex, maxDepth, render };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function escapeRegex(s) {
|
|
273
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// -----------------------------------------------------------------
|
|
277
|
+
// Layout discovery
|
|
278
|
+
// -----------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Resolve the format + extension of a flat project's source file.
|
|
282
|
+
*
|
|
283
|
+
* Explicit format: that format's extensions, preferring one that exists
|
|
284
|
+
* (so `format: "yaml"` finds `en.yml`). Auto: whichever `<inputLocale>.<ext>`
|
|
285
|
+
* actually exists; when several do, the directory's majority format
|
|
286
|
+
* (detectFormatFromDir — the pre-layout behaviour) breaks the tie; when none
|
|
287
|
+
* does, the majority format names the file the "source not found" error
|
|
288
|
+
* points at.
|
|
289
|
+
*/
|
|
290
|
+
function resolveFlatSource(localesDir, inputLocale, explicitFormat) {
|
|
291
|
+
const exists = (ext) => isFile(path.join(localesDir, `${inputLocale}${ext}`));
|
|
292
|
+
if (explicitFormat) {
|
|
293
|
+
const exts = extensionsForFormat(explicitFormat);
|
|
294
|
+
if (exts.length === 0) {
|
|
295
|
+
throw layoutError(`Unknown locale format "${explicitFormat}".`);
|
|
296
|
+
}
|
|
297
|
+
return { format: explicitFormat, ext: exts.find(exists) || exts[0] };
|
|
298
|
+
}
|
|
299
|
+
const present = Object.keys(FORMAT_BY_EXT).filter(exists);
|
|
300
|
+
const families = [...new Set(present.map(formatForExtension))];
|
|
301
|
+
if (families.length === 1) {
|
|
302
|
+
return { format: families[0], ext: present[0] };
|
|
303
|
+
}
|
|
304
|
+
const majority = detectFormatFromDir(localesDir);
|
|
305
|
+
if (families.length === 0) {
|
|
306
|
+
return { format: majority, ext: extensionsForFormat(majority)[0] };
|
|
307
|
+
}
|
|
308
|
+
if (families.includes(majority)) {
|
|
309
|
+
return { format: majority, ext: present.find(e => formatForExtension(e) === majority) };
|
|
310
|
+
}
|
|
311
|
+
throw layoutError(
|
|
312
|
+
`Found more than one source locale file (${present.map(e => inputLocale + e).join(', ')}) in ${localesDir} — `
|
|
313
|
+
+ 'set "format" in champollion.config.json to say which one is the source.');
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** gettext templates (*.pot) directly in `dir`, sorted. */
|
|
317
|
+
function findTemplates(dir) {
|
|
318
|
+
try {
|
|
319
|
+
return fs.readdirSync(dir)
|
|
320
|
+
.filter(n => n.endsWith('.pot') && isFile(path.join(dir, n)))
|
|
321
|
+
.sort()
|
|
322
|
+
.map(n => path.join(dir, n));
|
|
323
|
+
} catch {
|
|
324
|
+
return [];
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function tooManyTemplates(dir, pots, inputLocale) {
|
|
329
|
+
return layoutError(
|
|
330
|
+
`Found several gettext templates in ${dir} (${pots.map(p => path.basename(p)).join(', ')}) and no `
|
|
331
|
+
+ `${inputLocale} catalog — keep one template there, or create the source catalog `
|
|
332
|
+
+ `(e.g. \`msginit --locale=${inputLocale} --no-translator\`), so the source is not a guess.`);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** A LocaleFile for a template standing in for the source. */
|
|
336
|
+
function templateFile(pot, ns, inputLocale, base) {
|
|
337
|
+
return {
|
|
338
|
+
ns, code: inputLocale, path: pot, ext: '.pot', format: 'po',
|
|
339
|
+
rel: toPosix(path.relative(base, pot)), role: 'source', sourcePath: pot, template: true,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Add role + sourcePath to a layout's file factory: a target file knows the
|
|
345
|
+
* source file it mirrors (the document formats are written from it).
|
|
346
|
+
*/
|
|
347
|
+
function withSource(fileFor, sourceFiles, inputLocale) {
|
|
348
|
+
const byNs = new Map(sourceFiles.map(f => [f.ns, f.path]));
|
|
349
|
+
return (code, ns = '') => {
|
|
350
|
+
const f = fileFor(code, ns);
|
|
351
|
+
const role = code === inputLocale ? 'source' : 'target';
|
|
352
|
+
return { ...f, role, sourcePath: byNs.get(ns) ?? (role === 'source' ? f.path : null) };
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Choose the format family for a set of discovered files: the explicit
|
|
358
|
+
* format when configured, else the most common one (ties → FORMAT_BY_EXT
|
|
359
|
+
* order, i.e. json first).
|
|
360
|
+
*/
|
|
361
|
+
function chooseFamily(files, explicitFormat) {
|
|
362
|
+
if (explicitFormat) return explicitFormat;
|
|
363
|
+
const counts = new Map();
|
|
364
|
+
for (const f of files) {
|
|
365
|
+
const fam = formatForExtension(path.extname(f));
|
|
366
|
+
counts.set(fam, (counts.get(fam) || 0) + 1);
|
|
367
|
+
}
|
|
368
|
+
let best = null;
|
|
369
|
+
let bestCount = 0;
|
|
370
|
+
for (const fam of [...new Set(Object.values(FORMAT_BY_EXT))]) {
|
|
371
|
+
const c = counts.get(fam) || 0;
|
|
372
|
+
if (c > bestCount) { best = fam; bestCount = c; }
|
|
373
|
+
}
|
|
374
|
+
return best || 'json';
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Describe where every locale's files live for this project.
|
|
379
|
+
*
|
|
380
|
+
* @param {object} config - Resolved config (inputLocale, localesDir, format,
|
|
381
|
+
* optional localesPattern / localesLayout). Raw objects work too: a
|
|
382
|
+
* missing format means 'auto'.
|
|
383
|
+
* @param {{ cwd?: string }} [options] - cwd only shapes the human-readable
|
|
384
|
+
* `display` path (relative to the project root)
|
|
385
|
+
* @returns {LocaleLayout}
|
|
386
|
+
*
|
|
387
|
+
* @typedef {object} LocaleFile
|
|
388
|
+
* @property {string} ns - Namespace ('' when the layout has one file per locale)
|
|
389
|
+
* @property {string} code - Locale code this file belongs to
|
|
390
|
+
* @property {string} path - Absolute path
|
|
391
|
+
* @property {string} ext - Real extension, e.g. '.yml'
|
|
392
|
+
* @property {string} format - 'json' | 'toml' | 'yaml' | 'po' | 'arb'
|
|
393
|
+
* @property {string} rel - Display path relative to the layout base ('fr.json', 'fr/common.json')
|
|
394
|
+
* @property {'source'|'target'} role - Which side of a translation this file is
|
|
395
|
+
* @property {string|null} sourcePath - The source file this one mirrors (the
|
|
396
|
+
* document formats are written from it); the file's own path for a source
|
|
397
|
+
* @property {boolean} [template] - A gettext template (.pot) standing in for
|
|
398
|
+
* the source language's catalog
|
|
399
|
+
*
|
|
400
|
+
* @typedef {object} LocaleLayout
|
|
401
|
+
* @property {'flat'|'dir'|'pattern'} kind
|
|
402
|
+
* @property {string} format - Format family of the source files
|
|
403
|
+
* @property {string} sourceLocale
|
|
404
|
+
* @property {string} baseDir - Directory every locale file lives under
|
|
405
|
+
* @property {boolean} namespaced - True when a locale may span several files
|
|
406
|
+
* @property {string} display - Human description, e.g. "public/locales/{lang}/{ns}.json"
|
|
407
|
+
* @property {LocaleFile[]} sourceFiles - The source locale's files (flat: the
|
|
408
|
+
* expected file, which may not exist; dir/pattern: files found on disk)
|
|
409
|
+
* @property {string[]} ignored - Source-side files skipped (other format family)
|
|
410
|
+
* @property {(code: string, ns?: string) => LocaleFile} fileFor
|
|
411
|
+
* @property {(code: string) => LocaleFile[]} filesFor - Mirror of sourceFiles for `code`
|
|
412
|
+
* @property {() => string[]} listLocales - Target locale codes present on disk
|
|
413
|
+
* (exact code match, source excluded, sorted)
|
|
414
|
+
*/
|
|
415
|
+
function discoverLocaleLayout(config, { cwd = process.cwd() } = {}) {
|
|
416
|
+
const inputLocale = config.inputLocale || 'en';
|
|
417
|
+
const shown = (dir) => toPosix(path.relative(cwd, dir)) || '.';
|
|
418
|
+
const fmt = config.format && config.format !== 'auto' ? config.format : null;
|
|
419
|
+
if (fmt === 'docusaurus') {
|
|
420
|
+
throw new Error('Docusaurus projects use their own locale lane (i18n/<locale>/…{message} JSON) — the key-value layout does not apply.');
|
|
421
|
+
}
|
|
422
|
+
if (config.localesLayout != null && !LAYOUT_KINDS.includes(config.localesLayout)) {
|
|
423
|
+
throw layoutError(`"localesLayout" must be one of ${LAYOUT_KINDS.join(', ')} (got "${config.localesLayout}").`);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (config.localesPattern) {
|
|
427
|
+
if (config.localesLayout) {
|
|
428
|
+
throw layoutError('Set either "localesPattern" or "localesLayout", not both — a pattern already fixes the layout.');
|
|
429
|
+
}
|
|
430
|
+
return patternLayout(config, inputLocale, fmt, shown);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const localesDir = path.resolve(config.localesDir || './locales');
|
|
434
|
+
const sourceDir = path.join(localesDir, inputLocale);
|
|
435
|
+
const dirCandidates = isDirectory(sourceDir) ? walkLocaleFiles(sourceDir) : [];
|
|
436
|
+
const flatPresent = Object.keys(FORMAT_BY_EXT)
|
|
437
|
+
.filter(ext => isFile(path.join(localesDir, `${inputLocale}${ext}`)));
|
|
438
|
+
|
|
439
|
+
let kind = config.localesLayout || null;
|
|
440
|
+
if (!kind) {
|
|
441
|
+
if (dirCandidates.length > 0 && flatPresent.length > 0) {
|
|
442
|
+
throw layoutError(
|
|
443
|
+
`Both ${path.join(localesDir, inputLocale + flatPresent[0])} and a folder of locale files `
|
|
444
|
+
+ `${sourceDir}${path.sep} exist, so the layout is ambiguous. Set "localesLayout": "flat" `
|
|
445
|
+
+ '(one file per locale) or "dir" (one folder per locale) in champollion.config.json.');
|
|
446
|
+
}
|
|
447
|
+
kind = dirCandidates.length > 0 ? 'dir' : 'flat';
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return kind === 'dir'
|
|
451
|
+
? dirLayout(localesDir, inputLocale, fmt, dirCandidates, shown)
|
|
452
|
+
: flatLayout(localesDir, inputLocale, fmt, shown);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function flatLayout(localesDir, inputLocale, fmt, shown) {
|
|
456
|
+
const { format, ext } = resolveFlatSource(localesDir, inputLocale, fmt);
|
|
457
|
+
const baseFileFor = (code) => ({
|
|
458
|
+
ns: '', code, path: path.join(localesDir, `${code}${ext}`), ext, format, rel: `${code}${ext}`,
|
|
459
|
+
});
|
|
460
|
+
let sourceFile = { ...baseFileFor(inputLocale), role: 'source' };
|
|
461
|
+
sourceFile.sourcePath = sourceFile.path;
|
|
462
|
+
// gettext: no <source>.po → the one template in the folder (GNU po/).
|
|
463
|
+
if (format === 'po' && !isFile(sourceFile.path)) {
|
|
464
|
+
const pots = findTemplates(localesDir);
|
|
465
|
+
if (pots.length > 1) throw tooManyTemplates(localesDir, pots, inputLocale);
|
|
466
|
+
if (pots.length === 1) sourceFile = templateFile(pots[0], '', inputLocale, localesDir);
|
|
467
|
+
}
|
|
468
|
+
const fileFor = withSource(baseFileFor, [sourceFile], inputLocale);
|
|
469
|
+
return {
|
|
470
|
+
kind: 'flat',
|
|
471
|
+
format,
|
|
472
|
+
sourceLocale: inputLocale,
|
|
473
|
+
baseDir: localesDir,
|
|
474
|
+
namespaced: false,
|
|
475
|
+
display: `${shown(localesDir)}/{lang}${ext}`,
|
|
476
|
+
sourceFiles: [sourceFile],
|
|
477
|
+
ignored: [],
|
|
478
|
+
fileFor,
|
|
479
|
+
filesFor: (code) => [fileFor(code)],
|
|
480
|
+
listLocales: () => {
|
|
481
|
+
if (!isDirectory(localesDir)) return [];
|
|
482
|
+
return fs.readdirSync(localesDir)
|
|
483
|
+
.filter(f => f.endsWith(ext) && isFile(path.join(localesDir, f)))
|
|
484
|
+
.map(f => f.slice(0, -ext.length))
|
|
485
|
+
.filter(code => code && code !== inputLocale)
|
|
486
|
+
.sort();
|
|
487
|
+
},
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function dirLayout(localesDir, inputLocale, fmt, candidates, shown) {
|
|
492
|
+
const sourceDir = path.join(localesDir, inputLocale);
|
|
493
|
+
const all = candidates.length > 0 ? candidates : (isDirectory(sourceDir) ? walkLocaleFiles(sourceDir) : []);
|
|
494
|
+
const format = chooseFamily(all, fmt);
|
|
495
|
+
const exts = new Set(extensionsForFormat(format));
|
|
496
|
+
const chosen = all.filter(f => exts.has(path.extname(f).toLowerCase()));
|
|
497
|
+
const ignored = all.filter(f => !exts.has(path.extname(f).toLowerCase()))
|
|
498
|
+
.map(f => toPosix(path.relative(localesDir, f)));
|
|
499
|
+
|
|
500
|
+
const sourceFiles = chosen.map(f => {
|
|
501
|
+
const ext = path.extname(f);
|
|
502
|
+
const relInLocale = toPosix(path.relative(sourceDir, f));
|
|
503
|
+
const ns = relInLocale.slice(0, -ext.length);
|
|
504
|
+
return {
|
|
505
|
+
ns, code: inputLocale, path: f, ext, format, rel: `${inputLocale}/${relInLocale}`,
|
|
506
|
+
role: 'source', sourcePath: f,
|
|
507
|
+
};
|
|
508
|
+
}).sort((a, b) => a.ns.localeCompare(b.ns));
|
|
509
|
+
const extByNs = new Map(sourceFiles.map(f => [f.ns, f.ext]));
|
|
510
|
+
const defaultExt = extensionsForFormat(format)[0];
|
|
511
|
+
|
|
512
|
+
const fileFor = withSource((code, ns) => {
|
|
513
|
+
const ext = extByNs.get(ns) || defaultExt;
|
|
514
|
+
return {
|
|
515
|
+
ns, code, ext, format,
|
|
516
|
+
path: path.join(localesDir, code, ...`${ns}${ext}`.split('/')),
|
|
517
|
+
rel: `${code}/${ns}${ext}`,
|
|
518
|
+
};
|
|
519
|
+
}, sourceFiles, inputLocale);
|
|
520
|
+
|
|
521
|
+
return {
|
|
522
|
+
kind: 'dir',
|
|
523
|
+
format,
|
|
524
|
+
sourceLocale: inputLocale,
|
|
525
|
+
baseDir: localesDir,
|
|
526
|
+
namespaced: true,
|
|
527
|
+
display: `${shown(localesDir)}/{lang}/{ns}${defaultExt}`,
|
|
528
|
+
sourceFiles,
|
|
529
|
+
ignored,
|
|
530
|
+
fileFor,
|
|
531
|
+
filesFor: (code) => sourceFiles.map(f => fileFor(code, f.ns)),
|
|
532
|
+
// A locale is a sub-folder holding at least one file of this format, or
|
|
533
|
+
// an empty sub-folder (a project that created fr/ to ask for French).
|
|
534
|
+
// Folders holding only other files (utils/, components/) are not
|
|
535
|
+
// locales; stray files at the root are never locales.
|
|
536
|
+
listLocales: () => {
|
|
537
|
+
if (!isDirectory(localesDir)) return [];
|
|
538
|
+
return fs.readdirSync(localesDir, { withFileTypes: true })
|
|
539
|
+
.filter(e => e.isDirectory() && !e.name.startsWith('.') && !WALK_SKIP.has(e.name) && e.name !== inputLocale)
|
|
540
|
+
.filter(e => {
|
|
541
|
+
const d = path.join(localesDir, e.name);
|
|
542
|
+
const files = walkLocaleFiles(d);
|
|
543
|
+
if (files.some(f => exts.has(path.extname(f).toLowerCase()))) return true;
|
|
544
|
+
return fs.readdirSync(d).filter(n => !n.startsWith('.')).length === 0;
|
|
545
|
+
})
|
|
546
|
+
.map(e => e.name)
|
|
547
|
+
.sort();
|
|
548
|
+
},
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function patternLayout(config, inputLocale, fmt, shown) {
|
|
553
|
+
const compiled = compileLocalesPattern(config.localesPattern);
|
|
554
|
+
const extFormat = formatForExtension(compiled.ext);
|
|
555
|
+
// An .arb read as plain JSON is exactly the reported Flutter breakage:
|
|
556
|
+
// "@@locale" and every placeholder type in the @key metadata get
|
|
557
|
+
// translated, and gen-l10n refuses to build. Never do it.
|
|
558
|
+
if (fmt === 'json' && extFormat === 'arb') {
|
|
559
|
+
throw layoutError(
|
|
560
|
+
'"format": "json" would translate the @@locale and @key metadata of .arb files, which breaks '
|
|
561
|
+
+ '`flutter gen-l10n`. Remove "format" (ARB is detected from the extension) or set "format": "arb".');
|
|
562
|
+
}
|
|
563
|
+
// Explicit format wins; otherwise the extension decides, and an
|
|
564
|
+
// extension we cannot place fails loud instead of being parsed as JSON.
|
|
565
|
+
const format = fmt || extFormat;
|
|
566
|
+
if (!format) {
|
|
567
|
+
throw layoutError(
|
|
568
|
+
`Cannot tell the format of "${compiled.ext}" files from "localesPattern" — set "format" in champollion.config.json.`);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const matches = [];
|
|
572
|
+
if (isDirectory(compiled.base)) {
|
|
573
|
+
for (const f of walkLocaleFilesAnyExt(compiled.base, compiled.ext, compiled.maxDepth)) {
|
|
574
|
+
const m = compiled.regex.exec(toPosix(f));
|
|
575
|
+
if (m) matches.push({ path: f, lang: m.groups.lang, ns: compiled.hasNs ? m.groups.ns : '' });
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const baseFileFor = (code, ns = '') => {
|
|
580
|
+
const p = compiled.render(code, ns);
|
|
581
|
+
return {
|
|
582
|
+
ns, code, path: p, ext: compiled.ext, format,
|
|
583
|
+
rel: toPosix(path.relative(compiled.base, p)),
|
|
584
|
+
};
|
|
585
|
+
};
|
|
586
|
+
const asSource = (f) => ({ ...f, role: 'source', sourcePath: f.path });
|
|
587
|
+
|
|
588
|
+
let sourceFiles;
|
|
589
|
+
if (compiled.hasNs) {
|
|
590
|
+
sourceFiles = matches.filter(m => m.lang === inputLocale)
|
|
591
|
+
.map(m => asSource(baseFileFor(inputLocale, m.ns)))
|
|
592
|
+
.sort((a, b) => a.ns.localeCompare(b.ns));
|
|
593
|
+
} else {
|
|
594
|
+
// One file per locale: the expected source file, existing or not, so
|
|
595
|
+
// the caller's "source not found" error names the exact path.
|
|
596
|
+
sourceFiles = [asSource(baseFileFor(inputLocale, ''))];
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// gettext: no source-language catalog → its template (.pot).
|
|
600
|
+
if (format === 'po') {
|
|
601
|
+
const dirs = [...new Set([
|
|
602
|
+
compiled.hasNs ? null : path.dirname(sourceFiles[0].path),
|
|
603
|
+
compiled.base,
|
|
604
|
+
path.dirname(compiled.base),
|
|
605
|
+
].filter(Boolean))];
|
|
606
|
+
if (compiled.hasNs && sourceFiles.length === 0) {
|
|
607
|
+
for (const d of dirs) {
|
|
608
|
+
const pots = findTemplates(d);
|
|
609
|
+
if (pots.length === 0) continue;
|
|
610
|
+
sourceFiles = pots.map(p => templateFile(p, path.basename(p, '.pot'), inputLocale, compiled.base));
|
|
611
|
+
break;
|
|
612
|
+
}
|
|
613
|
+
} else if (!compiled.hasNs && !isFile(sourceFiles[0].path)) {
|
|
614
|
+
const fileName = compiled.pattern.split(/[\\/]/).pop();
|
|
615
|
+
const name = fileName.slice(0, fileName.length - compiled.ext.length)
|
|
616
|
+
.replace(/[_\-.]?\{lang\}[_\-.]?/g, '');
|
|
617
|
+
for (const d of dirs) {
|
|
618
|
+
if (name) {
|
|
619
|
+
const p = path.join(d, `${name}.pot`);
|
|
620
|
+
if (isFile(p)) { sourceFiles = [templateFile(p, '', inputLocale, compiled.base)]; break; }
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
const pots = findTemplates(d);
|
|
624
|
+
if (pots.length > 1) throw tooManyTemplates(d, pots, inputLocale);
|
|
625
|
+
if (pots.length === 1) { sourceFiles = [templateFile(pots[0], '', inputLocale, compiled.base)]; break; }
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
const fileFor = withSource(baseFileFor, sourceFiles, inputLocale);
|
|
630
|
+
|
|
631
|
+
return {
|
|
632
|
+
kind: 'pattern',
|
|
633
|
+
format,
|
|
634
|
+
sourceLocale: inputLocale,
|
|
635
|
+
baseDir: compiled.base,
|
|
636
|
+
namespaced: compiled.hasNs,
|
|
637
|
+
display: shown(compiled.render('{lang}', '{ns}')),
|
|
638
|
+
sourceFiles,
|
|
639
|
+
ignored: [],
|
|
640
|
+
fileFor,
|
|
641
|
+
filesFor: (code) => sourceFiles.map(f => fileFor(code, f.ns)),
|
|
642
|
+
listLocales: () => [...new Set(matches.map(m => m.lang))]
|
|
643
|
+
.filter(code => code !== inputLocale)
|
|
644
|
+
.sort(),
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** Files under a pattern's base carrying the pattern's own extension (which may be unknown, e.g. ".strings"). */
|
|
649
|
+
function walkLocaleFilesAnyExt(dir, ext, maxDepth) {
|
|
650
|
+
return walkFiles(dir, name => name.endsWith(ext), { maxDepth });
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Files making up one locale: the source's files for the input locale, the
|
|
655
|
+
* mirrored files for any other code.
|
|
656
|
+
*
|
|
657
|
+
* @param {object} config - Resolved config
|
|
658
|
+
* @param {{ code?: string, layout?: LocaleLayout }} [options]
|
|
659
|
+
* @returns {LocaleFile[]}
|
|
660
|
+
*/
|
|
661
|
+
function resolveLocaleFiles(config, { code, layout } = {}) {
|
|
662
|
+
const l = layout || discoverLocaleLayout(config);
|
|
663
|
+
const target = code || l.sourceLocale;
|
|
664
|
+
return target === l.sourceLocale ? l.sourceFiles : l.filesFor(target);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Throw the "source not found" error every command shares, worded for the
|
|
669
|
+
* layout, when the source locale has no readable file.
|
|
670
|
+
*
|
|
671
|
+
* @param {LocaleLayout} layout
|
|
672
|
+
*/
|
|
673
|
+
function assertSourceFiles(layout) {
|
|
674
|
+
if (layout.kind === 'flat' || (layout.kind === 'pattern' && !layout.namespaced)) {
|
|
675
|
+
const p = layout.sourceFiles[0].path;
|
|
676
|
+
if (!fs.existsSync(p)) {
|
|
677
|
+
const pot = layout.format === 'po' ? ' (nor a gettext template, .pot, where one is looked for)' : '';
|
|
678
|
+
throw new Error(`Source locale not found: ${p}${pot}`);
|
|
679
|
+
}
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
if (layout.sourceFiles.length === 0) {
|
|
683
|
+
const where = layout.kind === 'dir'
|
|
684
|
+
? path.join(layout.baseDir, layout.sourceLocale) + path.sep
|
|
685
|
+
: `${layout.display} with {lang} = ${layout.sourceLocale}`;
|
|
686
|
+
throw new Error(`Source locale not found: no ${layout.format} locale files in ${where}`);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// -----------------------------------------------------------------
|
|
691
|
+
// Read / write
|
|
692
|
+
// -----------------------------------------------------------------
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Read one locale file into a flat key → value map (JSON flattened to
|
|
696
|
+
* dot-paths; TOML/YAML/po/arb are already flat). A missing file reads as {}.
|
|
697
|
+
*
|
|
698
|
+
* Formats the reader does not implement fail loud here — never parsed as
|
|
699
|
+
* JSON, which would report a confusing "Invalid JSON" for a .po file.
|
|
700
|
+
*
|
|
701
|
+
* @param {LocaleFile} file
|
|
702
|
+
* @returns {object} Flat map
|
|
703
|
+
*/
|
|
704
|
+
function readLocaleFlat(file) {
|
|
705
|
+
assertReadableFormat(file);
|
|
706
|
+
const data = readLocaleFile(file.path, file.format, readOptions(file));
|
|
707
|
+
return file.format === 'json' ? flattenKeys(data) : { ...data };
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Read one locale file in the shape sync edits: the nested object for
|
|
712
|
+
* JSON (so untouched structure round-trips), the flat map otherwise.
|
|
713
|
+
*
|
|
714
|
+
* @param {LocaleFile} file
|
|
715
|
+
* @returns {object}
|
|
716
|
+
*/
|
|
717
|
+
function readLocaleData(file) {
|
|
718
|
+
assertReadableFormat(file);
|
|
719
|
+
return readLocaleFile(file.path, file.format, readOptions(file));
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/** gettext reads differ by side: a source falls back to msgid. */
|
|
723
|
+
function readOptions(file) {
|
|
724
|
+
return { role: file.role || 'target', locale: file.code || null };
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Write a locale file produced by readLocaleData()-shaped data, creating
|
|
729
|
+
* missing parent folders (a new locale in a dir layout has no folder yet).
|
|
730
|
+
* Document formats (po, arb) are rebuilt from the file's source.
|
|
731
|
+
*
|
|
732
|
+
* @param {LocaleFile} file
|
|
733
|
+
* @param {object} data - Nested object (JSON) or flat map (TOML/YAML/po/arb)
|
|
734
|
+
* @param {'hugo'|'nested'|null} [yamlStyle]
|
|
735
|
+
*/
|
|
736
|
+
function writeLocaleData(file, data, yamlStyle = null) {
|
|
737
|
+
assertReadableFormat(file);
|
|
738
|
+
fs.mkdirSync(path.dirname(file.path), { recursive: true });
|
|
739
|
+
writeLocaleFile(file.path, data, file.format, file.format !== 'json' ? data : undefined, yamlStyle, {
|
|
740
|
+
sourcePath: file.sourcePath || null,
|
|
741
|
+
locale: file.code || null,
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function assertReadableFormat(file) {
|
|
746
|
+
if (!LOCALE_FILE_FORMATS.includes(file.format)) {
|
|
747
|
+
throw new Error(
|
|
748
|
+
`${file.rel}: locale format "${file.format}" is not supported by this version of champollion `
|
|
749
|
+
+ `(supported: ${LOCALE_FILE_FORMATS.join(', ')}).`);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* The content of a brand-new, empty locale file of `format`, or null when
|
|
755
|
+
* this version cannot write that format.
|
|
756
|
+
*
|
|
757
|
+
* @param {string} format
|
|
758
|
+
* @param {string} [code] - The locale the file is for (po: the header's
|
|
759
|
+
* Language and Plural-Forms; arb: `@@locale`)
|
|
760
|
+
* @param {{ sourcePath?: string|null }} [options] - po: the template whose
|
|
761
|
+
* header fields (Project-Id-Version, POT-Creation-Date…) a new catalog carries
|
|
762
|
+
* @returns {string|null}
|
|
763
|
+
*/
|
|
764
|
+
function emptyLocaleContent(format, code = null, { sourcePath = null } = {}) {
|
|
765
|
+
if (format === 'json') return '{}\n';
|
|
766
|
+
// An empty TOML/YAML file reads back as {} (readLocaleFile short-circuits
|
|
767
|
+
// blank files), and is valid in both languages.
|
|
768
|
+
if (format === 'toml' || format === 'yaml') return '';
|
|
769
|
+
if ((format === 'po' || format === 'arb') && code) return emptyDocumentContent(format, code, { sourcePath });
|
|
770
|
+
return null;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* Create every missing target file for `codes`, mirroring the source's
|
|
775
|
+
* namespaces, as an empty file of the right format. Existing files are
|
|
776
|
+
* never touched. Used by `init --langs` and by sync for configured
|
|
777
|
+
* locales, so nobody has to hand-create an empty fr.json.
|
|
778
|
+
*
|
|
779
|
+
* @param {LocaleLayout} layout
|
|
780
|
+
* @param {string[]} codes - Target locale codes
|
|
781
|
+
* @returns {{ created: LocaleFile[], unsupported: LocaleFile[], refused: LocaleFile[] }}
|
|
782
|
+
* `unsupported`: files of a format this version cannot write; `refused`:
|
|
783
|
+
* paths a crafted code would put outside the layout's base directory.
|
|
784
|
+
*/
|
|
785
|
+
function createMissingTargetFiles(layout, codes) {
|
|
786
|
+
const created = [];
|
|
787
|
+
const unsupported = [];
|
|
788
|
+
const refused = [];
|
|
789
|
+
for (const code of codes) {
|
|
790
|
+
if (code === layout.sourceLocale) continue;
|
|
791
|
+
for (const file of layout.filesFor(code)) {
|
|
792
|
+
if (fs.existsSync(file.path)) continue;
|
|
793
|
+
if (!isContained(file.path, layout.baseDir)) { refused.push(file); continue; }
|
|
794
|
+
const content = emptyLocaleContent(file.format, code, { sourcePath: file.sourcePath || null });
|
|
795
|
+
if (content === null) { unsupported.push(file); continue; }
|
|
796
|
+
fs.mkdirSync(path.dirname(file.path), { recursive: true });
|
|
797
|
+
fs.writeFileSync(file.path, content, 'utf-8');
|
|
798
|
+
created.push(file);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
return { created, unsupported, refused };
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function isContained(p, parent) {
|
|
805
|
+
const r = path.resolve(p);
|
|
806
|
+
const base = path.resolve(parent);
|
|
807
|
+
return r.startsWith(base + path.sep) || r === base;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// -----------------------------------------------------------------
|
|
811
|
+
// Source units — the source locale, one entry per file
|
|
812
|
+
// -----------------------------------------------------------------
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Read every source file of the layout into a "unit": the per-file flat map
|
|
816
|
+
* sync diffs, translates and writes against. Fails loud when the source is
|
|
817
|
+
* missing (same message the flat path always gave).
|
|
818
|
+
*
|
|
819
|
+
* @param {LocaleLayout} layout
|
|
820
|
+
* @returns {SourceUnit[]}
|
|
821
|
+
*
|
|
822
|
+
* @typedef {object} SourceUnit
|
|
823
|
+
* @property {string} ns - Namespace ('' for single-file layouts)
|
|
824
|
+
* @property {LocaleFile} file - The source file
|
|
825
|
+
* @property {object} flat - Flat key → value map (unsafe keys removed)
|
|
826
|
+
* @property {'hugo'|'nested'|null} yamlStyle - YAML serialization style of the source
|
|
827
|
+
* @property {Map} pluralGroups - i18next plural groups (json only; empty otherwise)
|
|
828
|
+
* @property {object} context - key → translator note the source file carries
|
|
829
|
+
* (ARB `@key.description`, gettext msgctxt and `#.` comments); {} otherwise
|
|
830
|
+
*/
|
|
831
|
+
function loadSourceUnits(layout) {
|
|
832
|
+
assertSourceFiles(layout);
|
|
833
|
+
return layout.sourceFiles.map((file) => {
|
|
834
|
+
const flat = readLocaleFlat(file);
|
|
835
|
+
// Defense-in-depth: drop keys that could cause prototype pollution.
|
|
836
|
+
for (const key of Object.keys(flat)) {
|
|
837
|
+
if (isUnsafeKey(key)) delete flat[key];
|
|
838
|
+
}
|
|
839
|
+
// YAML comes in two shapes (Hugo plural sub-keys vs standard nesting);
|
|
840
|
+
// the writer must reproduce the SOURCE's, so probe the raw text once.
|
|
841
|
+
const yamlStyle = file.format === 'yaml'
|
|
842
|
+
? detectYAMLStyle(fs.readFileSync(file.path, 'utf-8'))
|
|
843
|
+
: null;
|
|
844
|
+
// i18next plural suffixes are a JSON convention; TOML/YAML (Hugo,
|
|
845
|
+
// go-i18n) carry plurals as sub-keys the format adapter already handles.
|
|
846
|
+
const pluralGroups = file.format === 'json'
|
|
847
|
+
? findPluralGroups(flat, layout.sourceLocale)
|
|
848
|
+
: new Map();
|
|
849
|
+
const context = file.format === 'po' || file.format === 'arb'
|
|
850
|
+
? readLocaleContext(file.path, file.format)
|
|
851
|
+
: {};
|
|
852
|
+
return { ns: file.ns, file, flat, yamlStyle, pluralGroups, context };
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* What a target locale is expected to contain for one source unit: the
|
|
858
|
+
* source map itself, or — when the file has i18next plural groups — the map
|
|
859
|
+
* with the TARGET's CLDR plural categories (lib/plurals.js).
|
|
860
|
+
*
|
|
861
|
+
* A source file that carries translator notes (ARB descriptions, gettext
|
|
862
|
+
* msgctxt / `#.` comments) returns an identity "expansion" holding only
|
|
863
|
+
* those notes as `descriptions` — the prompt context sync already threads
|
|
864
|
+
* from expansions — with no key mapping (origin {}), so lock keys,
|
|
865
|
+
* --force-keys and verify behave exactly as for a plain map.
|
|
866
|
+
*
|
|
867
|
+
* @param {SourceUnit} unit
|
|
868
|
+
* @param {string} sourceLocale
|
|
869
|
+
* @param {string} code - Target locale
|
|
870
|
+
* @returns {{ flat: object, expansion: ReturnType<typeof expandPluralsForLocale> }}
|
|
871
|
+
*/
|
|
872
|
+
function expectedForTarget(unit, sourceLocale, code) {
|
|
873
|
+
if (!unit.pluralGroups || unit.pluralGroups.size === 0) {
|
|
874
|
+
if (unit.context && Object.keys(unit.context).length > 0) {
|
|
875
|
+
return {
|
|
876
|
+
flat: unit.flat,
|
|
877
|
+
expansion: {
|
|
878
|
+
flat: unit.flat, origin: {}, descriptions: unit.context, unused: [], groups: 0, unknownLocale: false,
|
|
879
|
+
},
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
return { flat: unit.flat, expansion: null };
|
|
883
|
+
}
|
|
884
|
+
const expansion = expandPluralsForLocale(unit.flat, sourceLocale, code, unit.pluralGroups);
|
|
885
|
+
return { flat: expansion.flat, expansion };
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// -----------------------------------------------------------------
|
|
889
|
+
// Namespaced keys
|
|
890
|
+
// -----------------------------------------------------------------
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* The key a (namespace, key) pair has in shared key spaces (lock manifest,
|
|
894
|
+
* XLIFF ids, dry-run lists). Bare for single-file layouts — which is what
|
|
895
|
+
* keeps a flat project's lock file unchanged.
|
|
896
|
+
*
|
|
897
|
+
* @param {LocaleLayout|{namespaced: boolean}} layout
|
|
898
|
+
* @param {string} ns
|
|
899
|
+
* @param {string} key
|
|
900
|
+
* @returns {string}
|
|
901
|
+
*/
|
|
902
|
+
function lockKey(layout, ns, key) {
|
|
903
|
+
return layout.namespaced ? `${ns}${NS_SEPARATOR}${key}` : key;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* Split a shared-space key back into (namespace, key). For namespaced
|
|
908
|
+
* layouts the namespace is everything before the FIRST separator (a key
|
|
909
|
+
* may itself contain "::"; a namespace is a file path and cannot).
|
|
910
|
+
*
|
|
911
|
+
* @param {LocaleLayout|{namespaced: boolean}} layout
|
|
912
|
+
* @param {string} id
|
|
913
|
+
* @returns {{ ns: string, key: string }|null} null when a namespaced layout
|
|
914
|
+
* gets an id with no namespace
|
|
915
|
+
*/
|
|
916
|
+
function splitLockKey(layout, id) {
|
|
917
|
+
if (!layout.namespaced) return { ns: '', key: id };
|
|
918
|
+
const i = id.indexOf(NS_SEPARATOR);
|
|
919
|
+
if (i < 0) return null;
|
|
920
|
+
return { ns: id.slice(0, i), key: id.slice(i + NS_SEPARATOR.length) };
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* A user-typed key with its gettext context separator restored: "␄"
|
|
925
|
+
* (U+2404) or the literal text `\x04` → U+0004.
|
|
926
|
+
*
|
|
927
|
+
* @param {string} key
|
|
928
|
+
* @returns {string}
|
|
929
|
+
*/
|
|
930
|
+
function fromTypedContext(key) {
|
|
931
|
+
return key.replace(/\u2404/g, '\u0004').replace(/\\x04/g, '\u0004');
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* The subset of user-named keys (--force-keys) that applies to one
|
|
936
|
+
* namespace. In a namespaced layout "common::nav.home" names one file's
|
|
937
|
+
* key, and a bare "nav.home" names that key in every file that has it.
|
|
938
|
+
*
|
|
939
|
+
* @param {LocaleLayout|{namespaced: boolean}} layout
|
|
940
|
+
* @param {string[]} keys
|
|
941
|
+
* @param {string} ns
|
|
942
|
+
* @returns {string[]}
|
|
943
|
+
*/
|
|
944
|
+
function keysForNamespace(layout, keys, ns) {
|
|
945
|
+
if (!keys || keys.length === 0) return keys || [];
|
|
946
|
+
// A gettext key with a context is `msgctxt\u0004msgid`. U+0004 cannot be
|
|
947
|
+
// typed, so two stand-ins are accepted: "␄" (U+2404 — how reports print
|
|
948
|
+
// it, so a printed key pastes back) and the literal four characters
|
|
949
|
+
// `\x04` (what a person can type: `--redo 'keys:verb\x04Open'`). Neither
|
|
950
|
+
// occurs in real msgids; the separator `::` and `\,` are unaffected.
|
|
951
|
+
keys = keys.map(k => (typeof k === 'string' ? fromTypedContext(k) : k));
|
|
952
|
+
if (!layout.namespaced) return keys;
|
|
953
|
+
const out = [];
|
|
954
|
+
for (const k of keys) {
|
|
955
|
+
const i = k.indexOf(NS_SEPARATOR);
|
|
956
|
+
if (i < 0) out.push(k);
|
|
957
|
+
else if (k.slice(0, i) === ns) out.push(k.slice(i + NS_SEPARATOR.length));
|
|
958
|
+
}
|
|
959
|
+
return out;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// -----------------------------------------------------------------
|
|
963
|
+
// Framework detection for document formats (used by `init`)
|
|
964
|
+
// -----------------------------------------------------------------
|
|
965
|
+
|
|
966
|
+
/** `key: value` lines of a flat YAML file (l10n.yaml is flat). */
|
|
967
|
+
function readFlatYAML(file) {
|
|
968
|
+
const out = {};
|
|
969
|
+
let text;
|
|
970
|
+
try { text = fs.readFileSync(file, 'utf-8'); } catch { return out; }
|
|
971
|
+
for (const line of text.split(/\r?\n/)) {
|
|
972
|
+
const m = /^([A-Za-z0-9_-]+)\s*:\s*(.*?)\s*(?:#.*)?$/.exec(line);
|
|
973
|
+
if (m && m[2] !== '') out[m[1]] = m[2].replace(/^(['"])(.*)\1$/, '$2');
|
|
974
|
+
}
|
|
975
|
+
return out;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* A Flutter app's gen-l10n layout, or null when `cwd` is not a Flutter
|
|
980
|
+
* project. Reads l10n.yaml (`arb-dir`, `template-arb-file`) when present,
|
|
981
|
+
* else Flutter's defaults (lib/l10n, app_en.arb).
|
|
982
|
+
*
|
|
983
|
+
* The template file name gives both the pattern and the source locale the
|
|
984
|
+
* way gen-l10n reads it: the locale is what follows the first "_" that
|
|
985
|
+
* starts a locale CLDR knows ("app_en.arb" → app_{lang}.arb, en;
|
|
986
|
+
* "intl_pt_BR.arb" → intl_{lang}.arb, pt_BR).
|
|
987
|
+
*
|
|
988
|
+
* @param {string} cwd - Project root
|
|
989
|
+
* @returns {null | { framework: 'Flutter', format: 'arb', localesPattern: string,
|
|
990
|
+
* inputLocale: string, templateFile: string, templateExists: boolean,
|
|
991
|
+
* l10nYaml: boolean, targets: string[] }}
|
|
992
|
+
*/
|
|
993
|
+
function detectFlutterL10n(cwd) {
|
|
994
|
+
const pubspec = path.join(cwd, 'pubspec.yaml');
|
|
995
|
+
if (!isFile(pubspec)) return null;
|
|
996
|
+
const text = fs.readFileSync(pubspec, 'utf-8');
|
|
997
|
+
if (!/^\s*flutter\s*:/m.test(text) && !/sdk:\s*flutter\b/.test(text)) return null;
|
|
998
|
+
|
|
999
|
+
const l10nPath = path.join(cwd, 'l10n.yaml');
|
|
1000
|
+
const l10n = isFile(l10nPath) ? readFlatYAML(l10nPath) : {};
|
|
1001
|
+
const arbDir = (l10n['arb-dir'] || 'lib/l10n').replace(/\/+$/, '');
|
|
1002
|
+
const template = l10n['template-arb-file'] || 'app_en.arb';
|
|
1003
|
+
const stem = template.replace(/\.arb$/, '');
|
|
1004
|
+
|
|
1005
|
+
let prefix = null;
|
|
1006
|
+
let inputLocale = null;
|
|
1007
|
+
for (let i = 0; i < stem.length; i++) {
|
|
1008
|
+
if (stem[i] !== '_') continue;
|
|
1009
|
+
const candidate = stem.slice(i + 1);
|
|
1010
|
+
const lang = candidate.split(/[_-]/)[0];
|
|
1011
|
+
if (/^[a-z]{2,3}$/.test(lang) && Intl.PluralRules.supportedLocalesOf([lang]).length > 0) {
|
|
1012
|
+
prefix = stem.slice(0, i + 1);
|
|
1013
|
+
inputLocale = candidate;
|
|
1014
|
+
break;
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
if (!prefix) return null;
|
|
1018
|
+
|
|
1019
|
+
const localesPattern = `${toPosix(arbDir)}/${prefix}{lang}.arb`;
|
|
1020
|
+
const templatePath = path.join(cwd, ...toPosix(arbDir).split('/'), template);
|
|
1021
|
+
let targets = [];
|
|
1022
|
+
try {
|
|
1023
|
+
targets = discoverLocaleLayout({ inputLocale, localesPattern: path.join(cwd, localesPattern), format: 'arb' }, { cwd })
|
|
1024
|
+
.listLocales();
|
|
1025
|
+
} catch { /* an unreadable arb dir lists nothing */ }
|
|
1026
|
+
return {
|
|
1027
|
+
framework: 'Flutter', format: 'arb', localesPattern, inputLocale,
|
|
1028
|
+
templateFile: toPosix(path.relative(cwd, templatePath)), templateExists: isFile(templatePath),
|
|
1029
|
+
l10nYaml: isFile(l10nPath), targets,
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/**
|
|
1034
|
+
* A gettext layout under `cwd`, or null. Probes the conventional shapes:
|
|
1035
|
+
* <dir>/<lang>/LC_MESSAGES/<domain>.po (Django locale/, Babel/Flask
|
|
1036
|
+
* translations/, Sphinx locales/)
|
|
1037
|
+
* po/<lang>.po + po/<domain>.pot (GNU)
|
|
1038
|
+
* and reports the config that describes it. The source is the source
|
|
1039
|
+
* language's catalog when present, else a template (see GETTEXT TEMPLATES).
|
|
1040
|
+
*
|
|
1041
|
+
* @param {string} cwd - Project root
|
|
1042
|
+
* @param {{ source?: string }} [options]
|
|
1043
|
+
* @returns {null | { framework: string, format: 'po', localesPattern?: string,
|
|
1044
|
+
* localesDir?: string, inputLocale: string, sourceFiles: string[], targets: string[] }}
|
|
1045
|
+
*/
|
|
1046
|
+
function detectGettextLayout(cwd, { source = 'en' } = {}) {
|
|
1047
|
+
const framework = isFile(path.join(cwd, 'manage.py')) ? 'Django'
|
|
1048
|
+
: (isFile(path.join(cwd, 'babel.cfg')) ? 'Babel' : 'gettext');
|
|
1049
|
+
const describe = (config) => {
|
|
1050
|
+
try {
|
|
1051
|
+
const layout = discoverLocaleLayout({ inputLocale: source, format: 'po', ...config }, { cwd });
|
|
1052
|
+
const sourceFiles = layout.sourceFiles.filter(f => isFile(f.path)).map(f => toPosix(path.relative(cwd, f.path)));
|
|
1053
|
+
return { sourceFiles, targets: layout.listLocales() };
|
|
1054
|
+
} catch {
|
|
1055
|
+
return null;
|
|
1056
|
+
}
|
|
1057
|
+
};
|
|
1058
|
+
|
|
1059
|
+
for (const dir of ['locale', 'locales', 'translations', 'i18n', 'conf/locale']) {
|
|
1060
|
+
const abs = path.join(cwd, ...dir.split('/'));
|
|
1061
|
+
if (!isDirectory(abs)) continue;
|
|
1062
|
+
const hasCatalogs = fs.readdirSync(abs, { withFileTypes: true })
|
|
1063
|
+
.some(e => e.isDirectory() && walkFiles(path.join(abs, e.name, 'LC_MESSAGES'), n => n.endsWith('.po')).length > 0);
|
|
1064
|
+
if (!hasCatalogs) continue;
|
|
1065
|
+
const localesPattern = `${dir}/{lang}/LC_MESSAGES/{ns}.po`;
|
|
1066
|
+
const found = describe({ localesPattern: path.join(abs, '{lang}', 'LC_MESSAGES', '{ns}.po') });
|
|
1067
|
+
if (found) return { framework, format: 'po', localesPattern, inputLocale: source, ...found };
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
const po = path.join(cwd, 'po');
|
|
1071
|
+
if (isDirectory(po) && fs.readdirSync(po).some(n => n.endsWith('.po') || n.endsWith('.pot'))) {
|
|
1072
|
+
const found = describe({ localesDir: po, localesLayout: 'flat' });
|
|
1073
|
+
if (found) return { framework, format: 'po', localesDir: './po', inputLocale: source, ...found };
|
|
1074
|
+
}
|
|
1075
|
+
return null;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
export {
|
|
1079
|
+
NS_SEPARATOR,
|
|
1080
|
+
LAYOUT_KINDS,
|
|
1081
|
+
FORMAT_BY_EXT,
|
|
1082
|
+
formatForExtension,
|
|
1083
|
+
extensionsForFormat,
|
|
1084
|
+
compileLocalesPattern,
|
|
1085
|
+
discoverLocaleLayout,
|
|
1086
|
+
resolveLocaleFiles,
|
|
1087
|
+
assertSourceFiles,
|
|
1088
|
+
readLocaleFlat,
|
|
1089
|
+
readLocaleData,
|
|
1090
|
+
writeLocaleData,
|
|
1091
|
+
emptyLocaleContent,
|
|
1092
|
+
createMissingTargetFiles,
|
|
1093
|
+
loadSourceUnits,
|
|
1094
|
+
expectedForTarget,
|
|
1095
|
+
lockKey,
|
|
1096
|
+
splitLockKey,
|
|
1097
|
+
keysForNamespace,
|
|
1098
|
+
fromTypedContext,
|
|
1099
|
+
walkFiles,
|
|
1100
|
+
walkLocaleFiles,
|
|
1101
|
+
detectFlutterL10n,
|
|
1102
|
+
detectGettextLayout,
|
|
1103
|
+
};
|