Angular Schematics : automatiser ses migrations

Front-end 01/04/2026 16:00:00 angularforall.com
Angular Schematics Ng Update Angular Cli Migration
Angular Schematics : automatiser ses migrations

Angular Schematics : créer des générateurs ng generate, ng add et ng update pour automatiser migrations et scaffolding de code sur mesure.

Qu'est-ce qu'un Angular Schematic ?

Les Angular Schematics sont le moteur de transformation de code qui alimente Angular CLI en coulisses. Chaque fois que vous tapez ng generate component, ng add @angular/material ou ng update, vous déclenchez un schematic. Ce ne sont pas de simples scripts qui copient des fichiers — ce sont des transformations de code déclaratives, réversibles et composables.

La différence fondamentale avec un script Node classique tient à trois propriétés :

  • Virtualité : les Schematics opèrent sur un arbre de fichiers virtuel (Tree) avant de toucher le disque. Si une erreur survient, rien n'est écrit.
  • Compositionalité : plusieurs Schematics peuvent être chainés via chain() pour former des pipelines complexes.
  • Testabilité : grâce au Tree virtuel, on peut tester des transformations sans toucher au système de fichiers réel.
À retenir : Un Schematic est une fonction qui reçoit un Tree (arbre de fichiers virtuel) et un SchematicContext (contexte d'exécution), applique des transformations, et retourne un nouveau Tree. C'est du functional programming appliqué au scaffolding de code.

Voici les trois grands types de Schematics que vous rencontrerez :

Type Commande CLI Cas d'usage
Generation ng generate my-lib:component Scaffolding de fichiers, création de composants, services, modules custom
Add ng add my-lib Installer une librairie et configurer le projet (imports, styles, polyfills)
Update / Migration ng update my-lib Transformer le code existant lors des mises à jour de version majeure

Dans cet article, nous allons construire les trois types from scratch, avec des exemples concrets et des tests unitaires. Vous comprendrez comment Angular CLI lui-même génère vos composants, et vous serez capable d'automatiser les migrations répétitives de votre équipe.

Créer son premier Schematic

Pour créer une collection de Schematics, vous avez besoin du package @angular-devkit/schematics-cli qui fournit des outils de scaffolding et la commande schematics pour tester localement.

Installation et initialisation du projet

# Installer le CLI Schematics globalement
npm install -g @angular-devkit/schematics-cli

# Créer un nouveau projet de collection de Schematics
schematics blank --name=my-schematics

# Naviguer dans le projet
cd my-schematics

# Installer les dépendances (TypeScript + devkit)
npm install

La structure générée ressemble à ceci :

my-schematics/
├── src/
│   └── my-schematics/
│       ├── index.ts          ← Le point d'entrée du schematic
│       ├── index_spec.ts     ← Les tests unitaires
│       └── schema.json       ← Schéma JSON des options (facultatif)
├── collection.json           ← Manifeste de la collection
├── package.json
└── tsconfig.json

Le fichier collection.json

C'est le manifeste de votre collection — il déclare tous les Schematics disponibles et leurs points d'entrée.

// collection.json — déclare tous les schematics de votre package
{
    "$schema": "../node_modules/@angular-devkit/schematics/collection-schema.json",
    "schematics": {
        // Schematic appelé par : ng generate my-schematics:my-component
        "my-component": {
            "description": "Génère un composant Angular avec structure custom",
            "factory": "./src/my-component/index#myComponent",
            "schema": "./src/my-component/schema.json"
        },
        // Schematic d'installation appelé par : ng add my-schematics
        "ng-add": {
            "description": "Installe et configure my-schematics dans le projet",
            "factory": "./src/ng-add/index#ngAdd"
        },
        // Schematic de migration appelé par : ng update my-schematics
        "migration-v2": {
            "description": "Migration automatique vers la v2",
            "factory": "./src/migrations/v2/index#migrateToV2",
            "version": "2.0.0"
        }
    }
}

Compiler et tester localement

# Compiler les Schematics TypeScript en JavaScript
npm run build

# Lier le package localement pour tester dans un projet Angular
npm link

# Dans votre projet Angular, lier le package
cd /path/to/angular-project
npm link my-schematics

# Tester le schematic sans modifier les fichiers (--dry-run)
ng generate my-schematics:my-component --name=dashboard --dry-run
Note : L'option --dry-run est votre meilleur ami lors du développement. Elle simule toutes les transformations et affiche les fichiers qui seraient créés ou modifiés, sans toucher au disque. Indispensable pour déboguer vos Schematics.

L'API Tree : manipuler le filesystem virtuel

Le Tree est l'abstraction centrale des Schematics. C'est une représentation virtuelle du système de fichiers du projet Angular cible. Toutes vos transformations s'appliquent d'abord sur ce Tree virtuel ; elles ne sont écrites sur le disque que lorsque le Schematic se termine avec succès.

Les opérations fondamentales du Tree

// src/my-schematic/index.ts
import { Rule, Tree, SchematicContext } from '@angular-devkit/schematics';

export function mySchematic(): Rule {
    // Une Rule est une fonction (Tree, SchematicContext) => Tree | Observable<Tree>
    return (tree: Tree, context: SchematicContext): Tree => {

        // --- LIRE un fichier existant ---
        const content = tree.read('src/app/app.component.ts');
        if (content) {
            // content est un Buffer — convertir en string
            const contentStr = content.toString('utf-8');
            context.logger.info(`Taille fichier : ${contentStr.length} chars`);
        }

        // --- VÉRIFIER l'existence d'un fichier ---
        if (tree.exists('src/environments/environment.ts')) {
            context.logger.info('Fichier environment.ts trouvé');
        }

        // --- CRÉER un nouveau fichier ---
        tree.create(
            'src/app/my-feature/my-feature.component.ts',
            // Contenu du fichier à créer
            `import { Component } from '@angular/core';\n\n@Component({ selector: 'app-my-feature', template: '' })\nexport class MyFeatureComponent {}\n`
        );

        // --- MODIFIER un fichier existant ---
        tree.overwrite(
            'src/app/app.module.ts',
            // Nouveau contenu complet du fichier
            getUpdatedAppModule(content?.toString('utf-8') ?? '')
        );

        // --- SUPPRIMER un fichier ---
        if (tree.exists('src/app/old-file.ts')) {
            tree.delete('src/app/old-file.ts');
        }

        // --- RENOMMER un fichier ---
        tree.rename(
            'src/app/old-name.component.ts',
            'src/app/new-name.component.ts'
        );

        // Retourner le Tree modifié (les changements seront écrits sur disque)
        return tree;
    };
}

Lister les fichiers d'un répertoire

// Parcourir récursivement le répertoire src/app/
import { Rule, Tree, SchematicContext } from '@angular-devkit/schematics';

export function listComponents(): Rule {
    return (tree: Tree, context: SchematicContext): Tree => {
        // getDir() retourne un DirEntry pour naviguer l'arborescence
        const appDir = tree.getDir('src/app');

        // Lister les sous-répertoires immédiats
        context.logger.info('Sous-répertoires :');
        appDir.subdirs.forEach(dir => {
            context.logger.info(`  ${dir}`);
        });

        // Lister tous les fichiers .ts récursivement
        const tsFiles: string[] = [];
        tree.visit(filePath => {
            // Filtrer uniquement les fichiers TypeScript dans src/app/
            if (filePath.startsWith('/src/app/') && filePath.endsWith('.ts')) {
                tsFiles.push(filePath);
            }
        });

        context.logger.info(`${tsFiles.length} fichiers TypeScript trouvés`);
        return tree;
    };
}

Utiliser les templates de fichiers

Pour générer plusieurs fichiers avec un contenu dynamique (variables interpolées), Schematics fournit un système de templates puissant basé sur apply() et url().

// src/my-component/index.ts
import {
    Rule, Tree, SchematicContext,
    apply, url, template, move, mergeWith, MergeStrategy
} from '@angular-devkit/schematics';
import { strings } from '@angular-devkit/core';

export interface MyComponentOptions {
    name: string;      // Nom du composant
    path: string;      // Chemin de destination
    module?: string;   // Module à importer (optionnel)
}

export function myComponent(options: MyComponentOptions): Rule {
    return (tree: Tree, context: SchematicContext): Rule => {
        // Charger les fichiers templates depuis le dossier ./files/
        const templateSource = apply(
            url('./files'), // Chemin relatif vers les templates
            [
                // Interpoler les variables dans les noms de fichiers ET le contenu
                template({
                    ...strings,          // Utilitaires : dasherize, classify, camelize...
                    ...options,          // Les options passées par l'utilisateur
                    // Ajouter des helpers personnalisés
                    upper: (s: string) => s.toUpperCase(),
                }),
                // Déplacer les fichiers générés vers le chemin cible
                move(options.path)
            ]
        );

        // Fusionner les fichiers générés avec le Tree existant
        return mergeWith(templateSource, MergeStrategy.Default)(tree, context);
    };
}

Les fichiers templates utilisent la syntaxe <%= variable %> dans leurs noms et leur contenu :

// src/my-component/files/__name@dasherize__.component.ts.template
// Le nom du fichier sera transformé : "MyDashboard" → "my-dashboard.component.ts"

import { Component } from '@angular/core';

// Le nom de classe sera "classify" : "my-dashboard" → "MyDashboard"
@Component({
    selector: 'app-<%= dasherize(name) %>',
    standalone: true,
    templateUrl: './<%= dasherize(name) %>.component.html',
    styleUrls: ['./<%= dasherize(name) %>.component.scss']
})
export class <%= classify(name) %>Component {
    // Composant généré automatiquement par my-schematics
    title = '<%= name %>';
}
À retenir : Les utilitaires strings de @angular-devkit/core sont essentiels. dasherize('MyComponent')'my-component', classify('my-component')'MyComponent', camelize('my-component')'myComponent'. Angular CLI les utilise en interne pour tous ses générateurs.

Créer un générateur ng generate

Un générateur ng generate est le type de Schematic le plus courant. Il permet de scaffolding des composants, services, modules ou toute structure de fichiers avec des conventions propres à votre organisation. Voici comment créer un générateur de composants avec une structure enrichie — tests, Storybook et barrel exports inclus.

Définir le schéma des options (schema.json)

// src/smart-component/schema.json
// Décrit toutes les options disponibles pour ng generate my-lib:smart-component
{
    "$schema": "http://json-schema.org/schema",
    "$id": "SmartComponentSchema",
    "title": "Générateur de Smart Component",
    "type": "object",
    "properties": {
        "name": {
            "type": "string",
            "description": "Nom du composant (ex: user-profile)",
            "$default": { "$source": "argv", "index": 0 }
        },
        "path": {
            "type": "string",
            "description": "Chemin de destination",
            "default": "src/app"
        },
        "withStore": {
            "type": "boolean",
            "description": "Générer un service de store NgRx associé",
            "default": false
        },
        "withStorybook": {
            "type": "boolean",
            "description": "Générer un fichier Storybook .stories.ts",
            "default": true
        },
        "prefix": {
            "type": "string",
            "description": "Préfixe du sélecteur CSS",
            "default": "app"
        }
    },
    "required": ["name"]
}

Implémenter le générateur

// src/smart-component/index.ts
import {
    Rule, Tree, SchematicContext,
    apply, url, template, move, mergeWith,
    MergeStrategy, chain, noop
} from '@angular-devkit/schematics';
import { strings } from '@angular-devkit/core';
import { addDeclarationToModule } from '@schematics/angular/utility/ast-utils';
import { InsertChange } from '@schematics/angular/utility/change';
import * as ts from 'typescript';

export interface SmartComponentOptions {
    name: string;
    path: string;
    withStore: boolean;
    withStorybook: boolean;
    prefix: string;
}

export function smartComponent(options: SmartComponentOptions): Rule {
    return chain([
        // Étape 1 : Générer les fichiers depuis les templates
        generateFiles(options),
        // Étape 2 : Ajouter le store si demandé
        options.withStore ? generateStore(options) : noop(),
        // Étape 3 : Ajouter le Storybook si demandé
        options.withStorybook ? generateStorybook(options) : noop(),
        // Étape 4 : Mettre à jour le barrel (index.ts) du dossier parent
        updateBarrelFile(options),
    ]);
}

// Rule 1 : Générer les fichiers du composant depuis les templates
function generateFiles(options: SmartComponentOptions): Rule {
    return mergeWith(
        apply(url('./files/component'), [
            template({
                ...strings,   // dasherize, classify, camelize, etc.
                ...options,   // Toutes les options de l'utilisateur
            }),
            move(`${options.path}/${strings.dasherize(options.name)}`)
        ]),
        MergeStrategy.Default
    );
}

// Rule 2 : Générer un service NgRx store si --with-store
function generateStore(options: SmartComponentOptions): Rule {
    return mergeWith(
        apply(url('./files/store'), [
            template({ ...strings, ...options }),
            move(`${options.path}/${strings.dasherize(options.name)}/store`)
        ]),
        MergeStrategy.Default
    );
}

// Rule 3 : Générer le fichier Storybook
function generateStorybook(options: SmartComponentOptions): Rule {
    return mergeWith(
        apply(url('./files/storybook'), [
            template({ ...strings, ...options }),
            move(`${options.path}/${strings.dasherize(options.name)}`)
        ]),
        MergeStrategy.Default
    );
}

// Rule 4 : Mettre à jour ou créer le barrel index.ts
function updateBarrelFile(options: SmartComponentOptions): Rule {
    return (tree: Tree, context: SchematicContext): Tree => {
        const barrelPath = `${options.path}/index.ts`;
        const exportLine = `export * from './${strings.dasherize(options.name)}/${strings.dasherize(options.name)}.component';\n`;

        if (tree.exists(barrelPath)) {
            // Ajouter l'export au barrel existant
            const current = tree.read(barrelPath)!.toString('utf-8');
            // Éviter les doublons si on regénère
            if (!current.includes(exportLine)) {
                tree.overwrite(barrelPath, current + exportLine);
                context.logger.info(`Barrel mis à jour : ${barrelPath}`);
            }
        } else {
            // Créer un nouveau barrel
            tree.create(barrelPath, exportLine);
            context.logger.info(`Barrel créé : ${barrelPath}`);
        }

        return tree;
    };
}

Template du composant généré

// src/smart-component/files/component/__name@dasherize__.component.ts.template
// Ce fichier est le template TypeScript du composant généré
// Les <%= ... %> sont remplacés lors de la génération

import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';

@Component({
    selector: '<%= prefix %>-<%= dasherize(name) %>',
    standalone: true,
    imports: [CommonModule],
    templateUrl: './<%= dasherize(name) %>.component.html',
    styleUrls: ['./<%= dasherize(name) %>.component.scss']
})
export class <%= classify(name) %>Component implements OnInit {
    // Propriété titre initialisée avec le nom du composant
    title = '<%= classify(name) %>';

    ngOnInit(): void {
        // Initialisation du composant
        console.log('<%= classify(name) %>Component initialisé');
    }
}

Utilisation du générateur

# Générer un smart component simple
ng generate my-lib:smart-component user-profile

# Générer avec store NgRx et sans Storybook
ng generate my-lib:smart-component dashboard --with-store --no-with-storybook

# Générer dans un chemin spécifique
ng generate my-lib:smart-component product-card --path=src/app/features/products

# Voir ce qui serait généré sans rien créer
ng generate my-lib:smart-component order-list --dry-run
Conseil équipe : Publiez vos Schematics dans votre registry npm privé (Verdaccio, GitHub Packages, etc.). Toute l'équipe peut alors utiliser ng generate votre-org:smart-component pour respecter les conventions maison sans effort de mémorisation.

Créer un installateur ng add

Le Schematic ng-add est déclenché par ng add votre-package. Son rôle est d'automatiser tout ce qu'un développeur devrait faire manuellement après avoir installé votre librairie : ajouter des imports dans app.config.ts, injecter des styles dans angular.json, configurer les variables d'environnement, etc.

Structure du ng-add

// src/ng-add/index.ts
import {
    Rule, Tree, SchematicContext, chain,
    SchematicsException
} from '@angular-devkit/schematics';
import { NodePackageInstallTask } from '@angular-devkit/schematics/tasks';

export interface NgAddOptions {
    project?: string; // Projet Angular cible (monorepo)
    theme?: string;   // Thème CSS à installer
    locale?: string;  // Locale à configurer
}

export function ngAdd(options: NgAddOptions): Rule {
    return chain([
        // Étape 1 : Valider que c'est bien un projet Angular
        validateAngularProject(),
        // Étape 2 : Ajouter les styles CSS au angular.json
        addStylesToAngularJson(options),
        // Étape 3 : Configurer le provider dans app.config.ts
        addProviderToAppConfig(options),
        // Étape 4 : Créer le fichier de configuration par défaut
        createConfigFile(options),
        // Étape 5 : Déclencher l'installation npm (optionnel)
        installDependencies(),
    ]);
}

Modifier angular.json pour ajouter des styles

// Ajouter un fichier CSS de librairie dans les styles Angular
function addStylesToAngularJson(options: NgAddOptions): Rule {
    return (tree: Tree, context: SchematicContext): Tree => {
        // Lire et parser angular.json
        const angularJsonBuffer = tree.read('angular.json');
        if (!angularJsonBuffer) {
            throw new SchematicsException('angular.json non trouvé. Ce projet est-il un projet Angular ?');
        }

        const angularJson = JSON.parse(angularJsonBuffer.toString('utf-8'));
        const projectName = options.project || Object.keys(angularJson.projects)[0];
        const project = angularJson.projects[projectName];

        // Naviguer dans la structure angular.json jusqu'aux styles
        const buildOptions = project.architect.build.options;
        if (!buildOptions.styles) {
            buildOptions.styles = [];
        }

        // Vérifier que le style n'est pas déjà ajouté (idempotence)
        const styleToAdd = 'node_modules/my-lib/styles/my-lib.css';
        if (!buildOptions.styles.includes(styleToAdd)) {
            buildOptions.styles.unshift(styleToAdd); // Ajouter en premier
            // Réécrire angular.json avec l'indentation préservée
            tree.overwrite('angular.json', JSON.stringify(angularJson, null, 2));
            context.logger.info(`Style ajouté dans angular.json : ${styleToAdd}`);
        } else {
            context.logger.info('Style déjà présent dans angular.json, ignoré.');
        }

        return tree;
    };
}

Ajouter un provider dans app.config.ts

// Modifier app.config.ts pour ajouter provideMyLib()
function addProviderToAppConfig(options: NgAddOptions): Rule {
    return (tree: Tree, context: SchematicContext): Tree => {
        const configPath = 'src/app/app.config.ts';

        if (!tree.exists(configPath)) {
            // Projet NgModule classique — modifier app.module.ts à la place
            context.logger.warn('app.config.ts non trouvé. Modification de app.module.ts...');
            return addImportToAppModule()(tree, context);
        }

        let configContent = tree.read(configPath)!.toString('utf-8');

        // Ajouter l'import si absent
        if (!configContent.includes('provideMyLib')) {
            // Ajouter la ligne d'import en haut du fichier
            const importStatement = `import { provideMyLib } from 'my-lib';\n`;
            configContent = importStatement + configContent;

            // Ajouter provideMyLib() dans le tableau providers
            // Chercher le dernier provider dans le tableau et insérer après
            configContent = configContent.replace(
                /providers:\s*\[([^\]]*)\]/,
                (match, providers) => {
                    const hasProviders = providers.trim().length > 0;
                    const separator = hasProviders ? ',\n        ' : '\n        ';
                    return `providers: [${providers}${separator}provideMyLib()\n    ]`;
                }
            );

            tree.overwrite(configPath, configContent);
            context.logger.info('provideMyLib() ajouté dans app.config.ts');
        }

        return tree;
    };
}

