16 minute read

You wrote a sweet piece of code! Releasing it on NPM seems like the obvious next step. Right?

Try it! Not as easy to do as you might think. At high quality. From scratch.

So here’s a plug-and-play NPM package template that offers the following features:

  • Tree-shakable support for the latest ES6 goodies with eslint uber alles.

  • CJS distributions targeting specific browser support scenarios.

  • Command line interfaces for your widget with commander.

  • Automated lodash cherry-picking with babel-plugin-lodash.

  • mocha & chai for testing, with examples, and a sweet testing console.

  • Code formatting at every save & paste with prettier.

  • Automated documentation of your API with jsdoc-to-markdown and assembly of your README with concat-md.

  • One-button release to GitHub & publish to NPM with release-it.

See it on GitHub!   Clone the Repo!

If you want to create a React component in an NPM package, try my React Component NPM Package Template instead!

Setting Up Your Dev Environment

Use VS Code as your code editor! Not an absolute requirement, but you’ll be glad you did.

  1. Click here to generate a new repository from this template.

  2. Clone the new repository to your local machine.

  3. VS Code will ask to install a bunch of recommended extensions. Accept all of them. If you miss this prompt, follow these steps:

    • Open the VS Code Extensions tab
    • Enter @recommended into the search box
    • Click the Download link.

  4. Zero the package version and install dependencies by running these commands:

    npm version 0.0.0
    npm install
    

    This may produce an audit report. See Vulnerabilities below for more info.

  5. Run your tests from the command line:

    npm run test
    
    #  foo
    #    ✔ with input
    #    ✔ without input
    #
    #  2 passing (5ms)
    

    If you installed the VS Code extensions referenced above, use the Testing panel to visualize & run your unit tests.

  6. Package your code and link it locally by running:

    npm run package
    npm link
    
  7. Enter a few of your package CLI commands:

    mycli
    
    # foo nil!
    
    mycli -f bar
    
    # foo bar!
    
    mycli -v
    
    # 0.0.0
    
  8. Clean up by unlinking your package.

    npm unlink -g @karmaniverous/npm-package-template
    

Create Local Environment Variable File

Look for .env.local.template in your project directory. Copy this file and remove the .template extension from the copy.

Do not simply rename this file! Anybody who pulls your repo will need this template to create the same file in his own local environment.

Connect to GitHub

This template supports automated release management with release-it.

If you use GitHub, create a Personal Access Token (release-it only needs “repo” access; no “admin” or other scopes). Add it as the value of GITHUB_TOKEN in .env.local.

If you use GitLab, follow these instructions and place your token in the same file.

For other release control systems, consult the release-it README.

You can now create a release at GitHub and optionally publish it to NPM with this command:

npm run release

Vulnerabilities

At the time of this writing, running npm install will generate the following vulnerability warning:

6 vulnerabilities (3 high, 3 critical)

If you run npm audit, you will find that all of these vulnerabilities relate to the following dev dependencies, all of which are to do with docs generation:

npm list underscore

# @karmaniverous/npm-package-template@0.5.1-0
# ├─┬ concat-md@0.5.0
# │ └─┬ doctoc@1.4.0
# │   └── underscore@1.8.3
# └─┬ jsdoc-to-markdown@8.0.0
#   └─┬ jsdoc-api@8.0.0
#     └─┬ jsdoc@4.0.0
#       └── underscore@1.13.6

npm list trim

# @karmaniverous/npm-package-template@0.5.1-0
# └─┬ concat-md@0.5.0
#   └─┬ doctoc@1.4.0
#     └─┬ @textlint/markdown-to-ast@6.0.9
#       └─┬ remark-parse@5.0.0
#         └── trim@0.0.1

These vulnerable dependencies will NOT be included in your NPM package!

Package Structure

This template produces a hybrid NPM package that features both native ES and platform-specific CommonJS module entry points as well as a native ES CLI.

The template supports tree-shakable import from its native ES library entry point at at lib/index.js. This is where you write your source code! See import Entry Points for more info on developing to this entry point and adding new ones.

The template supports require from its platform-specific CommonJS library entry point configured at dist/default. This code is generated by Babel and not pushed to the repository, so you won’t see it here. See require Entry Points for more info on configuring this entry point and adding new ones.

The template features a native ES command-line interface (CLI) entry point at bin/mycli/index.js. This is where you write your CLI! See bin Entry Points below for more info on configuring this entry point and adding new ones.

