@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,693 @@
1
+ import * as path from "path";
2
+
3
+ import type { CustomParser } from "@featurevisor/parsers";
4
+ import type {
5
+ FormatPresets,
6
+ Locale,
7
+ Message,
8
+ Override,
9
+ Target,
10
+ Translation,
11
+ } from "@messagevisor/types";
12
+
13
+ import type { ProjectConfig } from "../config";
14
+ import type { Datasource } from "../datasource";
15
+ import { getProjectSetExecutions } from "../sets";
16
+ import { CLI_FORMAT_BOLD, CLI_FORMAT_GREEN } from "../tester/cliFormat";
17
+
18
+ type PruneTarget = "translations" | "formats";
19
+ type EntryKind = "message" | "override" | "locale";
20
+
21
+ export interface PruneProjectOptions {
22
+ pruneMode: PruneTarget;
23
+ locale?: string | string[];
24
+ target?: string | string[];
25
+ includeMessages?: string | string[];
26
+ excludeMessages?: string | string[];
27
+ apply?: boolean;
28
+ }
29
+
30
+ export interface PruneEntry {
31
+ kind: EntryKind;
32
+ key: string;
33
+ filePath: string;
34
+ locale?: string;
35
+ overrideKey?: string;
36
+ formatPath?: string;
37
+ inheritedFrom: string;
38
+ }
39
+
40
+ export interface PruneProjectResult {
41
+ pruneMode: PruneTarget;
42
+ apply: boolean;
43
+ entries: PruneEntry[];
44
+ changedFiles: string[];
45
+ }
46
+
47
+ function toArray(value?: string | string[]): string[] {
48
+ if (typeof value === "undefined") {
49
+ return [];
50
+ }
51
+
52
+ return Array.isArray(value) ? value : [value];
53
+ }
54
+
55
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
56
+ return typeof value === "object" && value !== null && !Array.isArray(value);
57
+ }
58
+
59
+ function deepEqual(left: unknown, right: unknown): boolean {
60
+ return JSON.stringify(left) === JSON.stringify(right);
61
+ }
62
+
63
+ function withoutKey<T extends Record<string, unknown>>(entity: T): T {
64
+ const { key: _key, ...rest } = entity; // eslint-disable-line @typescript-eslint/no-unused-vars
65
+
66
+ return rest as T;
67
+ }
68
+
69
+ function cloneWithoutKey<T extends Record<string, unknown>>(entity: T): T {
70
+ return JSON.parse(JSON.stringify(withoutKey(entity)));
71
+ }
72
+
73
+ function matchesPattern(key: string, patterns?: string[]) {
74
+ if (!patterns || patterns.length === 0) {
75
+ return false;
76
+ }
77
+
78
+ return patterns.some((pattern) => {
79
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
80
+ return new RegExp(`^${escaped}$`).test(key);
81
+ });
82
+ }
83
+
84
+ function assertKnownValues(label: string, requested: string[], available: string[]) {
85
+ for (const value of requested) {
86
+ if (!available.includes(value)) {
87
+ throw new Error(
88
+ `Unknown ${label} "${value}". Available ${label}s: ${available.join(", ") || "none"}.`,
89
+ );
90
+ }
91
+ }
92
+ }
93
+
94
+ function getEntityFilePath(
95
+ directoryPath: string,
96
+ key: string,
97
+ projectConfig: ProjectConfig,
98
+ suffix = "",
99
+ ) {
100
+ const parser = projectConfig.parser as CustomParser;
101
+
102
+ return path.relative(
103
+ process.cwd(),
104
+ path.join(directoryPath, ...key.split(projectConfig.namespaceCharacter)) +
105
+ `${suffix}.${parser.extension}`,
106
+ );
107
+ }
108
+
109
+ async function readAll<T>(
110
+ keys: string[],
111
+ read: (key: string) => Promise<T>,
112
+ ): Promise<Record<string, T>> {
113
+ const entries = await Promise.all(keys.map(async (key) => [key, await read(key)] as const));
114
+ return Object.fromEntries(entries);
115
+ }
116
+
117
+ function resolveLocaleChain(
118
+ localeKey: string,
119
+ locales: Record<string, Locale>,
120
+ field: "inheritFormatsFrom" | "inheritTranslationsFrom",
121
+ ) {
122
+ const chain: string[] = [];
123
+ const seen = new Set<string>();
124
+ let currentKey: string | undefined = localeKey;
125
+
126
+ while (currentKey && !seen.has(currentKey)) {
127
+ seen.add(currentKey);
128
+ chain.unshift(currentKey);
129
+ currentKey = locales[currentKey]?.[field];
130
+ }
131
+
132
+ return chain;
133
+ }
134
+
135
+ function resolveInheritedTranslationValue(
136
+ translations: Record<string, Translation> | undefined,
137
+ localeKey: string,
138
+ locales: Record<string, Locale>,
139
+ ) {
140
+ let currentKey = locales[localeKey]?.inheritTranslationsFrom;
141
+
142
+ while (currentKey) {
143
+ if (translations && typeof translations[currentKey] !== "undefined") {
144
+ return {
145
+ value: translations[currentKey],
146
+ inheritedFrom: currentKey,
147
+ };
148
+ }
149
+
150
+ currentKey = locales[currentKey]?.inheritTranslationsFrom;
151
+ }
152
+ }
153
+
154
+ function mergeFormats(parent?: FormatPresets, child?: FormatPresets): FormatPresets | undefined {
155
+ if (typeof parent === "undefined") {
156
+ return child;
157
+ }
158
+
159
+ if (typeof child === "undefined") {
160
+ return parent;
161
+ }
162
+
163
+ if (!isPlainObject(parent) || !isPlainObject(child)) {
164
+ return child;
165
+ }
166
+
167
+ const result: Record<string, unknown> = { ...parent };
168
+
169
+ for (const key of Object.keys(child)) {
170
+ result[key] = mergeFormats(
171
+ result[key] as FormatPresets | undefined,
172
+ child[key] as FormatPresets,
173
+ );
174
+ }
175
+
176
+ return result as FormatPresets;
177
+ }
178
+
179
+ function resolveInheritedFormats(
180
+ localeKey: string,
181
+ locales: Record<string, Locale>,
182
+ cache: Map<string, FormatPresets | undefined>,
183
+ ): FormatPresets | undefined {
184
+ const cacheKey = `inherited:${localeKey}`;
185
+
186
+ if (cache.has(cacheKey)) {
187
+ return cache.get(cacheKey);
188
+ }
189
+
190
+ const chain = resolveLocaleChain(localeKey, locales, "inheritFormatsFrom").slice(0, -1);
191
+ let formats: FormatPresets | undefined;
192
+
193
+ for (const key of chain) {
194
+ formats = mergeFormats(formats, locales[key]?.formats);
195
+ }
196
+
197
+ cache.set(cacheKey, formats);
198
+ return formats;
199
+ }
200
+
201
+ function resolveEffectiveFormatsForLocale(
202
+ localeKey: string,
203
+ locales: Record<string, Locale>,
204
+ cache: Map<string, FormatPresets | undefined>,
205
+ ): FormatPresets | undefined {
206
+ const cacheKey = `effective:${localeKey}`;
207
+
208
+ if (cache.has(cacheKey)) {
209
+ return cache.get(cacheKey);
210
+ }
211
+
212
+ const effective = mergeFormats(
213
+ resolveInheritedFormats(localeKey, locales, cache),
214
+ locales[localeKey]?.formats,
215
+ );
216
+
217
+ cache.set(cacheKey, effective);
218
+ return effective;
219
+ }
220
+
221
+ function getPathValue(value: unknown, pathSegments: string[]) {
222
+ let current = value;
223
+
224
+ for (const segment of pathSegments) {
225
+ if (!isPlainObject(current) || !(segment in current)) {
226
+ return undefined;
227
+ }
228
+
229
+ current = current[segment];
230
+ }
231
+
232
+ return current;
233
+ }
234
+
235
+ function findInheritedFormatSource(
236
+ localeKey: string,
237
+ pathSegments: string[],
238
+ value: unknown,
239
+ locales: Record<string, Locale>,
240
+ cache: Map<string, FormatPresets | undefined>,
241
+ ) {
242
+ let currentKey = locales[localeKey]?.inheritFormatsFrom;
243
+
244
+ while (currentKey) {
245
+ const effectiveFormats = resolveEffectiveFormatsForLocale(currentKey, locales, cache);
246
+
247
+ if (deepEqual(getPathValue(effectiveFormats, pathSegments), value)) {
248
+ return currentKey;
249
+ }
250
+
251
+ currentKey = locales[currentKey]?.inheritFormatsFrom;
252
+ }
253
+ }
254
+
255
+ async function getSelectedLocales(
256
+ datasource: Datasource,
257
+ options: Pick<PruneProjectOptions, "locale" | "target">,
258
+ ) {
259
+ const requestedLocales = toArray(options.locale);
260
+ const requestedTargets = toArray(options.target);
261
+
262
+ const [localeKeys, targetKeys] = await Promise.all([
263
+ datasource.listLocales(),
264
+ datasource.listTargets(),
265
+ ]);
266
+
267
+ assertKnownValues("locale", requestedLocales, localeKeys);
268
+ assertKnownValues("target", requestedTargets, targetKeys);
269
+
270
+ if (requestedTargets.length === 0) {
271
+ return requestedLocales.length > 0 ? requestedLocales.sort() : localeKeys;
272
+ }
273
+
274
+ const targets = await readAll<Target>(requestedTargets, (key) => datasource.readTarget(key));
275
+ const selected = new Set<string>();
276
+
277
+ for (const targetKey of requestedTargets) {
278
+ const targetLocales = targets[targetKey].locales?.length
279
+ ? targets[targetKey].locales || []
280
+ : localeKeys;
281
+
282
+ for (const locale of targetLocales) {
283
+ if (requestedLocales.length === 0 || requestedLocales.includes(locale)) {
284
+ selected.add(locale);
285
+ }
286
+ }
287
+ }
288
+
289
+ return Array.from(selected).sort();
290
+ }
291
+
292
+ async function getSelectedMessageKeys(
293
+ datasource: Datasource,
294
+ options: Pick<PruneProjectOptions, "target" | "includeMessages" | "excludeMessages">,
295
+ ) {
296
+ const requestedTargets = toArray(options.target);
297
+ const includeMessages = toArray(options.includeMessages);
298
+ const excludeMessages = toArray(options.excludeMessages);
299
+
300
+ const messageKeys = await datasource.listMessages();
301
+
302
+ if (requestedTargets.length === 0) {
303
+ return messageKeys
304
+ .filter((messageKey) =>
305
+ includeMessages.length > 0 ? matchesPattern(messageKey, includeMessages) : true,
306
+ )
307
+ .filter((messageKey) => !matchesPattern(messageKey, excludeMessages))
308
+ .sort();
309
+ }
310
+
311
+ const targets = await readAll<Target>(requestedTargets, (key) => datasource.readTarget(key));
312
+ const selected = new Set<string>();
313
+
314
+ for (const targetKey of requestedTargets) {
315
+ const includePatterns = targets[targetKey].includeMessages?.length
316
+ ? targets[targetKey].includeMessages
317
+ : ["*"];
318
+ const excludePatterns = targets[targetKey].excludeMessages || [];
319
+
320
+ for (const messageKey of messageKeys) {
321
+ if (
322
+ matchesPattern(messageKey, includePatterns) &&
323
+ !matchesPattern(messageKey, excludePatterns)
324
+ ) {
325
+ selected.add(messageKey);
326
+ }
327
+ }
328
+ }
329
+
330
+ return Array.from(selected)
331
+ .filter((messageKey) =>
332
+ includeMessages.length > 0 ? matchesPattern(messageKey, includeMessages) : true,
333
+ )
334
+ .filter((messageKey) => !matchesPattern(messageKey, excludeMessages))
335
+ .sort();
336
+ }
337
+
338
+ async function pruneTranslations(
339
+ projectConfig: ProjectConfig,
340
+ datasource: Datasource,
341
+ options: PruneProjectOptions,
342
+ ): Promise<PruneProjectResult> {
343
+ const [selectedLocales, messageKeys, localeKeys] = await Promise.all([
344
+ getSelectedLocales(datasource, options),
345
+ getSelectedMessageKeys(datasource, options),
346
+ datasource.listLocales(),
347
+ ]);
348
+ const locales = await readAll<Locale>(localeKeys, (key) => datasource.readLocale(key));
349
+ const entries: PruneEntry[] = [];
350
+ const changedFiles: string[] = [];
351
+
352
+ for (const messageKey of messageKeys) {
353
+ const message = await datasource.readMessage(messageKey);
354
+ let updatedMessage: Message | undefined;
355
+ let changed = false;
356
+ const filePath = getEntityFilePath(
357
+ projectConfig.messagesDirectoryPath,
358
+ messageKey,
359
+ projectConfig,
360
+ );
361
+
362
+ for (const locale of Object.keys(message.translations || {}).sort()) {
363
+ if (!selectedLocales.includes(locale)) {
364
+ continue;
365
+ }
366
+
367
+ const explicitValue = message.translations[locale];
368
+ const inherited = resolveInheritedTranslationValue(message.translations, locale, locales);
369
+
370
+ if (typeof inherited?.value !== "undefined" && inherited.value === explicitValue) {
371
+ entries.push({
372
+ kind: "message",
373
+ key: messageKey,
374
+ filePath,
375
+ locale,
376
+ inheritedFrom: inherited.inheritedFrom,
377
+ });
378
+
379
+ if (options.apply) {
380
+ updatedMessage =
381
+ updatedMessage ||
382
+ (cloneWithoutKey(message as unknown as Record<string, unknown>) as unknown as Message);
383
+ delete updatedMessage.translations[locale];
384
+ changed = true;
385
+ }
386
+ }
387
+ }
388
+
389
+ for (let index = 0; index < (message.overrides || []).length; index++) {
390
+ const override = (message.overrides || [])[index] as Override;
391
+
392
+ for (const locale of Object.keys(override.translations || {}).sort()) {
393
+ if (!selectedLocales.includes(locale)) {
394
+ continue;
395
+ }
396
+
397
+ const explicitValue = override.translations[locale];
398
+ const inherited = resolveInheritedTranslationValue(override.translations, locale, locales);
399
+
400
+ if (typeof inherited?.value !== "undefined" && inherited.value === explicitValue) {
401
+ entries.push({
402
+ kind: "override",
403
+ key: messageKey,
404
+ overrideKey: override.key,
405
+ filePath,
406
+ locale,
407
+ inheritedFrom: inherited.inheritedFrom,
408
+ });
409
+
410
+ if (options.apply) {
411
+ updatedMessage =
412
+ updatedMessage ||
413
+ (cloneWithoutKey(
414
+ message as unknown as Record<string, unknown>,
415
+ ) as unknown as Message);
416
+ delete (updatedMessage.overrides || [])[index].translations[locale];
417
+ changed = true;
418
+ }
419
+ }
420
+ }
421
+ }
422
+
423
+ if (options.apply && changed && updatedMessage) {
424
+ await datasource.writeMessage(messageKey, updatedMessage);
425
+ changedFiles.push(filePath);
426
+ }
427
+ }
428
+
429
+ return {
430
+ pruneMode: "translations",
431
+ apply: options.apply === true,
432
+ entries,
433
+ changedFiles,
434
+ };
435
+ }
436
+
437
+ function pruneFormatDuplicates(
438
+ currentValue: Record<string, unknown>,
439
+ inheritedValue: Record<string, unknown> | undefined,
440
+ pathSegments: string[],
441
+ localeKey: string,
442
+ filePath: string,
443
+ locales: Record<string, Locale>,
444
+ cache: Map<string, FormatPresets | undefined>,
445
+ entries: PruneEntry[],
446
+ ) {
447
+ let changed = false;
448
+
449
+ for (const key of Object.keys(currentValue)) {
450
+ const nextPath = [...pathSegments, key];
451
+ const localChild = currentValue[key];
452
+ const inheritedChild = isPlainObject(inheritedValue) ? inheritedValue[key] : undefined;
453
+
454
+ if (isPlainObject(localChild) && isPlainObject(inheritedChild)) {
455
+ changed =
456
+ pruneFormatDuplicates(
457
+ localChild,
458
+ inheritedChild,
459
+ nextPath,
460
+ localeKey,
461
+ filePath,
462
+ locales,
463
+ cache,
464
+ entries,
465
+ ) || changed;
466
+
467
+ if (Object.keys(localChild).length === 0) {
468
+ delete currentValue[key];
469
+ changed = true;
470
+ }
471
+
472
+ continue;
473
+ }
474
+
475
+ if (deepEqual(localChild, inheritedChild)) {
476
+ const inheritedFrom =
477
+ findInheritedFormatSource(localeKey, nextPath, localChild, locales, cache) ||
478
+ locales[localeKey]?.inheritFormatsFrom ||
479
+ "unknown";
480
+
481
+ entries.push({
482
+ kind: "locale",
483
+ key: localeKey,
484
+ filePath,
485
+ formatPath: nextPath.join("."),
486
+ inheritedFrom,
487
+ });
488
+ delete currentValue[key];
489
+ changed = true;
490
+ }
491
+ }
492
+
493
+ return changed;
494
+ }
495
+
496
+ async function pruneFormats(
497
+ projectConfig: ProjectConfig,
498
+ datasource: Datasource,
499
+ options: PruneProjectOptions,
500
+ ): Promise<PruneProjectResult> {
501
+ const selectedLocales = await getSelectedLocales(datasource, options);
502
+ const localeKeys = await datasource.listLocales();
503
+ const locales = await readAll<Locale>(localeKeys, (key) => datasource.readLocale(key));
504
+ const cache = new Map<string, FormatPresets | undefined>();
505
+ const entries: PruneEntry[] = [];
506
+ const changedFiles: string[] = [];
507
+
508
+ for (const localeKey of selectedLocales) {
509
+ const locale = locales[localeKey];
510
+
511
+ if (!locale?.formats || !isPlainObject(locale.formats)) {
512
+ continue;
513
+ }
514
+
515
+ const inheritedFormats = resolveInheritedFormats(localeKey, locales, cache);
516
+
517
+ if (!isPlainObject(inheritedFormats)) {
518
+ continue;
519
+ }
520
+
521
+ const filePath = getEntityFilePath(
522
+ projectConfig.localesDirectoryPath,
523
+ localeKey,
524
+ projectConfig,
525
+ );
526
+ const updatedLocale = cloneWithoutKey(locale as Record<string, unknown>) as Locale;
527
+ const localeEntriesStart = entries.length;
528
+ const changed = pruneFormatDuplicates(
529
+ updatedLocale.formats as Record<string, unknown>,
530
+ inheritedFormats as Record<string, unknown>,
531
+ [],
532
+ localeKey,
533
+ filePath,
534
+ locales,
535
+ cache,
536
+ entries,
537
+ );
538
+
539
+ if (!changed) {
540
+ entries.splice(localeEntriesStart);
541
+ continue;
542
+ }
543
+
544
+ if (isPlainObject(updatedLocale.formats) && Object.keys(updatedLocale.formats).length === 0) {
545
+ delete updatedLocale.formats;
546
+ }
547
+
548
+ if (options.apply) {
549
+ await datasource.writeLocale(localeKey, updatedLocale);
550
+ changedFiles.push(filePath);
551
+ }
552
+ }
553
+
554
+ return {
555
+ pruneMode: "formats",
556
+ apply: options.apply === true,
557
+ entries,
558
+ changedFiles,
559
+ };
560
+ }
561
+
562
+ export async function pruneProject(
563
+ projectConfig: ProjectConfig,
564
+ datasource: Datasource,
565
+ options: PruneProjectOptions,
566
+ ) {
567
+ if (options.pruneMode === "translations") {
568
+ return pruneTranslations(projectConfig, datasource, options);
569
+ }
570
+
571
+ return pruneFormats(projectConfig, datasource, options);
572
+ }
573
+
574
+ function printPruneResult(result: PruneProjectResult, set?: string) {
575
+ console.log("");
576
+ if (set) {
577
+ console.log(`Set "${set}":`);
578
+ }
579
+ console.log(CLI_FORMAT_BOLD, "Prune Messagevisor project");
580
+ console.log(` Target: ${result.pruneMode}`);
581
+ console.log(` Mode: ${result.apply ? "apply" : "preview"}`);
582
+ console.log(` Entries: ${result.entries.length}`);
583
+ console.log(` Files: ${new Set(result.entries.map((entry) => entry.filePath)).size}`);
584
+ if (result.apply) {
585
+ console.log(` Updated: ${result.changedFiles.length}`);
586
+ }
587
+ console.log("");
588
+
589
+ if (result.entries.length === 0) {
590
+ console.log(CLI_FORMAT_GREEN, `No prune-able ${result.pruneMode} found.`);
591
+ return;
592
+ }
593
+
594
+ const grouped = new Map<string, PruneEntry[]>();
595
+
596
+ for (const entry of result.entries) {
597
+ const label =
598
+ entry.kind === "override" ? `${entry.key} (override ${entry.overrideKey})` : entry.key;
599
+ const groupKey = `${entry.filePath}:::${label}`;
600
+ const existing = grouped.get(groupKey) || [];
601
+ existing.push(entry);
602
+ grouped.set(groupKey, existing);
603
+ }
604
+
605
+ for (const [groupKey, entries] of Array.from(grouped.entries())) {
606
+ const [filePath, label] = groupKey.split(":::");
607
+ console.log(CLI_FORMAT_BOLD, `${label}`);
608
+ console.log(` ${filePath}`);
609
+
610
+ for (const entry of entries) {
611
+ if (result.pruneMode === "translations") {
612
+ console.log(
613
+ ` - locale ${entry.locale} duplicates inherited value from ${entry.inheritedFrom}`,
614
+ );
615
+ } else {
616
+ console.log(
617
+ ` - formats.${entry.formatPath} duplicates inherited value from ${entry.inheritedFrom}`,
618
+ );
619
+ }
620
+ }
621
+
622
+ console.log("");
623
+ }
624
+
625
+ console.log(
626
+ CLI_FORMAT_GREEN,
627
+ result.apply
628
+ ? `Prune applied: ${result.entries.length} entries across ${result.changedFiles.length} files`
629
+ : `Prune preview complete: ${result.entries.length} entries across ${
630
+ new Set(result.entries.map((entry) => entry.filePath)).size
631
+ } files`,
632
+ );
633
+ }
634
+
635
+ function getTarget(parsed: Record<string, unknown>): PruneTarget {
636
+ const selected = ["translations", "formats"].filter((key) =>
637
+ Boolean(parsed[key]),
638
+ ) as PruneTarget[];
639
+
640
+ if (selected.length === 0) {
641
+ throw new Error("Pass exactly one of --translations or --formats.");
642
+ }
643
+
644
+ if (selected.length > 1) {
645
+ throw new Error("Pass exactly one of --translations or --formats.");
646
+ }
647
+
648
+ return selected[0];
649
+ }
650
+
651
+ export const prunePlugin = {
652
+ command: "prune",
653
+ handler: async ({ projectConfig, datasource, parsed }: any) => {
654
+ try {
655
+ const pruneMode = getTarget(parsed);
656
+ const executions = await getProjectSetExecutions(projectConfig, datasource, parsed.set);
657
+ const options = {
658
+ pruneMode,
659
+ locale: parsed.locale,
660
+ target: parsed.target,
661
+ includeMessages: parsed.includeMessages,
662
+ excludeMessages: parsed.excludeMessages,
663
+ apply: parsed.apply === true || parsed.apply === "true",
664
+ };
665
+
666
+ for (const execution of executions) {
667
+ const result = await pruneProject(execution.projectConfig, execution.datasource, options);
668
+ printPruneResult(result, projectConfig.sets ? execution.set : undefined);
669
+ }
670
+ } catch (error) {
671
+ if (error instanceof Error) {
672
+ console.error(error.message);
673
+ return false;
674
+ }
675
+
676
+ throw error;
677
+ }
678
+ },
679
+ examples: [
680
+ {
681
+ command: "prune --translations",
682
+ description: "preview prune-able inherited message translations",
683
+ },
684
+ {
685
+ command: "prune --translations --target=web --apply",
686
+ description: "apply pruning for inherited translations affecting a target",
687
+ },
688
+ {
689
+ command: "prune --formats --locale=en-US",
690
+ description: "preview prune-able inherited locale formats",
691
+ },
692
+ ],
693
+ };