Utiliser Rollup dans Symfony

Publié le

Pour gérer les assets fronts (CSS et JS pour faire simple), la méthode la plus simple avec Symfony est d’utiliser Webpack Encore (ou pas depuis la version 7).
Cette méthode 100% intégré au framework permet de répondre à la très grande majorité des cas pour livrer des fichiers compilés et minifiés lorsqu’on passe en production.
Mais parfois, le passage par Webpack ne permet de réaliser ce qu’on veut, ou ajoute du code dans le javascript inutile.

Dans mon cas, il s’agit de publier des Web Components natifs.
Mais je souhaite surtout que les fichiers générés n’aient aucun artifice de load que webpack ajoute automatiquement et dont il est impossible (ou très difficile ?) de se débarraser.
Je souhaitais simplement avoir un outil qui soit capable de :

Au moment où j’avais cherché une telle solution (mi 2020), je n’avais pas trouvé.
Et Rollup (un bundler qui fait le même job que Webpack) m’avais paru très efficace et m’a permis de mettre en place exactement ce dont j’avais besoin. Je partage ici tout ce que j’ai mis en place pour cela.

Mise en place de Rollup

La première étape consiste à ajouter les dépendences de Rollup à NPM pour pouvoir l’utiliser :

npm install rollup \
    @rollup/plugin-commonjs \
    @rollup/plugin-terser \
    @rollup/plugin-node-resolve \
    @rollup/plugin-replace \
    rollup-plugin-clear \
    rollup-plugin-styles \
    --save-dev

Puis on va ajouter le fichier rollup.config.js à la racine du projet, qui permet d’indiquer à rollup ce qu’il doit faire, où trouver les fichiers à bundler, où les placer ensuite, etc…
Ce fichier est similaire au fichier webpack.config.js.

Voici le fichier que j’utilise, je détaille ci-dessous les lignes importantes :

const fs = require("fs");
import terser from "@rollup/plugin-terser";
import clear from "rollup-plugin-clear";
import styles from "rollup-plugin-styles";
import symfonyPlugin from "./assets/SymfonyPlugin.js";
import commonjs from "@rollup/plugin-commonjs";
import { nodeResolve } from "@rollup/plugin-node-resolve";
import replace from "@rollup/plugin-replace";

const assetPaths = "assets/webcomponents/";
const inputs = fs
  .readdirSync(assetPaths)
  .filter((fn) => fn.startsWith("_") && fn.endsWith(".js"))
  .map((fn) => assetPaths + fn);

const ISPROD = process.env.ENV == "prod";

const publicPath = "/buildWcs";
const dirBuild = "buildWcs/";
const dir = "public/" + dirBuild;

const output = {
  dir: dir,
  format: "es",
  entryFileNames: "[name]-[hash].js",
  manualChunks: (id) => {
    const splitWebcomps = id.split("webcomponents/"),
      splitPath = (
        splitWebcomps.length > 1 ? splitWebcomps[splitWebcomps.length - 1] : id
      ).split("/");

    if (splitWebcomps.length > 1 && splitPath.length > 1) {
      return splitPath.join("_");
    }

    return splitPath[splitPath.length - 1];
  },
  plugins: [],
  compact: ISPROD,
  sourcemap: !ISPROD,
  minifyInternalExports: ISPROD,
};

if (ISPROD) {
  output.plugins.push(terser());
}

export default [
  {
    plugins: [
      clear({
        targets: [dir],
        watch: true,
      }),
      nodeResolve(),
      commonjs(),
      styles({
        mode: "emit",
      }),
      replace({
        preventAssignment: true,
        _env: JSON.stringify(process.env.ENV),
      }),
      symfonyPlugin({
        cssMinify: ISPROD,
        assetPaths: assetPaths,
        publicPath: publicPath,
        basePath: dirBuild,
      }),
    ],
    input: inputs,
    output: output,
  },
];
const assetPaths = "assets/webcomponents/";
const inputs = fs
  .readdirSync(assetPaths)
  .filter((fn) => fn.startsWith("_") && fn.endsWith(".js"))
  .map((fn) => assetPaths + fn);

Ces lignes permettent de lister tous les bundles de webComponents qui sont situés dans le dossier assets/webcomponents/.
Ces fichiers doivent commencer par _ et être des fichiers javascript.
Cela permet de pouvoir rajouter simplement des éléments aux builds, sans avoir à ajouter une ligne dans la configuration rollup.

