Publishing a Maintainable NPM Module with Continuous Integration

Development

Reading Time: 10 minutes

NPM is host to all kinds of packages. You can bundle up pretty much anything as long as it has a JavaScript entry point, but modules (code that can be imported and used in other JavaScript projects) are by far the most common type of package.

In this two-part series, you will learn how to build, test, and publish a JavaScript module to NPM, as well as how to update it using continuous integration that will automatically test and publish new versions. This post will focus on testing, building, and publishing a module, while Part II will focus on setting up continuous integration for updating the module.

The Module Idea

So, what are you going to build?

Drumroll.

A fairly small function for formatting date ranges as a human might write them.

> humanDateRange({startDate: new Date('2017-01-01'), endDate: new Date('2017-01-06')})
> Jan 1-6 2017)

I actually needed this for a project last year. In short, the requirements are:

  • If both dates are within the same year, only include the year after the second date. Otherwise include the respective year after each date.
  • If both dates are within the same month, only include the month before the first date. Otherwise include the respective month before each date.
  • Should have an option to use short form (Jan) or long form (January) for months.
  • Should have an option to include weekdays (Sun Jan 1-Fri 6 2017).
  • Should have an option to use short form (Sun) or long form (Sunday) for weekdays.
  • Should return null if not given a valid date object for both start and end date.

Exciting, huh? Let’s get started!

Setup

To get started, the first thing you’ll do is create a new folder and jump into it:

mkdir human-date-range && cd human-date-range

Next up, run:

npm init

The first thing you have to decide is the name of the package. It will suggest human-date-range, but that is already taken, so to avoid problems when publishing, give it a name unique to you: human-date-range-yourGitHubOrTwitterNameOrWhatever.

Hit Enter to pick the default for all other options except test command which should be set to jest --coverage. And speaking of tests, that’s the next thing to set up (I promise it will be quick).

I am not a religious Test Driven Development fan, but for this kind of project, where there is a very well-defined API to build and nothing to figure out or explore, I do follow TDD (mostly anyway).

Testing with Jest

Jest is a testing library from Facebook. One of the things I like about it is that it comes preconfigured for most common use cases. On an ES5 project, there is virtually no setup needed, and everything works out of the box after installing it from NPM.

To test an ES2015 project, you have to install an extra dependency: babel-preset-es2015:

npm install --save-dev jest babel-preset-es2015

You should also create a file called .babelrc in your root directory and add this code:

.babelrc

{
  "presets": ["es2015"]
}

This tells babel to use the es2015 preset.

That’s the setup part. To make sure everything is working, you can run npm test. You should see something like this:

Time to write a test

Next up, create a new src folder with an index.js file. And add a stub of your function:

src/index.js

export default function humanDateRange({  
  startDate,
  endDate,
  showWeekDays = false,
  month = 'short',
  weekday = 'short'
} = {}) {
}

This stub lists all the input needed to satisfy all of the requirements above, another thing I usually do when I know exactly what an API will look like.

With this placeholder function in place, you can import it to your test file and start writing some failing tests.

src/index.test.js

import humanDateRange from './index';

describe('humanDateRange', () => {  
  it('should return null if startDate or endDate are not date objects', () => {
    expect(humanDateRange({
      startDate: '2017-01-01', 
      endDate: new Date(2017, 0, 6)
    })).toBe(null);
  });
});

You don’t need to tell Jest where to look for tests; it will automatically look for files named x.test.js or x.spec.js, as well as any JS files placed in a directory named _tests_.

Now when you run npm test again, you should have one failing test.

humanDateRange requires the input startDate and endDate to be JavaScript Date objects and should return null on any invalid input.

Time to make the test pass:

export default function humanDateRange({  
  startDate,
  endDate,
  showWeekDays = false,
  month = 'short',
  year = 'numeric',
  weekday = 'short'
} = {}) {
  // Make sure both startDate and endDate are dates
  if (!isDate(startDate) || !isDate(endDate)) {
    return null;
  }
}

function isDate(date) {  
  return Object.prototype.toString.call(date) === '[object Date]';
}

