Comment j’ai développé mon premier plugin Strapi

Publié le Mis à jour le 22 min de lecture

Imaginez pouvoir générer du contenu directement dans votre CMS. Le rêve non ? C'est ce que j'ai voulu faire en développant pour la première fois un plugin Strapi pour intégrer OpenAI (ChatGPT).

L’objectif de base

Initialement, je voulais créer un plugin pour connecter Strapi à Jarside via leur API afin de créer automatiquement des articles optimisés pour le SEO.

Cependant, en discutant sur X, Victor m'a envoyé une vidéo montrant qu'une entreprise polonaise avait développé un plugin pour générer le titre, l'introduction, le corps, le meta title, la meta description, des images, un script vidéo, etc. mais avec l’API d’OpenAI (ChatGPT).

C'était exactement ce qu'il me fallait. J'ai donc cherché à le télécharger, mais j'ai réalisé que le plugin n'était pas disponible en open source. Ils l'ont gardé pour eux (et ils ont sans doute raison).

Mon objectif était donc de reproduire ce qui est montré dans la vidéo, à savoir un champ contenant un bouton qui ouvre une fenêtre modal où l'on saisit quelques paramètres avant de générer le contenu. On attend quelques secondes que le contenu se génère et on clique sur "appliquer" pour remplir nos champs avec le contenu généré.

Ainsi, à partir de cette vidéo et sans aucune connaissance en développement de plugin Strapi, j'ai entrepris de créer un plugin de génération de contenu nommé "Generator AI".

Préparer le terrain

La première chose que j'ai faite a été d'éplucher la documentation de Strapi pour voir comment créer un plugin.

Les développeurs de Strapi ont facilité la tâche en créant une commande CLI pour générer le boilerplate du plugin : yarn strapi generate ou npm run strapi generate.

Il suffit ensuite d'activer le plugin en suivant les instructions, d'aller dans le dossier du plugin, d'installer toutes les dépendances avec yarn install ou npm install, puis de revenir à la racine du projet pour lancer le serveur local avec yarn develop --watch-admin ou npm run develop -- --watch-admin.

Jusque-là, rien de bien compliqué. Il suffit de suivre la documentation. Cependant, ce qui suit demande plus de réflexion.

Comprendre l’architecture d’un plugin Strapi

Étonnement, l’architecture d’un plugin Strapi n’est pas expliquée dans la documentation. Heureusement, j’ai fini par tomber sur cet article qui explique comme cela se passe malgré qu’il date de 2022 et que des choses ont changés entre temps.

Voici donc la version 2023 de l’architecture d’un plugin Strapi :

