ai-localize-locale-engine 2.0.0 → 2.0.3
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/CHANGELOG.md +32 -0
- package/dist/index.d.mts +51 -1
- package/dist/index.d.ts +51 -1
- package/dist/index.js +88 -5
- package/dist/index.mjs +88 -5
- package/package.json +2 -2
- package/src/extractor.ts +50 -11
- package/src/writer.ts +119 -14
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# ai-localize-locale-engine
|
|
2
2
|
|
|
3
|
+
## 2.0.3
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- **`staticKeys` injection** — `Extractor` and `Writer` now merge `staticKeys` entries from config into every generated locale file; in nested mode they are placed in the default namespace file; in flat mode they are merged at the top level
|
|
8
|
+
- **`keyStyle: "screaming_snake"` support** — `Extractor` honours the new `keyStyle` config field; when set to `"screaming_snake"` all scanned hardcoded text generates UPPER_SNAKE_CASE keys matching the text value
|
|
9
|
+
|
|
10
|
+
### Patch Changes
|
|
11
|
+
|
|
12
|
+
- Updated dependencies
|
|
13
|
+
- ai-localize-shared@2.0.3
|
|
14
|
+
- ai-localize-config@2.0.3
|
|
15
|
+
|
|
16
|
+
## 2.0.2
|
|
17
|
+
|
|
18
|
+
### Minor Changes
|
|
19
|
+
|
|
20
|
+
- **Copy source value to target languages** — locale extract now initialises target language entries with the English source value instead of an empty string, so translators have immediate context
|
|
21
|
+
- **Flat locale file layout** — `localeStructure: "flat"` writes one `<lang>.json` per language with all namespace keys merged; non-default namespace keys are prefixed with `<namespace>.` to avoid collisions
|
|
22
|
+
|
|
23
|
+
### Patch Changes
|
|
24
|
+
|
|
25
|
+
- Updated dependencies
|
|
26
|
+
- ai-localize-shared@2.0.2
|
|
27
|
+
|
|
28
|
+
## 2.0.1
|
|
29
|
+
|
|
30
|
+
### Patch Changes
|
|
31
|
+
|
|
32
|
+
- Updated dependencies
|
|
33
|
+
- ai-localize-shared@2.0.1
|
|
34
|
+
|
|
3
35
|
## 2.0.0
|
|
4
36
|
|
|
5
37
|
### Major Changes
|
package/dist/index.d.mts
CHANGED
|
@@ -4,6 +4,26 @@ interface ExtractOptions {
|
|
|
4
4
|
defaultLanguage?: string;
|
|
5
5
|
targetLanguages?: string[];
|
|
6
6
|
namespaceSplitting?: boolean;
|
|
7
|
+
/**
|
|
8
|
+
* Static key/value pairs to inject into every generated locale file,
|
|
9
|
+
* in addition to the keys discovered by scanning source files.
|
|
10
|
+
*
|
|
11
|
+
* These keys are placed in the default namespace (`common` / `translation`)
|
|
12
|
+
* so they end up in `locales/<lang>/translation.json` (nested mode) or at
|
|
13
|
+
* the top level of `locales/<lang>.json` (flat mode).
|
|
14
|
+
*
|
|
15
|
+
* Static keys that collide with scanned keys are **skipped** — scanned
|
|
16
|
+
* keys always take precedence so existing translations are never overwritten.
|
|
17
|
+
*
|
|
18
|
+
* Example:
|
|
19
|
+
* staticKeys: {
|
|
20
|
+
* MAX_COUNT: "Max Count",
|
|
21
|
+
* ALLOWED: "Allowed",
|
|
22
|
+
* DISABLED: "Disabled",
|
|
23
|
+
* UNLIMITED: "Unlimited",
|
|
24
|
+
* }
|
|
25
|
+
*/
|
|
26
|
+
staticKeys?: Record<string, string>;
|
|
7
27
|
}
|
|
8
28
|
interface ExtractionResult {
|
|
9
29
|
localeFiles: LocaleFile[];
|
|
@@ -20,6 +40,20 @@ interface WriteOptions {
|
|
|
20
40
|
localesDir: string;
|
|
21
41
|
merge?: boolean;
|
|
22
42
|
sort?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Locale file layout.
|
|
45
|
+
*
|
|
46
|
+
* - `"nested"` (default) — one JSON file per language + namespace:
|
|
47
|
+
* `locales/en/common.json`, `locales/en/dashboard.json`
|
|
48
|
+
* For the "common" / default namespace the file is named `translation.json`.
|
|
49
|
+
*
|
|
50
|
+
* - `"flat"` — one JSON file per language, all namespaces merged:
|
|
51
|
+
* `locales/en.json`, `locales/fr.json`
|
|
52
|
+
* Namespace prefixes are prepended to keys to avoid collisions:
|
|
53
|
+
* `common.button.save`, `dashboard.header.title`
|
|
54
|
+
* (keys that already start with the namespace prefix are left as-is).
|
|
55
|
+
*/
|
|
56
|
+
localeStructure?: "nested" | "flat";
|
|
23
57
|
}
|
|
24
58
|
declare class LocaleWriter {
|
|
25
59
|
private options;
|
|
@@ -29,7 +63,23 @@ declare class LocaleWriter {
|
|
|
29
63
|
merged: string[];
|
|
30
64
|
created: string[];
|
|
31
65
|
};
|
|
32
|
-
private
|
|
66
|
+
private writeNested;
|
|
67
|
+
private resolveNestedFilePath;
|
|
68
|
+
/**
|
|
69
|
+
* In flat mode all LocaleFile entries for the same language are merged into
|
|
70
|
+
* a single `<localesDir>/<language>.json` file.
|
|
71
|
+
*
|
|
72
|
+
* To avoid key collisions between namespaces, each key is prefixed with its
|
|
73
|
+
* namespace name (e.g. `common.button.save`, `dashboard.header.title`).
|
|
74
|
+
* Keys that already start with the namespace prefix are left unchanged.
|
|
75
|
+
*/
|
|
76
|
+
private writeFlat;
|
|
77
|
+
/**
|
|
78
|
+
* Produces a flat key with the namespace prepended.
|
|
79
|
+
* If the key already starts with `<namespace>.` it is returned as-is
|
|
80
|
+
* to avoid double-prefixing (e.g. when namespace splitting already embedded it).
|
|
81
|
+
*/
|
|
82
|
+
private flattenKey;
|
|
33
83
|
private mergeEntries;
|
|
34
84
|
private sort;
|
|
35
85
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -4,6 +4,26 @@ interface ExtractOptions {
|
|
|
4
4
|
defaultLanguage?: string;
|
|
5
5
|
targetLanguages?: string[];
|
|
6
6
|
namespaceSplitting?: boolean;
|
|
7
|
+
/**
|
|
8
|
+
* Static key/value pairs to inject into every generated locale file,
|
|
9
|
+
* in addition to the keys discovered by scanning source files.
|
|
10
|
+
*
|
|
11
|
+
* These keys are placed in the default namespace (`common` / `translation`)
|
|
12
|
+
* so they end up in `locales/<lang>/translation.json` (nested mode) or at
|
|
13
|
+
* the top level of `locales/<lang>.json` (flat mode).
|
|
14
|
+
*
|
|
15
|
+
* Static keys that collide with scanned keys are **skipped** — scanned
|
|
16
|
+
* keys always take precedence so existing translations are never overwritten.
|
|
17
|
+
*
|
|
18
|
+
* Example:
|
|
19
|
+
* staticKeys: {
|
|
20
|
+
* MAX_COUNT: "Max Count",
|
|
21
|
+
* ALLOWED: "Allowed",
|
|
22
|
+
* DISABLED: "Disabled",
|
|
23
|
+
* UNLIMITED: "Unlimited",
|
|
24
|
+
* }
|
|
25
|
+
*/
|
|
26
|
+
staticKeys?: Record<string, string>;
|
|
7
27
|
}
|
|
8
28
|
interface ExtractionResult {
|
|
9
29
|
localeFiles: LocaleFile[];
|
|
@@ -20,6 +40,20 @@ interface WriteOptions {
|
|
|
20
40
|
localesDir: string;
|
|
21
41
|
merge?: boolean;
|
|
22
42
|
sort?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Locale file layout.
|
|
45
|
+
*
|
|
46
|
+
* - `"nested"` (default) — one JSON file per language + namespace:
|
|
47
|
+
* `locales/en/common.json`, `locales/en/dashboard.json`
|
|
48
|
+
* For the "common" / default namespace the file is named `translation.json`.
|
|
49
|
+
*
|
|
50
|
+
* - `"flat"` — one JSON file per language, all namespaces merged:
|
|
51
|
+
* `locales/en.json`, `locales/fr.json`
|
|
52
|
+
* Namespace prefixes are prepended to keys to avoid collisions:
|
|
53
|
+
* `common.button.save`, `dashboard.header.title`
|
|
54
|
+
* (keys that already start with the namespace prefix are left as-is).
|
|
55
|
+
*/
|
|
56
|
+
localeStructure?: "nested" | "flat";
|
|
23
57
|
}
|
|
24
58
|
declare class LocaleWriter {
|
|
25
59
|
private options;
|
|
@@ -29,7 +63,23 @@ declare class LocaleWriter {
|
|
|
29
63
|
merged: string[];
|
|
30
64
|
created: string[];
|
|
31
65
|
};
|
|
32
|
-
private
|
|
66
|
+
private writeNested;
|
|
67
|
+
private resolveNestedFilePath;
|
|
68
|
+
/**
|
|
69
|
+
* In flat mode all LocaleFile entries for the same language are merged into
|
|
70
|
+
* a single `<localesDir>/<language>.json` file.
|
|
71
|
+
*
|
|
72
|
+
* To avoid key collisions between namespaces, each key is prefixed with its
|
|
73
|
+
* namespace name (e.g. `common.button.save`, `dashboard.header.title`).
|
|
74
|
+
* Keys that already start with the namespace prefix are left unchanged.
|
|
75
|
+
*/
|
|
76
|
+
private writeFlat;
|
|
77
|
+
/**
|
|
78
|
+
* Produces a flat key with the namespace prepended.
|
|
79
|
+
* If the key already starts with `<namespace>.` it is returned as-is
|
|
80
|
+
* to avoid double-prefixing (e.g. when namespace splitting already embedded it).
|
|
81
|
+
*/
|
|
82
|
+
private flattenKey;
|
|
33
83
|
private mergeEntries;
|
|
34
84
|
private sort;
|
|
35
85
|
}
|
package/dist/index.js
CHANGED
|
@@ -47,7 +47,8 @@ var LocaleExtractor = class {
|
|
|
47
47
|
this.options = {
|
|
48
48
|
defaultLanguage: options.defaultLanguage || "en",
|
|
49
49
|
targetLanguages: options.targetLanguages || [],
|
|
50
|
-
namespaceSplitting: options.namespaceSplitting ?? true
|
|
50
|
+
namespaceSplitting: options.namespaceSplitting ?? true,
|
|
51
|
+
staticKeys: options.staticKeys ?? {}
|
|
51
52
|
};
|
|
52
53
|
}
|
|
53
54
|
extract(detectedTexts) {
|
|
@@ -67,6 +68,18 @@ var LocaleExtractor = class {
|
|
|
67
68
|
if (!namespaceMap.has(namespace)) namespaceMap.set(namespace, {});
|
|
68
69
|
namespaceMap.get(namespace)[localKey] = value;
|
|
69
70
|
}
|
|
71
|
+
const staticKeys = this.options.staticKeys;
|
|
72
|
+
if (staticKeys && Object.keys(staticKeys).length > 0) {
|
|
73
|
+
if (!namespaceMap.has(import_ai_localize_shared.DEFAULT_NAMESPACE)) {
|
|
74
|
+
namespaceMap.set(import_ai_localize_shared.DEFAULT_NAMESPACE, {});
|
|
75
|
+
}
|
|
76
|
+
const defaultNsBucket = namespaceMap.get(import_ai_localize_shared.DEFAULT_NAMESPACE);
|
|
77
|
+
for (const [key, value] of Object.entries(staticKeys)) {
|
|
78
|
+
if (!(key in defaultNsBucket)) {
|
|
79
|
+
defaultNsBucket[key] = value;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
70
83
|
for (const [ns, entries] of namespaceMap) {
|
|
71
84
|
namespaceMap.set(
|
|
72
85
|
ns,
|
|
@@ -77,7 +90,7 @@ var LocaleExtractor = class {
|
|
|
77
90
|
const allLanguages = [this.options.defaultLanguage, ...this.options.targetLanguages];
|
|
78
91
|
for (const lang of allLanguages) {
|
|
79
92
|
for (const [namespace, entries] of namespaceMap) {
|
|
80
|
-
const langEntries =
|
|
93
|
+
const langEntries = { ...entries };
|
|
81
94
|
localeFiles.push({ language: lang, namespace, entries: langEntries, filePath: "" });
|
|
82
95
|
}
|
|
83
96
|
}
|
|
@@ -91,14 +104,26 @@ var import_ai_localize_shared2 = require("ai-localize-shared");
|
|
|
91
104
|
var LocaleWriter = class {
|
|
92
105
|
options;
|
|
93
106
|
constructor(options) {
|
|
94
|
-
this.options = {
|
|
107
|
+
this.options = {
|
|
108
|
+
localesDir: options.localesDir,
|
|
109
|
+
merge: options.merge ?? true,
|
|
110
|
+
sort: options.sort ?? true,
|
|
111
|
+
localeStructure: options.localeStructure ?? "nested"
|
|
112
|
+
};
|
|
95
113
|
}
|
|
96
114
|
write(localeFiles) {
|
|
115
|
+
if (this.options.localeStructure === "flat") {
|
|
116
|
+
return this.writeFlat(localeFiles);
|
|
117
|
+
}
|
|
118
|
+
return this.writeNested(localeFiles);
|
|
119
|
+
}
|
|
120
|
+
// ─── Nested layout ───────────────────────────────────────────────────────────
|
|
121
|
+
writeNested(localeFiles) {
|
|
97
122
|
const written = [];
|
|
98
123
|
const merged = [];
|
|
99
124
|
const created = [];
|
|
100
125
|
for (const lf of localeFiles) {
|
|
101
|
-
const filePath = this.
|
|
126
|
+
const filePath = this.resolveNestedFilePath(lf);
|
|
102
127
|
lf.filePath = filePath;
|
|
103
128
|
(0, import_ai_localize_shared2.ensureDir)(path.dirname(filePath));
|
|
104
129
|
const existing = (0, import_ai_localize_shared2.readJsonSafe)(filePath);
|
|
@@ -114,9 +139,67 @@ var LocaleWriter = class {
|
|
|
114
139
|
}
|
|
115
140
|
return { written, merged, created };
|
|
116
141
|
}
|
|
117
|
-
|
|
142
|
+
resolveNestedFilePath(lf) {
|
|
118
143
|
return lf.namespace === "common" ? path.join(this.options.localesDir, lf.language, "translation.json") : path.join(this.options.localesDir, lf.language, `${lf.namespace}.json`);
|
|
119
144
|
}
|
|
145
|
+
// ─── Flat layout ─────────────────────────────────────────────────────────────
|
|
146
|
+
/**
|
|
147
|
+
* In flat mode all LocaleFile entries for the same language are merged into
|
|
148
|
+
* a single `<localesDir>/<language>.json` file.
|
|
149
|
+
*
|
|
150
|
+
* To avoid key collisions between namespaces, each key is prefixed with its
|
|
151
|
+
* namespace name (e.g. `common.button.save`, `dashboard.header.title`).
|
|
152
|
+
* Keys that already start with the namespace prefix are left unchanged.
|
|
153
|
+
*/
|
|
154
|
+
writeFlat(localeFiles) {
|
|
155
|
+
const byLanguage = /* @__PURE__ */ new Map();
|
|
156
|
+
for (const lf of localeFiles) {
|
|
157
|
+
const list = byLanguage.get(lf.language) ?? [];
|
|
158
|
+
list.push(lf);
|
|
159
|
+
byLanguage.set(lf.language, list);
|
|
160
|
+
}
|
|
161
|
+
const written = [];
|
|
162
|
+
const merged = [];
|
|
163
|
+
const created = [];
|
|
164
|
+
for (const [language, files] of byLanguage) {
|
|
165
|
+
const filePath = path.join(this.options.localesDir, `${language}.json`);
|
|
166
|
+
(0, import_ai_localize_shared2.ensureDir)(path.dirname(filePath));
|
|
167
|
+
const incomingEntries = {};
|
|
168
|
+
for (const lf of files) {
|
|
169
|
+
for (const [key, value] of Object.entries(lf.entries)) {
|
|
170
|
+
const flatKey = this.flattenKey(lf.namespace, key);
|
|
171
|
+
incomingEntries[flatKey] = value;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const existing = (0, import_ai_localize_shared2.readJsonSafe)(filePath);
|
|
175
|
+
if (existing && this.options.merge) {
|
|
176
|
+
const mergedEntries = this.mergeEntries(existing, incomingEntries);
|
|
177
|
+
(0, import_ai_localize_shared2.writeJson)(filePath, this.options.sort ? this.sort(mergedEntries) : mergedEntries);
|
|
178
|
+
merged.push(filePath);
|
|
179
|
+
} else {
|
|
180
|
+
(0, import_ai_localize_shared2.writeJson)(filePath, this.options.sort ? this.sort(incomingEntries) : incomingEntries);
|
|
181
|
+
created.push(filePath);
|
|
182
|
+
}
|
|
183
|
+
for (const lf of files) {
|
|
184
|
+
lf.filePath = filePath;
|
|
185
|
+
}
|
|
186
|
+
written.push(filePath);
|
|
187
|
+
}
|
|
188
|
+
return { written, merged, created };
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Produces a flat key with the namespace prepended.
|
|
192
|
+
* If the key already starts with `<namespace>.` it is returned as-is
|
|
193
|
+
* to avoid double-prefixing (e.g. when namespace splitting already embedded it).
|
|
194
|
+
*/
|
|
195
|
+
flattenKey(namespace, key) {
|
|
196
|
+
if (namespace === "common" || namespace === "translation") {
|
|
197
|
+
return key;
|
|
198
|
+
}
|
|
199
|
+
const prefix = `${namespace}.`;
|
|
200
|
+
return key.startsWith(prefix) ? key : `${prefix}${key}`;
|
|
201
|
+
}
|
|
202
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
120
203
|
mergeEntries(existing, incoming) {
|
|
121
204
|
const result = { ...existing };
|
|
122
205
|
for (const [key, value] of Object.entries(incoming)) {
|
package/dist/index.mjs
CHANGED
|
@@ -10,7 +10,8 @@ var LocaleExtractor = class {
|
|
|
10
10
|
this.options = {
|
|
11
11
|
defaultLanguage: options.defaultLanguage || "en",
|
|
12
12
|
targetLanguages: options.targetLanguages || [],
|
|
13
|
-
namespaceSplitting: options.namespaceSplitting ?? true
|
|
13
|
+
namespaceSplitting: options.namespaceSplitting ?? true,
|
|
14
|
+
staticKeys: options.staticKeys ?? {}
|
|
14
15
|
};
|
|
15
16
|
}
|
|
16
17
|
extract(detectedTexts) {
|
|
@@ -30,6 +31,18 @@ var LocaleExtractor = class {
|
|
|
30
31
|
if (!namespaceMap.has(namespace)) namespaceMap.set(namespace, {});
|
|
31
32
|
namespaceMap.get(namespace)[localKey] = value;
|
|
32
33
|
}
|
|
34
|
+
const staticKeys = this.options.staticKeys;
|
|
35
|
+
if (staticKeys && Object.keys(staticKeys).length > 0) {
|
|
36
|
+
if (!namespaceMap.has(DEFAULT_NAMESPACE)) {
|
|
37
|
+
namespaceMap.set(DEFAULT_NAMESPACE, {});
|
|
38
|
+
}
|
|
39
|
+
const defaultNsBucket = namespaceMap.get(DEFAULT_NAMESPACE);
|
|
40
|
+
for (const [key, value] of Object.entries(staticKeys)) {
|
|
41
|
+
if (!(key in defaultNsBucket)) {
|
|
42
|
+
defaultNsBucket[key] = value;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
33
46
|
for (const [ns, entries] of namespaceMap) {
|
|
34
47
|
namespaceMap.set(
|
|
35
48
|
ns,
|
|
@@ -40,7 +53,7 @@ var LocaleExtractor = class {
|
|
|
40
53
|
const allLanguages = [this.options.defaultLanguage, ...this.options.targetLanguages];
|
|
41
54
|
for (const lang of allLanguages) {
|
|
42
55
|
for (const [namespace, entries] of namespaceMap) {
|
|
43
|
-
const langEntries =
|
|
56
|
+
const langEntries = { ...entries };
|
|
44
57
|
localeFiles.push({ language: lang, namespace, entries: langEntries, filePath: "" });
|
|
45
58
|
}
|
|
46
59
|
}
|
|
@@ -54,14 +67,26 @@ import { writeJson, readJsonSafe, ensureDir } from "ai-localize-shared";
|
|
|
54
67
|
var LocaleWriter = class {
|
|
55
68
|
options;
|
|
56
69
|
constructor(options) {
|
|
57
|
-
this.options = {
|
|
70
|
+
this.options = {
|
|
71
|
+
localesDir: options.localesDir,
|
|
72
|
+
merge: options.merge ?? true,
|
|
73
|
+
sort: options.sort ?? true,
|
|
74
|
+
localeStructure: options.localeStructure ?? "nested"
|
|
75
|
+
};
|
|
58
76
|
}
|
|
59
77
|
write(localeFiles) {
|
|
78
|
+
if (this.options.localeStructure === "flat") {
|
|
79
|
+
return this.writeFlat(localeFiles);
|
|
80
|
+
}
|
|
81
|
+
return this.writeNested(localeFiles);
|
|
82
|
+
}
|
|
83
|
+
// ─── Nested layout ───────────────────────────────────────────────────────────
|
|
84
|
+
writeNested(localeFiles) {
|
|
60
85
|
const written = [];
|
|
61
86
|
const merged = [];
|
|
62
87
|
const created = [];
|
|
63
88
|
for (const lf of localeFiles) {
|
|
64
|
-
const filePath = this.
|
|
89
|
+
const filePath = this.resolveNestedFilePath(lf);
|
|
65
90
|
lf.filePath = filePath;
|
|
66
91
|
ensureDir(path.dirname(filePath));
|
|
67
92
|
const existing = readJsonSafe(filePath);
|
|
@@ -77,9 +102,67 @@ var LocaleWriter = class {
|
|
|
77
102
|
}
|
|
78
103
|
return { written, merged, created };
|
|
79
104
|
}
|
|
80
|
-
|
|
105
|
+
resolveNestedFilePath(lf) {
|
|
81
106
|
return lf.namespace === "common" ? path.join(this.options.localesDir, lf.language, "translation.json") : path.join(this.options.localesDir, lf.language, `${lf.namespace}.json`);
|
|
82
107
|
}
|
|
108
|
+
// ─── Flat layout ─────────────────────────────────────────────────────────────
|
|
109
|
+
/**
|
|
110
|
+
* In flat mode all LocaleFile entries for the same language are merged into
|
|
111
|
+
* a single `<localesDir>/<language>.json` file.
|
|
112
|
+
*
|
|
113
|
+
* To avoid key collisions between namespaces, each key is prefixed with its
|
|
114
|
+
* namespace name (e.g. `common.button.save`, `dashboard.header.title`).
|
|
115
|
+
* Keys that already start with the namespace prefix are left unchanged.
|
|
116
|
+
*/
|
|
117
|
+
writeFlat(localeFiles) {
|
|
118
|
+
const byLanguage = /* @__PURE__ */ new Map();
|
|
119
|
+
for (const lf of localeFiles) {
|
|
120
|
+
const list = byLanguage.get(lf.language) ?? [];
|
|
121
|
+
list.push(lf);
|
|
122
|
+
byLanguage.set(lf.language, list);
|
|
123
|
+
}
|
|
124
|
+
const written = [];
|
|
125
|
+
const merged = [];
|
|
126
|
+
const created = [];
|
|
127
|
+
for (const [language, files] of byLanguage) {
|
|
128
|
+
const filePath = path.join(this.options.localesDir, `${language}.json`);
|
|
129
|
+
ensureDir(path.dirname(filePath));
|
|
130
|
+
const incomingEntries = {};
|
|
131
|
+
for (const lf of files) {
|
|
132
|
+
for (const [key, value] of Object.entries(lf.entries)) {
|
|
133
|
+
const flatKey = this.flattenKey(lf.namespace, key);
|
|
134
|
+
incomingEntries[flatKey] = value;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const existing = readJsonSafe(filePath);
|
|
138
|
+
if (existing && this.options.merge) {
|
|
139
|
+
const mergedEntries = this.mergeEntries(existing, incomingEntries);
|
|
140
|
+
writeJson(filePath, this.options.sort ? this.sort(mergedEntries) : mergedEntries);
|
|
141
|
+
merged.push(filePath);
|
|
142
|
+
} else {
|
|
143
|
+
writeJson(filePath, this.options.sort ? this.sort(incomingEntries) : incomingEntries);
|
|
144
|
+
created.push(filePath);
|
|
145
|
+
}
|
|
146
|
+
for (const lf of files) {
|
|
147
|
+
lf.filePath = filePath;
|
|
148
|
+
}
|
|
149
|
+
written.push(filePath);
|
|
150
|
+
}
|
|
151
|
+
return { written, merged, created };
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Produces a flat key with the namespace prepended.
|
|
155
|
+
* If the key already starts with `<namespace>.` it is returned as-is
|
|
156
|
+
* to avoid double-prefixing (e.g. when namespace splitting already embedded it).
|
|
157
|
+
*/
|
|
158
|
+
flattenKey(namespace, key) {
|
|
159
|
+
if (namespace === "common" || namespace === "translation") {
|
|
160
|
+
return key;
|
|
161
|
+
}
|
|
162
|
+
const prefix = `${namespace}.`;
|
|
163
|
+
return key.startsWith(prefix) ? key : `${prefix}${key}`;
|
|
164
|
+
}
|
|
165
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
83
166
|
mergeEntries(existing, incoming) {
|
|
84
167
|
const result = { ...existing };
|
|
85
168
|
for (const [key, value] of Object.entries(incoming)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-localize-locale-engine",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.3",
|
|
4
4
|
"description": "Locale file generation, merging, deduplication and synchronization",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"module": "./dist/index.mjs",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
}
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"ai-localize-shared": "2.0.
|
|
16
|
+
"ai-localize-shared": "2.0.3"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
|
19
19
|
"tsup": "^8.0.1",
|
package/src/extractor.ts
CHANGED
|
@@ -9,6 +9,26 @@ export interface ExtractOptions {
|
|
|
9
9
|
defaultLanguage?: string;
|
|
10
10
|
targetLanguages?: string[];
|
|
11
11
|
namespaceSplitting?: boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Static key/value pairs to inject into every generated locale file,
|
|
14
|
+
* in addition to the keys discovered by scanning source files.
|
|
15
|
+
*
|
|
16
|
+
* These keys are placed in the default namespace (`common` / `translation`)
|
|
17
|
+
* so they end up in `locales/<lang>/translation.json` (nested mode) or at
|
|
18
|
+
* the top level of `locales/<lang>.json` (flat mode).
|
|
19
|
+
*
|
|
20
|
+
* Static keys that collide with scanned keys are **skipped** — scanned
|
|
21
|
+
* keys always take precedence so existing translations are never overwritten.
|
|
22
|
+
*
|
|
23
|
+
* Example:
|
|
24
|
+
* staticKeys: {
|
|
25
|
+
* MAX_COUNT: "Max Count",
|
|
26
|
+
* ALLOWED: "Allowed",
|
|
27
|
+
* DISABLED: "Disabled",
|
|
28
|
+
* UNLIMITED: "Unlimited",
|
|
29
|
+
* }
|
|
30
|
+
*/
|
|
31
|
+
staticKeys?: Record<string, string>;
|
|
12
32
|
}
|
|
13
33
|
|
|
14
34
|
export interface ExtractionResult {
|
|
@@ -20,16 +40,17 @@ export interface ExtractionResult {
|
|
|
20
40
|
export class LocaleExtractor {
|
|
21
41
|
private options: Required<ExtractOptions>;
|
|
22
42
|
|
|
23
|
-
|
|
43
|
+
constructor(options: ExtractOptions = {}) {
|
|
24
44
|
this.options = {
|
|
25
45
|
defaultLanguage: options.defaultLanguage || "en",
|
|
26
46
|
targetLanguages: options.targetLanguages || [],
|
|
27
47
|
namespaceSplitting: options.namespaceSplitting ?? true,
|
|
48
|
+
staticKeys: options.staticKeys ?? {},
|
|
28
49
|
};
|
|
29
50
|
}
|
|
30
51
|
|
|
31
52
|
extract(detectedTexts: DetectedText[]): ExtractionResult {
|
|
32
|
-
|
|
53
|
+
const keyValueMap = new Map<string, string>();
|
|
33
54
|
const existingKeys = new Set<string>();
|
|
34
55
|
|
|
35
56
|
for (const dt of detectedTexts) {
|
|
@@ -38,22 +59,38 @@ export class LocaleExtractor {
|
|
|
38
59
|
key = resolveKeyCollision(key, existingKeys);
|
|
39
60
|
}
|
|
40
61
|
existingKeys.add(key);
|
|
41
|
-
|
|
62
|
+
keyValueMap.set(key, dt.text);
|
|
42
63
|
}
|
|
43
64
|
|
|
44
65
|
const namespaceMap = new Map<string, Record<string, string>>();
|
|
45
66
|
for (const [key, value] of keyValueMap) {
|
|
46
67
|
const { namespace, localKey } = this.options.namespaceSplitting
|
|
47
68
|
? splitKeyNamespace(key)
|
|
48
|
-
|
|
69
|
+
: { namespace: DEFAULT_NAMESPACE, localKey: key };
|
|
49
70
|
if (!namespaceMap.has(namespace)) namespaceMap.set(namespace, {});
|
|
50
71
|
namespaceMap.get(namespace)![localKey] = value;
|
|
51
72
|
}
|
|
52
73
|
|
|
74
|
+
// Inject static keys into the default namespace.
|
|
75
|
+
// Scanned keys take precedence — static keys are only added when the key
|
|
76
|
+
// does not already exist in the default namespace bucket.
|
|
77
|
+
const staticKeys = this.options.staticKeys;
|
|
78
|
+
if (staticKeys && Object.keys(staticKeys).length > 0) {
|
|
79
|
+
if (!namespaceMap.has(DEFAULT_NAMESPACE)) {
|
|
80
|
+
namespaceMap.set(DEFAULT_NAMESPACE, {});
|
|
81
|
+
}
|
|
82
|
+
const defaultNsBucket = namespaceMap.get(DEFAULT_NAMESPACE)!;
|
|
83
|
+
for (const [key, value] of Object.entries(staticKeys)) {
|
|
84
|
+
if (!(key in defaultNsBucket)) {
|
|
85
|
+
defaultNsBucket[key] = value;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
53
90
|
for (const [ns, entries] of namespaceMap) {
|
|
54
|
-
|
|
91
|
+
namespaceMap.set(
|
|
55
92
|
ns,
|
|
56
|
-
|
|
93
|
+
Object.fromEntries(Object.entries(entries).sort(([a], [b]) => a.localeCompare(b)))
|
|
57
94
|
);
|
|
58
95
|
}
|
|
59
96
|
|
|
@@ -62,12 +99,14 @@ export class LocaleExtractor {
|
|
|
62
99
|
|
|
63
100
|
for (const lang of allLanguages) {
|
|
64
101
|
for (const [namespace, entries] of namespaceMap) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
102
|
+
// Default language gets the source text values.
|
|
103
|
+
// Target languages get empty string values so translators can fill them in.
|
|
104
|
+
const isDefault = lang === this.options.defaultLanguage;
|
|
105
|
+
const langEntries = isDefault
|
|
106
|
+
? { ...entries }
|
|
107
|
+
: Object.fromEntries(Object.keys(entries).map((k) => [k, '']));
|
|
69
108
|
localeFiles.push({ language: lang, namespace, entries: langEntries, filePath: "" });
|
|
70
|
-
|
|
109
|
+
}
|
|
71
110
|
}
|
|
72
111
|
|
|
73
112
|
return { localeFiles, keyCount: keyValueMap.size, namespaces: Array.from(namespaceMap.keys()) };
|
package/src/writer.ts
CHANGED
|
@@ -6,49 +6,154 @@ export interface WriteOptions {
|
|
|
6
6
|
localesDir: string;
|
|
7
7
|
merge?: boolean;
|
|
8
8
|
sort?: boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Locale file layout.
|
|
11
|
+
*
|
|
12
|
+
* - `"nested"` (default) — one JSON file per language + namespace:
|
|
13
|
+
* `locales/en/common.json`, `locales/en/dashboard.json`
|
|
14
|
+
* For the "common" / default namespace the file is named `translation.json`.
|
|
15
|
+
*
|
|
16
|
+
* - `"flat"` — one JSON file per language, all namespaces merged:
|
|
17
|
+
* `locales/en.json`, `locales/fr.json`
|
|
18
|
+
* Namespace prefixes are prepended to keys to avoid collisions:
|
|
19
|
+
* `common.button.save`, `dashboard.header.title`
|
|
20
|
+
* (keys that already start with the namespace prefix are left as-is).
|
|
21
|
+
*/
|
|
22
|
+
localeStructure?: "nested" | "flat";
|
|
9
23
|
}
|
|
10
24
|
|
|
11
25
|
export class LocaleWriter {
|
|
12
26
|
private options: Required<WriteOptions>;
|
|
13
27
|
|
|
14
28
|
constructor(options: WriteOptions) {
|
|
15
|
-
this.options = {
|
|
29
|
+
this.options = {
|
|
30
|
+
localesDir: options.localesDir,
|
|
31
|
+
merge: options.merge ?? true,
|
|
32
|
+
sort: options.sort ?? true,
|
|
33
|
+
localeStructure: options.localeStructure ?? "nested",
|
|
34
|
+
};
|
|
16
35
|
}
|
|
17
36
|
|
|
18
37
|
write(localeFiles: LocaleFile[]): { written: string[]; merged: string[]; created: string[] } {
|
|
19
|
-
|
|
38
|
+
if (this.options.localeStructure === "flat") {
|
|
39
|
+
return this.writeFlat(localeFiles);
|
|
40
|
+
}
|
|
41
|
+
return this.writeNested(localeFiles);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ─── Nested layout ───────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
private writeNested(localeFiles: LocaleFile[]): { written: string[]; merged: string[]; created: string[] } {
|
|
47
|
+
const written: string[] = [];
|
|
20
48
|
const merged: string[] = [];
|
|
21
49
|
const created: string[] = [];
|
|
22
50
|
|
|
23
51
|
for (const lf of localeFiles) {
|
|
24
|
-
const filePath = this.
|
|
25
|
-
|
|
52
|
+
const filePath = this.resolveNestedFilePath(lf);
|
|
53
|
+
lf.filePath = filePath;
|
|
26
54
|
ensureDir(path.dirname(filePath));
|
|
27
|
-
|
|
55
|
+
const existing = readJsonSafe<Record<string, string>>(filePath);
|
|
28
56
|
|
|
29
57
|
if (existing && this.options.merge) {
|
|
30
58
|
const mergedEntries = this.mergeEntries(existing, lf.entries);
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
59
|
+
writeJson(filePath, this.options.sort ? this.sort(mergedEntries) : mergedEntries);
|
|
60
|
+
merged.push(filePath);
|
|
61
|
+
} else {
|
|
34
62
|
writeJson(filePath, this.options.sort ? this.sort(lf.entries) : lf.entries);
|
|
35
63
|
created.push(filePath);
|
|
36
64
|
}
|
|
37
65
|
written.push(filePath);
|
|
38
66
|
}
|
|
39
|
-
|
|
67
|
+
|
|
68
|
+
return { written, merged, created };
|
|
40
69
|
}
|
|
41
70
|
|
|
42
|
-
private
|
|
71
|
+
private resolveNestedFilePath(lf: LocaleFile): string {
|
|
43
72
|
return lf.namespace === "common"
|
|
44
|
-
|
|
73
|
+
? path.join(this.options.localesDir, lf.language, "translation.json")
|
|
45
74
|
: path.join(this.options.localesDir, lf.language, `${lf.namespace}.json`);
|
|
46
75
|
}
|
|
47
76
|
|
|
48
|
-
|
|
77
|
+
// ─── Flat layout ─────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* In flat mode all LocaleFile entries for the same language are merged into
|
|
81
|
+
* a single `<localesDir>/<language>.json` file.
|
|
82
|
+
*
|
|
83
|
+
* To avoid key collisions between namespaces, each key is prefixed with its
|
|
84
|
+
* namespace name (e.g. `common.button.save`, `dashboard.header.title`).
|
|
85
|
+
* Keys that already start with the namespace prefix are left unchanged.
|
|
86
|
+
*/
|
|
87
|
+
private writeFlat(localeFiles: LocaleFile[]): { written: string[]; merged: string[]; created: string[] } {
|
|
88
|
+
// Group locale files by language
|
|
89
|
+
const byLanguage = new Map<string, LocaleFile[]>();
|
|
90
|
+
for (const lf of localeFiles) {
|
|
91
|
+
const list = byLanguage.get(lf.language) ?? [];
|
|
92
|
+
list.push(lf);
|
|
93
|
+
byLanguage.set(lf.language, list);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const written: string[] = [];
|
|
97
|
+
const merged: string[] = [];
|
|
98
|
+
const created: string[] = [];
|
|
99
|
+
|
|
100
|
+
for (const [language, files] of byLanguage) {
|
|
101
|
+
const filePath = path.join(this.options.localesDir, `${language}.json`);
|
|
102
|
+
ensureDir(path.dirname(filePath));
|
|
103
|
+
|
|
104
|
+
// Merge all namespace entries into a single flat object
|
|
105
|
+
const incomingEntries: Record<string, string> = {};
|
|
106
|
+
for (const lf of files) {
|
|
107
|
+
for (const [key, value] of Object.entries(lf.entries)) {
|
|
108
|
+
const flatKey = this.flattenKey(lf.namespace, key);
|
|
109
|
+
incomingEntries[flatKey] = value;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const existing = readJsonSafe<Record<string, string>>(filePath);
|
|
114
|
+
if (existing && this.options.merge) {
|
|
115
|
+
const mergedEntries = this.mergeEntries(existing, incomingEntries);
|
|
116
|
+
writeJson(filePath, this.options.sort ? this.sort(mergedEntries) : mergedEntries);
|
|
117
|
+
merged.push(filePath);
|
|
118
|
+
} else {
|
|
119
|
+
writeJson(filePath, this.options.sort ? this.sort(incomingEntries) : incomingEntries);
|
|
120
|
+
created.push(filePath);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Update filePath on each LocaleFile for reference
|
|
124
|
+
for (const lf of files) {
|
|
125
|
+
lf.filePath = filePath;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
written.push(filePath);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { written, merged, created };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Produces a flat key with the namespace prepended.
|
|
136
|
+
* If the key already starts with `<namespace>.` it is returned as-is
|
|
137
|
+
* to avoid double-prefixing (e.g. when namespace splitting already embedded it).
|
|
138
|
+
*/
|
|
139
|
+
private flattenKey(namespace: string, key: string): string {
|
|
140
|
+
// "common" is the default namespace — don't prepend to avoid verbose keys
|
|
141
|
+
if (namespace === "common" || namespace === "translation") {
|
|
142
|
+
return key;
|
|
143
|
+
}
|
|
144
|
+
const prefix = `${namespace}.`;
|
|
145
|
+
return key.startsWith(prefix) ? key : `${prefix}${key}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
private mergeEntries(
|
|
151
|
+
existing: Record<string, string>,
|
|
152
|
+
incoming: Record<string, string>
|
|
153
|
+
): Record<string, string> {
|
|
49
154
|
const result = { ...existing };
|
|
50
|
-
|
|
51
|
-
|
|
155
|
+
for (const [key, value] of Object.entries(incoming)) {
|
|
156
|
+
if (!(key in result) || result[key] === "") result[key] = value;
|
|
52
157
|
}
|
|
53
158
|
return result;
|
|
54
159
|
}
|