api-paginate 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,284 @@
1
+ # api-paginate
2
+
3
+ Paginate arrays in **Node** (and browsers). In-memory pagination for arrays. Use it in your return when sending data to users—in controllers, API routes, and try/catch blocks. Works with any array, regardless of ORM or database.
4
+
5
+ Returns JSON with `data`, `meta`, and `links`—ready to send. Configure once, pass only `route`.
6
+
7
+ ### Features
8
+
9
+ - **Framework-agnostic** — Use with Express, Next.js, Fastify, or plain Node
10
+ - **ORM-agnostic** — Works with Mongoose, Prisma, Sequelize, raw SQL, or plain arrays
11
+ - **JSON output** — `data`, `meta`, and `links` (familiar shape for API responses)
12
+ - **Configure once** — Set `baseUrl` at startup; use only `route` in return statements
13
+ - **Client & server** — Works in Node and browsers (auto-detects `window.location.origin`)
14
+ - **TypeScript** — Types included
15
+ - **Small** — No database queries, no heavy dependencies
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ npm install api-paginate
21
+ ```
22
+
23
+ The package supports both **CommonJS** (`require`) and **ESM** (`import`). Use `require('api-paginate')` in Node CommonJS and `import { ... } from 'api-paginate'` in ESM or TypeScript.
24
+
25
+ ## Usage
26
+
27
+ ### 1. Configure once at startup
28
+
29
+ ```javascript
30
+ // CommonJS
31
+ const { configure } = require('api-paginate');
32
+
33
+ // ESM / TypeScript
34
+ import { configure } from 'api-paginate';
35
+
36
+ configure({ baseUrl: process.env.API_BASE_URL || 'https://myserver.com' });
37
+ ```
38
+
39
+ ### 2. Use in your return—only route, never baseUrl
40
+
41
+ ```javascript
42
+ // CommonJS
43
+ const { paginate, paginateFromRequest } = require('api-paginate');
44
+
45
+ // ESM / TypeScript
46
+ import { paginate, paginateFromRequest } from 'api-paginate';
47
+
48
+ // paginate:
49
+ return res.json(paginate(docs, { route: '/users', current_page: 1, per_page: 20 }));
50
+
51
+ // paginateFromRequest (page from req.query):
52
+ return res.json(paginateFromRequest(req, users, { route: '/users', per_page: 15 }));
53
+ ```
54
+
55
+ ### Express example
56
+
57
+ Both CommonJS and ESM are supported:
58
+
59
+ ```javascript
60
+ // CommonJS
61
+ const { paginateFromRequest, configure } = require('api-paginate');
62
+
63
+ // ESM / TypeScript
64
+ import { paginateFromRequest, configure } from 'api-paginate';
65
+
66
+ configure({ baseUrl: process.env.API_BASE_URL });
67
+
68
+ app.get('/users', async (req, res) => {
69
+ try {
70
+ const users = await User.find().lean();
71
+ res.json(paginateFromRequest(req, users, { route: '/users', per_page: 15 }));
72
+ } catch (err) {
73
+ res.status(500).json({ error: err.message });
74
+ }
75
+ });
76
+ ```
77
+
78
+
79
+ Base URL is read from config—never pass it in return statements. In the browser, `route` uses `window.location.origin` if you skip `configure`.
80
+
81
+ ### When to use `paginate` vs `paginateFromRequest`
82
+
83
+ | Use | When |
84
+ |-----|------|
85
+ | `paginate` | You have the page number (e.g. from `req.query`, search params, or state) |
86
+ | `paginateFromRequest` | Express/Node backend: it reads `page` from `req.query` and builds `baseUrl` from `req` automatically |
87
+
88
+ ### Next.js App Router example
89
+
90
+ ```javascript
91
+ // app/api/users/route.js
92
+ import { paginate } from 'api-paginate';
93
+
94
+ export async function GET(request) {
95
+ const { searchParams } = new URL(request.url);
96
+ const users = await User.find().lean();
97
+ const result = paginate(users, {
98
+ current_page: parseInt(searchParams.get('page') || '1'),
99
+ per_page: 15,
100
+ route: '/api/users',
101
+ });
102
+ return Response.json(result);
103
+ }
104
+ ```
105
+
106
+ ### Error handling
107
+
108
+ Paginator throws `PaginatorError` when validation fails (invalid data, unsafe `pageParam`, etc.):
109
+
110
+ ```javascript
111
+ // CommonJS: const { paginate, PaginatorError } = require('api-paginate');
112
+ // ESM / TypeScript:
113
+ import { paginate, PaginatorError } from 'api-paginate';
114
+
115
+ try {
116
+ return res.json(paginate(docs, { route: '/users', per_page: 20 }));
117
+ } catch (err) {
118
+ if (err instanceof PaginatorError) {
119
+ return res.status(400).json({ error: err.message });
120
+ }
121
+ throw err;
122
+ }
123
+ ```
124
+
125
+ ## Options
126
+
127
+ | Parameter | Type | Default | Description |
128
+ |-----------|------|---------|-------------|
129
+ | `current_page` | number | 1 | Current page (1-based) |
130
+ | `per_page` | number | 15 | Items per page |
131
+ | `route` | string | - | Route for links (e.g. `/api/users`); combined with configured baseUrl or auto-detected origin |
132
+ | `baseUrl` | string | - | Override: full base URL (e.g. `https://api.example.com/users`); use route + configure instead |
133
+ | `path` | string | - | Deprecated alias for route |
134
+ | `pageParam` | string | 'page' | Query param name for page links (alphanumeric + underscore only; safe against injection) |
135
+
136
+ ### Config (`configure()`)
137
+
138
+ | Parameter | Description |
139
+ |-----------|-------------|
140
+ | `baseUrl` | Application origin (e.g. `https://api.example.com`) |
141
+ | `per_page` | Default items per page |
142
+ | `pageParam` | Default query param name |
143
+ | `route` | Default route when omitted in `paginate()` |
144
+
145
+ ## Response shape
146
+
147
+ ```json
148
+ {
149
+ "data": [{ "id": 1, "name": "Alice" }],
150
+ "meta": {
151
+ "current_page": 2,
152
+ "per_page": 15,
153
+ "total": 150,
154
+ "total_pages": 10,
155
+ "has_next": true,
156
+ "has_prev": true,
157
+ "from": 16,
158
+ "to": 30
159
+ },
160
+ "links": {
161
+ "first": "https://api.example.com/users?page=1",
162
+ "prev": "https://api.example.com/users?page=1",
163
+ "next": "https://api.example.com/users?page=3",
164
+ "last": "https://api.example.com/users?page=10"
165
+ }
166
+ }
167
+ ```
168
+
169
+ - **`data`** — Slice of items for the current page
170
+ - **`meta`** — Pagination metadata; `from` and `to` are 1-based (e.g. "items 16–30 of 47")
171
+ - **`links`** — `first`, `prev`, `next`, `last` URLs; `null` when not applicable (e.g. `prev` on page 1)
172
+
173
+ ---
174
+
175
+ ## Development (this package)
176
+
177
+ From the repository root:
178
+
179
+ ```bash
180
+ npm install
181
+ npm run build # build dist (cjs + esm + types)
182
+ npm test # run tests
183
+ ```
184
+
185
+ The docs and interactive simulator live in a separate repo (see the **api-paginate-web** repository).
186
+
187
+ ---
188
+
189
+ ## Pagination Algorithm
190
+
191
+ This section describes the exact algorithm used for in-memory pagination. The implementation is deterministic and predictable.
192
+
193
+ ### Overview
194
+
195
+ The algorithm takes a plain array and returns a subset (the current page) plus metadata and optional navigation links. All indexing is **1-based** for pages but **0-based** internally for array slicing.
196
+
197
+ ### Step-by-step
198
+
199
+ #### 1. Input normalization
200
+
201
+ | Input | Rule | Example |
202
+ |-------|------|---------|
203
+ | `current_page` | `Math.max(1, current_page ?? 1)` | `0`, `-1` → `1` |
204
+ | `per_page` | `Math.max(1, per_page ?? 15)` | `0`, `-5` → `1` |
205
+
206
+ Invalid values are clamped to at least 1 so every call yields a valid page.
207
+
208
+ #### 2. Derived values
209
+
210
+ ```
211
+ total = data.length
212
+ totalPages = total === 0 ? 1 : Math.ceil(total / per_page)
213
+ currentPage = Math.min(page, totalPages)
214
+ ```
215
+
216
+ - `totalPages` is 1 when there are no items (empty result, not zero pages).
217
+ - `currentPage` is clamped so requesting page 99 with 10 total pages returns page 10 instead of an empty slice.
218
+
219
+ #### 3. Slice indices (0-based)
220
+
221
+ ```
222
+ start = (currentPage - 1) * per_page
223
+ end = start + per_page
224
+ pageData = data.slice(start, end)
225
+ ```
226
+
227
+ `Array.prototype.slice(start, end)` is used, so the range is `[start, end)` (end is exclusive).
228
+
229
+ **Example:** `total = 47`, `per_page = 10`, `current_page = 3`
230
+ - `totalPages = 5`
231
+ - `currentPage = 3`
232
+ - `start = 20`, `end = 30`
233
+ - `pageData = data[20..29]` (10 items)
234
+
235
+ #### 4. Meta
236
+
237
+ | Field | Formula | Notes |
238
+ |-------|---------|-------|
239
+ | `current_page` | `currentPage` | 1-based |
240
+ | `per_page` | per_page | Items per page |
241
+ | `total` | `data.length` | Total items |
242
+ | `total_pages` | `totalPages` | At least 1 |
243
+ | `has_next` | `currentPage < totalPages` | True if a next page exists |
244
+ | `has_prev` | `currentPage > 1` | True if a previous page exists |
245
+ | `from` | `start + 1` if page non-empty, else `null` | 1-based first item index |
246
+ | `to` | `Math.min(end, total)` if page non-empty, else `null` | 1-based last item index |
247
+
248
+ `from` and `to` use 1-based, human-readable indexing (e.g. “items 16–30 of 47”).
249
+
250
+ #### 5. Links (optional)
251
+
252
+ If `baseUrl` or `path` is provided:
253
+
254
+ - Append `?page=N` or `&page=N` depending on whether the URL already contains `?`
255
+ - Use `pageParam` (default `"page"`) for the query parameter name
256
+ - `first`, `prev`, `next`, `last` are built from `current_page` and `total_pages`
257
+ - Any link that does not apply (e.g. `prev` on page 1) is `null`
258
+
259
+ ### Edge cases
260
+
261
+ | Case | Behavior |
262
+ |------|----------|
263
+ | Empty array | `data: []`, `meta.total: 0`, `meta.total_pages: 1`, `from`/`to`: `null` |
264
+ | Page beyond last page | Clamped to last page; returns last page’s data |
265
+ | `per_page` larger than total | Single page with all items |
266
+ | `per_page = 1` | One item per page |
267
+
268
+ ### Complexity
269
+
270
+ - **Time:** O(1) for meta and links; O(`per_page`) for `Array.prototype.slice` (shallow copy of that slice).
271
+ - **Space:** O(`per_page`) for the `data` slice; O(1) extra for metadata and link strings.
272
+
273
+ ### Invariants
274
+
275
+ 1. `1 ≤ current_page ≤ total_pages` always.
276
+ 2. `pageData.length ≤ per_page`.
277
+ 3. `from` and `to` are 1-based indices, or `null` when the page is empty.
278
+ 4. `from ≤ to` when both are non-null.
279
+
280
+ ---
281
+
282
+ ## License
283
+
284
+ MIT
@@ -0,0 +1,109 @@
1
+ interface PaginateOptions {
2
+ /** Current page (1-based); use this instead of deprecated page */
3
+ current_page?: number;
4
+ /** Items per page; use this instead of deprecated perPage */
5
+ per_page?: number;
6
+ /** Base URL for link generation; if omitted, uses configured baseUrl or auto-detects in browser */
7
+ baseUrl?: string;
8
+ /** Route/path for links (e.g. '/api/users'). Combines with configured baseUrl or window.origin. */
9
+ route?: string;
10
+ /** @deprecated Use route instead. Path for link generation. */
11
+ path?: string;
12
+ /** Query param name for page (default: 'page') */
13
+ pageParam?: string;
14
+ }
15
+ interface PaginateMeta {
16
+ current_page: number;
17
+ per_page: number;
18
+ total: number;
19
+ total_pages: number;
20
+ has_next: boolean;
21
+ has_prev: boolean;
22
+ from: number | null;
23
+ to: number | null;
24
+ }
25
+ interface PaginateLinks {
26
+ first: string | null;
27
+ prev: string | null;
28
+ next: string | null;
29
+ last: string | null;
30
+ }
31
+ interface PaginatedResult<T> {
32
+ data: T[];
33
+ meta: PaginateMeta;
34
+ links: PaginateLinks;
35
+ }
36
+ /** Express-style request (subset needed for paginateFromRequest) */
37
+ interface PaginateRequest {
38
+ query?: Record<string, string | string[] | undefined>;
39
+ protocol?: string;
40
+ get?: (name: string) => string | undefined;
41
+ path?: string;
42
+ originalUrl?: string;
43
+ }
44
+
45
+ interface PaginatorConfig {
46
+ /** Application base URL (origin), e.g. https://myserver.com or http://localhost:3000 */
47
+ baseUrl?: string;
48
+ /** Default route, e.g. /api (used when paginate is called without route) */
49
+ route?: string;
50
+ /** @deprecated Use route instead */
51
+ path?: string;
52
+ /** Default page param name */
53
+ pageParam?: string;
54
+ /** Default per_page */
55
+ per_page?: number;
56
+ /** @deprecated Use per_page instead */
57
+ perPage?: number;
58
+ }
59
+ /**
60
+ * Configure default options once. These are used when not overridden per-call.
61
+ * Call at app startup, e.g. configure({ baseUrl: process.env.NEXT_PUBLIC_BASE_URL })
62
+ */
63
+ declare function configure(options: PaginatorConfig): void;
64
+ /**
65
+ * Get current config (for testing or inspection)
66
+ */
67
+ declare function getConfig(): Readonly<PaginatorConfig>;
68
+ /**
69
+ * Reset config (mainly for testing)
70
+ */
71
+ declare function resetConfig(): void;
72
+
73
+ /** Custom error for paginator validation failures */
74
+ declare class PaginatorError extends Error {
75
+ constructor(message: string);
76
+ }
77
+
78
+ /**
79
+ * Paginate an array of data with Laravel-style meta and links.
80
+ * Validates inputs and throws PaginatorError on invalid data.
81
+ */
82
+ declare function paginate<T>(data: T[], options?: PaginateOptions): PaginatedResult<T>;
83
+
84
+ interface PaginateFromRequestOptions {
85
+ /** Route/path for links (defaults to request path) */
86
+ route?: string;
87
+ /** Items per page (default: 15); use per_page instead of deprecated perPage */
88
+ per_page?: number;
89
+ /** @deprecated Use per_page instead */
90
+ perPage?: number;
91
+ /** Query param name for page (default: 'page') */
92
+ pageParam?: string;
93
+ }
94
+ /**
95
+ * Paginate data for a backend response. Use in controllers: res.json(paginateFromRequest(req, data))
96
+ *
97
+ * Extracts page from req.query, builds baseUrl from req, and returns { data, meta, links }
98
+ * ready for res.json() or Response.json().
99
+ *
100
+ * @example
101
+ * // Express
102
+ * app.get('/users', async (req, res) => {
103
+ * const users = await User.find().lean();
104
+ * res.json(paginateFromRequest(req, users, { per_page: 15 }));
105
+ * });
106
+ */
107
+ declare function paginateFromRequest<T>(request: PaginateRequest, data: T[], options?: PaginateFromRequestOptions): PaginatedResult<T>;
108
+
109
+ export { type PaginateFromRequestOptions, type PaginateLinks, type PaginateMeta, type PaginateOptions, type PaginateRequest, type PaginatedResult, type PaginatorConfig, PaginatorError, configure, getConfig, paginate, paginateFromRequest, resetConfig };
@@ -0,0 +1,109 @@
1
+ interface PaginateOptions {
2
+ /** Current page (1-based); use this instead of deprecated page */
3
+ current_page?: number;
4
+ /** Items per page; use this instead of deprecated perPage */
5
+ per_page?: number;
6
+ /** Base URL for link generation; if omitted, uses configured baseUrl or auto-detects in browser */
7
+ baseUrl?: string;
8
+ /** Route/path for links (e.g. '/api/users'). Combines with configured baseUrl or window.origin. */
9
+ route?: string;
10
+ /** @deprecated Use route instead. Path for link generation. */
11
+ path?: string;
12
+ /** Query param name for page (default: 'page') */
13
+ pageParam?: string;
14
+ }
15
+ interface PaginateMeta {
16
+ current_page: number;
17
+ per_page: number;
18
+ total: number;
19
+ total_pages: number;
20
+ has_next: boolean;
21
+ has_prev: boolean;
22
+ from: number | null;
23
+ to: number | null;
24
+ }
25
+ interface PaginateLinks {
26
+ first: string | null;
27
+ prev: string | null;
28
+ next: string | null;
29
+ last: string | null;
30
+ }
31
+ interface PaginatedResult<T> {
32
+ data: T[];
33
+ meta: PaginateMeta;
34
+ links: PaginateLinks;
35
+ }
36
+ /** Express-style request (subset needed for paginateFromRequest) */
37
+ interface PaginateRequest {
38
+ query?: Record<string, string | string[] | undefined>;
39
+ protocol?: string;
40
+ get?: (name: string) => string | undefined;
41
+ path?: string;
42
+ originalUrl?: string;
43
+ }
44
+
45
+ interface PaginatorConfig {
46
+ /** Application base URL (origin), e.g. https://myserver.com or http://localhost:3000 */
47
+ baseUrl?: string;
48
+ /** Default route, e.g. /api (used when paginate is called without route) */
49
+ route?: string;
50
+ /** @deprecated Use route instead */
51
+ path?: string;
52
+ /** Default page param name */
53
+ pageParam?: string;
54
+ /** Default per_page */
55
+ per_page?: number;
56
+ /** @deprecated Use per_page instead */
57
+ perPage?: number;
58
+ }
59
+ /**
60
+ * Configure default options once. These are used when not overridden per-call.
61
+ * Call at app startup, e.g. configure({ baseUrl: process.env.NEXT_PUBLIC_BASE_URL })
62
+ */
63
+ declare function configure(options: PaginatorConfig): void;
64
+ /**
65
+ * Get current config (for testing or inspection)
66
+ */
67
+ declare function getConfig(): Readonly<PaginatorConfig>;
68
+ /**
69
+ * Reset config (mainly for testing)
70
+ */
71
+ declare function resetConfig(): void;
72
+
73
+ /** Custom error for paginator validation failures */
74
+ declare class PaginatorError extends Error {
75
+ constructor(message: string);
76
+ }
77
+
78
+ /**
79
+ * Paginate an array of data with Laravel-style meta and links.
80
+ * Validates inputs and throws PaginatorError on invalid data.
81
+ */
82
+ declare function paginate<T>(data: T[], options?: PaginateOptions): PaginatedResult<T>;
83
+
84
+ interface PaginateFromRequestOptions {
85
+ /** Route/path for links (defaults to request path) */
86
+ route?: string;
87
+ /** Items per page (default: 15); use per_page instead of deprecated perPage */
88
+ per_page?: number;
89
+ /** @deprecated Use per_page instead */
90
+ perPage?: number;
91
+ /** Query param name for page (default: 'page') */
92
+ pageParam?: string;
93
+ }
94
+ /**
95
+ * Paginate data for a backend response. Use in controllers: res.json(paginateFromRequest(req, data))
96
+ *
97
+ * Extracts page from req.query, builds baseUrl from req, and returns { data, meta, links }
98
+ * ready for res.json() or Response.json().
99
+ *
100
+ * @example
101
+ * // Express
102
+ * app.get('/users', async (req, res) => {
103
+ * const users = await User.find().lean();
104
+ * res.json(paginateFromRequest(req, users, { per_page: 15 }));
105
+ * });
106
+ */
107
+ declare function paginateFromRequest<T>(request: PaginateRequest, data: T[], options?: PaginateFromRequestOptions): PaginatedResult<T>;
108
+
109
+ export { type PaginateFromRequestOptions, type PaginateLinks, type PaginateMeta, type PaginateOptions, type PaginateRequest, type PaginatedResult, type PaginatorConfig, PaginatorError, configure, getConfig, paginate, paginateFromRequest, resetConfig };
package/dist/index.js ADDED
@@ -0,0 +1,181 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ PaginatorError: () => PaginatorError,
24
+ configure: () => configure,
25
+ getConfig: () => getConfig,
26
+ paginate: () => paginate,
27
+ paginateFromRequest: () => paginateFromRequest,
28
+ resetConfig: () => resetConfig
29
+ });
30
+ module.exports = __toCommonJS(index_exports);
31
+
32
+ // src/config.ts
33
+ var config = {};
34
+ function configure(options) {
35
+ config = { ...config, ...options };
36
+ }
37
+ function getConfig() {
38
+ return { ...config };
39
+ }
40
+ function resetConfig() {
41
+ config = {};
42
+ }
43
+ function joinBaseAndPath(base, path) {
44
+ const b = base.replace(/\/+$/, "");
45
+ const p = path.startsWith("/") ? path : `/${path}`;
46
+ return `${b}${p}`;
47
+ }
48
+ function resolveBaseUrl(options) {
49
+ const opts = options ?? {};
50
+ if (typeof opts.baseUrl === "string" && opts.baseUrl.trim()) {
51
+ return opts.baseUrl.trim();
52
+ }
53
+ const path = (typeof opts.route === "string" ? opts.route : opts.path) ?? "";
54
+ const pathTrimmed = typeof path === "string" ? path.trim() : "";
55
+ if (pathTrimmed && config.baseUrl) {
56
+ return joinBaseAndPath(config.baseUrl.trim(), pathTrimmed);
57
+ }
58
+ if (pathTrimmed && typeof window !== "undefined" && window?.location?.origin) {
59
+ return joinBaseAndPath(window.location.origin, pathTrimmed);
60
+ }
61
+ if (pathTrimmed) return pathTrimmed;
62
+ const configRoute = config.route ?? config.path;
63
+ if (config.baseUrl && configRoute) {
64
+ return joinBaseAndPath(config.baseUrl.trim(), configRoute.trim());
65
+ }
66
+ return config.baseUrl?.trim() ?? "";
67
+ }
68
+
69
+ // src/validate.ts
70
+ var MAX_PER_PAGE = 1e4;
71
+ var SAFE_PARAM_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
72
+ var PaginatorError = class _PaginatorError extends Error {
73
+ constructor(message) {
74
+ super(message);
75
+ this.name = "PaginatorError";
76
+ Object.setPrototypeOf(this, _PaginatorError.prototype);
77
+ }
78
+ };
79
+ function toSafeInt(value, defaults) {
80
+ if (value === void 0 || value === null) return defaults.fallback;
81
+ const n = typeof value === "number" ? value : parseInt(String(value), 10);
82
+ if (!Number.isFinite(n) || n < defaults.min) return defaults.min;
83
+ if (n > defaults.max) return defaults.max;
84
+ return Math.floor(n);
85
+ }
86
+ function validatePaginateInputs(data, options) {
87
+ if (data === null || data === void 0) {
88
+ throw new PaginatorError("paginate(data, options): data is required and must be an array");
89
+ }
90
+ if (!Array.isArray(data)) {
91
+ throw new PaginatorError(
92
+ `paginate(data, options): data must be an array, got ${typeof data}`
93
+ );
94
+ }
95
+ const opts = options ?? {};
96
+ const page = toSafeInt(opts.current_page, { min: 1, max: Number.MAX_SAFE_INTEGER, fallback: 1 });
97
+ const per_page = toSafeInt(opts.per_page, { min: 1, max: MAX_PER_PAGE, fallback: 15 });
98
+ const baseUrl = resolveBaseUrl(opts);
99
+ const rawParam = opts.pageParam ?? "page";
100
+ const param = typeof rawParam === "string" ? rawParam.trim() : "page";
101
+ if (!param) {
102
+ throw new PaginatorError("paginate: pageParam cannot be empty");
103
+ }
104
+ if (!SAFE_PARAM_REGEX.test(param)) {
105
+ throw new PaginatorError(
106
+ `paginate: pageParam must be a safe query param name (letters, numbers, underscore), got "${param}"`
107
+ );
108
+ }
109
+ return {
110
+ data,
111
+ page,
112
+ per_page,
113
+ baseUrl,
114
+ pageParam: param
115
+ };
116
+ }
117
+
118
+ // src/paginate.ts
119
+ function paginate(data, options) {
120
+ const { data: arr, page, per_page, baseUrl, pageParam } = validatePaginateInputs(data, options);
121
+ const total = arr.length;
122
+ const totalPages = total === 0 ? 1 : Math.ceil(total / per_page);
123
+ const currentPage = Math.min(page, totalPages);
124
+ const start = (currentPage - 1) * per_page;
125
+ const end = start + per_page;
126
+ const pageData = arr.slice(start, end);
127
+ const meta = {
128
+ current_page: currentPage,
129
+ per_page,
130
+ total,
131
+ total_pages: totalPages,
132
+ has_next: currentPage < totalPages,
133
+ has_prev: currentPage > 1,
134
+ from: pageData.length > 0 ? start + 1 : null,
135
+ to: pageData.length > 0 ? Math.min(end, total) : null
136
+ };
137
+ const links = buildLinks(baseUrl, pageParam, meta);
138
+ return { data: pageData, meta, links };
139
+ }
140
+ var UNSAFE_URL_PREFIX = /^(javascript|data|vbscript):/i;
141
+ function buildLinks(baseUrl, param, meta) {
142
+ if (!baseUrl || UNSAFE_URL_PREFIX.test(baseUrl)) {
143
+ return { first: null, prev: null, next: null, last: null };
144
+ }
145
+ const sep = baseUrl.includes("?") ? "&" : "?";
146
+ const pageNum = (n) => String(n);
147
+ return {
148
+ first: meta.total_pages > 0 ? `${baseUrl}${sep}${encodeURIComponent(param)}=1` : null,
149
+ prev: meta.current_page > 1 ? `${baseUrl}${sep}${encodeURIComponent(param)}=${pageNum(meta.current_page - 1)}` : null,
150
+ next: meta.current_page < meta.total_pages ? `${baseUrl}${sep}${encodeURIComponent(param)}=${pageNum(meta.current_page + 1)}` : null,
151
+ last: meta.total_pages > 0 ? `${baseUrl}${sep}${encodeURIComponent(param)}=${pageNum(meta.total_pages)}` : null
152
+ };
153
+ }
154
+
155
+ // src/paginateFromRequest.ts
156
+ function paginateFromRequest(request, data, options = {}) {
157
+ const req = request;
158
+ const pageParam = options.pageParam ?? "page";
159
+ const pageStr = req.query?.[pageParam];
160
+ const page = Array.isArray(pageStr) ? Math.max(1, parseInt(pageStr[0] ?? "1", 10) || 1) : Math.max(1, parseInt(String(pageStr ?? "1"), 10) || 1);
161
+ const protocol = req.protocol ?? "http";
162
+ const host = req.get?.("host") ?? "";
163
+ const pathname = (req.originalUrl ?? req.path ?? "/").split("?")[0];
164
+ const baseUrl = host ? `${protocol}://${host}${pathname}` : "";
165
+ return paginate(data, {
166
+ current_page: page,
167
+ per_page: options.per_page ?? options.perPage ?? 15,
168
+ baseUrl: baseUrl || void 0,
169
+ route: baseUrl ? void 0 : options.route ?? pathname,
170
+ pageParam
171
+ });
172
+ }
173
+ // Annotate the CommonJS export names for ESM import in node:
174
+ 0 && (module.exports = {
175
+ PaginatorError,
176
+ configure,
177
+ getConfig,
178
+ paginate,
179
+ paginateFromRequest,
180
+ resetConfig
181
+ });
package/dist/index.mjs ADDED
@@ -0,0 +1,149 @@
1
+ // src/config.ts
2
+ var config = {};
3
+ function configure(options) {
4
+ config = { ...config, ...options };
5
+ }
6
+ function getConfig() {
7
+ return { ...config };
8
+ }
9
+ function resetConfig() {
10
+ config = {};
11
+ }
12
+ function joinBaseAndPath(base, path) {
13
+ const b = base.replace(/\/+$/, "");
14
+ const p = path.startsWith("/") ? path : `/${path}`;
15
+ return `${b}${p}`;
16
+ }
17
+ function resolveBaseUrl(options) {
18
+ const opts = options ?? {};
19
+ if (typeof opts.baseUrl === "string" && opts.baseUrl.trim()) {
20
+ return opts.baseUrl.trim();
21
+ }
22
+ const path = (typeof opts.route === "string" ? opts.route : opts.path) ?? "";
23
+ const pathTrimmed = typeof path === "string" ? path.trim() : "";
24
+ if (pathTrimmed && config.baseUrl) {
25
+ return joinBaseAndPath(config.baseUrl.trim(), pathTrimmed);
26
+ }
27
+ if (pathTrimmed && typeof window !== "undefined" && window?.location?.origin) {
28
+ return joinBaseAndPath(window.location.origin, pathTrimmed);
29
+ }
30
+ if (pathTrimmed) return pathTrimmed;
31
+ const configRoute = config.route ?? config.path;
32
+ if (config.baseUrl && configRoute) {
33
+ return joinBaseAndPath(config.baseUrl.trim(), configRoute.trim());
34
+ }
35
+ return config.baseUrl?.trim() ?? "";
36
+ }
37
+
38
+ // src/validate.ts
39
+ var MAX_PER_PAGE = 1e4;
40
+ var SAFE_PARAM_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
41
+ var PaginatorError = class _PaginatorError extends Error {
42
+ constructor(message) {
43
+ super(message);
44
+ this.name = "PaginatorError";
45
+ Object.setPrototypeOf(this, _PaginatorError.prototype);
46
+ }
47
+ };
48
+ function toSafeInt(value, defaults) {
49
+ if (value === void 0 || value === null) return defaults.fallback;
50
+ const n = typeof value === "number" ? value : parseInt(String(value), 10);
51
+ if (!Number.isFinite(n) || n < defaults.min) return defaults.min;
52
+ if (n > defaults.max) return defaults.max;
53
+ return Math.floor(n);
54
+ }
55
+ function validatePaginateInputs(data, options) {
56
+ if (data === null || data === void 0) {
57
+ throw new PaginatorError("paginate(data, options): data is required and must be an array");
58
+ }
59
+ if (!Array.isArray(data)) {
60
+ throw new PaginatorError(
61
+ `paginate(data, options): data must be an array, got ${typeof data}`
62
+ );
63
+ }
64
+ const opts = options ?? {};
65
+ const page = toSafeInt(opts.current_page, { min: 1, max: Number.MAX_SAFE_INTEGER, fallback: 1 });
66
+ const per_page = toSafeInt(opts.per_page, { min: 1, max: MAX_PER_PAGE, fallback: 15 });
67
+ const baseUrl = resolveBaseUrl(opts);
68
+ const rawParam = opts.pageParam ?? "page";
69
+ const param = typeof rawParam === "string" ? rawParam.trim() : "page";
70
+ if (!param) {
71
+ throw new PaginatorError("paginate: pageParam cannot be empty");
72
+ }
73
+ if (!SAFE_PARAM_REGEX.test(param)) {
74
+ throw new PaginatorError(
75
+ `paginate: pageParam must be a safe query param name (letters, numbers, underscore), got "${param}"`
76
+ );
77
+ }
78
+ return {
79
+ data,
80
+ page,
81
+ per_page,
82
+ baseUrl,
83
+ pageParam: param
84
+ };
85
+ }
86
+
87
+ // src/paginate.ts
88
+ function paginate(data, options) {
89
+ const { data: arr, page, per_page, baseUrl, pageParam } = validatePaginateInputs(data, options);
90
+ const total = arr.length;
91
+ const totalPages = total === 0 ? 1 : Math.ceil(total / per_page);
92
+ const currentPage = Math.min(page, totalPages);
93
+ const start = (currentPage - 1) * per_page;
94
+ const end = start + per_page;
95
+ const pageData = arr.slice(start, end);
96
+ const meta = {
97
+ current_page: currentPage,
98
+ per_page,
99
+ total,
100
+ total_pages: totalPages,
101
+ has_next: currentPage < totalPages,
102
+ has_prev: currentPage > 1,
103
+ from: pageData.length > 0 ? start + 1 : null,
104
+ to: pageData.length > 0 ? Math.min(end, total) : null
105
+ };
106
+ const links = buildLinks(baseUrl, pageParam, meta);
107
+ return { data: pageData, meta, links };
108
+ }
109
+ var UNSAFE_URL_PREFIX = /^(javascript|data|vbscript):/i;
110
+ function buildLinks(baseUrl, param, meta) {
111
+ if (!baseUrl || UNSAFE_URL_PREFIX.test(baseUrl)) {
112
+ return { first: null, prev: null, next: null, last: null };
113
+ }
114
+ const sep = baseUrl.includes("?") ? "&" : "?";
115
+ const pageNum = (n) => String(n);
116
+ return {
117
+ first: meta.total_pages > 0 ? `${baseUrl}${sep}${encodeURIComponent(param)}=1` : null,
118
+ prev: meta.current_page > 1 ? `${baseUrl}${sep}${encodeURIComponent(param)}=${pageNum(meta.current_page - 1)}` : null,
119
+ next: meta.current_page < meta.total_pages ? `${baseUrl}${sep}${encodeURIComponent(param)}=${pageNum(meta.current_page + 1)}` : null,
120
+ last: meta.total_pages > 0 ? `${baseUrl}${sep}${encodeURIComponent(param)}=${pageNum(meta.total_pages)}` : null
121
+ };
122
+ }
123
+
124
+ // src/paginateFromRequest.ts
125
+ function paginateFromRequest(request, data, options = {}) {
126
+ const req = request;
127
+ const pageParam = options.pageParam ?? "page";
128
+ const pageStr = req.query?.[pageParam];
129
+ const page = Array.isArray(pageStr) ? Math.max(1, parseInt(pageStr[0] ?? "1", 10) || 1) : Math.max(1, parseInt(String(pageStr ?? "1"), 10) || 1);
130
+ const protocol = req.protocol ?? "http";
131
+ const host = req.get?.("host") ?? "";
132
+ const pathname = (req.originalUrl ?? req.path ?? "/").split("?")[0];
133
+ const baseUrl = host ? `${protocol}://${host}${pathname}` : "";
134
+ return paginate(data, {
135
+ current_page: page,
136
+ per_page: options.per_page ?? options.perPage ?? 15,
137
+ baseUrl: baseUrl || void 0,
138
+ route: baseUrl ? void 0 : options.route ?? pathname,
139
+ pageParam
140
+ });
141
+ }
142
+ export {
143
+ PaginatorError,
144
+ configure,
145
+ getConfig,
146
+ paginate,
147
+ paginateFromRequest,
148
+ resetConfig
149
+ };
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "api-paginate",
3
+ "version": "1.0.0",
4
+ "description": "Paginate arrays for Node and browsers. Returns JSON with data, meta, and links—ready for API responses.",
5
+ "main": "dist/index.js",
6
+ "module": "dist/index.mjs",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.mjs",
12
+ "require": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": ["dist", "README.md"],
16
+ "keywords": ["pagination", "array", "node", "nodejs", "api", "express", "nextjs", "react", "angular", "json", "res.json", "meta", "links"],
17
+ "license": "MIT",
18
+ "author": "Kabanda Kpanti Michael <michaelkpantiramp@gmail.com> (https://github.com/Michael-Builds)",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/Michael-Builds/paginate-json.git"
22
+ },
23
+ "homepage": "https://github.com/Michael-Builds/paginate-json#readme",
24
+ "bugs": {
25
+ "url": "https://github.com/Michael-Builds/paginate-json/issues"
26
+ },
27
+ "scripts": {
28
+ "build": "tsup src/index.ts --format cjs,esm --dts",
29
+ "test": "vitest run",
30
+ "test:watch": "vitest",
31
+ "demo": "npm run build && npx serve -p 3000",
32
+ "fixtures": "node scripts/generate-fixtures.js",
33
+ "web": "cd web && npm run dev -- -p 3001"
34
+ },
35
+ "devDependencies": {
36
+ "@tailwindcss/postcss": "^4",
37
+ "tailwindcss": "^4",
38
+ "tsup": "^8.0.0",
39
+ "typescript": "^5.0.0",
40
+ "vitest": "^1.0.0"
41
+ }
42
+ }