@messagevisor/catalog 0.6.0 → 0.8.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
@@ -104,6 +104,12 @@ class CatalogProgressReporter {
104
104
  return Date.now();
105
105
  }
106
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
+
107
113
  done(startedAt: number, detail?: string) {
108
114
  const suffix = detail ? ` ${detail}` : "";
109
115
  console.log(CLI_FORMAT_DIM, ` done in ${prettyDuration(Date.now() - startedAt)}${suffix}`);
@@ -335,6 +341,8 @@ export interface CatalogExportOptions {
335
341
  devEditors?: CatalogDevEditor[];
336
342
  withTranslationSearch?: boolean;
337
343
  withDuplicates?: boolean;
344
+ devSession?: CatalogDevSession;
345
+ preserveAssets?: boolean;
338
346
  }
339
347
 
340
348
  export interface CatalogServeOptions {
@@ -352,6 +360,7 @@ export interface CatalogServerHandle {
352
360
  interface CatalogBuildContext {
353
361
  rootDirectoryPath: string;
354
362
  repositoryRootDirectoryPath: string;
363
+ repositorySourceRootDirectoryPath: string;
355
364
  outputDirectoryPath: string;
356
365
  dataDirectoryPath: string;
357
366
  historyIndex: CatalogHistoryIndex;
@@ -361,6 +370,23 @@ interface CatalogBuildContext {
361
370
  withTranslationSearch: boolean;
362
371
  withDuplicates: boolean;
363
372
  progress: CatalogProgressReporter;
373
+ writer: CatalogJsonWriter;
374
+ }
375
+
376
+ interface CatalogDevSession {
377
+ outputDirectoryPath: string;
378
+ devEditors: CatalogDevEditor[];
379
+ historyIndex: CatalogHistoryIndex;
380
+ links: ReturnType<typeof getRepoLinks>;
381
+ repositoryRootDirectoryPath: string;
382
+ repositorySourceRootDirectoryPath: string;
383
+ }
384
+
385
+ interface CatalogDevRebuildRequest {
386
+ kind: "full" | "set" | "message";
387
+ reason: string;
388
+ set?: string;
389
+ messageKeys?: string[];
364
390
  }
365
391
 
366
392
  interface SourceFileInfo {
@@ -723,9 +749,41 @@ function toLocaleDuplicatesFile(
723
749
  };
724
750
  }
725
751
 
726
- async function writeJson(filePath: string, content: unknown) {
727
- await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
728
- await fs.promises.writeFile(filePath, JSON.stringify(content, null, 2));
752
+ class CatalogJsonWriter {
753
+ private readonly directories = new Map<string, Promise<void>>();
754
+
755
+ private ensureDirectory(directoryPath: string) {
756
+ let promise = this.directories.get(directoryPath);
757
+
758
+ if (!promise) {
759
+ promise = fs.promises.mkdir(directoryPath, { recursive: true }).then(() => undefined);
760
+ this.directories.set(directoryPath, promise);
761
+ }
762
+
763
+ return promise;
764
+ }
765
+
766
+ async write(filePath: string, content: unknown) {
767
+ await this.ensureDirectory(path.dirname(filePath));
768
+ await fs.promises.writeFile(filePath, JSON.stringify(content, null, 2));
769
+ }
770
+ }
771
+
772
+ async function mapWithConcurrency<T>(
773
+ items: T[],
774
+ concurrency: number,
775
+ callback: (item: T, index: number) => Promise<void>,
776
+ ) {
777
+ let nextIndex = 0;
778
+ const workerCount = Math.min(concurrency, items.length);
779
+ const workers = Array.from({ length: workerCount }, async () => {
780
+ while (nextIndex < items.length) {
781
+ const index = nextIndex++;
782
+ await callback(items[index], index);
783
+ }
784
+ });
785
+
786
+ await Promise.all(workers);
729
787
  }
730
788
 
731
789
  function getEntityDirectoryPaths(config: any): Record<CatalogEntityType | "test", string> {
@@ -870,6 +928,18 @@ function addHistoryIndexEntry(
870
928
  target[key].push(entry);
871
929
  }
872
930
 
931
+ function toEntityHistoryEntry(
932
+ entry: CatalogHistoryEntry,
933
+ entity: CatalogHistoryEntity,
934
+ ): CatalogHistoryEntry {
935
+ return {
936
+ commit: entry.commit,
937
+ author: entry.author,
938
+ timestamp: entry.timestamp,
939
+ entities: [entity],
940
+ };
941
+ }
942
+
873
943
  function buildCatalogHistoryIndex(entries: CatalogHistoryEntry[]): CatalogHistoryIndex {
874
944
  const index = createEmptyHistoryIndex();
875
945
  index.entries = entries;
@@ -883,7 +953,7 @@ function buildCatalogHistoryIndex(entries: CatalogHistoryEntry[]): CatalogHistor
883
953
  }
884
954
 
885
955
  const entityKey = getHistoryEntityKey(entity.type, entity.key, entity.set);
886
- addHistoryIndexEntry(index.byEntity, entityKey, entry);
956
+ addHistoryIndexEntry(index.byEntity, entityKey, toEntityHistoryEntry(entry, entity));
887
957
 
888
958
  if (!index.lastModifiedByEntity[entityKey]) {
889
959
  index.lastModifiedByEntity[entityKey] = toLastModified(entry);
@@ -1129,6 +1199,25 @@ function getRepositoryRootDirectoryPath(rootDirectoryPath: string) {
1129
1199
  }
1130
1200
  }
1131
1201
 
1202
+ function getRepositorySourceRootDirectoryPath(rootDirectoryPath: string) {
1203
+ try {
1204
+ const gitRootDirectoryPath =
1205
+ runGit(rootDirectoryPath, ["rev-parse", "--show-toplevel"]).trim() || rootDirectoryPath;
1206
+ const realRootDirectoryPath = getRealPath(rootDirectoryPath);
1207
+
1208
+ if (realRootDirectoryPath !== rootDirectoryPath) {
1209
+ return path.resolve(
1210
+ rootDirectoryPath,
1211
+ path.relative(realRootDirectoryPath, gitRootDirectoryPath),
1212
+ );
1213
+ }
1214
+
1215
+ return gitRootDirectoryPath;
1216
+ } catch (_error) {
1217
+ return rootDirectoryPath;
1218
+ }
1219
+ }
1220
+
1132
1221
  function getOwnerAndRepoFromGitRemote(origin: string, host: string) {
1133
1222
  const escapedHost = host.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
1134
1223
  const match = origin.match(new RegExp(`${escapedHost}[:/]([^/]+)/(.+?)(?:\\.git)?$`));
@@ -1204,17 +1293,28 @@ function chunkHistory(history: CatalogHistoryEntry[], pageSize = CATALOG_HISTORY
1204
1293
  return pages.length > 0 ? pages : [[]];
1205
1294
  }
1206
1295
 
1207
- async function writeHistoryPages(directoryPath: string, history: CatalogHistoryEntry[]) {
1296
+ async function writeHistoryPages(
1297
+ writer: CatalogJsonWriter,
1298
+ directoryPath: string,
1299
+ history: CatalogHistoryEntry[],
1300
+ options: { skipEmpty?: boolean } = {},
1301
+ ) {
1302
+ if (options.skipEmpty && history.length === 0) {
1303
+ return 1;
1304
+ }
1305
+
1208
1306
  const pages = chunkHistory(history);
1209
1307
 
1210
1308
  for (let index = 0; index < pages.length; index++) {
1211
- await writeJson(path.join(directoryPath, `page-${index + 1}.json`), {
1309
+ await writer.write(path.join(directoryPath, `page-${index + 1}.json`), {
1212
1310
  page: index + 1,
1213
1311
  pageSize: CATALOG_HISTORY_PAGE_SIZE,
1214
1312
  totalPages: pages.length,
1215
1313
  entries: pages[index],
1216
1314
  });
1217
1315
  }
1316
+
1317
+ return 0;
1218
1318
  }
1219
1319
 
1220
1320
  function getHistoryForEntity(
@@ -1227,11 +1327,12 @@ function getHistoryForEntity(
1227
1327
  }
1228
1328
 
1229
1329
  function getSourceFileInfo(
1230
- repositoryRootDirectoryPath: string,
1330
+ repositorySourceRootDirectoryPath: string,
1231
1331
  rootDirectoryPath: string,
1232
1332
  projectConfig: any,
1233
1333
  type: CatalogEntityType,
1234
1334
  key: string,
1335
+ options: { resolveAbsolutePath?: boolean } = {},
1235
1336
  ): SourceFileInfo {
1236
1337
  const directoryByType: Record<CatalogEntityType, string> = {
1237
1338
  locale: projectConfig.localesDirectoryPath,
@@ -1248,10 +1349,10 @@ function getSourceFileInfo(
1248
1349
  ...key.split(projectConfig.namespaceCharacter),
1249
1350
  ) + extension,
1250
1351
  );
1251
- const absolutePath = getRealPath(filePath);
1352
+ const absolutePath = options.resolveAbsolutePath ? getRealPath(filePath) : filePath;
1252
1353
 
1253
1354
  return {
1254
- sourcePath: toPosixPath(path.relative(repositoryRootDirectoryPath, absolutePath)),
1355
+ sourcePath: toPosixPath(path.relative(repositorySourceRootDirectoryPath, filePath)),
1255
1356
  absolutePath,
1256
1357
  };
1257
1358
  }
@@ -1284,13 +1385,13 @@ async function buildSetCatalog(
1284
1385
  const outputDirectoryPath = path.join(context.dataDirectoryPath, outputRelativeDirectory);
1285
1386
  const setStartedAt = context.progress.setStart(set);
1286
1387
  const entitiesStartedAt = context.progress.step("Processing entities");
1287
- const [localeKeys, messageKeys, attributeKeys, segmentKeys, targetKeys] = await Promise.all([
1388
+ const [localeKeys, messageKeys, attributeKeys, segmentKeys, targetKeys] = (await Promise.all([
1288
1389
  datasource.listLocales(),
1289
1390
  datasource.listMessages(),
1290
1391
  datasource.listAttributes(),
1291
1392
  datasource.listSegments(),
1292
1393
  datasource.listTargets(),
1293
- ]);
1394
+ ])) as [string[], string[], string[], string[], string[]];
1294
1395
  const [locales, messages, attributes, segments, targets] = await Promise.all([
1295
1396
  readAll<Locale>(localeKeys, (key) => datasource.readLocale(key)),
1296
1397
  readAll<Message>(messageKeys, (key) => datasource.readMessage(key)),
@@ -1440,7 +1541,7 @@ async function buildSetCatalog(
1440
1541
  };
1441
1542
 
1442
1543
  const historyStartedAt = context.progress.step("Writing history pages");
1443
- await writeHistoryPages(path.join(outputDirectoryPath, "history"), history);
1544
+ await writeHistoryPages(context.writer, path.join(outputDirectoryPath, "history"), history);
1444
1545
  context.progress.done(historyStartedAt, `(${pluralize(history.length, "entry", "entries")})`);
1445
1546
 
1446
1547
  const examplesStartedAt = context.progress.step("Evaluating examples");
@@ -1492,14 +1593,16 @@ async function buildSetCatalog(
1492
1593
  );
1493
1594
 
1494
1595
  const localesStartedAt = context.progress.step("Writing locales");
1495
- for (const localeKey of localeKeys) {
1596
+ let skippedEmptyHistoryCount = 0;
1597
+ await mapWithConcurrency(localeKeys, 32, async (localeKey) => {
1496
1598
  const locale = locales[localeKey];
1497
1599
  const sourceFileInfo = getSourceFileInfo(
1498
- context.repositoryRootDirectoryPath,
1600
+ context.repositorySourceRootDirectoryPath,
1499
1601
  context.rootDirectoryPath,
1500
1602
  projectConfig,
1501
1603
  "locale",
1502
1604
  localeKey,
1605
+ { resolveAbsolutePath: context.devEditors.length > 0 },
1503
1606
  );
1504
1607
  const detail = {
1505
1608
  type: "locale",
@@ -1525,26 +1628,28 @@ async function buildSetCatalog(
1525
1628
  targets: sortStrings(Array.from(localeTargets[localeKey] || [])),
1526
1629
  }),
1527
1630
  );
1528
- await writeJson(
1631
+ await context.writer.write(
1529
1632
  path.join(outputDirectoryPath, "entities", "locale", `${encodeKey(localeKey)}.json`),
1530
1633
  detail,
1531
1634
  );
1532
- await writeHistoryPages(
1635
+ skippedEmptyHistoryCount += await writeHistoryPages(
1636
+ context.writer,
1533
1637
  path.join(outputDirectoryPath, "history", "locale", encodeKey(localeKey)),
1534
1638
  getHistoryForEntity(context.historyIndex, "locale", localeKey, set || undefined),
1639
+ { skipEmpty: true },
1535
1640
  );
1536
- }
1641
+ });
1537
1642
  context.progress.done(localesStartedAt, `(${pluralize(localeKeys.length, "locale")})`);
1538
1643
 
1539
1644
  if (context.withDuplicates) {
1540
1645
  const duplicatesStartedAt = context.progress.step("Writing duplicate reports");
1541
1646
 
1542
- for (const localeKey of localeKeys) {
1543
- await writeJson(
1647
+ await mapWithConcurrency(localeKeys, 32, async (localeKey) => {
1648
+ await context.writer.write(
1544
1649
  path.join(outputDirectoryPath, "duplicates", "locales", `${encodeKey(localeKey)}.json`),
1545
1650
  toLocaleDuplicatesFile(localeKey, duplicatesByLocale),
1546
1651
  );
1547
- }
1652
+ });
1548
1653
 
1549
1654
  context.progress.done(duplicatesStartedAt, `(${pluralize(localeKeys.length, "locale")})`);
1550
1655
  }
@@ -1568,7 +1673,9 @@ async function buildSetCatalog(
1568
1673
  }
1569
1674
 
1570
1675
  const messagesStartedAt = context.progress.step("Writing messages");
1571
- for (const messageKey of messageKeys) {
1676
+ const messageDetailsStartedAt = context.progress.substep("Writing message details");
1677
+ let skippedEmptyMessageHistoryCount = 0;
1678
+ await mapWithConcurrency(messageKeys, 32, async (messageKey) => {
1572
1679
  const message = messages[messageKey];
1573
1680
  const overrides = (message.overrides || []).map((override: Override) => {
1574
1681
  const attributes = new Set<string>();
@@ -1583,11 +1690,12 @@ async function buildSetCatalog(
1583
1690
  };
1584
1691
  });
1585
1692
  const sourceFileInfo = getSourceFileInfo(
1586
- context.repositoryRootDirectoryPath,
1693
+ context.repositorySourceRootDirectoryPath,
1587
1694
  context.rootDirectoryPath,
1588
1695
  projectConfig,
1589
1696
  "message",
1590
1697
  messageKey,
1698
+ { resolveAbsolutePath: context.devEditors.length > 0 },
1591
1699
  );
1592
1700
  const detail = {
1593
1701
  type: "message",
@@ -1629,16 +1737,40 @@ async function buildSetCatalog(
1629
1737
  ...(overrideLocalesList.length > 0 ? { overrideLocales: overrideLocalesList } : {}),
1630
1738
  }),
1631
1739
  );
1632
- await writeJson(
1740
+ await context.writer.write(
1633
1741
  path.join(outputDirectoryPath, "entities", "message", `${encodeKey(messageKey)}.json`),
1634
1742
  detail,
1635
1743
  );
1636
- await writeHistoryPages(
1744
+ });
1745
+ context.progress.done(messageDetailsStartedAt, `(${pluralize(messageKeys.length, "message")})`);
1746
+
1747
+ const messageHistoryStartedAt = context.progress.substep("Writing message history pages");
1748
+ await mapWithConcurrency(messageKeys, 32, async (messageKey) => {
1749
+ const skippedHistory = await writeHistoryPages(
1750
+ context.writer,
1637
1751
  path.join(outputDirectoryPath, "history", "message", encodeKey(messageKey)),
1638
1752
  getHistoryForEntity(context.historyIndex, "message", messageKey, set || undefined),
1753
+ { skipEmpty: true },
1639
1754
  );
1640
- }
1641
- context.progress.done(messagesStartedAt, `(${pluralize(messageKeys.length, "message")})`);
1755
+ skippedEmptyMessageHistoryCount += skippedHistory;
1756
+ skippedEmptyHistoryCount += skippedHistory;
1757
+ });
1758
+ context.progress.done(
1759
+ messageHistoryStartedAt,
1760
+ `(${pluralize(messageKeys.length, "message")}, ${pluralize(
1761
+ skippedEmptyMessageHistoryCount,
1762
+ "empty history",
1763
+ "empty histories",
1764
+ )} skipped)`,
1765
+ );
1766
+ context.progress.done(
1767
+ messagesStartedAt,
1768
+ `(${pluralize(messageKeys.length, "message")}, ${pluralize(
1769
+ skippedEmptyMessageHistoryCount,
1770
+ "empty history",
1771
+ "empty histories",
1772
+ )} skipped)`,
1773
+ );
1642
1774
 
1643
1775
  if (context.withTranslationSearch) {
1644
1776
  const translationSearchStartedAt = context.progress.step("Building translation search shards");
@@ -1666,7 +1798,10 @@ async function buildSetCatalog(
1666
1798
  for (const [msgKey, valueSet] of Object.entries(messageMap)) {
1667
1799
  shardData[msgKey] = Array.from(valueSet);
1668
1800
  }
1669
- await writeJson(path.join(outputDirectoryPath, "translations", `${prefix}.json`), shardData);
1801
+ await context.writer.write(
1802
+ path.join(outputDirectoryPath, "translations", `${prefix}.json`),
1803
+ shardData,
1804
+ );
1670
1805
  }
1671
1806
  context.progress.done(
1672
1807
  translationSearchStartedAt,
@@ -1675,14 +1810,15 @@ async function buildSetCatalog(
1675
1810
  }
1676
1811
 
1677
1812
  const attributesStartedAt = context.progress.step("Writing attributes");
1678
- for (const attributeKey of attributeKeys) {
1813
+ await mapWithConcurrency(attributeKeys, 32, async (attributeKey) => {
1679
1814
  const attribute = attributes[attributeKey];
1680
1815
  const sourceFileInfo = getSourceFileInfo(
1681
- context.repositoryRootDirectoryPath,
1816
+ context.repositorySourceRootDirectoryPath,
1682
1817
  context.rootDirectoryPath,
1683
1818
  projectConfig,
1684
1819
  "attribute",
1685
1820
  attributeKey,
1821
+ { resolveAbsolutePath: context.devEditors.length > 0 },
1686
1822
  );
1687
1823
  const detail = {
1688
1824
  type: "attribute",
@@ -1714,28 +1850,31 @@ async function buildSetCatalog(
1714
1850
  },
1715
1851
  ),
1716
1852
  );
1717
- await writeJson(
1853
+ await context.writer.write(
1718
1854
  path.join(outputDirectoryPath, "entities", "attribute", `${encodeKey(attributeKey)}.json`),
1719
1855
  detail,
1720
1856
  );
1721
- await writeHistoryPages(
1857
+ skippedEmptyHistoryCount += await writeHistoryPages(
1858
+ context.writer,
1722
1859
  path.join(outputDirectoryPath, "history", "attribute", encodeKey(attributeKey)),
1723
1860
  getHistoryForEntity(context.historyIndex, "attribute", attributeKey, set || undefined),
1861
+ { skipEmpty: true },
1724
1862
  );
1725
- }
1863
+ });
1726
1864
  context.progress.done(attributesStartedAt, `(${pluralize(attributeKeys.length, "attribute")})`);
1727
1865
 
1728
1866
  const segmentsStartedAt = context.progress.step("Writing segments");
1729
- for (const segmentKey of segmentKeys) {
1867
+ await mapWithConcurrency(segmentKeys, 32, async (segmentKey) => {
1730
1868
  const segment = segments[segmentKey];
1731
1869
  const usedAttributes = new Set<string>();
1732
1870
  collectAttributeKeysFromConditions(segment.conditions, usedAttributes);
1733
1871
  const sourceFileInfo = getSourceFileInfo(
1734
- context.repositoryRootDirectoryPath,
1872
+ context.repositorySourceRootDirectoryPath,
1735
1873
  context.rootDirectoryPath,
1736
1874
  projectConfig,
1737
1875
  "segment",
1738
1876
  segmentKey,
1877
+ { resolveAbsolutePath: context.devEditors.length > 0 },
1739
1878
  );
1740
1879
  const detail = {
1741
1880
  type: "segment",
@@ -1755,19 +1894,21 @@ async function buildSetCatalog(
1755
1894
  targets: sortStrings(Array.from(segmentTargets[segmentKey] || [])),
1756
1895
  }),
1757
1896
  );
1758
- await writeJson(
1897
+ await context.writer.write(
1759
1898
  path.join(outputDirectoryPath, "entities", "segment", `${encodeKey(segmentKey)}.json`),
1760
1899
  detail,
1761
1900
  );
1762
- await writeHistoryPages(
1901
+ skippedEmptyHistoryCount += await writeHistoryPages(
1902
+ context.writer,
1763
1903
  path.join(outputDirectoryPath, "history", "segment", encodeKey(segmentKey)),
1764
1904
  getHistoryForEntity(context.historyIndex, "segment", segmentKey, set || undefined),
1905
+ { skipEmpty: true },
1765
1906
  );
1766
- }
1907
+ });
1767
1908
  context.progress.done(segmentsStartedAt, `(${pluralize(segmentKeys.length, "segment")})`);
1768
1909
 
1769
1910
  const targetsStartedAt = context.progress.step("Writing targets");
1770
- for (const targetKey of targetKeys) {
1911
+ await mapWithConcurrency(targetKeys, 32, async (targetKey) => {
1771
1912
  const target = targets[targetKey];
1772
1913
  const targetLocaleKeys = target.locales?.length ? target.locales : localeKeys;
1773
1914
  const formatsByLocale: Record<string, FormatPresets | undefined> = {};
@@ -1779,11 +1920,12 @@ async function buildSetCatalog(
1779
1920
  }
1780
1921
 
1781
1922
  const sourceFileInfo = getSourceFileInfo(
1782
- context.repositoryRootDirectoryPath,
1923
+ context.repositorySourceRootDirectoryPath,
1783
1924
  context.rootDirectoryPath,
1784
1925
  projectConfig,
1785
1926
  "target",
1786
1927
  targetKey,
1928
+ { resolveAbsolutePath: context.devEditors.length > 0 },
1787
1929
  );
1788
1930
  const detail = {
1789
1931
  type: "target",
@@ -1803,15 +1945,17 @@ async function buildSetCatalog(
1803
1945
  messageCount: targetMessages[targetKey].length,
1804
1946
  }),
1805
1947
  );
1806
- await writeJson(
1948
+ await context.writer.write(
1807
1949
  path.join(outputDirectoryPath, "entities", "target", `${encodeKey(targetKey)}.json`),
1808
1950
  detail,
1809
1951
  );
1810
- await writeHistoryPages(
1952
+ skippedEmptyHistoryCount += await writeHistoryPages(
1953
+ context.writer,
1811
1954
  path.join(outputDirectoryPath, "history", "target", encodeKey(targetKey)),
1812
1955
  getHistoryForEntity(context.historyIndex, "target", targetKey, set || undefined),
1956
+ { skipEmpty: true },
1813
1957
  );
1814
- }
1958
+ });
1815
1959
  context.progress.done(targetsStartedAt, `(${pluralize(targetKeys.length, "target")})`);
1816
1960
 
1817
1961
  const indexStartedAt = context.progress.step("Writing catalog index");
@@ -1819,9 +1963,12 @@ async function buildSetCatalog(
1819
1963
  index.entities[type].sort((a, b) => a.key.localeCompare(b.key));
1820
1964
  }
1821
1965
 
1822
- await writeJson(path.join(outputDirectoryPath, "index.json"), index);
1966
+ await context.writer.write(path.join(outputDirectoryPath, "index.json"), index);
1823
1967
  context.progress.done(indexStartedAt);
1824
- context.progress.done(setStartedAt, "total");
1968
+ context.progress.done(
1969
+ setStartedAt,
1970
+ `total (${pluralize(skippedEmptyHistoryCount, "empty history", "empty histories")} skipped)`,
1971
+ );
1825
1972
 
1826
1973
  return index;
1827
1974
  }
@@ -1848,6 +1995,455 @@ async function copyCatalogAssets(outputDirectoryPath: string) {
1848
1995
  await fs.promises.cp(distPath, outputDirectoryPath, { recursive: true });
1849
1996
  }
1850
1997
 
1998
+ async function createCatalogDevSession(
1999
+ rootDirectoryPath: string,
2000
+ projectConfig: any,
2001
+ options: { outDir?: string; devEditors?: CatalogDevEditor[] } = {},
2002
+ ): Promise<CatalogDevSession> {
2003
+ const outputDirectoryPath = options.outDir
2004
+ ? path.resolve(rootDirectoryPath, options.outDir)
2005
+ : projectConfig.catalogDirectoryPath;
2006
+
2007
+ return {
2008
+ outputDirectoryPath,
2009
+ devEditors: options.devEditors || detectDevEditors(),
2010
+ historyIndex: await getGitHistoryIndex(rootDirectoryPath, projectConfig),
2011
+ links: getRepoLinks(rootDirectoryPath),
2012
+ repositoryRootDirectoryPath: getRepositoryRootDirectoryPath(rootDirectoryPath),
2013
+ repositorySourceRootDirectoryPath: getRepositorySourceRootDirectoryPath(rootDirectoryPath),
2014
+ };
2015
+ }
2016
+
2017
+ async function readJsonFile<T>(filePath: string): Promise<T | undefined> {
2018
+ try {
2019
+ return JSON.parse(await fs.promises.readFile(filePath, "utf8")) as T;
2020
+ } catch (_error) {
2021
+ return undefined;
2022
+ }
2023
+ }
2024
+
2025
+ function getOutputRelativeDirectory(projectConfig: any, set?: string) {
2026
+ return projectConfig.sets ? path.join("sets", set || "") : "root";
2027
+ }
2028
+
2029
+ function getDataOutputDirectoryPath(session: CatalogDevSession, projectConfig: any, set?: string) {
2030
+ return path.join(
2031
+ session.outputDirectoryPath,
2032
+ "data",
2033
+ getOutputRelativeDirectory(projectConfig, set),
2034
+ );
2035
+ }
2036
+
2037
+ function getEntityKeyFromChangedPath(
2038
+ rootDirectoryPath: string,
2039
+ projectConfig: any,
2040
+ changedPath: string,
2041
+ ): EntityPathInfo | undefined {
2042
+ const relativePath = path.relative(rootDirectoryPath, changedPath);
2043
+
2044
+ if (!relativePath || relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
2045
+ return undefined;
2046
+ }
2047
+
2048
+ return getEntityInfoFromRelativePath(rootDirectoryPath, projectConfig, relativePath);
2049
+ }
2050
+
2051
+ function getChangedPathSummary(rootDirectoryPath: string, changedPaths: string[]) {
2052
+ return changedPaths
2053
+ .slice(0, 3)
2054
+ .map((changedPath) => formatCatalogPath(rootDirectoryPath, changedPath))
2055
+ .join(", ");
2056
+ }
2057
+
2058
+ function classifyCatalogDevChanges(
2059
+ rootDirectoryPath: string,
2060
+ projectConfig: any,
2061
+ changedPaths: string[],
2062
+ options: { withTranslationSearch: boolean; withDuplicates: boolean },
2063
+ ): CatalogDevRebuildRequest {
2064
+ const reason = getChangedPathSummary(rootDirectoryPath, changedPaths) || "project changes";
2065
+ const infos = changedPaths.map((changedPath) =>
2066
+ getEntityKeyFromChangedPath(rootDirectoryPath, projectConfig, changedPath),
2067
+ );
2068
+
2069
+ if (infos.length === 0 || infos.some((info) => !info)) {
2070
+ return { kind: "full", reason };
2071
+ }
2072
+
2073
+ const sets = new Set(infos.map((info) => info?.set || ""));
2074
+ const types = new Set(infos.map((info) => info?.type));
2075
+
2076
+ if (sets.size > 1) {
2077
+ return { kind: "full", reason };
2078
+ }
2079
+
2080
+ const set = Array.from(sets)[0] || undefined;
2081
+
2082
+ if (
2083
+ types.size === 1 &&
2084
+ types.has("message") &&
2085
+ !options.withTranslationSearch &&
2086
+ !options.withDuplicates
2087
+ ) {
2088
+ return {
2089
+ kind: "message",
2090
+ reason,
2091
+ set,
2092
+ messageKeys: sortStrings(infos.map((info) => info?.key || "").filter(Boolean)),
2093
+ };
2094
+ }
2095
+
2096
+ if (
2097
+ projectConfig.sets &&
2098
+ set &&
2099
+ types.size > 0 &&
2100
+ !types.has("test") &&
2101
+ !options.withTranslationSearch &&
2102
+ !options.withDuplicates
2103
+ ) {
2104
+ return { kind: "set", reason, set };
2105
+ }
2106
+
2107
+ return { kind: "full", reason };
2108
+ }
2109
+
2110
+ async function writeCatalogManifest(
2111
+ writer: CatalogJsonWriter,
2112
+ rootDirectoryPath: string,
2113
+ projectConfig: any,
2114
+ session: CatalogDevSession,
2115
+ options: {
2116
+ browserRouter: boolean;
2117
+ withTranslationSearch: boolean;
2118
+ withDuplicates: boolean;
2119
+ setIndexes: Record<string, CatalogSetIndex>;
2120
+ executions: Array<{ set: string; projectConfig: any; datasource: any }>;
2121
+ },
2122
+ ) {
2123
+ const manifest = {
2124
+ schemaVersion: CATALOG_SCHEMA_VERSION,
2125
+ generatedAt: new Date().toISOString(),
2126
+ router: options.browserRouter === false ? "hash" : "browser",
2127
+ sets: projectConfig.sets,
2128
+ setKeys: projectConfig.sets ? options.executions.map((execution) => execution.set) : [],
2129
+ dev: { editors: session.devEditors },
2130
+ features: {
2131
+ translationSearch: options.withTranslationSearch,
2132
+ duplicates: options.withDuplicates,
2133
+ },
2134
+ links: session.links,
2135
+ paths: {
2136
+ projectHistory: "data/project/history/page-1.json",
2137
+ root: projectConfig.sets ? undefined : "data/root/index.json",
2138
+ sets: projectConfig.sets
2139
+ ? Object.fromEntries(
2140
+ options.executions.map((execution) => [
2141
+ execution.set,
2142
+ `data/sets/${encodeURIComponent(execution.set)}/index.json`,
2143
+ ]),
2144
+ )
2145
+ : undefined,
2146
+ },
2147
+ counts: Object.fromEntries(
2148
+ Object.keys(options.setIndexes).map((key) => [key, options.setIndexes[key].counts]),
2149
+ ),
2150
+ };
2151
+
2152
+ await writer.write(path.join(session.outputDirectoryPath, "data", "manifest.json"), manifest);
2153
+ return manifest;
2154
+ }
2155
+
2156
+ function getMessageRelationshipFingerprint(message: Message) {
2157
+ const attributes = new Set<string>();
2158
+ const segments = new Set<string>();
2159
+
2160
+ for (const override of message.overrides || []) {
2161
+ collectAttributeKeysFromConditions(override.conditions, attributes);
2162
+ collectSegmentKeys(override.segments, segments);
2163
+ }
2164
+
2165
+ return {
2166
+ attributes: sortStrings(Array.from(attributes)),
2167
+ segments: sortStrings(Array.from(segments)),
2168
+ };
2169
+ }
2170
+
2171
+ function sameStringList(left: string[] = [], right: string[] = []) {
2172
+ if (left.length !== right.length) {
2173
+ return false;
2174
+ }
2175
+
2176
+ return left.every((value, index) => value === right[index]);
2177
+ }
2178
+
2179
+ function summarizeMessage(
2180
+ message: Message,
2181
+ messageKey: string,
2182
+ historyIndex: CatalogHistoryIndex,
2183
+ set: string | undefined,
2184
+ targets: string[],
2185
+ ) {
2186
+ const directLocales = Object.keys(message.translations || {});
2187
+ const overrideLocalesSet = new Set<string>();
2188
+
2189
+ for (const override of message.overrides || []) {
2190
+ for (const localeKey of Object.keys(override.translations || {})) {
2191
+ overrideLocalesSet.add(localeKey);
2192
+ }
2193
+ }
2194
+
2195
+ const overrideLocales = sortStrings(Array.from(overrideLocalesSet));
2196
+
2197
+ return getEntitySummary(message, "message", messageKey, historyIndex, set, {
2198
+ targets,
2199
+ ...(directLocales.length > 0 ? { locales: sortStrings(directLocales) } : {}),
2200
+ ...(overrideLocales.length > 0 ? { overrideLocales } : {}),
2201
+ });
2202
+ }
2203
+
2204
+ async function tryRebuildCatalogMessage(
2205
+ runtime: CatalogRuntime,
2206
+ rootDirectoryPath: string,
2207
+ rootProjectConfig: any,
2208
+ projectConfig: any,
2209
+ datasource: any,
2210
+ session: CatalogDevSession,
2211
+ request: CatalogDevRebuildRequest,
2212
+ ) {
2213
+ if (request.kind !== "message" || !request.messageKeys || request.messageKeys.length === 0) {
2214
+ return false;
2215
+ }
2216
+
2217
+ const dataDirectoryPath = getDataOutputDirectoryPath(session, rootProjectConfig, request.set);
2218
+ const indexPath = path.join(dataDirectoryPath, "index.json");
2219
+ const index = await readJsonFile<CatalogSetIndex>(indexPath);
2220
+
2221
+ if (!index) {
2222
+ return false;
2223
+ }
2224
+
2225
+ const [localeKeys, messageKeys, targetKeys] = (await Promise.all([
2226
+ datasource.listLocales(),
2227
+ datasource.listMessages(),
2228
+ datasource.listTargets(),
2229
+ ])) as [string[], string[], string[]];
2230
+ const messageKeySet = new Set(messageKeys);
2231
+
2232
+ if (request.messageKeys.some((messageKey) => !messageKeySet.has(messageKey))) {
2233
+ return false;
2234
+ }
2235
+
2236
+ const [locales, targets] = await Promise.all([
2237
+ readAll<Locale>(localeKeys, (key) => datasource.readLocale(key)),
2238
+ readAll<Target>(targetKeys, (key) => datasource.readTarget(key)),
2239
+ ]);
2240
+ const localeDirections = getLocaleDirections(locales);
2241
+ const targetMessages = Object.fromEntries(
2242
+ targetKeys.map((targetKey) => [
2243
+ targetKey,
2244
+ getTargetMessageKeys(targets[targetKey], messageKeys),
2245
+ ]),
2246
+ ) as Record<string, string[]>;
2247
+ const writer = new CatalogJsonWriter();
2248
+
2249
+ for (const messageKey of request.messageKeys) {
2250
+ const oldDetailPath = path.join(
2251
+ dataDirectoryPath,
2252
+ "entities",
2253
+ "message",
2254
+ `${encodeKey(messageKey)}.json`,
2255
+ );
2256
+ const oldDetail = await readJsonFile<any>(oldDetailPath);
2257
+
2258
+ if (!oldDetail) {
2259
+ return false;
2260
+ }
2261
+
2262
+ const message = await datasource.readMessage(messageKey);
2263
+ const messageTargets = sortStrings(
2264
+ targetKeys.filter((targetKey) => targetMessages[targetKey].includes(messageKey)),
2265
+ );
2266
+
2267
+ if (!sameStringList(sortStrings(oldDetail.targets || []), messageTargets)) {
2268
+ return false;
2269
+ }
2270
+
2271
+ const oldRelationshipFingerprint = getMessageRelationshipFingerprint(oldDetail.entity || {});
2272
+ const nextRelationshipFingerprint = getMessageRelationshipFingerprint(message);
2273
+
2274
+ if (
2275
+ !sameStringList(
2276
+ oldRelationshipFingerprint.attributes,
2277
+ nextRelationshipFingerprint.attributes,
2278
+ ) ||
2279
+ !sameStringList(oldRelationshipFingerprint.segments, nextRelationshipFingerprint.segments)
2280
+ ) {
2281
+ return false;
2282
+ }
2283
+
2284
+ const examples = await runtime.resolveExamples(projectConfig, datasource, {
2285
+ set: request.set,
2286
+ message: messageKey,
2287
+ onlyMessages: true,
2288
+ });
2289
+ const overrides = (message.overrides || []).map((override: Override) => {
2290
+ const attributes = new Set<string>();
2291
+ const overrideSegments = new Set<string>();
2292
+ collectAttributeKeysFromConditions(override.conditions, attributes);
2293
+ collectSegmentKeys(override.segments, overrideSegments);
2294
+
2295
+ return {
2296
+ ...override,
2297
+ usedAttributes: sortStrings(Array.from(attributes)),
2298
+ usedSegments: sortStrings(Array.from(overrideSegments)),
2299
+ };
2300
+ });
2301
+ const sourceFileInfo = getSourceFileInfo(
2302
+ session.repositorySourceRootDirectoryPath,
2303
+ rootDirectoryPath,
2304
+ projectConfig,
2305
+ "message",
2306
+ messageKey,
2307
+ { resolveAbsolutePath: session.devEditors.length > 0 },
2308
+ );
2309
+ const detail = {
2310
+ type: "message",
2311
+ key: messageKey,
2312
+ entity: { ...message, overrides },
2313
+ sourcePath: sourceFileInfo.sourcePath,
2314
+ editLinks: getEditorLinks(session.devEditors, sourceFileInfo),
2315
+ targets: messageTargets,
2316
+ localeKeys,
2317
+ localeDirections,
2318
+ translations: localeKeys.map((localeKey) =>
2319
+ resolveTranslationRow(message.translations, localeKey, locales),
2320
+ ),
2321
+ evaluatedExamples: examples.messages,
2322
+ overrideTranslations: overrides.map((override) => ({
2323
+ key: override.key,
2324
+ rows: localeKeys.map((localeKey) =>
2325
+ resolveTranslationRow(override.translations, localeKey, locales),
2326
+ ),
2327
+ })),
2328
+ lastModified: getLastModified(session.historyIndex, "message", messageKey, request.set),
2329
+ };
2330
+
2331
+ await writer.write(oldDetailPath, detail);
2332
+
2333
+ await writeHistoryPages(
2334
+ writer,
2335
+ path.join(dataDirectoryPath, "history", "message", encodeKey(messageKey)),
2336
+ getHistoryForEntity(session.historyIndex, "message", messageKey, request.set),
2337
+ { skipEmpty: true },
2338
+ );
2339
+
2340
+ const nextSummary = summarizeMessage(
2341
+ message,
2342
+ messageKey,
2343
+ session.historyIndex,
2344
+ request.set,
2345
+ messageTargets,
2346
+ );
2347
+ const existingSummaryIndex = index.entities.message.findIndex(
2348
+ (entry) => entry.key === messageKey,
2349
+ );
2350
+
2351
+ if (existingSummaryIndex === -1) {
2352
+ index.entities.message.push(nextSummary);
2353
+ } else {
2354
+ index.entities.message[existingSummaryIndex] = nextSummary;
2355
+ }
2356
+ }
2357
+
2358
+ index.entities.message.sort((left, right) => left.key.localeCompare(right.key));
2359
+ index.counts.message = messageKeys.length;
2360
+ await writer.write(indexPath, index);
2361
+
2362
+ return true;
2363
+ }
2364
+
2365
+ async function rebuildCatalogSetForDev(
2366
+ runtime: CatalogRuntime,
2367
+ rootDirectoryPath: string,
2368
+ projectConfig: any,
2369
+ datasource: any,
2370
+ session: CatalogDevSession,
2371
+ options: {
2372
+ set?: string;
2373
+ browserRouter: boolean;
2374
+ withTranslationSearch: boolean;
2375
+ withDuplicates: boolean;
2376
+ },
2377
+ ) {
2378
+ const writer = new CatalogJsonWriter();
2379
+ const progress = new CatalogProgressReporter(rootDirectoryPath, session.outputDirectoryPath);
2380
+ const executions = await runtime.getProjectSetExecutions(projectConfig, datasource);
2381
+ const setIndexes: Record<string, CatalogSetIndex> = {};
2382
+ const existingIndexes = await Promise.all(
2383
+ executions.map(async (execution) => {
2384
+ const indexPath = path.join(
2385
+ session.outputDirectoryPath,
2386
+ "data",
2387
+ getOutputRelativeDirectory(projectConfig, execution.set),
2388
+ "index.json",
2389
+ );
2390
+ return [execution.set || "root", await readJsonFile<CatalogSetIndex>(indexPath)] as const;
2391
+ }),
2392
+ );
2393
+
2394
+ for (const [key, index] of existingIndexes) {
2395
+ if (index) {
2396
+ setIndexes[key] = index;
2397
+ }
2398
+ }
2399
+
2400
+ const execution = executions.find((item) => (item.set || undefined) === options.set);
2401
+
2402
+ if (!execution) {
2403
+ return false;
2404
+ }
2405
+
2406
+ const outputRelativeDirectory = getOutputRelativeDirectory(projectConfig, execution.set);
2407
+ await fs.promises.rm(path.join(session.outputDirectoryPath, "data", outputRelativeDirectory), {
2408
+ recursive: true,
2409
+ force: true,
2410
+ });
2411
+
2412
+ const context: CatalogBuildContext = {
2413
+ rootDirectoryPath,
2414
+ repositoryRootDirectoryPath: session.repositoryRootDirectoryPath,
2415
+ repositorySourceRootDirectoryPath: session.repositorySourceRootDirectoryPath,
2416
+ outputDirectoryPath: session.outputDirectoryPath,
2417
+ dataDirectoryPath: path.join(session.outputDirectoryPath, "data"),
2418
+ historyIndex: session.historyIndex,
2419
+ runtime,
2420
+ devEditors: session.devEditors,
2421
+ duplicateResultsBySet: {},
2422
+ withTranslationSearch: options.withTranslationSearch,
2423
+ withDuplicates: options.withDuplicates,
2424
+ progress,
2425
+ writer,
2426
+ };
2427
+
2428
+ setIndexes[execution.set || "root"] = await buildSetCatalog(
2429
+ context,
2430
+ execution.set,
2431
+ execution.projectConfig,
2432
+ execution.datasource,
2433
+ outputRelativeDirectory,
2434
+ );
2435
+
2436
+ await writeCatalogManifest(writer, rootDirectoryPath, projectConfig, session, {
2437
+ browserRouter: options.browserRouter,
2438
+ withTranslationSearch: options.withTranslationSearch,
2439
+ withDuplicates: options.withDuplicates,
2440
+ setIndexes,
2441
+ executions,
2442
+ });
2443
+
2444
+ return true;
2445
+ }
2446
+
1851
2447
  export async function exportCatalog(
1852
2448
  runtime: CatalogRuntime,
1853
2449
  rootDirectoryPath: string,
@@ -1862,6 +2458,7 @@ export async function exportCatalog(
1862
2458
  const withTranslationSearch = options.withTranslationSearch === true;
1863
2459
  const withDuplicates = options.withDuplicates === true;
1864
2460
  const progress = new CatalogProgressReporter(rootDirectoryPath, outputDirectoryPath);
2461
+ const writer = new CatalogJsonWriter();
1865
2462
 
1866
2463
  progress.start({
1867
2464
  browserRouter: options.browserRouter !== false,
@@ -1873,7 +2470,11 @@ export async function exportCatalog(
1873
2470
  });
1874
2471
 
1875
2472
  let stepStartedAt = progress.step("Preparing output directory");
1876
- await fs.promises.rm(outputDirectoryPath, { recursive: true, force: true });
2473
+ if (options.preserveAssets) {
2474
+ await fs.promises.rm(dataDirectoryPath, { recursive: true, force: true });
2475
+ } else {
2476
+ await fs.promises.rm(outputDirectoryPath, { recursive: true, force: true });
2477
+ }
1877
2478
  await fs.promises.mkdir(dataDirectoryPath, { recursive: true });
1878
2479
  progress.done(stepStartedAt);
1879
2480
 
@@ -1883,13 +2484,17 @@ export async function exportCatalog(
1883
2484
  progress.done(stepStartedAt);
1884
2485
  }
1885
2486
 
1886
- const devEditors = options.dev ? options.devEditors || detectDevEditors() : [];
2487
+ const devEditors = options.dev
2488
+ ? options.devSession?.devEditors || options.devEditors || detectDevEditors()
2489
+ : [];
1887
2490
  stepStartedAt = progress.step("Reading Git history");
1888
- const historyIndex = await getGitHistoryIndex(rootDirectoryPath, projectConfig);
2491
+ const historyIndex =
2492
+ options.devSession?.historyIndex ||
2493
+ (await getGitHistoryIndex(rootDirectoryPath, projectConfig));
1889
2494
  progress.done(stepStartedAt, `(${pluralize(historyIndex.entries.length, "commit")})`);
1890
2495
 
1891
2496
  stepStartedAt = progress.step("Resolving repository links");
1892
- const links = getRepoLinks(rootDirectoryPath);
2497
+ const links = options.devSession?.links || getRepoLinks(rootDirectoryPath);
1893
2498
  progress.done(stepStartedAt);
1894
2499
 
1895
2500
  let duplicateResultsBySet: Record<string, CatalogDuplicateTranslationsSetResult> = {};
@@ -1920,7 +2525,12 @@ export async function exportCatalog(
1920
2525
 
1921
2526
  const context: CatalogBuildContext = {
1922
2527
  rootDirectoryPath,
1923
- repositoryRootDirectoryPath: getRepositoryRootDirectoryPath(rootDirectoryPath),
2528
+ repositoryRootDirectoryPath:
2529
+ options.devSession?.repositoryRootDirectoryPath ||
2530
+ getRepositoryRootDirectoryPath(rootDirectoryPath),
2531
+ repositorySourceRootDirectoryPath:
2532
+ options.devSession?.repositorySourceRootDirectoryPath ||
2533
+ getRepositorySourceRootDirectoryPath(rootDirectoryPath),
1924
2534
  outputDirectoryPath,
1925
2535
  dataDirectoryPath,
1926
2536
  historyIndex,
@@ -1930,6 +2540,7 @@ export async function exportCatalog(
1930
2540
  withTranslationSearch,
1931
2541
  withDuplicates,
1932
2542
  progress,
2543
+ writer,
1933
2544
  };
1934
2545
  stepStartedAt = progress.step("Discovering project sets");
1935
2546
  const executions = await runtime.getProjectSetExecutions(projectConfig, datasource);
@@ -1942,7 +2553,11 @@ export async function exportCatalog(
1942
2553
  const setIndexes: Record<string, CatalogSetIndex> = {};
1943
2554
 
1944
2555
  stepStartedAt = progress.step("Writing project history");
1945
- await writeHistoryPages(path.join(dataDirectoryPath, "project", "history"), historyIndex.entries);
2556
+ await writeHistoryPages(
2557
+ writer,
2558
+ path.join(dataDirectoryPath, "project", "history"),
2559
+ historyIndex.entries,
2560
+ );
1946
2561
  progress.done(stepStartedAt, `(${pluralize(historyIndex.entries.length, "entry", "entries")})`);
1947
2562
 
1948
2563
  for (const execution of executions) {
@@ -1984,7 +2599,7 @@ export async function exportCatalog(
1984
2599
  counts: Object.fromEntries(Object.keys(setIndexes).map((key) => [key, setIndexes[key].counts])),
1985
2600
  };
1986
2601
 
1987
- await writeJson(path.join(dataDirectoryPath, "manifest.json"), manifest);
2602
+ await writer.write(path.join(dataDirectoryPath, "manifest.json"), manifest);
1988
2603
  progress.done(stepStartedAt);
1989
2604
 
1990
2605
  progress.complete();
@@ -2041,11 +2656,34 @@ function injectCatalogLiveReloadClient(html: string) {
2041
2656
  return `${html}${script}`;
2042
2657
  }
2043
2658
 
2044
- function createProjectWatcher(
2659
+ function getCatalogInputWatchPaths(rootDirectoryPath: string, projectConfig: any) {
2660
+ const paths = [path.join(rootDirectoryPath, "messagevisor.config.js")];
2661
+
2662
+ if (projectConfig.sets) {
2663
+ paths.push(projectConfig.setsDirectoryPath);
2664
+ return paths;
2665
+ }
2666
+
2667
+ paths.push(
2668
+ projectConfig.localesDirectoryPath,
2669
+ projectConfig.messagesDirectoryPath,
2670
+ projectConfig.attributesDirectoryPath,
2671
+ projectConfig.segmentsDirectoryPath,
2672
+ projectConfig.targetsDirectoryPath,
2673
+ projectConfig.testsDirectoryPath,
2674
+ );
2675
+
2676
+ return paths.filter((entry): entry is string => typeof entry === "string" && entry.length > 0);
2677
+ }
2678
+
2679
+ function createCatalogInputWatcher(
2045
2680
  rootDirectoryPath: string,
2681
+ projectConfig: any,
2046
2682
  ignoredDirectoryPaths: string[],
2047
- onChange: (changedPath: string) => void,
2683
+ onChange: (changedPaths: string[]) => void,
2048
2684
  ) {
2685
+ const watchPaths = getCatalogInputWatchPaths(rootDirectoryPath, projectConfig);
2686
+
2049
2687
  function shouldIgnore(targetPath: string) {
2050
2688
  const resolvedTargetPath = path.resolve(targetPath);
2051
2689
 
@@ -2059,7 +2697,24 @@ function createProjectWatcher(
2059
2697
  });
2060
2698
  }
2061
2699
 
2062
- function collectSnapshotEntries(directoryPath: string, snapshotEntries: string[]) {
2700
+ function shouldWatch(targetPath: string) {
2701
+ const resolvedTargetPath = path.resolve(targetPath);
2702
+
2703
+ if (shouldIgnore(resolvedTargetPath)) {
2704
+ return false;
2705
+ }
2706
+
2707
+ return watchPaths.some((watchPath) => {
2708
+ const resolvedWatchPath = path.resolve(watchPath);
2709
+
2710
+ return (
2711
+ resolvedTargetPath === resolvedWatchPath ||
2712
+ resolvedTargetPath.startsWith(`${resolvedWatchPath}${path.sep}`)
2713
+ );
2714
+ });
2715
+ }
2716
+
2717
+ function collectSnapshotEntries(directoryPath: string, snapshotEntries: Map<string, string>) {
2063
2718
  if (shouldIgnore(directoryPath)) {
2064
2719
  return;
2065
2720
  }
@@ -2090,8 +2745,7 @@ function createProjectWatcher(
2090
2745
 
2091
2746
  try {
2092
2747
  const stat = fs.statSync(entryPath);
2093
- const relativePath = path.relative(rootDirectoryPath, entryPath);
2094
- snapshotEntries.push(`${relativePath}:${stat.size}:${stat.mtimeMs}`);
2748
+ snapshotEntries.set(entryPath, `${stat.size}:${stat.mtimeMs}`);
2095
2749
  } catch {
2096
2750
  // Ignore transient editor save races.
2097
2751
  }
@@ -2099,26 +2753,108 @@ function createProjectWatcher(
2099
2753
  }
2100
2754
 
2101
2755
  function createSnapshot() {
2102
- const snapshotEntries: string[] = [];
2103
- collectSnapshotEntries(rootDirectoryPath, snapshotEntries);
2104
- snapshotEntries.sort();
2105
- return snapshotEntries.join("|");
2756
+ const snapshotEntries = new Map<string, string>();
2757
+
2758
+ for (const watchPath of watchPaths) {
2759
+ if (!fs.existsSync(watchPath)) {
2760
+ continue;
2761
+ }
2762
+
2763
+ const stat = fs.statSync(watchPath);
2764
+
2765
+ if (stat.isFile()) {
2766
+ snapshotEntries.set(watchPath, `${stat.size}:${stat.mtimeMs}`);
2767
+ continue;
2768
+ }
2769
+
2770
+ collectSnapshotEntries(watchPath, snapshotEntries);
2771
+ }
2772
+
2773
+ return snapshotEntries;
2106
2774
  }
2107
2775
 
2108
- let previousSnapshot = createSnapshot();
2109
- const interval = setInterval(() => {
2110
- const nextSnapshot = createSnapshot();
2776
+ function getSnapshotChanges(previous: Map<string, string>, next: Map<string, string>) {
2777
+ const changedPaths = new Set<string>();
2111
2778
 
2112
- if (nextSnapshot === previousSnapshot) {
2113
- return;
2779
+ for (const [filePath, signature] of Array.from(next.entries())) {
2780
+ if (previous.get(filePath) !== signature) {
2781
+ changedPaths.add(filePath);
2782
+ }
2114
2783
  }
2115
2784
 
2116
- previousSnapshot = nextSnapshot;
2117
- onChange(rootDirectoryPath);
2118
- }, 250);
2785
+ for (const filePath of Array.from(previous.keys())) {
2786
+ if (!next.has(filePath)) {
2787
+ changedPaths.add(filePath);
2788
+ }
2789
+ }
2790
+
2791
+ return Array.from(changedPaths);
2792
+ }
2793
+
2794
+ function createPollingWatcher() {
2795
+ let previousSnapshot = createSnapshot();
2796
+ const interval = setInterval(() => {
2797
+ const nextSnapshot = createSnapshot();
2798
+ const changedPaths = getSnapshotChanges(previousSnapshot, nextSnapshot).filter(shouldWatch);
2799
+
2800
+ previousSnapshot = nextSnapshot;
2801
+
2802
+ if (changedPaths.length === 0) {
2803
+ return;
2804
+ }
2805
+
2806
+ onChange(changedPaths);
2807
+ }, 1000);
2808
+
2809
+ return () => {
2810
+ clearInterval(interval);
2811
+ };
2812
+ }
2813
+
2814
+ const watchers: fs.FSWatcher[] = [];
2815
+ let nativeWatcherFailed = false;
2816
+
2817
+ for (const watchPath of watchPaths) {
2818
+ if (!fs.existsSync(watchPath)) {
2819
+ continue;
2820
+ }
2821
+
2822
+ try {
2823
+ const stat = fs.statSync(watchPath);
2824
+ const directoryPath = stat.isDirectory() ? watchPath : path.dirname(watchPath);
2825
+ const watcher = fs.watch(
2826
+ directoryPath,
2827
+ { recursive: stat.isDirectory() },
2828
+ (_eventType, filename) => {
2829
+ const changedPath = filename
2830
+ ? path.resolve(directoryPath, filename.toString())
2831
+ : directoryPath;
2832
+
2833
+ if (shouldWatch(changedPath)) {
2834
+ onChange([changedPath]);
2835
+ }
2836
+ },
2837
+ );
2838
+
2839
+ watchers.push(watcher);
2840
+ } catch (_error) {
2841
+ nativeWatcherFailed = true;
2842
+ break;
2843
+ }
2844
+ }
2845
+
2846
+ if (nativeWatcherFailed || watchers.length === 0) {
2847
+ for (const watcher of watchers) {
2848
+ watcher.close();
2849
+ }
2850
+
2851
+ return createPollingWatcher();
2852
+ }
2119
2853
 
2120
2854
  return () => {
2121
- clearInterval(interval);
2855
+ for (const watcher of watchers) {
2856
+ watcher.close();
2857
+ }
2122
2858
  };
2123
2859
  }
2124
2860
 
@@ -2269,6 +3005,11 @@ function isWithDuplicatesEnabled(parsed: CatalogPluginParsedOptions) {
2269
3005
  return parsed.withDuplicates === true || parsed["with-duplicates"] === true;
2270
3006
  }
2271
3007
 
3008
+ export const __catalogDevInternals = {
3009
+ classifyCatalogDevChanges,
3010
+ getCatalogInputWatchPaths,
3011
+ };
3012
+
2272
3013
  export function createCatalogPlugin(
2273
3014
  runtime: CatalogRuntime,
2274
3015
  api: ReturnType<typeof createCatalogApi> = createCatalogApi(runtime),
@@ -2282,11 +3023,18 @@ export function createCatalogPlugin(
2282
3023
  const withDuplicates = isWithDuplicatesEnabled(parsed);
2283
3024
 
2284
3025
  if (!parsed.subcommand) {
3026
+ const outputDirectoryPath = parsed.outDir
3027
+ ? path.resolve(rootDirectoryPath, parsed.outDir)
3028
+ : projectConfig.catalogDirectoryPath;
3029
+ const devSession = await createCatalogDevSession(rootDirectoryPath, projectConfig, {
3030
+ outDir: parsed.outDir,
3031
+ });
2285
3032
  await api.exportCatalog(rootDirectoryPath, projectConfig, datasource, {
2286
3033
  outDir: parsed.outDir,
2287
3034
  copyAssets: !parsed.noAssets,
2288
3035
  browserRouter,
2289
3036
  dev: true,
3037
+ devSession,
2290
3038
  withTranslationSearch,
2291
3039
  withDuplicates,
2292
3040
  });
@@ -2297,9 +3045,6 @@ export function createCatalogPlugin(
2297
3045
  liveReload: true,
2298
3046
  });
2299
3047
 
2300
- const outputDirectoryPath = parsed.outDir
2301
- ? path.resolve(rootDirectoryPath, parsed.outDir)
2302
- : projectConfig.catalogDirectoryPath;
2303
3048
  const ignoredDirectoryPaths = [
2304
3049
  path.join(rootDirectoryPath, ".git"),
2305
3050
  path.join(rootDirectoryPath, "node_modules"),
@@ -2311,29 +3056,77 @@ export function createCatalogPlugin(
2311
3056
  outputDirectoryPath,
2312
3057
  ];
2313
3058
  let exportInFlight = false;
2314
- let exportQueued = false;
2315
- let queuedReason: string | null = null;
3059
+ let queuedChanges: string[] = [];
3060
+ let pendingChanges: string[] = [];
2316
3061
  let debounceTimer: ReturnType<typeof setTimeout> | null = null;
2317
3062
 
2318
- const runExportAndReload = async (reason: string) => {
3063
+ const runRebuildAndReload = async (changedPaths: string[]) => {
2319
3064
  if (exportInFlight) {
2320
- exportQueued = true;
2321
- queuedReason = queuedReason || reason;
3065
+ queuedChanges.push(...changedPaths);
2322
3066
  return;
2323
3067
  }
2324
3068
 
2325
3069
  exportInFlight = true;
2326
- console.log(`\n[catalog] Re-exporting because ${reason}`);
2327
-
2328
- try {
2329
- await api.exportCatalog(rootDirectoryPath, projectConfig, datasource, {
2330
- outDir: parsed.outDir,
2331
- copyAssets: !parsed.noAssets,
2332
- browserRouter,
2333
- dev: true,
3070
+ const request = classifyCatalogDevChanges(
3071
+ rootDirectoryPath,
3072
+ projectConfig,
3073
+ changedPaths,
3074
+ {
2334
3075
  withTranslationSearch,
2335
3076
  withDuplicates,
2336
- });
3077
+ },
3078
+ );
3079
+ console.log(`\n[catalog] Rebuilding (${request.kind}) because ${request.reason}`);
3080
+
3081
+ try {
3082
+ let handled = false;
3083
+
3084
+ if (request.kind === "message") {
3085
+ const [execution] = await runtime.getProjectSetExecutions(
3086
+ projectConfig,
3087
+ datasource,
3088
+ request.set,
3089
+ );
3090
+ handled = await tryRebuildCatalogMessage(
3091
+ runtime,
3092
+ rootDirectoryPath,
3093
+ projectConfig,
3094
+ execution.projectConfig,
3095
+ execution.datasource,
3096
+ devSession,
3097
+ request,
3098
+ );
3099
+ }
3100
+
3101
+ if (!handled && request.kind === "set" && request.set) {
3102
+ handled = await rebuildCatalogSetForDev(
3103
+ runtime,
3104
+ rootDirectoryPath,
3105
+ projectConfig,
3106
+ datasource,
3107
+ devSession,
3108
+ {
3109
+ set: request.set,
3110
+ browserRouter,
3111
+ withTranslationSearch,
3112
+ withDuplicates,
3113
+ },
3114
+ );
3115
+ }
3116
+
3117
+ if (!handled) {
3118
+ await api.exportCatalog(rootDirectoryPath, projectConfig, datasource, {
3119
+ outDir: parsed.outDir,
3120
+ copyAssets: false,
3121
+ preserveAssets: true,
3122
+ browserRouter,
3123
+ dev: true,
3124
+ devSession,
3125
+ withTranslationSearch,
3126
+ withDuplicates,
3127
+ });
3128
+ }
3129
+
2337
3130
  server.triggerReload();
2338
3131
  } catch (error) {
2339
3132
  console.error("[catalog] Export failed during watch mode");
@@ -2341,28 +3134,30 @@ export function createCatalogPlugin(
2341
3134
  } finally {
2342
3135
  exportInFlight = false;
2343
3136
 
2344
- if (exportQueued) {
2345
- const nextReason = queuedReason || "more project changes";
2346
- exportQueued = false;
2347
- queuedReason = null;
2348
- void runExportAndReload(nextReason);
3137
+ if (queuedChanges.length > 0) {
3138
+ const nextChanges = queuedChanges;
3139
+ queuedChanges = [];
3140
+ void runRebuildAndReload(nextChanges);
2349
3141
  }
2350
3142
  }
2351
3143
  };
2352
3144
 
2353
- const stopWatchingProject = createProjectWatcher(
3145
+ const stopWatchingProject = createCatalogInputWatcher(
2354
3146
  rootDirectoryPath,
3147
+ projectConfig,
2355
3148
  ignoredDirectoryPaths,
2356
- (changedPath) => {
2357
- const reason = `project change in ${path.relative(rootDirectoryPath, changedPath) || "."}`;
3149
+ (changedPaths) => {
3150
+ pendingChanges.push(...changedPaths);
2358
3151
 
2359
3152
  if (debounceTimer) {
2360
3153
  clearTimeout(debounceTimer);
2361
3154
  }
2362
3155
  debounceTimer = setTimeout(() => {
3156
+ const nextChanges = Array.from(new Set(pendingChanges));
3157
+ pendingChanges = [];
2363
3158
  debounceTimer = null;
2364
- void runExportAndReload(reason);
2365
- }, 150);
3159
+ void runRebuildAndReload(nextChanges);
3160
+ }, 250);
2366
3161
  },
2367
3162
  );
2368
3163