@katerynakhar/i18n-keeper 0.16.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/LICENSE +21 -0
- package/README.md +666 -0
- package/dist/apply.d.ts +66 -0
- package/dist/apply.js +254 -0
- package/dist/apply.js.map +1 -0
- package/dist/check.d.ts +5 -0
- package/dist/check.js +326 -0
- package/dist/check.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +525 -0
- package/dist/cli.js.map +1 -0
- package/dist/formats/error.d.ts +12 -0
- package/dist/formats/error.js +19 -0
- package/dist/formats/error.js.map +1 -0
- package/dist/formats/flatten.d.ts +6 -0
- package/dist/formats/flatten.js +26 -0
- package/dist/formats/flatten.js.map +1 -0
- package/dist/formats/json-write.d.ts +7 -0
- package/dist/formats/json-write.js +55 -0
- package/dist/formats/json-write.js.map +1 -0
- package/dist/formats/json.d.ts +6 -0
- package/dist/formats/json.js +21 -0
- package/dist/formats/json.js.map +1 -0
- package/dist/formats/php-write.d.ts +16 -0
- package/dist/formats/php-write.js +130 -0
- package/dist/formats/php-write.js.map +1 -0
- package/dist/formats/php.d.ts +44 -0
- package/dist/formats/php.js +356 -0
- package/dist/formats/php.js.map +1 -0
- package/dist/formats/po-write.d.ts +3 -0
- package/dist/formats/po-write.js +200 -0
- package/dist/formats/po-write.js.map +1 -0
- package/dist/formats/po.d.ts +30 -0
- package/dist/formats/po.js +202 -0
- package/dist/formats/po.js.map +1 -0
- package/dist/formats/write.d.ts +29 -0
- package/dist/formats/write.js +19 -0
- package/dist/formats/write.js.map +1 -0
- package/dist/formats/yaml-write.d.ts +12 -0
- package/dist/formats/yaml-write.js +42 -0
- package/dist/formats/yaml-write.js.map +1 -0
- package/dist/formats/yaml.d.ts +18 -0
- package/dist/formats/yaml.js +51 -0
- package/dist/formats/yaml.js.map +1 -0
- package/dist/glossary.d.ts +38 -0
- package/dist/glossary.js +140 -0
- package/dist/glossary.js.map +1 -0
- package/dist/lengths.d.ts +37 -0
- package/dist/lengths.js +195 -0
- package/dist/lengths.js.map +1 -0
- package/dist/mcp.d.ts +2 -0
- package/dist/mcp.js +374 -0
- package/dist/mcp.js.map +1 -0
- package/dist/memory.d.ts +72 -0
- package/dist/memory.js +162 -0
- package/dist/memory.js.map +1 -0
- package/dist/placeholders.d.ts +15 -0
- package/dist/placeholders.js +103 -0
- package/dist/placeholders.js.map +1 -0
- package/dist/plurals.d.ts +59 -0
- package/dist/plurals.js +280 -0
- package/dist/plurals.js.map +1 -0
- package/dist/report.d.ts +2 -0
- package/dist/report.js +126 -0
- package/dist/report.js.map +1 -0
- package/dist/scan.d.ts +23 -0
- package/dist/scan.js +250 -0
- package/dist/scan.js.map +1 -0
- package/dist/translate.d.ts +93 -0
- package/dist/translate.js +369 -0
- package/dist/translate.js.map +1 -0
- package/dist/types.d.ts +88 -0
- package/dist/types.js +50 -0
- package/dist/types.js.map +1 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +28 -0
- package/dist/version.js.map +1 -0
- package/package.json +77 -0
- package/src/apply.ts +334 -0
- package/src/check.ts +468 -0
- package/src/cli.ts +637 -0
- package/src/formats/error.ts +19 -0
- package/src/formats/flatten.ts +38 -0
- package/src/formats/json-write.ts +66 -0
- package/src/formats/json.ts +27 -0
- package/src/formats/php-write.ts +146 -0
- package/src/formats/php.ts +414 -0
- package/src/formats/po-write.ts +230 -0
- package/src/formats/po.ts +233 -0
- package/src/formats/write.ts +41 -0
- package/src/formats/yaml-write.ts +53 -0
- package/src/formats/yaml.ts +62 -0
- package/src/glossary.ts +191 -0
- package/src/lengths.ts +214 -0
- package/src/mcp.ts +451 -0
- package/src/memory.ts +227 -0
- package/src/placeholders.ts +123 -0
- package/src/plurals.ts +320 -0
- package/src/report.ts +162 -0
- package/src/scan.ts +280 -0
- package/src/translate.ts +486 -0
- package/src/types.ts +136 -0
- package/src/version.ts +27 -0
package/src/scan.ts
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { basename, join, relative, resolve, sep } from 'node:path';
|
|
3
|
+
import { FormatError } from './formats/error.js';
|
|
4
|
+
import { readJsonLocale } from './formats/json.js';
|
|
5
|
+
import { readPhpLocale } from './formats/php.js';
|
|
6
|
+
import { readPoLocale } from './formats/po.js';
|
|
7
|
+
import { readYamlLocale } from './formats/yaml.js';
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_RULES,
|
|
10
|
+
type Config,
|
|
11
|
+
type Leaf,
|
|
12
|
+
type LocaleBundle,
|
|
13
|
+
type ReadTarget,
|
|
14
|
+
} from './types.js';
|
|
15
|
+
import { DEFAULT_SYNTAXES } from './placeholders.js';
|
|
16
|
+
|
|
17
|
+
const CANDIDATE_DIRS = [
|
|
18
|
+
'locales',
|
|
19
|
+
'src/locales',
|
|
20
|
+
'public/locales',
|
|
21
|
+
'app/locales',
|
|
22
|
+
'i18n',
|
|
23
|
+
'src/i18n',
|
|
24
|
+
'lang',
|
|
25
|
+
'resources/lang',
|
|
26
|
+
'translations',
|
|
27
|
+
'src/translations',
|
|
28
|
+
'config/locales',
|
|
29
|
+
'locale',
|
|
30
|
+
'po',
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
/** Formats we can read, longest extension first so stripping is unambiguous. */
|
|
34
|
+
const EXTENSIONS = ['.json', '.yaml', '.php', '.yml', '.pot', '.po'];
|
|
35
|
+
|
|
36
|
+
function readerFor(file: string) {
|
|
37
|
+
if (file.endsWith('.php')) return readPhpLocale;
|
|
38
|
+
if (file.endsWith('.po') || file.endsWith('.pot')) return readPoLocale;
|
|
39
|
+
if (file.endsWith('.yml') || file.endsWith('.yaml')) return readYamlLocale;
|
|
40
|
+
return readJsonLocale;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function stripExtension(name: string): string | null {
|
|
44
|
+
for (const ext of EXTENSIONS) {
|
|
45
|
+
if (name.endsWith(ext)) return name.slice(0, -ext.length);
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const LOCALE_CODE = /^[a-z]{2,3}(?:[-_][A-Za-z0-9]{2,8})*$/;
|
|
51
|
+
|
|
52
|
+
export class ScanError extends Error {}
|
|
53
|
+
|
|
54
|
+
function isDir(p: string): boolean {
|
|
55
|
+
try {
|
|
56
|
+
return statSync(p).isDirectory();
|
|
57
|
+
} catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Finds the directory holding locale files, or throws with the paths it tried. */
|
|
63
|
+
export function findLocalesDir(root: string, explicit?: string): string {
|
|
64
|
+
if (explicit) {
|
|
65
|
+
const dir = resolve(root, explicit);
|
|
66
|
+
if (!isDir(dir)) throw new ScanError(`Locales directory not found: ${dir}`);
|
|
67
|
+
return dir;
|
|
68
|
+
}
|
|
69
|
+
for (const candidate of CANDIDATE_DIRS) {
|
|
70
|
+
const dir = resolve(root, candidate);
|
|
71
|
+
if (isDir(dir) && listLocales(dir).locales.length > 0) return dir;
|
|
72
|
+
}
|
|
73
|
+
throw new ScanError(
|
|
74
|
+
`No locales directory found under ${root}.\nTried: ${CANDIDATE_DIRS.join(', ')}\nPass one explicitly with --locales <path>.`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface LocaleLayout {
|
|
79
|
+
layout: 'flat' | 'nested';
|
|
80
|
+
locales: string[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** `locales/en.json` is flat; `locales/en/common.json` is nested. */
|
|
84
|
+
export function listLocales(dir: string): LocaleLayout {
|
|
85
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
86
|
+
|
|
87
|
+
const nested = entries
|
|
88
|
+
.filter((e) => e.isDirectory() && LOCALE_CODE.test(e.name))
|
|
89
|
+
.map((e) => e.name);
|
|
90
|
+
if (nested.length > 0) return { layout: 'nested', locales: nested.sort() };
|
|
91
|
+
|
|
92
|
+
const flat = entries
|
|
93
|
+
.filter((e) => e.isFile())
|
|
94
|
+
.map((e) => stripExtension(e.name))
|
|
95
|
+
.filter((name): name is string => name !== null && LOCALE_CODE.test(name));
|
|
96
|
+
return { layout: 'flat', locales: [...new Set(flat)].sort() };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function collectLocaleFiles(dir: string, acc: string[] = []): string[] {
|
|
100
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
101
|
+
const full = join(dir, entry.name);
|
|
102
|
+
if (entry.isDirectory()) collectLocaleFiles(full, acc);
|
|
103
|
+
else if (entry.isFile() && stripExtension(entry.name) !== null) acc.push(full);
|
|
104
|
+
}
|
|
105
|
+
return acc;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Whether a bundle's source text comes from msgid rather than from the locale
|
|
110
|
+
* itself. In gettext every catalogue carries the untranslated string, so the
|
|
111
|
+
* locale named as the source only supplies the keys.
|
|
112
|
+
*/
|
|
113
|
+
export function sourceIsMsgid(bundle: LocaleBundle): boolean {
|
|
114
|
+
return bundle.files.length > 0 && bundle.files.every((f) => f.endsWith('.po') || f.endsWith('.pot'));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function loadBundle(config: Config, locale: string): LocaleBundle {
|
|
118
|
+
const target: ReadTarget = {
|
|
119
|
+
skipped: [],
|
|
120
|
+
leaves: new Map<string, Leaf>(),
|
|
121
|
+
containers: new Set<string>(),
|
|
122
|
+
plurals: new Map<string, number>(),
|
|
123
|
+
nplurals: null,
|
|
124
|
+
};
|
|
125
|
+
const files: string[] = [];
|
|
126
|
+
const unreadable: Array<{ file: string; message: string }> = [];
|
|
127
|
+
const isSource = locale === config.sourceLocale;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* A malformed file is reported, not thrown. A project with a hundred
|
|
131
|
+
* locales and one bad file should still be checked; refusing to read any
|
|
132
|
+
* of it is how a linter becomes something people stop running.
|
|
133
|
+
*/
|
|
134
|
+
const read = (file: string, namespace: string): void => {
|
|
135
|
+
try {
|
|
136
|
+
readerFor(file)(file, namespace, locale, isSource, target);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
if (!(err instanceof FormatError)) throw err;
|
|
139
|
+
unreadable.push({ file, message: err.message });
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
if (config.layout === 'flat') {
|
|
144
|
+
// A locale may be en.json or en.php; both are read when both exist.
|
|
145
|
+
for (const ext of EXTENSIONS) {
|
|
146
|
+
const file = join(config.localesDir, `${locale}${ext}`);
|
|
147
|
+
if (!existsSync(file)) continue;
|
|
148
|
+
files.push(file);
|
|
149
|
+
read(file, '');
|
|
150
|
+
}
|
|
151
|
+
if (files.length === 0) {
|
|
152
|
+
throw new ScanError(`No locale file for "${locale}" in ${config.localesDir}`);
|
|
153
|
+
}
|
|
154
|
+
} else {
|
|
155
|
+
const localeRoot = join(config.localesDir, locale);
|
|
156
|
+
for (const file of collectLocaleFiles(localeRoot).sort()) {
|
|
157
|
+
// lang/en/shop/pricing.php -> "shop.pricing";
|
|
158
|
+
// locale/fr/LC_MESSAGES/messages.po -> "messages", since the gettext
|
|
159
|
+
// directory is layout, not namespace.
|
|
160
|
+
const relativePath = relative(localeRoot, file);
|
|
161
|
+
const namespace = (stripExtension(relativePath) ?? relativePath)
|
|
162
|
+
.split(sep)
|
|
163
|
+
.filter((part) => part !== 'LC_MESSAGES')
|
|
164
|
+
.join('.');
|
|
165
|
+
files.push(file);
|
|
166
|
+
read(file, namespace);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
locale,
|
|
172
|
+
files,
|
|
173
|
+
skipped: target.skipped,
|
|
174
|
+
unreadable,
|
|
175
|
+
leaves: target.leaves,
|
|
176
|
+
containers: target.containers,
|
|
177
|
+
plurals: target.plurals,
|
|
178
|
+
nplurals: target.nplurals,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface DetectOptions {
|
|
183
|
+
localesDir?: string;
|
|
184
|
+
sourceLocale?: string;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function detectProject(root: string, opts: DetectOptions = {}): Config {
|
|
188
|
+
const resolvedRoot = resolve(root);
|
|
189
|
+
if (!existsSync(resolvedRoot)) throw new ScanError(`Path not found: ${resolvedRoot}`);
|
|
190
|
+
|
|
191
|
+
const localesDir = findLocalesDir(resolvedRoot, opts.localesDir);
|
|
192
|
+
const { layout, locales } = listLocales(localesDir);
|
|
193
|
+
|
|
194
|
+
if (locales.length === 0) throw new ScanError(`No locale files in ${localesDir}`);
|
|
195
|
+
|
|
196
|
+
let sourceLocale = opts.sourceLocale ?? '';
|
|
197
|
+
if (sourceLocale && !locales.includes(sourceLocale)) {
|
|
198
|
+
throw new ScanError(`Source locale "${sourceLocale}" not among: ${locales.join(', ')}`);
|
|
199
|
+
}
|
|
200
|
+
// Laravel's :name is off by default because it false-positives on prose,
|
|
201
|
+
// but in a PHP project it is the interpolation syntax actually in use — and
|
|
202
|
+
// Laravel keeps string-keyed translations in JSON, so the file extension
|
|
203
|
+
// alone is not the signal. A composer.json or a lang/ directory is.
|
|
204
|
+
const syntaxes = [...DEFAULT_SYNTAXES];
|
|
205
|
+
if (usesLaravelPlaceholders(resolvedRoot, localesDir, layout, locales)) syntaxes.push('laravel');
|
|
206
|
+
|
|
207
|
+
const config: Config = {
|
|
208
|
+
root: resolvedRoot,
|
|
209
|
+
localesDir,
|
|
210
|
+
sourceLocale: sourceLocale || locales[0]!,
|
|
211
|
+
locales,
|
|
212
|
+
layout,
|
|
213
|
+
placeholderSyntaxes: syntaxes,
|
|
214
|
+
ignoreIdentical: [],
|
|
215
|
+
rules: { ...DEFAULT_RULES },
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
if (!sourceLocale) {
|
|
219
|
+
// Plain `en` first; then a single regional English, which is the source in
|
|
220
|
+
// a project that spells it en-US. Several of them means none is: Sphinx
|
|
221
|
+
// carries en_DE and en_GB purely for date formats, so there the most
|
|
222
|
+
// complete catalogue is the better guess than whichever sorts first.
|
|
223
|
+
const english = locales.filter((l) => /^en([-_]|$)/.test(l));
|
|
224
|
+
config.sourceLocale =
|
|
225
|
+
locales.find((l) => l === 'en') ??
|
|
226
|
+
(english.length === 1 ? english[0]! : mostComplete(config, locales));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return config;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** The locale with the most keys, which in practice is the one others follow. */
|
|
233
|
+
function mostComplete(config: Config, locales: string[]): string {
|
|
234
|
+
let best = locales[0]!;
|
|
235
|
+
let bestCount = -1;
|
|
236
|
+
|
|
237
|
+
for (const locale of locales) {
|
|
238
|
+
let count = 0;
|
|
239
|
+
try {
|
|
240
|
+
count = loadBundle({ ...config, sourceLocale: locale }, locale).leaves.size;
|
|
241
|
+
} catch {
|
|
242
|
+
continue; // an unreadable locale simply cannot be the source
|
|
243
|
+
}
|
|
244
|
+
if (count > bestCount) {
|
|
245
|
+
best = locale;
|
|
246
|
+
bestCount = count;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return best;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function hasPhpLocales(localesDir: string, layout: 'flat' | 'nested', locales: string[]): boolean {
|
|
254
|
+
if (layout === 'flat') {
|
|
255
|
+
return locales.some((locale) => existsSync(join(localesDir, `${locale}.php`)));
|
|
256
|
+
}
|
|
257
|
+
return locales.some((locale) => {
|
|
258
|
+
const dir = join(localesDir, locale);
|
|
259
|
+
return isDir(dir) && collectLocaleFiles(dir).some((file) => file.endsWith('.php'));
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Whether this project interpolates the PHP way.
|
|
265
|
+
*
|
|
266
|
+
* Tying it to the .php extension missed every Laravel app that keeps its
|
|
267
|
+
* string-keyed translations in lang/xx.json — which is Laravel's own
|
|
268
|
+
* convention, so the placeholders went unchecked in exactly the projects the
|
|
269
|
+
* syntax exists for.
|
|
270
|
+
*/
|
|
271
|
+
function usesLaravelPlaceholders(
|
|
272
|
+
root: string,
|
|
273
|
+
localesDir: string,
|
|
274
|
+
layout: 'flat' | 'nested',
|
|
275
|
+
locales: string[],
|
|
276
|
+
): boolean {
|
|
277
|
+
if (hasPhpLocales(localesDir, layout, locales)) return true;
|
|
278
|
+
if (existsSync(join(root, 'composer.json'))) return true;
|
|
279
|
+
return basename(localesDir) === 'lang';
|
|
280
|
+
}
|