@messagevisor/catalog 0.3.0 → 0.5.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
@@ -222,6 +222,7 @@ export interface CatalogExportOptions {
222
222
  browserRouter?: boolean;
223
223
  dev?: boolean;
224
224
  devEditors?: CatalogDevEditor[];
225
+ withTranslationSearch?: boolean;
225
226
  }
226
227
 
227
228
  export interface CatalogServeOptions {
@@ -245,6 +246,7 @@ interface CatalogBuildContext {
245
246
  runtime: CatalogRuntime;
246
247
  devEditors: CatalogDevEditor[];
247
248
  duplicateResultsBySet: Record<string, CatalogDuplicateTranslationsSetResult>;
249
+ withTranslationSearch: boolean;
248
250
  }
249
251
 
250
252
  interface SourceFileInfo {
@@ -1463,16 +1465,18 @@ async function buildSetCatalog(
1463
1465
  }
1464
1466
  const overrideLocalesList = sortStrings(Array.from(overrideLocalesSet));
1465
1467
 
1466
- // Build translation shards (direct + inherited + override, all locales combined)
1467
- for (const localeKey of localeKeys) {
1468
- const row = resolveTranslationRow(message.translations, localeKey, locales);
1469
- if (row.source !== "missing" && row.value) {
1470
- addToTranslationShard(messageKey, row.value);
1471
- }
1472
- for (const override of overrides) {
1473
- const overrideRow = resolveTranslationRow(override.translations, localeKey, locales);
1474
- if (overrideRow.source !== "missing" && overrideRow.value) {
1475
- addToTranslationShard(messageKey, overrideRow.value);
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
+ }
1476
1480
  }
1477
1481
  }
1478
1482
  }
@@ -1494,12 +1498,14 @@ async function buildSetCatalog(
1494
1498
  );
1495
1499
  }
1496
1500
 
