utilful 3.0.2 → 3.2.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.
@@ -0,0 +1,43 @@
1
+ import { error } from "./log.mjs";
2
+ import process from "node:process";
3
+ //#region src/cli/errors.ts
4
+ /**
5
+ * A condition the CLI recognized and phrased for a human. Anything else
6
+ * reaching the boundary is a defect in the tool and prints its stack unasked.
7
+ */
8
+ var CliError = class extends Error {};
9
+ /** Gets usage printed alongside its message. */
10
+ var ArgumentError = class extends CliError {};
11
+ /** Reports a failure the way the boundary does, for one that outlives `run`, such as a watch rebuild. */
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
+ }
19
+ error(sections.join("\n\n"));
20
+ process.exitCode = 1;
21
+ }
22
+ /**
23
+ * Reports whether the CLI raised this error deliberately rather than tripping
24
+ * over it. A system error such as `ENOENT` reaches the boundary as the honest
25
+ * answer to what the user asked for, so it reads as deliberate too, while an
26
+ * `ERR_*` error from a Node API is a wrong call and stays a defect.
27
+ */
28
+ function isExpected(error, expectedErrors) {
29
+ if (error instanceof CliError) return true;
30
+ if (expectedErrors.some((expectedError) => error instanceof expectedError)) return true;
31
+ return error instanceof Error && /^E[A-Z0-9]+$/.test(String(error.code));
32
+ }
33
+ function formatCauseChain(error) {
34
+ const causeLines = [];
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}`);
38
+ current = current.cause;
39
+ }
40
+ return causeLines.join("\n");
41
+ }
42
+ //#endregion
43
+ export { ArgumentError, CliError, reportFailure };
@@ -0,0 +1,5 @@
1
+ import { ArgDef, ArgsDef, BooleanArgDef, CommonArgs, ParsedArgs, PositionalArgDef, StringArgDef, commonArgs, parseArgs } from "./args.mjs";
2
+ import { ArgumentError, CliError, ErrorClass, ReportOptions, reportFailure } from "./errors.mjs";
3
+ import { CommandContext, CommandDef, CommandMeta, RunMainOptions, defineCommand, runCommand, runMain } from "./command.mjs";
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 };
@@ -0,0 +1,5 @@
1
+ import { log_exports } from "./log.mjs";
2
+ import { ArgumentError, CliError, reportFailure } from "./errors.mjs";
3
+ import { commonArgs, parseArgs } from "./args.mjs";
4
+ import { defineCommand, runCommand, runMain } from "./command.mjs";
5
+ export { ArgumentError, CliError, commonArgs, defineCommand, log_exports as log, parseArgs, reportFailure, runCommand, runMain };
@@ -0,0 +1,10 @@
1
+ declare namespace log_d_exports {
2
+ export { blankLine, error, info, success, warn };
3
+ }
4
+ declare function error(message: string): void;
5
+ declare function warn(message: string): void;
6
+ declare function info(message: string): void;
7
+ declare function success(message: string): void;
8
+ declare function blankLine(): void;
9
+ //#endregion
10
+ export { blankLine, error, info, log_d_exports, success, warn };
@@ -0,0 +1,28 @@
1
+ import { __exportAll } from "../_virtual/_rolldown/runtime.mjs";
2
+ import { paint } from "./style.mjs";
3
+ import process from "node:process";
4
+ //#region src/cli/log.ts
5
+ var log_exports = /* @__PURE__ */ __exportAll({
6
+ blankLine: () => blankLine,
7
+ error: () => error,
8
+ info: () => info,
9
+ success: () => success,
10
+ warn: () => warn
11
+ });
12
+ function error(message) {
13
+ console.error(`${paint("red", "✖", process.stderr)} ${message}`);
14
+ }
15
+ function warn(message) {
16
+ console.error(`${paint("yellow", "⚠", process.stderr)} ${message}`);
17
+ }
18
+ function info(message) {
19
+ console.error(`${paint("cyan", "●", process.stderr)} ${message}`);
20
+ }
21
+ function success(message) {
22
+ console.error(`${paint("green", "✔", process.stderr)} ${message}`);
23
+ }
24
+ function blankLine() {
25
+ console.error("");
26
+ }
27
+ //#endregion
28
+ export { blankLine, error, info, log_exports, success, warn };
@@ -0,0 +1,7 @@
1
+ import { styleText } from "node:util";
2
+ //#region src/cli/style.ts
3
+ function paint(style, text, stream) {
4
+ return styleText(style, text, { stream });
5
+ }
6
+ //#endregion
7
+ export { paint };
@@ -0,0 +1,29 @@
1
+ import { ArgsDef } from "./args.mjs";
2
+ import { CommandDef, RunMainOptions } from "./command.mjs";
3
+ //#region src/cli/testing.d.ts
4
+ interface FileMap {
5
+ [relativePath: string]: string;
6
+ }
7
+ interface CliResult {
8
+ stdout: string;
9
+ stderr: string;
10
+ exitCode: number;
11
+ }
12
+ interface RunOptions {
13
+ cwd?: string;
14
+ }
15
+ interface CliHarnessOptions extends Omit<RunMainOptions, "argv"> {
16
+ /** Only `runCliProcess` needs it. */
17
+ entry?: string;
18
+ }
19
+ interface CliHarness {
20
+ runCli: (argv: readonly string[], options?: RunOptions) => Promise<CliResult>;
21
+ /** Runs the entry file as a child process, where the exit code is real and stdout can be cut short by exiting. */
22
+ runCliProcess: (argv: readonly string[], options?: RunOptions) => Promise<CliResult>;
23
+ }
24
+ declare function createCliHarness<T extends ArgsDef>(command: CommandDef<T>, { entry, ...runOptions }?: CliHarnessOptions): CliHarness;
25
+ /** Registers an `afterEach` cleanup, so call it at the top level of a test file. */
26
+ declare function useTemporaryDirectories(prefix?: string): (files?: FileMap) => string;
27
+ declare function mockStdin(input: string): () => void;
28
+ //#endregion
29
+ export { CliHarness, CliHarnessOptions, CliResult, FileMap, RunOptions, createCliHarness, mockStdin, useTemporaryDirectories };
@@ -0,0 +1,120 @@
1
+ import { runMain } from "./command.mjs";
2
+ import process from "node:process";
3
+ import { execFile } from "node:child_process";
4
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
5
+ import * as os from "node:os";
6
+ import * as path from "node:path";
7
+ import { Readable } from "node:stream";
8
+ import { afterEach, vi } from "vitest";
9
+ //#region src/cli/testing.ts
10
+ var ProcessExitError = class extends Error {
11
+ constructor(exitCode) {
12
+ super(`process.exit(${exitCode})`);
13
+ this.exitCode = exitCode;
14
+ }
15
+ };
16
+ function createCliHarness(command, { entry, ...runOptions } = {}) {
17
+ return {
18
+ async runCli(argv, options = {}) {
19
+ const stdout = [];
20
+ const stderr = [];
21
+ const previousExitCode = process.exitCode;
22
+ const previousCwd = process.cwd();
23
+ process.exitCode = void 0;
24
+ const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation((chunk) => {
25
+ stdout.push(String(chunk));
26
+ return true;
27
+ });
28
+ const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk) => {
29
+ stderr.push(String(chunk));
30
+ return true;
31
+ });
32
+ const consoleLogSpy = vi.spyOn(console, "log").mockImplementation((...parts) => {
33
+ stdout.push(`${parts.map(String).join(" ")}\n`);
34
+ });
35
+ const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation((...parts) => {
36
+ stderr.push(`${parts.map(String).join(" ")}\n`);
37
+ });
38
+ const exitSpy = vi.spyOn(process, "exit").mockImplementation((code) => {
39
+ throw new ProcessExitError(typeof code === "number" ? code : 0);
40
+ });
41
+ let exitCode;
42
+ try {
43
+ if (options.cwd !== void 0) process.chdir(options.cwd);
44
+ await runMain(command, {
45
+ ...runOptions,
46
+ argv
47
+ });
48
+ exitCode = process.exitCode ?? 0;
49
+ } catch (error) {
50
+ if (!(error instanceof ProcessExitError)) throw error;
51
+ exitCode = error.exitCode;
52
+ } finally {
53
+ process.chdir(previousCwd);
54
+ process.exitCode = previousExitCode;
55
+ exitSpy.mockRestore();
56
+ consoleErrorSpy.mockRestore();
57
+ consoleLogSpy.mockRestore();
58
+ stderrSpy.mockRestore();
59
+ stdoutSpy.mockRestore();
60
+ }
61
+ return {
62
+ stdout: stdout.join(""),
63
+ stderr: stderr.join(""),
64
+ exitCode
65
+ };
66
+ },
67
+ runCliProcess(argv, options = {}) {
68
+ if (entry === void 0) return Promise.reject(/* @__PURE__ */ new Error("runCliProcess needs the entry file of the CLI"));
69
+ return new Promise((resolve, reject) => {
70
+ execFile(process.execPath, [entry, ...argv], {
71
+ cwd: options.cwd,
72
+ maxBuffer: 64 * 1024 * 1024
73
+ }, (spawnError, stdout, stderr) => {
74
+ if (spawnError && typeof spawnError.code !== "number") reject(spawnError);
75
+ else resolve({
76
+ stdout,
77
+ stderr,
78
+ exitCode: spawnError ? spawnError.code : 0
79
+ });
80
+ });
81
+ });
82
+ }
83
+ };
84
+ }
85
+ /** Registers an `afterEach` cleanup, so call it at the top level of a test file. */
86
+ function useTemporaryDirectories(prefix = "cli-test-") {
87
+ const directories = [];
88
+ afterEach(() => {
89
+ while (directories.length > 0) rmSync(directories.pop(), {
90
+ recursive: true,
91
+ force: true
92
+ });
93
+ });
94
+ return (files = {}) => {
95
+ const directory = mkdtempSync(path.join(os.tmpdir(), prefix));
96
+ directories.push(directory);
97
+ for (const [relativePath, contents] of Object.entries(files)) {
98
+ const filePath = path.join(directory, relativePath);
99
+ mkdirSync(path.dirname(filePath), { recursive: true });
100
+ writeFileSync(filePath, contents, "utf-8");
101
+ }
102
+ return directory;
103
+ };
104
+ }
105
+ function mockStdin(input) {
106
+ const stream = Readable.from([new TextEncoder().encode(input)]);
107
+ const originalStdin = process.stdin;
108
+ Object.defineProperty(process, "stdin", {
109
+ value: stream,
110
+ writable: true
111
+ });
112
+ return () => {
113
+ Object.defineProperty(process, "stdin", {
114
+ value: originalStdin,
115
+ writable: true
116
+ });
117
+ };
118
+ }
119
+ //#endregion
120
+ export { createCliHarness, mockStdin, useTemporaryDirectories };
@@ -0,0 +1,65 @@
1
+ import { paint } from "./style.mjs";
2
+ import { stripVTControlCharacters } from "node:util";
3
+ import process from "node:process";
4
+ //#region src/cli/usage.ts
5
+ function renderUsage(command, { parent, stream = process.stdout } = {}) {
6
+ const color = (style, text) => paint(style, text, stream);
7
+ const heading = (title) => color(["bold", "underline"], title);
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
+ const positionalLines = [];
13
+ const optionLines = [];
14
+ const commandLines = [];
15
+ const usageLine = [];
16
+ for (const [name, definition] of Object.entries(command.args ?? {})) {
17
+ const isRequired = definition.type !== "boolean" && definition.required === true;
18
+ const hasDefault = definition.type !== "positional" && definition.default !== void 0 && definition.default !== false;
19
+ const hints = [
20
+ definition.description,
21
+ isRequired ? color("gray", "(Required)") : void 0,
22
+ hasDefault ? color("gray", `(Default: ${definition.default})`) : void 0
23
+ ].filter((hint) => hint !== void 0).join(" ");
24
+ if (definition.type === "positional") {
25
+ const label = name.toUpperCase();
26
+ positionalLines.push([color("cyan", label), hints]);
27
+ usageLine.push(isRequired ? `<${label}>` : `[${label}]`);
28
+ continue;
29
+ }
30
+ 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;
32
+ optionLines.push([color("cyan", value === void 0 ? spellings : `${spellings}=${value}`), hints]);
33
+ if (definition.type === "boolean" && definition.default === true) optionLines.push([color("cyan", `--no-${name}`), ""]);
34
+ if (isRequired) usageLine.push(`--${name}=${value}`);
35
+ }
36
+ for (const [name, subCommand] of Object.entries(command.subCommands ?? {})) commandLines.push([color("cyan", name), subCommand.meta?.description ?? ""]);
37
+ const hasArguments = positionalLines.length > 0 || optionLines.length > 0;
38
+ if (commandLines.length > 0 && !hasArguments) usageLine.push(Object.keys(command.subCommands).join("|"));
39
+ const lines = [
40
+ color("gray", `${meta.description ?? ""} (${commandName}${version === void 0 ? "" : ` v${version}`})`),
41
+ "",
42
+ `${heading("USAGE")} ${color("cyan", [
43
+ commandName,
44
+ hasArguments ? "[OPTIONS]" : void 0,
45
+ ...usageLine
46
+ ].filter((part) => part !== void 0).join(" "))}`,
47
+ ""
48
+ ];
49
+ for (const [title, rows] of [
50
+ ["ARGUMENTS", positionalLines],
51
+ ["OPTIONS", optionLines],
52
+ ["COMMANDS", commandLines]
53
+ ]) if (rows.length > 0) lines.push(heading(title), "", formatColumns(rows), "");
54
+ if (commandLines.length > 0) lines.push(`Use ${color("cyan", `${commandName} <command> --help`)} for more information about a command.`);
55
+ return lines.join("\n").trimEnd();
56
+ }
57
+ function formatColumns(rows) {
58
+ const widths = rows[0].map((_, column) => Math.max(...rows.map((row) => visibleLength(row[column]))));
59
+ return rows.map((row) => row.map((cell, column) => ` ${cell}${" ".repeat(widths[column] - visibleLength(cell))}`).join("").trimEnd()).join("\n");
60
+ }
61
+ function visibleLength(text) {
62
+ return stripVTControlCharacters(text).length;
63
+ }
64
+ //#endregion
65
+ export { renderUsage };
package/dist/csv.d.mts CHANGED
@@ -106,8 +106,8 @@ declare function createCSVAsync<T extends Record<string, unknown>>(data: AsyncIt
106
106
  * Within quoted values, double quotes are escaped by doubling them.
107
107
  *
108
108
  * @example
109
- * escapeCSVValue('hello, world') // "hello, world"
110
- * escapeCSVValue('contains "quotes"') // "contains ""quotes"""
109
+ * escapeCSVValue('hello, world') // '"hello, world"'
110
+ * escapeCSVValue('contains "quotes"') // '"contains ""quotes"""'
111
111
  */
112
112
  declare function escapeCSVValue(value: unknown, options?: {
113
113
  /** @default ',' */
package/dist/csv.mjs CHANGED
@@ -17,9 +17,9 @@ function createCSV(data, columnsOrOptions, maybeOptions = {}) {
17
17
  columns = inferColumns(data);
18
18
  options = columnsOrOptions ?? {};
19
19
  }
20
- if (columns.length === 0 && data.length === 0) return "";
21
20
  const { delimiter = COMMA, addHeader = true, quoteAll = false, lineEnding = NEWLINE } = options;
22
21
  assertValidCSVDelimiter(delimiter);
22
+ if (columns.length === 0) return "";
23
23
  if (addHeader) {
24
24
  const header = encodeCSVHeader(columns.map(String), delimiter, quoteAll);
25
25
  if (data.length === 0) return header;
@@ -97,8 +97,8 @@ function encodeCSVRow(row, columns, delimiter, quoteAll) {
97
97
  * Within quoted values, double quotes are escaped by doubling them.
98
98
  *
99
99
  * @example
100
- * escapeCSVValue('hello, world') // "hello, world"
101
- * escapeCSVValue('contains "quotes"') // "contains ""quotes"""
100
+ * escapeCSVValue('hello, world') // '"hello, world"'
101
+ * escapeCSVValue('contains "quotes"') // '"contains ""quotes"""'
102
102
  */
103
103
  function escapeCSVValue(value, options = {}) {
104
104
  const { delimiter = COMMA, quoteAll = false } = options;
@@ -4,8 +4,8 @@ type Handler<T = unknown> = (event: T) => void;
4
4
  type WildcardHandler<T = Record<string, unknown>> = (type: keyof T, event: T[keyof T]) => void;
5
5
  type EventHandlerList<T = unknown> = Handler<T>[];
6
6
  type WildCardEventHandlerList<T = Record<string, unknown>> = WildcardHandler<T>[];
7
- type EventHandlerMap<Events extends Record<EventType, unknown>> = Map<keyof Events | "*", EventHandlerList<Events[keyof Events]> | WildCardEventHandlerList<Events>>;
8
- interface Emitter<Events extends Record<EventType, unknown>> {
7
+ type EventHandlerMap<Events extends Record<EventType, any>> = Map<keyof Events | "*", EventHandlerList<Events[keyof Events]> | WildCardEventHandlerList<Events>>;
8
+ interface Emitter<Events extends Record<EventType, any>> {
9
9
  events: EventHandlerMap<Events>;
10
10
  on<Key extends keyof Events>(type: Key, handler: Handler<Events[Key]>): void;
11
11
  on(type: "*", handler: WildcardHandler<Events>): void;
@@ -20,6 +20,6 @@ interface Emitter<Events extends Record<EventType, unknown>> {
20
20
  * @remarks Ported from `mitt`.
21
21
  * @see https://github.com/developit/mitt
22
22
  */
23
- declare function createEmitter<Events extends Record<EventType, unknown>>(events?: EventHandlerMap<Events>): Emitter<Events>;
23
+ declare function createEmitter<Events extends Record<EventType, any>>(events?: EventHandlerMap<Events>): Emitter<Events>;
24
24
  //#endregion
25
25
  export { Emitter, EventHandlerList, EventHandlerMap, EventType, Handler, WildCardEventHandlerList, WildcardHandler, createEmitter };
package/dist/emitter.mjs CHANGED
@@ -14,8 +14,6 @@ function createEmitter(events) {
14
14
  events,
15
15
  /**
16
16
  * Registers an event handler for the given type.
17
- *
18
- * @memberOf createEmitter
19
17
  */
20
18
  on(type, handler) {
21
19
  const handlers = events.get(type);
@@ -27,8 +25,6 @@ function createEmitter(events) {
27
25
  *
28
26
  * @remarks
29
27
  * If `handler` is omitted, all handlers of the given type are removed.
30
- *
31
- * @memberOf createEmitter
32
28
  */
33
29
  off(type, handler) {
34
30
  const handlers = events.get(type);
@@ -41,8 +37,6 @@ function createEmitter(events) {
41
37
  * @remarks
42
38
  * If present, `'*'` handlers are invoked after type-matched handlers.
43
39
  * Manually firing `'*'` handlers is not supported.
44
- *
45
- * @memberOf createEmitter
46
40
  */
47
41
  emit(type, evt) {
48
42
  let handlers = events.get(type);
package/dist/index.d.mts CHANGED
@@ -5,8 +5,8 @@ import { Emitter, EventHandlerList, EventHandlerMap, EventType, Handler, WildCar
5
5
  import { tryParseJSON } from "./json.mjs";
6
6
  import { interopDefault } from "./module.mjs";
7
7
  import { deepApply, isObject, memoize, objectEntries, objectKeys } from "./object.mjs";
8
- import { QueryObject, QueryValue, getPathname, joinURL, withBase, withLeadingSlash, withQuery, withTrailingSlash, withoutBase, withoutLeadingSlash, withoutTrailingSlash } from "./path.mjs";
8
+ import { ParsedQuery, QueryObject, QueryValue, getPathname, getQuery, joinURL, withBase, withLeadingSlash, withQuery, withTrailingSlash, withoutBase, withoutLeadingSlash, withoutTrailingSlash } from "./path.mjs";
9
9
  import { Err, ErrData, Ok, OkData, Result, ResultData, err, isErr, isOk, ok, toResult, tryCatch, unwrapResult } from "./result.mjs";
10
10
  import { TEMPLATE_PLACEHOLDER_RE, generateRandomId, template } from "./string.mjs";
11
- import { AutocompletableString, BrandedType, LooseAutocomplete, UnifyIntersection } from "./types.mjs";
12
- export { AutocompletableString, BrandedType, CSVCreateOptions, CSVParseOptions, CSVRow, Defu, DefuFn, DefuMerger, Emitter, Err, ErrData, EventHandlerList, EventHandlerMap, EventType, Handler, LooseAutocomplete, MaybeArray, Ok, OkData, QueryObject, QueryValue, Result, ResultData, TEMPLATE_PLACEHOLDER_RE, UnifyIntersection, WildCardEventHandlerList, WildcardHandler, createCSV, createCSVAsync, createCSVStream, createDefu, createEmitter, deepApply, defu, err, escapeCSVValue, generateRandomId, getPathname, interopDefault, isErr, isObject, isOk, joinURL, memoize, objectEntries, objectKeys, ok, parseCSV, parseCSVStream, template, toArray, toResult, tryCatch, tryParseJSON, unwrapResult, withBase, withLeadingSlash, withQuery, withTrailingSlash, withoutBase, withoutLeadingSlash, withoutTrailingSlash };
11
+ import { BrandedType, UnifyIntersection } from "./types.mjs";
12
+ export { BrandedType, CSVCreateOptions, CSVParseOptions, CSVRow, Defu, DefuFn, DefuMerger, Emitter, Err, ErrData, EventHandlerList, EventHandlerMap, EventType, Handler, MaybeArray, Ok, OkData, ParsedQuery, QueryObject, QueryValue, Result, ResultData, TEMPLATE_PLACEHOLDER_RE, UnifyIntersection, WildCardEventHandlerList, WildcardHandler, createCSV, createCSVAsync, createCSVStream, createDefu, createEmitter, deepApply, defu, err, escapeCSVValue, generateRandomId, getPathname, getQuery, interopDefault, isErr, isObject, isOk, joinURL, memoize, objectEntries, objectKeys, ok, parseCSV, parseCSVStream, template, toArray, toResult, tryCatch, tryParseJSON, unwrapResult, withBase, withLeadingSlash, withQuery, withTrailingSlash, withoutBase, withoutLeadingSlash, withoutTrailingSlash };
package/dist/index.mjs CHANGED
@@ -5,7 +5,7 @@ import { createEmitter } from "./emitter.mjs";
5
5
  import { tryParseJSON } from "./json.mjs";
6
6
  import { interopDefault } from "./module.mjs";
7
7
  import { deepApply, isObject, memoize, objectEntries, objectKeys } from "./object.mjs";
8
- import { getPathname, joinURL, withBase, withLeadingSlash, withQuery, withTrailingSlash, withoutBase, withoutLeadingSlash, withoutTrailingSlash } from "./path.mjs";
8
+ import { getPathname, getQuery, joinURL, withBase, withLeadingSlash, withQuery, withTrailingSlash, withoutBase, withoutLeadingSlash, withoutTrailingSlash } from "./path.mjs";
9
9
  import { Err, Ok, err, isErr, isOk, ok, toResult, tryCatch, unwrapResult } from "./result.mjs";
10
10
  import { TEMPLATE_PLACEHOLDER_RE, generateRandomId, template } from "./string.mjs";
11
- export { Err, Ok, TEMPLATE_PLACEHOLDER_RE, createCSV, createCSVAsync, createCSVStream, createDefu, createEmitter, deepApply, defu, err, escapeCSVValue, generateRandomId, getPathname, interopDefault, isErr, isObject, isOk, joinURL, memoize, objectEntries, objectKeys, ok, parseCSV, parseCSVStream, template, toArray, toResult, tryCatch, tryParseJSON, unwrapResult, withBase, withLeadingSlash, withQuery, withTrailingSlash, withoutBase, withoutLeadingSlash, withoutTrailingSlash };
11
+ export { Err, Ok, TEMPLATE_PLACEHOLDER_RE, createCSV, createCSVAsync, createCSVStream, createDefu, createEmitter, deepApply, defu, err, escapeCSVValue, generateRandomId, getPathname, getQuery, interopDefault, isErr, isObject, isOk, joinURL, memoize, objectEntries, objectKeys, ok, parseCSV, parseCSVStream, template, toArray, toResult, tryCatch, tryParseJSON, unwrapResult, withBase, withLeadingSlash, withQuery, withTrailingSlash, withoutBase, withoutLeadingSlash, withoutTrailingSlash };
package/dist/object.d.mts CHANGED
@@ -23,9 +23,14 @@ declare function objectKeys<T extends Record<any, any>>(obj: T): Array<`${Extrac
23
23
  */
24
24
  declare function objectEntries<T extends Record<any, any>>(obj: T): Array<[keyof T, T[keyof T]]>;
25
25
  /**
26
- * Deeply applies a callback to every key-value pair in the given object, as well as nested objects and arrays (including arrays nested inside arrays).
26
+ * Applies a callback to every key-value pair of the given object, and to every pair
27
+ * inside nested objects and arrays (including arrays nested inside arrays).
28
+ *
29
+ * @remarks
30
+ * The callback also fires for nested objects, so `item` is whichever object the pair
31
+ * belongs to rather than the one that was passed in.
27
32
  */
28
- declare function deepApply<T extends Record<any, any>>(data: T, callback: (item: T, key: keyof T, value: T[keyof T]) => void): void;
33
+ declare function deepApply<T extends Record<any, any>>(data: T, callback: (item: Record<string, any>, key: string, value: any) => void): void;
29
34
  /**
30
35
  * Checks if a value is an object with the plain `[object Object]` tag.
31
36
  *
package/dist/object.mjs CHANGED
@@ -31,7 +31,12 @@ function objectEntries(obj) {
31
31
  return Object.entries(obj);
32
32
  }
33
33
  /**
34
- * Deeply applies a callback to every key-value pair in the given object, as well as nested objects and arrays (including arrays nested inside arrays).
34
+ * Applies a callback to every key-value pair of the given object, and to every pair
35
+ * inside nested objects and arrays (including arrays nested inside arrays).
36
+ *
37
+ * @remarks
38
+ * The callback also fires for nested objects, so `item` is whichever object the pair
39
+ * belongs to rather than the one that was passed in.
35
40
  */
36
41
  function deepApply(data, callback) {
37
42
  for (const [key, value] of Object.entries(data)) {
package/dist/path.d.mts CHANGED
@@ -1,6 +1,8 @@
1
1
  //#region src/path.d.ts
2
2
  type QueryValue = string | number | boolean | QueryValue[] | Record<string, any> | null | undefined;
3
3
  type QueryObject = Record<string, QueryValue | QueryValue[]>;
4
+ /** Query parameters as read back from a URL, where a repeated key holds all its values. */
5
+ type ParsedQuery = Record<string, string | string[]>;
4
6
  /**
5
7
  * Removes the leading slash from the given path if it has one.
6
8
  */
@@ -25,6 +27,11 @@ declare function withTrailingSlash(path?: string): string;
25
27
  declare function joinURL(...paths: (string | undefined)[]): string;
26
28
  /**
27
29
  * Adds the base path to the input path, if it is not already present.
30
+ *
31
+ * @remarks
32
+ * An absolute URL is returned as it is, since a base path cannot prefix one –
33
+ * whether it carries a scheme (`https://example.com/foo`) or is protocol-relative
34
+ * (`//example.com/foo`).
28
35
  */
29
36
  declare function withBase(input?: string, base?: string): string;
30
37
  /**
@@ -35,13 +42,23 @@ declare function withoutBase(input?: string, base?: string): string;
35
42
  * Returns the pathname of the given path, which is the path without the query string or hash.
36
43
  *
37
44
  * @remarks
38
- * Absolute URLs (with a scheme, e.g. `https://example.com/foo`) return the URL's pathname.
39
- * All other inputs are returned unchanged with the query string and hash removed.
45
+ * Absolute URLs return the segment after the authority, whether they carry a
46
+ * scheme (`https://example.com/foo`) or are protocol-relative (`//example.com/foo`).
47
+ * The result is sliced out verbatim, never normalized, so percent-encoding and
48
+ * `..` segments survive as written.
40
49
  */
41
50
  declare function getPathname(path?: string): string;
42
51
  /**
43
52
  * Returns the URL with the given query parameters. If a query parameter is `undefined`, it is omitted.
44
53
  */
45
54
  declare function withQuery(input: string, query?: QueryObject): string;
55
+ /**
56
+ * Reads the query parameters of the given URL, ignoring the fragment.
57
+ *
58
+ * @remarks
59
+ * A parameter that appears more than once becomes an array of its values,
60
+ * in the order they appear. Values are percent-decoded.
61
+ */
62
+ declare function getQuery(input: string): ParsedQuery;
46
63
  //#endregion
47
- export { QueryObject, QueryValue, getPathname, joinURL, withBase, withLeadingSlash, withQuery, withTrailingSlash, withoutBase, withoutLeadingSlash, withoutTrailingSlash };
64
+ export { ParsedQuery, QueryObject, QueryValue, getPathname, getQuery, joinURL, withBase, withLeadingSlash, withQuery, withTrailingSlash, withoutBase, withoutLeadingSlash, withoutTrailingSlash };
package/dist/path.mjs CHANGED
@@ -1,4 +1,14 @@
1
1
  //#region src/path.ts
2
+ const URL_SCHEME_RE = /^[a-z][\w+.-]*:\/\//i;
3
+ /**
4
+ * Returns the index at which the authority of an absolute URL starts, or `-1`
5
+ * for a path that carries none. A protocol-relative input such as
6
+ * `//example.com/foo` counts as absolute: its authority follows the leading `//`.
7
+ */
8
+ function getAuthorityStart(path) {
9
+ if (path.startsWith("//")) return 2;
10
+ return URL_SCHEME_RE.test(path) ? path.indexOf("://") + 3 : -1;
11
+ }
2
12
  /**
3
13
  * Removes the leading slash from the given path if it has one.
4
14
  */
@@ -66,9 +76,14 @@ function joinURL(...paths) {
66
76
  }
67
77
  /**
68
78
  * Adds the base path to the input path, if it is not already present.
79
+ *
80
+ * @remarks
81
+ * An absolute URL is returned as it is, since a base path cannot prefix one –
82
+ * whether it carries a scheme (`https://example.com/foo`) or is protocol-relative
83
+ * (`//example.com/foo`).
69
84
  */
70
85
  function withBase(input = "", base = "") {
71
- if (!base || base === "/") return input;
86
+ if (!base || base === "/" || getAuthorityStart(input) !== -1) return input;
72
87
  const _base = withoutTrailingSlash(base);
73
88
  if (input.startsWith(_base) && (input.length === _base.length || input[_base.length] === "/" || input[_base.length] === "?" || input[_base.length] === "#")) return input;
74
89
  return joinURL(_base, input);
@@ -83,32 +98,47 @@ function withoutBase(input = "", base = "") {
83
98
  const trimmed = input.slice(_base.length);
84
99
  return trimmed[0] === "/" ? trimmed : `/${trimmed}`;
85
100
  }
86
- const URL_SCHEME_RE = /^[a-z][\w+.-]*:\/\//i;
87
101
  /**
88
102
  * Returns the pathname of the given path, which is the path without the query string or hash.
89
103
  *
90
104
  * @remarks
91
- * Absolute URLs (with a scheme, e.g. `https://example.com/foo`) return the URL's pathname.
92
- * All other inputs are returned unchanged with the query string and hash removed.
105
+ * Absolute URLs return the segment after the authority, whether they carry a
106
+ * scheme (`https://example.com/foo`) or are protocol-relative (`//example.com/foo`).
107
+ * The result is sliced out verbatim, never normalized, so percent-encoding and
108
+ * `..` segments survive as written.
93
109
  */
94
110
  function getPathname(path = "/") {
95
- if (URL_SCHEME_RE.test(path)) return new URL(path).pathname;
111
+ let pathStart = 0;
112
+ const authorityStart = getAuthorityStart(path);
113
+ if (authorityStart !== -1) {
114
+ let authorityEnd = path.length;
115
+ for (let i = authorityStart; i < path.length; i++) {
116
+ const character = path[i];
117
+ if (character === "/" || character === "?" || character === "#") {
118
+ authorityEnd = i;
119
+ break;
120
+ }
121
+ }
122
+ if (path[authorityEnd] !== "/") return "/";
123
+ pathStart = authorityEnd;
124
+ }
96
125
  let pathEnd = path.length;
97
- const queryIndex = path.indexOf("?");
98
- const hashIndex = path.indexOf("#");
126
+ const queryIndex = path.indexOf("?", pathStart);
127
+ const hashIndex = path.indexOf("#", pathStart);
99
128
  if (queryIndex !== -1) pathEnd = queryIndex;
100
129
  if (hashIndex !== -1 && hashIndex < pathEnd) pathEnd = hashIndex;
101
- return path.slice(0, pathEnd) || "/";
130
+ return path.slice(pathStart, pathEnd) || "/";
102
131
  }
103
132
  /**
104
133
  * Returns the URL with the given query parameters. If a query parameter is `undefined`, it is omitted.
105
134
  */
106
135
  function withQuery(input, query) {
107
136
  if (!query || Object.keys(query).length === 0) return input;
108
- const searchIndex = input.indexOf("?");
137
+ const { beforeHash, hash } = splitFragment(input);
138
+ const searchIndex = beforeHash.indexOf("?");
109
139
  const hasExistingParams = searchIndex !== -1;
110
- const base = hasExistingParams ? input.slice(0, searchIndex) : input;
111
- const searchParams = new URLSearchParams(hasExistingParams ? input.slice(searchIndex + 1) : void 0);
140
+ const base = hasExistingParams ? beforeHash.slice(0, searchIndex) : beforeHash;
141
+ const searchParams = new URLSearchParams(hasExistingParams ? beforeHash.slice(searchIndex + 1) : void 0);
112
142
  for (const [key, value] of Object.entries(query)) {
113
143
  if (value === void 0) {
114
144
  searchParams.delete(key);
@@ -120,7 +150,37 @@ function withQuery(input, query) {
120
150
  } else searchParams.set(key, normalizeQueryValue(value));
121
151
  }
122
152
  const queryString = searchParams.toString();
123
- return queryString ? `${base}?${queryString}` : base;
153
+ return queryString ? `${base}?${queryString}${hash}` : base + hash;
154
+ }
155
+ /**
156
+ * Reads the query parameters of the given URL, ignoring the fragment.
157
+ *
158
+ * @remarks
159
+ * A parameter that appears more than once becomes an array of its values,
160
+ * in the order they appear. Values are percent-decoded.
161
+ */
162
+ function getQuery(input) {
163
+ const { beforeHash } = splitFragment(input);
164
+ const searchIndex = beforeHash.indexOf("?");
165
+ if (searchIndex === -1) return {};
166
+ const query = Object.create(null);
167
+ for (const [key, value] of new URLSearchParams(beforeHash.slice(searchIndex + 1))) {
168
+ const existingValue = query[key];
169
+ if (existingValue === void 0) query[key] = value;
170
+ else if (Array.isArray(existingValue)) existingValue.push(value);
171
+ else query[key] = [existingValue, value];
172
+ }
173
+ return { ...query };
174
+ }
175
+ function splitFragment(input) {
176
+ const hashIndex = input.indexOf("#");
177
+ return hashIndex === -1 ? {
178
+ beforeHash: input,
179
+ hash: ""
180
+ } : {
181
+ beforeHash: input.slice(0, hashIndex),
182
+ hash: input.slice(hashIndex)
183
+ };
124
184
  }
125
185
  function normalizeQueryValue(value) {
126
186
  if (value === null) return "";
@@ -129,4 +189,4 @@ function normalizeQueryValue(value) {
129
189
  return String(value);
130
190
  }
131
191
  //#endregion
132
- export { getPathname, joinURL, withBase, withLeadingSlash, withQuery, withTrailingSlash, withoutBase, withoutLeadingSlash, withoutTrailingSlash };
192
+ export { getPathname, getQuery, joinURL, withBase, withLeadingSlash, withQuery, withTrailingSlash, withoutBase, withoutLeadingSlash, withoutTrailingSlash };