@messagevisor/catalog 0.9.0 → 0.15.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/index.html CHANGED
@@ -5,8 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <link rel="icon" type="image/png" href="/favicon.png" />
7
7
  <title>Messagevisor Catalog</title>
8
- <script type="module" crossorigin src="/assets/index-9TVwIAiT.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-BgEITLIy.css">
8
+ <script type="module" crossorigin src="/assets/index-C8ldlvUw.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-Bic-2mlS.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@messagevisor/catalog",
3
- "version": "0.9.0",
3
+ "version": "0.15.0",
4
4
  "description": "Static catalog UI for Messagevisor projects",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
@@ -57,5 +57,5 @@
57
57
  "typescript": "^5.7.2",
58
58
  "vite": "^6.0.7"
59
59
  },
60
- "gitHead": "0848f5c7cd310e1a3ff0c38a6b0697d77aa72970"
60
+ "gitHead": "e55dae69236c887ef9a689cc2983272a77dfae38"
61
61
  }
@@ -50,6 +50,7 @@ import {
50
50
  type DuplicateValuesSort,
51
51
  type SortDirection,
52
52
  } from "../utils/duplicateSorting";
53
+ import { getRelevantIcuFormats } from "../utils/relevantIcuFormats";
53
54
  import type { ParsedQuery } from "../utils/searchQuery";
54
55
  import { parseQuery } from "../utils/searchQuery";
55
56
 
@@ -342,6 +343,19 @@ function orderedFormatTypePillKeys(typesFromData: string[]): string[] {
342
343
  return [...FORMAT_TYPE_PRIMARY_PILLS, ...rest];
343
344
  }
344
345
 
