Introduction

La tendance actuelle des grandes entreprises qui gèrent leur propre site internet est à la création d’un Design System. Ce dernier a pour avantage d’apporter une cohérence graphique à toutes leurs applications (B2B ou B2C), mais également de ne pas avoir à re-développer plusieurs fois les mêmes composants graphiques (comme des boutons, des cards, et autres).

Dans cette série d’articles, nous allons voir comment initier un Design System en utilisant Lit pour le développement de “Web Components” utilisables partout et NPM Workspaces pour la gestion du code sous forme d’un monorepo.

Puisque de plus en plus d’équipes se tournent vers une gestion de leur code source via un “mono-repo” git (ce qui présente certains avantages : vue globale du code source, source unique et commune…, mais également des inconvénients comme la gestion des droits…), c’est ce que nous allons utiliser pour la création de notre Design System.

Création du monorepo

Dans le dossier de votre choix, ouvrez un terminal et exécutez la commande suivante :

npm init

Cette commande permet d’initialiser un nouveau projet utilisant NPM.

Suivez les instructions pour renseigner les différents champs.

La commande NPM vient de créer le fichier package.json.

Ajoutez à la racine du projet un dossier packages qui contiendra par la suite tous les composants du Design System.

Pour basculer le projet NPM en monorepo utilisant les workspaces, ajoutez la propriété workspaces dans le fichier package.json. Cette propriété prend comme valeur un tableau de chemins (paths) référençant les différents packages (il est également possible d’utiliser un pattern ou une regex).

Dans notre cas, le pattern utilisé est : "/packages/*" (tous les sous-dossiers).

Voilà, notre projet est prêt à accueillir les différents packages du Design System.

Tous nos packages vont avoir des dépendances en commun et c’est là, la force du monorepo NPM. Ce dernier ne va télécharger qu’une seule fois les dépendances. Elles seront liées aux différents packages qui les utilisent via des liens symboliques.

Ce système de gestion des dépendances permet de réduire considérablement la tailles des dossiers node_modules présents dans chacun des packages.

Mise en place des différents outils

Tous nos composants vont être écrits avec Lit, qui est une librairie pour créer simplement et rapidement des “Web Components”. Ces “Web Components” seront les composants du Design System. Ils seront légers, faciles à implémenter et utilisables partout (dans du HTML/JavaScript classique ou dans des frameworks / librairies comme React, Vue ou Angular pour ne citer que les plus importants).

Les composants seront écrits en utilisant la surcouche TypeScript afin de bénéficier du typage statique et de permettre d’avoir un peu plus de rigueur dans l’écriture du code.

Pour pouvoir transpiler le TypeScript et construire nos composants, il nous faut utiliser un “module bundler” comme Webpack ou Rollup. Nous allons utiliser Rollup.js car il est rapide et facile à configurer. Rollup nous servira aussi pour créer un serveur de développement local.

Ajoutez les différentes dépendances de Rollup avec la commande suivante :

npm install -D @rollup/plugin-replace rollup-plugin-inline-svg rollup-plugin-livereload rollup-plugin-node-resolve rollup-plugin-postcss rollup-plugin-postcss-lit rollup-plugin-serve rollup-plugin-typescript2 rollup-plugin-filesize rollup-plugin-terser rollup-plugin-peer-deps-external

Créez ensuite 2 fichiers de configuration pour avoir un serveur de développement et un serveur de test. (Nous verrons plus tard la partie test des composants).

Le premier fichier rollup.config.js pour packager les composants en mode production.

import filesize from 'rollup-plugin-filesize';
import { terser } from 'rollup-plugin-terser';
import resolve from 'rollup-plugin-node-resolve';
import replace from '@rollup/plugin-replace';
import typescript from 'rollup-plugin-typescript2';
import svg from 'rollup-plugin-inline-svg';
import postcss from 'rollup-plugin-postcss';
import postcssLit from 'rollup-plugin-postcss-lit';
import peerDepsExternal from 'rollup-plugin-peer-deps-external';

import { join } from 'path';