├── README.md                 // Un read me classique
├── admin                     // Partie front-end du plugin
│   └── src
│       ├── components        // Components React du plugin
│       │   ├── Initializer
│       │   │   └── index.js  // Initialisation des components
│       │   └── PluginIcon
│       │       └── index.js  // Icône du plugin affichée dans le menu de navigation
│       ├── index.js          // Configuration du plugin
│       ├── pages             // Différentes pages du plugin
│       │   ├── App
│       │   │   └── index.js  // Squelette autour de votre page. Il est commun à toutes vos pages. C'est donc là que vous devrez mettre une navigation par exemple
│       │   └── HomePage
│       │       └── index.js  // Home page du plugin. Page créé par défaut
│       ├── pluginId.js       // Variable pluginId récupéré à partir du package.json
│       ├── translations      // Fichiers de traductions de votre plugin en i18n
│       │   ├── en.json
│       │   └── fr.json
│       └── utils             // Dossier contenant toutes les fonctions que vous allez réutiliser dans votre front
│           └── getTrad.js    // Function getTrad qui renvoie le fichier de traduction correspondant à votre langue
├── server                    // Partie back-end du plugin
│   ├── bootstrap.js          // Function appelée juste après que le plugin soit enregistré
│   ├── config
│   │   └── index.js          // Fichier qui contient les paramètres par défaut du plugin
│   ├── content-types         // Dossier qui contient les schémas des content-types (models) particuliers pour votre plugin
│   │   └── index.js          // Fichier qui charges tous les content-types
│   ├── controllers
│   │   ├── index.js          // Fichier qui charge tous les controllers
│   │   └── my-controller.js  // Controller créé par défaut. Vous pouvez le renommé ou supprimer
│   ├── destroy.js            // Fonction qui est appelé pour nettoyer le plugin après que l'instance de Strapi est détruite
│   ├── index.js              // Fichier qui exportes tous les autres dossiers/fichiers de la partie server
│   ├── middlewares           // Dossier qui contient tous les middlewares
│   │   └── index.js
│   ├── policies              // Dossier qui contient toutes les policies (logique excécutée avant d'atteindre le controller)
│   │   └── index.js
│   ├── register.js           // Function appelée pour charger le plugin, avant boostrap
│   ├── routes
│   │   └── index.js          // Fichier qui contient les routes par défaut du plugin
│   └── services
│       ├── index.js          // Fichier qui charges tous les services (fonctions réutilisables des controllers)
│       └── my-service.js     // Service par défaut. Vous pouvez rennomer ou supprimer
├── strapi-admin.js           // Point d'entrée du front-end
└── strapi-server.js          // Point d'entrée du back-end

Ajouter un nouveau type de champ

Pour que l’on puisse interagir avec le plugin dans l’éditeur de contenu, il faut créer un nouveau type de champ sur la partie admin (le front-end). Pour cela, il faut commencer par le déclarer dans le fichier mon-plugin/admin/src/index.js en ajoutant la fonction app.customFields.register() à l’objet register :

// mon-plugin/admin/src/index.js

app.customFields.register({
  name: "generator",
  pluginId: "generator-ai",
  type: "string",
  intlLabel: {
    id: `${pluginId}.plugin.field.generator.field`,
    defaultMessage: "Generator button",
  },
  intlDescription: {
    id: `${pluginId}.plugin.field.generator.description`,
    defaultMessage: "Let AI do your writing !",
  },
  icon: PluginIcon,
  components: {
    Input: async () =>
      import("./components/Input"), // Component qui va être appelé à l'affichage de la page d'édition de contenu
  },
})

Après cet ajout, si l’on va dans le Content-Type Builder et que l’on ajoute un nouveau champ, notre champ apparaît dans l’onglet “Custom”. Parfait !

Le champ personnalisé
Le champ personnalisé

Développer l’interface utilisateur du plugin

Comme je l’ai dit en préambule, je voulais que notre nouveau champ soit un bouton qui, quand on clique dessus, ouvre une modal de génération de contenu.

Pour faire cela, rendez-vous dans le fichier du composant qui est déclaré dans app.customFields.register(). Dans notre cas, il se trouve dans mon-plugin/admin/src/components/index.js.

Pour nous simplifier la vie et surtout garder une unité de design dans l’interface, les équipes de Strapi ont développé un “design system”. Un design system défini des règles, des bonnes pratiques, des couleurs, des tailles, des polices etc. pour assurer la cohérence d’UI/UX sur un même univers.

Dans ce design system, Strapi met à disposition des components React qui permettent de gagner beaucoup de temps. Ainsi, pour le bouton, nous n’allons pas le créer nous même mais nous allons utiliser le component mis à disposition dans le design system. Nous pouvons alors faire un import { Button } from "@strapi/design-system"

// mon-plugin/admin/src/components/index.js

import React from "react";
import {Button} from "@strapi/design-system";
import getTrad from "../../utils/getTrad";

export default function Input() {
    return (
        <Button onClick={() => console.log("click")}>
            // formatMessage() est une fonction qui vient récupérer la clé de
            traduction et afficher le texte correpsondant
            {formatMessage({
                id: getTrad("plugin.field.generator.button"),
            })}
        </Button>
    );
}