Testing if a JavaScript variable is a Date object can be done in a number of ways. This isDate function at the bottom uses a clever little trick to call .toString() on the input and comparing that string to the expected [object Date].

With this check in place for the required inputs, the test should now pass:

Check if dates are within the same month

Add a new test and watch it fail, this time testing that if you pass in startDate and endDate within the same month, it only writes out the name of the month once.

src/index.test.js (after the end of the last it() block)

  it('should recognize dates within the same month', () => {
    expect(humanDateRange({
      startDate: new Date(2017, 0, 1), 
      endDate: new Date(2017, 0, 6)
    })).toBe('Jan 1-6 2017');
  });

src/index.js

export default function humanDateRange({  
  startDate,
  endDate,
  showWeekDays = false,
  month = 'short',
  year = 'numeric',
  weekday = 'short'
} = {}) {
  // Make sure both startDate and endDate are dates
  if (!isDate(startDate) || !isDate(endDate)) {
    return null;
  }

  const sYear = startDate.getFullYear();
  const sDate = startDate.getDate();
  const sMonth = startDate.toLocaleString('en-US', {month});
  const eYear = endDate.getFullYear();
  const eDate = endDate.getDate();
  const eMonth = endDate.toLocaleString('en-US', {month});

  // Check if month and year are the same
  if (sYear === eYear && sMonth === eMonth) {
    return `${sMonth} ${sDate}-${eDate} ${eYear}`;
  }
}

The idea here is to first pick out year, month, and date variables from the start date and end date. You can then use those variables both for comparisons and for formatting a return string.

toLocaleString() takes a second parameter with options. Sending in {month: 'short'} makes sure it returns “Jan” instead of “January”. You don’t actually have to write out short since you defined month with a default value (month = 'short') at the top of the function.

When the year and months match, you return a formatted string that will satisfy the test above Jan 1-6 2017 format.

The block in back ticks `` is called a template literal, which was introduced in ES2015. They support expressions (indicated by a dollar sign and curly braces), which will be evaluated at runtime. So ${sMonth} becomes Jan.

Check if dates are within the same year

This time, add two tests, testing respectively that the year is not written twice if the start date and end date are within the same year, and that it does print out both years when they are not the same:

src/index.test.js

it('should recognize dates within the same year', () => {
    expect(humanDateRange({
      startDate: new Date(2017, 0, 1), 
      endDate: new Date(2017, 1, 1)
    })).toBe('Jan 1-Feb 1 2017');
  });

  it('should print full dates when different years', () => {
    expect(humanDateRange({
      startDate: new Date(2016, 0, 1), 
      endDate: new Date(2017, 0, 1)
    })).toBe('Jan 1 2016-Jan 1 2017');
  });

src/index.js (after the if (sYear === eYear && sMonth === eMonth) block)

  // Check if year is the same
  if (sYear === eYear) {
    return `${sMonth} ${sDate}-${eMonth} ${eDate} ${eYear}`;
  }

  return `${sMonth} ${sDate} ${sYear}-${eMonth} ${eDate} ${eYear}`;

This is some fine deduction logic! You already checked the dates for same month and year, and now you check for same year. If both those checks fail, you can deduce that the dates are from different years and return the format Jan 1 2016-Jan 1 2017.

Both your new tests should now pass!

Optionally use the full month name

src/index.test.js

  it('should use long month name if specified', () => {
    expect(humanDateRange({
      startDate: new Date(2016, 0, 1),
      endDate: new Date(2017, 0, 1),
      month: 'long'
    })).toBe('January 1 2016-January 1 2017');
  });

Sending in month: 'long' should set the function to format month as “January” instead of “Jan”. Unfortunately, this test already passes. Because month was already defined as input to the function with a default month = 'short', and you were already using that in toLocaleString(), this just works.

Optionally add weekdays

src/index.test.js

  it('should show weekdays if specified', () => {
    expect(humanDateRange({
      startDate: new Date(2017, 0, 1), 
      endDate: new Date(2017, 0, 6), 
      showWeekDays: true
    })).toBe('Sun Jan 1-Fri 6 2017');
  });

