AEM Guide

Comment le module ui.frontend transforme un build Webpack en ClientLib AEM

Comment ui.frontend compile le JS/CSS avec webpack via frontend-maven-plugin, et comment aem-clientlib-generator transforme ce dist/ en un cq:ClientLibraryFolder qu'ui.apps finit par livrer dans AEM.

ui.frontendwebpackclientlibmavenfrontend-maven-plugin

ui.frontend est le module de l’archetype de projet AEM d’Adobe où vit réellement le code frontend : TypeScript/JavaScript, Sass/CSS, et la configuration d’un bundler, compilé avec npm exactement comme dans un projet frontend autonome. Un développeur frontend peut y travailler, lancer npm run start, sans jamais toucher à Java ni au reste du reactor Maven. La seule chose spécifique à AEM dans tout cela, c’est ce qui se passe à la fin du build : sa sortie doit devenir un clientlib AEM, car c’est le seul moyen pour AEM de servir du CSS/JS à une page rendue. Cet article couvre exactement ce point de bascule — d’un dossier dist/ webpack à un cq:ClientLibraryFolder qui finit dans ui.apps.

Ce qu’est réellement ui.frontend

Quand on exécute le aem-project-archetype d’Adobe, la propriété frontendModule détermine la variante de ui.frontend générée : general (webpack simple + TypeScript/Sass), angular, react, ou none/decoupled si l’on n’en veut pas. Cet article se concentre sur la variante general, car c’est la base la plus courante et celle dont partent la plupart des projets ; les variantes React et Angular changent de bundler (react-scripts, Angular CLI) mais convergent vers le même mécanisme de sortie décrit ci-dessous.

Le package.json du module general déclare webpack 5, TypeScript, Babel, Sass et ESLint comme devDependencies, et expose ces scripts npm :

{
  "scripts": {
    "dev": "webpack --env dev --config ./webpack.dev.js && clientlib --verbose",
    "prod": "webpack --config ./webpack.prod.js && clientlib --verbose",
    "start": "webpack-dev-server --open --config ./webpack.dev.js",
  },
}

npm run start démarre un webpack-dev-server avec rechargement en direct sur un gabarit HTML statique — utile pour itérer sur le markup/les styles sans instance AEM en cours d’exécution, même s’il ne reflète pas le markup réellement rendu par AEM. npm run dev et npm run prod sont les deux scripts qui comptent pour AEM : ils lancent un build webpack complet puis passent la main à aem-clientlib-generator (la commande clientlib), qui est la partie qui produit réellement du contenu AEM.

Le ui.frontend/pom.xml relie tout cela à Maven via com.github.eirslett:frontend-maven-plugin, lié à la phase generate-resources :

<plugin>
  <groupId>com.github.eirslett</groupId>
  <artifactId>frontend-maven-plugin</artifactId>
  <executions>
    <execution>
      <id>npm run prod</id>
      <phase>generate-resources</phase>
      <goals><goal>npm</goal></goals>
      <configuration>
        <arguments>run prod</arguments>
      </configuration>
    </execution>
  </executions>
</plugin>

frontend-maven-plugin installe une distribution locale de Node/npm si nécessaire et exécute npm run prod. C’est pourquoi un mvn clean install sur un projet AEM tout juste généré peut compiler le frontend sans que personne n’ait Node installé globalement, et pourquoi un développeur frontend peut itérer sur ui.frontend avec de simples commandes npm sans avoir besoin de Maven ni d’un JDK — les deux mondes ne se rencontrent qu’à ce seul point d’attache du plugin. (Un profil Maven fedDev substitue npm run dev, pour des source maps et une sortie non minifiée pendant le développement local.)

De la sortie webpack à un clientlib AEM

Webpack seul ne sait produire que des bundles JS/CSS — il n’a aucune idée de ce qu’est un cq:ClientLibraryFolder. Cette traduction est le travail de la seconde commande dans npm run prod : clientlib --verbose, du paquet npm aem-clientlib-generator, configuré dans ui.frontend/clientlib.config.js.

Webpack écrit lui-même sa sortie dans ui.frontend/dist/, répartie en deux librairies logiques :

clientlib.config.js indique à aem-clientlib-generator comment transformer chacun de ces dossiers en clientlib AEM et, surtout, où l’écrire :

const CLIENTLIB_DIR = path.join(
  __dirname,
  '..',
  'ui.apps',
  'src',
  'main',
  'content',
  'jcr_root',
  'apps',
  '${appId}',
  'clientlibs',
);