En réalité, ce même principe de composant existe quasiment pour toutes les balises HTML. En effet, pour mon plugin, je n’ai pas eu besoin d’écrire une seule balise HTML native. J’ai uniquement utiliser des composants venant du design system de Strapi.

Finalement, je suis arrivé à ce résultat pour ma modal :

Interface de paramètrages de la génération
Interface de paramètrages de la génération

Comme vous pouvez le voir dans le gif ci-dessus, les champs demandés sont uniquement des champs de paramètre, comme le ton ou la longueur du contenu demandé. Ces paramètres sont ensuite envoyés dans la requête à l’IA.

Cependant, afin que l’IA comprenne ce qu’on lui dise, il faut être un peu plus exhaustif. Où se cachent donc les prompts complets ?

Permettre à l’utilisateur de définir les prompts envoyés à l’IA

Tout d’abord, je voulais pouvoir faire cela individuellement par champ. Je me suis donc dit qu’il fallait profiter de l’onglet “Advanced” à la création du champ et de mettre mes réglages ici.

Réglages avancés du champs
Réglages avancés du champs

Cependant, après avoir galéré à essayer, j’ai enfin trouvé dans la documentation le fait que l’on ne pouvait pas faire ce que l’on voulait et qu’il fallait respecter la liste de champs disponibles ici. C’est-à-dire des choses assez simple comme rendre le champ obligatoire, définir une longueur maximale ou encore une définir une regex de validation.

L’alternative a été de faire quelque chose de plus basique, à savoir créer une page de configuration dans les paramètres Strapi pour permettre à l’utilisateur de personnaliser ses prompts.

Ainsi, dans le dossier mon-plugin/admin/src/pages, nous pouvons une nouvelle page. Pour cela, il faut créer le dossier Settings puis le sous-dossier Configuration contenant le fichier index.js. Toutefois, cette page n’est encore chargée par Strapi puisque nous ne l’avons pas averti que nous voulions la charger.

Pour cela, il faut modifier le fichier mon-plugin/admin/src/index.js et ajouter/modifier la fonction app.createSettingSection() pour ajouter le code suivant :

// mon-plugin/admin/src/index.js

app.createSettingSection(
  {
    id: pluginId,
    intlLabel: {
      id: `${pluginId}.plugin.name`,
      defaultMessage: "Generator AI",
    },
  },
  [
    {
      intlLabel: {
        id: `${pluginId}.plugin.page.configuration`,
        defaultMessage: "Configuration",
      },
      id: "settings.configuration",
      to: `/settings/${pluginId}/configuration`,
      Component: async () => {
        return import("./pages/Settings/Configuration");
      },
    },
  ]
);

Puis, de la même manière que sur l’Input nous pouvons créer le layout de la page en utilisant les components du design system pour arriver au résultat ci-dessous.

Interface de la configuration
Interface de la configuration

Mais, à partir de là, j’ai rencontré un nouveau problème : comment et où sauvegarde-t-on ces réglages ?

Sauvegarder les réglages du plugin Strapi

Figurez-vous que ce n’est expliqué nul part dans la documentation de Strapi. J’ai du fouiller longuement sur Google. De plus, il est impossible de se faire aider par ChatGPT car la dernière version de Strapi qu’il connait est la 3.6.0. Heureusement, l’article que j’ai mentionné au début explique comment faire cela.

Nous pouvons donc faire cela en créant une route, qui est gérée par un controller qui fait appel à un service.

// Ajout de la route dans mon-plugin/server/routes/index.js

module.exports = [
    {
        method: "POST",
        path: "/settings",
        handler: "settingsController.setSettings",
        config: {
            policies: [],
        },
    }
];
// Création du controller dans mon-plugin/server/controllers/settings.js

"use strict";