format: 'es',
entryFileNames: '[name]-[hash].js',

On veut un format es, c’est-à-dire module javascript.
Et les fichiers doivent se nommer avec leur nom d’origine et un hash.

manualChunks: (id) => {
    const splitWebcomps = id.split('webcomponents/'),
        splitPath = (splitWebcomps.length > 1 ? splitWebcomps[splitWebcomps.length - 1] : id).split('/');

    if (splitWebcomps.length > 1 && splitPath.length > 1) {
        return splitPath.join('_');
    }

    return splitPath[splitPath.length - 1];
},

Cette partie permet de déterminer le nom à utiliser pour le nom de fichier qui va être généré, qui est directement issu du fichier d’origine.

symfonyPlugin({
  cssMinify: ISPROD,
  assetPaths: assetPaths,
  publicPath: publicPath,
  basePath: dirBuild,
});

Ici on utilise un plugin Symfony importé depuis ./assets/SymfonyPlugin.js auquel on donne quelques informations sur les dossiers à utiliser.

Plugin Symfony pour Rollup

Le plugin Symfony a plusieurs buts :

Le contenu du fichier /assets/SymfonyPlugin.js

import { createFilter } from "@rollup/pluginutils";

export default function SymfonyPlugin(config) {
  const cssFilter = createFilter(/\.css$/i),
    jsFilter = createFilter(/\.js$/i),
    cssJSFilter = createFilter(/\.css-.*\.js$/i),
    regexImportCss = /import '\.\/.*\.css-.*\.js';/gi,
    cssStyles = {},
    curDir = __dirname + "/",
    curDirAssets = curDir + config.assetPaths;

  return {
    name: "symfony-plugin",

    async renderChunk(code, chunk, options) {
      if (cssJSFilter(chunk.fileName)) {
        return "";
      }

      if (jsFilter(chunk.fileName)) {
        // Remove all CSS in JS imports
        return code.replace(regexImportCss, "");
      }

      return null;
    },

    async transform(css, id) {
      if (!cssFilter(id)) {
        return null;
      }
      try {
        const transformedCss = config.cssMinify ? minifyCSS(css) : css;
        cssStyles[id.replace(curDirAssets, "")] = transformedCss;

        return {
          // We have to use at least one the variable in order ro generate the chunk
          // This chunk won't be used anyway, but we need it ot get the generated hashtag
          code: `const css = ${JSON.stringify(
            transformedCss
          )}; console.log(css); export default css;`,
          //code: `export default ${JSON.stringify(transformedCss)};`,
          map: {
            mappings: "",
          },
        };
      } catch (error) {
        this.error(error.message, {
          column: parseInt(error.column),
          line: parseInt(error.line),
        });
      }
    },

    async generateBundle(options, bundle, isWrite) {
      if (!isWrite) {
        return;
      }

      // Read manifest.json just wrote by rollup-plugin-output-manifest
      const entrypoints = {
        entrypoints: {},
      };
      const manifest = {};

      for (const [file, chunk] of Object.entries(bundle)) {
        // Do a copy for this watch generation to not mess up with previous/next run
        const cssStylesCopy = JSON.parse(JSON.stringify(cssStyles));

        if (chunk.isEntry) {
          const publicPath = config.publicPath + chunk.fileName;

          // Add it into manifest
          manifest[config.basePath + chunk.name + ".js"] = publicPath;

          // Add it as a standalone entrypoints
          entrypoints["entrypoints"][chunk.name] = {
            js: [publicPath],
          };

          // Add it with all it's import in entrypoints, used for mobile
          const imports = [publicPath];
          chunk.imports.forEach((imp) => {
            let matchingCss = false;
            // Search through CSS styles if we have the matching JS importing file
            for (let [key, value] of Object.entries(cssStylesCopy)) {
              key = key.replaceAll("/", "_");
              if (imp.startsWith(key)) {
                // We found it, use it's hashtag to emit the real regular CSS file
                const hash = imp.replace(key, "").split(".")[0];
                const cssFilename = key.split(".css")[0] + hash + ".css";
                this.emitFile({
                  type: "asset",
                  fileName: cssFilename,
                  source: value,
                });

                if (!entrypoints["entrypoints"][chunk.name]["css"]) {
                  entrypoints["entrypoints"][chunk.name]["css"] = [];
                }

                entrypoints["entrypoints"][chunk.name]["css"].push(
                  config.publicPath + cssFilename
                );

                delete cssStylesCopy[key];
                matchingCss = true;
              }
            }

            if (!matchingCss) {
              imports.push(config.publicPath + imp);
            }
          });

          entrypoints["entrypoints"][chunk.name + "_all"] = {
            js: imports,
          };
          if (entrypoints["entrypoints"][chunk.name]["css"]) {
            entrypoints["entrypoints"][chunk.name + "_all"]["css"] =
              entrypoints["entrypoints"][chunk.name]["css"];
          }
        }
      }

      this.emitFile({
        type: "asset",
        fileName: "manifest.json",
        source: JSON.stringify(manifest, false, 2),
      });
      this.emitFile({
        type: "asset",
        fileName: "entrypoints.json",
        source: JSON.stringify(entrypoints, false, 2),
      });
    },
  };
}

