@messagevisor/core 0.0.1 → 0.1.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 (211) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/LICENSE +21 -0
  3. package/README.md +7 -0
  4. package/jest.config.js +8 -0
  5. package/lib/benchmark/index.d.ts +2 -0
  6. package/lib/benchmark/index.js +417 -0
  7. package/lib/benchmark/index.js.map +1 -0
  8. package/lib/builder/index.d.ts +70 -0
  9. package/lib/builder/index.js +831 -0
  10. package/lib/builder/index.js.map +1 -0
  11. package/lib/cli/index.d.ts +28 -0
  12. package/lib/cli/index.js +182 -0
  13. package/lib/cli/index.js.map +1 -0
  14. package/lib/config/index.d.ts +61 -0
  15. package/lib/config/index.js +255 -0
  16. package/lib/config/index.js.map +1 -0
  17. package/lib/create/index.d.ts +2 -0
  18. package/lib/create/index.js +405 -0
  19. package/lib/create/index.js.map +1 -0
  20. package/lib/datasource/filesystemAdapter.d.ts +44 -0
  21. package/lib/datasource/filesystemAdapter.js +424 -0
  22. package/lib/datasource/filesystemAdapter.js.map +1 -0
  23. package/lib/datasource/index.d.ts +39 -0
  24. package/lib/datasource/index.js +96 -0
  25. package/lib/datasource/index.js.map +1 -0
  26. package/lib/error.d.ts +6 -0
  27. package/lib/error.js +49 -0
  28. package/lib/error.js.map +1 -0
  29. package/lib/evaluate/cli.d.ts +8 -0
  30. package/lib/evaluate/cli.js +179 -0
  31. package/lib/evaluate/cli.js.map +1 -0
  32. package/lib/evaluate/index.d.ts +10 -0
  33. package/lib/evaluate/index.js +131 -0
  34. package/lib/evaluate/index.js.map +1 -0
  35. package/lib/examples/coerceExampleIsoDates.d.ts +12 -0
  36. package/lib/examples/coerceExampleIsoDates.js +81 -0
  37. package/lib/examples/coerceExampleIsoDates.js.map +1 -0
  38. package/lib/examples/index.d.ts +63 -0
  39. package/lib/examples/index.js +713 -0
  40. package/lib/examples/index.js.map +1 -0
  41. package/lib/exporter/index.d.ts +60 -0
  42. package/lib/exporter/index.js +610 -0
  43. package/lib/exporter/index.js.map +1 -0
  44. package/lib/find-duplicates/index.d.ts +41 -0
  45. package/lib/find-duplicates/index.js +297 -0
  46. package/lib/find-duplicates/index.js.map +1 -0
  47. package/lib/generate-code/index.d.ts +11 -0
  48. package/lib/generate-code/index.js +157 -0
  49. package/lib/generate-code/index.js.map +1 -0
  50. package/lib/generate-code/typescript.d.ts +14 -0
  51. package/lib/generate-code/typescript.js +307 -0
  52. package/lib/generate-code/typescript.js.map +1 -0
  53. package/lib/importer/index.d.ts +64 -0
  54. package/lib/importer/index.js +1092 -0
  55. package/lib/importer/index.js.map +1 -0
  56. package/lib/index.d.ts +18 -0
  57. package/lib/index.js +35 -0
  58. package/lib/index.js.map +1 -0
  59. package/lib/info/index.d.ts +17 -0
  60. package/lib/info/index.js +132 -0
  61. package/lib/info/index.js.map +1 -0
  62. package/lib/init/index.d.ts +30 -0
  63. package/lib/init/index.js +348 -0
  64. package/lib/init/index.js.map +1 -0
  65. package/lib/lint/index.d.ts +1 -0
  66. package/lib/lint/index.js +6 -0
  67. package/lib/lint/index.js.map +1 -0
  68. package/lib/linter/attributeSchema.d.ts +7 -0
  69. package/lib/linter/attributeSchema.js +36 -0
  70. package/lib/linter/attributeSchema.js.map +1 -0
  71. package/lib/linter/checkLocaleCircularDependency.d.ts +7 -0
  72. package/lib/linter/checkLocaleCircularDependency.js +42 -0
  73. package/lib/linter/checkLocaleCircularDependency.js.map +1 -0
  74. package/lib/linter/conditionSchema.d.ts +3 -0
  75. package/lib/linter/conditionSchema.js +283 -0
  76. package/lib/linter/conditionSchema.js.map +1 -0
  77. package/lib/linter/formatSchema.d.ts +325 -0
  78. package/lib/linter/formatSchema.js +165 -0
  79. package/lib/linter/formatSchema.js.map +1 -0
  80. package/lib/linter/icuStyleLint.d.ts +6 -0
  81. package/lib/linter/icuStyleLint.js +226 -0
  82. package/lib/linter/icuStyleLint.js.map +1 -0
  83. package/lib/linter/index.d.ts +34 -0
  84. package/lib/linter/index.js +557 -0
  85. package/lib/linter/index.js.map +1 -0
  86. package/lib/linter/localeSchema.d.ts +672 -0
  87. package/lib/linter/localeSchema.js +50 -0
  88. package/lib/linter/localeSchema.js.map +1 -0
  89. package/lib/linter/messageSchema.d.ts +35 -0
  90. package/lib/linter/messageSchema.js +115 -0
  91. package/lib/linter/messageSchema.js.map +1 -0
  92. package/lib/linter/printError.d.ts +8 -0
  93. package/lib/linter/printError.js +41 -0
  94. package/lib/linter/printError.js.map +1 -0
  95. package/lib/linter/schema.d.ts +33 -0
  96. package/lib/linter/schema.js +192 -0
  97. package/lib/linter/schema.js.map +1 -0
  98. package/lib/linter/segmentSchema.d.ts +8 -0
  99. package/lib/linter/segmentSchema.js +18 -0
  100. package/lib/linter/segmentSchema.js.map +1 -0
  101. package/lib/linter/targetSchema.d.ts +337 -0
  102. package/lib/linter/targetSchema.js +39 -0
  103. package/lib/linter/targetSchema.js.map +1 -0
  104. package/lib/linter/testSchema.d.ts +71 -0
  105. package/lib/linter/testSchema.js +165 -0
  106. package/lib/linter/testSchema.js.map +1 -0
  107. package/lib/linter/zodHelpers.d.ts +2 -0
  108. package/lib/linter/zodHelpers.js +15 -0
  109. package/lib/linter/zodHelpers.js.map +1 -0
  110. package/lib/list/index.d.ts +8 -0
  111. package/lib/list/index.js +524 -0
  112. package/lib/list/index.js.map +1 -0
  113. package/lib/matrix.d.ts +4 -0
  114. package/lib/matrix.js +66 -0
  115. package/lib/matrix.js.map +1 -0
  116. package/lib/promoter/index.d.ts +65 -0
  117. package/lib/promoter/index.js +1208 -0
  118. package/lib/promoter/index.js.map +1 -0
  119. package/lib/prune/index.d.ts +37 -0
  120. package/lib/prune/index.js +673 -0
  121. package/lib/prune/index.js.map +1 -0
  122. package/lib/sets.d.ts +10 -0
  123. package/lib/sets.js +120 -0
  124. package/lib/sets.js.map +1 -0
  125. package/lib/tester/cliFormat.d.ts +8 -0
  126. package/lib/tester/cliFormat.js +15 -0
  127. package/lib/tester/cliFormat.js.map +1 -0
  128. package/lib/tester/index.d.ts +35 -0
  129. package/lib/tester/index.js +713 -0
  130. package/lib/tester/index.js.map +1 -0
  131. package/lib/tester/matrix.d.ts +14 -0
  132. package/lib/tester/matrix.js +76 -0
  133. package/lib/tester/matrix.js.map +1 -0
  134. package/lib/tester/prettyDuration.d.ts +1 -0
  135. package/lib/tester/prettyDuration.js +30 -0
  136. package/lib/tester/prettyDuration.js.map +1 -0
  137. package/lib/tester/printTestResult.d.ts +2 -0
  138. package/lib/tester/printTestResult.js +32 -0
  139. package/lib/tester/printTestResult.js.map +1 -0
  140. package/lib/tester/types.d.ts +29 -0
  141. package/lib/tester/types.js +3 -0
  142. package/lib/tester/types.js.map +1 -0
  143. package/package.json +41 -13
  144. package/src/benchmark/index.spec.ts +375 -0
  145. package/src/benchmark/index.ts +433 -0
  146. package/src/builder/index.spec.ts +822 -0
  147. package/src/builder/index.ts +920 -0
  148. package/src/cli/index.spec.ts +54 -0
  149. package/src/cli/index.ts +150 -0
  150. package/src/config/index.spec.ts +70 -0
  151. package/src/config/index.ts +259 -0
  152. package/src/create/index.spec.ts +272 -0
  153. package/src/create/index.ts +295 -0
  154. package/src/datasource/filesystemAdapter.ts +313 -0
  155. package/src/datasource/index.ts +135 -0
  156. package/src/error.ts +33 -0
  157. package/src/evaluate/cli.spec.ts +368 -0
  158. package/src/evaluate/cli.ts +130 -0
  159. package/src/evaluate/index.ts +161 -0
  160. package/src/examples/coerceExampleIsoDates.spec.ts +81 -0
  161. package/src/examples/coerceExampleIsoDates.ts +98 -0
  162. package/src/examples/index.spec.ts +453 -0
  163. package/src/examples/index.ts +854 -0
  164. package/src/exporter/index.spec.ts +443 -0
  165. package/src/exporter/index.ts +643 -0
  166. package/src/find-duplicates/index.spec.ts +289 -0
  167. package/src/find-duplicates/index.ts +314 -0
  168. package/src/generate-code/index.ts +92 -0
  169. package/src/generate-code/typescript.spec.ts +241 -0
  170. package/src/generate-code/typescript.ts +284 -0
  171. package/src/importer/index.spec.ts +1101 -0
  172. package/src/importer/index.ts +1190 -0
  173. package/src/index.ts +18 -0
  174. package/src/info/index.ts +67 -0
  175. package/src/init/index.spec.ts +279 -0
  176. package/src/init/index.ts +292 -0
  177. package/src/lint/index.ts +1 -0
  178. package/src/linter/attributeSchema.ts +38 -0
  179. package/src/linter/checkLocaleCircularDependency.ts +51 -0
  180. package/src/linter/conditionSchema.ts +386 -0
  181. package/src/linter/formatSchema.ts +170 -0
  182. package/src/linter/icuStyleLint.ts +312 -0
  183. package/src/linter/index.spec.ts +824 -0
  184. package/src/linter/index.ts +460 -0
  185. package/src/linter/localeSchema.ts +70 -0
  186. package/src/linter/messageSchema.ts +152 -0
  187. package/src/linter/printError.ts +52 -0
  188. package/src/linter/schema.ts +230 -0
  189. package/src/linter/segmentSchema.ts +15 -0
  190. package/src/linter/targetSchema.ts +50 -0
  191. package/src/linter/testSchema.spec.ts +405 -0
  192. package/src/linter/testSchema.ts +239 -0
  193. package/src/linter/zodHelpers.ts +16 -0
  194. package/src/list/index.spec.ts +431 -0
  195. package/src/list/index.ts +463 -0
  196. package/src/matrix.ts +69 -0
  197. package/src/promoter/index.spec.ts +584 -0
  198. package/src/promoter/index.ts +1267 -0
  199. package/src/prune/index.spec.ts +418 -0
  200. package/src/prune/index.ts +693 -0
  201. package/src/sets.ts +74 -0
  202. package/src/tester/cliFormat.ts +11 -0
  203. package/src/tester/featurevisorIntegration.spec.ts +101 -0
  204. package/src/tester/index.spec.ts +577 -0
  205. package/src/tester/index.ts +679 -0
  206. package/src/tester/matrix.ts +106 -0
  207. package/src/tester/prettyDuration.ts +34 -0
  208. package/src/tester/printTestResult.ts +40 -0
  209. package/src/tester/types.ts +32 -0
  210. package/tsconfig.cjs.json +11 -0
  211. package/tsconfig.typecheck.json +4 -0