module.exports = ({strapi}) => ({
    async setSettings(ctx) {
        const {body} = ctx.request;
        try {
            ctx.body = await strapi
                .plugin("generator-ai")
                .service("settings")
                .setSettings(body);
        } catch (err) {
            ctx.throw(500, err);
        }
    },
});
// Export du nouveau controller dans mon-plugin/server/controllers/index.js

"use strict";

const settingsController = require("./settings");

module.exports = {
    settingsController,
};
// Création du service utilisé par le controller dans mon-plugin/server/services/settings.js

"use strict";

module.exports = ({strapi}) => {
    const pluginStore = strapi.store({
        environment: "",
        type: "plugin",
        name: "generator-ai",
    });

    return {
        async setSettings(settings) {
            const value = settings;
            await pluginStore.set({key: "settings", value});
            return "SOMETHING";
        },
    };
};
// Export du nouveau service dans mon-plugin/server/services/index.js

"use strict";

const settings = require("./settings");

module.exports = {
    settings,
};

Cependant, en copiant le code du tutoriel, je n’arrivais pas accéder à la méthode strapi.store(). En effet, il la met en dehors du module.exports et mon IDE ne trouvait pas la variable strapi.

Puis, j’ai réalisé que son code était basé sur une ancienne version de Strapi. J’ai donc utiliser la méthode strapi.store() mais cette fois dans le module.exports car j’avais accès à la variable strapi en paramètre du module.exports.

À partir de là, bingo, on peut enregistrer nos paramètres dans ce fameux store qui est en réalité une ligne dans la base de données à la table main.strapi_core_store_settings.

Faire des requête HTTP depuis la partie admin (front)

Néanmoins, j’ai rencontré un nouveau problème. Je n’arrivais pas faire des requêtes HTTP depuis mon front, avec React.

En effet, toujours sur le même tutoriel, la personne effectue ses requêtes sur une instance d’Axios qui n’a pas été générée par le CLI Strapi. J'ai donc pensé que c'était un oubli. J'ai alors copié/collé son fichier et je me suis rendu compte qu’il y avait une erreur dans la console node disant que c’était une fonction dépréciée. Encore une fois, rien n'est mentionné dans la documentation de Strapi à propos de cela …

Finalement, après avoir effectué des recherches sur Google, je suis tombé sur cette page qui indique qu'il faut désormais utiliser la fonction useFetchClient(). J'ai donc cherché cette fonction dans la documentation, mais je n'ai, une nouvelle fois, rien trouvé. J'ai finalement découvert sur la documentation des contributeurs à quoi sert ce hook. Il suffit alors de l’utiliser comme ceci :

import {useState} from "react";
import {useFetchClient} from "@strapi/helper-plugin";

const Component = () => {
  const {get} = useFetchClient();
  const requestURL = "/some-endpoint";

  const handleGetData = async () => {
    const {data} = await get(requestURL);
    setItems(data.items);
  };

  return <button onClick={handleGetData}>Show Items</button>;
};

Ainsi, une fois cela résolu, nous pouvons faire une requête “POST” pour enregistrer les réglages dans la base de donnée.

Fournir des prompts par défaut

Afin de faciliter la vie de l’utilisateur, je voulais fournir une base de prompts prêts à être utilisés.

J’ai donc cherché où est-ce que je pouvais stocker cela. Dans la documentation, j’ai trouvé que l’on pouvait enregistrer les paramètres par défauts du plugin dans le fichier mon-plugin/server/config/index.js.

// Paramètres par défaut du plugin dans mon-plugin/server/config/index.js

"use strict";