346
+ function orderedFormatSectionKeys(typesFromData: string[]): string[] {
347
+ const available = new Set(typesFromData);
348
+ const primary = FORMAT_TYPE_PRIMARY_PILLS.filter((type) => available.has(type));
349
+ const rest = typesFromData
350
+ .filter((type) => !FORMAT_TYPE_PRIMARY_PILLS.includes(type))
351
+ .sort((a, b) => a.localeCompare(b));
352
+ return [...primary, ...rest];
353
+ }
354
+
355
+ function formatStyleFragmentId(type: string, style: string): string {
356
+ return `format-${slugifyFragment(`${type}-${style || "default"}`)}`;
357
+ }
358
+
345
359
  function setSearchParam(searchParams: URLSearchParams, key: string, value?: string) {
346
360
  const next = new URLSearchParams(searchParams);
347
361
 
@@ -729,6 +743,8 @@ function FormatRowsTable(props: {
729
743
  );
730
744
  }
731
745
 
746
+ useScrollToHash([splitPath, q, props.selectedFormatType, rows.length, visibleRows.length]);
747
+
732
748
  if (rows.length === 0) {
733
749
  return <p className="text-sm text-muted">No formats found.</p>;
734
750
  }
@@ -743,6 +759,19 @@ function FormatRowsTable(props: {
743
759
  }
744
760
 
745
761
  const splitPlans = splitPath ? buildFormatSplitRowPlans(visibleRows) : null;
762
+ const splitSectionKeys = splitPath
763
+ ? orderedFormatSectionKeys(collectSortedFormatTypes(visibleRows))
764
+ : [];
765
+ const splitRowsByType = splitPath
766
+ ? visibleRows.reduce<Record<string, FormatRow[]>>((groups, row) => {
767
+ const type = splitFormatPath(row.path).type;
768
+ if (!groups[type]) {
769
+ groups[type] = [];
770
+ }
771
+ groups[type].push(row);
772
+ return groups;
773
+ }, {})
774
+ : {};
746
775
 
747
776
  function segmentBody(segment: string) {
748
777
  return segment ? (
@@ -820,10 +849,16 @@ function FormatRowsTable(props: {
820
849
 
821
850
  function renderSplitStyleCellContent(plan: FormatSplitRowPlan) {
822
851
  const sourceMeta = renderFormatSourceMeta(plan.row);
852
+ const fragmentId = formatStyleFragmentId(plan.parts.type, plan.parts.style);
823
853
 
824
854
  return (
825
855
  <div className="flex min-w-0 flex-col items-start gap-1.5">
826
- <div className="w-full min-w-0">{renderSplitSegment(plan.parts.style, "style")}</div>
856
+ <a
857
+ href={`#${fragmentId}`}
858
+ className="w-full min-w-0 rounded-sm text-text hover:text-primary hover:underline"
859
+ >
860
+ {renderSplitSegment(plan.parts.style, "style")}
861
+ </a>
827
862
  {sourceMeta}
828
863
  </div>
829
864
  );
@@ -920,6 +955,109 @@ function FormatRowsTable(props: {
920
955
  );
921
956
  }
922
957
 
958
+ function renderSplitTable(plans: FormatSplitRowPlan[]) {
959
+ return (
960
+ <div className="min-w-0 overflow-x-auto rounded-lg border border-border">
961
+ <table className="w-full min-w-[34rem] table-fixed border-collapse bg-surface text-xs">
962
+ <colgroup>
963
+ {showExampleColumn ? (
964
+ <>
965
+ <col className="min-w-0 w-[24%]" />
966
+ <col className="min-w-0 w-[18%]" />
967
+ <col className="min-w-0 w-[28%]" />
968
+ <col className="min-w-0 w-[30%]" />
969
+ </>
970
+ ) : (
971
+ <>
972
+ <col className="min-w-0 w-[30%]" />
973
+ <col className="min-w-0 w-[30%]" />
974
+ <col className="min-w-0 w-[40%]" />
975
+ </>
976
+ )}
977
+ </colgroup>
978
+ <thead className="bg-elevated text-left text-[11px] uppercase tracking-wide text-muted">
979
+ <tr>
980
+ <th className="align-middle border-b border-r border-border/50 px-3 py-2 font-semibold">
981
+ Style
982
+ </th>
983
+ {showExampleColumn ? (
984
+ <th className="align-middle border-b border-r border-border/50 px-3 py-2 font-semibold">
985
+ Example
986
+ </th>
987
+ ) : null}
988
+ <th className="align-middle border-b border-r border-border/40 px-3 py-2 font-semibold">
989
+ Param
990
+ </th>
991
+ <th className="align-middle border-b border-border px-3 py-2 font-semibold">Value</th>
992
+ </tr>
993
+ </thead>
994
+ <tbody>
995
+ {plans.map((plan) => {
996
+ const bandClass = bandSurfaceClass(plan.typeBand);
997
+
998
+ return (
999
+ <tr key={plan.row.path}>
1000
+ {plan.showStyleCell ? (
1001
+ <td
1002
+ id={formatStyleFragmentId(plan.parts.type, plan.parts.style)}
1003
+ rowSpan={plan.styleRowSpan}
1004
+ className={[
1005
+ "align-middle min-w-0 scroll-mt-2 px-3 py-2 font-medium",
1006
+ bandClass,
1007
+ formatSplitCellBorderClass("style"),
1008
+ ].join(" ")}
1009
+ >
1010
+ {renderSplitStyleCellContent(plan)}
1011
+ </td>
1012
+ ) : null}
1013
+ {renderSplitExampleColumn(plan, bandClass)}
1014
+ <td
1015
+ className={[
1016
+ "align-middle min-w-0 px-3 py-2 font-medium text-muted",
1017
+ bandClass,
1018
+ formatSplitCellBorderClass("param"),
1019
+ ].join(" ")}
1020
+ >
1021
+ {renderSplitSegment(plan.parts.param, "param")}
1022
+ </td>
1023
+ {renderValueColumn(
1024
+ plan.row,
1025
+ ["align-middle min-w-0 px-3 py-2", bandClass].join(" "),
1026
+ )}
1027
+ </tr>
1028
+ );
1029
+ })}
1030
+ </tbody>
1031
+ </table>
1032
+ </div>
1033
+ );
1034
+ }
1035
+
1036
+ if (splitPath) {
1037
+ return (
1038
+ <div className="space-y-5">
1039
+ {splitSectionKeys.map((typeKey) => {
1040
+ const sectionRows = splitRowsByType[typeKey] || [];
1041
+ const sectionPlans = buildFormatSplitRowPlans(sectionRows);
1042
+ const styleCount = new Set(
1043
+ sectionRows.map((row) => splitFormatPath(row.path).style).filter(Boolean),
1044
+ ).size;
1045
+ return (
1046
+ <section key={typeKey} className="space-y-2">
1047
+ <div className="flex items-baseline justify-between gap-3">
1048
+ <h3 className="text-sm font-semibold text-text">{typeKey}</h3>
1049
+ <span className="text-xs text-muted">
1050
+ {styleCount} {styleCount === 1 ? "style" : "styles"}
1051
+ </span>
1052
+ </div>
1053
+ {renderSplitTable(sectionPlans)}
1054
+ </section>
1055
+ );
1056
+ })}
1057
+ </div>
1058
+ );
1059
+ }
1060
+
923
1061
  return (
924
1062
  <div className="min-w-0 overflow-x-auto rounded-xl border border-border">
925
1063
  <table className="w-full min-w-[40rem] table-fixed border-collapse bg-surface text-xs">
@@ -2405,12 +2543,18 @@ function LocaleExampleDetails(props: {
2405
2543
  example: EvaluatedLocaleExample;
2406
2544
  setKey?: string;
2407
2545
  localeDirection?: string;
2546
+ computedFormats?: unknown;
2408
2547
  showLocale?: boolean;
2409
2548
  highlightQuery?: string;
2410
2549
  }) {
2411
2550
  const { example, setKey, localeDirection } = props;
2412
2551
  const q = props.highlightQuery?.trim() ?? "";
2413
2552
  const highlight = Boolean(q);
2553
+ const relevantFormats = getRelevantIcuFormats(
2554
+ example.rawMessage || example.originalTranslation,
2555
+ props.computedFormats,
2556
+ example.formats,
2557
+ );
2414
2558
 
2415
2559
  function localeLink(localeKey: string) {
2416
2560
  return highlight ? (
@@ -2562,6 +2706,18 @@ function LocaleExampleDetails(props: {
2562
2706
  )}
2563
2707
  </InputField>
2564
2708
  )}
2709
+
2710
+ {typeof relevantFormats !== "undefined" && (
2711
+ <InputField label="Relevant formats">
2712
+ {highlight ? (
2713
+ <pre className="max-w-full whitespace-pre-wrap rounded border border-border bg-elevated p-4 text-xs text-text [overflow-wrap:anywhere]">
2714
+ <SearchHighlight text={JSON.stringify(relevantFormats, null, 2)} query={q} />
2715
+ </pre>
2716
+ ) : (
2717
+ <JsonValueBlock value={relevantFormats} />
2718
+ )}
2719
+ </InputField>
2720
+ )}
2565
2721
  </div>
2566
2722
  );
2567
2723
  }
@@ -2570,6 +2726,7 @@ function LocaleExamplesExpandedView(props: {
2570
2726
  examples: EvaluatedLocaleExample[];
2571
2727
  setKey?: string;
2572
2728
  localeDirection?: string;
2729
+ computedFormats?: unknown;
2573
2730
  searchQuery: string;
2574
2731
  }) {
2575
2732
  return (
@@ -2595,6 +2752,7 @@ function LocaleExamplesExpandedView(props: {
2595
2752
  example={example}
2596
2753
  setKey={props.setKey}
2597
2754
  localeDirection={props.localeDirection}
2755
+ computedFormats={props.computedFormats}
2598
2756
  showLocale={false}
2599
2757
  highlightQuery={props.searchQuery}
2600
2758
  />
@@ -2614,6 +2772,7 @@ function LocaleExamplesCompactView(props: {
2614
2772
  examples: EvaluatedLocaleExample[];
2615
2773
  setKey?: string;
2616
2774
  localeDirection?: string;
2775
+ computedFormats?: unknown;
2617
2776
  searchQuery: string;
2618
2777
  }) {
2619
2778
  const [expandedExampleIds, setExpandedExampleIds] = React.useState<string[]>([]);
@@ -2753,6 +2912,7 @@ function LocaleExamplesCompactView(props: {
2753
2912
  example={example}
2754
2913
  setKey={props.setKey}
2755
2914
  localeDirection={props.localeDirection}
2915
+ computedFormats={props.computedFormats}
2756
2916
  showLocale={false}
2757
2917
  highlightQuery={props.searchQuery}
2758
2918
  />
@@ -2888,6 +3048,7 @@ export function LocaleExamplesTab() {
2888
3048
  examples={filteredExamples}
2889
3049
  setKey={setKey}
2890
3050
  localeDirection={localeDirection}
3051
+ computedFormats={detail.computedFormats}
2891
3052
  searchQuery={searchQuery}
2892
3053
  />
2893
3054
  ) : (
@@ -2895,6 +3056,7 @@ export function LocaleExamplesTab() {
2895
3056
  examples={filteredExamples}
2896
3057
  setKey={setKey}
2897
3058
  localeDirection={localeDirection}
3059
+ computedFormats={detail.computedFormats}
2898
3060
  searchQuery={searchQuery}
2899
3061
  />
2900
3062
  )}
@@ -0,0 +1,99 @@
1
+ import { extractIcuFormatStyleReferences, getRelevantIcuFormats } from "./relevantIcuFormats";
2
+
3
+ describe("relevant ICU formats", function () {
4
+ const computedFormats = {
5
+ number: {
6
+ decimal: { maximumFractionDigits: 2 },
7
+ roundingExpand: { maximumFractionDigits: 2, roundingMode: "expand" },
8
+ },
9
+ date: {
10
+ fullStyle: { dateStyle: "full" },
11
+ },
12
+ time: {
13
+ seconds: { hour: "numeric", minute: "2-digit", second: "2-digit" },
14
+ },
15
+ };
16
+
17
+ it("finds a named number style in a raw message", function () {
18
+ expect(
19
+ getRelevantIcuFormats("Value: {value, number, roundingExpand}", computedFormats),
20
+ ).toEqual({
21
+ number: {
22
+ roundingExpand: { maximumFractionDigits: 2, roundingMode: "expand" },
23
+ },
24
+ });
25
+ });
26
+
27
+ it("can use the original translation from a message-key example", function () {
28
+ expect(getRelevantIcuFormats("Amount: {amount, number, decimal}", computedFormats)).toEqual({
29
+ number: {
30
+ decimal: { maximumFractionDigits: 2 },
31
+ },
32
+ });
33
+ });
34
+
35
+ it("includes date and time styles", function () {
36
+ expect(
37
+ getRelevantIcuFormats(
38
+ "When: {when, date, fullStyle} at {when, time, seconds}",
39
+ computedFormats,
40
+ ),
41
+ ).toEqual({
42
+ date: {
43
+ fullStyle: { dateStyle: "full" },
44
+ },
45
+ time: {
46
+ seconds: { hour: "numeric", minute: "2-digit", second: "2-digit" },
47
+ },
48
+ });
49
+ });
50
+
51
+ it("deduplicates repeated styles while preserving first-seen type order", function () {
52
+ expect(
53
+ extractIcuFormatStyleReferences(
54
+ "{amount, number, decimal} then {otherAmount, number, decimal}",
55
+ ),
56
+ ).toEqual({
57
+ number: ["decimal"],
58
+ });
59
+ });
60
+
61
+ it("finds style references nested in ICU plural and select branches", function () {
62
+ expect(
63
+ getRelevantIcuFormats(
64
+ "{count, plural, one {{amount, number, decimal}} other {{when, date, fullStyle}}}",
65
+ computedFormats,
66
+ ),
67
+ ).toEqual({
68
+ number: {
69
+ decimal: { maximumFractionDigits: 2 },
70
+ },
71
+ date: {
72
+ fullStyle: { dateStyle: "full" },
73
+ },
74
+ });
75
+ });
76
+
77
+ it("ignores unknown styles, skeletons, and non-format ICU constructs", function () {
78
+ expect(
79
+ getRelevantIcuFormats(
80
+ "{amount, number, missing} {when, date, ::yyyyMMdd} {status, select, open {Open} other {Other}}",
81
+ computedFormats,
82
+ ),
83
+ ).toBeUndefined();
84
+ });
85
+
86
+ it("applies inline example formats as style-level overrides", function () {
87
+ expect(
88
+ getRelevantIcuFormats("{amount, number, decimal}", computedFormats, {
89
+ number: {
90
+ decimal: { maximumFractionDigits: 4 },
91
+ },
92
+ }),
93
+ ).toEqual({
94
+ number: {
95
+ decimal: { maximumFractionDigits: 4 },
96
+ },
97
+ });
98
+ });
99
+ });
@@ -0,0 +1,129 @@
1
+ type FormatBucket = Record<string, unknown>;
2
+ type FormatPresetsLike = Record<string, FormatBucket | unknown>;
3
+
4
+ const ICU_FORMAT_TYPES = ["number", "date", "time"] as const;
5
+
6
+ type IcuFormatType = (typeof ICU_FORMAT_TYPES)[number];
7
+
8
+ const ICU_FORMAT_TYPE_SET = new Set<string>(ICU_FORMAT_TYPES);
9
+
10
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
11
+ return typeof value === "object" && value !== null && !Array.isArray(value);
12
+ }
13
+
14
+ function mergeFormatPresetsLike(
15
+ parent?: FormatPresetsLike,
16
+ child?: FormatPresetsLike,
17
+ ): FormatPresetsLike | undefined {
18
+ if (!isPlainObject(parent)) {
19
+ return isPlainObject(child) ? child : undefined;
20
+ }
21
+
22
+ if (!isPlainObject(child)) {
23
+ return parent;
24
+ }
25
+
26
+ const result: FormatPresetsLike = { ...parent };
27
+
28
+ for (const typeKey of Object.keys(child)) {
29
+ const parentStyles = result[typeKey];
30
+ const childStyles = child[typeKey];
31
+
32
+ if (!isPlainObject(parentStyles) || !isPlainObject(childStyles)) {
33
+ result[typeKey] = childStyles;
34
+ continue;
35
+ }
36
+
37
+ result[typeKey] = {
38
+ ...parentStyles,
39
+ ...childStyles,
40
+ };
41
+ }
42
+
43
+ return result;
44
+ }
45
+
46
+ function normalizeIcuStyleName(value: string) {
47
+ const style = value.trim();
48
+
49
+ if (!style || style.startsWith("::")) {
50
+ return undefined;
51
+ }
52
+
53
+ return style.replace(/,$/, "").trim() || undefined;
54
+ }
55
+
56
+ export function extractIcuFormatStyleReferences(message: string | undefined) {
57
+ const references: Partial<Record<IcuFormatType, string[]>> = {};
58
+
59
+ if (!message) {
60
+ return references;
61
+ }
62
+
63
+ const pattern = /\{[^{}]*,\s*(number|date|time)\s*,\s*([^{}]+?)\}/g;
64
+ let match: RegExpExecArray | null;
65
+
66
+ while ((match = pattern.exec(message))) {
67
+ const type = match[1] as IcuFormatType;
68
+ const style = normalizeIcuStyleName(match[2]);
69
+
70
+ if (!style) {
71
+ continue;
72
+ }
73
+
74
+ if (!references[type]) {
75
+ references[type] = [];
76
+ }
77
+
78
+ if (!references[type]!.includes(style)) {
79
+ references[type]!.push(style);
80
+ }
81
+ }
82
+
83
+ return references;
84
+ }
85
+
86
+ export function getRelevantIcuFormats(
87
+ message: string | undefined,
88
+ computedFormats: unknown,
89
+ exampleFormats?: unknown,
90
+ ) {
91
+ const effectiveFormats = mergeFormatPresetsLike(
92
+ isPlainObject(computedFormats) ? computedFormats : undefined,
93
+ isPlainObject(exampleFormats) ? exampleFormats : undefined,
94
+ );
95
+ const references = extractIcuFormatStyleReferences(message);
96
+ const relevant: Partial<Record<IcuFormatType, Record<string, unknown>>> = {};
97
+
98
+ if (!effectiveFormats) {
99
+ return undefined;
100
+ }
101
+
102
+ for (const typeKey of Object.keys(references)) {
103
+ if (!ICU_FORMAT_TYPE_SET.has(typeKey)) {
104
+ continue;
105
+ }
106
+
107
+ const type = typeKey as IcuFormatType;
108
+ const styles = references[type] || [];
109
+ const availableStyles = effectiveFormats[type];
110
+
111
+ if (!isPlainObject(availableStyles)) {
112
+ continue;
113
+ }
114
+
115
+ for (const style of styles) {
116
+ if (!Object.prototype.hasOwnProperty.call(availableStyles, style)) {
117
+ continue;
118
+ }
119
+
120
+ if (!relevant[type]) {
121
+ relevant[type] = {};
122
+ }
123
+
124
+ relevant[type]![style] = availableStyles[style];
125
+ }
126
+ }
127
+
128
+ return Object.keys(relevant).length > 0 ? relevant : undefined;
129
+ }