Here at neeto, we build and manage a lot of products. Each product has its team. However, ensuring consistent design and functionalities across these products poses a significant challenge. To aid our product teams in focusing on their core business logic, we've organized common functionalities into separate packages.
In this blog, we'll look into how we build, release and maintain these packages to support our product development cycles. Let's take a look at some of these key packages.
neeto-cist contains essential pure utility functions and forms the backbone of our development framework.
neeto-ui is the foundational design structure for our Neeto products. It contains basic-level components like Buttons, Input fields, etc.
Built on top of neeto-ui
, the neeto-molecules
package houses reusable UI
elements like a login page, settings page, sidebars etc. It simplifies the
creation of consistent user experiences across Neeto products.
neeto-commons
store crucial elements such as initializers, constants, hooks
and configurations shared across our entire product range. We wrote in great
detail about the
challenges we faced while building neeto-commons.
Let's look at how we build, release and maintain these packages.
In general we use Babel to transpile and Rollup to bundle with some exceptions.
Let's look at our standard build configuration.
import peerDepsExternal from "rollup-plugin-peer-deps-external";
import svgr from "@svgr/rollup";
import babel from "@rollup/plugin-babel";
import resolve from "@rollup/plugin-node-resolve";
import alias from "@rollup/plugin-alias";
import json from "@rollup/plugin-json";
import commonjs from "@rollup/plugin-commonjs";
import styles from "rollup-plugin-styles";
import aliases from "./aliases";
export default {
input: {
Component1: "src/components/Component1",
Component2: "src/components/Component2",
//... rest of the entry points
},
output: ["esm", "cjs"].map(format => ({
format,
sourcemap: true,
//... other options
})),
plugins: [
// To automatically externalize peerDependencies in a Rollup bundle.
peerDepsExternal(),
// Inline any svg files
svgr(),
// To integrate Rollup and Babel.
babel({
exclude: "node_modules/**",
babelHelpers: "runtime",
}),
// To use third party modules from node_modules
resolve({ extensions: [".js", ".jsx", ".svg"] }),
// To define aliases while bundling package.
alias({ entries: aliases }),
// To convert .json files to ES6 modules.
json(),
// To convert CommonJS modules to ES6.
commonjs(),
// Handle styles
styles({
minimize: true,
extensions: [".css", ".scss", ".min.css"],
}),
],
};
When dealing with multiple entry points, passing an array of input
is a common
mistake people make. The problem with an input
array is that it will be
duplicated if there is shared code. So, the best approach here is to pass a
key-value object. This way, Rollup will create separate files for shared code
and reuse them everywhere.
Looking at the output
, notice that we built it for CommonJS and ECMAScript.
Even though we don't have any Node.js projects (we are mainly a Rails company),
we still need the CommonJS format to run Jest tests and some scripts for build
and automation purposes.
When using Babel to transpile and Rollup to bundle, we ran into some
configuration pain points.
@rollup/plugin-babel
plugin made the configuration far easier.
@svgr/rollup
converts normal svg
files to react components.
rollup-plugin-peer-deps-external
automatically externalizes peer dependencies, keeping the bundle lean. Even
though we don't have any CommonJS modules, it's better to add
@rollup/plugin-commonjs
so that if there is any deep in dependency rabbit hole.
Above is a simplified version of the Rollup configuration we use on
neeto-cist
, neeto-ui
and neeto-molecules
. We took a much simpler approach
when building neeto-commons
. The reason for abandoning bundling for
neeto-commons
is that within this package, we have a variety of commonly used
elements such as components, hooks, initializers, constants, utils, etc., each
with varying sizes and dependency requirements. If the host project only wants
to use a few simple util functions, we don't want them to be served with the
entire bundle and need to install lots of dependencies.
Instead of serving everything from the root index.js
, we use
exports
field in the
package.json
to specify which files can be imported by the host project. Below
is a sample of our exports.
"exports": {
"./react-utils": {
"import": "./react-utils/index.js",
"require": "./cjs/react-utils/index.js",
"types": "./react-utils.d.ts"
},
"./react-utils/*": {
"import": "./react-utils/*",
"require": "./cjs/react-utils/*",
"types": "./react-utils.d.ts"
},
"./utils": {
"import": "./utils/index.js",
"require": "./cjs/utils/index.js",
"types": "./utils.d.ts"
},
"./utils/*": {
"import": "./utils/*",
"require": "./cjs/utils/*",
"types": "./utils.d.ts"
},
"./initializers": {
"import": "./initializers/index.js",
"require": "./cjs/initializers/index.js",
"types": "./initializers.d.ts"
},
"./constants": {
"import": "./constants/index.js",
"require": "./cjs/constants/index.js",
"types": "./constants.d.ts"
}
}
We export, for example, ./react-utils
and ./react-utils/*
because we want to
support both import styles below.
import { useLocalStorage } from "neetocommons/react-utils";
import useLocalStorage from "neetocommons/react-utils/useLocalStorage";
We initially employed the first import style, but as we expanded, we recognized the necessity of supporting a more concise approach in smaller projects. This second method involves importing solely the target file without additional dependencies, significantly improving tree-shaking capabilities.
We also ensure we build for esm
and cjs
. Below is our simplified Babel
config.
const defaultConfigurations = require("./defaultConfigurations");
const alias = {
assets: "./src/assets",
neetocist: "@bigbinary/neeto-cist",
// others
};
module.exports = function (api) {
const config = defaultConfigurations(api);
config.sourceMaps = true;
config.plugins.push(
["module-resolver", { root: ["./src"], alias }],
"inline-react-svg"
);
if (process.env.BABEL_MODE === "commonjs") {
config.overrides = [
{
presets: [["@babel/preset-env", { modules: "commonjs" }]],
},
];
}
return config;
};
When transpiling, we ensure aliases are correctly resolved, and if there are any
SVG files, we inline them using the
inline-react-svg
plugin to make life easy for the host application. Below is our build script.
"scripts": {
"build:pre": "del-cli dist",
"build:es": "babel --extensions \".js,.jsx\" src --out-dir=dist",
"build:cjs": "BABEL_MODE=commonjs babel --extensions \".js,.jsx\" src --out-dir=dist/cjs",
"build:post": "node ./.scripts/post-build.mjs",
"build": "yarn build:pre && yarn build:es && yarn build:cjs && yarn build:post",
}
We did not want to create a release every time we merged a PR, and we also didn't want to manually release packages every time. Instead, we rely on GitHub Labels while running Github Actions to do the release.
We created three labels specifically for this purpose: patch
, minor
and
major
. As the names suggest, these labels help us create patch
, minor
and
major
versions. When we create a PR and want to do a release when merging,
attach any of these labels, and Github Action will create releases accordingly.
name: "Create and publish releases"
on:
pull_request:
branches:
- main
types: [closed]
jobs:
release:
name: "Create Release"
runs-on: ubuntu-latest
if: >-
${{ github.event.pull_request.merged == true && (
contains(github.event.pull_request.labels.*.name, 'patch') ||
contains(github.event.pull_request.labels.*.name, 'minor') ||
contains(github.event.pull_request.labels.*.name, 'major') ) }}
As evident from the code above, we trigger the Create and publish releases
GitHub Action only if any of the three aforementioned labels are present. Once
that condition is met, we proceed to use
yarn version
to
update the version.
- name: Bump the patch version and create a git tag on release
if: ${{ contains(github.event.pull_request.labels.*.name, 'patch') }}
run: yarn version --patch --no-git-tag-version
- name: Bump the minor version and create a git tag on release
if: ${{ contains(github.event.pull_request.labels.*.name, 'minor') }}
run: yarn version --minor --no-git-tag-version
- name: Bump the major version and create a git tag on release
if: ${{ contains(github.event.pull_request.labels.*.name, 'major') }}
run: yarn version --major --no-git-tag-version
Then, we extract changelogs from PR's title and description.
- name: Extract changelog
id: CHANGELOG
run: |
content=$(echo '${{ steps.PR.outputs.pr_body }}' | python3 -c 'import json; import sys; print(json.dumps(sys.stdin.read().partition("**Description**")[2].partition("**Checklist**")[0].strip()))')
echo "CHANGELOG=${content}" >> $GITHUB_ENV
shell: bash
- name: Update Changelog
continue-on-error: true
uses: stefanzweifel/changelog-updater-action@v1
with:
latest-version: ${{ steps.package-version.outputs.version }}
release-notes: ${{ fromJson(env.CHANGELOG) }}
Finally, we publish the package to NPM.
- name: Publish the package on NPM
uses: JS-DevTools/npm-publish
with:
access: "public"
token: ${{ secrets.NPM_TOKEN }}
Now that the release is done, we need to propagate changes to our products. For
example, we may have extracted an existing functionality from one of the
packages. Here is an example of
how we standardized keyboard shortcuts in neeto
and extracted out into a package. Now, the product team needs to remove that
functionality and use it from the package. Reaching out to each team and asking
them to replace it can be tedious, especially considering their priorities. So
we came up with a plan to use custom
ESLint rules to enforce
them, and we built eslint-plugin-neeto
.
Let's look at a few examples of how we use eslint-plugin-neeto
to enforce
changes on the product team.
There are some third-party packages we decided not to use when we found better
alternatives, and they even included our own. For example, bootstrap
,
moment.js
, @bigbinary/neeto-utils
, etc. To enforce this, we create ESLint
rules called no-blacklisted-imports
. This rule throws a lint error if
developers attempt to commit changes that include these blacklisted imports.
Another example is that we moved higher level constants, commonly utilized
across various products, to neeto-commons
. To enforce this, we created an
ESLint rule called use-common-constants
. This rule detects any local imports
and recommends importing from neeto-commons
instead.
Here is another great blog post in which we explained the challenges we faced while adding translations and enforcing them using ESLint and Babel plugins.
These are just a few of the many ESLint and Babel plugins we created.
If this blog was helpful, check out our full blog archive.