@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/dist/assets/index-BJS9aW0t.js +73 -0
- package/dist/index.html +1 -1
- package/lib/node/index.d.ts +7 -0
- package/lib/node/index.js +29 -14
- package/lib/node/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/lists/EntityList.spec.ts +34 -0
- package/src/components/lists/EntityList.tsx +69 -13
- package/src/components/ui/EntityKey.spec.ts +33 -0
- package/src/components/ui/EntityKey.tsx +91 -12
- package/src/components/ui/SearchHighlight.spec.ts +24 -0
- package/src/components/ui/SearchHighlight.tsx +58 -0
- package/src/node/index.spec.ts +120 -9
- package/src/node/index.ts +34 -15
- package/src/pages/EntityDetailPage.spec.ts +48 -0
- package/src/pages/EntityDetailPage.tsx +87 -89
- package/src/pages/ListPage.tsx +3 -0
- package/src/types.ts +3 -0
- package/src/utils/duplicateSorting.ts +46 -0
- package/dist/assets/index-OXIn4K-M.js +0 -73
|
@@ -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
|
|
184
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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}>
|
|
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
|
-
|
|
2
|
-
|
|
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
|
-
{
|
|
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
|
+
}
|
package/src/node/index.spec.ts
CHANGED
|
@@ -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.
|
|
261
|
-
|
|
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.
|
|
272
|
-
value:
|
|
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.
|
|
348
|
-
value:
|
|
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),
|