@messagevisor/catalog 0.4.0 → 0.6.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,117 @@ 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
+ done(startedAt: number, detail?: string) {
108
+ const suffix = detail ? ` ${detail}` : "";
109
+ console.log(CLI_FORMAT_DIM, ` done in ${prettyDuration(Date.now() - startedAt)}${suffix}`);
110
+ }
111
+
112
+ setStart(set: string | undefined) {
113
+ console.log("");
114
+ if (set) {
115
+ console.log(CLI_FORMAT_BOLD, `Set "${set}"`);
116
+ } else {
117
+ console.log(CLI_FORMAT_BOLD, "Root catalog");
118
+ }
119
+ return Date.now();
120
+ }
121
+
122
+ complete() {
123
+ console.log("");
124
+ console.log(
125
+ CLI_FORMAT_GREEN,
126
+ `Catalog exported to ${formatCatalogPath(this.rootDirectoryPath, this.outputDirectoryPath)}`,
127
+ );
128
+ console.log(CLI_FORMAT_BOLD, `Time: ${prettyDuration(Date.now() - this.startedAt)}`);
129
+ }
130
+ }
131
+
21
132
  export interface CatalogPluginParsedOptions {
22
133
  _: string[];
23
134
  [key: string]: any;
@@ -223,6 +334,7 @@ export interface CatalogExportOptions {
223
334
  dev?: boolean;
224
335
  devEditors?: CatalogDevEditor[];
225
336
  withTranslationSearch?: boolean;
337
+ withDuplicates?: boolean;
226
338
  }
227
339
 
228
340
  export interface CatalogServeOptions {
@@ -247,6 +359,8 @@ interface CatalogBuildContext {
247
359
  devEditors: CatalogDevEditor[];
248
360
  duplicateResultsBySet: Record<string, CatalogDuplicateTranslationsSetResult>;
249
361
  withTranslationSearch: boolean;
362
+ withDuplicates: boolean;
363
+ progress: CatalogProgressReporter;
250
364
  }
251
365
 
252
366
  interface SourceFileInfo {
@@ -1168,6 +1282,8 @@ async function buildSetCatalog(
1168
1282
  outputRelativeDirectory: string,
1169
1283
  ) {
1170
1284
  const outputDirectoryPath = path.join(context.dataDirectoryPath, outputRelativeDirectory);
1285
+ const setStartedAt = context.progress.setStart(set);
1286
+ const entitiesStartedAt = context.progress.step("Processing entities");
1171
1287
  const [localeKeys, messageKeys, attributeKeys, segmentKeys, targetKeys] = await Promise.all([
1172
1288
  datasource.listLocales(),
1173
1289
  datasource.listMessages(),
@@ -1182,6 +1298,18 @@ async function buildSetCatalog(
1182
1298
  readAll<Segment>(segmentKeys, (key) => datasource.readSegment(key)),
1183
1299
  readAll<Target>(targetKeys, (key) => datasource.readTarget(key)),
1184
1300
  ]);
1301
+ context.progress.done(
1302
+ entitiesStartedAt,
1303
+ `(${[
1304
+ pluralize(localeKeys.length, "locale"),
1305
+ pluralize(messageKeys.length, "message"),
1306
+ pluralize(attributeKeys.length, "attribute"),
1307
+ pluralize(segmentKeys.length, "segment"),
1308
+ pluralize(targetKeys.length, "target"),
1309
+ ].join(", ")})`,
1310
+ );
1311
+
1312
+ const relationshipsStartedAt = context.progress.step("Mapping relationships");
1185
1313
  const messageTargets: Record<string, string[]> = {};
1186
1314
  const targetMessages: Record<string, string[]> = {};
1187
1315
  const localeTargets: Record<string, Set<string>> = {};
@@ -1282,6 +1410,7 @@ async function buildSetCatalog(
1282
1410
  }
1283
1411
  }
1284
1412
  }
1413
+ context.progress.done(relationshipsStartedAt);
1285
1414
 
1286
1415
  const history = set ? context.historyIndex.bySet[set] || [] : context.historyIndex.entries;
1287
1416
  const localeDirections = getLocaleDirections(locales);
@@ -1310,8 +1439,11 @@ async function buildSetCatalog(
1310
1439
  },
1311
1440
  };
1312
1441
 
1442
+ const historyStartedAt = context.progress.step("Writing history pages");
1313
1443
  await writeHistoryPages(path.join(outputDirectoryPath, "history"), history);
1444
+ context.progress.done(historyStartedAt, `(${pluralize(history.length, "entry", "entries")})`);
1314
1445
 
1446
+ const examplesStartedAt = context.progress.step("Evaluating examples");
1315
1447
  const evaluatedMessageExamplesByKey = (
1316
1448
  await context.runtime.resolveExamples(projectConfig, datasource, {
1317
1449
  onlyMessages: true,
@@ -1345,7 +1477,21 @@ async function buildSetCatalog(
1345
1477
  });
1346
1478
  return accumulator;
1347
1479
  }, {});
1480
+ context.progress.done(
1481
+ examplesStartedAt,
1482
+ `(${pluralize(
1483
+ Object.values(evaluatedMessageExamplesByKey).reduce(
1484
+ (total, items) => total + items.length,
1485
+ 0,
1486
+ ),
1487
+ "message example",
1488
+ )}, ${pluralize(
1489
+ Object.values(evaluatedLocaleExamplesByKey).reduce((total, items) => total + items.length, 0),
1490
+ "locale example",
1491
+ )})`,
1492
+ );
1348
1493
 
1494
+ const localesStartedAt = context.progress.step("Writing locales");
1349
1495
  for (const localeKey of localeKeys) {
1350
1496
  const locale = locales[localeKey];
1351
1497
  const sourceFileInfo = getSourceFileInfo(
@@ -1383,15 +1529,25 @@ async function buildSetCatalog(
1383
1529
  path.join(outputDirectoryPath, "entities", "locale", `${encodeKey(localeKey)}.json`),
1384
1530
  detail,
1385
1531
  );
1386
- await writeJson(
1387
- path.join(outputDirectoryPath, "duplicates", "locales", `${encodeKey(localeKey)}.json`),
1388
- toLocaleDuplicatesFile(localeKey, duplicatesByLocale),
1389
- );
1390
1532
  await writeHistoryPages(
1391
1533
  path.join(outputDirectoryPath, "history", "locale", encodeKey(localeKey)),
1392
1534
  getHistoryForEntity(context.historyIndex, "locale", localeKey, set || undefined),
1393
1535
  );
1394
1536
  }
1537
+ context.progress.done(localesStartedAt, `(${pluralize(localeKeys.length, "locale")})`);
1538
+
1539
+ if (context.withDuplicates) {
1540
+ const duplicatesStartedAt = context.progress.step("Writing duplicate reports");
1541
+
1542
+ for (const localeKey of localeKeys) {
1543
+ await writeJson(
1544
+ path.join(outputDirectoryPath, "duplicates", "locales", `${encodeKey(localeKey)}.json`),
1545
+ toLocaleDuplicatesFile(localeKey, duplicatesByLocale),
1546
+ );
1547
+ }
1548
+
1549
+ context.progress.done(duplicatesStartedAt, `(${pluralize(localeKeys.length, "locale")})`);
1550
+ }
1395
1551
 
1396
1552
  // translationShards[3charPrefix][messageKey] = Set<lowercased value>
1397
1553
  const translationShards: Record<string, Record<string, Set<string>>> = {};
@@ -1411,6 +1567,7 @@ async function buildSetCatalog(
1411
1567
  }
1412
1568
  }
1413
1569
 
1570
+ const messagesStartedAt = context.progress.step("Writing messages");
1414
1571
  for (const messageKey of messageKeys) {
1415
1572
  const message = messages[messageKey];
1416
1573
  const overrides = (message.overrides || []).map((override: Override) => {
@@ -1465,22 +1622,6 @@ async function buildSetCatalog(
1465
1622
  }
1466
1623
  const overrideLocalesList = sortStrings(Array.from(overrideLocalesSet));
1467
1624
 
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
1625
  index.entities.message.push(
1485
1626
  getEntitySummary(message, "message", messageKey, context.historyIndex, set || undefined, {
1486
1627
  targets: sortStrings(messageTargets[messageKey] || []),
@@ -1497,8 +1638,29 @@ async function buildSetCatalog(
1497
1638
  getHistoryForEntity(context.historyIndex, "message", messageKey, set || undefined),
1498
1639
  );
1499
1640
  }
1641
+ context.progress.done(messagesStartedAt, `(${pluralize(messageKeys.length, "message")})`);
1500
1642
 
1501
1643
  if (context.withTranslationSearch) {
1644
+ const translationSearchStartedAt = context.progress.step("Building translation search shards");
1645
+
1646
+ for (const messageKey of messageKeys) {
1647
+ const message = messages[messageKey];
1648
+
1649
+ for (const localeKey of localeKeys) {
1650
+ const row = resolveTranslationRow(message.translations, localeKey, locales);
1651
+ if (row.source !== "missing" && row.value) {
1652
+ addToTranslationShard(messageKey, row.value);
1653
+ }
1654
+
1655
+ for (const override of message.overrides || []) {
1656
+ const overrideRow = resolveTranslationRow(override.translations, localeKey, locales);
1657
+ if (overrideRow.source !== "missing" && overrideRow.value) {
1658
+ addToTranslationShard(messageKey, overrideRow.value);
1659
+ }
1660
+ }
1661
+ }
1662
+ }
1663
+
1502
1664
  for (const [prefix, messageMap] of Object.entries(translationShards)) {
1503
1665
  const shardData: Record<string, string[]> = {};
1504
1666
  for (const [msgKey, valueSet] of Object.entries(messageMap)) {
@@ -1506,8 +1668,13 @@ async function buildSetCatalog(
1506
1668
  }
1507
1669
  await writeJson(path.join(outputDirectoryPath, "translations", `${prefix}.json`), shardData);
1508
1670
  }
1671
+ context.progress.done(
1672
+ translationSearchStartedAt,
1673
+ `(${pluralize(Object.keys(translationShards).length, "shard")})`,
1674
+ );
1509
1675
  }
1510
1676
 
1677
+ const attributesStartedAt = context.progress.step("Writing attributes");
1511
1678
  for (const attributeKey of attributeKeys) {
1512
1679
  const attribute = attributes[attributeKey];
1513
1680
  const sourceFileInfo = getSourceFileInfo(
@@ -1556,7 +1723,9 @@ async function buildSetCatalog(
1556
1723
  getHistoryForEntity(context.historyIndex, "attribute", attributeKey, set || undefined),
1557
1724
  );
1558
1725
  }
1726
+ context.progress.done(attributesStartedAt, `(${pluralize(attributeKeys.length, "attribute")})`);
1559
1727
 
1728
+ const segmentsStartedAt = context.progress.step("Writing segments");
1560
1729
  for (const segmentKey of segmentKeys) {
1561
1730
  const segment = segments[segmentKey];
1562
1731
  const usedAttributes = new Set<string>();
@@ -1595,7 +1764,9 @@ async function buildSetCatalog(
1595
1764
  getHistoryForEntity(context.historyIndex, "segment", segmentKey, set || undefined),
1596
1765
  );
1597
1766
  }
1767
+ context.progress.done(segmentsStartedAt, `(${pluralize(segmentKeys.length, "segment")})`);
1598
1768
 
1769
+ const targetsStartedAt = context.progress.step("Writing targets");
1599
1770
  for (const targetKey of targetKeys) {
1600
1771
  const target = targets[targetKey];
1601
1772
  const targetLocaleKeys = target.locales?.length ? target.locales : localeKeys;
@@ -1641,12 +1812,16 @@ async function buildSetCatalog(
1641
1812
  getHistoryForEntity(context.historyIndex, "target", targetKey, set || undefined),
1642
1813
  );
1643
1814
  }
1815
+ context.progress.done(targetsStartedAt, `(${pluralize(targetKeys.length, "target")})`);
1644
1816
 
1817
+ const indexStartedAt = context.progress.step("Writing catalog index");
1645
1818
  for (const type of Object.keys(index.entities) as CatalogEntityType[]) {
1646
1819
  index.entities[type].sort((a, b) => a.key.localeCompare(b.key));
1647
1820
  }
1648
1821
 
1649
1822
  await writeJson(path.join(outputDirectoryPath, "index.json"), index);
1823
+ context.progress.done(indexStartedAt);
1824
+ context.progress.done(setStartedAt, "total");
1650
1825
 
1651
1826
  return index;
1652
1827
  }
@@ -1685,20 +1860,64 @@ export async function exportCatalog(
1685
1860
  : projectConfig.catalogDirectoryPath;
1686
1861
  const dataDirectoryPath = path.join(outputDirectoryPath, "data");
1687
1862
  const withTranslationSearch = options.withTranslationSearch === true;
1863
+ const withDuplicates = options.withDuplicates === true;
1864
+ const progress = new CatalogProgressReporter(rootDirectoryPath, outputDirectoryPath);
1865
+
1866
+ progress.start({
1867
+ browserRouter: options.browserRouter !== false,
1868
+ sets: projectConfig.sets === true,
1869
+ features: [
1870
+ ...(withTranslationSearch ? ["translation search"] : []),
1871
+ ...(withDuplicates ? ["duplicates"] : []),
1872
+ ],
1873
+ });
1688
1874
 
1875
+ let stepStartedAt = progress.step("Preparing output directory");
1689
1876
  await fs.promises.rm(outputDirectoryPath, { recursive: true, force: true });
1690
1877
  await fs.promises.mkdir(dataDirectoryPath, { recursive: true });
1878
+ progress.done(stepStartedAt);
1691
1879
 
1692
1880
  if (options.copyAssets !== false) {
1881
+ stepStartedAt = progress.step("Copying Catalog UI assets");
1693
1882
  await copyCatalogAssets(outputDirectoryPath);
1883
+ progress.done(stepStartedAt);
1694
1884
  }
1695
1885
 
1696
1886
  const devEditors = options.dev ? options.devEditors || detectDevEditors() : [];
1887
+ stepStartedAt = progress.step("Reading Git history");
1697
1888
  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
- );
1889
+ progress.done(stepStartedAt, `(${pluralize(historyIndex.entries.length, "commit")})`);
1890
+
1891
+ stepStartedAt = progress.step("Resolving repository links");
1892
+ const links = getRepoLinks(rootDirectoryPath);
1893
+ progress.done(stepStartedAt);
1894
+
1895
+ let duplicateResultsBySet: Record<string, CatalogDuplicateTranslationsSetResult> = {};
1896
+ if (withDuplicates) {
1897
+ stepStartedAt = progress.step("Scanning duplicate translations");
1898
+ duplicateResultsBySet = Object.fromEntries(
1899
+ (await runtime.findDuplicateTranslations(projectConfig, datasource)).results.map((result) => [
1900
+ getDuplicateSetKey(result.set),
1901
+ result,
1902
+ ]),
1903
+ );
1904
+ progress.done(
1905
+ stepStartedAt,
1906
+ `(${pluralize(
1907
+ Object.values(duplicateResultsBySet).reduce(
1908
+ (total, result) =>
1909
+ total +
1910
+ result.locales.reduce(
1911
+ (localeTotal, localeResult) => localeTotal + localeResult.duplicateValues.length,
1912
+ 0,
1913
+ ),
1914
+ 0,
1915
+ ),
1916
+ "duplicate value",
1917
+ )})`,
1918
+ );
1919
+ }
1920
+
1702
1921
  const context: CatalogBuildContext = {
1703
1922
  rootDirectoryPath,
1704
1923
  repositoryRootDirectoryPath: getRepositoryRootDirectoryPath(rootDirectoryPath),
@@ -1709,11 +1928,22 @@ export async function exportCatalog(
1709
1928
  devEditors,
1710
1929
  duplicateResultsBySet,
1711
1930
  withTranslationSearch,
1931
+ withDuplicates,
1932
+ progress,
1712
1933
  };
1934
+ stepStartedAt = progress.step("Discovering project sets");
1713
1935
  const executions = await runtime.getProjectSetExecutions(projectConfig, datasource);
1936
+ progress.done(
1937
+ stepStartedAt,
1938
+ projectConfig.sets
1939
+ ? `(${executions.map((execution) => execution.set).join(", ") || "none"})`
1940
+ : "(root)",
1941
+ );
1714
1942
  const setIndexes: Record<string, CatalogSetIndex> = {};
1715
1943
 
1944
+ stepStartedAt = progress.step("Writing project history");
1716
1945
  await writeHistoryPages(path.join(dataDirectoryPath, "project", "history"), historyIndex.entries);
1946
+ progress.done(stepStartedAt, `(${pluralize(historyIndex.entries.length, "entry", "entries")})`);
1717
1947
 
1718
1948
  for (const execution of executions) {
1719
1949
  const outputRelativeDirectory = projectConfig.sets ? path.join("sets", execution.set) : "root";
@@ -1726,6 +1956,7 @@ export async function exportCatalog(
1726
1956
  );
1727
1957
  }
1728
1958
 
1959
+ stepStartedAt = progress.step("Writing manifest");
1729
1960
  const manifest = {
1730
1961
  schemaVersion: CATALOG_SCHEMA_VERSION,
1731
1962
  generatedAt: new Date().toISOString(),
@@ -1735,8 +1966,9 @@ export async function exportCatalog(
1735
1966
  dev: options.dev ? { editors: devEditors } : undefined,
1736
1967
  features: {
1737
1968
  translationSearch: withTranslationSearch,
1969
+ duplicates: withDuplicates,
1738
1970
  },
1739
- links: getRepoLinks(rootDirectoryPath),
1971
+ links,
1740
1972
  paths: {
1741
1973
  projectHistory: "data/project/history/page-1.json",
1742
1974
  root: projectConfig.sets ? undefined : "data/root/index.json",
@@ -1753,8 +1985,9 @@ export async function exportCatalog(
1753
1985
  };
1754
1986
 
1755
1987
  await writeJson(path.join(dataDirectoryPath, "manifest.json"), manifest);
1988
+ progress.done(stepStartedAt);
1756
1989
 
1757
- console.log(`Catalog exported to ${outputDirectoryPath}`);
1990
+ progress.complete();
1758
1991
 
1759
1992
  return {
1760
1993
  outputDirectoryPath,
@@ -2032,6 +2265,10 @@ function isWithTranslationSearchEnabled(parsed: CatalogPluginParsedOptions) {
2032
2265
  return parsed.withTranslationSearch === true || parsed["with-translation-search"] === true;
2033
2266
  }
2034
2267
 
2268
+ function isWithDuplicatesEnabled(parsed: CatalogPluginParsedOptions) {
2269
+ return parsed.withDuplicates === true || parsed["with-duplicates"] === true;
2270
+ }
2271
+
2035
2272
  export function createCatalogPlugin(
2036
2273
  runtime: CatalogRuntime,
2037
2274
  api: ReturnType<typeof createCatalogApi> = createCatalogApi(runtime),
@@ -2042,6 +2279,7 @@ export function createCatalogPlugin(
2042
2279
  const allowedSubcommands = ["export", "serve"];
2043
2280
  const browserRouter = !(parsed.hashRouter || parsed["hash-router"]);
2044
2281
  const withTranslationSearch = isWithTranslationSearchEnabled(parsed);
2282
+ const withDuplicates = isWithDuplicatesEnabled(parsed);
2045
2283
 
2046
2284
  if (!parsed.subcommand) {
2047
2285
  await api.exportCatalog(rootDirectoryPath, projectConfig, datasource, {
@@ -2050,6 +2288,7 @@ export function createCatalogPlugin(
2050
2288
  browserRouter,
2051
2289
  dev: true,
2052
2290
  withTranslationSearch,
2291
+ withDuplicates,
2053
2292
  });
2054
2293
  const server = await api.serveCatalog(rootDirectoryPath, projectConfig, datasource, {
2055
2294
  outDir: parsed.outDir,
@@ -2093,6 +2332,7 @@ export function createCatalogPlugin(
2093
2332
  browserRouter,
2094
2333
  dev: true,
2095
2334
  withTranslationSearch,
2335
+ withDuplicates,
2096
2336
  });
2097
2337
  server.triggerReload();
2098
2338
  } catch (error) {
@@ -2141,6 +2381,7 @@ export function createCatalogPlugin(
2141
2381
  copyAssets: !parsed.noAssets,
2142
2382
  browserRouter,
2143
2383
  withTranslationSearch,
2384
+ withDuplicates,
2144
2385
  });
2145
2386
  }
2146
2387
 
@@ -1149,7 +1149,8 @@ export function EntityDetailPage() {
1149
1149
  }
1150
1150
 
1151
1151
  const entity = detail.entity as Record<string, any>;
1152
- const tabs = getTabs(type, baseRoute);
1152
+ const { manifest } = useCatalog();
1153
+ const tabs = getTabs(type, baseRoute, manifest.features?.duplicates === true);
1153
1154
 
1154
1155
  return (
1155
1156
  <div>
@@ -1178,7 +1179,7 @@ export function EntityDetailPage() {
1178
1179
  );
1179
1180
  }
1180
1181
 
1181
- function getTabs(type: string, baseRoute: string) {
1182
+ function getTabs(type: string, baseRoute: string, duplicatesEnabled = false) {
1182
1183
  const shared = [
1183
1184
  { label: "Overview", to: baseRoute, end: true },
1184
1185
  { label: "History", to: `${baseRoute}/history` },
@@ -1189,7 +1190,7 @@ function getTabs(type: string, baseRoute: string) {
1189
1190
  shared[0],
1190
1191
  { label: "Formats", to: `${baseRoute}/formats` },
1191
1192
  { label: "Examples", to: `${baseRoute}/examples` },
1192
- { label: "Duplicates", to: `${baseRoute}/duplicates` },
1193
+ ...(duplicatesEnabled ? [{ label: "Duplicates", to: `${baseRoute}/duplicates` }] : []),
1193
1194
  shared[1],
1194
1195
  ];
1195
1196
  }
@@ -2895,6 +2896,7 @@ function SortArrow(props: { active: boolean; direction: SortDirection }) {
2895
2896
 
2896
2897
  export function LocaleDuplicatesTab() {
2897
2898
  const { detail, setKey } = useEntityDetail();
2899
+ const { manifest } = useCatalog();
2898
2900
  const [searchParams, setSearchParams] = useSearchParams();
2899
2901
  const [duplicates, setDuplicates] = React.useState<LocaleDuplicates | null>(null);
2900
2902
  const [error, setError] = React.useState<string | null>(null);
@@ -2905,6 +2907,7 @@ export function LocaleDuplicatesTab() {
2905
2907
  });
2906
2908
  const localeDirection = (detail.entity as Record<string, any>).direction as string | undefined;
2907
2909
  const searchQuery = searchParams.get("q") ?? "";
2910
+ const duplicatesEnabled = manifest.features?.duplicates === true;
2908
2911
 
2909
2912
  function toggleDuplicateValue(duplicate: DuplicateTranslationValue) {
2910
2913
  const duplicateHash = hashTranslationValue(duplicate.value);
@@ -2922,6 +2925,10 @@ export function LocaleDuplicatesTab() {
2922
2925
  }
2923
2926
 
2924
2927
  React.useEffect(() => {
2928
+ if (!duplicatesEnabled) {
2929
+ return;
2930
+ }
2931
+
2925
2932
  let cancelled = false;
2926
2933
 
2927
2934
  setDuplicates(null);
@@ -2943,7 +2950,7 @@ export function LocaleDuplicatesTab() {
2943
2950
  return () => {
2944
2951
  cancelled = true;
2945
2952
  };
2946
- }, [detail.key, setKey]);
2953
+ }, [detail.key, duplicatesEnabled, setKey]);
2947
2954
 
2948
2955
  React.useEffect(() => {
2949
2956
  if (!duplicates || typeof window === "undefined") {
@@ -2973,6 +2980,10 @@ export function LocaleDuplicatesTab() {
2973
2980
 
2974
2981
  useScrollToHash([duplicates?.duplicateValues.length, expandedDuplicateHashes.length]);
2975
2982
 
2983
+ if (!duplicatesEnabled) {
2984
+ return <Navigate to=".." replace />;
2985
+ }
2986
+
2976
2987
  if (error) {
2977
2988
  return <EmptyState title="Unable to load duplicate translations" description={error} />;
2978
2989
  }
package/src/types.ts CHANGED
@@ -90,6 +90,7 @@ export interface CatalogManifest {
90
90
  };
91
91
  features?: {
92
92
  translationSearch?: boolean;
93
+ duplicates?: boolean;
93
94
  };
94
95
  paths: {
95
96
  projectHistory: string;