@messagevisor/catalog 0.5.0 → 0.7.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/src/node/index.ts CHANGED
@@ -18,6 +18,123 @@ import type {
18
18
 
19
19
  import { attachFormatExamplePreviews } from "./formatExamplePreview";
20
20
 
21
+ const CLI_FORMAT_GREEN = "\x1b[32m%s\x1b[0m";
22
+ const CLI_FORMAT_DIM = "\x1b[2m%s\x1b[0m";
23
+ const CLI_FORMAT_BOLD = "\x1b[1m%s\x1b[0m";
24
+
25
+ function colorize(value: string, colorCode: number) {
26
+ return `\x1b[${colorCode}m${value}\x1b[0m`;
27
+ }
28
+
29
+ function prettyDuration(diffInMs: number) {
30
+ let diff = Math.abs(diffInMs);
31
+
32
+ if (diff === 0) {
33
+ return "0ms";
34
+ }
35
+
36
+ const ms = diff % 1000;
37
+ diff = (diff - ms) / 1000;
38
+ const secs = diff % 60;
39
+ diff = (diff - secs) / 60;
40
+ const mins = diff % 60;
41
+ const hrs = (diff - mins) / 60;
42
+
43
+ let result = "";
44
+
45
+ if (hrs) {
46
+ result += ` ${hrs}h`;
47
+ }
48
+
49
+ if (mins) {
50
+ result += ` ${mins}m`;
51
+ }
52
+
53
+ if (secs) {
54
+ result += ` ${secs}s`;
55
+ }
56
+
57
+ if (ms) {
58
+ result += ` ${ms}ms`;
59
+ }
60
+
61
+ return result.trim();
62
+ }
63
+
64
+ function pluralize(count: number, singular: string, plural = `${singular}s`) {
65
+ return `${count} ${count === 1 ? singular : plural}`;
66
+ }
67
+
68
+ function formatCatalogPath(rootDirectoryPath: string, filePath: string) {
69
+ const relativePath = path.relative(rootDirectoryPath, filePath);
70
+
71
+ if (relativePath && !relativePath.startsWith("..") && !path.isAbsolute(relativePath)) {
72
+ return relativePath;
73
+ }
74
+
75
+ return filePath;
76
+ }
77
+
78
+ class CatalogProgressReporter {
79
+ private readonly startedAt = Date.now();
80
+
81
+ constructor(
82
+ private readonly rootDirectoryPath: string,
83
+ private readonly outputDirectoryPath: string,
84
+ ) {}
85
+
86
+ start(options: { browserRouter: boolean; sets: boolean; features: string[] }) {
87
+ console.log("");
88
+ console.log(CLI_FORMAT_BOLD, "Generating Messagevisor catalog");
89
+ console.log(
90
+ ` ${colorize("Output", 36)}: ${formatCatalogPath(
91
+ this.rootDirectoryPath,
92
+ this.outputDirectoryPath,
93
+ )}`,
94
+ );
95
+ console.log(` ${colorize("Router", 36)}: ${options.browserRouter ? "browser" : "hash"}`);
96
+ console.log(` ${colorize("Sets", 36)}: ${options.sets ? "enabled" : "none"}`);
97
+ console.log(` ${colorize("Features", 36)}: ${options.features.join(", ") || "none"}`);
98
+ console.log("");
99
+ }
100
+
101
+ step(label: string, detail?: string) {
102
+ const suffix = detail ? `: ${colorize(detail, 2)}` : "";
103
+ console.log(` ${colorize("•", 36)} ${label}${suffix}`);
104
+ return Date.now();
105
+ }
106
+
107
+ substep(label: string, detail?: string) {
108
+ const suffix = detail ? `: ${colorize(detail, 2)}` : "";
109
+ console.log(` ${colorize("•", 36)} ${label}${suffix}`);
110
+ return Date.now();
111
+ }
112
+
113
+ done(startedAt: number, detail?: string) {
114
+ const suffix = detail ? ` ${detail}` : "";
115
+ console.log(CLI_FORMAT_DIM, ` done in ${prettyDuration(Date.now() - startedAt)}${suffix}`);
116
+ }
117
+
118
+ setStart(set: string | undefined) {
119
+ console.log("");
120
+ if (set) {
121
+ console.log(CLI_FORMAT_BOLD, `Set "${set}"`);
122
+ } else {
123
+ console.log(CLI_FORMAT_BOLD, "Root catalog");
124
+ }
125
+ return Date.now();
126
+ }
127
+
128
+ complete() {
129
+ console.log("");
130
+ console.log(
131
+ CLI_FORMAT_GREEN,
132
+ `Catalog exported to ${formatCatalogPath(this.rootDirectoryPath, this.outputDirectoryPath)}`,
133
+ );
134
+ console.log(CLI_FORMAT_BOLD, `Time: ${prettyDuration(Date.now() - this.startedAt)}`);
135
+ }
136
+ }
137
+
21
138
  export interface CatalogPluginParsedOptions {
22
139
  _: string[];
23
140
  [key: string]: any;
@@ -223,6 +340,7 @@ export interface CatalogExportOptions {
223
340
  dev?: boolean;
224
341
  devEditors?: CatalogDevEditor[];
225
342
  withTranslationSearch?: boolean;
343
+ withDuplicates?: boolean;
226
344
  }
227
345
 
228
346
  export interface CatalogServeOptions {
@@ -240,6 +358,7 @@ export interface CatalogServerHandle {
240
358
  interface CatalogBuildContext {
241
359
  rootDirectoryPath: string;
242
360
  repositoryRootDirectoryPath: string;
361
+ repositorySourceRootDirectoryPath: string;
243
362
  outputDirectoryPath: string;
244
363
  dataDirectoryPath: string;
245
364
  historyIndex: CatalogHistoryIndex;
@@ -247,6 +366,9 @@ interface CatalogBuildContext {
247
366
  devEditors: CatalogDevEditor[];
248
367
  duplicateResultsBySet: Record<string, CatalogDuplicateTranslationsSetResult>;
249
368
  withTranslationSearch: boolean;
369
+ withDuplicates: boolean;
370
+ progress: CatalogProgressReporter;
371
+ writer: CatalogJsonWriter;
250
372
  }
251
373
 
252
374
  interface SourceFileInfo {
@@ -609,9 +731,41 @@ function toLocaleDuplicatesFile(
609
731
  };
610
732
  }
611
733
 
612
- async function writeJson(filePath: string, content: unknown) {
613
- await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
614
- await fs.promises.writeFile(filePath, JSON.stringify(content, null, 2));
734
+ class CatalogJsonWriter {
735
+ private readonly directories = new Map<string, Promise<void>>();
736
+
737
+ private ensureDirectory(directoryPath: string) {
738
+ let promise = this.directories.get(directoryPath);
739
+
740
+ if (!promise) {
741
+ promise = fs.promises.mkdir(directoryPath, { recursive: true }).then(() => undefined);
742
+ this.directories.set(directoryPath, promise);
743
+ }
744
+
745
+ return promise;
746
+ }
747
+
748
+ async write(filePath: string, content: unknown) {
749
+ await this.ensureDirectory(path.dirname(filePath));
750
+ await fs.promises.writeFile(filePath, JSON.stringify(content, null, 2));
751
+ }
752
+ }
753
+
754
+ async function mapWithConcurrency<T>(
755
+ items: T[],
756
+ concurrency: number,
757
+ callback: (item: T, index: number) => Promise<void>,
758
+ ) {
759
+ let nextIndex = 0;
760
+ const workerCount = Math.min(concurrency, items.length);
761
+ const workers = Array.from({ length: workerCount }, async () => {
762
+ while (nextIndex < items.length) {
763
+ const index = nextIndex++;
764
+ await callback(items[index], index);
765
+ }
766
+ });
767
+
768
+ await Promise.all(workers);
615
769
  }
616
770
 
617
771
  function getEntityDirectoryPaths(config: any): Record<CatalogEntityType | "test", string> {
@@ -756,6 +910,18 @@ function addHistoryIndexEntry(
756
910
  target[key].push(entry);
757
911
  }
758
912
 
913
+ function toEntityHistoryEntry(
914
+ entry: CatalogHistoryEntry,
915
+ entity: CatalogHistoryEntity,
916
+ ): CatalogHistoryEntry {
917
+ return {
918
+ commit: entry.commit,
919
+ author: entry.author,
920
+ timestamp: entry.timestamp,
921
+ entities: [entity],
922
+ };
923
+ }
924
+
759
925
  function buildCatalogHistoryIndex(entries: CatalogHistoryEntry[]): CatalogHistoryIndex {
760
926
  const index = createEmptyHistoryIndex();
761
927
  index.entries = entries;
@@ -769,7 +935,7 @@ function buildCatalogHistoryIndex(entries: CatalogHistoryEntry[]): CatalogHistor
769
935
  }
770
936
 
771
937
  const entityKey = getHistoryEntityKey(entity.type, entity.key, entity.set);
772
- addHistoryIndexEntry(index.byEntity, entityKey, entry);
938
+ addHistoryIndexEntry(index.byEntity, entityKey, toEntityHistoryEntry(entry, entity));
773
939
 
774
940
  if (!index.lastModifiedByEntity[entityKey]) {
775
941
  index.lastModifiedByEntity[entityKey] = toLastModified(entry);
@@ -1015,6 +1181,25 @@ function getRepositoryRootDirectoryPath(rootDirectoryPath: string) {
1015
1181
  }
1016
1182
  }
1017
1183
 
1184
+ function getRepositorySourceRootDirectoryPath(rootDirectoryPath: string) {
1185
+ try {
1186
+ const gitRootDirectoryPath =
1187
+ runGit(rootDirectoryPath, ["rev-parse", "--show-toplevel"]).trim() || rootDirectoryPath;
1188
+ const realRootDirectoryPath = getRealPath(rootDirectoryPath);
1189
+
1190
+ if (realRootDirectoryPath !== rootDirectoryPath) {
1191
+ return path.resolve(
1192
+ rootDirectoryPath,
1193
+ path.relative(realRootDirectoryPath, gitRootDirectoryPath),
1194
+ );
1195
+ }
1196
+
1197
+ return gitRootDirectoryPath;
1198
+ } catch (_error) {
1199
+ return rootDirectoryPath;
1200
+ }
1201
+ }
1202
+
1018
1203
  function getOwnerAndRepoFromGitRemote(origin: string, host: string) {
1019
1204
  const escapedHost = host.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
1020
1205
  const match = origin.match(new RegExp(`${escapedHost}[:/]([^/]+)/(.+?)(?:\\.git)?$`));
@@ -1090,17 +1275,28 @@ function chunkHistory(history: CatalogHistoryEntry[], pageSize = CATALOG_HISTORY
1090
1275
  return pages.length > 0 ? pages : [[]];
1091
1276
  }
1092
1277
 
1093
- async function writeHistoryPages(directoryPath: string, history: CatalogHistoryEntry[]) {
1278
+ async function writeHistoryPages(
1279
+ writer: CatalogJsonWriter,
1280
+ directoryPath: string,
1281
+ history: CatalogHistoryEntry[],
1282
+ options: { skipEmpty?: boolean } = {},
1283
+ ) {
1284
+ if (options.skipEmpty && history.length === 0) {
1285
+ return 1;
1286
+ }
1287
+
1094
1288
  const pages = chunkHistory(history);
1095
1289
 
1096
1290
  for (let index = 0; index < pages.length; index++) {
1097
- await writeJson(path.join(directoryPath, `page-${index + 1}.json`), {
1291
+ await writer.write(path.join(directoryPath, `page-${index + 1}.json`), {
1098
1292
  page: index + 1,
1099
1293
  pageSize: CATALOG_HISTORY_PAGE_SIZE,
1100
1294
  totalPages: pages.length,
1101
1295
  entries: pages[index],
1102
1296
  });
1103
1297
  }
1298
+
1299
+ return 0;
1104
1300
  }
1105
1301
 
1106
1302
  function getHistoryForEntity(
@@ -1113,11 +1309,12 @@ function getHistoryForEntity(
1113
1309
  }
1114
1310
 
1115
1311
  function getSourceFileInfo(
1116
- repositoryRootDirectoryPath: string,
1312
+ repositorySourceRootDirectoryPath: string,
1117
1313
  rootDirectoryPath: string,
1118
1314
  projectConfig: any,
1119
1315
  type: CatalogEntityType,
1120
1316
  key: string,
1317
+ options: { resolveAbsolutePath?: boolean } = {},
1121
1318
  ): SourceFileInfo {
1122
1319
  const directoryByType: Record<CatalogEntityType, string> = {
1123
1320
  locale: projectConfig.localesDirectoryPath,
@@ -1134,10 +1331,10 @@ function getSourceFileInfo(
1134
1331
  ...key.split(projectConfig.namespaceCharacter),
1135
1332
  ) + extension,
1136
1333
  );
1137
- const absolutePath = getRealPath(filePath);
1334
+ const absolutePath = options.resolveAbsolutePath ? getRealPath(filePath) : filePath;
1138
1335
 
1139
1336
  return {
1140
- sourcePath: toPosixPath(path.relative(repositoryRootDirectoryPath, absolutePath)),
1337
+ sourcePath: toPosixPath(path.relative(repositorySourceRootDirectoryPath, filePath)),
1141
1338
  absolutePath,
1142
1339
  };
1143
1340
  }
@@ -1168,13 +1365,15 @@ async function buildSetCatalog(
1168
1365
  outputRelativeDirectory: string,
1169
1366
  ) {
1170
1367
  const outputDirectoryPath = path.join(context.dataDirectoryPath, outputRelativeDirectory);
1171
- const [localeKeys, messageKeys, attributeKeys, segmentKeys, targetKeys] = await Promise.all([
1368
+ const setStartedAt = context.progress.setStart(set);
1369
+ const entitiesStartedAt = context.progress.step("Processing entities");
1370
+ const [localeKeys, messageKeys, attributeKeys, segmentKeys, targetKeys] = (await Promise.all([
1172
1371
  datasource.listLocales(),
1173
1372
  datasource.listMessages(),
1174
1373
  datasource.listAttributes(),
1175
1374
  datasource.listSegments(),
1176
1375
  datasource.listTargets(),
1177
- ]);
1376
+ ])) as [string[], string[], string[], string[], string[]];
1178
1377
  const [locales, messages, attributes, segments, targets] = await Promise.all([
1179
1378
  readAll<Locale>(localeKeys, (key) => datasource.readLocale(key)),
1180
1379
  readAll<Message>(messageKeys, (key) => datasource.readMessage(key)),
@@ -1182,6 +1381,18 @@ async function buildSetCatalog(
1182
1381
  readAll<Segment>(segmentKeys, (key) => datasource.readSegment(key)),
1183
1382
  readAll<Target>(targetKeys, (key) => datasource.readTarget(key)),
1184
1383
  ]);
1384
+ context.progress.done(
1385
+ entitiesStartedAt,
1386
+ `(${[
1387
+ pluralize(localeKeys.length, "locale"),
1388
+ pluralize(messageKeys.length, "message"),
1389
+ pluralize(attributeKeys.length, "attribute"),
1390
+ pluralize(segmentKeys.length, "segment"),
1391
+ pluralize(targetKeys.length, "target"),
1392
+ ].join(", ")})`,
1393
+ );
1394
+
1395
+ const relationshipsStartedAt = context.progress.step("Mapping relationships");
1185
1396
  const messageTargets: Record<string, string[]> = {};
1186
1397
  const targetMessages: Record<string, string[]> = {};
1187
1398
  const localeTargets: Record<string, Set<string>> = {};
@@ -1282,6 +1493,7 @@ async function buildSetCatalog(
1282
1493
  }
1283
1494
  }
1284
1495
  }
1496
+ context.progress.done(relationshipsStartedAt);
1285
1497
 
1286
1498
  const history = set ? context.historyIndex.bySet[set] || [] : context.historyIndex.entries;
1287
1499
  const localeDirections = getLocaleDirections(locales);
@@ -1310,8 +1522,11 @@ async function buildSetCatalog(
1310
1522
  },
1311
1523
  };
1312
1524
 
1313
- await writeHistoryPages(path.join(outputDirectoryPath, "history"), history);
1525
+ const historyStartedAt = context.progress.step("Writing history pages");
1526
+ await writeHistoryPages(context.writer, path.join(outputDirectoryPath, "history"), history);
1527
+ context.progress.done(historyStartedAt, `(${pluralize(history.length, "entry", "entries")})`);
1314
1528
 
1529
+ const examplesStartedAt = context.progress.step("Evaluating examples");
1315
1530
  const evaluatedMessageExamplesByKey = (
1316
1531
  await context.runtime.resolveExamples(projectConfig, datasource, {
1317
1532
  onlyMessages: true,
@@ -1345,15 +1560,31 @@ async function buildSetCatalog(
1345
1560
  });
1346
1561
  return accumulator;
1347
1562
  }, {});
1563
+ context.progress.done(
1564
+ examplesStartedAt,
1565
+ `(${pluralize(
1566
+ Object.values(evaluatedMessageExamplesByKey).reduce(
1567
+ (total, items) => total + items.length,
1568
+ 0,
1569
+ ),
1570
+ "message example",
1571
+ )}, ${pluralize(
1572
+ Object.values(evaluatedLocaleExamplesByKey).reduce((total, items) => total + items.length, 0),
1573
+ "locale example",
1574
+ )})`,
1575
+ );
1348
1576
 
1349
- for (const localeKey of localeKeys) {
1577
+ const localesStartedAt = context.progress.step("Writing locales");
1578
+ let skippedEmptyHistoryCount = 0;
1579
+ await mapWithConcurrency(localeKeys, 32, async (localeKey) => {
1350
1580
  const locale = locales[localeKey];
1351
1581
  const sourceFileInfo = getSourceFileInfo(
1352
- context.repositoryRootDirectoryPath,
1582
+ context.repositorySourceRootDirectoryPath,
1353
1583
  context.rootDirectoryPath,
1354
1584
  projectConfig,
1355
1585
  "locale",
1356
1586
  localeKey,
1587
+ { resolveAbsolutePath: context.devEditors.length > 0 },
1357
1588
  );
1358
1589
  const detail = {
1359
1590
  type: "locale",
@@ -1379,18 +1610,30 @@ async function buildSetCatalog(
1379
1610
  targets: sortStrings(Array.from(localeTargets[localeKey] || [])),
1380
1611
  }),
1381
1612
  );
1382
- await writeJson(
1613
+ await context.writer.write(
1383
1614
  path.join(outputDirectoryPath, "entities", "locale", `${encodeKey(localeKey)}.json`),
1384
1615
  detail,
1385
1616
  );
1386
- await writeJson(
1387
- path.join(outputDirectoryPath, "duplicates", "locales", `${encodeKey(localeKey)}.json`),
1388
- toLocaleDuplicatesFile(localeKey, duplicatesByLocale),
1389
- );
1390
- await writeHistoryPages(
1617
+ skippedEmptyHistoryCount += await writeHistoryPages(
1618
+ context.writer,
1391
1619
  path.join(outputDirectoryPath, "history", "locale", encodeKey(localeKey)),
1392
1620
  getHistoryForEntity(context.historyIndex, "locale", localeKey, set || undefined),
1621
+ { skipEmpty: true },
1393
1622
  );
1623
+ });
1624
+ context.progress.done(localesStartedAt, `(${pluralize(localeKeys.length, "locale")})`);
1625
+
1626
+ if (context.withDuplicates) {
1627
+ const duplicatesStartedAt = context.progress.step("Writing duplicate reports");
1628
+
1629
+ await mapWithConcurrency(localeKeys, 32, async (localeKey) => {
1630
+ await context.writer.write(
1631
+ path.join(outputDirectoryPath, "duplicates", "locales", `${encodeKey(localeKey)}.json`),
1632
+ toLocaleDuplicatesFile(localeKey, duplicatesByLocale),
1633
+ );
1634
+ });
1635
+
1636
+ context.progress.done(duplicatesStartedAt, `(${pluralize(localeKeys.length, "locale")})`);
1394
1637
  }
1395
1638
 
1396
1639
  // translationShards[3charPrefix][messageKey] = Set<lowercased value>
@@ -1411,7 +1654,10 @@ async function buildSetCatalog(
1411
1654
  }
1412
1655
  }
1413
1656
 
1414
- for (const messageKey of messageKeys) {
1657
+ const messagesStartedAt = context.progress.step("Writing messages");
1658
+ const messageDetailsStartedAt = context.progress.substep("Writing message details");
1659
+ let skippedEmptyMessageHistoryCount = 0;
1660
+ await mapWithConcurrency(messageKeys, 32, async (messageKey) => {
1415
1661
  const message = messages[messageKey];
1416
1662
  const overrides = (message.overrides || []).map((override: Override) => {
1417
1663
  const attributes = new Set<string>();
@@ -1426,11 +1672,12 @@ async function buildSetCatalog(
1426
1672
  };
1427
1673
  });
1428
1674
  const sourceFileInfo = getSourceFileInfo(
1429
- context.repositoryRootDirectoryPath,
1675
+ context.repositorySourceRootDirectoryPath,
1430
1676
  context.rootDirectoryPath,
1431
1677
  projectConfig,
1432
1678
  "message",
1433
1679
  messageKey,
1680
+ { resolveAbsolutePath: context.devEditors.length > 0 },
1434
1681
  );
1435
1682
  const detail = {
1436
1683
  type: "message",
@@ -1465,22 +1712,6 @@ async function buildSetCatalog(
1465
1712
  }
1466
1713
  const overrideLocalesList = sortStrings(Array.from(overrideLocalesSet));
1467
1714
 
1468
- if (context.withTranslationSearch) {
1469
- // Build translation shards (direct + inherited + override, all locales combined)
1470
- for (const localeKey of localeKeys) {
1471
- const row = resolveTranslationRow(message.translations, localeKey, locales);
1472
- if (row.source !== "missing" && row.value) {
1473
- addToTranslationShard(messageKey, row.value);
1474
- }
1475
- for (const override of overrides) {
1476
- const overrideRow = resolveTranslationRow(override.translations, localeKey, locales);
1477
- if (overrideRow.source !== "missing" && overrideRow.value) {
1478
- addToTranslationShard(messageKey, overrideRow.value);
1479
- }
1480
- }
1481
- }
1482
- }
1483
-
1484
1715
  index.entities.message.push(
1485
1716
  getEntitySummary(message, "message", messageKey, context.historyIndex, set || undefined, {
1486
1717
  targets: sortStrings(messageTargets[messageKey] || []),
@@ -1488,34 +1719,88 @@ async function buildSetCatalog(
1488
1719
  ...(overrideLocalesList.length > 0 ? { overrideLocales: overrideLocalesList } : {}),
1489
1720
  }),
1490
1721
  );
1491
- await writeJson(
1722
+ await context.writer.write(
1492
1723
  path.join(outputDirectoryPath, "entities", "message", `${encodeKey(messageKey)}.json`),
1493
1724
  detail,
1494
1725
  );
1495
- await writeHistoryPages(
1726
+ });
1727
+ context.progress.done(messageDetailsStartedAt, `(${pluralize(messageKeys.length, "message")})`);
1728
+
1729
+ const messageHistoryStartedAt = context.progress.substep("Writing message history pages");
1730
+ await mapWithConcurrency(messageKeys, 32, async (messageKey) => {
1731
+ const skippedHistory = await writeHistoryPages(
1732
+ context.writer,
1496
1733
  path.join(outputDirectoryPath, "history", "message", encodeKey(messageKey)),
1497
1734
  getHistoryForEntity(context.historyIndex, "message", messageKey, set || undefined),
1735
+ { skipEmpty: true },
1498
1736
  );
1499
- }
1737
+ skippedEmptyMessageHistoryCount += skippedHistory;
1738
+ skippedEmptyHistoryCount += skippedHistory;
1739
+ });
1740
+ context.progress.done(
1741
+ messageHistoryStartedAt,
1742
+ `(${pluralize(messageKeys.length, "message")}, ${pluralize(
1743
+ skippedEmptyMessageHistoryCount,
1744
+ "empty history",
1745
+ "empty histories",
1746
+ )} skipped)`,
1747
+ );
1748
+ context.progress.done(
1749
+ messagesStartedAt,
1750
+ `(${pluralize(messageKeys.length, "message")}, ${pluralize(
1751
+ skippedEmptyMessageHistoryCount,
1752
+ "empty history",
1753
+ "empty histories",
1754
+ )} skipped)`,
1755
+ );
1500
1756
 
1501
1757
  if (context.withTranslationSearch) {
1758
+ const translationSearchStartedAt = context.progress.step("Building translation search shards");
1759
+
1760
+ for (const messageKey of messageKeys) {
1761
+ const message = messages[messageKey];
1762
+
1763
+ for (const localeKey of localeKeys) {
1764
+ const row = resolveTranslationRow(message.translations, localeKey, locales);
1765
+ if (row.source !== "missing" && row.value) {
1766
+ addToTranslationShard(messageKey, row.value);
1767
+ }
1768
+
1769
+ for (const override of message.overrides || []) {
1770
+ const overrideRow = resolveTranslationRow(override.translations, localeKey, locales);
1771
+ if (overrideRow.source !== "missing" && overrideRow.value) {
1772
+ addToTranslationShard(messageKey, overrideRow.value);
1773
+ }
1774
+ }
1775
+ }
1776
+ }
1777
+
1502
1778
  for (const [prefix, messageMap] of Object.entries(translationShards)) {
1503
1779
  const shardData: Record<string, string[]> = {};
1504
1780
  for (const [msgKey, valueSet] of Object.entries(messageMap)) {
1505
1781
  shardData[msgKey] = Array.from(valueSet);
1506
1782
  }
1507
- await writeJson(path.join(outputDirectoryPath, "translations", `${prefix}.json`), shardData);
1783
+ await context.writer.write(
1784
+ path.join(outputDirectoryPath, "translations", `${prefix}.json`),
1785
+ shardData,
1786
+ );
1508
1787
  }
1788
+ context.progress.done(
1789
+ translationSearchStartedAt,
1790
+ `(${pluralize(Object.keys(translationShards).length, "shard")})`,
1791
+ );
1509
1792
  }
1510
1793
 
1511
- for (const attributeKey of attributeKeys) {
1794
+ const attributesStartedAt = context.progress.step("Writing attributes");
1795
+ await mapWithConcurrency(attributeKeys, 32, async (attributeKey) => {
1512
1796
  const attribute = attributes[attributeKey];
1513
1797
  const sourceFileInfo = getSourceFileInfo(
1514
- context.repositoryRootDirectoryPath,
1798
+ context.repositorySourceRootDirectoryPath,
1515
1799
  context.rootDirectoryPath,
1516
1800
  projectConfig,
1517
1801
  "attribute",
1518
1802
  attributeKey,
1803
+ { resolveAbsolutePath: context.devEditors.length > 0 },
1519
1804
  );
1520
1805
  const detail = {
1521
1806
  type: "attribute",
@@ -1547,26 +1832,31 @@ async function buildSetCatalog(
1547
1832
  },
1548
1833
  ),
1549
1834
  );
1550
- await writeJson(
1835
+ await context.writer.write(
1551
1836
  path.join(outputDirectoryPath, "entities", "attribute", `${encodeKey(attributeKey)}.json`),
1552
1837
  detail,
1553
1838
  );
1554
- await writeHistoryPages(
1839
+ skippedEmptyHistoryCount += await writeHistoryPages(
1840
+ context.writer,
1555
1841
  path.join(outputDirectoryPath, "history", "attribute", encodeKey(attributeKey)),
1556
1842
  getHistoryForEntity(context.historyIndex, "attribute", attributeKey, set || undefined),
1843
+ { skipEmpty: true },
1557
1844
  );
1558
- }
1845
+ });
1846
+ context.progress.done(attributesStartedAt, `(${pluralize(attributeKeys.length, "attribute")})`);
1559
1847
 
1560
- for (const segmentKey of segmentKeys) {
1848
+ const segmentsStartedAt = context.progress.step("Writing segments");
1849
+ await mapWithConcurrency(segmentKeys, 32, async (segmentKey) => {
1561
1850
  const segment = segments[segmentKey];
1562
1851
  const usedAttributes = new Set<string>();
1563
1852
  collectAttributeKeysFromConditions(segment.conditions, usedAttributes);
1564
1853
  const sourceFileInfo = getSourceFileInfo(
1565
- context.repositoryRootDirectoryPath,
1854
+ context.repositorySourceRootDirectoryPath,
1566
1855
  context.rootDirectoryPath,
1567
1856
  projectConfig,
1568
1857
  "segment",
1569
1858
  segmentKey,
1859
+ { resolveAbsolutePath: context.devEditors.length > 0 },
1570
1860
  );
1571
1861
  const detail = {
1572
1862
  type: "segment",
@@ -1586,17 +1876,21 @@ async function buildSetCatalog(
1586
1876
  targets: sortStrings(Array.from(segmentTargets[segmentKey] || [])),
1587
1877
  }),
1588
1878
  );
1589
- await writeJson(
1879
+ await context.writer.write(
1590
1880
  path.join(outputDirectoryPath, "entities", "segment", `${encodeKey(segmentKey)}.json`),
1591
1881
  detail,
1592
1882
  );
1593
- await writeHistoryPages(
1883
+ skippedEmptyHistoryCount += await writeHistoryPages(
1884
+ context.writer,
1594
1885
  path.join(outputDirectoryPath, "history", "segment", encodeKey(segmentKey)),
1595
1886
  getHistoryForEntity(context.historyIndex, "segment", segmentKey, set || undefined),
1887
+ { skipEmpty: true },
1596
1888
  );
1597
- }
1889
+ });
1890
+ context.progress.done(segmentsStartedAt, `(${pluralize(segmentKeys.length, "segment")})`);
1598
1891
 
1599
- for (const targetKey of targetKeys) {
1892
+ const targetsStartedAt = context.progress.step("Writing targets");
1893
+ await mapWithConcurrency(targetKeys, 32, async (targetKey) => {
1600
1894
  const target = targets[targetKey];
1601
1895
  const targetLocaleKeys = target.locales?.length ? target.locales : localeKeys;
1602
1896
  const formatsByLocale: Record<string, FormatPresets | undefined> = {};
@@ -1608,11 +1902,12 @@ async function buildSetCatalog(
1608
1902
  }
1609
1903
 
1610
1904
  const sourceFileInfo = getSourceFileInfo(
1611
- context.repositoryRootDirectoryPath,
1905
+ context.repositorySourceRootDirectoryPath,
1612
1906
  context.rootDirectoryPath,
1613
1907
  projectConfig,
1614
1908
  "target",
1615
1909
  targetKey,
1910
+ { resolveAbsolutePath: context.devEditors.length > 0 },
1616
1911
  );
1617
1912
  const detail = {
1618
1913
  type: "target",
@@ -1632,21 +1927,30 @@ async function buildSetCatalog(
1632
1927
  messageCount: targetMessages[targetKey].length,
1633
1928
  }),
1634
1929
  );
1635
- await writeJson(
1930
+ await context.writer.write(
1636
1931
  path.join(outputDirectoryPath, "entities", "target", `${encodeKey(targetKey)}.json`),
1637
1932
  detail,
1638
1933
  );
1639
- await writeHistoryPages(
1934
+ skippedEmptyHistoryCount += await writeHistoryPages(
1935
+ context.writer,
1640
1936
  path.join(outputDirectoryPath, "history", "target", encodeKey(targetKey)),
1641
1937
  getHistoryForEntity(context.historyIndex, "target", targetKey, set || undefined),
1938
+ { skipEmpty: true },
1642
1939
  );
1643
- }
1940
+ });
1941
+ context.progress.done(targetsStartedAt, `(${pluralize(targetKeys.length, "target")})`);
1644
1942
 
1943
+ const indexStartedAt = context.progress.step("Writing catalog index");
1645
1944
  for (const type of Object.keys(index.entities) as CatalogEntityType[]) {
1646
1945
  index.entities[type].sort((a, b) => a.key.localeCompare(b.key));
1647
1946
  }
1648
1947
 
1649
- await writeJson(path.join(outputDirectoryPath, "index.json"), index);
1948
+ await context.writer.write(path.join(outputDirectoryPath, "index.json"), index);
1949
+ context.progress.done(indexStartedAt);
1950
+ context.progress.done(
1951
+ setStartedAt,
1952
+ `total (${pluralize(skippedEmptyHistoryCount, "empty history", "empty histories")} skipped)`,
1953
+ );
1650
1954
 
1651
1955
  return index;
1652
1956
  }
@@ -1685,23 +1989,69 @@ export async function exportCatalog(
1685
1989
  : projectConfig.catalogDirectoryPath;
1686
1990
  const dataDirectoryPath = path.join(outputDirectoryPath, "data");
1687
1991
  const withTranslationSearch = options.withTranslationSearch === true;
1992
+ const withDuplicates = options.withDuplicates === true;
1993
+ const progress = new CatalogProgressReporter(rootDirectoryPath, outputDirectoryPath);
1994
+ const writer = new CatalogJsonWriter();
1995
+
1996
+ progress.start({
1997
+ browserRouter: options.browserRouter !== false,
1998
+ sets: projectConfig.sets === true,
1999
+ features: [
2000
+ ...(withTranslationSearch ? ["translation search"] : []),
2001
+ ...(withDuplicates ? ["duplicates"] : []),
2002
+ ],
2003
+ });
1688
2004
 
2005
+ let stepStartedAt = progress.step("Preparing output directory");
1689
2006
  await fs.promises.rm(outputDirectoryPath, { recursive: true, force: true });
1690
2007
  await fs.promises.mkdir(dataDirectoryPath, { recursive: true });
2008
+ progress.done(stepStartedAt);
1691
2009
 
1692
2010
  if (options.copyAssets !== false) {
2011
+ stepStartedAt = progress.step("Copying Catalog UI assets");
1693
2012
  await copyCatalogAssets(outputDirectoryPath);
2013
+ progress.done(stepStartedAt);
1694
2014
  }
1695
2015
 
1696
2016
  const devEditors = options.dev ? options.devEditors || detectDevEditors() : [];
2017
+ stepStartedAt = progress.step("Reading Git history");
1697
2018
  const historyIndex = await getGitHistoryIndex(rootDirectoryPath, projectConfig);
1698
- const duplicateTranslations = await runtime.findDuplicateTranslations(projectConfig, datasource);
1699
- const duplicateResultsBySet = Object.fromEntries(
1700
- duplicateTranslations.results.map((result) => [getDuplicateSetKey(result.set), result]),
1701
- );
2019
+ progress.done(stepStartedAt, `(${pluralize(historyIndex.entries.length, "commit")})`);
2020
+
2021
+ stepStartedAt = progress.step("Resolving repository links");
2022
+ const links = getRepoLinks(rootDirectoryPath);
2023
+ progress.done(stepStartedAt);
2024
+
2025
+ let duplicateResultsBySet: Record<string, CatalogDuplicateTranslationsSetResult> = {};
2026
+ if (withDuplicates) {
2027
+ stepStartedAt = progress.step("Scanning duplicate translations");
2028
+ duplicateResultsBySet = Object.fromEntries(
2029
+ (await runtime.findDuplicateTranslations(projectConfig, datasource)).results.map((result) => [
2030
+ getDuplicateSetKey(result.set),
2031
+ result,
2032
+ ]),
2033
+ );
2034
+ progress.done(
2035
+ stepStartedAt,
2036
+ `(${pluralize(
2037
+ Object.values(duplicateResultsBySet).reduce(
2038
+ (total, result) =>
2039
+ total +
2040
+ result.locales.reduce(
2041
+ (localeTotal, localeResult) => localeTotal + localeResult.duplicateValues.length,
2042
+ 0,
2043
+ ),
2044
+ 0,
2045
+ ),
2046
+ "duplicate value",
2047
+ )})`,
2048
+ );
2049
+ }
2050
+
1702
2051
  const context: CatalogBuildContext = {
1703
2052
  rootDirectoryPath,
1704
2053
  repositoryRootDirectoryPath: getRepositoryRootDirectoryPath(rootDirectoryPath),
2054
+ repositorySourceRootDirectoryPath: getRepositorySourceRootDirectoryPath(rootDirectoryPath),
1705
2055
  outputDirectoryPath,
1706
2056
  dataDirectoryPath,
1707
2057
  historyIndex,
@@ -1709,11 +2059,27 @@ export async function exportCatalog(
1709
2059
  devEditors,
1710
2060
  duplicateResultsBySet,
1711
2061
  withTranslationSearch,
2062
+ withDuplicates,
2063
+ progress,
2064
+ writer,
1712
2065
  };
2066
+ stepStartedAt = progress.step("Discovering project sets");
1713
2067
  const executions = await runtime.getProjectSetExecutions(projectConfig, datasource);
2068
+ progress.done(
2069
+ stepStartedAt,
2070
+ projectConfig.sets
2071
+ ? `(${executions.map((execution) => execution.set).join(", ") || "none"})`
2072
+ : "(root)",
2073
+ );
1714
2074
  const setIndexes: Record<string, CatalogSetIndex> = {};
1715
2075
 
1716
- await writeHistoryPages(path.join(dataDirectoryPath, "project", "history"), historyIndex.entries);
2076
+ stepStartedAt = progress.step("Writing project history");
2077
+ await writeHistoryPages(
2078
+ writer,
2079
+ path.join(dataDirectoryPath, "project", "history"),
2080
+ historyIndex.entries,
2081
+ );
2082
+ progress.done(stepStartedAt, `(${pluralize(historyIndex.entries.length, "entry", "entries")})`);
1717
2083
 
1718
2084
  for (const execution of executions) {
1719
2085
  const outputRelativeDirectory = projectConfig.sets ? path.join("sets", execution.set) : "root";
@@ -1726,6 +2092,7 @@ export async function exportCatalog(
1726
2092
  );
1727
2093
  }
1728
2094
 
2095
+ stepStartedAt = progress.step("Writing manifest");
1729
2096
  const manifest = {
1730
2097
  schemaVersion: CATALOG_SCHEMA_VERSION,
1731
2098
  generatedAt: new Date().toISOString(),
@@ -1735,8 +2102,9 @@ export async function exportCatalog(
1735
2102
  dev: options.dev ? { editors: devEditors } : undefined,
1736
2103
  features: {
1737
2104
  translationSearch: withTranslationSearch,
2105
+ duplicates: withDuplicates,
1738
2106
  },
1739
- links: getRepoLinks(rootDirectoryPath),
2107
+ links,
1740
2108
  paths: {
1741
2109
  projectHistory: "data/project/history/page-1.json",
1742
2110
  root: projectConfig.sets ? undefined : "data/root/index.json",
@@ -1752,9 +2120,10 @@ export async function exportCatalog(
1752
2120
  counts: Object.fromEntries(Object.keys(setIndexes).map((key) => [key, setIndexes[key].counts])),
1753
2121
  };
1754
2122
 
1755
- await writeJson(path.join(dataDirectoryPath, "manifest.json"), manifest);
2123
+ await writer.write(path.join(dataDirectoryPath, "manifest.json"), manifest);
2124
+ progress.done(stepStartedAt);
1756
2125
 
1757
- console.log(`Catalog exported to ${outputDirectoryPath}`);
2126
+ progress.complete();
1758
2127
 
1759
2128
  return {
1760
2129
  outputDirectoryPath,
@@ -2032,6 +2401,10 @@ function isWithTranslationSearchEnabled(parsed: CatalogPluginParsedOptions) {
2032
2401
  return parsed.withTranslationSearch === true || parsed["with-translation-search"] === true;
2033
2402
  }
2034
2403
 
2404
+ function isWithDuplicatesEnabled(parsed: CatalogPluginParsedOptions) {
2405
+ return parsed.withDuplicates === true || parsed["with-duplicates"] === true;
2406
+ }
2407
+
2035
2408
  export function createCatalogPlugin(
2036
2409
  runtime: CatalogRuntime,
2037
2410
  api: ReturnType<typeof createCatalogApi> = createCatalogApi(runtime),
@@ -2042,6 +2415,7 @@ export function createCatalogPlugin(
2042
2415
  const allowedSubcommands = ["export", "serve"];
2043
2416
  const browserRouter = !(parsed.hashRouter || parsed["hash-router"]);
2044
2417
  const withTranslationSearch = isWithTranslationSearchEnabled(parsed);
2418
+ const withDuplicates = isWithDuplicatesEnabled(parsed);
2045
2419
 
2046
2420
  if (!parsed.subcommand) {
2047
2421
  await api.exportCatalog(rootDirectoryPath, projectConfig, datasource, {
@@ -2050,6 +2424,7 @@ export function createCatalogPlugin(
2050
2424
  browserRouter,
2051
2425
  dev: true,
2052
2426
  withTranslationSearch,
2427
+ withDuplicates,
2053
2428
  });
2054
2429
  const server = await api.serveCatalog(rootDirectoryPath, projectConfig, datasource, {
2055
2430
  outDir: parsed.outDir,
@@ -2093,6 +2468,7 @@ export function createCatalogPlugin(
2093
2468
  browserRouter,
2094
2469
  dev: true,
2095
2470
  withTranslationSearch,
2471
+ withDuplicates,
2096
2472
  });
2097
2473
  server.triggerReload();
2098
2474
  } catch (error) {
@@ -2141,6 +2517,7 @@ export function createCatalogPlugin(
2141
2517
  copyAssets: !parsed.noAssets,
2142
2518
  browserRouter,
2143
2519
  withTranslationSearch,
2520
+ withDuplicates,
2144
2521
  });
2145
2522
  }
2146
2523