react-presenter 0.1.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/LICENSE +21 -0
- package/README.md +491 -0
- package/dist/index.cjs +268 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +164 -0
- package/dist/index.d.ts +164 -0
- package/dist/index.js +261 -0
- package/dist/index.js.map +1 -0
- package/dist/react.cjs +30 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +142 -0
- package/dist/react.d.ts +142 -0
- package/dist/react.js +27 -0
- package/dist/react.js.map +1 -0
- package/package.json +70 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/proxy.ts
|
|
4
|
+
var memoCache = /* @__PURE__ */ new WeakMap();
|
|
5
|
+
function createDataProxy(presenter, data, options = {}) {
|
|
6
|
+
return new Proxy(presenter, {
|
|
7
|
+
get(target, property, receiver) {
|
|
8
|
+
if (property in target) {
|
|
9
|
+
if (options.memoize && typeof property === "string") {
|
|
10
|
+
const proto = Object.getPrototypeOf(target);
|
|
11
|
+
const descriptor = proto ? Object.getOwnPropertyDescriptor(proto, property) : void 0;
|
|
12
|
+
if (descriptor && typeof descriptor.get === "function") {
|
|
13
|
+
let cache = memoCache.get(target);
|
|
14
|
+
if (!cache) {
|
|
15
|
+
cache = /* @__PURE__ */ new Map();
|
|
16
|
+
memoCache.set(target, cache);
|
|
17
|
+
}
|
|
18
|
+
if (cache.has(property)) {
|
|
19
|
+
return cache.get(property);
|
|
20
|
+
}
|
|
21
|
+
const value = Reflect.get(target, property, receiver);
|
|
22
|
+
cache.set(property, value);
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return Reflect.get(target, property, receiver);
|
|
27
|
+
}
|
|
28
|
+
if (typeof property === "string" && data !== null && typeof data === "object" && property in data) {
|
|
29
|
+
return data[property];
|
|
30
|
+
}
|
|
31
|
+
return void 0;
|
|
32
|
+
},
|
|
33
|
+
has(target, property) {
|
|
34
|
+
if (property in target) return true;
|
|
35
|
+
if (data !== null && typeof data === "object") {
|
|
36
|
+
return property in data;
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// src/reflection.ts
|
|
44
|
+
var EXCLUDED_KEYS = /* @__PURE__ */ new Set(["constructor"]);
|
|
45
|
+
function getGetterNames(instance, stopAt) {
|
|
46
|
+
const names = /* @__PURE__ */ new Set();
|
|
47
|
+
let proto = Object.getPrototypeOf(instance);
|
|
48
|
+
while (proto && proto !== Object.prototype) {
|
|
49
|
+
if (proto === stopAt.prototype) break;
|
|
50
|
+
for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(proto))) {
|
|
51
|
+
if (!EXCLUDED_KEYS.has(key) && typeof descriptor.get === "function") {
|
|
52
|
+
names.add(key);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
proto = Object.getPrototypeOf(proto);
|
|
56
|
+
}
|
|
57
|
+
return Array.from(names);
|
|
58
|
+
}
|
|
59
|
+
function getAsyncMethodNames(instance, stopAt) {
|
|
60
|
+
const names = /* @__PURE__ */ new Set();
|
|
61
|
+
let proto = Object.getPrototypeOf(instance);
|
|
62
|
+
while (proto && proto !== Object.prototype) {
|
|
63
|
+
if (proto === stopAt.prototype) break;
|
|
64
|
+
for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(proto))) {
|
|
65
|
+
if (!EXCLUDED_KEYS.has(key) && typeof descriptor.value === "function" && descriptor.value.constructor?.name === "AsyncFunction") {
|
|
66
|
+
names.add(key);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
proto = Object.getPrototypeOf(proto);
|
|
70
|
+
}
|
|
71
|
+
return Array.from(names);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// src/serialize.ts
|
|
75
|
+
function serializePresenter(presenter, stopAt, options = {}) {
|
|
76
|
+
const allKeys = getGetterNames(presenter, stopAt);
|
|
77
|
+
const onlySet = options.only ? new Set(options.only) : null;
|
|
78
|
+
const exceptSet = options.except ? new Set(options.except) : null;
|
|
79
|
+
const allowedKeys = allKeys.filter((key) => {
|
|
80
|
+
if (onlySet && !onlySet.has(key)) return false;
|
|
81
|
+
if (exceptSet && exceptSet.has(key)) return false;
|
|
82
|
+
return true;
|
|
83
|
+
});
|
|
84
|
+
const orderedKeys = onlySet ? Array.from(onlySet).filter((key) => allowedKeys.includes(key)) : allowedKeys;
|
|
85
|
+
const result = {};
|
|
86
|
+
for (const key of orderedKeys) {
|
|
87
|
+
try {
|
|
88
|
+
result[key] = presenter[key];
|
|
89
|
+
} catch {
|
|
90
|
+
result[key] = void 0;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// src/config.ts
|
|
97
|
+
var globalConfig = {
|
|
98
|
+
locale: "en-US"
|
|
99
|
+
};
|
|
100
|
+
function configurePresenter(config) {
|
|
101
|
+
globalConfig = { ...globalConfig, ...config };
|
|
102
|
+
}
|
|
103
|
+
function getPresenterConfig() {
|
|
104
|
+
return globalConfig;
|
|
105
|
+
}
|
|
106
|
+
var defaultTranslate = (key) => key;
|
|
107
|
+
|
|
108
|
+
// src/format.ts
|
|
109
|
+
function toDate(value) {
|
|
110
|
+
return value instanceof Date ? value : new Date(value);
|
|
111
|
+
}
|
|
112
|
+
var RELATIVE_DIVISORS_MS = {
|
|
113
|
+
year: 1e3 * 60 * 60 * 24 * 365,
|
|
114
|
+
quarter: 1e3 * 60 * 60 * 24 * 91,
|
|
115
|
+
month: 1e3 * 60 * 60 * 24 * 30,
|
|
116
|
+
week: 1e3 * 60 * 60 * 24 * 7,
|
|
117
|
+
day: 1e3 * 60 * 60 * 24,
|
|
118
|
+
hour: 1e3 * 60 * 60,
|
|
119
|
+
minute: 1e3 * 60,
|
|
120
|
+
second: 1e3
|
|
121
|
+
};
|
|
122
|
+
function createFormatAdapter(locale) {
|
|
123
|
+
const config = getPresenterConfig();
|
|
124
|
+
const resolvedLocale = locale ?? config.locale ?? "en-US";
|
|
125
|
+
const overrides = config.formatters ?? {};
|
|
126
|
+
const base = {
|
|
127
|
+
date(value, options) {
|
|
128
|
+
return new Intl.DateTimeFormat(resolvedLocale, options).format(toDate(value));
|
|
129
|
+
},
|
|
130
|
+
number(value, options) {
|
|
131
|
+
return new Intl.NumberFormat(resolvedLocale, options).format(value);
|
|
132
|
+
},
|
|
133
|
+
currency(value, currency = "USD", options) {
|
|
134
|
+
return new Intl.NumberFormat(resolvedLocale, {
|
|
135
|
+
style: "currency",
|
|
136
|
+
currency,
|
|
137
|
+
...options
|
|
138
|
+
}).format(value);
|
|
139
|
+
},
|
|
140
|
+
relativeTime(value, unit = "day") {
|
|
141
|
+
const date = toDate(value);
|
|
142
|
+
const diffMs = date.getTime() - Date.now();
|
|
143
|
+
const divisor = RELATIVE_DIVISORS_MS[unit] ?? RELATIVE_DIVISORS_MS.day;
|
|
144
|
+
const rtf = new Intl.RelativeTimeFormat(resolvedLocale, { numeric: "auto" });
|
|
145
|
+
return rtf.format(Math.round(diffMs / divisor), unit);
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
return { ...base, ...overrides };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// src/Presenter.ts
|
|
152
|
+
var Presenter = class _Presenter {
|
|
153
|
+
constructor(data, context) {
|
|
154
|
+
this.data = data;
|
|
155
|
+
this.context = context ?? {};
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Intl-backed date/number/currency/relative-time formatting, aware of
|
|
159
|
+
* `context.locale`. Override globally via `configurePresenter`.
|
|
160
|
+
*/
|
|
161
|
+
get format() {
|
|
162
|
+
const locale = this.context?.locale;
|
|
163
|
+
return createFormatAdapter(locale);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Translate a key via the globally configured `translate` function
|
|
167
|
+
* (wire up i18next / next-intl / FormatJS / etc. once with
|
|
168
|
+
* `configurePresenter({ translate })`). Falls back to returning the key
|
|
169
|
+
* itself if nothing is configured, so it's always safe to call.
|
|
170
|
+
*/
|
|
171
|
+
t(key, params) {
|
|
172
|
+
const config = getPresenterConfig();
|
|
173
|
+
const translate = config.translate ?? defaultTranslate;
|
|
174
|
+
const locale = this.context?.locale;
|
|
175
|
+
return translate(key, params, locale ?? config.locale);
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Instantiate a presenter for a single entity and wrap it in a Proxy so
|
|
179
|
+
* every raw attribute not shadowed by a getter passes through
|
|
180
|
+
* automatically.
|
|
181
|
+
*/
|
|
182
|
+
static present(data, context, options) {
|
|
183
|
+
const presenter = new this(data, context);
|
|
184
|
+
return createDataProxy(presenter, data, options);
|
|
185
|
+
}
|
|
186
|
+
/** Present a collection: `UserPresenter.presentMany(rawUsers)`. */
|
|
187
|
+
static presentMany(dataList, context, options) {
|
|
188
|
+
return dataList.map(
|
|
189
|
+
(item) => (
|
|
190
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
191
|
+
this.present(item, context, options)
|
|
192
|
+
)
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Resolve every async computed property and merge it with the sync
|
|
197
|
+
* getters into a single plain, JSON-serializable object.
|
|
198
|
+
*
|
|
199
|
+
* JavaScript has no `async get` syntax, so async computed properties are
|
|
200
|
+
* plain `async` methods instead:
|
|
201
|
+
*
|
|
202
|
+
* ```ts
|
|
203
|
+
* class UserPresenter extends Presenter<User> {
|
|
204
|
+
* async profileScore() {
|
|
205
|
+
* return calculateScore(this.data);
|
|
206
|
+
* }
|
|
207
|
+
* }
|
|
208
|
+
*
|
|
209
|
+
* const resolved = await UserPresenter.present(user).resolve();
|
|
210
|
+
* resolved.profileScore; // number
|
|
211
|
+
* ```
|
|
212
|
+
*
|
|
213
|
+
* `resolve()` auto-detects any `async` method on the presenter — no
|
|
214
|
+
* extra registration needed — calls each with no arguments, and merges
|
|
215
|
+
* the results with `toJSON()`. A rejected async property resolves to
|
|
216
|
+
* `undefined` rather than failing the whole call.
|
|
217
|
+
*/
|
|
218
|
+
async resolve(options) {
|
|
219
|
+
const asyncKeys = getAsyncMethodNames(this, _Presenter);
|
|
220
|
+
const resolvedEntries = await Promise.all(
|
|
221
|
+
asyncKeys.map(async (key) => {
|
|
222
|
+
try {
|
|
223
|
+
const method = this[key];
|
|
224
|
+
return [key, await method.call(this)];
|
|
225
|
+
} catch {
|
|
226
|
+
return [key, void 0];
|
|
227
|
+
}
|
|
228
|
+
})
|
|
229
|
+
);
|
|
230
|
+
return {
|
|
231
|
+
...this.toJSON(options),
|
|
232
|
+
...Object.fromEntries(resolvedEntries)
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Serialize the presenter's own computed getters (never the raw
|
|
237
|
+
* `data`/`context`) to a plain object. Safe to return from a Next.js
|
|
238
|
+
* Server Action or pass as props to a Client Component.
|
|
239
|
+
*/
|
|
240
|
+
toJSON(options) {
|
|
241
|
+
return serializePresenter(this, _Presenter, options);
|
|
242
|
+
}
|
|
243
|
+
/** `presenter.only("id", "fullName", "avatarUrl")` */
|
|
244
|
+
only(...keys) {
|
|
245
|
+
return this.toJSON({ only: keys });
|
|
246
|
+
}
|
|
247
|
+
/** `presenter.except("internalNotes")` */
|
|
248
|
+
except(...keys) {
|
|
249
|
+
return this.toJSON({ except: keys });
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
// src/decorate.ts
|
|
254
|
+
function decorate(data, PresenterClass, context, options) {
|
|
255
|
+
return PresenterClass.present(data, context, options);
|
|
256
|
+
}
|
|
257
|
+
function decorateMany(dataList, PresenterClass, context, options) {
|
|
258
|
+
return dataList.map((item) => decorate(item, PresenterClass, context, options));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
exports.Presenter = Presenter;
|
|
262
|
+
exports.configurePresenter = configurePresenter;
|
|
263
|
+
exports.createFormatAdapter = createFormatAdapter;
|
|
264
|
+
exports.decorate = decorate;
|
|
265
|
+
exports.decorateMany = decorateMany;
|
|
266
|
+
exports.getPresenterConfig = getPresenterConfig;
|
|
267
|
+
//# sourceMappingURL=index.cjs.map
|
|
268
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/proxy.ts","../src/reflection.ts","../src/serialize.ts","../src/config.ts","../src/format.ts","../src/Presenter.ts","../src/decorate.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["import type { PresentOptions } from \"./types\";\n\nconst memoCache = new WeakMap<object, Map<string, unknown>>();\n\n/**\n * Wrap a presenter instance so that reading any property first checks the\n * presenter itself (its getters/methods win), and falls back to the same\n * property on the raw `data` object if the presenter doesn't define it.\n * This is what lets `UserPresenter.present(user).email` work without\n * writing `get email() { return this.data.email }` for every field\n * (use case 10).\n */\nexport function createDataProxy<P extends object, T>(\n presenter: P,\n data: T,\n options: PresentOptions = {}\n): P & T {\n return new Proxy(presenter, {\n get(target, property, receiver) {\n if (property in target) {\n if (options.memoize && typeof property === \"string\") {\n const proto = Object.getPrototypeOf(target);\n const descriptor = proto ? Object.getOwnPropertyDescriptor(proto, property) : undefined;\n if (descriptor && typeof descriptor.get === \"function\") {\n let cache = memoCache.get(target);\n if (!cache) {\n cache = new Map();\n memoCache.set(target, cache);\n }\n if (cache.has(property)) {\n return cache.get(property);\n }\n const value = Reflect.get(target, property, receiver);\n cache.set(property, value);\n return value;\n }\n }\n return Reflect.get(target, property, receiver);\n }\n\n if (\n typeof property === \"string\" &&\n data !== null &&\n typeof data === \"object\" &&\n property in (data as object)\n ) {\n return (data as Record<string, unknown>)[property];\n }\n\n return undefined;\n },\n\n has(target, property) {\n if (property in target) return true;\n if (data !== null && typeof data === \"object\") {\n return property in (data as object);\n }\n return false;\n },\n }) as P & T;\n}\n","const EXCLUDED_KEYS = new Set([\"constructor\"]);\n\n/**\n * Collect every `get` accessor defined anywhere in `instance`'s prototype\n * chain, up to (but not including) `stopAt`'s own prototype. This is how\n * `toJSON()` / `only()` / `except()` know which properties are\n * presenter-defined (as opposed to raw pass-through data attributes).\n */\nexport function getGetterNames(instance: object, stopAt: Function): string[] {\n const names = new Set<string>();\n let proto: object | null = Object.getPrototypeOf(instance);\n\n while (proto && proto !== Object.prototype) {\n if (proto === stopAt.prototype) break;\n\n for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(proto))) {\n if (!EXCLUDED_KEYS.has(key) && typeof descriptor.get === \"function\") {\n names.add(key);\n }\n }\n\n proto = Object.getPrototypeOf(proto);\n }\n\n return Array.from(names);\n}\n\n/**\n * Collect every plain `async` method (not accessor — JS has no\n * `async get`) defined anywhere in `instance`'s prototype chain, up to\n * `stopAt`. Used by `resolve()` to auto-discover async computed\n * properties without requiring any extra registration from the user.\n */\nexport function getAsyncMethodNames(instance: object, stopAt: Function): string[] {\n const names = new Set<string>();\n let proto: object | null = Object.getPrototypeOf(instance);\n\n while (proto && proto !== Object.prototype) {\n if (proto === stopAt.prototype) break;\n\n for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(proto))) {\n if (\n !EXCLUDED_KEYS.has(key) &&\n typeof descriptor.value === \"function\" &&\n descriptor.value.constructor?.name === \"AsyncFunction\"\n ) {\n names.add(key);\n }\n }\n\n proto = Object.getPrototypeOf(proto);\n }\n\n return Array.from(names);\n}\n","import type { ToJSONOptions } from \"./types\";\nimport { getGetterNames } from \"./reflection\";\n\n/**\n * Serialize a presenter to a plain object containing only its own\n * computed getters — never the raw `data`/`context`, and never a getter\n * that isn't defined on the presenter itself. This is what makes\n * `toJSON()` safe to send across a server action / API boundary: it can\n * never accidentally leak the full underlying entity.\n *\n * A getter that throws (e.g. an authorization check like\n * `if (!this.context.currentUser.can(...)) throw ...`) is treated the\n * same as one that returns `undefined` — it's simply omitted from the\n * output rather than blowing up serialization for the whole object.\n */\nexport function serializePresenter<P extends object>(\n presenter: P,\n stopAt: Function,\n options: ToJSONOptions = {}\n): Record<string, unknown> {\n const allKeys = getGetterNames(presenter, stopAt);\n const onlySet = options.only ? new Set(options.only) : null;\n const exceptSet = options.except ? new Set(options.except) : null;\n\n const allowedKeys = allKeys.filter((key) => {\n if (onlySet && !onlySet.has(key)) return false;\n if (exceptSet && exceptSet.has(key)) return false;\n return true;\n });\n\n // Preserve the order the caller asked for in `only`, when given.\n const orderedKeys = onlySet\n ? Array.from(onlySet).filter((key) => allowedKeys.includes(key))\n : allowedKeys;\n\n const result: Record<string, unknown> = {};\n for (const key of orderedKeys) {\n try {\n result[key] = (presenter as Record<string, unknown>)[key];\n } catch {\n result[key] = undefined;\n }\n }\n return result;\n}\n","import type { PresenterGlobalConfig, TranslateFn } from \"./types\";\n\nlet globalConfig: PresenterGlobalConfig = {\n locale: \"en-US\",\n};\n\n/**\n * Wire up your app's i18n library / default locale / formatter overrides\n * once, e.g. in a root layout or app entry point:\n *\n * ```ts\n * configurePresenter({\n * translate: (key, params, locale) => i18next.t(key, { ...params, lng: locale }),\n * locale: \"en-US\",\n * });\n * ```\n */\nexport function configurePresenter(config: PresenterGlobalConfig): void {\n globalConfig = { ...globalConfig, ...config };\n}\n\nexport function getPresenterConfig(): PresenterGlobalConfig {\n return globalConfig;\n}\n\n/** Identity fallback so `this.t(key)` is always safe to call. */\nexport const defaultTranslate: TranslateFn = (key) => key;\n","import type { FormatAdapter } from \"./types\";\nimport { getPresenterConfig } from \"./config\";\n\nfunction toDate(value: Date | string | number): Date {\n return value instanceof Date ? value : new Date(value);\n}\n\nconst RELATIVE_DIVISORS_MS: Record<string, number> = {\n year: 1000 * 60 * 60 * 24 * 365,\n quarter: 1000 * 60 * 60 * 24 * 91,\n month: 1000 * 60 * 60 * 24 * 30,\n week: 1000 * 60 * 60 * 24 * 7,\n day: 1000 * 60 * 60 * 24,\n hour: 1000 * 60 * 60,\n minute: 1000 * 60,\n second: 1000,\n};\n\n/**\n * Build a locale-aware formatter. Presenters call this via `this.format`,\n * automatically passing `context.locale` — you rarely need to call it\n * directly.\n */\nexport function createFormatAdapter(locale?: string): FormatAdapter {\n const config = getPresenterConfig();\n const resolvedLocale = locale ?? config.locale ?? \"en-US\";\n const overrides = config.formatters ?? {};\n\n const base: FormatAdapter = {\n date(value, options) {\n return new Intl.DateTimeFormat(resolvedLocale, options).format(toDate(value));\n },\n number(value, options) {\n return new Intl.NumberFormat(resolvedLocale, options).format(value);\n },\n currency(value, currency = \"USD\", options) {\n return new Intl.NumberFormat(resolvedLocale, {\n style: \"currency\",\n currency,\n ...options,\n }).format(value);\n },\n relativeTime(value, unit = \"day\") {\n const date = toDate(value);\n const diffMs = date.getTime() - Date.now();\n const divisor = RELATIVE_DIVISORS_MS[unit] ?? RELATIVE_DIVISORS_MS.day;\n const rtf = new Intl.RelativeTimeFormat(resolvedLocale, { numeric: \"auto\" });\n return rtf.format(Math.round(diffMs / divisor), unit);\n },\n };\n\n return { ...base, ...overrides };\n}\n","import type { AnyRecord, ContextOf, DataOf, PresentOptions, ToJSONOptions } from \"./types\";\nimport { createDataProxy } from \"./proxy\";\nimport { getAsyncMethodNames } from \"./reflection\";\nimport { serializePresenter } from \"./serialize\";\nimport { createFormatAdapter } from \"./format\";\nimport { defaultTranslate, getPresenterConfig } from \"./config\";\n\n/**\n * Base class for all presenters/decorators.\n *\n * ```ts\n * class ProductPresenter extends Presenter<Product> {\n * get isNew() {\n * const days = (Date.now() - new Date(this.data.created_at).getTime()) / 86_400_000;\n * return days <= 7;\n * }\n * }\n *\n * const product = ProductPresenter.present(rawProduct);\n * product.isNew; // presenter getter\n * product.seller; // passed through from rawProduct automatically\n * ```\n */\nexport abstract class Presenter<T, C = AnyRecord> {\n readonly data: T;\n readonly context: C;\n\n constructor(data: T, context?: C) {\n this.data = data;\n this.context = (context ?? ({} as C)) as C;\n }\n\n /**\n * Intl-backed date/number/currency/relative-time formatting, aware of\n * `context.locale`. Override globally via `configurePresenter`.\n */\n protected get format() {\n const locale = (this.context as AnyRecord | undefined)?.locale as string | undefined;\n return createFormatAdapter(locale);\n }\n\n /**\n * Translate a key via the globally configured `translate` function\n * (wire up i18next / next-intl / FormatJS / etc. once with\n * `configurePresenter({ translate })`). Falls back to returning the key\n * itself if nothing is configured, so it's always safe to call.\n */\n protected t(key: string, params?: AnyRecord): string {\n const config = getPresenterConfig();\n const translate = config.translate ?? defaultTranslate;\n const locale = (this.context as AnyRecord | undefined)?.locale as string | undefined;\n return translate(key, params, locale ?? config.locale);\n }\n\n /**\n * Instantiate a presenter for a single entity and wrap it in a Proxy so\n * every raw attribute not shadowed by a getter passes through\n * automatically.\n */\n static present<P extends Presenter<any, any>>(\n this: new (data: DataOf<P>, context?: ContextOf<P>) => P,\n data: DataOf<P>,\n context?: ContextOf<P>,\n options?: PresentOptions\n ): P & DataOf<P> {\n // eslint-disable-next-line new-cap\n const presenter = new this(data, context);\n return createDataProxy(presenter, data, options);\n }\n\n /** Present a collection: `UserPresenter.presentMany(rawUsers)`. */\n static presentMany<P extends Presenter<any, any>>(\n this: new (data: DataOf<P>, context?: ContextOf<P>) => P,\n dataList: readonly DataOf<P>[],\n context?: ContextOf<P>,\n options?: PresentOptions\n ): Array<P & DataOf<P>> {\n return dataList.map((item) =>\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n (this as any).present(item, context, options)\n );\n }\n\n /**\n * Resolve every async computed property and merge it with the sync\n * getters into a single plain, JSON-serializable object.\n *\n * JavaScript has no `async get` syntax, so async computed properties are\n * plain `async` methods instead:\n *\n * ```ts\n * class UserPresenter extends Presenter<User> {\n * async profileScore() {\n * return calculateScore(this.data);\n * }\n * }\n *\n * const resolved = await UserPresenter.present(user).resolve();\n * resolved.profileScore; // number\n * ```\n *\n * `resolve()` auto-detects any `async` method on the presenter — no\n * extra registration needed — calls each with no arguments, and merges\n * the results with `toJSON()`. A rejected async property resolves to\n * `undefined` rather than failing the whole call.\n */\n async resolve(this: Presenter<T, C>, options?: ToJSONOptions): Promise<Record<string, unknown>> {\n const asyncKeys = getAsyncMethodNames(this, Presenter);\n\n const resolvedEntries = await Promise.all(\n asyncKeys.map(async (key) => {\n try {\n const method = (this as unknown as Record<string, () => Promise<unknown>>)[key];\n return [key, await method.call(this)] as const;\n } catch {\n return [key, undefined] as const;\n }\n })\n );\n\n return {\n ...this.toJSON(options),\n ...Object.fromEntries(resolvedEntries),\n };\n }\n\n /**\n * Serialize the presenter's own computed getters (never the raw\n * `data`/`context`) to a plain object. Safe to return from a Next.js\n * Server Action or pass as props to a Client Component.\n */\n toJSON(options?: ToJSONOptions): Record<string, unknown> {\n return serializePresenter(this, Presenter, options);\n }\n\n /** `presenter.only(\"id\", \"fullName\", \"avatarUrl\")` */\n only(...keys: string[]): Record<string, unknown> {\n return this.toJSON({ only: keys });\n }\n\n /** `presenter.except(\"internalNotes\")` */\n except(...keys: string[]): Record<string, unknown> {\n return this.toJSON({ except: keys });\n }\n}\n","import type { Presenter } from \"./Presenter\";\nimport type { PresentOptions, PresenterConstructor } from \"./types\";\n\n/**\n * Functional, Rails-`decorate`-style alternative to `Presenter.present`:\n *\n * ```ts\n * import { decorate } from \"react-presenter\";\n * const user = decorate(rawUser, UserPresenter);\n * ```\n */\nexport function decorate<T, C, P extends Presenter<T, C>>(\n data: T,\n PresenterClass: PresenterConstructor<T, C, P>,\n context?: C,\n options?: PresentOptions\n): P & T {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n return (PresenterClass as any).present(data, context, options);\n}\n\n/** Functional equivalent of `presentMany`. */\nexport function decorateMany<T, C, P extends Presenter<T, C>>(\n dataList: readonly T[],\n PresenterClass: PresenterConstructor<T, C, P>,\n context?: C,\n options?: PresentOptions\n): Array<P & T> {\n return dataList.map((item) => decorate(item, PresenterClass, context, options));\n}\n"]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
type AnyRecord = Record<string, unknown>;
|
|
2
|
+
/**
|
|
3
|
+
* Shape tsc needs to accept a presenter class as a value (e.g. in
|
|
4
|
+
* `decorate(data, UserPresenter)` or `usePresenter(UserPresenter, data)`),
|
|
5
|
+
* so `T`/`C`/`P` can be inferred from the class that's passed in.
|
|
6
|
+
*/
|
|
7
|
+
interface PresenterConstructor<T, C, P> {
|
|
8
|
+
new (data: T, context?: C): P;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Extract a presenter's `data` type from the presenter type itself. Used
|
|
12
|
+
* so that `SomePresenter.present(data, context)` infers `T`/`C` from the
|
|
13
|
+
* concrete subclass (`this`) rather than independently from whatever gets
|
|
14
|
+
* passed at the call site — which is what lets a partial context object
|
|
15
|
+
* literal type-check against the presenter's declared context type.
|
|
16
|
+
*/
|
|
17
|
+
type DataOf<P> = P extends {
|
|
18
|
+
data: infer T;
|
|
19
|
+
} ? T : never;
|
|
20
|
+
type ContextOf<P> = P extends {
|
|
21
|
+
context: infer C;
|
|
22
|
+
} ? C : never;
|
|
23
|
+
interface PresentOptions {
|
|
24
|
+
/**
|
|
25
|
+
* Cache each getter's return value the first time it's read on a given
|
|
26
|
+
* presenter instance. Useful when a computed getter is expensive (e.g.
|
|
27
|
+
* heavy formatting or derived aggregation) and may be read more than
|
|
28
|
+
* once during a render. Off by default because most getters are cheap
|
|
29
|
+
* and memoizing can hide the fact that `data` changed underneath you.
|
|
30
|
+
*/
|
|
31
|
+
memoize?: boolean;
|
|
32
|
+
}
|
|
33
|
+
interface ToJSONOptions {
|
|
34
|
+
/** Only include these presenter properties, in the given order. */
|
|
35
|
+
only?: readonly string[];
|
|
36
|
+
/** Include every presenter property except these. */
|
|
37
|
+
except?: readonly string[];
|
|
38
|
+
}
|
|
39
|
+
interface FormatAdapter {
|
|
40
|
+
date(value: Date | string | number, options?: Intl.DateTimeFormatOptions): string;
|
|
41
|
+
number(value: number, options?: Intl.NumberFormatOptions): string;
|
|
42
|
+
currency(value: number, currency?: string, options?: Intl.NumberFormatOptions): string;
|
|
43
|
+
relativeTime(value: Date | string | number, unit?: Intl.RelativeTimeFormatUnit): string;
|
|
44
|
+
}
|
|
45
|
+
type TranslateFn = (key: string, params?: AnyRecord, locale?: string) => string;
|
|
46
|
+
interface PresenterGlobalConfig {
|
|
47
|
+
/** Plug in your i18n library (i18next, next-intl, FormatJS, ...) here. */
|
|
48
|
+
translate?: TranslateFn;
|
|
49
|
+
/** Fallback locale used when a presenter's context has none. */
|
|
50
|
+
locale?: string;
|
|
51
|
+
/** Override individual Intl-backed formatters globally. */
|
|
52
|
+
formatters?: Partial<FormatAdapter>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Base class for all presenters/decorators.
|
|
57
|
+
*
|
|
58
|
+
* ```ts
|
|
59
|
+
* class ProductPresenter extends Presenter<Product> {
|
|
60
|
+
* get isNew() {
|
|
61
|
+
* const days = (Date.now() - new Date(this.data.created_at).getTime()) / 86_400_000;
|
|
62
|
+
* return days <= 7;
|
|
63
|
+
* }
|
|
64
|
+
* }
|
|
65
|
+
*
|
|
66
|
+
* const product = ProductPresenter.present(rawProduct);
|
|
67
|
+
* product.isNew; // presenter getter
|
|
68
|
+
* product.seller; // passed through from rawProduct automatically
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
declare abstract class Presenter<T, C = AnyRecord> {
|
|
72
|
+
readonly data: T;
|
|
73
|
+
readonly context: C;
|
|
74
|
+
constructor(data: T, context?: C);
|
|
75
|
+
/**
|
|
76
|
+
* Intl-backed date/number/currency/relative-time formatting, aware of
|
|
77
|
+
* `context.locale`. Override globally via `configurePresenter`.
|
|
78
|
+
*/
|
|
79
|
+
protected get format(): FormatAdapter;
|
|
80
|
+
/**
|
|
81
|
+
* Translate a key via the globally configured `translate` function
|
|
82
|
+
* (wire up i18next / next-intl / FormatJS / etc. once with
|
|
83
|
+
* `configurePresenter({ translate })`). Falls back to returning the key
|
|
84
|
+
* itself if nothing is configured, so it's always safe to call.
|
|
85
|
+
*/
|
|
86
|
+
protected t(key: string, params?: AnyRecord): string;
|
|
87
|
+
/**
|
|
88
|
+
* Instantiate a presenter for a single entity and wrap it in a Proxy so
|
|
89
|
+
* every raw attribute not shadowed by a getter passes through
|
|
90
|
+
* automatically.
|
|
91
|
+
*/
|
|
92
|
+
static present<P extends Presenter<any, any>>(this: new (data: DataOf<P>, context?: ContextOf<P>) => P, data: DataOf<P>, context?: ContextOf<P>, options?: PresentOptions): P & DataOf<P>;
|
|
93
|
+
/** Present a collection: `UserPresenter.presentMany(rawUsers)`. */
|
|
94
|
+
static presentMany<P extends Presenter<any, any>>(this: new (data: DataOf<P>, context?: ContextOf<P>) => P, dataList: readonly DataOf<P>[], context?: ContextOf<P>, options?: PresentOptions): Array<P & DataOf<P>>;
|
|
95
|
+
/**
|
|
96
|
+
* Resolve every async computed property and merge it with the sync
|
|
97
|
+
* getters into a single plain, JSON-serializable object.
|
|
98
|
+
*
|
|
99
|
+
* JavaScript has no `async get` syntax, so async computed properties are
|
|
100
|
+
* plain `async` methods instead:
|
|
101
|
+
*
|
|
102
|
+
* ```ts
|
|
103
|
+
* class UserPresenter extends Presenter<User> {
|
|
104
|
+
* async profileScore() {
|
|
105
|
+
* return calculateScore(this.data);
|
|
106
|
+
* }
|
|
107
|
+
* }
|
|
108
|
+
*
|
|
109
|
+
* const resolved = await UserPresenter.present(user).resolve();
|
|
110
|
+
* resolved.profileScore; // number
|
|
111
|
+
* ```
|
|
112
|
+
*
|
|
113
|
+
* `resolve()` auto-detects any `async` method on the presenter — no
|
|
114
|
+
* extra registration needed — calls each with no arguments, and merges
|
|
115
|
+
* the results with `toJSON()`. A rejected async property resolves to
|
|
116
|
+
* `undefined` rather than failing the whole call.
|
|
117
|
+
*/
|
|
118
|
+
resolve(this: Presenter<T, C>, options?: ToJSONOptions): Promise<Record<string, unknown>>;
|
|
119
|
+
/**
|
|
120
|
+
* Serialize the presenter's own computed getters (never the raw
|
|
121
|
+
* `data`/`context`) to a plain object. Safe to return from a Next.js
|
|
122
|
+
* Server Action or pass as props to a Client Component.
|
|
123
|
+
*/
|
|
124
|
+
toJSON(options?: ToJSONOptions): Record<string, unknown>;
|
|
125
|
+
/** `presenter.only("id", "fullName", "avatarUrl")` */
|
|
126
|
+
only(...keys: string[]): Record<string, unknown>;
|
|
127
|
+
/** `presenter.except("internalNotes")` */
|
|
128
|
+
except(...keys: string[]): Record<string, unknown>;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Functional, Rails-`decorate`-style alternative to `Presenter.present`:
|
|
133
|
+
*
|
|
134
|
+
* ```ts
|
|
135
|
+
* import { decorate } from "react-presenter";
|
|
136
|
+
* const user = decorate(rawUser, UserPresenter);
|
|
137
|
+
* ```
|
|
138
|
+
*/
|
|
139
|
+
declare function decorate<T, C, P extends Presenter<T, C>>(data: T, PresenterClass: PresenterConstructor<T, C, P>, context?: C, options?: PresentOptions): P & T;
|
|
140
|
+
/** Functional equivalent of `presentMany`. */
|
|
141
|
+
declare function decorateMany<T, C, P extends Presenter<T, C>>(dataList: readonly T[], PresenterClass: PresenterConstructor<T, C, P>, context?: C, options?: PresentOptions): Array<P & T>;
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Wire up your app's i18n library / default locale / formatter overrides
|
|
145
|
+
* once, e.g. in a root layout or app entry point:
|
|
146
|
+
*
|
|
147
|
+
* ```ts
|
|
148
|
+
* configurePresenter({
|
|
149
|
+
* translate: (key, params, locale) => i18next.t(key, { ...params, lng: locale }),
|
|
150
|
+
* locale: "en-US",
|
|
151
|
+
* });
|
|
152
|
+
* ```
|
|
153
|
+
*/
|
|
154
|
+
declare function configurePresenter(config: PresenterGlobalConfig): void;
|
|
155
|
+
declare function getPresenterConfig(): PresenterGlobalConfig;
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Build a locale-aware formatter. Presenters call this via `this.format`,
|
|
159
|
+
* automatically passing `context.locale` — you rarely need to call it
|
|
160
|
+
* directly.
|
|
161
|
+
*/
|
|
162
|
+
declare function createFormatAdapter(locale?: string): FormatAdapter;
|
|
163
|
+
|
|
164
|
+
export { type AnyRecord, type FormatAdapter, type PresentOptions, Presenter, type PresenterConstructor, type PresenterGlobalConfig, type ToJSONOptions, type TranslateFn, configurePresenter, createFormatAdapter, decorate, decorateMany, getPresenterConfig };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
type AnyRecord = Record<string, unknown>;
|
|
2
|
+
/**
|
|
3
|
+
* Shape tsc needs to accept a presenter class as a value (e.g. in
|
|
4
|
+
* `decorate(data, UserPresenter)` or `usePresenter(UserPresenter, data)`),
|
|
5
|
+
* so `T`/`C`/`P` can be inferred from the class that's passed in.
|
|
6
|
+
*/
|
|
7
|
+
interface PresenterConstructor<T, C, P> {
|
|
8
|
+
new (data: T, context?: C): P;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Extract a presenter's `data` type from the presenter type itself. Used
|
|
12
|
+
* so that `SomePresenter.present(data, context)` infers `T`/`C` from the
|
|
13
|
+
* concrete subclass (`this`) rather than independently from whatever gets
|
|
14
|
+
* passed at the call site — which is what lets a partial context object
|
|
15
|
+
* literal type-check against the presenter's declared context type.
|
|
16
|
+
*/
|
|
17
|
+
type DataOf<P> = P extends {
|
|
18
|
+
data: infer T;
|
|
19
|
+
} ? T : never;
|
|
20
|
+
type ContextOf<P> = P extends {
|
|
21
|
+
context: infer C;
|
|
22
|
+
} ? C : never;
|
|
23
|
+
interface PresentOptions {
|
|
24
|
+
/**
|
|
25
|
+
* Cache each getter's return value the first time it's read on a given
|
|
26
|
+
* presenter instance. Useful when a computed getter is expensive (e.g.
|
|
27
|
+
* heavy formatting or derived aggregation) and may be read more than
|
|
28
|
+
* once during a render. Off by default because most getters are cheap
|
|
29
|
+
* and memoizing can hide the fact that `data` changed underneath you.
|
|
30
|
+
*/
|
|
31
|
+
memoize?: boolean;
|
|
32
|
+
}
|
|
33
|
+
interface ToJSONOptions {
|
|
34
|
+
/** Only include these presenter properties, in the given order. */
|
|
35
|
+
only?: readonly string[];
|
|
36
|
+
/** Include every presenter property except these. */
|
|
37
|
+
except?: readonly string[];
|
|
38
|
+
}
|
|
39
|
+
interface FormatAdapter {
|
|
40
|
+
date(value: Date | string | number, options?: Intl.DateTimeFormatOptions): string;
|
|
41
|
+
number(value: number, options?: Intl.NumberFormatOptions): string;
|
|
42
|
+
currency(value: number, currency?: string, options?: Intl.NumberFormatOptions): string;
|
|
43
|
+
relativeTime(value: Date | string | number, unit?: Intl.RelativeTimeFormatUnit): string;
|
|
44
|
+
}
|
|
45
|
+
type TranslateFn = (key: string, params?: AnyRecord, locale?: string) => string;
|
|
46
|
+
interface PresenterGlobalConfig {
|
|
47
|
+
/** Plug in your i18n library (i18next, next-intl, FormatJS, ...) here. */
|
|
48
|
+
translate?: TranslateFn;
|
|
49
|
+
/** Fallback locale used when a presenter's context has none. */
|
|
50
|
+
locale?: string;
|
|
51
|
+
/** Override individual Intl-backed formatters globally. */
|
|
52
|
+
formatters?: Partial<FormatAdapter>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Base class for all presenters/decorators.
|
|
57
|
+
*
|
|
58
|
+
* ```ts
|
|
59
|
+
* class ProductPresenter extends Presenter<Product> {
|
|
60
|
+
* get isNew() {
|
|
61
|
+
* const days = (Date.now() - new Date(this.data.created_at).getTime()) / 86_400_000;
|
|
62
|
+
* return days <= 7;
|
|
63
|
+
* }
|
|
64
|
+
* }
|
|
65
|
+
*
|
|
66
|
+
* const product = ProductPresenter.present(rawProduct);
|
|
67
|
+
* product.isNew; // presenter getter
|
|
68
|
+
* product.seller; // passed through from rawProduct automatically
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
declare abstract class Presenter<T, C = AnyRecord> {
|
|
72
|
+
readonly data: T;
|
|
73
|
+
readonly context: C;
|
|
74
|
+
constructor(data: T, context?: C);
|
|
75
|
+
/**
|
|
76
|
+
* Intl-backed date/number/currency/relative-time formatting, aware of
|
|
77
|
+
* `context.locale`. Override globally via `configurePresenter`.
|
|
78
|
+
*/
|
|
79
|
+
protected get format(): FormatAdapter;
|
|
80
|
+
/**
|
|
81
|
+
* Translate a key via the globally configured `translate` function
|
|
82
|
+
* (wire up i18next / next-intl / FormatJS / etc. once with
|
|
83
|
+
* `configurePresenter({ translate })`). Falls back to returning the key
|
|
84
|
+
* itself if nothing is configured, so it's always safe to call.
|
|
85
|
+
*/
|
|
86
|
+
protected t(key: string, params?: AnyRecord): string;
|
|
87
|
+
/**
|
|
88
|
+
* Instantiate a presenter for a single entity and wrap it in a Proxy so
|
|
89
|
+
* every raw attribute not shadowed by a getter passes through
|
|
90
|
+
* automatically.
|
|
91
|
+
*/
|
|
92
|
+
static present<P extends Presenter<any, any>>(this: new (data: DataOf<P>, context?: ContextOf<P>) => P, data: DataOf<P>, context?: ContextOf<P>, options?: PresentOptions): P & DataOf<P>;
|
|
93
|
+
/** Present a collection: `UserPresenter.presentMany(rawUsers)`. */
|
|
94
|
+
static presentMany<P extends Presenter<any, any>>(this: new (data: DataOf<P>, context?: ContextOf<P>) => P, dataList: readonly DataOf<P>[], context?: ContextOf<P>, options?: PresentOptions): Array<P & DataOf<P>>;
|
|
95
|
+
/**
|
|
96
|
+
* Resolve every async computed property and merge it with the sync
|
|
97
|
+
* getters into a single plain, JSON-serializable object.
|
|
98
|
+
*
|
|
99
|
+
* JavaScript has no `async get` syntax, so async computed properties are
|
|
100
|
+
* plain `async` methods instead:
|
|
101
|
+
*
|
|
102
|
+
* ```ts
|
|
103
|
+
* class UserPresenter extends Presenter<User> {
|
|
104
|
+
* async profileScore() {
|
|
105
|
+
* return calculateScore(this.data);
|
|
106
|
+
* }
|
|
107
|
+
* }
|
|
108
|
+
*
|
|
109
|
+
* const resolved = await UserPresenter.present(user).resolve();
|
|
110
|
+
* resolved.profileScore; // number
|
|
111
|
+
* ```
|
|
112
|
+
*
|
|
113
|
+
* `resolve()` auto-detects any `async` method on the presenter — no
|
|
114
|
+
* extra registration needed — calls each with no arguments, and merges
|
|
115
|
+
* the results with `toJSON()`. A rejected async property resolves to
|
|
116
|
+
* `undefined` rather than failing the whole call.
|
|
117
|
+
*/
|
|
118
|
+
resolve(this: Presenter<T, C>, options?: ToJSONOptions): Promise<Record<string, unknown>>;
|
|
119
|
+
/**
|
|
120
|
+
* Serialize the presenter's own computed getters (never the raw
|
|
121
|
+
* `data`/`context`) to a plain object. Safe to return from a Next.js
|
|
122
|
+
* Server Action or pass as props to a Client Component.
|
|
123
|
+
*/
|
|
124
|
+
toJSON(options?: ToJSONOptions): Record<string, unknown>;
|
|
125
|
+
/** `presenter.only("id", "fullName", "avatarUrl")` */
|
|
126
|
+
only(...keys: string[]): Record<string, unknown>;
|
|
127
|
+
/** `presenter.except("internalNotes")` */
|
|
128
|
+
except(...keys: string[]): Record<string, unknown>;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Functional, Rails-`decorate`-style alternative to `Presenter.present`:
|
|
133
|
+
*
|
|
134
|
+
* ```ts
|
|
135
|
+
* import { decorate } from "react-presenter";
|
|
136
|
+
* const user = decorate(rawUser, UserPresenter);
|
|
137
|
+
* ```
|
|
138
|
+
*/
|
|
139
|
+
declare function decorate<T, C, P extends Presenter<T, C>>(data: T, PresenterClass: PresenterConstructor<T, C, P>, context?: C, options?: PresentOptions): P & T;
|
|
140
|
+
/** Functional equivalent of `presentMany`. */
|
|
141
|
+
declare function decorateMany<T, C, P extends Presenter<T, C>>(dataList: readonly T[], PresenterClass: PresenterConstructor<T, C, P>, context?: C, options?: PresentOptions): Array<P & T>;
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Wire up your app's i18n library / default locale / formatter overrides
|
|
145
|
+
* once, e.g. in a root layout or app entry point:
|
|
146
|
+
*
|
|
147
|
+
* ```ts
|
|
148
|
+
* configurePresenter({
|
|
149
|
+
* translate: (key, params, locale) => i18next.t(key, { ...params, lng: locale }),
|
|
150
|
+
* locale: "en-US",
|
|
151
|
+
* });
|
|
152
|
+
* ```
|
|
153
|
+
*/
|
|
154
|
+
declare function configurePresenter(config: PresenterGlobalConfig): void;
|
|
155
|
+
declare function getPresenterConfig(): PresenterGlobalConfig;
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Build a locale-aware formatter. Presenters call this via `this.format`,
|
|
159
|
+
* automatically passing `context.locale` — you rarely need to call it
|
|
160
|
+
* directly.
|
|
161
|
+
*/
|
|
162
|
+
declare function createFormatAdapter(locale?: string): FormatAdapter;
|
|
163
|
+
|
|
164
|
+
export { type AnyRecord, type FormatAdapter, type PresentOptions, Presenter, type PresenterConstructor, type PresenterGlobalConfig, type ToJSONOptions, type TranslateFn, configurePresenter, createFormatAdapter, decorate, decorateMany, getPresenterConfig };
|