@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/README.md +32 -13
- package/dist/cache.d.ts +14 -1
- package/dist/cache.js +57 -1
- package/dist/command.js +83 -9
- package/dist/generate.d.ts +11 -1
- package/dist/generate.js +60 -12
- package/dist/logger.d.ts +62 -0
- package/dist/logger.js +273 -0
- package/dist/page.d.ts +3 -0
- package/dist/page.js +499 -0
- package/dist/paging.d.ts +31 -3
- package/dist/paging.js +315 -6
- package/dist/probe.js +11 -0
- package/dist/prompt.d.ts +15 -1
- package/dist/prompt.js +40 -6
- package/dist/router.d.ts +8 -2
- package/dist/router.js +23 -5
- package/dist/server.d.ts +8 -0
- package/dist/server.js +346 -41
- package/dist/setup.d.ts +18 -0
- package/dist/setup.js +57 -15
- package/dist/spec.d.ts +9 -0
- package/dist/spec.js +45 -7
- package/package.json +3 -3
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'} · ${documents.length} document${documents.length === 1 ? '' : 's'} · <a href="/__faker/routes">routes</a> · <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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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">×</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
|
|
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]
|
|
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
|