A look back at my first published npm library 5 years ago

I recently looked back at some npm packages I first published 5 years ago, and thought it would be an interesting exercise to bring them up to 2021 standards.

For the sake of this article, we will be focusing on the library https://github.com/AntonioVdlC/html-es6cape by first looking at the original code that has been published for the past 5 years, then we will look at some of the changes I recently made on that project and finally reflect a bit on the tooling current landscape.

5 years ago

This was my first npm package, which I built following Kent C. Dodds' course "How to Write a JavaScript Library".

The library per se is just 10 lines of code, so nothing really interesting there, but the tools around the code are very ... 2015!

index.js

// List of the characters we want to escape and their HTML escaped version
const chars = {
  "&": "&",
  ">": ">",
  "<": "&lt;",
  '"': "&quot;",
  "'": "&#39;",
  "`": "&#96;"
};

// Dynamically create a RegExp from the `chars` object
const re = new RegExp(Object.keys(chars).join("|"), "g");

// Return the escaped string
export default (str = "") => String(str).replace(re, match => chars[match]);

package.json

{
  "name": "html-es6cape",
  "version": "1.0.5",
  "description": "Escape HTML special characters (including `)",
  "main": "dist/index.js",
  "jsnext:main": "src/index.js",
  "scripts": {
    ...
  },
  "devDependencies": {
    "babel": "^5.8.29",
    "chai": "^3.4.0",
    "codecov.io": "^0.1.6",
    "istanbul": "^0.4.0",
    "mocha": "^2.3.3",
    "uglify-js": "^2.5.0"
  },
  ...
}

As this was 2015, all the hype was around ES6! But because it was 2015, using directly ES6 syntax in the wild was not really an option, hence babel being a centerpiece of the toolchain.

Rollup just came about the same time with native support for ES modules. As most npm packages were built around CommonJS (and still are), they started to promote a jsnext:main field to link to code using ES modules, as their tooling was optimised for that.

For testing purposes, you had pretty much the default setup of Mocha, Chai and Istanbul, with reports being pushed to CodeCov.

Another interesting aspect is the use of TravisCI, which was also pretty much the default in open source at the time:
.travis.yml

language: node_js
cache:
  directories:
  - node_modules
branches:
  only:
  - master
node_js:
- iojs
before_install:
- npm i -g npm@^2.0.0
before_script:
- npm prune
script:
- npm run test
- npm run coverage:check
after_success:
- npm run coverage:report
Yes, that says iojs and npm@^2.0.0 ... living on the bleeding edge of technology!

Today

So, looking at the code from 5 years ago, there were a few things that needed to be dusted off, and some niceties to add in, because why not:

  • Using TypeScript
  • Supporting both ES modules and CommonJS
  • Migrating the tests to Jest (which provides coverage out-of-the-box)
  • Moving from TravisCI to GitHub Actions
  • Adding prettier for code formatting (+ pre-commit hooks)

Using TypeScript

As part of modernizing the project, I thought it would be a good idea to port those 10 lines of code to TypeScript. The benefits of using TypeScript for an open-source library are that you have an extra layer of static analysis on potential contributions, and, more importantly, it generates types by default which leads to a better developer experience using the library in some IDEs.

chars.ts

// List of the characters we want to escape and their HTML escaped version
const chars: Record<string, string> = {
  "&": "&amp;",
  ">": "&gt;",
  "<": "&lt;",
  '"': "&quot;",
  "'": "&#39;",
  "`": "&#96;",
};

export default chars;

index.ts

import chars from "./chars";

// Dynamically create a RegExp from the `chars` object
const re = new RegExp(Object.keys(chars).join("|"), "g");

// Return the escaped string
function escape(str: string = ""): string {
  return String(str).replace(re, (match) => chars[match]);
}

export default escape;

Supporting both ES modules and CommonJS

Supporting both ES modules and CommonJS deliverables from a TypeScript code base meant a fair amount of changes to the build tooling as well:

package.json

{
  "name": "html-es6cape",
  "version": "2.0.0",
  "description": "Escape HTML special characters (including `)",
  "main": "dist/index.cjs.js",
  "module": "dist/index.esm.js",
  "types": "dist/index.d.ts",
  "files": [
    "dist/index.cjs.js",
    "dist/index.esm.js",
    "dist/index.d.ts"
  ],
  "scripts": {
    ...
    "type:check": "tsc --noEmit",
	...
    "prebuild": "rimraf dist && mkdir dist",
    "build": "npm run build:types && npm run build:lib",
    "build:types": "tsc --declaration --emitDeclarationOnly --outDir dist",
    "build:lib": "rollup -c",
	...
  },
  ...
  "devDependencies": {
    ...
    "@rollup/plugin-typescript": "^8.2.0",
	...
    "rimraf": "^3.0.2",
    "rollup": "^2.41.2",
    "rollup-plugin-terser": "^7.0.2",
    "tslib": "^2.1.0",
    "typescript": "^4.2.3"
  }
}

