@nexusbloom/cli 0.9.6 → 0.9.8
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/package.json +1 -1
- package/src/values.js +368 -5
- package/src/wizard.js +6 -3
package/package.json
CHANGED
package/src/values.js
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
/** Distinguishes "the field is absent" from "the field holds undefined". */
|
|
16
|
+
import { createHash, randomUUID } from "crypto";
|
|
17
|
+
|
|
16
18
|
export const MISSING = Symbol("missing");
|
|
17
19
|
|
|
18
20
|
/**
|
|
@@ -96,6 +98,40 @@ export function formatPath(segments) {
|
|
|
96
98
|
return out;
|
|
97
99
|
}
|
|
98
100
|
|
|
101
|
+
// ─── Helpers for the computed transforms ─────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Compare loosely enough that `eq(200)` matches a JSON number but `eq("200")`
|
|
105
|
+
* also matches the string a tool happened to return, while still refusing to
|
|
106
|
+
* claim `[1]` equals `[1]`.
|
|
107
|
+
*/
|
|
108
|
+
function coerceLike(other, sample) {
|
|
109
|
+
if (typeof other === "string" && typeof sample === "number") {
|
|
110
|
+
const n = Number(other);
|
|
111
|
+
return isNaN(n) ? other : n;
|
|
112
|
+
}
|
|
113
|
+
return other;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Equality that forgives a number arriving as text, without extending that
|
|
118
|
+
* forgiveness to containers. Comparing by string would make two separate
|
|
119
|
+
* `[1]`s equal and two `{a:1}`s equal, which is never what "are these the same"
|
|
120
|
+
* should answer.
|
|
121
|
+
*/
|
|
122
|
+
function looseEqual(a, b) {
|
|
123
|
+
if (a === b) return true;
|
|
124
|
+
const bothPrimitives =
|
|
125
|
+
(a === null || typeof a !== "object") && (b === null || typeof b !== "object");
|
|
126
|
+
if (!bothPrimitives) return false;
|
|
127
|
+
return String(a) === String(b);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Stable hash of a stringified value, for change detection and cache keys. */
|
|
131
|
+
function digest(value, algorithm) {
|
|
132
|
+
return createHash(algorithm).update(toStr(value), "utf8").digest("hex");
|
|
133
|
+
}
|
|
134
|
+
|
|
99
135
|
// ─── Coercion helpers ────────────────────────────────────────────────────────
|
|
100
136
|
|
|
101
137
|
const toStr = (v) => {
|
|
@@ -204,6 +240,226 @@ export const TRANSFORMS = {
|
|
|
204
240
|
return r;
|
|
205
241
|
},
|
|
206
242
|
default: (v, fallback) => (v === MISSING || v === undefined || v === null || v === "" ? fallback : v),
|
|
243
|
+
|
|
244
|
+
// — arithmetic —
|
|
245
|
+
//
|
|
246
|
+
// The tools in a catalogue rarely line up numerically: one returns a count, the
|
|
247
|
+
// next wants a word count. These fill the gap without a dedicated tool, which is
|
|
248
|
+
// the difference between "add a tool" and "compose what exists".
|
|
249
|
+
//
|
|
250
|
+
// A wrong input type returns the value untouched rather than throwing or
|
|
251
|
+
// coercing, so a transform never silently invents a number.
|
|
252
|
+
add: (v, n) => (typeof v === "number" && isFinite(v) ? v + Number(n) : v),
|
|
253
|
+
sub: (v, n) => (typeof v === "number" && isFinite(v) ? v - Number(n) : v),
|
|
254
|
+
mul: (v, n) => (typeof v === "number" && isFinite(v) ? v * Number(n) : v),
|
|
255
|
+
div: (v, n) => (typeof v === "number" && isFinite(v) && Number(n) !== 0 ? v / Number(n) : v),
|
|
256
|
+
mod: (v, n) => (typeof v === "number" && isFinite(v) && Number(n) !== 0 ? v % Number(n) : v),
|
|
257
|
+
inc: (v) => (typeof v === "number" && isFinite(v) ? v + 1 : v),
|
|
258
|
+
dec: (v) => (typeof v === "number" && isFinite(v) ? v - 1 : v),
|
|
259
|
+
neg: (v) => (typeof v === "number" && isFinite(v) ? -v : v),
|
|
260
|
+
abs: (v) => (typeof v === "number" && isFinite(v) ? Math.abs(v) : v),
|
|
261
|
+
pow: (v, n) => (typeof v === "number" && isFinite(v) ? v ** Number(n) : v),
|
|
262
|
+
round: (v, p = 0) => (typeof v === "number" && isFinite(v) ? Number(v.toFixed(Number(p))) : v),
|
|
263
|
+
floor: (v) => (typeof v === "number" && isFinite(v) ? Math.floor(v) : v),
|
|
264
|
+
ceil: (v) => (typeof v === "number" && isFinite(v) ? Math.ceil(v) : v),
|
|
265
|
+
clamp: (v, lo, hi) =>
|
|
266
|
+
typeof v === "number" && isFinite(v)
|
|
267
|
+
? Math.min(Math.max(v, Number(lo)), Number(hi))
|
|
268
|
+
: v,
|
|
269
|
+
|
|
270
|
+
// Aggregate — the whole input array collapses to one number.
|
|
271
|
+
sum: (v) => (Array.isArray(v) ? v.filter((n) => typeof n === "number").reduce((a, b) => a + b, 0) : v),
|
|
272
|
+
avg: (v) => {
|
|
273
|
+
if (!Array.isArray(v)) return v;
|
|
274
|
+
const nums = v.filter((n) => typeof n === "number");
|
|
275
|
+
return nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : MISSING;
|
|
276
|
+
},
|
|
277
|
+
// These collapse an *array*; they do not compare against a bound. `clamp(lo,hi)`
|
|
278
|
+
// is the one that takes bounds. Ignoring a second argument silently was the
|
|
279
|
+
// reason `min(120, 60)` returned 120 instead of erroring, so it now returns the
|
|
280
|
+
// value untouched rather than pretending to have clamped anything.
|
|
281
|
+
min: (v, ...ignored) =>
|
|
282
|
+
ignored.length
|
|
283
|
+
? v
|
|
284
|
+
: Array.isArray(v) && v.length
|
|
285
|
+
? Math.min(...v.filter((n) => typeof n === "number"))
|
|
286
|
+
: v,
|
|
287
|
+
max: (v, ...ignored) =>
|
|
288
|
+
ignored.length
|
|
289
|
+
? v
|
|
290
|
+
: Array.isArray(v) && v.length
|
|
291
|
+
? Math.max(...v.filter((n) => typeof n === "number"))
|
|
292
|
+
: v,
|
|
293
|
+
// Sum one field across an array of objects — the common "total of the column".
|
|
294
|
+
sumBy: (v, key) => {
|
|
295
|
+
if (!Array.isArray(v)) return v;
|
|
296
|
+
return v
|
|
297
|
+
.map((r) => getPath(r, key))
|
|
298
|
+
.filter((n) => typeof n === "number")
|
|
299
|
+
.reduce((a, b) => a + b, 0);
|
|
300
|
+
},
|
|
301
|
+
|
|
302
|
+
// — comparison —
|
|
303
|
+
//
|
|
304
|
+
// These return real booleans, so they are the input to `then()` and to any tool
|
|
305
|
+
// taking a boolean. A non-matching value is `false`, never MISSING.
|
|
306
|
+
eq: (v, other) => looseEqual(v, other),
|
|
307
|
+
ne: (v, other) => !looseEqual(v, other),
|
|
308
|
+
gt: (v, n) => typeof v === "number" && isFinite(v) && v > Number(n),
|
|
309
|
+
gte: (v, n) => typeof v === "number" && isFinite(v) && v >= Number(n),
|
|
310
|
+
lt: (v, n) => typeof v === "number" && isFinite(v) && v < Number(n),
|
|
311
|
+
lte: (v, n) => typeof v === "number" && isFinite(v) && v <= Number(n),
|
|
312
|
+
between: (v, lo, hi) => typeof v === "number" && isFinite(v) && v >= Number(lo) && v <= Number(hi),
|
|
313
|
+
includes: (v, needle) =>
|
|
314
|
+
typeof v === "string"
|
|
315
|
+
? v.includes(needle)
|
|
316
|
+
: Array.isArray(v)
|
|
317
|
+
? v.some((x) => x === needle)
|
|
318
|
+
: false,
|
|
319
|
+
startsWith: (v, s) => typeof v === "string" && v.startsWith(s),
|
|
320
|
+
endsWith: (v, s) => typeof v === "string" && v.endsWith(s),
|
|
321
|
+
// Regex source only — a pattern with spaces or flags cannot be expressed, and a
|
|
322
|
+
// bad pattern is the user's typo rather than something to guess at.
|
|
323
|
+
matches: (v, re) => {
|
|
324
|
+
if (typeof v !== "string" || !re) return false;
|
|
325
|
+
try {
|
|
326
|
+
return new RegExp(String(re)).test(v);
|
|
327
|
+
} catch {
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
},
|
|
331
|
+
isEmpty: (v) =>
|
|
332
|
+
v === MISSING || v === undefined || v === null || v === "" ||
|
|
333
|
+
(Array.isArray(v) && v.length === 0) ||
|
|
334
|
+
(typeof v === "object" && !Array.isArray(v) && Object.keys(v).length === 0),
|
|
335
|
+
|
|
336
|
+
// — branching —
|
|
337
|
+
//
|
|
338
|
+
// `then(cond, whenTrue, whenFalse)` is what a linear pipeline was missing: pick
|
|
339
|
+
// between two values on a condition, without a step to do it. `coalesce` is the
|
|
340
|
+
// safe default chain that does not need a condition at all.
|
|
341
|
+
then: (v, cond, whenTrue, whenFalse) => {
|
|
342
|
+
const test = typeof cond === "boolean" ? cond : v === true;
|
|
343
|
+
return test ? whenTrue : whenFalse === undefined ? MISSING : whenFalse;
|
|
344
|
+
},
|
|
345
|
+
coalesce: (v, ...fallbacks) => {
|
|
346
|
+
if (v !== MISSING && v !== undefined && v !== null && v !== "") return v;
|
|
347
|
+
for (const f of fallbacks) {
|
|
348
|
+
if (f !== MISSING && f !== undefined && f !== null && f !== "") return f;
|
|
349
|
+
}
|
|
350
|
+
return MISSING;
|
|
351
|
+
},
|
|
352
|
+
|
|
353
|
+
// — arrays —
|
|
354
|
+
//
|
|
355
|
+
// `pluck` is the one that unlocks the most: it turns an array of records into a
|
|
356
|
+
// flat array of one field, which everything above (`len`, `sort`, `sumBy`,
|
|
357
|
+
// `join`) can then work on.
|
|
358
|
+
pluck: (v, key) =>
|
|
359
|
+
Array.isArray(v) ? v.map((row) => getPath(row, key)) : v,
|
|
360
|
+
unique: (v) => (Array.isArray(v) ? [...new Set(v)] : v),
|
|
361
|
+
compact: (v) =>
|
|
362
|
+
Array.isArray(v) ? v.filter((x) => x !== undefined && x !== null && x !== "") : v,
|
|
363
|
+
flatten: (v) => (Array.isArray(v) ? v.flat(Infinity) : v),
|
|
364
|
+
chunk: (v, n) => {
|
|
365
|
+
if (!Array.isArray(v)) return v;
|
|
366
|
+
const size = Math.max(1, Number(n) || 1);
|
|
367
|
+
const out = [];
|
|
368
|
+
for (let i = 0; i < v.length; i += size) out.push(v.slice(i, i + size));
|
|
369
|
+
return out;
|
|
370
|
+
},
|
|
371
|
+
without: (v, needle) => (Array.isArray(v) ? v.filter((x) => x !== needle) : v),
|
|
372
|
+
// Keep array elements whose field passes a comparison: where("status","eq",200).
|
|
373
|
+
where: (v, key, op, operand) => {
|
|
374
|
+
if (!Array.isArray(v)) return v;
|
|
375
|
+
const test = (row) => {
|
|
376
|
+
const got = getPath(row, key);
|
|
377
|
+
switch (op) {
|
|
378
|
+
case "eq": return looseEqual(got, operand);
|
|
379
|
+
case "ne": return !looseEqual(got, operand);
|
|
380
|
+
case "gt": return typeof got === "number" && got > Number(operand);
|
|
381
|
+
case "gte": return typeof got === "number" && got >= Number(operand);
|
|
382
|
+
case "lt": return typeof got === "number" && got < Number(operand);
|
|
383
|
+
case "lte": return typeof got === "number" && got <= Number(operand);
|
|
384
|
+
case "contains": return String(got ?? "").includes(String(operand));
|
|
385
|
+
case "exists": return got !== MISSING && got !== undefined && got !== null;
|
|
386
|
+
default: return false;
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
return v.filter(test);
|
|
390
|
+
},
|
|
391
|
+
|
|
392
|
+
// — naming and shape —
|
|
393
|
+
title: (v) => toStr(v).replace(/\b\w/g, (c) => c.toUpperCase()),
|
|
394
|
+
camel: (v) => toStr(v).replace(/[-_\s]+(.)?/g, (_, c) => (c ? c.toUpperCase() : "")).replace(/^(.)/, (c) => c.toLowerCase()),
|
|
395
|
+
snake: (v) => toStr(v).replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[-\s]+/g, "_").toLowerCase(),
|
|
396
|
+
kebab: (v) => toStr(v).replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/[_\s]+/g, "-").toLowerCase(),
|
|
397
|
+
slugify: (v) =>
|
|
398
|
+
toStr(v)
|
|
399
|
+
.toLowerCase()
|
|
400
|
+
.normalize("NFKD")
|
|
401
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
402
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
403
|
+
.replace(/^-+|-+$/g, ""),
|
|
404
|
+
truncate: (v, n, ellipsis = "…") => {
|
|
405
|
+
const s = toStr(v);
|
|
406
|
+
const max = Math.max(0, Number(n) || 0);
|
|
407
|
+
if (s.length <= max) return s;
|
|
408
|
+
// Prefer a word boundary, but only if one is close enough that truncating to
|
|
409
|
+
// it does not throw away most of what was asked for.
|
|
410
|
+
const hard = s.slice(0, max);
|
|
411
|
+
const space = hard.lastIndexOf(" ");
|
|
412
|
+
const cut = space > max * 0.6 ? hard.slice(0, space).replace(/\s+$/, "") : hard;
|
|
413
|
+
return cut + ellipsis;
|
|
414
|
+
},
|
|
415
|
+
repeat: (v, n) => toStr(v).repeat(Math.max(0, Number(n) || 0)),
|
|
416
|
+
// Show the first and last few characters, e.g. mask(4) → "jTI…Nj".
|
|
417
|
+
mask: (v, n = 3) => {
|
|
418
|
+
const s = toStr(v);
|
|
419
|
+
const keep = Math.max(0, Number(n) || 0);
|
|
420
|
+
if (s.length <= keep * 2) return s;
|
|
421
|
+
return `${s.slice(0, keep)}${"…".repeat(1)}${s.slice(-keep)}`;
|
|
422
|
+
},
|
|
423
|
+
|
|
424
|
+
// — encoding and identity —
|
|
425
|
+
//
|
|
426
|
+
// Hashing here is for change detection and cache keys, never for passwords.
|
|
427
|
+
md5: (v) => digest(v, "md5"),
|
|
428
|
+
sha1: (v) => digest(v, "sha1"),
|
|
429
|
+
sha256: (v) => digest(v, "sha256"),
|
|
430
|
+
base64: (v) => Buffer.from(toStr(v), "utf8").toString("base64"),
|
|
431
|
+
unbase64: (v) => {
|
|
432
|
+
const text = toStr(v).trim();
|
|
433
|
+
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(text)) return MISSING;
|
|
434
|
+
try {
|
|
435
|
+
const bytes = Buffer.from(text, "base64");
|
|
436
|
+
// Buffer.from silently drops anything outside the alphabet, so the only
|
|
437
|
+
// way to notice the input was never base64 is to re-encode and compare.
|
|
438
|
+
// Garbage bytes are worse than an honest MISSING.
|
|
439
|
+
const reencoded = bytes.toString("base64").replace(/=+$/, "");
|
|
440
|
+
if (reencoded !== text.replace(/=+$/, "")) return MISSING;
|
|
441
|
+
return bytes.toString("utf8");
|
|
442
|
+
} catch {
|
|
443
|
+
return MISSING;
|
|
444
|
+
}
|
|
445
|
+
},
|
|
446
|
+
urlEncode: (v) => encodeURIComponent(toStr(v)),
|
|
447
|
+
urlDecode: (v) => {
|
|
448
|
+
try {
|
|
449
|
+
return decodeURIComponent(toStr(v));
|
|
450
|
+
} catch {
|
|
451
|
+
return MISSING;
|
|
452
|
+
}
|
|
453
|
+
},
|
|
454
|
+
uuid: () => randomUUID(),
|
|
455
|
+
|
|
456
|
+
// — introspection —
|
|
457
|
+
type: (v) => (v === MISSING ? "missing" : Array.isArray(v) ? "array" : v === null ? "null" : typeof v),
|
|
458
|
+
isArray: (v) => Array.isArray(v),
|
|
459
|
+
isString: (v) => typeof v === "string",
|
|
460
|
+
isNumber: (v) => typeof v === "number" && isFinite(v),
|
|
461
|
+
isObject: (v) => v !== null && typeof v === "object" && !Array.isArray(v),
|
|
462
|
+
isBool: (v) => typeof v === "boolean",
|
|
207
463
|
};
|
|
208
464
|
|
|
209
465
|
/** Transforms that are no-ops on their argument order, used for plan hints. */
|
|
@@ -217,6 +473,20 @@ export const TRANSFORM_ARITY = {
|
|
|
217
473
|
sort: 2,
|
|
218
474
|
pick: 2,
|
|
219
475
|
default: 2,
|
|
476
|
+
|
|
477
|
+
add: 2, sub: 2, mul: 2, div: 2, mod: 2, pow: 2,
|
|
478
|
+
round: 2, clamp: 3, sumBy: 2,
|
|
479
|
+
eq: 2, ne: 2, gt: 2, gte: 2, lt: 2, lte: 2,
|
|
480
|
+
between: 3, includes: 2, startsWith: 2, endsWith: 2, matches: 2,
|
|
481
|
+
then: 4,
|
|
482
|
+
// Arity is how many arguments the *builder* asks for, which is one less than
|
|
483
|
+
// the argument count of the hand-written form for a variadic transform.
|
|
484
|
+
// coalesce takes any number of fallbacks; prompting for a fixed count would
|
|
485
|
+
// understate it, so the builder asks for one and a written pipeline may list
|
|
486
|
+
// as many as it likes.
|
|
487
|
+
coalesce: 2,
|
|
488
|
+
pluck: 2, chunk: 2, without: 2, where: 4,
|
|
489
|
+
truncate: 3, repeat: 2, mask: 2,
|
|
220
490
|
};
|
|
221
491
|
|
|
222
492
|
export function transformNames() {
|
|
@@ -260,6 +530,71 @@ export const TRANSFORM_DOCS = {
|
|
|
260
530
|
unjson: "parse a JSON string",
|
|
261
531
|
pick: "one field out of an object",
|
|
262
532
|
default: "value to use when the path is missing",
|
|
533
|
+
// The catalogue is grouped by purpose; `nxb pipe --transforms` and the
|
|
534
|
+
// builder's picker both read from here, so a transform and its description
|
|
535
|
+
// are always edited together.
|
|
536
|
+
add: "add a number",
|
|
537
|
+
sub: "subtract a number",
|
|
538
|
+
mul: "multiply by a number",
|
|
539
|
+
div: "divide by a number",
|
|
540
|
+
mod: "remainder after division",
|
|
541
|
+
pow: "raise to a power",
|
|
542
|
+
inc: "add one",
|
|
543
|
+
dec: "subtract one",
|
|
544
|
+
neg: "negate",
|
|
545
|
+
abs: "absolute value",
|
|
546
|
+
round: "round to N decimal places",
|
|
547
|
+
floor: "round down to an integer",
|
|
548
|
+
ceil: "round up to an integer",
|
|
549
|
+
clamp: "keep a number between a low and a high",
|
|
550
|
+
sum: "add up every number in an array",
|
|
551
|
+
avg: "average of every number in an array",
|
|
552
|
+
min: "smallest number in an array (to bound a single number use clamp)",
|
|
553
|
+
max: "largest number in an array (to bound a single number use clamp)",
|
|
554
|
+
sumBy: "add up one field across an array of objects",
|
|
555
|
+
eq: "is it equal to a value",
|
|
556
|
+
ne: "is it different from a value",
|
|
557
|
+
gt: "is it greater than a number",
|
|
558
|
+
gte: "is it at least a number",
|
|
559
|
+
lt: "is it less than a number",
|
|
560
|
+
lte: "is it at most a number",
|
|
561
|
+
between: "is a number within a low and a high",
|
|
562
|
+
includes: "does a string or array contain a value",
|
|
563
|
+
startsWith: "does a string start with text",
|
|
564
|
+
endsWith: "does a string end with text",
|
|
565
|
+
matches: "does a string match a regular expression",
|
|
566
|
+
isEmpty: "is the value empty — blank, [], or {}",
|
|
567
|
+
then: "choose between two values on a condition",
|
|
568
|
+
coalesce: "first value that is not empty",
|
|
569
|
+
pluck: "pull one field out of every object in an array",
|
|
570
|
+
unique: "remove duplicates from an array",
|
|
571
|
+
compact: "drop empty values from an array",
|
|
572
|
+
flatten: "flatten a nested array",
|
|
573
|
+
chunk: "split an array into fixed-size groups",
|
|
574
|
+
without: "remove every occurrence of a value",
|
|
575
|
+
where: "keep array items whose field passes a test",
|
|
576
|
+
title: "Title Case",
|
|
577
|
+
camel: "camelCase",
|
|
578
|
+
snake: "snake_case",
|
|
579
|
+
kebab: "kebab-case",
|
|
580
|
+
slugify: "url-safe slug",
|
|
581
|
+
truncate: "shorten to N characters",
|
|
582
|
+
repeat: "repeat a string N times",
|
|
583
|
+
mask: "hide the middle, e.g. jTI…Nj",
|
|
584
|
+
md5: "MD5 hex digest",
|
|
585
|
+
sha1: "SHA-1 hex digest",
|
|
586
|
+
sha256: "SHA-256 hex digest",
|
|
587
|
+
base64: "Base64-encode text",
|
|
588
|
+
unbase64: "decode Base64 text",
|
|
589
|
+
urlEncode: "percent-encode for a URL",
|
|
590
|
+
urlDecode: "decode percent-encoding",
|
|
591
|
+
uuid: "a random UUID",
|
|
592
|
+
type: "the value's type as text",
|
|
593
|
+
isArray: "is it an array",
|
|
594
|
+
isString: "is it text",
|
|
595
|
+
isNumber: "is it a number",
|
|
596
|
+
isObject: "is it an object",
|
|
597
|
+
isBool: "is it true or false",
|
|
263
598
|
};
|
|
264
599
|
|
|
265
600
|
/**
|
|
@@ -284,8 +619,32 @@ export const TRANSFORM_GROUPS = [
|
|
|
284
619
|
names: ["int", "float", "num", "str", "bool"] },
|
|
285
620
|
{ label: "json / unjson", doc: "serialise / parse",
|
|
286
621
|
names: ["json", "unjson"] },
|
|
287
|
-
{ label: "default(v)", doc: "supply a value when
|
|
288
|
-
names: ["default"] },
|
|
622
|
+
{ label: "default(v) / coalesce(a,b,…) / then(cond,a,b)", doc: "supply a value when one is missing, or choose on a condition",
|
|
623
|
+
names: ["default", "coalesce", "then"] },
|
|
624
|
+
{ label: "eq / ne / gt / gte / lt / lte / between(a,b)", doc: "compare — the result is true or false",
|
|
625
|
+
names: ["eq", "ne", "gt", "gte", "lt", "lte", "between"] },
|
|
626
|
+
{ label: "includes / startsWith / endsWith / matches(re) / isEmpty", doc: "test text and arrays",
|
|
627
|
+
names: ["includes", "startsWith", "endsWith", "matches", "isEmpty"] },
|
|
628
|
+
{ label: "pluck(field) / where(field,op,value)", doc: "select out of an array of records",
|
|
629
|
+
names: ["pluck", "where"] },
|
|
630
|
+
{ label: "unique / compact / flatten / chunk(n) / without(v)", doc: "reshape an array",
|
|
631
|
+
names: ["unique", "compact", "flatten", "chunk", "without"] },
|
|
632
|
+
{ label: "add(n) / sub(n) / mul(n) / div(n) / mod(n) / pow(n)", doc: "arithmetic on a number",
|
|
633
|
+
names: ["add", "sub", "mul", "div", "mod", "pow"] },
|
|
634
|
+
{ label: "inc / dec / neg / abs / round(p) / floor / ceil / clamp(lo,hi)", doc: "adjust a number",
|
|
635
|
+
names: ["inc", "dec", "neg", "abs", "round", "floor", "ceil", "clamp"] },
|
|
636
|
+
{ label: "sum / avg / min / max / sumBy(field)", doc: "collapse an array to one number",
|
|
637
|
+
names: ["sum", "avg", "min", "max", "sumBy"] },
|
|
638
|
+
{ label: "title / camel / snake / kebab / slugify", doc: "change a string's shape",
|
|
639
|
+
names: ["title", "camel", "snake", "kebab", "slugify"] },
|
|
640
|
+
{ label: "truncate(n) / repeat(n) / mask(n)", doc: "shorten, repeat or hide part of a string",
|
|
641
|
+
names: ["truncate", "repeat", "mask"] },
|
|
642
|
+
{ label: "md5 / sha1 / sha256 / base64 / unbase64", doc: "digest or encode — for fingerprints, not secrets",
|
|
643
|
+
names: ["md5", "sha1", "sha256", "base64", "unbase64"] },
|
|
644
|
+
{ label: "urlEncode / urlDecode / uuid", doc: "URL and identity helpers",
|
|
645
|
+
names: ["urlEncode", "urlDecode", "uuid"] },
|
|
646
|
+
{ label: "type / isArray / isString / isNumber / isObject / isBool", doc: "what is this value",
|
|
647
|
+
names: ["type", "isArray", "isString", "isNumber", "isObject", "isBool"] },
|
|
289
648
|
];
|
|
290
649
|
|
|
291
650
|
/**
|
|
@@ -350,14 +709,18 @@ export function validateTransformArg(raw) {
|
|
|
350
709
|
* @param {Array<{name: string, args: any[]}>} chain
|
|
351
710
|
* @returns {any|typeof MISSING}
|
|
352
711
|
*/
|
|
712
|
+
/** Transforms permitted to replace a missing value with a fallback. */
|
|
713
|
+
const FALLBACK_TRANSFORMS = new Set(["default", "coalesce"]);
|
|
714
|
+
|
|
353
715
|
export function applyTransforms(value, chain) {
|
|
354
716
|
let cur = value;
|
|
355
717
|
for (const step of chain) {
|
|
356
718
|
const fn = TRANSFORMS[step.name];
|
|
357
719
|
if (!fn) throw new Error(`Unknown transform "${step.name}". Run \`nxb pipe --transforms\`.`);
|
|
358
|
-
// A missing value stays missing unless the transform
|
|
359
|
-
//
|
|
360
|
-
|
|
720
|
+
// A missing value stays missing unless the transform is explicitly allowed to
|
|
721
|
+
// supply a fallback — `default(v)` and `coalesce(…)`. Anything else would
|
|
722
|
+
// be inventing a value the tool never produced.
|
|
723
|
+
if (cur === MISSING && !FALLBACK_TRANSFORMS.has(step.name)) return MISSING;
|
|
361
724
|
// A step may legitimately take no argument; treat a missing `args` as
|
|
362
725
|
// empty rather than failing to iterate undefined.
|
|
363
726
|
cur = fn(cur, ...(step.args || []));
|
package/src/wizard.js
CHANGED
|
@@ -246,7 +246,7 @@ async function wireFields(manifest, sources, P = defaultPrompts) {
|
|
|
246
246
|
* The returned string is appended to `$path`, so it carries its own `|`.
|
|
247
247
|
*/
|
|
248
248
|
async function promptTransformChain(field, P) {
|
|
249
|
-
const picked = await P.
|
|
249
|
+
const picked = await P.search({
|
|
250
250
|
message: `${field}: transform${chalk.gray(" (optional)")}`,
|
|
251
251
|
source: (term) => transformChoices(term, { withNone: true }),
|
|
252
252
|
pageSize: 15,
|
|
@@ -262,7 +262,7 @@ async function promptTransformChain(field, P) {
|
|
|
262
262
|
default: false,
|
|
263
263
|
});
|
|
264
264
|
if (!more) break;
|
|
265
|
-
const next = await P.
|
|
265
|
+
const next = await P.search({
|
|
266
266
|
message: `${field}: another transform`,
|
|
267
267
|
source: (term) => transformChoices(term, { withNone: true }),
|
|
268
268
|
pageSize: 15,
|
|
@@ -284,7 +284,10 @@ function transformChoices(term, { withNone = false } = {}) {
|
|
|
284
284
|
const hits = term ? rankTransforms(term) : transformNames();
|
|
285
285
|
return [
|
|
286
286
|
...skip,
|
|
287
|
-
|
|
287
|
+
// No cap: this is a searchable list, so it paginates and scrolls. Truncating
|
|
288
|
+
// here silently hid every transform past the cut-off, which is unreachable
|
|
289
|
+
// by typing the name you can see.
|
|
290
|
+
...hits.map((n) => ({
|
|
288
291
|
value: n,
|
|
289
292
|
name: `${chalk.cyan(n.padEnd(10))} ${chalk.dim(TRANSFORM_DOCS[n] || "")}`,
|
|
290
293
|
})),
|