After you’ve set up your dev environment, your package will look like this. The git & npm columns indicate which files participate in git push and npm publish, respectively.

git npm    Item                         Description
---------------------------------------------------------------------------
        └─ npm-package-template ....... package root
 x ....... ├─ .babelrc ................ global Babel config
 x . x ... ├─ .env .................... global env variables
 ......... ├─ .env.local .............. create from template
 x . x ... ├─ .env.local.template ..... local env secrets template
 x ....... ├─ .eslintrc.json .......... global ESLint config
 x ....... ├─ .gitignore .............. package git ignore
 x ....... ├─ .npmignore .............. package npm ignore
 x ....... ├─ .vscode ................. VS Code project config
 x ....... │  ├─ extensions.json ...... recommended extensions
 x ....... │  └─ settings.json ........ project settings
 x . x ... ├─ bin ..................... all CLI source code (ES)
 x . x ... │  └─ mycli ................ mycli code (ES)
 x . x ... │     └─ index.js .......... mycli entry point (ES)
 x . x ... ├─ dist .................... all CommonJS distributions
 x . x ... │  ├─ default .............. specific CommonJS distribution
 x ....... │  │  ├─ .babelrc .......... Babel target settings
 x ....... │  │  ├─ .gitignore ........ don't send the lib to git...
 x ....... │  │  ├─ .npmignore ........ but DO send it to NPM
 x . x ... │  │  └─ lib/ .............. dist code (generated by Babel)
 x . x ... │  └─ package.json ......... dist code is CommonJS
 x ....... ├─ doc ..................... all documentation config
 x ....... │  ├─ .prettierignore ...... ignore handlebars formatting
 x ....... │  ├─ 1-main.md ............ your main README content
 x ....... │  ├─ 2-cli.md ............. your CLI documentation
 x ....... │  ├─ 3-api.md ............. api docs (generated by jsdoc)
 x ....... │  ├─ 4-footer.md .......... your README footer
 x ....... │  ├─ api-template.hbs ..... your API docs template
 x ....... │  └─ jsdoc.config.json .... ignore test code
 x . x ... ├─ lib ..................... your library source code (ES)
 x . x ... │  ├─ foo .................. structure your code how you like
 x . x ... │  │  ├─ foo.js ............ source code module (ES)
 x ....... │  │  └─ foo.test.js ....... test script (ES)
 x . x ... │  ├─ index.js ............. library import entry point (ES)
 x ....... ├─ package-lock.json ....... dependencies (managed by npm)
 x . x ... ├─ package.json ............ package config
 x . x ... └─ README.md ............... generated by jsdoc

NPM Scripts

Script Description
npm run lint Executes eslint to find errors in your code.
npm run test Executes mocha to run all unit tests.
npm run build Executes babel to build all CJS distributions.
npm run doc Executes jsdoc2md to build your README file from the doc directory.
npm run package Runs lint, test, build & doc to exercise your full packaging process.
npm run release Runs package & executes release-it to create a GitHub release & publish your code to NPM.

Package Entry Points

import Entry Points

All custom library code lives in the lib directory. This is native ES code and you can structure it however you like.

Package import entry points are defined in package.json like this:

{
  "exports": {
    ".": {
      "import": "./lib/index.js"
    }
  }
}

There is currently a single import entry point defined at the package root, which points to lib/index.js. The entry point will expose whatever this module exports, and can be referenced in an ES module like this:

import { foo, PACKAGE_INFO } from '@karmaniverous/npm-package-template`;

Some key guidelines:

  • Avoid using module default exports, even internally. This can create unexpected weirdness in the Babel-generated CJS distributions. Use named exports instead.

See the Node Package Entry Points documentation for more info on defining additional entry points.

Do not move or rename lib/index.js without making matching changes in package.json, or your ES exports will not work!

require Entry Points

The CJS distributions behind your require entry points are generated by Babel from your ES source code in lib. Each distribution is optimized for a set of browser constraints as expressed by a browserslist query.

The template’s default distribution is located at dist/default and uses .babelrc to set browserslist query "defaults".

Package require entry points are defined in package.json like this:

{
  "exports": {
    ".": {
      "require": "./dist/default/lib/index.js"
    }
  }
}

There is currently a single require entry point defined at the package root, which points to dist/default/lib/index.js. The entry point will expose whatever this module exports, and can be referenced in CJS code like this:

