- Monorepo
Everyone is tired with creating templates for each type of NPM package with pre-configured skeletons, managing different dependencies across own packages, linking packages for local development with instant packages rebuilding which is "wasting developers time", writing proper configuration for linters, babel
, typescript
, etc, and adds a lot of repeatable steps for developers. The worst part appears when one of the key dependencies is getting breaking change upgrade, for instance babel
- it produces plenty of duplicated changes in each of the package, massive amount of merge request to be reviewed and introduces high change to make a mistake (or even worse - forget about change) in one of the packages. Hopefully, it's year 2018 and JavaScript world has a solution for managing group of the packages in the one repository. This approach calls monorepo
.
There are many of approaches how to implement monorepo - yarn workspaces
, buck
, pants
, lerna
, and others. In this article we'll cover lerna
approach with yarn
as package manager. Moreover, lerna
integrates so greatly with yarn workspaces
as it allows to use workspaces commands directly in the repository even without lerna
.
The goal of this article is to create monorepo starter with Babel for building packages, TypeScript for having benefits of statically typed languages in the JavaScript world, static code analyzing tools (Tslint, Stylelint), Prettier for having automatically formated code (no more tabs or spaces holly wars), Storybook for developing components, and last, but not least - Lerna for publishing. All components will be written with React and StyledComponents for CSS-in-JS styling.
Let's not waste time on long talks about what are those tools and why they are so important, and proceed to the real configuration and will see how everyone will benefit from each in the future.
For those who are impatient, here is the link to the repository which contains whole set of results form this article - configured monorepo, ready to go and use.
Packages:
- NodeJS LTS
- yarn v1.5+
- Lerna v3+
Local NPM registry will be used in whole article to avoid publishing to global registry plenty of test packages. There are plenty of ways how to set up private NPM repository. In this example Verdaccio will be used for such purpose.
Configuration is simple tnd trivial:
$ yarn global add verdaccio
$ verdaccio &
$ npm set registry http://localhost:4873/
$ npm adduser --registry http://localhost:4873
Username: test
Password: ***
Email: ***
Now we could test how it works:
yarn info @babel/cli
should still show package details and in console where we started verdaccio
we should see incoming request
http --> 200, req: 'GET https://registry.npmjs.org/@babel%2Fcli' (streaming)
http --> 200, req: 'GET https://registry.npmjs.org/@babel%2Fcli', bytes: 0/85596
Moreover, you could open http://localhost:4873/
for searching published packages.
Time to start our configuration.
$ lerna init
And you have initial structure of the monorepo created by Lerna.
A little bit of important theory about packages Versioning. This step is the most important one on the repository creation stage as it would impact how do we publish/tags our packages.
Lerna supports two types of packages versioning:
- Independent
- Exact
When exact type is chosen, Lerna will use the same version for all packages in monorepo. In case when independent version is selected, Lerna will release each package with independent version. More details about versioning is on official Lerna page
This article will consider only independent versioning for all packages as on the initial stage of packages development of some packages would have much more releases then others, and with independent versioning we would have only required packages released.
Going back to the real examples:
$ lerna init --independent
lerna info version 3.4.0
lerna info Updating package.json
lerna info Creating lerna.json
lerna info Creating packages directory
lerna success Initialized Lerna files
and new repository for packages with independent versioning is ready.
By default lerna is using NPM, however, it's quite simple to set Yarn as package manager:
{
"packages": ["packages/*"],
"version": "independent",
"useWorkspaces": true,
"npmClient": "yarn"
}
Moreover, with "useWorkspaces": true
we allow lerna
to support yarn workspaces
and with "packages": ["packages/*"]
we specify in which folder(s) we would have all our packages.
TypeScript could be used in two ways:
- native TypeScript with
tsc
- @babel/preset-typescript
Implementation from babel
does not have all features from latest TypeScript (like const enum
), however, in case of usage babel
we do not miss all cool plugins and integration with all tools looks much easier.
In this article we would use only babel
with typescript
preset.
Just hit next command to install all required plugins and presets:
$ yarn add -DW @babel/cli @babel/core @babel/preset-typescript @babel/preset-env [email protected] @babel/preset-react typescript @types/node
[email protected]
should be noticed separately as it's needed for properly resolving babel-core
packages for all libs which requires old version of babel and for avoiding duplicated packages installed and possible misusage of babel
configuration. Next line have to be added to the root package.json
:
{
"resolutions": {
"babel-core": "^7.0.0-bridge.0"
}
}
Configuration is quite simple and trivial - @babel/preset-react and @babel/preset-typescript plus simple config for @babel/preset-env.
In Release candidate version, Babel team has removed support for stage-*
and preset-201*
packages, so it means that all actually used plugins should be set by users manually.
Apart from removing stage-*
packages, Babel has changed approach for looking up config files - it looks for config in cwd
till the first package.json
. More details could be found in official Babel documentation
Let's configure babel.config.js
with next data:
module.exports = (api) => {
api.cache(true);
return {
presets: [
[
'@babel/env',
{
targets: {
browsers: 'Last 2 Chrome versions, Firefox ESR',
node: '8.9',
},
},
],
[
'@babel/preset-react',
{
development: process.env.BABEL_ENV !== 'build',
},
],
'@babel/preset-typescript',
],
env: {
build: {
ignore: [
'**/*.test.tsx',
'**/*.test.ts',
'**/*.story.tsx',
'__snapshots__',
'__tests__',
'__stories__',
],
},
},
ignore: ['node_modules'],
};
};
Due to changes for config lookup, build command in monorepo should have path to .babelrc
or babel.config.js
specified, or as --root-mode option. It could be done directly in build commands:
- In root package.json:
{
"scripts": {
"build": "lerna exec --parallel 'BABEL_ENV=build babel src --out-dir dist --source-maps --extensions .ts,.tsx --config-file ../../babel.config.js --delete-dir-on-start --no-comments'"
}
}
build
command in each package. In this case each of packages could be built independently withoutlerna
. In this casebuild
command is a little bit different:
{
"scripts": {
"build": "BABEL_ENV=build babel src --out-dir dist --source-maps --extensions .ts,.tsx --delete-dir-on-start --config-file ../../babel.config.js --no-comments"
}
}
- Another way how to treat parent babel config is
extends
option provided by latest babel. The easiest approach for that is setting next lines inpackage.json
on package level:
{
"extends": "../../babel.config.js"
}
- In this case build command doesn't require passing path to babel config and will be simplified:
{
"scripts": {
"build": "lerna exec --parallel 'BABEL_ENV=build babel src --out-dir dist --source-maps --extensions .ts,.tsx --delete-dir-on-start --no-comments'"
}
}
- Last and easiest option is just add
--root-mode=upward
option to the build command which allows to resolve babel config upward from the current root. In this case build command looks like:
{
"scripts": {
"build": "lerna exec --parallel 'BABEL_ENV=build babel --root-mode upward src --out-dir dist --source-maps --extensions .ts,.tsx --delete-dir-on-start --no-comments'"
}
}
@babel/plugin-transform-typescript does not perform type-check for it's input. However, it could be checked with native typescript
compiler (tsc
). It should be run with option noEmit
for checking types only without emitting any code.
Let's create minimal required tsconfig.json
in the root of monorepo:
{
"compilerOptions": {
"noEmit": true,
"strict": true,
"jsx": "react",
"target": "esnext",
"module": "esnext",
"moduleResolution": "node",
"esModuleInterop": true,
"rootDir": "./",
"baseUrl": "./",
"paths": {
"*": ["node_modules", "packages"]
}
},
"include": ["packages"],
"exclude": ["node_modules"]
}
and put it in prebuild
step to run type checks automatically prior each build
{
"scripts": {
"prebuild": "tsc"
}
}
Lerna does not have ability to add peer dependency for packages. Nevertheless, it can be done with yarn workspaces - all we need is just add workspaces definition to the root package.json
{
"workspaces": ["packages/*"]
}
In our case react
, as well as styled-components
, are defined as peerDependencies as all our packages will have them, however we don't want to have plenty of dependencies on each package installed separately.
As we decided to set react
and styled-components
as peer dependency, we still should have them, and associated types definitions for typescript, installed in our node_modules
. So let's add them as devDependency
to the root of monorepo:
yarn add -DW react @types/react styled-components
Having typescript for types checking could protect us from creating plenty of mistakes and save a lot of time for writing basic unit tests related to incorrect input data. However, it won't protect us from writing over-complicated, unreadable, or even hacky code. Even more, it won't protect us from using incorrect or unsupported CSS rules, would it be CSS, SCSS or CSS-in-JS.
Due to tslint deprecation plan in 2019, @typescript-eslint
is going to be used as standard stacic code analyzer.
Let's add all required packages as devDependency in the root of our monorepo:
yarn add -DW eslint eslint-plugin-react @typescript-eslint/parser @typescript-eslint/eslint-plugin eslint-plugin-import
and create simple config file for it .eslintrc.js
:
module.exports = {
env: {
node: true,
browser: true,
jest: true,
},
parser: '@typescript-eslint/parser',
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:@typescript-eslint/recommended',
'prettier/@typescript-eslint',
'plugin:import/errors',
'plugin:import/warnings',
'plugin:import/typescript',
],
parserOptions: {
ecmaVersion: 2018,
sourceType: 'module',
ecmaFeatures: {
jsx: true,
},
},
settings: {
react: {
version: 'detect',
},
},
rules: {
'react/prop-types': 'off',
'import/no-extraneous-dependencies': ['error'],
},
};
and new script in package.json for linting *.ts
files:
{
"scripts": {
"lint:ts": "eslint 'packages/**/*.ts{,x}'"
}
}
Now static code analyzer could be run with simple yarn lint:ts command.
The easiest solution for automating such checks is using tslint
for static code analyzing.
Let's install it as devDependency in the root of our monorepo:
yarn add -DW tslint tslint-react
and create simple config file for it tslint.json
:
{
"extends": ["tslint:latest", "tslint-react"],
"rules": {
"semicolon": "single"
}
}
and new script in package.json for linting *.ts
files:
{
"scripts": {
"lint:ts": "tslint 'packages/**/*.ts{,x}'"
}
}
Now static code analyzer could be run with simple yarn lint:ts
command.
Let's use stylelint
for improving our CSS styles quality and readability. It could be easily used for analyzing CSS-in-JS as well as with simple CSS or SCSS files.
Installation and configuration process as simple as with tslint
:
yarn add -DW stylelint stylelint-processor-styled-components stylelint-config-styled-components stylelint-config-standard
Create simple config file for it tslint.json
:
{
"processors": ["stylelint-processor-styled-components"],
"extends": ["stylelint-config-standard", "stylelint-config-styled-components"]
}
and new script in package.json for linting *.ts
files:
{
"scripts": {
"lint:css": "stylelint 'packages/**/*.ts{,x}'"
}
}
Now static code analyzer could be run with simple yarn lint:css
command.
For now we have lint:ts
for checking typescript code quality and lint:css
for CSS, however, they are still separated commands and it would be uncomfortable to run them separately all the time. Let's group them in one unified lint
command and run it with npm-run-al
:
yarn add -DW npm-run-all
and new script in the root package.json
:
{
"scripts": {
"lint": "run-p -c lint:*",
"lint:ts": "tslint 'packages/**/*.ts{,x}'",
"lint:css": "stylelint 'packages/**/*.ts{,x}'"
}
}
Note: run-p -c
allows to run all lint:*
commands even if one of them failed. It's useful in case of separated static code analyzer steps, as after one run we have output from tslint
and stylelint
, instead of only first failed.
For now we have typescript
for static types checking, eslint
and stylelint
for static code analyzing. Still, we could write unreadable or not well formatted code. We could avoid all issues relate to code formatting with prettier
. It will automatically format our code according to predefined standards. Moreover, it will fix some issues reported by eslint
.
As usual, installation and configuration is very simple:
yarn add -DW prettier eslint-plugin-prettier eslint-config-prettier
Next is needed is .prettierrc
:
{
"printWidth": 80,
"tabWidth": 2,
"singleQuote": true,
"trailingComma": "all",
"arrowParens": "always"
}
and integration with eslint
, as both of the tools have common rules (like tabWidth, trailingComma, etc). Next lines should be changed in the .eslintrc.js
to make it work with prettier:
module.exports = {
// existing eslint configuration
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:@typescript-eslint/recommended',
'prettier/@typescript-eslint',
'plugin:prettier/recommended',
'plugin:import/errors',
'plugin:import/warnings',
'plugin:import/typescript',
],
// existing eslint configuration
};
Important note is quotemark
rule. Because of jsx
usage we have to override default recommended rules which requires to have single quotemark everywhere.
Last but not least, let's add script to the root package.json
for automatic code formatting based on defined above rules:
{
"scripts": {
"fix": "yarn lint:ts --fix"
}
}
For now we have TypeScript for checking types, tslint
and stylelint
for static code quality analyzing. Last, but not least part is testing. Let's use jest
as test runner and test assertion tool. It has the best support for react
, including snapshot testing, extensive mocking library, build-in coverage reporting and ability to run tests in different processes. ts-jest
should be used for running typescript
code. Installation is also quite simple:
yarn add -DW jest ts-jest @types/jest
jest
could be configured in two ways:
yarn jest --init
and answer for all questions- manually create config file with minimum required configuration
Here is basic setup from jest.config.js
in the root of the monorepo
module.exports = {
clearMocks: true,
coverageDirectory: 'coverage',
coverageReporters: ['text', 'clover'],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
globals: {
'ts-jest': {
extends: './babel.config.js',
},
},
moduleFileExtensions: ['ts', 'tsx', 'js'],
notify: true,
notifyMode: 'always',
roots: ['<rootDir>packages'],
testMatch: ['**/__tests__/*.+(ts|tsx|js)', '**/*.test.+(ts|tsx|js)'],
transform: {
'^.+\\.(ts|tsx)$': 'ts-jest',
},
};
Notes:
ts-jest
could use babel config or pure typescript compiler. In our case we have babel configured, so it could be used with just 1 line in config:
globals: {
'ts-jest': {
extends: './babel.config.js',
},
},
coverageThreshold
is protecting us from writing not enough tests and having poor test coverage.
As soon as we have basic config, it's time to install enzyme
(and all related libraries) which is extending support for react
:
yarn add -DW enzyme enzyme-adapter-react-16 @types/enzyme @types/enzyme-adapter-react-16
Add setupTestFrameworkScriptFile: '<rootDir>jest/setupTests.ts'
into jest.config.js
file and proper setup for enzyme
into jest/setupTests.ts
:
import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
Enzyme.configure({ adapter: new Adapter() });
Now jest
is able to render react
components, however, it will serialize snapshots as pure objects and we want to have it serialized as HTML markup. We could achieve it with proper serializer:
yarn add -DW enzyme-to-json@next
and snapshotSerializers: ['enzyme-to-json/serializer']
in jest.config.js.
We are almost there, we are able to run tests and create proper snapshots. Nevertheless, we still have issues with styled-components
- on each change in styles, styled-components
is creating different class name. Based on it we'll have plenty of false negative tests fails just because of class name is changed. Let's fix it with proper tool
yarn add -DW jest-styled-components
import 'jest-styled-components'
should be added to the jest/setupTests.ts
.
Just to sum up, jest.config.js
:
module.exports = {
clearMocks: true,
coverageDirectory: 'coverage',
coverageReporters: ['text', 'clover'],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
globals: {
'ts-jest': {
extends: './babel.config.js',
},
},
moduleFileExtensions: ['ts', 'tsx', 'js'],
notify: true,
notifyMode: 'always',
roots: ['<rootDir>packages'],
testMatch: ['**/__tests__/*.+(ts|tsx|js)', '**/*.test.+(ts|tsx|js)'],
transform: {
'^.+\\.(ts|tsx)$': 'ts-jest',
},
setupTestFrameworkScriptFile: '<rootDir>jest/setupTests.ts',
snapshotSerializers: ['enzyme-to-json/serializer'],
};
That's it, jest
is configured and could be run. Let's add test
to the root package.json
scripts:
{
"scripts": {
"test": "jest"
}
}
We already able to write code in monorepo with typescript
, analyze it with tslint
and stylelint
, test it with jest
. However, we still cannot see how our components will look like and we cannot even debug it properly.
There are plenty of ways how to present react component. Let's go with most famous one - storybook
. It allows to present separate components and/or group of them as well as testing it in real browsers, and having documentation close to it.
Latest stable version of [email protected]
works with webpack@3
, babel@^6
and typescript@^2.7
. As we have latest @babel@^7
and typescript@^3
it's better to use next
version of storybook which has the same set of dependencies as our monorepo, even if it's bleeding edge version.
If you do it for the first time, you should have @storybook@cli
installed globally on your machine and init:
yarn global add @storybook/cli@next
getstorybook
It will automatically detect project type (react), installs all required packages and create basic configuration folder.
Still, as we use typescript, we have to install typings for those packages, loader for wepback and needed peerDependencies:
yarn add -DW @types/storybook__react @types/storybook__addon-actions @types/storybook__addon-links react-dom webpack awesome-typescript-loader
Storybook inits application as it is javascript based, as we have typescript everywhere, we have to change path resolution for stories in storybook/config.js
file:
const req = require.context('../packages', true, /.story.tsx?$/);
Last but not least part is webpack.config.js
inside storybook
folder, just create it with next content:
module.exports = (baseConfig, env, config) => {
config.module.rules.push({
test: /\.(ts|tsx)$/,
loader: require.resolve('awesome-typescript-loader'),
});
config.resolve.extensions.push('.ts', '.tsx');
return config;
};
and associated configuration part in tsconfig.json
for awesome-typescript-loader
:
{
"awesomeTypescriptLoaderOptions": {
"useBabel": true,
"babelCore": "@babel/core"
}
}
Configuration part is ready, we could start storybook with yarn storybook
command.
As soon as we add storybook
to our devDependencies and run yarn lint:ts
we will get an error from eslint
:
{path_to_repository}/monorepo/packages/input/src/Input.story.tsx
1:1 error '@storybook/addon-knobs' should be listed in the project's dependencies. Run 'npm i -S @storybook/addon-knobs' to add it import/no-extraneous-dependencies
2:1 error '@storybook/react' should be listed in the project's dependencies. Run 'npm i -S @storybook/react' to add it import/no-extraneous-dependencies
This means that we need to configure import/no-extraneous-dependencies
to be able to work with development dependecies which are provided in package.json
in root of monorepository.
Let's add override to .eslintrc.js
:
// .eslintrc.js
modeule.exports = {
// existing eslint configuration
overrides: [
{
files: ['*.{test,spec,story}.ts{,x}'],
rules: {
'import/no-extraneous-dependencies': ['error', { packageDir: './' }],
},
},
],
};
This allow linter to check story
, test
and spec
files with extended list of development dependencies, which is provided by package.json in root of monorepository.
As soon as we add storybook
to our devDependencies and run yarn lint:ts
we will get an error from tslint
:
ERROR: Module '@storybook/react' is not listed as dependency in package.json
The reason is obvious, we use package which is installed as devDependency in our source code (in the story file). Unfortunately there is no options like override for specific path or files. It could be done by splitting lint-ts
command into two separated for production code (which will be shipped as packages) and for test code (storybook, tests, etc).
Let's create config for tslint
for production phase, called tslint.prod.json
:
{
"extends": ["./tslint.json"],
"rules": {
"no-implicit-dependencies": true
}
}
Another config for the test phase called tslint.test.json
:
{
"extends": ["./tslint.json"],
"rules": {
"no-implicit-dependencies": [false, "dev"]
}
}
"no-implicit-dependencies": false
into tslint.json
to disable this rule by default. This one is need to fix issues with IDEs as by default they use tslint.json
for all files, whether it test or production code.
Last, but not least, scripts in the root package.json
have to be adjusted:
{
"scripts": {
"fix": "run-p -c lint:ts-* --fix",
"lint:ts": "run-p -c lint:ts-*",
"lint:ts-prod": "tslint --config tslint.prod.json 'packages/**/*.ts{,x}' --exclude '**/*.{test,story}.ts{,x}'",
"lint:ts-test": "tslint --config tslint.test.json 'packages/**/*.{test,story}.ts{,x}'"
}
}
Storybook allows to pass any props to the react component without rebuilding stories, just through UI interface. To do it, we have to add one more addons - @storybook/addon-knobs
yarn add -DW @storybook/addon-knobs@next @types/storybook__addon-knobs moment
NOTE: moment has to be installed because of wrong peerDependencies management on storybook
and react-datetime
level.
Next step is to add import '@storybook/addon-knobs/register';
to the storybook/addons.js
and modify storybook/config.js
to have global decorator for each story:
import { configure, addDecorator } from '@storybook/react';
import { withKnobs } from '@storybook/addon-knobs';
const req = require.context('../packages', true, /.story.tsx?$/);
function loadStories() {
addDecorator(withKnobs);
req.keys().forEach((filename) => req(filename));
}
configure(loadStories, module);
Now knobs
could be used in the stories.
As we already have build command as well as code formatting and static code analyzing tools in place, it's time to use them all together in the build process:
{
"scripts": {
"prebuild": "run-p tsc lint test",
"build": "lerna exec --parallel 'BABEL_ENV=build babel src --root-mode upward --out-dir dist --source-maps --extensions .ts,.tsx --delete-dir-on-start --no-comments'",
"lint:css": "stylelint 'packages/**/*.ts{,x}'",
"lint:ts": "run-p -c lint:ts-*",
"lint:ts-prod": "tslint --config tslint.prod.json 'packages/**/*.ts{,x}' --exclude '**/*.{test,story}.ts{,x}'",
"lint:ts-test": "tslint --config tslint.test.json 'packages/**/*.{test,story}.ts{,x}'",
"lint": "run-p -c lint:*",
"test": "jest",
"tsc": "tsc"
}
}
As soon as we run yarn build
command, yarn
automatically will run tsc
for type checks, tslint
for code quality analyzing and test
on each of packages in the monorepo.
If they succeed, build
will proceed and prepare all packages for publishing.
Let's use conventional commit for committing our work for having consistent commit messages across the monorepo and as a benefit, proper version creation per package basing on conventional-commit approach.
Let's install required packages:
yarn add -DW commitizen cz-lerna-changelog@^2.0.0
cz-lerna-changelog should be installed with version ^2.0.0
as it supports latest lerna
Configuration is quite simple, just add next line to the root package.json
file:
{
"config": {
"commitizen": {
"path": "./node_modules/cz-lerna-changelog"
}
}
}
and simple alias for commit command:
{
"scripts": {
"commit": "git-cz"
}
}
Now it's time to test it, just change something in one of the packages, stage changes with git and run yarn commit
:
? Select the type of change that you're committing:
❯ feat: ✨ A new feature (note: this will indicate a release)
fix: 🛠 A bug fix (note: this will indicate a release)
docs: Documentation only changes
style: Changes that do not affect the meaning of the code
(white-space, formatting, missing semi-colons, etc)
refactor: A code change that neither fixes a bug nor adds a feature
perf: A code change that improves performance
As described earlier, lerna
is used for publishing packages. It could be configured quite easily with next lines in the lerna.json
:
{
"command": {
"publish": {
"conventionalCommits": true,
"registry": "http://localhost:4873",
"access": "public",
"npmClient": "yarn",
"allowBranch": ["master", "feature/*"]
}
}
}
The config is self descriptive, however, the most important parts are:
registry
- specifies where do we want to publish our packagesconventionalCommits
- allows us to use conventional commits for determining new versions
All other options could be found on the official lerna
documentation.
Now we could add simple alias to the our scripts for having release
command there:
{
"scripts": {
"prerelease": "yarn build",
"release": "lerna publish"
}
}
That's it. The key part is configured and ready to be used. Time to test it with real packages.
Let's init first simple package which will be just Input with optional label for it:
$ mkdir packages/input && cd packages/input
$ yarn init
yarn init v1.7.0
question name (input): @webtv/input
question version (1.0.0): 0.0.0
question description: Input component
question entry point (index.js): /dist/index.ts
question repository url:
question author: chef
question license (MIT):
question private: true
success Saved package.json
Done in 85.84s.
and add react
and styled-components
as peerDependencies:
yarn workspace @webtv/input add -P react styled-components
Let's create simple input with optional label:
import * as React from 'react';
import styled from 'styled-components';
export interface LabelProps {
labelWidth?: number;
}
export interface InputWithLabelProps extends LabelProps {
id?: string;
label?: string;
}
export interface InputWithoutLabelProps extends LabelProps {
id: string;
label: string;
}
export type InputLabelProps = InputWithLabelProps | InputWithoutLabelProps;
export interface InputProps {
name?: string;
type?: string;
}
const Wrapper = styled.div`
display: flex;
margin: 10px;
`;
const Label = styled<LabelProps, 'label'>('label')`
margin-right: 10px;
font-weight: bold;
width: ${({ labelWidth = 100 }) => labelWidth}px;
`;
const NativeInput = styled.input`
width: 100%;
`;
export const Input: React.SFC<InputProps & InputLabelProps> = ({
label,
id,
labelWidth,
...rest
}) => (
<Wrapper>
{label && (
<Label labelWidth={labelWidth} htmlFor={id}>
{label}:
</Label>
)}
<NativeInput id={id} {...rest} />
</Wrapper>
);
Input.defaultProps = {
type: 'text',
};
storybook
is configured, so it's time to create first story and test first package as well as storybook
and typescript
integration. Just create Input.story.tsx
inside input
package.
import { text } from '@storybook/addon-knobs';
import { storiesOf } from '@storybook/react';
import * as React from 'react';
import { Input } from './Input';
storiesOf('Input', module)
.add('default', () => <Input />)
.add('with label', () => (
<Input id="test" label={text('Label', 'Username')} />
))
.add('with label and type', () => (
<Input
id="test"
label={text('Label', 'Username')}
type={text('Type', 'text')}
/>
));
and run yarn storybook
. When the build process is finished, storybook will be accessible under http://localhost:6006
jest
is configured and ready to be used. Let's create simple snapshot tests for our package:
import { mount } from 'enzyme';
import * as React from 'react';
import { Input } from './Input';
describe('Input', () => {
test('should match snapshot and styles for default props', () => {
expect(mount(<Input />)).toMatchSnapshot();
});
test('should match snapshot with label', () => {
expect(mount(<Input id="test" label="Name" />)).toMatchSnapshot();
});
});
All tests could be run with simple command:
yarn test
Now we have a chance to check how our integration of jest
and jest-styled-components
works. Just open created snapshot and check that classnames are replaced with c0
, c1
, etc:
exports[`Input should match snapshot and styles for default props 1`] = `
.c0 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
margin: 10px;
}
.c1 {
width: 100%;
}
It's time to build our package. Simply run
yarn build
and we should have packages/input/dist
folder with all compiled files. The most important one is packages/input/dist/Input.js
which has:
const Input = ({ label, id, labelWidth, ...rest }) =>
React.createElement(
Wrapper,
null,
label &&
React.createElement(
Label,
{
labelWidth: labelWidth,
htmlFor: id,
},
label,
':',
),
React.createElement(
NativeInput,
_extends(
{
id: id,
},
rest,
),
),
);
It's time to publish our first package, just hit yarn release
and answer Yes
for publishing question. The result should be like:
$ lerna publish
lerna notice cli v3.4.0
lerna info versioning independent
lerna info Verifying npm credentials
lerna info Looking for changed packages since initial commit.
Changes:
- @webtv/input: 0.0.0 => 0.1.0
? Are you sure you want to publish these packages? Yes
lerna info git Pushing tags...
lerna info publish Publishing packages to npm...
lerna WARN EREGISTRY Registry "http://localhost:4873" does not support `npm access ls-packages`, skipping permission checks...
lerna WARN ENOLICENSE Packages @webtv/input, @webtv/login-form are missing a license
lerna notice
lerna notice 📦 @webtv/[email protected]
lerna notice === Tarball Contents ===
lerna notice 640B package.json
lerna notice 358B CHANGELOG.md
lerna notice 389B dist/index.js
lerna notice 176B dist/index.js.map
lerna notice 1.8kB dist/Input.js
lerna notice 1.9kB dist/Input.js.map
lerna notice === Tarball Details ===
lerna notice name: @webtv/input
lerna notice version: 0.1.0
lerna notice filename: taxi-input-0.1.0.tgz
lerna notice package size: 2.3 kB
lerna notice unpacked size: 5.3 kB
lerna notice shasum: 8685cb61ee263c3af0b73d1daa2295f35eaa04d8
lerna notice integrity: sha512-y5o/D+DevV5oS[...]P2S2XqeRi89jA==
lerna notice total files: 6
lerna info published @webtv/input 0.1.0
Successfully published:
- @webtv/[email protected]
lerna success published 1 package
Lerna
says that package was published successfully. Now we could check it with yarn info
or through web interface for our local npm registry (http://localhost:4873
):
$ yarn info @webtv/input
yarn info v1.10.1
{ name: '@webtv/input',
versions:
[ '0.1.0' ],
'dist-tags':
{ latest: '0.1.0' },
version: '0.1.0',
description: 'Input component',
...
}
Done in 1.33s.
Moreover, lerna
should create proper tags in our repository associated with released packages and versions:
$ git tag
@webtv/[email protected]
Last, but not least, let's check that proper Changelog
was created for our package:
$ cat packages/input/CHANGELOG.md
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
<a name="0.1.0"></a>
# 0.1.0 (2018-10-14)
### Features
* **Input:** add default labelWidth ([8302608](https://github.com/serhii-havrylenko/monorepo-babel-ts-lerna-starter/commit/8302608))
Now we have first package successfully released, what's left? Second package which is dependant on our @webtv/input
package is left. Integration with lerna
would stay the same, simple as it is, however, it would requires some magic for making storybook
, jest
and tslint
works with dependent packages and proper modules resolutions without rebuilding them all the time. Plus, as a bonus we'll generate types declarations for our packages and include them as a part of final build.
More details will be in the next article.
As everyone knows, the main goal of the monorepo is to have multiple packages inside one repository for easily solving dependencies between them and simple release process.
Let's create second package which is dependant on our @webtv/input
and check how our tools works with it.
Just create second package in the same way as the first one. In our example it would be @webtv/login-form
with next content:
import { Input } from '@webtv/input';
import * as React from 'react';
import styled from 'styled-components';
const Wrapper = styled.div`
display: flex;
flex-direction: column;
margin: 10px;
`;
const ButtonsWrapper = styled.div`
text-align: right;
`;
export interface LoginFormProps {
onClick?: () => void;
}
export const LoginForm: React.SFC<LoginFormProps> = ({ onClick }) => (
<Wrapper>
<Input id="name" label="Name" />
<Input id="password" label="Password" />
<ButtonsWrapper>
<button onClick={onClick}>Log in</button>
</ButtonsWrapper>
</Wrapper>
);
Now we could add @webtv/input
to the list of dependencies. This could be achieved easily with next command:
yarn lerna add @webtv/input --scope=@webtv/login-form
As soon as we have it, it's time to run tsc
and check what typescript
thinks about our code:
$ yarn tsc
yarn run v1.10.1
$ tsc
packages/login-form/src/LoginForm.tsx:1:23 - error TS2307: Cannot find module '@webtv/input'.
1 import { Input } from '@webtv/input';
~~~~~~~~~~~~~
error Command failed with exit code 1.
It happens because in the package.json
file for @webtv/input
package we have "main": "dist/index.js"
which is pointing to the build version of the package and we have not run build
command. So, let's build our packages and check tsc
again:
$ yarn tsc
yarn run v1.10.1
$ tsc
packages/login-form/src/LoginForm.tsx:1:23 - error TS7016: Could not find a declaration file for module '@webtv/input'. 'monorepo-babel-ts/packages/input/dist/index.js' implicitly has an 'any' type.
Try `npm install @types/taxi__input` if it exists or add a new declaration (.d.ts) file containing `declare module '@webtv/input';`
1 import { Input } from '@webtv/input';
~~~~~~~~~~~~~
error Command failed with exit code 1.
Now typescript
could find our package, however it doesn't know anything about typings for that module. It happens because we built our package with babel
and it cannot create declaration files.
Let's configure tsc
to generate types declarations for our modules. As a first step, we have to move login-form
package to the different folder (we would need to have only compilable with tsc
packages in the packages
directory).
We would need:
tsconfig.build.json
in the root of monorepo with:
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"emitDeclarationOnly": true,
"declaration": true
},
"include": [],
"exclude": ["**/*.story.*", "**/*.test.*", "dist"]
}
Where we specify that we need to emit declarations only and exclude test, stories and dist folder.
Next we would need tsconfig.build.json
inside the each of our packages with:
{
"extends": "../../tsconfig.build.json",
"compilerOptions": {
"declarationDir": "./dist",
"rootDir": "./src",
"baseUrl": "./"
},
"include": ["./src"]
}
Here we would specify from where we would like to take files for generating declarations and where we would like to put them.
Now we would need script inside the root of monorepo for generating declarations:
{
"scripts": {
"build:declarations": "lerna exec --parallel 'tsc --project ./tsconfig.build.json'",
"postbuild": "yarn build:declarations"
}
}
Time to test it:
$ yarn build:declarations
yarn run v1.10.1
$ lerna exec --parallel 'tsc --project ./tsconfig.build.json'
lerna notice cli v3.4.0
lerna info versioning independent
lerna info Executing command in 1 package: "tsc --project ./tsconfig.build.json"
lerna success exec Executed command in 1 package: "tsc --project ./tsconfig.build.json"
As the result we should have *.d.ts
files generated in the dist folder:
$ find packages/input/dist/ -name '*.d.ts'
packages/input/dist/Input.d.ts
packages/input/dist/index.d.ts
Now we could check that @webtv/login-form
works as well, just return it back to the packages
directory and check that tsc
works now.
$ yarn tsc
yarn run v1.10.1
$ tsc
Done in 4.22s.
As soon as we generate declarations and run eslitn
we would have errors like:
{path_to_repository}/monorepo/packages/input/dist/Input.d.ts
3:3 error Delete `··` prettier/prettier
6:3 error Delete `··` prettier/prettier
7:1 error Delete `··` prettier/prettier
10:3 error Delete `··` prettier/prettier
11:1 error Delete `··` prettier/prettier
13:38 error Replace `·InputWithLabelProps` with `⏎··|·InputWithLabelProps⏎·` prettier/prettier
15:1 error Delete `··` prettier/prettier
16:3 error Delete `··` prettier/prettier
18:53 error Replace `InputProps·&·InputLabelProps` with `⏎··InputProps·&·InputLabelProps⏎` prettier/prettier
It happens because eslitn
tries to validate *d.ts
files as well.
Let's add .eslintignore
configuration with list of files and directories which should be never linted:
dist/
*.d.ts
Now if we run yarn lint:ts
we won't have any errors related to .d.ts
files.
As soon as we generate declarations and run tslint
we would have errors like:
ERROR: packages/input/dist/Input.d.ts[13, 38]: Replace `·InputWithLabelProps` with `⏎··|·InputWithLabelProps⏎·`
ERROR: packages/input/dist/Input.d.ts[15, 3]: Delete `··`
ERROR: packages/input/dist/Input.d.ts[16, 1]: Delete `··`
It happens because tslint
tries to validate *d.ts
files as well. Let's add the to the ignore. With latest version of tslint
we could do it in configuration file, so:
1. tslint.prod.json
{
"linterOptions": {
"exclude": ["**/*.d.ts", "**/*.{test,story}.ts{,x}"]
}
}
2. tslint.test.json
{
"linterOptions": {
"exclude": ["**/*.d.ts"]
}
}
Now if we run yarn lint:ts
we won't have any errors related to .d.ts
files.
We are able to generate declarations for all packages, run storybook
and tests
, however, as soon as we change @webtv/input
we would not see any changes in the storybook
for @webtv/login-form
because we still would use previously built version. To see these changes we would need to rebuild packages one more time. The same applies to the jest
runs.
This approach looks not so cool if each change in one of the packages would require constant rebuild. Lucky we have better option how to fix this issues. More details in the next paragraphs.
Firstly, let's remove all dist
folders inside our packages to have clean code only. On this stage tsc
should still fail. Now we could extend tsconfig.json
with:
{
"compilerOptions": {
"paths": {
"@webtv/*": ["packages/*/src"],
"*": ["node_modules", "packages"]
}
}
}
"@webtv/*": ["packages/*/src"]
tells to tsc
where to search for @webtv/
packages. In our case we would like to search them in packages/*/src
folders as there we have our source code.
Now we could run tsc
and it would finish successfully without any errors like we had earlier.
As storybook
is using webpack
with awesome-typescript-loader
and babel
integration, as soon as we start storybook we would get next errors:
ERROR in ./packages/login-form/src/LoginForm.tsx
Module not found: Error: Can't resolve '@webtv/input' in 'monorepo-babel-ts/packages/login-form/src'
@ ./packages/login-form/src/LoginForm.tsx 8:13-35
It happens even when tsc
could find those modules. The reason of this is simple: tsc
checks files, babel
transforms them and tries to execute, and it doesn't know anything about @webtv/input
as main
still points to the dist
folder.
However, we could override it with aliases for webpack
. It could be done in two ways:
- Manual one, in this case we'll have to define each package which we would like to have in
webpack.config.resolve.alias
. This approach is simple, but it introduces potential issues in the future when someone could forget about aliases or introduces wrong one. - Automated one with next info in the
storybook/webpack.config.js
const path = require('path');
const { lstatSync, readdirSync } = require('fs');
const basePath = path.resolve(__dirname, '../', 'packages');
const packages = readdirSync(basePath).filter((name) =>
lstatSync(path.join(basePath, name)).isDirectory(),
);
module.exports = (baseConfig, env, config) => {
config.module.rules.push({
test: /\.(ts|tsx)$/,
loader: require.resolve('awesome-typescript-loader'),
});
config.resolve.extensions.push('.ts', '.tsx');
Object.assign(config.resolve.alias, {
...packages.reduce(
(acc, name) => ({
...acc,
[`@webtv/${name}`]: path.join(basePath, name, 'src'),
}),
{},
),
});
return config;
};
This code is rather simple and self explanatory. The most important part is in reducer for building aliases:
packages.reduce(
(acc, name) => ({
...acc,
[`@webtv/${name}`]: path.join(basePath, name, 'src'),
}),
{},
);
With this reducer we would have automatically generated list of our packages from packages
directory.
Now we could start storybook and check that it works, moreover, if we change @webtv/input
and check stories for @webtv/login-form
they would have changes immediately without even building packages.
We have tsc
and storybook
working, however, if we run jest
it would fail with:
FAIL packages/login-form/src/LoginForm.test.tsx
● Test suite failed to run
Cannot find module '@webtv/input' from 'LoginForm.test.tsx'
It happens because jest
doesn't tolerate tsconfig.json
, webpack
aliases or even babel-webpack-aliases
. However, this problem could be solved with simple line in jest.config.js
:
{
moduleNameMapper: {
'@webtv/(.+)$': '<rootDir>packages/$1/src',
},
}
'@webtv/(.+)$': '<rootDir>packages/$1/src
will tell jest where to find source code for @webtv/
packages and again it points to the src
instead of dist
.
Now we could run yarn test
and it will work as expected without even building packages.
The goal of the article was to configure monorepo with lerna
, typescript
, babel
, tslint
, stylelint
, jest
and semantic-release
and it was achieved. We do have monorepo with all those tools in place and it's ready for real usage. Packages could be created, developed, tested, presented and published easily with one commands in the root of monorepo.
For those who doesn't want to use babel
, but pure typescript
, changes would be extremely simple - just drop babel.config.js
, change awesome-typescript-loader
for using tsconfig.json
instead of babel
, ts-jest
to use it as well and build
command from babel
to tsc
(just replace emitDeclarationOnly
to false
in tsconfig.build.json
) and that's it, typescript
could be used without babel
.