Déclencher l'installation npm

// Demander à Angular CLI d'exécuter npm install après le schematic
function installDependencies(): Rule {
    return (_tree: Tree, context: SchematicContext): void => {
        // NodePackageInstallTask déclenche npm install automatiquement
        context.addTask(new NodePackageInstallTask());
        context.logger.info('Installation des dépendances npm en cours...');
    };
}
À retenir : Le Schematic ng-add doit être idempotent — si on l'exécute deux fois, le résultat doit être identique. Vérifiez toujours si un import ou une configuration existe déjà avant de l'ajouter. Les utilisateurs réexécutent parfois ng add par erreur.

Créer des migrations ng update

Les migrations ng update sont les Schematics les plus complexes — et les plus précieux. Quand vous publiez une version majeure avec des breaking changes, vous pouvez fournir une migration automatique qui transforme le code des utilisateurs. C'est exactement ce que fait Angular CLI lui-même pour chaque version majeure d'Angular.

Déclarer une migration dans package.json

// package.json de votre librairie
// Pointer vers le fichier de collection de migrations
{
    "name": "my-lib",
    "version": "2.0.0",
    "schematics": "./collection.json",
    // Déclaration OBLIGATOIRE pour que ng update trouve vos migrations
    "ng-update": {
        "migrations": "./migrations.json",
        "packageGroup": ["my-lib", "my-lib-testing"]
    }
}
// migrations.json — catalogue des migrations par version
{
    "$schema": "../node_modules/@angular-devkit/schematics/collection-schema.json",
    "schematics": {
        // Migration déclenchée lors de ng update my-lib (v1 → v2)
        "migration-v2-rename-service": {
            "version": "2.0.0",
            "description": "Renomme OldService en NewService (breaking change v2)",
            "factory": "./src/migrations/v2-rename-service/index#migrateRenameService"
        },
        // Migration déclenchée lors de ng update my-lib (v2 → v3)
        "migration-v3-standalone": {
            "version": "3.0.0",
            "description": "Migre les modules vers Standalone Components",
            "factory": "./src/migrations/v3-standalone/index#migrateToStandalone"
        }
    }
}

