@messagevisor/catalog 0.2.0 → 0.4.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
+ });
@@ -30,9 +30,11 @@ import { PageHeader } from "../components/layout/PageHeader";
30
30
  import { Tabs } from "../components/layout/Tabs";
31
31
  import { Badge } from "../components/ui/Badge";
32
32
  import { CodeBlock } from "../components/ui/CodeBlock";
33
+ import { EntityKey } from "../components/ui/EntityKey";
33
34
  import { LabelValueBadge } from "../components/ui/LabelValueBadge";
34
35
  import { EmptyState } from "../components/ui/EmptyState";
35
36
  import { Input } from "../components/ui/Input";
37
+ import { SearchHighlight } from "../components/ui/SearchHighlight";
36
38
  import { FieldGrid } from "../components/details/FieldGrid";
37
39
  import { ConditionTree } from "../components/details/ConditionTree";
38
40
  import { GroupSegmentTree } from "../components/details/GroupSegmentTree";
@@ -42,6 +44,12 @@ import { UsageLinks } from "../components/details/UsageLinks";
42
44
  import { HistoryTimeline } from "../components/history/HistoryTimeline";
43
45
  import { useCatalog } from "../context/CatalogContext";
44
46
  import { hashTranslationValue } from "../utils/hashTranslationValue";
47
+ import {
48
+ getNextDuplicateValuesSort,
49
+ sortDuplicateValues,
50
+ type DuplicateValuesSort,
51
+ type SortDirection,
52
+ } from "../utils/duplicateSorting";
45
53
  import type { ParsedQuery } from "../utils/searchQuery";
46
54
  import { parseQuery } from "../utils/searchQuery";
47
55
 
@@ -93,51 +101,6 @@ function slugifyFragment(value: string) {
93
101
  .replace(/^-+|-+$/g, "");
94
102
  }
95
103
 
