Skip to content

A collection of high-order functions providing a bevy of new declarative features

Notifications You must be signed in to change notification settings

Xunnamius/black-flag-extensions

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

90 Commits
 
 

Repository files navigation

Black Lives Matter! Last commit timestamp Codecov Source license Monthly Downloads NPM version Uses Semantic Release!

Black Flag Extensions

Black Flag Extensions (BFE) is a collection of high-order functions that wrap Black Flag commands' exports to provide a bevy of new declarative features, some of which are heavily inspired by yargs's GitHub Issues reports. It's like type-fest or jest-extended, but for Black Flag and yargs!

The goal of these extensions is to collect validation behavior that I find myself constantly re-implementing while also standardizing my workarounds for a few of yargs's rough edges. That said, it's important to note that BFE does not represent a complete propositional logic and so cannot describe every possible relation between arguments. Nor should it; BFE makes it easy to fall back to using the yargs API imperatively when required.

In exchange for straying a bit from the vanilla yargs API, BFE greatly increases Black Flag's declarative powers.

See also: why are @black-flag/extensions and @black-flag/core separate packages?


Install

npm install @black-flag/extensions

Usage

See also: differences between BFE and Yargs.

withBuilderExtensions

⪢ API reference: withBuilderExtensions

This function enables several additional options-related units of functionality via analysis of the returned options configuration object and the parsed command line arguments (i.e. argv).

import { withBuilderExtensions } from '@black-flag/extensions';

export default function command({ state }) {
  const [builder, withHandlerExtensions] = withBuilderExtensions(
    (blackFlag, helpOrVersionSet, argv) => {
      blackFlag.strict(false);

      // ▼ The "returned options configuration object"
      return {
        'my-argument': {
          alias: ['arg1'],
          demandThisOptionXor: ['arg2'],
          string: true
        },
        arg2: {
          boolean: true,
          demandThisOptionXor: ['my-argument']
        }
      };
    },
    { disableAutomaticGrouping: true }
  );

  return {
    name: 'my-command',
    builder,
    handler: withHandlerExtensions(({ myArgument, arg2 }) => {
      state.outputManager.log(
        'Executing command with arguments: arg1=${myArgument} arg2=${arg2}'
      );
    })
  };
}

Note how, in the previous example, the option names passed to configuration keys, e.g. { demandThisOptionXor: ['my-argument'] }, are represented by their exact canonical names as defined (e.g. 'my‑argument') and not their aliases ('arg1') or camel-case expanded forms ('myArgument'). All BFE configuration keys expect canonical option names in this way; passing an alias or a camel-case expansion will result in erroneous behavior.

In the same vein, withBuilderExtensions will throw if you attempt to add a command option with a name, alias, or camel-case expansion that conflicts with another of that command's options. This sanity check takes into account the following yargs-parser settings configuration settings: camel-case-expansion, strip-aliased, strip-dashed.

Also note how withBuilderExtensions returns a two-element array of the form: [builder, withHandlerExtensions]. builder should be exported as your command's builder function without being invoked. If you want to implement additional imperative logic, pass a customBuilder function to withBuilderExtensions as demonstrated in the previous example; otherwise, you should pass an options configuration object.

On the other hand, withHandlerExtensions should be invoked immediately, and its return value should be exported as your command's handler function as demonstrated in the previous example. You should pass a customHandler to withHandlerExtensions upon invocation, though this is not required. If you call withHandlerExtensions() without providing a customHandler, a placeholder function that throws CommandNotImplementedError will be used instead, indicating that the command has not yet been implemented. This mirrors Black Flag's default behavior for unimplemented command handlers.

New Option Configuration Keys

This section details the new configuration keys made available by BFE, each implementing an options-related unit of functionality beyond that offered by vanilla yargs and Black Flag.

