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 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
- The `cli` module is the exception: it is Node-only (22.13 or later) and lives on its subpaths `utilful/cli` and `utilful/cli/testing` alone.
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, one level of sub-commands, `--help` and `--version`, and an error boundary that prints a recognized error as a message and anything else with its stack. `-h` and `-v` belong to the runner, so no option may take either letter as its alias.
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 { CliError, commonArgs, defineCommand } from 'utilful/cli'
67
+ import type { CommandDef } from 'utilful/cli'
68
+ import { CliError, defineCommand } from 'utilful/cli'
67
69
 
68
- const build = defineCommand({
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`
@@ -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
- } ? string : string | undefined; };
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 commonArgs = { verbose: {
4
+ const verboseArg = {
5
5
  type: "boolean",
6
- description: "Print the cause chain and stack trace on failure"
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 };
@@ -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
- declare function defineCommand<T extends ArgsDef>(command: CommandDef<T>): CommandDef<T>;
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
@@ -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 { name, firstOperand, rest } = findSubCommand(command, argv);
37
- if (name !== void 0) return runCommand(command.subCommands[name], rest);
38
- if (command.subCommands !== void 0 && command.run === void 0) throw new ArgumentError(firstOperand === void 0 ? "Missing command" : `Unknown command: ${firstOperand}`);
39
- const args = parseArgs$1(rest, command.args ?? {}, { allowExtraPositionals: command.allowExtraPositionals });
40
- await command.run?.({ args });
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 { name } = findSubCommand(command, argv);
44
- return name === void 0 ? renderUsage(command, { stream }) : renderUsage(command.subCommands[name], {
45
- parent: command,
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
@@ -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`: message only, no stack. */
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;
@@ -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 sections = [error$1 instanceof Error ? describe?.(error$1) ?? error$1.message : String(error$1)];
14
- if (verbose || !isExpected(error$1, expectedErrors)) {
15
- const causeChain = formatCauseChain(error$1);
16
- if (causeChain) sections.push(causeChain);
17
- if (error$1 instanceof Error && error$1.stack) sections.push(error$1.stack);
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
- causeLines.push(`Caused by: ${current.name || "Error"}: ${current.message}`);
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");
@@ -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
- export { type ArgDef, type ArgsDef, ArgumentError, type BooleanArgDef, CliError, type CommandContext, type CommandDef, type CommandMeta, type CommonArgs, type ErrorClass, type ParsedArgs, type PositionalArgDef, type ReportOptions, type RunMainOptions, type StringArgDef, commonArgs, defineCommand, log_d_exports as log, parseArgs, reportFailure, runCommand, runMain };
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 };
@@ -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
- export { ArgumentError, CliError, commonArgs, defineCommand, log_exports as log, parseArgs, reportFailure, runCommand, runMain };
6
+ import { readStdin } from "./stdin.mjs";
7
+ export { ArgumentError, CliError, commonArgs, defineCommand, log_exports as log, paint, parseArgs, readStdin, reportFailure, runCommand, runMain };
@@ -0,0 +1,4 @@
1
+ //#region src/cli/stdin.d.ts
2
+ declare function readStdin(): Promise<string>;
3
+ //#endregion
4
+ export { readStdin };
@@ -0,0 +1,8 @@
1
+ import process from "node:process";
2
+ import { text } from "node:stream/consumers";
3
+ //#region src/cli/stdin.ts
4
+ function readStdin() {
5
+ return text(process.stdin);
6
+ }
7
+ //#endregion
8
+ export { readStdin };
@@ -0,0 +1,6 @@
1
+ import { styleText } from "node:util";
2
+ //#region src/cli/style.d.ts
3
+ type Style = Parameters<typeof styleText>[0];
4
+ declare function paint(style: Style, text: string, stream: NodeJS.WriteStream): string;
5
+ //#endregion
6
+ export { Style, paint };
@@ -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 };
@@ -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
- Object.defineProperty(process, "stdin", {
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 };
@@ -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, { parent, stream = process.stdout } = {}) {
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 === "string" ? `<${definition.valueHint ?? name}>` : void 0;
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.3.0",
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"