Skip to main content

Chapitre 78 : Développement de plugins PostCSS personnalisés

Concepts clés : Manipulation de l'AST (Abstract Syntax Tree) CSS, Visiteurs de nœuds, Transformation structurelle du CSS

Le développement de plugins PostCSS représente le sommet de la maîtrise de l'outillage CSS. Contrairement aux approches basées sur des expressions régulières (Regex) qui sont fragiles et sujettes aux erreurs de parsing, PostCSS transforme le code CSS en un AST (Abstract Syntax Tree).

Un plugin PostCSS est essentiellement une fonction qui reçoit cet arbre, le parcourt, et modifie ses nœuds selon des règles logiques précises avant que l'arbre ne soit reconverti en chaîne de caractères (stringification).

L'Architecture de l'AST PostCSS

1. Quoi

L'AST (Abstract Syntax Tree) est une représentation hiérarchique du code source. PostCSS décompose le CSS en objets JavaScript structurés. La hiérarchie typique est la suivante :

  • Root : Le point d'entrée, représentant l'intégralité du fichier CSS.
  • Rule : Un bloc composé d'un sélecteur (ex: .btn) et d'un corps contenant des déclarations.
  • Declaration : Une paire propriété/valeur (ex: color: red).
  • AtRule : Les règles commençant par @ (ex: @media, @keyframes).
  • Comment : Les commentaires CSS, traités comme des nœuds à part entière.

2. Pourquoi

Manipuler l'AST plutôt que du texte brut offre trois avantages critiques :

  1. Fiabilité : Vous ne risquez pas de modifier accidentellement du texte à l'intérieur d'une chaîne de caractères ou d'un commentaire.
  2. Précision : Vous avez accès au contexte (ex: savoir si une déclaration color se trouve à l'intérieur d'une @media query spécifique).
  3. Performance : PostCSS optimise le parcours de l'arbre pour que plusieurs plugins puissent agir sur le même AST sans avoir à reparser le fichier à chaque fois.

3. Comment

A. Syntaxe de base

Un plugin est une fonction qui retourne une fonction. Cette seconde fonction est celle appelée par PostCSS pour chaque fichier traité.

import postcss, { PluginCreator } from 'postcss';

const myPlugin: PluginCreator = (opts = {}) => {
return {
postcssPlugin: 'my-custom-plugin',
Once(root) {
// Logique exécutée une seule fois par fichier
console.log('Traitement du fichier CSS...');
},
};
};

export default myPlugin;

B. Cas concret : Plugin de "Design Tokens"

Imaginons un besoin métier : remplacer des variables fictives comme token(primary-color) par des valeurs réelles provenant d'un fichier de configuration JSON.

import postcss, { PluginCreator, Declaration } from 'postcss';

interface TokenConfig {
[key: string]: string;
}

const tokenConfig: TokenConfig = {
'primary-color': '#3498db',
'spacing-unit': '8px',
};

const tokenPlugin: PluginCreator = (opts = {}) => {
return {
postcssPlugin: 'postcss-design-tokens',

// On utilise walkDecls pour cibler uniquement les déclarations
Declaration(decl: Declaration) {
// On cherche le pattern token(...) dans la valeur
const tokenRegex = /token\(([^)]+)\)/g;

if (tokenRegex.test(decl.value)) {
// Remplacement dynamique des tokens par les valeurs du config
decl.value = decl.value.replace(tokenRegex, (_, tokenName) => {
const value = tokenConfig[tokenName];
if (!value) {
throw new Error(`Token inconnu : ${tokenName} à la ligne ${decl.source.start.line}`);
}
return value;
});
}
},
};
};

export default tokenPlugin;

C. Limitations

  • L'ordre des plugins : Un plugin qui supprime des nœuds peut empêcher un plugin suivant de les trouver.
  • La complexité temporelle : L'utilisation intensive de root.walk() à l'intérieur d'autres boucles de parcours peut transformer un plugin performant en goulot d'étranglement (complexité quadratique).
  • La gestion des sources : Modifier les valeurs sans mettre à jour les source maps peut rendre le débogage en navigateur impossible.

4. Zone de Danger

Erreur commune : Utiliser root.walkRules() pour modifier des propriétés. C'est inefficace car vous devez ensuite boucler manuellement sur les déclarations de chaque règle.