Implémenter une migration de renommage

Cas concret : entre la v1 et la v2, vous renommez OldDataService en DataService. La migration doit mettre à jour tous les imports et toutes les injections dans le projet de l'utilisateur.

// src/migrations/v2-rename-service/index.ts
import { Rule, Tree, SchematicContext } from '@angular-devkit/schematics';

export function migrateRenameService(): Rule {
    return (tree: Tree, context: SchematicContext): Tree => {
        context.logger.info('Migration v2 : renommage OldDataService → DataService');

        // Compteur pour le rapport de migration
        let filesModified = 0;

        // Parcourir tous les fichiers TypeScript du projet
        tree.visit(filePath => {
            // Ignorer les fichiers hors src/ et les fichiers de spec
            if (!filePath.startsWith('/src/') || filePath.endsWith('.spec.ts')) {
                return;
            }
            // Ne traiter que les fichiers TypeScript
            if (!filePath.endsWith('.ts')) {
                return;
            }

            const content = tree.read(filePath);
            if (!content) return;

            let contentStr = content.toString('utf-8');
            let modified = false;

            // Remplacement 1 : l'import de l'ancien service
            if (contentStr.includes('OldDataService')) {
                contentStr = contentStr
                    // Mettre à jour le nom d'import dans la déclaration import
                    .replace(
                        /import\s*\{\s*OldDataService\s*\}\s*from\s*['"]my-lib['"]/g,
                        `import { DataService } from 'my-lib'`
                    )
                    // Mettre à jour toutes les occurrences du nom de classe
                    .replace(/OldDataService/g, 'DataService');

                modified = true;
            }

            // Écrire le fichier modifié dans le Tree
            if (modified) {
                tree.overwrite(filePath, contentStr);
                filesModified++;
                context.logger.info(`  Migré : ${filePath}`);
            }
        });

        // Rapport final de la migration
        context.logger.info(`Migration v2 terminée. ${filesModified} fichier(s) modifié(s).`);
        return tree;
    };
}

Migration AST-based pour des transformations précises

Pour des transformations complexes (modifier des décorateurs, restructurer des imports), l'approche regex peut produire des faux positifs. On préfère alors utiliser le compilateur TypeScript directement via l'API AST.

// src/migrations/v3-standalone/index.ts
// Migration qui utilise l'AST TypeScript pour transformer les décorateurs
import { Rule, Tree, SchematicContext } from '@angular-devkit/schematics';
import * as ts from 'typescript';

export function migrateToStandalone(): Rule {
    return (tree: Tree, context: SchematicContext): Tree => {
        // Récupérer tous les fichiers de composants Angular
        const componentFiles: string[] = [];
        tree.visit(filePath => {
            if (filePath.endsWith('.component.ts') && filePath.startsWith('/src/')) {
                componentFiles.push(filePath);
            }
        });

        for (const filePath of componentFiles) {
            const content = tree.read(filePath)?.toString('utf-8');
            if (!content) continue;

            // Parser le fichier avec l'API TypeScript pour obtenir l'AST
            const sourceFile = ts.createSourceFile(
                filePath,
                content,
                ts.ScriptTarget.Latest,
                true // Préserver les trivia (espaces, commentaires)
            );

            // Chercher le décorateur @Component
            const updatedContent = addStandaloneToDecorator(sourceFile, content);

            if (updatedContent !== content) {
                tree.overwrite(filePath, updatedContent);
                context.logger.info(`Standalone ajouté : ${filePath}`);
            }
        }

        return tree;
    };
}

// Ajoute standalone: true dans le décorateur @Component s'il est absent
function addStandaloneToDecorator(sourceFile: ts.SourceFile, content: string): string {
    let result = content;

    // Parcourir les noeuds de l'AST récursivement
    const visit = (node: ts.Node): void => {
        // Chercher les classes avec des décorateurs
        if (ts.isClassDeclaration(node) && node.modifiers) {
            for (const modifier of node.modifiers) {
                if (
                    ts.isDecorator(modifier) &&
                    ts.isCallExpression(modifier.expression)
                ) {
                    const expr = modifier.expression;
                    // Vérifier que c'est @Component (pas @Directive, @Pipe...)
                    if (
                        ts.isIdentifier(expr.expression) &&
                        expr.expression.text === 'Component' &&
                        expr.arguments.length > 0
                    ) {
                        const arg = expr.arguments[0];
                        if (ts.isObjectLiteralExpression(arg)) {
                            // Vérifier que standalone n'est pas déjà présent
                            const hasStandalone = arg.properties.some(
                                p => ts.isPropertyAssignment(p) &&
                                     ts.isIdentifier(p.name) &&
                                     p.name.text === 'standalone'
                            );

                            if (!hasStandalone) {
                                // Insérer standalone: true après le { du décorateur
                                const insertPos = arg.getStart() + 1;
                                result =
                                    result.slice(0, insertPos) +
                                    '\n    standalone: true,' +
                                    result.slice(insertPos);
                            }
                        }
                    }
                }
            }
        }
        ts.forEachChild(node, visit);
    };

    visit(sourceFile);
    return result;
}
Bonne pratique : Préférez toujours l'approche AST pour les migrations de production. Les expressions régulières peuvent mal gérer les commentaires, les chaînes de caractères multi-lignes, ou les espaces inattendus. L'AST TypeScript est la seule approche vraiment fiable.

Tester ses Schematics

Le Tree virtuel rend les Schematics exceptionnellement testables. Le package @angular-devkit/schematics/testing fournit SchematicTestRunner et UnitTestTree — tout ce dont vous avez besoin pour des tests unitaires rapides, sans système de fichiers réel.

Tester un générateur ng generate

// src/smart-component/index_spec.ts
import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing';
import * as path from 'path';

// Chemin vers le collection.json de notre package
const collectionPath = path.join(__dirname, '../../collection.json');

describe('smart-component schematic', () => {
    // Le runner charge notre collection de Schematics
    const runner = new SchematicTestRunner('my-lib', collectionPath);

    // App tree de base simulant un projet Angular minimal
    let appTree: UnitTestTree;

    beforeEach(async () => {
        // Créer un workspace Angular vide pour les tests
        appTree = await runner.runSchematic(
            'workspace',      // Schematic Angular CLI : créer un workspace
            { name: 'test-app', version: '17.0.0' },
            new UnitTestTree(null as any) // Tree vide de départ
        );
    });

    it('devrait créer les fichiers du composant', async () => {
        // Exécuter notre schematic avec les options voulues
        const tree = await runner.runSchematic(
            'smart-component',           // Nom du schematic dans collection.json
            { name: 'user-profile', path: 'src/app' }, // Options
            appTree                      // Tree de base (projet Angular)
        );

        // Vérifier que les fichiers ont été créés
        expect(tree.exists('src/app/user-profile/user-profile.component.ts')).toBeTruthy();
        expect(tree.exists('src/app/user-profile/user-profile.component.html')).toBeTruthy();
        expect(tree.exists('src/app/user-profile/user-profile.component.scss')).toBeTruthy();
        expect(tree.exists('src/app/user-profile/user-profile.component.spec.ts')).toBeTruthy();
    });

    it('devrait générer le bon sélecteur CSS', async () => {
        const tree = await runner.runSchematic(
            'smart-component',
            { name: 'user-profile', path: 'src/app', prefix: 'app' },
            appTree
        );

        // Lire le contenu du composant généré
        const componentContent = tree.readContent(
            'src/app/user-profile/user-profile.component.ts'
        );

        // Vérifier la présence du bon sélecteur dans le template
        expect(componentContent).toContain("selector: 'app-user-profile'");
    });

    it('devrait générer le store si --with-store est activé', async () => {
        const tree = await runner.runSchematic(
            'smart-component',
            { name: 'dashboard', path: 'src/app', withStore: true },
            appTree
        );

        // Vérifier que le dossier store a été créé
        expect(tree.exists('src/app/dashboard/store/dashboard.store.ts')).toBeTruthy();
    });

    it('ne devrait pas créer de doublon dans le barrel si relancé', async () => {
        // Premier lancement
        let tree = await runner.runSchematic(
            'smart-component',
            { name: 'product', path: 'src/app' },
            appTree
        );

        // Second lancement (simuler une réexécution)
        tree = await runner.runSchematic(
            'smart-component',
            { name: 'order', path: 'src/app' },
            tree // Utiliser le tree résultant du premier lancement
        );

        const barrelContent = tree.readContent('src/app/index.ts');
        // Vérifier qu'il n'y a pas de doublons d'export
        const exportLines = barrelContent.split('\n').filter(l => l.includes('export'));
        const uniqueExports = new Set(exportLines);
        expect(exportLines.length).toBe(uniqueExports.size);
    });
});

Tester une migration ng update

// src/migrations/v2-rename-service/index_spec.ts
import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing';
import * as path from 'path';

// Pointer vers le fichier migrations.json (pas collection.json !)
const migrationsPath = path.join(__dirname, '../../../migrations.json');

describe('migration v2 : renommage service', () => {
    const runner = new SchematicTestRunner('my-lib-migrations', migrationsPath);

    it('devrait renommer OldDataService en DataService', async () => {
        // Créer un Tree avec un fichier simulant un composant de l'utilisateur
        const tree = new UnitTestTree(null as any);

        // Simuler un fichier existant qui utilise l'ancien service
        tree.create('src/app/home/home.component.ts', `
import { Component, inject } from '@angular/core';
import { OldDataService } from 'my-lib';

@Component({ selector: 'app-home', template: '' })
export class HomeComponent {
    // Injection de l'ancien service (sera migré automatiquement)
    private dataService = inject(OldDataService);
}
        `.trim());

        // Exécuter la migration
        const migratedTree = await runner.runSchematic(
            'migration-v2-rename-service',
            {},     // Pas d'options pour les migrations
            tree
        );

        const content = migratedTree.readContent('src/app/home/home.component.ts');

        // Vérifier que l'import a été mis à jour
        expect(content).toContain("import { DataService } from 'my-lib'");
        // Vérifier que l'ancien nom n'existe plus
        expect(content).not.toContain('OldDataService');
        // Vérifier que le nouveau nom est bien utilisé
        expect(content).toContain('inject(DataService)');
    });

    it('ne devrait pas modifier les fichiers sans OldDataService', async () => {
        const tree = new UnitTestTree(null as any);

        const originalContent = `
import { Component } from '@angular/core';

@Component({ selector: 'app-other', template: '' })
export class OtherComponent {}
        `.trim();

        tree.create('src/app/other/other.component.ts', originalContent);

        const migratedTree = await runner.runSchematic(
            'migration-v2-rename-service',
            {},
            tree
        );

        // Le fichier ne doit pas avoir été modifié
        const content = migratedTree.readContent('src/app/other/other.component.ts');
        expect(content).toBe(originalContent);
    });
});

Lancer les tests

# Lancer tous les tests Schematics
npm test

# Lancer avec coverage
npm test -- --coverage

# En watch mode pendant le développement
npm test -- --watch
Astuce CI/CD : Les tests de Schematics s'exécutent entièrement en mémoire et ne touchent jamais le disque. Ils sont donc très rapides (quelques millisecondes par test) et parfaitement adaptés à une pipeline CI. Ajoutez-les systématiquement à votre GitHub Actions ou GitLab CI.

Checklist qualité pour vos Schematics

  • Chaque Schematic est couvert par des tests unitaires (SchematicTestRunner)
  • Les Schematics ng-add sont idempotents (double exécution = même résultat)
  • Les migrations gèrent le cas "fichier non trouvé" sans planter
  • --dry-run testé manuellement avant chaque publication
  • Les messages de log (context.logger) sont clairs et informatifs
  • Le schema.json valide les options et fournit des messages d'erreur utiles
  • Les migrations ng update sont testées sur un projet Angular réel avant publication
  • Le migrations.json déclare la bonne version cible ("version")

Conclusion

Les Angular Schematics sont un outil de productivité puissant, trop souvent ignoré en dehors des mainteneurs de librairies. Que ce soit pour standardiser le scaffolding au sein d'une équipe, automatiser l'installation d'un design system, ou fournir des migrations automatiques lors des mises à jour de version majeure, ils permettent d'éliminer des tâches répétitives et sources d'erreurs humaines.

La courbe d'apprentissage initiale — comprendre le Tree, les Rule, les templates et le SchematicTestRunner — est compensée rapidement par le gain de temps dans toute équipe qui développe des librairies Angular partagées. Dans un contexte monorepo Nx, les Schematics sont encore plus pertinents : Nx les utilise pour tous ses générateurs et expose une API compatible qui vous permet de créer vos propres générateurs de workspace.

À retenir : Commencez par un générateur ng generate simple pour votre composant maison le plus utilisé. C'est le meilleur point d'entrée — la valeur ajoutée est immédiatement visible, et vous maîtriserez naturellement l'API Tree avant de passer aux migrations ng update, plus complexes.

Partager