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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 react-presenter contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,491 @@
1
+ # react-presenter
2
+
3
+ Clean presentation logic for React and Next.js applications with TypeScript presenters. The core is framework-agnostic and also works with NestJS, Angular, Vue.js, and Node.js.
4
+
5
+ Keep formatting, derived booleans, i18n, and authorization-aware fields
6
+ out of your JSX and off your raw API/DB entities, put them in a small
7
+ class next to the component that uses them.
8
+
9
+ ```tsx
10
+ class ProductPresenter extends Presenter<Product> {
11
+ get isNew() {
12
+ const days =
13
+ (Date.now() - new Date(this.data.created_at).getTime()) / 86_400_000;
14
+ return days <= 7;
15
+ }
16
+
17
+ get formattedPrice() {
18
+ return this.format.currency(this.data.price, this.data.currency);
19
+ }
20
+ }
21
+
22
+ function ProductCard({ product }: { product: Product }) {
23
+ const p = ProductPresenter.present(product);
24
+ return (
25
+ <article>
26
+ <h2>{p.title}</h2> {/* passed through from `product` automatically */}
27
+ <span>{p.formattedPrice}</span>
28
+ {p.isNew && <Badge>NEW</Badge>}
29
+ </article>
30
+ );
31
+ }
32
+ ```
33
+
34
+ ---
35
+
36
+ ## Table of contents
37
+
38
+ - [Install](#install)
39
+ - [Quick start](#quick-start)
40
+ - [Core concepts](#core-concepts)
41
+ - [Use cases](#use-cases)
42
+ 1. [Generic `Presenter<T>` base + automatic attribute mapping](#1-generic-presentert-base--automatic-attribute-mapping)
43
+ 2. [Collections](#2-collections)
44
+ 3. [Server-action / RSC friendly output](#3-server-action--rsc-friendly-output)
45
+ 4. [Formatting & localization](#4-formatting--localization)
46
+ 5. [Serialization: `toJSON`, `only`, `except`](#5-serialization-tojson-only-except)
47
+ 6. [Context](#6-context)
48
+ 7. [Authorization-aware presentation](#7-authorization-aware-presentation)
49
+ 8. [Async computed properties](#8-async-computed-properties)
50
+ 9. [Functional `decorate()`](#9-functional-decorate)
51
+ 10. [Automatic attribute mapping (detail)](#10-automatic-attribute-mapping-detail)
52
+ - [React integration](#react-integration)
53
+ - [Next.js: Server Components & Server Actions](#nextjs-server-components--server-actions)
54
+ - [API reference](#api-reference)
55
+ - [A note on `async get`](#a-note-on-async-get)
56
+ - [Testing](#testing)
57
+ - [Project layout](#project-layout)
58
+
59
+ ---
60
+
61
+ ## Install
62
+
63
+ ```bash
64
+ npm install react-presenter
65
+ ```
66
+
67
+ React is an **optional peer dependency**, the core `Presenter` class has
68
+ no React dependency at all and works in plain Node, NestJs, Next.js Server
69
+ Components, API routes, or anywhere else. Only `react-presenter/react`
70
+ (the `usePresenter` hook) needs React installed.
71
+
72
+ ## Quick start
73
+
74
+ ```ts
75
+ // presenters/user-presenter.ts
76
+ import { Presenter } from "react-presenter";
77
+
78
+ interface User {
79
+ id: string;
80
+ email: string;
81
+ firstName: string;
82
+ lastName: string;
83
+ createdAt: string;
84
+ }
85
+
86
+ export class UserPresenter extends Presenter<User> {
87
+ get fullName() {
88
+ return `${this.data.firstName} ${this.data.lastName}`;
89
+ }
90
+
91
+ get formattedCreatedAt() {
92
+ return this.format.date(this.data.createdAt, { dateStyle: "medium" });
93
+ }
94
+ }
95
+ ```
96
+
97
+ ```tsx
98
+ // components/user-card.tsx
99
+ import { UserPresenter } from "../presenters/user-presenter";
100
+
101
+ function UserCard({ user }: { user: User }) {
102
+ const p = UserPresenter.present(user);
103
+ return (
104
+ <div>
105
+ <h3>{p.fullName}</h3> {/* presenter getter */}
106
+ <p>{p.email}</p> {/* passed through from `user` */}
107
+ <time>{p.formattedCreatedAt}</time>
108
+ </div>
109
+ );
110
+ }
111
+ ```
112
+
113
+ ## Core concepts
114
+
115
+ - **`Presenter<T, C>`** — abstract base class. `T` is your raw data
116
+ shape, `C` is an optional context type (locale, current user, etc).
117
+ - **`.present(data, context?, options?)`** — static factory. Instantiates
118
+ your presenter and wraps it in a `Proxy` so any attribute of `data` not
119
+ shadowed by a getter is available directly.
120
+ - **`.presentMany(dataList, context?, options?)`** — same, for arrays.
121
+ - **`.toJSON()` / `.only()` / `.except()`** — serialize _only_ the
122
+ presenter's own computed getters, never the raw entity. Safe to send
123
+ across a server boundary.
124
+ - **`.resolve()`** — await every async computed property and merge the
125
+ result with `toJSON()`.
126
+
127
+ ---
128
+
129
+ ## Use cases
130
+
131
+ ### 1. Generic `Presenter<T>` base + automatic attribute mapping
132
+
133
+ ```ts
134
+ class ProductPresenter extends Presenter<Product> {
135
+ get formattedPrice() {
136
+ return this.format.currency(this.data.price, this.data.currency);
137
+ }
138
+ get isNew() {
139
+ const days =
140
+ (Date.now() - new Date(this.data.created_at).getTime()) / 86_400_000;
141
+ return days <= 7;
142
+ }
143
+ }
144
+
145
+ const product = ProductPresenter.present(rawProduct);
146
+ product.price; // ← raw pass-through, no getter needed
147
+ product.formattedPrice; // ← presenter getter
148
+ product.location; // ← raw pass-through
149
+ product.isNew; // ← presenter getter
150
+ product.seller; // ← raw pass-through (the whole nested object)
151
+ ```
152
+
153
+ ### 2. Collections
154
+
155
+ ```ts
156
+ const user = UserPresenter.present(rawUser);
157
+ const users = UserPresenter.presentMany(rawUsers);
158
+
159
+ users.map((u) => u.fullName);
160
+ ```
161
+
162
+ Context is shared across every item:
163
+
164
+ ```ts
165
+ const users = UserPresenter.presentMany(rawUsers, { currentUserId: "456" });
166
+ users[0].canEdit; // evaluated per-item against the same context
167
+ ```
168
+
169
+ ### 3. Server-action / RSC friendly output
170
+
171
+ Presenter instances use `Proxies`, so they can't be passed directly between Next.js Server and Client Components but directly works in server component. Call `.toJSON()`, `.only()`, or `.except()` first to convert the presenter into a serializable object when you pass to client component.
172
+ — see the [Next.js section](#nextjs-server-components--server-actions) below for
173
+ the full pattern.
174
+
175
+ ### 4. Formatting & localization
176
+
177
+ ```ts
178
+ class UserPresenter extends Presenter<User, { locale?: string }> {
179
+ get statusLabel() {
180
+ return this.t(`users.status.${this.data.status}`);
181
+ }
182
+ get formattedJoinDate() {
183
+ return this.format.date(this.data.createdAt, { dateStyle: "long" });
184
+ }
185
+ }
186
+ ```
187
+
188
+ `this.format` is an Intl-backed adapter (`.date`, `.number`, `.currency`,
189
+ `.relativeTime`) that automatically uses `context.locale`. `this.t(key,
190
+ params?)` calls whatever translate function you've wired up globally:
191
+
192
+ ```ts
193
+ import { configurePresenter } from "react-presenter";
194
+ import i18next from "i18next";
195
+
196
+ configurePresenter({
197
+ translate: (key, params, locale) =>
198
+ i18next.t(key, { ...params, lng: locale }),
199
+ locale: "en-US",
200
+ });
201
+ ```
202
+
203
+ Call `configurePresenter` once, near your app's entry point. Without it,
204
+ `this.t(key)` just returns `key` unchanged, so it's always safe to call.
205
+
206
+ ### 5. Serialization: `toJSON`, `only`, `except`
207
+
208
+ ```ts
209
+ const presenter = UserPresenter.present(user);
210
+
211
+ presenter.toJSON(); // every presenter getter
212
+ presenter.only("id", "fullName", "avatarUrl"); // just these, in this order
213
+ presenter.except("internalNotes"); // everything except these
214
+ presenter.toJSON({ only: ["id", "fullName"] }); // equivalent to only()
215
+ presenter.toJSON({ except: ["internalNotes"] }); // equivalent to except()
216
+ ```
217
+
218
+ `toJSON()` **only ever includes getters you defined on the presenter** —
219
+ never `data`, never `context`, never a raw attribute that's only visible
220
+ via the automatic pass-through. This is deliberate: it's what makes it
221
+ safe to return a presenter's `toJSON()` from an API route or Server
222
+ Action without worrying you've leaked the whole underlying entity.
223
+
224
+ ### 6. Context
225
+
226
+ ```ts
227
+ const presenter = UserPresenter.present(user, {
228
+ locale: "ja-JP",
229
+ currentUserId: "456",
230
+ });
231
+
232
+ class UserPresenter extends Presenter<
233
+ User,
234
+ { locale: string; currentUserId: string }
235
+ > {
236
+ get canEdit() {
237
+ return this.context.currentUserId === this.data.id;
238
+ }
239
+ get formattedDate() {
240
+ return this.format.date(this.data.createdAt);
241
+ }
242
+ }
243
+ ```
244
+
245
+ Context is just a second constructor argument — put whatever your
246
+ presentation logic depends on in there (current user, feature flags,
247
+ locale, request-scoped data, etc).
248
+
249
+ ### 7. Authorization-aware presentation
250
+
251
+ ```ts
252
+ class UserPresenter extends Presenter<
253
+ User,
254
+ { currentUser?: { can(p: string): boolean } }
255
+ > {
256
+ get showEmail() {
257
+ return this.context.currentUser?.can("users.read_email") === true;
258
+ }
259
+ get email() {
260
+ if (!this.showEmail) return undefined;
261
+ return this.data.email;
262
+ }
263
+ }
264
+ ```
265
+
266
+ If a getter **throws** instead of returning `undefined` (e.g. you prefer
267
+ to `throw new Error("not authorized")`), `toJSON()` / `only()` /
268
+ `except()` catch it and omit the key rather than failing the whole
269
+ serialization — so either style works.
270
+
271
+ ### 8. Async computed properties
272
+
273
+ ```ts
274
+ class UserPresenter extends Presenter<User> {
275
+ async profileScore() {
276
+ return calculateScore(this.data);
277
+ }
278
+ }
279
+
280
+ const presenter = UserPresenter.present(user);
281
+ const resolved = await presenter.resolve();
282
+ resolved.profileScore; // number
283
+ resolved.fullName; // sync getters are included too
284
+ ```
285
+
286
+ See [the note below](#a-note-on-async-get) on why this is a plain
287
+ `async` method rather than `async get profileScore()`.
288
+
289
+ ### 9. Functional `decorate()`
290
+
291
+ ```ts
292
+ import { decorate, decorateMany } from "react-presenter";
293
+
294
+ const user = decorate(rawUser, UserPresenter);
295
+ const users = decorateMany(rawUsers, UserPresenter, context);
296
+ ```
297
+
298
+ Identical to `UserPresenter.present(...)` / `.presentMany(...)` — pick
299
+ whichever reads better at the call site.
300
+
301
+ ### 10. Automatic attribute mapping (detail)
302
+
303
+ Given:
304
+
305
+ ```ts
306
+ type User = {
307
+ id: string;
308
+ email: string;
309
+ active: boolean;
310
+ firstName: string;
311
+ lastName: string;
312
+ };
313
+ ```
314
+
315
+ you do **not** need to write pass-through getters for every field:
316
+
317
+ ```ts
318
+ class UserPresenter extends Presenter<User> {
319
+ // nothing here yet — id/email/active/firstName/lastName all work already
320
+ }
321
+
322
+ const user = UserPresenter.present(rawUser);
323
+ user.email; // works — proxied straight through to rawUser.email
324
+ ```
325
+
326
+ `.present()` wraps the presenter instance in a `Proxy`: a property read
327
+ checks the presenter itself first (so a getter you _do_ define always
328
+ wins), then falls back to the same key on the raw data object. This is
329
+ implemented with `Proxy` `get`/`has` traps, not code generation, so it
330
+ works for any object shape without configuration.
331
+
332
+ ---
333
+
334
+ ## React integration
335
+
336
+ The core package has zero React dependency — call `.present()` directly
337
+ in a Server Component. For Client Components, `react-presenter/react`
338
+ provides hooks that memoize presenter creation across re-renders:
339
+
340
+ ```tsx
341
+ "use client";
342
+ import { usePresenter } from "react-presenter/react";
343
+
344
+ function ProductCard({ product }: { product: Product }) {
345
+ const p = usePresenter(ProductPresenter, product);
346
+ return <h2>{p.title}</h2>;
347
+ }
348
+ ```
349
+
350
+ `usePresenterMany` is the collection equivalent. Both recompute only
351
+ when `data`/`context` (or your own custom `deps` array, passed as a 5th
352
+ argument) change.
353
+
354
+ ## Next.js: Server Components & Server Actions
355
+
356
+ Presenters work directly in Server Components, no client boundary
357
+ needed if you're just rendering:
358
+
359
+ ```tsx
360
+ // app/products/[id]/page.tsx (Server Component)
361
+ import { ProductPresenter } from "@/presenters/product-presenter";
362
+
363
+ export default async function ProductPage({
364
+ params,
365
+ }: {
366
+ params: { id: string };
367
+ }) {
368
+ const product = await getProduct(params.id);
369
+ const p = ProductPresenter.present(product);
370
+ return (
371
+ <h1>
372
+ {p.title} — {p.formattedPrice}
373
+ </h1>
374
+ );
375
+ }
376
+ ```
377
+
378
+ If you need to **pass presented data across a server/client boundary**
379
+ (props into a Client Component, or a Server Action's return value),
380
+ serialize first — the Proxy-wrapped instance itself is not
381
+ serializable:
382
+
383
+ ```tsx
384
+ // app/products/[id]/page.tsx
385
+ const p = ProductPresenter.present(product);
386
+ return <ProductCardClient product={p.toJSON()} />; // plain object, safe
387
+ ```
388
+
389
+ ```ts
390
+ // actions/update-user.ts
391
+ "use server";
392
+ import { UserPresenter } from "@/presenters/user-presenter";
393
+
394
+ export async function updateUser(id: string, data: FormData) {
395
+ const user = await db.user.update({
396
+ where: { id },
397
+ data: { name: data.get("name") as string },
398
+ });
399
+
400
+ // Return only what the client needs, never the raw entity:
401
+ return UserPresenter.present(user).only("id", "fullName", "avatarUrl");
402
+ }
403
+ ```
404
+
405
+ This is also what makes `toJSON()`/`only()`/`except()` valuable even
406
+ outside Next.js: they're a deliberate allowlist between your database
407
+ entity and anything that leaves the server.
408
+
409
+ ---
410
+
411
+ ## API reference
412
+
413
+ ### `class Presenter<T, C = Record<string, unknown>>`
414
+
415
+ | Member | Description |
416
+ | ------------------------------------ | ------------------------------------------------------------------------------------------ |
417
+ | `constructor(data: T, context?: C)` | Usually called for you via `.present()`. |
418
+ | `this.data: T` | The raw entity. |
419
+ | `this.context: C` | Whatever you passed as context (`{}` if omitted). |
420
+ | `this.format` _(protected)_ | Intl-backed `{ date, number, currency, relativeTime }`, locale-aware via `context.locale`. |
421
+ | `this.t(key, params?)` _(protected)_ | Calls the globally configured `translate` function. |
422
+ | `.toJSON(options?)` | Plain object of presenter-defined getters only. |
423
+ | `.only(...keys)` | Shorthand for `toJSON({ only: keys })`. |
424
+ | `.except(...keys)` | Shorthand for `toJSON({ except: keys })`. |
425
+ | `.resolve(options?)` | `Promise<object>` — awaits every `async` method and merges with `toJSON(options)`. |
426
+
427
+ ### Static methods
428
+
429
+ | Method | Description |
430
+ | ----------------------------------------------------- | -------------------------------------------------------------- |
431
+ | `Presenter.present(data, context?, options?)` | Instantiate + wrap in the pass-through Proxy. Returns `P & T`. |
432
+ | `Presenter.presentMany(dataList, context?, options?)` | Array version of `present`. |
433
+
434
+ `options: { memoize?: boolean }` — when `true`, each getter's value is
435
+ cached the first time it's read on a given instance (per-property, via
436
+ an internal `WeakMap`). Off by default.
437
+
438
+ ### Top-level exports (`react-presenter`)
439
+
440
+ - `Presenter` — the base class.
441
+ - `decorate(data, PresenterClass, context?, options?)` / `decorateMany(...)`
442
+ - `configurePresenter({ translate?, locale?, formatters? })`
443
+ - `getPresenterConfig()`
444
+ - `createFormatAdapter(locale?)` — build a standalone formatter, e.g. for use outside a presenter.
445
+ - Types: `PresentOptions`, `ToJSONOptions`, `PresenterConstructor`, `FormatAdapter`, `TranslateFn`, `PresenterGlobalConfig`, `AnyRecord`.
446
+
447
+ ### `react-presenter/react`
448
+
449
+ - `usePresenter(PresenterClass, data, context?, options?, deps?)`
450
+ - `usePresenterMany(PresenterClass, dataList, context?, options?, deps?)`
451
+
452
+ ---
453
+
454
+ ## A note on `async`
455
+
456
+ The implemented API keeps the spirit of "async computed
457
+ property" but as a plain `async` method (no `get`):
458
+
459
+ ```ts
460
+ class UserPresenter extends Presenter<User> {
461
+ async profileScore() {
462
+ return calculateScore(this.data);
463
+ }
464
+ }
465
+ ```
466
+
467
+ `.resolve()` auto-detects every `async` method anywhere on the
468
+ presenter's prototype chain (no extra registration/decorator needed),
469
+ calls each with no arguments, and merges the resolved values with
470
+ `toJSON()`. If one rejects, it resolves to `undefined` in the output
471
+ instead of failing the whole call.
472
+
473
+ ---
474
+
475
+ ## Testing
476
+
477
+ The package ships with a full Vitest suite covering every use case
478
+ above: automatic attribute pass-through & override, memoization,
479
+ collections & shared context, `toJSON`/`only`/`except`, `resolve()`,
480
+ and context-driven authorization/i18n/formatting.
481
+
482
+ ```bash
483
+ npm install
484
+ npm run typecheck # tsc --noEmit
485
+ npm run build # tsup → dist/{index,react}.{js,cjs,d.ts}
486
+ npm test # vitest run
487
+ ```
488
+
489
+ ## Contributing OR Fix a bug?
490
+
491
+ Please read the [Contributing Guidelines](https://github.com/salmanx/react-presenter/blob/main/CONTRIBUTING.md).