Bonne pratique : Utiliser les "Listeners" (comme Declaration ou Rule dans l'objet retourné) ou root.walkDecls(). PostCSS optimise ces parcours pour minimiser les itérations.

Techniques Avancées de Manipulation

Modification et Insertion de Nœuds

Pour aller au-delà du simple remplacement de texte, vous devez manipuler la structure même de l'AST.

1. Ajouter une propriété (Append)

Pour ajouter une propriété à une règle existante, on utilise la méthode append() sur l'objet Rule.

root.walkRules(rule => {
if (rule.selector === '.card') {
// Ajoute une nouvelle déclaration à la fin du bloc .card
rule.append({ prop: 'border-radius', value: '4px' });
}
});

2. Remplacer un nœud (replaceWith)

La méthode replaceWith() permet de substituer un nœud par un autre, ou même par une chaîne de caractères qui sera reparsée.

root.walkDecls('display', decl => {
if (decl.value === 'flex') {
// Remplace 'display: flex' par 'display: grid'
decl.replaceWith('display: grid');
}
});

3. Supprimer un nœud (remove)

La méthode remove() excise totalement le nœud de l'arbre.

root.walkDecls('outline', decl => {
// Supprime toutes les propriétés outline pour le build de production
decl.remove();
});

Questions clés

Q1 : Quelle est la différence entre root.walkDecls() et le listener Declaration() ?

Découvrir la réponse

root.walkDecls() est une méthode impérative que vous appelez manuellement. Le listener Declaration() est une approche déclarative où PostCSS vous "notifie" dès qu'il rencontre une déclaration lors de son propre parcours optimisé. Pour les plugins modernes, les listeners sont préférables pour la performance.

Q2 : Comment gérer les plugins qui dépendent d'autres plugins ?

Découvrir la réponse

PostCSS ne garantit pas l'ordre d'exécution au-delà de l'ordre dans lequel ils sont déclarés dans la configuration. Si votre plugin dépend d'une transformation préalable, vous devez documenter cet ordre ou utiliser des mécanismes de validation au début de l'exécution (Once hook).

Q3 : Pourquoi utiliser postcss-value-parser en complément de PostCSS ?

Découvrir la réponse

PostCSS fragmente le CSS jusqu'à la valeur de la déclaration (ex: margin: 10px 20px). Cependant, il ne parse pas l'intérieur de la valeur. Pour modifier précisément le deuxième argument d'une fonction calc() ou d'un rgba(), postcss-value-parser est indispensable pour transformer la valeur elle-même en un AST secondaire.

Q4 : Quel est l'impact d'un plugin mal optimisé sur le build ?

Découvrir la réponse

Un plugin qui effectue des recherches globales (root.walk) à chaque nœud visité peut augmenter le temps de build de quelques millisecondes à plusieurs secondes sur des fichiers CSS de grande taille (plusieurs Mo), impactant directement la boucle de feedback du développeur (HMR).

Mise en pratique

Exercice 1 : Reproduction guidée (Niveau Facile)

Créez un plugin nommé postcss-no-red qui parcourt toutes les déclarations de couleur et remplace toute occurrence de la valeur red par blue.

Découvrir la solution commentée
import postcss, { PluginCreator } from 'postcss';

const postcssNoRed: PluginCreator = () => {
return {
postcssPlugin: 'postcss-no-red',
Declaration(decl) {
// On cible uniquement la propriété 'color' ou 'background-color'
if (decl.prop === 'color' || decl.prop === 'background-color') {
if (decl.value === 'red') {
decl.value = 'blue';
}
}
},
};
};

export default postcssNoRed;

Exercice 2 : Adaptation (Niveau Intermédiaire)

Modifiez le plugin précédent pour qu'il accepte une option replacementColor. Si l'option n'est pas fournie, la couleur par défaut doit être green. Le plugin doit remplacer red par cette couleur.

Découvrir la solution commentée
import postcss, { PluginCreator } from 'postcss';

interface PluginOptions {
replacementColor?: string;
}

const postcssColorSwap: PluginCreator<PluginOptions> = (opts = {}) => {
// Valeur par défaut si l'option n'est pas spécifiée
const targetColor = opts.replacementColor || 'green';

return {
postcssPlugin: 'postcss-color-swap',
Declaration(decl) {
if (decl.value === 'red') {
decl.value = targetColor;
}
},
};
};

export default postcssColorSwap;

Exercice 3 : Conception (Niveau Expert)

Développez un plugin postcss-responsive-helper qui détecte toutes les règles ayant un sélecteur commençant par .res- (ex: .res-container). Pour chaque règle trouvée, le plugin doit :

  1. Ajouter automatiquement la propriété box-sizing: border-box.
  2. Si la règle contient déjà width, ajouter une propriété max-width: 100%.
Découvrir la solution commentée
import postcss, { PluginCreator, Rule } from 'postcss';

const postcssResponsiveHelper: PluginCreator = () => {
return {
postcssPlugin: 'postcss-responsive-helper',
Rule(rule: Rule) {
// Vérification si le sélecteur commence par .res-
// Note: On utilise split(',') car un sélecteur peut être une liste (.res-1, .res-2)
const selectors = rule.selector.split(',');
const isResponsive = selectors.some(s => s.trim().startsWith('.res-'));

if (isResponsive) {
// 1. Ajout de box-sizing
// On vérifie d'abord si elle n'existe pas déjà pour éviter les doublons
const hasBoxSizing = rule.walkDecls('box-sizing');
if (!hasBoxSizing.next()) {
rule.append({ prop: 'box-sizing', value: 'border-box' });
}

// 2. Ajout de max-width si width est présent
let hasWidth = false;
rule.walkDecls('width', () => {
hasWidth = true;
});

if (hasWidth) {
// On vérifie si max-width existe déjà
const hasMaxWidth = rule.walkDecls('max-width');
if (!hasMaxWidth.next()) {
rule.append({ prop: 'max-width', value: '100%' });
}
}
}
},
};
};

export default postcssResponsiveHelper;