export default {
  input: join('src', 'index.ts'), // Path du point d'entrée du composant
  output: {
    file: join('dist', 'index.js'), // Path de sortie de build du composant
    format: 'esm', // package de sortie ==> ici un es module
  },
  onwarn(warning) {
    if (warning.code !== 'THIS_IS_UNDEFINED') {
      console.error(`(!) ${warning.message}`);
    }
  },
  plugins: [
    peerDepsExternal(), // Ajoute la possibilité d'utiliser des peerDependencies
    typescript(),
    replace({ 'Reflect.decorate': 'undefined', preventAssignment: true }),
    resolve(),

    postcss({ inject: false }), // Utilisation du scss
    postcssLit(),
    terser({
      module: true,
      warnings: true,
      mangle: {
        properties: {
          regex: /^__/,
        },
      },
    }), // Transpile en ES6 pour les navigateur récent
    filesize({
      showBrotliSize: true,
    }), // Indique le poids de sortie du composant
    svg(), // permet de gérer les SVG
  ]
};

Le second fichier rollup.config.dev.js pour créer notre environnement de développement local.

import serve from 'rollup-plugin-serve';
import typescript from 'rollup-plugin-typescript2';
import resolve from 'rollup-plugin-node-resolve';
import replace from '@rollup/plugin-replace';
import livereload from 'rollup-plugin-livereload';
import svg from 'rollup-plugin-inline-svg';
import postcss from 'rollup-plugin-postcss';
import postcssLit from 'rollup-plugin-postcss-lit';
import { join } from 'path';

const port = +(process.argv.find((t) => t.includes('port='))?.split('=')[1] || 10001); // Customize les ports du serveur de dev

export default {
  input: join('src', 'index.ts'), // Path du point d\'entrée du composant
  output: {
    file: join('dev', 'index.js'), // Path de sortie
    format: 'esm',
  },
  onwarn(warning) {
    if (warning.code !== 'THIS_IS_UNDEFINED') {
      console.error(`(!) ${warning.message}`);
    }
  },
  plugins: [
    typescript(),
    replace({ 'Reflect.decorate': 'undefined', preventAssignment: true }),
    resolve(),
    postcss({ inject: false }),
    postcssLit(),
    svg(),
    serve({
      port,
      open: true,
      verbose: true,
      contentBase: ['dev', join('..', '..', 'static')],
    }), // Serveur de développement local
    livereload({ watch: 'dev' }), // Rechargement de page à la volée suite à une modification du code
  ],
};

Nous avons maintenant un environnement pour développer nos composants en mode “monorepo”.

Notre premier composant

Nous allons maintenant créer le premier en utilisant un template Lit avec Typescript. Le premier composant sera un composant assez simple : un bouton.

Dans le dossier packages, créer un dossier ilb-button. Ce dossier sera peuplé du contenu du template de base TypeScript d’un composant Lit que nous allons modifier.

Le dossier ilb-button contient l’arborescente suivante :

Un dossier dev, où seul le fichier index.html va nous servir. Les fichiers index.d.ts, index.d.ts.map et index.js sont générés par le serveur de développement lors de la transpilation de notre composant.

Le fichier index.html permet d’injecter notre composant et sera servi par le serveur de développement.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Button Component</title>
    <script type="module" src="./index.js"></script>
</head>
<body class="red-bg">
    <ilb-button link="http://www.ineat.fr">Click Me !!</ilb-button>
    <script>
        const button = document.getElementsByTagName('ilb-button');
        console.log(button);
        button.addEventListener('click', (e) => console.log(e));
    </script>
</body>
</html>

On déclare notre composant en tant que module dans le head de la page à la ligne 8. Puis dans le corps de la page à la ligne 11. Nous avons à la suite un petit script qui nous permet de récupérer l’évènement de click.

Le dossier src va contenir deux fichiers, le premier correspondant au code source (index.ts) et le second au style (index.scss) du composant ilb-button.

Le fichier index.ts est relativement simple :

import {LitElement, html} from 'lit';
import {customElement} from 'lit/decorators.js';

import styles from './index.scss';

@customElement('ilb-button') // Décorateur permettant la déclaration du tag custom ilb-button
export class IlbButton extends LitElement { // La classe du composant IlbButton qui étend la class LitElement

