Générer des images Open Graph dynamiques via un endpoint REST WordPress (avec cache disque et invalidation)
Créez des images Open Graph dynamiques pour WordPress via un endpoint REST, avec cache disque, invalidation automatique et fallback GD/Imagick. Tutoriel.
Sommaire
Générer des images Open Graph dynamiques via un endpoint REST WordPress (avec cache disque et invalidation)
Les aperçus sociaux efficaces dopent le CTR. Dans ce guide, on construit un plugin WordPress minimal qui génère des images Open Graph (OG) à la volée via un endpoint REST, avec rendu via Imagick ou GD, cache sur disque, entêtes HTTP solides (ETag/Last-Modified) et invalidation automatique à la mise à jour des contenus. À la fin, vous publierez des pages avec des balises OG image qui pointent vers une URL dynamique rapide, compatible avec les robots sociaux.
Objectif
Nous allons créer un plugin qui expose un endpoint REST public retournant une image PNG Open Graph pour chaque article. Le rendu utilise d’abord Imagick (si disponible), sinon bascule sur GD. Les images générées sont stockées dans un cache disque et invalidées automatiquement à chaque mise à jour du contenu. Par exemple, pour l’article ID 123, l’URL sera:
Le rendu affichera un fond, un bandeau, le titre automatiquement word-wrap, le nom du site et éventuellement la catégorie principale. Vous pourrez voir immédiatement l’effet dans les partageurs sociaux (Facebook, X/Twitter, LinkedIn).
Objectif et prérequis
L’objectif est de produire pour chaque article une image OG 1200x630 à l’URL /wp-json/og/v1/post/{id}, puis d’injecter cette URL dans les balises og:image du head. Le plugin stockera ses fichiers dans wp-content/uploads/og et réutilisera les images déjà générées tant que le contenu n’a pas changé.
Côté prérequis: il faut un WordPress 6.x avec PHP 8.1 ou supérieur. L’extension Imagick ou GD doit être disponible pour le rendu. L’instance doit pouvoir écrire dans le système de fichiers, en particulier dans le dossier uploads. Les permaliens et l’API REST doivent être actifs. Enfin, on suivra une organisation simple: un dossier de plugin og-image-generator/ avec un fichier principal og-image-generator.php et un sous-dossier inc/ qui contiendra la logique (rendu, REST, cache, hooks, CLI).
Bootstrap du plugin
Commencez par créer le dossier du plugin:
- wp-content/plugins/og-image-generator/
- wp-content/plugins/og-image-generator/inc/
Dans og-image-generator.php, placez l’en-tête standard, chargez les includes, définissez les constantes et, à l’activation, préparez le répertoire de cache avec un .htaccess qui sert les PNG efficacement.
Exemple complet du fichier principal:
<?php
/**
* Plugin Name: OG Image Generator
* Description: Génère des images Open Graph dynamiques via un endpoint REST, avec cache disque et invalidation.
* Version: 1.0.0
* Author: Votre Nom
* Requires PHP: 8.1
* Requires at least: 6.0
*/
if (!defined('ABSPATH')) {
exit;
}
// Définitions de base (ajustées à l’environnement)
define('OG_IMG_WIDTH', 1200);
define('OG_IMG_HEIGHT', 630);
// On résout les chemins d’upload dynamiquement
add_action('plugins_loaded', function () {
$uploads = wp_upload_dir(null, false);
if (!empty($uploads['basedir']) && !empty($uploads['baseurl'])) {
define('OG_IMG_DIR', wp_normalize_path($uploads['basedir'] . '/og'));
define('OG_IMG_URL', trailingslashit($uploads['baseurl']) . 'og');
} else {
// Fallback minimal : évite les notices si wp_upload_dir échoue très tôt
define('OG_IMG_DIR', WP_CONTENT_DIR . '/uploads/og');
define('OG_IMG_URL', content_url('uploads/og'));
}
});
// Autoload minimal des includes
add_action('plugins_loaded', function () {
$base = __DIR__ . '/inc/';
require_once $base . 'cache.php';
require_once $base . 'render.php';
require_once $base . 'rest.php';
require_once $base . 'hooks.php';
if (defined('WP_CLI') && WP_CLI) {
require_once $base . 'cli.php';
}
});
// Activation: créer le dossier et .htaccess
register_activation_hook(__FILE__, function () {
// Force résolution de OG_IMG_DIR/URL
if (!defined('OG_IMG_DIR') || !defined('OG_IMG_URL')) {
$uploads = wp_upload_dir(null, false);
if (!empty($uploads['basedir']) && !empty($uploads['baseurl'])) {
if (!defined('OG_IMG_DIR')) define('OG_IMG_DIR', wp_normalize_path($uploads['basedir'] . '/og'));
if (!defined('OG_IMG_URL')) define('OG_IMG_URL', trailingslashit($uploads['baseurl']) . 'og');
} else {
if (!defined('OG_IMG_DIR')) define('OG_IMG_DIR', WP_CONTENT_DIR . '/uploads/og');
if (!defined('OG_IMG_URL')) define('OG_IMG_URL', content_url('uploads/og'));
}
}
if (!file_exists(OG_IMG_DIR)) {
wp_mkdir_p(OG_IMG_DIR);
}
// .htaccess: long cache public (les URLs sont versionnées)
$ht = <<<HT
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType image/png "access plus 30 days"
</IfModule>
<IfModule mod_headers.c>
Header set Cache-Control "public, max-age=2592000, immutable"
</IfModule>
HT;
$htPath = OG_IMG_DIR . '/.htaccess';
if (!file_exists($htPath)) {
@file_put_contents($htPath, $ht);
}
// Cron de nettoyage hebdo
if (!wp_next_scheduled('og_image_cleanup_cron')) {
wp_schedule_event(time() + 3600, 'weekly', 'og_image_cleanup_cron');
}
});
// Déclare la récurrence "weekly" si absente
add_filter('cron_schedules', function ($schedules) {
if (!isset($schedules['weekly'])) {
$schedules['weekly'] = [
'interval' => 7 * DAY_IN_SECONDS,
'display' => __('Once Weekly', 'og-image-generator'),
];
}
return $schedules;
});
// Désactivation: on garde le cache mais nettoie le cron
register_deactivation_hook(__FILE__, function () {
$ts = wp_next_scheduled('og_image_cleanup_cron');
if ($ts) {
wp_unschedule_event($ts, 'og_image_cleanup_cron');
}
});
Créez ensuite un sous-dossier inc/ qui va accueillir la logique: cache.php (clé/version/fichiers), render.php (rendu Imagick/GD), rest.php (endpoint), hooks.php (invalidation, head, cleanup), cli.php (commande WP-CLI).
Rendu d’image (Imagick puis fallback GD)
Le rendu s’isole dans une fonction render_og_image($post, $destPath). Elle choisit Imagick si disponible, sinon GD. Elle dessine un fond homogène, un bandeau coloré, le titre word-wrap avec ellipse, le nom du site, et la catégorie principale. On met une police .ttf (par exemple Inter-SemiBold.ttf) dans le plugin et on vérifie son existence avant usage.
Créez inc/render.php:
<?php
if (!defined('ABSPATH')) exit;
function ogi_get_font_path(): ?string {
// Placez une police dans le dossier du plugin (ex: Inter-SemiBold.ttf)
$font = plugin_dir_path(__FILE__) . '../assets/Inter-SemiBold.ttf';
if (file_exists($font)) {
return realpath($font);
}
return null; // On laissera Imagick/GD choisir une police par défaut
}
function ogi_primary_category_name(WP_Post $post): ?string {
$cats = get_the_category($post->ID);
if (!empty($cats)) {
return $cats[0]->name;
}
return null;
}
function ogi_wrap_text_imagick(ImagickDraw $draw, string $text, int $maxWidth, Imagick $img): string {
$words = preg_split('/\s+/u', trim($text));
$lines = [];
$current = '';
foreach ($words as $w) {
$try = $current === '' ? $w : ($current . ' ' . $w);
$metrics = $img->queryFontMetrics($draw, $try);
if ($metrics['textWidth'] > $maxWidth && $current !== '') {
$lines[] = $current;
$current = $w;
} else {
$current = $try;
}
}
if ($current !== '') {
$lines[] = $current;
}
// Limiter à 3 lignes max et ellipser
if (count($lines) > 3) {
$lines = array_slice($lines, 0, 3);
}
// Ellipse si la dernière ligne dépasse
$last = end($lines);
$metrics = $img->queryFontMetrics($draw, $last);
if ($metrics['textWidth'] > $maxWidth) {
// Tronque progressivement
for ($i = mb_strlen($last, 'UTF-8'); $i > 0; $i--) {
$candidate = mb_substr($last, 0, $i, 'UTF-8') . '…';
$m = $img->queryFontMetrics($draw, $candidate);
if ($m['textWidth'] <= $maxWidth) {
$lines[count($lines)-1] = $candidate;
break;
}
}
}
return implode("\n", $lines);
}
function ogi_wrap_text_gd(string $text, string $font, int $fontSize, int $maxWidth): string {
$words = preg_split('/\s+/u', trim($text));
$lines = [];
$current = '';
$measure = function ($txt) use ($font, $fontSize) {
$box = imagettfbbox($fontSize, 0, $font, $txt);
$width = abs($box[2] - $box[0]);
return $width;
};
foreach ($words as $w) {
$try = $current === '' ? $w : ($current . ' ' . $w);
if ($measure($try) > $maxWidth && $current !== '') {
$lines[] = $current;
$current = $w;
} else {
$current = $try;
}
}
if ($current !== '') {
$lines[] = $current;
}
if (count($lines) > 3) {
$lines = array_slice($lines, 0, 3);
}
$lastIdx = count($lines) - 1;
if ($lastIdx >= 0 && $measure($lines[$lastIdx]) > $maxWidth) {
$line = $lines[$lastIdx];
for ($i = mb_strlen($line, 'UTF-8'); $i > 0; $i--) {
$candidate = mb_substr($line, 0, $i, 'UTF-8') . '…';
if ($measure($candidate) <= $maxWidth) {
$lines[$lastIdx] = $candidate;
break;
}
}
}
return implode("\n", $lines);
}
/**
* Rendu principal OG (1200x630)
*/
function render_og_image(WP_Post $post, string $destPath): bool {
$title = html_entity_decode(get_the_title($post), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$siteName = html_entity_decode(get_bloginfo('name'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$category = ogi_primary_category_name($post);
$bgColor = '#0b1221';
$accent = '#4f46e5'; // indigo
$textCol = '#ffffff';
$subCol = '#a9b1c7';
$fontPath = ogi_get_font_path();
// Essai Imagick d'abord
if (class_exists('Imagick')) {
try {
$img = new Imagick();
$img->newImage(OG_IMG_WIDTH, OG_IMG_HEIGHT, new ImagickPixel($bgColor));
$img->setImageFormat('png');
$draw = new ImagickDraw();
if ($fontPath) {
$draw->setFont($fontPath);
}
$draw->setFillColor(new ImagickPixel($textCol));
$draw->setTextAntialias(true);
$draw->setFontSize(60);
$draw->setGravity(Imagick::GRAVITY_NORTHWEST);
// Bandeau accent
$band = new ImagickDraw();
$band->setFillColor(new ImagickPixel($accent));
$band->rectangle(0, 0, OG_IMG_WIDTH, 16);
$img->drawImage($band);
// Titre (zones)
$padding = 64;
$maxWidth = OG_IMG_WIDTH - 2 * $padding;
// Wrap
$wrapped = ogi_wrap_text_imagick($draw, $title, $maxWidth, $img);
// Titre
$img->annotateImage($draw, $padding, 160, 0, $wrapped);
// Sous-ligne: site + catégorie
$metaDraw = new ImagickDraw();
if ($fontPath) {
$metaDraw->setFont($fontPath);
}
$metaDraw->setFillColor(new ImagickPixel($subCol));
$metaDraw->setFontSize(28);
$metaDraw->setTextAntialias(true);
$metaText = $siteName;
if ($category) {
$metaText .= ' • ' . $category;
}
$img->annotateImage($metaDraw, $padding, OG_IMG_HEIGHT - 64, 0, $metaText);
// Compression/optimisation
$img->setImageCompression(Imagick::COMPRESSION_ZIP);
$img->setImageCompressionQuality(95);
$img->stripImage();
// Écriture
$ok = $img->writeImage($destPath);
$img->destroy();
return (bool)$ok;
} catch (Throwable $e) {
error_log('[OGI][Imagick] Rendu échoué post_id='.$post->ID.' err='.$e->getMessage());
// fallback GD ci-dessous
}
}
// Fallback GD
if (!function_exists('imagecreatetruecolor')) {
error_log('[OGI][GD] GD indisponible');
return false;
}
$im = imagecreatetruecolor(OG_IMG_WIDTH, OG_IMG_HEIGHT);
// Couleurs
[$br, $bg, $bb] = [11, 18, 33];
$bgAlloc = imagecolorallocate($im, $br, $bg, $bb);
imagefilledrectangle($im, 0, 0, OG_IMG_WIDTH, OG_IMG_HEIGHT, $bgAlloc);
// Bandeau
[$ar, $ag, $ab] = [79, 70, 229];
$accentAlloc = imagecolorallocate($im, $ar, $ag, $ab);
imagefilledrectangle($im, 0, 0, OG_IMG_WIDTH, 16, $accentAlloc);
$white = imagecolorallocate($im, 255, 255, 255);
$sub = imagecolorallocate($im, 169, 177, 199);
$padding = 64;
$maxWidth = OG_IMG_WIDTH - 2 * $padding;
$titleSize = 36; // taille en pt pour GD (plus petit que Imagick)
$metaSize = 16;
if ($fontPath) {
$wrapped = ogi_wrap_text_gd($title, $fontPath, $titleSize, $maxWidth);
$lines = explode("\n", $wrapped);
$y = 140;
foreach ($lines as $line) {
imagettftext($im, $titleSize, 0, $padding, $y, $white, $fontPath, $line);
$y += 1.6 * $titleSize;
}
$metaText = $siteName . ($category ? ' • ' . $category : '');
imagettftext($im, $metaSize, 0, $padding, OG_IMG_HEIGHT - 48, $sub, $fontPath, $metaText);
} else {
// Sans police TTF, on utilise imagestring (qualité moindre)
$y = 120;
imagestring($im, 5, $padding, $y, $title, $white);
imagestring($im, 3, $padding, OG_IMG_HEIGHT - 60, $siteName . ($category ? ' • ' . $category : ''), $sub);
}
// Écriture PNG
$ok = imagepng($im, $destPath, 6);
imagedestroy($im);
return (bool)$ok;
}
Placez votre police dans plugins/og-image-generator/assets/Inter-SemiBold.ttf, ou remplacez par une autre police TTF libre. Le code gère l’absence de cette police, au prix d’un rendu GD moins qualitatif.
Clé de cache et écriture disque
Chaque image s’écrit sur le disque avec un nom versionné, réutilisé tant que le contenu n’a pas changé. La clé dépend de la date de modification, des métadonnées et de l’URL du site. Un verrou léger via wp_cache_add évite les courses concurrentes. En cas d’impossibilité d’écriture, on peut stocker un binaire en transient courte durée (utile en dev, à éviter en prod).
Créez inc/cache.php:
<?php
if (!defined('ABSPATH')) exit;
function ogi_is_dir_writable(): bool {
if (!file_exists(OG_IMG_DIR)) {
@wp_mkdir_p(OG_IMG_DIR);
}
return is_dir(OG_IMG_DIR) && wp_is_writable(OG_IMG_DIR);
}
function ogi_post_version(WP_Post $post): string {
$meta = get_post_meta($post->ID);
// On anonymise les valeurs de méta pour éviter un hash géant
$meta_norm = [];
foreach ($meta as $k => $vals) {
$meta_norm[$k] = md5(maybe_serialize($vals));
}
$payload = $post->post_modified_gmt . '|' . md5(json_encode($meta_norm)) . '|' . site_url();
return md5($payload);
}
function ogi_cache_filename(int $postId, string $ver): string {
return "og-{$postId}-{$ver}.png";
}
function ogi_cache_path(int $postId, string $ver): string {
return trailingslashit(OG_IMG_DIR) . ogi_cache_filename($postId, $ver);
}
function ogi_cache_url(int $postId, string $ver): string {
return trailingslashit(OG_IMG_URL) . ogi_cache_filename($postId, $ver);
}
function ogi_current_cached_file(WP_Post $post): ?string {
$ver = ogi_post_version($post);
$path = ogi_cache_path($post->ID, $ver);
return file_exists($path) ? $path : null;
}
function ogi_acquire_lock(int $postId, int $ttl = 30): bool {
return wp_cache_add("og_gen_{$postId}", 1, '', $ttl);
}
function ogi_release_lock(int $postId): void {
wp_cache_delete("og_gen_{$postId}");
}
function ogi_store_binary_transient(int $postId, string $ver, string $binary): void {
set_transient("og_image_bin_{$postId}_{$ver}", $binary, MINUTE_IN_SECONDS * 5);
}
function ogi_get_binary_transient(int $postId, string $ver): ?string {
$bin = get_transient("og_image_bin_{$postId}_{$ver}");
return $bin ? $bin : null;
}
function ogi_delete_old_files_for(int $postId): int {
if (!file_exists(OG_IMG_DIR)) return 0;
$deleted = 0;
$pattern = sprintf('/^og-%d-[a-f0-9]{32}\.png$/', $postId);
$dh = opendir(OG_IMG_DIR);
if ($dh) {
while (($file = readdir($dh)) !== false) {
if (preg_match($pattern, $file)) {
@unlink(OG_IMG_DIR . '/' . $file);
$deleted++;
}
}
closedir($dh);
}
return $deleted;
}
Avec cette base, on sait calculer le nom de fichier versionné, vérifier l’existence en cache, et effacer proprement lors d’une invalidation.
Endpoint REST image/png
On déclare une route REST GET /wp-json/og/v1/post/{id}. Elle valide le post (publish, post types autorisés), applique un rate limiting simple, sert le fichier s’il existe, sinon génère, écrit, et renvoie l’image avec les entêtes appropriés. On ajoute ETag et Last-Modified, et on supporte If-None-Match/If-Modified-Since pour un 304 Not Modified rapide.
Créez inc/rest.php:
<?php
if (!defined('ABSPATH')) exit;
add_action('rest_api_init', function () {
register_rest_route('og/v1', '/post/(?P<id>\d+)', [
'methods' => 'GET',
'permission_callback' => '__return_true',
'args' => [
'id' => [
'validate_callback' => 'is_numeric',
],
'theme' => [
'validate_callback' => function ($param) {
return in_array($param, ['light', 'dark'], true) || $param === null;
},
'required' => false,
],
],
'callback' => 'ogi_rest_image_handler',
]);
});
function ogi_rate_limit_ok(string $ip): bool {
$key = 'og_rate_' . preg_replace('/[^0-9a-f\.:]/i', '', $ip) . '_' . gmdate('YmdHi'); // par minute
$count = (int) get_transient($key);
if ($count > 30) {
return false;
}
set_transient($key, $count + 1, MINUTE_IN_SECONDS + 10);
return true;
}
function ogi_send_png_response(string $path, string $filename) {
$mtime = filemtime($path);
$etag = '"' . md5($filename . '|' . $mtime) . '"';
$lastMod = gmdate('D, d M Y H:i:s', $mtime) . ' GMT';
// Conditionnel
$inm = $_SERVER['HTTP_IF_NONE_MATCH'] ?? '';
$ims = $_SERVER['HTTP_IF_MODIFIED_SINCE'] ?? '';
if ($inm === $etag || $ims === $lastMod) {
header('HTTP/1.1 304 Not Modified');
header('ETag: ' . $etag);
header('Last-Modified: ' . $lastMod);
header('Cache-Control: public, max-age=86400, immutable');
exit;
}
$len = filesize($path);
header('Content-Type: image/png');
header('Content-Length: ' . $len);
header('ETag: ' . $etag);
header('Last-Modified: ' . $lastMod);
header('Cache-Control: public, max-age=86400, immutable');
readfile($path);
exit;
}
function ogi_rest_image_handler(WP_REST_Request $req) {
$id = (int) $req->get_param('id');
$post = get_post($id);
if (!$post) {
return new WP_Error('og_not_found', 'Article introuvable', ['status' => 404]);
}
if ('publish' !== get_post_status($post)) {
return new WP_Error('og_not_published', 'Contenu non publié', ['status' => 404]);
}
if (post_password_required($post)) {
return new WP_Error('og_forbidden', 'Contenu protégé', ['status' => 403]);
}
$allowed = apply_filters('og_image_post_types', ['post', 'page']);
if (!in_array($post->post_type, (array)$allowed, true)) {
return new WP_Error('og_unsupported_type', 'Type de post non supporté', ['status' => 400]);
}
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
if (!ogi_rate_limit_ok($ip)) {
return new WP_Error('og_rate_limited', 'Trop de requêtes', ['status' => 429]);
}
$ver = ogi_post_version($post);
$filename = ogi_cache_filename($post->ID, $ver);
$path = ogi_cache_path($post->ID, $ver);
// Si déjà en cache disque
if (file_exists($path)) {
ogi_send_png_response($path, $filename);
}
// Tentative de binaire en transient si pas de disque
if (!ogi_is_dir_writable()) {
$bin = ogi_get_binary_transient($post->ID, $ver);
if ($bin) {
header('Content-Type: image/png');
header('Cache-Control: public, max-age=86400');
echo $bin;
exit;
}
}
// Lock léger pour éviter la course
$gotLock = ogi_acquire_lock($post->ID, 30);
if (!$gotLock) {
// Backoff court: on attend jusqu’à 2s que l’autre requête finisse
$waited = 0;
while ($waited < 2000000) {
if (file_exists($path)) {
ogi_send_png_response($path, $filename);
}
usleep(200000);
$waited += 200000;
}
// Toujours rien: 503
return new WP_Error('og_busy', 'Génération en cours, réessayez', ['status' => 503]);
}
// Génération
$ok = render_og_image($post, $path);
ogi_release_lock($post->ID);
if (!$ok || !file_exists($path)) {
// Sauvegarde en transient si disque KO
if (!ogi_is_dir_writable()) {
ob_start();
// Dernier essai: rendu vers un fichier temp puis lecture
$tmp = wp_tempnam('og-img');
if ($tmp && render_og_image($post, $tmp) && file_exists($tmp)) {
$bin = file_get_contents($tmp);
@unlink($tmp);
if ($bin) {
ogi_store_binary_transient($post->ID, $ver, $bin);
header('Content-Type: image/png');
header('Cache-Control: public, max-age=86400');
echo $bin;
exit;
}
}
ob_end_clean();
}
error_log('[OGI] Échec génération post_id=' . $post->ID);
return new WP_Error('og_internal', 'Erreur de génération', ['status' => 500]);
}
// Succès
ogi_send_png_response($path, $filename);
}
Cette implémentation respecte le cache navigateur/robots, retourne 304 quand possible, et limite les abus par minute. Le paramètre optionnel theme est prévu pour la section “Aller plus loin”.
Invalidation à la mise à jour
À chaque sauvegarde d’article, on supprime toutes les versions og-{id}-*.png pour forcer la régénération au prochain hit. On retire les verrous éventuels et on propose en option une pré-génération asynchrone 10 secondes après, pour éviter que le premier robot subisse le coût du rendu.
Créez inc/hooks.php:
<?php
if (!defined('ABSPATH')) exit;
// Invalidation à la sauvegarde / publication
add_action('save_post', function ($postId, $post, $update) {
if (wp_is_post_revision($postId)) return;
// On s'assure que ce ne sont que les types autorisés
$allowed = apply_filters('og_image_post_types', ['post', 'page']);
if (!in_array($post->post_type, (array)$allowed, true)) return;
// Si transition to publish ou update publié
$status = get_post_status($postId);
if ($status !== 'publish') return;
$deleted = ogi_delete_old_files_for($postId);
ogi_release_lock($postId);
// Option: pré-génération à chaud
if (!wp_next_scheduled('og_image_warmup_single', [$postId])) {
wp_schedule_single_event(time() + 10, 'og_image_warmup_single', [$postId]);
}
}, 10, 3);
// Warmup: génère et met en cache
add_action('og_image_warmup_single', function ($postId) {
$post = get_post($postId);
if (!$post || get_post_status($post) !== 'publish') return;
$ver = ogi_post_version($post);
$path = ogi_cache_path($post->ID, $ver);
if (file_exists($path)) return;
if (!ogi_is_dir_writable()) return;
if (!ogi_acquire_lock($post->ID, 30)) return;
try {
render_og_image($post, $path);
} catch (Throwable $e) {
error_log('[OGI][Warmup] ' . $e->getMessage());
} finally {
ogi_release_lock($post->ID);
}
});
// Intégration dans <head>
add_action('wp_head', function () {
if (!is_singular()) return;
global $post;
if (!$post) return;
if (post_password_required($post)) return;
$allowed = apply_filters('og_image_post_types', ['post', 'page']);
if (!in_array($post->post_type, (array)$allowed, true)) return;
$url = site_url('/wp-json/og/v1/post/' . $post->ID);
// Ces echo produisent du HTML dans le head. Dans un thème classique, c’est ce que l’on souhaite.
echo "\n<!-- OG Image Generator -->\n";
echo '<meta property="og:image" content="' . esc_url($url) . "\" />\n";
echo '<meta property="og:image:secure_url" content="' . esc_url($url) . "\" />\n";
echo '<meta property="og:image:type" content="image/png" />' . "\n";
echo '<meta property="og:image:width" content="' . (int)OG_IMG_WIDTH . "\" />\n";
echo '<meta property="og:image:height" content="' . (int)OG_IMG_HEIGHT . "\" />\n";
}, 99);
// Intégration avec Yoast SEO si présent
add_filter('wpseo_opengraph_image', function ($img) {
if (!is_singular()) return $img;
$post = get_post();
if (!$post) return $img;
return site_url('/wp-json/og/v1/post/' . $post->ID);
});
// Nettoyage hebdomadaire: supprime les orphelins
add_action('og_image_cleanup_cron', function () {
if (!file_exists(OG_IMG_DIR)) return;
// Récupère les posts publiés pertinents
$types = apply_filters('og_image_post_types', ['post', 'page']);
$posts = get_posts([
'post_type' => (array)$types,
'post_status' => 'publish',
'posts_per_page' => -1,
'fields' => 'ids',
'no_found_rows' => true,
]);
$current = [];
foreach ($posts as $pid) {
$p = get_post($pid);
$ver = ogi_post_version($p);
$current[ogi_cache_filename($pid, $ver)] = true;
}
$dh = opendir(OG_IMG_DIR);
if (!$dh) return;
while (($file = readdir($dh)) !== false) {
if (!preg_match('/^og-(\d+)-[a-f0-9]{32}\.png$/', $file)) continue;
$full = OG_IMG_DIR . '/' . $file;
$isCurrent = isset($current[$file]);
$isOld = (time() - filemtime($full)) > 60 * DAY_IN_SECONDS;
if (!$isCurrent || $isOld) {
@unlink($full);
}
}
closedir($dh);
});
Si vous utilisez RankMath ou d’autres plugins SEO, remplacez le filtre Yoast par l’équivalent (RankMath: rank_math/opengraph/facebook/image).
Intégration Open Graph dans le head
L’intégration côté front consiste à émettre les balises OG qui pointent vers l’endpoint. L’exemple dans hooks.php ajoute les metas og:image, og:image:width/height/type. Si Yoast est actif, on remplace l’image par défaut via le filtre wpseo_opengraph_image. En pratique, vous n’avez rien à faire de plus: activez le plugin, visitez une page, inspectez le head et vérifiez que l’URL /wp-json/og/v1/post/{id} est bien présente.
Dans un thème sans plugin SEO, le hook wp_head suffit. Avec un plugin SEO, utilisez leurs filtres pour éviter les conflits et laissez ce plugin gérer les balises.
Sécurité et performance
L’endpoint est public, mais il ne divulgue aucune donnée sensible: il ne sert qu’un PNG. On filtre strictement l’ID et on refuse les articles non publiés ou protégés par mot de passe (403). Pour éviter les abus, on implémente un rate limiting simple par IP (30 req/min), qui suffira pour les robots sociaux courants. Pour la performance, on s’appuie sur:
- Cache disque versionné: un même contenu renvoie toujours la même ressource, ce qui autorise un Cache-Control long côté CDN et clients.
- ETag/Last-Modified: les clients renverront If-None-Match/If-Modified-Since et recevront 304 si rien n’a changé.
- .htaccess dans uploads/og: si vous servez directement les PNG générés (hors REST), l’Apache les fournira avec un cache de 30 jours. Le REST ajoute néanmoins les entêtes nécessaires.
- CDN conseillé: placez /wp-json/og/v1/post/* derrière un CDN avec TTL 24h et validation conditionnelle. La version change quand le contenu change, ce qui invalide naturellement.
Pour la maintenance, un cron hebdomadaire supprime les PNG orphelins non utilisés et purge les fichiers très anciens.
Tests et debug
Testez d’abord en local l’API REST et les entêtes.
- Vérification des en-têtes:
curl -I https://site.test/wp-json/og/v1/post/123
Vous devez voir Content-Type: image/png, Cache-Control: public, ETag et Last-Modified. Un second appel doit pouvoir retourner 304 Not Modified:
curl -I -H 'If-None-Match: "votreETagIci"' https://site.test/wp-json/og/v1/post/123
- Téléchargement du PNG pour inspection:
curl -o /tmp/og.png https://site.test/wp-json/og/v1/post/123
open /tmp/og.png # macOS; sur Linux, utilisez xdg-open
- Commande WP-CLI pour générer à la demande (créez inc/cli.php):
<?php
if (!defined('WP_CLI')) return;
class OGI_CLI_Command {
/**
* Génère l'image OG d'un post et renvoie le chemin de fichier.
*
* ## OPTIONS
*
* <post_id>
* : ID de l'article.
*
* ## EXAMPLES
*
* wp og generate 123
*/
public function generate($args, $assoc_args) {
[$post_id] = $args;
$post = get_post((int)$post_id);
if (!$post) {
WP_CLI::error('Post introuvable.');
}
$ver = ogi_post_version($post);
$path = ogi_cache_path($post->ID, $ver);
if (file_exists($path)) {
WP_CLI::success("Déjà en cache: $path");
return;
}
if (!ogi_is_dir_writable()) {
WP_CLI::error('Dossier non inscriptible: ' . OG_IMG_DIR);
}
if (!ogi_acquire_lock($post->ID, 30)) {
WP_CLI::warning('Verrou concurrent. Réessayez.');
return;
}
$ok = render_og_image($post, $path);
ogi_release_lock($post->ID);
if ($ok && file_exists($path)) {
WP_CLI::success("Généré: $path");
} else {
WP_CLI::error('Échec de génération.');
}
}
}
WP_CLI::add_command('og', 'OGI_CLI_Command');
Exemple d’utilisation:
wp og generate 123
- Debug social: utilisez le Facebook Sharing Debugger et le Twitter Card Validator pour tester l’URL /wp-json/og/v1/post/{id}. Vérifiez la résolution 1200x630, le type image/png et l’affichage du titre. Si le rendu échoue (exception Imagick/GD), le plugin logue l’erreur via error_log() avec l’ID du post; l’endpoint renverra un JSON d’erreur 500 clair.
Aller plus loin
Pour enrichir le visuel, vous pouvez intégrer l’image mise en avant comme arrière-plan, avec un flou contrôlé. En Imagick, appliquez un gaussianBlurImage avant d’ajouter les textes. Vous pouvez aussi proposer des thèmes graphiques différents via un paramètre de requête et internationaliser le rendu pour les alphabets CJK/emoji et les langues RTL.
Exemples:
- Image mise en avant en background (Imagick), à placer dans render_og_image:
// Après la création $img
$thumb_id = get_post_thumbnail_id($post);
if ($thumb_id) {
$src = wp_get_attachment_image_src($thumb_id, 'full');
if ($src && !empty($src[0])) {
$bg = new Imagick();
$bg->readImage($src[0]);
$bg->resizeImage(OG_IMG_WIDTH, OG_IMG_HEIGHT, Imagick::FILTER_LANCZOS, 1, true);
$bg->cropThumbnailImage(OG_IMG_WIDTH, OG_IMG_HEIGHT);
$bg->gaussianBlurImage(10, 5);
$img->compositeImage($bg, Imagick::COMPOSITE_ATOP, 0, 0);
$overlay = new ImagickDraw();
$overlay->setFillColor(new ImagickPixel('rgba(11,18,33,0.65)'));
$overlay->rectangle(0, 0, OG_IMG_WIDTH, OG_IMG_HEIGHT);
$img->drawImage($overlay);
$bg->destroy();
}
}
Thèmes multiples: utilisez le paramètre ?theme=dark|light dans l’endpoint (déjà validé). Dans render_og_image, ajustez couleurs et police selon $req->get_param('theme') en passant l’info via une variable globale ou un setter (par simplicité, stockez dans $GLOBALS['ogi_theme'] lors de la requête REST et lisez-la dans render_og_image).
Internationalisation: ajoutez des polices Noto (Noto Sans CJK, Noto Color Emoji) dans assets/ et sélectionnez la police en fonction de la locale get_locale() ou de la présence de caractères spécifiques. Pour les langues RTL, Imagick supporte setTextDirection via ImagickDraw::setTextDirection(Imagick::TEXT_DIRECTION_RTL) sur les versions récentes; sinon, prétraitez la chaîne.
Pré-génération: programmez un crawl quotidien du sitemap pour remplir le cache avant le passage des robots. Exemple WP-CLI:
# Générez toutes les images OG des 500 derniers posts publiés
wp eval '
$ids = get_posts(["post_type"=>"post","post_status"=>"publish","fields"=>"ids","posts_per_page"=>500]);
foreach ($ids as $id) {
$post = get_post($id);
$ver = ogi_post_version($post);
$path = ogi_cache_path($id, $ver);
if (!file_exists($path) && ogi_is_dir_writable()) {
ogi_acquire_lock($id, 15);
render_og_image($post, $path);
ogi_release_lock($id);
echo "OK $id\n";
}
}
'
Checklist
- Relire
- Tester les commandes / snippets
- Publier
Ressources
- Spécification Open Graph (og:image): https://ogp.me/
- WordPress REST API Handbook: https://developer.wordpress.org/rest-api/
- WP-CLI Handbook: https://make.wordpress.org/cli/handbook/
- Imagick pour PHP: https://www.php.net/imagick
- GD pour PHP: https://www.php.net/manual/fr/book.image.php
- Facebook Sharing Debugger: https://developers.facebook.com/tools/debug/
- Card Validator de X/Twitter: https://cards-dev.twitter.com/validator
Conclusion
Vous disposez maintenant d’un plugin WordPress autonome qui génère des images Open Graph dynamiques, robustes et rapides. L’endpoint REST produit un PNG versionné en cache disque, avec un rendu fiable via Imagick ou GD, des entêtes HTTP complets et une invalidation automatique à chaque mise à jour de contenu. En y ajoutant un CDN et des variantes de thème, vous obtenez un système de previews sociaux performant, personnalisable et maintenable.