const { foo, PACKAGE_INFO } = require('@karmaniverous/npm-package-template`);

See the Node Package Entry Points documentation for more info on defining additional entry points.

To create a new CJS distribution:

  1. Duplicate the default directory in dist and give the copy a meaningful name (e.g. mydist).

  2. In the new directory’s .babelrc file, change the targets value to reflect this distribution’s browserslist query:

    {
      "targets": "new query"
    }
    
  3. Add a new require entry point and a new build script step to your project’s package.json:

    {
      "exports": {
        "./mydist": {
          "require": "./dist/mydist/lib/index.js"
        }
      },
      "scripts": {
        "build": "babel lib -d dist/default/lib --delete-dir-on-start --config-file ./dist/default/.babelrc && babel lib -d dist/mydist/lib --delete-dir-on-start --config-file ./dist/mydist/.babelrc"
      }
    }
    
  4. Build the new distribution with npm run build.

Do not move or rename dist/default.js without making matching changes in package.json, or your CJS exports will not work!

bin Entry Points

Each command-line interface (CLI) entry point is a subdirectory of your bin directory. These are native ES code and you can structure them internally however you like.

CLI entry points are defined in package.json like this:

{
  "bin": {
    "mycli": "bin/mycli/index.js"
  }
}

There is currently a single CLI entry point defined at the package root, which points to bin/mycli/index.js and exposes the mycli command.

See the NPM documentaion documentation for more info on defining CLI entry points.

To create a new CLI entry point:

  1. Create a new subdirectory under bin (e.g. myothercli).

  2. Add an index.js file expressing your command logic, along with other supporting code. Use commander for great results!

  3. Add the new CLI entry point to your package.json like this:

{
  "bin": {
    "myothercli": "bin/myothercli/index.js"
  }
}

Do not move or rename bin/mycli/index.js without making matching changes in package.json, or your CLI will not work!

Unit Testing

By default, this template supports mocha tests using the chai assertion library. The included sample tests express the should assertion syntax.

The default configuration will recognize any file as a test file that…

  • has .test. just before its file name extension (i.e. example.test.js).
  • is not located in the node_modules or dist directories.

The sample code packages tests next to the source code they exercise. If you prefer to segregate your tests and related artifacts into a test directory, that will work as well.

Either way, all test files meeting the above conditions and anything under a test directory will be excluded from the build.

To enable mocha-specific linting in your test files, add the following directive at the top of every test file:

/* eslint-env mocha */

The recommended Mocha Test Explorer extension will suface all of your tests into a sidebar console, nested to reflect your describe hierarchy. It will also decorate your test source code with test running and status reporting controls.

Package Documentation

When you run npm run doc, these two steps happen in order:

  1. jsdoc2md parses the source code in lib for JSDoc-formatted comments, and generates API documenation in doc/3-api.md using doc/api-template.hbs as a template. It ignores test files and anything with the @private tag.

  2. concat-md concatenates every .md file in doc into your main README file. Files are concatenated in filename order (hence the numbered files).

You can exert fine control over the final result by doing the following:

  • Edit 1-main.md. This is the main content of your README file. At a minimum it should contain a header and a brief description of the project, but it can be whatever you want.

  • Edit or delete 2-cli.md. This is your CLI documentation. The easiest way to get this is to run your CLI’s help function on the command line & copy the result (e.g. mycli -h). If your package has no CLI, just delete this file.

  • Edit api-template.hbs. This is your API documentation template. jsdoc2md will render documentation based on the JSDoc-formatted comments in your source code and render them at the {{>main}} tag in the template. Add any other content in Markdown format.

  • Edit 4-footer.md. This footer will appear at the bottom of your README file.

Some tips:

  • Add whatever additional .md files you like! Just remember they will be rendered in filename order.

  • If your main content is really long, consider linking to a blog post instead (like this one!). That way you can update your documentation without having to create a new release.

  • Use commander to create your CLI and take full advantage of its help system (e.g. program & option descriptions). This gives you a more usable CLI, and as a side bonus you just need to run mycli -h to copy & paste your full CLI documentation.

    > mycli -h
    Usage: mycli [options]
    
    Foos your bar.
    
    Options:
      -b, --bar <string>  foo what?
      -v, --version       display package version
      -h, --help          display help for command
    
  • Take full advantage of JSDoc by leveraging @typedef syntax (an example) and importing types from a common file (another example).

Don’t edit your README file directly! Any changes you make will be lost the next time you run npm run doc.

Integration Testing

Your unit tests exercise your code internally. Integration testing validates your code externally, and might include the following:

  • Testing your import entry points from external code.

  • Testing your require entry points from external code.

  • Testing your CLI entry points from the command line.

There are several ways to accomplish these:

  1. Run npm run link to install a global symlink to your local package. This has an effect similar to npm install -g my-package, and will enable you to test your CLI entry points from your local command line without publishing to NPM.

  2. Run npm install c:/package-path/my-package to install your package as a file dependency in another package. This will enable you to test your import and require entry points without publishing to NPM. Run a global install or use npx mycli to test your CLI entry points.

  3. Publish a release to NPM with a pre-release version number. Then run npm install my-package 0.0.1-0 (reflecting the pre-release version) to install your package into another package as a dependency, and test as required.

Each of these approaches has its trade-offs, but a smart plan is to skip the first and do the last two, in that order.

If the second approach passes, why bother with the third?

Because your local project contains all your files, whereas your package .npmignore files determine which ones actually get published to NPM. That last approach validates that your published package has the expected contents.

As a final check, review your package’s Code tab at NPM in order to validate that your .npmignore files blocked everything they should have (e.g. environment secrets, dev tool configs).

Create & Publish a Release

Before you can publish a package to NPM, you’ll need to set up an NPM account.

Package Scope & Access

Your NPM user name is a scope. If you create an organization, its unique organization name is also a scope.

Unscoped packages have names like lodash. An unscoped package name must be unique across NPM.

Scoped packages have names like @karmaniverous/serify-deserify. @karmaniverous in this case is the scope. A scoped package name only needs to be unique within its scope.

NPM packages may be public or private. A public package can be seen and used by anyone. A private package can only be seen & used by your collaborators or other users with access to your organization scope.

Only scoped packages can be private. Only paid NPM accounts can create private packages.

Even if you are only creating public packages, it is a good idea to create scoped packages because it groups them logically and gives you much more flexibility in naming them.

Click here for more info about NPM package scope & access.

Configuring package.json

When you publish an NPM package, NPM gets most of its info from your package.json file.

Set the following values in package.json, using the template file as an example.

This info is critical. You can’t publish your package properly without it:

  • name – The desired package name on NPM. Include scope if relevant. See Package Scope & Access for more info.

  • version – Your package version. Uses semantic versioning. Set this initially to 0.0.0 and the template’s release process will manage it from there.

  • publishConfig.accessrestricted for private packages, otherwise public. See Package Scope & Access for more info.

  • exports – Your import and require entry points. Click the links for more info.

  • bin – Your CLI entry points. See bin Entry Points above for more info.

This info is important but you can always update it in the next release:

  • author – Your name, however you’d like it to appear.

  • bugs.url – A URL for users to report bugs. By default, use the issues page of your GitHub repo.

  • description – A text description of your package. Will be used as the META description of your NPM package page, so keep it under 160 chars.

  • homepage – The main web page of your project. By default, use your GitHub repo’s README link.

  • keywords – An array of strings that will appear as tags on the NPM package page.

  • license – The license associated with your package. See this list of valid license identifiers.

  • repository.url – GitHub repository URL.

Generating the Release

Before you begin, ensure you have committed all changes to your working branch.

Run this command:

npm run package

This will check your code for errors, run all of your tests, generate all of your documentation, and create your build. If there are any issues, fix them. If you make any changes, commit them.

Now run this command:

npm run release

This will generate your package again, just to validate there are no more changes. You will then be asked to select a release increment. Otherwise accept all defaults.

Your release will be generated on GitHub and published to NPM.

Note that if you have configured Two-Factor Authentication at NPM you will be asked to enter a One-Time Password (OTP).

Add other release-it options after --- (Windows) or -- (Mac/Linux). For example, to specify a patch release, and accept all defaults with no user interaction, run this command:

# Windows only.
npm run release --- patch --ci

# Mac/Linux.
npm run release -- patch --ci

See the release-it README for more info on available options.

Integrate a Template Update

Follow these instructions.

Issues

  • The documentation system is choking on dynamic import assertions for for now I’ve added an exclusion to the one directory that uses them (packageInfo).

Leave a comment