  static get styles(): any { // L'import de nos styles, on peut importer plusieurs feuilles de style
    return [styles];
  }

  // La méthode de rendu du composant, ici un simple boutton
  render() {
    return html`
      <button class="btn">
        <svg>
          <rect x="0" y="0" fill="none" width="100%" height="100%"/>
        </svg>
        <slot></slot>
      </button>
    `;
  }
}

On y retrouve les imports liés à Lit et à la feuille de style SCSS du composant. Et le code Lit permettant de créer le composant ilb-button.

Pour utiliser l’import de la feuille de style SCSS dans un fichier TypeScript et ne pas avoir d’erreur, il faut créer fichier de déclaration de type.
Créer un dossier assets/@types dans le dossier src du composant. Puis dans ce dossier créer un fichier index.d.ts contenant le code suivant :

declare module '*.scss' {
    import { CSSResultArray } from 'lit';
  
    const content: CSSResultArray;
    export default content;
}

Et le fichier index.scss :

@import url(https://fonts.googleapis.com/css?family=Roboto:400,100,900);

//colors
$grey: #323232;
$white: #fff;

//default button
.btn {
  color: $grey;
  cursor: pointer;
  border: none;
  font-size:16px;
  font-weight: 400;
  line-height: 45px;
  margin: 0 0 2em;
  max-width: 160px; 
  position: relative;
  text-decoration: none;
  text-transform: uppercase;
  width: 100%; 
  
  @media(min-width: 600px) {
      
    margin: 0 1em 2em;
    
  }
  
  &:hover { text-decoration: none; }
  
}

.btn {
  background: darken($white, 1.5%);
  font-weight: 100;
  
  svg {
    height: 45px;
    left: 0;
    position: absolute;
    top: 0; 
    width: 100%; 
  }
  
  rect {
    fill: none;
    stroke: $grey;
    stroke-width: 2;
    stroke-dasharray: 422, 0;
    transition: all 0.35s linear;
  }
}

.btn:hover {
  background: rgba($grey, 0);
  font-weight: 900;
  letter-spacing: 1px;
  
  rect {
    stroke-width: 5;
    stroke-dasharray: 15, 310;
    stroke-dashoffset: 48;
    transition: all 1.35s cubic-bezier(0.19, 1, 0.22, 1);
  }
}

Nous avons ensuite les fichiers de configuration du composant (customElement.json), du gestionnaire de dépendances (package.json), du transpileur TypeScript (tsconfig.json), du serveur de développement (web-dev-server.config.js) et du serveur de test (web-test-server.config.js).

  • customElement.json
{
  "version": "experimental",
  "tags": [
    {
      "name": "ilb-button",
      "path": "./src/index.ts",
      "description": "Button Design System",
      "properties": [
        {
          "name": "styles",
          "type": "CSSResult",
          "default": "\"css`\\n    .btn {\\n      background: red\\n    }\\n  `\""
        }
      ]
    }
  ]
}

Ce fichier n’est pas obligatoire mais il permet d’avoir une première documentation du composant.

  • package.json
{
    "name": "ilb-button",
    "version": "0.0.0",
    "description": "Button webComponent",
    "keywords": [
        "button",
        "web-components",
        "lit-element",
        "typescript"
    ],
    "license": "ISC",
    "main": "dist/index.js",
    "unpkg": "dist/index.js",
    "type": "module",
    "types": "dist/index.d.ts",
    "files": [
        "dist"
    ],
    "scripts": {
        "build": "rollup -c ../../rollup.config.js",
        "serve": "rollup -w -c ../../rollup.config.dev.js",
        "checksize": "rollup -c ../../rollup.config.js ; cat dist/index.js | gzip -9 | wc -c ; rm dist/index.js",
        "prepublish": "rollup -c ../../rollup.config.js",
        "test": "web-test-runner ./src/**/*.spec.ts --node-resolve",
        "test:watch": "web-test-runner ./src/**/*.spec.ts --node-resolve --watch",
        "lint": "eslint ./src --ext ts"
    },
    "devDependencies": {
        "node-sass": "^7.0.1",
        "rollup": "^2.74.1",
        "sass": "^1.52.1"
    },
    "dependencies": {
        "lit": "^2.0.2"
    }
}

Le fichier de configuration possède les informations sur notre composant (nom, version, …), les script d’execution (build, serve, …) qui utilise les fichiers de configuration rollup présent à la racine du monorepo ainsi que les dépendances du composant (devDependencies et dependencies).

  • tsconfig.json
{
	"compilerOptions": {
		"target": "es2017",
		"module": "es2015",
		"lib": ["es2017", "dom", "dom.iterable"],
		"declaration": true,
		"declarationMap": true,
		"sourceMap": true,
		"outDir": "./",
		"rootDir": "./src",
		"strict": true,
		"noUnusedLocals": true,
		"noUnusedParameters": true,
		"noImplicitReturns": true,
		"noFallthroughCasesInSwitch": true,
		"moduleResolution": "node",
		"allowSyntheticDefaultImports": true,
		"experimentalDecorators": true,
		"forceConsistentCasingInFileNames": true,
		"plugins": [{
			"name": "ts-lit-plugin",
			"strict": true
		}]
	},
	"include": ["src/**/*.ts", "../global.d.ts"],
	"exclude": [""]
}

La configuration TypeScript du composant.

Pour plus d’informations, vous pouvez vous référer à la documentation officielle TypeScript.

  • web-dev-server.config.js
import { esbuildPlugin } from "@web/dev-server-esbuild";

export default {
  plugins: [
    esbuildPlugin({ ts: true }) // déclaration du plugin pour transpiler le TS en JS
  ],
};

La configuration du serveur de développement.

  • web-test-server.config.js
/**
 * @license
 * Copyright 2021 Google LLC
 * SPDX-License-Identifier: BSD-3-Clause
 */

import {legacyPlugin} from '@web/dev-server-legacy';
import {playwrightLauncher} from '@web/test-runner-playwright';

const mode = process.env.MODE || 'dev';
if (!['dev', 'prod'].includes(mode)) {
  throw new Error(`MODE must be "dev" or "prod", was "${mode}"`);
}

// Uncomment for testing on Sauce Labs
// Must run `npm i --save-dev @web/test-runner-saucelabs` and set
// SAUCE_USERNAME and SAUCE_USERNAME environment variables
// ===========
// import {createSauceLabsLauncher} from '@web/test-runner-saucelabs';
// const sauceLabsLauncher = createSauceLabsLauncher(
//   {
//     user: process.env.SAUCE_USERNAME,
//     key: process.env.SAUCE_USERNAME,
//   },
//   {
//     'sauce:options': {
//       name: 'unit tests',
//       build: `${process.env.GITHUB_REF ?? 'local'} build ${
//         process.env.GITHUB_RUN_NUMBER ?? ''
//       }`,
//     },
//   }
// );

// Uncomment for testing on BrowserStack
// Must run `npm i --save-dev @web/test-runner-browserstack` and set
// BROWSER_STACK_USERNAME and BROWSER_STACK_ACCESS_KEY environment variables
// ===========
// import {browserstackLauncher as createBrowserstackLauncher} from '@web/test-runner-browserstack';
// const browserstackLauncher = (config) => createBrowserstackLauncher({
//   capabilities: {
//     'browserstack.user': process.env.BROWSER_STACK_USERNAME,
//     'browserstack.key': process.env.BROWSER_STACK_ACCESS_KEY,
//     project: 'my-element',
//     name: 'unit tests',
//     build: `${process.env.GITHUB_REF ?? 'local'} build ${
//       process.env.GITHUB_RUN_NUMBER ?? ''
//     }`,
//     ...config,
//   }
// });

const browsers = {
  // Local browser testing via playwright
  // ===========
  chromium: playwrightLauncher({product: 'chromium'}),
  firefox: playwrightLauncher({product: 'firefox'}),
  webkit: playwrightLauncher({product: 'webkit'}),

  // Uncomment example launchers for running on Sauce Labs
  // ===========
  // chromium: sauceLabsLauncher({browserName: 'chrome', browserVersion: 'latest', platformName: 'Windows 10'}),
  // firefox: sauceLabsLauncher({browserName: 'firefox', browserVersion: 'latest', platformName: 'Windows 10'}),
  // edge: sauceLabsLauncher({browserName: 'MicrosoftEdge', browserVersion: 'latest', platformName: 'Windows 10'}),
  // ie11: sauceLabsLauncher({browserName: 'internet explorer', browserVersion: '11.0', platformName: 'Windows 10'}),
  // safari: sauceLabsLauncher({browserName: 'safari', browserVersion: 'latest', platformName: 'macOS 10.15'}),

  // Uncomment example launchers for running on Sauce Labs
  // ===========
  // chromium: browserstackLauncher({browserName: 'Chrome', os: 'Windows', os_version: '10'}),
  // firefox: browserstackLauncher({browserName: 'Firefox', os: 'Windows', os_version: '10'}),
  // edge: browserstackLauncher({browserName: 'MicrosoftEdge', os: 'Windows', os_version: '10'}),
  // ie11: browserstackLauncher({browserName: 'IE', browser_version: '11.0', os: 'Windows', os_version: '10'}),
  // safari: browserstackLauncher({browserName: 'Safari', browser_version: '14.0', os: 'OS X', os_version: 'Big Sur'}),
};

// Prepend BROWSERS=x,y to `npm run test` to run a subset of browsers
// e.g. `BROWSERS=chromium,firefox npm run test`
const noBrowser = (b) => {
  throw new Error(`No browser configured named '${b}'; using defaults`);
};
let commandLineBrowsers;
try {
  commandLineBrowsers = process.env.BROWSERS?.split(',').map(
    (b) => browsers[b] ?? noBrowser(b)
  );
} catch (e) {
  console.warn(e);
}

// https://modern-web.dev/docs/test-runner/cli-and-configuration/
export default {
  rootDir: '.',
  files: ['./test/**/*_test.js'],
  nodeResolve: {exportConditions: mode === 'dev' ? ['development'] : []},
  preserveSymlinks: true,
  browsers: commandLineBrowsers ?? Object.values(browsers),
  testFramework: {
    // https://mochajs.org/api/mocha
    config: {
      ui: 'tdd',
      timeout: '60000',
    },
  },
  plugins: [
    // Detect browsers without modules (e.g. IE11) and transform to SystemJS
    // (https://modern-web.dev/docs/dev-server/plugins/legacy/).
    legacyPlugin({
      polyfills: {
        webcomponents: true,
        // Inject lit's polyfill-support module into test files, which is required
        // for interfacing with the webcomponents polyfills
        custom: [
          {
            name: 'lit-polyfill-support',
            path: 'node_modules/lit/polyfill-support.js',
            test: "!('attachShadow' in Element.prototype) || !('getRootNode' in Element.prototype) || window.ShadyDOM && window.ShadyDOM.force",
            module: false,
          },
        ],
      },
    }),
  ],
};

La configuration du serveur de test que nous utiliseront dans un autre article.

Le premier composant est prêt, il faut maintenant installer les dépendances spécifiques à notre composant (comme Lit) via la commande :

npm i --workspace=ilb-button // permet de spécifier le package ou s'éxecute la commande d'installation
// ou
npm i --workspaces // pour éxecuter la commande sur tous les packages

Puis nous allons démarrer le serveur de développement via la commande :

npm run --workspace=ilb-button serve

On obtient :

Le serveur de développement fonctionne correctement et permet le hot-reload en cas de modification du code source.

Pour tester la compilation en mode production du composant ilb-button, exécutez la commande suivante :

npm run --workspace=ilb-button build 
// ou en version abrégée
npm run -w ilb-button build

On obtient le résultat suivant:

Le composant est packagé en web component dans le dossier dist du package ilb-button.

Conclusion

Nous avons maintenant les bases de notre monorepo contenant le premier composant de notre futur Design System.

Nous verrons dans la suite :

  • Comment y ajouter d’autres composants ?
  • Comment un composant peut avoir un autre composant en dépendance ?
  • Comment publier nos composants sur un gestionnaire de packages comme un serveur Nexus, ou JFrog ?
  • Comment créer un CLI pour faciliter la vie vos développeurs lors de la création de composant ?
  • Et bien d’autres use-cases.