@@ -0,0 +1,289 @@
1
+ import * as fs from "fs";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+
5
+ import { getProjectConfig } from "../config";
6
+ import { Datasource } from "../datasource";
7
+ import { findDuplicateTranslations, findDuplicatesPlugin } from "./index";
8
+
9
+ async function writeFile(root: string, relativePath: string, content: string) {
10
+ const filePath = path.join(root, relativePath);
11
+ await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
12
+ await fs.promises.writeFile(filePath, content);
13
+ }
14
+
15
+ async function createProject() {
16
+ const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "messagevisor-duplicates-"));
17
+
18
+ await writeFile(root, "messagevisor.config.js", "module.exports = {};\n");
19
+ await writeFile(root, "locales/en.yml", "description: English\n");
20
+ await writeFile(
21
+ root,
22
+ "locales/en-US.yml",
23
+ "description: English US\ninheritTranslationsFrom: en\n",
24
+ );
25
+ await writeFile(root, "locales/nl.yml", "description: Dutch\n");
26
+ await writeFile(
27
+ root,
28
+ "messages/actions/save.yml",
29
+ "description: Save action\ntranslations:\n en: Save\n nl: Opslaan\n",
30
+ );
31
+ await writeFile(
32
+ root,
33
+ "messages/common/save.yml",
34
+ "description: Common save\ntranslations:\n en: Save\n en-US: Save\n nl: Opslaan\n",
35
+ );
36
+ await writeFile(
37
+ root,
38
+ "messages/actions/cancel.yml",
39
+ "description: Cancel action\ntranslations:\n en: Cancel\n nl: Annuleren\n",
40
+ );
41
+ await writeFile(
42
+ root,
43
+ "messages/override-only.yml",
44
+ [
45
+ "description: Override should not count",
46
+ "translations:",
47
+ " en: Unique base",
48
+ "overrides:",
49
+ " - key: duplicate",
50
+ " segments: '*'",
51
+ " translations:",
52
+ " en: Save",
53
+ "",
54
+ ].join("\n"),
55
+ );
56
+ await writeFile(
57
+ root,
58
+ "messages/empty/one.yml",
59
+ "description: Empty one\ntranslations:\n en: ''\n nl: ' '\n",
60
+ );
61
+ await writeFile(
62
+ root,
63
+ "messages/empty/two.yml",
64
+ "description: Empty two\ntranslations:\n en: ''\n nl: ' '\n",
65
+ );
66
+ await writeFile(
67
+ root,
68
+ "messages/archived/save.yml",
69
+ "description: Archived save\narchived: true\ntranslations:\n en: Save\n nl: Opslaan\n",
70
+ );
71
+
72
+ return root;
73
+ }
74
+
75
+ async function createSetsProject() {
76
+ const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "messagevisor-duplicates-sets-"));
77
+
78
+ await writeFile(root, "messagevisor.config.js", "module.exports = { sets: true };\n");
79
+
80
+ for (const set of ["dev", "production"]) {
81
+ await writeFile(root, `sets/${set}/locales/en.yml`, "description: English\n");
82
+ await writeFile(
83
+ root,
84
+ `sets/${set}/messages/actions/save.yml`,
85
+ `description: Save ${set}\ntranslations:\n en: Save ${set}\n`,
86
+ );
87
+ await writeFile(
88
+ root,
89
+ `sets/${set}/messages/common/save.yml`,
90
+ `description: Common save ${set}\ntranslations:\n en: Save ${set}\n`,
91
+ );
92
+ }
93
+
94
+ return root;
95
+ }
96
+
97
+ function getDatasource(root: string) {
98
+ const projectConfig = getProjectConfig(root);
99
+ const datasource = new Datasource(projectConfig, root);
100
+
101
+ return { projectConfig, datasource };
102
+ }
103
+
104
+ function stripAnsi(value: string) {
105
+ return value.replace(/\x1b\[[0-9;]*m/g, "");
106
+ }
107
+
108
+ describe("findDuplicateTranslations", function () {
109
+ it("finds direct and inherited duplicate translations grouped by locale", async function () {
110
+ const root = await createProject();
111
+ const { projectConfig, datasource } = getDatasource(root);
112
+
113
+ const result = await findDuplicateTranslations(projectConfig, datasource);
114
+
115
+ expect(result.summary).toEqual({
116
+ sets: 1,
117
+ locales: 3,
118
+ duplicateValues: 3,
119
+ duplicateMessageKeys: 6,
120
+ });
121
+ expect(result.results[0].set).toEqual(null);
122
+ expect(result.results[0].locales.map((entry) => entry.locale)).toEqual(["en", "en-US", "nl"]);
123
+
124
+ const enUsDuplicate = result.results[0].locales
125
+ .find((entry) => entry.locale === "en-US")
126
+ ?.duplicateValues.find((entry) => entry.value === "Save");
127
+
128
+ expect(enUsDuplicate).toEqual({
129
+ value: "Save",
130
+ messageKeys: ["actions.save", "common.save"],
131
+ sources: [
132
+ { messageKey: "actions.save", locale: "en" },
133
+ { messageKey: "common.save", locale: "en-US" },
134
+ ],
135
+ });
136
+ });
137
+
138
+ it("ignores overrides, empty values, whitespace-only values, and archived messages", async function () {
139
+ const root = await createProject();
140
+ const { projectConfig, datasource } = getDatasource(root);
141
+
142
+ const result = await findDuplicateTranslations(projectConfig, datasource, {
143
+ locale: "en",
144
+ });
145
+
146
+ const values = result.results[0].locales[0].duplicateValues;
147
+
148
+ expect(values).toHaveLength(1);
149
+ expect(values[0].value).toEqual("Save");
150
+ expect(values[0].messageKeys).toEqual(["actions.save", "common.save"]);
151
+ expect(values[0].messageKeys).not.toContain("override-only");
152
+ expect(values[0].messageKeys).not.toContain("empty.one");
153
+ expect(values[0].messageKeys).not.toContain("empty.two");
154
+ expect(values[0].messageKeys).not.toContain("archived.save");
155
+ });
156
+
157
+ it("filters by locale and reports empty results cleanly", async function () {
158
+ const root = await createProject();
159
+ const { projectConfig, datasource } = getDatasource(root);
160
+
161
+ const result = await findDuplicateTranslations(projectConfig, datasource, {
162
+ locale: "fr",
163
+ }).catch((error) => error);
164
+
165
+ expect(result.message).toContain('Unknown locale "fr"');
166
+
167
+ await writeFile(
168
+ root,
169
+ "messages/common/save.yml",
170
+ "description: Common save\ntranslations:\n en: Store\n nl: Bewaren\n",
171
+ );
172
+
173
+ const empty = await findDuplicateTranslations(projectConfig, datasource, {
174
+ locale: "en-US",
175
+ });
176
+
177
+ expect(empty.summary).toEqual({
178
+ sets: 1,
179
+ locales: 0,
180
+ duplicateValues: 0,
181
+ duplicateMessageKeys: 0,
182
+ });
183
+ expect(empty.results).toEqual([{ set: null, locales: [] }]);
184
+ });
185
+
186
+ it("scans sets independently by default and narrows to one set with --set", async function () {
187
+ const root = await createSetsProject();
188
+ const { projectConfig, datasource } = getDatasource(root);
189
+
190
+ const allSets = await findDuplicateTranslations(projectConfig, datasource);
191
+
192
+ expect(allSets.summary).toEqual({
193
+ sets: 2,
194
+ locales: 2,
195
+ duplicateValues: 2,
196
+ duplicateMessageKeys: 4,
197
+ });
198
+ expect(allSets.results.map((entry) => entry.set)).toEqual(["dev", "production"]);
199
+ expect(allSets.results[0].locales[0].duplicateValues[0].value).toEqual("Save dev");
200
+ expect(allSets.results[1].locales[0].duplicateValues[0].value).toEqual("Save production");
201
+
202
+ const production = await findDuplicateTranslations(projectConfig, datasource, {
203
+ set: "production",
204
+ });
205
+
206
+ expect(production.summary).toEqual({
207
+ sets: 1,
208
+ locales: 1,
209
+ duplicateValues: 1,
210
+ duplicateMessageKeys: 2,
211
+ });
212
+ expect(production.results.map((entry) => entry.set)).toEqual(["production"]);
213
+ });
214
+ });
215
+
216
+ describe("findDuplicatesPlugin", function () {
217
+ it("prints readable duplicate output", async function () {
218
+ const root = await createProject();
219
+ const { projectConfig, datasource } = getDatasource(root);
220
+ const logSpy = jest.spyOn(console, "log").mockImplementation(() => undefined);
221
+
222
+ await findDuplicatesPlugin.handler({
223
+ projectConfig,
224
+ datasource,
225
+ parsed: { locale: "en-US" },
226
+ });
227
+
228
+ const rawOutput = logSpy.mock.calls.map((call) => call.join(" ")).join("\n");
229
+ const output = stripAnsi(rawOutput);
230
+
231
+ expect(rawOutput).toContain("\x1b[1m");
232
+ expect(rawOutput).toContain("\x1b[33m");
233
+ expect(output).toContain("Finding duplicate Messagevisor translations");
234
+ expect(output).toContain("Duplicate values: 1");
235
+ expect(output).toContain('Locale "en-US"');
236
+ expect(output).toContain('"Save"');
237
+ expect(output).toContain("- actions.save (from en)");
238
+ expect(output).toContain("- common.save");
239
+ expect(output).toContain("Duplicate scan complete: 1 value(s) across 1 locale(s).");
240
+
241
+ logSpy.mockRestore();
242
+ });
243
+
244
+ it("prints JSON output and respects --pretty", async function () {
245
+ const root = await createProject();
246
+ const { projectConfig, datasource } = getDatasource(root);
247
+ const logSpy = jest.spyOn(console, "log").mockImplementation(() => undefined);
248
+
249
+ await findDuplicatesPlugin.handler({
250
+ projectConfig,
251
+ datasource,
252
+ parsed: { locale: "en-US", json: true, pretty: true },
253
+ });
254
+
255
+ const output = logSpy.mock.calls[0][0];
256
+
257
+ expect(output).toContain('\n "summary":');
258
+ expect(JSON.parse(output).results[0].locales[0].duplicateValues[0]).toEqual({
259
+ value: "Save",
260
+ messageKeys: ["actions.save", "common.save"],
261
+ sources: [
262
+ { messageKey: "actions.save", locale: "en" },
263
+ { messageKey: "common.save", locale: "en-US" },
264
+ ],
265
+ });
266
+
267
+ logSpy.mockRestore();
268
+ });
269
+
270
+ it("returns false and prints a friendly message for expected errors", async function () {
271
+ const root = await createProject();
272
+ const { projectConfig, datasource } = getDatasource(root);
273
+ const errorSpy = jest.spyOn(console, "error").mockImplementation(() => undefined);
274
+
275
+ await expect(
276
+ findDuplicatesPlugin.handler({
277
+ projectConfig,
278
+ datasource,
279
+ parsed: { set: "dev" },
280
+ }),
281
+ ).resolves.toEqual(false);
282
+
283
+ expect(errorSpy).toHaveBeenCalledWith(
284
+ "Option --set can only be used when project sets are enabled.",
285
+ );
286
+
287
+ errorSpy.mockRestore();
288
+ });
289
+ });
@@ -0,0 +1,314 @@
1
+ import type { Locale, Message, Translation } from "@messagevisor/types";
2
+
3
+ import type { ProjectConfig } from "../config";
4
+ import type { Datasource } from "../datasource";
5
+ import { MessagevisorCLIError, printMessagevisorCLIError } from "../error";
6
+ import { getProjectSetExecutions } from "../sets";
7
+ import { CLI_FORMAT_BOLD, CLI_FORMAT_CYAN, CLI_FORMAT_GREEN, colorize } from "../tester/cliFormat";
8
+
9
+ export interface FindDuplicatesOptions {
10
+ set?: string;
11
+ locale?: string;
12
+ }
13
+
14
+ export interface DuplicateTranslationSource {
15
+ messageKey: string;
16
+ locale: string;
17
+ }
18
+
19
+ export interface DuplicateTranslationValue {
20
+ value: string;
21
+ messageKeys: string[];
22
+ sources: DuplicateTranslationSource[];
23
+ }
24
+
25
+ export interface DuplicateTranslationLocaleResult {
26
+ locale: string;
27
+ duplicateValues: DuplicateTranslationValue[];
28
+ }
29
+
30
+ export interface DuplicateTranslationSetResult {
31
+ set: string | null;
32
+ locales: DuplicateTranslationLocaleResult[];
33
+ }
34
+
35
+ export interface FindDuplicatesResult {
36
+ summary: {
37
+ sets: number;
38
+ locales: number;
39
+ duplicateValues: number;
40
+ duplicateMessageKeys: number;
41
+ };
42
+ results: DuplicateTranslationSetResult[];
43
+ }
44
+
45
+ interface ResolvedTranslation {
46
+ value: string;
47
+ sourceLocale: string;
48
+ }
49
+
50
+ async function readAll<T>(
51
+ keys: string[],
52
+ read: (key: string) => Promise<T>,
53
+ ): Promise<Record<string, T>> {
54
+ const entries = await Promise.all(keys.map(async (key) => [key, await read(key)] as const));
55
+ return Object.fromEntries(entries);
56
+ }
57
+
58
+ function resolveLocaleChain(localeKey: string, locales: Record<string, Locale>) {
59
+ const chain: string[] = [];
60
+ const seen = new Set<string>();
61
+ let currentKey: string | undefined = localeKey;
62
+
63
+ while (currentKey && !seen.has(currentKey)) {
64
+ seen.add(currentKey);
65
+ chain.unshift(currentKey);
66
+ currentKey = locales[currentKey]?.inheritTranslationsFrom;
67
+ }
68
+
69
+ return chain;
70
+ }
71
+
72
+ function resolveTranslation(
73
+ translations: Record<string, Translation> | undefined,
74
+ localeKey: string,
75
+ locales: Record<string, Locale>,
76
+ ): ResolvedTranslation | undefined {
77
+ const candidates = resolveLocaleChain(localeKey, locales).reverse();
78
+
79
+ for (const candidate of candidates) {
80
+ if (typeof translations?.[candidate] !== "undefined") {
81
+ return {
82
+ value: translations[candidate],
83
+ sourceLocale: candidate,
84
+ };
85
+ }
86
+ }
87
+ }
88
+
89
+ function isAvailable(message: Message) {
90
+ return !message.archived;
91
+ }
92
+
93
+ function hasContent(value: string) {
94
+ return value.trim().length > 0;
95
+ }
96
+
97
+ function toDuplicateLocaleResult(
98
+ locale: string,
99
+ messages: Record<string, Message>,
100
+ locales: Record<string, Locale>,
101
+ ): DuplicateTranslationLocaleResult {
102
+ const entriesByValue = new Map<string, DuplicateTranslationSource[]>();
103
+
104
+ for (const [messageKey, message] of Object.entries(messages)) {
105
+ if (!isAvailable(message)) {
106
+ continue;
107
+ }
108
+
109
+ const resolved = resolveTranslation(message.translations, locale, locales);
110
+
111
+ if (!resolved || !hasContent(resolved.value)) {
112
+ continue;
113
+ }
114
+
115
+ const existing = entriesByValue.get(resolved.value) || [];
116
+ existing.push({
117
+ messageKey,
118
+ locale: resolved.sourceLocale,
119
+ });
120
+ entriesByValue.set(resolved.value, existing);
121
+ }
122
+
123
+ const duplicateValues = Array.from(entriesByValue.entries())
124
+ .filter(([, entries]) => entries.length > 1)
125
+ .map(([value, sources]) => ({
126
+ value,
127
+ messageKeys: sources.map((source) => source.messageKey).sort(),
128
+ sources: sources
129
+ .slice()
130
+ .sort(
131
+ (a, b) => a.messageKey.localeCompare(b.messageKey) || a.locale.localeCompare(b.locale),
132
+ ),
133
+ }))
134
+ .sort((a, b) => a.value.localeCompare(b.value));
135
+
136
+ return {
137
+ locale,
138
+ duplicateValues,
139
+ };
140
+ }
141
+
142
+ async function findDuplicatesInDatasource(
143
+ datasource: Datasource,
144
+ set: string | null,
145
+ options: Pick<FindDuplicatesOptions, "locale">,
146
+ ): Promise<DuplicateTranslationSetResult> {
147
+ const [localeKeys, messageKeys] = await Promise.all([
148
+ datasource.listLocales(),
149
+ datasource.listMessages(),
150
+ ]);
151
+
152
+ if (options.locale && !localeKeys.includes(options.locale)) {
153
+ throw new MessagevisorCLIError(
154
+ `Unknown locale "${options.locale}". Available locales: ${localeKeys.join(", ") || "none"}.`,
155
+ );
156
+ }
157
+
158
+ const selectedLocales = options.locale ? [options.locale] : localeKeys;
159
+ const [locales, messages] = await Promise.all([
160
+ readAll<Locale>(localeKeys, (key) => datasource.readLocale(key)),
161
+ readAll<Message>(messageKeys, (key) => datasource.readMessage(key)),
162
+ ]);
163
+
164
+ return {
165
+ set,
166
+ locales: selectedLocales
167
+ .map((locale) => toDuplicateLocaleResult(locale, messages, locales))
168
+ .filter((result) => result.duplicateValues.length > 0),
169
+ };
170
+ }
171
+
172
+ function summarize(results: DuplicateTranslationSetResult[]): FindDuplicatesResult["summary"] {
173
+ const duplicateValues = results.reduce(
174
+ (sum, result) =>
175
+ sum +
176
+ result.locales.reduce((localeSum, locale) => localeSum + locale.duplicateValues.length, 0),
177
+ 0,
178
+ );
179
+ const duplicateMessageKeys = results.reduce(
180
+ (sum, result) =>
181
+ sum +
182
+ result.locales.reduce(
183
+ (localeSum, locale) =>
184
+ localeSum +
185
+ locale.duplicateValues.reduce(
186
+ (valueSum, duplicate) => valueSum + duplicate.messageKeys.length,
187
+ 0,
188
+ ),
189
+ 0,
190
+ ),
191
+ 0,
192
+ );
193
+
194
+ return {
195
+ sets: results.length,
196
+ locales: results.reduce((sum, result) => sum + result.locales.length, 0),
197
+ duplicateValues,
198
+ duplicateMessageKeys,
199
+ };
200
+ }
201
+
202
+ export async function findDuplicateTranslations(
203
+ projectConfig: ProjectConfig,
204
+ datasource: Datasource,
205
+ options: FindDuplicatesOptions = {},
206
+ ): Promise<FindDuplicatesResult> {
207
+ if (!projectConfig.sets && options.set) {
208
+ throw new MessagevisorCLIError("Option --set can only be used when project sets are enabled.");
209
+ }
210
+
211
+ const executions = await getProjectSetExecutions(projectConfig, datasource, options.set);
212
+ const results = await Promise.all(
213
+ executions.map((execution) =>
214
+ findDuplicatesInDatasource(
215
+ execution.datasource,
216
+ projectConfig.sets ? execution.set : null,
217
+ options,
218
+ ),
219
+ ),
220
+ );
221
+
222
+ return {
223
+ summary: summarize(results),
224
+ results,
225
+ };
226
+ }
227
+
228
+ function printDuplicateSources(locale: string, duplicate: DuplicateTranslationValue) {
229
+ for (const source of duplicate.sources) {
230
+ const inheritedSuffix = source.locale !== locale ? colorize(` (from ${source.locale})`, 2) : "";
231
+ console.log(` - ${colorize(source.messageKey, 1)}${inheritedSuffix}`);
232
+ }
233
+ }
234
+
235
+ function printPlainResult(result: FindDuplicatesResult, hasSets: boolean) {
236
+ console.log("");
237
+ console.log(CLI_FORMAT_BOLD, "Finding duplicate Messagevisor translations");
238
+ console.log(` Sets: ${result.summary.sets}`);
239
+ console.log(` Locales: ${result.summary.locales}`);
240
+ console.log(` Duplicate values: ${result.summary.duplicateValues}`);
241
+ console.log(` Message key hits: ${result.summary.duplicateMessageKeys}`);
242
+ console.log("");
243
+
244
+ if (result.summary.duplicateValues === 0) {
245
+ console.log(CLI_FORMAT_GREEN, "No duplicate translations found.");
246
+ return;
247
+ }
248
+
249
+ for (const setResult of result.results) {
250
+ if (setResult.locales.length === 0) {
251
+ continue;
252
+ }
253
+
254
+ if (hasSets) {
255
+ console.log(CLI_FORMAT_BOLD, `Set "${setResult.set}"`);
256
+ }
257
+
258
+ for (const localeResult of setResult.locales) {
259
+ console.log(CLI_FORMAT_CYAN, `Locale "${localeResult.locale}"`);
260
+
261
+ for (const duplicate of localeResult.duplicateValues) {
262
+ console.log(` ${colorize(JSON.stringify(duplicate.value), 33)}`);
263
+ printDuplicateSources(localeResult.locale, duplicate);
264
+ }
265
+
266
+ console.log("");
267
+ }
268
+ }
269
+
270
+ console.log(
271
+ CLI_FORMAT_GREEN,
272
+ `Duplicate scan complete: ${result.summary.duplicateValues} value(s) across ${result.summary.locales} locale(s).`,
273
+ );
274
+ }
275
+
276
+ export const findDuplicatesPlugin = {
277
+ command: "find-duplicates",
278
+ handler: async ({ projectConfig, datasource, parsed }: any) => {
279
+ try {
280
+ const result = await findDuplicateTranslations(projectConfig, datasource, {
281
+ set: parsed.set,
282
+ locale: parsed.locale,
283
+ });
284
+
285
+ if (parsed.json) {
286
+ console.log(parsed.pretty ? JSON.stringify(result, null, 2) : JSON.stringify(result));
287
+ return;
288
+ }
289
+
290
+ printPlainResult(result, projectConfig.sets);
291
+ } catch (error) {
292
+ if (printMessagevisorCLIError(error)) {
293
+ return false;
294
+ }
295
+
296
+ throw error;
297
+ }
298
+ },
299
+ examples: [
300
+ { command: "find-duplicates", description: "find duplicate translation values" },
301
+ {
302
+ command: "find-duplicates --locale=en-US",
303
+ description: "find duplicates for one locale",
304
+ },
305
+ {
306
+ command: "find-duplicates --set=staging",
307
+ description: "find duplicates in one set",
308
+ },
309
+ {
310
+ command: "find-duplicates --locale=en-US --json --pretty",
311
+ description: "print duplicate translations as JSON",
312
+ },
313
+ ],
314
+ };
@@ -0,0 +1,92 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+
4
+ import type { Plugin } from "../cli";
5
+ import type { ProjectConfig } from "../config";
6
+ import type { Datasource } from "../datasource";
7
+ import { MessagevisorCLIError, printMessagevisorCLIError } from "../error";
8
+ import {
9
+ generateTypeScriptCodeForProject,
10
+ type TypeScriptCodeGenerationOptions,
11
+ } from "./typescript";
12
+
13
+ export const ALLOWED_LANGUAGES_FOR_CODE_GENERATION = ["typescript"];
14
+
15
+ export interface GenerateCodeOptions extends TypeScriptCodeGenerationOptions {
16
+ language?: string;
17
+ outDir?: string;
18
+ }
19
+
20
+ export async function generateCodeForProject(
21
+ projectConfig: ProjectConfig,
22
+ datasource: Datasource,
23
+ rootDirectoryPath: string,
24
+ options: GenerateCodeOptions,
25
+ ) {
26
+ if (!options.language) {
27
+ throw new MessagevisorCLIError("Option --language is required.");
28
+ }
29
+
30
+ if (!options.outDir) {
31
+ throw new MessagevisorCLIError("Option --out-dir is required.");
32
+ }
33
+
34
+ if (ALLOWED_LANGUAGES_FOR_CODE_GENERATION.indexOf(options.language) === -1) {
35
+ throw new MessagevisorCLIError(
36
+ `Language ${options.language} is not supported. Supported languages: ${ALLOWED_LANGUAGES_FOR_CODE_GENERATION.join(", ")}.`,
37
+ );
38
+ }
39
+
40
+ const outDir = path.isAbsolute(options.outDir)
41
+ ? options.outDir
42
+ : path.join(rootDirectoryPath, options.outDir);
43
+
44
+ await fs.promises.mkdir(outDir, { recursive: true });
45
+
46
+ const result = await generateTypeScriptCodeForProject(projectConfig, datasource, outDir, {
47
+ set: options.set,
48
+ target: options.target,
49
+ includeMessages: options.includeMessages,
50
+ excludeMessages: options.excludeMessages,
51
+ react: options.react,
52
+ });
53
+
54
+ console.log(
55
+ `Generated ${result.files.length} TypeScript file(s) for ${result.messageKeys.length} message key(s) in ${outDir}`,
56
+ );
57
+
58
+ return result;
59
+ }
60
+
61
+ export const generateCodePlugin: Plugin = {
62
+ command: "generate-code",
63
+ handler: async ({ rootDirectoryPath, projectConfig, datasource, parsed }) => {
64
+ try {
65
+ await generateCodeForProject(projectConfig, datasource, rootDirectoryPath, {
66
+ language: parsed.language,
67
+ outDir: parsed.outDir,
68
+ set: parsed.set,
69
+ target: parsed.target,
70
+ includeMessages: parsed.includeMessages,
71
+ excludeMessages: parsed.excludeMessages,
72
+ react: parsed.react,
73
+ });
74
+ } catch (error) {
75
+ if (printMessagevisorCLIError(error)) {
76
+ return false;
77
+ }
78
+
79
+ throw error;
80
+ }
81
+ },
82
+ examples: [
83
+ {
84
+ command: "generate-code --language typescript --out-dir src/generated",
85
+ description: "Generate TypeScript message key helpers",
86
+ },
87
+ {
88
+ command: "generate-code --language typescript --out-dir src/generated --react",
89
+ description: "Generate TypeScript and React message key helpers",
90
+ },
91
+ ],
92
+ };