@zenera/faker 1.1.20 → 1.1.22

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/page.js ADDED
@@ -0,0 +1,499 @@
1
+ import { bannerArt } from '@zenera/cli/lib';
2
+ import { basename } from 'node:path';
3
+ import { called } from "./spec.js";
4
+ // ---------------------------------------------------------------------------
5
+ // The index page
6
+ //
7
+ // What a person does first with a mock is point a browser at it, and a 404 on
8
+ // `/` tells them nothing about what the thing is serving. So the root is a
9
+ // contents page: every operation, grouped by the document it came from, in the
10
+ // order the document declared it, and a button that calls it.
11
+ //
12
+ // It is one self-contained string — nothing is fetched over a network the mock
13
+ // may well be standing in for: the stylesheet, the run dialog's script and the
14
+ // operation descriptions it works from are all inline. The banner is the CLI's
15
+ // own art, so the page and the terminal agree about what this program is
16
+ // called.
17
+ // ---------------------------------------------------------------------------
18
+ const BRAND = { head: 'Zenera', accent: 'Faker', subtitle: 'Mock API Server' };
19
+ export function indexPage(operations) {
20
+ const documents = group(operations);
21
+ const title = `${BRAND.head} ${BRAND.accent}`;
22
+ const at = new Map(operations.map((operation, i) => [operation, i]));
23
+ // `<` is escaped so no description can close this script element early.
24
+ const descriptors = JSON.stringify(operations.map(callable)).replace(/</g, '\\u003c');
25
+ return `<!doctype html>
26
+ <html lang="en">
27
+ <head>
28
+ <meta charset="utf-8">
29
+ <meta name="viewport" content="width=device-width, initial-scale=1">
30
+ <title>${esc(title)}</title>
31
+ <style>${CSS}</style>
32
+ </head>
33
+ <body>
34
+ <header>
35
+ ${banner()}
36
+ <p class="caption">${esc(BRAND.subtitle)}</p>
37
+ <p class="meta">${operations.length} operation${operations.length === 1 ? '' : 's'} &middot; ${documents.length} document${documents.length === 1 ? '' : 's'} &middot; <a href="/__faker/routes">routes</a> &middot; <a href="/__faker/health">health</a></p>
38
+ </header>
39
+ <main>
40
+ ${documents.map((d) => document(d, at)).join('\n') || '<p class="empty">No operations are being served.</p>'}
41
+ </main>
42
+ ${DIALOG}
43
+ <script type="application/json" id="faker-ops">${descriptors}</script>
44
+ <script>${SCRIPT}</script>
45
+ </body>
46
+ </html>
47
+ `;
48
+ }
49
+ /** By source document, each keeping the order its document declared. */
50
+ function group(operations) {
51
+ const out = new Map();
52
+ for (const operation of operations) {
53
+ const list = out.get(operation.source) ?? [];
54
+ list.push(operation);
55
+ out.set(operation.source, list);
56
+ }
57
+ return [...out];
58
+ }
59
+ function document([source, operations], at) {
60
+ return `<section>
61
+ <h2>${esc(basename(source))} <span class="source">${esc(source)}</span></h2>
62
+ ${operations.map((operation) => entry(operation, at.get(operation) ?? 0)).join('\n')}
63
+ </section>`;
64
+ }
65
+ function entry(operation, index) {
66
+ const parts = [
67
+ `<div class="op">`,
68
+ `<div class="sig"><span class="verb ${operation.method}">${operation.method.toUpperCase()}</span><code class="path">${esc(called(operation))}</code><span class="id">${esc(operation.operationId)}</span><button type="button" class="try" data-op="${index}">Run</button></div>`,
69
+ ];
70
+ if (operation.summary) {
71
+ parts.push(`<p class="summary">${esc(operation.summary)}</p>`);
72
+ }
73
+ if (operation.description && operation.description !== operation.summary) {
74
+ parts.push(`<p class="about">${esc(operation.description)}</p>`);
75
+ }
76
+ parts.push(`<p class="answer">${esc(answer(operation))}</p>`);
77
+ if (operation.params.length > 0 || operation.requestBody) {
78
+ parts.push(`<div class="rows"><table class="params">${rows(operation).join('')}</table></div>`);
79
+ }
80
+ parts.push('</div>');
81
+ return parts.join('\n');
82
+ }
83
+ /** The one line that says what a call gets back. */
84
+ function answer(operation) {
85
+ const body = operation.success.schema ? typeOf(operation.success.schema) : 'no body';
86
+ const paged = operation.paging
87
+ ? ` · pages by ${operation.paging.param}${operation.paging.next ? ` → ${operation.paging.next}` : ''}`
88
+ : '';
89
+ return `→ ${operation.success.status} ${body}${paged}`;
90
+ }
91
+ function rows(operation) {
92
+ const out = operation.params.map((p) => `<tr><td class="name">${esc(p.name)}</td><td class="in">${esc(p.in)}</td><td class="type">${esc(typeOf(p.schema))}</td><td class="need">${p.required ? 'required' : ''}</td><td class="note">${esc(p.description ?? '')}</td></tr>`);
93
+ if (operation.requestBody) {
94
+ out.push(`<tr><td class="name">body</td><td class="in">body</td><td class="type">${esc(typeOf(operation.requestBody.schema))}</td><td class="need">${operation.requestBody.required ? 'required' : ''}</td><td class="note"></td></tr>`);
95
+ }
96
+ return out;
97
+ }
98
+ /** A schema in as many words as fit in a column: the shape, not the contract. */
99
+ function typeOf(schema, root = schema, depth = 0) {
100
+ const node = deref(schema, root);
101
+ const type = Array.isArray(node.type) ? node.type.join(' | ') : node.type;
102
+ if (type === 'array' && depth < 3) {
103
+ const items = node.items;
104
+ return `${isObject(items) ? typeOf(items, root, depth + 1) : 'any'}[]`;
105
+ }
106
+ if (Array.isArray(node.enum)) {
107
+ return brief(node.enum.map((v) => String(v)), ' | ');
108
+ }
109
+ if (isObject(node.properties)) {
110
+ return `object { ${brief(Object.keys(node.properties), ', ')} }`;
111
+ }
112
+ if (typeof type === 'string') {
113
+ return `${type}${typeof node.format === 'string' ? ` <${node.format}>` : ''}`;
114
+ }
115
+ for (const key of ['oneOf', 'anyOf', 'allOf']) {
116
+ if (Array.isArray(node[key])) {
117
+ return key;
118
+ }
119
+ }
120
+ return 'any';
121
+ }
122
+ /**
123
+ * Anything shared or recursive was hoisted into `$defs` under a made-up name,
124
+ * so the pointer says nothing and the shape it points at says everything.
125
+ */
126
+ function deref(schema, root, hops = 0) {
127
+ const ref = schema.$ref;
128
+ if (typeof ref !== 'string' || !ref.startsWith(DEFS) || hops >= 4) {
129
+ return schema;
130
+ }
131
+ const defs = root.$defs;
132
+ if (!isObject(defs)) {
133
+ return schema;
134
+ }
135
+ const target = defs[ref.slice(DEFS.length)];
136
+ return isObject(target) ? deref(target, root, hops + 1) : schema;
137
+ }
138
+ const DEFS = '#/$defs/';
139
+ function callable(operation) {
140
+ // Cookies are not the browser's to set from a script, so they are shown in
141
+ // the table and left out of the form.
142
+ const fields = operation.params
143
+ .filter((p) => p.in !== 'cookie')
144
+ .map((p) => {
145
+ const node = deref(p.schema, p.schema);
146
+ return {
147
+ name: p.name,
148
+ in: p.in,
149
+ required: p.required,
150
+ type: typeOf(p.schema),
151
+ choices: choicesOf(node),
152
+ description: p.description,
153
+ };
154
+ });
155
+ const body = operation.requestBody;
156
+ return {
157
+ method: operation.method,
158
+ path: operation.path,
159
+ shown: called(operation),
160
+ fields,
161
+ body: body
162
+ ? {
163
+ required: body.required,
164
+ sample: JSON.stringify(sample(body.schema, body.schema), null, 2),
165
+ }
166
+ : undefined,
167
+ };
168
+ }
169
+ function choicesOf(node) {
170
+ if (Array.isArray(node.enum)) {
171
+ return node.enum.map((v) => String(v));
172
+ }
173
+ return node.type === 'boolean' ? ['true', 'false'] : undefined;
174
+ }
175
+ /** Something shaped like the body, so the textarea is edited rather than written. */
176
+ function sample(schema, root, depth = 0) {
177
+ const node = deref(schema, root);
178
+ if (node.default !== undefined) {
179
+ return node.default;
180
+ }
181
+ if (Array.isArray(node.enum)) {
182
+ return node.enum[0];
183
+ }
184
+ const type = Array.isArray(node.type) ? node.type[0] : node.type;
185
+ if (type === 'array') {
186
+ return depth < 2 && isObject(node.items) ? [sample(node.items, root, depth + 1)] : [];
187
+ }
188
+ if (isObject(node.properties) && depth < 3) {
189
+ // Only what the document insists on. An optional branch filled in with
190
+ // blanks is a request the mock would reject before it answered.
191
+ const need = Array.isArray(node.required) ? node.required.map(String) : null;
192
+ const out = {};
193
+ for (const [name, sub] of Object.entries(node.properties)) {
194
+ if (isObject(sub) && (need === null || need.includes(name))) {
195
+ out[name] = sample(sub, root, depth + 1);
196
+ }
197
+ }
198
+ return out;
199
+ }
200
+ if (Array.isArray(node.allOf) && depth < 3) {
201
+ // The branches of an `allOf` all apply at once, so they are merged.
202
+ return node.allOf
203
+ .filter(isObject)
204
+ .reduce((all, branch) => Object.assign(all, sample(branch, root, depth + 1)), {});
205
+ }
206
+ for (const key of ['oneOf', 'anyOf']) {
207
+ const branches = node[key];
208
+ if (Array.isArray(branches) && isObject(branches[0]) && depth < 3) {
209
+ return sample(branches[0], root, depth + 1);
210
+ }
211
+ }
212
+ if (type === 'integer' || type === 'number') {
213
+ return 0;
214
+ }
215
+ if (type === 'boolean') {
216
+ return false;
217
+ }
218
+ if (type === 'string') {
219
+ // A blank fails `format`, and the mock checks the request before it
220
+ // answers it, so a formatted string starts out as something legal.
221
+ return LIKE[String(node.format)] ?? '';
222
+ }
223
+ return type === 'object' ? {} : null;
224
+ }
225
+ const LIKE = {
226
+ email: 'someone@example.com',
227
+ hostname: 'example.com',
228
+ uri: 'https://example.com',
229
+ 'uri-reference': '/example',
230
+ uuid: '00000000-0000-4000-8000-000000000000',
231
+ date: '2024-01-01',
232
+ 'date-time': '2024-01-01T00:00:00Z',
233
+ time: '00:00:00',
234
+ duration: 'P1D',
235
+ ipv4: '127.0.0.1',
236
+ ipv6: '::1',
237
+ };
238
+ /** A list cut short: a contents page is not the document. */
239
+ const brief = (values, sep) => values.slice(0, MOST).join(sep) + (values.length > MOST ? `${sep}…` : '');
240
+ const MOST = 6;
241
+ const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
242
+ /** The CLI's block face, split at the word so the accent keeps its colour. */
243
+ function banner() {
244
+ const { head, accent } = bannerArt(BRAND);
245
+ const rows = head.map((row, i) => `<span class="head">${paint(row)}</span> <span class="accent">${paint(accent[i] ?? '')}</span>`);
246
+ return `<pre class="banner" role="img" aria-label="${esc(`${BRAND.head} ${BRAND.accent}`)}">${rows.join('\n')}</pre>`;
247
+ }
248
+ /**
249
+ * Stroke and bevel, the terminal's two tones. Without the darker bevel the
250
+ * letter reads as a doubled outline rather than as a raised face.
251
+ */
252
+ const paint = (row) => row.replace(/[█▀▄]+|[^█▀▄ ]+/g, (run) => /[█▀▄]/.test(run) ? `<b>${run}</b>` : `<i>${run}</i>`);
253
+ const esc = (s) => s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
254
+ const DIALOG = `<dialog id="run">
255
+ <div class="run-head"><h3 id="run-title"></h3><button type="button" id="run-close" aria-label="Close">&times;</button></div>
256
+ <form id="run-form">
257
+ <div id="run-fields"></div>
258
+ <div class="actions"><button type="submit" class="go">Run</button><button type="button" id="run-cancel" class="cancel">Cancel</button></div>
259
+ </form>
260
+ <pre id="run-out" hidden></pre>
261
+ </dialog>`;
262
+ // Values from the document reach the dialog as text nodes rather than markup:
263
+ // the page escapes what it prints, and this has to do the same by construction.
264
+ const SCRIPT = `
265
+ const specs = JSON.parse(document.getElementById('faker-ops').textContent);
266
+ const dialog = document.getElementById('run');
267
+ const title = document.getElementById('run-title');
268
+ const slots = document.getElementById('run-fields');
269
+ const form = document.getElementById('run-form');
270
+ const out = document.getElementById('run-out');
271
+ let current = null;
272
+
273
+ function box(field) {
274
+ if (field.choices) {
275
+ const select = document.createElement('select');
276
+ if (!field.required) {
277
+ select.append(new Option('', ''));
278
+ }
279
+ for (const choice of field.choices) {
280
+ select.append(new Option(choice, choice));
281
+ }
282
+ return select;
283
+ }
284
+ const input = document.createElement('input');
285
+ input.type = 'text';
286
+ input.placeholder = field.type;
287
+ return input;
288
+ }
289
+
290
+ function head(name, hint, required) {
291
+ const span = document.createElement('span');
292
+ span.className = 'label';
293
+ span.textContent = name;
294
+ const where = document.createElement('em');
295
+ where.textContent = hint;
296
+ span.append(' ', where);
297
+ if (required) {
298
+ const need = document.createElement('b');
299
+ need.textContent = 'required';
300
+ span.append(' ', need);
301
+ }
302
+ return span;
303
+ }
304
+
305
+ function row(field) {
306
+ const label = document.createElement('label');
307
+ label.className = 'field';
308
+ const input = box(field);
309
+ input.name = field.in + ':' + field.name;
310
+ input.required = field.required;
311
+ label.append(head(field.name, field.in + ' \u00b7 ' + field.type, field.required), input);
312
+ if (field.description) {
313
+ const note = document.createElement('small');
314
+ note.textContent = field.description;
315
+ label.append(note);
316
+ }
317
+ return label;
318
+ }
319
+
320
+ function bodyRow(body) {
321
+ const label = document.createElement('label');
322
+ label.className = 'field';
323
+ const area = document.createElement('textarea');
324
+ area.name = '@body';
325
+ area.value = body.sample;
326
+ area.required = body.required;
327
+ label.append(head('body', 'application/json', body.required), area);
328
+ return label;
329
+ }
330
+
331
+ function show(index) {
332
+ current = specs[index];
333
+ title.textContent = current.method.toUpperCase() + ' ' + current.shown;
334
+ slots.replaceChildren();
335
+ for (const field of current.fields) {
336
+ slots.append(row(field));
337
+ }
338
+ if (current.body) {
339
+ slots.append(bodyRow(current.body));
340
+ }
341
+ if (!current.fields.length && !current.body) {
342
+ const hint = document.createElement('p');
343
+ hint.className = 'hint';
344
+ hint.textContent = 'This call takes no arguments.';
345
+ slots.append(hint);
346
+ }
347
+ out.hidden = true;
348
+ out.textContent = '';
349
+ dialog.showModal();
350
+ }
351
+
352
+ form.addEventListener('submit', async (event) => {
353
+ event.preventDefault();
354
+ const spec = current;
355
+ const data = new FormData(form);
356
+ const path = spec.path.replace(/\\{(.*?)\\}/g, (whole, name) =>
357
+ encodeURIComponent(String(data.get('path:' + name) || '')),
358
+ );
359
+ const url = new URL(path, location.origin);
360
+ const headers = {};
361
+ for (const field of spec.fields) {
362
+ const value = data.get(field.in + ':' + field.name);
363
+ if (value === null || value === '') {
364
+ continue;
365
+ }
366
+ if (field.in === 'query') {
367
+ url.searchParams.set(field.name, value);
368
+ }
369
+ if (field.in === 'header') {
370
+ headers[field.name] = value;
371
+ }
372
+ }
373
+ let body;
374
+ const typed = spec.body ? String(data.get('@body') || '').trim() : '';
375
+ if (typed && spec.method !== 'get' && spec.method !== 'head') {
376
+ body = typed;
377
+ headers['content-type'] = 'application/json';
378
+ }
379
+
380
+ out.hidden = false;
381
+ out.dataset.ok = 'true';
382
+ out.textContent = 'running\u2026';
383
+ const started = performance.now();
384
+ try {
385
+ const res = await fetch(url, { method: spec.method.toUpperCase(), headers, body });
386
+ const answer = await res.text();
387
+ let shown = answer;
388
+ try {
389
+ shown = JSON.stringify(JSON.parse(answer), null, 2);
390
+ } catch {}
391
+ out.dataset.ok = String(res.ok);
392
+ out.textContent =
393
+ res.status + ' ' + res.statusText + ' \u00b7 ' +
394
+ Math.round(performance.now() - started) + 'ms \u00b7 ' +
395
+ spec.method.toUpperCase() + ' ' + url.pathname + url.search + '\\n\\n' + shown;
396
+ } catch (err) {
397
+ out.dataset.ok = 'false';
398
+ out.textContent = String(err);
399
+ }
400
+ });
401
+
402
+ for (const button of document.querySelectorAll('button.try')) {
403
+ button.addEventListener('click', () => show(Number(button.dataset.op)));
404
+ }
405
+ document.getElementById('run-close').addEventListener('click', () => dialog.close());
406
+ document.getElementById('run-cancel').addEventListener('click', () => dialog.close());
407
+ `;
408
+ // The two schemes are the terminal's two, and the accent is the same mint the
409
+ // `zen faker` banner is painted in on both.
410
+ const CSS = `
411
+ :root {
412
+ color-scheme: dark light;
413
+ --bg: #12131a; --fg: #e6e7ee; --dim: #8b8fa3; --line: #262838;
414
+ --card: #191b25; --accent: #87ffaf; --shade: #5fd787;
415
+ }
416
+ @media (prefers-color-scheme: light) {
417
+ :root { --bg: #fbfbfd; --fg: #1d1f2a; --dim: #6a6e80; --line: #e2e3ec;
418
+ --card: #ffffff; --accent: #14915a; --shade: #0f7548; }
419
+ }
420
+ * { box-sizing: border-box; }
421
+ body { margin: 0; padding: 2rem 0 4rem; background: var(--bg); color: var(--fg);
422
+ font: 15px/1.55 ui-sans-serif, -apple-system, "Segoe UI", Roboto, sans-serif; }
423
+ header, main { width: 90%; margin: 0 auto; }
424
+ .banner { font: 11px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
425
+ margin: 0; overflow-x: auto; }
426
+ .banner b, .banner i { font: inherit; }
427
+ .banner .head b { color: var(--fg); }
428
+ .banner .head i { color: var(--dim); }
429
+ .banner .accent b { color: var(--accent); }
430
+ .banner .accent i { color: var(--shade); }
431
+ .caption { margin: .75rem 0 0; color: var(--dim); letter-spacing: .35em;
432
+ text-transform: uppercase; font-size: 11px; }
433
+ .meta { margin: .35rem 0 2.5rem; color: var(--dim); font-size: 13px; }
434
+ .meta a { color: var(--shade); text-decoration: none; }
435
+ .meta a:hover { text-decoration: underline; }
436
+ h2 { font-size: 14px; font-weight: 600; margin: 2.5rem 0 .75rem;
437
+ padding-bottom: .4rem; border-bottom: 1px solid var(--line); }
438
+ h2 .source { font-weight: 400; color: var(--dim); font-size: 12px; margin-left: .5rem;
439
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
440
+ .op { background: var(--card); border: 1px solid var(--line); border-radius: 8px;
441
+ padding: .8rem 1rem; margin: .5rem 0; }
442
+ .sig { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
443
+ .verb { font: 600 11px/1 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .08em;
444
+ padding: .3rem .45rem; border-radius: 4px; color: #0c0d12; background: var(--dim); }
445
+ .verb.get { background: #87ffaf; } .verb.post { background: #7fbcff; }
446
+ .verb.put { background: #ffd479; } .verb.patch { background: #d3a6ff; }
447
+ .verb.delete { background: #ff8f8f; }
448
+ .path { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px;
449
+ overflow-wrap: anywhere; }
450
+ .id { color: var(--dim); font-size: 12px; margin-left: auto;
451
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
452
+ .summary { margin: .5rem 0 0; }
453
+ .about { margin: .3rem 0 0; color: var(--dim); font-size: 13px; white-space: pre-wrap;
454
+ overflow-wrap: anywhere; }
455
+ .answer { margin: .5rem 0 0; color: var(--shade); font-size: 12px; overflow-wrap: anywhere;
456
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
457
+ .rows { overflow-x: auto; }
458
+ .params { margin: .6rem 0 0; border-collapse: collapse; width: 100%; font-size: 12px; }
459
+ .params td { padding: .2rem .5rem .2rem 0; vertical-align: top; overflow-wrap: break-word;
460
+ border-top: 1px solid var(--line); }
461
+ .params .name { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
462
+ .params .in, .params .need { color: var(--dim); white-space: nowrap; }
463
+ .params .type { color: var(--dim); max-width: 18rem; overflow-wrap: anywhere; }
464
+ .params .note { color: var(--dim); width: 50%; }
465
+ .empty { color: var(--dim); }
466
+ .try { background: transparent; color: var(--shade); border: 1px solid var(--line);
467
+ border-radius: 6px; padding: .2rem .6rem; font: 600 11px/1.6 inherit; cursor: pointer; }
468
+ .try:hover { border-color: var(--accent); color: var(--accent); }
469
+ dialog { width: min(48rem, 92%); padding: 0; color: var(--fg); background: var(--card);
470
+ border: 1px solid var(--line); border-radius: 10px; }
471
+ dialog::backdrop { background: rgba(0, 0, 0, .55); }
472
+ .run-head { display: flex; align-items: center; gap: .6rem;
473
+ padding: .8rem 1rem; border-bottom: 1px solid var(--line); }
474
+ .run-head h3 { margin: 0; overflow-wrap: anywhere;
475
+ font: 600 13px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; }
476
+ #run-close { margin-left: auto; background: none; border: 0; color: var(--dim);
477
+ font-size: 18px; line-height: 1; cursor: pointer; }
478
+ #run-form { padding: 1rem; max-height: 55vh; overflow-y: auto; }
479
+ .field { display: block; margin: 0 0 .9rem; }
480
+ .field .label { font: 600 12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; }
481
+ .field .label em { font-style: normal; font-weight: 400; color: var(--dim); font-size: 11px; }
482
+ .field .label b { color: var(--shade); font-size: 11px; }
483
+ .field small { display: block; margin-top: .2rem; color: var(--dim); font-size: 11px; }
484
+ .field input, .field select, .field textarea { display: block; width: 100%; margin-top: .3rem;
485
+ padding: .4rem .5rem; color: var(--fg); background: var(--bg); border: 1px solid var(--line);
486
+ border-radius: 6px; font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
487
+ .field textarea { min-height: 9rem; resize: vertical; }
488
+ .hint { color: var(--dim); font-size: 13px; margin: 0 0 .9rem; }
489
+ .actions { display: flex; gap: .5rem; }
490
+ .go { background: var(--accent); color: #0c0d12; border: 0; border-radius: 6px;
491
+ padding: .45rem 1.1rem; font: 600 13px/1.4 inherit; cursor: pointer; }
492
+ .cancel { background: transparent; color: var(--dim); border: 1px solid var(--line);
493
+ border-radius: 6px; padding: .45rem .9rem; font: 13px/1.4 inherit; cursor: pointer; }
494
+ #run-out { margin: 0; padding: .8rem 1rem; max-height: 40vh; overflow: auto;
495
+ border-top: 1px solid var(--line); background: var(--bg); white-space: pre-wrap;
496
+ overflow-wrap: anywhere; font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
497
+ #run-out[data-ok="false"] { color: #ff8f8f; }
498
+ `;
499
+ //# sourceMappingURL=page.js.map
package/dist/paging.d.ts CHANGED
@@ -3,8 +3,10 @@ import type { ParamSpec } from './spec.ts';
3
3
  export type PagingStyle = 'cursor' | 'offset';