96
- function escapeRegExp(value: string) {
97
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
98
- }
99
-
100
- function ExamplesSearchHighlight(props: { text: string; query: string }) {
101
- const q = props.query.trim();
102
- if (!q) {
103
- return <>{props.text}</>;
104
- }
105
-
106
- const escaped = escapeRegExp(q);
107
- const regex = new RegExp(escaped, "gi");
108
- const parts: React.ReactNode[] = [];
109
- let lastIndex = 0;
110
- let key = 0;
111
-
112
- for (const match of props.text.matchAll(regex)) {
113
- if (match.index !== undefined && match.index > lastIndex) {
114
- parts.push(props.text.slice(lastIndex, match.index));
115
- }
116
-
117
- if (match.index !== undefined) {
118
- parts.push(
119
- <mark
120
- key={`hm-${match.index}-${key++}`}
121
- className={[
122
- "rounded-[3px] bg-amber-100 px-0.5 py-px text-inherit",
123
- "shadow-[inset_0_-2px_0_0_rgba(251,191,36,0.35)] ring-1 ring-amber-400/25 ring-inset",
124
- "transition-[background-color,box-shadow] duration-150",
125
- ].join(" ")}
126
- >
127
- {match[0]}
128
- </mark>,
129
- );
130
- lastIndex = match.index + match[0].length;
131
- }
132
- }
133
-
134
- if (lastIndex < props.text.length) {
135
- parts.push(props.text.slice(lastIndex));
136
- }
137
-
138
- return <>{parts}</>;
139
- }
140
-
141
104
  function isEntityPath(value: string | undefined): value is EntityPath {
142
105
  return (
143
106
  value === "locales" ||
@@ -784,7 +747,7 @@ function FormatRowsTable(props: {
784
747
  function segmentBody(segment: string) {
785
748
  return segment ? (
786
749
  highlight ? (
787
- <ExamplesSearchHighlight text={segment} query={highlightNeedle} />
750
+ <SearchHighlight text={segment} query={highlightNeedle} />
788
751
  ) : (
789
752
  segment
790
753
  )
@@ -850,11 +813,7 @@ function FormatRowsTable(props: {
850
813
  row.source === "inherited" ? "text-muted" : "",
851
814
  ].join(" ")}
852
815
  >
853
- {highlight ? (
854
- <ExamplesSearchHighlight text={valueText} query={highlightNeedle} />
855
- ) : (
856
- valueText
857
- )}
816
+ {highlight ? <SearchHighlight text={valueText} query={highlightNeedle} /> : valueText}
858
817
  </div>
859
818
  {(showInheritedBadge || showTargetBadge) && (
860
819
  <div className="flex shrink-0 flex-col items-end justify-center gap-1">
@@ -880,7 +839,7 @@ function FormatRowsTable(props: {
880
839
  function renderExampleCellContent(preview: string | undefined) {
881
840
  return preview ? (
882
841
  highlight ? (
883
- <ExamplesSearchHighlight text={preview} query={highlightNeedle} />
842
+ <SearchHighlight text={preview} query={highlightNeedle} />
884
843
  ) : (
885
844
  preview
886
845
  )
@@ -1077,7 +1036,7 @@ function FormatRowsTable(props: {
1077
1036
  <td className={flatFormatClass}>
1078
1037
  <div className="whitespace-pre-wrap [overflow-wrap:anywhere]">
1079
1038
  {highlight ? (
1080
- <ExamplesSearchHighlight text={row.path} query={highlightNeedle} />
1039
+ <SearchHighlight text={row.path} query={highlightNeedle} />
1081
1040
  ) : (
1082
1041
  row.path
1083
1042
  )}
@@ -1195,7 +1154,15 @@ export function EntityDetailPage() {
1195
1154
  return (
1196
1155
  <div>
1197
1156
  <PageHeader
1198
- title={`${entityLabels[type].singular}: ${detail.key}`}
1157
+ title={
1158
+ <span className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-1">
1159
+ <span className="whitespace-nowrap">{entityLabels[type].singular}:</span>
1160
+ <EntityKey
1161
+ value={detail.key}
1162
+ className="min-w-0 text-[1.45rem] font-extrabold leading-tight"
1163
+ />
1164
+ </span>
1165
+ }
1199
1166
  description={
1200
1167
  <div className="flex flex-wrap items-center gap-2">
1201
1168
  {entity.archived && <Badge tone="danger">archived</Badge>}
@@ -1638,7 +1605,7 @@ function MessageTranslationOverridesDetails(props: {
1638
1605
  to={`${getEntityRoute("message", props.messageKey, props.setKey)}/overrides#${key}`}
1639
1606
  className="text-sm font-semibold text-primary hover:underline"
1640
1607
  >
1641
- {key}
1608
+ <EntityKey value={key} className="font-semibold" />
1642
1609
  </Link>
1643
1610
  {row.source === "inherited" && row.from && (
1644
1611
  <LabelValueBadge
@@ -1800,8 +1767,8 @@ export function MessageOverridesTab() {
1800
1767
  <section key={override.key} className="space-y-4">
1801
1768
  <div className="space-y-3">
1802
1769
  <div className="group flex items-center gap-2">
1803
- <h2 id={override.key} className="font-semibold">
1804
- {override.key}
1770
+ <h2 id={override.key} className="font-semibold [overflow-wrap:anywhere]">
1771
+ <EntityKey value={override.key} className="font-semibold" />
1805
1772
  </h2>
1806
1773
  <ExamplePermalink targetId={override.key} />
1807
1774
  </div>
@@ -1950,7 +1917,7 @@ function ExampleTitle(props: {
1950
1917
  }) {
1951
1918
  const titleContent =
1952
1919
  props.highlightQuery?.trim() && props.title ? (
1953
- <ExamplesSearchHighlight text={props.title} query={props.highlightQuery} />
1920
+ <SearchHighlight text={props.title} query={props.highlightQuery} />
1954
1921
  ) : (
1955
1922
  props.title
1956
1923
  );
@@ -1966,7 +1933,7 @@ function ExampleTitle(props: {
1966
1933
  {props.description?.trim() ? (
1967
1934
  props.highlightQuery?.trim() ? (
1968
1935
  <div className="text-sm text-muted whitespace-pre-wrap [overflow-wrap:anywhere]">
1969
- <ExamplesSearchHighlight text={props.description.trim()} query={props.highlightQuery} />
1936
+ <SearchHighlight text={props.description.trim()} query={props.highlightQuery} />
1970
1937
  </div>
1971
1938
  ) : (
1972
1939
  <div className="text-sm text-muted">
@@ -2000,7 +1967,7 @@ function ExampleTable(props: {
2000
1967
  .join(" ")}
2001
1968
  style={props.direction ? { unicodeBidi: "plaintext" } : undefined}
2002
1969
  >
2003
- <ExamplesSearchHighlight
1970
+ <SearchHighlight
2004
1971
  text={
2005
1972
  typeof props.evaluatedTranslation === "string"
2006
1973
  ? props.evaluatedTranslation
@@ -2411,7 +2378,7 @@ function LocaleExampleDetails(props: {
2411
2378
  to={getEntityRoute("locale", localeKey, setKey)}
2412
2379
  className="font-medium text-primary hover:underline"
2413
2380
  >
2414
- <ExamplesSearchHighlight text={localeKey} query={q} />
2381
+ <SearchHighlight text={localeKey} query={q} />
2415
2382
  </Link>
2416
2383
  ) : (
2417
2384
  <SourceLocaleLink localeKey={localeKey} />
@@ -2435,11 +2402,7 @@ function LocaleExampleDetails(props: {
2435
2402
  to={getEntityRoute("message", example.message, setKey)}
2436
2403
  className="font-medium text-primary hover:underline"
2437
2404
  >
2438
- {highlight ? (
2439
- <ExamplesSearchHighlight text={example.message} query={q} />
2440
- ) : (
2441
- example.message
2442
- )}
2405
+ {highlight ? <SearchHighlight text={example.message} query={q} /> : example.message}
2443
2406
  </Link>
2444
2407
  </InputField>
2445
2408
 
@@ -2457,7 +2420,7 @@ function LocaleExampleDetails(props: {
2457
2420
  .join(" ")}
2458
2421
  style={localeDirection ? { unicodeBidi: "plaintext" } : undefined}
2459
2422
  >
2460
- <ExamplesSearchHighlight text={example.originalTranslation} query={q} />
2423
+ <SearchHighlight text={example.originalTranslation} query={q} />
2461
2424
  </div>
2462
2425
  ) : (
2463
2426
  <TranslationValueBlock
@@ -2499,7 +2462,7 @@ function LocaleExampleDetails(props: {
2499
2462
  >
2500
2463
  {highlight ? (
2501
2464
  <pre className="max-w-full whitespace-pre-wrap rounded border border-border bg-elevated p-4 text-xs text-text [overflow-wrap:anywhere]">
2502
- <ExamplesSearchHighlight text={example.rawMessage} query={q} />
2465
+ <SearchHighlight text={example.rawMessage} query={q} />
2503
2466
  </pre>
2504
2467
  ) : (
2505
2468
  <CodeBlock value={example.rawMessage} />
@@ -2512,7 +2475,7 @@ function LocaleExampleDetails(props: {
2512
2475
  <InputField label="Values">
2513
2476
  {highlight ? (
2514
2477
  <pre className="max-w-full whitespace-pre-wrap rounded border border-border bg-elevated p-4 text-xs text-text [overflow-wrap:anywhere]">
2515
- <ExamplesSearchHighlight text={JSON.stringify(example.values, null, 2)} query={q} />
2478
+ <SearchHighlight text={JSON.stringify(example.values, null, 2)} query={q} />
2516
2479
  </pre>
2517
2480
  ) : (
2518
2481
  <JsonValueBlock value={example.values} />
@@ -2524,7 +2487,7 @@ function LocaleExampleDetails(props: {
2524
2487
  <InputField label="Context">
2525
2488
  {highlight ? (
2526
2489
  <pre className="max-w-full whitespace-pre-wrap rounded border border-border bg-elevated p-4 text-xs text-text [overflow-wrap:anywhere]">
2527
- <ExamplesSearchHighlight text={JSON.stringify(example.context, null, 2)} query={q} />
2490
+ <SearchHighlight text={JSON.stringify(example.context, null, 2)} query={q} />
2528
2491
  </pre>
2529
2492
  ) : (
2530
2493
  <JsonValueBlock value={example.context} />
@@ -2535,11 +2498,7 @@ function LocaleExampleDetails(props: {
2535
2498
  {typeof example.timeZone !== "undefined" && (
2536
2499
  <InputField label="Time zone">
2537
2500
  <span className="font-mono text-sm text-text">
2538
- {highlight ? (
2539
- <ExamplesSearchHighlight text={example.timeZone} query={q} />
2540
- ) : (
2541
- example.timeZone
2542
- )}
2501
+ {highlight ? <SearchHighlight text={example.timeZone} query={q} /> : example.timeZone}
2543
2502
  </span>
2544
2503
  </InputField>
2545
2504
  )}
@@ -2547,11 +2506,7 @@ function LocaleExampleDetails(props: {
2547
2506
  {typeof example.currency !== "undefined" && (
2548
2507
  <InputField label="Currency">
2549
2508
  <span className="font-mono text-sm text-text">
2550
- {highlight ? (
2551
- <ExamplesSearchHighlight text={example.currency} query={q} />
2552
- ) : (
2553
- example.currency
2554
- )}
2509
+ {highlight ? <SearchHighlight text={example.currency} query={q} /> : example.currency}
2555
2510
  </span>
2556
2511
  </InputField>
2557
2512
  )}
@@ -2560,7 +2515,7 @@ function LocaleExampleDetails(props: {
2560
2515
  <InputField label="Formats">
2561
2516
  {highlight ? (
2562
2517
  <pre className="max-w-full whitespace-pre-wrap rounded border border-border bg-elevated p-4 text-xs text-text [overflow-wrap:anywhere]">
2563
- <ExamplesSearchHighlight text={JSON.stringify(example.formats, null, 2)} query={q} />
2518
+ <SearchHighlight text={JSON.stringify(example.formats, null, 2)} query={q} />
2564
2519
  </pre>
2565
2520
  ) : (
2566
2521
  <JsonValueBlock value={example.formats} />
@@ -2703,14 +2658,14 @@ function LocaleExamplesCompactView(props: {
2703
2658
  onClick={() => toggleExample(exampleId)}
2704
2659
  >
2705
2660
  <td className="border-b border-border px-3 py-2 font-medium text-muted">
2706
- <ExamplesSearchHighlight
2661
+ <SearchHighlight
2707
2662
  text={getLocaleExampleCompactLabel(example)}
2708
2663
  query={props.searchQuery}
2709
2664
  />
2710
2665
  </td>
2711
2666
  <td className="min-w-0 border-b border-border px-3 py-2 text-muted">
2712
2667
  <div className="whitespace-pre-wrap [overflow-wrap:anywhere]">
2713
- <ExamplesSearchHighlight
2668
+ <SearchHighlight
2714
2669
  text={example.description || "—"}
2715
2670
  query={props.searchQuery}
2716
2671
  />
@@ -2725,7 +2680,7 @@ function LocaleExamplesCompactView(props: {
2725
2680
  style={props.localeDirection ? { unicodeBidi: "plaintext" } : undefined}
2726
2681
  >
2727
2682
  <div className="whitespace-pre-wrap [overflow-wrap:anywhere]">
2728
- <ExamplesSearchHighlight
2683
+ <SearchHighlight
2729
2684
  text={
2730
2685
  typeof example.evaluatedTranslation === "string"
2731
2686
  ? example.evaluatedTranslation
@@ -2743,7 +2698,7 @@ function LocaleExamplesCompactView(props: {
2743
2698
  <div className="group flex items-center gap-2">
2744
2699
  <h3 className="text-sm font-semibold">
2745
2700
  {props.searchQuery.trim() ? (
2746
- <ExamplesSearchHighlight
2701
+ <SearchHighlight
2747
2702
  text={getLocaleExampleTitle(example)}
2748
2703
  query={props.searchQuery}
2749
2704
  />
@@ -2930,12 +2885,24 @@ function filterDuplicateValuesBySearch(
2930
2885
  });
2931
2886
  }
2932
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
+
2933
2896
  export function LocaleDuplicatesTab() {
2934
2897
  const { detail, setKey } = useEntityDetail();
2935
2898
  const [searchParams, setSearchParams] = useSearchParams();
2936
2899
  const [duplicates, setDuplicates] = React.useState<LocaleDuplicates | null>(null);
2937
2900
  const [error, setError] = React.useState<string | null>(null);
2938
2901
  const [expandedDuplicateHashes, setExpandedDuplicateHashes] = React.useState<string[]>([]);
2902
+ const [sort, setSort] = React.useState<DuplicateValuesSort>({
2903
+ column: "messages",
2904
+ direction: "desc",
2905
+ });
2939
2906
  const localeDirection = (detail.entity as Record<string, any>).direction as string | undefined;
2940
2907
  const searchQuery = searchParams.get("q") ?? "";
2941
2908
 
@@ -3018,9 +2985,9 @@ export function LocaleDuplicatesTab() {
3018
2985
  return <p className="text-sm text-muted">No duplicate translations found for this locale.</p>;
3019
2986
  }
3020
2987
 
3021
- const visibleDuplicateValues = filterDuplicateValuesBySearch(
3022
- duplicates.duplicateValues,
3023
- searchQuery,
2988
+ const visibleDuplicateValues = sortDuplicateValues(
2989
+ filterDuplicateValuesBySearch(duplicates.duplicateValues, searchQuery),
2990
+ sort,
3024
2991
  );
3025
2992
 
3026
2993
  return (
@@ -3066,8 +3033,48 @@ export function LocaleDuplicatesTab() {
3066
3033
  </colgroup>
3067
3034
  <thead className="bg-elevated text-left text-[11px] uppercase tracking-wide text-muted">
3068
3035
  <tr>
3069
- <th className="border-b border-border px-3 py-2 font-semibold">Duplicate value</th>
3070
- <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>
3071
3078
  </tr>
3072
3079
  </thead>
3073
3080
  <tbody>
@@ -3097,7 +3104,7 @@ export function LocaleDuplicatesTab() {
3097
3104
  style={localeDirection ? { unicodeBidi: "plaintext" } : undefined}
3098
3105
  >
3099
3106
  <div className="truncate">
3100
- <ExamplesSearchHighlight text={duplicate.value} query={searchQuery} />
3107
+ <SearchHighlight text={duplicate.value} query={searchQuery} />
3101
3108
  </div>
3102
3109
  </td>
3103
3110
  <td className="min-w-0 border-b border-border px-3 py-2 text-muted">
@@ -3141,9 +3148,9 @@ export function LocaleDuplicatesTab() {
3141
3148
  <td className="min-w-0 border-b border-border px-3 py-2">
3142
3149
  <Link
3143
3150
  to={getEntityRoute("message", messageKey, setKey)}
3144
- className="font-medium text-primary hover:underline"
3151
+ className="font-medium text-primary [overflow-wrap:anywhere] hover:underline"
3145
3152
  >
3146
- <ExamplesSearchHighlight
3153
+ <SearchHighlight
3147
3154
  text={messageKey}
3148
3155
  query={searchQuery}
3149
3156
  />
@@ -3158,7 +3165,7 @@ export function LocaleDuplicatesTab() {
3158
3165
  className="font-medium text-primary hover:underline"
3159
3166
  onClick={(event) => event.stopPropagation()}
3160
3167
  >
3161
- <ExamplesSearchHighlight
3168
+ <SearchHighlight
3162
3169
  text={sourceLocale}
3163
3170
  query={searchQuery}
3164
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;