module.exports = {
  context: path.join(__dirname, 'dist'),
  clientLibRoot: CLIENTLIB_DIR,
  libs: [
    {
      name: 'clientlib-dependencies',
      categories: ['${appId}.dependencies'],
      allowProxy: true,
      serializationFormat: 'xml',
      assets: {
        js: { cwd: 'clientlib-dependencies', files: ['**/*.js'] },
        css: { cwd: 'clientlib-dependencies', files: ['**/*.css'] },
      },
    },
    {
      name: 'clientlib-site',
      categories: ['${appId}.site'],
      dependencies: ['${appId}.dependencies'],
      allowProxy: true,
      serializationFormat: 'xml',
      assets: {
        js: { cwd: 'clientlib-site', files: ['**/*.js'] },
        css: { cwd: 'clientlib-site', files: ['**/*.css'] },
        resources: {
          cwd: 'clientlib-site',
          files: ['**/*.*'],
          ignore: ['**/*.js', '**/*.css'],
        },
      },
    },
  ],
};

${appId} est la propriété de l’archetype Maven choisie à la génération du projet (l’identifiant court de l’application, p. ex. myproject) — elle devient à la fois le nom du nœud /apps/<appId> et le préfixe de chaque catégorie. Remarquez clientLibRoot : il ne pointe nulle part dans ui.frontend — il pointe directement dans l’arborescence source de ui.apps. C’est le mécanisme réel derrière « ui.frontend produit le clientlib qui vit dans ui.apps » : une simple écriture sur le système de fichiers dans les sources d’un autre module, pas une étape de packaging ni une déclaration de dépendance au runtime.

Anatomie du clientlib généré

Avec la configuration ci-dessus, un build produit cette structure directement sous ui.apps/src/main/content/jcr_root/apps/myproject/clientlibs/clientlib-site/ :

clientlib-site/
├── .content.xml
├── js.txt
├── css.txt
├── js/
│   └── site.js
├── css/
│   └── site.css
└── resources/
    └── site.js.map

.content.xml déclare le nœud cq:ClientLibraryFolder avec les propriétés issues de la configuration :

<?xml version="1.0" encoding="UTF-8"?>
<jcr:root xmlns:jcr="http://www.jcp.org/jcr/1.0"
    jcr:primaryType="cq:ClientLibraryFolder"
    allowProxy="{Boolean}true"
    categories="[myproject.site]"
    dependencies="[myproject.dependencies]"/>

js.txt est le manifeste que le HTML Library Manager lit pour savoir quels fichiers sous js/ concaténer, et dans quel ordre :

#base=js
site.js

css.txt suit le même modèle sur le dossier css/. Comme aem-clientlib-generator régénère les deux fichiers à chaque build à partir de ce que webpack a produit, on ne les maintient jamais à la main pour ce module — c’est d’ailleurs exactement pour cela qu’il ne faut pas les éditer à la main (plus de détails plus bas).

Le relais vers ui.apps

ui.frontend et ui.apps sont des modules Maven indépendants, sans <dependency> entre eux, mais le pom.xml parent liste ui.frontend avant ui.apps dans <modules>, et le reactor Maven respecte cet ordre déclaré tant qu’aucun graphe de dépendances n’impose autre chose. Ainsi, dans un mvn clean install complet, au moment où le filevault-package-maven-plugin d’ui.apps parcourt ui.apps/src/main/content/jcr_root pour construire le content package, les dossiers clientlib-site et clientlib-dependencies sont déjà présents sur le disque — écrits par aem-clientlib-generator durant la phase generate-resources précédente d’ui.frontend. ui.apps les empaquette alors comme n’importe quel autre contenu sous jcr_root, sans traitement particulier.

C’est tout le relais : une écriture sur le système de fichiers d’un module vers l’arborescence source d’un autre, synchronisée par l’ordre des phases Maven. C’est aussi pourquoi un mvn -pl ui.apps (ou tout reactor partiel qui saute ui.frontend) livre silencieusement ce qui a été généré en dernier sur le disque, périmé ou non — Maven n’a aucun moyen de savoir que le frontend doit être reconstruit, sauf si ui.frontend fait réellement partie du reactor de cette exécution.

Une fois le clientlib présent dans un package ui.apps déployé, un script HTL référence sa catégorie — jamais un chemin de fichier — via le gabarit clientlib des Core Components :

<sly
  data-sly-use.clientlib="core/wcm/components/commons/v1/templates/clientlib.html"
  data-sly-call="${clientlib.css @ categories='myproject.site'}"
/>
<sly data-sly-call="${clientlib.js @ categories='myproject.site'}" />

En pratique, la plupart des projets générés par l’archetype ne codent même pas cela en dur dans chaque composant — clientlib-site et clientlib-dependencies sont configurés dans la Page Policy de la page (Content Page Template → Page Information → Page Policy), donc chaque page utilisant ce gabarit les récupère automatiquement.

Là où cela se manifeste en pratique