Publishing a Maintainable NPM Module with Continuous Integration – Via Codeship
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 IdeaSo, 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!SetupTo get started, the first thing you'll do is create a new folder and jump into it:mkdir human-date-range &amp;&amp; cd human-date-rangeNext up, run:npm initThe 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 JestJest 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-es2015You 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 testNext up, create a new src folder with an index.js file. And add a stub of your function:src/index.jsexport 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.jsimport 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 monthAdd 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.jsexport 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 &amp;&amp; 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 yearThis 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.jsit('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 namesrc/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 weekdayssrc/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 &amp;&amp; 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 namessrc/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.jsexport 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 &amp;&amp; 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 RollupYou 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é setupStart by installing the dependencies:npm install --save-dev rollup rollup-plugin-buble bubleNext, create a new file for rollup config:rollup.config.jsimport 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 &amp;&amp; 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 NPMTime to make the final preparations and publish this module.Module entry pointWhen 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 hookWith 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 &amp;&amp; npm run build"
  },This will make sure your tests pass and your build succeeds before your module can be published.Adding an NPM userAnyone can publish to NPM as long as they have an NPM account. You can create one right from the command line by running:npm adduserFollow 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 publishIt'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.0I 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.