Publishing a Maintainable NPM Module with Continuous Integration
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!
Module 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 two extra dependencies: babel-jest
and babel-preset-es2015
:
npm install --save-dev jest babel-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
and
'human-date-range'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 on 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
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 your Rollup 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.
Reference: | Publishing a Maintainable NPM Module with Continuous Integration from our WCG partner Fredrik Andersson at the Codeship Blog blog. |