4
4
  export interface Paging {
5
5
  style: PagingStyle;
6
- /** the query parameter that turns the page */
6
+ /** the parameter that turns the page */
7
7
  param: string;
8
+ /** where the parameter lives: 'body' when in request body, absent when in query */
9
+ paramIn?: 'query' | 'body';
8
10
  /** the page-size parameter, when the operation takes one */
9
11
  size?: string;
10
12
  /** the response property carrying the token for the page after this one */
@@ -25,7 +27,7 @@ export interface Paging {
25
27
  * a one-shot list — so a control that turns the page *and* a property that says
26
28
  * where the next one is are both required.
27
29
  */
28
- export declare function pagingOf(params: readonly ParamSpec[], schema: Schema | undefined): Paging | undefined;
30
+ export declare function pagingOf(params: readonly ParamSpec[], schema: Schema | undefined, bodySchema?: Schema | undefined): Paging | undefined;
29
31
  /**
30
32
  * The paging an actual request reveals, for a document that declared none.
31
33
  *
@@ -35,7 +37,7 @@ export declare function pagingOf(params: readonly ParamSpec[], schema: Schema |
35
37
  * evidence even when the document is silent, and silence is exactly the case
36
38
  * nothing else here can catch.
37
39
  */
38
- export declare function pagingSeen(params: readonly ParamSpec[], schema: Schema | undefined, query: Iterable<[string, string]>): Paging | undefined;
40
+ export declare function pagingSeen(params: readonly ParamSpec[], schema: Schema | undefined, query: Iterable<[string, string]>, body?: unknown): Paging | undefined;
39
41
  /** The token a body offers for the next page, or nothing when it offers none. */
40
42
  export declare function tokenOf(value: unknown, paging: Paging): string | undefined;
41
43
  /**
@@ -48,4 +50,30 @@ export declare function tokenOf(value: unknown, paging: Paging): string | undefi
48
50
  * obvious. Where the schema leaves no room, this changes nothing and says so.
49
51
  */
50
52
  export declare function cutLoop(value: unknown, paging: Paging, sent: string): boolean;
53
+ /**
54
+ * Terminates pagination unconditionally on a response object: nullifies or deletes
55
+ * the next token if allowed, or empties items if the token is required non-nullable.
56
+ */
57
+ export declare function cutPaging(value: unknown, paging: Paging): boolean;
58
+ /** Sets the items array to empty and sets more to false. */
59
+ export declare function emptyItems(value: unknown, paging: Paging): boolean;
60
+ /** Retrieves the sent pagination token from query parameters or request body. */
61
+ export declare function getSentToken(query: URLSearchParams, body: unknown, paging: Paging): string | undefined;
62
+ /** Decodes the 1-based page index from base64 JSON token `{"p": n}` if present. */
63
+ export declare function parsePageFromToken(token: string, style?: PagingStyle): number | undefined;
64
+ export interface PaginationCutResult {
65
+ cut: boolean;
66
+ reason: string;
67
+ }
68
+ /**
69
+ * Tracks active pagination streams to bound iterations and prevent infinite loops.
70
+ *
71
+ * Randomly bounds pagination to 1-10 pages by default, intercepts cycle loops and
72
+ * echoed tokens, and empties responses when clients loop past the end.
73
+ */
74
+ export declare class PaginationLimiter {
75
+ #private;
76
+ constructor(maxPages?: number | (() => number), maxEntries?: number);
77
+ process(operationKey: string, streamKey: string, sent: string | undefined, value: unknown, paging: Paging): PaginationCutResult | undefined;
78
+ }
51
79
  //# sourceMappingURL=paging.d.ts.map