utilful 3.3.0 → 3.5.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 +66 -13
- package/dist/cli/args.d.mts +15 -5
- package/dist/cli/args.mjs +14 -6
- package/dist/cli/command.d.mts +7 -2
- package/dist/cli/command.mjs +41 -9
- package/dist/cli/errors.d.mts +1 -1
- package/dist/cli/errors.mjs +10 -9
- package/dist/cli/index.d.mts +4 -2
- package/dist/cli/index.mjs +3 -1
- package/dist/cli/stdin.d.mts +4 -0
- package/dist/cli/stdin.mjs +8 -0
- package/dist/cli/style.d.mts +6 -0
- package/dist/cli/testing.d.mts +2 -0
- package/dist/cli/testing.mjs +20 -6
- package/dist/cli/usage.mjs +2 -5
- package/dist/image/index.d.mts +32 -0
- package/dist/image/index.mjs +69 -0
- package/dist/image/size.mjs +11 -0
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -11,6 +11,7 @@ A collection of TypeScript utilities that I use across my projects.
|
|
|
11
11
|
- [CSV](#csv)
|
|
12
12
|
- [Defu](#defu)
|
|
13
13
|
- [Emitter](#emitter)
|
|
14
|
+
- [Image](#image)
|
|
14
15
|
- [JSON](#json)
|
|
15
16
|
- [Module](#module)
|
|
16
17
|
- [Object](#object)
|
|
@@ -38,7 +39,7 @@ import { defu } from 'utilful' // Everything
|
|
|
38
39
|
import { joinURL } from 'utilful/path' // Just the path helpers
|
|
39
40
|
```
|
|
40
41
|
|
|
41
|
-
|
|
42
|
+
Two modules are the exception and live on their subpaths alone. `cli` is Node-only (24.16 or later) and ships as `utilful/cli` and `utilful/cli/testing`. `image` is browser-only and ships as `utilful/image`.
|
|
42
43
|
|
|
43
44
|
## API
|
|
44
45
|
|
|
@@ -56,23 +57,26 @@ declare function toArray<T>(array?: MaybeArray<T> | null | undefined): T[]
|
|
|
56
57
|
|
|
57
58
|
### CLI
|
|
58
59
|
|
|
59
|
-
A command runner on top of Node's `util.parseArgs`: strict option parsing,
|
|
60
|
+
A command runner on top of Node's `util.parseArgs`: strict option parsing, sub-commands at any depth, `--help`, `--version` and `--verbose` on every command, and an error boundary that prints a recognized error as its message and causes, and anything else with its stack as well. `--verbose` adds the stack to every failure but a wrong argument. `-h` and `-v` belong to the runner, so no option may take either letter as its alias.
|
|
60
61
|
|
|
61
62
|
#### `defineCommand`
|
|
62
63
|
|
|
63
|
-
Types a command definition, so `run` receives its `args` typed by their definitions.
|
|
64
|
+
Types a command definition, so `run` receives its `args` typed by their definitions. Declared `as const`, the arguments also type an exported command under `isolatedDeclarations`.
|
|
64
65
|
|
|
65
66
|
```ts
|
|
66
|
-
import {
|
|
67
|
+
import type { CommandDef } from 'utilful/cli'
|
|
68
|
+
import { CliError, defineCommand } from 'utilful/cli'
|
|
67
69
|
|
|
68
|
-
const
|
|
70
|
+
const buildArgs = {
|
|
71
|
+
'file': { type: 'positional', description: 'Entry file', required: true }, // string
|
|
72
|
+
'out-dir': { type: 'string', alias: 'd', description: 'Output directory', valueHint: 'path' }, // string | undefined
|
|
73
|
+
'format': { type: 'enum', options: ['esm', 'iife'], default: 'esm' }, // 'esm' | 'iife'
|
|
74
|
+
'watch': { type: 'boolean', alias: 'w', description: 'Rebuild on change' }, // boolean, --no-watch turns it off
|
|
75
|
+
} as const
|
|
76
|
+
|
|
77
|
+
export const build: CommandDef<typeof buildArgs> = defineCommand({
|
|
69
78
|
meta: { name: 'build', description: 'Compile the entry file' },
|
|
70
|
-
args:
|
|
71
|
-
...commonArgs, // Adds --verbose
|
|
72
|
-
'file': { type: 'positional', description: 'Entry file', required: true }, // string
|
|
73
|
-
'out-dir': { type: 'string', alias: 'd', description: 'Output directory', valueHint: 'path' }, // string | undefined
|
|
74
|
-
'watch': { type: 'boolean', alias: 'w', description: 'Rebuild on change' }, // boolean, --no-watch turns it off
|
|
75
|
-
},
|
|
79
|
+
args: buildArgs,
|
|
76
80
|
run({ args }) {
|
|
77
81
|
if (args.watch && args['out-dir'] === undefined)
|
|
78
82
|
throw new CliError('--watch needs an --out-dir') // Printed as a message, no stack
|
|
@@ -95,11 +99,11 @@ const main = defineCommand({
|
|
|
95
99
|
void runMain(main, { expectedErrors: [MyLibraryError] }) // Reported like a `CliError`
|
|
96
100
|
```
|
|
97
101
|
|
|
98
|
-
Also exported: `runCommand` (runs the tree and throws instead of reporting), `parseArgs`, `reportFailure` (for a failure that outlives `run`, such as a watch rebuild), and `log` with `error`, `warn`, `info`, `success` and `blankLine`, all writing to stderr.
|
|
102
|
+
Also exported: `runCommand` (runs the tree and throws instead of reporting), `parseArgs`, `reportFailure` (for a failure that outlives `run`, such as a watch rebuild), `readStdin` (everything piped in, as text), `paint` (`styleText` for a given stream, hex colors included), and `log` with `error`, `warn`, `info`, `success` and `blankLine`, all writing to stderr.
|
|
99
103
|
|
|
100
104
|
#### `createCliHarness`
|
|
101
105
|
|
|
102
|
-
From `utilful/cli/testing`, which needs `vitest`. `runCli` runs the tree in-process and captures both streams and the exit code, `runCliProcess` runs the entry file as a child process. `useTemporaryDirectories` and `mockStdin` ship alongside.
|
|
106
|
+
From `utilful/cli/testing`, which needs `vitest`. `runCli` runs the tree in-process and captures both streams and the exit code, `runCliProcess` runs the entry file as a child process. Both take a `cwd` and an `env`, where `undefined` removes a variable. `useTemporaryDirectories` and `mockStdin` ship alongside and clean up after the test.
|
|
103
107
|
|
|
104
108
|
```ts
|
|
105
109
|
import { createCliHarness, useTemporaryDirectories } from 'utilful/cli/testing'
|
|
@@ -428,6 +432,55 @@ emitter.on('foo', onFoo) // Listen
|
|
|
428
432
|
emitter.off('foo', onFoo) // Unlisten
|
|
429
433
|
```
|
|
430
434
|
|
|
435
|
+
### Image
|
|
436
|
+
|
|
437
|
+
#### `toReducedBlob`
|
|
438
|
+
|
|
439
|
+
Downscales an image blob so its longer side fits `maxDimension` and re-encodes it. Resizing happens in `createImageBitmap` with `resizeQuality: 'high'`, so the canvas only ever holds the reduced image.
|
|
440
|
+
|
|
441
|
+
```ts
|
|
442
|
+
type ReducedBlobType = 'image/jpeg' | 'image/png' | 'image/webp'
|
|
443
|
+
|
|
444
|
+
interface ReducedBlobOptions {
|
|
445
|
+
/**
|
|
446
|
+
* Maximum width or height in pixels. Smaller images are never upscaled.
|
|
447
|
+
*/
|
|
448
|
+
maxDimension?: number
|
|
449
|
+
/**
|
|
450
|
+
* MIME type of the output. Defaults to the source type if it is one of these, otherwise `image/jpeg`.
|
|
451
|
+
*/
|
|
452
|
+
type?: ReducedBlobType
|
|
453
|
+
/**
|
|
454
|
+
* Encoder quality between 0 and 1, applied to JPEG and WebP.
|
|
455
|
+
* @default 0.85
|
|
456
|
+
*/
|
|
457
|
+
quality?: number
|
|
458
|
+
/**
|
|
459
|
+
* Whether to re-encode an image that already has the right size and type, which drops EXIF data such as the location.
|
|
460
|
+
* @default false
|
|
461
|
+
*/
|
|
462
|
+
stripMetadata?: boolean
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
declare function toReducedBlob(blob: Blob, options?: ReducedBlobOptions): Promise<Blob>
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
The original blob comes back untouched when the image already fits and the output type matches the source type. Anything else is re-encoded:
|
|
469
|
+
|
|
470
|
+
- Re-encoding drops EXIF data such as the location. Set `stripMetadata` to re-encode an image that would otherwise be returned as it is.
|
|
471
|
+
- Any other source type, like HEIC or GIF, becomes JPEG. Animation is lost and transparent pixels turn black – pass `type: 'image/png'` to keep transparency.
|
|
472
|
+
- The function throws if the browser cannot encode the output type, rather than silently returning PNG. Safari cannot encode WebP, which includes a WebP source without an explicit `type`.
|
|
473
|
+
- Re-encoding an image that is not resized draws it at full size. Safari on iOS 17 and earlier limits a canvas to 16.7 megapixels and throws beyond that, so pass `maxDimension` along for camera photos.
|
|
474
|
+
|
|
475
|
+
**Example:**
|
|
476
|
+
|
|
477
|
+
```ts
|
|
478
|
+
import { toReducedBlob } from 'utilful/image'
|
|
479
|
+
|
|
480
|
+
const reduced = await toReducedBlob(file, { maxDimension: 2048 })
|
|
481
|
+
const webp = await toReducedBlob(file, { maxDimension: 1024, type: 'image/webp', quality: 0.8 })
|
|
482
|
+
```
|
|
483
|
+
|
|
431
484
|
### JSON
|
|
432
485
|
|
|
433
486
|
#### `tryParseJSON`
|
package/dist/cli/args.d.mts
CHANGED
|
@@ -15,30 +15,40 @@ interface BooleanArgDef {
|
|
|
15
15
|
description?: string;
|
|
16
16
|
default?: boolean;
|
|
17
17
|
}
|
|
18
|
+
interface EnumArgDef extends Omit<StringArgDef, "type"> {
|
|
19
|
+
type: "enum";
|
|
20
|
+
options: readonly string[];
|
|
21
|
+
}
|
|
18
22
|
interface PositionalArgDef {
|
|
19
23
|
type: "positional";
|
|
20
24
|
description?: string;
|
|
21
25
|
required?: boolean;
|
|
22
26
|
}
|
|
23
|
-
type ArgDef = StringArgDef | BooleanArgDef | PositionalArgDef;
|
|
27
|
+
type ArgDef = StringArgDef | BooleanArgDef | EnumArgDef | PositionalArgDef;
|
|
24
28
|
type ArgsDef = Record<string, ArgDef>;
|
|
25
29
|
type ParsedArgs<T extends ArgsDef = ArgsDef> = {
|
|
26
30
|
/** Every positional, bound by a definition or not. */
|
|
27
31
|
_: string[];
|
|
28
32
|
} & { [K in keyof T]: ArgDef extends T[K] ? string | boolean | undefined : T[K] extends {
|
|
29
33
|
type: "boolean";
|
|
30
|
-
} ? boolean : T[K] extends {
|
|
34
|
+
} ? boolean : OptionalUnless<T[K], T[K] extends {
|
|
35
|
+
type: "enum";
|
|
36
|
+
options: readonly (infer V)[];
|
|
37
|
+
} ? V : string>; };
|
|
38
|
+
type OptionalUnless<D, V> = D extends {
|
|
31
39
|
default: string;
|
|
32
40
|
} | {
|
|
33
41
|
required: true;
|
|
34
|
-
} ?
|
|
42
|
+
} ? V : V | undefined;
|
|
43
|
+
/** @deprecated See `commonArgs`. */
|
|
35
44
|
interface CommonArgs extends ArgsDef {
|
|
36
45
|
verbose: BooleanArgDef;
|
|
37
46
|
}
|
|
47
|
+
/** @deprecated The runner accepts `--verbose` on every command, so spreading this adds nothing. */
|
|
38
48
|
declare const commonArgs: CommonArgs;
|
|
39
49
|
/** Parses `argv` against a definition. An absent boolean reads as `false`, and `--no-<name>` turns one off. */
|
|
40
|
-
declare function parseArgs$1<T extends ArgsDef>(argv: readonly string[], argsDef: T, { allowExtraPositionals }?: {
|
|
50
|
+
declare function parseArgs$1<const T extends ArgsDef>(argv: readonly string[], argsDef: T, { allowExtraPositionals }?: {
|
|
41
51
|
allowExtraPositionals?: boolean;
|
|
42
52
|
}): ParsedArgs<T>;
|
|
43
53
|
//#endregion
|
|
44
|
-
export { ArgDef, ArgsDef, BooleanArgDef, CommonArgs, ParsedArgs, PositionalArgDef, StringArgDef, commonArgs, parseArgs$1 as parseArgs };
|
|
54
|
+
export { ArgDef, ArgsDef, BooleanArgDef, CommonArgs, EnumArgDef, ParsedArgs, PositionalArgDef, StringArgDef, commonArgs, parseArgs$1 as parseArgs };
|
package/dist/cli/args.mjs
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { ArgumentError } from "./errors.mjs";
|
|
2
2
|
import { parseArgs } from "node:util";
|
|
3
3
|
//#region src/cli/args.ts
|
|
4
|
-
const
|
|
4
|
+
const verboseArg = {
|
|
5
5
|
type: "boolean",
|
|
6
|
-
description: "Print the
|
|
7
|
-
}
|
|
6
|
+
description: "Print the stack trace on failure"
|
|
7
|
+
};
|
|
8
|
+
/** @deprecated The runner accepts `--verbose` on every command, so spreading this adds nothing. */
|
|
9
|
+
const commonArgs = { verbose: verboseArg };
|
|
8
10
|
/** Parses `argv` against a definition. An absent boolean reads as `false`, and `--no-<name>` turns one off. */
|
|
9
11
|
function parseArgs$1(argv, argsDef, { allowExtraPositionals = false } = {}) {
|
|
10
12
|
let parseResult;
|
|
@@ -28,6 +30,7 @@ function parseArgs$1(argv, argsDef, { allowExtraPositionals = false } = {}) {
|
|
|
28
30
|
continue;
|
|
29
31
|
}
|
|
30
32
|
const value = parseResult.values[name];
|
|
33
|
+
if (definition.type === "enum") assertEnumValue(name, definition, value);
|
|
31
34
|
if (definition.type === "boolean") args[name] = value ?? false;
|
|
32
35
|
else if (value === void 0 && definition.required === true) throw new ArgumentError(`Missing required argument: --${name}`);
|
|
33
36
|
else args[name] = value;
|
|
@@ -45,7 +48,7 @@ function toNodeOptions(argsDef) {
|
|
|
45
48
|
const options = {};
|
|
46
49
|
for (const [name, definition] of Object.entries(argsDef)) {
|
|
47
50
|
if (definition.type === "positional") continue;
|
|
48
|
-
const option = { type: definition.type };
|
|
51
|
+
const option = { type: definition.type === "boolean" ? "boolean" : "string" };
|
|
49
52
|
if (definition.alias !== void 0) option.short = definition.alias;
|
|
50
53
|
if (definition.default !== void 0) option.default = definition.default;
|
|
51
54
|
options[name] = option;
|
|
@@ -80,7 +83,7 @@ function splitShortOptionValues(argv) {
|
|
|
80
83
|
function joinNegativeValues(argv, argsDef) {
|
|
81
84
|
const optionNamesBySpelling = /* @__PURE__ */ new Map();
|
|
82
85
|
for (const [name, definition] of Object.entries(argsDef)) {
|
|
83
|
-
if (definition.type !== "string") continue;
|
|
86
|
+
if (definition.type !== "string" && definition.type !== "enum") continue;
|
|
84
87
|
optionNamesBySpelling.set(`--${name}`, name);
|
|
85
88
|
if (definition.alias !== void 0) optionNamesBySpelling.set(`-${definition.alias}`, name);
|
|
86
89
|
}
|
|
@@ -102,8 +105,13 @@ function joinNegativeValues(argv, argsDef) {
|
|
|
102
105
|
}
|
|
103
106
|
return joinedArguments;
|
|
104
107
|
}
|
|
108
|
+
function assertEnumValue(name, definition, value) {
|
|
109
|
+
const options = definition.options.join(", ");
|
|
110
|
+
if (definition.default !== void 0 && !definition.options.includes(definition.default)) throw new Error(`The default of --${name}, ${JSON.stringify(definition.default)}, is not one of: ${options}`);
|
|
111
|
+
if (value !== void 0 && !definition.options.includes(value)) throw new ArgumentError(`Invalid value for --${name}: ${JSON.stringify(value)}. Expected one of: ${options}`);
|
|
112
|
+
}
|
|
105
113
|
function isNodeArgumentError(error) {
|
|
106
114
|
return error instanceof Error && String(error.code).startsWith("ERR_PARSE_ARGS");
|
|
107
115
|
}
|
|
108
116
|
//#endregion
|
|
109
|
-
export { commonArgs, parseArgs$1 as parseArgs, toNodeOptions };
|
|
117
|
+
export { commonArgs, parseArgs$1 as parseArgs, toNodeOptions, verboseArg };
|
package/dist/cli/command.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ArgsDef, ParsedArgs } from "./args.mjs";
|
|
1
|
+
import { ArgDef, ArgsDef, ParsedArgs } from "./args.mjs";
|
|
2
2
|
import { ReportOptions } from "./errors.mjs";
|
|
3
3
|
//#region src/cli/command.d.ts
|
|
4
4
|
interface CommandMeta {
|
|
@@ -20,7 +20,12 @@ interface RunMainOptions extends Omit<ReportOptions, "verbose"> {
|
|
|
20
20
|
/** @default process.argv.slice(2) */
|
|
21
21
|
argv?: readonly string[];
|
|
22
22
|
}
|
|
23
|
-
|
|
23
|
+
type ArgDefKey = ArgDef extends (infer D) ? D extends unknown ? keyof D : never : never;
|
|
24
|
+
/** Maps every key no argument definition knows to `never`, so a misspelled one fails where the arguments are passed. */
|
|
25
|
+
type KnownKeysOnly<T> = { [K in keyof T]: { [P in keyof T[K]]: P extends ArgDefKey ? T[K][P] : never; }; };
|
|
26
|
+
declare function defineCommand<const T extends ArgsDef>(command: CommandDef<T> & {
|
|
27
|
+
args?: KnownKeysOnly<T>;
|
|
28
|
+
}): CommandDef<T>;
|
|
24
29
|
/**
|
|
25
30
|
* Runs the command tree and reports whatever it throws. Help someone asked for
|
|
26
31
|
* is the result of the run and goes to stdout; usage that follows a wrong
|
package/dist/cli/command.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ArgumentError, reportFailure } from "./errors.mjs";
|
|
2
|
-
import { parseArgs as parseArgs$1, toNodeOptions } from "./args.mjs";
|
|
2
|
+
import { parseArgs as parseArgs$1, toNodeOptions, verboseArg } from "./args.mjs";
|
|
3
3
|
import { renderUsage } from "./usage.mjs";
|
|
4
4
|
import { parseArgs } from "node:util";
|
|
5
5
|
import process from "node:process";
|
|
@@ -33,19 +33,51 @@ async function runMain(command, options = {}) {
|
|
|
33
33
|
}
|
|
34
34
|
/** Runs like `runMain`, but without the error boundary. */
|
|
35
35
|
async function runCommand(command, argv) {
|
|
36
|
-
const {
|
|
37
|
-
|
|
38
|
-
if (
|
|
39
|
-
const args = parseArgs$1(rest,
|
|
40
|
-
await
|
|
36
|
+
const { commands, firstOperand, rest } = resolveCommand(command, argv);
|
|
37
|
+
const leaf = withRunnerArgs(commands.at(-1));
|
|
38
|
+
if (isDispatchOnly(leaf)) throw new ArgumentError(firstOperand === void 0 ? "Missing command" : `Unknown command: ${firstOperand}`);
|
|
39
|
+
const args = parseArgs$1(rest, leaf.args ?? {}, { allowExtraPositionals: leaf.allowExtraPositionals });
|
|
40
|
+
await leaf.run?.({ args });
|
|
41
41
|
}
|
|
42
42
|
function usageFor(command, argv, stream) {
|
|
43
|
-
const {
|
|
44
|
-
return
|
|
45
|
-
|
|
43
|
+
const { commands, keys } = resolveCommand(command, argv);
|
|
44
|
+
return renderUsage(withRunnerArgs(commands.at(-1)), {
|
|
45
|
+
commandPath: [command.meta?.name, ...keys].filter((name) => name !== void 0).join(" "),
|
|
46
|
+
version: commands.findLast((walked) => walked.meta?.version !== void 0)?.meta?.version,
|
|
46
47
|
stream
|
|
47
48
|
});
|
|
48
49
|
}
|
|
50
|
+
function resolveCommand(command, argv) {
|
|
51
|
+
const commands = [command];
|
|
52
|
+
const keys = [];
|
|
53
|
+
let rest = [...argv];
|
|
54
|
+
while (true) {
|
|
55
|
+
const found = findSubCommand(commands.at(-1), rest);
|
|
56
|
+
if (found.name === void 0) return {
|
|
57
|
+
commands,
|
|
58
|
+
keys,
|
|
59
|
+
firstOperand: found.firstOperand,
|
|
60
|
+
rest
|
|
61
|
+
};
|
|
62
|
+
commands.push(commands.at(-1).subCommands[found.name]);
|
|
63
|
+
keys.push(found.name);
|
|
64
|
+
rest = found.rest;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function isDispatchOnly(command) {
|
|
68
|
+
return command.subCommands !== void 0 && command.run === void 0;
|
|
69
|
+
}
|
|
70
|
+
/** `runMain` reads `--verbose` from argv itself, so strict parsing has to accept it on every command that parses arguments. */
|
|
71
|
+
function withRunnerArgs(command) {
|
|
72
|
+
if (isDispatchOnly(command)) return command;
|
|
73
|
+
return {
|
|
74
|
+
...command,
|
|
75
|
+
args: {
|
|
76
|
+
...command.args,
|
|
77
|
+
verbose: command.args?.verbose ?? verboseArg
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
}
|
|
49
81
|
/**
|
|
50
82
|
* Finds the sub-command the first operand names, wherever it stands among the
|
|
51
83
|
* options. Telling an operand from the value of an option needs every
|
package/dist/cli/errors.d.mts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type ErrorClass = abstract new (...args: never[]) => Error;
|
|
3
3
|
interface ReportOptions {
|
|
4
4
|
verbose?: boolean;
|
|
5
|
-
/** Treated like `CliError`:
|
|
5
|
+
/** Treated like `CliError`: no stack unless `verbose`. */
|
|
6
6
|
expectedErrors?: readonly ErrorClass[];
|
|
7
7
|
/** Renders an error where its message alone will not do; `undefined` falls back to the message. */
|
|
8
8
|
describe?: (error: Error) => string | undefined;
|
package/dist/cli/errors.mjs
CHANGED
|
@@ -10,12 +10,11 @@ var CliError = class extends Error {};
|
|
|
10
10
|
var ArgumentError = class extends CliError {};
|
|
11
11
|
/** Reports a failure the way the boundary does, for one that outlives `run`, such as a watch rebuild. */
|
|
12
12
|
function reportFailure(error$1, { verbose = false, expectedErrors = [], describe } = {}) {
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
13
|
+
const message = error$1 instanceof Error ? describe?.(error$1) ?? error$1.message : String(error$1);
|
|
14
|
+
const sections = [message];
|
|
15
|
+
const causeChain = formatCauseChain(error$1, message);
|
|
16
|
+
if (causeChain) sections.push(causeChain);
|
|
17
|
+
if ((verbose || !isExpected(error$1, expectedErrors)) && error$1 instanceof Error && error$1.stack) sections.push(error$1.stack);
|
|
19
18
|
error(sections.join("\n\n"));
|
|
20
19
|
process.exitCode = 1;
|
|
21
20
|
}
|
|
@@ -30,11 +29,13 @@ function isExpected(error, expectedErrors) {
|
|
|
30
29
|
if (expectedErrors.some((expectedError) => error instanceof expectedError)) return true;
|
|
31
30
|
return error instanceof Error && /^E[A-Z0-9]+$/.test(String(error.code));
|
|
32
31
|
}
|
|
33
|
-
function formatCauseChain(error) {
|
|
32
|
+
function formatCauseChain(error, message) {
|
|
34
33
|
const causeLines = [];
|
|
34
|
+
const seen = /* @__PURE__ */ new Set([error]);
|
|
35
35
|
let current = error instanceof Error ? error.cause : void 0;
|
|
36
|
-
while (current instanceof Error) {
|
|
37
|
-
|
|
36
|
+
while (current instanceof Error && !seen.has(current)) {
|
|
37
|
+
seen.add(current);
|
|
38
|
+
if (current.message !== message) causeLines.push(`Caused by: ${current.name || "Error"}: ${current.message}`);
|
|
38
39
|
current = current.cause;
|
|
39
40
|
}
|
|
40
41
|
return causeLines.join("\n");
|
package/dist/cli/index.d.mts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { ArgDef, ArgsDef, BooleanArgDef, CommonArgs, ParsedArgs, PositionalArgDef, StringArgDef, commonArgs, parseArgs } from "./args.mjs";
|
|
1
|
+
import { ArgDef, ArgsDef, BooleanArgDef, CommonArgs, EnumArgDef, ParsedArgs, PositionalArgDef, StringArgDef, commonArgs, parseArgs } from "./args.mjs";
|
|
2
2
|
import { ArgumentError, CliError, ErrorClass, ReportOptions, reportFailure } from "./errors.mjs";
|
|
3
3
|
import { CommandContext, CommandDef, CommandMeta, RunMainOptions, defineCommand, runCommand, runMain } from "./command.mjs";
|
|
4
4
|
import { log_d_exports } from "./log.mjs";
|
|
5
|
-
|
|
5
|
+
import { readStdin } from "./stdin.mjs";
|
|
6
|
+
import { Style, paint } from "./style.mjs";
|
|
7
|
+
export { type ArgDef, type ArgsDef, ArgumentError, type BooleanArgDef, CliError, type CommandContext, type CommandDef, type CommandMeta, type CommonArgs, type EnumArgDef, type ErrorClass, type ParsedArgs, type PositionalArgDef, type ReportOptions, type RunMainOptions, type StringArgDef, type Style, commonArgs, defineCommand, log_d_exports as log, paint, parseArgs, readStdin, reportFailure, runCommand, runMain };
|
package/dist/cli/index.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { paint } from "./style.mjs";
|
|
1
2
|
import { log_exports } from "./log.mjs";
|
|
2
3
|
import { ArgumentError, CliError, reportFailure } from "./errors.mjs";
|
|
3
4
|
import { commonArgs, parseArgs } from "./args.mjs";
|
|
4
5
|
import { defineCommand, runCommand, runMain } from "./command.mjs";
|
|
5
|
-
|
|
6
|
+
import { readStdin } from "./stdin.mjs";
|
|
7
|
+
export { ArgumentError, CliError, commonArgs, defineCommand, log_exports as log, paint, parseArgs, readStdin, reportFailure, runCommand, runMain };
|
package/dist/cli/testing.d.mts
CHANGED
|
@@ -11,6 +11,7 @@ interface CliResult {
|
|
|
11
11
|
}
|
|
12
12
|
interface RunOptions {
|
|
13
13
|
cwd?: string;
|
|
14
|
+
env?: Record<string, string | undefined>;
|
|
14
15
|
}
|
|
15
16
|
interface CliHarnessOptions extends Omit<RunMainOptions, "argv"> {
|
|
16
17
|
/** Only `runCliProcess` needs it. */
|
|
@@ -24,6 +25,7 @@ interface CliHarness {
|
|
|
24
25
|
declare function createCliHarness<T extends ArgsDef>(command: CommandDef<T>, { entry, ...runOptions }?: CliHarnessOptions): CliHarness;
|
|
25
26
|
/** Registers an `afterEach` cleanup, so call it at the top level of a test file. */
|
|
26
27
|
declare function useTemporaryDirectories(prefix?: string): (files?: FileMap) => string;
|
|
28
|
+
/** Pipes `input` into stdin until the test finishes; the function it returns restores stdin earlier. */
|
|
27
29
|
declare function mockStdin(input: string): () => void;
|
|
28
30
|
//#endregion
|
|
29
31
|
export { CliHarness, CliHarnessOptions, CliResult, FileMap, RunOptions, createCliHarness, mockStdin, useTemporaryDirectories };
|
package/dist/cli/testing.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
|
5
5
|
import * as os from "node:os";
|
|
6
6
|
import * as path from "node:path";
|
|
7
7
|
import { Readable } from "node:stream";
|
|
8
|
-
import { afterEach, vi } from "vitest";
|
|
8
|
+
import { afterEach, onTestFinished, vi } from "vitest";
|
|
9
9
|
//#region src/cli/testing.ts
|
|
10
10
|
var ProcessExitError = class extends Error {
|
|
11
11
|
constructor(exitCode) {
|
|
@@ -20,6 +20,7 @@ function createCliHarness(command, { entry, ...runOptions } = {}) {
|
|
|
20
20
|
const stderr = [];
|
|
21
21
|
const previousExitCode = process.exitCode;
|
|
22
22
|
const previousCwd = process.cwd();
|
|
23
|
+
const previousEnv = Object.fromEntries(Object.keys(options.env ?? {}).map((name) => [name, process.env[name]]));
|
|
23
24
|
process.exitCode = void 0;
|
|
24
25
|
const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation((chunk) => {
|
|
25
26
|
stdout.push(String(chunk));
|
|
@@ -41,6 +42,7 @@ function createCliHarness(command, { entry, ...runOptions } = {}) {
|
|
|
41
42
|
let exitCode;
|
|
42
43
|
try {
|
|
43
44
|
if (options.cwd !== void 0) process.chdir(options.cwd);
|
|
45
|
+
setEnv(options.env ?? {});
|
|
44
46
|
await runMain(command, {
|
|
45
47
|
...runOptions,
|
|
46
48
|
argv
|
|
@@ -51,6 +53,7 @@ function createCliHarness(command, { entry, ...runOptions } = {}) {
|
|
|
51
53
|
exitCode = error.exitCode;
|
|
52
54
|
} finally {
|
|
53
55
|
process.chdir(previousCwd);
|
|
56
|
+
setEnv(previousEnv);
|
|
54
57
|
process.exitCode = previousExitCode;
|
|
55
58
|
exitSpy.mockRestore();
|
|
56
59
|
consoleErrorSpy.mockRestore();
|
|
@@ -69,6 +72,10 @@ function createCliHarness(command, { entry, ...runOptions } = {}) {
|
|
|
69
72
|
return new Promise((resolve, reject) => {
|
|
70
73
|
execFile(process.execPath, [entry, ...argv], {
|
|
71
74
|
cwd: options.cwd,
|
|
75
|
+
env: {
|
|
76
|
+
...process.env,
|
|
77
|
+
...options.env
|
|
78
|
+
},
|
|
72
79
|
maxBuffer: 67108864
|
|
73
80
|
}, (spawnError, stdout, stderr) => {
|
|
74
81
|
if (spawnError && typeof spawnError.code !== "number") reject(spawnError);
|
|
@@ -82,6 +89,10 @@ function createCliHarness(command, { entry, ...runOptions } = {}) {
|
|
|
82
89
|
}
|
|
83
90
|
};
|
|
84
91
|
}
|
|
92
|
+
function setEnv(env) {
|
|
93
|
+
for (const [name, value] of Object.entries(env)) if (value === void 0) delete process.env[name];
|
|
94
|
+
else process.env[name] = value;
|
|
95
|
+
}
|
|
85
96
|
/** Registers an `afterEach` cleanup, so call it at the top level of a test file. */
|
|
86
97
|
function useTemporaryDirectories(prefix = "cli-test-") {
|
|
87
98
|
const directories = [];
|
|
@@ -102,19 +113,22 @@ function useTemporaryDirectories(prefix = "cli-test-") {
|
|
|
102
113
|
return directory;
|
|
103
114
|
};
|
|
104
115
|
}
|
|
116
|
+
/** Pipes `input` into stdin until the test finishes; the function it returns restores stdin earlier. */
|
|
105
117
|
function mockStdin(input) {
|
|
106
118
|
const stream = Readable.from([new TextEncoder().encode(input)]);
|
|
107
119
|
const originalStdin = process.stdin;
|
|
108
|
-
|
|
109
|
-
value: stream,
|
|
110
|
-
writable: true
|
|
111
|
-
});
|
|
112
|
-
return () => {
|
|
120
|
+
const restoreStdin = () => {
|
|
113
121
|
Object.defineProperty(process, "stdin", {
|
|
114
122
|
value: originalStdin,
|
|
115
123
|
writable: true
|
|
116
124
|
});
|
|
117
125
|
};
|
|
126
|
+
onTestFinished(restoreStdin);
|
|
127
|
+
Object.defineProperty(process, "stdin", {
|
|
128
|
+
value: stream,
|
|
129
|
+
writable: true
|
|
130
|
+
});
|
|
131
|
+
return restoreStdin;
|
|
118
132
|
}
|
|
119
133
|
//#endregion
|
|
120
134
|
export { createCliHarness, mockStdin, useTemporaryDirectories };
|
package/dist/cli/usage.mjs
CHANGED
|
@@ -2,13 +2,10 @@ import { paint } from "./style.mjs";
|
|
|
2
2
|
import { stripVTControlCharacters } from "node:util";
|
|
3
3
|
import process from "node:process";
|
|
4
4
|
//#region src/cli/usage.ts
|
|
5
|
-
function renderUsage(command, {
|
|
5
|
+
function renderUsage(command, { commandPath: commandName = command.meta?.name ?? "", version = command.meta?.version, stream = process.stdout } = {}) {
|
|
6
6
|
const color = (style, text) => paint(style, text, stream);
|
|
7
7
|
const heading = (title) => color("bold", title);
|
|
8
8
|
const meta = command.meta ?? {};
|
|
9
|
-
const parentMeta = parent?.meta ?? {};
|
|
10
|
-
const commandName = [parentMeta.name, meta.name].filter((name) => name !== void 0).join(" ");
|
|
11
|
-
const version = meta.version ?? parentMeta.version;
|
|
12
9
|
const positionalLines = [];
|
|
13
10
|
const optionLines = [];
|
|
14
11
|
const commandLines = [];
|
|
@@ -28,7 +25,7 @@ function renderUsage(command, { parent, stream = process.stdout } = {}) {
|
|
|
28
25
|
continue;
|
|
29
26
|
}
|
|
30
27
|
const spellings = [definition.alias === void 0 ? void 0 : `-${definition.alias}`, `--${name}`].filter((spelling) => spelling !== void 0).join(", ");
|
|
31
|
-
const value = definition.type === "
|
|
28
|
+
const value = definition.type === "boolean" ? void 0 : `<${definition.valueHint ?? (definition.type === "enum" ? definition.options.join("|") : name)}>`;
|
|
32
29
|
optionLines.push([color("cyan", spellings) + (value === void 0 ? "" : color("dim", `=${value}`)), hints]);
|
|
33
30
|
if (definition.type === "boolean" && definition.default === true) optionLines.push([color("cyan", `--no-${name}`), ""]);
|
|
34
31
|
if (isRequired) usageLine.push(`--${name}=${value}`);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
//#region src/image/index.d.ts
|
|
2
|
+
type ReducedBlobType = typeof REDUCED_BLOB_TYPES[number];
|
|
3
|
+
interface ReducedBlobOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Maximum width or height in pixels. Smaller images are never upscaled.
|
|
6
|
+
*/
|
|
7
|
+
maxDimension?: number;
|
|
8
|
+
/**
|
|
9
|
+
* MIME type of the output. Defaults to the source type if it is one of these, otherwise `image/jpeg`.
|
|
10
|
+
*/
|
|
11
|
+
type?: ReducedBlobType;
|
|
12
|
+
/**
|
|
13
|
+
* Encoder quality between 0 and 1, applied to JPEG and WebP.
|
|
14
|
+
* @default 0.85
|
|
15
|
+
*/
|
|
16
|
+
quality?: number;
|
|
17
|
+
/**
|
|
18
|
+
* Whether to re-encode an image that already has the right size and type, which drops EXIF data such as the location.
|
|
19
|
+
* @default false
|
|
20
|
+
*/
|
|
21
|
+
stripMetadata?: boolean;
|
|
22
|
+
}
|
|
23
|
+
declare const REDUCED_BLOB_TYPES: readonly ["image/jpeg", "image/png", "image/webp"];
|
|
24
|
+
/**
|
|
25
|
+
* Downscales an image blob to fit the maximum dimension and re-encodes it, preserving the aspect ratio.
|
|
26
|
+
*
|
|
27
|
+
* @remarks
|
|
28
|
+
* Returns the original blob if nothing needs to change. Browser-only.
|
|
29
|
+
*/
|
|
30
|
+
declare function toReducedBlob(blob: Blob, options?: ReducedBlobOptions): Promise<Blob>;
|
|
31
|
+
//#endregion
|
|
32
|
+
export { ReducedBlobOptions, ReducedBlobType, toReducedBlob };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { getReducedSize } from "./size.mjs";
|
|
2
|
+
//#region src/image/index.ts
|
|
3
|
+
const REDUCED_BLOB_TYPES = [
|
|
4
|
+
"image/jpeg",
|
|
5
|
+
"image/png",
|
|
6
|
+
"image/webp"
|
|
7
|
+
];
|
|
8
|
+
const DEFAULT_QUALITY = .85;
|
|
9
|
+
/**
|
|
10
|
+
* Downscales an image blob to fit the maximum dimension and re-encodes it, preserving the aspect ratio.
|
|
11
|
+
*
|
|
12
|
+
* @remarks
|
|
13
|
+
* Returns the original blob if nothing needs to change. Browser-only.
|
|
14
|
+
*/
|
|
15
|
+
async function toReducedBlob(blob, options = {}) {
|
|
16
|
+
const { maxDimension, type, quality = DEFAULT_QUALITY, stripMetadata = false } = options;
|
|
17
|
+
if (!maxDimension && !type && !stripMetadata) return blob;
|
|
18
|
+
const outputType = type ?? (isReducedBlobType(blob.type) ? blob.type : "image/jpeg");
|
|
19
|
+
const source = await createImageBitmap(blob);
|
|
20
|
+
let resized;
|
|
21
|
+
try {
|
|
22
|
+
const size = getReducedSize(source, maxDimension);
|
|
23
|
+
const isResized = size.width !== source.width || size.height !== source.height;
|
|
24
|
+
if (!isResized && outputType === blob.type && !stripMetadata) return blob;
|
|
25
|
+
if (isResized) resized = await createImageBitmap(source, {
|
|
26
|
+
resizeWidth: size.width,
|
|
27
|
+
resizeHeight: size.height,
|
|
28
|
+
resizeQuality: "high"
|
|
29
|
+
});
|
|
30
|
+
return await encodeBitmap(resized ?? source, outputType, quality);
|
|
31
|
+
} finally {
|
|
32
|
+
source.close();
|
|
33
|
+
resized?.close();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function isReducedBlobType(type) {
|
|
37
|
+
return REDUCED_BLOB_TYPES.includes(type);
|
|
38
|
+
}
|
|
39
|
+
async function encodeBitmap(bitmap, type, quality) {
|
|
40
|
+
let blob;
|
|
41
|
+
if (typeof OffscreenCanvas !== "undefined") {
|
|
42
|
+
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
|
|
43
|
+
drawBitmap(canvas.getContext("2d"), bitmap);
|
|
44
|
+
blob = await canvas.convertToBlob({
|
|
45
|
+
type,
|
|
46
|
+
quality
|
|
47
|
+
});
|
|
48
|
+
} else {
|
|
49
|
+
const canvas = document.createElement("canvas");
|
|
50
|
+
canvas.width = bitmap.width;
|
|
51
|
+
canvas.height = bitmap.height;
|
|
52
|
+
try {
|
|
53
|
+
drawBitmap(canvas.getContext("2d"), bitmap);
|
|
54
|
+
blob = await new Promise((resolve) => canvas.toBlob(resolve, type, quality));
|
|
55
|
+
} finally {
|
|
56
|
+
canvas.width = 0;
|
|
57
|
+
canvas.height = 0;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (!blob) throw new Error("Failed to encode the image");
|
|
61
|
+
if (blob.type !== type) throw new Error(`This browser cannot encode ${type}`);
|
|
62
|
+
return blob;
|
|
63
|
+
}
|
|
64
|
+
function drawBitmap(context, bitmap) {
|
|
65
|
+
if (!context) throw new Error("Failed to get a 2D canvas context");
|
|
66
|
+
context.drawImage(bitmap, 0, 0);
|
|
67
|
+
}
|
|
68
|
+
//#endregion
|
|
69
|
+
export { toReducedBlob };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
//#region src/image/size.ts
|
|
2
|
+
function getReducedSize(size, maxDimension) {
|
|
3
|
+
if (!maxDimension || Math.max(size.width, size.height) <= maxDimension) return size;
|
|
4
|
+
const ratio = maxDimension / Math.max(size.width, size.height);
|
|
5
|
+
return {
|
|
6
|
+
width: Math.max(1, Math.round(size.width * ratio)),
|
|
7
|
+
height: Math.max(1, Math.round(size.height * ratio))
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
//#endregion
|
|
11
|
+
export { getReducedSize };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "utilful",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "3.
|
|
4
|
+
"version": "3.5.0",
|
|
5
5
|
"packageManager": "pnpm@11.25.0",
|
|
6
6
|
"description": "A collection of TypeScript utilities",
|
|
7
7
|
"author": "Johann Schopplich <hello@johannschopplich.com>",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"csv",
|
|
20
20
|
"defu",
|
|
21
21
|
"emitter",
|
|
22
|
+
"image",
|
|
22
23
|
"result",
|
|
23
24
|
"typescript",
|
|
24
25
|
"url",
|
|
@@ -54,6 +55,10 @@
|
|
|
54
55
|
"types": "./dist/emitter.d.mts",
|
|
55
56
|
"default": "./dist/emitter.mjs"
|
|
56
57
|
},
|
|
58
|
+
"./image": {
|
|
59
|
+
"types": "./dist/image/index.d.mts",
|
|
60
|
+
"default": "./dist/image/index.mjs"
|
|
61
|
+
},
|
|
57
62
|
"./json": {
|
|
58
63
|
"types": "./dist/json.d.mts",
|
|
59
64
|
"default": "./dist/json.mjs"
|