module.exports = {
    default: ({env}) => ({
        prompts: {
            base: `You're an experienced copywriter, you know how to write SEO-optimized blog post for the topic "__TOPIC__", targeting __TARGET__ audience with a __TONE__ tone. You must write in __LANGUAGE__. Don't make any comments. Keep theses instructions in memory for every answers you will give.`,
            title: `Generate the title for this blog post. Don't put your answer into quotes.`,
            introduction: `Generate an introduction for this blog post.`,
            body: `Generate only the body and with level 2 subtitles for this blog post. Add a lot of details. It must be formatted in markdown and have in bold what’s important. The body must make __LENGTH__ words approximatively.`,
            metaTitle: `Generate the meta title optimized for SEO with a maximum of 60 characters (white spaces and punctuation included,). Don't put your answer into quotes.`,
            metaDescription: `Generate the meta description optimized for SEO with a maximum of 160 characters (white spaces and punctuation included). Don't put your answer into quotes.`,
        },
        language: "english",
        length: 800,
        model: "gpt-3.5-turbo-16k",
    }),
    validator() {
    },
};

Une fois les paramètres par défaut définis, il faut les lire et les enregistrer dans la base de données. Le plus logique selon moi est de le faire au moment de la première requête getSettings(). Ainsi, nous pouvons vérifier si les réglages existent dans la base de données et réagir en fonction :

  • Si oui, on les renvoie
  • Si non, on récupère les réglages par défaut que j’enregistre dans la base de données et que je renvoie.
// Service settings dans mong-plugin/server/services/settings.js

"use strict";

const {array, object, string, number} = require("yup");

const settingsValidationSchema = object({
  prompts: object({
    base: string().required().trim(),
    title: string().required().trim(),
    introduction: string().required().trim(),
    body: string().required().trim(),
    metaTitle: string().required().trim(),
    metaDescription: string().required().trim(),
  }),
  model: string().required().trim(),
  target: string().trim(),
  language: string().trim(),
  tone: string().trim(),
  length: number().integer().positive(),
  contentTypes: array().of(
    object().shape({
      uid: string().required().trim(),
      mapping: array().of(
        object().shape({
          generatedField: string().required().trim(),
          normalField: string().required().trim(),
        })
      ),
    })
  ),
});

module.exports = ({strapi}) => {
  // Création du store (connexion avec la base de données)
  const pluginStore = strapi.store({
    environment: "",
    type: "plugin",
    name: "generator-ai",
  });

  async function createDefaultConfig() {
    const settings = {
      prompts: {
        base: strapi.plugin("generator-ai").config("prompts.base"),
        title: strapi.plugin("generator-ai").config("prompts.title"),
        introduction: strapi
          .plugin("generator-ai")
          .config("prompts.introduction"),
        body: strapi.plugin("generator-ai").config("prompts.body"),
        metaTitle: strapi.plugin("generator-ai").config("prompts.metaTitle"),
        metaDescription: strapi
          .plugin("generator-ai")
          .config("prompts.metaDescription"),
      },
      language: strapi.plugin("generator-ai").config("language"),
      length: strapi.plugin("generator-ai").config("length"),
      model: strapi.plugin("generator-ai").config("model"),
      contentTypes: [],
    };

    // Enregistrer dans la base de données les réglages par défaut
    await pluginStore.set({
      key: "settings",
      value: settings,
    });

    // Renvoie des settings par défaut
    return settings;
  }

  return {
    async getSettings() {
      // Appel à la base de données pour récupérer les settings
      let config = await pluginStore.get({key: "settings"});
      // Si ça ne récupère rien, ça vient récupérer les réglages par défaut
      if (config === null) {
        config = await createDefaultConfig();
      }

      return config;
    },

    async setSettings(settings) {
      // Valider si l'objet settings est correct
      await settingsValidationSchema.validate(settings);
      // Enregistrer dans la base de données si la validation est bonne
      await pluginStore.set({key: "settings", value: settings});
      // Renvoie de tous les réglages
      return settings;
    },

    async resetDefaultPrompt({prompt}) {
      return strapi.plugin("generator-ai").config(`prompts.${prompt}`);
    },
  };
};

Comme vous pouvez le voir dans l’extrait de code ci-dessus, nous pouvons questionner le fichier de configuration par défaut grâce à strapi.plugin("generator-ai").config("prompts.base")

