ai-localize-locale-engine 1.0.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/dist/index.d.mts +51 -0
- package/dist/index.d.ts +51 -0
- package/dist/index.js +199 -0
- package/dist/index.mjs +161 -0
- package/package.json +34 -0
- package/src/__tests__/extractor.test.ts +52 -0
- package/src/deduplicator.ts +19 -0
- package/src/extractor.ts +75 -0
- package/src/index.ts +4 -0
- package/src/synchronizer.ts +36 -0
- package/src/writer.ts +59 -0
- package/tsconfig.json +6 -0
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { LocaleFile, DetectedText } from '@ai-localize/shared';
|
|
2
|
+
|
|
3
|
+
interface ExtractOptions {
|
|
4
|
+
defaultLanguage?: string;
|
|
5
|
+
targetLanguages?: string[];
|
|
6
|
+
namespaceSplitting?: boolean;
|
|
7
|
+
}
|
|
8
|
+
interface ExtractionResult {
|
|
9
|
+
localeFiles: LocaleFile[];
|
|
10
|
+
keyCount: number;
|
|
11
|
+
namespaces: string[];
|
|
12
|
+
}
|
|
13
|
+
declare class LocaleExtractor {
|
|
14
|
+
private options;
|
|
15
|
+
constructor(options?: ExtractOptions);
|
|
16
|
+
extract(detectedTexts: DetectedText[]): ExtractionResult;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface WriteOptions {
|
|
20
|
+
localesDir: string;
|
|
21
|
+
merge?: boolean;
|
|
22
|
+
sort?: boolean;
|
|
23
|
+
}
|
|
24
|
+
declare class LocaleWriter {
|
|
25
|
+
private options;
|
|
26
|
+
constructor(options: WriteOptions);
|
|
27
|
+
write(localeFiles: LocaleFile[]): {
|
|
28
|
+
written: string[];
|
|
29
|
+
merged: string[];
|
|
30
|
+
created: string[];
|
|
31
|
+
};
|
|
32
|
+
private resolveFilePath;
|
|
33
|
+
private mergeEntries;
|
|
34
|
+
private sort;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
declare function deduplicateTexts(texts: DetectedText[]): DetectedText[];
|
|
38
|
+
declare function filterAlreadyTranslated(texts: DetectedText[], existingKeys: Set<string>): DetectedText[];
|
|
39
|
+
declare function findUnusedKeys(localeKeys: string[], sourceKeys: string[]): string[];
|
|
40
|
+
|
|
41
|
+
declare class LocaleSynchronizer {
|
|
42
|
+
private localesDir;
|
|
43
|
+
private defaultLanguage;
|
|
44
|
+
constructor(localesDir: string, defaultLanguage?: string);
|
|
45
|
+
sync(): {
|
|
46
|
+
updated: string[];
|
|
47
|
+
addedKeys: number;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export { type ExtractOptions, type ExtractionResult, LocaleExtractor, LocaleSynchronizer, LocaleWriter, type WriteOptions, deduplicateTexts, filterAlreadyTranslated, findUnusedKeys };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { LocaleFile, DetectedText } from '@ai-localize/shared';
|
|
2
|
+
|
|
3
|
+
interface ExtractOptions {
|
|
4
|
+
defaultLanguage?: string;
|
|
5
|
+
targetLanguages?: string[];
|
|
6
|
+
namespaceSplitting?: boolean;
|
|
7
|
+
}
|
|
8
|
+
interface ExtractionResult {
|
|
9
|
+
localeFiles: LocaleFile[];
|
|
10
|
+
keyCount: number;
|
|
11
|
+
namespaces: string[];
|
|
12
|
+
}
|
|
13
|
+
declare class LocaleExtractor {
|
|
14
|
+
private options;
|
|
15
|
+
constructor(options?: ExtractOptions);
|
|
16
|
+
extract(detectedTexts: DetectedText[]): ExtractionResult;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface WriteOptions {
|
|
20
|
+
localesDir: string;
|
|
21
|
+
merge?: boolean;
|
|
22
|
+
sort?: boolean;
|
|
23
|
+
}
|
|
24
|
+
declare class LocaleWriter {
|
|
25
|
+
private options;
|
|
26
|
+
constructor(options: WriteOptions);
|
|
27
|
+
write(localeFiles: LocaleFile[]): {
|
|
28
|
+
written: string[];
|
|
29
|
+
merged: string[];
|
|
30
|
+
created: string[];
|
|
31
|
+
};
|
|
32
|
+
private resolveFilePath;
|
|
33
|
+
private mergeEntries;
|
|
34
|
+
private sort;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
declare function deduplicateTexts(texts: DetectedText[]): DetectedText[];
|
|
38
|
+
declare function filterAlreadyTranslated(texts: DetectedText[], existingKeys: Set<string>): DetectedText[];
|
|
39
|
+
declare function findUnusedKeys(localeKeys: string[], sourceKeys: string[]): string[];
|
|
40
|
+
|
|
41
|
+
declare class LocaleSynchronizer {
|
|
42
|
+
private localesDir;
|
|
43
|
+
private defaultLanguage;
|
|
44
|
+
constructor(localesDir: string, defaultLanguage?: string);
|
|
45
|
+
sync(): {
|
|
46
|
+
updated: string[];
|
|
47
|
+
addedKeys: number;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export { type ExtractOptions, type ExtractionResult, LocaleExtractor, LocaleSynchronizer, LocaleWriter, type WriteOptions, deduplicateTexts, filterAlreadyTranslated, findUnusedKeys };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var index_exports = {};
|
|
32
|
+
__export(index_exports, {
|
|
33
|
+
LocaleExtractor: () => LocaleExtractor,
|
|
34
|
+
LocaleSynchronizer: () => LocaleSynchronizer,
|
|
35
|
+
LocaleWriter: () => LocaleWriter,
|
|
36
|
+
deduplicateTexts: () => deduplicateTexts,
|
|
37
|
+
filterAlreadyTranslated: () => filterAlreadyTranslated,
|
|
38
|
+
findUnusedKeys: () => findUnusedKeys
|
|
39
|
+
});
|
|
40
|
+
module.exports = __toCommonJS(index_exports);
|
|
41
|
+
|
|
42
|
+
// src/extractor.ts
|
|
43
|
+
var import_shared = require("@ai-localize/shared");
|
|
44
|
+
var LocaleExtractor = class {
|
|
45
|
+
options;
|
|
46
|
+
constructor(options = {}) {
|
|
47
|
+
this.options = {
|
|
48
|
+
defaultLanguage: options.defaultLanguage || "en",
|
|
49
|
+
targetLanguages: options.targetLanguages || [],
|
|
50
|
+
namespaceSplitting: options.namespaceSplitting ?? true
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
extract(detectedTexts) {
|
|
54
|
+
const keyValueMap = /* @__PURE__ */ new Map();
|
|
55
|
+
const existingKeys = /* @__PURE__ */ new Set();
|
|
56
|
+
for (const dt of detectedTexts) {
|
|
57
|
+
let key = dt.suggestedKey;
|
|
58
|
+
if (keyValueMap.has(key) && keyValueMap.get(key) !== dt.text) {
|
|
59
|
+
key = (0, import_shared.resolveKeyCollision)(key, existingKeys);
|
|
60
|
+
}
|
|
61
|
+
existingKeys.add(key);
|
|
62
|
+
keyValueMap.set(key, dt.text);
|
|
63
|
+
}
|
|
64
|
+
const namespaceMap = /* @__PURE__ */ new Map();
|
|
65
|
+
for (const [key, value] of keyValueMap) {
|
|
66
|
+
const { namespace, localKey } = this.options.namespaceSplitting ? (0, import_shared.splitKeyNamespace)(key) : { namespace: import_shared.DEFAULT_NAMESPACE, localKey: key };
|
|
67
|
+
if (!namespaceMap.has(namespace)) namespaceMap.set(namespace, {});
|
|
68
|
+
namespaceMap.get(namespace)[localKey] = value;
|
|
69
|
+
}
|
|
70
|
+
for (const [ns, entries] of namespaceMap) {
|
|
71
|
+
namespaceMap.set(
|
|
72
|
+
ns,
|
|
73
|
+
Object.fromEntries(Object.entries(entries).sort(([a], [b]) => a.localeCompare(b)))
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
const localeFiles = [];
|
|
77
|
+
const allLanguages = [this.options.defaultLanguage, ...this.options.targetLanguages];
|
|
78
|
+
for (const lang of allLanguages) {
|
|
79
|
+
for (const [namespace, entries] of namespaceMap) {
|
|
80
|
+
const langEntries = lang === this.options.defaultLanguage ? { ...entries } : Object.fromEntries(Object.keys(entries).map((k) => [k, ""]));
|
|
81
|
+
localeFiles.push({ language: lang, namespace, entries: langEntries, filePath: "" });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { localeFiles, keyCount: keyValueMap.size, namespaces: Array.from(namespaceMap.keys()) };
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// src/writer.ts
|
|
89
|
+
var path = __toESM(require("path"));
|
|
90
|
+
var import_shared2 = require("@ai-localize/shared");
|
|
91
|
+
var LocaleWriter = class {
|
|
92
|
+
options;
|
|
93
|
+
constructor(options) {
|
|
94
|
+
this.options = { localesDir: options.localesDir, merge: options.merge ?? true, sort: options.sort ?? true };
|
|
95
|
+
}
|
|
96
|
+
write(localeFiles) {
|
|
97
|
+
const written = [];
|
|
98
|
+
const merged = [];
|
|
99
|
+
const created = [];
|
|
100
|
+
for (const lf of localeFiles) {
|
|
101
|
+
const filePath = this.resolveFilePath(lf);
|
|
102
|
+
lf.filePath = filePath;
|
|
103
|
+
(0, import_shared2.ensureDir)(path.dirname(filePath));
|
|
104
|
+
const existing = (0, import_shared2.readJsonSafe)(filePath);
|
|
105
|
+
if (existing && this.options.merge) {
|
|
106
|
+
const mergedEntries = this.mergeEntries(existing, lf.entries);
|
|
107
|
+
(0, import_shared2.writeJson)(filePath, this.options.sort ? this.sort(mergedEntries) : mergedEntries);
|
|
108
|
+
merged.push(filePath);
|
|
109
|
+
} else {
|
|
110
|
+
(0, import_shared2.writeJson)(filePath, this.options.sort ? this.sort(lf.entries) : lf.entries);
|
|
111
|
+
created.push(filePath);
|
|
112
|
+
}
|
|
113
|
+
written.push(filePath);
|
|
114
|
+
}
|
|
115
|
+
return { written, merged, created };
|
|
116
|
+
}
|
|
117
|
+
resolveFilePath(lf) {
|
|
118
|
+
return lf.namespace === "common" ? path.join(this.options.localesDir, lf.language, "translation.json") : path.join(this.options.localesDir, lf.language, `${lf.namespace}.json`);
|
|
119
|
+
}
|
|
120
|
+
mergeEntries(existing, incoming) {
|
|
121
|
+
const result = { ...existing };
|
|
122
|
+
for (const [key, value] of Object.entries(incoming)) {
|
|
123
|
+
if (!(key in result) || result[key] === "") result[key] = value;
|
|
124
|
+
}
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
sort(entries) {
|
|
128
|
+
return Object.fromEntries(Object.entries(entries).sort(([a], [b]) => a.localeCompare(b)));
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// src/deduplicator.ts
|
|
133
|
+
function deduplicateTexts(texts) {
|
|
134
|
+
const seen = /* @__PURE__ */ new Map();
|
|
135
|
+
for (const dt of texts) {
|
|
136
|
+
const key = dt.text.toLowerCase().trim();
|
|
137
|
+
if (!seen.has(key)) seen.set(key, dt);
|
|
138
|
+
}
|
|
139
|
+
return Array.from(seen.values());
|
|
140
|
+
}
|
|
141
|
+
function filterAlreadyTranslated(texts, existingKeys) {
|
|
142
|
+
return texts.filter((dt) => !existingKeys.has(dt.suggestedKey));
|
|
143
|
+
}
|
|
144
|
+
function findUnusedKeys(localeKeys, sourceKeys) {
|
|
145
|
+
const sourceSet = new Set(sourceKeys);
|
|
146
|
+
return localeKeys.filter((k) => !sourceSet.has(k));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// src/synchronizer.ts
|
|
150
|
+
var fs = __toESM(require("fs"));
|
|
151
|
+
var path2 = __toESM(require("path"));
|
|
152
|
+
var import_shared3 = require("@ai-localize/shared");
|
|
153
|
+
var LocaleSynchronizer = class {
|
|
154
|
+
constructor(localesDir, defaultLanguage = "en") {
|
|
155
|
+
this.localesDir = localesDir;
|
|
156
|
+
this.defaultLanguage = defaultLanguage;
|
|
157
|
+
}
|
|
158
|
+
localesDir;
|
|
159
|
+
defaultLanguage;
|
|
160
|
+
sync() {
|
|
161
|
+
const updated = [];
|
|
162
|
+
let addedKeys = 0;
|
|
163
|
+
const defaultDir = path2.join(this.localesDir, this.defaultLanguage);
|
|
164
|
+
if (!fs.existsSync(defaultDir)) return { updated, addedKeys };
|
|
165
|
+
const defaultFiles = fs.readdirSync(defaultDir).filter((f) => f.endsWith(".json"));
|
|
166
|
+
const langDirs = fs.readdirSync(this.localesDir, { withFileTypes: true }).filter((e) => e.isDirectory() && e.name !== this.defaultLanguage).map((e) => e.name);
|
|
167
|
+
for (const namespace of defaultFiles) {
|
|
168
|
+
const defaultEntries = (0, import_shared3.readJsonSafe)(path2.join(defaultDir, namespace));
|
|
169
|
+
if (!defaultEntries) continue;
|
|
170
|
+
for (const lang of langDirs) {
|
|
171
|
+
const targetPath = path2.join(this.localesDir, lang, namespace);
|
|
172
|
+
(0, import_shared3.ensureDir)(path2.dirname(targetPath));
|
|
173
|
+
const existing = (0, import_shared3.readJsonSafe)(targetPath) || {};
|
|
174
|
+
let changed = false;
|
|
175
|
+
for (const key of Object.keys(defaultEntries)) {
|
|
176
|
+
if (!(key in existing)) {
|
|
177
|
+
existing[key] = "";
|
|
178
|
+
addedKeys++;
|
|
179
|
+
changed = true;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (changed) {
|
|
183
|
+
(0, import_shared3.writeJson)(targetPath, existing);
|
|
184
|
+
updated.push(targetPath);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return { updated, addedKeys };
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
192
|
+
0 && (module.exports = {
|
|
193
|
+
LocaleExtractor,
|
|
194
|
+
LocaleSynchronizer,
|
|
195
|
+
LocaleWriter,
|
|
196
|
+
deduplicateTexts,
|
|
197
|
+
filterAlreadyTranslated,
|
|
198
|
+
findUnusedKeys
|
|
199
|
+
});
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// src/extractor.ts
|
|
2
|
+
import {
|
|
3
|
+
splitKeyNamespace,
|
|
4
|
+
resolveKeyCollision,
|
|
5
|
+
DEFAULT_NAMESPACE
|
|
6
|
+
} from "@ai-localize/shared";
|
|
7
|
+
var LocaleExtractor = class {
|
|
8
|
+
options;
|
|
9
|
+
constructor(options = {}) {
|
|
10
|
+
this.options = {
|
|
11
|
+
defaultLanguage: options.defaultLanguage || "en",
|
|
12
|
+
targetLanguages: options.targetLanguages || [],
|
|
13
|
+
namespaceSplitting: options.namespaceSplitting ?? true
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
extract(detectedTexts) {
|
|
17
|
+
const keyValueMap = /* @__PURE__ */ new Map();
|
|
18
|
+
const existingKeys = /* @__PURE__ */ new Set();
|
|
19
|
+
for (const dt of detectedTexts) {
|
|
20
|
+
let key = dt.suggestedKey;
|
|
21
|
+
if (keyValueMap.has(key) && keyValueMap.get(key) !== dt.text) {
|
|
22
|
+
key = resolveKeyCollision(key, existingKeys);
|
|
23
|
+
}
|
|
24
|
+
existingKeys.add(key);
|
|
25
|
+
keyValueMap.set(key, dt.text);
|
|
26
|
+
}
|
|
27
|
+
const namespaceMap = /* @__PURE__ */ new Map();
|
|
28
|
+
for (const [key, value] of keyValueMap) {
|
|
29
|
+
const { namespace, localKey } = this.options.namespaceSplitting ? splitKeyNamespace(key) : { namespace: DEFAULT_NAMESPACE, localKey: key };
|
|
30
|
+
if (!namespaceMap.has(namespace)) namespaceMap.set(namespace, {});
|
|
31
|
+
namespaceMap.get(namespace)[localKey] = value;
|
|
32
|
+
}
|
|
33
|
+
for (const [ns, entries] of namespaceMap) {
|
|
34
|
+
namespaceMap.set(
|
|
35
|
+
ns,
|
|
36
|
+
Object.fromEntries(Object.entries(entries).sort(([a], [b]) => a.localeCompare(b)))
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
const localeFiles = [];
|
|
40
|
+
const allLanguages = [this.options.defaultLanguage, ...this.options.targetLanguages];
|
|
41
|
+
for (const lang of allLanguages) {
|
|
42
|
+
for (const [namespace, entries] of namespaceMap) {
|
|
43
|
+
const langEntries = lang === this.options.defaultLanguage ? { ...entries } : Object.fromEntries(Object.keys(entries).map((k) => [k, ""]));
|
|
44
|
+
localeFiles.push({ language: lang, namespace, entries: langEntries, filePath: "" });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { localeFiles, keyCount: keyValueMap.size, namespaces: Array.from(namespaceMap.keys()) };
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// src/writer.ts
|
|
52
|
+
import * as path from "path";
|
|
53
|
+
import { writeJson, readJsonSafe, ensureDir } from "@ai-localize/shared";
|
|
54
|
+
var LocaleWriter = class {
|
|
55
|
+
options;
|
|
56
|
+
constructor(options) {
|
|
57
|
+
this.options = { localesDir: options.localesDir, merge: options.merge ?? true, sort: options.sort ?? true };
|
|
58
|
+
}
|
|
59
|
+
write(localeFiles) {
|
|
60
|
+
const written = [];
|
|
61
|
+
const merged = [];
|
|
62
|
+
const created = [];
|
|
63
|
+
for (const lf of localeFiles) {
|
|
64
|
+
const filePath = this.resolveFilePath(lf);
|
|
65
|
+
lf.filePath = filePath;
|
|
66
|
+
ensureDir(path.dirname(filePath));
|
|
67
|
+
const existing = readJsonSafe(filePath);
|
|
68
|
+
if (existing && this.options.merge) {
|
|
69
|
+
const mergedEntries = this.mergeEntries(existing, lf.entries);
|
|
70
|
+
writeJson(filePath, this.options.sort ? this.sort(mergedEntries) : mergedEntries);
|
|
71
|
+
merged.push(filePath);
|
|
72
|
+
} else {
|
|
73
|
+
writeJson(filePath, this.options.sort ? this.sort(lf.entries) : lf.entries);
|
|
74
|
+
created.push(filePath);
|
|
75
|
+
}
|
|
76
|
+
written.push(filePath);
|
|
77
|
+
}
|
|
78
|
+
return { written, merged, created };
|
|
79
|
+
}
|
|
80
|
+
resolveFilePath(lf) {
|
|
81
|
+
return lf.namespace === "common" ? path.join(this.options.localesDir, lf.language, "translation.json") : path.join(this.options.localesDir, lf.language, `${lf.namespace}.json`);
|
|
82
|
+
}
|
|
83
|
+
mergeEntries(existing, incoming) {
|
|
84
|
+
const result = { ...existing };
|
|
85
|
+
for (const [key, value] of Object.entries(incoming)) {
|
|
86
|
+
if (!(key in result) || result[key] === "") result[key] = value;
|
|
87
|
+
}
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
sort(entries) {
|
|
91
|
+
return Object.fromEntries(Object.entries(entries).sort(([a], [b]) => a.localeCompare(b)));
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// src/deduplicator.ts
|
|
96
|
+
function deduplicateTexts(texts) {
|
|
97
|
+
const seen = /* @__PURE__ */ new Map();
|
|
98
|
+
for (const dt of texts) {
|
|
99
|
+
const key = dt.text.toLowerCase().trim();
|
|
100
|
+
if (!seen.has(key)) seen.set(key, dt);
|
|
101
|
+
}
|
|
102
|
+
return Array.from(seen.values());
|
|
103
|
+
}
|
|
104
|
+
function filterAlreadyTranslated(texts, existingKeys) {
|
|
105
|
+
return texts.filter((dt) => !existingKeys.has(dt.suggestedKey));
|
|
106
|
+
}
|
|
107
|
+
function findUnusedKeys(localeKeys, sourceKeys) {
|
|
108
|
+
const sourceSet = new Set(sourceKeys);
|
|
109
|
+
return localeKeys.filter((k) => !sourceSet.has(k));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// src/synchronizer.ts
|
|
113
|
+
import * as fs from "fs";
|
|
114
|
+
import * as path2 from "path";
|
|
115
|
+
import { readJsonSafe as readJsonSafe2, writeJson as writeJson2, ensureDir as ensureDir2 } from "@ai-localize/shared";
|
|
116
|
+
var LocaleSynchronizer = class {
|
|
117
|
+
constructor(localesDir, defaultLanguage = "en") {
|
|
118
|
+
this.localesDir = localesDir;
|
|
119
|
+
this.defaultLanguage = defaultLanguage;
|
|
120
|
+
}
|
|
121
|
+
localesDir;
|
|
122
|
+
defaultLanguage;
|
|
123
|
+
sync() {
|
|
124
|
+
const updated = [];
|
|
125
|
+
let addedKeys = 0;
|
|
126
|
+
const defaultDir = path2.join(this.localesDir, this.defaultLanguage);
|
|
127
|
+
if (!fs.existsSync(defaultDir)) return { updated, addedKeys };
|
|
128
|
+
const defaultFiles = fs.readdirSync(defaultDir).filter((f) => f.endsWith(".json"));
|
|
129
|
+
const langDirs = fs.readdirSync(this.localesDir, { withFileTypes: true }).filter((e) => e.isDirectory() && e.name !== this.defaultLanguage).map((e) => e.name);
|
|
130
|
+
for (const namespace of defaultFiles) {
|
|
131
|
+
const defaultEntries = readJsonSafe2(path2.join(defaultDir, namespace));
|
|
132
|
+
if (!defaultEntries) continue;
|
|
133
|
+
for (const lang of langDirs) {
|
|
134
|
+
const targetPath = path2.join(this.localesDir, lang, namespace);
|
|
135
|
+
ensureDir2(path2.dirname(targetPath));
|
|
136
|
+
const existing = readJsonSafe2(targetPath) || {};
|
|
137
|
+
let changed = false;
|
|
138
|
+
for (const key of Object.keys(defaultEntries)) {
|
|
139
|
+
if (!(key in existing)) {
|
|
140
|
+
existing[key] = "";
|
|
141
|
+
addedKeys++;
|
|
142
|
+
changed = true;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (changed) {
|
|
146
|
+
writeJson2(targetPath, existing);
|
|
147
|
+
updated.push(targetPath);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return { updated, addedKeys };
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
export {
|
|
155
|
+
LocaleExtractor,
|
|
156
|
+
LocaleSynchronizer,
|
|
157
|
+
LocaleWriter,
|
|
158
|
+
deduplicateTexts,
|
|
159
|
+
filterAlreadyTranslated,
|
|
160
|
+
findUnusedKeys
|
|
161
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ai-localize-locale-engine",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Locale file generation, merging, deduplication and synchronization",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"module": "./dist/index.mjs",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.mjs",
|
|
12
|
+
"require": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"ai-localize-shared": "1.0.0"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"tsup": "^8.0.1",
|
|
20
|
+
"typescript": "^5.3.3",
|
|
21
|
+
"vitest": "^1.2.1"
|
|
22
|
+
},
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "tsup src/index.ts --format cjs,esm --dts",
|
|
29
|
+
"dev": "tsup src/index.ts --format cjs,esm --dts --watch",
|
|
30
|
+
"typecheck": "tsc --noEmit",
|
|
31
|
+
"test": "vitest run",
|
|
32
|
+
"lint": "eslint src --ext .ts"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { LocaleExtractor } from '../extractor.js';
|
|
3
|
+
import type { DetectedText } from '@ai-localize/shared';
|
|
4
|
+
|
|
5
|
+
const mockTexts: DetectedText[] = [
|
|
6
|
+
{
|
|
7
|
+
filePath: 'src/pages/Home.tsx',
|
|
8
|
+
line: 5,
|
|
9
|
+
column: 0,
|
|
10
|
+
text: 'Welcome to our app',
|
|
11
|
+
suggestedKey: 'pages.home.welcome_to_our_app',
|
|
12
|
+
context: 'jsx-text',
|
|
13
|
+
nodeType: 'JSXText',
|
|
14
|
+
alreadyTranslated: false,
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
filePath: 'src/components/Button.tsx',
|
|
18
|
+
line: 3,
|
|
19
|
+
column: 0,
|
|
20
|
+
text: 'Save',
|
|
21
|
+
suggestedKey: 'components.button.save',
|
|
22
|
+
context: 'jsx-text',
|
|
23
|
+
nodeType: 'JSXText',
|
|
24
|
+
alreadyTranslated: false,
|
|
25
|
+
},
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
describe('LocaleExtractor', () => {
|
|
29
|
+
it('extracts locale keys into locale files', () => {
|
|
30
|
+
const extractor = new LocaleExtractor({
|
|
31
|
+
defaultLanguage: 'en',
|
|
32
|
+
targetLanguages: ['fr'],
|
|
33
|
+
namespaceSplitting: true,
|
|
34
|
+
});
|
|
35
|
+
const { localeFiles, keyCount } = extractor.extract(mockTexts);
|
|
36
|
+
expect(keyCount).toBe(2);
|
|
37
|
+
expect(localeFiles.length).toBeGreaterThan(0);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('creates empty entries for target languages', () => {
|
|
41
|
+
const extractor = new LocaleExtractor({
|
|
42
|
+
defaultLanguage: 'en',
|
|
43
|
+
targetLanguages: ['fr', 'de'],
|
|
44
|
+
namespaceSplitting: false,
|
|
45
|
+
});
|
|
46
|
+
const { localeFiles } = extractor.extract(mockTexts);
|
|
47
|
+
const frFiles = localeFiles.filter((f) => f.language === 'fr');
|
|
48
|
+
expect(frFiles.length).toBeGreaterThan(0);
|
|
49
|
+
const frEntries = Object.values(frFiles[0].entries);
|
|
50
|
+
expect(frEntries.every((v) => v === '')).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { DetectedText } from "@ai-localize/shared";
|
|
2
|
+
|
|
3
|
+
export function deduplicateTexts(texts: DetectedText[]): DetectedText[] {
|
|
4
|
+
const seen = new Map<string, DetectedText>();
|
|
5
|
+
for (const dt of texts) {
|
|
6
|
+
const key = dt.text.toLowerCase().trim();
|
|
7
|
+
if (!seen.has(key)) seen.set(key, dt);
|
|
8
|
+
}
|
|
9
|
+
return Array.from(seen.values());
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function filterAlreadyTranslated(texts: DetectedText[], existingKeys: Set<string>): DetectedText[] {
|
|
13
|
+
return texts.filter((dt) => !existingKeys.has(dt.suggestedKey));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function findUnusedKeys(localeKeys: string[], sourceKeys: string[]): string[] {
|
|
17
|
+
const sourceSet = new Set(sourceKeys);
|
|
18
|
+
return localeKeys.filter((k) => !sourceSet.has(k));
|
|
19
|
+
}
|
package/src/extractor.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { DetectedText, LocaleFile } from "@ai-localize/shared";
|
|
2
|
+
import {
|
|
3
|
+
splitKeyNamespace,
|
|
4
|
+
resolveKeyCollision,
|
|
5
|
+
DEFAULT_NAMESPACE,
|
|
6
|
+
} from "@ai-localize/shared";
|
|
7
|
+
|
|
8
|
+
export interface ExtractOptions {
|
|
9
|
+
defaultLanguage?: string;
|
|
10
|
+
targetLanguages?: string[];
|
|
11
|
+
namespaceSplitting?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ExtractionResult {
|
|
15
|
+
localeFiles: LocaleFile[];
|
|
16
|
+
keyCount: number;
|
|
17
|
+
namespaces: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class LocaleExtractor {
|
|
21
|
+
private options: Required<ExtractOptions>;
|
|
22
|
+
|
|
23
|
+
constructor(options: ExtractOptions = {}) {
|
|
24
|
+
this.options = {
|
|
25
|
+
defaultLanguage: options.defaultLanguage || "en",
|
|
26
|
+
targetLanguages: options.targetLanguages || [],
|
|
27
|
+
namespaceSplitting: options.namespaceSplitting ?? true,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
extract(detectedTexts: DetectedText[]): ExtractionResult {
|
|
32
|
+
const keyValueMap = new Map<string, string>();
|
|
33
|
+
const existingKeys = new Set<string>();
|
|
34
|
+
|
|
35
|
+
for (const dt of detectedTexts) {
|
|
36
|
+
let key = dt.suggestedKey;
|
|
37
|
+
if (keyValueMap.has(key) && keyValueMap.get(key) !== dt.text) {
|
|
38
|
+
key = resolveKeyCollision(key, existingKeys);
|
|
39
|
+
}
|
|
40
|
+
existingKeys.add(key);
|
|
41
|
+
keyValueMap.set(key, dt.text);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const namespaceMap = new Map<string, Record<string, string>>();
|
|
45
|
+
for (const [key, value] of keyValueMap) {
|
|
46
|
+
const { namespace, localKey } = this.options.namespaceSplitting
|
|
47
|
+
? splitKeyNamespace(key)
|
|
48
|
+
: { namespace: DEFAULT_NAMESPACE, localKey: key };
|
|
49
|
+
if (!namespaceMap.has(namespace)) namespaceMap.set(namespace, {});
|
|
50
|
+
namespaceMap.get(namespace)![localKey] = value;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
for (const [ns, entries] of namespaceMap) {
|
|
54
|
+
namespaceMap.set(
|
|
55
|
+
ns,
|
|
56
|
+
Object.fromEntries(Object.entries(entries).sort(([a], [b]) => a.localeCompare(b)))
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const localeFiles: LocaleFile[] = [];
|
|
61
|
+
const allLanguages = [this.options.defaultLanguage, ...this.options.targetLanguages];
|
|
62
|
+
|
|
63
|
+
for (const lang of allLanguages) {
|
|
64
|
+
for (const [namespace, entries] of namespaceMap) {
|
|
65
|
+
const langEntries =
|
|
66
|
+
lang === this.options.defaultLanguage
|
|
67
|
+
? { ...entries }
|
|
68
|
+
: Object.fromEntries(Object.keys(entries).map((k) => [k, ""]));
|
|
69
|
+
localeFiles.push({ language: lang, namespace, entries: langEntries, filePath: "" });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { localeFiles, keyCount: keyValueMap.size, namespaces: Array.from(namespaceMap.keys()) };
|
|
74
|
+
}
|
|
75
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { readJsonSafe, writeJson, ensureDir } from "@ai-localize/shared";
|
|
4
|
+
|
|
5
|
+
export class LocaleSynchronizer {
|
|
6
|
+
constructor(private localesDir: string, private defaultLanguage = "en") {}
|
|
7
|
+
|
|
8
|
+
sync(): { updated: string[]; addedKeys: number } {
|
|
9
|
+
const updated: string[] = [];
|
|
10
|
+
let addedKeys = 0;
|
|
11
|
+
const defaultDir = path.join(this.localesDir, this.defaultLanguage);
|
|
12
|
+
if (!fs.existsSync(defaultDir)) return { updated, addedKeys };
|
|
13
|
+
|
|
14
|
+
const defaultFiles = fs.readdirSync(defaultDir).filter((f) => f.endsWith(".json"));
|
|
15
|
+
const langDirs = fs
|
|
16
|
+
.readdirSync(this.localesDir, { withFileTypes: true })
|
|
17
|
+
.filter((e) => e.isDirectory() && e.name !== this.defaultLanguage)
|
|
18
|
+
.map((e) => e.name);
|
|
19
|
+
|
|
20
|
+
for (const namespace of defaultFiles) {
|
|
21
|
+
const defaultEntries = readJsonSafe<Record<string, string>>(path.join(defaultDir, namespace));
|
|
22
|
+
if (!defaultEntries) continue;
|
|
23
|
+
for (const lang of langDirs) {
|
|
24
|
+
const targetPath = path.join(this.localesDir, lang, namespace);
|
|
25
|
+
ensureDir(path.dirname(targetPath));
|
|
26
|
+
const existing = readJsonSafe<Record<string, string>>(targetPath) || {};
|
|
27
|
+
let changed = false;
|
|
28
|
+
for (const key of Object.keys(defaultEntries)) {
|
|
29
|
+
if (!(key in existing)) { existing[key] = ""; addedKeys++; changed = true; }
|
|
30
|
+
}
|
|
31
|
+
if (changed) { writeJson(targetPath, existing); updated.push(targetPath); }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return { updated, addedKeys };
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/writer.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import * as path from "path";
|
|
2
|
+
import type { LocaleFile } from "@ai-localize/shared";
|
|
3
|
+
import { writeJson, readJsonSafe, ensureDir } from "@ai-localize/shared";
|
|
4
|
+
|
|
5
|
+
export interface WriteOptions {
|
|
6
|
+
localesDir: string;
|
|
7
|
+
merge?: boolean;
|
|
8
|
+
sort?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class LocaleWriter {
|
|
12
|
+
private options: Required<WriteOptions>;
|
|
13
|
+
|
|
14
|
+
constructor(options: WriteOptions) {
|
|
15
|
+
this.options = { localesDir: options.localesDir, merge: options.merge ?? true, sort: options.sort ?? true };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
write(localeFiles: LocaleFile[]): { written: string[]; merged: string[]; created: string[] } {
|
|
19
|
+
const written: string[] = [];
|
|
20
|
+
const merged: string[] = [];
|
|
21
|
+
const created: string[] = [];
|
|
22
|
+
|
|
23
|
+
for (const lf of localeFiles) {
|
|
24
|
+
const filePath = this.resolveFilePath(lf);
|
|
25
|
+
lf.filePath = filePath;
|
|
26
|
+
ensureDir(path.dirname(filePath));
|
|
27
|
+
const existing = readJsonSafe<Record<string, string>>(filePath);
|
|
28
|
+
|
|
29
|
+
if (existing && this.options.merge) {
|
|
30
|
+
const mergedEntries = this.mergeEntries(existing, lf.entries);
|
|
31
|
+
writeJson(filePath, this.options.sort ? this.sort(mergedEntries) : mergedEntries);
|
|
32
|
+
merged.push(filePath);
|
|
33
|
+
} else {
|
|
34
|
+
writeJson(filePath, this.options.sort ? this.sort(lf.entries) : lf.entries);
|
|
35
|
+
created.push(filePath);
|
|
36
|
+
}
|
|
37
|
+
written.push(filePath);
|
|
38
|
+
}
|
|
39
|
+
return { written, merged, created };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private resolveFilePath(lf: LocaleFile): string {
|
|
43
|
+
return lf.namespace === "common"
|
|
44
|
+
? path.join(this.options.localesDir, lf.language, "translation.json")
|
|
45
|
+
: path.join(this.options.localesDir, lf.language, `${lf.namespace}.json`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private mergeEntries(existing: Record<string, string>, incoming: Record<string, string>): Record<string, string> {
|
|
49
|
+
const result = { ...existing };
|
|
50
|
+
for (const [key, value] of Object.entries(incoming)) {
|
|
51
|
+
if (!(key in result) || result[key] === "") result[key] = value;
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private sort(entries: Record<string, string>): Record<string, string> {
|
|
57
|
+
return Object.fromEntries(Object.entries(entries).sort(([a], [b]) => a.localeCompare(b)));
|
|
58
|
+
}
|
|
59
|
+
}
|