From 9c9b935366c2dbeee243de7c9a7074b9b92b70d5 Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Tue, 6 Jan 2026 13:31:21 -0800 Subject: [PATCH 1/3] feat: add camelCaseValues transform for kebab-to-camel option keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a transform helper for use with map() that converts kebab-case option keys to camelCase in the result values: const parser = map( opt.options({ 'output-dir': opt.string() }), camelCaseValues, ); // --output-dir ./dist β†’ values.outputDir Also fixes map() to properly compose transforms when chaining multiple map() calls (was previously overwriting instead of chaining). Includes: - camelCaseValues transform function - KebabToCamel and CamelCaseKeys type utilities - Tests for the new functionality - README documentation - Updated transforms.ts example to demonstrate usage πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- README.md | 30 +++++++++++++++ examples/transforms.ts | 15 +++++--- src/bargs.ts | 80 ++++++++++++++++++++++++++++++++++++++-- src/index.ts | 5 ++- src/types.ts | 50 +++++++++++++++++++++---- test/combinators.test.ts | 59 ++++++++++++++++++++++++++++- 6 files changed, 220 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 0a930d8..2f57f64 100644 --- a/README.md +++ b/README.md @@ -480,6 +480,36 @@ const globals = map( ); ``` +### CamelCase Option Keys + +If you prefer camelCase property names instead of kebab-case, use the `camelCaseValues` transform: + +```typescript +import { bargs, map, opt, camelCaseValues } from '@boneskull/bargs'; + +const { values } = await bargs + .create('my-cli') + .globals( + map( + opt.options({ + 'output-dir': opt.string({ default: '/tmp' }), + 'dry-run': opt.boolean(), + }), + camelCaseValues, + ), + ) + .parseAsync(['--output-dir', './dist', '--dry-run']); + +console.log(values.outputDir); // './dist' +console.log(values.dryRun); // true +``` + +The `camelCaseValues` transform: + +- Converts all kebab-case keys to camelCase (`output-dir` β†’ `outputDir`) +- Preserves keys that are already camelCase or have no hyphens +- Is fully type-safeβ€”TypeScript knows the transformed key names + ## Epilog By default, **bargs** displays your package's homepage and repository URLs (from `package.json`) at the end of help output. URLs become clickable hyperlinks in supported terminals. diff --git a/examples/transforms.ts b/examples/transforms.ts index db46945..223d1a8 100644 --- a/examples/transforms.ts +++ b/examples/transforms.ts @@ -5,6 +5,7 @@ * Demonstrates how to use map() transforms: * * - Global transforms via map() applied to globals parser + * - Using camelCaseValues to convert kebab-case to camelCase * - Command-specific transforms via map() in command parsers * - Computed/derived values flowing through handlers * - Full type inference with the (Parser, handler) API @@ -15,7 +16,7 @@ */ import { existsSync, readFileSync } from 'node:fs'; -import { bargs, map, opt, pos } from '../src/index.js'; +import { bargs, camelCaseValues, map, opt, pos } from '../src/index.js'; // ═══════════════════════════════════════════════════════════════════════════════ // CONFIG TYPE @@ -31,15 +32,18 @@ interface Config { // GLOBAL OPTIONS WITH TRANSFORM // ═══════════════════════════════════════════════════════════════════════════════ -// Global options with transform that loads config from file +// Global options using kebab-case (CLI-friendly) const baseGlobals = opt.options({ config: opt.string(), - outputDir: opt.string(), + 'output-dir': opt.string(), // CLI: --output-dir verbose: opt.boolean({ default: false }), }); -// Apply transform to add computed properties using map(parser, fn) form -const globals = map(baseGlobals, ({ positionals, values }) => { +// First, convert kebab-case to camelCase for ergonomic property access +const camelGlobals = map(baseGlobals, camelCaseValues); + +// Then apply additional transforms for computed properties +const globals = map(camelGlobals, ({ positionals, values }) => { let fileConfig: Config = {}; // Load config from JSON file if specified @@ -49,6 +53,7 @@ const globals = map(baseGlobals, ({ positionals, values }) => { } // Return enriched values with file config merged in + // Note: values.outputDir is now camelCase thanks to camelCaseValues! return { positionals, values: { diff --git a/src/bargs.ts b/src/bargs.ts index 841ea64..a94fb29 100644 --- a/src/bargs.ts +++ b/src/bargs.ts @@ -8,6 +8,7 @@ */ import type { + CamelCaseKeys, CliBuilder, Command, CreateOptions, @@ -155,30 +156,59 @@ export function map< parserOrFn: Parser | TransformFn, maybeFn?: TransformFn, ): ((parser: Parser) => Parser) | Parser { + // Helper to compose transforms (chains existing + new) + const composeTransform = ( + parser: Parser, + fn: TransformFn, + ): TransformFn => { + const existing = ( + parser as { + __transform?: ( + r: ParseResult, + ) => ParseResult | Promise>; + } + ).__transform; + + if (!existing) { + return fn as TransformFn; + } + + // Chain: existing transform first, then new transform + return (r: ParseResult) => { + const r1 = existing(r); + if (r1 instanceof Promise) { + return r1.then(fn); + } + return fn(r1); + }; + }; + // Direct form: map(parser, fn) returns Parser // Check for Parser first since CallableParser is also a function if (isParser(parserOrFn)) { const parser = parserOrFn; const fn = maybeFn!; + const composedTransform = composeTransform(parser, fn); return { ...parser, __brand: 'Parser', __positionals: [] as unknown as P2, - __transform: fn, + __transform: composedTransform, __values: {} as V2, - } as Parser & { __transform: typeof fn }; + } as Parser & { __transform: typeof composedTransform }; } // Curried form: map(fn) returns (parser) => Parser const fn = parserOrFn; return (parser: Parser): Parser => { + const composedTransform = composeTransform(parser, fn); return { ...parser, __brand: 'Parser', __positionals: [] as unknown as P2, - __transform: fn, + __transform: composedTransform, __values: {} as V2, - } as Parser & { __transform: typeof fn }; + } as Parser & { __transform: typeof composedTransform }; }; } /** @@ -307,6 +337,48 @@ export function merge( return result; } + +// ═══════════════════════════════════════════════════════════════════════════════ +// CAMEL CASE HELPER +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Convert kebab-case string to camelCase. + */ +const kebabToCamel = (s: string): string => + s.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()); + +/** + * Transform for use with `map()` that converts kebab-case option keys to + * camelCase. + * + * @example + * + * ```typescript + * import { bargs, opt, map, camelCaseValues } from '@boneskull/bargs'; + * + * const { values } = await bargs + * .create('my-cli') + * .globals( + * map(opt.options({ 'output-dir': opt.string() }), camelCaseValues), + * ) + * .parseAsync(); + * + * console.log(values.outputDir); // camelCased! + * ``` + */ +export const camelCaseValues = ( + result: ParseResult, +): ParseResult, P> => ({ + ...result, + values: Object.fromEntries( + Object.entries(result.values as Record).map(([k, v]) => [ + kebabToCamel(k), + v, + ]), + ) as CamelCaseKeys, +}); + // ═══════════════════════════════════════════════════════════════════════════════ // CLI BUILDER // ═══════════════════════════════════════════════════════════════════════════════ diff --git a/src/index.ts b/src/index.ts index 4f1694b..71cc95c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,7 +24,7 @@ */ // Main API -export { bargs, handle, map, merge } from './bargs.js'; +export { bargs, camelCaseValues, handle, map, merge } from './bargs.js'; export type { TransformFn } from './bargs.js'; // Errors @@ -67,6 +67,8 @@ export type { // Option definitions ArrayOption, BooleanOption, + // CamelCase utilities + CamelCaseKeys, // Parser combinator types CliBuilder, CliResult, @@ -86,6 +88,7 @@ export type { InferPositionals, InferTransformedPositionals, InferTransformedValues, + KebabToCamel, NumberOption, NumberPositional, OptionDef, diff --git a/src/types.ts b/src/types.ts index e08d5b0..165a324 100644 --- a/src/types.ts +++ b/src/types.ts @@ -36,6 +36,22 @@ export interface BooleanOption extends OptionBase { type: 'boolean'; } +/** + * Transform all keys of an object type from kebab-case to camelCase. + * + * Used with `camelCaseValues` to provide type-safe camelCase option keys. + * + * @example + * + * ```typescript + * type Original = { 'output-dir': string; 'dry-run': boolean }; + * type Camel = CamelCaseKeys; // { outputDir: string; dryRun: boolean } + * ``` + */ +export type CamelCaseKeys = { + [K in keyof T as KebabToCamel]: T[K]; +}; + /** * CLI builder for fluent configuration. */ @@ -211,6 +227,10 @@ export interface EnumArrayOption extends OptionBase { type: 'array'; } +// ═══════════════════════════════════════════════════════════════════════════════ +// POSITIONAL DEFINITIONS +// ═══════════════════════════════════════════════════════════════════════════════ + /** * Enum option definition with string choices. */ @@ -220,10 +240,6 @@ export interface EnumOption extends OptionBase { type: 'enum'; } -// ═══════════════════════════════════════════════════════════════════════════════ -// POSITIONAL DEFINITIONS -// ═══════════════════════════════════════════════════════════════════════════════ - /** * Enum positional definition with string choices. */ @@ -279,6 +295,10 @@ export type InferOption = T extends BooleanOption ? number : never; +// ═══════════════════════════════════════════════════════════════════════════════ +// CAMELCASE UTILITIES +// ═══════════════════════════════════════════════════════════════════════════════ + /** * Infer values type from an options schema. */ @@ -346,10 +366,6 @@ export type InferTransformedPositionals< : TPositionalsIn : TPositionalsIn; -// ═══════════════════════════════════════════════════════════════════════════════ -// TYPE INFERENCE -// ═══════════════════════════════════════════════════════════════════════════════ - /** * Infer the output values type from a transforms config. */ @@ -358,6 +374,24 @@ export type InferTransformedValues = ? TOut : TValuesIn; +// ═══════════════════════════════════════════════════════════════════════════════ +// TYPE INFERENCE +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Convert a kebab-case string type to camelCase. + * + * @example + * + * ```typescript + * type Result = KebabToCamel<'output-dir'>; // 'outputDir' + * type Nested = KebabToCamel<'my-long-option'>; // 'myLongOption' + * ``` + */ +export type KebabToCamel = S extends `${infer T}-${infer U}` + ? `${T}${Capitalize>}` + : S; + /** * Number option definition. */ diff --git a/test/combinators.test.ts b/test/combinators.test.ts index 69b1692..6752d1b 100644 --- a/test/combinators.test.ts +++ b/test/combinators.test.ts @@ -4,7 +4,7 @@ import { expect } from 'bupkis'; import { describe, it } from 'node:test'; -import { handle, map, merge } from '../src/bargs.js'; +import { camelCaseValues, handle, map, merge } from '../src/bargs.js'; import { opt, pos } from '../src/opt.js'; describe('merge()', () => { @@ -222,3 +222,60 @@ describe('combined usage', () => { ]); }); }); + +describe('camelCaseValues()', () => { + it('converts kebab-case keys to camelCase', () => { + const result = camelCaseValues({ + positionals: [] as const, + values: { + 'dry-run': true, + 'output-dir': '/tmp', + verbose: false, + }, + }); + + expect(result.values, 'to satisfy', { + dryRun: true, + outputDir: '/tmp', + verbose: false, + }); + // Original keys should NOT exist + expect(result.values, 'not to have key', 'output-dir'); + expect(result.values, 'not to have key', 'dry-run'); + }); + + it('handles nested kebab-case', () => { + const result = camelCaseValues({ + positionals: [] as const, + values: { 'my-long-option-name': 'value' }, + }); + + expect(result.values, 'to satisfy', { myLongOptionName: 'value' }); + }); + + it('preserves positionals unchanged', () => { + const result = camelCaseValues({ + positionals: ['file1', 'file2'] as const, + values: { 'output-dir': '/tmp' }, + }); + + expect(result.positionals, 'to satisfy', ['file1', 'file2']); + }); + + it('works with map() for type-safe transforms', () => { + const parser = map( + opt.options({ + 'dry-run': opt.boolean(), + 'output-dir': opt.string({ default: '/tmp' }), + }), + camelCaseValues, + ); + + expect(parser.__brand, 'to be', 'Parser'); + expect(parser.__optionsSchema, 'to satisfy', { + 'dry-run': { type: 'boolean' }, + 'output-dir': { type: 'string' }, + }); + // Note: schema keeps original keys, transform happens at runtime + }); +}); From dd73698bfccd526af1b5fda2648a819fdadb0f8b Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Tue, 6 Jan 2026 13:49:25 -0800 Subject: [PATCH 2/3] feat: add automatic --no- support for boolean options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All boolean options now automatically support a negated form to explicitly set the option to false: --verbose β†’ { verbose: true } --no-verbose β†’ { verbose: false } (neither) β†’ { verbose: default or undefined } If both --flag and --no-flag are specified, throws a HelpError with a clear message about the conflict. In help output, booleans with default: true display as --no- since that's how users would turn them off. Short aliases are not shown for negated forms. Includes: - Parser support for processing --no-* flags - Help text updates for default:true booleans - Comprehensive tests for both parser and help - README documentation πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- README.md | 26 ++++++++++ src/help.ts | 16 +++++- src/parser.ts | 57 +++++++++++++++++++++- test/help.test.ts | 86 +++++++++++++++++++++++++++++++++ test/parser.test.ts | 115 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 297 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2f57f64..aaed53c 100644 --- a/README.md +++ b/README.md @@ -336,6 +336,32 @@ opt.count(); // -vvv β†’ 3 | `hidden` | `boolean` | Hide from `--help` output | | `required` | `boolean` | Mark as required (makes the option non-nullable) | +### Boolean Negation (`--no-`) + +All boolean options automatically support a negated form `--no-` to explicitly set the option to `false`: + +```shell +$ my-cli --verbose # verbose: true +$ my-cli --no-verbose # verbose: false +$ my-cli # verbose: undefined (or default) +``` + +If both `--flag` and `--no-flag` are specified, bargs throws an error: + +```shell +$ my-cli --verbose --no-verbose +Error: Conflicting options: --verbose and --no-verbose cannot both be specified +``` + +In help output, booleans with `default: true` display as `--no-` (since that's how users would turn them off): + +```typescript +opt.options({ + colors: opt.boolean({ default: true, description: 'Use colors' }), +}); +// Help output shows: --no-colors Use colors [boolean] default: true +``` + ### `opt.options(schema)` Create a parser from an options schema: diff --git a/src/help.ts b/src/help.ts index 801920d..4b05590 100644 --- a/src/help.ts +++ b/src/help.ts @@ -172,6 +172,9 @@ const getTypeLabel = (def: OptionDef): string => { /** * Format a single option for help output. + * + * For boolean options with `default: true`, shows `--no-` instead of + * `--` since that's how users would turn it off. */ const formatOptionHelp = ( name: string, @@ -180,9 +183,18 @@ const formatOptionHelp = ( ): string => { const parts: string[] = []; - // Build flag string: -v, --verbose + // For boolean options with default: true, show --no- + // since that's how users would turn it off + const displayName = + def.type === 'boolean' && def.default === true ? `no-${name}` : name; + + // Build flag string: -v, --verbose (or --no-verbose for default:true booleans) const shortAlias = def.aliases?.find((a) => a.length === 1); - const flagText = shortAlias ? `-${shortAlias}, --${name}` : ` --${name}`; + // Don't show short alias for negated booleans + const flagText = + shortAlias && displayName === name + ? `-${shortAlias}, --${displayName}` + : ` --${displayName}`; parts.push(` ${styler.flag(flagText)}`); // Pad to align descriptions diff --git a/src/parser.ts b/src/parser.ts index e92d481..6cae3cc 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -20,8 +20,13 @@ import type { PositionalsSchema, } from './types.js'; +import { HelpError } from './errors.js'; + /** * Build parseArgs options config from our options schema. + * + * For boolean options, also adds `no-` variants to support explicit + * negation (e.g., `--no-verbose` sets `verbose` to `false`). */ const buildParseArgsConfig = ( schema: OptionsSchema, @@ -55,6 +60,11 @@ const buildParseArgsConfig = ( } config[name] = opt; + + // For boolean options, add negated form (--no-) + if (def.type === 'boolean') { + config[`no-${name}`] = { type: 'boolean' }; + } } return config; @@ -183,6 +193,45 @@ const coercePositionals = ( return result; }; +/** + * Process negated boolean options (--no-). + * + * - If `--no-` is true and `--` is not set, sets `` to false + * - If both `--` and `--no-` are set, throws an error + * - Removes all `no-` keys from the result + */ +const processNegatedBooleans = ( + values: Record, + schema: OptionsSchema, +): Record => { + const result = { ...values }; + + for (const [name, def] of Object.entries(schema)) { + if (def.type !== 'boolean') { + continue; + } + + const negatedKey = `no-${name}`; + const hasPositive = result[name] === true; + const hasNegative = result[negatedKey] === true; + + if (hasPositive && hasNegative) { + throw new HelpError( + `Conflicting options: --${name} and --${negatedKey} cannot both be specified`, + ); + } + + if (hasNegative && !hasPositive) { + result[name] = false; + } + + // Always remove the negated key from result + delete result[negatedKey]; + } + + return result; +}; + /** * Options for parseSimple. */ @@ -221,8 +270,14 @@ export const parseSimple = < strict: true, }); + // Process negated boolean options (--no-) + const processedValues = processNegatedBooleans( + values as Record, + optionsSchema, + ); + // Coerce and apply defaults - const coercedValues = coerceValues(values, optionsSchema); + const coercedValues = coerceValues(processedValues, optionsSchema); const coercedPositionals = coercePositionals(positionals, positionalsSchema); return { diff --git a/test/help.test.ts b/test/help.test.ts index 9c6b25d..12db907 100644 --- a/test/help.test.ts +++ b/test/help.test.ts @@ -130,6 +130,92 @@ describe('generateHelp', () => { expect(help, 'to contain', 'LOGGING'); expect(help, 'to contain', 'NETWORK'); }); + + describe('boolean negation display', () => { + it('shows --no- for boolean with default: true', () => { + const help = stripAnsi( + generateHelp({ + name: 'my-cli', + options: { + verbose: opt.boolean({ + default: true, + description: 'Enable verbose output', + }), + }, + }), + ); + + expect(help, 'to contain', '--no-verbose'); + expect(help, 'not to match', /\s--verbose\s/); // should not show --verbose without "no-" + }); + + it('shows -- for boolean with default: false', () => { + const help = stripAnsi( + generateHelp({ + name: 'my-cli', + options: { + verbose: opt.boolean({ + default: false, + description: 'Enable verbose output', + }), + }, + }), + ); + + expect(help, 'to contain', '--verbose'); + expect(help, 'not to contain', '--no-verbose'); + }); + + it('shows -- for boolean without default', () => { + const help = stripAnsi( + generateHelp({ + name: 'my-cli', + options: { + verbose: opt.boolean({ description: 'Enable verbose output' }), + }, + }), + ); + + expect(help, 'to contain', '--verbose'); + expect(help, 'not to contain', '--no-verbose'); + }); + + it('does not show short alias for negated boolean', () => { + const help = stripAnsi( + generateHelp({ + name: 'my-cli', + options: { + verbose: opt.boolean({ + aliases: ['v'], + default: true, + description: 'Enable verbose output', + }), + }, + }), + ); + + // Should show --no-verbose but NOT -v for the negated form + expect(help, 'to contain', '--no-verbose'); + expect(help, 'not to match', /-v,\s*--no-verbose/); + }); + + it('shows short alias for non-negated boolean', () => { + const help = stripAnsi( + generateHelp({ + name: 'my-cli', + options: { + verbose: opt.boolean({ + aliases: ['v'], + default: false, + description: 'Enable verbose output', + }), + }, + }), + ); + + expect(help, 'to match', /-v,\s*--verbose/); + }); + }); }); describe('generateHelp positionals', () => { diff --git a/test/parser.test.ts b/test/parser.test.ts index 34cab42..f699ed5 100644 --- a/test/parser.test.ts +++ b/test/parser.test.ts @@ -4,6 +4,7 @@ import { expect } from 'bupkis'; import { describe, it } from 'node:test'; +import { HelpError } from '../src/errors.js'; import { opt } from '../src/opt.js'; import { parseSimple } from '../src/parser.js'; import { validatePositionalsSchema } from '../src/validate.js'; @@ -31,6 +32,120 @@ describe('parseSimple', () => { expect(result.values, 'to deeply equal', { verbose: true }); }); + describe('boolean negation (--no-)', () => { + it('sets flag to false with --no-', () => { + const result = parseSimple({ + args: ['--no-verbose'], + options: { + verbose: opt.boolean(), + }, + }); + + expect(result.values, 'to deeply equal', { verbose: false }); + }); + + it('--no- overrides default: true', () => { + const result = parseSimple({ + args: ['--no-verbose'], + options: { + verbose: opt.boolean({ default: true }), + }, + }); + + expect(result.values, 'to deeply equal', { verbose: false }); + }); + + it('-- sets flag to true', () => { + const result = parseSimple({ + args: ['--verbose'], + options: { + verbose: opt.boolean(), + }, + }); + + expect(result.values, 'to deeply equal', { verbose: true }); + }); + + it('throws HelpError when both -- and --no- are specified', () => { + expect( + () => + parseSimple({ + args: ['--verbose', '--no-verbose'], + options: { + verbose: opt.boolean(), + }, + }), + 'to throw a', + HelpError, + ); + }); + + it('error message mentions conflicting options', () => { + expect( + () => + parseSimple({ + args: ['--no-verbose', '--verbose'], + options: { + verbose: opt.boolean(), + }, + }), + 'to throw', + /Conflicting options.*--verbose.*--no-verbose/, + ); + }); + + it('negated keys never appear in result values', () => { + const result = parseSimple({ + args: ['--no-verbose'], + options: { + verbose: opt.boolean(), + }, + }); + + expect(Object.keys(result.values), 'not to contain', 'no-verbose'); + expect(result.values, 'to have keys', ['verbose']); + }); + + it('works with multiple boolean options', () => { + const result = parseSimple({ + args: ['--verbose', '--no-quiet', '--no-debug'], + options: { + debug: opt.boolean({ default: true }), + quiet: opt.boolean(), + verbose: opt.boolean(), + }, + }); + + expect(result.values, 'to deeply equal', { + debug: false, + quiet: false, + verbose: true, + }); + }); + + it('applies default when neither flag nor negation provided', () => { + const result = parseSimple({ + args: [], + options: { + verbose: opt.boolean({ default: true }), + }, + }); + + expect(result.values, 'to deeply equal', { verbose: true }); + }); + + it('returns undefined when no flag, no negation, and no default', () => { + const result = parseSimple({ + args: [], + options: { + verbose: opt.boolean(), + }, + }); + + expect(result.values.verbose, 'to be', undefined); + }); + }); + it('parses number options', () => { const result = parseSimple({ args: ['--count', '5'], From 865b97d1def0e2712e9a7811d6ff2dfaa2cdd0e1 Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Tue, 6 Jan 2026 14:30:53 -0800 Subject: [PATCH 3/3] fix: address PR review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move CamelCaseKeys type to correct alphabetical position per linting rules - Update kebabToCamel regex to handle uppercase letters after hyphens πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/bargs.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bargs.ts b/src/bargs.ts index a94fb29..a01179b 100644 --- a/src/bargs.ts +++ b/src/bargs.ts @@ -346,7 +346,7 @@ export function merge( * Convert kebab-case string to camelCase. */ const kebabToCamel = (s: string): string => - s.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()); + s.replace(/-([a-zA-Z])/g, (_, c: string) => c.toUpperCase()); /** * Transform for use with `map()` that converts kebab-case option keys to