Accéder aux clés d’API stockés dans le fichier .env

Pour pouvoir faire des requêtes à une API externe, il faut bien souvent des clés d’API. Dans le cas de Strapi, elles sont à stocker dans le fichier .env. Afin que le plugin puisse les lire, il faut aller à la racine de Strapi dans le fichier config/plugins.js pour ajouter la configuration utilisateur comme ceci:

// config/plugin.js

module.exports = {
    "generator-ai": {
        enabled: true,
        config: {
            openaiApiKey: process.env.OPENAI_API_KEY,
        },
        resolve: "./src/plugins/generator-ai",
    },
};

Ce n’était pas l’unique solution car nous aurions pu le mettre dans le fichier de config du plugin (mon-plugin/server/config/index.js), de la même manière que les paramètres par défaut. Cependant, ça ne laisse pas à l’utilisateur la possibilité de changer, si besoin, le nom de sa variable d’environnement. Il est donc préférable de le faire dans le configuration à la racine pour que l’utilisateur ait la main.

Ensuite, lu par Strapi, nous pouvons accéder à la variable d’environnement de la même manière que les paramètres par défaut. Ainsi, dans le fichier mon-plugin/server/services/openai.js, nous pouvons lire la clé de cette manière strapi.plugin("generator-ai").config("openaiApiKey").

// mon-plugin/server/services/openai.js

const openai = new OpenAI({
    apiKey: strapi.plugin("generator-ai").config("openaiApiKey"),
});

Faire des requêtes à OpenAI

Grâce à la clé d’API, nous pouvons désormais faire des requêtes à l’API d’OpenAI. Toutefois, en essayant d’avoir une discussions avec l’IA, je me suis rendu compte qu’elle oubliait le contexte à chaque requête. La raison est simple mais étonnante.

En effet, lorsque l’on utilise la completions sur la partie Chat d’OpenAI (en gros les requêtes à ChatGPT), d’une requête à l’autre, le contexte n’est pas conservé alors que l’API renvoie un ID de conversation. Ainsi, à chaque appel d’API, il faut renvoyer non seulement tous les messages précédents mais aussi les réponses précédentes de l’IA.

Nous pouvons solutionner le problème en faisant une boucle qui garde en mémoire les résultats précédents et qui les renvoie pour la requête suivante.

/**
 * Generates content by having a conversation with the OpenAI API.
 *
 * @param {Object} params - The parameters for content generation:
 * @param {string} params.topic - The topic to generate content about.
 * @param {string} params.language - The language to generate the content in.
 * @param {string} params.tone - The desired tone of the generated content.
 * @param {number} params.length - The approximate word count for generated body content.
 * @param {string} params.target - The target audience for the content.
 * @param {string[]} params.fieldsToGenerate - The content fields to generate like title, body etc.
 *
 * @returns {Object} - The generated content results and usage stats.
 * @returns {Object} results - The generated content for each requested field.
 * @returns {number} token - The total tokens used to generate the content.
 *
 * This uses the OpenAI SDK to interact with the OpenAI API.
 * It gets the prompt templates from the generator plugin's settings.
 * The prompts are parameterized based on the input params.
 * A conversation is constructed with the base prompt and prompt for each field.
 * The OpenAI API is called to generate completions for each prompt.
 * The results are extracted and usage tokens tracked.
 */

"use strict";

const OpenAI = require("openai");
const generatedFieldsList = [
    "title",
    "introduction",
    "body",
    "metaTitle",
    "metaDescription",
];

