@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.
@@ -10,14 +10,23 @@ import { EmptyState } from "../ui/EmptyState";
10
10
  import { Input } from "../ui/Input";
11
11
  import { Button } from "../ui/Button";
12
12
  import { EntityKey } from "../ui/EntityKey";
13
+ import { SearchHighlight } from "../ui/SearchHighlight";
13
14
  import { CATALOG_LIST_INITIAL_LIMIT } from "../../config";
14
15
  import type { ParsedQuery } from "../../utils/searchQuery";
15
16
  import { parseQuery } from "../../utils/searchQuery";
16
17
 
18
+ interface EntityListHighlightTerms {
19
+ key: string[];
20
+ description: string[];
21
+ relationship: string[];
22
+ lastModified: string[];
23
+ }
24
+
17
25
  function matchesQuery(
18
26
  entity: EntitySummary,
19
27
  parsed: ParsedQuery,
20
28
  translationShard: TranslationShard | null,
29
+ translationSearchEnabled: boolean,
21
30
  ): boolean {
22
31
  const { freeText, qualifiers } = parsed;
23
32
 
@@ -70,6 +79,7 @@ function matchesQuery(
70
79
  break;
71
80
  }
72
81
  case "translation": {
82
+ if (!translationSearchEnabled) break;
73
83
  if (q.value.length < 3) return true; // require 3+ chars; don't filter otherwise
74
84
  if (!translationShard) return true; // optimistically include while loading
75
85
  const values = translationShard[entity.key];
@@ -91,13 +101,14 @@ function getQueryHints(
91
101
  type: EntityType,
92
102
  firstTargetKey: string | undefined,
93
103
  firstLocaleKey: string | undefined,
104
+ translationSearchEnabled: boolean,
94
105
  ): string[] | null {
95
106
  const target = firstTargetKey;
96
107
  const locale = firstLocaleKey;
97
108
 
98
109
  if (type === "message") {
99
110
  return [
100
- 'translation:"keyword"',
111
+ ...(translationSearchEnabled ? ['translation:"keyword"'] : []),
101
112
  ...(target ? [`target:${target}`] : []),
102
113
  ...(locale ? [`locale:${locale}`] : []),
103
114
  'description:"keyword"',
@@ -116,15 +127,17 @@ function QueryHints({
116
127
  query,
117
128
  firstTargetKey,
118
129
  firstLocaleKey,
130
+ translationSearchEnabled,
119
131
  onHintClick,
120
132
  }: {
121
133
  type: EntityType;
122
134
  query: string;
123
135
  firstTargetKey: string | undefined;
124
136
  firstLocaleKey: string | undefined;
137
+ translationSearchEnabled: boolean;
125
138
  onHintClick: (hint: string) => void;
126
139
  }) {
127
- const hints = getQueryHints(type, firstTargetKey, firstLocaleKey);
140
+ const hints = getQueryHints(type, firstTargetKey, firstLocaleKey, translationSearchEnabled);
128
141
  if (!hints) return null;
129
142
 
130
143
  return (
@@ -164,7 +177,7 @@ function getStatusBadges(entity: EntitySummary) {
164
177
  );
165
178
  }
166
179
 
167
- function LastModified(props: { entity: EntitySummary }) {
180
+ function LastModified(props: { entity: EntitySummary; highlightQuery: string[] }) {
168
181
  if (!props.entity.lastModified) {
169
182
  return <span>Last modified n/a</span>;
170
183
  }
@@ -180,8 +193,11 @@ function LastModified(props: { entity: EntitySummary }) {
180
193
 
181
194
  return (
182
195
  <span>
183
- Last modified by <span className="font-semibold">{props.entity.lastModified.author}</span> on{" "}
184
- {formattedDate}
196
+ Last modified by{" "}
197
+ <span className="font-semibold">
198
+ <SearchHighlight text={props.entity.lastModified.author} query={props.highlightQuery} />
199
+ </span>{" "}
200
+ on <SearchHighlight text={formattedDate} query={props.highlightQuery} />
185
201
  </span>
186
202
  );
187
203
  }
@@ -194,6 +210,32 @@ function getRelationshipBadges(type: EntityType, entity: EntitySummary) {
194
210
  return entity.targets || [];
195
211
  }
196
212
 
213
+ function uniqueTerms(terms: string[]) {
214
+ return Array.from(new Set(terms.map((term) => term.trim()).filter(Boolean)));
215
+ }
216
+
217
+ export function getEntityListHighlightTerms(query: string): EntityListHighlightTerms {
218
+ const parsed = parseQuery(query);
219
+ const freeText = uniqueTerms(parsed.freeText);
220
+ const description = uniqueTerms(
221
+ parsed.qualifiers
222
+ .filter((qualifier) => qualifier.key === "description")
223
+ .map((qualifier) => qualifier.value),
224
+ );
225
+ const relationship = uniqueTerms(
226
+ parsed.qualifiers
227
+ .filter((qualifier) => qualifier.key === "target" || qualifier.key === "locale")
228
+ .map((qualifier) => qualifier.value),
229
+ );
230
+
231
+ return {
232
+ key: freeText,
233
+ description,
234
+ relationship,
235
+ lastModified: freeText,
236
+ };
237
+ }
238
+
197
239
  function getSortDirection(sortValue: string | null) {
198
240
  if (!sortValue || sortValue === "name" || sortValue === "name:asc" || sortValue === "asc") {
199
241
  return "asc";
@@ -225,6 +267,7 @@ export function EntityList(props: {
225
267
  entities: EntitySummary[];
226
268
  setKey?: string;
227
269
  allEntities?: Record<EntityType, EntitySummary[]>;
270
+ translationSearchEnabled?: boolean;
228
271
  }) {
229
272
  const [searchParams, setSearchParams] = useSearchParams();
230
273
  const [showAll, setShowAll] = React.useState(false);
@@ -242,12 +285,14 @@ export function EntityList(props: {
242
285
 
243
286
  const firstTargetKey = props.allEntities?.target?.find((e) => !e.archived)?.key;
244
287
  const firstLocaleKey = props.allEntities?.locale?.find((e) => !e.archived)?.key;
245
- const hasHintsDefined = getQueryHints(props.type, firstTargetKey, firstLocaleKey) !== null;
288
+ const translationSearchEnabled = props.translationSearchEnabled === true;
289
+ const hasHintsDefined =
290
+ getQueryHints(props.type, firstTargetKey, firstLocaleKey, translationSearchEnabled) !== null;
246
291
 
247
292
  // Compute the 3-char shard prefix needed for the current query
248
293
  const _translationQual = parseQuery(query).qualifiers.find((q) => q.key === "translation");
249
294
  const neededShardKey =
250
- _translationQual && _translationQual.value.length >= 3
295
+ translationSearchEnabled && _translationQual && _translationQual.value.length >= 3
251
296
  ? _translationQual.value.slice(0, 3).toLowerCase()
252
297
  : null;
253
298
 
@@ -279,6 +324,7 @@ export function EntityList(props: {
279
324
 
280
325
  // Pass shard to matchesQuery only when the loaded shard matches what's needed
281
326
  const activeShard = loadedShardKey === neededShardKey ? translationShard : null;
327
+ const highlightTerms = React.useMemo(() => getEntityListHighlightTerms(query), [query]);
282
328
 
283
329
  const filtered = React.useMemo(() => {
284
330
  const parsed = parseQuery(query);
@@ -286,14 +332,14 @@ export function EntityList(props: {
286
332
 
287
333
  const matching = props.entities.filter((entity) => {
288
334
  if (!hasQuery) return true;
289
- return matchesQuery(entity, parsed, activeShard);
335
+ return matchesQuery(entity, parsed, activeShard, translationSearchEnabled);
290
336
  });
291
337
 
292
338
  return matching.slice().sort((left, right) => {
293
339
  const result = left.key.localeCompare(left.key === right.key ? "" : right.key);
294
340
  return sortDirection === "desc" ? result * -1 : result;
295
341
  });
296
- }, [query, props.entities, sortDirection, activeShard]);
342
+ }, [query, props.entities, sortDirection, activeShard, translationSearchEnabled]);
297
343
 
298
344
  const visible = showAll ? filtered : filtered.slice(0, CATALOG_LIST_INITIAL_LIMIT);
299
345
  const hasHiddenEntities = filtered.length > CATALOG_LIST_INITIAL_LIMIT && !showAll;
@@ -365,6 +411,7 @@ export function EntityList(props: {
365
411
  query={query}
366
412
  firstTargetKey={firstTargetKey}
367
413
  firstLocaleKey={firstLocaleKey}
414
+ translationSearchEnabled={translationSearchEnabled}
368
415
  onHintClick={handleHintClick}
369
416
  />
370
417
  </div>
@@ -410,9 +457,16 @@ export function EntityList(props: {
410
457
  <div className="min-w-0 flex-1">
411
458
  <div className="flex flex-col justify-between gap-2 md:flex-row md:items-start">
412
459
  <div className="min-w-0">
413
- <EntityKey value={entity.key} className="text-sm font-semibold text-primary" />
460
+ <EntityKey
461
+ value={entity.key}
462
+ className="text-sm font-semibold text-primary"
463
+ highlightQuery={highlightTerms.key}
464
+ />
414
465
  <div className="mt-1 truncate text-sm text-muted">
415
- {entity.description || "No description"}
466
+ <SearchHighlight
467
+ text={entity.description || "No description"}
468
+ query={highlightTerms.description}
469
+ />
416
470
  </div>
417
471
  </div>
418
472
  <div className="shrink-0">{getStatusBadges(entity)}</div>
@@ -420,11 +474,13 @@ export function EntityList(props: {
420
474
  <div className="mt-2 flex flex-col gap-2 text-xs text-muted md:flex-row md:items-center md:justify-between">
421
475
  <div className="flex flex-wrap gap-2">
422
476
  {getRelationshipBadges(props.type, entity).map((label) => (
423
- <Badge key={label}>{label}</Badge>
477
+ <Badge key={label}>
478
+ <SearchHighlight text={label} query={highlightTerms.relationship} />
479
+ </Badge>
424
480
  ))}
425
481
  </div>
426
482
  <span className="shrink-0 md:text-right">
427
- <LastModified entity={entity} />
483
+ <LastModified entity={entity} highlightQuery={highlightTerms.lastModified} />
428
484
  </span>
429
485
  </div>
430
486
  </div>
@@ -0,0 +1,33 @@
1
+ import * as React from "react";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+
4
+ import { EntityKey } from "./EntityKey";
5
+
6
+ describe("EntityKey", function () {
7
+ it("highlights namespace queries that include dots", function () {
8
+ const html = renderToStaticMarkup(
9
+ React.createElement(EntityKey, {
10
+ value: "auth.xyz.login",
11
+ highlightQuery: ["auth.xyz"],
12
+ }),
13
+ );
14
+
15
+ expect(html).toContain("<mark");
16
+ expect(html).toContain("auth");
17
+ expect(html).toContain("xyz");
18
+ expect(html).toContain("<wbr");
19
+ });
20
+
21
+ it("highlights trailing namespace dots while preserving word-break hints", function () {
22
+ const html = renderToStaticMarkup(
23
+ React.createElement(EntityKey, {
24
+ value: "auth.signin.title",
25
+ highlightQuery: ["auth."],
26
+ }),
27
+ );
28
+
29
+ expect(html).toContain("<mark");
30
+ expect(html).toContain("auth");
31
+ expect(html).toContain("<wbr");
32
+ });
33
+ });
@@ -1,20 +1,99 @@
1
- export function EntityKey(props: { value: string; className?: string }) {
2
- const parts = props.value.split(".");
1
+ import * as React from "react";
2
+
3
+ import { SearchHighlightMark } from "./SearchHighlight";
4
+
5
+ interface HighlightRange {
6
+ start: number;
7
+ end: number;
8
+ }
9
+
10
+ function getHighlightRanges(value: string, queries: string[]): HighlightRange[] {
11
+ const lowerValue = value.toLowerCase();
12
+ const ranges: HighlightRange[] = [];
13
+
14
+ for (const rawQuery of queries) {
15
+ const query = rawQuery.trim().toLowerCase();
16
+ if (!query) continue;
17
+
18
+ let start = lowerValue.indexOf(query);
19
+ while (start !== -1) {
20
+ ranges.push({ start, end: start + query.length });
21
+ start = lowerValue.indexOf(query, start + query.length);
22
+ }
23
+ }
24
+
25
+ return ranges
26
+ .sort((left, right) => left.start - right.start || right.end - left.end)
27
+ .reduce<HighlightRange[]>((merged, range) => {
28
+ const previous = merged[merged.length - 1];
29
+ if (!previous || range.start > previous.end) {
30
+ merged.push({ ...range });
31
+ } else {
32
+ previous.end = Math.max(previous.end, range.end);
33
+ }
34
+ return merged;
35
+ }, []);
36
+ }
37
+
38
+ function renderKeyTextWithBreaks(text: string) {
39
+ return text.split(".").flatMap((part, index, parts) => {
40
+ const nodes: React.ReactNode[] = [part];
41
+ if (index < parts.length - 1) {
42
+ nodes.push(
43
+ <React.Fragment key={`dot-${index}`}>
44
+ .<wbr />
45
+ </React.Fragment>,
46
+ );
47
+ }
48
+ return nodes;
49
+ });
50
+ }
51
+
52
+ function renderHighlightedKey(value: string, queries: string[]) {
53
+ const ranges = getHighlightRanges(value, queries);
54
+ if (ranges.length === 0) {
55
+ return renderKeyTextWithBreaks(value);
56
+ }
57
+
58
+ const nodes: React.ReactNode[] = [];
59
+ let cursor = 0;
60
+
61
+ ranges.forEach((range, index) => {
62
+ if (range.start > cursor) {
63
+ nodes.push(
64
+ <React.Fragment key={`text-${cursor}`}>
65
+ {renderKeyTextWithBreaks(value.slice(cursor, range.start))}
66
+ </React.Fragment>,
67
+ );
68
+ }
69
+
70
+ nodes.push(
71
+ <SearchHighlightMark key={`highlight-${range.start}-${index}`}>
72
+ {renderKeyTextWithBreaks(value.slice(range.start, range.end))}
73
+ </SearchHighlightMark>,
74
+ );
75
+ cursor = range.end;
76
+ });
77
+
78
+ if (cursor < value.length) {
79
+ nodes.push(
80
+ <React.Fragment key={`text-${cursor}`}>
81
+ {renderKeyTextWithBreaks(value.slice(cursor))}
82
+ </React.Fragment>,
83
+ );
84
+ }
85
+
86
+ return nodes;
87
+ }
88
+
89
+ export function EntityKey(props: { value: string; className?: string; highlightQuery?: string[] }) {
90
+ const highlightQuery = props.highlightQuery || [];
3
91
 
4
92
  return (
5
93
  <span
6
94
  className={["inline leading-snug [overflow-wrap:anywhere]", props.className || ""].join(" ")}
7
95
  >
8
- {parts.map((part, index) => (
9
- <span key={`${part}-${index}`}>
10
- {part}
11
- {index < parts.length - 1 ? (
12
- <>
13
- .<wbr />
14
- </>
15
- ) : null}
16
- </span>
17
- ))}
96
+ {renderHighlightedKey(props.value, highlightQuery)}
18
97
  </span>
19
98
  );
20
99
  }
@@ -0,0 +1,24 @@
1
+ import * as React from "react";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+
4
+ import { SearchHighlight } from "./SearchHighlight";
5
+
6
+ describe("SearchHighlight", function () {
7
+ it("highlights case-insensitive text matches with the shared mark styling", function () {
8
+ const html = renderToStaticMarkup(
9
+ React.createElement(SearchHighlight, { text: "Welcome back", query: "welcome" }),
10
+ );
11
+
12
+ expect(html).toContain("<mark");
13
+ expect(html).toContain("bg-amber-100");
14
+ expect(html).toContain(">Welcome</mark>");
15
+ });
16
+
17
+ it("escapes query text before matching", function () {
18
+ const html = renderToStaticMarkup(
19
+ React.createElement(SearchHighlight, { text: "Use plan.* literally", query: "plan.*" }),
20
+ );
21
+
22
+ expect(html).toContain(">plan.*</mark>");
23
+ });
24
+ });
@@ -0,0 +1,58 @@
1
+ import * as React from "react";
2
+
3
+ function escapeRegExp(value: string) {
4
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
5
+ }
6
+
7
+ function normalizeQueries(query: string | string[]) {
8
+ const queries = Array.isArray(query) ? query : [query];
9
+
10
+ return Array.from(
11
+ new Set(queries.map((item) => item.trim()).filter((item) => item.length > 0)),
12
+ ).sort((left, right) => right.length - left.length);
13
+ }
14
+
15
+ export function SearchHighlight(props: { text: string; query: string | string[] }) {
16
+ const queries = normalizeQueries(props.query);
17
+ if (queries.length === 0) {
18
+ return <>{props.text}</>;
19
+ }
20
+
21
+ const regex = new RegExp(queries.map(escapeRegExp).join("|"), "gi");
22
+ const parts: React.ReactNode[] = [];
23
+ let lastIndex = 0;
24
+ let key = 0;
25
+
26
+ for (const match of props.text.matchAll(regex)) {
27
+ if (match.index !== undefined && match.index > lastIndex) {
28
+ parts.push(props.text.slice(lastIndex, match.index));
29
+ }
30
+
31
+ if (match.index !== undefined) {
32
+ parts.push(
33
+ <SearchHighlightMark key={`hm-${match.index}-${key++}`}>{match[0]}</SearchHighlightMark>,
34
+ );
35
+ lastIndex = match.index + match[0].length;
36
+ }
37
+ }
38
+
39
+ if (lastIndex < props.text.length) {
40
+ parts.push(props.text.slice(lastIndex));
41
+ }
42
+
43
+ return <>{parts}</>;
44
+ }
45
+
46
+ export function SearchHighlightMark(props: { children: React.ReactNode }) {
47
+ return (
48
+ <mark
49
+ className={[
50
+ "rounded-[3px] bg-amber-100 px-0.5 py-px text-inherit",
51
+ "shadow-[inset_0_-2px_0_0_rgba(251,191,36,0.35)] ring-1 ring-amber-400/25 ring-inset",
52
+ "transition-[background-color,box-shadow] duration-150",
53
+ ].join(" ")}
54
+ >
55
+ {props.children}
56
+ </mark>
57
+ );
58
+ }
@@ -37,6 +37,15 @@ async function readJson<T>(root: string, relativePath: string): Promise<T> {
37
37
  return JSON.parse(await fs.promises.readFile(path.join(root, relativePath), "utf8"));
38
38
  }
39
39
 
40
+ async function pathExists(root: string, relativePath: string) {
41
+ try {
42
+ await fs.promises.access(path.join(root, relativePath));
43
+ return true;
44
+ } catch {
45
+ return false;
46
+ }
47
+ }
48
+
40
49
  async function createProject() {
41
50
  const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "messagevisor-catalog-"));
42
51
  const interpolationModulePath = path.join(
@@ -63,6 +72,8 @@ async function createProject() {
63
72
  "direction: ltr",
64
73
  "formats:",
65
74
  " number:",
75
+ " decimal:",
76
+ " maximumFractionDigits: 2",
66
77
  " money:",
67
78
  " style: currency",
68
79
  " currency: USD",
@@ -242,7 +253,11 @@ describe("catalog", function () {
242
253
  expect(manifest.sets).toBe(false);
243
254
  expect(manifest.router).toBe("browser");
244
255
  expect(manifest.dev).toBeUndefined();
256
+ expect(manifest.features).toEqual({ translationSearch: false });
245
257
  expect(manifest.paths.root).toBe("data/root/index.json");
258
+ await expect(pathExists(root, "catalog-out/data/root/translations/77656c.json")).resolves.toBe(
259
+ false,
260
+ );
246
261
  expect(index.counts.message).toBe(2);
247
262
  expect(
248
263
  index.entities.message.find((entry: any) => entry.key === "common.welcome").targets,
@@ -257,19 +272,16 @@ describe("catalog", function () {
257
272
  "web",
258
273
  ]);
259
274
  expect(index.entities.target.find((entry: any) => entry.key === "web").messageCount).toBe(2);
260
- expect(locale.computedFormats.number.money).toEqual({
261
- style: "currency",
262
- currency: "USD",
263
- currencyDisplay: "code",
264
- });
275
+ expect(locale.computedFormats.number.decimal).toEqual({ maximumFractionDigits: 2 });
276
+ expect(locale.computedFormats.number.money).toEqual({ currencyDisplay: "code" });
265
277
  expect(locale.entity.examples).toHaveLength(1);
266
278
  expect(locale.entity.direction).toBe("ltr");
267
279
  expect(locale.entity.promotable).toBe(false);
268
280
  expect(locale.formatRows).toEqual(
269
281
  expect.arrayContaining([
270
282
  expect.objectContaining({
271
- path: "number.money.style",
272
- value: "currency",
283
+ path: "number.decimal.maximumFractionDigits",
284
+ value: 2,
273
285
  source: "inherited",
274
286
  from: "en",
275
287
  examplePreview: expect.any(String),
@@ -344,8 +356,8 @@ describe("catalog", function () {
344
356
  examplePreview: expect.any(String),
345
357
  }),
346
358
  expect.objectContaining({
347
- path: "number.money.style",
348
- value: "currency",
359
+ path: "number.decimal.maximumFractionDigits",
360
+ value: 2,
349
361
  source: "inherited",
350
362
  from: "en",
351
363
  examplePreview: expect.any(String),
@@ -391,6 +403,26 @@ describe("catalog", function () {
391
403
  expect(history.entries).toEqual([]);
392
404
  });
393
405
 
406
+ it("exports translation search shards only when opted in", async function () {
407
+ const root = await createProject();
408
+ roots.push(root);
409
+ const projectConfig = getProjectConfig(root);
410
+ const datasource = new Datasource(projectConfig, root);
411
+
412
+ await catalogApi.exportCatalog(root, projectConfig, datasource, {
413
+ outDir: "catalog-out",
414
+ copyAssets: false,
415
+ withTranslationSearch: true,
416
+ });
417
+
418
+ const manifest = await readJson<any>(root, "catalog-out/data/manifest.json");
419
+ const shard = await readJson<any>(root, "catalog-out/data/root/translations/77656c.json");
420
+
421
+ expect(manifest.features).toEqual({ translationSearch: true });
422
+ expect(shard["common.welcome"]).toEqual(expect.arrayContaining(["welcome", "welcome pro"]));
423
+ expect(shard["common.draft"]).toEqual(["welcome"]);
424
+ });
425
+
394
426
  it("streams Git history into project, entity, and last-modified catalog data", async function () {
395
427
  const root = await createProject();
396
428
  roots.push(root);
@@ -668,7 +700,11 @@ describe("catalog", function () {
668
700
  const admin = await readJson<any>(root, "catalog-out/data/sets/admin/index.json");
669
701
 
670
702
  expect(manifest.sets).toBe(true);
703
+ expect(manifest.features).toEqual({ translationSearch: false });
671
704
  expect(manifest.setKeys).toEqual(["admin", "storefront"]);
705
+ await expect(
706
+ pathExists(root, "catalog-out/data/sets/storefront/translations/73746f.json"),
707
+ ).resolves.toBe(false);
672
708
  const storefrontDuplicates = await readJson<any>(
673
709
  root,
674
710
  "catalog-out/data/sets/storefront/duplicates/locales/en.json",
@@ -708,6 +744,45 @@ describe("catalog", function () {
708
744
  });
709
745
  });
710
746
 
747
+ it("exports set translation search shards when opted in", async function () {
748
+ const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "messagevisor-catalog-"));
749
+ roots.push(root);
750
+
751
+ await writeFile(root, "messagevisor.config.js", "module.exports = { sets: true };\n");
752
+
753
+ for (const set of ["storefront", "admin"]) {
754
+ await writeFile(root, `sets/${set}/locales/en.yml`, "description: English\n");
755
+ await writeFile(
756
+ root,
757
+ `sets/${set}/messages/common/welcome.yml`,
758
+ `description: Welcome\ntranslations:\n en: ${set}\n`,
759
+ );
760
+ }
761
+
762
+ const projectConfig = getProjectConfig(root);
763
+ const datasource = new Datasource(projectConfig, root);
764
+
765
+ await catalogApi.exportCatalog(root, projectConfig, datasource, {
766
+ outDir: "catalog-out",
767
+ copyAssets: false,
768
+ withTranslationSearch: true,
769
+ });
770
+
771
+ const manifest = await readJson<any>(root, "catalog-out/data/manifest.json");
772
+ const storefrontShard = await readJson<any>(
773
+ root,
774
+ "catalog-out/data/sets/storefront/translations/73746f.json",
775
+ );
776
+ const adminShard = await readJson<any>(
777
+ root,
778
+ "catalog-out/data/sets/admin/translations/61646d.json",
779
+ );
780
+
781
+ expect(manifest.features).toEqual({ translationSearch: true });
782
+ expect(storefrontShard).toEqual({ "common.welcome": ["storefront"] });
783
+ expect(adminShard).toEqual({ "common.welcome": ["admin"] });
784
+ });
785
+
711
786
  it("groups streamed Git history by set", async function () {
712
787
  const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "messagevisor-catalog-"));
713
788
  roots.push(root);
@@ -900,10 +975,46 @@ describe("catalog plugin", function () {
900
975
  );
901
976
  });
902
977
 
978
+ it("forwards translation search option for dev catalog mode", async function () {
979
+ const { handler } = createPlugin();
980
+
981
+ await handler({ _: ["catalog"], withTranslationSearch: true });
982
+
983
+ expect(exportMock).toHaveBeenLastCalledWith(
984
+ expect.any(String),
985
+ expect.any(Object),
986
+ expect.any(Object),
987
+ expect.objectContaining({ withTranslationSearch: true, dev: true }),
988
+ );
989
+ });
990
+
991
+ it("forwards translation search option for export subcommand", async function () {
992
+ const { handler } = createPlugin();
993
+
994
+ await handler({
995
+ _: ["catalog", "export"],
996
+ subcommand: "export",
997
+ "with-translation-search": true,
998
+ });
999
+
1000
+ expect(exportMock).toHaveBeenLastCalledWith(
1001
+ expect.any(String),
1002
+ expect.any(Object),
1003
+ expect.any(Object),
1004
+ expect.objectContaining({ withTranslationSearch: true }),
1005
+ );
1006
+ });
1007
+
903
1008
  it("forwards long and short port options for serve subcommand", async function () {
904
1009
  const { handler } = createPlugin();
905
1010
 
906
1011
  await handler({ _: ["catalog", "serve"], subcommand: "serve", port: 3103 });
1012
+ expect(serveMock).toHaveBeenLastCalledWith(
1013
+ expect.any(String),
1014
+ expect.any(Object),
1015
+ expect.any(Object),
1016
+ expect.not.objectContaining({ withTranslationSearch: true }),
1017
+ );
907
1018
  expect(serveMock).toHaveBeenLastCalledWith(
908
1019
  expect.any(String),
909
1020
  expect.any(Object),