DEV Community

loading...
Cover image for A look back at my first published npm library 5 years ago

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

Antonio Villagra De La Cruz
・6 min read

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.

GitHub logo AntonioVdlC / html-es6cape

⚡ - Escape HTML special characters (including `)


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]);
Enter fullscreen mode Exit fullscreen mode

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"
  },
  ...
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Supporting both ES modules and CommonJS

Supporting both ES modules and CommonJS deliverables from a TypeScript codebase 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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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()],
  },
];
Enter fullscreen mode Exit fullscreen mode

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;!"
    );
  });
});
Enter fullscreen mode Exit fullscreen mode

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",
    ...
  }
}
Enter fullscreen mode Exit fullscreen mode

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",
  ],
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

.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}}
Enter fullscreen mode Exit fullscreen mode

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


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.

Discussion (2)

Collapse
fenok profile image
Leonid Fenko

Great article, it certainly motivated me to figure out how GitHub Actions work and set them up for my own projects already 😅
One question though: did I get it right that Babel is being used only for testing? And if so, isn't it easier to use ts-jest instead? It seems to be more powerful and easier to set up. Am I missing something?

Collapse
antoniovdlc profile image
Antonio Villagra De La Cruz Author

Thanks for taking the time to read the article!

ts-jest is definitely a great alternative to using babel. I didn't look too much into it, because my reasoning here was that I didn't particularly need type checking for the tests, and I could live with the limitations of using babel (kulshekhar.github.io/ts-jest/docs/...), but I guess that would depend on your requirements.