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/dist/index.js ADDED
@@ -0,0 +1,261 @@
1
+ // src/proxy.ts
2
+ var memoCache = /* @__PURE__ */ new WeakMap();
3
+ function createDataProxy(presenter, data, options = {}) {
4
+ return new Proxy(presenter, {
5
+ get(target, property, receiver) {
6
+ if (property in target) {
7
+ if (options.memoize && typeof property === "string") {
8
+ const proto = Object.getPrototypeOf(target);
9
+ const descriptor = proto ? Object.getOwnPropertyDescriptor(proto, property) : void 0;
10
+ if (descriptor && typeof descriptor.get === "function") {
11
+ let cache = memoCache.get(target);
12
+ if (!cache) {
13
+ cache = /* @__PURE__ */ new Map();
14
+ memoCache.set(target, cache);
15
+ }
16
+ if (cache.has(property)) {
17
+ return cache.get(property);
18
+ }
19
+ const value = Reflect.get(target, property, receiver);
20
+ cache.set(property, value);
21
+ return value;
22
+ }
23
+ }
24
+ return Reflect.get(target, property, receiver);
25
+ }
26
+ if (typeof property === "string" && data !== null && typeof data === "object" && property in data) {
27
+ return data[property];
28
+ }
29
+ return void 0;
30
+ },
31
+ has(target, property) {
32
+ if (property in target) return true;
33
+ if (data !== null && typeof data === "object") {
34
+ return property in data;
35
+ }
36
+ return false;
37
+ }
38
+ });
39
+ }
40
+
41
+ // src/reflection.ts
42
+ var EXCLUDED_KEYS = /* @__PURE__ */ new Set(["constructor"]);
43
+ function getGetterNames(instance, stopAt) {
44
+ const names = /* @__PURE__ */ new Set();
45
+ let proto = Object.getPrototypeOf(instance);
46
+ while (proto && proto !== Object.prototype) {
47
+ if (proto === stopAt.prototype) break;
48
+ for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(proto))) {
49
+ if (!EXCLUDED_KEYS.has(key) && typeof descriptor.get === "function") {
50
+ names.add(key);
51
+ }
52
+ }
53
+ proto = Object.getPrototypeOf(proto);
54
+ }
55
+ return Array.from(names);
56
+ }
57
+ function getAsyncMethodNames(instance, stopAt) {
58
+ const names = /* @__PURE__ */ new Set();
59
+ let proto = Object.getPrototypeOf(instance);
60
+ while (proto && proto !== Object.prototype) {
61
+ if (proto === stopAt.prototype) break;
62
+ for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(proto))) {
63
+ if (!EXCLUDED_KEYS.has(key) && typeof descriptor.value === "function" && descriptor.value.constructor?.name === "AsyncFunction") {
64
+ names.add(key);
65
+ }
66
+ }
67
+ proto = Object.getPrototypeOf(proto);
68
+ }
69
+ return Array.from(names);
70
+ }
71
+
72
+ // src/serialize.ts
73
+ function serializePresenter(presenter, stopAt, options = {}) {
74
+ const allKeys = getGetterNames(presenter, stopAt);
75
+ const onlySet = options.only ? new Set(options.only) : null;
76
+ const exceptSet = options.except ? new Set(options.except) : null;
77
+ const allowedKeys = allKeys.filter((key) => {
78
+ if (onlySet && !onlySet.has(key)) return false;
79
+ if (exceptSet && exceptSet.has(key)) return false;
80
+ return true;
81
+ });
82
+ const orderedKeys = onlySet ? Array.from(onlySet).filter((key) => allowedKeys.includes(key)) : allowedKeys;
83
+ const result = {};
84
+ for (const key of orderedKeys) {
85
+ try {
86
+ result[key] = presenter[key];
87
+ } catch {
88
+ result[key] = void 0;
89
+ }
90
+ }
91
+ return result;
92
+ }
93
+
94
+ // src/config.ts
95
+ var globalConfig = {
96
+ locale: "en-US"
97
+ };
98
+ function configurePresenter(config) {
99
+ globalConfig = { ...globalConfig, ...config };
100
+ }
101
+ function getPresenterConfig() {
102
+ return globalConfig;
103
+ }
104
+ var defaultTranslate = (key) => key;
105
+
106
+ // src/format.ts
107
+ function toDate(value) {
108
+ return value instanceof Date ? value : new Date(value);
109
+ }
110
+ var RELATIVE_DIVISORS_MS = {
111
+ year: 1e3 * 60 * 60 * 24 * 365,
112
+ quarter: 1e3 * 60 * 60 * 24 * 91,
113
+ month: 1e3 * 60 * 60 * 24 * 30,
114
+ week: 1e3 * 60 * 60 * 24 * 7,
115
+ day: 1e3 * 60 * 60 * 24,
116
+ hour: 1e3 * 60 * 60,
117
+ minute: 1e3 * 60,
118
+ second: 1e3
119
+ };
120
+ function createFormatAdapter(locale) {
121
+ const config = getPresenterConfig();
122
+ const resolvedLocale = locale ?? config.locale ?? "en-US";
123
+ const overrides = config.formatters ?? {};
124
+ const base = {
125
+ date(value, options) {
126
+ return new Intl.DateTimeFormat(resolvedLocale, options).format(toDate(value));
127
+ },
128
+ number(value, options) {
129
+ return new Intl.NumberFormat(resolvedLocale, options).format(value);
130
+ },
131
+ currency(value, currency = "USD", options) {
132
+ return new Intl.NumberFormat(resolvedLocale, {
133
+ style: "currency",
134
+ currency,
135
+ ...options
136
+ }).format(value);
137
+ },
138
+ relativeTime(value, unit = "day") {
139
+ const date = toDate(value);
140
+ const diffMs = date.getTime() - Date.now();
141
+ const divisor = RELATIVE_DIVISORS_MS[unit] ?? RELATIVE_DIVISORS_MS.day;
142
+ const rtf = new Intl.RelativeTimeFormat(resolvedLocale, { numeric: "auto" });
143
+ return rtf.format(Math.round(diffMs / divisor), unit);
144
+ }
145
+ };
146
+ return { ...base, ...overrides };
147
+ }
148
+
149
+ // src/Presenter.ts
150
+ var Presenter = class _Presenter {
151
+ constructor(data, context) {
152
+ this.data = data;
153
+ this.context = context ?? {};
154
+ }
155
+ /**
156
+ * Intl-backed date/number/currency/relative-time formatting, aware of
157
+ * `context.locale`. Override globally via `configurePresenter`.
158
+ */
159
+ get format() {
160
+ const locale = this.context?.locale;
161
+ return createFormatAdapter(locale);
162
+ }
163
+ /**
164
+ * Translate a key via the globally configured `translate` function
165
+ * (wire up i18next / next-intl / FormatJS / etc. once with
166
+ * `configurePresenter({ translate })`). Falls back to returning the key
167
+ * itself if nothing is configured, so it's always safe to call.
168
+ */
169
+ t(key, params) {
170
+ const config = getPresenterConfig();
171
+ const translate = config.translate ?? defaultTranslate;
172
+ const locale = this.context?.locale;
173
+ return translate(key, params, locale ?? config.locale);
174
+ }
175
+ /**
176
+ * Instantiate a presenter for a single entity and wrap it in a Proxy so
177
+ * every raw attribute not shadowed by a getter passes through
178
+ * automatically.
179
+ */
180
+ static present(data, context, options) {
181
+ const presenter = new this(data, context);
182
+ return createDataProxy(presenter, data, options);
183
+ }
184
+ /** Present a collection: `UserPresenter.presentMany(rawUsers)`. */
185
+ static presentMany(dataList, context, options) {
186
+ return dataList.map(
187
+ (item) => (
188
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
189
+ this.present(item, context, options)
190
+ )
191
+ );
192
+ }
193
+ /**
194
+ * Resolve every async computed property and merge it with the sync
195
+ * getters into a single plain, JSON-serializable object.
196
+ *
197
+ * JavaScript has no `async get` syntax, so async computed properties are
198
+ * plain `async` methods instead:
199
+ *
200
+ * ```ts
201
+ * class UserPresenter extends Presenter<User> {
202
+ * async profileScore() {
203
+ * return calculateScore(this.data);
204
+ * }
205
+ * }
206
+ *
207
+ * const resolved = await UserPresenter.present(user).resolve();
208
+ * resolved.profileScore; // number
209
+ * ```
210
+ *
211
+ * `resolve()` auto-detects any `async` method on the presenter — no
212
+ * extra registration needed — calls each with no arguments, and merges
213
+ * the results with `toJSON()`. A rejected async property resolves to
214
+ * `undefined` rather than failing the whole call.
215
+ */
216
+ async resolve(options) {
217
+ const asyncKeys = getAsyncMethodNames(this, _Presenter);
218
+ const resolvedEntries = await Promise.all(
219
+ asyncKeys.map(async (key) => {
220
+ try {
221
+ const method = this[key];
222
+ return [key, await method.call(this)];
223
+ } catch {
224
+ return [key, void 0];
225
+ }
226
+ })
227
+ );
228
+ return {
229
+ ...this.toJSON(options),
230
+ ...Object.fromEntries(resolvedEntries)
231
+ };
232
+ }
233
+ /**
234
+ * Serialize the presenter's own computed getters (never the raw
235
+ * `data`/`context`) to a plain object. Safe to return from a Next.js
236
+ * Server Action or pass as props to a Client Component.
237
+ */
238
+ toJSON(options) {
239
+ return serializePresenter(this, _Presenter, options);
240
+ }
241
+ /** `presenter.only("id", "fullName", "avatarUrl")` */
242
+ only(...keys) {
243
+ return this.toJSON({ only: keys });
244
+ }
245
+ /** `presenter.except("internalNotes")` */
246
+ except(...keys) {
247
+ return this.toJSON({ except: keys });
248
+ }
249
+ };
250
+
251
+ // src/decorate.ts
252
+ function decorate(data, PresenterClass, context, options) {
253
+ return PresenterClass.present(data, context, options);
254
+ }
255
+ function decorateMany(dataList, PresenterClass, context, options) {
256
+ return dataList.map((item) => decorate(item, PresenterClass, context, options));
257
+ }
258
+
259
+ export { Presenter, configurePresenter, createFormatAdapter, decorate, decorateMany, getPresenterConfig };
260
+ //# sourceMappingURL=index.js.map
261
+ //# sourceMappingURL=index.js.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.js","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/react.cjs ADDED
@@ -0,0 +1,30 @@
1
+ "use client";
2
+ 'use strict';
3
+
4
+ var react = require('react');
5
+
6
+ function usePresenter(PresenterClass, data, context, options, deps = [data, context]) {
7
+ return react.useMemo(
8
+ () => (
9
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
10
+ PresenterClass.present(data, context, options)
11
+ ),
12
+ // eslint-disable-next-line react-hooks/exhaustive-deps
13
+ deps
14
+ );
15
+ }
16
+ function usePresenterMany(PresenterClass, dataList, context, options, deps = [dataList, context]) {
17
+ return react.useMemo(
18
+ () => (
19
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
20
+ PresenterClass.presentMany(dataList, context, options)
21
+ ),
22
+ // eslint-disable-next-line react-hooks/exhaustive-deps
23
+ deps
24
+ );
25
+ }
26
+
27
+ exports.usePresenter = usePresenter;
28
+ exports.usePresenterMany = usePresenterMany;
29
+ //# sourceMappingURL=react.cjs.map
30
+ //# sourceMappingURL=react.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/react.ts"],"names":["useMemo"],"mappings":";;;;AAsBO,SAAS,YAAA,CACd,gBACA,IAAA,EACA,OAAA,EACA,SACA,IAAA,GAA2B,CAAC,IAAA,EAAM,OAAO,CAAA,EAClC;AACP,EAAA,OAAOA,aAAA;AAAA,IACL;AAAA;AAAA,MAEG,cAAA,CAAuB,OAAA,CAAQ,IAAA,EAAM,OAAA,EAAS,OAAO;AAAA,KAAA;AAAA;AAAA,IAExD;AAAA,GACF;AACF;AAGO,SAAS,gBAAA,CACd,gBACA,QAAA,EACA,OAAA,EACA,SACA,IAAA,GAA2B,CAAC,QAAA,EAAU,OAAO,CAAA,EAC/B;AACd,EAAA,OAAOA,aAAA;AAAA,IACL;AAAA;AAAA,MAEG,cAAA,CAAuB,WAAA,CAAY,QAAA,EAAU,OAAA,EAAS,OAAO;AAAA,KAAA;AAAA;AAAA,IAEhE;AAAA,GACF;AACF","file":"react.cjs","sourcesContent":["\"use client\";\n\nimport { useMemo } from \"react\";\nimport type { Presenter } from \"./Presenter\";\nimport type { PresentOptions, PresenterConstructor } from \"./types\";\n\n/**\n * Memoize `Presenter.present` across re-renders, so the Proxy + presenter\n * instance aren't rebuilt every render. Recomputes only when `deps`\n * change (defaults to `[data, context]`, matching most usage).\n *\n * ```tsx\n * function ProductCard({ product }: { product: Product }) {\n * const presenter = usePresenter(ProductPresenter, product);\n * return <h2>{presenter.title}</h2>;\n * }\n * ```\n *\n * This is a client-only convenience — presenters themselves have no\n * React dependency and work directly in Server Components without this\n * hook; just call `ProductPresenter.present(product)` there.\n */\nexport function usePresenter<T, C, P extends Presenter<T, C>>(\n PresenterClass: PresenterConstructor<T, C, P>,\n data: T,\n context?: C,\n options?: PresentOptions,\n deps: readonly unknown[] = [data, context]\n): P & T {\n return useMemo(\n () =>\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n (PresenterClass as any).present(data, context, options) as P & T,\n // eslint-disable-next-line react-hooks/exhaustive-deps\n deps\n );\n}\n\n/** `presentMany` equivalent of `usePresenter`. */\nexport function usePresenterMany<T, C, P extends Presenter<T, C>>(\n PresenterClass: PresenterConstructor<T, C, P>,\n dataList: readonly T[],\n context?: C,\n options?: PresentOptions,\n deps: readonly unknown[] = [dataList, context]\n): Array<P & T> {\n return useMemo(\n () =>\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n (PresenterClass as any).presentMany(dataList, context, options) as Array<P & T>,\n // eslint-disable-next-line react-hooks/exhaustive-deps\n deps\n );\n}\n"]}
@@ -0,0 +1,142 @@
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
+
46
+ /**
47
+ * Base class for all presenters/decorators.
48
+ *
49
+ * ```ts
50
+ * class ProductPresenter extends Presenter<Product> {
51
+ * get isNew() {
52
+ * const days = (Date.now() - new Date(this.data.created_at).getTime()) / 86_400_000;
53
+ * return days <= 7;
54
+ * }
55
+ * }
56
+ *
57
+ * const product = ProductPresenter.present(rawProduct);
58
+ * product.isNew; // presenter getter
59
+ * product.seller; // passed through from rawProduct automatically
60
+ * ```
61
+ */
62
+ declare abstract class Presenter<T, C = AnyRecord> {
63
+ readonly data: T;
64
+ readonly context: C;
65
+ constructor(data: T, context?: C);
66
+ /**
67
+ * Intl-backed date/number/currency/relative-time formatting, aware of
68
+ * `context.locale`. Override globally via `configurePresenter`.
69
+ */
70
+ protected get format(): FormatAdapter;
71
+ /**
72
+ * Translate a key via the globally configured `translate` function
73
+ * (wire up i18next / next-intl / FormatJS / etc. once with
74
+ * `configurePresenter({ translate })`). Falls back to returning the key
75
+ * itself if nothing is configured, so it's always safe to call.
76
+ */
77
+ protected t(key: string, params?: AnyRecord): string;
78
+ /**
79
+ * Instantiate a presenter for a single entity and wrap it in a Proxy so
80
+ * every raw attribute not shadowed by a getter passes through
81
+ * automatically.
82
+ */
83
+ 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>;
84
+ /** Present a collection: `UserPresenter.presentMany(rawUsers)`. */
85
+ 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>>;
86
+ /**
87
+ * Resolve every async computed property and merge it with the sync
88
+ * getters into a single plain, JSON-serializable object.
89
+ *
90
+ * JavaScript has no `async get` syntax, so async computed properties are
91
+ * plain `async` methods instead:
92
+ *
93
+ * ```ts
94
+ * class UserPresenter extends Presenter<User> {
95
+ * async profileScore() {
96
+ * return calculateScore(this.data);
97
+ * }
98
+ * }
99
+ *
100
+ * const resolved = await UserPresenter.present(user).resolve();
101
+ * resolved.profileScore; // number
102
+ * ```
103
+ *
104
+ * `resolve()` auto-detects any `async` method on the presenter — no
105
+ * extra registration needed — calls each with no arguments, and merges
106
+ * the results with `toJSON()`. A rejected async property resolves to
107
+ * `undefined` rather than failing the whole call.
108
+ */
109
+ resolve(this: Presenter<T, C>, options?: ToJSONOptions): Promise<Record<string, unknown>>;
110
+ /**
111
+ * Serialize the presenter's own computed getters (never the raw
112
+ * `data`/`context`) to a plain object. Safe to return from a Next.js
113
+ * Server Action or pass as props to a Client Component.
114
+ */
115
+ toJSON(options?: ToJSONOptions): Record<string, unknown>;
116
+ /** `presenter.only("id", "fullName", "avatarUrl")` */
117
+ only(...keys: string[]): Record<string, unknown>;
118
+ /** `presenter.except("internalNotes")` */
119
+ except(...keys: string[]): Record<string, unknown>;
120
+ }
121
+
122
+ /**
123
+ * Memoize `Presenter.present` across re-renders, so the Proxy + presenter
124
+ * instance aren't rebuilt every render. Recomputes only when `deps`
125
+ * change (defaults to `[data, context]`, matching most usage).
126
+ *
127
+ * ```tsx
128
+ * function ProductCard({ product }: { product: Product }) {
129
+ * const presenter = usePresenter(ProductPresenter, product);
130
+ * return <h2>{presenter.title}</h2>;
131
+ * }
132
+ * ```
133
+ *
134
+ * This is a client-only convenience — presenters themselves have no
135
+ * React dependency and work directly in Server Components without this
136
+ * hook; just call `ProductPresenter.present(product)` there.
137
+ */
138
+ declare function usePresenter<T, C, P extends Presenter<T, C>>(PresenterClass: PresenterConstructor<T, C, P>, data: T, context?: C, options?: PresentOptions, deps?: readonly unknown[]): P & T;
139
+ /** `presentMany` equivalent of `usePresenter`. */
140
+ declare function usePresenterMany<T, C, P extends Presenter<T, C>>(PresenterClass: PresenterConstructor<T, C, P>, dataList: readonly T[], context?: C, options?: PresentOptions, deps?: readonly unknown[]): Array<P & T>;
141
+
142
+ export { usePresenter, usePresenterMany };
@@ -0,0 +1,142 @@
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
+
46
+ /**
47
+ * Base class for all presenters/decorators.
48
+ *
49
+ * ```ts
50
+ * class ProductPresenter extends Presenter<Product> {
51
+ * get isNew() {
52
+ * const days = (Date.now() - new Date(this.data.created_at).getTime()) / 86_400_000;
53
+ * return days <= 7;
54
+ * }
55
+ * }
56
+ *
57
+ * const product = ProductPresenter.present(rawProduct);
58
+ * product.isNew; // presenter getter
59
+ * product.seller; // passed through from rawProduct automatically
60
+ * ```
61
+ */
62
+ declare abstract class Presenter<T, C = AnyRecord> {
63
+ readonly data: T;
64
+ readonly context: C;
65
+ constructor(data: T, context?: C);
66
+ /**
67
+ * Intl-backed date/number/currency/relative-time formatting, aware of
68
+ * `context.locale`. Override globally via `configurePresenter`.
69
+ */
70
+ protected get format(): FormatAdapter;
71
+ /**
72
+ * Translate a key via the globally configured `translate` function
73
+ * (wire up i18next / next-intl / FormatJS / etc. once with
74
+ * `configurePresenter({ translate })`). Falls back to returning the key
75
+ * itself if nothing is configured, so it's always safe to call.
76
+ */
77
+ protected t(key: string, params?: AnyRecord): string;
78
+ /**
79
+ * Instantiate a presenter for a single entity and wrap it in a Proxy so
80
+ * every raw attribute not shadowed by a getter passes through
81
+ * automatically.
82
+ */
83
+ 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>;
84
+ /** Present a collection: `UserPresenter.presentMany(rawUsers)`. */
85
+ 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>>;
86
+ /**
87
+ * Resolve every async computed property and merge it with the sync
88
+ * getters into a single plain, JSON-serializable object.
89
+ *
90
+ * JavaScript has no `async get` syntax, so async computed properties are
91
+ * plain `async` methods instead:
92
+ *
93
+ * ```ts
94
+ * class UserPresenter extends Presenter<User> {
95
+ * async profileScore() {
96
+ * return calculateScore(this.data);
97
+ * }
98
+ * }
99
+ *
100
+ * const resolved = await UserPresenter.present(user).resolve();
101
+ * resolved.profileScore; // number
102
+ * ```
103
+ *
104
+ * `resolve()` auto-detects any `async` method on the presenter — no
105
+ * extra registration needed — calls each with no arguments, and merges
106
+ * the results with `toJSON()`. A rejected async property resolves to
107
+ * `undefined` rather than failing the whole call.
108
+ */
109
+ resolve(this: Presenter<T, C>, options?: ToJSONOptions): Promise<Record<string, unknown>>;
110
+ /**
111
+ * Serialize the presenter's own computed getters (never the raw
112
+ * `data`/`context`) to a plain object. Safe to return from a Next.js
113
+ * Server Action or pass as props to a Client Component.
114
+ */
115
+ toJSON(options?: ToJSONOptions): Record<string, unknown>;
116
+ /** `presenter.only("id", "fullName", "avatarUrl")` */
117
+ only(...keys: string[]): Record<string, unknown>;
118
+ /** `presenter.except("internalNotes")` */
119
+ except(...keys: string[]): Record<string, unknown>;
120
+ }
121
+
122
+ /**
123
+ * Memoize `Presenter.present` across re-renders, so the Proxy + presenter
124
+ * instance aren't rebuilt every render. Recomputes only when `deps`
125
+ * change (defaults to `[data, context]`, matching most usage).
126
+ *
127
+ * ```tsx
128
+ * function ProductCard({ product }: { product: Product }) {
129
+ * const presenter = usePresenter(ProductPresenter, product);
130
+ * return <h2>{presenter.title}</h2>;
131
+ * }
132
+ * ```
133
+ *
134
+ * This is a client-only convenience — presenters themselves have no
135
+ * React dependency and work directly in Server Components without this
136
+ * hook; just call `ProductPresenter.present(product)` there.
137
+ */
138
+ declare function usePresenter<T, C, P extends Presenter<T, C>>(PresenterClass: PresenterConstructor<T, C, P>, data: T, context?: C, options?: PresentOptions, deps?: readonly unknown[]): P & T;
139
+ /** `presentMany` equivalent of `usePresenter`. */
140
+ declare function usePresenterMany<T, C, P extends Presenter<T, C>>(PresenterClass: PresenterConstructor<T, C, P>, dataList: readonly T[], context?: C, options?: PresentOptions, deps?: readonly unknown[]): Array<P & T>;
141
+
142
+ export { usePresenter, usePresenterMany };
package/dist/react.js ADDED
@@ -0,0 +1,27 @@
1
+ "use client";
2
+ import { useMemo } from 'react';
3
+
4
+ function usePresenter(PresenterClass, data, context, options, deps = [data, context]) {
5
+ return useMemo(
6
+ () => (
7
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
8
+ PresenterClass.present(data, context, options)
9
+ ),
10
+ // eslint-disable-next-line react-hooks/exhaustive-deps
11
+ deps
12
+ );
13
+ }
14
+ function usePresenterMany(PresenterClass, dataList, context, options, deps = [dataList, context]) {
15
+ return useMemo(
16
+ () => (
17
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
18
+ PresenterClass.presentMany(dataList, context, options)
19
+ ),
20
+ // eslint-disable-next-line react-hooks/exhaustive-deps
21
+ deps
22
+ );
23
+ }
24
+
25
+ export { usePresenter, usePresenterMany };
26
+ //# sourceMappingURL=react.js.map
27
+ //# sourceMappingURL=react.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/react.ts"],"names":[],"mappings":";;AAsBO,SAAS,YAAA,CACd,gBACA,IAAA,EACA,OAAA,EACA,SACA,IAAA,GAA2B,CAAC,IAAA,EAAM,OAAO,CAAA,EAClC;AACP,EAAA,OAAO,OAAA;AAAA,IACL;AAAA;AAAA,MAEG,cAAA,CAAuB,OAAA,CAAQ,IAAA,EAAM,OAAA,EAAS,OAAO;AAAA,KAAA;AAAA;AAAA,IAExD;AAAA,GACF;AACF;AAGO,SAAS,gBAAA,CACd,gBACA,QAAA,EACA,OAAA,EACA,SACA,IAAA,GAA2B,CAAC,QAAA,EAAU,OAAO,CAAA,EAC/B;AACd,EAAA,OAAO,OAAA;AAAA,IACL;AAAA;AAAA,MAEG,cAAA,CAAuB,WAAA,CAAY,QAAA,EAAU,OAAA,EAAS,OAAO;AAAA,KAAA;AAAA;AAAA,IAEhE;AAAA,GACF;AACF","file":"react.js","sourcesContent":["\"use client\";\n\nimport { useMemo } from \"react\";\nimport type { Presenter } from \"./Presenter\";\nimport type { PresentOptions, PresenterConstructor } from \"./types\";\n\n/**\n * Memoize `Presenter.present` across re-renders, so the Proxy + presenter\n * instance aren't rebuilt every render. Recomputes only when `deps`\n * change (defaults to `[data, context]`, matching most usage).\n *\n * ```tsx\n * function ProductCard({ product }: { product: Product }) {\n * const presenter = usePresenter(ProductPresenter, product);\n * return <h2>{presenter.title}</h2>;\n * }\n * ```\n *\n * This is a client-only convenience — presenters themselves have no\n * React dependency and work directly in Server Components without this\n * hook; just call `ProductPresenter.present(product)` there.\n */\nexport function usePresenter<T, C, P extends Presenter<T, C>>(\n PresenterClass: PresenterConstructor<T, C, P>,\n data: T,\n context?: C,\n options?: PresentOptions,\n deps: readonly unknown[] = [data, context]\n): P & T {\n return useMemo(\n () =>\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n (PresenterClass as any).present(data, context, options) as P & T,\n // eslint-disable-next-line react-hooks/exhaustive-deps\n deps\n );\n}\n\n/** `presentMany` equivalent of `usePresenter`. */\nexport function usePresenterMany<T, C, P extends Presenter<T, C>>(\n PresenterClass: PresenterConstructor<T, C, P>,\n dataList: readonly T[],\n context?: C,\n options?: PresentOptions,\n deps: readonly unknown[] = [dataList, context]\n): Array<P & T> {\n return useMemo(\n () =>\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n (PresenterClass as any).presentMany(dataList, context, options) as Array<P & T>,\n // eslint-disable-next-line react-hooks/exhaustive-deps\n deps\n );\n}\n"]}