module.exports = ({strapi}) => ({
    async startGenerating({
                              topic,
                              language,
                              tone,
                              length,
                              target,
                              fieldsToGenerate,
                          }) {
        function replaceMagicKeys(prompt) {
            return prompt
                .replace("__TOPIC__", topic)
                .replace("__LANGUAGE__", language)
                .replace("__TONE__", tone)
                .replace("__LENGTH__", length)
                .replace("__TARGET__", target);
        }

        const settings = await strapi
            .plugin("generator-ai")
            .service("settings")
            .getSettings();

        const openai = new OpenAI({
            apiKey: strapi.plugin("generator-ai").config("openaiApiKey"),
        });

        const results = {};
        let totalTokens = 0;
        const messages = [
            {
                role: "system",
                content: replaceMagicKeys(settings.prompts.base),
            },
        ];

        const orderedFieldsToGenerate = fieldsToGenerate.sort(function (a, b) {
            return generatedFieldsList.indexOf(a) - generatedFieldsList.indexOf(b);
        });

        for (const field of orderedFieldsToGenerate) {
            switch (field) {
                case "title":
                    messages.push({
                        role: "user",
                        content: replaceMagicKeys(settings.prompts.title),
                    });
                    break;
                case "introduction":
                    messages.push({
                        role: "user",
                        content: replaceMagicKeys(settings.prompts.introduction),
                    });
                    break;
                case "body":
                    messages.push({
                        role: "user",
                        content: replaceMagicKeys(settings.prompts.body),
                    });
                    break;
                case "metaTitle":
                    messages.push({
                        role: "user",
                        content: replaceMagicKeys(settings.prompts.metaTitle),
                    });
                    break;
                case "metaDescription":
                    messages.push({
                        role: "user",
                        content: replaceMagicKeys(settings.prompts.metaDescription),
                    });
                    break;
            }

            const completion = await openai.chat.completions
                .create({
                    messages: messages,
                    model: settings.model,
                })
                .catch((err) => {
                    if (err instanceof OpenAI.APIError) {
                        console.log(err.status);
                        console.log(err.name);

                        console.log(err.headers);
                    } else {
                        console.log("error");
                        throw err;
                    }
                });

            console.log(completion);
            messages.push({
                role: "assistant",
                content: completion.choices[0].message.content,
            });

            results[field] = completion.choices[0].message.content;
            totalTokens = totalTokens + completion.usage.total_tokens;
        }

        return {
            results,
            token: totalTokens,
        };
    }
});

Valider les paramètres saisis par l’utilisateur

Maintenant que nous arrivons à faire des requêtes à OpenAI ou enregistrer nos réglages, il faut valider les champs saisis par l’utilisateur.

N’ayant aucune idée de comment faire cela, j’ai fouillé dans le code source de Strapi. Je me suis aperçu voir qu’ils utilisaient formik pour la gestion du formulaire yup pour la validation des données. Je ne connaissais pas du tout mais c’est super simple à utiliser. Vous pouvez voir un exemple ici.

Cependant, j’ai buté sur une petite difficulté avec l’utilisation du design system de Strapi. En effet, pour les autres champs qu’un input classique, formik ne fonctionnait pas. Normalement, il détecte automatiquement le onChange du champ, mais étant donné que c’est un composant personnalisé, ça ne fonctionnait. Mains, après un peu de réflexion, j’ai fini par trouver la solution. L’astuce a été d’utiliser la fonction formik.setFieldValue() comme ci-dessous :

<Field
    as={SingleSelect}
    hint={formatMessage({
        id: getTrad("Settings.Configuration.ContentLayout.prompt.model.hint"),
    })}
    label={formatMessage({
        id: getTrad("Settings.Configuration.ContentLayout.prompt.model.label"),
    })}
    name="model"
    required
    error={formik?.errors?.model && formik.errors.model}
    onClear={() => formik.setFieldValue("model", "")} // Faire manuellement l'action
    onChange={(value) => formik.setFieldValue("model", value)}  // Faire aussi ici manuellement l'action
>
    {openaiModelsList.map((item, index) => (
        <SingleSelectOption value={item} key={index}>
            {item}
        </SingleSelectOption>
    ))}
</Field>;

Appliquer le contenu généré aux champs de la page