Note that the checks enabled by these configuration keys:

  • Are run on Black Flag's second parsing pass except where noted. This allows BFE to perform checks against argument values in addition to the argument existence checks enabled by vanilla yargs.

  • Will ignore the existence of the default key (unless it's a custom check). This means you can use keys like requires and conflicts alongside default without causing unresolvable CLI errors. This avoids a rather unintuitive yargs footgun.

  • Will take into account the following yargs-parser settings configuration settings: camel-case-expansion, strip-aliased, strip-dashed. Note that dot-notation is not currently recognized or considered by BFE, but may in the future.

Logical Keys

In the below definitions, P, Q, and R are arguments (or argument-value pairs) configured via a hypothetical call to blackFlag.options({ P: { [key]: [Q, R] }}). The truth values of P, Q, and R represent the existence of each respective argument (and its value) in the argv parse result. gwav is a predicate standing for "given with any value," meaning the argument was given on the command line.

Key Definition
requires P ⟹ (Q ∧ R) or ¬P ∨ (Q ∧ R)
conflicts P ⟹ (¬Q ∧ ¬R) or ¬P ∨ (¬Q ∧ ¬R)
implies P ⟹ (Q ∧ R ∧ (gwav(Q) ⟹ Q) ∧ (gwav(R) ⟹ R))
demandThisOptionIf (Q ∨ R) ⟹ P or P ∨ (¬Q ∧ ¬R)
demandThisOption P
demandThisOptionOr P ∨ Q ∨ R
demandThisOptionXor P ⊕ Q ⊕ R

Relational Keys

Key
check
subOptionOf
looseImplications

requires

requires is a superset of and replacement for vanilla yargs's implies. BFE also has its own implication implementation. Choose BFE's implies over requires when you want one argument to imply the value of another without requiring the other argument to be explicitly given in argv.

{ P: { requires: [Q, R] }} can be read as P ⟹ (Q ∧ R) or ¬P ∨ (Q ∧ R), with truth values denoting existence.

requires enables checks to ensure the specified arguments, or argument-value pairs, are given conditioned on the existence of another argument. For example:

{
  "x": { "requires": "y" }, // ◄ Disallows x without y
  "y": {}
}

This configuration will trigger a check to ensure that ‑y is given whenever ‑x is given.

requires also supports checks against the parsed values of arguments in addition to the argument existence checks demonstrated above. For example:

{
  // ▼ Disallows x unless y == 'one' and z is given
  "x": { "requires": [{ "y": "one" }, "z"] },
  "y": {},
  "z": { "requires": "y" } // ◄ Disallows z unless y is given
}

This configuration allows the following arguments: no arguments (), ‑y=..., ‑y=... ‑z, ‑xz ‑y=one; and disallows: ‑x, ‑z, ‑x ‑y=..., ‑xz ‑y=..., ‑xz.


conflicts

conflicts is a superset of vanilla yargs's conflicts.

{ P: { conflicts: [Q, R] }} can be read as P ⟹ (¬Q ∧ ¬R) or ¬P ∨ (¬Q ∧ ¬R), with truth values denoting existence.

conflicts enables checks to ensure the specified arguments, or argument-value pairs, are never given conditioned on the existence of another argument. For example:

{
  "x": { "conflicts": "y" }, // ◄ Disallows y if x is given
  "y": {}
}

This configuration will trigger a check to ensure that ‑y is never given whenever ‑x is given.

conflicts also supports checks against the parsed values of arguments in addition to the argument existence checks demonstrated above. For example:

{
  // ▼ Disallows y == 'one' or z if x is given
  "x": { "conflicts": [{ "y": "one" }, "z"] },
  "y": {},
  "z": { "conflicts": "y" } // ◄ Disallows y if z is given
}

This configuration allows the following arguments: no arguments (), ‑y=..., ‑x, ‑z, ‑x ‑y=...; and disallows: ‑y=... ‑z, ‑x ‑y=one, ‑xz ‑y=one, ‑xz.


implies

BFE's implies, since it sets arguments in argv if they are not already set, is a weaker form of requires/conflicts. Choose requires over BFE's implies when you want one argument to imply the value of another while requiring the other argument to be explicitly given in argv.

BFE's implies replaces vanilla yargs's implies in a breaking way. The two implementations are nothing alike. If you're looking for vanilla yargs's functionality, see requires.

implies will set a default value for the specified arguments conditioned on the existence of another argument. Unless looseImplications is set to true, if any of the specified arguments are explicitly given on the command line, their values must match the specified argument-value pairs respectively (similar to requires/conflicts). For this reason, implies only accepts one or more argument-value pairs and not raw strings. For example:

{
  "x": { "implies": { "y": true } }, // ◄ x becomes synonymous with xy
  "y": {}
}

This configuration make it so that ‑x and ‑x ‑y=true result in the exact same argv. Further, unlike requires, implies makes no demands on argument existence and so allows the following arguments: no arguments (), ‑x, ‑y=true, ‑y=false, ‑x ‑y=true; and disallows: ‑x ‑y=false.

Note that attempting to imply a value for a non-existent option will throw a framework error.

Additionally, if any of the specified arguments have their own defaults configured, said defaults will be overridden by the values of implies. For example:

{
  "x": { "implies": { "y": true } },
  "y": { "default": false } // ◄ y will still default to true if x is given
}

For describing much more intricate implications between various arguments and their values, see subOptionOf.

Handling Transitive Implications

implies configurations do not cascade transitively. This means if argument P implies argument Q, and argument Q implies argument R, and P is given, the only check that will be performed is on P and Q. If P must imply some value for both Q and R, specify this explicitly in P's configuration. For example:

{
- P: { "implies": { Q: true } },
+ P: { "implies": { Q: true, R: true } },
  Q: { "implies": { R: true } },
  R: {}
}

This has implications beyond just implies. An implied value will not transitively satisfy any other BFE logic checks (such as demandThisOptionXor) or trigger any relational behavior (such as with subOptionOf). The implied argument-value pair will simply be merged into argv as if you had done it manually in your command's handler. Instead of relying on implicit transitive relationships via implies, prefer the explicit direct relationships described by other configuration keys.

However, any per-option checks you've configured, which are run last (at the very end of withHandlerExtensions), will see the implied argument-value pairs. Therefore, use check to guarantee any complex invariants, if necessary; ideally, you shouldn't be setting bad defaults via implies, but BFE won't stop you from doing so.

Handling Parser Configuration

Like other BFE checks, implies does take into account the yargs-parser settings camel-case-expansion, strip-aliased, and strip-dashed; but does not currently pay attention to dot-notation or duplicate-arguments-array. implies may still work when using the latter parser configurations, but it is recommended you turn them off instead.

looseImplications

If looseImplications is set to true, any of the specified arguments, when explicitly given on the command line, will override any configured implications instead of causing an error. When looseImplications is set to false, which is the default, values given on the command line must match the specified argument-value pairs respectively (similar to requires/conflicts).


demandThisOptionIf

demandThisOptionIf is a superset of vanilla yargs's demandOption.

{ P: { demandThisOptionIf: [Q, R] }} can be read as (Q ∨ R) ⟹ P or P ∨ (¬Q ∧ ¬R), with truth values denoting existence.

demandThisOptionIf enables checks to ensure an argument is given when at least one of the specified groups of arguments, or argument-value pairs, is also given. For example:

{
  "x": {},
  "y": { "demandThisOptionIf": "x" }, // ◄ Demands y if x is given
  "z": { "demandThisOptionIf": "x" } // ◄ Demands z if x is given
}

This configuration allows the following arguments: no arguments (), ‑y, ‑z, ‑yz, ‑xyz; and disallows: ‑x, ‑xy, ‑xz.

demandThisOptionIf also supports checks against the parsed values of arguments in addition to the argument existence checks demonstrated above. For example:

{
  // ▼ Demands x if y == 'one' or z is given
  "x": { "demandThisOptionIf": [{ "y": "one" }, "z"] },
  "y": {},
  "z": {}
}

This configuration allows the following arguments: no arguments (), ‑x, ‑y=..., ‑x ‑y=..., ‑xz, -xz y=...; and disallows: ‑z, ‑y=one, ‑y=... ‑z.

Note that a more powerful implementation of demandThisOptionIf can be achieved via subOptionOf.


demandThisOption

demandThisOption is an alias of vanilla yargs's demandOption. demandOption is disallowed by intellisense.

{ P: { demandThisOption: true }} can be read as P, with truth values denoting existence.

demandThisOption enables checks to ensure an argument is always given. This is equivalent to demandOption from vanilla yargs. For example:

{
  "x": { "demandThisOption": true }, // ◄ Disallows ∅, y
  "y": { "demandThisOption": false }
}

This configuration will trigger a check to ensure that ‑x is given.

Note that, as an alias of vanilla yargs's demandOption, this check is outsourced to yargs, which means it runs on Black Flag's first and second parsing passes like any other configurations key coming from vanilla yargs.


demandThisOptionOr

demandThisOptionOr is a superset of vanilla yargs's demandOption.

{ P: { demandThisOptionOr: [Q, R] }} can be read as P ∨ Q ∨ R, with truth values denoting existence.

demandThisOptionOr enables non-optional inclusive disjunction checks per group. Put another way, demandThisOptionOr enforces a "logical or" relation within groups of required options. For example:

{
  "x": { "demandThisOptionOr": ["y", "z"] }, // ◄ Demands x or y or z
  "y": { "demandThisOptionOr": ["x", "z"] }, // ◄ Mirrors the above (discarded)
  "z": { "demandThisOptionOr": ["x", "y"] } // ◄ Mirrors the above (discarded)
}

This configuration will trigger a check to ensure at least one of x, y, or z is given. In other words, this configuration allows the following arguments: ‑x, ‑y, ‑z, ‑xy, ‑xz, ‑yz, ‑xyz; and disallows: no arguments ().

In the interest of readability, consider mirroring the appropriate demandThisOptionOr configuration to the other relevant options, though this is not required (redundant groups are discarded). The previous example demonstrates proper mirroring.

demandThisOptionOr also supports checks against the parsed values of arguments in addition to the argument existence checks demonstrated above. For example:

{
  // ▼ Demands x or y == 'one' or z
  "x": { "demandThisOptionOr": [{ "y": "one" }, "z"] },
  "y": {},
  "z": {}
}

This configuration allows the following arguments: ‑x, ‑y=one, ‑z, ‑x ‑y=..., ‑xz, ‑y=... ‑z, ‑xz ‑y=...; and disallows: no arguments (), ‑y=....


demandThisOptionXor

demandThisOptionXor is a superset of vanilla yargs's demandOption + conflicts.

{ P: { demandThisOptionXor: [Q, R] }} can be read as P ⊕ Q ⊕ R, with truth values denoting existence.

demandThisOptionXor enables non-optional exclusive disjunction checks per exclusivity group. Put another way, demandThisOptionXor enforces mutual exclusivity within groups of required options. For example:

{
  "x": { "demandThisOptionXor": ["y"] }, // ◄ Disallows ∅, z, w, xy, xyw, xyz, xyzw
  "y": { "demandThisOptionXor": ["x"] }, // ◄ Mirrors the above (discarded)
  "z": { "demandThisOptionXor": ["w"] }, // ◄ Disallows ∅, x, y, zw, xzw, yzw, xyzw
  "w": { "demandThisOptionXor": ["z"] } // ◄ Mirrors the above (discarded)
}

This configuration will trigger a check to ensure exactly one of ‑x or ‑y is given, and exactly one of ‑z or ‑w is given. In other words, this configuration allows the following arguments: ‑xz, ‑xw, ‑yz, ‑yw; and disallows: no arguments (), ‑x, ‑y, ‑z, ‑w, ‑xy, ‑zw, ‑xyz, ‑xyw, ‑xzw, ‑yzw, ‑xyzw.

In the interest of readability, consider mirroring the appropriate demandThisOptionXor configuration to the other relevant options, though this is not required (redundant groups are discarded). The previous example demonstrates proper mirroring.

demandThisOptionXor also supports checks against the parsed values of arguments in addition to the argument existence checks demonstrated above. For example:

{
  // ▼ Demands x xor y == 'one' xor z
  "x": { "demandThisOptionXor": [{ "y": "one" }, "z"] },
  "y": {},
  "z": {}
}

This configuration allows the following arguments: ‑x, ‑y=one, ‑z, ‑x ‑y=..., ‑y=... ‑z; and disallows: no arguments (), ‑y=..., ‑x ‑y=one, ‑xz, ‑y=one ‑z, ‑xz ‑y=....


check

check is the declarative option-specific version of vanilla yargs's yargs::check().

This function receives the currentArgumentValue, which you are free to type as you please, and the fully parsed argv. If this function throws, the exception will bubble. If this function returns an instance of Error, a string, or any non-truthy value (including undefined or not returning anything), Black Flag will throw a CliError on your behalf.

All check functions are run in definition order and always at the very end of the second parsing pass, well after all other BFE checks have passed and all updates to argv have been applied (including from subOptionOf and BFE's implies). This means check always sees the final version of argv, which is the same version that the command's handler is passed.

Note that check functions are skipped if their corresponding argument does not exist in argv.

When a check fails, execution of its command's handler function will cease and configureErrorHandlingEpilogue will be invoked (unless you threw/returned a GracefulEarlyExitError). For example:

export const [builder, withHandlerExtensions] = withBuilderExtensions({
  x: {
    number: true,
    check: function (currentXArgValue, fullArgv) {
      if (currentXArgValue < 0 || currentXArgValue > 10) {
        throw new Error(
          `"x" must be between 0 and 10 (inclusive), saw: ${currentXArgValue}`
        );
      }

      return true;
    }
  },
  y: {
    boolean: true,
    default: false,
    requires: 'x',
    check: function (currentYArgValue, fullArgv) {
      if (currentYArgValue && fullArgv.x <= 5) {
        throw new Error(
          `"x" must be greater than 5 to use 'y', saw: ${fullArgv.x}`
        );
      }

      return true;
    }
  }
});

See the yargs documentation on yargs::check() for more information.


subOptionOf

One of Black Flag's killer features is native support for dynamic options. However, taking advantage of this feature in a command's builder export requires a strictly imperative approach.

Take, for example, the init command from @black-flag/demo:

// Taken at 06/04/2024 from @black-flag/demo "myctl" CLI
// @ts-check

/**
 * @type {import('@black-flag/core').Configuration['builder']}
 */
export const builder = function (yargs, _, argv) {
  yargs.parserConfiguration({ 'parse-numbers': false });

  if (argv && argv.lang) {
    // This code block implements our dynamic options (depending on --lang)
    return argv.lang === 'node'
      ? {
          lang: { choices: ['node'], demandOption: true },
          version: { choices: ['19.8', '20.9', '21.1'], default: '21.1' }
        }
      : {
          lang: { choices: ['python'], demandOption: true },
          version: {
            choices: ['3.10', '3.11', '3.12'],
            default: '3.12'
          }
        };
  } else {
    // This code block represents the fallback
    return {
      lang: {
        choices: ['node', 'python'],
        demandOption: true,
        default: 'python'
      },
      version: { string: true, default: 'latest' }
    };
  }
};

/**
 * @type {import('@black-flag/core').Configuration<{ lang: string, version: string }>['handler']}
 */
export const handler = function ({ lang, version }) {
  console.log(`> Initializing new ${lang}@${version} project...`);
};

Among other freebies, taking advantage of dynamic options support gifts your CLI with help text more gorgeous and meaningful than anything you could accomplish with vanilla yargs:

myctl init --lang 'node' --version=21.1
> initializing new [email protected] project...
myctl init --lang 'python' --version=21.1
Usage: myctl init

Options:
  --help     Show help text                                            [boolean]
  --lang                                                     [choices: "python"]
  --version                                    [choices: "3.10", "3.11", "3.12"]

Invalid values:
  Argument: version, Given: "21.1", Choices: "3.10", "3.11", "3.12"
myctl init --lang fake
Usage: myctl init

Options:
  --help     Show help text                                            [boolean]
  --lang                                             [choices: "node", "python"]
  --version                                                             [string]

Invalid values:
  Argument: lang, Given: "fake", Choices: "node", "python"
myctl init --help
Usage: myctl init

Options:
  --help     Show help text                                            [boolean]
  --lang                                             [choices: "node", "python"]
  --version                                                             [string]

Ideally, Black Flag would allow us to describe the relationship between ‑‑lang and its suboption ‑‑version declaratively, without having to drop down to imperative interactions with the yargs API like we did above.

This is the goal of the subOptionOf configuration key. Using subOptionOf, developers can take advantage of dynamic options without sweating the implementation details.

Note that subOptionOf updates are run and applied during Black Flag's second parsing pass.

For example:

/**
 * @type {import('@black-flag/core').Configuration['builder']}
 */
export const [builder, withHandlerExtensions] = withBuilderExtensions({
  x: {
    choices: ['a', 'b', 'c'],
    demandThisOption: true,
    description: 'A choice'
  },
  y: {
    number: true,
    description: 'A number'
  },
  z: {
    // ▼ These configurations are applied as the baseline or "fallback" during
    //   Black Flag's first parsing pass. The updates within subOptionOf are
    //   evaluated and applied during Black Flag's second parsing pass.
    boolean: true,
    description: 'A useful context-sensitive flag',
    subOptionOf: {
      // ▼ Ignored if x is not given
      x: [
        {
          when: (currentXArgValue, fullArgv) => currentXArgValue === 'a',
          update:
            // ▼ We can pass an updater function that returns an opt object.
            //   This object will *replace* the argument's old configuration!
            (oldXArgumentConfig, fullArgv) => {
              return {
                // ▼ We don't want to lose the old config, so we spread it
                ...oldXArgumentConfig,
                description: 'This is a switch specifically for the "a" choice'
              };
            }
        },
        {
          when: (currentXArgValue, fullArgv) => currentXArgValue !== 'a',
          update:
            // ▼ Or we can just pass the replacement configuration object. Note
            //   that, upon multiple `when` matches, the last update in the
            //   chain will win. If you want merge behavior instead of overwrite,
            //   spread the old config in the object you return.
            {
              string: true,
              description: 'This former-flag now accepts a string instead'
            }
        }
      ],
      // ▼ Ignored if y is not given. If x and y ARE given, since this occurs
      //   after the x config, this update will overwrite any others. Use the
      //   functional form + object spread to preserve the old configuration.
      y: {
        when: (currentYArgValue, fullArgv) =>
          fullArgv.x === 'a' && currentYArgValue > 5,
        update: (oldConfig, fullArgv) => {
          return {
            array: true,
            demandThisOption: true,
            description:
              'This former-flag now accepts an array of two or more strings',
            check: function (currentZArgValue, fullArgv) {
              return (
                currentZArgValue.length >= 2 ||
                `"z" must be an array of two or more strings, only saw: ${currentZArgValue.length ?? 0}`
              );
            }
          };
        }
      },
      // ▼ Since "does-not-exist" is not an option defined anywhere, this will
      //   always be ignored
      'does-not-exist': []
    }
  }
});

Note that you cannot nest subOptionOf keys within each other or return an object containing subOptionOf from an update. Doing so will trigger a framework error.

Now we're ready to re-implement the init command from myctl using our new declarative superpowers:

export const [builder, withHandlerExtensions] = withBuilderExtensions(
  function (blackFlag) {
    blackFlag.parserConfiguration({ 'parse-numbers': false });

    return {
      lang: {
        // ▼ These two are our fallback or "baseline" configurations for --lang
        choices: ['node', 'python'],
        demandThisOption: true,
        default: 'python',

        subOptionOf: {
          // ▼ Yep, --lang is also a suboption of --lang
          lang: [
            {
              when: (lang) => lang === 'node',
              // ▼ Remember: updates overwrite any old config (including baseline)
              update: {
                choices: ['node'],
                demandThisOption: true
              }
            },
            {
              when: (lang) => lang !== 'node',
              update: {
                choices: ['python'],
                demandThisOption: true
              }
            }
          ]
        }
      },

      // Another benefit of subOptionOf: all configuration relevant to a specific
      // option is co-located within that option and not spread across some
      // function or file. We don't have to go looking for the logic that's
      // modifying --version since it's all right here in one code block.
      version: {
        // ▼ These two are our fallback or "baseline" configurations for --version
        string: true,
        default: 'latest',

        subOptionOf: {
          // ▼ --version is a suboption of --lang
          lang: [
            {
              when: (lang) => lang === 'node',
              update: {
                choices: ['19.8', '20.9', '21.1'],
                default: '21.1'
              }
            },
            {
              when: (lang) => lang !== 'node',
              update: {
                choices: ['3.10', '3.11', '3.12'],
                default: '3.12'
              }
            }
          ]
        }
      }
    };
  }
);

Easy peasy!

Support for default with conflicts/requires/etc

BFE (and, consequently, BF/yargs when not generating help text) will ignore the existence of the default key until near the end of BFE's execution.

This means the optional customBuilder function passed to withBuilderExtensions will not see any defaulted values. However, your command handlers will.

Note that an explicitly undefined default, i.e. { default: undefined }, will be deleted from the configuration object and completely ignored by BFE, Black Flag, and yargs. This differs from yargs's default behavior, which is to recognize undefined defaults.

Defaults are set before any check functions are run, before any implications are set, and before the relevant command handler is invoked, but after all other BFE checks have succeeded. This enables the use of keys like requires and conflicts alongside default without causing impossible configurations that throw unresolvable CLI errors.

This workaround avoids a (in my opinion) rather unintuitive yargs footgun, though there are decent arguments in support of vanilla yargs's behavior.

Strange and Impossible Configurations

Note that there are no sanity checks performed to prevent options configurations that are unresolvable, so care must be taken not to ask for something insane.

For example, the following configurations are impossible to resolve:

{
  "x": { "requires": "y" },
  "y": { "conflicts": "x" }
}
{
  "x": { "requires": "y", "demandThisOptionXor": "y" },
  "y": {}
}

Similarly, silly configurations like the following, while typically resolvable, are strange and may not work as expected:

{
  "x": { "requires": "x", "demandThisOptionXor": "x" }
}
{
  "x": { "implies": { "x": 5 } }
}

Automatic Grouping of Related Options

To support this functionality, options must be described declaratively. Defining options imperatively will break this feature.

BFE supports automatic grouping of related options for improved UX. These new groups are:

  • "Required Options": options configured with demandThisOption.
  • "Required Options (at least one)": options configured with demandThisOptionOr.
  • "Required Options (mutually exclusive)": options configured with demandThisOptionXor.
  • "Common Options": options provided via { commonOptions: [...] } to withBuilderExtensions as its second parameter: withBuilderExtensions({/*...*/}, { commonOptions });
  • "Optional Options": remaining options that do not fall into any of the above categories.

An example from xunnctl:

$ x f b --help
Usage: xunnctl firewall ban

Add an IP from the global hostile IP list.

Required Options:
  --ip  An ipv4, ipv6, or supported CIDR                                                        [array]

Optional Options:
  --comment  Include custom text with the ban comment where applicable                         [string]

Common Options:
  --help         Show help text                                                               [boolean]
  --hush         Set output to be somewhat less verbose                      [boolean] [default: false]
  --quiet        Set output to be dramatically less verbose (implies --hush) [boolean] [default: false]
  --silent       No output will be generated (implies --quiet)               [boolean] [default: false]
  --config-path  Use a custom configuration file
                                [string] [default: "/home/freelance/.config/xunnctl-nodejs/state.json"]
$ x d z u --help
Usage: xunnctl dns zone update

Reinitialize a DNS zones.

Required Options (at least one):
  --apex            Zero or more zone apex domains                                              [array]
  --apex-all-known  Include all known zone apex domains                                       [boolean]

Optional Options:
  --force        Disable protections                                                          [boolean]
  --purge-first  Delete pertinent records on the zone before recreating them                  [boolean]

Common Options:
  --help         Show help text                                                               [boolean]
  --hush         Set output to be somewhat less verbose                      [boolean] [default: false]
  --quiet        Set output to be dramatically less verbose (implies --hush) [boolean] [default: false]
  --silent       No output will be generated (implies --quiet)               [boolean] [default: false]
  --config-path  Use a custom configuration file
                                [string] [default: "/home/freelance/.config/xunnctl-nodejs/state.json"]

This feature can be disabled by passing { disableAutomaticGrouping: true } to withBuilderExtensions as its second parameter:

const [builder, withHandlerExtensions] = withBuilderExtensions(
  {
    // ...
  },
  { disableAutomaticGrouping: true }
);

withUsageExtensions

⪢ API reference: withUsageExtensions

This thin wrapper function is used for more consistent and opinionated usage string generation.

// file: xunnctl/commands/firewall/ban.js
return {
  // ...
  description: 'Add an IP from the global hostile IP list',
  usage: withUsageExtensions(
    "$1.\n\nAdditional description text that only appears in this command's help text."
  )
};
$ x f b --help
Usage: xunnctl firewall ban

Add an IP from the global hostile IP list.

Additional description text that only appears in this command's help text.

Required Options:
  --ip  An ipv4, ipv6, or supported CIDR                                                        [array]

Optional Options:
  --comment  Include custom text with the ban comment where applicable                         [string]

Common Options:
  --help         Show help text                                                               [boolean]
  --hush         Set output to be somewhat less verbose                      [boolean] [default: false]
  --quiet        Set output to be dramatically less verbose (implies --hush) [boolean] [default: false]
  --silent       No output will be generated (implies --quiet)               [boolean] [default: false]
  --config-path  Use a custom configuration file
                                [string] [default: "/home/freelance/.config/xunnctl-nodejs/state.json"]

getInvocableExtendedHandler

⪢ API reference: getInvocableExtendedHandler

Unlike Black Flag, BFE puts strict constraints on the order in which command exports must be invoked and evaluated. Specifically: an extended command's builder export must be invoked twice, with the correct parameters each time, before that extended command's handler can be invoked.

This can make it especially cumbersome to import an extended command from a file and then invoke its handler, which is dead simple for normal Black Flag commands.

For example, take the following extended command:

// file: my-cli/commands/command-A.ts
import { type CustomExecutionContext } from '../configure';

export type CustomCliArguments = {
  /* ... */
};

export default function command({ state }: CustomExecutionContext) {
  const [builder, withHandlerExtensions] =
    withBuilderExtensions<CustomCliArguments>({
      // ...
    });

  return {
    builder,
    handler: withHandlerExtensions<CustomCliArguments>(
      async function (/* ... */) {
        // ...
      }
    )
  };
}

Were command-A a normal non-extended Black Flag command, we could simply import it into another command (command-B) and run it like so:

// file: my-cli/commands/command-B.ts
import { type CustomExecutionContext } from '../configure';

export type CustomCliArguments = {
  /* ... */
};

export default function command(context: CustomExecutionContext) {
  const [builder, withHandlerExtensions] =
    withBuilderExtensions<CustomCliArguments>({
      // ...
    });

  return {
    builder,
    handler: withHandlerExtensions<CustomCliArguments>(async function (argv) {
      const { handler } = (await import('./command-A.js')).default(context);
      await handler({ ...argv, somethingElse: true });

      // ...
    })
  };
}

Instead, since command-A was created using Black Flag Extensions, we must go through the following rigamarole:

// file: my-cli/commands/command-B.ts
import { type CustomExecutionContext } from '../configure';

export type CustomCliArguments = {
  /* ... */
};

export default function command(context: CustomExecutionContext) {
  const [builder, withHandlerExtensions] =
    withBuilderExtensions<CustomCliArguments>({
      // ...
    });

  return {
    builder,
    handler: withHandlerExtensions<CustomCliArguments>(async function (argv) {
      const blackFlag = {
        /* some kind of black hole mock or proxy */
      };

      const commandAArgv = {
        ...argv,
        somethingElse: true
      };

      const { builder, handler } = (await import('./command-A.js')).default(
        context
      );

      builder(blackFlag, false, undefined);
      builder(blackFlag, false, commandAArgv);

      await handler(commandAArgv);

      // ...
    })
  };
}

Having to go through all that just to invoke one command within another quickly becomes verbose and tiresome. To say nothing of the fact that command-A might be changed down the road to export a configuration object or something other than a default function.

Now we've got transitive tight-couplings between commands, which makes bugs more likely and harder to spot.

Hence the purpose of getInvocableExtendedHandler. This function returns a version of the extended command's handler function that is ready to invoke immediately. It can be used with both BFE and normal Black Flag command exports.

Note that command builder and handler exports invoked via getInvocableExtendedHandler will receive an argv containing the $artificiallyInvoked symbol. This allows handlers to avoid potentially dangerous actions (such as altering global context state) when the command isn't actually being invoked by Black Flag.

However, to get intellisense/TypeScript support for the existence of $artificiallyInvoked in argv, you must use BfeStrictArguments.

For example, in JavaScript:

// file: my-cli/commands/command-B.js
export default function command(context) {
  const [builder, withHandlerExtensions] = withBuilderExtensions({
    // ...
  });

  return {
    builder,
    handler: withHandlerExtensions(async function (argv) {
      const handler = await getInvocableExtendedHandler(
        // This accepts a function, an object, a default export, a Promise, etc
        import('./command-A.js'),
        context
      );

      await handler({ ...argv, somethingElse: true });

      // ...
    })
  };
}

Or in TypeScript:

// file: my-cli/commands/command-B.ts
import { type CustomExecutionContext } from '../configure';

import {
  default as commandA,
  type CustomCliArguments as CommandACliArguments
} from './command-A';

export type CustomCliArguments = {
  /* ... */
};

export default function command(context: CustomExecutionContext) {
  const [builder, withHandlerExtensions] =
    withBuilderExtensions<CustomCliArguments>({
      // ...
    });

  return {
    builder,
    handler: withHandlerExtensions<CustomCliArguments>(async function (argv) {
      const handler = await getInvocableExtendedHandler<
        CommandACliArguments,
        typeof context
      >(commandA, context);

      await handler({ ...argv, somethingElse: true });

      // ...
    })
  };
}

Examples

In this section are two example implementations of a "deploy" command.

Example 1

Suppose we wanted a "deploy" command with the following somewhat contrived feature set:

  • Ability to deploy to a Vercel production target, a Vercel preview target, or to a remote target via SSH.

  • When deploying to Vercel, allow the user to choose to deploy only to preview (‑‑only-preview) or only to production (‑‑only-production), if desired.

    • Deploy to the preview target only by default.

    • If both ‑‑only-preview=false and ‑‑only-production=false, deploy to both the preview and production environments.

    • If both ‑‑only-preview=true and ‑‑only-production=true, throw an error.

  • When deploying to a remote target via SSH, require both a ‑‑host and ‑‑to-path be provided.

    • If ‑‑host or ‑‑to-path are provided, they must be accompanied by ‑‑target=ssh since these options don't make sense if ‑‑target is something else.

What follows is an example implementation:

import { type ChildConfiguration } from '@black-flag/core';
import {
  withBuilderExtensions,
  withUsageExtensions
} from '@black-flag/extensions';

import { type CustomExecutionContext } from '../configure.ts';

export enum DeployTarget {
  Vercel = 'vercel',
  Ssh = 'ssh'
}

export const deployTargets = Object.values(DeployTarget);

// ▼ Let's keep our custom CLI arguments strongly 💪🏿 typed
export type CustomCliArguments = {
  target: DeployTarget;
} & ( // We could make these subtypes even stronger, but the returns are diminishing
  | {
      target: DeployTarget.Vercel;
      production: boolean;
      preview: boolean;
    }
  | {
      target: DeployTarget.Ssh;
      host: string;
      toPath: string;
    }
);

export default function command({ state }: CustomExecutionContext) {
  const [builder, withHandlerExtensions] =
    withBuilderExtensions<CustomCliArguments>({
      target: {
        demandThisOption: true, // ◄ Just an alias for { demandOption: true }
        choices: deployTargets,
        description: 'Select deployment target and strategy'
      },
      'only-production': {
        alias: ['production', 'prod'],
        boolean: true,
        // ▼ Error if --only-preview/--only-preview=true, otherwise set to false
        implies: { 'only-preview': false },
        requires: { target: DeployTarget.Vercel }, // ◄ Error if --target != vercel
        default: false, // ◄ Works in a sane way alongside conflicts/requires
        description: 'Only deploy to the remote production environment'
      },
      'only-preview': {
        alias: ['preview'],
        boolean: true,
        implies: { 'only-production': false },
        requires: { target: DeployTarget.Vercel },
        default: true,
        description: 'Only deploy to the remote preview environment'
      },
      host: {
        string: true,
        // ▼ Inverse of { conflicts: { target: DeployTarget.Vercel }} in this example
        requires: { target: DeployTarget.Ssh }, // ◄ Error if --target != ssh
        // ▼ Demand --host if --target=ssh
        demandThisOptionIf: { target: DeployTarget.Ssh },
        description: 'The host to use'
      },
      'to-path': {
        string: true,
        requires: { target: DeployTarget.Ssh },
        // ▼ Demand --to-path if --target=ssh
        demandThisOptionIf: { target: DeployTarget.Ssh },
        description: 'The deploy destination path to use'
      }
    });

  return {
    builder,
    description: 'Deploy distributes to the appropriate remote',
    usage: withUsageExtensions('$1.\n\nSupports both Vercel and SSH targets!'),
    handler: withHandlerExtensions<CustomCliArguments>(async function ({
      target,
      production: productionOnly,
      preview: previewOnly,
      host,
      toPath
    }) {
      // if(state[...]) ...

      switch (target) {
        case DeployTarget.Vercel: {
          if (previewOnly || (!productionOnly && !previewOnly)) {
            // Push to preview via vercel
          }

          if (productionOnly) {
            // Push to production via vercel
          }

          break;
        }

        case DeployTarget.Ssh: {
          // Push to host at path via ssh
          break;
        }
      }
    })
  } satisfies ChildConfiguration<CustomCliArguments, CustomExecutionContext>;
}

Example 2

Suppose we wanted a "deploy" command with the following more realistic feature set:

  • Ability to deploy to a Vercel production target, a Vercel preview target, or to a remote target via SSH.

  • When deploying to Vercel, allow the user to choose to deploy to preview (‑‑preview), or to production (‑‑production), or both.

    • Deploy to the preview target by default.

    • If both ‑‑preview=false and ‑‑production=false, throw an error.

    • If both ‑‑preview=true and ‑‑production=true, deploy to both the preview and production environments.

  • When deploying to a remote target via SSH, require a ‑‑host and ‑‑to-path be provided.

    • If ‑‑host or ‑‑to-path are provided, they must be accompanied by ‑‑target=ssh since these options don't make sense if ‑‑target is something else.
  • Output more useful and extremely specific help text depending on the combination of arguments received.

What follows is an example implementation:

import { type ChildConfiguration } from '@black-flag/core';

import {
  withBuilderExtensions,
  withUsageExtensions
} from '@black-flag/extensions';

import { type CustomExecutionContext } from '../configure.ts';

export enum DeployTarget {
  Vercel = 'vercel',
  Ssh = 'ssh'
}

export const deployTargets = Object.values(DeployTarget);

export type CustomCliArguments = { target: DeployTarget } & (
  | {
      target: DeployTarget.Vercel;
      production: boolean;
      preview: boolean;
    }
  | {
      target: DeployTarget.Ssh;
      host: string;
      toPath: string;
    }
);

export default function command({ state }: CustomExecutionContext) {
  const [builder, withHandlerExtensions] = withBuilderExtensions<
    CustomCliArguments,
    GlobalExecutionContext
  >({
    target: {
      description: 'Select deployment target and strategy',
      demandThisOption: true,
      choices: deployTargets,
      subOptionOf: {
        target: {
          // Since subOptionOf runs on 2nd parse, target MUST be a DeployTarget
          // by the time subOptionOf is considered. Yay!
          when: () => true,
          update(oldOptionConfig, { target }) {
            return {
              ...oldOptionConfig,
              choices: [target]
            };
          }
        }
      }
    },
    production: {
      alias: ['prod'],
      boolean: true,
      description: 'Deploy to the remote production environment',
      requires: { target: DeployTarget.Vercel },
      // ▼ This overrides --preview's default if --production is given
      implies: { preview: false },
      // ▼ This allows implications to be overridden by command line arguments
      looseImplications: true,
      subOptionOf: {
        target: {
          // Since subOptionOf runs on 2nd parse, target MUST be defined (as
          // a DeployTarget) by the time subOptionOf is considered. If it
          // weren't, we'd have to ensure target was not undefined, too. Boo!
          when: (target: DeployTarget) => target !== DeployTarget.Vercel,
          update(oldOptionConfig) {
            return {
              ...oldOptionConfig,
              hidden: true
            };
          }
        }
      }
    },
    preview: {
      boolean: true,
      description: 'Deploy to the remote preview environment',
      requires: { target: DeployTarget.Vercel },
      default: true,
      check: function (preview, argv) {
        return (
          argv.target !== DeployTarget.Vercel ||
          preview ||
          argv.production ||
          'must choose either --preview or --production deployment environment'
        );
      },
      subOptionOf: {
        target: {
          when: (target: DeployTarget) => target !== DeployTarget.Vercel,
          update(oldOptionConfig) {
            return {
              ...oldOptionConfig,
              hidden: true
            };
          }
        }
      }
    },
    host: {
      string: true,
      description: 'The ssh deploy host',
      requires: { target: DeployTarget.Ssh },
      //demandThisOptionIf: { target: DeployTarget.Ssh },
      subOptionOf: {
        target: [
          {
            // ▼ Unlike demandThisOptionIf, this changes the help text output!
            when: (target: DeployTarget) => target === DeployTarget.Ssh,
            update(oldOptionConfig) {
              return {
                ...oldOptionConfig,
                demandThisOption: true
              };
            }
          },
          {
            when: (target: DeployTarget) => target !== DeployTarget.Ssh,
            update(oldOptionConfig) {
              return {
                ...oldOptionConfig,
                hidden: true
              };
            }
          }
        ]
      }
    },
    'to-path': {
      string: true,
      description: 'The ssh deploy destination path',
      requires: { target: DeployTarget.Ssh },
      //demandThisOptionIf: { target: DeployTarget.Ssh },
      subOptionOf: {
        target: [
          {
            when: (target: DeployTarget) => target === DeployTarget.Ssh,
            update(oldOptionConfig) {
              return {
                ...oldOptionConfig,
                demandThisOption: true
              };
            }
          },
          {
            when: (target: DeployTarget) => target !== DeployTarget.Ssh,
            update(oldOptionConfig) {
              return {
                ...oldOptionConfig,
                hidden: true
              };
            }
          }
        ]
      }
    }
  });

  return {
    builder,
    description: 'Deploy distributes to the appropriate remote',
    usage: withUsageExtensions('$1.\n\nSupports both Vercel and SSH targets!'),
    handler: withHandlerExtensions<CustomCliArguments>(async function ({
      target,
      production,
      preview,
      host,
      toPath
    }) {
      // if(state[...]) ...

      switch (target) {
        case DeployTarget.Vercel: {
          if (production) {
            // Push to production via vercel
          }

          if (preview) {
            // Push to preview via vercel
          }

          break;
        }

        case DeployTarget.Ssh: {
          // Push to host at path via ssh
          break;
        }
      }
    })
  } satisfies ChildConfiguration<CustomCliArguments, CustomExecutionContext>;
}

Sample Outputs

$ x deploy
Usage: xscripts deploy

Deploy distributes to the appropriate remote.

Required Options:
  --target  Select deployment target and strategy                       [required] [choices: "vercel", "ssh"]

Optional Options:
  --production, --prod  Deploy to the remote production environment                                 [boolean]
  --preview             Deploy to the remote preview environment                    [boolean] [default: true]
  --host                The ssh deploy host                                                          [string]
  --to-path             The ssh deploy destination path                                              [string]

Common Options:
  --help    Show help text                                                                          [boolean]
  --hush    Set output to be somewhat less verbose                                 [boolean] [default: false]
  --quiet   Set output to be dramatically less verbose (implies --hush)            [boolean] [default: false]
  --silent  No output will be generated (implies --quiet)                          [boolean] [default: false]

  xscripts:<error> ❌ Execution failed: missing required argument: target
$ x deploy --help
Usage: xscripts deploy

Deploy distributes to the appropriate remote.

Required Options:
  --target  Select deployment target and strategy                       [choices: "vercel", "ssh"]

Optional Options:
  --production, --prod  Deploy to the remote production environment     [boolean]
  --preview             Deploy to the remote preview environment        [boolean] [default: true]
  --host                The ssh deploy host                             [string]
  --to-path             The ssh deploy destination path                 [string]

Common Options:
  --help    Show help text                                              [boolean]
  --hush    Set output to be somewhat less verbose                      [boolean] [default: false]
  --quiet   Set output to be dramatically less verbose (implies --hush) [boolean] [default: false]
  --silent  No output will be generated (implies --quiet)               [boolean] [default: false]
$ x deploy --target=ssh
Usage: xscripts deploy

Deploy distributes to the appropriate remote.

Required Options:
  --target   Select deployment target and strategy                      [required] [choices: "ssh"]
  --host     The ssh deploy host                                        [string] [required]
  --to-path  The ssh deploy destination path                            [string] [required]

Common Options:
  --help    Show help text                                              [boolean]
  --hush    Set output to be somewhat less verbose                      [boolean] [default: false]
  --quiet   Set output to be dramatically less verbose (implies --hush) [boolean] [default: false]
  --silent  No output will be generated (implies --quiet)               [boolean] [default: false]

  xscripts:<error> ❌ Execution failed: missing required arguments: host, to-path
$ x deploy --target=vercel --to-path
Usage: xscripts deploy

Deploy distributes to the appropriate remote.

Required Options:
  --target  Select deployment target and strategy                       [required] [choices: "vercel"]

Optional Options:
  --production, --prod  Deploy to the remote production environment                          [boolean]
  --preview             Deploy to the remote preview environment             [boolean] [default: true]

Common Options:
  --help    Show help text                                                                   [boolean]
  --hush    Set output to be somewhat less verbose                          [boolean] [default: false]
  --quiet   Set output to be dramatically less verbose (implies --hush)     [boolean] [default: false]
  --silent  No output will be generated (implies --quiet)                   [boolean] [default: false]

  xscripts:<error> ❌ Execution failed: the following arguments must be given alongside "to-path":
  xscripts:<error>
  xscripts:<error> ⮞  --target="ssh"
$ x deploy --target=ssh --host prime --to-path '/some/path' --preview
Usage: xscripts deploy

Deploy distributes to the appropriate remote.

Required Options:
  --target   Select deployment target and strategy                      [required] [choices: "ssh"]
  --host     The ssh deploy host                                                [string] [required]
  --to-path  The ssh deploy destination path                                    [string] [required]

Common Options:
  --help    Show help text                                                                [boolean]
  --hush    Set output to be somewhat less verbose                       [boolean] [default: false]
  --quiet   Set output to be dramatically less verbose (implies --hush)  [boolean] [default: false]
  --silent  No output will be generated (implies --quiet)                [boolean] [default: false]

  xscripts:<error> ❌ Execution failed: the following arguments must be given alongside "preview":
  xscripts:<error>
  xscripts:<error> ⮞  --target="vercel"
$ x deploy --target=vercel --preview=false --production=false
xscripts:<error> ❌ Execution failed: must choose either --preview or --production deployment environment

Appendix

Further documentation can be found under docs/.

Differences between Black Flag Extensions and Yargs

When using BFE, command options must be configured by returning an opt object from your command's builder rather than imperatively invoking the yargs API.

For example:

export function builder(blackFlag) {
- // DO NOT use yargs's imperative API to define options! This *BREAKS* BFE!
- blackFlag.option('f', {
-   alias: 'file',
-   demandOption: true,
-   default: '/etc/passwd',
-   describe: 'x marks the spot',
-   type: 'string'
- });
-
- // DO NOT use yargs's imperative API to define options! This *BREAKS* BFE!
- blackFlag
-   .alias('f', 'file')
-   .demandOption('f')
-   .default('f', '/etc/passwd')
-   .describe('f', 'x marks the spot')
-   .string('f');
-
- // DO NOT use yargs's imperative API to define options! This *BREAKS* BFE!
- blackFlag.options({
-   f: {
-     alias: 'file',
-     demandOption: true,
-     default: '/etc/passwd',
-     describe: 'x marks the spot',
-     type: 'string'
-   }
- });
-
+ // INSTEAD, use yargs / Black Flag's declarative API to define options 🙂
+ return {
+   f: {
+     alias: 'file',
+     demandThisOption: true,
+     default: '/etc/passwd',
+     describe: 'x marks the spot',
+     type: 'string'
+   }
+ };
}

The yargs API can and should still be invoked for purposes other than defining options on a command, e.g. blackFlag.strict(false).

To this end, the following yargs API functions are soft-disabled via intellisense:

  • option
  • options

However, no attempt is made by BFE to restrict your use of the yargs API at runtime. Therefore, using yargs's API to work around these artificial limitations, e.g. in your command's builder function or via the configureExecutionPrologue hook, will result in undefined behavior.

Black Flag versus Black Flag Extensions

The goal of Black Flag (@black-flag/core) is to be as close to a drop-in replacement as possible for vanilla yargs, specifically for users of yargs::commandDir(). This means Black Flag must go out of its way to maintain 1:1 parity with the vanilla yargs API (with a few minor exceptions).

As a consequence, yargs's imperative nature tends to leak through Black Flag's abstraction at certain points, such as with the blackFlag parameter of the builder export. This is a good thing! Since we want access to all of yargs's killer features without Black Flag getting in the way.

However, this comes with costs. For one, the yargs's API has suffered from a bit of feature creep over the years. A result of this is a rigid API with an abundance of footguns and an inability to address them without introducing massively breaking changes.

BFE takes the "YOLO" approach by exporting several functions that build on top of Black Flag's feature set without worrying too much about maintaining 1:1 parity with the vanilla yargs's API. This way, one can opt-in to a more opinionated but (in my opinion) cleaner, more consistent, and more intuitive developer experience.

Published Package Details

This is a CJS2 package with statically-analyzable exports built by Babel for Node.js versions that are not end-of-life. For TypeScript users, this package supports both "Node10" and "Node16" module resolution strategies.

Expand details

That means both CJS2 (via require(...)) and ESM (via import { ... } from ... or await import(...)) source will load this package from the same entry points when using Node. This has several benefits, the foremost being: less code shipped/smaller package size, avoiding dual package hazard entirely, distributables are not packed/bundled/uglified, a drastically less complex build process, and CJS consumers aren't shafted.

Each entry point (i.e. ENTRY) in package.json's exports[ENTRY] object includes one or more export conditions. These entries may or may not include: an exports[ENTRY].types condition pointing to a type declarations file for TypeScript and IDEs, an exports[ENTRY].module condition pointing to (usually ESM) source for Webpack/Rollup, an exports[ENTRY].node condition pointing to (usually CJS2) source for Node.js require and import, an exports[ENTRY].default condition pointing to source for browsers and other environments, and other conditions not enumerated here. Check the package.json file to see which export conditions are supported.

Though package.json includes { "type": "commonjs" }, note that any ESM-only entry points will be ES module (.mjs) files. Finally, package.json also includes the sideEffects key, which is false for optimal tree shaking where appropriate.

License

See LICENSE.

Contributing and Support

New issues and pull requests are always welcome and greatly appreciated! 🤩 Just as well, you can star 🌟 this project to let me know you found it useful! ✊🏿 Or you could buy me a beer 🥺Thank you!

See CONTRIBUTING.md and SUPPORT.md for more information.

Contributors

All Contributors

Thanks goes to these wonderful people (emoji key):

Bernard
Bernard

🚇 💻 📖 🚧 ⚠️ 👀
Add your contributions

This project follows the all-contributors specification. Contributions of any kind welcome!

About

A collection of high-order functions providing a bevy of new declarative features

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published