Créer un type de contenu personnalisé (CPT) dans WordPress
Téléchargements
Tu veux créer un plugin WordPress moderne qui affiche une FAQ dynamique sans rechargement de page ?
Dans cet article, on va construire ensemble un plugin propre, modulaire et performant, avec un chargement asynchrone via la REST API, sans jQuery, et 100 % compatible avec les bonnes pratiques WordPress (sécurité, i18n, accessibilité, performances).
Objectifs du plugin :
Créer un Custom Post Type (CPT) dédié pour stocker les questions/réponses.
Créer une Route REST pour exposer les données.
Créer un Shortcode pour afficher la FAQ où tu veux sur le site.
Réaliser un Chargement asynchrone en JavaScript Vanilla.
Afficher un skeleton Loader façon LinkedIn pour un rendu fluide et moderne.
Préparer un Text domain pour anticipé une traduction dans d’autres langues.
À la fin de l’article, tu auras non seulement un plugin fonctionnel, mais aussi les bonnes pratiques pour créer tous tes futurs plugins comme un pro.
C’est dans le dossier /wp-content/plugins que WordPress pourra détecter la création de ton plugin. À l’intérieur de ce répertoire, crée un nouveau dossier nommé cwp-faq et ajoute-y la structure suivante :
cwp-faq/
├─ assets/
│ ├─ css/
│ │ └─ style.css ← styles front-end
│ └─ js/
│ └─ script.js ← chargement asynchrone
├─ classes/
│ ├─ class-api.php ← REST API
│ ├─ class-cpt.php ← Custom Post Type (CPT)
│ └─ class-cwp-faq.php ← shortcode + assets
├─ languages/
└─ cwp-faq.php ← fichier principal
Cette structure t’assure un plugin bien organisé : les classes PHP pour la logique, les assets pour le front, et le fichier principal pour initialiser le tout.
Le fichier cwp-faq.php est le point de départ de ton plugin : WordPress l’utilise pour détecter, charger et initialiser ton code.
Il contient les informations du plugin, les constantes globales, les inclusions de classes, et le bootstrap qui lance ton système.
C’est le seul fichier que WordPress lit directement lors de l’activation du plugin, tout le reste est organisé et chargé depuis ici.
Pour que WordPress reconnaisse ton plugin, le fichier principal doit commencer par un en-tête de métadonnées bien spécifique.
Cet en-tête, placé tout en haut du fichier cwp-faq.php, permet à WordPress d’afficher le nom, la description, la version, l’auteur, et d’autres informations dans l’administration des extensions.
Voici l’exemple à coller au tout début du fichier :
<?php
/**
* Plugin Name: CustomWP FAQ
* Description: Un plugin minimaliste de démonstration pour gérer et afficher une FAQ en asynchrone.
* Version: 1.0.0
* Author: William Malbos
* Author URI: https://www.wmalbos.fr
* Text Domain: cwp-faq
* Domain Path: /languages
* License: GPL-3.0-or-later
* License URI: http://www.gnu.org/licenses/gpl-3.0.html
*/
Juste après l’en-tête du plugin, ajoute une protection pour empêcher l’accès direct au fichier (vérification d’ABSPATH), puis déclare les constantes qui serviront partout dans ton code :
CWP_FAQ_VERSION : version du plugin, pratique pour le cache-busting des CSS/JS.
CWP_FAQ_TEXT_DOMAIN : text-domain centralisé pour l’i18n (traductions).
CWP_FAQ_PATH : chemin serveur absolu du plugin (utile pour les includes et accès fichiers).
CWP_FAQ_URL : URL publique du plugin (utile pour ajouter le CSS/JS ou référencer des images).
// Sécurité - Prévention d'accès direct au fichier
defined('ABSPATH') or exit;
// Définition des constantes
define("CWP_FAQ_VERSION", '1.0.0');
define("CWP_FAQ_TEXT_DOMAIN", 'cwp-faq');
define('CWP_FAQ_PATH', __DIR__ . '/');
define("CWP_FAQ_URL", plugin_dir_url(__FILE__));
Garde la version synchronisée avec l’en-tête du plugin dans le fichier cwp-faq.php et utilise-la quand tu enqueues tes assets pour forcer le rafraîchissement côté navigateur.
Ensuite, charge les fichiers de classes du plugin que l’on va créer. On utilise require_once pour éviter les inclusions multiples et la constante CWP_FAQ_PATH pour des chemins fiables quel que soit l’environnement, et éviter de faire des copier/coller de plugin_dir_url(__FILE__):
// Inclusions des fichiers nécessaires
require_once CWP_FAQ_PATH . 'classes/class-cpt.php';
require_once CWP_FAQ_PATH . 'classes/class-api.php';
require_once CWP_FAQ_PATH . 'classes/class-cwp-faq.php';
Pour terminer le fichier principal, on “branche” l’initialisation du plugin sur le hook plugins_loaded.
À ce moment-là, WordPress a chargé les autres extensions et les fonctions i18n, c’est l’endroit idéal pour :
Charger les traductions via load_plugin_textdomain(...).
Instancier la classe principale CWP_FAQ, qui va enregistrer le CPT, la route REST, le shortcode et enqueuer les assets.
// Bootstrap main plugin
add_action('plugins_loaded', static function () {
// Traductions
load_plugin_textdomain(CWP_FAQ_TEXT_DOMAIN, false, dirname(plugin_basename(__FILE__)).'/languages');
// Initialisation du plugin
new CWP_FAQ();
});
Si tu n’as pas encore de traductions, tu peux laisser l’appel en place (il ne casse rien) ou le commenter jusqu’à l’ajout des fichiers de traduction dans le dossier /languages.
Maintenant que notre fichier principal est prêt et que le plugin peut être détecté par WordPress, on va passer à la mise en place de la classe principale.
Cette classe servira de point d’entrée pour le front-end du plugin : elle se chargera d’enregistrer et de charger les fichiers CSS et JavaScript nécessaires à l’affichage de la FAQ.
On crée donc une nouvelle classe CWP_FAQ qui, pour l’instant, aura pour rôle unique de charger les assets front du plugin au bon moment du cycle WordPress.
<?php
final class CWP_FAQ
{
public function __construct()
{
add_action('wp_enqueue_scripts', [$this, 'enqueue_assets']);
}
public function enqueue_assets(): void
{
if (is_admin()) return;
wp_enqueue_style('cwp-faq-css', CWP_FAQ_URL . 'assets/css/style.css', [], CWP_FAQ_VERSION);
wp_enqueue_script('cwp-faq-js', CWP_FAQ_URL . 'assets/js/script.js', [], CWP_FAQ_VERSION, true);
}
}
[$this, 'enqueue_assets'] est un callable PHP qui dit à WordPress d’appeler la méthode d’instance enqueue_assets de l’objet courant ($this) quand le hook se déclenche.
En bref : “au moment de wp_enqueue_scripts, il exécute enqueue_assets() sur cette classe.”
Maintenant que la classe charge correctement les fichiers CSS et JS, on va lui ajouter une méthode pour afficher le shortcode du plugin.
Cette méthode render_shortcode() sera appelée lorsque l’utilisateur insère [cwp_faq] dans une page ou un article depuis l’administration.
public function render_shortcode($atts = []): string
{
$atts = shortcode_atts([
'per_page' => 50,
'order' => 'ASC',
], $atts, 'cwp_faq');
// Définition du conteneur principal
$html = '<div class="cwp-faq" data-cwp-faq="1"'
. ' data-per-page="' . (int)$atts['per_page'] . '"'
. ' data-order="' . esc_attr($atts['order']) . '"';
$html .= $this->get_skeleton_markup();
$html .= '</div>';
return $html;
}
Elle commence par définir les attributs du shortcode avec leurs valeurs par défaut (per_page et order), puis génère un conteneur HTML <div class="cwp-faq"> muni de data-attributes qui serviront plus tard au script JavaScript pour charger les données via l’API REST.
À l’intérieur de ce conteneur, on insère un skeleton loader grâce à la méthode get_skeleton_markup() que l’on fera un peu plus tard.
Ce skeleton permettra d’afficher une structure temporaire pendant le chargement asynchrone, offrant ainsi une meilleure perception de vitesse et une expérience plus fluide pour l’utilisateur.
Pour que WordPress sache quoi exécuter lorsque le shortcode [cwp_faq] est utilisé, on doit maintenant l’enregistrer dans le constructeur de notre classe.
Cette instruction indique à WordPress que, lorsqu’il rencontre le shortcode [cwp_faq] dans une page ou un article, il doit appeler la méthode render_shortcode() de notre classe.
Ainsi, c’est cette méthode qui générera le conteneur HTML et le skeleton loader au moment de l’affichage.
public function __construct()
{
// Ajout du shortcode => [cwp_faq]
add_shortcode('cwp_faq', [$this, 'render_shortcode']);
// Chargement des assets
add_action('wp_enqueue_scripts', [$this, 'enqueue_assets']);
}
Ensuite, il nous reste à créer cette fonction get_skeleton_markup pour générer des items fantômes à la manière de LinkedIn.
private function get_skeleton_markup(int $count = 6): string
{
$out = '<div class="cwp-faq-skeleton" aria-hidden="true">';
for ($i = 0; $i < $count; $i++) {
$out .= '<div class="cwp-faq-skel-item">'
. '<div class="cwp-faq-skel-line cwp-faq-skel-line--title"></div>'
. '<div class="cwp-faq-skel-line"></div>'
. '<div class="cwp-faq-skel-line cwp-faq-skel-line--short"></div>'
. '</div>';
}
$out .= '</div>';
return $out;
}
Depuis l’administration WordPress, crée (ou édite) une page, puis ajoute le shortcode [cwp_faq] dans le contenu.
Enregistre et prévisualise la page : tu dois voir le skeleton loader s’afficher d’abord, puis la FAQ se chargera dès que le JavaScript récupérera les données.
Pour charger la FAQ en asynchrone, on doit exposer une route spécifique comme /cwp/v1/faqs qui renverra les données au format JSON des différents items à afficher.
On crée donc une classe CWP_FAQ_API qui va enregistrer notre route lors de l’événement rest_api_init. Au passage, on va utiliser une constante API_PREFIX pour créer un préfix à nos différentes routes si l’on souhaite en rajouter.
Pour commencer, on met en place une version minimale qui répond “Youpi !” — histoire de vérifier rapidement que tout fonctionne avant d’implémenter la vraie logique.
<?php
final class CWP_FAQ_API
{
public const API_PREFIX = 'cwp/v1';
public static function boot(): void
{
// Quand la REST API s'initialise, on déclare nos routes.
add_action('rest_api_init', [self::class, 'register_rest_routes']);
}
public static function register_rest_routes(): void
{
register_rest_route(self::API_PREFIX, '/faqs', [
'methods' => WP_REST_Server::READABLE, // Méthode HTTP autorisée : READABLE = GET
'callback' => [self::class, 'get_list'], // Execute la fonction get_list quand la route /faqs est appelée
'permission_callback' => '__return_true', // Permissions : ici lecture publique (pas de restriction d'authentification)
]);
}
public static function get_list(WP_REST_Request $request): WP_REST_Response
{
// Réponse minimale pour valider le fonctionnement de la route.
return new WP_REST_Response(['items' => 'Youpi !'], 200);
}
}
Dans enqueue_assets(), on ajoute wp_add_inline_script pour exposer des valeurs PHP au front avant l’exécution de notre script. Concrètement, on crée un objet global window.CWP_FAQ_SETTINGS contenant :
endpoint : l’URL complète de la route REST, générée proprement via rest_url(...) puis sécurisée avec esc_url_raw(...).
nonce : un jeton de sécurité (wp_create_nonce('wp_rest')) utile si, plus tard, certaines requêtes nécessitent une vérification côté API.
Le troisième paramètre 'before' garantit que ces données sont disponibles dès le chargement de cwp-faq-js.
Tu pourrais aussi utiliser wp_localize_script(), mais wp_add_inline_script(...) reste ici plus explicite pour pousser un objet de configuration JSON (wp_json_encode).
public function enqueue_assets(): void
{
if (is_admin()) return;
wp_enqueue_style('cwp-faq-css', CWP_FAQ_URL . 'assets/css/style.css', [], CWP_FAQ_VERSION);
wp_enqueue_script('cwp-faq-js', CWP_FAQ_URL . 'assets/js/script.js', [], CWP_FAQ_VERSION, true);
wp_add_inline_script(
'cwp-faq-js',
'window.CWP_FAQ_SETTINGS = ' . wp_json_encode([
'endpoint' => esc_url_raw(rest_url(CWP_FAQ_API::API_PREFIX . '/faqs')),
'nonce' => wp_create_nonce('wp_rest'),
]) . ';',
'before'
);
}
Pour tester notre API, on ajoute dans le JavaScript un appel fetch vers notre route REST, en récupérant l’URL directement depuis CWP_FAQ_SETTINGS.endpoint (injecté par PHP).
(function () {
const settings = window.CWP_FAQ_SETTINGS || {};
const container = document.querySelector('[data-cwp-faq="1"]');
if (!settings.endpoint || !container) return;
fetch(settings.endpoint, {
headers: {'X-WP-Nonce': settings.nonce || ''}
})
.then(res => res.json())
.then(data => {
console.log(data)
})
.catch(() => {
container.textContent = 'Erreur lors du chargement.';
});
})();
Si la réponse “Youpi !” s’affiche dans la console, c’est que le shortcode, le chargement des assets, et la route REST sont bien tous connectés ensemble.
Dans ce fichier, on déclare le type de contenu personnalisé qui va stocker chaque entrée de FAQ (une question + sa réponse).
<?php
final class CWP_FAQ_CPT
{
// Nom du Custom Post Type (CPT) pour simplifier les appels dans le code
public const CPT_QUESTION_NAME = 'cwp_faq';
public static function boot(): void
{
add_action('init', [self::class, 'register_post_type']);
}
public static function register_post_type(): void
{
register_post_type(self::CPT_QUESTION_NAME, [
// Libellés de l’administration (tous traduisibles via le text-domain du plugin)
'labels' => [
'name' => __('FAQ', CWP_FAQ_TEXT_DOMAIN),
'singular_name' => __('Question', CWP_FAQ_TEXT_DOMAIN),
'add_new' => __('Ajouter', CWP_FAQ_TEXT_DOMAIN),
'add_new_item' => __('Ajouter une question', CWP_FAQ_TEXT_DOMAIN),
'edit_item' => __('Modifier la question', CWP_FAQ_TEXT_DOMAIN),
'new_item' => __('Nouvelle question', CWP_FAQ_TEXT_DOMAIN),
'view_item' => __('Voir la question', CWP_FAQ_TEXT_DOMAIN),
'search_items' => __('Rechercher', CWP_FAQ_TEXT_DOMAIN),
'not_found' => __('Aucun résultat', CWP_FAQ_TEXT_DOMAIN),
'not_found_in_trash' => __('Aucun résultat dans la corbeille', CWP_FAQ_TEXT_DOMAIN),
'menu_name' => __('FAQ', CWP_FAQ_TEXT_DOMAIN),
],
// Visibilité & accès
'public' => false, // Pas de pages publiques générées (on affichera via shortcode + REST)
'show_ui' => true, // Affiché et éditable dans l’admin
'show_in_menu' => true, // Ajoute une entrée de menu
'menu_position' => 25, // Position approximative dans le menu admin
'menu_icon' => 'dashicons-editor-help', // Icône cohérente (point d’interrogation)
// Capacités & comportement
'supports' => ['title', 'editor', 'page-attributes'], // title=question, editor=réponse, page-attributes=menu_order
'has_archive' => false, // Pas d’archive front
'rewrite' => false, // Pas de permaliens/front-end pour ce CPT
'capability_type'=> 'post', // Capacités analogues aux articles (simple à gérer)
]);
}
}
__('FAQ', CWP_FAQ_TEXT_DOMAIN) appelle la fonction de traduction de WordPress :
CWP_FAQ_TEXT_DOMAIN indique le text-domain du plugin pour aller chercher la traduction dans tes fichiers .po/.mo. Si aucune traduction n’est trouvée ou que les fichiers n’existent pas, WordPress retourne la chaîne d’origine.
Il faut penser à rajouter l’appel à cette classe dans le __construct de notre classe CWP_FAQ.
public function __construct()
{
CWP_FAQ_CPT::boot();
CWP_FAQ_API::boot();
// Shortcode [cwp_faq]
add_shortcode('cwp_faq', [$this, 'render_shortcode']);
// Assets front (uniquement si shortcode présent dans le contenu)
add_action('wp_enqueue_scripts', [$this, 'enqueue_assets']);
}
Complétons la méthode get_list() de notre CWP_FAQ_API pour qu’elle renvoie correctement les éléments.
Elle va lire les paramètres (per_page, order, search), interroger le CPT de la FAQ de façon légère (IDs uniquement, sans comptage global), puis retourner un JSON propre sous la forme { id, question, answer }, avec un contenu filtré par les hooks WordPress et assaini (wp_kses_post).
public static function get_list(WP_REST_Request $request): WP_REST_Response
{
// On récupère uniquement des contenus publiés, avec pagination et tri contrôlés par les params REST.
$args = [
'post_type' => CWP_FAQ_CPT::CPT_QUESTION_NAME, // CPT des FAQ
'post_status' => 'publish', // on n’expose que le publié
'posts_per_page' => (int) $request->get_param('per_page'), // pagination (sécurisé en int)
'orderby' => ['menu_order' => 'ASC', 'title' => 'ASC'], // ordre stable: d’abord menu_order puis titre
'order' => $request->get_param('order') === 'DESC' ? 'DESC' : 'ASC', // sens du tri, borné à ASC/DESC
's' => sanitize_text_field((string) $request->get_param('search')), // recherche plein texte (optionnelle)
'no_found_rows' => true, // perf: pas de calcul du total
'fields' => 'ids', // perf: on ne récupère que les IDs
];
// Exécute la requête la plus légère possible (IDs uniquement).
$ids = get_posts($args);
// Transforme chaque ID en objet simple exploitable côté front.
$items = [];
foreach ($ids as $post_id) {
$items[] = [
'id' => (int) $post_id,
'question' => get_the_title($post_id), // le titre sert de question
'answer' => wp_kses_post(apply_filters('the_content', get_post_field('post_content', $post_id))),// le contenu sert de réponse
];
}
// Retourne une réponse REST standardisée au format JSON
return new WP_REST_Response(['items' => $items], 200);
}
Parfait ! Nous avons presque terminé, si tout s’est bien passé dans la console tu devrai désormais avoir les items d’affichés
On peut alors compléter notre JavaScript proprement pour remplacer nos squelettes fantômes avec une belle structure pour nos questions / réponses.
(function () {
'use strict';
const SETTINGS = window.CWP_FAQ_SETTINGS || {};
const ENDPOINT = SETTINGS.endpoint || '';
function buildUrl(container) {
const params = new URLSearchParams();
const per = container.getAttribute('data-per-page') || '50';
const order = container.getAttribute('data-order') || 'ASC';
if (per) params.set('per_page', per);
if (order) params.set('order', order);
return ENDPOINT + (ENDPOINT.includes('?') ? '&' : '?') + params.toString();
}
/**
* Rendu du skeleton (initialement injecté côté PHP pour un meilleur LCP)
*/
function renderSkeleton(container) {
container.classList.add('cwp-faq--loading');
}
/**
* Supprime le skeleton
*/
function clearSkeleton(container) {
const skel = document.querySelector('.cwp-faq-skeleton', container);
if (skel) skel.remove();
container.classList.remove('cwp-faq--loading');
}
/**
* Rendu d’un message d’erreur lors d’un problème de chargement
*/
function renderError(container, message) {
clearSkeleton(container);
container.innerHTML =
'<p class="cwp-faq-msg" role="alert">' +
String(message || 'Une erreur est survenue. Merci de réessayer.') +
'</p>';
}
/**
* Rendu de la liste de questions/réponses
*/
function renderItems(container, items) {
clearSkeleton(container);
if (!items || !items.length) {
container.innerHTML = '<p class="cwp-faq-msg">Aucune question disponible pour le moment.</p>';
return;
}
container.innerHTML = `<div class="cwp-faq-list">` +
items.map(item => `
<details class="cwp-faq-item">
<summary class="cwp-faq-q">${item.question}</summary>
<div class="cwp-faq-a">${item.answer}</div>
</details>
`).join('') +
`</div>`;
// Comportement "accordéon" : on ferme les autres quand on en ouvre un
container.addEventListener('toggle', (e) => {
const target = e.target;
if (target && target.tagName === 'details' && target.open) {
container
.querySelectorAll('.cwp-faq-item[open]')
.forEach((node) => {
if (node !== target) node.open = false;
});
}
});
}
/**
* Récupération des données et affichage du rendu
*/
async function hydrate(container) {
if (!ENDPOINT) {
renderError(container, 'Endpoint REST introuvable.');
return;
}
renderSkeleton(container);
try {
const url = buildUrl(container);
const res = await fetch(url, {
headers: {'X-WP-Nonce': SETTINGS.nonce || ''},
credentials: 'same-origin',
});
if (!res.ok) throw new Error('HTTP ' + res.status);
const json = await res.json();
renderItems(container, json && json.items ? json.items : []);
} catch (err) {
console.error('[FAQ] fetch error', err);
renderError(container, 'Impossible de charger la FAQ.');
}
}
document.addEventListener('DOMContentLoaded', function () {
document
.querySelectorAll('[data-cwp-faq="1"]')
.forEach(hydrate);
});
})();
Si tout est bien en place, la page affiche désormais nos questions sous forme de dropdowns : chaque entrée apparaît dans un bloc <details>/<summary> ouvrable, et l’accordéon ferme automatiquement les autres items lorsqu’on en ouvre un.
Améliorons un peu notre code avec une vérification conditionnelle.
On récupère le contenu courant via global $post, puis on teste la présence du shortcode [cwp_faq] avec has_shortcode.
S’il n’y a aucun post ou que le shortcode est absent, on ne charge pas nos assets — ainsi, aucun CSS/JS inutile n’est servi sur les autres pages.
public function enqueue_assets(): void
{
if (is_admin()) return;
global $post;
// Pas de post ou pas de shortcode [cwp_faq] dans le contenu => on ne charge rien
if (!$post || !has_shortcode((string)$post->post_content, 'cwp_faq')) return;
wp_enqueue_style('cwp-faq-css', CWP_FAQ_URL . 'assets/css/style.css', [], CWP_FAQ_VERSION);
wp_enqueue_script('cwp-faq-js', CWP_FAQ_URL . 'assets/js/script.js', [], CWP_FAQ_VERSION, true);
wp_add_inline_script(
'cwp-faq-js',
'window.CWP_FAQ_SETTINGS = ' . wp_json_encode([
'endpoint' => esc_url_raw(rest_url(CWP_FAQ_API::API_PREFIX . '/faqs')),
'nonce' => wp_create_nonce('wp_rest'),
]) . ';',
'before'
);
}
Tu as maintenant une base saine : un CPT propre, une route REST qui répond vite, et un rendu front dynamique, accessible et réactif. À partir de là, tu peux faire évoluer le plugin sans tout casser.
Par exemple, ajoute une taxonomie pour organiser les questions et filtre l’API par catégorie, améliore l’UX avec une recherche instantanée côté front, ou introduis une gestion de cache (par exemple avec des transients) pour soulager la base si la FAQ grossit. Côté SEO, le JSON-LD (Schema.org: FAQPage/Question/Answer) fera une vraie différence.
Sur le plan code, pense à un autoloader (PSR-4) quand le plugin grandit, à des tests unitaires simples sur la construction des réponses API, et à un passage PHPCS pour rester aligné avec les standards WordPress. Enfin, ouvre la porte au theming avec quelques variables CSS (espacements, radius, couleurs) pour adapter la FAQ à n’importe quel thème sans toucher au cœur du plugin.
Plan du chapitre
Découvre d’autres articles de la même catégorie pour enrichir tes connaissances et ton inspiration.