next-formatter 2.0.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/README.md +91 -0
- package/dist/cjs/client.js +28 -0
- package/dist/cjs/core.js +198 -0
- package/dist/cjs/create.js +27 -0
- package/dist/cjs/index.js +14 -0
- package/dist/cjs/server.js +18 -0
- package/dist/esm/client.mjs +25 -0
- package/dist/esm/core.mjs +196 -0
- package/dist/esm/create.mjs +24 -0
- package/dist/esm/index.mjs +12 -0
- package/dist/esm/server.mjs +16 -0
- package/dist/types/client.d.ts +50 -0
- package/dist/types/client.d.ts.map +1 -0
- package/dist/types/core.d.ts +99 -0
- package/dist/types/core.d.ts.map +1 -0
- package/dist/types/create.d.ts +46 -0
- package/dist/types/create.d.ts.map +1 -0
- package/dist/types/index.d.ts +60 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/server.d.ts +22 -0
- package/dist/types/server.d.ts.map +1 -0
- package/package.json +106 -0
package/README.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
### next-formatter
|
|
2
|
+
|
|
3
|
+
Universal number, currency, date, and relative time formatter for Next.js App Router — works in both **Server Components** and **Client Components** with zero hydration mismatches.
|
|
4
|
+
|
|
5
|
+
[→ View Full Documentation (Interactive)](https://gauravgorade.github.io/next-formatter/)
|
|
6
|
+
|
|
7
|
+
### Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install next-formatter
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
### Recommended Setup
|
|
14
|
+
|
|
15
|
+
Define your configuration once in a shared library file to ensure consistency across the server and client.
|
|
16
|
+
|
|
17
|
+
**lib/formatter.ts**
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
import { getFormatter as _getFormatter } from "next-formatter/server";
|
|
21
|
+
import { useFormatter } from "next-formatter/client";
|
|
22
|
+
import type { NextFormatterConfig } from "next-formatter";
|
|
23
|
+
|
|
24
|
+
const config: NextFormatterConfig = {
|
|
25
|
+
locale: "en-US",
|
|
26
|
+
currency: "USD",
|
|
27
|
+
rules: {
|
|
28
|
+
compactThreshold: 10000,
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const formatterConfig = config;
|
|
33
|
+
export const getFormatter = () => _getFormatter(config);
|
|
34
|
+
export { useFormatter };
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### 1. Initialize Provider
|
|
38
|
+
|
|
39
|
+
Add the `NextFormatterProvider` to your root layout.
|
|
40
|
+
|
|
41
|
+
**app/layout.tsx**
|
|
42
|
+
|
|
43
|
+
```tsx
|
|
44
|
+
import { NextFormatterProvider } from "next-formatter";
|
|
45
|
+
import { formatterConfig } from "@/lib/formatter";
|
|
46
|
+
|
|
47
|
+
export default async function RootLayout({ children }) {
|
|
48
|
+
return (
|
|
49
|
+
<html lang="en">
|
|
50
|
+
<body>
|
|
51
|
+
<NextFormatterProvider config={formatterConfig}>{children}</NextFormatterProvider>
|
|
52
|
+
</body>
|
|
53
|
+
</html>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### 2. Usage in Server Components
|
|
59
|
+
|
|
60
|
+
```tsx
|
|
61
|
+
import { getFormatter } from "@/lib/formatter";
|
|
62
|
+
|
|
63
|
+
export default async function Page() {
|
|
64
|
+
const fmt = await getFormatter();
|
|
65
|
+
return <h1>{fmt.currency(49.99)}</h1>;
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### 3. Usage in Client Components
|
|
70
|
+
|
|
71
|
+
```tsx
|
|
72
|
+
"use client";
|
|
73
|
+
import { useFormatter } from "@/lib/formatter";
|
|
74
|
+
|
|
75
|
+
export function Price({ value }) {
|
|
76
|
+
const { currency } = useFormatter();
|
|
77
|
+
return <span>{currency(value)}</span>;
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Why next-formatter?
|
|
82
|
+
|
|
83
|
+
- **Safe ICU Hydration**: Zero spacing or fraction-digit mismatches between server and client.
|
|
84
|
+
- **Dynamic Resolvers**: Detect locale from cookies, headers, or your database.
|
|
85
|
+
- **Micro-Bundle**: Optimized for the App Router with negligible client-side impact.
|
|
86
|
+
|
|
87
|
+
[→ Full Documentation](https://gauravgorade.github.io/next-formatter/)
|
|
88
|
+
|
|
89
|
+
### License
|
|
90
|
+
|
|
91
|
+
MIT © [gauravgorade](https://github.com/gauravgorade)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
var react = require('react');
|
|
5
|
+
var core = require('./core');
|
|
6
|
+
var jsxRuntime = require('react/jsx-runtime');
|
|
7
|
+
|
|
8
|
+
var FormatterContext = react.createContext(null);
|
|
9
|
+
function FormattersProvider({ config, children }) {
|
|
10
|
+
const formatters = react.useMemo(
|
|
11
|
+
() => core.createFormatters(config),
|
|
12
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
13
|
+
[config.locale, config.currency, config.fallback, JSON.stringify(config.rules)]
|
|
14
|
+
);
|
|
15
|
+
return /* @__PURE__ */ jsxRuntime.jsx(FormatterContext.Provider, { value: formatters, children });
|
|
16
|
+
}
|
|
17
|
+
function useFormatter() {
|
|
18
|
+
const ctx = react.useContext(FormatterContext);
|
|
19
|
+
if (!ctx) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
"[next-formatter] useFormatter() must be used inside <NextFormatterProvider />.\nMake sure you have <NextFormatterProvider> in your root layout."
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
return ctx;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
exports.FormattersProvider = FormattersProvider;
|
|
28
|
+
exports.useFormatter = useFormatter;
|
package/dist/cjs/core.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/core.ts
|
|
4
|
+
var DEFAULT_RULES = {
|
|
5
|
+
compactThreshold: 1e4,
|
|
6
|
+
minimumFractionDigits: 0,
|
|
7
|
+
maximumFractionDigits: 2,
|
|
8
|
+
currencyDisplay: "narrowSymbol",
|
|
9
|
+
numberFormat: {},
|
|
10
|
+
dateFormat: { year: "numeric", month: "short", day: "2-digit" },
|
|
11
|
+
dateTimeFormat: {
|
|
12
|
+
year: "numeric",
|
|
13
|
+
month: "short",
|
|
14
|
+
day: "2-digit",
|
|
15
|
+
hour: "2-digit",
|
|
16
|
+
minute: "2-digit",
|
|
17
|
+
hour12: true
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
function createFormatters(config = {}) {
|
|
21
|
+
const locale = config.locale ?? "en-US";
|
|
22
|
+
const defaultCurrency = config.currency ?? "USD";
|
|
23
|
+
const fallback = config.fallback ?? "\u2014";
|
|
24
|
+
const rules = {
|
|
25
|
+
...DEFAULT_RULES,
|
|
26
|
+
...config.rules
|
|
27
|
+
};
|
|
28
|
+
function toNumber(value) {
|
|
29
|
+
if (value == null || value === "") return null;
|
|
30
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
31
|
+
return Number.isNaN(n) ? null : n;
|
|
32
|
+
}
|
|
33
|
+
function toValidDate(value) {
|
|
34
|
+
if (value == null || value === "") return null;
|
|
35
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
36
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
37
|
+
}
|
|
38
|
+
function isLarge(n) {
|
|
39
|
+
return Math.abs(n) >= rules.compactThreshold;
|
|
40
|
+
}
|
|
41
|
+
function normalizeICU(str) {
|
|
42
|
+
return str.replace(/\u202f/g, " ").replace(/\u00a0/g, " ").trim();
|
|
43
|
+
}
|
|
44
|
+
function alignFractionDigits(opts) {
|
|
45
|
+
const { minimumFractionDigits, maximumFractionDigits, maximumSignificantDigits, minimumSignificantDigits } = opts;
|
|
46
|
+
if (minimumFractionDigits !== void 0 && maximumFractionDigits !== void 0 && minimumFractionDigits !== maximumFractionDigits && maximumSignificantDigits === void 0 && minimumSignificantDigits === void 0) {
|
|
47
|
+
return { ...opts, minimumFractionDigits: maximumFractionDigits };
|
|
48
|
+
}
|
|
49
|
+
return opts;
|
|
50
|
+
}
|
|
51
|
+
const numberCache = /* @__PURE__ */ new Map();
|
|
52
|
+
function getNumberFormatter(options) {
|
|
53
|
+
const key = JSON.stringify({ locale, ...options });
|
|
54
|
+
if (!numberCache.has(key)) {
|
|
55
|
+
numberCache.set(key, new Intl.NumberFormat(locale, options));
|
|
56
|
+
}
|
|
57
|
+
return numberCache.get(key);
|
|
58
|
+
}
|
|
59
|
+
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto", style: "long" });
|
|
60
|
+
const formatters = {
|
|
61
|
+
/**
|
|
62
|
+
* Format a plain number.
|
|
63
|
+
* @example
|
|
64
|
+
* fmt.number(1234567) // "1.2M"
|
|
65
|
+
* fmt.number(1234, { notation: "standard" }) // "1,234"
|
|
66
|
+
* fmt.number(1.5678, { maximumFractionDigits: 1 }) // "1.6"
|
|
67
|
+
*/
|
|
68
|
+
number(value, options = {}) {
|
|
69
|
+
const n = toNumber(value);
|
|
70
|
+
if (n == null) return fallback;
|
|
71
|
+
return normalizeICU(
|
|
72
|
+
getNumberFormatter(
|
|
73
|
+
alignFractionDigits({
|
|
74
|
+
style: "decimal",
|
|
75
|
+
notation: isLarge(n) ? "compact" : "standard",
|
|
76
|
+
minimumFractionDigits: rules.minimumFractionDigits,
|
|
77
|
+
maximumFractionDigits: rules.maximumFractionDigits,
|
|
78
|
+
...rules.numberFormat,
|
|
79
|
+
...options
|
|
80
|
+
})
|
|
81
|
+
).format(n)
|
|
82
|
+
);
|
|
83
|
+
},
|
|
84
|
+
/**
|
|
85
|
+
* Format a currency value.
|
|
86
|
+
* @example
|
|
87
|
+
* fmt.currency(49900) // "$49.9K"
|
|
88
|
+
* fmt.currency(1234, { currency: "EUR" }) // "€1,234"
|
|
89
|
+
* fmt.currency(1234, { currencyDisplay: "code" }) // "USD 1,234"
|
|
90
|
+
* fmt.currency(1234, { minimumFractionDigits: 2 }) // "$1,234.00"
|
|
91
|
+
*/
|
|
92
|
+
currency(value, options = {}) {
|
|
93
|
+
const n = toNumber(value);
|
|
94
|
+
if (n == null) return fallback;
|
|
95
|
+
const { currency: overrideCurrency, currencyDisplay, ...rest } = options;
|
|
96
|
+
return normalizeICU(
|
|
97
|
+
getNumberFormatter(
|
|
98
|
+
alignFractionDigits({
|
|
99
|
+
style: "currency",
|
|
100
|
+
currency: overrideCurrency ?? defaultCurrency,
|
|
101
|
+
notation: isLarge(n) ? "compact" : "standard",
|
|
102
|
+
minimumFractionDigits: rules.minimumFractionDigits,
|
|
103
|
+
maximumFractionDigits: rules.maximumFractionDigits,
|
|
104
|
+
currencyDisplay: currencyDisplay ?? rules.currencyDisplay,
|
|
105
|
+
...rules.numberFormat,
|
|
106
|
+
...rest
|
|
107
|
+
})
|
|
108
|
+
).format(n)
|
|
109
|
+
);
|
|
110
|
+
},
|
|
111
|
+
/**
|
|
112
|
+
* Format a percentage. Input is treated as a raw percentage (50 → "50%").
|
|
113
|
+
* Uses minimumFractionDigits and maximumFractionDigits from rules.
|
|
114
|
+
* For values that would round to zero, maximumSignificantDigits is set to
|
|
115
|
+
* max(maximumFractionDigits, 4) to preserve meaningful precision.
|
|
116
|
+
* @example
|
|
117
|
+
* fmt.percentage(12.5) // "12.50%" (with default rules min=0, max=2 → aligned to 2)
|
|
118
|
+
* fmt.percentage(0.001) // "0.001%" (wouldBeZero → uses significantDigits)
|
|
119
|
+
* fmt.percentage(12.5, { maximumFractionDigits: 0 }) // "13%"
|
|
120
|
+
*/
|
|
121
|
+
percentage(value, options = {}) {
|
|
122
|
+
const n = toNumber(value);
|
|
123
|
+
if (n == null) return fallback;
|
|
124
|
+
const normalized = n / 100;
|
|
125
|
+
const wouldBeZero = parseFloat(normalized.toFixed(3)) === 0;
|
|
126
|
+
const sigDigits = Math.max(rules.maximumFractionDigits, 4);
|
|
127
|
+
return getNumberFormatter({
|
|
128
|
+
style: "percent",
|
|
129
|
+
...wouldBeZero ? { maximumSignificantDigits: sigDigits } : alignFractionDigits({
|
|
130
|
+
minimumFractionDigits: rules.minimumFractionDigits,
|
|
131
|
+
maximumFractionDigits: rules.maximumFractionDigits
|
|
132
|
+
}),
|
|
133
|
+
...options
|
|
134
|
+
}).format(normalized);
|
|
135
|
+
},
|
|
136
|
+
/**
|
|
137
|
+
* Format a duration in seconds. Pure math — no Intl, no ICU risk.
|
|
138
|
+
* @example
|
|
139
|
+
* fmt.duration(150) // "2m 30s"
|
|
140
|
+
* fmt.duration(45) // "45s"
|
|
141
|
+
*/
|
|
142
|
+
duration(value) {
|
|
143
|
+
const n = toNumber(value);
|
|
144
|
+
if (n == null) return fallback;
|
|
145
|
+
const mins = Math.floor(n / 60);
|
|
146
|
+
const secs = n % 60;
|
|
147
|
+
return mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
|
|
148
|
+
},
|
|
149
|
+
/**
|
|
150
|
+
* Format a date.
|
|
151
|
+
* @example
|
|
152
|
+
* fmt.date("2024-01-15") // "Jan 15, 2024"
|
|
153
|
+
* fmt.date("2024-01-15", { dateStyle: "full" }) // "Monday, January 15, 2024"
|
|
154
|
+
*/
|
|
155
|
+
date(value, options = {}) {
|
|
156
|
+
const date = toValidDate(value);
|
|
157
|
+
if (!date) return fallback;
|
|
158
|
+
const resolvedOptions = options.dateStyle ? options : { ...rules.dateFormat, ...options };
|
|
159
|
+
return normalizeICU(new Intl.DateTimeFormat(locale, resolvedOptions).format(date));
|
|
160
|
+
},
|
|
161
|
+
dateTime(value, options = {}) {
|
|
162
|
+
const date = toValidDate(value);
|
|
163
|
+
if (!date) return fallback;
|
|
164
|
+
const resolvedOptions = options.dateStyle || options.timeStyle ? options : { ...rules.dateTimeFormat, ...options };
|
|
165
|
+
return normalizeICU(new Intl.DateTimeFormat(locale, resolvedOptions).format(date));
|
|
166
|
+
},
|
|
167
|
+
/**
|
|
168
|
+
* Format a date as relative time.
|
|
169
|
+
* Always pass an explicit `now` timestamp — this ensures consistent
|
|
170
|
+
* output whether called from a server or client component.
|
|
171
|
+
* @example
|
|
172
|
+
* const now = Date.now();
|
|
173
|
+
* fmt.relativeTime("2024-01-15", now) // "3 months ago"
|
|
174
|
+
*/
|
|
175
|
+
relativeTime(value, now = Date.now()) {
|
|
176
|
+
const date = toValidDate(value);
|
|
177
|
+
if (!date) return fallback;
|
|
178
|
+
const diffSec = Math.round((date.getTime() - now) / 1e3);
|
|
179
|
+
const abs = Math.abs(diffSec);
|
|
180
|
+
if (abs < 60) return rtf.format(diffSec, "second");
|
|
181
|
+
const min = Math.round(diffSec / 60);
|
|
182
|
+
if (Math.abs(min) < 60) return rtf.format(min, "minute");
|
|
183
|
+
const hr = Math.round(min / 60);
|
|
184
|
+
if (Math.abs(hr) < 24) return rtf.format(hr, "hour");
|
|
185
|
+
const day = Math.round(hr / 24);
|
|
186
|
+
if (Math.abs(day) < 7) return rtf.format(day, "day");
|
|
187
|
+
const week = Math.round(day / 7);
|
|
188
|
+
if (Math.abs(week) < 4) return rtf.format(week, "week");
|
|
189
|
+
const month = Math.round(day / 30);
|
|
190
|
+
if (Math.abs(month) < 12) return rtf.format(month, "month");
|
|
191
|
+
const year = Math.round(day / 365);
|
|
192
|
+
return rtf.format(year, "year");
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
return formatters;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
exports.createFormatters = createFormatters;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/create.ts
|
|
4
|
+
async function resolveLocale(resolver, defaultLocale) {
|
|
5
|
+
const resolved = await resolver?.();
|
|
6
|
+
if (resolved) return resolved;
|
|
7
|
+
if (defaultLocale) return defaultLocale;
|
|
8
|
+
try {
|
|
9
|
+
const { headers } = await import('next/headers');
|
|
10
|
+
const h = await headers();
|
|
11
|
+
const acceptLanguage = h.get("accept-language");
|
|
12
|
+
if (acceptLanguage) {
|
|
13
|
+
const locale = acceptLanguage.split(",")[0]?.trim().split(";")[0]?.trim();
|
|
14
|
+
if (locale) return locale;
|
|
15
|
+
}
|
|
16
|
+
} catch {
|
|
17
|
+
}
|
|
18
|
+
return "en-US";
|
|
19
|
+
}
|
|
20
|
+
async function resolveCurrency(resolver, defaultCurrency) {
|
|
21
|
+
const resolved = await resolver?.();
|
|
22
|
+
if (resolved) return resolved;
|
|
23
|
+
return defaultCurrency ?? "USD";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
exports.resolveCurrency = resolveCurrency;
|
|
27
|
+
exports.resolveLocale = resolveLocale;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var client = require('./client');
|
|
4
|
+
var create = require('./create');
|
|
5
|
+
var jsxRuntime = require('react/jsx-runtime');
|
|
6
|
+
|
|
7
|
+
// src/index.tsx
|
|
8
|
+
async function NextFormatterProvider({ config = {}, children }) {
|
|
9
|
+
const locale = await create.resolveLocale(config.getLocale, config.locale);
|
|
10
|
+
const currency = await create.resolveCurrency(config.getCurrency, config.currency);
|
|
11
|
+
return /* @__PURE__ */ jsxRuntime.jsx(client.FormattersProvider, { config: { locale, currency, fallback: config.fallback, rules: config.rules }, children });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
exports.NextFormatterProvider = NextFormatterProvider;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var core = require('./core');
|
|
4
|
+
var create = require('./create');
|
|
5
|
+
|
|
6
|
+
// src/server.ts
|
|
7
|
+
async function getFormatter(config = {}) {
|
|
8
|
+
const locale = await create.resolveLocale(config.getLocale, config.locale);
|
|
9
|
+
const currency = await create.resolveCurrency(config.getCurrency, config.currency);
|
|
10
|
+
return core.createFormatters({
|
|
11
|
+
locale,
|
|
12
|
+
currency,
|
|
13
|
+
fallback: config.fallback,
|
|
14
|
+
rules: config.rules
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
exports.getFormatter = getFormatter;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { createContext, useMemo, useContext } from 'react';
|
|
3
|
+
import { createFormatters } from './core';
|
|
4
|
+
import { jsx } from 'react/jsx-runtime';
|
|
5
|
+
|
|
6
|
+
var FormatterContext = createContext(null);
|
|
7
|
+
function FormattersProvider({ config, children }) {
|
|
8
|
+
const formatters = useMemo(
|
|
9
|
+
() => createFormatters(config),
|
|
10
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
11
|
+
[config.locale, config.currency, config.fallback, JSON.stringify(config.rules)]
|
|
12
|
+
);
|
|
13
|
+
return /* @__PURE__ */ jsx(FormatterContext.Provider, { value: formatters, children });
|
|
14
|
+
}
|
|
15
|
+
function useFormatter() {
|
|
16
|
+
const ctx = useContext(FormatterContext);
|
|
17
|
+
if (!ctx) {
|
|
18
|
+
throw new Error(
|
|
19
|
+
"[next-formatter] useFormatter() must be used inside <NextFormatterProvider />.\nMake sure you have <NextFormatterProvider> in your root layout."
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
return ctx;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export { FormattersProvider, useFormatter };
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// src/core.ts
|
|
2
|
+
var DEFAULT_RULES = {
|
|
3
|
+
compactThreshold: 1e4,
|
|
4
|
+
minimumFractionDigits: 0,
|
|
5
|
+
maximumFractionDigits: 2,
|
|
6
|
+
currencyDisplay: "narrowSymbol",
|
|
7
|
+
numberFormat: {},
|
|
8
|
+
dateFormat: { year: "numeric", month: "short", day: "2-digit" },
|
|
9
|
+
dateTimeFormat: {
|
|
10
|
+
year: "numeric",
|
|
11
|
+
month: "short",
|
|
12
|
+
day: "2-digit",
|
|
13
|
+
hour: "2-digit",
|
|
14
|
+
minute: "2-digit",
|
|
15
|
+
hour12: true
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
function createFormatters(config = {}) {
|
|
19
|
+
const locale = config.locale ?? "en-US";
|
|
20
|
+
const defaultCurrency = config.currency ?? "USD";
|
|
21
|
+
const fallback = config.fallback ?? "\u2014";
|
|
22
|
+
const rules = {
|
|
23
|
+
...DEFAULT_RULES,
|
|
24
|
+
...config.rules
|
|
25
|
+
};
|
|
26
|
+
function toNumber(value) {
|
|
27
|
+
if (value == null || value === "") return null;
|
|
28
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
29
|
+
return Number.isNaN(n) ? null : n;
|
|
30
|
+
}
|
|
31
|
+
function toValidDate(value) {
|
|
32
|
+
if (value == null || value === "") return null;
|
|
33
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
34
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
35
|
+
}
|
|
36
|
+
function isLarge(n) {
|
|
37
|
+
return Math.abs(n) >= rules.compactThreshold;
|
|
38
|
+
}
|
|
39
|
+
function normalizeICU(str) {
|
|
40
|
+
return str.replace(/\u202f/g, " ").replace(/\u00a0/g, " ").trim();
|
|
41
|
+
}
|
|
42
|
+
function alignFractionDigits(opts) {
|
|
43
|
+
const { minimumFractionDigits, maximumFractionDigits, maximumSignificantDigits, minimumSignificantDigits } = opts;
|
|
44
|
+
if (minimumFractionDigits !== void 0 && maximumFractionDigits !== void 0 && minimumFractionDigits !== maximumFractionDigits && maximumSignificantDigits === void 0 && minimumSignificantDigits === void 0) {
|
|
45
|
+
return { ...opts, minimumFractionDigits: maximumFractionDigits };
|
|
46
|
+
}
|
|
47
|
+
return opts;
|
|
48
|
+
}
|
|
49
|
+
const numberCache = /* @__PURE__ */ new Map();
|
|
50
|
+
function getNumberFormatter(options) {
|
|
51
|
+
const key = JSON.stringify({ locale, ...options });
|
|
52
|
+
if (!numberCache.has(key)) {
|
|
53
|
+
numberCache.set(key, new Intl.NumberFormat(locale, options));
|
|
54
|
+
}
|
|
55
|
+
return numberCache.get(key);
|
|
56
|
+
}
|
|
57
|
+
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto", style: "long" });
|
|
58
|
+
const formatters = {
|
|
59
|
+
/**
|
|
60
|
+
* Format a plain number.
|
|
61
|
+
* @example
|
|
62
|
+
* fmt.number(1234567) // "1.2M"
|
|
63
|
+
* fmt.number(1234, { notation: "standard" }) // "1,234"
|
|
64
|
+
* fmt.number(1.5678, { maximumFractionDigits: 1 }) // "1.6"
|
|
65
|
+
*/
|
|
66
|
+
number(value, options = {}) {
|
|
67
|
+
const n = toNumber(value);
|
|
68
|
+
if (n == null) return fallback;
|
|
69
|
+
return normalizeICU(
|
|
70
|
+
getNumberFormatter(
|
|
71
|
+
alignFractionDigits({
|
|
72
|
+
style: "decimal",
|
|
73
|
+
notation: isLarge(n) ? "compact" : "standard",
|
|
74
|
+
minimumFractionDigits: rules.minimumFractionDigits,
|
|
75
|
+
maximumFractionDigits: rules.maximumFractionDigits,
|
|
76
|
+
...rules.numberFormat,
|
|
77
|
+
...options
|
|
78
|
+
})
|
|
79
|
+
).format(n)
|
|
80
|
+
);
|
|
81
|
+
},
|
|
82
|
+
/**
|
|
83
|
+
* Format a currency value.
|
|
84
|
+
* @example
|
|
85
|
+
* fmt.currency(49900) // "$49.9K"
|
|
86
|
+
* fmt.currency(1234, { currency: "EUR" }) // "€1,234"
|
|
87
|
+
* fmt.currency(1234, { currencyDisplay: "code" }) // "USD 1,234"
|
|
88
|
+
* fmt.currency(1234, { minimumFractionDigits: 2 }) // "$1,234.00"
|
|
89
|
+
*/
|
|
90
|
+
currency(value, options = {}) {
|
|
91
|
+
const n = toNumber(value);
|
|
92
|
+
if (n == null) return fallback;
|
|
93
|
+
const { currency: overrideCurrency, currencyDisplay, ...rest } = options;
|
|
94
|
+
return normalizeICU(
|
|
95
|
+
getNumberFormatter(
|
|
96
|
+
alignFractionDigits({
|
|
97
|
+
style: "currency",
|
|
98
|
+
currency: overrideCurrency ?? defaultCurrency,
|
|
99
|
+
notation: isLarge(n) ? "compact" : "standard",
|
|
100
|
+
minimumFractionDigits: rules.minimumFractionDigits,
|
|
101
|
+
maximumFractionDigits: rules.maximumFractionDigits,
|
|
102
|
+
currencyDisplay: currencyDisplay ?? rules.currencyDisplay,
|
|
103
|
+
...rules.numberFormat,
|
|
104
|
+
...rest
|
|
105
|
+
})
|
|
106
|
+
).format(n)
|
|
107
|
+
);
|
|
108
|
+
},
|
|
109
|
+
/**
|
|
110
|
+
* Format a percentage. Input is treated as a raw percentage (50 → "50%").
|
|
111
|
+
* Uses minimumFractionDigits and maximumFractionDigits from rules.
|
|
112
|
+
* For values that would round to zero, maximumSignificantDigits is set to
|
|
113
|
+
* max(maximumFractionDigits, 4) to preserve meaningful precision.
|
|
114
|
+
* @example
|
|
115
|
+
* fmt.percentage(12.5) // "12.50%" (with default rules min=0, max=2 → aligned to 2)
|
|
116
|
+
* fmt.percentage(0.001) // "0.001%" (wouldBeZero → uses significantDigits)
|
|
117
|
+
* fmt.percentage(12.5, { maximumFractionDigits: 0 }) // "13%"
|
|
118
|
+
*/
|
|
119
|
+
percentage(value, options = {}) {
|
|
120
|
+
const n = toNumber(value);
|
|
121
|
+
if (n == null) return fallback;
|
|
122
|
+
const normalized = n / 100;
|
|
123
|
+
const wouldBeZero = parseFloat(normalized.toFixed(3)) === 0;
|
|
124
|
+
const sigDigits = Math.max(rules.maximumFractionDigits, 4);
|
|
125
|
+
return getNumberFormatter({
|
|
126
|
+
style: "percent",
|
|
127
|
+
...wouldBeZero ? { maximumSignificantDigits: sigDigits } : alignFractionDigits({
|
|
128
|
+
minimumFractionDigits: rules.minimumFractionDigits,
|
|
129
|
+
maximumFractionDigits: rules.maximumFractionDigits
|
|
130
|
+
}),
|
|
131
|
+
...options
|
|
132
|
+
}).format(normalized);
|
|
133
|
+
},
|
|
134
|
+
/**
|
|
135
|
+
* Format a duration in seconds. Pure math — no Intl, no ICU risk.
|
|
136
|
+
* @example
|
|
137
|
+
* fmt.duration(150) // "2m 30s"
|
|
138
|
+
* fmt.duration(45) // "45s"
|
|
139
|
+
*/
|
|
140
|
+
duration(value) {
|
|
141
|
+
const n = toNumber(value);
|
|
142
|
+
if (n == null) return fallback;
|
|
143
|
+
const mins = Math.floor(n / 60);
|
|
144
|
+
const secs = n % 60;
|
|
145
|
+
return mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
|
|
146
|
+
},
|
|
147
|
+
/**
|
|
148
|
+
* Format a date.
|
|
149
|
+
* @example
|
|
150
|
+
* fmt.date("2024-01-15") // "Jan 15, 2024"
|
|
151
|
+
* fmt.date("2024-01-15", { dateStyle: "full" }) // "Monday, January 15, 2024"
|
|
152
|
+
*/
|
|
153
|
+
date(value, options = {}) {
|
|
154
|
+
const date = toValidDate(value);
|
|
155
|
+
if (!date) return fallback;
|
|
156
|
+
const resolvedOptions = options.dateStyle ? options : { ...rules.dateFormat, ...options };
|
|
157
|
+
return normalizeICU(new Intl.DateTimeFormat(locale, resolvedOptions).format(date));
|
|
158
|
+
},
|
|
159
|
+
dateTime(value, options = {}) {
|
|
160
|
+
const date = toValidDate(value);
|
|
161
|
+
if (!date) return fallback;
|
|
162
|
+
const resolvedOptions = options.dateStyle || options.timeStyle ? options : { ...rules.dateTimeFormat, ...options };
|
|
163
|
+
return normalizeICU(new Intl.DateTimeFormat(locale, resolvedOptions).format(date));
|
|
164
|
+
},
|
|
165
|
+
/**
|
|
166
|
+
* Format a date as relative time.
|
|
167
|
+
* Always pass an explicit `now` timestamp — this ensures consistent
|
|
168
|
+
* output whether called from a server or client component.
|
|
169
|
+
* @example
|
|
170
|
+
* const now = Date.now();
|
|
171
|
+
* fmt.relativeTime("2024-01-15", now) // "3 months ago"
|
|
172
|
+
*/
|
|
173
|
+
relativeTime(value, now = Date.now()) {
|
|
174
|
+
const date = toValidDate(value);
|
|
175
|
+
if (!date) return fallback;
|
|
176
|
+
const diffSec = Math.round((date.getTime() - now) / 1e3);
|
|
177
|
+
const abs = Math.abs(diffSec);
|
|
178
|
+
if (abs < 60) return rtf.format(diffSec, "second");
|
|
179
|
+
const min = Math.round(diffSec / 60);
|
|
180
|
+
if (Math.abs(min) < 60) return rtf.format(min, "minute");
|
|
181
|
+
const hr = Math.round(min / 60);
|
|
182
|
+
if (Math.abs(hr) < 24) return rtf.format(hr, "hour");
|
|
183
|
+
const day = Math.round(hr / 24);
|
|
184
|
+
if (Math.abs(day) < 7) return rtf.format(day, "day");
|
|
185
|
+
const week = Math.round(day / 7);
|
|
186
|
+
if (Math.abs(week) < 4) return rtf.format(week, "week");
|
|
187
|
+
const month = Math.round(day / 30);
|
|
188
|
+
if (Math.abs(month) < 12) return rtf.format(month, "month");
|
|
189
|
+
const year = Math.round(day / 365);
|
|
190
|
+
return rtf.format(year, "year");
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
return formatters;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export { createFormatters };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// src/create.ts
|
|
2
|
+
async function resolveLocale(resolver, defaultLocale) {
|
|
3
|
+
const resolved = await resolver?.();
|
|
4
|
+
if (resolved) return resolved;
|
|
5
|
+
if (defaultLocale) return defaultLocale;
|
|
6
|
+
try {
|
|
7
|
+
const { headers } = await import('next/headers');
|
|
8
|
+
const h = await headers();
|
|
9
|
+
const acceptLanguage = h.get("accept-language");
|
|
10
|
+
if (acceptLanguage) {
|
|
11
|
+
const locale = acceptLanguage.split(",")[0]?.trim().split(";")[0]?.trim();
|
|
12
|
+
if (locale) return locale;
|
|
13
|
+
}
|
|
14
|
+
} catch {
|
|
15
|
+
}
|
|
16
|
+
return "en-US";
|
|
17
|
+
}
|
|
18
|
+
async function resolveCurrency(resolver, defaultCurrency) {
|
|
19
|
+
const resolved = await resolver?.();
|
|
20
|
+
if (resolved) return resolved;
|
|
21
|
+
return defaultCurrency ?? "USD";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export { resolveCurrency, resolveLocale };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { FormattersProvider } from './client';
|
|
2
|
+
import { resolveLocale, resolveCurrency } from './create';
|
|
3
|
+
import { jsx } from 'react/jsx-runtime';
|
|
4
|
+
|
|
5
|
+
// src/index.tsx
|
|
6
|
+
async function NextFormatterProvider({ config = {}, children }) {
|
|
7
|
+
const locale = await resolveLocale(config.getLocale, config.locale);
|
|
8
|
+
const currency = await resolveCurrency(config.getCurrency, config.currency);
|
|
9
|
+
return /* @__PURE__ */ jsx(FormattersProvider, { config: { locale, currency, fallback: config.fallback, rules: config.rules }, children });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export { NextFormatterProvider };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createFormatters } from './core';
|
|
2
|
+
import { resolveLocale, resolveCurrency } from './create';
|
|
3
|
+
|
|
4
|
+
// src/server.ts
|
|
5
|
+
async function getFormatter(config = {}) {
|
|
6
|
+
const locale = await resolveLocale(config.getLocale, config.locale);
|
|
7
|
+
const currency = await resolveCurrency(config.getCurrency, config.currency);
|
|
8
|
+
return createFormatters({
|
|
9
|
+
locale,
|
|
10
|
+
currency,
|
|
11
|
+
fallback: config.fallback,
|
|
12
|
+
rules: config.rules
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export { getFormatter };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type PropsWithChildren } from "react";
|
|
2
|
+
import { type Formatter, type FormatterConfig } from "./core";
|
|
3
|
+
interface FormattersProviderProps {
|
|
4
|
+
config: FormatterConfig;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Low-level client provider. Accepts a pre-resolved config object with plain
|
|
8
|
+
* string `locale` and `currency` values — no async resolvers.
|
|
9
|
+
*
|
|
10
|
+
* Prefer `<NextFormatterProvider>` in your root layout when you need dynamic
|
|
11
|
+
* config resolution (e.g. locale/currency from session, cookies, or headers).
|
|
12
|
+
* `NextFormatterProvider` resolves async config server-side and passes the
|
|
13
|
+
* result to this provider internally.
|
|
14
|
+
*
|
|
15
|
+
* Use `<FormattersProvider>` directly only when you already have resolved
|
|
16
|
+
* values, e.g. in Storybook, tests, or non-Next.js React apps.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* // layout.tsx
|
|
20
|
+
* import { FormattersProvider } from "next-formatter/client";
|
|
21
|
+
*
|
|
22
|
+
* <FormattersProvider config={{ locale: "en-US", currency: "USD" }}>
|
|
23
|
+
* {children}
|
|
24
|
+
* </FormattersProvider>
|
|
25
|
+
*/
|
|
26
|
+
export declare function FormattersProvider({ config, children }: PropsWithChildren<FormattersProviderProps>): import("react/jsx-runtime").JSX.Element;
|
|
27
|
+
/**
|
|
28
|
+
* Returns all formatters inside any Client Component.
|
|
29
|
+
* Must be used inside `<NextFormatterProvider>` or `<FormattersProvider>`.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* "use client";
|
|
33
|
+
* import { useFormatter } from "next-formatter/client";
|
|
34
|
+
*
|
|
35
|
+
* const fmt = useFormatter();
|
|
36
|
+
*
|
|
37
|
+
* fmt.number(9876543); // "9,876,543"
|
|
38
|
+
* fmt.currency(1234); // "$1,234.00"
|
|
39
|
+
* fmt.percentage(0.1275); // "12.75%"
|
|
40
|
+
* fmt.date(new Date()); // "Jan 1, 2025"
|
|
41
|
+
* fmt.dateTime(new Date()); // "Jan 1, 2025, 12:00 AM"
|
|
42
|
+
* fmt.relativeTime("2024-01-15"); // "2 years ago"
|
|
43
|
+
* fmt.duration(150); // "2m 30s"
|
|
44
|
+
*
|
|
45
|
+
* // or destructure
|
|
46
|
+
* const { currency, date, percentage } = useFormatter();
|
|
47
|
+
*/
|
|
48
|
+
export declare function useFormatter(): Formatter;
|
|
49
|
+
export {};
|
|
50
|
+
//# sourceMappingURL=client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/client.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAsC,KAAK,iBAAiB,EAAE,MAAM,OAAO,CAAC;AACnF,OAAO,EAAoB,KAAK,SAAS,EAAE,KAAK,eAAe,EAAE,MAAM,QAAQ,CAAC;AAIhF,UAAU,uBAAuB;IAC/B,MAAM,EAAE,eAAe,CAAC;CACzB;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,kBAAkB,CAAC,EAAE,MAAM,EAAE,QAAQ,EAAE,EAAE,iBAAiB,CAAC,uBAAuB,CAAC,2CAQlG;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,YAAY,IAAI,SAAS,CAWxC"}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
export type NumericInput = number | string | null | undefined;
|
|
2
|
+
export type DateInput = string | number | Date | null | undefined;
|
|
3
|
+
export type NumberOptions = Pick<Intl.NumberFormatOptions, "notation" | "minimumFractionDigits" | "maximumFractionDigits" | "minimumSignificantDigits" | "maximumSignificantDigits" | "minimumIntegerDigits" | "useGrouping" | "signDisplay">;
|
|
4
|
+
export type CurrencyOptions = NumberOptions & {
|
|
5
|
+
currency?: string;
|
|
6
|
+
currencyDisplay?: "symbol" | "narrowSymbol" | "code" | "name";
|
|
7
|
+
currencySign?: "standard" | "accounting";
|
|
8
|
+
};
|
|
9
|
+
export type PercentageOptions = Pick<Intl.NumberFormatOptions, "minimumFractionDigits" | "maximumFractionDigits" | "minimumSignificantDigits" | "maximumSignificantDigits" | "signDisplay">;
|
|
10
|
+
export type DateOptions = Intl.DateTimeFormatOptions;
|
|
11
|
+
export type DateTimeOptions = Intl.DateTimeFormatOptions;
|
|
12
|
+
export type FormatterRules = {
|
|
13
|
+
/** Numbers >= this use compact notation (1.2K, 3.4M). Default: 10000 */
|
|
14
|
+
compactThreshold?: number;
|
|
15
|
+
/**
|
|
16
|
+
* Minimum fraction digits for number, currency and percentage formatters.
|
|
17
|
+
* Default: 0.
|
|
18
|
+
* alignFractionDigits sets this to maximumFractionDigits automatically
|
|
19
|
+
* to prevent ICU hydration mismatches.
|
|
20
|
+
*/
|
|
21
|
+
minimumFractionDigits?: number;
|
|
22
|
+
/**
|
|
23
|
+
* Maximum fraction digits for number, currency and percentage formatters.
|
|
24
|
+
* Default: 2.
|
|
25
|
+
* For wouldBeZero percentage values, maximumSignificantDigits is set to
|
|
26
|
+
* max(maximumFractionDigits, 4) to preserve meaningful precision.
|
|
27
|
+
*/
|
|
28
|
+
maximumFractionDigits?: number;
|
|
29
|
+
/** Default currency display style. Default: "narrowSymbol" */
|
|
30
|
+
currencyDisplay?: "symbol" | "narrowSymbol" | "code" | "name";
|
|
31
|
+
/** Default date format options */
|
|
32
|
+
dateFormat?: Intl.DateTimeFormatOptions;
|
|
33
|
+
/** Default dateTime format options */
|
|
34
|
+
dateTimeFormat?: Intl.DateTimeFormatOptions;
|
|
35
|
+
/** Default number format options — merged with per-call overrides */
|
|
36
|
+
numberFormat?: NumberOptions;
|
|
37
|
+
};
|
|
38
|
+
export type FormatterConfig = {
|
|
39
|
+
locale?: string;
|
|
40
|
+
currency?: string;
|
|
41
|
+
fallback?: string;
|
|
42
|
+
rules?: FormatterRules;
|
|
43
|
+
};
|
|
44
|
+
export declare function createFormatters(config?: FormatterConfig): {
|
|
45
|
+
/**
|
|
46
|
+
* Format a plain number.
|
|
47
|
+
* @example
|
|
48
|
+
* fmt.number(1234567) // "1.2M"
|
|
49
|
+
* fmt.number(1234, { notation: "standard" }) // "1,234"
|
|
50
|
+
* fmt.number(1.5678, { maximumFractionDigits: 1 }) // "1.6"
|
|
51
|
+
*/
|
|
52
|
+
number(value: NumericInput, options?: NumberOptions): string;
|
|
53
|
+
/**
|
|
54
|
+
* Format a currency value.
|
|
55
|
+
* @example
|
|
56
|
+
* fmt.currency(49900) // "$49.9K"
|
|
57
|
+
* fmt.currency(1234, { currency: "EUR" }) // "€1,234"
|
|
58
|
+
* fmt.currency(1234, { currencyDisplay: "code" }) // "USD 1,234"
|
|
59
|
+
* fmt.currency(1234, { minimumFractionDigits: 2 }) // "$1,234.00"
|
|
60
|
+
*/
|
|
61
|
+
currency(value: NumericInput, options?: CurrencyOptions): string;
|
|
62
|
+
/**
|
|
63
|
+
* Format a percentage. Input is treated as a raw percentage (50 → "50%").
|
|
64
|
+
* Uses minimumFractionDigits and maximumFractionDigits from rules.
|
|
65
|
+
* For values that would round to zero, maximumSignificantDigits is set to
|
|
66
|
+
* max(maximumFractionDigits, 4) to preserve meaningful precision.
|
|
67
|
+
* @example
|
|
68
|
+
* fmt.percentage(12.5) // "12.50%" (with default rules min=0, max=2 → aligned to 2)
|
|
69
|
+
* fmt.percentage(0.001) // "0.001%" (wouldBeZero → uses significantDigits)
|
|
70
|
+
* fmt.percentage(12.5, { maximumFractionDigits: 0 }) // "13%"
|
|
71
|
+
*/
|
|
72
|
+
percentage(value: NumericInput, options?: PercentageOptions): string;
|
|
73
|
+
/**
|
|
74
|
+
* Format a duration in seconds. Pure math — no Intl, no ICU risk.
|
|
75
|
+
* @example
|
|
76
|
+
* fmt.duration(150) // "2m 30s"
|
|
77
|
+
* fmt.duration(45) // "45s"
|
|
78
|
+
*/
|
|
79
|
+
duration(value: NumericInput): string;
|
|
80
|
+
/**
|
|
81
|
+
* Format a date.
|
|
82
|
+
* @example
|
|
83
|
+
* fmt.date("2024-01-15") // "Jan 15, 2024"
|
|
84
|
+
* fmt.date("2024-01-15", { dateStyle: "full" }) // "Monday, January 15, 2024"
|
|
85
|
+
*/
|
|
86
|
+
date(value: DateInput, options?: DateOptions): string;
|
|
87
|
+
dateTime(value: DateInput, options?: DateTimeOptions): string;
|
|
88
|
+
/**
|
|
89
|
+
* Format a date as relative time.
|
|
90
|
+
* Always pass an explicit `now` timestamp — this ensures consistent
|
|
91
|
+
* output whether called from a server or client component.
|
|
92
|
+
* @example
|
|
93
|
+
* const now = Date.now();
|
|
94
|
+
* fmt.relativeTime("2024-01-15", now) // "3 months ago"
|
|
95
|
+
*/
|
|
96
|
+
relativeTime(value: DateInput, now?: number): string;
|
|
97
|
+
};
|
|
98
|
+
export type Formatter = ReturnType<typeof createFormatters>;
|
|
99
|
+
//# sourceMappingURL=core.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"core.d.ts","sourceRoot":"","sources":["../../src/core.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,YAAY,GAAG,MAAM,GAAG,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;AAC9D,MAAM,MAAM,SAAS,GAAG,MAAM,GAAG,MAAM,GAAG,IAAI,GAAG,IAAI,GAAG,SAAS,CAAC;AAIlE,MAAM,MAAM,aAAa,GAAG,IAAI,CAC9B,IAAI,CAAC,mBAAmB,EACtB,UAAU,GACV,uBAAuB,GACvB,uBAAuB,GACvB,0BAA0B,GAC1B,0BAA0B,GAC1B,sBAAsB,GACtB,aAAa,GACb,aAAa,CAChB,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG,aAAa,GAAG;IAC5C,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,eAAe,CAAC,EAAE,QAAQ,GAAG,cAAc,GAAG,MAAM,GAAG,MAAM,CAAC;IAC9D,YAAY,CAAC,EAAE,UAAU,GAAG,YAAY,CAAC;CAC1C,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG,IAAI,CAClC,IAAI,CAAC,mBAAmB,EACxB,uBAAuB,GAAG,uBAAuB,GAAG,0BAA0B,GAAG,0BAA0B,GAAG,aAAa,CAC5H,CAAC;AAEF,MAAM,MAAM,WAAW,GAAG,IAAI,CAAC,qBAAqB,CAAC;AACrD,MAAM,MAAM,eAAe,GAAG,IAAI,CAAC,qBAAqB,CAAC;AAIzD,MAAM,MAAM,cAAc,GAAG;IAC3B,wEAAwE;IACxE,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;;;;OAKG;IACH,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B;;;;;OAKG;IACH,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,8DAA8D;IAC9D,eAAe,CAAC,EAAE,QAAQ,GAAG,cAAc,GAAG,MAAM,GAAG,MAAM,CAAC;IAC9D,kCAAkC;IAClC,UAAU,CAAC,EAAE,IAAI,CAAC,qBAAqB,CAAC;IACxC,sCAAsC;IACtC,cAAc,CAAC,EAAE,IAAI,CAAC,qBAAqB,CAAC;IAC5C,qEAAqE;IACrE,YAAY,CAAC,EAAE,aAAa,CAAC;CAC9B,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG;IAC5B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,cAAc,CAAC;CACxB,CAAC;AAuBF,wBAAgB,gBAAgB,CAAC,MAAM,GAAE,eAAoB;IA4EzD;;;;;;OAMG;kBACW,YAAY,YAAW,aAAa,GAAQ,MAAM;IAkBhE;;;;;;;OAOG;oBACa,YAAY,YAAW,eAAe,GAAQ,MAAM;IAsBpE;;;;;;;;;OASG;sBACe,YAAY,YAAW,iBAAiB,GAAQ,MAAM;IAuBxE;;;;;OAKG;oBACa,YAAY,GAAG,MAAM;IAQrC;;;;;OAKG;gBACS,SAAS,YAAW,WAAW,GAAQ,MAAM;oBASzC,SAAS,YAAW,eAAe,GAAQ,MAAM;IAQjE;;;;;;;OAOG;wBACiB,SAAS,QAAO,MAAM,GAAgB,MAAM;EAsBnE;AAED,MAAM,MAAM,SAAS,GAAG,UAAU,CAAC,OAAO,gBAAgB,CAAC,CAAC"}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type FormatterConfig, type Formatter, type FormatterRules } from "./core";
|
|
2
|
+
export type { FormatterConfig, Formatter };
|
|
3
|
+
export type NextFormatterConfig = {
|
|
4
|
+
/**
|
|
5
|
+
* Default currency.
|
|
6
|
+
* @default "USD"
|
|
7
|
+
*/
|
|
8
|
+
currency?: string;
|
|
9
|
+
/**
|
|
10
|
+
* Default locale. Falls back to accept-language header if not set.
|
|
11
|
+
*/
|
|
12
|
+
locale?: string;
|
|
13
|
+
/**
|
|
14
|
+
* Shown for null, undefined, or invalid values.
|
|
15
|
+
* @default "—"
|
|
16
|
+
*/
|
|
17
|
+
fallback?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Global formatting rules — applied to every format call.
|
|
20
|
+
* Can be overridden per format call.
|
|
21
|
+
*/
|
|
22
|
+
rules?: FormatterRules;
|
|
23
|
+
/**
|
|
24
|
+
* Resolve locale dynamically per request.
|
|
25
|
+
* Return undefined to fall back to header detection.
|
|
26
|
+
* @example
|
|
27
|
+
* getLocale: async () => {
|
|
28
|
+
* const session = await auth();
|
|
29
|
+
* return session?.user?.locale;
|
|
30
|
+
* }
|
|
31
|
+
*/
|
|
32
|
+
getLocale?: () => string | undefined | Promise<string | undefined>;
|
|
33
|
+
/**
|
|
34
|
+
* Resolve currency dynamically per request.
|
|
35
|
+
* Return undefined to fall back to config default.
|
|
36
|
+
* @example
|
|
37
|
+
* getCurrency: async () => {
|
|
38
|
+
* const session = await auth();
|
|
39
|
+
* return session?.user?.currency;
|
|
40
|
+
* }
|
|
41
|
+
*/
|
|
42
|
+
getCurrency?: () => string | undefined | Promise<string | undefined>;
|
|
43
|
+
};
|
|
44
|
+
export declare function resolveLocale(resolver: (() => string | undefined | Promise<string | undefined>) | undefined, defaultLocale: string | undefined): Promise<string>;
|
|
45
|
+
export declare function resolveCurrency(resolver: (() => string | undefined | Promise<string | undefined>) | undefined, defaultCurrency: string | undefined): Promise<string>;
|
|
46
|
+
//# sourceMappingURL=create.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"create.d.ts","sourceRoot":"","sources":["../../src/create.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,eAAe,EAAE,KAAK,SAAS,EAAE,KAAK,cAAc,EAAE,MAAM,QAAQ,CAAC;AAEnF,YAAY,EAAE,eAAe,EAAE,SAAS,EAAE,CAAC;AAE3C,MAAM,MAAM,mBAAmB,GAAG;IAChC;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAElB;;OAEG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAEhB;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAElB;;;OAGG;IACH,KAAK,CAAC,EAAE,cAAc,CAAC;IAEvB;;;;;;;;OAQG;IACH,SAAS,CAAC,EAAE,MAAM,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;IAEnE;;;;;;;;OAQG;IACH,WAAW,CAAC,EAAE,MAAM,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;CACtE,CAAC;AAIF,wBAAsB,aAAa,CACjC,QAAQ,EAAE,CAAC,MAAM,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC,GAAG,SAAS,EAC9E,aAAa,EAAE,MAAM,GAAG,SAAS,GAChC,OAAO,CAAC,MAAM,CAAC,CAuBjB;AAED,wBAAsB,eAAe,CACnC,QAAQ,EAAE,CAAC,MAAM,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC,GAAG,SAAS,EAC9E,eAAe,EAAE,MAAM,GAAG,SAAS,GAClC,OAAO,CAAC,MAAM,CAAC,CAIjB"}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { type PropsWithChildren } from "react";
|
|
2
|
+
import { type NextFormatterConfig } from "./create";
|
|
3
|
+
/**
|
|
4
|
+
* Root provider for Next.js App Router. Place in `app/layout.tsx`.
|
|
5
|
+
*
|
|
6
|
+
* Async Server Component — resolves locale and currency server-side per request,
|
|
7
|
+
* then passes the resolved config to the client-side context. Zero client bundle cost.
|
|
8
|
+
*
|
|
9
|
+
* Resolution order for locale:
|
|
10
|
+
* 1. `getLocale()` resolver (session, db, cookie)
|
|
11
|
+
* 2. `locale` static value
|
|
12
|
+
* 3. `accept-language` request header
|
|
13
|
+
* 4. `"en-US"` default
|
|
14
|
+
*
|
|
15
|
+
* Resolution order for currency:
|
|
16
|
+
* 1. `getCurrency()` resolver (session, db, cookie)
|
|
17
|
+
* 2. `currency` static value
|
|
18
|
+
* 3. `"USD"` default
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* // Zero config — locale auto-detected from accept-language header
|
|
22
|
+
* <NextFormatterProvider>{children}</NextFormatterProvider>
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* // Static locale and currency
|
|
26
|
+
* <NextFormatterProvider config={{ locale: "de-DE", currency: "EUR" }}>
|
|
27
|
+
* {children}
|
|
28
|
+
* </NextFormatterProvider>
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* // Dynamic locale and currency from session
|
|
32
|
+
* <NextFormatterProvider
|
|
33
|
+
* config={{
|
|
34
|
+
* getLocale: async () => (await auth())?.user?.locale,
|
|
35
|
+
* getCurrency: async () => (await auth())?.user?.currency,
|
|
36
|
+
* fallback: "N/A",
|
|
37
|
+
* rules: {
|
|
38
|
+
* compactThreshold: 50_000,
|
|
39
|
+
* currencyDisplay: "symbol",
|
|
40
|
+
* dateFormat: { year: "numeric", month: "long", day: "2-digit" },
|
|
41
|
+
* },
|
|
42
|
+
* }}
|
|
43
|
+
* >
|
|
44
|
+
* {children}
|
|
45
|
+
* </NextFormatterProvider>
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* // Recommended — define config once in lib/formatter.ts, import everywhere
|
|
49
|
+
* import { formatterConfig } from "@/lib/formatter";
|
|
50
|
+
*
|
|
51
|
+
* <NextFormatterProvider config={formatterConfig}>
|
|
52
|
+
* {children}
|
|
53
|
+
* </NextFormatterProvider>
|
|
54
|
+
*/
|
|
55
|
+
export declare function NextFormatterProvider({ config, children }: PropsWithChildren<{
|
|
56
|
+
config?: NextFormatterConfig;
|
|
57
|
+
}>): Promise<import("react/jsx-runtime").JSX.Element>;
|
|
58
|
+
export type { FormatterConfig, Formatter, FormatterRules, NumericInput, DateInput } from "./core";
|
|
59
|
+
export type { NextFormatterConfig } from "./create";
|
|
60
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,iBAAiB,EAAE,MAAM,OAAO,CAAC;AAE/C,OAAO,EAAkC,KAAK,mBAAmB,EAAE,MAAM,UAAU,CAAC;AAEpF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmDG;AACH,wBAAsB,qBAAqB,CAAC,EAAE,MAAW,EAAE,QAAQ,EAAE,EAAE,iBAAiB,CAAC;IAAE,MAAM,CAAC,EAAE,mBAAmB,CAAA;CAAE,CAAC,oDAKzH;AAED,YAAY,EAAE,eAAe,EAAE,SAAS,EAAE,cAAc,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,QAAQ,CAAC;AAClG,YAAY,EAAE,mBAAmB,EAAE,MAAM,UAAU,CAAC"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type Formatter } from "./core";
|
|
2
|
+
import { type NextFormatterConfig } from "./create";
|
|
3
|
+
/**
|
|
4
|
+
* Use in Server Components or Server Actions.
|
|
5
|
+
* Locale is auto-detected from request headers — pass config only to override.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* // Zero config
|
|
9
|
+
* const fmt = await getFormatter();
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* // With overrides
|
|
13
|
+
* const fmt = await getFormatter({ currency: "EUR" });
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* // Dynamic currency from session
|
|
17
|
+
* const fmt = await getFormatter({
|
|
18
|
+
* getCurrency: async () => (await auth())?.user?.currency,
|
|
19
|
+
* });
|
|
20
|
+
*/
|
|
21
|
+
export declare function getFormatter(config?: NextFormatterConfig): Promise<Formatter>;
|
|
22
|
+
//# sourceMappingURL=server.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/server.ts"],"names":[],"mappings":"AAAA,OAAO,EAAoB,KAAK,SAAS,EAAE,MAAM,QAAQ,CAAC;AAC1D,OAAO,EAAkC,KAAK,mBAAmB,EAAE,MAAM,UAAU,CAAC;AAEpF;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAsB,YAAY,CAAC,MAAM,GAAE,mBAAwB,GAAG,OAAO,CAAC,SAAS,CAAC,CAUvF"}
|
package/package.json
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "next-formatter",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "A universal formatter for numbers, currency, dates, and relative time — built for Next.js App Router, supporting server components, client components, and Node.js backends.",
|
|
5
|
+
"author": "gauravgorade",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"next.js",
|
|
9
|
+
"nextjs",
|
|
10
|
+
"next-intl",
|
|
11
|
+
"react-intl",
|
|
12
|
+
"formatter",
|
|
13
|
+
"intl",
|
|
14
|
+
"internationalization",
|
|
15
|
+
"i18n",
|
|
16
|
+
"currency",
|
|
17
|
+
"currency-formatter",
|
|
18
|
+
"number-format",
|
|
19
|
+
"number-formatter",
|
|
20
|
+
"date-format",
|
|
21
|
+
"date-formatter",
|
|
22
|
+
"relative-time",
|
|
23
|
+
"percentage",
|
|
24
|
+
"duration",
|
|
25
|
+
"server-components",
|
|
26
|
+
"client-components",
|
|
27
|
+
"app-router",
|
|
28
|
+
"react",
|
|
29
|
+
"typescript",
|
|
30
|
+
"hydration-safe",
|
|
31
|
+
"icu",
|
|
32
|
+
"intl-numberformat",
|
|
33
|
+
"intl-datetimeformat",
|
|
34
|
+
"universal-formatter",
|
|
35
|
+
"next-formatter",
|
|
36
|
+
"compact-notation",
|
|
37
|
+
"locale",
|
|
38
|
+
"react-server-components",
|
|
39
|
+
"rsc"
|
|
40
|
+
],
|
|
41
|
+
"repository": {
|
|
42
|
+
"type": "git",
|
|
43
|
+
"url": "git+https://github.com/gauravgorade/next-formatter.git"
|
|
44
|
+
},
|
|
45
|
+
"homepage": "https://github.com/gauravgorade/next-formatter#readme",
|
|
46
|
+
"bugs": {
|
|
47
|
+
"url": "https://github.com/gauravgorade/next-formatter/issues"
|
|
48
|
+
},
|
|
49
|
+
"sideEffects": [
|
|
50
|
+
"./dist/esm/client.mjs",
|
|
51
|
+
"./dist/cjs/client.js"
|
|
52
|
+
],
|
|
53
|
+
"main": "./dist/cjs/index.js",
|
|
54
|
+
"module": "./dist/esm/index.mjs",
|
|
55
|
+
"types": "./dist/types/index.d.ts",
|
|
56
|
+
"exports": {
|
|
57
|
+
".": {
|
|
58
|
+
"types": "./dist/types/index.d.ts",
|
|
59
|
+
"import": "./dist/esm/index.mjs",
|
|
60
|
+
"require": "./dist/cjs/index.js"
|
|
61
|
+
},
|
|
62
|
+
"./server": {
|
|
63
|
+
"types": "./dist/types/server.d.ts",
|
|
64
|
+
"import": "./dist/esm/server.mjs",
|
|
65
|
+
"require": "./dist/cjs/server.js"
|
|
66
|
+
},
|
|
67
|
+
"./client": {
|
|
68
|
+
"types": "./dist/types/client.d.ts",
|
|
69
|
+
"import": "./dist/esm/client.mjs",
|
|
70
|
+
"require": "./dist/cjs/client.js"
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
"files": [
|
|
74
|
+
"dist"
|
|
75
|
+
],
|
|
76
|
+
"scripts": {
|
|
77
|
+
"build": "node --eval \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsup && node scripts/add-use-client.mjs && tsc",
|
|
78
|
+
"dev": "tsup --watch",
|
|
79
|
+
"test": "vitest run",
|
|
80
|
+
"test:watch": "vitest",
|
|
81
|
+
"prepublishOnly": "npm test && npm run build"
|
|
82
|
+
},
|
|
83
|
+
"devDependencies": {
|
|
84
|
+
"@testing-library/react": "^16.0.0",
|
|
85
|
+
"@types/react": "^19.0.0",
|
|
86
|
+
"jsdom": "^25.0.0",
|
|
87
|
+
"next": "^15.0.0",
|
|
88
|
+
"react": "^19.0.0",
|
|
89
|
+
"tsup": "^8.0.0",
|
|
90
|
+
"typescript": "^5.0.0",
|
|
91
|
+
"vitest": "^3.0.0"
|
|
92
|
+
},
|
|
93
|
+
"peerDependencies": {
|
|
94
|
+
"next": ">=14.0.0",
|
|
95
|
+
"react": ">=18.3.0",
|
|
96
|
+
"@types/react": ">=18.3.0"
|
|
97
|
+
},
|
|
98
|
+
"peerDependenciesMeta": {
|
|
99
|
+
"next": {
|
|
100
|
+
"optional": true
|
|
101
|
+
},
|
|
102
|
+
"@types/react": {
|
|
103
|
+
"optional": true
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|