@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.
Files changed (103) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +666 -0
  3. package/dist/apply.d.ts +66 -0
  4. package/dist/apply.js +254 -0
  5. package/dist/apply.js.map +1 -0
  6. package/dist/check.d.ts +5 -0
  7. package/dist/check.js +326 -0
  8. package/dist/check.js.map +1 -0
  9. package/dist/cli.d.ts +2 -0
  10. package/dist/cli.js +525 -0
  11. package/dist/cli.js.map +1 -0
  12. package/dist/formats/error.d.ts +12 -0
  13. package/dist/formats/error.js +19 -0
  14. package/dist/formats/error.js.map +1 -0
  15. package/dist/formats/flatten.d.ts +6 -0
  16. package/dist/formats/flatten.js +26 -0
  17. package/dist/formats/flatten.js.map +1 -0
  18. package/dist/formats/json-write.d.ts +7 -0
  19. package/dist/formats/json-write.js +55 -0
  20. package/dist/formats/json-write.js.map +1 -0
  21. package/dist/formats/json.d.ts +6 -0
  22. package/dist/formats/json.js +21 -0
  23. package/dist/formats/json.js.map +1 -0
  24. package/dist/formats/php-write.d.ts +16 -0
  25. package/dist/formats/php-write.js +130 -0
  26. package/dist/formats/php-write.js.map +1 -0
  27. package/dist/formats/php.d.ts +44 -0
  28. package/dist/formats/php.js +356 -0
  29. package/dist/formats/php.js.map +1 -0
  30. package/dist/formats/po-write.d.ts +3 -0
  31. package/dist/formats/po-write.js +200 -0
  32. package/dist/formats/po-write.js.map +1 -0
  33. package/dist/formats/po.d.ts +30 -0
  34. package/dist/formats/po.js +202 -0
  35. package/dist/formats/po.js.map +1 -0
  36. package/dist/formats/write.d.ts +29 -0
  37. package/dist/formats/write.js +19 -0
  38. package/dist/formats/write.js.map +1 -0
  39. package/dist/formats/yaml-write.d.ts +12 -0
  40. package/dist/formats/yaml-write.js +42 -0
  41. package/dist/formats/yaml-write.js.map +1 -0
  42. package/dist/formats/yaml.d.ts +18 -0
  43. package/dist/formats/yaml.js +51 -0
  44. package/dist/formats/yaml.js.map +1 -0
  45. package/dist/glossary.d.ts +38 -0
  46. package/dist/glossary.js +140 -0
  47. package/dist/glossary.js.map +1 -0
  48. package/dist/lengths.d.ts +37 -0
  49. package/dist/lengths.js +195 -0
  50. package/dist/lengths.js.map +1 -0
  51. package/dist/mcp.d.ts +2 -0
  52. package/dist/mcp.js +374 -0
  53. package/dist/mcp.js.map +1 -0
  54. package/dist/memory.d.ts +72 -0
  55. package/dist/memory.js +162 -0
  56. package/dist/memory.js.map +1 -0
  57. package/dist/placeholders.d.ts +15 -0
  58. package/dist/placeholders.js +103 -0
  59. package/dist/placeholders.js.map +1 -0
  60. package/dist/plurals.d.ts +59 -0
  61. package/dist/plurals.js +280 -0
  62. package/dist/plurals.js.map +1 -0
  63. package/dist/report.d.ts +2 -0
  64. package/dist/report.js +126 -0
  65. package/dist/report.js.map +1 -0
  66. package/dist/scan.d.ts +23 -0
  67. package/dist/scan.js +250 -0
  68. package/dist/scan.js.map +1 -0
  69. package/dist/translate.d.ts +93 -0
  70. package/dist/translate.js +369 -0
  71. package/dist/translate.js.map +1 -0
  72. package/dist/types.d.ts +88 -0
  73. package/dist/types.js +50 -0
  74. package/dist/types.js.map +1 -0
  75. package/dist/version.d.ts +1 -0
  76. package/dist/version.js +28 -0
  77. package/dist/version.js.map +1 -0
  78. package/package.json +77 -0
  79. package/src/apply.ts +334 -0
  80. package/src/check.ts +468 -0
  81. package/src/cli.ts +637 -0
  82. package/src/formats/error.ts +19 -0
  83. package/src/formats/flatten.ts +38 -0
  84. package/src/formats/json-write.ts +66 -0
  85. package/src/formats/json.ts +27 -0
  86. package/src/formats/php-write.ts +146 -0
  87. package/src/formats/php.ts +414 -0
  88. package/src/formats/po-write.ts +230 -0
  89. package/src/formats/po.ts +233 -0
  90. package/src/formats/write.ts +41 -0
  91. package/src/formats/yaml-write.ts +53 -0
  92. package/src/formats/yaml.ts +62 -0
  93. package/src/glossary.ts +191 -0
  94. package/src/lengths.ts +214 -0
  95. package/src/mcp.ts +451 -0
  96. package/src/memory.ts +227 -0
  97. package/src/placeholders.ts +123 -0
  98. package/src/plurals.ts +320 -0
  99. package/src/report.ts +162 -0
  100. package/src/scan.ts +280 -0
  101. package/src/translate.ts +486 -0
  102. package/src/types.ts +136 -0
  103. 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
+ }