@messagevisor/catalog 0.8.0 → 0.13.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-Bic-2mlS.css +1 -0
- package/dist/assets/index-C8ldlvUw.js +73 -0
- package/dist/index.html +2 -2
- package/lib/node/index.js +7 -2
- package/lib/node/index.js.map +1 -1
- package/package.json +2 -2
- package/src/node/index.ts +8 -2
- package/src/pages/EntityDetailPage.tsx +225 -24
- package/src/utils/relevantIcuFormats.spec.ts +99 -0
- package/src/utils/relevantIcuFormats.ts +129 -0
- package/dist/assets/index-4rkVIXGk.js +0 -73
- package/dist/assets/index-DrsX4U8c.css +0 -1
|
@@ -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 ? (
|
|
@@ -756,6 +785,48 @@ function FormatRowsTable(props: {
|
|
|
756
785
|
);
|
|
757
786
|
}
|
|
758
787
|
|
|
788
|
+
function renderFormatSourceBadge(row: FormatRow) {
|
|
789
|
+
if (row.source === "inherited" && row.from) {
|
|
790
|
+
return (
|
|
791
|
+
<LabelValueBadge
|
|
792
|
+
label="inherited from"
|
|
793
|
+
value={row.from}
|
|
794
|
+
to={getEntityRoute("locale", row.from, setKey)}
|
|
795
|
+
tone="inheritance"
|
|
796
|
+
compact
|
|
797
|
+
/>
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
if (row.source === "target") {
|
|
802
|
+
return <LabelValueBadge label="from" value="target" tone="neutral" compact />;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
return null;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function renderFormatSourceMeta(row: FormatRow) {
|
|
809
|
+
if (row.source === "inherited" && row.from) {
|
|
810
|
+
return (
|
|
811
|
+
<div className="text-[10px] font-normal leading-snug text-faint">
|
|
812
|
+
Inherited from{" "}
|
|
813
|
+
<Link
|
|
814
|
+
to={getEntityRoute("locale", row.from, setKey)}
|
|
815
|
+
className="font-medium text-muted hover:text-primary hover:underline"
|
|
816
|
+
>
|
|
817
|
+
{row.from}
|
|
818
|
+
</Link>
|
|
819
|
+
</div>
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
if (row.source === "target") {
|
|
824
|
+
return <div className="text-[10px] font-normal leading-snug text-faint">Target override</div>;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
return null;
|
|
828
|
+
}
|
|
829
|
+
|
|
759
830
|
/** Plain text in the cell. Param stays monospace for paths. */
|
|
760
831
|
function renderSplitSegment(segment: string, role: "type" | "style" | "param") {
|
|
761
832
|
const body = segmentBody(segment);
|
|
@@ -776,6 +847,23 @@ function FormatRowsTable(props: {
|
|
|
776
847
|
);
|
|
777
848
|
}
|
|
778
849
|
|
|
850
|
+
function renderSplitStyleCellContent(plan: FormatSplitRowPlan) {
|
|
851
|
+
const sourceMeta = renderFormatSourceMeta(plan.row);
|
|
852
|
+
const fragmentId = formatStyleFragmentId(plan.parts.type, plan.parts.style);
|
|
853
|
+
|
|
854
|
+
return (
|
|
855
|
+
<div className="flex min-w-0 flex-col items-start gap-1.5">
|
|
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>
|
|
862
|
+
{sourceMeta}
|
|
863
|
+
</div>
|
|
864
|
+
);
|
|
865
|
+
}
|
|
866
|
+
|
|
779
867
|
function bandSurfaceClass(band: number) {
|
|
780
868
|
return band % 2 === 0 ? "bg-surface" : "bg-elevated/[0.2]";
|
|
781
869
|
}
|
|
@@ -801,8 +889,7 @@ function FormatRowsTable(props: {
|
|
|
801
889
|
|
|
802
890
|
function renderValueColumn(row: FormatRow, bandAndPaddingClass: string) {
|
|
803
891
|
const valueText = formatValue(row.value);
|
|
804
|
-
const
|
|
805
|
-
const showTargetBadge = row.source === "target";
|
|
892
|
+
const badge = splitPath ? null : renderFormatSourceBadge(row);
|
|
806
893
|
|
|
807
894
|
return (
|
|
808
895
|
<td className={[bandAndPaddingClass, formatSplitCellBorderClass("value")].join(" ")}>
|
|
@@ -815,21 +902,8 @@ function FormatRowsTable(props: {
|
|
|
815
902
|
>
|
|
816
903
|
{highlight ? <SearchHighlight text={valueText} query={highlightNeedle} /> : valueText}
|
|
817
904
|
</div>
|
|
818
|
-
{
|
|
819
|
-
<div className="flex shrink-0 flex-col items-end justify-center gap-1">
|
|
820
|
-
{showInheritedBadge && row.from ? (
|
|
821
|
-
<LabelValueBadge
|
|
822
|
-
label="inherited from"
|
|
823
|
-
value={row.from}
|
|
824
|
-
to={getEntityRoute("locale", row.from, setKey)}
|
|
825
|
-
tone="inheritance"
|
|
826
|
-
compact
|
|
827
|
-
/>
|
|
828
|
-
) : null}
|
|
829
|
-
{showTargetBadge ? (
|
|
830
|
-
<LabelValueBadge label="from" value="target" tone="neutral" compact />
|
|
831
|
-
) : null}
|
|
832
|
-
</div>
|
|
905
|
+
{badge && (
|
|
906
|
+
<div className="flex shrink-0 flex-col items-end justify-center gap-1">{badge}</div>
|
|
833
907
|
)}
|
|
834
908
|
</div>
|
|
835
909
|
</td>
|
|
@@ -881,6 +955,109 @@ function FormatRowsTable(props: {
|
|
|
881
955
|
);
|
|
882
956
|
}
|
|
883
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
|
+
|
|
884
1061
|
return (
|
|
885
1062
|
<div className="min-w-0 overflow-x-auto rounded-xl border border-border">
|
|
886
1063
|
<table className="w-full min-w-[40rem] table-fixed border-collapse bg-surface text-xs">
|
|
@@ -896,9 +1073,9 @@ function FormatRowsTable(props: {
|
|
|
896
1073
|
</>
|
|
897
1074
|
) : (
|
|
898
1075
|
<>
|
|
899
|
-
<col className="min-w-0 w-[
|
|
900
|
-
<col className="min-w-0 w-[
|
|
901
|
-
<col className="min-w-0 w-[
|
|
1076
|
+
<col className="min-w-0 w-[28%]" />
|
|
1077
|
+
<col className="min-w-0 w-[32%]" />
|
|
1078
|
+
<col className="min-w-0 w-[40%]" />
|
|
902
1079
|
</>
|
|
903
1080
|
)
|
|
904
1081
|
) : showExampleColumn ? (
|
|
@@ -912,9 +1089,9 @@ function FormatRowsTable(props: {
|
|
|
912
1089
|
) : (
|
|
913
1090
|
<>
|
|
914
1091
|
<col className="min-w-0 w-[12%]" />
|
|
915
|
-
<col className="min-w-0 w-[
|
|
916
|
-
<col className="min-w-0 w-[
|
|
917
|
-
<col className="min-w-0 w-[
|
|
1092
|
+
<col className="min-w-0 w-[24%]" />
|
|
1093
|
+
<col className="min-w-0 w-[28%]" />
|
|
1094
|
+
<col className="min-w-0 w-[36%]" />
|
|
918
1095
|
</>
|
|
919
1096
|
)}
|
|
920
1097
|
</colgroup>
|
|
@@ -1003,7 +1180,7 @@ function FormatRowsTable(props: {
|
|
|
1003
1180
|
formatSplitCellBorderClass("style"),
|
|
1004
1181
|
].join(" ")}
|
|
1005
1182
|
>
|
|
1006
|
-
{
|
|
1183
|
+
{renderSplitStyleCellContent(plan)}
|
|
1007
1184
|
</td>
|
|
1008
1185
|
) : null}
|
|
1009
1186
|
{renderSplitExampleColumn(plan, bandClass)}
|
|
@@ -2366,12 +2543,18 @@ function LocaleExampleDetails(props: {
|
|
|
2366
2543
|
example: EvaluatedLocaleExample;
|
|
2367
2544
|
setKey?: string;
|
|
2368
2545
|
localeDirection?: string;
|
|
2546
|
+
computedFormats?: unknown;
|
|
2369
2547
|
showLocale?: boolean;
|
|
2370
2548
|
highlightQuery?: string;
|
|
2371
2549
|
}) {
|
|
2372
2550
|
const { example, setKey, localeDirection } = props;
|
|
2373
2551
|
const q = props.highlightQuery?.trim() ?? "";
|
|
2374
2552
|
const highlight = Boolean(q);
|
|
2553
|
+
const relevantFormats = getRelevantIcuFormats(
|
|
2554
|
+
example.rawMessage || example.originalTranslation,
|
|
2555
|
+
props.computedFormats,
|
|
2556
|
+
example.formats,
|
|
2557
|
+
);
|
|
2375
2558
|
|
|
2376
2559
|
function localeLink(localeKey: string) {
|
|
2377
2560
|
return highlight ? (
|
|
@@ -2523,6 +2706,18 @@ function LocaleExampleDetails(props: {
|
|
|
2523
2706
|
)}
|
|
2524
2707
|
</InputField>
|
|
2525
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
|
+
)}
|
|
2526
2721
|
</div>
|
|
2527
2722
|
);
|
|
2528
2723
|
}
|
|
@@ -2531,6 +2726,7 @@ function LocaleExamplesExpandedView(props: {
|
|
|
2531
2726
|
examples: EvaluatedLocaleExample[];
|
|
2532
2727
|
setKey?: string;
|
|
2533
2728
|
localeDirection?: string;
|
|
2729
|
+
computedFormats?: unknown;
|
|
2534
2730
|
searchQuery: string;
|
|
2535
2731
|
}) {
|
|
2536
2732
|
return (
|
|
@@ -2556,6 +2752,7 @@ function LocaleExamplesExpandedView(props: {
|
|
|
2556
2752
|
example={example}
|
|
2557
2753
|
setKey={props.setKey}
|
|
2558
2754
|
localeDirection={props.localeDirection}
|
|
2755
|
+
computedFormats={props.computedFormats}
|
|
2559
2756
|
showLocale={false}
|
|
2560
2757
|
highlightQuery={props.searchQuery}
|
|
2561
2758
|
/>
|
|
@@ -2575,6 +2772,7 @@ function LocaleExamplesCompactView(props: {
|
|
|
2575
2772
|
examples: EvaluatedLocaleExample[];
|
|
2576
2773
|
setKey?: string;
|
|
2577
2774
|
localeDirection?: string;
|
|
2775
|
+
computedFormats?: unknown;
|
|
2578
2776
|
searchQuery: string;
|
|
2579
2777
|
}) {
|
|
2580
2778
|
const [expandedExampleIds, setExpandedExampleIds] = React.useState<string[]>([]);
|
|
@@ -2714,6 +2912,7 @@ function LocaleExamplesCompactView(props: {
|
|
|
2714
2912
|
example={example}
|
|
2715
2913
|
setKey={props.setKey}
|
|
2716
2914
|
localeDirection={props.localeDirection}
|
|
2915
|
+
computedFormats={props.computedFormats}
|
|
2717
2916
|
showLocale={false}
|
|
2718
2917
|
highlightQuery={props.searchQuery}
|
|
2719
2918
|
/>
|
|
@@ -2849,6 +3048,7 @@ export function LocaleExamplesTab() {
|
|
|
2849
3048
|
examples={filteredExamples}
|
|
2850
3049
|
setKey={setKey}
|
|
2851
3050
|
localeDirection={localeDirection}
|
|
3051
|
+
computedFormats={detail.computedFormats}
|
|
2852
3052
|
searchQuery={searchQuery}
|
|
2853
3053
|
/>
|
|
2854
3054
|
) : (
|
|
@@ -2856,6 +3056,7 @@ export function LocaleExamplesTab() {
|
|
|
2856
3056
|
examples={filteredExamples}
|
|
2857
3057
|
setKey={setKey}
|
|
2858
3058
|
localeDirection={localeDirection}
|
|
3059
|
+
computedFormats={detail.computedFormats}
|
|
2859
3060
|
searchQuery={searchQuery}
|
|
2860
3061
|
/>
|
|
2861
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
|
+
}
|