This test does fail! You need to add some weekday logic:

src/index.js

  const sYear = startDate.getFullYear();
  const sDate = startDate.getDate();
  const sWeekday = showWeekDays ? startDate.toLocaleString('en-US', {weekday}) + ' ' : '';
  const sMonth = startDate.toLocaleString('en-US', {month});
  const eYear = endDate.getFullYear();
  const eDate = endDate.getDate();
  const eWeekday = showWeekDays ? endDate.toLocaleString('en-US', {weekday}) + ' ' : '';
  const eMonth = endDate.toLocaleString('en-US', {month});

  // Check if month and year are the same
  if (sYear === eYear && sMonth === eMonth) {
    return `${sWeekday}${sMonth} ${sDate}-${eWeekday}${eDate} ${eYear}`;
  }

  // Check if year is the same
  if (sYear === eYear) {
    return `${sWeekday}${sMonth} ${sDate}-${eWeekday}${eMonth} ${eDate} ${eYear}`;
  }

  return `${sWeekday}${sMonth} ${sDate} ${sYear}-${eWeekday}${eMonth} ${eDate} ${eYear}`;

sWeekday and eWeekday will be empty strings when showWeekDays is false, and otherwise they are created using toLocaleString much like the month names.

With the weekday variables in place, add them into the three different template literals.

Final test, full weekday names

src/index.test.js

  it('should use long weekday name if specified', () => {
    expect(humanDateRange({
      startDate: new Date(2017, 0, 1),
      endDate: new Date(2017, 0, 6),
      showWeekDays: true,
      weekday: 'long'
    })).toBe('Sunday Jan 1-Friday 6 2017');
  });

Like the month name test, this one will just pass because of how the function uses toLocaleString() with the default weekday param.

The final code should look like this:

src/index.js

export default function humanDateRange({  
  startDate,
  endDate,
  showWeekDays = false,
  month = 'short',
  weekday = 'short'
}) {
  // Make sure both startDate and endDate are dates
  if (!isDate(startDate) || !isDate(endDate)) {
    return null;
  }

  const sYear = startDate.getFullYear();
  const sDate = startDate.getDate();
  const sWeekday = showWeekDays ? startDate.toLocaleString('en-US', {weekday}) + ' ' : '';
  const sMonth = startDate.toLocaleString('en-US', {month});
  const eYear = endDate.getFullYear();
  const eDate = endDate.getDate();
  const eWeekday = showWeekDays ? endDate.toLocaleString('en-US', {weekday}) + ' ' : '';
  const eMonth = endDate.toLocaleString('en-US', {month});

  // Check if month and year are the same
  if (sYear === eYear && sMonth === eMonth) {
    return `${sWeekday}${sMonth} ${sDate}-${eWeekday}${eDate} ${eYear}`;
  }

  // Check if year is the same
  if (sYear === eYear) {
    return `${sWeekday}${sMonth} ${sDate}-${eWeekday}${eMonth} ${eDate} ${eYear}`;
  }

  return `${sWeekday}${sMonth} ${sDate} ${sYear}-${eWeekday}${eMonth} ${eDate} ${eYear}`;
}

function isDate(date) {  
  return Object.prototype.toString.call(date) === '[object Date]';
}

Building with Rollup

You are almost ready to publish this beauty, but I want you to consider this short checklist first.

  • Your module should work both for import humanDateRange from
    'human-date-range'
    and require('human-date-range') statements.
  • Your module should work for anyone who imports/requires your module without them having to figure out if they should convert your code to the version of JavaScript they are using.

This will be done by creating two builds of your library: one CommonJS version that supports require, and one ECMAScript module version that supports import.

The build tool you will use is Rollup coupled with Bublé as the ES2015 to ES6 transpiler.

The build will probably be less complicated than you think, since they are both awesome tools that work great together with very little setup.

Rollup/Bublé setup

Start by installing the dependencies:

npm install --save-dev rollup rollup-plugin-buble buble

Next, create a new file for rollup config:

rollup.config.js

import buble from 'rollup-plugin-buble';

export default {  
  entry: 'src/index.js',
  moduleName: 'human-date-range',
  plugins: [
    buble()
  ],
  format: process.env.format,
  dest: `dist/index.${process.env.format}.js`
};

And in your package.json, add a new "build" property to "scripts" so that it looks like this:

  "scripts": {
    "test": "jest --coverage",
    "build": "rollup -c --environment format:cjs && rollup -c --environment format:es"
  },

You can now run npm run build, which will run Rollup twice, once with --environment format:cjs which will create a CommonJS version in dist/index.cjs.js, and once with --environment format:es which will create an EcmaScript module version at dist/index.es.js.

!Sign up for a free Codeship Account hbspt.cta.load(1169977, ‘964db6a6-69da-4366-afea-b129019aff07’, {});

Publishing to NPM

Time to make the final preparations and publish this module.

Module entry point

When someone NPM-installs your module, they will expect to be able to require() it into their projects. This will point them to whatever entry point is set in the "main" field in package.json.

Your "main" field will have be set to the default, index.js, right now, but you want to point it to dist/index.js where Rollup will put your CommonJS build. That will not work great right now since your src/index.js does not follow the CommonJS standard; your dist/index.js does.

For people who instead import your module into their project, note that, using ES2015 standard, there is another field you can set in package.json: "module".

package.json

  "main": "dist/index.js",
  "module": "src/index.js",

Now both require() and import users should be happy with your module. There is more information in the Rollup docs and on the 2ality blog about having different entry points for different consumers of your package.

Prepublish hook

With the current setup, you have to remember to manually build the ES5/CommonJS version of your module before publishing. That is pretty dangerous. If you forget that step, your CommonJS users will be using the previous version, and probably without you noticing it, until you start receiving weird bug reports.

There is an easy fix for this though. Add a "prepublish" script to your package.json so your scripts section looks like this:

  "scripts": {
    "test": "jest --coverage",
    "build": "rollup -c",
    "prepublish": "npm test && npm run build"
  },

This will make sure your tests pass and your build succeeds before your module can be published.

Adding an NPM user

Anyone can publish to NPM as long as they have an NPM account. You can create one right from the command line by running:

npm adduser

Follow the prompts and you’ll soon be a registered NPM user. If you already have an NPM account, you can instead run npm login.

NPM publish

It’s time!

npm publish

]

There you go! The first thing that will happen is that the "prepublish" script will run, first running your tests, and then your Rollup build. And finally, when all that succeeds, your module will be published. There are no grand firework displays to confirm this, just a single line that looks something like this:

+ human-date-range@1.0.0

I think you totally deserve fireworks for publishing a package to NPM though:

💥🎆🎇🎆🎇🎆🎇🎆🎇🎆🎇💥

Well done! In Part II, you will learn how to update your module using continuous integration.

Subscribe via Email

Over 60,000 people from companies like Netflix, Apple, Spotify and O'Reilly are reading our articles.
Subscribe to receive a weekly newsletter with articles around Continuous Integration, Docker, and software development best practices.



We promise that we won't spam you. You can unsubscribe any time.

Join the Discussion

Leave us some comments on what you think about this topic or if you like to add something.

  • Pingback: Node.js Weekly Update – 2 June, 2017 | Webammer()

  • jorge_avila

    I like the way you added jest in there ;-)
    Your guide is very clear and easy to follow.
    Many thanks.

  • Valentino

    Thanks for this post Fredrik, can’t wait for the second part! Just a side note: I think there is no need to specify babel-jest, it’s automatically installed with Jest:

    npm install –save-dev jest babel-preset-es2015

    Cheers!

    • Fredrik Andersson

      Thanks for the feedback, babel-jest was still a needed dependency when I wrote this, but now it’s not. So it’s removed and the mistake in your other comment should be fixed as well. 🙂

  • Valentino

    I think I’ve found another typo: in the Module entry point section, the line “main” in package.json should look like this “main”: “dist/index.cjs.js”, shouldn’t it?
    Cheers!