ai-localize-locale-engine 2.0.3 → 2.0.5

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.js CHANGED
@@ -90,7 +90,8 @@ var LocaleExtractor = class {
90
90
  const allLanguages = [this.options.defaultLanguage, ...this.options.targetLanguages];
91
91
  for (const lang of allLanguages) {
92
92
  for (const [namespace, entries] of namespaceMap) {
93
- const langEntries = { ...entries };
93
+ const isDefault = lang === this.options.defaultLanguage;
94
+ const langEntries = isDefault ? { ...entries } : Object.fromEntries(Object.keys(entries).map((k) => [k, ""]));
94
95
  localeFiles.push({ language: lang, namespace, entries: langEntries, filePath: "" });
95
96
  }
96
97
  }
package/dist/index.mjs CHANGED
@@ -53,7 +53,8 @@ var LocaleExtractor = class {
53
53
  const allLanguages = [this.options.defaultLanguage, ...this.options.targetLanguages];
54
54
  for (const lang of allLanguages) {
55
55
  for (const [namespace, entries] of namespaceMap) {
56
- const langEntries = { ...entries };
56
+ const isDefault = lang === this.options.defaultLanguage;
57
+ const langEntries = isDefault ? { ...entries } : Object.fromEntries(Object.keys(entries).map((k) => [k, ""]));
57
58
  localeFiles.push({ language: lang, namespace, entries: langEntries, filePath: "" });
58
59
  }
59
60
  }
package/package.json CHANGED
@@ -1,10 +1,15 @@
1
1
  {
2
2
  "name": "ai-localize-locale-engine",
3
- "version": "2.0.3",
3
+ "version": "2.0.5",
4
4
  "description": "Locale file generation, merging, deduplication and synchronization",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
7
7
  "types": "./dist/index.d.ts",
8
+ "files": [
9
+ "dist",
10
+ "README.md",
11
+ "CHANGELOG.md"
12
+ ],
8
13
  "exports": {
9
14
  ".": {
10
15
  "types": "./dist/index.d.ts",
@@ -12,8 +17,23 @@
12
17
  "require": "./dist/index.js"
13
18
  }
14
19
  },
20
+ "keywords": [
21
+ "i18n",
22
+ "localization",
23
+ "l10n",
24
+ "internationalization",
25
+ "ai-localize",
26
+ "locale",
27
+ "locale-files",
28
+ "json",
29
+ "translation",
30
+ "extraction"
31
+ ],
32
+ "engines": {
33
+ "node": ">=18.0.0"
34
+ },
15
35
  "dependencies": {
16
- "ai-localize-shared": "2.0.3"
36
+ "ai-localize-shared": "2.0.5"
17
37
  },
18
38
  "devDependencies": {
19
39
  "tsup": "^8.0.1",
@@ -1,52 +0,0 @@
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
- });
@@ -1,19 +0,0 @@
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 DELETED
@@ -1,114 +0,0 @@
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
- * 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>;
32
- }
33
-
34
- export interface ExtractionResult {
35
- localeFiles: LocaleFile[];
36
- keyCount: number;
37
- namespaces: string[];
38
- }
39
-
40
- export class LocaleExtractor {
41
- private options: Required<ExtractOptions>;
42
-
43
- constructor(options: ExtractOptions = {}) {
44
- this.options = {
45
- defaultLanguage: options.defaultLanguage || "en",
46
- targetLanguages: options.targetLanguages || [],
47
- namespaceSplitting: options.namespaceSplitting ?? true,
48
- staticKeys: options.staticKeys ?? {},
49
- };
50
- }
51
-
52
- extract(detectedTexts: DetectedText[]): ExtractionResult {
53
- const keyValueMap = new Map<string, string>();
54
- const existingKeys = new Set<string>();
55
-
56
- for (const dt of detectedTexts) {
57
- let key = dt.suggestedKey;
58
- if (keyValueMap.has(key) && keyValueMap.get(key) !== dt.text) {
59
- key = resolveKeyCollision(key, existingKeys);
60
- }
61
- existingKeys.add(key);
62
- keyValueMap.set(key, dt.text);
63
- }
64
-
65
- const namespaceMap = new Map<string, Record<string, string>>();
66
- for (const [key, value] of keyValueMap) {
67
- const { namespace, localKey } = this.options.namespaceSplitting
68
- ? splitKeyNamespace(key)
69
- : { namespace: DEFAULT_NAMESPACE, localKey: key };
70
- if (!namespaceMap.has(namespace)) namespaceMap.set(namespace, {});
71
- namespaceMap.get(namespace)![localKey] = value;
72
- }
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
-
90
- for (const [ns, entries] of namespaceMap) {
91
- namespaceMap.set(
92
- ns,
93
- Object.fromEntries(Object.entries(entries).sort(([a], [b]) => a.localeCompare(b)))
94
- );
95
- }
96
-
97
- const localeFiles: LocaleFile[] = [];
98
- const allLanguages = [this.options.defaultLanguage, ...this.options.targetLanguages];
99
-
100
- for (const lang of allLanguages) {
101
- for (const [namespace, entries] of namespaceMap) {
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, '']));
108
- localeFiles.push({ language: lang, namespace, entries: langEntries, filePath: "" });
109
- }
110
- }
111
-
112
- return { localeFiles, keyCount: keyValueMap.size, namespaces: Array.from(namespaceMap.keys()) };
113
- }
114
- }
package/src/index.ts DELETED
@@ -1,4 +0,0 @@
1
- export * from "./extractor.js";
2
- export * from "./writer.js";
3
- export * from "./deduplicator.js";
4
- export * from "./synchronizer.js";
@@ -1,36 +0,0 @@
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 DELETED
@@ -1,164 +0,0 @@
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
- * 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";
23
- }
24
-
25
- export class LocaleWriter {
26
- private options: Required<WriteOptions>;
27
-
28
- constructor(options: WriteOptions) {
29
- this.options = {
30
- localesDir: options.localesDir,
31
- merge: options.merge ?? true,
32
- sort: options.sort ?? true,
33
- localeStructure: options.localeStructure ?? "nested",
34
- };
35
- }
36
-
37
- write(localeFiles: LocaleFile[]): { written: string[]; merged: string[]; created: string[] } {
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[] = [];
48
- const merged: string[] = [];
49
- const created: string[] = [];
50
-
51
- for (const lf of localeFiles) {
52
- const filePath = this.resolveNestedFilePath(lf);
53
- lf.filePath = filePath;
54
- ensureDir(path.dirname(filePath));
55
- const existing = readJsonSafe<Record<string, string>>(filePath);
56
-
57
- if (existing && this.options.merge) {
58
- const mergedEntries = this.mergeEntries(existing, lf.entries);
59
- writeJson(filePath, this.options.sort ? this.sort(mergedEntries) : mergedEntries);
60
- merged.push(filePath);
61
- } else {
62
- writeJson(filePath, this.options.sort ? this.sort(lf.entries) : lf.entries);
63
- created.push(filePath);
64
- }
65
- written.push(filePath);
66
- }
67
-
68
- return { written, merged, created };
69
- }
70
-
71
- private resolveNestedFilePath(lf: LocaleFile): string {
72
- return lf.namespace === "common"
73
- ? path.join(this.options.localesDir, lf.language, "translation.json")
74
- : path.join(this.options.localesDir, lf.language, `${lf.namespace}.json`);
75
- }
76
-
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> {
154
- const result = { ...existing };
155
- for (const [key, value] of Object.entries(incoming)) {
156
- if (!(key in result) || result[key] === "") result[key] = value;
157
- }
158
- return result;
159
- }
160
-
161
- private sort(entries: Record<string, string>): Record<string, string> {
162
- return Object.fromEntries(Object.entries(entries).sort(([a], [b]) => a.localeCompare(b)));
163
- }
164
- }
package/tsconfig.json DELETED
@@ -1,6 +0,0 @@
1
- {
2
- "extends": "../../tsconfig.base.json",
3
- "compilerOptions": { "outDir": "./dist", "rootDir": "./src" },
4
- "include": ["src/**/*"],
5
- "exclude": ["node_modules", "dist"]
6
- }