1497
- for (const [prefix, messageMap] of Object.entries(translationShards)) {
1498
- const shardData: Record<string, string[]> = {};
1499
- for (const [msgKey, valueSet] of Object.entries(messageMap)) {
1500
- shardData[msgKey] = Array.from(valueSet);
1501
+ if (context.withTranslationSearch) {
1502
+ for (const [prefix, messageMap] of Object.entries(translationShards)) {
1503
+ const shardData: Record<string, string[]> = {};
1504
+ for (const [msgKey, valueSet] of Object.entries(messageMap)) {
1505
+ shardData[msgKey] = Array.from(valueSet);
1506
+ }
1507
+ await writeJson(path.join(outputDirectoryPath, "translations", `${prefix}.json`), shardData);
1501
1508
  }
1502
- await writeJson(path.join(outputDirectoryPath, "translations", `${prefix}.json`), shardData);
1503
1509
  }
1504
1510
 
1505
1511
  for (const attributeKey of attributeKeys) {
@@ -1678,6 +1684,7 @@ export async function exportCatalog(
1678
1684
  ? path.resolve(rootDirectoryPath, options.outDir)
1679
1685
  : projectConfig.catalogDirectoryPath;
1680
1686
  const dataDirectoryPath = path.join(outputDirectoryPath, "data");
1687
+ const withTranslationSearch = options.withTranslationSearch === true;
1681
1688
 
1682
1689
  await fs.promises.rm(outputDirectoryPath, { recursive: true, force: true });
1683
1690
  await fs.promises.mkdir(dataDirectoryPath, { recursive: true });
@@ -1701,6 +1708,7 @@ export async function exportCatalog(
1701
1708
  runtime,
1702
1709
  devEditors,
1703
1710
  duplicateResultsBySet,
1711
+ withTranslationSearch,
1704
1712
  };
1705
1713
  const executions = await runtime.getProjectSetExecutions(projectConfig, datasource);
1706
1714
  const setIndexes: Record<string, CatalogSetIndex> = {};
@@ -1725,6 +1733,9 @@ export async function exportCatalog(
1725
1733
  sets: projectConfig.sets,
1726
1734
  setKeys: projectConfig.sets ? executions.map((execution) => execution.set) : [],
1727
1735
  dev: options.dev ? { editors: devEditors } : undefined,
1736
+ features: {
1737
+ translationSearch: withTranslationSearch,
1738
+ },
1728
1739
  links: getRepoLinks(rootDirectoryPath),
1729
1740
  paths: {
1730
1741
  projectHistory: "data/project/history/page-1.json",
@@ -2017,6 +2028,10 @@ export function createCatalogApi(runtime: CatalogRuntime) {
2017
2028
  };
2018
2029
  }
2019
2030
 
2031
+ function isWithTranslationSearchEnabled(parsed: CatalogPluginParsedOptions) {
2032
+ return parsed.withTranslationSearch === true || parsed["with-translation-search"] === true;
2033
+ }
2034
+
2020
2035
  export function createCatalogPlugin(
2021
2036
  runtime: CatalogRuntime,
2022
2037
  api: ReturnType<typeof createCatalogApi> = createCatalogApi(runtime),
@@ -2026,6 +2041,7 @@ export function createCatalogPlugin(
2026
2041
  handler: async ({ rootDirectoryPath, projectConfig, datasource, parsed }) => {
2027
2042
  const allowedSubcommands = ["export", "serve"];
2028
2043
  const browserRouter = !(parsed.hashRouter || parsed["hash-router"]);
2044
+ const withTranslationSearch = isWithTranslationSearchEnabled(parsed);
2029
2045
 
2030
2046
  if (!parsed.subcommand) {
2031
2047
  await api.exportCatalog(rootDirectoryPath, projectConfig, datasource, {
@@ -2033,6 +2049,7 @@ export function createCatalogPlugin(
2033
2049
  copyAssets: !parsed.noAssets,
2034
2050
  browserRouter,
2035
2051
  dev: true,
2052
+ withTranslationSearch,
2036
2053
  });
2037
2054
  const server = await api.serveCatalog(rootDirectoryPath, projectConfig, datasource, {
2038
2055
  outDir: parsed.outDir,
@@ -2075,6 +2092,7 @@ export function createCatalogPlugin(
2075
2092
  copyAssets: !parsed.noAssets,
2076
2093
  browserRouter,
2077
2094
  dev: true,
2095
+ withTranslationSearch,
2078
2096
  });
2079
2097
  server.triggerReload();
2080
2098
  } catch (error) {
@@ -2122,6 +2140,7 @@ export function createCatalogPlugin(
2122
2140
  outDir: parsed.outDir,
2123
2141
  copyAssets: !parsed.noAssets,
2124
2142
  browserRouter,
2143
+ withTranslationSearch,
2125
2144
  });
2126
2145
  }
2127
2146
 
@@ -0,0 +1,48 @@
1
+ import type { DuplicateTranslationValue } from "../types";
2
+ import { sortDuplicateValues } from "../utils/duplicateSorting";
3
+
4
+ function duplicate(value: string, count: number): DuplicateTranslationValue {
5
+ const messageKeys = Array.from({ length: count }, (_, index) => `message.${index + 1}`);
6
+
7
+ return {
8
+ value,
9
+ messageKeys,
10
+ sources: messageKeys.map((messageKey) => ({ messageKey, locale: "en" })),
11
+ };
12
+ }
13
+
14
+ describe("Locale duplicate sorting", function () {
15
+ it("sorts by message count descending by default", function () {
16
+ const sorted = sortDuplicateValues(
17
+ [duplicate("two", 2), duplicate("four", 4), duplicate("three", 3)],
18
+ { column: "messages", direction: "desc" },
19
+ );
20
+
21
+ expect(sorted.map((item) => item.value)).toEqual(["four", "three", "two"]);
22
+ });
23
+
24
+ it("sorts duplicate values alphabetically", function () {
25
+ const sorted = sortDuplicateValues(
26
+ [duplicate("Banana", 2), duplicate("Apple", 3), duplicate("Cherry", 2)],
27
+ { column: "value", direction: "asc" },
28
+ );
29
+
30
+ expect(sorted.map((item) => item.value)).toEqual(["Apple", "Banana", "Cherry"]);
31
+ });
32
+
33
+ it("can reverse both sortable columns", function () {
34
+ expect(
35
+ sortDuplicateValues([duplicate("two", 2), duplicate("four", 4)], {
36
+ column: "messages",
37
+ direction: "asc",
38
+ }).map((item) => item.value),
39
+ ).toEqual(["two", "four"]);
40
+
41
+ expect(
42
+ sortDuplicateValues([duplicate("Apple", 2), duplicate("Banana", 2)], {
43
+ column: "value",
44
+ direction: "desc",
45
+ }).map((item) => item.value),
46
+ ).toEqual(["Banana", "Apple"]);
47
+ });
48
+ });
@@ -34,6 +34,7 @@ import { EntityKey } from "../components/ui/EntityKey";
34
34
  import { LabelValueBadge } from "../components/ui/LabelValueBadge";
35
35
  import { EmptyState } from "../components/ui/EmptyState";
36
36
  import { Input } from "../components/ui/Input";
37
+ import { SearchHighlight } from "../components/ui/SearchHighlight";
37
38
  import { FieldGrid } from "../components/details/FieldGrid";
38
39
  import { ConditionTree } from "../components/details/ConditionTree";
39
40
  import { GroupSegmentTree } from "../components/details/GroupSegmentTree";
@@ -43,6 +44,12 @@ import { UsageLinks } from "../components/details/UsageLinks";
43
44
  import { HistoryTimeline } from "../components/history/HistoryTimeline";
44
45
  import { useCatalog } from "../context/CatalogContext";
45
46
  import { hashTranslationValue } from "../utils/hashTranslationValue";
47
+ import {
48
+ getNextDuplicateValuesSort,
49
+ sortDuplicateValues,
50
+ type DuplicateValuesSort,
51
+ type SortDirection,
52
+ } from "../utils/duplicateSorting";
46
53
  import type { ParsedQuery } from "../utils/searchQuery";
47
54
  import { parseQuery } from "../utils/searchQuery";
48
55
 
@@ -94,51 +101,6 @@ function slugifyFragment(value: string) {
94
101
  .replace(/^-+|-+$/g, "");
95
102
  }
96
103
 
97
- function escapeRegExp(value: string) {
98
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
99
- }
100
-
101
- function ExamplesSearchHighlight(props: { text: string; query: string }) {
102
- const q = props.query.trim();
103
- if (!q) {
104
- return <>{props.text}</>;
105
- }
106
-
107
- const escaped = escapeRegExp(q);
108
- const regex = new RegExp(escaped, "gi");
109
- const parts: React.ReactNode[] = [];
110
- let lastIndex = 0;
111
- let key = 0;
112
-
113
- for (const match of props.text.matchAll(regex)) {
114
- if (match.index !== undefined && match.index > lastIndex) {
115
- parts.push(props.text.slice(lastIndex, match.index));
116
- }
117
-
118
- if (match.index !== undefined) {
119
- parts.push(
120
- <mark
121
- key={`hm-${match.index}-${key++}`}
122
- className={[
123
- "rounded-[3px] bg-amber-100 px-0.5 py-px text-inherit",
124
- "shadow-[inset_0_-2px_0_0_rgba(251,191,36,0.35)] ring-1 ring-amber-400/25 ring-inset",
125
- "transition-[background-color,box-shadow] duration-150",
126
- ].join(" ")}
127
- >
128
- {match[0]}
129
- </mark>,
130
- );
131
- lastIndex = match.index + match[0].length;
132
- }
133
- }
134
-
135
- if (lastIndex < props.text.length) {
136
- parts.push(props.text.slice(lastIndex));
137
- }
138
-
139
- return <>{parts}</>;
140
- }
141
-
142
104
  function isEntityPath(value: string | undefined): value is EntityPath {
143
105
  return (
144
106
  value === "locales" ||
@@ -785,7 +747,7 @@ function FormatRowsTable(props: {
785
747
  function segmentBody(segment: string) {
786
748
  return segment ? (
787
749
  highlight ? (
788
- <ExamplesSearchHighlight text={segment} query={highlightNeedle} />
750
+ <SearchHighlight text={segment} query={highlightNeedle} />
789
751
  ) : (
790
752
  segment
791
753
  )
@@ -851,11 +813,7 @@ function FormatRowsTable(props: {
851
813
  row.source === "inherited" ? "text-muted" : "",
852
814
  ].join(" ")}
853
815
  >
854
- {highlight ? (
855
- <ExamplesSearchHighlight text={valueText} query={highlightNeedle} />
856
- ) : (
857
- valueText
858
- )}
816
+ {highlight ? <SearchHighlight text={valueText} query={highlightNeedle} /> : valueText}
859
817
  </div>
860
818
  {(showInheritedBadge || showTargetBadge) && (
861
819
  <div className="flex shrink-0 flex-col items-end justify-center gap-1">
@@ -881,7 +839,7 @@ function FormatRowsTable(props: {
881
839
  function renderExampleCellContent(preview: string | undefined) {
882
840
  return preview ? (
883
841
  highlight ? (
884
- <ExamplesSearchHighlight text={preview} query={highlightNeedle} />
842
+ <SearchHighlight text={preview} query={highlightNeedle} />
885
843
  ) : (
886
844
  preview
887
845
  )
@@ -1078,7 +1036,7 @@ function FormatRowsTable(props: {
1078
1036
  <td className={flatFormatClass}>
1079
1037
  <div className="whitespace-pre-wrap [overflow-wrap:anywhere]">
1080
1038
  {highlight ? (
1081
- <ExamplesSearchHighlight text={row.path} query={highlightNeedle} />
1039
+ <SearchHighlight text={row.path} query={highlightNeedle} />
1082
1040
  ) : (
1083
1041
  row.path
1084
1042
  )}
@@ -1959,7 +1917,7 @@ function ExampleTitle(props: {
1959
1917
  }) {
1960
1918
  const titleContent =
1961
1919
  props.highlightQuery?.trim() && props.title ? (
1962
- <ExamplesSearchHighlight text={props.title} query={props.highlightQuery} />
1920
+ <SearchHighlight text={props.title} query={props.highlightQuery} />
1963
1921
  ) : (
1964
1922
  props.title
1965
1923
  );
@@ -1975,7 +1933,7 @@ function ExampleTitle(props: {
1975
1933
  {props.description?.trim() ? (
1976
1934
  props.highlightQuery?.trim() ? (
1977
1935
  <div className="text-sm text-muted whitespace-pre-wrap [overflow-wrap:anywhere]">
1978
- <ExamplesSearchHighlight text={props.description.trim()} query={props.highlightQuery} />
1936
+ <SearchHighlight text={props.description.trim()} query={props.highlightQuery} />
1979
1937
  </div>
1980
1938
  ) : (
1981
1939
  <div className="text-sm text-muted">
@@ -2009,7 +1967,7 @@ function ExampleTable(props: {
2009
1967
  .join(" ")}
2010
1968
  style={props.direction ? { unicodeBidi: "plaintext" } : undefined}
2011
1969
  >
2012
- <ExamplesSearchHighlight
1970
+ <SearchHighlight
2013
1971
  text={
2014
1972
  typeof props.evaluatedTranslation === "string"
2015
1973
  ? props.evaluatedTranslation
@@ -2420,7 +2378,7 @@ function LocaleExampleDetails(props: {
2420
2378
  to={getEntityRoute("locale", localeKey, setKey)}
2421
2379
  className="font-medium text-primary hover:underline"
2422
2380
  >
2423
- <ExamplesSearchHighlight text={localeKey} query={q} />
2381
+ <SearchHighlight text={localeKey} query={q} />
2424
2382
  </Link>
2425
2383
  ) : (
2426
2384
  <SourceLocaleLink localeKey={localeKey} />
@@ -2444,11 +2402,7 @@ function LocaleExampleDetails(props: {
2444
2402
  to={getEntityRoute("message", example.message, setKey)}
2445
2403
  className="font-medium text-primary hover:underline"
2446
2404
  >
2447
- {highlight ? (
2448
- <ExamplesSearchHighlight text={example.message} query={q} />
2449
- ) : (
2450
- example.message
2451
- )}
2405
+ {highlight ? <SearchHighlight text={example.message} query={q} /> : example.message}
2452
2406
  </Link>
2453
2407
  </InputField>
2454
2408
 
@@ -2466,7 +2420,7 @@ function LocaleExampleDetails(props: {
2466
2420
  .join(" ")}
2467
2421
  style={localeDirection ? { unicodeBidi: "plaintext" } : undefined}
2468
2422
  >
2469
- <ExamplesSearchHighlight text={example.originalTranslation} query={q} />
2423
+ <SearchHighlight text={example.originalTranslation} query={q} />
2470
2424
  </div>
2471
2425
  ) : (
2472
2426
  <TranslationValueBlock
@@ -2508,7 +2462,7 @@ function LocaleExampleDetails(props: {
2508
2462
  >
2509
2463
  {highlight ? (
2510
2464
  <pre className="max-w-full whitespace-pre-wrap rounded border border-border bg-elevated p-4 text-xs text-text [overflow-wrap:anywhere]">
2511
- <ExamplesSearchHighlight text={example.rawMessage} query={q} />
2465
+ <SearchHighlight text={example.rawMessage} query={q} />
2512
2466
  </pre>
2513
2467
  ) : (
2514
2468
  <CodeBlock value={example.rawMessage} />
@@ -2521,7 +2475,7 @@ function LocaleExampleDetails(props: {
2521
2475
  <InputField label="Values">
2522
2476
  {highlight ? (
2523
2477
  <pre className="max-w-full whitespace-pre-wrap rounded border border-border bg-elevated p-4 text-xs text-text [overflow-wrap:anywhere]">
2524
- <ExamplesSearchHighlight text={JSON.stringify(example.values, null, 2)} query={q} />
2478
+ <SearchHighlight text={JSON.stringify(example.values, null, 2)} query={q} />
2525
2479
  </pre>
2526
2480
  ) : (
2527
2481
  <JsonValueBlock value={example.values} />
@@ -2533,7 +2487,7 @@ function LocaleExampleDetails(props: {
2533
2487
  <InputField label="Context">
2534
2488
  {highlight ? (
2535
2489
  <pre className="max-w-full whitespace-pre-wrap rounded border border-border bg-elevated p-4 text-xs text-text [overflow-wrap:anywhere]">
2536
- <ExamplesSearchHighlight text={JSON.stringify(example.context, null, 2)} query={q} />
2490
+ <SearchHighlight text={JSON.stringify(example.context, null, 2)} query={q} />
2537
2491
  </pre>
2538
2492
  ) : (
2539
2493
  <JsonValueBlock value={example.context} />
@@ -2544,11 +2498,7 @@ function LocaleExampleDetails(props: {
2544
2498
  {typeof example.timeZone !== "undefined" && (
2545
2499
  <InputField label="Time zone">
2546
2500
  <span className="font-mono text-sm text-text">
2547
- {highlight ? (
2548
- <ExamplesSearchHighlight text={example.timeZone} query={q} />
2549
- ) : (
2550
- example.timeZone
2551
- )}
2501
+ {highlight ? <SearchHighlight text={example.timeZone} query={q} /> : example.timeZone}
2552
2502
  </span>
2553
2503
  </InputField>
2554
2504
  )}
@@ -2556,11 +2506,7 @@ function LocaleExampleDetails(props: {
2556
2506
  {typeof example.currency !== "undefined" && (
2557
2507
  <InputField label="Currency">
2558
2508
  <span className="font-mono text-sm text-text">
2559
- {highlight ? (
2560
- <ExamplesSearchHighlight text={example.currency} query={q} />
2561
- ) : (
2562
- example.currency
2563
- )}
2509
+ {highlight ? <SearchHighlight text={example.currency} query={q} /> : example.currency}
2564
2510
  </span>
2565
2511
  </InputField>
2566
2512
  )}
@@ -2569,7 +2515,7 @@ function LocaleExampleDetails(props: {
2569
2515
  <InputField label="Formats">
2570
2516
  {highlight ? (
2571
2517
  <pre className="max-w-full whitespace-pre-wrap rounded border border-border bg-elevated p-4 text-xs text-text [overflow-wrap:anywhere]">
2572
- <ExamplesSearchHighlight text={JSON.stringify(example.formats, null, 2)} query={q} />
2518
+ <SearchHighlight text={JSON.stringify(example.formats, null, 2)} query={q} />
2573
2519
  </pre>
2574
2520
  ) : (
2575
2521
  <JsonValueBlock value={example.formats} />
@@ -2712,14 +2658,14 @@ function LocaleExamplesCompactView(props: {
2712
2658
  onClick={() => toggleExample(exampleId)}
2713
2659
  >
2714
2660
  <td className="border-b border-border px-3 py-2 font-medium text-muted">
2715
- <ExamplesSearchHighlight
2661
+ <SearchHighlight
2716
2662
  text={getLocaleExampleCompactLabel(example)}
2717
2663
  query={props.searchQuery}
2718
2664
  />
2719
2665
  </td>
2720
2666
  <td className="min-w-0 border-b border-border px-3 py-2 text-muted">
2721
2667
  <div className="whitespace-pre-wrap [overflow-wrap:anywhere]">
2722
- <ExamplesSearchHighlight
2668
+ <SearchHighlight
2723
2669
  text={example.description || "—"}
2724
2670
  query={props.searchQuery}
2725
2671
  />
@@ -2734,7 +2680,7 @@ function LocaleExamplesCompactView(props: {
2734
2680
  style={props.localeDirection ? { unicodeBidi: "plaintext" } : undefined}
2735
2681
  >
2736
2682
  <div className="whitespace-pre-wrap [overflow-wrap:anywhere]">
2737
- <ExamplesSearchHighlight
2683
+ <SearchHighlight
2738
2684
  text={
2739
2685
  typeof example.evaluatedTranslation === "string"
2740
2686
  ? example.evaluatedTranslation
@@ -2752,7 +2698,7 @@ function LocaleExamplesCompactView(props: {
2752
2698
  <div className="group flex items-center gap-2">
2753
2699
  <h3 className="text-sm font-semibold">
2754
2700
  {props.searchQuery.trim() ? (
2755
- <ExamplesSearchHighlight
2701
+ <SearchHighlight
2756
2702
  text={getLocaleExampleTitle(example)}
2757
2703
  query={props.searchQuery}
2758
2704
  />
@@ -2939,12 +2885,24 @@ function filterDuplicateValuesBySearch(
2939
2885
  });
2940
2886
  }
2941
2887
 
2888
+ function SortArrow(props: { active: boolean; direction: SortDirection }) {
2889
+ return (
2890
+ <span className={props.active ? "text-primary" : "text-muted/60"} aria-hidden="true">
2891
+ {props.active ? (props.direction === "asc" ? "↑" : "↓") : "↕"}
2892
+ </span>
2893
+ );
2894
+ }
2895
+
2942
2896
  export function LocaleDuplicatesTab() {
2943
2897
  const { detail, setKey } = useEntityDetail();
2944
2898
  const [searchParams, setSearchParams] = useSearchParams();
2945
2899
  const [duplicates, setDuplicates] = React.useState<LocaleDuplicates | null>(null);
2946
2900
  const [error, setError] = React.useState<string | null>(null);
2947
2901
  const [expandedDuplicateHashes, setExpandedDuplicateHashes] = React.useState<string[]>([]);
2902
+ const [sort, setSort] = React.useState<DuplicateValuesSort>({
2903
+ column: "messages",
2904
+ direction: "desc",
2905
+ });
2948
2906
  const localeDirection = (detail.entity as Record<string, any>).direction as string | undefined;
2949
2907
  const searchQuery = searchParams.get("q") ?? "";
2950
2908
 
@@ -3027,9 +2985,9 @@ export function LocaleDuplicatesTab() {
3027
2985
  return <p className="text-sm text-muted">No duplicate translations found for this locale.</p>;
3028
2986
  }
3029
2987
 
3030
- const visibleDuplicateValues = filterDuplicateValuesBySearch(
3031
- duplicates.duplicateValues,
3032
- searchQuery,
2988
+ const visibleDuplicateValues = sortDuplicateValues(
2989
+ filterDuplicateValuesBySearch(duplicates.duplicateValues, searchQuery),
2990
+ sort,
3033
2991
  );
3034
2992
 
3035
2993
  return (
@@ -3075,8 +3033,48 @@ export function LocaleDuplicatesTab() {
3075
3033
  </colgroup>
3076
3034
  <thead className="bg-elevated text-left text-[11px] uppercase tracking-wide text-muted">
3077
3035
  <tr>
3078
- <th className="border-b border-border px-3 py-2 font-semibold">Duplicate value</th>
3079
- <th className="border-b border-border px-3 py-2 font-semibold">Messages</th>
3036
+ <th
3037
+ className="border-b border-border px-3 py-2 font-semibold"
3038
+ aria-sort={
3039
+ sort.column === "value"
3040
+ ? sort.direction === "asc"
3041
+ ? "ascending"
3042
+ : "descending"
3043
+ : "none"
3044
+ }
3045
+ >
3046
+ <button
3047
+ type="button"
3048
+ className="inline-flex cursor-pointer items-center gap-1 text-left font-semibold uppercase tracking-wide text-muted transition-colors hover:text-text"
3049
+ onClick={() =>
3050
+ setSort((current) => getNextDuplicateValuesSort(current, "value"))
3051
+ }
3052
+ >
3053
+ <span>Duplicate value</span>
3054
+ <SortArrow active={sort.column === "value"} direction={sort.direction} />
3055
+ </button>
3056
+ </th>
3057
+ <th
3058
+ className="border-b border-border px-3 py-2 font-semibold"
3059
+ aria-sort={
3060
+ sort.column === "messages"
3061
+ ? sort.direction === "asc"
3062
+ ? "ascending"
3063
+ : "descending"
3064
+ : "none"
3065
+ }
3066
+ >
3067
+ <button
3068
+ type="button"
3069
+ className="inline-flex cursor-pointer items-center gap-1 text-left font-semibold uppercase tracking-wide text-muted transition-colors hover:text-text"
3070
+ onClick={() =>
3071
+ setSort((current) => getNextDuplicateValuesSort(current, "messages"))
3072
+ }
3073
+ >
3074
+ <span>Messages</span>
3075
+ <SortArrow active={sort.column === "messages"} direction={sort.direction} />
3076
+ </button>
3077
+ </th>
3080
3078
  </tr>
3081
3079
  </thead>
3082
3080
  <tbody>
@@ -3106,7 +3104,7 @@ export function LocaleDuplicatesTab() {
3106
3104
  style={localeDirection ? { unicodeBidi: "plaintext" } : undefined}
3107
3105
  >
3108
3106
  <div className="truncate">
3109
- <ExamplesSearchHighlight text={duplicate.value} query={searchQuery} />
3107
+ <SearchHighlight text={duplicate.value} query={searchQuery} />
3110
3108
  </div>
3111
3109
  </td>
3112
3110
  <td className="min-w-0 border-b border-border px-3 py-2 text-muted">
@@ -3152,7 +3150,7 @@ export function LocaleDuplicatesTab() {
3152
3150
  to={getEntityRoute("message", messageKey, setKey)}
3153
3151
  className="font-medium text-primary [overflow-wrap:anywhere] hover:underline"
3154
3152
  >
3155
- <ExamplesSearchHighlight
3153
+ <SearchHighlight
3156
3154
  text={messageKey}
3157
3155
  query={searchQuery}
3158
3156
  />
@@ -3167,7 +3165,7 @@ export function LocaleDuplicatesTab() {
3167
3165
  className="font-medium text-primary hover:underline"
3168
3166
  onClick={(event) => event.stopPropagation()}
3169
3167
  >
3170
- <ExamplesSearchHighlight
3168
+ <SearchHighlight
3171
3169
  text={sourceLocale}
3172
3170
  query={searchQuery}
3173
3171
  />
@@ -7,6 +7,7 @@ import type { CatalogIndex, EntityPath } from "../types";
7
7
  import { EntityList } from "../components/lists/EntityList";
8
8
  import { PageHeader } from "../components/layout/PageHeader";
9
9
  import { EmptyState } from "../components/ui/EmptyState";
10
+ import { useCatalog } from "../context/CatalogContext";
10
11
 
11
12
  function isEntityPath(value: string | undefined): value is EntityPath {
12
13
  return (
@@ -20,6 +21,7 @@ function isEntityPath(value: string | undefined): value is EntityPath {
20
21
 
21
22
  export function ListPage() {
22
23
  const { entityPath, setKey } = useParams();
24
+ const { manifest } = useCatalog();
23
25
  const [index, setIndex] = React.useState<CatalogIndex | null>(null);
24
26
  const [error, setError] = React.useState<string | null>(null);
25
27
 
@@ -53,6 +55,7 @@ export function ListPage() {
53
55
  entities={index.entities[type]}
54
56
  setKey={setKey}
55
57
  allEntities={index.entities}
58
+ translationSearchEnabled={manifest.features?.translationSearch === true}
56
59
  />
57
60
  </div>
58
61
  );
package/src/types.ts CHANGED
@@ -88,6 +88,9 @@ export interface CatalogManifest {
88
88
  source: string;
89
89
  commit: string;
90
90
  };
91
+ features?: {
92
+ translationSearch?: boolean;
93
+ };
91
94
  paths: {
92
95
  projectHistory: string;
93
96
  root?: string;
@@ -0,0 +1,46 @@
1
+ import type { DuplicateTranslationValue } from "../types";
2
+
3
+ export type DuplicateValuesSortColumn = "value" | "messages";
4
+ export type SortDirection = "asc" | "desc";
5
+
6
+ export interface DuplicateValuesSort {
7
+ column: DuplicateValuesSortColumn;
8
+ direction: SortDirection;
9
+ }
10
+
11
+ export function sortDuplicateValues(
12
+ duplicateValues: DuplicateTranslationValue[],
13
+ sort: DuplicateValuesSort,
14
+ ): DuplicateTranslationValue[] {
15
+ return duplicateValues.slice().sort((left, right) => {
16
+ const direction = sort.direction === "asc" ? 1 : -1;
17
+ const fallback = left.value.localeCompare(right.value);
18
+
19
+ if (sort.column === "messages") {
20
+ const result = left.messageKeys.length - right.messageKeys.length;
21
+ return (result || fallback) * direction;
22
+ }
23
+
24
+ return (
25
+ (left.value.localeCompare(right.value) ||
26
+ left.messageKeys.length - right.messageKeys.length) * direction
27
+ );
28
+ });
29
+ }
30
+
31
+ export function getNextDuplicateValuesSort(
32
+ current: DuplicateValuesSort,
33
+ column: DuplicateValuesSortColumn,
34
+ ): DuplicateValuesSort {
35
+ if (current.column !== column) {
36
+ return {
37
+ column,
38
+ direction: column === "messages" ? "desc" : "asc",
39
+ };
40
+ }
41
+
42
+ return {
43
+ column,
44
+ direction: current.direction === "asc" ? "desc" : "asc",
45
+ };
46
+ }