next-formatter 2.0.2 → 2.0.3
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/cjs/client.js +195 -2
- package/dist/cjs/server.js +235 -10
- package/dist/esm/client.mjs +194 -1
- package/dist/esm/server.mjs +229 -4
- package/package.json +2 -15
- package/dist/cjs/core.js +0 -198
- package/dist/cjs/create.js +0 -27
- package/dist/esm/core.mjs +0 -196
- package/dist/esm/create.mjs +0 -24
- /package/dist/{cjs → esm}/index.js +0 -0
package/dist/cjs/client.js
CHANGED
|
@@ -2,13 +2,206 @@
|
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
4
|
var react = require('react');
|
|
5
|
-
var core = require('./core');
|
|
6
5
|
var jsxRuntime = require('react/jsx-runtime');
|
|
7
6
|
|
|
7
|
+
// src/core.ts
|
|
8
|
+
var DEFAULT_RULES = {
|
|
9
|
+
compactThreshold: 1e4,
|
|
10
|
+
minimumFractionDigits: 0,
|
|
11
|
+
maximumFractionDigits: 2,
|
|
12
|
+
currencyDisplay: "narrowSymbol",
|
|
13
|
+
numberFormat: {},
|
|
14
|
+
dateFormat: { year: "numeric", month: "short", day: "2-digit" },
|
|
15
|
+
dateTimeFormat: {
|
|
16
|
+
year: "numeric",
|
|
17
|
+
month: "short",
|
|
18
|
+
day: "2-digit",
|
|
19
|
+
hour: "2-digit",
|
|
20
|
+
minute: "2-digit",
|
|
21
|
+
hour12: true
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
function createFormatters(config = {}) {
|
|
25
|
+
const locale = config.locale ?? "en-US";
|
|
26
|
+
const defaultCurrency = config.currency ?? "USD";
|
|
27
|
+
const fallback = config.fallback ?? "\u2014";
|
|
28
|
+
const rules = {
|
|
29
|
+
...DEFAULT_RULES,
|
|
30
|
+
...config.rules
|
|
31
|
+
};
|
|
32
|
+
function toNumber(value) {
|
|
33
|
+
if (value == null || value === "") return null;
|
|
34
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
35
|
+
return Number.isNaN(n) ? null : n;
|
|
36
|
+
}
|
|
37
|
+
function toValidDate(value) {
|
|
38
|
+
if (value == null || value === "") return null;
|
|
39
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
40
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
41
|
+
}
|
|
42
|
+
function isLarge(n) {
|
|
43
|
+
return Math.abs(n) >= rules.compactThreshold;
|
|
44
|
+
}
|
|
45
|
+
function normalizeICU(str) {
|
|
46
|
+
return str.replace(/\u202f/g, " ").replace(/\u00a0/g, " ").trim();
|
|
47
|
+
}
|
|
48
|
+
function alignFractionDigits(opts) {
|
|
49
|
+
const { minimumFractionDigits, maximumFractionDigits, maximumSignificantDigits, minimumSignificantDigits } = opts;
|
|
50
|
+
if (minimumFractionDigits !== void 0 && maximumFractionDigits !== void 0 && minimumFractionDigits !== maximumFractionDigits && maximumSignificantDigits === void 0 && minimumSignificantDigits === void 0) {
|
|
51
|
+
return { ...opts, minimumFractionDigits: maximumFractionDigits };
|
|
52
|
+
}
|
|
53
|
+
return opts;
|
|
54
|
+
}
|
|
55
|
+
const numberCache = /* @__PURE__ */ new Map();
|
|
56
|
+
function getNumberFormatter(options) {
|
|
57
|
+
const key = JSON.stringify({ locale, ...options });
|
|
58
|
+
if (!numberCache.has(key)) {
|
|
59
|
+
numberCache.set(key, new Intl.NumberFormat(locale, options));
|
|
60
|
+
}
|
|
61
|
+
return numberCache.get(key);
|
|
62
|
+
}
|
|
63
|
+
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto", style: "long" });
|
|
64
|
+
const formatters = {
|
|
65
|
+
/**
|
|
66
|
+
* Format a plain number.
|
|
67
|
+
* @example
|
|
68
|
+
* fmt.number(1234567) // "1.2M"
|
|
69
|
+
* fmt.number(1234, { notation: "standard" }) // "1,234"
|
|
70
|
+
* fmt.number(1.5678, { maximumFractionDigits: 1 }) // "1.6"
|
|
71
|
+
*/
|
|
72
|
+
number(value, options = {}) {
|
|
73
|
+
const n = toNumber(value);
|
|
74
|
+
if (n == null) return fallback;
|
|
75
|
+
return normalizeICU(
|
|
76
|
+
getNumberFormatter(
|
|
77
|
+
alignFractionDigits({
|
|
78
|
+
style: "decimal",
|
|
79
|
+
notation: isLarge(n) ? "compact" : "standard",
|
|
80
|
+
minimumFractionDigits: rules.minimumFractionDigits,
|
|
81
|
+
maximumFractionDigits: rules.maximumFractionDigits,
|
|
82
|
+
...rules.numberFormat,
|
|
83
|
+
...options
|
|
84
|
+
})
|
|
85
|
+
).format(n)
|
|
86
|
+
);
|
|
87
|
+
},
|
|
88
|
+
/**
|
|
89
|
+
* Format a currency value.
|
|
90
|
+
* @example
|
|
91
|
+
* fmt.currency(49900) // "$49.9K"
|
|
92
|
+
* fmt.currency(1234, { currency: "EUR" }) // "€1,234"
|
|
93
|
+
* fmt.currency(1234, { currencyDisplay: "code" }) // "USD 1,234"
|
|
94
|
+
* fmt.currency(1234, { minimumFractionDigits: 2 }) // "$1,234.00"
|
|
95
|
+
*/
|
|
96
|
+
currency(value, options = {}) {
|
|
97
|
+
const n = toNumber(value);
|
|
98
|
+
if (n == null) return fallback;
|
|
99
|
+
const { currency: overrideCurrency, currencyDisplay, ...rest } = options;
|
|
100
|
+
return normalizeICU(
|
|
101
|
+
getNumberFormatter(
|
|
102
|
+
alignFractionDigits({
|
|
103
|
+
style: "currency",
|
|
104
|
+
currency: overrideCurrency ?? defaultCurrency,
|
|
105
|
+
notation: isLarge(n) ? "compact" : "standard",
|
|
106
|
+
minimumFractionDigits: rules.minimumFractionDigits,
|
|
107
|
+
maximumFractionDigits: rules.maximumFractionDigits,
|
|
108
|
+
currencyDisplay: currencyDisplay ?? rules.currencyDisplay,
|
|
109
|
+
...rules.numberFormat,
|
|
110
|
+
...rest
|
|
111
|
+
})
|
|
112
|
+
).format(n)
|
|
113
|
+
);
|
|
114
|
+
},
|
|
115
|
+
/**
|
|
116
|
+
* Format a percentage. Input is treated as a raw percentage (50 → "50%").
|
|
117
|
+
* Uses minimumFractionDigits and maximumFractionDigits from rules.
|
|
118
|
+
* For values that would round to zero, maximumSignificantDigits is set to
|
|
119
|
+
* max(maximumFractionDigits, 4) to preserve meaningful precision.
|
|
120
|
+
* @example
|
|
121
|
+
* fmt.percentage(12.5) // "12.50%" (with default rules min=0, max=2 → aligned to 2)
|
|
122
|
+
* fmt.percentage(0.001) // "0.001%" (wouldBeZero → uses significantDigits)
|
|
123
|
+
* fmt.percentage(12.5, { maximumFractionDigits: 0 }) // "13%"
|
|
124
|
+
*/
|
|
125
|
+
percentage(value, options = {}) {
|
|
126
|
+
const n = toNumber(value);
|
|
127
|
+
if (n == null) return fallback;
|
|
128
|
+
const normalized = n / 100;
|
|
129
|
+
const wouldBeZero = parseFloat(normalized.toFixed(3)) === 0;
|
|
130
|
+
const sigDigits = Math.max(rules.maximumFractionDigits, 4);
|
|
131
|
+
return getNumberFormatter({
|
|
132
|
+
style: "percent",
|
|
133
|
+
...wouldBeZero ? { maximumSignificantDigits: sigDigits } : alignFractionDigits({
|
|
134
|
+
minimumFractionDigits: rules.minimumFractionDigits,
|
|
135
|
+
maximumFractionDigits: rules.maximumFractionDigits
|
|
136
|
+
}),
|
|
137
|
+
...options
|
|
138
|
+
}).format(normalized);
|
|
139
|
+
},
|
|
140
|
+
/**
|
|
141
|
+
* Format a duration in seconds. Pure math — no Intl, no ICU risk.
|
|
142
|
+
* @example
|
|
143
|
+
* fmt.duration(150) // "2m 30s"
|
|
144
|
+
* fmt.duration(45) // "45s"
|
|
145
|
+
*/
|
|
146
|
+
duration(value) {
|
|
147
|
+
const n = toNumber(value);
|
|
148
|
+
if (n == null) return fallback;
|
|
149
|
+
const mins = Math.floor(n / 60);
|
|
150
|
+
const secs = n % 60;
|
|
151
|
+
return mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
|
|
152
|
+
},
|
|
153
|
+
/**
|
|
154
|
+
* Format a date.
|
|
155
|
+
* @example
|
|
156
|
+
* fmt.date("2024-01-15") // "Jan 15, 2024"
|
|
157
|
+
* fmt.date("2024-01-15", { dateStyle: "full" }) // "Monday, January 15, 2024"
|
|
158
|
+
*/
|
|
159
|
+
date(value, options = {}) {
|
|
160
|
+
const date = toValidDate(value);
|
|
161
|
+
if (!date) return fallback;
|
|
162
|
+
const resolvedOptions = options.dateStyle ? options : { ...rules.dateFormat, ...options };
|
|
163
|
+
return normalizeICU(new Intl.DateTimeFormat(locale, resolvedOptions).format(date));
|
|
164
|
+
},
|
|
165
|
+
dateTime(value, options = {}) {
|
|
166
|
+
const date = toValidDate(value);
|
|
167
|
+
if (!date) return fallback;
|
|
168
|
+
const resolvedOptions = options.dateStyle || options.timeStyle ? options : { ...rules.dateTimeFormat, ...options };
|
|
169
|
+
return normalizeICU(new Intl.DateTimeFormat(locale, resolvedOptions).format(date));
|
|
170
|
+
},
|
|
171
|
+
/**
|
|
172
|
+
* Format a date as relative time.
|
|
173
|
+
* Always pass an explicit `now` timestamp — this ensures consistent
|
|
174
|
+
* output whether called from a server or client component.
|
|
175
|
+
* @example
|
|
176
|
+
* const now = Date.now();
|
|
177
|
+
* fmt.relativeTime("2024-01-15", now) // "3 months ago"
|
|
178
|
+
*/
|
|
179
|
+
relativeTime(value, now = Date.now()) {
|
|
180
|
+
const date = toValidDate(value);
|
|
181
|
+
if (!date) return fallback;
|
|
182
|
+
const diffSec = Math.round((date.getTime() - now) / 1e3);
|
|
183
|
+
const abs = Math.abs(diffSec);
|
|
184
|
+
if (abs < 60) return rtf.format(diffSec, "second");
|
|
185
|
+
const min = Math.round(diffSec / 60);
|
|
186
|
+
if (Math.abs(min) < 60) return rtf.format(min, "minute");
|
|
187
|
+
const hr = Math.round(min / 60);
|
|
188
|
+
if (Math.abs(hr) < 24) return rtf.format(hr, "hour");
|
|
189
|
+
const day = Math.round(hr / 24);
|
|
190
|
+
if (Math.abs(day) < 7) return rtf.format(day, "day");
|
|
191
|
+
const week = Math.round(day / 7);
|
|
192
|
+
if (Math.abs(week) < 4) return rtf.format(week, "week");
|
|
193
|
+
const month = Math.round(day / 30);
|
|
194
|
+
if (Math.abs(month) < 12) return rtf.format(month, "month");
|
|
195
|
+
const year = Math.round(day / 365);
|
|
196
|
+
return rtf.format(year, "year");
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
return formatters;
|
|
200
|
+
}
|
|
8
201
|
var FormatterContext = react.createContext(null);
|
|
9
202
|
function FormattersProvider({ config, children }) {
|
|
10
203
|
const formatters = react.useMemo(
|
|
11
|
-
() =>
|
|
204
|
+
() => createFormatters(config),
|
|
12
205
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
13
206
|
[config.locale, config.currency, config.fallback, JSON.stringify(config.rules)]
|
|
14
207
|
);
|
package/dist/cjs/server.js
CHANGED
|
@@ -1,20 +1,245 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
var
|
|
4
|
-
var core = require('./core');
|
|
5
|
-
var create = require('./create');
|
|
3
|
+
var react = require('react');
|
|
6
4
|
var jsxRuntime = require('react/jsx-runtime');
|
|
7
5
|
|
|
8
|
-
// src/
|
|
6
|
+
// src/client.tsx
|
|
7
|
+
|
|
8
|
+
// src/core.ts
|
|
9
|
+
var DEFAULT_RULES = {
|
|
10
|
+
compactThreshold: 1e4,
|
|
11
|
+
minimumFractionDigits: 0,
|
|
12
|
+
maximumFractionDigits: 2,
|
|
13
|
+
currencyDisplay: "narrowSymbol",
|
|
14
|
+
numberFormat: {},
|
|
15
|
+
dateFormat: { year: "numeric", month: "short", day: "2-digit" },
|
|
16
|
+
dateTimeFormat: {
|
|
17
|
+
year: "numeric",
|
|
18
|
+
month: "short",
|
|
19
|
+
day: "2-digit",
|
|
20
|
+
hour: "2-digit",
|
|
21
|
+
minute: "2-digit",
|
|
22
|
+
hour12: true
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
function createFormatters(config = {}) {
|
|
26
|
+
const locale = config.locale ?? "en-US";
|
|
27
|
+
const defaultCurrency = config.currency ?? "USD";
|
|
28
|
+
const fallback = config.fallback ?? "\u2014";
|
|
29
|
+
const rules = {
|
|
30
|
+
...DEFAULT_RULES,
|
|
31
|
+
...config.rules
|
|
32
|
+
};
|
|
33
|
+
function toNumber(value) {
|
|
34
|
+
if (value == null || value === "") return null;
|
|
35
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
36
|
+
return Number.isNaN(n) ? null : n;
|
|
37
|
+
}
|
|
38
|
+
function toValidDate(value) {
|
|
39
|
+
if (value == null || value === "") return null;
|
|
40
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
41
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
42
|
+
}
|
|
43
|
+
function isLarge(n) {
|
|
44
|
+
return Math.abs(n) >= rules.compactThreshold;
|
|
45
|
+
}
|
|
46
|
+
function normalizeICU(str) {
|
|
47
|
+
return str.replace(/\u202f/g, " ").replace(/\u00a0/g, " ").trim();
|
|
48
|
+
}
|
|
49
|
+
function alignFractionDigits(opts) {
|
|
50
|
+
const { minimumFractionDigits, maximumFractionDigits, maximumSignificantDigits, minimumSignificantDigits } = opts;
|
|
51
|
+
if (minimumFractionDigits !== void 0 && maximumFractionDigits !== void 0 && minimumFractionDigits !== maximumFractionDigits && maximumSignificantDigits === void 0 && minimumSignificantDigits === void 0) {
|
|
52
|
+
return { ...opts, minimumFractionDigits: maximumFractionDigits };
|
|
53
|
+
}
|
|
54
|
+
return opts;
|
|
55
|
+
}
|
|
56
|
+
const numberCache = /* @__PURE__ */ new Map();
|
|
57
|
+
function getNumberFormatter(options) {
|
|
58
|
+
const key = JSON.stringify({ locale, ...options });
|
|
59
|
+
if (!numberCache.has(key)) {
|
|
60
|
+
numberCache.set(key, new Intl.NumberFormat(locale, options));
|
|
61
|
+
}
|
|
62
|
+
return numberCache.get(key);
|
|
63
|
+
}
|
|
64
|
+
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto", style: "long" });
|
|
65
|
+
const formatters = {
|
|
66
|
+
/**
|
|
67
|
+
* Format a plain number.
|
|
68
|
+
* @example
|
|
69
|
+
* fmt.number(1234567) // "1.2M"
|
|
70
|
+
* fmt.number(1234, { notation: "standard" }) // "1,234"
|
|
71
|
+
* fmt.number(1.5678, { maximumFractionDigits: 1 }) // "1.6"
|
|
72
|
+
*/
|
|
73
|
+
number(value, options = {}) {
|
|
74
|
+
const n = toNumber(value);
|
|
75
|
+
if (n == null) return fallback;
|
|
76
|
+
return normalizeICU(
|
|
77
|
+
getNumberFormatter(
|
|
78
|
+
alignFractionDigits({
|
|
79
|
+
style: "decimal",
|
|
80
|
+
notation: isLarge(n) ? "compact" : "standard",
|
|
81
|
+
minimumFractionDigits: rules.minimumFractionDigits,
|
|
82
|
+
maximumFractionDigits: rules.maximumFractionDigits,
|
|
83
|
+
...rules.numberFormat,
|
|
84
|
+
...options
|
|
85
|
+
})
|
|
86
|
+
).format(n)
|
|
87
|
+
);
|
|
88
|
+
},
|
|
89
|
+
/**
|
|
90
|
+
* Format a currency value.
|
|
91
|
+
* @example
|
|
92
|
+
* fmt.currency(49900) // "$49.9K"
|
|
93
|
+
* fmt.currency(1234, { currency: "EUR" }) // "€1,234"
|
|
94
|
+
* fmt.currency(1234, { currencyDisplay: "code" }) // "USD 1,234"
|
|
95
|
+
* fmt.currency(1234, { minimumFractionDigits: 2 }) // "$1,234.00"
|
|
96
|
+
*/
|
|
97
|
+
currency(value, options = {}) {
|
|
98
|
+
const n = toNumber(value);
|
|
99
|
+
if (n == null) return fallback;
|
|
100
|
+
const { currency: overrideCurrency, currencyDisplay, ...rest } = options;
|
|
101
|
+
return normalizeICU(
|
|
102
|
+
getNumberFormatter(
|
|
103
|
+
alignFractionDigits({
|
|
104
|
+
style: "currency",
|
|
105
|
+
currency: overrideCurrency ?? defaultCurrency,
|
|
106
|
+
notation: isLarge(n) ? "compact" : "standard",
|
|
107
|
+
minimumFractionDigits: rules.minimumFractionDigits,
|
|
108
|
+
maximumFractionDigits: rules.maximumFractionDigits,
|
|
109
|
+
currencyDisplay: currencyDisplay ?? rules.currencyDisplay,
|
|
110
|
+
...rules.numberFormat,
|
|
111
|
+
...rest
|
|
112
|
+
})
|
|
113
|
+
).format(n)
|
|
114
|
+
);
|
|
115
|
+
},
|
|
116
|
+
/**
|
|
117
|
+
* Format a percentage. Input is treated as a raw percentage (50 → "50%").
|
|
118
|
+
* Uses minimumFractionDigits and maximumFractionDigits from rules.
|
|
119
|
+
* For values that would round to zero, maximumSignificantDigits is set to
|
|
120
|
+
* max(maximumFractionDigits, 4) to preserve meaningful precision.
|
|
121
|
+
* @example
|
|
122
|
+
* fmt.percentage(12.5) // "12.50%" (with default rules min=0, max=2 → aligned to 2)
|
|
123
|
+
* fmt.percentage(0.001) // "0.001%" (wouldBeZero → uses significantDigits)
|
|
124
|
+
* fmt.percentage(12.5, { maximumFractionDigits: 0 }) // "13%"
|
|
125
|
+
*/
|
|
126
|
+
percentage(value, options = {}) {
|
|
127
|
+
const n = toNumber(value);
|
|
128
|
+
if (n == null) return fallback;
|
|
129
|
+
const normalized = n / 100;
|
|
130
|
+
const wouldBeZero = parseFloat(normalized.toFixed(3)) === 0;
|
|
131
|
+
const sigDigits = Math.max(rules.maximumFractionDigits, 4);
|
|
132
|
+
return getNumberFormatter({
|
|
133
|
+
style: "percent",
|
|
134
|
+
...wouldBeZero ? { maximumSignificantDigits: sigDigits } : alignFractionDigits({
|
|
135
|
+
minimumFractionDigits: rules.minimumFractionDigits,
|
|
136
|
+
maximumFractionDigits: rules.maximumFractionDigits
|
|
137
|
+
}),
|
|
138
|
+
...options
|
|
139
|
+
}).format(normalized);
|
|
140
|
+
},
|
|
141
|
+
/**
|
|
142
|
+
* Format a duration in seconds. Pure math — no Intl, no ICU risk.
|
|
143
|
+
* @example
|
|
144
|
+
* fmt.duration(150) // "2m 30s"
|
|
145
|
+
* fmt.duration(45) // "45s"
|
|
146
|
+
*/
|
|
147
|
+
duration(value) {
|
|
148
|
+
const n = toNumber(value);
|
|
149
|
+
if (n == null) return fallback;
|
|
150
|
+
const mins = Math.floor(n / 60);
|
|
151
|
+
const secs = n % 60;
|
|
152
|
+
return mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
|
|
153
|
+
},
|
|
154
|
+
/**
|
|
155
|
+
* Format a date.
|
|
156
|
+
* @example
|
|
157
|
+
* fmt.date("2024-01-15") // "Jan 15, 2024"
|
|
158
|
+
* fmt.date("2024-01-15", { dateStyle: "full" }) // "Monday, January 15, 2024"
|
|
159
|
+
*/
|
|
160
|
+
date(value, options = {}) {
|
|
161
|
+
const date = toValidDate(value);
|
|
162
|
+
if (!date) return fallback;
|
|
163
|
+
const resolvedOptions = options.dateStyle ? options : { ...rules.dateFormat, ...options };
|
|
164
|
+
return normalizeICU(new Intl.DateTimeFormat(locale, resolvedOptions).format(date));
|
|
165
|
+
},
|
|
166
|
+
dateTime(value, options = {}) {
|
|
167
|
+
const date = toValidDate(value);
|
|
168
|
+
if (!date) return fallback;
|
|
169
|
+
const resolvedOptions = options.dateStyle || options.timeStyle ? options : { ...rules.dateTimeFormat, ...options };
|
|
170
|
+
return normalizeICU(new Intl.DateTimeFormat(locale, resolvedOptions).format(date));
|
|
171
|
+
},
|
|
172
|
+
/**
|
|
173
|
+
* Format a date as relative time.
|
|
174
|
+
* Always pass an explicit `now` timestamp — this ensures consistent
|
|
175
|
+
* output whether called from a server or client component.
|
|
176
|
+
* @example
|
|
177
|
+
* const now = Date.now();
|
|
178
|
+
* fmt.relativeTime("2024-01-15", now) // "3 months ago"
|
|
179
|
+
*/
|
|
180
|
+
relativeTime(value, now = Date.now()) {
|
|
181
|
+
const date = toValidDate(value);
|
|
182
|
+
if (!date) return fallback;
|
|
183
|
+
const diffSec = Math.round((date.getTime() - now) / 1e3);
|
|
184
|
+
const abs = Math.abs(diffSec);
|
|
185
|
+
if (abs < 60) return rtf.format(diffSec, "second");
|
|
186
|
+
const min = Math.round(diffSec / 60);
|
|
187
|
+
if (Math.abs(min) < 60) return rtf.format(min, "minute");
|
|
188
|
+
const hr = Math.round(min / 60);
|
|
189
|
+
if (Math.abs(hr) < 24) return rtf.format(hr, "hour");
|
|
190
|
+
const day = Math.round(hr / 24);
|
|
191
|
+
if (Math.abs(day) < 7) return rtf.format(day, "day");
|
|
192
|
+
const week = Math.round(day / 7);
|
|
193
|
+
if (Math.abs(week) < 4) return rtf.format(week, "week");
|
|
194
|
+
const month = Math.round(day / 30);
|
|
195
|
+
if (Math.abs(month) < 12) return rtf.format(month, "month");
|
|
196
|
+
const year = Math.round(day / 365);
|
|
197
|
+
return rtf.format(year, "year");
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
return formatters;
|
|
201
|
+
}
|
|
202
|
+
var FormatterContext = react.createContext(null);
|
|
203
|
+
function FormattersProvider({ config, children }) {
|
|
204
|
+
const formatters = react.useMemo(
|
|
205
|
+
() => createFormatters(config),
|
|
206
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
207
|
+
[config.locale, config.currency, config.fallback, JSON.stringify(config.rules)]
|
|
208
|
+
);
|
|
209
|
+
return /* @__PURE__ */ jsxRuntime.jsx(FormatterContext.Provider, { value: formatters, children });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// src/create.ts
|
|
213
|
+
async function resolveLocale(resolver, defaultLocale) {
|
|
214
|
+
const resolved = await resolver?.();
|
|
215
|
+
if (resolved) return resolved;
|
|
216
|
+
if (defaultLocale) return defaultLocale;
|
|
217
|
+
try {
|
|
218
|
+
const { headers } = await import('next/headers');
|
|
219
|
+
const h = await headers();
|
|
220
|
+
const acceptLanguage = h.get("accept-language");
|
|
221
|
+
if (acceptLanguage) {
|
|
222
|
+
const locale = acceptLanguage.split(",")[0]?.trim().split(";")[0]?.trim();
|
|
223
|
+
if (locale) return locale;
|
|
224
|
+
}
|
|
225
|
+
} catch {
|
|
226
|
+
}
|
|
227
|
+
return "en-US";
|
|
228
|
+
}
|
|
229
|
+
async function resolveCurrency(resolver, defaultCurrency) {
|
|
230
|
+
const resolved = await resolver?.();
|
|
231
|
+
if (resolved) return resolved;
|
|
232
|
+
return defaultCurrency ?? "USD";
|
|
233
|
+
}
|
|
9
234
|
async function NextFormatterProvider({ config = {}, children }) {
|
|
10
|
-
const locale = await
|
|
11
|
-
const currency = await
|
|
12
|
-
return /* @__PURE__ */ jsxRuntime.jsx(
|
|
235
|
+
const locale = await resolveLocale(config.getLocale, config.locale);
|
|
236
|
+
const currency = await resolveCurrency(config.getCurrency, config.currency);
|
|
237
|
+
return /* @__PURE__ */ jsxRuntime.jsx(FormattersProvider, { config: { locale, currency, fallback: config.fallback, rules: config.rules }, children });
|
|
13
238
|
}
|
|
14
239
|
async function getFormatter(config = {}) {
|
|
15
|
-
const locale = await
|
|
16
|
-
const currency = await
|
|
17
|
-
return
|
|
240
|
+
const locale = await resolveLocale(config.getLocale, config.locale);
|
|
241
|
+
const currency = await resolveCurrency(config.getCurrency, config.currency);
|
|
242
|
+
return createFormatters({
|
|
18
243
|
locale,
|
|
19
244
|
currency,
|
|
20
245
|
fallback: config.fallback,
|
package/dist/esm/client.mjs
CHANGED
|
@@ -1,8 +1,201 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { createContext, useMemo, useContext } from 'react';
|
|
3
|
-
import { createFormatters } from './core';
|
|
4
3
|
import { jsx } from 'react/jsx-runtime';
|
|
5
4
|
|
|
5
|
+
// src/core.ts
|
|
6
|
+
var DEFAULT_RULES = {
|
|
7
|
+
compactThreshold: 1e4,
|
|
8
|
+
minimumFractionDigits: 0,
|
|
9
|
+
maximumFractionDigits: 2,
|
|
10
|
+
currencyDisplay: "narrowSymbol",
|
|
11
|
+
numberFormat: {},
|
|
12
|
+
dateFormat: { year: "numeric", month: "short", day: "2-digit" },
|
|
13
|
+
dateTimeFormat: {
|
|
14
|
+
year: "numeric",
|
|
15
|
+
month: "short",
|
|
16
|
+
day: "2-digit",
|
|
17
|
+
hour: "2-digit",
|
|
18
|
+
minute: "2-digit",
|
|
19
|
+
hour12: true
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
function createFormatters(config = {}) {
|
|
23
|
+
const locale = config.locale ?? "en-US";
|
|
24
|
+
const defaultCurrency = config.currency ?? "USD";
|
|
25
|
+
const fallback = config.fallback ?? "\u2014";
|
|
26
|
+
const rules = {
|
|
27
|
+
...DEFAULT_RULES,
|
|
28
|
+
...config.rules
|
|
29
|
+
};
|
|
30
|
+
function toNumber(value) {
|
|
31
|
+
if (value == null || value === "") return null;
|
|
32
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
33
|
+
return Number.isNaN(n) ? null : n;
|
|
34
|
+
}
|
|
35
|
+
function toValidDate(value) {
|
|
36
|
+
if (value == null || value === "") return null;
|
|
37
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
38
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
39
|
+
}
|
|
40
|
+
function isLarge(n) {
|
|
41
|
+
return Math.abs(n) >= rules.compactThreshold;
|
|
42
|
+
}
|
|
43
|
+
function normalizeICU(str) {
|
|
44
|
+
return str.replace(/\u202f/g, " ").replace(/\u00a0/g, " ").trim();
|
|
45
|
+
}
|
|
46
|
+
function alignFractionDigits(opts) {
|
|
47
|
+
const { minimumFractionDigits, maximumFractionDigits, maximumSignificantDigits, minimumSignificantDigits } = opts;
|
|
48
|
+
if (minimumFractionDigits !== void 0 && maximumFractionDigits !== void 0 && minimumFractionDigits !== maximumFractionDigits && maximumSignificantDigits === void 0 && minimumSignificantDigits === void 0) {
|
|
49
|
+
return { ...opts, minimumFractionDigits: maximumFractionDigits };
|
|
50
|
+
}
|
|
51
|
+
return opts;
|
|
52
|
+
}
|
|
53
|
+
const numberCache = /* @__PURE__ */ new Map();
|
|
54
|
+
function getNumberFormatter(options) {
|
|
55
|
+
const key = JSON.stringify({ locale, ...options });
|
|
56
|
+
if (!numberCache.has(key)) {
|
|
57
|
+
numberCache.set(key, new Intl.NumberFormat(locale, options));
|
|
58
|
+
}
|
|
59
|
+
return numberCache.get(key);
|
|
60
|
+
}
|
|
61
|
+
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto", style: "long" });
|
|
62
|
+
const formatters = {
|
|
63
|
+
/**
|
|
64
|
+
* Format a plain number.
|
|
65
|
+
* @example
|
|
66
|
+
* fmt.number(1234567) // "1.2M"
|
|
67
|
+
* fmt.number(1234, { notation: "standard" }) // "1,234"
|
|
68
|
+
* fmt.number(1.5678, { maximumFractionDigits: 1 }) // "1.6"
|
|
69
|
+
*/
|
|
70
|
+
number(value, options = {}) {
|
|
71
|
+
const n = toNumber(value);
|
|
72
|
+
if (n == null) return fallback;
|
|
73
|
+
return normalizeICU(
|
|
74
|
+
getNumberFormatter(
|
|
75
|
+
alignFractionDigits({
|
|
76
|
+
style: "decimal",
|
|
77
|
+
notation: isLarge(n) ? "compact" : "standard",
|
|
78
|
+
minimumFractionDigits: rules.minimumFractionDigits,
|
|
79
|
+
maximumFractionDigits: rules.maximumFractionDigits,
|
|
80
|
+
...rules.numberFormat,
|
|
81
|
+
...options
|
|
82
|
+
})
|
|
83
|
+
).format(n)
|
|
84
|
+
);
|
|
85
|
+
},
|
|
86
|
+
/**
|
|
87
|
+
* Format a currency value.
|
|
88
|
+
* @example
|
|
89
|
+
* fmt.currency(49900) // "$49.9K"
|
|
90
|
+
* fmt.currency(1234, { currency: "EUR" }) // "€1,234"
|
|
91
|
+
* fmt.currency(1234, { currencyDisplay: "code" }) // "USD 1,234"
|
|
92
|
+
* fmt.currency(1234, { minimumFractionDigits: 2 }) // "$1,234.00"
|
|
93
|
+
*/
|
|
94
|
+
currency(value, options = {}) {
|
|
95
|
+
const n = toNumber(value);
|
|
96
|
+
if (n == null) return fallback;
|
|
97
|
+
const { currency: overrideCurrency, currencyDisplay, ...rest } = options;
|
|
98
|
+
return normalizeICU(
|
|
99
|
+
getNumberFormatter(
|
|
100
|
+
alignFractionDigits({
|
|
101
|
+
style: "currency",
|
|
102
|
+
currency: overrideCurrency ?? defaultCurrency,
|
|
103
|
+
notation: isLarge(n) ? "compact" : "standard",
|
|
104
|
+
minimumFractionDigits: rules.minimumFractionDigits,
|
|
105
|
+
maximumFractionDigits: rules.maximumFractionDigits,
|
|
106
|
+
currencyDisplay: currencyDisplay ?? rules.currencyDisplay,
|
|
107
|
+
...rules.numberFormat,
|
|
108
|
+
...rest
|
|
109
|
+
})
|
|
110
|
+
).format(n)
|
|
111
|
+
);
|
|
112
|
+
},
|
|
113
|
+
/**
|
|
114
|
+
* Format a percentage. Input is treated as a raw percentage (50 → "50%").
|
|
115
|
+
* Uses minimumFractionDigits and maximumFractionDigits from rules.
|
|
116
|
+
* For values that would round to zero, maximumSignificantDigits is set to
|
|
117
|
+
* max(maximumFractionDigits, 4) to preserve meaningful precision.
|
|
118
|
+
* @example
|
|
119
|
+
* fmt.percentage(12.5) // "12.50%" (with default rules min=0, max=2 → aligned to 2)
|
|
120
|
+
* fmt.percentage(0.001) // "0.001%" (wouldBeZero → uses significantDigits)
|
|
121
|
+
* fmt.percentage(12.5, { maximumFractionDigits: 0 }) // "13%"
|
|
122
|
+
*/
|
|
123
|
+
percentage(value, options = {}) {
|
|
124
|
+
const n = toNumber(value);
|
|
125
|
+
if (n == null) return fallback;
|
|
126
|
+
const normalized = n / 100;
|
|
127
|
+
const wouldBeZero = parseFloat(normalized.toFixed(3)) === 0;
|
|
128
|
+
const sigDigits = Math.max(rules.maximumFractionDigits, 4);
|
|
129
|
+
return getNumberFormatter({
|
|
130
|
+
style: "percent",
|
|
131
|
+
...wouldBeZero ? { maximumSignificantDigits: sigDigits } : alignFractionDigits({
|
|
132
|
+
minimumFractionDigits: rules.minimumFractionDigits,
|
|
133
|
+
maximumFractionDigits: rules.maximumFractionDigits
|
|
134
|
+
}),
|
|
135
|
+
...options
|
|
136
|
+
}).format(normalized);
|
|
137
|
+
},
|
|
138
|
+
/**
|
|
139
|
+
* Format a duration in seconds. Pure math — no Intl, no ICU risk.
|
|
140
|
+
* @example
|
|
141
|
+
* fmt.duration(150) // "2m 30s"
|
|
142
|
+
* fmt.duration(45) // "45s"
|
|
143
|
+
*/
|
|
144
|
+
duration(value) {
|
|
145
|
+
const n = toNumber(value);
|
|
146
|
+
if (n == null) return fallback;
|
|
147
|
+
const mins = Math.floor(n / 60);
|
|
148
|
+
const secs = n % 60;
|
|
149
|
+
return mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
|
|
150
|
+
},
|
|
151
|
+
/**
|
|
152
|
+
* Format a date.
|
|
153
|
+
* @example
|
|
154
|
+
* fmt.date("2024-01-15") // "Jan 15, 2024"
|
|
155
|
+
* fmt.date("2024-01-15", { dateStyle: "full" }) // "Monday, January 15, 2024"
|
|
156
|
+
*/
|
|
157
|
+
date(value, options = {}) {
|
|
158
|
+
const date = toValidDate(value);
|
|
159
|
+
if (!date) return fallback;
|
|
160
|
+
const resolvedOptions = options.dateStyle ? options : { ...rules.dateFormat, ...options };
|
|
161
|
+
return normalizeICU(new Intl.DateTimeFormat(locale, resolvedOptions).format(date));
|
|
162
|
+
},
|
|
163
|
+
dateTime(value, options = {}) {
|
|
164
|
+
const date = toValidDate(value);
|
|
165
|
+
if (!date) return fallback;
|
|
166
|
+
const resolvedOptions = options.dateStyle || options.timeStyle ? options : { ...rules.dateTimeFormat, ...options };
|
|
167
|
+
return normalizeICU(new Intl.DateTimeFormat(locale, resolvedOptions).format(date));
|
|
168
|
+
},
|
|
169
|
+
/**
|
|
170
|
+
* Format a date as relative time.
|
|
171
|
+
* Always pass an explicit `now` timestamp — this ensures consistent
|
|
172
|
+
* output whether called from a server or client component.
|
|
173
|
+
* @example
|
|
174
|
+
* const now = Date.now();
|
|
175
|
+
* fmt.relativeTime("2024-01-15", now) // "3 months ago"
|
|
176
|
+
*/
|
|
177
|
+
relativeTime(value, now = Date.now()) {
|
|
178
|
+
const date = toValidDate(value);
|
|
179
|
+
if (!date) return fallback;
|
|
180
|
+
const diffSec = Math.round((date.getTime() - now) / 1e3);
|
|
181
|
+
const abs = Math.abs(diffSec);
|
|
182
|
+
if (abs < 60) return rtf.format(diffSec, "second");
|
|
183
|
+
const min = Math.round(diffSec / 60);
|
|
184
|
+
if (Math.abs(min) < 60) return rtf.format(min, "minute");
|
|
185
|
+
const hr = Math.round(min / 60);
|
|
186
|
+
if (Math.abs(hr) < 24) return rtf.format(hr, "hour");
|
|
187
|
+
const day = Math.round(hr / 24);
|
|
188
|
+
if (Math.abs(day) < 7) return rtf.format(day, "day");
|
|
189
|
+
const week = Math.round(day / 7);
|
|
190
|
+
if (Math.abs(week) < 4) return rtf.format(week, "week");
|
|
191
|
+
const month = Math.round(day / 30);
|
|
192
|
+
if (Math.abs(month) < 12) return rtf.format(month, "month");
|
|
193
|
+
const year = Math.round(day / 365);
|
|
194
|
+
return rtf.format(year, "year");
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
return formatters;
|
|
198
|
+
}
|
|
6
199
|
var FormatterContext = createContext(null);
|
|
7
200
|
function FormattersProvider({ config, children }) {
|
|
8
201
|
const formatters = useMemo(
|