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 :
- démarrer de mes fichiers javascript de départ
- parcourir tout l’arbre d’import de ces fichiers
- tree shaking
- générer un fichier javascript par fichier trouvé (pour l’optimisation du cache et du load grâce à HTTP2+)
- gérer aussi la CSS qui va avec
- minifier chacun des fichiers
- et s’intégrer parfaitement à l’enviromment Symfony comme s’est fait avec Webpack Encore.
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 :
- Générer les fichiers
manifest.json
etentrypoints.json
qui seront ensuite utile à Symfony pour l’utilisation - Générer les fichiers CSS importés par les Web Components JS pour les mettre dans un fichier CSS à part, spécifique à chaque Web Component.
- Minifier très simplement ces fichiers CSS
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.