/* minify css */
function minifyCSS(content) {
  content = content.replace(/\/\*(?:(?!\*\/)[\s\S])*\*\/|[\r\n\t]+/g, "");
  content = content.replace(/ {2,}/g, " ");
  content = content.replace(/ ([{:}]) /g, "$1");
  content = content.replace(/([{:}]) /g, "$1");
  content = content.replace(/([;,]) /g, "$1");
  content = content.replace(/ !/g, "!");
  return content;
}

Scripts de build

On peut ajouter des scripts dans package.json pour builder simplement les web Components.
Tout comme encore, on a la possibilité de faire le build ne version de dev (non minifié), en prod ou en watch :

{
  "scripts": {
    "wcDev": "npx rollup -c --environment ENV:dev",
    "wcWatch": "npx rollup -c --environment ENV:dev -w",
    "wc": "npx rollup -c --environment ENV:prod"
  }
}

On utilisera ensuite npm run wcDev, npm run wcWatch ou npm run wc selon le cas.

Utilisation des Web Components dans Symfony

On a maintenant à disposition tout le nécessaire pour créer les assets des Web Components, dans un build différent de webpack encore.
On peut maintenant utiliser réellement ces Web Components dans Symfony.

Configuration du build

Dans le fichier config/packages/webpack_encore.yaml, on va ajouter les chemins où sont stocker notre build de Web Components pour qui Symfony sache où aller les chercher.
On va nommer le build webcomponents, que l’on réutilisera ensuite lors de l’intégration.

webpack_encore:
  builds:
    webcomponents: "%kernel.project_dir%/public/buildWcs"

framework:
  assets:
    json_manifest_path: "%kernel.project_dir%/public/build/manifest.json"
    packages:
      webcomponents:
        json_manifest_path: "%kernel.project_dir%/public/buildWcs/manifest.json"

Utilisation dans les templates

Et pour finir, lorsqu’on veut utiliser un bundle de `webComponents``, il suffit d’utiliser ceci :

{{ encore_entry_script_tags('_test', null, 'webcomponents', attributes={
    type: 'module'
}) }}
{{ encore_entry_link_tags('_test', null, 'webcomponents') }}

_test correspond au nom du fichier javascript d’entrée que l’on aura créé dans assets/webcomponents/.
Symfony n’ajoutera qu’une seule ligne pour intégrer l’index des modules.
C’est ensuite le navigateur qui chargera les autres imports javascript.
L’utilisation de l’attribut type="module" est indispensable pour que les navigateurs chargent correctement les fichiers.
La ligne encore_entry_link_tags n’est nécessaire que si les Web Components importes des fichiers CSS

Exemple de Web Component

Pour parfaire ce billet, voici 3 fichiers très simples que vous pouvez créer dans assets/webcomponents/ si vous voulez tester l’ensemble.

Fichier _test.js

import TestWc from "./testWc.js";

Fichier testWc.js

import "./testWc.css";

const template = document.createElement("template");
template.innerHTML = `
<strong>Hello</strong>
`;

class TestWc extends HTMLElement {
  connectedCallback() {
    this.attachShadow({
      mode: "open",
    });

    this.shadowRoot.append(template.content);

    this.addEventListener("click", (e) => {
      alert("hello");
    });
  }
}

window.customElements.define("test-wc", TestWc);

export default TestWc;

Fichier testWc.css

test-wc {
  border: 10px solid red;
}

Et après avoir importer les assets :

{{ encore_entry_script_tags('_test', null, 'webcomponents', attributes={
    type: 'module'
}) }}
{{ encore_entry_link_tags('_test', null, 'webcomponents') }}

vous pourrez simplement utiliser <test-wc></test-wc> dans votre HTML.