Avec tout ce que l’on a fait précédemment, nous pouvons désormais générer du contenu. Mais, une fois que le contenu est généré, comment sait-on dans quel champ reporter ce contenu généré ?

Pour cela, nous avons créé une page de réglages qui permet de faire la correspondance des champs. Globalement, elle permet de dire : “Pour ce type de contenu, je veux que le champ généré content corresponde au champ existant richText”.

Interface de la correspondance des champs
Interface de la correspondance des champs

Cependant, une fois que nous avons fait cette correspondance, je ne trouvais pas le moyen de faire le rapprochement entre les champs de ma modal et ceux de l’éditeur de contenu.

Reporter une valeur générer dans le contenu
Reporter une valeur générer dans le contenu

J’ai d’abord pensé qu’il était possible de faire cela en ciblant un id avec document.getElementById() ou avec un query selector document.querySelector() mais je me suis rendu compte que Strapi n’expose, dans le code source, aucun moyen de cibler les champs voulu. Ce n’était donc pas possible de la faire de cette manière.

Or, je me suis aperçu que le component Input (celui qui est utilisé pour générer le bouton et la modal) reçoit des props qui contient la fonction onChange(). Et, cette fonction permet de modifier un champ dans l’éditeur de contenu.

onChange({
    target: {
        name, // Nom de l'input à modifier
        value, // Valeur à donner à l'input
        type, // (Optionnel) le type de champ
    },
});

Encore une fois, elle n’est documenté nul part. Il faut fouiller dans le code source et sur internet pour comprendre à quoi elle sert.

Il faut donc passer un nom de champ à la fonction onChange(). Ça tombe bien puisque nous les avons grâce à la correspondance de champ. Ils sont stockés dans la base de données. Nous avons donc juste à lire ces donner et à appeler la fonction onChange() pour modifier un-à-un les champs de la correspondance.

Appliquer le contenu généré au contenu
Appliquer le contenu généré au contenu

Traduire le plugin

Finalement, la dernière chose que nous avons fait a été de traduire le plugin. D’un point de vue fun, c’est la pire chose à faire. Mais, bon, c’est nécessaire et pratique une fois que c’est fait.

Avec Strapi, cela reste relativement simple de traduire un plugin. En effet, ils utilisent la très connu librairie react-intl.

Il suffit alors de faire un import import { useIntl } from "react-intl" et récupérer la fonction const { formatMessage } = useIntl(). Ensuite, nous pouvons utiliser la fonction formatMessage() en passant un objet contenant un id et un defaultMessage. Pour lui passer l’id, nous pouvons utiliser la fonction getTrad() (décrite plus haut dans l’article, au niveau de l’architecture du plugin). Cette fonction va récupérer la clé de traduction correspondante dans notre fichier de langue (ex: fr.json)

formatMessage({
  id: getTrad("global.notification.generation.get.warning.title"),
  defaultMessage: "Attention il y a une erreur"
})

Pour faire vos traductions, je vous conseille de faire terminer totalement une langue, puis vous le donner à ChatGPT et vous lui demandez de le traduire dans la langue que vous souhaitez. C’est magique !

Conclusion

N’ayant jamais touché à Strapi auparavant, et encore moins développé un plugin, j’ai trouvé l’exercice très instructif.

Néanmoins, sur les 47 heures passés à développé le plugin (dont beaucoup d’heures d’apprentissage), je pense que j’ai dû perdre au moins 7 heures à cause d’une documentation trop légère, voire inexistante. Cela créé une véritable barrière à l’entrée, qui, je pense, est préjudiciable à la croissance du nombre de plugin Strapi. Toutefois, l’expérience développeur est bonne, l’architecture est simple, les API sont biens faites et faciles à utiliser.

Je compte mettre le plugin en open source prochainement. J’attends d’abord de l’utiliser dans des cas réels pour peaufiner ce qui est nécessaire. L’objectif est de le mettre sur le store de plugin Strapi. Pensez donc à me suivre sur X/X pour être tenu au courant.