Of notice are the type checking step type:check which is pair with other static analysis tools (like ESLint) to ensure the soundness of the source code.

To be able to publish code that would work both for ES modules and CommonJS, I've leveraged Rollup, and after some trial and error, arrived at the following configuration:

rollup.config.js

import typescript from "@rollup/plugin-typescript";
import { terser } from "rollup-plugin-terser";

export default [
  {
    input: "src/index.ts",
    output: {
      file: "dist/index.cjs.js",
      format: "cjs",
      exports: "default",
    },
    plugins: [typescript(), terser()],
  },
  {
    input: "src/index.ts",
    output: {
      file: "dist/index.esm.js",
      format: "es",
    },
    plugins: [typescript(), terser()],
  },
];

Migrating tests to Jest

While improving the tooling around writing and building the library, the existing test setup looked a bit too complex for the simple needs of such a small open-source project. Luckily, there exists one tool that provides a test runner, an assertion library and code coverage out-of-the-box: Jest.

test/index.test.js

import chars from "../src/chars.ts";
import escape from "../src/index.ts";

describe("html-es6cape", () => {
  it("should coerce the argument to a String (if not null or undefined)", () => {
    expect(escape(true)).toEqual("true");
    expect(escape(27)).toEqual("27");
    expect(escape("string")).toEqual("string");

    expect(escape(undefined)).not.toEqual("undefined");
    expect(escape()).not.toEqual("undefined");
  });

  it("should return an empty string if null or undefined", () => {
    expect(escape()).toEqual("");
    expect(escape(undefined)).toEqual("");
  });

  Object.keys(chars).forEach((key) => {
    it('should return "' + key + '" when passed "' + chars[key] + '"', () => {
      expect(escape(key)).toEqual(chars[key]);
    });
  });

  it("should replace all the special characters in a string", () => {
    expect(
      escape(
        `Newark -> O'Hare & O'Hare <- Hartfield-Jackson ... "Whoop" \`whoop\`!`
      )
    ).toEqual(
      "Newark -&gt; O&#39;Hare &amp; O&#39;Hare &lt;- Hartfield-Jackson ... &quot;Whoop&quot; &#96;whoop&#96;!"
    );
  });

  it("should work as a template tag on template literals", () => {
    expect(
      escape`Newark -> O'Hare & O'Hare <- Hartfield-Jackson ... "Whoop" \`whoop\`!`
    ).toEqual(
      "Newark -&gt; O&#39;Hare &amp; O&#39;Hare &lt;- Hartfield-Jackson ... &quot;Whoop&quot; &#96;whoop&#96;!"
    );
  });
});

The code in itself isn't particularly interesting, but to be able to test TypeScript code with Jest required some heavy lifting!

package.json

{
  "name": "html-es6cape",
  "version": "2.0.0",
  "description": "Escape HTML special characters (including `)",
  ...
  "scripts": {
    ...
    "test": "jest",
	...
  },
  ...
  "devDependencies": {
    "@babel/core": "^7.13.10",
    "@babel/preset-env": "^7.13.10",
    "@babel/preset-typescript": "^7.13.0",
    ...
    "@types/jest": "^26.0.20",
    "babel-jest": "^26.6.3",
    ...
    "jest": "^26.6.3",
    ...
  }
}

For Jest to understand TypeScript, it needs to compile it first. This is where Babel comes in and produces JavaScript out of the TypeScript source code.

babel.config.js

module.exports = {
  presets: [
    ["@babel/preset-env", { targets: { node: "current" } }],
    "@babel/preset-typescript",
  ],
};

Moving from TravisCI to GitHub Actions

After spending way more time than I originally planned on this simple migration, the last piece of the puzzle was to move from TravisCI to GitHub Actions and still have CI/CD working as before (automatic tests + publishing).

.github/workflows/test.yml

name: test

on: push

jobs:
  test:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        node-version: [12.x, 14.x, 15.x]

    steps:
      - uses: actions/checkout@v2
      - name: Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v2
        with:
          node-version: ${{ matrix.node-version }}
      - run: npm ci
      - run: npm run format:check
      - run: npm run type:check
      - run: npm test

.github/workflow/publish.yml

name: publish

on:
  release:
    types: [created]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-node@v2
        with:
          node-version: 12
      - run: npm ci
      - run: npm test

  publish-npm:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-node@v2
        with:
          node-version: 12
          registry-url: https://registry.npmjs.org/
      - run: npm ci
      - run: npm run build
      - run: npm publish
        env:
          NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}

With that in place, I had pretty much replicated the CI/CD pipeline I previously had on TravisCI.

Conclusion

There are still a few topics we haven't touched upon (pre-commit hooks, auto-formatting, ...), but I am quite pleased with the new setup and I will probably be using a similar one moving forward whenever I'd write a small npm package.