Catalogue des fonctionnalités
398 fonctionnalités · 22 modules
Audit du code source · CMS ClusTraly

Toutes les fonctionnalités, avec leur fonctionnement exact

Catalogue complet et corrigé, reconstruit à partir du code réel (contrôleurs, services, vues). Chaque ligne décrit précisément le mécanisme sous-jacent étapes, options, stockage, sécurité et cas limites.

Clustraly est un CMS éditorial auto-hébergeable en PHP : ce catalogue recense l'intégralité de ses fonctionnalités, module par module, avec leur fonctionnement réel. Retour à l'accueil · voir les 22 modules.

22
modules
398
fonctionnalités documentées
11
modules majeurs ajoutés
Contenu

📝 Articles

23 fonctionnalités
Fonctionnalité Actions Fonctionnement exact
Liste, filtres et file de relecture ListerFiltrerPaginerTrierScoper Index paginé à 20 articles/page, triés du plus récent au plus ancien. • Filtre de statut (tous, brouillon, publié, planifié, archivé) et filtre de workflow éditorial via ?workflow=in_review affichant un badge de comptage en direct de la file de relecture. • Chaque ligne montre des cercles de score SEO et de citabilité GEO, le nombre de vues et un avertissement « relecture nécessaire » (review_needed). • Les auteurs sans la permission articles.edit_others ne voient que leurs propres articles (scoping au niveau objet). • L'indentation hiérarchique s'appuie sur parent_id pour refléter l'arborescence.
Création d'article et choix de mode CréerChoisir modePré-remplir (voix) Le formulaire de création propose au choix un mode Manuel ou un mode Assisté par IA (assistant/wizard). • Le paramètre ?title= permet un pré-remplissage du titre depuis une saisie vocale, avec une liste blanche de valeurs autorisées. • Un interrupteur « Full-Auto » enchaîne toutes les étapes sans validation intermédiaire. • Le mode manuel ouvre directement l'éditeur riche et les panneaux latéraux (SEO, publication, cocon).
Persistance (enregistrement et mise à jour) EnregistrerMettre à jourAssainirGénérer slug À l'enregistrement, un slug unique est dérivé du slug fourni, à défaut du focus_keyword, à défaut du titre (SlugService::generateUnique). • Le HTML est nettoyé par HtmlSanitizer, l'extrait auto-généré si vide, word_count et reading_time calculés (≈ mots ÷ 200, min. 1) et la langue résolue via LanguageResolver. • published_at est posé au premier passage en « published ». • En mise à jour, un changement de slug crée automatiquement une redirection 301 (handleSlugChange) et une version « Before edit » est prise avant écriture. • Les hooks content.saving (mutation d'attributs), content.saved puis, sur transition vers publié, content.published sont déclenchés.
Assistant IA en 7 étapes (wizard) Suivre étapesValiderStreamerRégénérer Assistant guidé: (1) mot-clé cible + langue de rédaction, (2) stratégie de mots-clés (H2 secondaires + mots-clés lexicaux H3/corps, générés par IA et éditables), (3) rédaction du contenu avec aperçu en streaming et statistiques mots/titres, (4) titre et slug avec régénération IA du titre selon instructions, puis étapes image à la une et génération des métas, (7) analyse SEO/GEO (cercles de score + liste de critères). • Chaque étape se valide manuellement, ou l'interrupteur Full-Auto enchaîne le tout sans validation. • Le choix Manuel ou Assisté par IA s'effectue à la création d'un nouvel article.
Barre d'outils IA de l'éditeur RédigerAméliorerRégénérerSuggérer Barre d'outils IA au-dessus de l'éditeur riche: rédiger ou améliorer le contenu intégral (en streaming), régénérer les métas (meta title + description), générer une image à la une par IA, régénérer le titre (menu de 3 suggestions), régénérer les mots-clés secondaires et lexicaux, régénérer/résumer l'extrait, et suggérer des tags par IA appliqués via des « chips ». • Chaque bouton par champ appelle un point de terminaison IA dédié et réinjecte le résultat dans le champ correspondant. • Ces actions sont ponctuelles (à la demande), distinctes de l'assistant 7 étapes.
Dictée vocale DicterTranscrireInsérer La dictée vocale permet de saisir du texte à la voix dans les champs titre et contenu de l'éditeur. • À la création, une transcription vocale peut aussi pré-remplir le titre via le paramètre ?title= (valeurs sur liste blanche). • Le texte dicté est inséré dans le champ ciblé pour être ensuite édité normalement.
Générateur d'article IA autonome (SSE) GénérerStreamerVérifier budget Flux dédié /admin/articles/generate qui produit un article complet par IA (ArticleGeneratorService). • La génération est diffusée en Server-Sent-Events (streaming temps réel) après une vérification du plafond de budget IA. • Les paramètres incluent le sujet, la langue et le cocon_type. • Un contrôle anti-cannibalisation évite de dupliquer l'intention de recherche d'articles existants.
Autosave (sauvegarde automatique) Sauvegarder autoHorodater Un POST AJAX /admin/articles/{id}/autosave enregistre en continu le titre, le contenu et l'extrait, puis renvoie un horodatage saved_at (HH:MM:SS). • Il applique les valeurs sur l'article existant et le sauvegarde sans rechargement de page. • L'autosave sert aussi de battement de cœur (heartbeat) qui rafraîchit le verrou d'édition concurrent. • Un contrôle de propriété (denyIfNotOwner) protège l'accès.
Verrou d'édition concurrent (edit-lock) AcquérirRafraîchirAvertirLibérer À l'ouverture de l'éditeur, un verrou souple est posé sur l'article (articles.locked_by / locked_at) via EditLockService::acquire. • C'est un verrou de niveau AVERTISSEMENT: un second éditeur voit une bannière « X édite actuellement cet article » plutôt qu'un blocage dur. • Le verrou expire automatiquement après un TTL de 300 secondes s'il n'est pas rafraîchi, si bien qu'un onglet abandonné ne bloque jamais durablement; l'autosave et l'update servent de heartbeat pour le maintenir. • Il est libéré à la publication (EditLockService::release).
Workflow éditorial (machine à états) SoumettreAssignerApprouverRejeterPublierRouvrir Machine à états distincte du statut de publication: none/draftin_reviewapprovedpublished, avec branche rejected et réouverture. • Transitions: soumettre pour relecture (choix d'un relecteur ou « n'importe lequel »), assigner/réassigner un relecteur, approuver (commentaire optionnel), rejeter/demander des changements (commentaire obligatoire), publier un article approuvé (déclenche content.published, libère le verrou, pose published_at), rouvrir/re-soumettre après changements. • Chaque transition valide l'état source, journalise une ligne ArticleReview (fil de commentaires + traçabilité), notifie l'utilisateur concerné et écrit une entrée d'audit. • Un « publish gate » force les auteurs sans articles.publish à rester en brouillon; seuls les rôles disposant de workflow.review apparaissent comme relecteurs.
Planification de publication (scheduled_at) PlanifierDaterFiltrer Un champ scheduled_at (datetime) programme une date/heure de publication future, stockée telle quelle (valeur vide → null). • Le statut « scheduled » dispose de son propre filtre dans la liste des articles. • Le champ est enregistré à la création comme à la mise à jour depuis la barre latérale de publication.
Archivage et republication ArchiverRepublier Un article peut être archivé (statut « archived »), individuellement ou via l'action groupée « archive » (UPDATE status='archived'), ce qui le retire de la circulation publiée tout en le conservant; il reste accessible sous le filtre « archivé ». • La republication se fait en repassant le statut à « published » (par mise à jour ou action groupée « publish »), ce qui repose published_at via COALESCE(published_at, NOW()) sans écraser une date existante. • Le cache de l'article est invalidé à chaque transition.
Actions groupées PublierBrouillonArchiverSupprimer (lot) Depuis la liste, une multi-sélection permet: publier (status=published, published_at via COALESCE, déclenche content.published pour chaque transition réelle), passer en brouillon, archiver, ou supprimer (soft-delete de chacun vers la corbeille). • Pour les auteurs sans articles.edit_others, la sélection est silencieusement restreinte à leurs propres articles (ownedArticleIds). • La publication groupée obéit au même « publish gate » que la publication unitaire. • Le cache est invalidé pour tous les articles concernés et chaque opération est journalisée à l'audit.
Suppression et corbeille (soft-delete) SupprimerEnvoyer en corbeilleRestaurer La suppression (DELETE) est un soft-delete restaurable: TrashService::trash déplace l'article ET tous ses enregistrements enfants (tags, catégories, traductions, commentaires, versions, méta SEO) vers la corbeille sans les effacer, de sorte qu'une restauration le remet intact. • L'invalidation de cache, le retrait de l'index de recherche, le hook content.trashed et l'entrée d'audit sont gérés par TrashService. • La suppression définitive n'intervient que depuis la Corbeille ou via le cron de purge par rétention. • La suppression en masse passe par la même mécanique pour chaque id sélectionné.
Prévisualisation brouillon en direct PrévisualiserRendreBloquer indexation Un POST /admin/articles/preview rend l'article dans le vrai gabarit public (vue blog/show du thème) à partir du titre, contenu, catégories, tags et image à la une du formulaire, sans rien persister. • Cela permet de voir un brouillon non enregistré exactement comme il apparaîtrait en ligne. • Le rendu force les directives robots noindex,nofollow pour empêcher toute indexation.
Liens de prévisualisation tokenisés (partage) GénérerRégénérerRévoquerPartager Génère un lien de partage sans compte pour qu'un relecteur externe ouvre un brouillon via /preview/{token}. • Le token brut est une valeur aléatoire de 256 bits (64 hex) affichée UNE seule fois; seule son empreinte SHA-256 est stockée, donc une fuite de base ne reconstitue pas de lien fonctionnel. • Les liens sont à durée de vie limitée (7 jours par défaut), révocables et limités à une seule entité: générer un nouveau lien révoque les précédents (un seul lien actif par entité). • Résolution en lecture seule (réutilisable jusqu'à expiration ou révocation), garde de propriété auteur, et purge opportuniste des liens périmés à la création.
Moteur de score SEO + GEO AnalyserScorerMarquer à relire L'analyseur on-page (ContentAnalyzer) calcule un score SEO sur 15 critères: mot-clé dans le titre/H1/intro/gras, densité, secondaire en H2, lexical en H3, longueur des paragraphes et phrases, mots de transition, images + attribut alt, longueur du texte, meta description. • Il produit aussi un score de lisibilité et un score de citabilité GEO (structure faisant autorité, réponse directe, données structurées, qualité des sources, couverture, fraîcheur) avec bonus (FAQ, liens externes, tableau de données, média riche). • Les résultats sont persistés (seo_score, readability_score, seo_score_details) et review_needed est positionné quand le score SEO est inférieur à 50. • Les scores sont restitués sous forme de cercles et d'une liste de critères dans l'éditeur.
Métadonnées SEO par contenu Éditer métasChoisir robotsDéfinir canonical La boîte SEO enregistre dans SeoMeta le meta title, la meta description, les jeux de mots-clés focus/secondaires/lexicaux (listes séparées par virgules stockées en JSON), une directive robots (index/noindex, follow/nofollow), l'URL canonique, ainsi que og_title et og_description (Open Graph, articles). • Les métas ne sont persistées que si au moins un champ SEO est renseigné. • Ces champs alimentent l'analyseur SEO/GEO et le rendu public.
Taxonomie et auto-tagging AssignerSynchroniserAuto-taguer Synchronise les catégories (avec une catégorie primaire désignée) et les tags de l'article. • Les nouveaux tags suggérés par l'IA sont créés à l'enregistrement. • En l'absence de tags choisis, des tags sont auto-générés à partir des mots-clés focus + secondaires. • En l'absence de catégorie choisie, une catégorie est auto-sélectionnée ou auto-créée depuis le mot-clé focus.
Champs cocon (topic cluster) TyperRelierOrdonner Métadonnées de silo sémantique reliant l'article à une structure pilier/cluster: cocon_type (pillar, cluster ou support), pillar_id (lien vers un article pilier), parent_id (hiérarchie) et cocon_order (ordre). • Ces champs structurent le maillage interne et sont saisis depuis le formulaire d'édition.
Options de publication Régler visibilitéÉpinglerAutoriser commentairesChoisir langueImage à la une Barre latérale de publication: visibilité public/privé, bascule is_featured (mise en avant) et allow_comments (commentaires autorisés, activé par défaut), choix de la langue du contenu. • L'image à la une se choisit depuis une modale de la médiathèque, se génère par IA, ou se retire. • La planification (scheduled_at) et le statut sont pilotés depuis la même barre.
Versioning / révisions SnapshotterRestaurerVerrouillerComparer (diff)Élaguer Une version est créée automatiquement à la création (« Initial version ») et avant chaque édition (« Before edit »). • On peut lister les versions d'une entité (HTML ou JSON AJAX), restaurer une version l'état courant étant lui-même snapshotté d'abord (« Before restore to vN »), donc annulable, avec écriture restreinte aux vraies colonnes de la table (whitelist anti-injection de nom de colonne) verrouiller/déverrouiller une version, et comparer deux versions (diff champ par champ old/new, API JSON). • L'élagage automatique conserve au plus max_versions révisions (défaut 20, réglable). • Des gardes de permission par entité (articles/pages .view/.edit) et de propriété auteur s'appliquent.
Hooks d'extension de contenu (plugins) FiltrerRéagir Des points d'extension pour plugins sont déclenchés tout au long du cycle de vie: le filtre content.saving permet de muter les attributs de l'article avant insertion/mise à jour (un retour non-tableau est ignoré et ne peut casser l'opération), l'action content.saved après persistance complète (ligne + taxonomies + méta SEO), content.published sur la seule transition vers « publié » (alimente webhooks et cache), et content.trashed / content.restored / content.deleting sur les événements de corbeille. • Ces hooks permettent aux plugins de réagir aux événements de contenu sans modifier le cœur.
Contenu

📄 Pages, Catégories, Étiquettes, Corbeille

22 fonctionnalités
Fonctionnalité Actions Fonctionnement exact
Pages Liste ListerTrier GET /admin/pages hydrate toutes les pages via Page::hydrateMany et les trie par sort_order ASC puis title ASC (double clé de tri). La vue admin/pages/index affiche l'ensemble des pages statiques. Contrairement à la liste des articles, la liste des pages n'est pas paginée. Le titre d'écran est traduit via __('Pages').
Pages Création & mise à jour (CRUD) CréerEnregistrerÉditerMettre à jour Le formulaire de création (GET /admin/pages/create) accepte un pré-remplissage vocal ?title= whitelisté (trim + coupe à 200 caractères, auto-échappé par la vue). À l'enregistrement le titre est obligatoire (sinon flash d'erreur et retour au formulaire), le slug est rendu unique par SlugService::generateUnique à partir du slug soumis, sinon du focus_keyword, sinon du titre, le contenu HTML est nettoyé par HtmlSanitizer::clean et la langue résolue par LanguageResolver::forContent. Un UUID v4 est généré, puis les attributs traversent le filtre plugin content.saving (un retour non-tableau est ignoré) avant insertion, suivi des actions content.saved et content.published (si publiée d'emblée), d'un audit page.create ou page.update et d'une invalidation cache via CacheService::invalidatePage. Les métadonnées SEO ne sont écrites que si focus_keyword, meta_title ou meta_description sont renseignés, avec review_needed=1 lorsque le score SEO passe sous 50.
Pages Redirection 301 au changement de slug DétecterRediriger À la mise à jour, si le slug soumis (ou dérivé du focus_keyword) diffère de l'ancien, il est re-unicisé par SlugService::generateUnique en excluant l'id courant, puis SlugService::handleSlugChange crée une redirection 301 automatique de l'ancienne URL vers la nouvelle. Le cache de l'ancienne et de la nouvelle URL est invalidé. published_at n'est posé qu'à la première publication (transition draft vers published) et l'action content.published n'est déclenchée que sur cette transition non-publié vers publié.
Pages Templates ChoisirAppliquer Chaque page porte un champ template (valeur 'default' par défaut) sélectionnable au formulaire parmi les gabarits disponibles (default ou personnalisé). Le rendu public passe par ThemeView::renderPage avec ce template. L'aperçu brouillon respecte lui aussi le template choisi. Le template détermine la mise en page appliquée à la page statique dans le thème.
Pages Hiérarchie, page d'accueil & ordre RattacherOrdonnerDéfinir l'accueil Une page se rattache à une page parente via parent_id, et le sélecteur de parent en édition exclut la page elle-même pour éviter l'auto-référence. Le drapeau is_homepage (0 ou 1) désigne la page d'accueil du site. sort_order (entier) fixe l'ordre d'affichage et sert de clé de tri primaire de la liste. La visibilité est public ou private et le statut draft ou published.
Pages Actions groupées PublierRepasser en brouillonSupprimer POST /admin/pages/bulk applique une action à une sélection d'ids (sinon message « aucun élément sélectionné »). 'publish' passe les pages en published avec published_at = COALESCE(published_at, NOW()) et déclenche content.published pour chaque page qui transitionne réellement (ids capturés au préalable par un SELECT sur status différent de published). 'draft' les repasse en brouillon. 'delete' route chaque page vers la corbeille (soft-delete restaurable) via TrashService::trash. Le cache de toutes les pages affectées est invalidé par slug et un message compte les pages traitées.
Pages Mise à la corbeille (soft-delete) SupprimerRestaurer DELETE /admin/pages/{id} appelle TrashService::trash('page', id) et renvoie 404 si la page est introuvable. La page et ses données liées (traductions, meta SEO) sont conservées telles quelles pour qu'une restauration la remette intacte, seule la ligne étant retirée immédiatement de l'index de recherche. La suppression définitive et le nettoyage en cascade n'ont lieu que depuis la Corbeille ou le cron de purge. Cache, hook content.trashed et audit sont pris en charge par TrashService.
Pages Aperçu brouillon dans le thème public Prévisualiser POST /admin/pages/preview rend le contenu non enregistré (titre, contenu nettoyé par HtmlSanitizer, slug, template) dans la vraie mise en page du thème public via ThemeView::renderPage, sans rien persister. Le SeoService force robots=noindex,nofollow et un meta_title suffixé « Preview ». Un fil d'Ariane Accueil puis Titre est injecté. C'est un rendu volatil destiné à la relecture avant sauvegarde.
Pages Liens de partage de brouillon (tokens) GénérerRévoquerPartager Depuis l'éditeur, PreviewController crée ou révoque un lien public tokenisé, ses routes étant protégées par la permission pages.edit (que le rôle auteur ne possède pas, d'où une garde de propriété nécessaire seulement pour les articles). PreviewTokenService::create génère un token aléatoire de 256 bits (64 caractères hex) dont seul le hash SHA-256 est stocké, avec une durée de vie par défaut de 7 jours (604800 s) et un seul lien actif par entité (les liens antérieurs sont révoqués avant d'en émettre un nouveau, et purgeStale nettoie les jetons révoqués ou expirés de plus d'un jour). L'URL brute /preview/{token} n'est affichée qu'une seule fois via un flash, jamais récupérable ensuite. Le lien reste réutilisable jusqu'à expiration ou révocation (resolve en lecture seule vérifie revoked_at IS NULL et expires_at supérieur à maintenant), la révocation posant revoked_at.
Catégories CRUD, métadonnées, hiérarchie & 301 ListerCréerÉditerMettre à jour GET /admin/categories liste toutes les catégories (Category::all) et le formulaire propose les racines (Category::roots) comme parents. Le nom est obligatoire et le slug rendu unique par SlugService::generateUnique. Chaque catégorie porte des métadonnées riches: description, image, icon, color, focus_keyword, sort_order et parent_id pour une hiérarchie à N niveaux, le sélecteur de parent en édition ne listant que les racines et excluant la catégorie courante. Si le slug change à la mise à jour, il est re-unicisé (id exclu) et SlugService::handleSlugChange crée une redirection 301 des chemins /category/{ancien} vers /category/{nouveau}. Le cache catégorie (ancien et nouveau slug, plus accueil) est invalidé via CacheService::invalidateCategory à la création et à la mise à jour.
Catégories Réorganisation glisser-déposer & re-parentage Glisser-déposerRéordonnerRe-parenter PUT /admin/api/sort/categories reçoit un corps JSON de la forme items contenant, pour chaque catégorie, id, sort_order et parent_id. Le contrôleur boucle et exécute, pour chaque item valide (id supérieur à 0), un UPDATE categories SET sort_order=?, parent_id=? WHERE id=?, ce qui permet à la fois de réordonner et de rattacher une catégorie à un nouveau parent (parent_id vide ou null équivaut au niveau racine). La réponse est un JSON success=true. L'opération pilote directement depuis l'interface l'ordre d'affichage et l'imbrication de l'arbre de catégories.
Catégories Mise à la corbeille SupprimerRestaurer DELETE /admin/categories/{id} appelle TrashService::trash('category', id) et renvoie 404 si absente. Les liens pivot (article_categories), traductions, meta SEO et le rattachement des sous-catégories sont conservés pour qu'une restauration remette la catégorie intacte. La suppression définitive n'intervient que depuis la Corbeille ou le cron de purge, avec alors nettoyage complet des enfants et détachement des sous-catégories au niveau racine (parent_id=NULL).
Étiquettes Liste & création inline ListerCréer en ligneCompter les usages GET /admin/tags sert d'écran unique: il liste les étiquettes (Tag::all) avec leur nombre d'utilisations et fait aussi office de formulaire de création inline. POST /admin/tags exige un nom (sinon flash d'erreur), génère un slug unique via SlugService::generateUnique et crée l'étiquette avec name, slug, description, focus_keyword, icon et image. Tag::ensureColumns est appelé à la volée pour garantir la présence des colonnes attendues (auto-migration légère du schéma). La création reste sur la page /admin/tags.
Étiquettes Édition ModifierRenommerRe-slugger PUT /admin/tags/{id} met à jour name, slug, description, focus_keyword, icon et image (404 si introuvable, nom obligatoire). Si le slug change, il est re-unicisé par SlugService::generateUnique en excluant l'id courant. Tag::ensureColumns est de nouveau invoqué avant sauvegarde pour sécuriser le schéma. La modification renvoie vers /admin/tags avec un message de succès.
Étiquettes Fusion (anti-doublon / anti-cannibalisation) FusionnerConsoliderSupprimer la source POST /admin/tags/merge consolide une étiquette source dans une cible (rejet si source ou cible manquante, ou si elles sont identiques). Les liaisons article_tags sont déplacées de la source vers la cible via UPDATE IGNORE (les doublons, articles déjà taggés avec la cible, sont ignorés), puis les liaisons résiduelles de la source sont supprimées (DELETE FROM article_tags). Enfin l'étiquette source est retirée définitivement via forceDelete() et non le soft-delete destroy, car une fusion consolide l'étiquette et n'est pas une suppression restaurable. Cet outil élimine les doublons de taxonomie et évite la cannibalisation entre étiquettes redondantes.
Étiquettes Mise à la corbeille SupprimerRestaurer DELETE /admin/tags/{id} appelle TrashService::trash('tag', id). Les liaisons article_tags sont conservées, de sorte qu'une restauration ré-attache l'étiquette à ses articles d'origine. La suppression définitive (avec nettoyage des pivots) n'a lieu que depuis la Corbeille ou le cron de purge. À distinguer de la fusion, qui, elle, supprime définitivement la source consolidée.
Corbeille Vue d'ensemble, onglets & compteurs ConsulterFiltrer par typePaginer GET /admin/trash est un centre unique de soft-delete avec un onglet par type d'entité (article, page, category, tag, media, comment) et un compteur d'éléments par type (TrashService::counts) plus un total. Si aucun type valide n'est passé en paramètre, l'écran ouvre le premier onglet non vide, sinon le premier type déclaré. Chaque section liste ses lignes supprimées triées par deleted_at décroissant (les plus récemment supprimées d'abord), paginées à 20 par page. La fenêtre de rétention (trash_retention_days, défaut 30) est affichée, et l'accès est protégé par les permissions trash.view (lecture), trash.restore (restauration) et trash.purge (suppression et vidage).
Corbeille Mécanique du soft-delete Mettre à la corbeilleDésindexer TrashService::trash(type, id) ne récupère qu'une ligne vivante (scopée), appelle model->trash() (qui pose deleted_at) et retire aussitôt l'entité de l'index de recherche via SearchIndex::deleteForEntity, l'index n'étant sinon reconstruit qu'au reindex complet. Aucune donnée enfant n'est touchée lors de la mise à la corbeille, ce qui est précisément la condition d'une restauration à l'identique. L'action déclenche le hook content.trashed, invalide le cache public concerné et journalise un audit type.trash. Elle retourne false si la ligne est absente ou déjà en corbeille.
Corbeille Restauration RestaurerRé-attacherRéindexer POST /admin/trash/{type}/{id}/restore appelle TrashService::restore, qui recharge la ligne via findTrashed puis model->restore() (efface deleted_at). Comme les enfants (pivots, traductions, meta) n'avaient pas été touchés à la mise en corbeille, l'entité revient intacte avec ses rattachements et réapparaît dans l'index de recherche au prochain reindex. Le hook content.restored est déclenché, le cache invalidé et un audit type.restore enregistré. Un message d'erreur s'affiche si l'élément n'est pas trouvé dans la corbeille.
Corbeille Suppression définitive (cascade) Supprimer définitivementNettoyer en cascade DELETE /admin/trash/{type}/{id} appelle TrashService::forceDelete, qui déclenche d'abord content.deleting (les plugins peuvent encore lire le graphe complet), effectue un nettoyage en cascade des lignes dépendantes (aucune contrainte FK ON DELETE CASCADE en base) puis model->forceDelete(). Nettoyage en cascade selon le type:
• Page: cocon_nodes détaché, page_translations, content_versions, seo_meta, seo_schemas et index de recherche
• Catégorie: article_categories, category_translations, seo_meta, et sous-catégories rebasculées au niveau racine (parent_id=NULL)
• Étiquette: article_tags.
Le cache est invalidé et un audit type.force_delete consigné; l'action renvoie 404 ou une erreur si la ligne n'est pas présente en corbeille.
Corbeille Vider une section ViderPurger en masse POST /admin/trash/{type}/empty appelle TrashService::emptyTrash(type), qui récupère tous les ids en corbeille du type (onlyTrashed puis pluck) et applique forceDelete à chacun, avec le même nettoyage en cascade que la suppression individuelle. Le nombre réellement purgé est retourné et affiché dans le message de succès, et un audit trash.empty est consigné. Cette action de vidage est protégée par la permission trash.purge.
Corbeille Rétention & purge automatique (cron) Purger automatiquementAppliquer la rétention TrashService::purgeExpired(days) est le cron quotidien: il calcule une date de coupe (maintenant moins days multiplié par 86400) et, pour chaque type, force-delete toutes les lignes dont deleted_at est antérieur à cette coupe, avec le nettoyage en cascade complet. La durée de rétention provient du réglage advanced.trash_retention_days (défaut 30 jours, minimum 1). Un audit trash.purge_expired est enregistré dès qu'au moins une ligne est purgée. Ce mécanisme garantit qu'aucun élément supprimé ne subsiste indéfiniment au-delà de la fenêtre de rétention.
Contenu

🖼️ Médiathèque

15 fonctionnalités
Fonctionnalité Actions Fonctionnement exact
Grille de la médiathèque (navigation, filtre par type, recherche, pagination) ParcourirFiltrerRechercherPaginer GET /admin/media affiche une grille paginée de 36 éléments par page, triés par created_at décroissant (les plus récents d'abord). • Le filtre par type applique un whereLike sur mime_type avec le préfixe choisi suivi de « % » (ex. « image% », « video% », « audio% », « application% »), ce qui regroupe images, vidéos, audio et documents. • La recherche (paramètre q) fait un whereLike sur original_name (%terme%). • Le total est compté sur une copie de la requête AVANT pagination pour calculer totalPages = ceil(total / 36) ; l'état des filtres (currentType, search) est renvoyé à la vue pour réafficher les contrôles.
Détail d'un média (JSON) ConsulterInspecter GET /admin/media/{id} renvoie un JSON complet du média enrichi de champs calculés: url publique (getUrl), taille lisible (getHumanSize), icône et couleur d'icône, catégorie de type, et drapeaux is_image, is_video, is_audio, is_pdf. • Les champs title, description, caption, alt_text sont normalisés à chaîne vide si absents. • Le scan d'utilisation (findUsage) est inclus dans data['usage']. • Renvoie 404 si le média est introuvable et 500 (avec journalisation via error_log) en cas d'erreur serveur.
Navigateur AJAX pour l'éditeur riche (TinyMCE) ParcourirSélectionnerPaginer GET /admin/media/browse sert l'insertion d'images dans l'éditeur riche: 20 éléments par page, filtrés par type (défaut « image ») via whereLike sur mime_type. • Le total complet est compté AVANT page() (double clone de la requête) pour que l'appelant puisse afficher « Page X / Y » et désactiver « Suivant » sur la dernière page sans deviner. • Chaque item est renvoyé avec url, human_size, icon et icon_color ; la réponse porte media, total, page, perPage et totalPages.
Téléversement sécurisé 7 couches de validation TéléverserValiderBloquerRandomiser le nom POST /admin/media/upload applique 7 couches strictes. • Couche 1: liste blanche d'extensions (images jpg/jpeg/png/gif/webp/svg/ico/avif, vidéos mp4/webm/ogg/ogv/mov, audio mp3/wav/oga/m4a/flac, documents pdf/doc/docx/xls/xlsx/ppt/pptx/odt/ods/csv/txt/rtf/md, archives zip/gz/tar, polices woff/woff2/ttf/otf). • Couche 2: liste noire d'extensions dangereuses (php, php3-8, phtml, phar, asp, jsp, js, exe, sh, bat, htaccess, ini, sql, etc.). • Couche 3: blocage des doubles extensions (chaque segment intermédiaire vérifié contre la liste noire). • Couche 4: MIME réel via finfo, rejet de application/octet-stream et de tout MIME ne correspondant pas à l'extension whitelistée. • Couche 5: getimagesize obligatoire pour les images (sauf ico) sinon rejet, avec récupération width/height. • Couche 6: limite de taille (setting general.upload_max_size, défaut 50 Mo). • Couche 7: nom randomisé bin2hex(random_bytes(16)) + extension, rangé sous storage/uploads/AAAA/MM. La même chaîne s'exécute intégralement sur le remplacement de fichier.
Assainissement SVG (SvgSanitizer) NettoyerVérifierRejeter Couche 5b du téléversement: tout fichier SVG passe par SvgSanitizer::clean() avant écriture disque. • Les déclarations <!DOCTYPE> et <!ENTITY> sont retirées par regex en amont (parades XXE et « billion-laughs »). • Le DOM est chargé avec LIBXML_NONET (aucun accès réseau) et volontairement SANS LIBXML_NOENT (aucune expansion d'entités). • Les balises actives (script, foreignObject, iframe, embed, object, animate, animateTransform, animateMotion, set, handler, listener) sont supprimées via local-name() (indépendant du namespace), ainsi que les attributs dangereux: gestionnaires on*, URIs javascript: ou data:...script dans href/src/xlink:href, et styles javascript:/expression(). • Défense en profondeur: la sortie nettoyée est re-parsée et ré-inspectée ; s'il reste quoi que ce soit de dangereux, le document entier est rejeté (retour null), l'upload refusé (422) et l'événement journalisé en catégorie « security ».
Dérivés WebP responsives (300 / 768 / 1200) GénérerRedimensionnerStocker Après un upload ou remplacement d'image raster (hors svg et ico), generateThumbnails produit trois dérivés WebP aux largeurs 300, 768 et 1200 px via GD. • Le redimensionnement utilise imagecopyresampled avec transparence préservée (imagealphablending false + imagesavealpha true) et une qualité WebP de 82. • Si la largeur d'origine est inférieure ou égale à la largeur cible, l'image source est réutilisée telle quelle (aucun agrandissement). • Les fichiers sont nommés {stem}-{largeur}w.webp à côté de l'original ; les chemins sont enregistrés dans path_300, path_768 et path_1200 en conservant le préfixe « uploads/ » pour que la srcset résolve correctement /storage/... (sinon les variantes renverraient 404).
Poster vidéo (extraction de première image) ExtraireTranscoderStocker À l'upload d'une vidéo (mime video/*), VideoPosterService::generate tente d'extraire une image de couverture via ffmpeg, invoqué par proc_open avec un TABLEAU d'arguments (jamais de shell, donc aucune injection possible), sous timeout mural de 20 s + proc_terminate. • Il tente d'abord une image à 1 s (évite une première frame souvent noire), sinon repli à 0 s. • Le JPEG obtenu est transcodé en WebP (qualité 82) via GD si disponible, sinon le JPEG est conservé ; le chemin est stocké dans poster_path. • Service OPTIONNEL et FAIL-OPEN: si proc_open est désactivé, si aucun binaire ffmpeg n'est trouvé (chemin configuré, PATH, puis emplacements courants) ou si l'extraction échoue, generate renvoie null et l'upload réussit sans poster. • Sur un remplacement vidéo vers image, le poster obsolète est effacé (poster_path remis à null).
Édition des métadonnées ModifierEnregistrer PUT /admin/media/{id} met à jour title, alt_text, caption et description ; chaque champ retombe sur la valeur existante s'il n'est pas fourni. • Les valeurs sont appliquées via fill() puis persistées par save(), et le média mis à jour est renvoyé en JSON (404 si introuvable). • À l'upload, alt_text est renseignable dès le POST et title est pré-rempli automatiquement avec le nom de fichier d'origine sans son extension.
Remplacement de fichier (même ID) RemplacerRevaliderRégénérer POST /admin/media/{id}/replace remplace le binaire tout en conservant l'ID du média (les références de contenu restent valides). • La chaîne complète de validation en 7 couches (et l'assainissement SVG) est ré-exécutée sur le nouveau fichier. • Les anciens fichiers physiques (principal + dérivés 300/768/1200 + poster) sont supprimés via @unlink. • Un nouveau nom randomisé est généré, les colonnes filename, original_name, mime_type, extension, size, path, width et height sont mises à jour, puis les dérivés WebP et le poster sont régénérés tous réinitialisés pour le nouveau fichier, si bien qu'un remplacement vidéo vers image efface le poster (poster_path = null).
Éditeur d'image côté serveur (GD) PivoterRetournerRecadrerRedimensionner POST /admin/media/{id}/edit-image édite l'image en place via l'extension GD (les formats svg et ico sont refusés). • Actions supportées: rotate (angle en degrés, imagerotate appliqué en -angle), flip (horizontal ou vertical via IMG_FLIP_HORIZONTAL/VERTICAL), crop (x/y/w/h bornés à des valeurs sûres, imagecrop), resize (w/h, imagecreatetruecolor + imagecopyresampled avec canal alpha préservé). • La sauvegarde respecte le format d'origine: imagepng, imagegif, imagejpeg (qualité 90) ou par défaut imagewebp (qualité 82). • width, height et size sont recalculés et enregistrés, les dérivés WebP sont régénérés, et l'URL renvoyée porte un cache-buster ?v=timestamp pour forcer le rafraîchissement.
Suppression et suppression groupée (corbeille) SupprimerSélectionnerPurger en différé DELETE /admin/media/{id} déplace le média vers la Corbeille (TrashService::trash): les fichiers sur disque ET les références article/page sont CONSERVÉS pour permettre une restauration, et ne sont retirés qu'à la purge définitive depuis la Corbeille ou par le cron de rétention (forceDelete). • POST /admin/media/bulk-delete accepte une liste d'IDs (tableau ou CSV), filtre les valeurs inférieures ou égales à 0, plafonne à 100 éléments (anti-abus) et déplace chacun vers la corbeille ; la réponse renvoie le nombre supprimé, files_removed restant à 0 puisque la suppression physique est différée.
Scan d'utilisation AnalyserLocaliser GET /admin/media/{id}/usage (et le champ usage de la fiche détail) recherche où le fichier est effectivement référencé. • findUsage cible le « stem » unique (le hash aléatoire du nom de fichier) plutôt que l'URL complète, afin de détecter aussi bien l'original que n'importe quel dérivé -300w/-768w/-1200w qui partagent ce stem ; si le stem fait moins de 8 caractères, repli sur l'URL complète comme aiguille de recherche. • Il interroge les tables articles et pages (content LIKE %stem% OU featured_image = id, avec LIMIT 50 chacune) et renvoie, par occurrence, le type, l'id, le titre et le lien d'édition admin. • Échoue silencieusement (usage vide) si les tables sont absentes.
Générateur de plan thématique (cocon) formulaire et génération IA OuvrirSaisirGénérer GET /admin/thematic (ThematicController) affiche le formulaire avec les articles piliers existants (cocon_type = pillar, statut published, triés par titre) et le budget IA courant (checkBudget) ; redirige vers /admin/ai avec un flash d'erreur si l'IA n'est pas configurée. • POST /admin/thematic/generate produit le plan via AiService::generateThematicPlan à partir d'un thème (obligatoire, sinon 400), d'une langue (résolue par LanguageResolver::forGeneration) et d'un nombre d'articles borné entre 3 et 30. • La réponse du modèle est débarrassée de ses clôtures ```json par regex puis décodée en JSON (repli sur {raw:...} si non parsable) ; l'usage est renvoyé (tokens, coût USD, modèle).
Application du plan (brouillons + génération en arrière-plan) AppliquerCréer les brouillonsEnfiler la génération POST /admin/thematic/apply valide que le plan est un tableau JSON de LISTE (array_is_list les objets JSON sont rejetés en 400) plafonné à 100 articles. • ThematicGeneratorService::applyPlan crée d'abord SYNCHRONEMENT et sans appel IA les coquilles brouillon: le pilier est traité en premier pour câbler pillar_id et parent_id sur les clusters/supports ; chaque coquille reçoit un slug unique, un uuid, un squelette d'outline avec marqueur de langue invisible (commentaire HTML) et generation_status = 'pending'. • Chaque mot-clé de focus est contrôlé par AntiCannibalizationService: un mot-clé déjà « possédé » fait sauter l'article (skipped). • Une seule tâche thematic_generate est ensuite enfilée dans scheduled_tasks: le worker pseudo-cron écrit UN article complet par tick (FPM-safe, un appel Claude, non-piliers d'abord puis pilier), tisse jusqu'à 6 liens internes (bloc « See also » / « In this series ») et ne publie JAMAIS automatiquement (statut reste draft). • La réponse renvoie created, skipped, failed, queued et ids.
Suivi de la génération (polling + SSE) SonderDiffuser la progression GET /admin/thematic/status (poller de apply) reçoit une liste d'ids et renvoie le décompte par état de generation_status: total, pending, generating, generated, failed. • Un endpoint SSE de progression existe (Sse::open(600)): l'ID de progression est validé contre le path traversal (regex alphanumérique + tirets, 1 à 64 caractères) et un fichier cache temporaire est lu pour émettre des événements phase/current/total/percent. • Comme la génération réelle est pilotée par le cron et n'écrit pas ce fichier cache, l'endpoint émet immédiatement un événement terminal (status idle/done) plutôt que de tenir la connexion ouverte pendant les 120 s de timeout.
Interactions

💬 Commentaires, Messages de contact, Formulaires, Newsletter

15 fonctionnalités
Fonctionnalité Actions Fonctionnement exact
Modération des commentaires (admin) ListerFiltrerApprouverMarquer spamSupprimerActions groupées Liste paginée (20/page) via GET /admin/comments avec badge du nombre en attente et total dans l'en-tête. Filtrage par pastille de statut Tous / En attente / Approuvés / Spam (tout statut inconnu est ramené à « Tous »). Actions par ligne: approuver (PUT .../{id}/approve), marquer spam (PUT .../{id}/spam), suppression douce vers la Corbeille (DELETE .../{id}) via TrashService (donc restaurable, non définitive). Actions groupées approuver / spam / supprimer sur sélection (POST /admin/comments/bulk) avec case « tout sélectionner » gérée en Alpine, plus lien profond vers l'article parent. Permissions: comments.view pour lister, comments.moderate pour toute mutation.
Soumission publique de commentaire (auto-modération) SoumettreFiltrer anti-spamClassifierLimiter par IP POST /comment depuis un article publié, avec redirection vers #comments portant un drapeau ?comment= de statut; un commentaire n'est JAMAIS publié directement. Peut être coupé globalement par antispam.comments_enabled (renvoie 404). Défenses: honeypot (champ website) et time-trap signé; un bot est silencieusement stocké en pending sans fuite de signal. Validation: nom requis (max 100), contenu requis (max 5000), email facultatif au format vérifié; rejet si l'article est inexistant ou non publié; CAPTCHA optionnel si un fournisseur est activé pour « comments ». Plafond de fréquence par IP (antispam.comment_max_per_hour, défaut 5/h) puis classification: trop de liens (comment_max_links) ou mot banni (comment_banned_words) => stocké en spam, sinon pending; le webhook/hook comment.created n'est déclenché que pour les commentaires non-spam. Le rendu public n'affiche que les commentaires approuvés via Comment::approvedForArticle().
Boîte de réception des messages de contact (admin) ListerFiltrerChanger statutSupprimerActions groupéesRépondre Liste paginée (20/page) via GET /admin/contact avec badge non-lus et total. Filtrage par pastille Tous / Non lus / Lus / Répondus / Spam et changement de statut par message (PUT .../{id}/status vers unread/read/replied/spam). Suppression définitive par ligne (DELETE .../{id}) SANS corbeille, contrairement aux commentaires. Actions groupées marquer lu / répondu / spam ou supprimer (POST /admin/contact/bulk) avec « tout sélectionner ». Chaque message affiche l'adresse IP de l'expéditeur et propose un lien de réponse rapide mailto: pré-rempli « RE: » avec email et sujet. Permissions partagées avec les commentaires: comments.view pour lister, comments.moderate pour les mutations.
Soumission publique du formulaire de contact SoumettreValiderPersisterNotifier POST /page/contact renvoyant TOUJOURS du JSON pour la soumission fetch du thème. Honeypot et time-trap (form_min_seconds): un bot reçoit un faux succès et rien n'est stocké. Validation nom / email (format) / message requis => 422 avec erreurs par champ; plafonds nom 150, sujet 255, message 5000 et sujet par défaut si vide. Le message est persisté en statut unread avec l'IP dans contact_messages; en cas d'échec DB, journalisation et réponse 500. Plafond par IP (antispub.contact_max_per_hour antispam.contact_max_per_hour, défaut 5/h) renvoyant 429. Une notification email HTML est mise en file asynchrone vers l'adresse from_email du site (au mieux, le message n'est jamais perdu) et le webhook/hook contact.created est déclenché.
Constructeur de formulaires (admin, CRUD) CréerÉditerSupprimerAjouter/réordonner champsConfigurer livraison/stockage/RGPD Liste des formulaires avec nombre de champs, nombre de soumissions, shortcode et statut (GET /admin/forms); création, édition et suppression complètes (la suppression détruit aussi toutes les soumissions, les fichiers téléversés et le dossier d'upload). Schéma de champs répétable côté client (Alpine) normalisé et validé côté serveur: 8 types (text, email, tel, textarea, select, checkbox, radio, file), max 40 champs, ajout/retrait/déplacement haut-bas. Config par champ: libellé, nom machine (auto-slug unifié), placeholder, aide, obligatoire, options (select/radio/checkbox), regex de validation vérifiée à la compilation (abandonnée si invalide). Réglages du formulaire: slug (auto-unique via SlugService), description, statut actif/inactif, message de succès, bascule de notification email + email de remplacement, bascule de stockage en base store_submissions + retention_days (0 = défaut global), bascule consentement RGPD require_consent + texte personnalisé. Génération auto d'UUID + created_by, journalisation d'audit form.create / form.update / form.delete; permissions forms.view / forms.create / forms.edit / forms.delete.
Soumissions de formulaire: boîte, export CSV, téléchargement fichiers (admin) ListerFiltrerChanger statutSupprimerExporter CSVTélécharger fichier Boîte par formulaire (GET /admin/forms/{id}/submissions, 25/page) avec valeurs de champs rendues et mise en évidence visuelle des non-lues + lien mailto vers l'auteur. Filtrage Tous / Non lus / Lus / Spam et bascule de statut par soumission (PUT .../submissions/{sid}/status). Suppression d'une soumission avec ses fichiers téléversés (DELETE .../submissions/{sid}, audit form.submission_delete) et actions groupées lu/non-lu/spam ou supprimer (POST .../submissions/bulk). Export CSV de toutes les soumissions avec colonnes de champs dynamiques + ID/Date/Statut/IP et BOM UTF-8 pour Excel (GET .../submissions/export). Téléchargement d'un fichier stocké pour un champ (GET .../submissions/{sid}/file/{field}) avec validation de chemin garantissant que le fichier reste sous storage/forms (stockage privé).
Formulaires publics (page /form/{slug}, shortcode, pipeline) AfficherInsérer via shortcodeValiderTéléverserPersisterNotifier Page thématisée autonome GET /form/{slug} (méta noindex,follow, formulaires actifs uniquement) et expansion du shortcode [form slug="x"] ou [form id=N] dans tout contenu de page/article (filtre content.render). Rendu par type: file avec liste accept, groupes checkbox simple/multiple, radio, select, marqueurs requis, attribut pattern regex, texte d'aide. POST /form/{slug} renvoie du JSON en AJAX ou une page-résultat thématisée avec formulaire re-rendu et bandeau d'erreur en no-JS (amélioration progressive). Défenses: honeypot + time-trap (form_min_seconds / form_max_age) => faux succès silencieux; validation par champ (requis, email, motif tel, liste blanche d'options, regex) => 422; consentement RGPD imposé si require_consent; CAPTCHA optionnel pour « forms »; plafond par IP (antispam.form_max_per_hour, défaut 10/h => 429). Upload sécurisé: plafond de taille (max_size_mb), liste blanche d'extensions, blocage double-extension et exécutables, reniflage MIME (rejet html/svg/php/js), nom de fichier aléatoire sous storage/forms/{id}/Y/m privé. Persistance (données JSON, nom/email extraits, IP, user-agent, drapeau + horodatage de consentement) et incrément de submission_count si store_submissions; notification email avec tableau des champs vers notify_email/email du site si activée; hook form.submitted déclenché.
Purge de rétention des soumissions (cron) PurgerEffacer fichiers Tâche cron cleanup_form_submissions déclenchée par le PseudoCronMiddleware, appelant FormService::purgeFormSubmissions(defaultDays). Chaque formulaire peut surcharger le délai global via son retention_days; la valeur 0 conserve indéfiniment. Avant la suppression définitive des lignes hors fenêtre de rétention, les fichiers référencés par ces lignes sont dissociés (supprimés) du disque. Suppression permanente et irréversible (pas de corbeille).
Abonnés newsletter (admin) ListerFiltrerSupprimerExporter CSV Liste des abonnés (jusqu'à 200) avec email, nom, statut, langue, source et date d'inscription (GET /admin/newsletter), et filtrage par puces avec compteurs en direct Tous / Confirmés / En attente / Désinscrits. Effacement d'un abonné (POST .../subscribers/{id}/delete, audit newsletter.subscriber_delete). Export CSV de tous les abonnés avec BOM UTF-8 (GET /admin/newsletter/export, audit newsletter.export) protégé contre l'injection de formule (neutralisation CWE-1236: préfixage des cellules commençant par = + - @). Permission requise: newsletter.manage.
Campagnes newsletter (admin) ComposerPrévisualiserInsérer derniers articlesTesterEnvoyer Historique des campagnes avec statut Draft/Sending/Sent, envoyés/total et date (GET /admin/newsletter/campaigns) et vue détail montrant statut, compteurs, dates créé/envoyé et HTML brut du message (GET .../campaigns/{id}). Composition sujet + corps HTML (GET/POST .../campaigns/compose) avec aide « insérer les derniers articles » qui pré-remplit le corps d'un digest HTML des 5 articles publiés les plus récents (?prefill=latest). Envoi d'un email de test du brouillon courant à une adresse arbitraire (action=test). Envoi réel à tous les abonnés confirmés (action=send): la campagne est persistée puis un message par abonné confirmé est mis en file, avec garde anti-double-envoi via revendication d'état draft->sending et bouton désactivé si 0 confirmé. Chaque message porte un pied de désinscription automatique et un en-tête List-Unsubscribe (RFC 8058); audit newsletter.campaign_send avec le nombre mis en file. Permission: newsletter.manage.
Inscription publique newsletter (double opt-in) S'inscrireConfirmerSe désinscrireAnti-spam Page publique thématisée GET /newsletter (avec remontée des flash) et POST /newsletter/subscribe qui crée un abonné en attente et envoie un lien de confirmation (JSON en AJAX ou redirection + flash). Défenses: honeypot + time-trap et succès neutre pour les bots; anti-énumération via réponse identique et neutre pour nouveau / en attente / déjà abonné. Un jeton de confirmation à usage unique est stocké HACHÉ (SHA-256) avec TTL 48h; le jeton brut n'est envoyé qu'une fois par email, jamais stocké. Cooldown anti-bombardement de 120s pour les ré-inscriptions en attente répétées; horodatage du consentement + IP + source + langue enregistrés. GET /newsletter/confirm/{token} termine le double opt-in (atomique, usage unique, expiration vérifiée); GET /newsletter/unsubscribe/{token} = désinscription humaine 1-clic (idempotente, jeton stable); POST /newsletter/unsubscribe/{token} = one-click RFC 8058 (exempt CSRF, authentifié par jeton, réponse 200 « OK » minimale). Routes subscribe et one-click limitées en débit.
Widget d'inscription newsletter AfficherConfigurerPoster (double opt-in) Formulaire de widget auto-suffisant rendu par NewsletterService::widgetFormHtml() avec titre / description / bouton configurables, exposé comme type de widget « newsletter » dans le système de widgets. Le markup embarque des styles en ligne, un jeton CSRF, le champ honeypot website, un jeton de temps signé et un champ caché source=widget. Il se dépose dans n'importe quelle zone de widget du thème et poste vers le point d'entrée du double opt-in, réutilisant donc tout le pipeline anti-spam et de confirmation.
Intégration RGPD export / effacement newsletter ExporterEffacer par email Toutes les lignes d'abonné associées à un email peuvent être exportées (NewsletterService::exportForEmail) et effacées (NewsletterService::deleteForEmail). Ces opérations sont câblées dans le pipeline de données du sujet RGPD via RgpdDataService sous la clé de couverture newsletter_subscriptions (table des abonnés), assurant à la fois le droit d'accès (portabilité) et le droit à l'effacement (« droit à l'oubli ») par adresse email.
Moteur anti-spam (SpamFilterService) Piéger honeypotSigner/valider time-trapClassifier contenuCompter par IP Moteur léger et sans dépendance à 4 couches indépendantes. Honeypot: toute valeur non vide d'un champ invisible trahit un bot. Time-trap: un horodatage de rendu est signé par HMAC-SHA256 avec secret_key (jeton timestamp:signature); à la soumission on vérifie la signature par hash_equals (anti-forge) puis que l'âge est compris entre minSeconds et maxAge un POST « à l'aveugle » sans jeton valide ou trop rapide est rejeté. Classification de contenu: comptage des liens (motifs http(s)://, www., [url) au-delà de maxLinks, URL détectée dans le nom d'auteur (url_in_name), et mots bannis (sous-chaîne insensible à la casse depuis une liste CSV) => spam avec raisons. Fréquence par IP: comptage des lignes récentes dans une table sur liste blanche (contact_messages, comments, form_submissions) sur une fenêtre en secondes; en cas d'erreur SQL le compteur échoue « ouvert » (fail-open) pour ne jamais bloquer un utilisateur légitime.
CAPTCHA optionnel (CaptchaService) Détecter configAfficher widgetVérifier côté serveur Défi CAPTCHA respectueux de la vie privée, entièrement piloté par la configuration (config/app.php['antispam'] alimenté par .env), supportant Cloudflare Turnstile ou hCaptcha. Inerte par défaut: sans fournisseur ni les deux clés, isConfigured() est faux, aucun script/widget n'est émis et verify() passe le formulaire fonctionne alors sur honeypot + time-trap seuls (dégradation gracieuse). Activation par formulaire via enabledFor('comments'/'contact'/'forms') (antispam.captcha_on_{form}); le widget est un conteneur div + script externe autorisé en CSP (pas de code en ligne), le champ de réponse est cf-turnstile-response ou h-captcha-response. La vérification serveur poste le token à l'API siteverify du fournisseur (cURL avec repli sur contexte de flux, timeout 5s): jeton manquant => échec, et toute panne de transport échoue « fermé » (fail-closed, journalisée) pour qu'une panne provoquée ne soit pas une porte dérobée.
SEO & Sémantique

🔎 Gestion SEO & Redirections

19 fonctionnalités
Fonctionnalité Actions Fonctionnement exact
Tableau de bord SEO (vue d'ensemble) ConsulterNaviguer Page d'accueil admin du SEO (route GET /seo -> seo.view, SeoController::index) rendue par app/Views/admin/seo/index.php. Affiche 4 cartes KPI calculées côté serveur (lignes 28-100 du contrôleur): nombre de redirections actives, statut « Actif » du sitemap, nombre de types de schémas JSON-LD distincts, et score SEO moyen 0-100 coloré selon le palier atteint. Propose des liens rapides vers le gestionnaire de redirections et vers le /sitemap.xml public. Inclut un tableau des redirections récentes (source, cible, badge de type 301 ou 302, compteur de hits).
Top mots-clés focus ConsulterAnalyser Liste les 10 principaux focus keywords agrégés depuis la table seo_meta, chacun accompagné du nombre de pages qui le ciblent. Sert d'aperçu de la couverture éditoriale et met en évidence une éventuelle sur-représentation d'un même mot-clé (risque de cannibalisation). Calcul réalisé dans SeoController::index.
Détection de pages orphelines DétecterÉditer Détecte, via une requête dédiée, les pages sur lesquelles aucun lien interne ne pointe. Chaque entrée expose un lien direct vers l'édition de la page concernée pour corriger le maillage interne. Outil de diagnostic de l'architecture de liens, affiché sur le tableau de bord SEO.
Éditeur robots.txt inline ÉditerEnregistrer Éditeur Alpine.js intégré au tableau de bord permettant d'afficher et de modifier le contenu de robots.txt sans quitter la page. La sauvegarde POST vers /admin/settings avec tab=seo et persiste le réglage seo.robots_txt. Fournit une édition rapide du fichier depuis le hub SEO.
Éditeur de balises méta par entité ChargerValiderEnregistrer Lit/écrit les balises SEO d'une entité via GET /seo/meta/{entityType}/{entityId} (editMeta, renvoie le méta en JSON) et POST /seo/meta (saveMeta), avec whitelist stricte des types article, page, category, tag, home, archive. Champs gérés: meta_title, meta_description, meta_keywords, og_title, og_description, og_type, twitter_card, twitter_title, twitter_description, canonical_url, robots, focus_keyword, secondary_keywords. Validation: canonical_url via FILTER_VALIDATE_URL, robots (combinaisons index, noindex, follow, nofollow), og_type (article, website, blog, profile), twitter_card (summary, summary_large_image, app, player). À l'enregistrement, recalcule et persiste seo_score + readability_score quand focus keyword et contenu sont présents, avec une cible de mots par type (page vs article). Stockage via le modèle SeoMeta (findForEntity, findOrCreateForEntity).
Moteur de score SEO (15 critères) AnalyserNoter Analyseur déterministe 100% PHP (ContentAnalyzer::analyze), sans appel IA et quasi instantané (<100ms), avec garde-fou anti-DoS (contenu >500 000 octets ignoré). Évalue 15 critères totalisant 110 points bruts normalisés sur 0-100: mot-clé en début de titre (10), longueur de titre 55-65 car. (8), mot-clé en H1 (8), secondaires en H2 (10), lexicaux en H3 (8), mot-clé dans l'intro 300 premiers car. (8), densité 0,50-2,40% (10), mot-clé en &lt;strong&gt; (5), paragraphes ≥8 mots (5), mots de transition ≥30% (7), phrases longues ≤25% (7), présence d'images (5), attribut alt (4), longueur min. 2500 mots article ou 1500 page (10), méta-description 120-155 car. (5). Renvoie le détail par critère (points gagnés, réussi ou non, message-conseil) plus des points bonus informatifs (FAQ, liens externes, tableau, média riche).
Analyse de lisibilité Flesch-Kincaid FR MesurerClasser Calcule un score de lisibilité 0-100 via la formule Flesch adaptée au français: 207 − 1,015 × (mots/phrases) − 73,6 × (syllabes/mots). Le comptage syllabique s'appuie sur le helper SyllableCounter et l'extraction de phrases protège les abréviations courantes (M., Mme., Dr., etc.) pour ne pas fausser le découpage. Le score brut est mappé sur un palier lisible: Très facile ≥90, Facile ≥80, Assez facile ≥70, Standard ≥60, Assez difficile ≥50, Difficile ≥30, Très difficile en dessous. Multilingue (fr par défaut via normLang, + en, es, pt, de) pour la détection des mots de transition. Retourne aussi les moyennes mots/phrase et syllabes/mot.
Score de citabilité GEO (IA) ÉvaluerDiagnostiquer Score informatif 0-100 estimant la probabilité qu'un moteur d'IA générative cite le contenu (optimisation GEO), somme de 6 facteurs: structure autoritative /20 (définitions, listes &lt;ol&gt;, statistiques), réponse directe /20 (paragraphes de 30-150 mots, 1er paragraphe définitionnel nommant le mot-clé), données structurées /15 (FAQ, &lt;table&gt;, listes), qualité des sources /15 (tournures de citation « selon », « d'après », liens externes https), couverture /15 (≥2000 mots, nombre de H2/H3), fraîcheur /15 (année courante ou précédente, mention « mis à jour »). Les motifs de détection (définition, citation, statistiques) sont spécifiques à la langue (fr, en, es, pt, de). Chaque facteur renvoie son détail (max, points obtenus, libellé).
Scoring à la demande & poids configurables ScorerConfigurer POST /seo/score note un couple titre, contenu, focus_keyword, slug et meta_description arbitraire, avec secondaires et lexicaux optionnels et content_type (article ou page) pilotant la cible de mots (SeoController::calculateScore). GET /api/seo/score/{articleId} renvoie le score stocké d'un article, calculé par le même moteur unifié afin que éditeur, meta box et API concordent. GET /api/seo-weights expose les 10 poids de scoring configurables (meta_title, meta_description, h1_unique, headings, content_length, image_alt, internal_links, keyword_title, slug, excerpt). Tout transite par SeoService::calculateScore, simple wrapper qui délègue à ContentAnalyzer::analyze (source unique de vérité), et retourne le détail des 15 critères + readability_score + citability_score.
Gestionnaire de redirections (CRUD 301/302) ListerCréerModifierSupprimer CRUD complet avec pagination (20 par page, page hors bornes ramenée dans l'intervalle) affichant source, cible, type, hits et statut actif. Création (validateRedirectFields): la source doit commencer par /, la cible être relative ou une URL http(s) valide, ≤500 car., sans auto-boucle, type 301 ou 302, et une source déjà existante est rejetée en 409. Modification via PUT (mêmes règles, avec auto-exclusion lors du contrôle de doublon). Suppression via DELETE. Interface: app/Views/admin/seo/redirects.php (modales d'ajout, d'édition et d'import, handlers JS).
Import CSV en masse des redirections ImporterValider Import groupé depuis un fichier CSV téléversé (≤1 Mo, extension .csv uniquement, ligne d'en-tête ignorée). Chaque ligne est revalidée avec les règles manuelles (source /, cible valide, type 301 ou 302), les doublons sont sautés et la note tronquée à 500 car., puis les compteurs importés/ignorés sont renvoyés. Côté service, RedirectionService::importCsv applique en plus une validation d'URL bloquant //, javascript:, data:, vbscript: et mailto:. Traitement dans SeoController::importCsv (lignes 418-490).
Auto-redirections sur changement de slug + fusion de chaînes GénérerFusionnerDétecter Service RedirectionService qui crée automatiquement une 301 marquée is_auto=1 lorsqu'un slug change (createAutoRedirect(oldSlug,newSlug), en création ou mise à jour). mergeChains() aplatit itérativement les chaînes A->B->C en A->C (maximum 10 passes) pour ne conserver qu'un seul saut. hasCircularRedirect() détecte les boucles jusqu'à une profondeur de 10. validateRedirectUrl() durcit les cibles en bloquant //, javascript:, data:, vbscript: et mailto:.
Middleware d'exécution runtime des redirections IntercepterCompterRediriger Middleware public SeoRedirectMiddleware::handle, enregistré dans la pile publique (routes.php:217), qui à chaque requête cherche une redirection active correspondant au chemin courant via SeoRedirect::findActiveBySource. En cas de correspondance, il incrémente le compteur de hits pour l'analytique (recordHit) puis émet une réponse 301 ou 302 vers la cible. Exécution transparente côté visiteur, sans intervention admin.
Données structurées JSON-LD (Schema.org) GénérerFusionner SchemaService produit des schémas génériques: WebSite (+SearchAction), Organization (logo, contactPoint), Article, WebPage, BreadcrumbList. Types topiques « Cartes Topiques »: HowTo (étapes depuis &lt;ol&gt; ou &lt;h2&gt;), QAPage, ItemList (items depuis &lt;h2&gt;), Product, Service (provider, areaServed), DefinedTerm, Dataset, Review. Des assembleurs génèrent les bundles page d'accueil (WebSite + Organization), article et page. Les schémas custom stockés par entité (table seo_schemas) sont fusionnés cumulativement avec déduplication par @type (le custom l'emporte). Extensible via le hook plugin schema.jsonld; sortie conditionnée par le toggle enable_schema des réglages.
Détection automatique de FAQ (FAQPage) DétecterGénérer Auto-détecte une FAQ dans le contenu (H2/H3 contenant « ? », listes &lt;dt&gt;/&lt;dd&gt;) et produit un schéma FAQPage (extractFaqFromContent, max 10 entrées, dédupliquées). Le résultat s'intègre à la sortie JSON-LD de la page sans balisage manuel des questions/réponses. Implémenté dans SchemaService (faqPage lignes 225-248, extraction 259-314).
Rendu &lt;head&gt; SEO (Open Graph + Twitter Card) AssemblerSurcharger SeoService::renderHead assemble tout le bloc &lt;head&gt; SEO d'une entité: title, meta description, keywords, robots et canonical (conscient du base-path). Open Graph: og:title, og:description, og:type, og:url, og:site_name, plus og:locale (locale courante via mapping fr_FR, en_US...) et og:locale:alternate pour chaque autre langue active. og:image en cascade: override -> média par entité -> image sociale par défaut du site (social.og_default_image, absolutisée si racine-relative). Twitter Card: twitter:card (défaut summary_large_image), twitter:title, twitter:description et twitter:image avec repli sur les valeurs OG. API override() par entité sans écriture en base; hook plugin seo.head pour transformer le HTML assemblé.
Alternates hreflang (&lt;head&gt;) GénérerInjecter HreflangService::generate produit les balises &lt;link rel=alternate hreflang&gt; par langue pour les entités article, page, category et tag, injectées dans le &lt;head&gt; par renderHead (encapsulé dans un try/catch pour ne jamais casser la page). La langue par défaut emploie le slug source non préfixé; les autres /{code}/{slug-traduit} (repli sur le slug source sous préfixe). Émet x-default à l'URL de langue par défaut, se désactive s'il n'existe qu'une seule langue active, et exclut les entités en corbeille (deleted_at). Ne génère un alternate que pour les langues réellement traduites.
Anti-cannibalisation (unicité du focus keyword) VérifierSuggérerAuditer Garde contre deux contenus ciblant le même focus keyword. POST /api/seo/check-keyword (ApiController::checkKeyword) signale les articles en conflit sur un mot-clé, avec exclude id optionnel. validateKeyword() confronte le mot-clé aux articles et catégories existants; suggestAlternative() propose un mot-clé différent via Claude (repli déterministe « keyword-cat-alt »); auditAll() liste tous les focus keywords dupliqués du site. Service: AntiCannibalizationService.
Réglages SEO (onglet Settings) ConfigurerEnregistrer Surface de configuration SEO globale sous Réglages, distincte de l'Agent SEO. Permet de définir default_meta_title et default_meta_description, l'identifiant google_analytics_id et la vérification google_search_console, d'éditer directement robots_txt, de basculer enable_schema (activation de la sortie JSON-LD), et de fixer og_default_image comme repli de partage social (onglet Social). Géré par SettingsController (proxies seo/saveSeo lignes 774-787, schéma seo 617-624, social 631).
SEO & Sémantique

🤖 Fichiers SEO publics & Agent SEO Complété

27 fonctionnalités
Fonctionnalité Actions Fonctionnement exact
Index des sitemaps XML ServirFiltrer (hook)Horodater Sert l'index sur /sitemap.xml et /sitemap (SitemapController->index → SitemapService::generateIndex). Parcourt les 4 types (articles, pages, categories, tags), saute tout type dont SitemapConfig->is_included est faux, et émet une entrée <sitemap> par type inclus avec <loc> absolu (UrlHelper::sitemap) et <lastmod> ISO 8601 UTC. Le document est déclaré dans le namespace sitemaps.org 0.9 et passe par le filtre plugin sitemap.index avant d'être servi. Toutes les valeurs sont échappées via htmlspecialchars ENT_XML1.
Sitemaps par type + configuration GénérerRouterClamper prioritéForcer homepage /sitemap-{type}.xml ou /sitemap/{type} dispatche via generateForType vers un générateur dédié; tout type hors des 4 connus renvoie un urlset vide (contrôleur en 404). Chaque générateur lit SitemapConfig (changefreq, priority bornée entre 0.0 et 1.0 par number_format, include_images) avec des défauts par type (articles 0.8 weekly, pages 0.9 monthly, categories 0.6 monthly, tags 0.4 monthly). Le sitemap pages force la page d'accueil en tête (priority 1.0, changefreq daily) puis exclut la page marquée is_homepage de la boucle. Après génération, last_generated est persisté; le hook sitemap.urlset reçoit le XML et le type, et toutes les réponses sitemap portent l'en-tête X-Robots-Tag: noindex.
Annotations image dans le sitemap Émettre <image:image>Résoudre média Uniquement dans le sitemap articles et si include_images est actif: chaque article avec featured_image émet un bloc <image:image> contenant <image:loc> (URL média absolue), <image:title> (alt_text) et <image:caption> (caption) quand ces champs existent. Le namespace image (google.com/schemas/sitemap-image/1.1) n'est déclaré dans <urlset> que lorsque les images sont incluses (openUrlset(true)). L'image est résolue via Article::featuredImage() puis UrlHelper::media.
Annotations hreflang dans le sitemap Émettre <xhtml:link>Détecter traductionx-default Pour les entités à slug (articles, pages), slugAlternates ajoute une ligne <xhtml:link rel="alternate" hreflang="{code}"> par langue active plus un x-default. La langue par défaut pointe vers le slug source non préfixé; les autres langues interrogent la table {entity}_translations et ne sont émises que si une traduction existe, avec le slug traduit (ou le slug source sous /{code}/ en repli, résolu par P2). Rien n'est produit si moins de deux langues actives ou slug vide; x-default cible l'URL en langue par défaut. Toutes les valeurs sont échappées ENT_XML1.
robots.txt public dynamique ServirReplierFiltrer (hook)Mettre en cache GET /robots.txt (RobotsController->index) sert le réglage seo.robots_txt s'il est non vide, sinon un fallback intégré (buildFallback) qui reproduit le groupe User-agent: * du composeur. Le fallback autorise explicitement /assets/, /themes/, /storage/media/, /storage/uploads/, /storage/ai/, favicon.ico, favicon.svg, manifest.json et interdit /admin/, /api/, les sous-dossiers privés de storage (logs, backups, sessions, exports, cache) ainsi que /search et /recherche, puis une ligne Sitemap absolue. Il ne bloque JAMAIS /storage/ en entier (cela masquerait la médiathèque) et intègre le base path d'installation (sous-dossier). Le hook plugin robots.txt peut transformer le corps (custom ou fallback); la réponse porte Content-Type text/plain et Cache-Control public, max-age=86400.
Flux RSS global & par langue ServirTraduirePréfixer URLs /rss.xml ou /feed sert generateGlobal: les 20 derniers articles publiés (status=published, deleted_at NULL) triés par published_at DESC. Pour une locale non-défaut (/{lang}/rss.xml ou /{lang}/feed), chaque item est superposé avec sa traduction article_translations (title, slug, excerpt) et les URLs sont préfixées /{lang}. Le canal expose title, link, description (réglages généraux), la langue, un lastBuildDate au format RFC 2822 et un atom:link rel="self". La récupération DB (generate*) et l'assemblage XML (buildFeed, pur, sans DB) sont séparés; l'image est résolue en amont dans normalizeItem.
Flux RSS par catégorie ServirJoindre articles404 sur slug inconnu /rss/categorie/{slug}.xml ou /feed/categorie/{slug} appelle generateByCategory qui résout la catégorie par slug (deleted_at NULL) et renvoie null → 404 si inconnue. Les items sont les 20 articles publiés joints via article_categories à cette catégorie, triés par published_at DESC. Le canal reprend le nom, l'URL (UrlHelper::category), la description de la catégorie et un self link /rss/categorie/{slug}.
Enrichissement média & robustesse des items RSS Enrichir image (3 formats)Deviner MIMENettoyer XML Chaque item porte son image de trois façons: <enclosure> (url + length en octets + type MIME), Media RSS <media:content> (+ fileSize, width, height) avec <media:thumbnail> (vignette de 300px de large, hauteur calculée sur le ratio source), et un <content:encoded> en CDATA (img + excerpt en HTML). resolveImage lit la table media (path, path_300, mime_type, size, width, height), devine le MIME par extension si absent et retombe sur la taille disque (filesize sous storage/) si media.size manque. Avant échappement ENT_XML1, les caractères de contrôle C0 interdits en XML 1.0 sont supprimés (sûr en UTF-8), et toute séquence ]]> dans le CDATA est neutralisée.
llms.txt / llms-full.txt (GEO) Servir markdown404 tant que non publiéMettre en cache /llms.txt sert le réglage seo.llms_txt et /llms-full.txt sert seo.llms_full_txt (LlmsController). Si le contenu trimé est vide, la réponse est 404 avec le corps « Not published yet. »; sinon Content-Type text/markdown et Cache-Control public, max-age=3600. Ces fichiers de guidage pour crawlers IA ne sont jamais servis tant qu'un admin n'a pas confirmé une proposition de l'Agent SEO, et leur contenu est versionné (seo_file_versions).
Hreflang alternates (<head>) GénérerRouter selon entitéExclure corbeille HreflangService::generate produit des <link rel="alternate" hreflang="{code}"> pour article, page, category, tag. buildPath route l'URL selon l'entité (article et page à la racine /{slug}, category /category/{slug}, tag /blog/tag/{slug}) pour rester cohérent avec la canonical. La langue par défaut utilise le slug source; les autres n'apparaissent que si une traduction existe (slug traduit, sinon slug source sous /{code}); x-default pointe vers l'URL par défaut et rien n'est émis si moins de deux langues actives. Les entités en corbeille (deleted_at non NULL) sont exclues des alternates.
Agent SEO Mission Fondation (SSE) PercevoirComposerRaffiner (Claude)AuditerStocker POST /admin/seo/agent/mission (action=foundation) ouvre un flux SSE (Sse::open(300)) émettant les labels d'étape (facts, draft, agent, guard, store) et les chunks du modèle. runFoundationMission collecte les faits, compose les brouillons déterministes (robots + squelettes llms et llms-full), puis Claude::stream (max_tokens 16000, temperature 0.3, timeout 300) renvoie un JSON (résumé, analyse, dossier GEO à 3 postures, note de gouvernance, ajustements robots, markdown llms, justifications, risques). Les ajustements whitelisted sont appliqués puis l'audit dur est rejoué; en cas d'échec après ajustements le brouillon pur est restauré. La proposition (fichiers, audit, diffs ligne à ligne, pédagogie, ajustements) est stockée en statut pending et supersède toute proposition foundation pending antérieure; le budget IA est vérifié avant lancement et la connexion DB reconnectée après le long stream.
Agent SEO Mission Veille (SSE) Rechercher le webClasser par familleFiltrerProposer ajouts registre action=watch lance runWatchMission qui interroge Claude avec recherche web (sendWithWebSearch) pour trouver les nouveaux user-agents crawlers documentés après une date, en excluant les bots déjà connus du registre. Chaque bot retourné est filtré: UA validé par regex (2 à 60 caractères), absent du registre, famille dans la liste autorisée (ai_training, ai_search, ai_assistant, search_engine, social, seo_tool, scraper); l'URL source issue du modèle n'est conservée que si http(s), sinon vidée (anti-XSS dans l'admin). last_watch_at est horodaté; si des bots sont trouvés une proposition « watch » (registry_additions) est stockée pour confirmation humaine, sinon un résumé « registre à jour » est renvoyé.
Agent SEO Ask (Q&A ancré, SSE) Ancrer sur l'état réelRépondre en fluxVérifier budget POST /admin/seo/agent/ask ouvre un SSE (180s) et streame la réponse de ask(): Claude répond à la question de l'admin (600 caractères max) STRICTEMENT à partir de l'état réel du site (faits collectés, robots.txt et llms.txt publiés, digest de mémoire), dans la langue de l'UI, en 10 à 20 lignes texte. Le budget IA est vérifié avant; la réponse n'est jamais publiée (contrairement aux missions). Des chips de questions préréglées sont proposées dans la vue agent.
Agent SEO Boîte de propositions + détail pédagogique ListerConsulter JSONAfficher KPIHistorique versions La vue agent liste les 20 dernières propositions (id, mission, statut, titre, résumé, dates) et un historique de 30 versions de fichiers, plus des KPI (nombre de bots du registre, version du registre, propositions pending, dernière veille). GET /admin/seo/agent/proposals/{id} renvoie le payload JSON décodé: pédagogie (résumé, analyse, dossier GEO à 3 postures avec impact, risque et réversibilité, note de gouvernance, risques résiduels), diff ligne à ligne (added/removed plafonnés), matrice d'audit URL×bot et contenu intégral des fichiers proposés.
Agent SEO Publier une proposition Ré-auditer côté serveurRéclamer atomiquementVersionnerMémoriser POST /admin/seo/agent/proposals/{id}/publish (clic humain) exécute publishProposal: pour une mission non-watch, l'audit déterministe est REJOUÉ côté serveur sur le contenu exact (frontière de confiance qu'un POST direct ne peut contourner, le bouton désactivé de l'UI n'étant que décoratif); un échec refuse la publication. Une réclamation atomique (UPDATE ... WHERE status='pending') garantit qu'un seul publish bascule pending → published. Foundation écrit chaque fichier (robots_txt, llms_txt, llms_full_txt) dans seo_file_versions avec version incrémentée et is_active=1 (les précédentes passant à 0) et met à jour le réglage servi; watch fusionne les registry_additions dans seo_agent.registry_extra. En cas d'erreur transactionnelle la réclamation est relâchée pour permettre une nouvelle tentative, et un succès est consigné en mémoire.
Agent SEO Rejeter une proposition RejeterConsigner la raisonAlimenter la mémoire POST /admin/seo/agent/proposals/{id}/reject fait passer la proposition pending à rejected avec la raison humaine (tronquée à 1000 caractères), horodatage et auteur. La raison est enregistrée dans la mémoire de décision (kind=rejected) afin d'influencer les futures missions via le memoryDigest injecté dans les prompts. Une proposition déjà décidée renvoie une erreur 422.
Agent SEO Rollback d'une version de fichier Réactiver versionRepublierMémoriser POST /admin/seo/agent/rollback réactive une version antérieure: la ligne seo_file_versions ciblée (par id + file) devient is_active=1, les autres du même fichier repassent à 0, et le réglage servi (seo.robots_txt, llms_txt ou llms_full_txt) est réécrit avec son contenu, republiant instantanément l'ancienne version. Un file hors mapping (robots, llms, llms_full) ou une version introuvable renvoie une erreur; l'opération est consignée en mémoire (kind=rollback).
Agent SEO Politique GEO / outils SEO Définir posturesValiderMémoriserInviter à régénérer POST /admin/seo/agent/policy enregistre trois réglages (groupe seo_agent): geo_posture (open, selective, closed), seo_tools_posture (allowed, limited, blocked) et auto_publish_minor (0 ou 1), avec validation stricte (422 sinon). Ces postures pilotent la composition déterministe du robots.txt via la matrice de posture (open = toutes familles IA autorisées, selective = entraînement bloqué mais recherche et assistants autorisés, closed = toutes familles IA bloquées). Le changement est enregistré en mémoire (kind=policy) et la réponse invite à relancer la mission pour régénérer les fichiers, la sauvegarde ne régénérant rien à elle seule.
Agent SEO Testeur URL × Bot Tester crawlabilitéRendre le verdictBorner les entrées GET /admin/seo/agent/test?url=&bot= renvoie un verdict allowed ou blocked contre le robots.txt PUBLIÉ via RobotsSimulator::decide, avec la règle décisive et le groupe user-agent correspondant. Les entrées sont bornées (url ≤ 500, bot ≤ 80 caractères, sinon 422). Si aucun robots.txt n'est publié, un verdict allow par défaut est renvoyé avec un message précisant que tout est autorisé sauf /admin et /api. C'est exactement la même logique de décision que celle utilisée par l'auditeur de régression.
Agent SEO Registre de bots crawler CataloguerFusionner ajouts confirmésInterroger par familleExposer matrice SeoBotRegistry::builtin fournit un catalogue versionné (VERSION 2026-06-10) d'environ 90 crawlers répartis en 7 familles (ai_training, ai_search, ai_assistant, search_engine, social, seo_tool, scraper), chacun avec organisation, drapeaux wildcards et crawl_delay, et une note. all() fusionne à la lecture les ajouts humains confirmés stockés en seo_agent.registry_extra (réglage de type json, déjà décodé). byFamily filtre par famille et postureMatrix expose l'autorisation par famille IA selon la posture; ces familles pilotent les groupes du robots.txt généré (moteurs et sociaux toujours autorisés, scrapers toujours bloqués, outils SEO pilotés par la policy).
Agent SEO Composeur robots.txt déterministe Projeter policy+faits+registreGrouper par familleDédupliquer les paramètresSitemaps absolus RobotsComposer::compose projette policy + faits + registre en un robots.txt: en-tête commenté (posture, langues), groupe par défaut (User-agent: *) avec les Allow d'assets et média must-allow, les Disallow standards (préfixes privés, routes de recherche) et la déduplication des paramètres de tracking (utm_, fbclid=, gclid=, msclkid=, ref=, ...) en /*? et /*&. Chaque famille IA reçoit son groupe Allow: / ou Disallow: / selon la posture; comme un bot ayant son propre groupe ignore le groupe * (RFC 9309), le bloc Disallow partagé est répété dans chaque groupe autorisé. Les outils SEO suivent leur posture (bloqués, ou Allow avec Crawl-delay: 10 quand limited et que le bot honore le crawl-delay), les scrapers reçoivent Disallow: /, les bots sociaux Allow: /, puis les lignes Sitemap absolues; les triples sauts de ligne sont normalisés. Le fichier est un pur produit dérivé, non éditable à la main.
Agent SEO Ajustements whitelisted + sanitisation des chemins Appliquer 3 ops autoriséesSanitiser cheminsDécoder %-encoding applyAdjustments n'accepte que trois opérations (add_disallow, add_allow, remove_rule) ciblant un groupe (* ou un user-agent exact); toute autre op ou valeur vide est rejetée et rapportée dans « dropped ». Les insertions se placent juste après la dernière ligne User-agent du groupe, et remove_rule ne balaie jamais au-delà de la frontière du groupe suivant. sanitizePaths exige un chemin commençant par /, sans espaces ni caractères de contrôle, et refuse les synonymes de blocage total (« / », « /* », « /$ »), les motifs sensibles (.env, .git, .sql, .bak, secret, password, .htaccess, .htpasswd) et tout lang= après décodage %-encoding jusqu'à point fixe (decodeFixpoint, 5 passes) pour empêcher les contournements type /%2Eenv ou /*?l%61ng=.
Agent SEO Parseur + simulateur robots (RFC 9309) Parser groupes/wildcardsSélectionner et fusionner le groupeDécider (longest-match, fail-closed) RobotsSimulator::parse implémente un parseur RFC 9309 (groupes, wildcards * et $) ignorant les commentaires sans casser l'état de groupe. matchGroup sélectionne par jeton user-agent le plus long (sinon *) et FUSIONNE tous les groupes partageant ce jeton (RFC 9309 §2.2.1). decide applique le verdict par correspondance la plus longue (à longueur égale, Allow l'emporte sur Disallow), « Disallow: » vide valant tout autoriser. pathMatches compile le motif en regex (* → n'importe quoi, $ ancre la fin) et renvoie null si PCRE échoue (backtracking); ce null est traité en fail-closed (bloqué), jamais fail-open, pour qu'un motif piégé ne fasse pas passer un blocage réel.
Agent SEO Auditeur robots.txt (règles dures + matrice de régression) Vérifier syntaxe/tailleInterdire le blocage racineTester URLs réelles × bots audit applique des règles inviolables: au moins un groupe, taille ≤ 500 Ko, la racine « / » jamais bloquée pour un bot inconnu (groupe *), au moins une ligne Sitemap et toutes absolues (http(s)), aucun chemin sensible listé et aucune règle Disallow bloquant lang= (après décodage %). Puis regressionMatrix teste une grille d'URLs RÉELLES du site (accueil, blog, article récent, CSS du thème actif, image média, sitemap, llms.txt attendus autorisés; /admin, /api, /search, ?utm_ attendus bloqués; catégorie et langue secondaire seulement si elles existent) contre les bots majeurs (Googlebot, Bingbot, GPTBot, OAI-SearchBot, ClaudeBot, PerplexityBot, Twitterbot, AhrefsBot). Toute régression (un bot indispensable ne peut plus crawler une URL autorisée) ou fuite (une URL censée être bloquée reste accessible) ajoute une erreur, et une seule erreur bloque toute proposition ou publication.
Agent SEO Auditeur llms.txt Valider la structureVérifier l'intégrité des liensInterdire les zones privées auditLlms exige un H1 (# ...), recommande un bloc de description (> ...), avertit au-delà de 60 Ko, et surtout vérifie l'intégrité des liens: chaque lien interne du markdown doit pointer vers une URL réelle connue des faits (top_articles, pillars, recent_articles, public_pages, categories, sitemaps, plus /, /blog, /llms.txt, /llms-full.txt), les variantes préfixées par langue étant acceptées; tout lien inventé est une erreur. Toute référence à /admin ou /api est refusée. Le résultat (ok, errors, warnings) conditionne l'acceptation de la version produite par l'agent, sinon le squelette déterministe (composeLlmsSkeleton, construit directement depuis les faits) est conservé.
Agent SEO Perception des faits Collecter site/routes/assets/contenuIntrospecter routes.phpDétecter le drift SeoFactsService::collect agrège: site (nom, description, url, langues actives, langue par défaut), routes (introspection SANS effet de bord de app/routes.php, avec suivi des prefixes de group pour classer public GET, privé /admin et /api, auth, recherche), sitemaps attendus, assets must-allow (assets, thème actif, storage/media, favicons, manifest), inventaire de contenu (compteurs publiés, catégories avec comptes, pool de curation = mieux notés × plus lus × plus récents, piliers via cocon_type, articles récents, pages publiques), policy courante et publishedFacts (nombre de lignes et tête des fichiers publiés, pour détecter le drift face aux drafts). Chaque collecteur est défensif: une source en échec retombe sur une valeur saine plutôt que de casser la mission.
Agent SEO Mémoire de décision Enregistrer les décisionsDigérerInjecter dans les prompts remember insère une entrée (kind = policy, published, rejected ou rollback + contenu tronqué à 2000 caractères) dans seo_agent_memory. memoryDigest relit les 20 dernières décisions et les formate (« [date] kind: contenu ») pour les injecter dans les prompts de la mission fondation et du Q&A, afin que l'agent respecte les choix humains passés. Le timestamp last_watch_at trace la dernière veille. Les échecs d'écriture en mémoire sont silencieux (error_log) et ne cassent jamais la mission en cours.
SEO & Sémantique

🕸️ Topic Clusters & Cocon Sémantique

22 fonctionnalités
Fonctionnalité Actions Fonctionnement exact
Gestion des projets (liste, brouillons, CRUD) ListerCréerOuvrirSupprimerEnregistrer/Éditer brouillon La liste (GET /admin/cocon) sépare les projets en statut 'draft' dans une section « Brouillons » et les projets déjà générés.
• L'ouverture d'un projet (GET /admin/cocon/{id}) renvoie l'arbre en HTML, ou l'arbre + la progression en JSON si l'en-tête Accept: application/json est présent.
• La suppression (DELETE /admin/cocon/{id}) applique un contrôle de propriété masqué en 404 pour un projet d'un autre utilisateur.
• L'enregistrement (POST /admin/cocon/store) route selon mode, draft_id et action: crée un nouveau projet ou met à jour un brouillon existant sur place.
• L'action 'draft' correspond à « Enregistrer pour plus tard », l'action 'create' à « Créer et générer maintenant » (service CoconProjectService: list/create/updateDraft/delete).
Auto-save du wizard (brouillon réservé) Sauvegarder autoReprendreÉditer brouillon nommé POST /admin/cocon/autosave fait un upsert en arrière-plan (debounce ~2s) vers un unique emplacement de brouillon réservé par type et renvoie {success,id}.
• L'appel échoue en douceur (jamais de redirection ni d'erreur) si le mot-clé est vide ou en mode architect.
• À la réouverture du wizard, le brouillon réservé est repris automatiquement via findAutoDraft ('classic' ou 'topical').
• L'édition d'un brouillon nommé précis via ?draft={id} est protégée par contrôle de propriété et de type, et désactive l'auto-save.
• La vue create.php pilote l'état d'affichage (autoStatus / _autoTimer).
Wizard de création classique Cocon Sémantique (Auto vs Architect) Choisir modeChoisir type de contenuRégler langue/profondeur/nb nœuds Formulaire configurant un cocon en mode Automatic (l'IA construit tout l'arbre) ou Architect (semi-manuel).
• content_type = articles ou pages.
• Options réglables: langue, max_depth borné 0-10 (0 = auto), node_count_hint borné 5-100, et notes en texte libre.
• Les bornes de mode, content_type, max_depth et node_count_hint sont validées côté CoconProjectService; les valeurs par défaut passent par wizardData.
Wizard « Cartes Topiques » Topical Map (2026, USA + International) avec auto-remplissage IA Régler scope/marchéSaisir contexte businessSuggérer nom+marché (IA)Suggérer inputs business (IA) Wizard multi-étapes pour un Topical Authority Graph: scope_preset (small, medium ou large whitelist stricte), node_count_hint, max_depth et market/pays.
• Contexte business saisi: produit, concurrents, personas, localisations.
• « Suggérer nom et marché » (POST /admin/cocon/suggest-topic) déduit à partir du mot-clé + langue; « Suggérer inputs business » (POST /admin/cocon/suggest-business) à partir du mot-clé + langue + marché.
• Ces deux aides IA s'exécutent AVANT que le projet existe (aucun id projet), et sont protégées par la permission cocon.create, la limite de débit IA et le budget IA quotidien.
Génération IA de l'arbre de mots-clés classic (SSE) Ouvrir flux SSEGénérer multi-étapesAuto-lancer GET /admin/cocon/{id}/generate-tree ouvre un flux Server-Sent Events émettant des événements progress, complete, error et done, avec heartbeats.
• La génération est multi-étapes via CoconTreeGenerator::generateTreeMultiStep avec callbacks de progression par étape.
• Réservé aux projets classiques: un projet topical est refusé avec un code 409 (il doit utiliser generate-graph).
• Auto-démarrage possible à l'ouverture de l'arbre via ?generate=1 (bouton « Lancer la création »); transport géré par startSse / sendSseEvent / sendSseHeartbeat.
Génération du Topical Authority Graph (SSE, dans la requête) Ouvrir flux SSEConstruire graphe hub-and-spoke GET /admin/cocon/{id}/generate-graph diffuse en SSE (progress/complete/error/done) et pré-valide existence, propriété et mode avant d'ouvrir le flux.
• Construit le graphe topique en étoile (hub-and-spoke) via TopicalGraphGenerator, en miroir de generateTree.
• Réservé aux projets topical: refuse tout projet non-topical avec un 409.
• Générateur « in-request » (exécuté dans la requête HTTP), à distinguer du build arrière-plan build-map pour les grosses cartes.
Build arrière-plan scalable des cartes topiques (build-map) Mettre en fileSuivre progressionPauseRepriseAnnuler POST /admin/cocon/{id}/build-map met en file une reconstruction fraîche: efface les nœuds + arêtes existants, crée un job, marque le projet 'building' et planifie la chaîne du worker.
• GET .../build-map-status renvoie par polling le job {status, stage, nodes_created, target, current_depth, max_depth, percent, active, error}.
• POST .../build-map-control gère pause, resume ou cancel du job actif du projet.
• Le panneau de progression est amorcé sans flash via buildJob dans show(), et l'arbre se rafraîchit à mesure que les nœuds apparaissent.
• Build résumable et checkpointé (de 100 à 20 000+ nœuds) piloté par le scheduler plutôt que par la requête HTTP (service CoconBuildService).
Worker de file d'attente (tâche cocon_build auto-replanifiée) Tick cronTraiter jobÉtendre par lotsCompléter cronTick récupère les jobs 'running' bloqués (heartbeat périmé > 5 min), sélectionne le prochain job en file, se met en attente sur blocage budget ('waiting_budget') et maintient la chaîne vivante.
• processJob revendique atomiquement queued→running, crée la racine une seule fois, étend la frontière par tranches d'environ 45s puis cède la main en se re-mettant en file.
• expandBatch fait un appel IA par lot de parents → jusqu'à 12 enfants chacun, avec page_type, funnel_stage et schema_type assainis par ENUM et déduplication des mots-clés à l'échelle du projet.
• Garde-fou anti-poison: le job échoue après 10 échecs de lot consécutifs; complete() sauvegarde une version de l'arbre et passe le projet à 'ready'.
• estimate() pré-calcule target/effective/branching et le nombre d'appels IA approximatif; plafond absolu de nœuds via advanced.cocon_max_nodes (défaut 20000). Worker breadth-first, budget-aware et résumable au crash.
Dashboard admin de la file de builds Lister buildsContrôler jobsVoir santé schedulerLancer maintenant Écran unique (GET /admin/build-queue) listant les 40 builds les plus récents avec nom de carte, badge de statut, barre de progression (fait/cible), profondeur et date de mise à jour.
• Contrôles par job: Pause, Resume, Cancel, Retry (POST /admin/build-queue/control).
• Santé du scheduler: Actif ou Non-lancé (dernière exécution < 3 min via cron.lock), nombre de scheduled_tasks en attente, affichage du plafond de sécurité.
• « Run now » déclenche immédiatement un tick du scheduler (POST /admin/build-queue/run-now).
• Affiche l'indice d'étape « waiting for AI budget… » sur les jobs mis en attente budget.
Configuration du déclencheur worker (cron système + token web-cron) Générer/rotater tokenDésactiver tokenAfficher instructions cron Génère ou fait tourner le token web-cron (POST /admin/build-queue/token) en produisant une URL GET /cron/run?token=… avec bouton de copie.
• Désactive le token web-cron (POST /admin/build-queue/token-clear).
• Affiche les instructions du cron système avec la commande exacte « php bin/console cron:run » (aucun token requis en CLI).
• L'endpoint public GET /cron/run?token= accepte soit le CRON_TOKEN du .env, soit le web_token stocké en base (comparaison via hash_equals), puis exécute le tick du scheduler.
• Setup clé-en-main: Option A crontab système, Option B URL web-cron secrète.
Édition manuelle de l'arbre / des nœuds RenommerÉditer mots-clésTyperAjouterSupprimerDéplacerRéordonner Renommer un mot-clé de nœud (PUT /update-node, garde 255 caractères); éditer les mots-clés secondaires/lexicaux (PUT /update-node-keywords, garde JSON valide).
• Définir page_type + funnel_stage (PUT /update-node-type; whitelist ENUM de 12 types de page et TOFU/MOFU/BOFU).
• Ajouter un nœud ou une branche racine (POST /add-node); supprimer avec ou sans enfants (DELETE /delete-node): re-parente les enfants, décrémente la profondeur, annule les pointeurs canonical orphelins.
• Déplacer un nœud vers un nouveau parent (POST /move-node; garde anti-cycle empêchant de le déplacer dans son propre descendant); réordonner sort_order en masse (POST /reorder, validation tout-ou-rien).
• CRUD complet protégé IDOR/propriété, avec recalcul de profondeur (recalcDepths) et nettoyage des canoniques.
Assist IA par nœud / branche Régénérer nœudRégénérer brancheSuggérer enfantEnrichir un/tous Régénérer un mot-clé de nœud unique (POST /regenerate-node); régénérer toute une branche (POST /regenerate-branch: supprime puis recrée les descendants).
• Suggérer un mot-clé enfant via IA (POST /suggest-child).
• Enrichir un nœud (POST /enrich-node): mots-clés secondaires + lexicaux, search_intent heuristique, target_word_count selon la profondeur, et brief IA.
• Enrichir tous les nœuds (POST /enrich-all): enrichit les nœuds vides et génère le maillage interne (table cocon_links: liens ascendant/descendant/latéral, link_juice, drapeau pillar).
Outils de structure en masse (mode Architect) Importer structureAjouter enfants en masseMarquer prêt Importer une structure (POST /import-nodes): coller du texte indenté (2 espaces = 1 niveau), ce qui REMPLACE tout l'arbre, max 200 nœuds, avec pré-validation complète avant l'effacement.
• Ajout en masse d'enfants (POST /add-multiple-nodes): jusqu'à 200 mots-clés sous un même parent.
• Marquer le projet prêt (POST /mark-ready; exige au moins 2 nœuds).
• Modal d'import avec aperçu en direct, insertion d'exemple et retrait automatique des préfixes (N0:, -, *).
Cascade sémantique Propager contexte POST /admin/cocon/{id}/semantic-cascade (applySemanticCascadeToProject) propage le contexte sémantique de chaque parent vers ses enfants via IA pour obtenir un arbre plus cohérent, et renvoie le nombre de nœuds mis à jour.
• Déclenchée par un bouton « Cascade » dans la vue tree.php.
Détection + résolution anti-cannibalisation (intra-map) AnalyserCharger cacheRésoudreAuto-corriger Lancer l'analyse (POST /check-cannibalization): un SEUL appel IA sur l'ensemble des nœuds, met les conflits en cache sur le projet avec un checked_at.
• Charger la dernière analyse en cache sans appel IA (GET /cannibalization).
• Résoudre un conflit (POST /cannibalization/resolve) par merge, differentiate, canonical ou delete (avec gardes anti-boucle/self/descendant et repointage du canonical).
• Auto-corriger tout (POST /cannibalization/autofix): applique jusqu'à 200 résolutions, avec échec en douceur par item.
• UI: chips KEEP/CHANGE liés dans l'arbre, correctif recommandé + alternatives par conflit. Détecte les pages en concurrence pour une même intention.
Matrice de couverture sémantique Afficher couvertureFiltrer GET /admin/cocon/{id}/coverage renvoie une carte mot-clé→usages par nœud plus des statistiques: total_keywords, orphan_keywords (usage unique), well_covered (3 usages ou plus) et total_nodes.
• Vue croisée montrant où chaque mot-clé est employé (rôle primary, secondary ou lexical), signalant les orphelins et les termes bien couverts.
• Modal avec filtre et légende de rôle P/S/L.
Versioning / rollback de l'arbre Snapshot autoLister versionsRestaurer saveTreeVersion() écrit un instantané après chaque mutation (édition de nœud, déplacement, import, résolution de conflit) et à l'achèvement d'un build.
• GET /admin/cocon/{id}/versions liste les versions stockées.
• POST /admin/cocon/{id}/restore-version restaure par version_index (reconstruit l'arbre; 422 sur index invalide).
• API back-end (getVersions / restoreVersion côté CoconProjectService); aucun bouton de restauration dédié n'a été trouvé dans tree.php.
Construction de la structure en entités (SSE) Vérifier étatDiffuser build SSE POST /admin/cocon/{id}/build vérifie que le projet est dans un état constructible (ready ou draft).
• GET /admin/cocon/{id}/build-progress diffuse en SSE (progress/complete/error/done) l'exécution de CoconStructureBuilder::build.
• Transforme l'arbre en véritables entités page/article.
Writing Studio (rédaction IA du contenu des nœuds) Lister nœudsEstimer coûtRédiger (SSE)Réécrire/AméliorerPause/Skip/StopMarquer terminé Liste les nœuds en attente/échec à rédiger (GET /pending-nodes) et estime coût + appels IA + durée avec coûts par modèle en direct et usage du compte (GET /estimate-cost?action=generate ou write&model=).
• Rédige le contenu (GET /write-progress en SSE) en mode node, branch ou all, avec override de modèle optionnel et maintien par heartbeat; describeError catégorise les échecs.
• Re-vérifie le content_status d'un nœud après un flux interrompu (GET /node-status); réécrit un nœud (POST /rewrite-node SSE) et améliore un nœud (POST /improve-node SSE).
• Demande ou annule l'annulation via un flag de settings projet (POST /cancel-writing, /clear-cancel); marque le projet terminé uniquement s'il ne reste aucun nœud en attente/échec (POST /mark-completed).
• UI Studio: carrousel avec score SEO, Pause/Resume, Skip, Stop, attente inter-page de 30s et modal de confirmation de coût. Rédaction en streaming avec estimation de coût, sélection de modèle, budget affiché et cadençage par page.
Publication des entités du cocon PublierDépublierDéclencher hook POST /admin/cocon/{id}/publish (mode node, branch ou all) passe les articles/pages au statut published.
• POST /admin/cocon/{id}/unpublish les repasse en draft.
• Détecte la transition draft→published et déclenche Hooks::doAction('content.published') par entité (pour plugins et webhooks).
• Publication/dépublication au périmètre nœud, branche ou projet entier (changeEntityStatus).
Export du projet (JSON / CSV) Exporter fichier GET /admin/cocon/{id}/export?format=json ou csv télécharge l'arbre du projet en pièce jointe (en-tête Content-Disposition attachment, nom de fichier cocon-&lt;keyword&gt;.&lt;ext&gt;).
• Sérialisation gérée par CoconProjectService::export.
Modes de visualisation de l'arbre + inspecteur de nœud Changer de vueAjuster/Exporter PNGRafraîchirFiltrerInspecter Six modes de vue sur l'arbre/graphe: List, Mind Map, Org Chart, Tree H, Network (D3) et Graph (Cytoscape).
• Mode Graph: fit-to-screen et Export PNG; arêtes verticales (parent) + latérales (topiques) via GET /graph-data.
• Rafraîchissement JSON de l'arbre en direct (GET /tree-data) après les éditions et pendant le polling du build arrière-plan.
• Recherche/filtre de mots-clés, Expand all / Collapse all, menu contextuel au clic-droit.
• Panneau de détails du nœud: mot-clé, volume, difficulté, profondeur, page_type/funnel, mots-clés secondaires/lexicaux, target word count, brief et statut de contenu.
Intelligence Artificielle

IA Générateurs & tableaux de bord

22 fonctionnalités
Fonctionnalité Actions Fonctionnement exact
Tableau de bord IA (Assistant IA) ConsulterSurveillerLancerRécupérer (JSON) Page d'atterrissage du sous-système IA (GET /admin/ai) exposant l'état de configuration: API Claude (Active ou Non configurée la simple présence de la clé sert de signal d'activation, sans drapeau séparé), état image WaveSpeed, et une carte de disponibilité du streaming SSE.
Affiche les KPI de dépense/usage par utilisateur: coût du jour vs limite quotidienne et coût mensuel vs limite mensuelle avec barres de progression, plus le nombre de requêtes du jour et du mois.
Propose des tuiles de lancement rapide (Générer du contenu, Générer des images la tuile image est masquée quand WaveSpeed n'est pas configuré) et une tuile 'AI Analytics' désactivée (placeholder 'Coming in Step 6', non implémentée).
Un tableau des 10 dernières requêtes IA (type, modèle, tokens, coût, durée, date) est affiché, et l'endpoint GET /admin/ai/usage renvoie les stats d'usage en direct au format JSON; une bannière d'avertissement + lien 'Go to AI Settings' apparaît si non configuré. Route protégée par RateLimitMiddleware('ai') et permission ai.use.
Générateur de contenu mode Générer un article GénérerDiffuser (SSE)Copier Atelier de contenu autonome (/admin/ai/content). Le mode Générer produit un article soit en streaming SSE (GET /ai/stream-article) avec chunks de tokens en temps réel, soit en mode bloquant (POST /ai/generate-article).
Paramètres: sujet/mot-clé, plan (outline), mots-clés secondaires et lexicaux, nombre de mots (borné entre 200 et 5000), langue.
Quand aucun plan brut n'est fourni, le prompt est construit via PromptBuilder::buildStandard; le mode bloquant enregistre la sortie dans la table ai_generated_content.
Le flux SSE relève max_execution_time à 600s et désactive le buffering de sortie (Sse::open) pour que les longues générations ne soient pas interrompues; chaque run affiche ses tokens, son coût et son modèle.
Générateur de contenu mode Améliorer/réécrire AméliorerRéécrireDiffuser (SSE)Copier Le mode Améliorer prend un contenu collé + des instructions libres et le réécrit en streaming SSE (GET ou POST /ai/stream-improve).
Il peut consommer un JSON analysis_results pour cibler des corrections précises (issues d'audit à corriger).
Le prompt est bâti via PromptBuilder::buildImprove et le plafond max_tokens est relevé à 8192 afin de permettre une réelle expansion du texte.
La sortie est copiable dans le presse-papiers et affiche les tokens, le coût et le modèle du run.
Générateur de contenu Méta SEO GénérerCopier Le mode Méta (POST /ai/generate-meta) génère un couple meta title + meta description avec des cibles de longueur strictes: 55–65 caractères pour le titre, 140–155 pour la description.
La réponse renvoie des champs aplatis meta_title et meta_description directement exploitables.
Le run expose les tokens, le coût et le modèle consommés.
Générateur de contenu Suggestions Suggérer Le mode Suggestions (POST /ai/suggestions) produit trois types de propositions, validés par type côté serveur avant l'appel Claude: titres, plan (outline) ou mots-clés.
Un type invalide est rejeté avant toute dépense IA.
Outils IA intégrés à l'éditeur (AJAX) SuggérerRégénérerRésumerTaguer Assistants AJAX invoqués depuis les éditeurs d'article/page/taxonomie.
• Stratégie de mots-clés (POST /ai/suggest-keywords): depuis un mot-clé principal, renvoie 2–4 secondaires + 8–15 lexicaux et un titre suggéré de 55–65 caractères, sensible au content_type et au contexte.
• Régénérer le titre (POST /ai/regenerate-title): 3 titres SEO alternatifs honorant des instructions libres optionnelles, mot-clé placé en tête, 55–65 caractères.
• Régénérer la méta (POST /ai/regenerate-meta): meta title+description suivant les instructions.
• Générer un extrait (POST /ai/generate-excerpt): résumé accrocheur ≤200 caractères depuis le contenu.
• Suggérer des tags (POST /ai/suggest-tags): jusqu'à 5 tags pertinents depuis le contenu ou le mot-clé.
Analyse SEO par IA AnalyserScorer POST /ai/analyze appelle Claude pour produire un score 0–100 accompagné de issues[] et strengths[] au format JSON.
C'est une analyse facturée (appel IA réel), distincte du scoring déterministe.
Résultat destiné à guider les corrections dans le mode Améliorer.
Score de contenu déterministe (sans IA) Scorer (sans IA) POST /ai/analyze-content exécute un scoring PHP purement déterministe (ContentAnalyzer), SANS aucun appel IA ni coût.
Il parse une entrée mots-clés fournie au format JSON ou CSV.
Il applique des seuils de mots minimum distincts selon qu'il s'agit d'une page ou d'un article.
Assistants taxonomie (catégories) Générer Via /ai/generate-article avec un paramètre type, l'IA assiste les éditeurs de catégorie/taxonomie.
• category_description: description en texte brut.
• category_keyword: mot-clé focus unique.
• category_icon: icône Font Awesome issue d'une liste blanche (~130 choix curés) avec repli sûr sur fa-folder si aucun choix valide.
Générateur d'images IA (WaveSpeed FLUX) GénérerDimensionnerTéléchargerInsérer Page de génération d'images WaveSpeed FLUX (/admin/ai/images). Génère depuis un prompt libre (POST /ai/generate-image) ou construit automatiquement un prompt à partir d'un mot-clé via PromptRegistry 'image.article_cover'.
Dimensions choisies parmi des tailles autorisées (512, 720, 768, 1024, 1280, 1536; dimensions invalides silencieusement écartées) avec presets Carré 1024, Paysage 1280×768, Portrait 768×1280, Large 1536×1024, et prompt négatif optionnel; exemples de prompts et astuces fournis.
L'image générée est auto-téléchargée, crée un enregistrement média (is_ai_generated=1), fixe l'alt_text (mot-clé ou prompt) et génère des variantes WebP responsive 300/768/1200 (srcset), sauvegardées dans la Médiathèque.
Affiche le solde de crédits WaveSpeed en direct, l'estimation d'images restantes et le coût par image, avec avertissements de solde faible ou épuisé.
Une pré-vérification budgétaire renvoie HTTP 429 avec motif avant toute dépense; les erreurs WaveSpeed remontent en messages 502 actionnables; la dépense est journalisée même si le téléchargement/variante/insertion échoue ensuite (intégrité de facturation, statut 'failed').
Générateur d'articles IA optimisés SEO (cocon-type) GénérerDiffuser (SSE)Plafonner (budget) Flux séparé /admin/articles/generate (GET /articles/generate pour le formulaire) qui génère un article JSON complet (titre, contenu, extrait, métas, mot-clé, tags), en mode bloquant (POST /articles/generate) ou streaming SSE (GET /articles/generate/stream).
Le cocon_type pilote longueur et modèle: pillar (~2500 mots, modèle content_gen), cluster (~1500), support/défaut (~1000) modèle résolu depuis les réglages (ai_model_pillar ou ai_model_article) avec repli config.
La langue est résolue via LanguageResolver::forGeneration.
Le prompt système injecte la LISTE COMPLÈTE des mots-clés focus existants marqués INTERDITS (garantie d'unicité, anti-cannibalisation).
Le plafond budgétaire IA est vérifié avant génération (402 en cas de refus) et la dépense est journalisée dans ai_requests.
Tableau de bord des coûts IA Filtrer (période)ConsulterVisualiser Analytique de dépense agrégée sur toutes les requêtes IA (/admin/ai-costs).
Bascule de période 7 jours / 30 jours / 1 an (?period=week ou month ou year).
Totaux affichés: coût total, nombre de générations, budget mensuel, pourcentage d'utilisation du budget (code couleur au-delà de 50% et 80%) avec barre de progression.
Tableau 'Coûts par modèle' (appels, tokens prompt/completion/total, coût, durée moyenne) et tableau 'Coûts par type' (appels, coût), plus un graphique à barres 'Coûts quotidiens' (Chart.js auto-hébergé).
Le monthly_budget est lu depuis settings (group=ai).
AI Prompt Designer ParcourirÉditerAméliorer (IA)Expliquer (IA)VersionnerRestaurerRéinitialiser Catalogue (/admin/prompts) pour voir, éditer, améliorer/expliquer par IA, versionner et annuler chaque prompt système du CMS; restreint à la permission settings.edit.
Prompts groupés par catégorie (writing, tools, theme, advanced-code-managed) avec badges Factory/Customized/Code/Contract et un compteur d'alertes pour les prompts personnalisés ayant cassé leur contrat de sortie.
L'édition (GET /prompts/edit) montre le défaut usine, l'override courant, les {tokens} déclarés et l'historique complet des versions; la sauvegarde (POST /prompts/save) valide la présence des placeholders requis, refuse le vide, ignore les no-op, ajoute à un historique linéaire et écrit un journal d'audit.
Restauration d'une version antérieure (POST /prompts/restore restaurer un marqueur de reset revient à l'usine, la restauration est elle-même versionnée) et réinitialisation usine (POST /prompts/reset, efface l'override mais conserve les versions custom).
'Improve with AI' (POST /prompts/assist mode=improve) déclenche un méta-prompt 'Principal AI Prompt Architect' renvoyant réécriture + évaluation + scores sur 8 dimensions + scores projetés + issues + stratégie + changements, en validant que placeholders et marqueurs de sortie requis sont conservés et qu'aucun schéma de sortie n'a fuité; 'Explain' (mode=explain) fournit une explication ligne par ligne + astuces dans la langue de l'admin.
Les prompts gérés par le code (cocon.master, topical.graph, editorial.profile ou ideas, seo_agent.foundation, theme.compose, theme.custom_block, voice.dialogue) sont en lecture seule avec références fichier:ligne.
Routeur de modèles IA AffecterRéinitialiserConsulterDemander un avis (IA) Matrice d'affectation modèle-par-tâche (/admin/ai/models), restreinte à settings.edit: assigne un modèle Claude/WaveSpeed précis à chacune des ~30 tâches IA, groupées en 9 catégories (General, Topical map, Semantic cocoon, Content & editor, Images, AI agents, Theme Studio, System & internal, Plugins).
Par tâche: pool de modèles autorisés, défaut config, override courant, et recommandation curée best/value avec une justification en une ligne; possibilité de laisser ' default '.
Enregistrement de toute la matrice dans Setting ai.model_overrides (POST /ai/models/save, valide les ids contre les cartes de coût en direct); réinitialisation d'une tâche ou de tout (POST /ai/models/reset avec clé ou 'all').
'Second opinion' (POST /ai/models/advise) fait recommander par Claude le meilleur modèle qualité et le meilleur rapport valeur pour une tâche parmi son menu autorisé, justification dans la langue de l'UI.
Cartes de référence des modèles (nom, palier, badge de latence, prix calculé depuis la carte de coût en direct, descriptif, forces, best-for) incluant Opus 4.8/4.7/4.6, Sonnet 5/4.6, Haiku 4.5, Fable 5, FLUX schnell/dev/pro, les modèles legacy étant déconseillés.
Les changements prennent effet dans toute l'app sans redéploiement (ClaudeClient::modelForTask et WaveSpeedClient lisent l'override au moment de l'appel); une option 'model' codée en dur l'emporte toujours sur le routeur; chaque save/reset écrit un journal d'audit.
Agent Éditorial Mission Profil ProfilerExplorer le webRéviserPublierRejeter Mission Profil (SSE POST /editorial/agent/mission action=profile): profile la niche, l'audience et le ton à partir du contenu réel du site, puis effectue des recherches web pour découvrir les concurrents avec URLs de preuve; produit une proposition en attente (thématiques + concurrents + pédagogie).
Révision d'une proposition (GET /editorial/agent/proposals/{id}) puis validation/publication (POST .../publish → écrit thématiques et concurrents dans les réglages et fixe profile_done) ou rejet motivé (POST .../reject).
Agent Éditorial Mission Idéation & pipeline de garde IdéerScorerFiltrer (garde)Dédupliquer Mission Idéation (SSE POST /editorial/agent/mission action=ideation): trois scouts (tendances web, lacunes concurrents, mineur interne sur recherches manquées, cocons incomplets et articles obsolètes) + un stratège produisent N briefs scorés de type create/update/complete.
Un pipeline de garde déterministe s'applique ensuite aux idées: validation de placement (catégorie, pilier, article cible doivent être réels), hygiène des URLs sources, anti-cannibalisation (correspondance exacte du focus_keyword + similarité de titre Jaccard qui reclasse un 'create' en 'update'), déduplication du lot vs idées récentes, et plafond de lot.
Agent Éditorial Inbox d'idées & décisions ListerOuvrirApprouverRejeter Inbox d'idées: liste par statut (proposed, approved, writing, drafted, failed, rejected, expired) et ouverture du brief complet d'une idée en modale (GET /editorial/agent/ideas/{id}).
Approuver une idée l'envoie dans la file de rédaction (POST .../approve); la rejeter avec un motif fait mémoriser le retour par l'agent (POST .../reject).
Chaque décision est enregistrée dans editorial_memory pour la traçabilité.
Agent Éditorial Rédaction du brouillon RédigerDiffuser (SSE)Approuver-et-écrire 'Approve & write now' (SSE POST /editorial/agent/ideas/{id}/write) diffuse le brouillon en direct et auto-approuve en un geste une idée encore 'proposed'.
La rédaction produit un article DRAFT placé en catégorie/cocon avec seo_meta (mots-clés secondaires+lexicaux), tissage de liens internes depuis de vrais articles publiés, assainissement HTML, garde finale d'unicité du mot-clé, workflow_status=in_review et review_needed=1.
Une checklist de finition signale ce qu'un humain doit compléter (catégorie, liens internes, image à la une, mots-clés secondaires/lexicaux, extrait, meta description) sur le brief + une Notification.
L'agent ne publie JAMAIS: il ne crée que des brouillons.
Agent Éditorial Règle des 48h & balayage cron AutomatiserBalayer (cron)Planifier Règle des 48h via balayage cron: les idées non tranchées au-delà de leur échéance de 48h sont auto-approuvées (meilleurs scores, dans le quota hebdomadaire) en brouillons, ou expirées.
Le sweep récupère aussi les idées 'writing' bloquées et écrit une idée approuvée par tick.
Planification auto-entretenue (ensureSweepScheduled): une tâche editorial_sweep en attente existe toujours; le quota hebdomadaire de rédaction est appliqué.
Agent Éditorial Configuration & couches de prompts ConfigurerPersonnaliser (prompts)Restaurer Configuration de l'agent (POST /editorial/agent/config): ideas_per_batch (1–15), weekly_quota (1–10), cadence_days (1–30), bascule auto_approve_48h, plus listes éditables de thématiques et de concurrents (re-validées côté serveur, liste blanche http(s), plafonds).
Personnalisation des prompts (POST /editorial/agent/prompts, settings.edit uniquement): 4 COUCHES admin éditables (charter, ideation, writer, profile, ≤1500 caractères chacune) posées par-dessus un cœur verrouillé, avec restauration en un clic de la valeur précédente d'une couche; les changements sont journalisés dans la mémoire de l'agent.
On peut consulter les prompts effectifs (cœur verrouillé + couches courantes) ainsi que la liste des brouillons.
Gouvernance des coûts IA, budgets & journalisation (transverse) PlafonnerLimiter (débit)JournaliserStocker Couche d'enforcement traversée par chaque fonctionnalité IA.
Plafonds budgétaires quotidien et mensuel (la valeur AI des Settings DB l'emporte sur config/env, défauts durs 10 $/jour et 100 $/mois) vérifiés avant chaque appel IA, refus remontés avec motifs (429/402/503); une vérification en mode système (sans session) applique les mêmes plafonds à la dépense globale pour le cron.
Limite de débit par minute (requests_per_minute) + RateLimitMiddleware('ai') au niveau route sur chaque endpoint /admin/ai/*.
Journalisation de chaque requête texte dans ai_requests (provider, modèle, type, tokens prompt/completion/total, cost_usd, duration_ms, status) et de chaque image dans ai_requests + lignes de détail ai_images (modèle, prompt, negative_prompt, dimensions, steps, seed, output_url, status); le contenu généré est stocké dans ai_generated_content (drapeau is_applied, markApplied).
logClaudeRequest permet aux services collaborateurs (Cocon, Éditorial, Theme) d'enregistrer leur dépense uniformément; des appels Claude avec recherche web (sendWithWebSearch) et un vrai tool-use (sendTools) sont disponibles pour les agents.
Assistants carte topique & cocon (AiService) GénérerSuggérerAnalyser (cannibalisation) Générateurs Claude additionnels exposés via AiService et la vue admin/ai/thematic.php.
• Plan thématique (generateThematicPlan): 1 pilier + N briefs d'articles cluster (titre, slug, mot-clé, méta, extrait, plan) au format JSON.
• Suggestion d'inputs business (suggestBusinessInputs): produit, vrais concurrents, personas et localisations depuis mot-clé + langue + marché.
• Suggestion de méta de sujet (suggestTopicMeta): nom de projet + marché cible.
• Analyse de cannibalisation par lot pour une carte topique entière (checkCannibalizationMap): un seul appel IA détecte les conflits de mots-clés intra-carte et propose des correctifs merge/differentiate/canonical/delete avec filtrage du bruit d'ascendance; un check mono-mot-clé avec remédiation IA existe aussi (checkCannibalization).
L'intention sémantique du menu de commandes (commandIntent) est également routée via AiService.
Intelligence Artificielle

🎙️ Assistant vocal / Commande vocale Nouveau

14 fonctionnalités
Fonctionnalité Actions Fonctionnement exact
Mot d'activation passif « OK Clustraly » (wake word) ÉcouterDétecterRéveillerRelancer Écouteur côté client qui transforme la parole en requêtes du menu de commandes : ce n'est qu'un canal au-dessus de command-menu.js, jamais un second cerveau.
• Détection de la phrase de réveil via findWake() avec variantes localisées (« ok clustraly ou okay clustraly ou ok clusterly ou ok cluster ly »), en retenant la dernière occurrence.
• À la détection : passage en mode commande, avec consommation optionnelle des mots prononcés après le mot de réveil dans la même phrase (commande inline).
• Écoute continue passive : l'API SpeechRecognition auto-arrêtante est relancée par un watchdog (backoff exponentiel 400ms→10s).
• Écoute suspendue quand un input/textarea/select/contentEditable a le focus, quand l'onglet est masqué ou quand la permission micro est 'prompt'/'denied' ; garde-écho ignorant les résultats pendant que la TTS parle ; délai d'inactivité de 12s ramenant du mode commande au mode réveil. Les transcriptions intermédiaires alimentent cm.setQuery pour afficher ce qui est entendu.
Bouton micro push-to-talk BasculerActiver voixDemander microAfficher l'état Un bouton micro d'en-tête (#voice-btn, [data-voice-toggle]) et un micro dans le lanceur ([data-voice-ptt]) démarrent le mode commande au clic un geste utilisateur qui peut (re)accorder le micro même après un blocage.
• Au premier clic : activation automatique de la voix (saveFlags voice_enabled=1) et demande du micro.
• Indicateur d'état en direct (data-voice-state off/paused/listening/active) avec aria-pressed et une région de statut aria-live.
• Les boutons restent masqués tant que voice-assistant.js n'a pas confirmé le support du navigateur et que l'utilisateur n'a pas activé la voix.
Navigation vocale et choix vocal des résultats NaviguerProposerChoisirAnnulerÉnoncer À partir des scores lexicaux du lanceur, décide de naviguer directement, d'offrir un choix multiple parlé, ou d'escalader vers le dialogue IA.
• Navigation directe quand le meilleur score ≥ 70 (STRONG_SCORE) ou qu'un seul vrai résultat existe, routée via cm.activate pour que frecency/apprentissage se déclenchent comme un Entrée clavier.
• Plusieurs vrais résultats : énonce « J'ai trouvé N résultats lequel ? » et attend un choix ordinal.
• Sélection par ordinal vocal : « premier/deuxième/troisième » (+ « numéro un/1 », etc., appariés sur mot entier pour les variantes courtes) ; annulation vocale (« cancel ou close ou never mind ») fermant le lanceur et sortant du mode commande.
• Le libellé de destination est énoncé avant de naviguer, plafonné pour que la navigation ne soit jamais retardée > 2,5s.
Réponses parlées (synthèse vocale / TTS) ÉnoncerActiver/désactiverInterrompre Narration optionnelle des réponses de l'assistant via SpeechSynthesis, activable par utilisateur, dans la locale de l'admin.
• Énonce salutations, comptes de résultats, confirmations de navigation, résumés d'écriture et lignes d'erreur.
• Conditionnée au drapeau par utilisateur voice_speak_replies et à la disponibilité de speechSynthesis dans le navigateur.
• Annule l'énoncé précédent avant de parler, avec un plafond de temps strict pour que la TTS ne bloque jamais le flux (NAV_SPEECH_CAP_MS).
• Utilise cfg.lang (VoiceService::bcp47 de la locale courante) comme tag BCP-47 de l'utterance.
Page de réglages vocaux par utilisateur Basculer les drapeauxEnregistrerDemander microAfficher les notes Hub de réglages du propre compte à /admin/profile/voice avec trois drapeaux, enregistrés par POST de formulaire classique ou par une API JSON de fond utilisée par le JS.
• « Assistant vocal activé » (interrupteur maître ; le cocher demande la permission micro sur le geste via getUserMedia), « Toujours écouter "OK Clustraly" » vs push-to-talk, et « Réponses parlées ».
• Enregistrement soit par POST de formulaire protégé CSRF (→ flash « Voice settings saved. » et voice_onboarded=1), soit par POST JSON /admin/api/voice-settings (fail-open {saved:bool}) utilisé pour l'accept/refus d'onboarding et l'auto-désactivation propre.
• Affiche une note « non supporté dans ce navigateur » (Firefox/Brave/HTTP) au lieu du formulaire, et « désactivé par l'administrateur » quand le kill switch est actif.
Modale d'onboarding (première visite) PrésenterTesterConfirmerPersisterIgnorer Modale en 4 étapes affichée une seule fois (quand le drapeau voice_onboarded est absent et le kill switch inactif) pour obtenir le geste micro et exécuter un test de mot de réveil en direct.
• Étape intro : pitch + avis RGPD avec « Activer le microphone »/« Pas maintenant ».
• Étape test : essai en direct (mode 'waketest') réussi quand la phrase de réveil est entendue ; étape done : confirmation de succès puis entrée automatique en mode commande.
• Étape denied : permission refusée → désactivation persistante propre, réactivable dans les réglages.
• Toute fermeture (backdrop/Échap/« Pas maintenant ») force voice_onboarded=1 pour que la modale ne revienne jamais ; chaque issue persiste voice_enabled/voice_onboarded via l'endpoint JSON de réglages.
Dialogue IA vocal V2 (endpoint voice-dialogue) NaviguerClarifierPréremplirPlafonnerRetomber (fallback) Quand la couche lexicale ne trouve rien, un tour Claude tool-use résout l'intention sur l'index d'écrans filtré par permissions + le registre d'actions ; l'état vit côté client, plafonné à 6 tours utilisateur.
• Outil navigate : ouvre un écran admin par son id, l'URL étant résolue côté serveur depuis l'index client, jamais depuis la sortie IA.
• Outil ask_clarification : une question courte avec 2–3 options localisées, choisies par voix ou clic ; outil prefill_navigate : ouvre un écran de création avec un paramètre extrait par l'IA (ex. titre d'article/page) whitelisté et borné en longueur.
• Plafond de 6 tours utilisateur imposé côté client et serveur, avec validation stricte de l'alternance user/assistant ; fail-open absolu (pas de clé IA, budget épuisé, timeout ou entrée malformée → {action:'fallback'}, retour au lanceur classique).
• Le niveau-2 IA propre au lanceur est supprimé pendant que le dialogue pilote (un seul chemin facturé à la fois). Route POST /api/voice-dialogue protégée par RateLimit 'ai' + CSRF.
Clarifications apprises (voix → command_menu_learned) ApprendreEnvoyerIgnorer Une navigation de dialogue résolue enseigne la PREMIÈRE requête parlée → entrée résolue dans le même magasin appris que le clavier, pour répondre au niveau 1 la prochaine fois.
• Sur une réponse navigate avec entryId non nul, la paire (première requête normalisée, id d'entrée) est POSTée vers /admin/api/command-learn via sendBeacon/fetch.
• L'apprentissage est ignoré pour les cibles paramétrées/prefill (entryId null) des one-shots sans rien à apprendre.
Écritures vocales V3 (proposer → confirmer → exécuter) ProposerValiderConfirmerExécuterJournaliser L'IA peut PROPOSER une écriture ; le serveur valide+résume seulement, l'admin confirme sur une carte visuelle, puis un POST CSRF vers /voice-execute la réalise. Les créations arrivent en brouillon, les suppressions sont corbeille-seulement, tout est journalisé via:voice.
• Quatre actions : create-draft-article (article en BROUILLON depuis un titre dicté + contenu optionnel dicté, HTML-assaini, jamais publié depuis la voix) ; create-draft-page (page en brouillon) ; trash-current (met à la corbeille l'entité de la page en cours, réversible destructif → confirmation par clic uniquement, jamais par « oui » vocal) ; set-status-current (bascule le statut en draft ou published ; publier exige le droit .publish, brouillon le droit .edit).
• Le serveur re-valide permission, paramètres et l'entité concrète (id issu du contexte de page de confiance, jamais de l'IA) fail-closed au moindre doute.
• Carte de confirmation visuelle Confirmer/Annuler : écritures non destructives confirmables par « oui » vocal (variantes d'affirmation), destructives exigeant le clic.
• Chaque écriture auditée (voice.create / voice.trash / voice.status avec via:voice) ; la création est aussi versionnée (VersionService « Voice draft ») et invalide le cache. Écritures contextuelles offertes seulement en édition d'article/page (window.__voiceContext).
Dictée vocale par champ V3b (éditeurs) DicterInsérerPonctuerCapitaliserArrêter Dictée push-to-talk indépendante pour les champs de l'éditeur d'article/page : les transcriptions sont insérées au curseur avec ponctuation parlée et auto-capitalisation ; n'écrit rien au serveur.
• Un bouton micro par champ ([data-voice-dictate=fieldId]) bascule la dictée pour un input/textarea (titre, contenu) ; transcriptions finales insérées au curseur avec espacement de jointure et dispatch d'un événement input pour le comptage de mots/autosave Alpine.
• Ponctuation parlée sur 10 locales : point, virgule, point d'interrogation, point d'exclamation, deux-points, point-virgule, nouvelle ligne, nouveau paragraphe (appariement du plus long segment) ; auto-capitalisation légère en début, après ponctuation de fin de phrase et après retours à la ligne, sensible à l'Unicode multi-écritures.
• Cycle push-to-talk avec relance watchdog ; Échap/soumission/onglet masqué arrêtent la dictée, et focaliser le champ met en pause le reconnaisseur de réveil pour qu'ils ne se disputent jamais le micro.
• Boutons révélés seulement sur navigateurs non-Brave avec SpeechRecognition + contexte sécurisé + voix activée.
Kill switch global (Réglages → Avancé) BasculerMasquerNeutraliser Réglage avancé réservé admin (voice_kill_switch) qui masque et stoppe l'assistant vocal pour tous les utilisateurs ; quand actif, le serveur n'émet aucun __voiceConfig donc tout le JS vocal devient no-op.
• Quand actif : le layout saute __voiceConfig + l'onboarding, la page de réglages affiche « désactivé par l'administrateur » et les diagnostics le signalent en ligne rouge.
• Lecture fail-open partout (un incident de réglages ne provoque jamais de 500 sur le layout).
Page de diagnostics et de réinitialisation RapporterTesterPingVider le cacheRéinitialiser Page d'auto-test vert/rouge de tout le sous-système vocal (serveur + ce navigateur) à /admin/voice/diagnostics, avec tests micro/TTS en direct et outillage de remise à zéro.
• Rapport serveur : tables présentes (user_settings, command_menu_learned), clé IA Claude configurée, état du kill switch, les 4 drapeaux de l'utilisateur, présence des clés i18n, liste des endpoints vocaux ; signatures des fichiers déployés (existence, taille, mtime, md5 court, présence de chaînes-fonctionnalités : setQuery, voice-dialogue, voice-execute, requestMicPermission, buildChunk, voice-dictate-btn) pour détecter un upload FTP obsolète.
• Vérifs client : support SpeechRecognition/synthesis, chargement de __voiceConfig/__voice/__commandMenu/__voiceDictation, balises script présentes, __commandMenuIndex inliné, contexte sécurisé, détection Brave, état de permission micro, bannière DIAGNOSIS en langage clair.
• Boutons : ping d'endpoint (GET /admin/api/command-search), self-test, test micro en direct, test de réponse parlée (TTS), vidage du cache vocal local (localStorage cmdmenu-recents / *voice*).
• Réinitialisations : « mon compte » (POST /voice/reset, supprime les 4 drapeaux pour rejouer l'onboarding) et « tout le monde/global » (POST /voice/reset-global, droit settings.edit supprime les drapeaux de tous, vide command_menu_learned, désactive le kill switch, audité voice.reset_global).
Détection de capacité du navigateur et masquage des points d'entrée DétecterÉcrire un cookieÉlaguer l'indexMasquer Serveur et client coopèrent pour que le module « se comporte comme s'il n'existait pas » sur les navigateurs incapables d'entrée vocale, sans toucher au reste de l'admin.
• browserSupported() côté serveur fait confiance à un cookie de verdict client (clustraly_voice_cap 1/0), sinon infère depuis HTTP-vs-sécurisé et l'UA Firefox pour décider s'il faut même rendre les points d'entrée vocaux.
• Le client écrit le cookie de verdict à chaque chargement (6 mois, SameSite=Lax, Secure sur https) ; détection de Firefox (pas de SpeechRecognition), Brave (livre l'API mais retire le moteur → navigator.brave.isBrave asynchrone) et contexte non sécurisé.
• pruneVoiceMenu() retire les tuiles du lanceur vocal, lignes d'aide, liens de sidebar et les ids voice-assistant/voice-diagnostics de l'index de recherche local à la première visite (pré-cookie).
• Fail-open : tout accroc laisse le module visible et l'admin pleinement fonctionnel au clavier.
Extensibilité plugin des actions vocales EnregistrerFiltrerAssainir Les plugins enregistrent leurs propres actions vocales de navigation et d'écriture via des filtres, garde-formés et filtrés par permissions comme command.menu.items.
• Filtre voice.actions : ajoute des actions de navigation prefill dialoguables (cibles /admin same-origin uniquement, javascript: et protocole-relatif rejetés, params whitelistés/bornés en longueur, filtrés via Auth::can).
• Filtre voice.write.actions : ajoute des actions d'écriture create/trash/status (kind et entité whitelistés, chaque écriture forcée en confirmation visuelle).
• Les champs de compat V1/V3 (destructive, confirm) sont assainis pour qu'une écriture destructive de plugin ne puisse jamais être confirmée par la voix.
Design & Thèmes

🎨 Thèmes, Widgets, Menus & Design Presets

14 fonctionnalités
Fonctionnalité Actions Fonctionnement exact
Bibliothèque de thèmes (liste & gestion) ListerEnrichir métadonnéesAfficher badgesSignaler erreursGuider (état vide) Grille de tous les thèmes installés, ordonnés, enrichie en direct depuis le manifeste (image d'aperçu, tags, auteur, version, description). Chaque carte affiche un badge « Actif » sur le thème en ligne, un badge « IA » pour les thèmes ai_generated, et un badge de score qualité Studio (tonalité pass/warn/blocked) renvoyant vers la carte de score. Une bannière d'erreur par thème apparaît lorsqu'un thème a planté et que le site est retombé sur le thème par défaut. Un état vide propose d'importer un thème ou d'en créer un avec l'IA. Servi par ThemeController::index et la vue admin/themes/index.php.
Activation de thème (porte qualité) ActiverVérifier dossierBloquerForcerJournaliser L'activation (POST /admin/themes/{id}/activate) vérifie d'abord que le dossier du thème existe sur disque (sauf « default »). Les thèmes Studio (v2) doivent franchir une porte qualité: blocage dur et non contournable sur problème critique, et blocage si le score est inférieur à 90 sauf envoi explicite de « Publier quand même » (publish_anyway=1). Les thèmes v1 non-Studio sont « grandfathered » (porte autorisée d'office). Chaque tentative est journalisée (theme.activate, activate_blocked ou activate_forced). Principe fail-open: un analyseur cassé ne bloque jamais l'activation. Logique portée par le service ThemeQualityGate.
Prévisualisation de thème PrévisualiserRendre sans activer GET /admin/themes/{id}/preview redirige vers le site public en /?theme_preview={slug}. Le rendu applique le thème choisi même s'il est inactif, sans le mettre en ligne. Permet de contrôler le rendu réel d'un thème avant activation.
Duplication de thème DupliquerGénérer slug uniqueCopier dossierRéécrire theme.json POST /admin/themes/{id}/duplicate clone à la fois les fichiers et l'enregistrement DB. Un slug unique est généré par incrément ({slug}-copy, -copy-2, …), le dossier du thème est copié récursivement, puis theme.json (name et slug) est réécrit dans la copie. Un nouvel enregistrement DB inactif est créé, et l'historique de score obsolète d'un slug réutilisé est purgé pour repartir propre.
Suppression de thème SupprimerProtéger actif/defaultNettoyer historique DELETE /admin/themes/{id} supprime les fichiers, l'enregistrement DB et l'historique de score. L'opération refuse de supprimer le thème actif ainsi que le thème système « default ». Le dossier est effacé récursivement et le fichier d'historique de score qualité est retiré. L'interface protège l'action par une modale de confirmation.
Export de thème en ZIP ExporterEmpaqueterStreamer GET /admin/themes/{id}/export construit une archive nommée {slug}-{version}.zip. Le contenu du dossier est ajouté récursivement à l'archive. Le fichier est ensuite streamé en pièce jointe application/zip avec un en-tête Content-Length, téléchargeant l'intégralité du thème dans une archive versionnée.
Import de thème préconstruit (ZIP) ImporterValiderFiltrer cheminsAssainir SVGCréer enregistrement POST /admin/themes/import (déclenché par un input fichier caché à auto-soumission) installe un thème depuis un ZIP avec validation de sécurité en couches. Contrôles: extension .zip et taille max 50 Mo, theme.json valide avec slug obligatoire (assaini en [a-z0-9-]), rejet d'un slug déjà existant. Sécurité pré-extraction: filtre anti-traversée de chemin (« .. » ou « / » initial), rejet des liens symboliques dissimulés (parcours lstat), et assainissement de chaque SVG embarqué via SvgSanitizer avant dépôt dans le dossier servi. L'installation utilise rename() avec un repli copie-récursive en cas d'EXDEV, sans jamais laisser d'arbre partiel; un enregistrement DB inactif est créé depuis le manifeste et l'action journalisée (theme.import).
Carte de score qualité ConsulterDétailler par catégorieHistoriserAgir inline GET /admin/themes/{id}/score affiche un bulletin en lecture seule notant un thème Studio sur 100 avec verdict pass/warn/blocked et cadran de score. Répartition par catégories pondérées: Design & lisibilité (20), UX & conversion (15), Mobile & responsive (15), Vitesse (15), SEO (20), Code & sécurité (15) et Anti « AI-look » (13). Détail repliable par vérification avec sévérité (critique/majeur/mineur) et messages, liste des problèmes bloquants critiques non contournables et des correctifs manuels recommandés (non mécaniques). Une timeline d'historique tague chaque événement (generate, edit, ai-edit, undo, custom-block, autofix) et des actions inline Activer / Publier quand même / Éditer / Prévisualiser sont proposées. Les thèmes non-Studio affichent une notice « non soumis à la porte » et un panneau fail-open « analyse indisponible »; le bulletin n'est jamais lui-même une porte.
Correction qualité en un clic (autofix) Corriger mécaniquementRecompilerHistoriserJournaliser POST /admin/themes/{id}/autofix applique des réparations qualité mécaniques. Il re-résout le skin en mode AA-safe (abandon des couleurs verbatim pour que SkinEngine re-dérive une palette WCAG-AA) puis recompile le thème en place. Le score post-correction est capturé dans l'historique (événement « autofix ») et l'opération est journalisée (theme.autofix, avec correctifs appliqués et nouveau score). Un message no-op est renvoyé lorsqu'aucune correction mécanique n'est disponible.
Personnalisateur visuel de thème PersonnaliserPrévisualiser en directEnregistrerRéinitialiser GET /admin/themes/{id}/customize ouvre un personnalisateur en panneaux scindés avec aperçu live (sans IA). Édition des couleurs Clair + Sombre (primary, secondary, accent, surface, surface_alt, text, text_secondary, border, success, warning, danger) via sélecteur de couleur et champ hex; choix des polices titres et corps dans une liste de 16 polices auto-hébergées; rayon de bordure de mise en page (4, 8, 12, 16 ou 24px) et style d'en-tête (sticky-blur, transparent ou solid); bascules d'animations (scroll reveal, barre de progression, retour en haut, parallaxe). L'aperçu iframe met à jour les variables CSS en temps réel, avec bascule d'affichage Bureau / Tablette(768) / Mobile(390). L'enregistrement (POST .../customize) fait un upsert par clé dans theme_settings; la réinitialisation (POST .../reset) annule toutes les personnalisations après modale de confirmation.
Menus CRUD & constructeur imbriqué ListerCréerÉditerSupprimerRéordonner (glisser-déposer) Gestion des menus de navigation: liste avec compteur d'items (GET /admin/menus), création (POST /admin/menus) et mise à jour (PUT /admin/menus/{id}) avec slug auto-unique, suppression (DELETE /admin/menus/{id}) du menu et de tous ses items. Ajout d'items (libellé, URL, classe d'icône) via le panneau de gauche, avec type d'item validé à custom, article, page ou category. Réordonnancement par glisser-déposer (SortableJS) et retrait d'items dans un constructeur Alpine; à l'enregistrement, les items sont persistés par re-synchronisation complète (syncItems). Les traductions d'items par langue sont supportées au rendu. Un ancien endpoint de tri (MenuController::sort) subsiste dans le code mais sa route a été retirée.
Widgets zones, types & gestion inline AssignerÉditer inlineSupprimerRéordonnerAssainir HTML 8 zones de widgets (Sidebar, Left Sidebar, Footer 1, Footer 2, Footer 3, Header Top, Homepage Hero, Between Articles) affichent chacune leurs widgets assignés. Ajout (POST /admin/widgets): choix parmi 10 types, titre et zone cible, type et zone validés contre une liste blanche. Les 10 types: Articles récents, populaires et liés, Nuage de tags, Catégories, Recherche, HTML personnalisé, Liens sociaux, Table des matières, Newsletter. Édition inline (PUT /admin/widgets/{id}): titre, bascule is_active, corps HTML personnalisé, la config étant fusionnée et non écrasée; suppression (DELETE) qui purge aussi les assignations de zone; glisser-déposer pour réordonner et déplacer entre zones (PUT /admin/api/sort/widgets, JSON). Le HTML personnalisé est assaini via liste blanche DOM (retrait des gestionnaires d'événements, des URIs javascript:/vbscript:/data: non-image et des directives de framework); le widget Newsletter rend un vrai formulaire d'inscription double opt-in (honeypot + piège temporel + CSRF) postant vers /newsletter/subscribe. Traductions de widgets par langue supportées au rendu.
Design presets & moteur de skins Rechercher (BM25)RecommanderConvertir paletteMapper polices Jeux de données UI/UX en lecture seule alimentant la génération de thèmes: presets de style (styles.csv), palettes de couleurs (colors.csv, tokens shadcn), pairings de polices (typography.csv) et règles de raisonnement UI (ui-reasoning.csv). Recherche BM25 sur un ou tous les jeux (search) et recommandation complète de design-system à partir d'un brief (designSystem). Conversion d'une palette shadcn vers le contrat theme.json de 19 clés × clair/sombre avec auto-ajustement WCAG AA (themeColors), calcul du ratio de contraste WCAG 2.1 (contrastRatio) et mapping vers police auto-hébergée (mapFontFamily) évitant toute requête Google Fonts. SkinPresets expose des swatches curées, 5 pairings de polices et des dimensions raffinables (densité, rayon, motion) partagées par le wizard et l'éditeur.
Bibliothèques de recettes & sections (catalogue de composition) CataloguerValider (liste blanche)Matérialiser en plan Catalogue faisant autorité sur le système de fichiers, adossé au Studio et aux presets. 13 recettes couvrant les types de site (blog-classic/editorial/minimal, magazine-editorial/frontpage/visual, vitrine-classic/showcase/studio, landing-app/product/proof/saas), validées contre la liste blanche des sections live (les recettes malformées sont ignorées, fail-open). Familles de sections: header (5), footer (4), hero (8), posts (4), marketing (13), media (4), misc (4). La fonction materialize() transforme une recette + un skin en un plan de site complet et compilable. Un catalogue de personnalités de skin (personalities.json) fournit les presets editorial, galerie, magazine, studio, deepspace et aurora.
Design & Thèmes

🧩 Theme Studio (cœur, IA) Nouveau

16 fonctionnalités
Fonctionnalité Actions Fonctionnement exact
Assistant Étape 1 : Brief Ouvrir l'assistantDécrire le siteChoisir le typeSélectionner la languePréciser (options) Moteur du cœur (distinct du plugin AI Studio Design), ouvert via GET /admin/themes/studio (l'ancienne route /themes/create y redirige). L'utilisateur décrit le site en texte libre, choisit un type parmi Blog, Magazine, Vitrine (showcase) ou Landing page, et une langue de contenu parmi 10 locales (en, fr, es, de, pt, zh, ar, ru, ja, id). Un volet « Plus d'options » permet de renseigner le Nom du site et le Secteur. Le tout est piloté par un stepper client à 4 étapes (Brief, Propositions, Refine, Générer). Rendu par ThemeStudioController::wizard et la vue studio-wizard.php.
Assistant Étape 2 : Propositions Générer 3–4 propositionsPaginerPrévisualiserChoisir POST /admin/themes/studio/propose dérive 3 à 4 propositions déterministes du brief, sans aucun appel IA (croisement recipe × skin). Un bouton « Autres propositions » pagine par offset. Chaque proposition est prévisualisée dans son propre iframe same-origin via GET /admin/themes/studio/preview-live, rendue à partir de sa recette par slot. Choisir une proposition amorce les curseurs de l'étape Refine depuis son skin réel. S'appuie sur StudioProposals.php et RecipeLibrary.php.
Assistant Étape 3 : Refine (skin live) Choisir paletteChoisir typoRégler densité, coins, motionBasculer clair/sombrePrévisualiser Contrôles de design bornés, recompilés côté serveur à chaque changement. Palette parmi 12 swatches curées (ids 93,4,76,64,63,3,50,36,16,34,28,39), typographie parmi 5 pairings auto-hébergés (editorial, magazine, warm, modern, technical). Réglages Densité (airy/regular/compact), Coins/rayon (sharp/soft/round/pill), Motion (calm/editorial/energetic) et mode d'apparence (clair/sombre). L'aperçu iframe est recompilé serveur via previewLive/refinedSkin, toutes les entrées étant validées et whitelistées. S'appuie sur SkinPresets.php.
Assistant Étape 4 : Génération IA (SSE) Lancer la générationSuivre la progression SSERecevoir le thèmeActiver, Éditer, Prévisualiser ou Régénérer POST /admin/themes/studio/generate protégé par CSRF, rate-limit IA et permission themes.edit. Le pipeline IA réel diffuse sa progression en SSE : plan → copy → images → signature → compile → audit, avec une checklist live (Composition, Rédaction, Images, Finitions, Compilation). La composition reste verrouillée sur la recette choisie : l'IA écrit le contenu mais ne recompose jamais la mise en page. Nom de thème par défaut localisé si le Nom du site est vide, slug unique translittéré. À la fin, émet slug, theme_id, name, audit et URLs preview/activate/customize, journalise theme.studio_generate, puis affiche un écran résultat (Activer, Éditer dans le customizer, Aperçu complet, Régénérer le contenu) avec gestion d'erreur « Réessayer ». Moteur ThemeStudioGenerator.php + ArtDirector.php.
Éditeur de blocs sélection & édition directe Ouvrir l'éditeurSurvoler puis cliquer une sectionCharger le schémaÉditer variant, options et slotsAppliquer le patch GET /admin/themes/{id}/edit (permission themes.edit) rend le thème en mode édition avec marqueurs de provenance par section via editor-frame. Le survol met en surbrillance et le clic sélectionne une section dans l'iframe, editor-section charge le schéma du bloc et ses valeurs courantes. On édite le Layout/variant, les options (toggle booléen ou select enum) et les slots : texte, média (URL + sélecteur de bibliothèque), répéteurs list&lt;link&gt;, list&lt;text&gt; et list&lt;object&gt; (ajout, retrait, réordonnancement, list&lt;text&gt; imbriquée). editor-patch applique un patch borné, re-valide le plan et recompile atomiquement, les clés refusées ou ignorées étant signalées honnêtement, journal theme.block_edit. BlockPatchService.php.
Éditeur de blocs sélecteur média (bibliothèque) Ouvrir la modale médiaParcourir images et vidéosChoisir un média Ouverte depuis un slot média ou un champ média de répéteur. Parcourt images et vidéos via GET /admin/media/browse?type= en réutilisant l'endpoint de l'éditeur d'articles. Choisir un item écrit une URL relative à l'origine puis déclenche patch + recompilation. Implémenté dans studio-editor.php (picker, openPicker, chooseMedia).
Éditeur de blocs retouche IA conversationnelle Envoyer un prompt de blocRouter l'action IARecompilerKeep/Cancel POST /admin/themes/studio/editor-ai protégé par CSRF, rate-limit IA et themes.edit c'est le « Retoucher en parlant ». Un seul appel IA route la demande vers une action : edit (delta borné), insert (section de bibliothèque), delete, move up/down, skin patch, ou custom (nouveau bloc généré). Chaque édition qui aboutit snapshote le plan pré-édition (undo) et recompile atomiquement. Fail-open : une sortie IA inexploitable ne change rien (applied:false). Affordance Keep/Cancel pour les éditions IA, journalisé par action. BlockEditorService.php.
Éditeur de blocs opérations structurelles de sections Lister les sections insérablesAjouter ou insérerSupprimerDéplacer haut/bas GET editor-addable liste les sections insérables par familles (hero, posts, marketing, media, misc). editor-add-section insère une section de bibliothèque après le bloc sélectionné, amorcée avec ses slots par défaut. editor-delete-section supprime la section sélectionnée mais refuse la dernière section restante. editor-move-section réordonne haut/bas. Chaque opération valide le plan, snapshote pour undo, recompile atomiquement et journalise theme.block_structure. BlockStructureService.php + theme-library/sections.
Éditeur de blocs blocs custom IA (réutilisables) Créer un bloc customValider et écrire sur disqueLister « Mes blocs »Insérer un bloc existant POST /admin/themes/studio/editor-newblock (CSRF + rate-limit IA) génère une section sur mesure depuis un prompt. CustomBlockValidator la valide, puis manifest + partial + css sont écrits sous custom-sections/, la section est insérée dans le plan et le thème recompilé. editor-blocks liste les blocs custom réutilisables du thème, editor-insert insère un bloc existant. En cas de refus, l'outil suggère la section de bibliothèque la plus proche, journal theme.custom_block. CustomBlockService.php + CustomBlockValidator.php.
Éditeur de blocs panneau skin local & undo Lire le skin courantAppliquer un changement de skin bornéAnnuler la dernière opération GET editor-skin lit les valeurs de skin courantes. editor-skin-apply applique un changement borné : swatch de palette, pairing typographique, densité, coins/rayon, motion, mode clair/sombre par défaut. editor-undo annule le dernier snapshot de plan et revient sur une opération edit, add, delete, move ou skin. Journalise theme.block_skin et theme.block_undo, et chaque édition snapshote le score qualité post-édition. BlockSkinService.php + PlanHistory.php.
Carte de score qualité /100 Voir la carteLire le détail par catégorieLister les blocagesConsulter l'historiqueAgir en ligne GET /admin/themes/{id}/score affiche un bulletin en lecture seule notant le thème /100 (cadran pass/warn/blocked), jamais un gate en soi. Décomposition par catégorie pondérée : Design & lisibilité (20), UX & conversion (15), Mobile & responsive (15), Vitesse (15), SEO (20), Code & sécurité (15), Anti « AI-look » (13). Détail par check repliable avec sévérité (critical/major/minor) et messages, liste des problèmes bloquants (critical) non contournables et des corrections manuelles recommandées. Timeline d'historique taguée par événement (generate, edit, ai-edit, undo, custom-block, autofix) et actions inline Activer, Publish-anyway, Éditer, Prévisualiser. Les thèmes non-Studio affichent un avis « non soumis au gate » et un panneau fail-open « analyse indisponible ». ThemeQualityAnalyzer.php + ThemeScoreHistory.php.
Gate d'activation (qualité) Activer un thèmeVérifier le disqueBloquer sur critiqueAutoriser un override bornéJournaliser POST /admin/themes/{id}/activate met un thème en ligne, mais les thèmes Studio (v2) doivent passer un gate qualité. Vérifie d'abord que le répertoire du thème existe sur disque (sauf default). Blocage dur sur tout problème qualité critique (non contournable), et blocage si le score est inférieur à 90 sauf envoi de « Publish anyway » (publish_anyway=1). Les thèmes non-Studio v1 sont grandfathered (gate autorisé d'office). Fail-open : un analyseur cassé ne bloque jamais l'activation. Journalise theme.activate, activate_blocked ou activate_forced. ThemeQualityGate.php.
Autofix qualité en un clic Lancer l'autofixRe-dériver une palette AA-safeRecompilerSnapshoter le scoreJournaliser POST /admin/themes/{id}/autofix applique des réparations qualité mécaniques. Il re-résout le skin AA-safe (retire les couleurs verbatim pour que SkinEngine re-dérive une palette WCAG-AA) puis recompile sur place. Le score post-fix est snapshoté dans l'historique (événement 'autofix') et theme.autofix est journalisé avec les correctifs appliqués et le nouveau score. Un message no-op s'affiche quand aucune correction mécanique n'est disponible. ThemeQualityGate.php.
Customizer visuel Ouvrir le customizerÉditer les couleurs clair/sombreChoisir les policesRégler layout et animationsPrévisualiserEnregistrer ou Réinitialiser GET /admin/themes/{id}/customize ouvre un customizer live à panneaux séparés. Édite les couleurs Clair + Sombre (primary, secondary, accent, surface, surface_alt, text, text_secondary, border, success, warning, danger) via color picker + champ hex, et choisit les polices titres/corps dans une liste de 16 fontes auto-hébergées. Règle le border-radius (4/8/12/16/24px) et le style d'en-tête (sticky-blur, transparent, solid), et bascule les animations (scroll reveal, barre de progression, back-to-top, parallax). Aperçu iframe live mettant à jour les variables CSS en temps réel, viewport commutable Desktop, Tablet(768) ou Mobile(390). Enregistrement (POST .../customize) en upsert par clé dans theme_settings, réinitialisation totale (POST .../reset) avec modale de confirmation. customize.php.
Moteur du cœur presets design & SkinEngine Rechercher (BM25)Recommander un design-systemConvertir la palette en WCAG-AAMapper les fontes auto-hébergées Moteur du cœur (et non le plugin) : datasets UI/UX en lecture seule styles.csv, palettes colors.csv (tokens shadcn), pairings typography.csv, règles ui-reasoning.csv. Recherche BM25 sur un ou tous les datasets (search) et recommandation complète de design-system pour un brief (designSystem). Convertit une palette shadcn dans le contrat theme.json (19 clés × clair/sombre) avec auto-ajustement WCAG AA (themeColors), calcule le ratio de contraste WCAG 2.1 (contrastRatio) et mappe les familles de polices auto-hébergées (mapFontFamily), sans jamais requêter Google Fonts. SkinPresets expose les swatches curées, 5 pairings et les dimensions raffinables (densité, rayon, motion) partagés par l'assistant et l'éditeur. DesignPresetService.php + SkinEngine.php + SkinPresets.php.
Moteur du cœur bibliothèques de recipes & sections Fournir les recipesFournir les familles de sectionsMatérialiser un site-planExposer les personnalités de skin Catalogue faisant autorité par le système de fichiers, qui alimente le Studio du cœur. 13 recipes couvrant les types de site (blog-classic/editorial/minimal, magazine-editorial/frontpage/visual, vitrine-classic/showcase/studio, landing-app/product/proof/saas), validées contre la whitelist de sections live (recette malformée ignorée, fail-open). Familles de sections : header (5), footer (4), hero (8), posts (4), marketing (13), media (4), misc (4). materialize() transforme recette + skin en un site-plan complet et compilable. Catalogue de personnalités de skin (personalities.json) : editorial, galerie, magazine, studio, deepspace, aurora. RecipeLibrary.php + SectionLibrary.php + theme-library/recipes/sections/skins.
Administration & Système

⚙️ Administration

17 fonctionnalités
Fonctionnalité Actions Fonctionnement exact
Comptes utilisateurs (CRUD & cycle de vie) ListerCréerÉditerActiver/désactiverSupprimer La liste (GET /admin/users) affiche le libellé de rôle, l'état actif, l'activation du 2FA, la date de dernière connexion et un badge couronne pour les super-admins. La création (POST /admin/users) prend username, email, display_name, password, role et is_active, et marque automatiquement is_verified pour les comptes créés par un admin. L'édition (PUT /admin/users/{id}) met à jour profil, rôle et état actif, le mot de passe restant inchangé s'il est laissé vide. Un bouton rapide (POST /admin/users/{id}/toggle-active) désactive « en douceur » un compte qui ne peut alors plus se connecter, et la suppression se fait via DELETE. Validation serveur stricte : username regex 3-50 caractères, unicité username/email, email valide, mot de passe >=8 caractères avec au moins une lettre et un chiffre. Chaque action mutante est écrite dans le journal d'audit (user.create, update, toggle_active, delete, disable_2fa) sans jamais journaliser de secret.
Garde-fous anti-verrouillage & anti-escalade Bloquer suppression/désactivationRestreindre rôles Règles de sécurité invisibles empêchant un admin de se verrouiller dehors ou d'élever ses privilèges. On bloque la suppression, la désactivation ou la rétrogradation du DERNIER super-admin actif (rôle portant le joker « * »), le contrôle s'exécutant dans une transaction verrouillée (SELECT ... FOR UPDATE) pour éviter une course TOCTOU. On interdit aussi de désactiver ou supprimer son propre compte. L'assignation de rôle est restreinte : un acteur non super-admin ne peut attribuer qu'un rôle dont l'ensemble de permissions est un sous-ensemble du sien (le super-admin peut tout attribuer). Côté UI, les boutons activer/désactiver et supprimer sont masqués pour sa propre ligne et pour la ligne du dernier super-admin.
Réinitialisation admin du 2FA d'un utilisateur Réinitialiser/désactiver 2FAAfficher statut Un administrateur peut réinitialiser (désactiver) l'authentification à deux facteurs d'un autre utilisateur, typiquement en cas de perte de son appareil d'authentification (POST /admin/users/{id}/disable-2fa). Le bouton n'est proposé que si l'utilisateur a effectivement le 2FA activé. Après réinitialisation, l'utilisateur se connecte avec son seul mot de passe jusqu'à ce qu'il ré-enrôle le 2FA. Le formulaire d'édition affiche pour chaque utilisateur le statut 2FA activé ou non-activé. L'opération est tracée dans le journal d'audit sous user.disable_2fa.
Rôles & permissions ListerCréerÉditerSynchroniser permissionsSupprimer CRUD des rôles (GET /admin/roles) avec le nombre d'utilisateurs par rôle et une matrice de cases à cocher de permissions. La création prend un name (slug regex minuscules/chiffres/-/_), un label, une description et un jeu de permissions ; l'édition (PUT) re-synchronise l'ensemble des permissions. Protections des rôles système : le name est immuable, le rôle ne peut être supprimé, et le joker super-admin « * » est ré-affirmé pour qu'un admin natif ne puisse jamais être dépouillé de ses pouvoirs. On refuse de supprimer un rôle assigné à des utilisateurs. Anti-escalade sur l'édition des permissions : un non super-admin ne peut ajouter ou retirer que des permissions qu'il détient lui-même, celles qu'il n'a pas étant gelées (impossible d'accorder « * » ou de retirer une permission plus riche). Les identifiants de permissions soumis sont validés/dédupliqués contre les vraies lignes de permissions (évite les lignes pivot orphelines), le cache de permissions Auth est vidé, et l'audit journalise role.create/update/delete.
Paramètres du site groupés (11 onglets) ConsulterEnregistrer par ongletValiderChiffrer secrets Hub de paramètres onglet par onglet (GET/POST /admin/settings?tab=) couvrant : general, reading, seo, social, email, ai, appearance, rgpd, languages, backup, advanced, chacun avec sa propre validation stricte. Exemples : General (nom/description/URL du site, fuseau validé contre DateTimeZone, format de date, posts_per_page, upload_max_size, langue) • Reading (homepage_type posts/page/landing + pages validées existantes et publiées) • SEO (meta par défaut, Google Analytics, Search Console, robots_txt, enable_schema) • Social (URLs réseaux, handle Twitter, image OG, schémas d'URL dangereux javascript:/data: rejetés) • AI (clé API Claude, modèle validé contre le catalogue de coûts, budgets jour/mois numériques positifs, clé WaveSpeed, langue de génération active) • Appearance (couleur, polices, logo/favicon protégés par schéma, CSS et JS d'en-tête personnalisés) • RGPD (mode simple/advanced/none, bannière, URL de confidentialité, cookie/TTL, anonymize_ip, rétention sessions) • Languages (default_language, enable_multilang, url_strategy prefix/query/subdomain) • Advanced (cache, minify_html, debug_mode, maintenance_mode, voice_kill_switch, require_2fa, require_email_verification, rétentions audit/corbeille/formulaires, log_level en liste blanche). Les champs secrets (Setting::SECRET_KEYS) sont stockés chiffrés ; un POST vide préserve le secret existant (les champs mot de passe ne sont jamais réaffichés) et l'absence d'APP_ENCRYPTION_KEY affiche une bannière et refuse d'enregistrer les secrets. Des routes proxy dédiées existent pour appearance, seo, rgpd, et l'audit journalise settings.update en enregistrant uniquement les noms de champs, jamais les valeurs.
Délivrabilité e-mail (DKIM / SPF / DMARC / bounce) Générer clé DKIMRecommander DNSVérifier DNSServir le guideConfigurer SMTP/bounce Conseiller de délivrabilité de l'onglet e-mail. Génère une paire de clés DKIM 2048 bits (POST /admin/settings/email/dkim/generate) en stockant la clé privée chiffrée et en pré-remplissant dkim_domain avec le sélecteur « clustraly » (audit settings.dkim_generate). Recommande des enregistrements DNS SPF/DKIM/DMARC adaptés au fournisseur SMTP détecté, avec une vérification DNS en direct des enregistrements recommandés (POST /admin/settings/email/check-dns renvoyant du JSON). Sert le guide de délivrabilité en texte brut depuis docs/DELIVERABILITY.md avec en-tête nosniff. Configure le SMTP (host, port 1-65535, username, password, encryption tls/ssl/none, from email/nom validés), la signature DKIM (dkim_enabled/domain/selector/private_key), l'adresse de rapport DMARC (dmarc_rua, email validé) et la boîte de rebond (bounce_enabled/host/port/username/password/encryption, suppress_bounced) avec validation port et chiffrement.
Configuration des sauvegardes & test destination PlanifierChoisir destinationChiffrer archivesTester connexion Onglet backup configurant la planification (none/daily/weekly/monthly), l'heure (HH:MM validée), le jour de semaine, le type de sauvegarde et le mode de rétention (jours ou nombre de copies, bornés). Le pilote de destination est choisi et validé contre StorageDriverFactory::DRIVERS : local, FTP/FTPS/SFTP (protocole, host, port 1-65535, user, pass, chemin, mode passif, empreinte host SFTP), S3-compatible (bucket, key, secret, region, endpoint protégé par schéma, prefix, path_style), ou Google Drive OAuth (access/refresh token, client id/secret, folder id). Le chiffrement d'archive au repos peut être activé avec une passphrase obligatoire (refus d'activation sans passphrase) et une option keep-local. Un test en direct de la connexion et des identifiants de la destination distante est disponible (POST /admin/settings/backup/test renvoyant du JSON, audit backup.test_connection).
Ré-encryption des secrets Ré-encrypter secrets en clairRapporter le compte Action de maintenance de l'onglet Advanced qui chiffre tout paramètre secret encore stocké en clair, par exemple écrit avant que la clé de chiffrement n'existe (POST /admin/settings/reencrypt-secrets). Elle ré-encrypte les secrets en clair vers la forme enc:v1: et rapporte le nombre de secrets ré-encryptés. L'opération refuse de s'exécuter quand APP_ENCRYPTION_KEY est absente et journalise un avertissement de sécurité. Elle s'appuie sur Setting::reencryptPlaintextSecrets côté modèle.
Gestion des langues de publication ListerCréerÉditerDéfinir défaut/repliSupprimer CRUD des langues configurées du site (GET /admin/languages), triées par langue par défaut puis sort_order. La création prend code (regex ISO 639, ex. en ou en-US), name, native_name, emoji drapeau, is_active, is_rtl, sort_order, avec rejet des codes en doublon. L'édition permet aussi de fixer is_default et is_fallback : cocher l'un décoche automatiquement toutes les autres lignes, avec un contrôle de doublon de code excluant soi-même. Garde-fous de suppression : on ne peut pas supprimer la langue par défaut, et on refuse la suppression si une ligne *_translations (article, page, catégorie, tag, menu_item) référence encore le code de langue.
Couverture des traductions & édition manuelle Afficher matriceFiltrerÉditer côte-à-côteEnregistrer Matrice de couverture (GET /admin/translations) affichant, par type d'entité (article, category, tag, page, menu_item) croisé avec chaque langue active, le compte traduit/total et le pourcentage avec des badges à code couleur. On peut filtrer les articles publiés en « tous » ou « traduction manquante » pour une langue donnée, avec pagination 25 par page. L'édition (GET /admin/translations/edit?type=&id=&lang=) présente le contenu source à côté des champs traduisibles. L'enregistrement (POST /admin/translations/save) applique des jeux de champs par type (title, slug, excerpt, content, meta_title, meta_description, image_alt pour les articles, etc.) ; entity_type est validé en liste blanche contre l'injection SQL, lang est validé contre les langues actives, et le résultat est inséré/mis à jour (upsert) dans les tables *_translations.
Traduction automatique IA du contenu Auto-traduire (1 ou toutes langues)Contrôler budgetEnregistrer Traduction en un clic d'une entité par l'IA Claude (POST /admin/translations/auto/{entity}/{id}) vers une seule target_lang ou, si omise, vers toutes les langues actives. Garde-fous : bloquée quand les fonctions IA sont désactivées (Paramètres > AI) et quand le budget IA est dépassé (HTTP 429). Elle utilise une invite à délimiteurs (@@@field@@@) issue du PromptRegistry pour que le HTML soit renvoyé brut, avec repli champ par champ vers la source si le modèle omet un champ, et journalise chaque requête Claude avec son coût. Elle est limitée en débit via le RateLimitMiddleware « ai », insère/met à jour les résultats dans les *_translations, et renvoie en JSON le nombre de traductions traitées.
Boîte de notifications ListerMarquer luTout marquer luSupprimer Liste par utilisateur des 50 dernières notifications (GET /admin/notifications), incluant les diffusions (user_id NULL), avec compteur de non-lues, pastilles de priorité (critical/warning/info) et badges de catégorie. On peut marquer une notification lue (POST /admin/notifications/{id}/read, en AJAX), tout marquer lu (POST /admin/notifications/read-all) ou supprimer une notification (DELETE). Protection IDOR : mayAccess() n'autorise à agir que sur ses propres notifications ciblées ou sur les diffusions, sinon renvoie 404. Un bouton « Voir » de lien profond optionnel est proposé par notification.
Préférences de notifications Afficher matriceBasculer In-App/EmailEnregistrer Matrice de préférences par utilisateur (GET /admin/notifications/preferences) sur 6 catégories : Contenu publié, Génération IA, Alertes SEO, Événements système, Alertes de sécurité, Import/Export. Pour chaque catégorie, l'utilisateur bascule la livraison In-App (enabled) et Email puis enregistre (POST /admin/notifications/preferences). Les choix sont insérés/mis à jour (upsert) dans la table notification_preferences. La logique s'appuie sur NotificationPreference (CATEGORIES, forUser, save).
Profil : 2FA (TOTP) Consulter statutEnrôler (QR/clé)ActiverDésactiver Espace 2FA en libre-service (GET /admin/profile/security) où le secret candidat n'est conservé qu'en session tant qu'il n'est pas vérifié. L'enrôlement se fait en scannant un QR otpauth rendu côté serveur en SVG inline (sans JS ni CDN) ou en saisissant la clé de configuration base32 manuelle. L'activation (POST /admin/profile/security/enable) exige la vérification d'un code à 6 chiffres ; le secret chiffré n'est persisté qu'après vérification, et l'activation est refusée si APP_ENCRYPTION_KEY est absente. La désactivation (POST /admin/profile/security/disable) requiert un code d'authentificateur courant OU un code de récupération et alimente le limiteur de débit par IP. Les événements d'activation et de désactivation sont journalisés côté sécurité.
Profil : codes de récupération 2FA Afficher une foisCompter restantsRégénérer Des codes de récupération à usage unique sont émis juste après l'activation ou la régénération du 2FA et affichés exactement une fois via un message flash. L'écran de sécurité affiche le nombre de codes de récupération inutilisés restants. La régénération du lot (POST /admin/profile/security/recovery-codes) invalide les anciens codes, exige un code d'authentificateur courant et est limitée en débit. La mécanique repose sur RecoveryCodeService (generateBatch, storeBatch, consume, remaining).
Profil : langue de l'interface admin Consulter localesEnregistrer langue UICookie AdminLang Sélection par utilisateur de la langue de l'interface admin, indépendante des langues de publication du site (GET /admin/profile/preferences), listant les locales disponibles depuis les fichiers lang/*.json livrés. L'enregistrement (POST /admin/profile/preferences) écrit le UserSetting ui_locale ; une valeur vide ou inconnue signifie « suivre la langue par défaut du site ». Il pose ou efface un cookie AdminLang valable un an pour que les pages de login et 2FA conservent la langue choisie après déconnexion. Le choix est aussi piloté par le sélecteur de langue de la barre supérieure, qui revient de façon sûre vers le chemin interne /admin d'origine en préservant filtres et pagination.
Surveillance de session (statut & prolongation) Lire temps restantProlonger la session Endpoints AJAX soutenant la fenêtre modale d'expiration par inactivité. Le statut (GET /admin/api/session/status) renvoie remaining_seconds, expires_at, la durée de vie et le jeton CSRF courant ; il NE DOIT PAS faire glisser la session (exclu via AuthMiddleware NO_SLIDE_PATHS) et est limité en débit par le limiteur « api ». La prolongation (POST /admin/api/session/extend), le « Rester connecté », fait glisser la session d'une durée de vie complète et fait tourner (rotate) le jeton CSRF en renvoyant le nouveau jeton ; elle est protégée par CSRF et limitée en débit. La mécanique s'appuie sur Session (expiresAt, lifetime, touch) et CsrfMiddleware.
Administration & Système

🔌 Intégration Plugins & API interne

14 fonctionnalités
Fonctionnalité Actions Fonctionnement exact
Découverte & liste des plugins ScannerListerAfficher statutAfficher badges PluginManager::discover() scanne le dossier plugins/, lit chaque plugin.json, valide le manifeste et renvoie une liste triée (ksort) avec slug, manifeste, codes d'erreur et drapeau valid. Le contrôleur PluginController::index (GET /admin/plugins, permission plugins.view) croise la découverte disque avec l'état en base (installedState() lit la table plugins) pour déterminer installé/actif. La vue affiche par plugin un statut (Actif, Inactif ou Non installé), un badge Expérimental (drapeau experimental du manifeste), un badge Manifeste invalide listant les codes d'erreur, et un badge d'intégrité (Vérifié, Modifié ou ). L'intégrité n'est calculée que pour un plugin installé, puisqu'elle exige un checksum stocké.
Validation de manifeste & confinement de namespace ValiderConfinerSignaler erreurs validate() contrôle le plugin.json: slug sûr (regex ^[a-z0-9][a-z0-9-]{1,79}$), cohérence slug (pas de slug_mismatch), champs requis présents et de type chaîne (name, version, namespace, main), version au format semver, et main en identifiant de classe valide. Le namespace doit impérativement débuter par la racine Clustraly\Plugins\ (erreur namespace_not_under_plugins_root), ce qui garantit qu'un plugin ne peut jamais masquer une classe du cœur ou de l'application. Chaque code d'erreur retourné est remonté dans l'UI. Le confinement est ensuite matérialisé au démarrage par un unique autoloader PSR-4 restreint aux préfixes sous NS_ROOT.
Activation & installation automatique ActiverInstallerMigrerVérifier version CMSRejeter conflitJournaliser POST /admin/plugins/{slug}/activate (CSRF + permission plugins.manage) appelle activate(), qui installe d'abord si besoin via install(): revalidation du manifeste, contrôle de requires_cms par version_compare contre la version du CMS, exécution des migrations SQL du plugin, calcul puis stockage du checksum SHA-256, et upsert de la ligne plugins (is_active inchangé). Les fichiers de migration doivent être préfixés par le slug ({slug}_ ou {slug}-), sinon l'installation échoue, car le journal de migrations partagé est indexé par nom de fichier. activate() refuse ensuite l'activation si un autre plugin actif possède déjà le même namespace (namespace_conflict), puis passe is_active = 1. Le succès ou l'échec est affiché en flash et l'action réussie est auditée plugin.activate.
Désactivation (bascule douce) DésactiverJournaliser POST /admin/plugins/{slug}/deactivate (CSRF + plugins.manage) appelle deactivate(), qui valide le slug puis positionne is_active = 0 dans la table plugins. C'est un soft toggle: les fichiers du plugin, ses données et ses migrations restent en place, seul le chargement au démarrage cesse (seuls les plugins actifs sont autoloadés et bootés). L'opération est toujours signalée en succès et auditée plugin.deactivate.
Désinstallation (fichiers + registre) DésinstallerSupprimer ligneEffacer fichiersConfirmerJournaliser DELETE /admin/plugins/{slug} (CSRF + plugins.manage) appelle uninstall(), qui supprime la ligne de registre (DELETE FROM plugins) puis efface récursivement le dossier du plugin via rrmdir(). La suppression disque est strictement confinée: le realpath du dossier ciblé doit commencer par le realpath du dossier plugins/, sinon rien n'est effacé, et les liens symboliques ne sont pas suivis lors du parcours récursif (protection contre les évasions de chemin). L'UI demande une confirmation avant l'action, et la désinstallation réussie est auditée plugin.uninstall.
Intégrité SHA-256 & détection d'altération Calculer checksumVérifierAfficher badgeBloquer au boot (option) computeChecksum() calcule un SHA-256 incrémental (hash_init puis hash_update) sur tous les fichiers .php et .json du plugin, en hachant à la fois le chemin relatif et le contenu de chaque fichier trié. Ce checksum est capturé à l'installation/activation et stocké en base; verifyIntegrity() le recompare avec hash_equals pour afficher le badge Vérifié ou Modifié. En option, la variable d'environnement PLUGIN_INTEGRITY_ENFORCE=true transforme cette détection en barrière fail-closed: au démarrage, un plugin actif dont le code sur disque ne correspond plus au checksum est refusé au chargement et journalisé, sans interrompre le reste du boot. Par défaut la vérification est désactivée (coût nul par requête) et reste une défense en profondeur, présupposant un attaquant disposant déjà d'un accès en écriture.
Chargement isolé au démarrage Autoloader PSR-4Charger actifs seulementIsolerIgnorer collisions PluginManager::boot() (invoqué avant l'action cms.boot) ne charge que les plugins actifs et valides. Il enregistre un unique autoloader spl_autoload_register confiné aux préfixes sous Clustraly\Plugins\, instancie la classe main de chaque plugin, vérifie qu'elle implémente PluginInterface, puis appelle register(). Tout est encadré par try/catch: un plugin absent, une classe introuvable, un contrat non respecté ou un register() qui lève une exception sont journalisés sans jamais interrompre la requête. Un garde anti-shadowing ignore de façon déterministe tout plugin actif réclamant un namespace déjà revendiqué, et une table plugins manquante (avant migration) fait échouer proprement (retour vide).
Système de hooks (actions & filtres) Enregistrer listenerDéclencher actionAppliquer filtreIsoler La classe Hooks est le socle d'extension des plugins, avec deux mécanismes façon WordPress sur un registre unique: les actions (addAction/doAction) exécutées pour leurs effets de bord (retour ignoré), et les filtres (addFilter/applyFilters) qui chaînent et transforment une valeur passée en premier argument. Les listeners sont ordonnés par priorité croissante (défaut 10) et ne reçoivent que leurs acceptedArgs premiers arguments (défaut 1). Chaque listener s'exécute dans un try/catch: une exception est journalisée sans casser la chaîne, et pour un filtre la valeur courante est conservée intacte. La liste des listeners est figée (snapshot) avant itération pour une ré-entrance déterministe; le cœur expose des hooks documentés (cms.boot, content.saved, content.published, content.render, view.output, seo.head, sitemap.urlset, ai.generating, etc.).
API JSON admin opérations articles ListerLireCréerAutosauvegarder Endpoints JSON de gestion d'articles sous /admin/api. articles (GET, pagination page/per_page plafonné à 50, filtre status) et article (GET /{id}) appliquent un contrôle au niveau objet: les rôles non élevés (sans articles.edit_others) ne listent/lisent que leurs propres articles via ownsOrCan, sinon 403 JSON. createArticle (POST) décode le corps JSON, exige un titre, désinfecte le contenu par HtmlSanitizer::clean, génère un slug unique via SlugService::generateUnique et met status/visibility en liste blanche contre leurs ENUM (sinon MySQL non strict coerce en valeur vide), avant de renvoyer 201. autosaveArticle (POST /{id}/autosave) crée une version de brouillon via VersionService::createVersion de type autosave et renvoie l'horodatage.
API JSON admin endpoints SEO Lire pondérationsVérifier cannibalisationCalculer score seoWeights (GET /admin/api/seo-weights) renvoie les dix pondérations du score SEO lues depuis les réglages (Setting::getValue avec valeurs par défaut). checkKeyword (POST /admin/api/seo/check-keyword) effectue un contrôle anti-cannibalisation: recherche dans seo_meta les articles partageant le même focus_keyword (avec exclusion optionnelle d'un id) et renvoie les conflits détectés. seoScore (GET /admin/api/seo/score/{articleId}) recalcule le score via le moteur unifié SeoService::calculateScore en réutilisant titre, contenu, mot-clé focus, mots-clés secondaires et lexicaux, slug, meta description et cible de mots, garantissant exactement le même score que l'éditeur. Toutes exigent la permission seo.view (le POST ajoute le CSRF).
API JSON admin analytics, heatmap & stats dashboard Récupérer pages vuesRécupérer heatmapAgréger stats analyticsPageviews (GET, ?days borné entre 1 et 90) renvoie l'aperçu, les pages vues par jour et le top 10 via AnalyticsService. analyticsHeatmap (GET /{articleId}) renvoie les stats et zones d'une carte de chaleur d'article via HeatmapService, avec un try/catch qui convertit toute erreur SQL (ex. table heatmap_data manquante) en 500 JSON exploitable au lieu d'un 500 HTML illisible côté client. dashboardStats (GET) agrège des compteurs (articles, publiés, pages, médias, commentaires en attente, score SEO moyen, redirections, meta manquantes, analytics), chaque sous-requête étant tolérante aux pannes (try/catch renvoyant 0 si une table n'existe pas encore). Les deux premiers exigent la permission analytics.view.
API JSON admin actions groupées (bulk) PublierMettre en brouillonArchiverSupprimer vers corbeille POST /admin/api/bulk (CSRF + articles.create) applique une action sur une liste d'ids d'articles: publish, draft, archive ou delete. Le contrôle au niveau objet filtre d'abord les ids non possédés pour les rôles non élevés (sans articles.edit_others), et l'action publish est en outre soumise au verrou éditorial articles.publish (403 sinon, avec invitation à soumettre pour relecture). publish/draft/archive réalisent un UPDATE de statut en masse (publish renseigne published_at via COALESCE); delete route chaque article vers la corbeille restaurable via TrashService::trash plutôt qu'une suppression définitive. La réponse renvoie le nombre d'éléments affectés et l'action appliquée.
API JSON admin préférences, layout, tri & autocomplétion Enregistrer préférencesEnregistrer layoutRéordonner catégoriesAutocompléter preferences (PUT, CSRF) n'accepte qu'une liste blanche stricte de colonnes réellement existantes (dashboard_layout) et rejette tout autre champ en 400, encodant les tableaux en JSON. dashboardLayout (PUT, CSRF) filtre le layout contre la liste canonique des blocs (DashboardController::BLOCKS) avec array_unique, un tableau vide restant une valeur légitime (« tout masquer »). sortCategories (PUT, CSRF + categories.edit) met à jour sort_order et parent_id par lot. searchAutocomplete (GET, min 2 caractères, permission articles.view) et tagsAutocomplete (GET, permission tags.view) renvoient des suggestions de recherche et de tags. La méthode sortMenuItems existe encore mais sa route a été retirée comme code mort (le tri des menus est persisté par la sauvegarde du formulaire de menu).
API JSON admin sécurité & contrôle d'accès Authentifier sessionAutoriser par permissionExiger CSRFLimiter le débit Tous les endpoints /admin/api sont regroupés sous un RateLimitMiddleware('api') (limitation de débit par seau « api ») et protégés par l'authentification de session du groupe admin. La plupart des routes portent un AuthorizeMiddleware de permission fine (articles.view, articles.create, articles.edit, seo.view, analytics.view, tags.view, categories.edit), et toutes les mutations (POST/PUT) ajoutent CsrfMiddleware pour bloquer les requêtes inter-sites; quelques routes (stats du dashboard, layout, préférences) s'appuient sur la session, plus le CSRF pour les PUT. Le contrôle d'accès au niveau objet (Auth::can, Auth::ownsOrCan) restreint en plus lecture et actions aux ressources possédées pour les rôles non élevés. Les réponses sont uniformément en JSON avec des codes HTTP adaptés (403, 404, 422, 500).
Administration & Système

🔗 Intégration Jetons API, Webhooks & API REST publique Complété

16 fonctionnalités
Fonctionnalité Actions Fonctionnement exact
Jetons API liste et statuts Consulter la listeVoir le préfixe masquéVoir propriétaire/scopes/dernière utilisation/expirationLire le statut GET /admin/api-tokens affiche un tableau admin de tous les jetons émis. Chaque ligne présente le nom, un préfixe masqué (clustraly_xxxx…), le propriétaire (nom d'affichage ou username), les scopes accordés, l'horodatage de dernière utilisation, l'expiration et un statut dérivé. Le secret complet n'est jamais réaffiché ici seul le préfixe reste visible. Le rendu est produit par ApiTokenService::listForAdmin et le statut (Actif, Expiré ou Révoqué) est calculé dynamiquement par la méthode status.
Création de jeton avec révélation unique du secret Créer un jetonChoisir les scopesFixer l'expirationCopier le secretJournaliser (audit) POST /admin/api-tokens émet un jeton à haute entropie au format clustraly_&lt;préfixe&gt;_&lt;secret&gt;, dont seule l'empreinte SHA-256 est stockée en base (le secret en clair n'est jamais persisté). L'admin renseigne un nom, coche les scopes (content:read pré-coché, content:write) et définit une expiration en jours (0 = jamais). La valeur brute est affichée une seule fois via un message flash « one-shot » doté d'un bouton Copier dans le presse-papiers, puis devient irrécupérable. L'action api_token.create est journalisée dans l'audit, sans jamais y consigner le jeton brut.
Révocation de jeton RévoquerConfirmerJournaliser (audit) DELETE /admin/api-tokens/{id} révoque un jeton via un UPDATE idempotent et « race-safe » (résistant aux appels concurrents), de sorte que toute application l'utilisant cesse immédiatement d'être authentifiée. Une boîte de dialogue de confirmation avertit l'admin avant l'opération. L'action api_token.revoke est enregistrée dans l'audit. Le statut du jeton bascule alors définitivement sur « Révoqué ».
Modèle de scopes, expiration et dernière utilisation Normaliser/dé-dupliquer les scopesVérifier le jetonHorodater l'usageDériver le statut Chaque jeton porte un ensemble de scopes JSON normalisé et dé-dupliqué contre KNOWN_SCOPES (content:read, content:write) plus le joker '*'. À chaque appel authentifié, la méthode verify contrôle que le jeton est actif, non révoqué et non expiré. L'usage déclenche un marquage « best-effort » de last_used_at et last_used_ip (IP appelante) via touch. Le statut est dérivé à la volée en actif, révoqué ou expiré selon l'état de révocation et la date d'expiration.
Webhooks CRUD des points de terminaison ListerCréerÉditerMettre à jourSupprimerBasculer actif/inactifJournaliser (audit) Les écrans /admin/webhooks permettent de créer (POST), éditer (GET .../edit), mettre à jour (PUT) et supprimer (DELETE) des points de terminaison sortants, chacun défini par un nom, une URL HTTPS, les événements souscrits et un interrupteur actif/inactif. L'URL est soumise à une validation anti-SSRF stricte via SafeHttpClient: HTTPS obligatoire et adresse IP publique uniquement, ce qui bloque les cibles internes ou privées. La suppression d'un webhook supprime aussi son journal de livraisons associé. Les actions webhook.create, webhook.update et webhook.delete sont journalisées dans l'audit.
Gestion du secret de signature Générer le secretRévéler à l'éditionRégénérer (rotation)Chiffrer au repos Chaque webhook possède un secret de signature HMAC au format whsec_…, généré une seule fois et affiché une seule fois à la création. Le secret courant est révélable sur l'écran d'édition (clic pour sélectionner via data-select) et peut être régénéré lors d'une mise à jour en cochant regenerate_secret, ce qui réaffiche une seule fois le nouveau secret. Au repos, le secret est chiffré en AES-256-GCM (via le mécanisme Setting) dès qu'une clé de chiffrement est configurée. Ce secret sert à signer le corps de chaque livraison sortante.
Catalogue d'événements souscriptibles Sélectionner via cases à cocherSouscrire au joker '*'S'abonner aux événements contenu/commentaire/contact/erreur Un webhook s'abonne à un sous-ensemble des 9 événements dispatchables ou au joker '*' (qui se réduit à l'ensemble des événements). Le catalogue couvre article.published, article.updated, page.published, page.updated, comment.created, contact.created, ainsi que les alertes Error-Intelligence error.new, error.regression et error.spike. Les événements contenu, commentaire et contact sont câblés depuis le système de hooks (registerHooks). La normalisation (normalizeEvents) gère à la fois le joker et la sélection par cases à cocher.
Journal de livraison, ping de test et renvoi Consulter les livraisonsEnvoyer un événement de testRenvoyer une livraison stockéeJournaliser (audit) Un journal de livraisons à corps signé affiche, pour chaque envoi, le statut (Delivered, Failed ou Pending), le code HTTP de réponse, le compteur tentatives/max, l'erreur éventuelle et l'horodatage en vue globale (50 dernières) et par webhook (100). Le bouton « Send test event » (POST .../test) déclenche un ping synthétique vers l'endpoint. Le bouton « Resend » (POST .../deliveries/{id}/resend) rejoue une livraison déjà stockée. Les actions webhook.test et webhook.resend sont enregistrées dans l'audit.
Moteur de livraison signée et de retry Mettre en fileSigner HMAC-SHA256Envoyer via client anti-SSRFRéessayer avec backoffMarquer succès/échec Pour chaque webhook souscrit, une livraison est mise en file sous forme d'une ligne scheduled_tasks, puis envoyée par le worker cron. Chaque POST porte une signature HMAC-SHA256 dans l'en-tête X-Clustraly-Signature: sha256=…, accompagnée d'en-têtes event, delivery, webhook et timestamp, et transite par le SafeHttpClient (HTTPS/IP publique, délais d'expiration, réponse plafonnée). En cas d'échec, la livraison est réessayée avec un backoff exponentiel (1m, 5m, 30m, 2h, 6h) jusqu'à un maximum de 6 tentatives avant d'être marquée « failed ». Chaque tentative met à jour last_status et last_delivery_at du webhook.
API REST publique lecture des articles (/api/v1) Lister les articles paginésObtenir un article par slugFiltrer par langue (?lang) GET /api/v1/articles retourne des résumés paginés d'articles publiés et à visibilité publique, avec paramètre ?lang. GET /api/v1/articles/{slug} renvoie le détail complet (auteur, catégories, tags, image à la une). Le périmètre « publié + public » est imposé côté serveur via publishedArticles dans BaseController. L'authentification Bearer et le scope content:read sont requis. La sérialisation applique la locale multilingue et un whitelisting de champs (jamais Model::toArray), écartant toute fuite de champ interne.
API REST publique écriture des articles (/api/v1) Créer un articleMettre à jour par slugAssainir le HTMLDéclencher les hooks contenuJournaliser (audit) POST /api/v1/articles et PUT /api/v1/articles/{slug} créent ou mettent à jour des articles et exigent le scope content:write. Le HTML est nettoyé via HtmlSanitizer::clean, comme dans le chemin admin, en protection contre le XSS stocké. Le statut est limité à draft ou published (normalizeStatus), le slug est auto-unifié avec redirection en cas de changement, et le nombre de mots et le temps de lecture sont recalculés. Les hooks content.saving, content.saved et content.published sont déclenchés (ce qui fait donc partir les webhooks), et les actions api.article.create/api.article.update sont journalisées avec l'id du jeton appelant.
API REST publique lecture des pages (/api/v1) Lister les pages paginéesObtenir une page par slugFiltrer par langue (?lang) GET /api/v1/pages retourne une liste paginée de pages publiées à visibilité publique (avec ?lang), et GET /api/v1/pages/{slug} renvoie le détail (template, méta, image à la une). L'accès est en lecture seule, authentifié Bearer, et exige le scope content:read. Le périmètre publié+public est imposé côté serveur via publishedPages. La sérialisation applique la locale et un whitelisting de champs par ApiTransformer.
API REST publique taxonomies catégories et tags (/api/v1) Lister les catégoriesObtenir une catégorie + ses articlesLister les tagsObtenir un tag + ses articles GET /api/v1/categories et GET /api/v1/tags listent les termes en lecture seule. GET /api/v1/categories/{id} et GET /api/v1/tags/{id} renvoient le terme accompagné de ses articles publiés, paginés. Le scope content:read est requis sur ces routes. La sérialisation passe par ApiTransformer avec whitelisting de champs.
API REST publique recherche (/api/v1/search) Rechercher (min 2 caractères)Re-filtrer en directRetourner suggestion/correction GET /api/v1/search?q= effectue une recherche plein texte sur l'index préconstruit, avec un minimum de 2 caractères, un paramètre ?lang et une pagination. Les résultats sont re-filtrés en direct pour retirer les articles et pages non publics (dropNonPublic), en défense en profondeur. La réponse inclut d'éventuelles métadonnées de suggestion orthographique ou de correction. Le scope content:read est requis.
API REST publique doc de découverte/health & préflight CORS Obtenir le document de découverte/healthRépondre au préflight CORS (OPTIONS) GET /api/v1 renvoie un document de découverte/health contenant le nom de l'application, les versions api et cms, le nom et les scopes du jeton appelant, la liste des scopes disponibles et une carte des ressources. Ce document requiert le scope content:read. Les requêtes OPTIONS /api/v1 et /api/v1/{any} fournissent un repli de préflight CORS. Il sert de point d'entrée d'auto-documentation pour les intégrateurs.
API REST publique auth Bearer, gating de scope, CORS & rate-limit Authentifier par BearerContrôler le scope par routeGérer le CORSLimiter le débit par jeton Chaque endpoint /api/v1 exige un en-tête Authorization: Bearer résolu vers une ligne de jeton active, faute de quoi un 401 JSON est renvoyé avec un en-tête WWW-Authenticate (ApiAuthMiddleware). Un contrôle de scope par route (ApiScopeMiddleware) impose content:read ou content:write, le joker '*' satisfaisant n'importe quel scope, avec un 403 JSON insufficient_scope si le scope est insuffisant. Le CORS est pris en charge et une limitation de débit par jeton s'applique via le bucket 'api'. Ces middlewares s'appliquent au groupe de routes puis, plus finement, par route.
Administration & Système

🛠️ Maintenance Sauvegardes, Mises à jour, Import/Export

14 fonctionnalités
Fonctionnalité Actions Fonctionnement exact
Création manuelle de sauvegarde CréerChoisir le typeRecevoir la réponse L'administrateur déclenche une sauvegarde à la demande via POST /admin/backups/run, qui produit une archive ZIP du dump SQL et/ou des médias et assets.
Trois types sont acceptés: full (défaut), database (BDD seule) ou files (fichiers seuls); tout type inconnu est ramené à full.
La réponse est renvoyée en AJAX ou en redirection avec l'identifiant, le nom de fichier, le statut et la taille lisible.
L'opération est journalisée dans l'audit sous backup.create et une notification est émise à l'achèvement ou en cas d'échec.
Liste / tableau de bord des sauvegardes ConsulterVoir statutsVoir planning GET /admin/backups affiche les 20 dernières sauvegardes via Backup::latest(20) avec nom de fichier, type, taille, emplacement de stockage, statut, date et une icône cadenas pour les archives chiffrées.
La destination courante (Local, FTP/SFTP, S3 ou Google Drive) est indiquée avec un lien vers Réglages &gt; Sauvegarde.
Un panneau « Sauvegardes automatiques » résume la planification, la dernière exécution (badge de statut et erreur éventuelle) et la prochaine exécution, ou « Imminent (prochain passage cron) ».
Chaque ligne porte un badge de statut: Terminé, En cours, Échoué ou Inconnu.
Restauration de sauvegarde RestaurerConfirmerDéchiffrer si besoin POST /admin/backups/{id}/restore (permission backups.restore, CSRF) rejoue le dump SQL et recrée les fichiers médias/assets, après une confirmation UI avertissant que les données actuelles seront écrasées.
Le mécanisme est « remote-aware »: si aucune copie locale n'existe, l'artefact est récupéré depuis la destination distante, puis déchiffré s'il est chiffré (passphrase requise).
Pour un rejeu fidèle inter-hôtes, le mode SQL est relâché (SET SESSION sql_mode=''); statements_total et statements_failed sont comptés et la restauration échoue si au moins une instruction échoue.
Un garde anti-« path traversal » filtre les entrées du ZIP, bloque les extensions exécutables (php, phtml, phar, cgi, pl, py, sh, htaccess) et assainit les SVG via SvgSanitizer; l'action est journalisée backup.restore avec notification de succès.
Suppression de sauvegarde SupprimerConfirmer DELETE /admin/backups/{id} (via _method=DELETE, permission backups.delete, CSRF, confirmation UI) supprime la sauvegarde partout où elle réside.
La purge « best-effort » cible le fichier local, l'objet distant et la ligne en base de données.
Si la sauvegarde est introuvable, une réponse 404 (JSON ou flash) est renvoyée.
L'opération est journalisée backup.delete.
Téléchargement de sauvegarde Télécharger GET /admin/backups/{id}/download (permission backups.view) n'est proposé que pour les sauvegardes terminées.
resolveLocalArtifact récupère l'archive depuis la destination distante lorsqu'aucune copie locale n'est présente; la copie temporaire est supprimée après mise en tampon (buffering).
L'artefact est servi tel qu'il est stocké donc encore chiffré s'il l'était.
L'action est journalisée backup.download.
Envoi (upload) d'une sauvegarde existante vers le distant EnvoyerSurcharger le driver POST /admin/backups/{id}/upload (permission backups.create, CSRF) pousse manuellement une sauvegarde locale déjà créée vers une destination distante; le bouton n'apparaît que si la destination configurée n'est pas « local ».
Un paramètre « driver » optionnel permet de surcharger la cible, validé contre les drivers connus.
Si la cible résout vers le local, l'envoi est rejeté (422).
Le résultat est journalisé backup.upload avec le driver et un indicateur ok, puis renvoyé en flash ou JSON (502 en cas d'échec distant).
Sauvegardes automatiques / planifiées PlanifierChoisir fréquence, heure, typeLaisser le cron exécuter La planification se règle dans Réglages &gt; Sauvegarde: backup_schedule (aucune, quotidienne, hebdomadaire, mensuelle), backup_time (HH:MM), backup_weekday (0-6) et backup_type (full, database, files).
enqueueIfDue() dépose une tâche one-shot auto_backup dans scheduled_tasks et empêche l'empilement (une seule tâche en attente ou en cours à la fois).
Un modèle déterministe « déclencher une fois par période à HH:MM » calcule la prochaine exécution.
L'exécution effective est assurée par le pseudo-cron ou GET /cron/run qui appelle BackupService::run(); l'UI affiche le résumé du planning, le statut/erreur de la dernière tâche auto et la prochaine exécution prévue.
Politique de rétention Choisir le modeDéfinir le seuil Deux stratégies s'appliquent à la fois aux fichiers locaux et aux objets distants: par âge (backup_retention_mode=days, backup_retention de 1 à 3650, défaut 30 suppression au-delà de N jours) ou par nombre (mode count, backup_retention_copies de 1 à 1000, défaut 7 conserver les N copies les plus récentes).
pruneOld() purge les lignes en base et les fichiers locaux; pruneRemote() purge les objets distants en « best-effort », l'âge étant déduit du mtime ou du nom de fichier.
La plus récente sauvegarde terminée est toujours conservée, de sorte que le site ne se retrouve jamais sans aucune sauvegarde.
Destinations de stockage distant (multi-driver) Choisir la destinationConfigurerTester la connexionPurger la copie locale (option) backup_destination sélectionne la cible: local, ftp, s3 ou gdrive, construite par une fabrique de drivers (StorageDriverFactory) qui déchiffre les identifiants stockés.
FTP couvre protocole ftp/ftps/sftp, hôte, port, utilisateur, mot de passe chiffré, chemin, mode passif et empreinte (fingerprint) d'hôte SFTP; S3 couvre bucket, région, clé, secret chiffré, endpoint personnalisé, préfixe et bascule path-style; Google Drive couvre jetons d'accès et de rafraîchissement chiffrés, id de dossier, client id et client secret chiffré.
POST /admin/settings/backup/test sonde en JSON les identifiants de la configuration ENREGISTRÉE (journalisé backup.test_connection).
La bascule backup_keep_local permet de supprimer la copie locale après un envoi distant réussi.
Chiffrement au repos des archives ActiverDéfinir la passphrase Activé par backup_encrypt plus une passphrase backup_encrypt_key; l'activation est bloquée si aucune passphrase n'est définie.
ArchiveCipher::encryptFile produit une archive .enc et positionne le drapeau is_encrypted; la restauration et le téléchargement détectent et déchiffrent automatiquement.
Il s'agit d'un chiffrement côté serveur appliqué à l'archive avant qu'elle ne quitte le serveur.
Fail-safe: si le chiffrement est demandé mais que la passphrase est illisible (APP_ENCRYPTION_KEY manquante ou renouvelée), la copie en clair est conservée EN LOCAL, l'envoi distant est ignoré et une notification critique est émise aucun texte clair n'est jamais expédié au distant.
Mises à jour système / migrations de base Consulter la versionAppliquer les migrationsConfirmer GET /admin/system/updates (permission system.update) affiche la version courante, le nombre de migrations appliquées, les noms des migrations en attente et, en option, une bannière « nouvelle version disponible » issue d'un manifeste distant avec lien vers les notes de version.
POST /admin/system/updates/run (CSRF, confirmation UI) applique les migrations en attente de façon sûre: sauvegarde BDD automatique et instantané .env en 0600, puis exécution des migrations, purge des caches et journalisation system.update.
Un court-circuit « déjà à jour » évite toute sauvegarde ou purge inutile.
La vérification du manifeste distant se fait via update.manifest_url à travers un client SafeHttpClient anti-SSRF (comparaison par version_compare).
Export de données Choisir les entitésExporter en JSONExporter en CSV Sélection par cases à cocher des entités (articles, catégories, tags, pages chacune avec sa table *_translations).
POST /admin/export/download en format=json (défaut) télécharge clustraly-export-AAAA-MM-JJ.json contenant cms, version, format_version et checksum (format v2 avec empreinte SHA-256).
En format=csv, il télécharge clustraly-export-AAAA-MM-JJ.zip avec un fichier CSV par entité.
Permission settings.edit, CSRF; opération journalisée data.export avec les entités et le format.
Import de données Téléverser le JSONChoisir la stratégie de doublons POST /admin/import/upload (multipart, permission settings.edit, CSRF) importe un fichier d'export JSON Clustraly.
Deux stratégies de doublons: skip (INSERT IGNORE, ignore les slugs existants) ou merge (INSERT ... ON DUPLICATE KEY UPDATE).
La validation vérifie l'entête cms=clustraly, met en liste blanche les tables d'entités et de traductions, et intersecte les lignes avec les colonnes réelles de chaque table (protection contre l'injection SQL).
Le résultat rapporte « X enregistrements importés », émet une notification importCompleted et est journalisé data.import avec le nombre d'enregistrements et la stratégie; un chemin d'import CSV (importCsv) existe dans le service mais n'est actuellement pas référencé par le contrôleur.
Exécution planifiée (cron / pseudo-cron) Exposer l'URL cronExécuter les tâches dues GET /cron/run, protégé par token, exécute sous verrou les tâches scheduled_tasks arrivées à échéance.
Côté sauvegarde, il dispatche le type auto_backup vers BackupService::run; à chaque tick, BackupScheduleService::enqueueIfDue() met en file la sauvegarde planifiée si elle est due c'est le moteur d'exécution de la planification automatique.
Le même runner assure d'autres tâches de maintenance et une relève de file, mais son rôle ici est de faire tourner les sauvegardes programmées.
BuildQueueController expose l'URL web-cron /cron/run?token=… destinée aux planificateurs externes.
Administration & Système

📧 Maintenance Email (file, délivrabilité) & Cache Complété

12 fonctionnalités
Fonctionnalité Actions Fonctionnement exact
File d'attente email (outbox asynchrone) ListerFiltrerConsulter le détail Page GET /admin/emails (permission emails.manage) qui liste les emails en file avec des chips de filtrage par statut (Tous, En attente, Envoyé, Échoué, Annulé) et un compteur par statut. Le détail (GET /admin/emails/{id}) expose le destinataire, le sujet, le contexte, le reply-to, le compteur de tentatives N/max, les horodatages de mise en file, d'envoi et de prochaine tentative, la dernière erreur et le corps brut du message. Le modèle est asynchrone: la requête web se contente d'enfiler le message tandis qu'un worker cron effectue l'envoi réel. La file est persistée en base, ce qui autorise historique, ré-essais et traçabilité.
Renvoi & annulation d'un email RenvoyerAnnuler Renvoyer (POST /admin/emails/{id}/resend, CSRF) remet le message en file en réinitialisant son compteur de tentatives; l'action n'est offerte que pour les statuts Échoué, Envoyé ou Annulé et est journalisée email.resend. Annuler (POST /admin/emails/{id}/cancel, CSRF) stoppe un (ré)envoi; proposée pour les statuts En attente, En cours d'envoi ou Échoué, journalisée email.cancel. Ces deux commandes donnent un contrôle manuel sur la livraison sans relancer tout le pipeline.
Mécanique de livraison & retry (backoff) EnfilerRéessayerRécupérer(Dés)activer la file Réclamation atomique du statut pending vers sending pour éviter qu'un même message soit envoyé deux fois par des ticks cron concurrents. En cas d'échec, back-off exponentiel selon la grille [1m, 5m, 30m, 2h, 6h] jusqu'à max_attempts (5 tentatives). La routine reap() récupère les lignes bloquées ou orphelines restées en « sending ». Un interrupteur email.queue_enabled active ou désactive la mise en file. L'exécution est portée par le cron (type de tâche email_send vers MailQueueService::sendTask), déclenché à chaque tick.
Transport SMTP (mailer) EnvoyerMettre en fileEnvoyer en critique Mailer sans dépendance externe exposant send() (immédiat), queue() (asynchrone) et sendCritical() (envoi immédiat, puis bascule en file si échec). Il construit un corps multipart texte+HTML conforme RFC 5322 (quoted-printable) avec Message-ID, en-têtes List-Unsubscribe et one-click RFC 8058, ainsi que Precedence: bulk. Il gère STARTTLS/SSL et AUTH LOGIN; en l'absence d'hôte SMTP configuré, il se replie sur la fonction PHP mail() avec expéditeur d'enveloppe -f. Les CR/LF sont retirés des en-têtes pour bloquer l'injection d'en-têtes. Deux points d'extension sont exposés: email.sending (filtre) et email.sent (action).
Liste de suppression (bounces/plaintes) ConsulterAjouter (suppression manuelle)Retirer Liste GET /admin/emails/suppressions (permission emails.manage) affichant adresse, badge de type (Hard bounce, Soft bounce, Complaint, Manual), motif, nombre de hits et date du dernier rebond. Ajout manuel d'une adresse (POST .../suppressions/add, CSRF, adresse validée; journalisé email.suppress_add) et retrait par id (POST .../suppressions/{id}/remove, CSRF; journalisé email.suppress_remove). Le mailer et la file sautent les destinataires supprimés de type hard, complaint ou manual, préservant la réputation d'expéditeur. Un interrupteur suppress_bounced gouverne la prise en compte des rebonds.
Polling POP3 des rebonds (DSN/ARF) ConfigurerInterroger la boîte (cron)ClasserAuto-supprimer Configuration dans Réglages > Email: bounce_enabled, bounce_host, bounce_port, bounce_username, mot de passe chiffré et bounce_encryption (ssl/tls). Le cron (type process_bounces vers BounceService::poll()) implémente un client POP3 en pur PHP (mise à niveau STLS, commandes RETR/DELE, 50 messages max par passe). parseBounce classe chaque message en hard, soft ou complaint à partir des DSN standard, de X-Failed-Recipients, des plaintes ARF et des adresses 5xx; seuls les hard et complaint sont persistés dans email_suppressions via un upsert protégé contre les accès concurrents. La même boîte sert aussi d'expéditeur d'enveloppe (Return-Path / -f) pour l'alignement SPF.
Conseiller de délivrabilité (SPF/DKIM/DMARC) Recommander les enregistrementsVérifier le DNS en directConsulter le guide Panneau de recommandations dans Réglages > Email qui construit les enregistrements DNS conseillés pour le domaine d'envoi: SPF (include du fournisseur auto-détecté parmi Google, M365, SendGrid, Mailgun, SES, Brevo, Mailjet, Postmark, etc.), DKIM (valeur p= réelle si une clé existe) et DMARC (p=none de monitoring avec rua). Vérification DNS en direct (POST .../email/check-dns, JSON) renvoyant par enregistrement un statut ok/warn/missing/unknown assorti de conseils de politique (avertissement sur +all, p= manquant). Le domaine d'envoi est dérivé de from_email, sinon de l'hôte de site_url, sinon de l'hôte de app.url. Un guide de délivrabilité (GET .../email/guide) sert le fichier docs/DELIVERABILITY.md en text/plain.
Signature DKIM & génération de clé Générer la paire de clésConfigurerSigner les messages Génération en un clic d'une paire DKIM (POST .../email/dkim/generate, CSRF, permission settings.edit): RSA 2048 bits, la clé privée est stockée chiffrée, l'opération pré-remplit dkim_domain et le sélecteur « clustraly »; journalisé settings.dkim_generate. Configuration associée: dkim_enabled, dkim_domain, dkim_selector, clé privée chiffrée et dmarc_rua. Le mailer signe chaque message SMTP (rsa-sha256, canonicalisation relaxed/relaxed) uniquement lorsque DKIM est configuré ET que le domaine du From s'aligne sur d= (sinon il saute la signature et le journalise). La valeur de clé publique à publier en DNS est affichée à l'admin, et des candidats de configuration OpenSSL de repli couvrent les builds Windows/Docker minimalistes.
Vider le cache ViderInvalider OPcache POST /admin/cache/clear (permission settings.edit, CSRF) vide le répertoire storage/cache/pages et invalide l'OPcache pour les fichiers clés. L'action émet une notification cacheCleared et répond soit en JSON (requête AJAX) soit par une redirection vers le referer. C'est la purge manuelle complète du cache HTML de pages.
Statistiques du cache Consulter les stats GET /admin/api/cache/stats (permission settings.view, endpoint à débit limité) renvoie le nombre de fichiers, la taille en octets et en format lisible, ainsi que la stratégie de purge effective. Sert au suivi de l'occupation du cache disque depuis l'admin.
Auto-purge du cache (FIFO/LRU) Purger automatiquement Lorsque la taille dépasse cache.max_size_mb, une auto-purge ramène l'occupation à environ 80% de la limite. Deux stratégies: fifo par défaut ou lru; le mode lru exécute une sonde de fiabilité de l'atime et se rétrograde automatiquement en fifo si l'atime n'est pas fiable. La fraîcheur des entrées (TTL) est évaluée via le mtime des fichiers.
Invalidation ciblée du cache Invalider un article, une catégorie ou une page Helpers programmatiques invalidateArticle, invalidateCategory et invalidatePage qui purgent les entrées de cache d'une entité donnée à travers toutes les variantes de langue actives. Cela permet de rafraîchir seulement le contenu réellement modifié plutôt que de vider tout le cache. La fraîcheur repose sur le mtime (TTL).
Sécurité & Conformité

🔐 Sécurité Authentification, 2FA, Anti-intrusion Complété

12 fonctionnalités
Fonctionnalité Actions Fonctionnement exact
Journal des tentatives de connexion (dashboard) ConsulterFiltrer par emailFiltrer par IPPaginer Visionneuse admin en lecture seule (permission security.view) listant chaque tentative de connexion, succès comme échec, à raison de 50 par page et les plus récentes en premier.
• Chaque ligne porte un badge de statut coloré (vert = succès, rouge = échec).
• Filtres par email (correspondance LIKE sur email_tried) et par adresse IP (LIKE sur ip_address), combinables et conservés à travers la pagination.
• Côté backend, RateLimitMiddleware::recordAttempt() insère chaque tentative dans login_attempts et purge les lignes échouées d'une IP dès qu'une connexion réussit.
Verrouillage progressif après échecs & rate-limiting à fenêtre glissante Verrouiller progressivementLimiter par minuteRenvoyer 429Échouer en mode ouvert Défense anti-force-brute à deux étages.
• Verrouillage IP escaladé sur le bucket auth (POST uniquement): 3 échecs/24h → 30 min, 6 → 1h, 10 → 24h.
• Limiteur générique à compteur par fenêtre glissante, par identifiant (id de token, sinon id utilisateur, sinon IP): auth 10/min, api 60/min, ai 20/min (surchargeable par variables d'environnement, valeur <=0 désactive).
• Un dépassement renvoie une réponse 429 avec en-têtes Retry-After et X-RateLimit-Limit/Remaining/Reset, formatée en JSON, HTML ou enveloppe API selon l'appelant.
recentFailedAttempts(ip) est exposé pour piloter l'affichage du CAPTCHA de connexion.
• Comportement fail-open sur erreur de stockage (la connexion continue via login_attempts même si la table rate_limits est absente).
CAPTCHA de connexion (Turnstile ou hCaptcha) Détecter le fournisseurAfficher le widgetVérifier le tokenÉmettre la CSP CAPTCHA optionnel respectueux de la vie privée, à auto-détection du fournisseur Cloudflare Turnstile ou hCaptcha (inerte tant que non configuré).
• Le widget n'apparaît sur le formulaire de connexion qu'une fois recentFailedAttempts >= antispam.login_captcha_after (défaut 3), c.-à-d. après que l'IP a franchi le seuil d'échecs.
• Le token résolu est vérifié côté serveur AVANT tout contrôle des identifiants, en fail-closed (une erreur de transport bloque la connexion).
• Des flags par formulaire (captcha_on_login, captcha_on_rgpd, etc.) activent le CAPTCHA indépendamment.
• Le service émet aussi les directives CSP propres au fournisseur, rend le HTML du widget et expose le nom du champ de réponse.
Piège à bots honeypot Piéger les botsRediriger en silenceDéclencher le hook auth.failed Champ honeypot caché (website) injecté sur les formulaires de connexion et de mot de passe oublié.
• Si la valeur est remplie (signature typique d'un bot), la requête est silencieusement redirigée sans traitement des identifiants, sur les deux formulaires.
• Le hook de plugin auth.failed est déclenché à chaque tentative d'identifiants échouée.
Journal d'audit visionneuse admin ConsulterFiltrer par utilisateurFiltrer par actionFiltrer par datesPaginer Piste d'audit en lecture seule (permission security.view), paginée à 50 entrées par page, les plus récentes d'abord.
• Filtres: nom d'utilisateur (LIKE), action via liste déroulante peuplée par distinctActions(), et plage de dates from/to (validées au format AAAA-MM-JJ et bornées à la journée entière).
• Les filtres se combinent et persistent à travers la pagination.
• Chaque entrée affiche l'action, le type et l'id d'entité, l'IP, le user-agent et l'horodatage.
Moteur de journalisation d'audit EnregistrerCaviarder les secretsPlafonner la chargePurger Enregistreur append-only appelé partout dans le CMS via record(action, entityType, entityId, meta) utilisé notamment par auth.login, auth.logout, auth.login_failed, user.disable_2fa, etc.
• Résout automatiquement l'utilisateur agissant et le client (IP, user-agent).
• Les valeurs meta sensibles (clés pass, pwd, secret, token, api_key, totp, otp, recovery, cookie, salt...) sont caviardées et la longueur des valeurs bornée.
• La charge meta est plafonnée à 8 Ko (stocke {_truncated:true} en cas de dépassement) et la méthode ne lève jamais d'exception.
• Fournit purge(days) pour la rétention (suppression des entrées plus vieilles que N jours) et distinctActions() pour alimenter le filtre.
Activation 2FA (TOTP) Afficher le statutGénérer secret + QRActiver par vérificationDésactiver par code Hub 2FA en self-service (aucune permission requise, sur son propre compte) depuis la page de sécurité.
• Génère un secret candidat (en session uniquement) accompagné d'un QR code SVG inline (QrCodeService) et d'une clé base32 saisissable manuellement.
• Activation via POST /profile/security/enable (CSRF + rate limit) après vérification d'un code TOTP; le secret n'est persisté (chiffré AES) qu'une fois un code vérifié, et l'activation est refusée si APP_ENCRYPTION_KEY manque (jamais de secret en clair).
• La désactivation exige un code TOTP ou de récupération valide (alimente le throttle IP).
• TOTP autonome conforme RFC 6238: secret base32, HMAC-SHA1, 6 chiffres, période 30s, tolérance ±1 pas, vérification à temps constant.
Codes de récupération 2FA Émettre le lotRégénérerConsommerAfficher le restant À l'activation de la 2FA, un lot de 10 codes (80 bits chacun) est émis et affiché une seule fois via message flash.
• Régénération possible: invalide les anciens et exige le code TOTP courant.
• Un code est consommé à la connexion ou pour désactiver la 2FA via un UPDATE à usage unique protégé contre les accès concurrents (single-use, race-safe).
• La page de sécurité affiche le nombre de codes inutilisés restants.
• Seuls les hachages SHA-256 sont stockés; l'affichage formate les codes en blocs de 4 caractères.
Étape de connexion 2FA Différer la connexionVérifier le TOTPBasculer vers un code de récupérationFinaliser Second facteur imposé après un mot de passe correct lorsque la 2FA est active.
• La connexion est mise en attente (_2fa_pending, TTL 600s) avec régénération de session (anti-fixation).
• Le prompt GET /admin/2fa n'est atteignable qu'en cours de login.
• On vérifie d'abord le code TOTP, à défaut un code de récupération à usage unique (chaque tentative est enregistrée dans le throttle IP).
• La connexion est finalisée puis auditée auth.login avec via=2fa ou via=2fa_recovery, et le nombre de codes de récupération restants est affiché quand l'un est utilisé.
• Délibérément, aucun succès n'est enregistré à l'étape mot de passe afin de maintenir le throttle IP sur la vérification du code.
Politique require_2fa imposée & reset 2FA par admin Imposer la 2FARediriger vers le setupRéinitialiser la 2FA d'un utilisateurEffacer secret + codes Le réglage require_2fa positionne _2fa_setup_required, ce qui fait canaliser l'utilisateur vers la mise en place de la 2FA par AuthMiddleware.
• Un admin peut réinitialiser la 2FA d'un autre utilisateur (appareil perdu) via POST /users/{id}/disable-2fa (permission users.edit + CSRF), action auditée user.disable_2fa.
• La désactivation efface le secret et l'intégralité des codes de récupération de l'utilisateur ciblé.
Mot de passe oublié / réinitialisation Demander la réinitialisationÉmettre le tokenRéinitialiserInvalider les sessions GET/POST /admin/forgot-password répond toujours de façon neutre (pas d'énumération de comptes) et comporte un honeypot.
• Un token de 256 bits (seul le SHA-256 est stocké) est émis et envoyé une seule fois par Mailer::sendCritical, avec anti-bombardement (pas de renvoi si un token frais a été demandé dans les 90s) et purge des tokens périmés.
GET/POST /admin/reset-password/{token} valide le token à usage unique (TTL 60 min) et exige un mot de passe de 8 caractères minimum avec confirmation.
• Consommation atomique du token, puis mise à jour du hash et purge de login_attempts et locked_until.
• Toutes les sessions existantes de l'utilisateur sont invalidées après la réinitialisation.
Vérification d'email Vérifier l'adresseRenvoyer le lienImposer la vérification GET /admin/verify-email/{token} confirme l'adresse et fonctionne déconnecté via une page de résultat autonome.
POST /admin/verify-email/resend renvoie le lien (CSRF + rate limit, anti-bombardement 90s).
GET /admin/verify-email/notice sert la page d'atterrissage lorsque la vérification est imposée.
• La politique require_email_verification canalise les utilisateurs non vérifiés via AuthMiddleware.
• Le token à usage unique (TTL 24h, SHA-256 stocké) n'est validé que si l'adresse email courante correspond toujours à celle liée au token.
Sécurité & Conformité

🛡️ Sécurité Intelligence des erreurs & RGPD Complété

17 fonctionnalités
Fonctionnalité Actions Fonctionnement exact
Console d'intelligence des erreurs (liste groupée) ConsulterFiltrerRechercherDéclencher test Tableau de bord admin (permission errors.view) listant les erreurs backend, frontend et CSP capturées puis dédupliquées en groupes par empreinte (fingerprint), 30 par page, triées par last_seen.
Filtres combinables : statut (open, investigating, resolved, ignored, regressed), sévérité (critical, error, warning) et catégorie (db, permission, validation, ai, network, plugin, template, csp, http, performance, other), plus une recherche plein-texte sur le titre et la classe d'exception.
Des onglets récapitulatifs affichent le compte par statut (all, open, investigating, resolved, ignored, regressed).
Un bouton déclenche une erreur de test capturée de bout en bout (permission errors.manage + jeton CSRF).
Détail d'un groupe d'erreurs Afficher pileVoir contexteLire breadcrumbsConsulter timeline Page d'incident par groupe montrant l'évènement le plus récent ou l'échantillon avec une pile d'appels enrichie (±6 lignes de code extraites des fichiers app/, ligne fautive « culprit » surlignée).
Le contexte de requête est assaini : route, méthode HTTP, rôle, user_id, IP anonymisée, user-agent, requête caviardée.
Affiche aussi les breadcrumbs (entrées de log récentes caviardées), une timeline des occurrences récentes (jusqu'à 25) avec request_id, un sparkline des évènements par jour sur 14 jours et la ventilation des occurrences par version applicative.
Gestion du cycle de vie (statut) Changer statutRésoudreDétecter régressionSupprimer groupe Transitions manuelles d'état sur un groupe (open, investigating, resolved, ignored) protégées par la permission errors.manage + CSRF.
La résolution horodate automatiquement resolved_by, resolved_at et resolved_version.
Si une empreinte déjà résolue réapparaît, le groupe bascule automatiquement en « regressed » avec regressed_version et regressed_at.
Un groupe et l'ensemble de ses évènements peuvent être supprimés (errors.manage + CSRF + confirmation).
Tableau de bord des tendances (spikes) Choisir périodeLire KPIRepérer picsCorréler versions Analytique inter-groupes sur une fenêtre sélectionnable de 7, 14, 30 ou 90 jours.
Tuiles KPI : groupes ouverts, total d'occurrences (résistant à l'échantillonnage), évènements sur la période, nombre de régressions.
Barres journalières avec détection de pics (spike si &gt;= plancher de 5 ET &gt;= 3x la moyenne), tableau des erreurs les plus fréquentes (par volume sur la fenêtre), indicateur de corrélation par version (évènements par app_version) et comptes par sévérité.
Un indicateur « sampled » signale quand l'échantillonnage anti-flood est actif.
Diagnostic IA d'erreur DiagnostiquerRe-diagnostiquerAuto (cron)Gérer budget Demande à Claude de diagnostiquer un groupe une seule fois (résultat mis en cache), construit uniquement à partir des données stockées déjà caviardées.
Mode manuel inline (errors.manage + CSRF, l'admin attend le résultat) avec bouton de re-diagnostic forçant l'écrasement ; mode auto mettant en file une tâche planifiée « error_diagnose » asynchrone sur chaque nouveau groupe (dédupliquée, reprogrammée selon le budget).
Produit un diagnostic structuré : root_cause, explanation, culprit_file/line, category, severity, confidence, suggested_fix, fix_prompt, patch, prevention.
Un garde-fou de budget (manuel par utilisateur ou global cron) gère le dépassement (« budget exceeded ») et échoue de façon ouverte (fail-open) si l'IA est indisponible ; le diagnostic, le modèle et la confiance sont stockés sur le groupe avec barre de confiance et horodatage/modèle affichés.
Auto-fix assisté (staging uniquement) Préparer correctifSauvegarder BDDAuditer par hashRéviser patch Application guidée et fail-closed d'un patch de diagnostic (permission errors.autofix + CSRF), re-vérifiant l'environnement et l'opt-in côté serveur.
Garde-barrière stricte de staging (liste blanche STAGING_ENVS ; APP_ENV non défini ou inconnu traité comme production, donc bloqué) et opt-in obligatoire ERROR_AUTOFIX_ENABLED.
Prend une sauvegarde de sécurité de la base (BackupService) avant application, puis journalise l'action error.autofix avec l'environnement, le nom du fichier de sauvegarde et le hash du patch (jamais le contenu du patch).
Le patch proposé, non vérifié, est présenté pour revue manuelle ; le bouton est masqué hors staging et le service n'écrit jamais dans les fichiers app/.
Export fix-package (dossier Claude-Code) Télécharger .mdCopier promptCopier package complet Dossier markdown autonome, téléchargeable (permission errors.view) ou copiable dans le presse-papiers via un mécanisme CSP-propre (data-copy).
Il regroupe le résumé de l'erreur, le diagnostic IA, la pile enrichie, le contexte de reproduction, les breadcrumbs, un rappel des conventions et un prompt de correction prêt à coller.
Deux copies possibles : le prompt de correction concis, ou le package complet.
Fournit un prompt de repli lorsqu'aucun fix_prompt IA n'est disponible.
Export rapport d'incident Télécharger .mdCopier rapport Rapport markdown partageable et sûr en matière de caviardage, audité comme error.export lors du téléchargement (permission errors.view).
Contient le résumé et les statistiques, le calcul de la durée d'incident active (active-span), l'historique résolu/régressé/dernière-alerte, un tableau de corrélation par version, la timeline des occurrences récentes et un résumé du diagnostic.
Volontairement exempt de payload, de pile d'appels et de breadcrumbs.
Également copiable dans le presse-papiers.
Alerting multi-canal (notif/email/webhook) Notifier adminEmailerWebhook signéLimiter (throttle) Déclenche des alertes sur les évènements new, regression, spike et critical (configurable via error_intel.alert_on), avec un plancher de sévérité qui supprime les « new »/« spike » de niveau warning (ex. bruit CSP).
Trois canaux : une Notification admin persistante diffusée, un email asynchrone vers error_intel.alert_email (Mailer::queue) et un webhook sortant signé error.{trigger} via WebhookService.
Chaque groupe est limité (throttle) par comparaison de last_alert_at avec alert_throttle (défaut 900s) et par une vérification du taux de pics (spike_per_min).
L'ensemble est conditionné par la configuration.
Capture d'erreurs frontend (beacon JS navigateur) Recevoir POSTCaviarderInjecter trackerPlafonner Endpoint public POST /api/client-error, sans session ni CSRF, limité au bucket « api », plafonné à 16 Ko et répondant toujours 204.
Conditionné par ERROR_CAPTURE_FRONTEND (no-op silencieux si désactivé).
Une config avec nonce et le script error-tracker.js sont injectés dans public.footer avec un taux d'échantillonnage.
La charge utile non fiable (message, source, stack) est caviardée puis normalisée en évènement de type « frontend », avec un plafond global anti-flood par heure.
Beacon de rapport de violation CSP Recevoir POSTParser 3 formatsNormaliserPlafonner Endpoint public POST /api/csp-report, sans session, limité au bucket « api », plafonné à 16 Ko, répondant toujours 204 et conditionné par ERROR_CAPTURE_CSP (no-op silencieux si désactivé).
Il analyse trois formats : legacy « csp-report », Reporting API (tableau body) et objet nu.
Chaque violation est normalisée en évènement CspViolation de sévérité warning, groupé par directive + hôte bloqué.
Un plafond anti-flood par heure borne l'ingestion.
Rétention / échantillonnage anti-flood Purger (cron)Rogner évènementsÉchantillonnerPlafonner sources Tâche quotidienne cleanup_error_events qui supprime les groupes ignored/resolved (et leurs évènements) au-delà de la rétention, et rogne les évènements plus anciens que error_intel.retention_days (défaut 30 ; &lt;=0 désactive) en préservant les lignes sample_event_id.
À la capture, un échantillonnage anti-flood (error_intel.sample_threshold) conserve 1 évènement sur 10 dès qu'un groupe dépasse le seuil d'évènements par minute.
Un plafond par source non fiable (untrusted_max_per_hour, défaut 500) borne les beacons frontend et CSP.
Demande RGPD publique self-service (double opt-in) Afficher formulaireCréer demandeAnti-spamConfirmer email Formulaire visiteur GET /privacy/data-request (avec en-têtes de sécurité) et soumission POST /privacy/data-request (CSRF + rate limit) créant une demande d'export ou d'effacement.
Protections anti-spam : honeypot, time-trap (âge min/max du formulaire) et CAPTCHA RGPD optionnel, toutes repliées vers le même message neutre.
Garde anti-doublon de 24h par email.
Un lien de confirmation à usage unique est envoyé par email (TTL 48h, hash SHA-256 stocké) et la demande reste inactionnable tant qu'elle n'est pas confirmée ; GET /privacy/data-request/confirm/{token} confirme la propriété de l'adresse avec des réponses neutres.
Gestion admin des demandes RGPD ListerExporter JSON ou HTMLSupprimer/anonymiserVérifier Console admin (permission settings.view) listant les demandes paginées avec badges type + statut + en-attente/confirmé.
Export JSON téléchargeable (POST /rgpd/{id}/export?format=json, audité rgpd.export) ou rapport HTML lisible (format=html, ouvert dans un nouvel onglet).
Suppression/anonymisation (POST /rgpd/{id}/delete) avec gardes serveur : type=delete, demande non déjà complétée, email confirmé (audité rgpd.delete).
Vérification (POST /rgpd/{id}/verify) pour un contrôle d'identité manuel hors bande (audité rgpd.verify) ; toute action divulgatrice ou destructrice est bloquée tant que l'email n'est pas confirmé (fail-closed), et une note signale un compte privilégié laissé intact.
Moteur d'export/effacement RGPD (~25 tables) Recenser sourcesExporterProtéger secretsEffacer en transaction Registre déclaratif des sources de données personnelles (compte, commentaires, soumissions contact/formulaire, newsletter, login_attempts, sessions, password_resets, recovery_codes, email_verifications, api_tokens, emails en file ou supprimés, audit_logs, article_reviews, notifications, rgpd_requests, articles/pages/cocoon/media/révisions rédigés, ai_requests/images/content) couvrant environ 25 tables.
Export structuré (sujet + lignes par source + generated_at) en JSON indenté ou rapport HTML autonome échappé ; safeColumns() écarte toute colonne au nom sensible (password_hash, totp_secret, token, payload, stripe_*...) même déclarée ou ajoutée par plugin, et le journalise.
L'effacement s'exécute en UNE transaction (rollback sur erreur) avec stratégie par source : suppression, anonymisation en place, ou conservation pour intégrité ; la ligne de compte est anonymisée en place (hash de mot de passe inutilisable, PII nettoyée, 2FA effacée) en gardant l'id pour l'intégrité référentielle.
Un garde-fou protège les comptes privilégiés (permission wildcard) appariés par email seul, saute les sources par user-id et les signale pour revue manuelle ; les fichiers uploadés de formulaires sont physiquement supprimés (unlink) avant la ligne.
L'analytique pseudonymisée (hash IP à sel rotatif, sans email ni user id) est rapportée hors périmètre, et le moteur est extensible via le hook rgpd.data_sources (ex. shop_orders).
Consentement, bannière cookies & gating analytics Gérer modesLire/écrire cookieConditionner trackingPseudonymiser IP Modes de consentement (le mode « none » désactive la bannière) avec lecture/écriture d'un cookie rgpd_consent à catégories necessary, analytics et preferences.
shouldShowBanner() décide de l'affichage et la bannière côté client (accepter/refuser) écrit le cookie en SameSite=Strict.
canTrackAnalytics() et hasConsent(type) conditionnent la capture analytics et heatmap (via RgpdMiddleware et HeatmapApiController).
La pseudonymisation hashIp() utilise un pepper secret persistant et non public (setting chiffré, sinon setting en clair, sinon repli par processus).
Réglages RGPD ConsulterEnregistrer Onglet d'administration GET /admin/settings/rgpd (permission settings.view) et enregistrement POST /admin/settings/rgpd (permission settings.edit + CSRF).
Configure rgpd_mode (mode de consentement), rgpd_banner_text (texte de la bannière), rgpd_cookie_ttl (durée de vie du cookie de consentement) et rgpd_cookie_name (nom du cookie).
Analytics & Productivité

📊 Analytics, Statistiques, Heatmap & Tableau de bord Nouveau

22 fonctionnalités
Fonctionnalité Actions Fonctionnement exact
Page Analytics Vue d'ensemble & filtre de période ConsulterFiltrer Route GET /admin/analytics (permission analytics.view), rendu serveur par AnalyticsController::index. Le paramètre period est validé contre une liste blanche 7/14/30/90 (toute autre valeur retombe à 30 jours), puis la fenêtre est calculée de -{jours} days 00:00:00 à aujourd'hui 23:59:59. Quatre cartes KPI affichent Pageviews (COUNT sur analytics_pageviews), Sessions (COUNT sur analytics_sessions), Pages/session moyennes (AVG pageview_count arrondi à 0,1) et Durée moyenne en secondes (AVG duration_sec arrondi). Les pills de période rechargent la vue ou rafraîchissent les graphiques via l'API chart-data.
Graphique « Traffic Overview » Visualiser Courbe Chart.js traçant les pageviews et les sessions par jour. Les séries proviennent de pageviewsPerDay() et sessionsPerDay() (GROUP BY DATE). Une boucle serveur comble les jours manquants de -jours à 0: chaque date absente vaut 0 et les libellés sont formatés « M j » (ex. « Mar 5 »). Garantit une courbe continue sans trous même un jour sans trafic.
Répartition par appareil (doughnut) Visualiser Doughnut Chart.js de la répartition des sessions par device_type (desktop, mobile, tablet, bot) via deviceBreakdown() (GROUP BY device_type sur analytics_sessions, device_type NON NULL). Légende et pourcentages calculés côté client. Les bots étant exclus dès la capture, la part « bot » reste normalement nulle.
Top navigateurs (barres horizontales) Visualiser Diagramme à barres horizontales des 5 navigateurs les plus fréquents via browserBreakdown() (GROUP BY browser sur analytics_sessions, tri décroissant, LIMIT 5). Les valeurs Edge, Opera, Chrome, Firefox, Safari, IE ou Other proviennent de la détection User-Agent effectuée au moment de la création de la session.
Top pages (tableau) Consulter Tableau des 10 URLs les plus vues sur la période via topPages() (GROUP BY url, ORDER BY views DESC, LIMIT 10 sur analytics_pageviews). Colonnes URL et nombre de vues.
Top référents (tableau) Consulter Tableau des 10 référents les plus fréquents via topReferrers(), en excluant explicitement les référents vides ou NULL (WHERE referrer != '' AND referrer IS NOT NULL), GROUP BY referrer, ORDER BY count DESC, LIMIT 10.
API JSON chart-data (rafraîchissement AJAX) InterrogerRafraîchir GET /admin/analytics/chart-data (analytics.view). Renvoie en JSON labels, pageviews, sessions (mêmes séries comblées que la page), plus overview et devices pour la période validée (7/14/30/90, défaut 30). Sert à rafraîchir les graphiques en AJAX sans recharger la page.
API JSON temps réel (realtime) InterrogerSuivre en direct GET /admin/analytics/realtime. Fenêtre glissante des 30 dernières minutes (time() − 1800). Renvoie active_visitors (= sessions démarrées), pageviews et les 5 pages les plus vues. Alimente un widget « temps réel ».
API JSON pageviews Interroger GET /admin/api/analytics/pageviews. Le paramètre days est borné entre 1 et 90 (min/max, défaut 30). Renvoie overview (KPI), per_day (map date vers compteur) et top_pages (10). Endpoint JSON générique pour widgets ou intégrations.
API JSON heatmap par article Interroger GET /admin/api/analytics/heatmap/{articleId} (analytics.view). Un articleId ≤ 0 renvoie 400. Sinon renvoie les stats agrégées et les zones d'attention. Le tout est enveloppé dans un try/catch: en cas d'erreur SQL (table heatmap_data absente en production, etc.) il renvoie un JSON d'erreur 500 (« Heatmap data unavailable ») au lieu d'un HTML 500, préservant le contrat JSON pour le client. L'erreur est journalisée via error_log.
Suivi serveur des pageviews & sessions (RGPD, sans cookie) SuivreEnregistrer AnalyticsMiddleware s'exécute sur le groupe public. Il ne trace que les requêtes GET aboutissant à une réponse 200, hors préfixes /admin, /api, /storage et /sitemap. Il exige le consentement RGPD (RgpdService::canTrackAnalytics) sinon ne trace rien. AnalyticsService::trackPageview construit un identifiant de session SANS cookie: sha256 de IP + User-Agent + heure (format Y-m-d-H), donc une session par visiteur et par heure. La session est créée (INSERT) ou mise à jour (UPDATE pageview_count +1, last_active). Le pageview est inséré avec url/referrer/user_agent tronqués (500/512), ip_hash, device_type, duration_sec = 0 et viewed_at. L'IP est hachée via RgpdService::hashIp; le fuseau horaire vient des réglages. Échec silencieux (try/catch) pour ne jamais casser la page.
Détection appareil / navigateur / OS & exclusion des bots DétecterExclure detectDevice() classe l'User-Agent (en minuscules):
• bot si présence de bot, crawler, spider, slurp, mediapartners, lighthouse, pagespeed ou headlesschrome la capture s'arrête alors immédiatement, aucun enregistrement
• tablet si ipad, ou android sans « mobile »
• mobile si mobile, iphone, ipod, android, blackberry, opera mini ou windows phone
• sinon desktop. detectBrowser() reconnaît Edge, Opera, Chrome, Firefox, Safari, IE, sinon Other; detectOS() reconnaît Windows, macOS, Linux, Android, iOS, sinon Other. Heuristiques par sous-chaînes, l'ordre des tests évitant les faux positifs (tablette avant mobile, Chrome avant Safari).
Événements personnalisés (trackEvent) Enregistrer AnalyticsService::trackEvent insère dans analytics_events: session_id (même hash horaire cookieless), event_name, event_category, event_label, event_value (numérique nullable), url tronquée à 500, metadata en JSON (json_encode, nullable) et created_at. API purement programmatique (pas d'interface dédiée) pour tracer des interactions personnalisées rattachées à une session sans cookie.
Collecte heatmap endpoint public & gate de consentement CollecterValider POST /api/heatmap (rate-limité, profil « api »), géré par HeatmapApiController::store. Refuse avec 403 {consent_required} si le consentement analytics manque (RgpdService::canTrackAnalytics). Le corps JSON est décodé; sans article_id il renvoie 400 {invalid}. Sinon HeatmapService::track persiste la charge (enveloppé dans un try/catch, échec silencieux) et renvoie 200 {ok}. Endpoint public conçu pour recevoir les beacons du tracker frontend.
Tracker heatmap frontend (scroll, zones H2, clics, temps) MesurerEnvoyer heatmap-tracker.js ne s'active que si un élément &lt;article data-article-id&gt; existe. Le session_id est un UUID stocké en sessionStorage (crypto.randomUUID). Il mesure: la profondeur de scroll maximale (0-100, arrondie) via un écouteur scroll passif; le temps par zone H2 via IntersectionObserver (seuil 0,5) qui cumule les secondes d'intersection par id de H2 (ou les 40 premiers caractères du titre); les clics (x/y en pageX/pageY, balise cible, temps relatif) plafonnés à 100. À l'envoi il ferme les timers de zone ouverts, compose l'objet {article_id, session_id, scroll_depth, time_per_zone, click_positions, total_time} et l'émet via navigator.sendBeacon vers (base)+/api/heatmap, avec repli fetch keepalive. Déclenché sur beforeunload ET toutes les 30 s. Le script n'est injecté par le layout public qu'après consentement analytics (lecture du cookie rgpd_consent).
Agrégation heatmap par article (stats + zones) AgrégerCalculer HeatmapService::getArticleStats délègue à HeatmapData::articleStats: une requête agrège total_sessions (COUNT), avg_scroll_depth (AVG max_scroll_depth), avg_reading_time (AVG total_time), complete_reads (SUM read_complete) et completion_rate = SUM(read_complete) / GREATEST(COUNT, 1) × 100 arrondi à 0,1. read_complete vaut 1 dès que max_scroll_depth ≥ 90 (fixé à l'enregistrement, la profondeur étant bornée à 100). getZoneHeatmap lit tous les time_per_zone de l'article, décode chaque JSON, somme les secondes par zone puis divise par le nombre de sessions: on obtient l'attention moyenne (en secondes, arrondie à 0,1) par zone H2. Résultats surfacés via l'API heatmap admin.
Tableau de bord d'accueil admin (blocs KPI, activité, sous-systèmes) ConsulterNaviguer GET /admin (DashboardController::index), rendu serveur, composé de jusqu'à 12 blocs:
• stats (Articles avec split publiés/brouillons, Pages, Media, Commentaires en attente)
• analytics 30 j (Pageviews, Sessions, Pages/session)
• sparkline 7 j
• actions rapides (Nouvel article/page, Génération IA, Analytics complet, SEO, Backups)
• articles récents (5 derniers avec badges de statut)
• commentaires en attente
• aperçu SEO (score moyen, redirections, meta manquantes chargé en différé)
• usage IA (coût jour/mois vs limites, requêtes)
• notifications (5 dernières + badge non-lus)
• backups (dernier backup, taille, total)
• cache (pages cachées, taille, stratégie de purge)
• système (version PHP, utilisateur, version app, session). Les blocs backups et cache sont masqués selon les permissions (backups.view, settings.view) pour ne pas divulguer ce que leurs pages dédiées refusent. Chaque bloc optionnel est récupéré en fail-open (try/catch vers valeur neutre) et seulement s'il est effectivement affiché.
Sparkline pageviews 7 jours Visualiser Mini-graphique Chart.js des pageviews des 7 derniers jours. Les séries sont construites côté serveur depuis pageviewsPerDay(-7 j), les jours manquants valant 0, avec des libellés en jour de la semaine (« D »). Theme-aware: il se redessine lors du basculement clair/sombre pour rester lisible.
Personnalisation des blocs par utilisateur PersonnaliserEnregistrer Panneau « Customize » avec une case à cocher par bloc parmi les 12 canoniques (DashboardController::BLOCKS). Sauvegarde via PUT /admin/api/dashboard/layout (protégé CSRF): le serveur ne garde que les chaînes, les intersecte avec la liste blanche BLOCKS et déduplique (array_unique), puis stocke la liste JSON dans users.dashboard_layout (colonne TEXT). Sémantique: NULL = jamais personnalisé (jeu par défaut = blocs autorisés), tableau vide [] = tout masqué (valeur légitime conservée telle quelle). Toast de succès puis rechargement; toast ou modale d'erreur en cas d'échec. Persistance alternative via PUT /admin/api/preferences (liste blanche stricte réduite à dashboard_layout, sinon 400). Au rendu, la disposition sauvegardée est ré-intersectée avec les blocs autorisés par permission.
API JSON stats du dashboard (fail-open) Interroger GET /admin/api/dashboard/stats. Renvoie articles, published, pages, media, comments_pending, overview analytics 30 j, avgSeoScore, redirectCount et missingMeta. Chaque métrique est isolée dans son propre try/catch: une table manquante (seo_meta, seo_redirects, analytics…) dégrade la valeur à 0 au lieu de provoquer un 500. missingMeta = nombre de publiés − entités disposant d'une meta_description non vide (borné à 0). Un try/catch externe renvoie, en dernier recours, un objet entièrement à zéro accompagné d'un champ error.
Monitoring d'erreurs externe (Sentry / webhook) ConfigurerTransférer Service optionnel (Point #48), activé uniquement si monitoring.dsn (variable MONITORING_DSN) est défini; register() l'enregistre comme reporter du Logger, si bien que chaque log error ou critical est transféré. Deux drivers auto-détectés par parseDsn: DSN Sentry (PUBLIC_KEY@host/PROJECT_ID) vers /api/{project}/store/ avec en-tête X-Sentry-Auth, mapping de niveau (critical vers fatal, etc.), release/environment/server_name/transaction/tags/extra et données d'exception; sinon webhook https générique en JSON structuré (service, level, message, channel, request_id, env, release, url, method, exception, context) pour Slack, Discord ou un collecteur maison. Sécurité: HTTPS uniquement, plafond de 10 envois par requête, garde de réentrance, timeouts courts (4 s / 3 s), client SafeHttpClient anti-SSRF, ne lève jamais d'exception et retire systématiquement la query string des URLs. captureException/captureMessage sont exposés pour un signalement direct.
Endpoints de health-check / uptime SonderSurveiller GET /health (statut global: base + stockage + version + horodatage, 200 ok / 503 degraded) et GET /health/db (connectivité base seule, 200 ok / 503 down). Non authentifiés et volontairement minimalistes, dispatchés AVANT Database::init() donc /health répond même pendant une panne DB (sa raison d'être). La sonde base est rapide et bornée: elle réutilise une connexion déjà ouverte, sinon effectue un pré-check TCP fsockopen (plafond ~1 s, fiable même sous Windows) puis une connexion PDO à timeout court (2 s); elle ne divulgue jamais l'hôte, les identifiants ni la raison de l'échec. checkStorage vérifie que logs, cache et storage sont des dossiers accessibles en écriture (et les crée s'ils sont absents). Réponses avec en-têtes Cache-Control: no-store et X-Request-Id.
Analytics & Productivité

⌨️ Command Menu (Ctrl+K), Prévisualisation & PWA Nouveau

20 fonctionnalités
Fonctionnalité Actions Fonctionnement exact
Lanceur du command menu (ouverture/fermeture, focus) Ouvrir (Ctrl+K/Cmd+K)Ouvrir (double-Shift)Ouvrir ([data-cm-open])Fermer (backdrop/Escape)Piéger et restaurer le focus Lanceur admin global de type Spotlight ouvert au clavier (Ctrl+K ou Cmd+K, actif même à l'intérieur des champs de saisie), par deux appuis Shift nus en moins de 400 ms, ou par tout déclencheur [data-cm-open].
La fermeture se fait par clic sur le fond (backdrop), via [data-cm-close] ou par Escape.
Escape suit un comportement en deux temps: le 1er appui vide la requête, le 2e ferme le panneau (comportement spotlight).
Le focus est piégé dans le panneau puis restauré sur l'élément déclencheur à la fermeture; le raccourci agit en bascule ouvrir/fermer.
Grille de 9 sections + bande supérieure + raccourcis chiffres Afficher 9 sections métierAfficher bande Dashboard/AnalyticsSauter via chiffres 1-9Afficher puces de séquence et infobulles À l'état de repos, l'overlay affiche une carte de 9 sections métier teintées (Contenu, Audience, SEO, IA, Apparence, Administration, Intégrations, Maintenance, Sécurité & Journaux) plus une bande supérieure de destinations (Dashboard, Analytics).
Les touches 1 à 9 ouvrent la première entrée de la section n (uniquement à l'état de repos).
Chaque entrée affiche sa puce de go-séquence et une infobulle de description.
Les paires de teintes clair/sombre par section sont validées en contraste AA; l'ensemble est filtré par permissions côté serveur.
Recherche floue locale pondérée (niveau 1) Scorer (exact>préfixe>mot>sous-chaîne>alias>initiales>section)Surligner via markGrouper (Pages, Actions)Naviguer au clavier Matcher client insensible aux accents et à la casse opérant sur l'index inliné filtré par permissions (libellés, alias, initiales, section).
Scoring pondéré: correspondance exacte > préfixe > mot > sous-chaîne > alias > initiales > section; en multi-mots chaque token doit correspondre et les scores sont moyennés.
Les alias i18n par entrée (cmdmenu.alias.&lt;id&gt;) sont résolus pour la locale courante et les correspondances surlignées via &lt;mark&gt; en tenant compte des accents.
Résultats groupés (Pages, Actions) avec état sans-résultat (message, astuce, suggestions populaires: articles/settings/seo).
Navigation clavier: flèches haut/bas, Entrée (ouvrir), Ctrl/Cmd+Entrée (nouvel onglet), Tab (groupe suivant/précédent).
Séquences clavier 'g…' / 'n…' + hotkeys sidebar/aide Naviguer (g+lettre)Créer (n+lettre)Basculer sidebar ([)Ouvrir l'aide (?) Séquences globales à deux touches: 'g' + lettre navigue vers une section (ex. g a → Articles, g s → SEO), 'n' + lettre déclenche une action de création (ex. n a → nouvel article).
Touches simples: '[' bascule la sidebar, '?' ouvre la modale d'aide des raccourcis.
Une puce d'indice de séquence en attente s'affiche et s'auto-annule après 1,5 s.
Les séquences sont limitées à l'index filtré par permissions et neutralisées dans les inputs/textareas/contenteditable ainsi que lorsqu'une autre modale est ouverte.
Modale d'aide des raccourcis (?) Ouvrir/fermer (?, [data-cm-help], contrôle)Lister les raccourcisPiéger le focus Dialogue d'aide rendu côté serveur à partir du même index filtré par permissions que le lanceur, garantissant que ses puces ne peuvent jamais diverger de celles du lanceur.
Il liste les raccourcis du lanceur, la catégorie Autres (?, [), et les puces Création (n…) et Navigation (g…).
Il possède son propre piège de focus.
Les lignes dépourvues de séquence sont omises.
Actions rapides de création ('n…') Créer article/page/média/utilisateur/formulaire/widgetCréer cocon pilierCréer cluster topical Raccourcis de création accessibles uniquement via la recherche, regroupés sous Actions, jamais affichés dans la sidebar ni dans la grille.
Entrées: nouvel article, nouvelle page, upload média, nouvel utilisateur, nouveau formulaire, nouveau widget, nouveau cocon pilier, nouveau cluster topical (assistants de cluster SEO).
Chaque action est protégée par sa permission de création (articles.create, pages.create, media.upload, users.create, forms.create, widgets.edit, cocon.view).
Recherche de contenu réel (groupe Contenus) Rechercher titre (articles/pages/médias)Filtrer par permission et scoper BOLADébouncer/annuler côté clientFail-open par type GET /admin/api/command-search renvoie jusqu'à 8 articles/pages/médias correspondants (brouillons inclus) via une recherche LIKE sur le titre, chaque type étant indépendamment protégé par permission (articles.view, pages.view, media.view).
Scoping objet BOLA: les utilisateurs non privilégiés ne voient que leurs propres articles sauf permission articles.edit_others.
Les médias correspondent sur original_name ou alt_text et renvoient le mime en méta; un libellé de statut (draft/published/scheduled/archived) est fourni.
Fail-open par type avec journalisation d'erreur, requête minimale de 2 caractères, maximum 8 lignes.
Côté client: debounce de 200 ms, annulation des requêtes obsolètes, ajout incrémental sous les résultats locaux.
Repli IA sémantique (niveau 2, Haiku) Appeler AiService::commandIntent (Claude Haiku)Retourner ≤3 suggestionsLimiter (rate-limit 'ai' + CSRF)Fail-open absolu POST /admin/api/command-intent envoie la requête plus l'index d'entrées filtré par permissions à Claude Haiku (AiService::commandIntent, budget #39, journalisé dans ai_requests) et renvoie au plus 3 suggestions ordonnées par confiance avec une raison de 1 à 3 mots.
Fail-open absolu: pas de clé API, budget épuisé, timeout ou toute erreur → groupe vide en HTTP 200.
Protégé par rate-limit (bucket 'ai') et CSRF; longueur de requête 3-190.
Le client ne déclenche que si le meilleur score lexical est ≤ 45, 600 ms après l'arrêt de la frappe, avec un seul appel facturable en cours.
Rendu d'un groupe 'AI suggestions ✨ · &lt;raison&gt;', dédupliqué face aux lignes déjà à l'écran.
Boucle d'apprentissage d'intention auto-apprenante (niveau 3) Apprendre au clic (beacon)Mémoriser requête→entrée par localeBooster (+150) sans IAPurger (180 j) POST /admin/api/command-learn mémorise l'association requête normalisée → entry_id par locale (globale) dans la table command_menu_learned, via un beacon déclenché au clic sur une suggestion IA ou sur un résultat lexical mal classé (rang ≥ 3).
L'intention apprise est classée en premier grâce à un boost de +150, sans nouvel appel IA, de sorte qu'une intention comprise répond instantanément au niveau 1 la fois suivante.
Les ids hors de l'index filtré par permissions de l'utilisateur sont refusés.
Table auto-créée (DDL idempotent) avec purge opportuniste de rétention à 180 jours; carte apprise servie inline, filtrée par permissions, top 200 par locale.
Fail-open: les erreurs de stockage renvoient learned:false / carte vide.
Destinations récentes (frecency) Enregistrer chaque clic (compteur + horodatage)Scorer (demi-vie 14 j, bonus +10)Afficher top-5 en pillsPlafonner/purger le magasin Magasin localStorage par utilisateur (clé cl-cmdmenu-recents-&lt;user&gt;) enregistrant chaque clic de destination du lanceur (compteur + horodatage de dernière utilisation).
Scoring de frecency à demi-vie de 14 jours, avec un bonus Recent de +10 dans le score de recherche.
Quand la requête est vide, les 5 destinations les plus récentes s'affichent sous forme de pills.
Le magasin est plafonné à 40 entrées et purge les entrées qui ne figurent plus dans l'index (permission révoquée ou plugin désactivé).
Extensibilité plugin du command menu (command.menu.items) Fusionner via le filtre command.menu.itemsGarder la forme (fail-open)Assainir couleurs/URLNormaliser les items Les plugins actifs peuvent enregistrer des sections/entrées/actions via le filtre command.menu.items, qui alimentent d'un coup la sidebar, le lanceur, la recherche, l'aide et les séquences.
Fail-open guardé par forme: les contributions malformées sont ignorées et le cœur est préservé.
Sécurité: liste blanche de couleurs hex pour les teintes de section (pas d'injection CSS), et les URL de plugin doivent être des chemins admin de même origine (rejet de javascript:, data:, protocole-relatif et externes).
Les items de plugin sont normalisés avec des valeurs par défaut sûres (icône, type, description).
Modèle de navigation sidebar (source de vérité partagée) Rendre la sidebar filtrée (navItems)Exposer via le filtre legacy admin.menuMémoïser par requêteFail-open CommandMenuService est l'unique source alimentant à la fois la sidebar admin (forme plate legacy via le filtre admin.menu) et le lanceur, les gardant synchronisés.
navItems() rend la sidebar filtrée par permissions avec en-têtes de section; les sections entièrement non autorisées disparaissent (en-tête compris).
Le résultat est mémoïsé par requête.
Fail-open vers un menu vide en cas d'erreur de stockage.
Hooks d'intégration du canal vocal (additifs) Exposer window.__commandMenuRéutiliser le chemin EntréeSupprimer l'IA niveau 2 en vocalFiltrer les entrées requiresVoice Le lanceur expose une petite surface JS publique (window.__commandMenu: setQuery, resultCount, resultLabel, activate, topScore, setIntentSuppressed) pour que l'assistant vocal pousse des transcriptions et active des résultats via le même moteur de matching/apprentissage.
L'activation vocale réutilise le chemin clavier Entrée (frecency et apprentissage se déclenchent).
Le repli IA de niveau 2 est supprimé pendant le dialogue vocal (un seul chemin facturable).
Les entrées requiresVoice (Voice Assistant, Voice Diagnostics) sont filtrées sur les navigateurs non supportés ou en kill switch, et un bouton micro push-to-talk caché n'est révélé que si la voix est supportée et activée.
Génération / révocation de lien de prévisualisation partageable Créer (POST preview-token)Révoquer (DELETE)RégénérerPurger les jetons obsolètes Depuis l'éditeur d'article/page, génère ou révoque un lien de prévisualisation privé et expirant via POST/DELETE /admin/articles/{id}/preview-token et /admin/pages/{id}/preview-token.
Jeton aléatoire de 256 bits, hash SHA-256 stocké, TTL de 7 jours, révocable; créer un nouveau lien révoque les précédents (un seul lien actif par entité).
L'URL brute n'est affichée (flashée) qu'une seule fois pour copie, seul le hash est persisté.
Garde d'appartenance au niveau objet pour les articles (auteurs limités aux leurs; articles.edit_others ou '*' pour tous), purge opportuniste des jetons obsolètes/révoqués à la création, protégé par CSRF + articles.edit / pages.edit.
Panneau de partage de prévisualisation dans l'éditeur Afficher l'URL unique + copierAfficher statut/expirationGénérer/Régénérer/RévoquerAfficher l'état vide Carte d'éditeur partagée affichant le statut du lien actif et, à la création, l'URL brute unique avec un bouton de copie dans le presse-papiers.
Lorsqu'un lien existe, elle montre son statut actif et sa date d'expiration.
Formulaires Générer / Régénérer / Révoquer, la révocation étant protégée par une modale de confirmation.
Un état vide invite à créer un lien de relecture privé et expirant.
Prévisualisation publique tokenisée du brouillon Résoudre le jeton → entité (sans le consommer)Rendre le brouillon (article/page)Ajouter une bannière brouillonForcer noindex/no-store GET /preview/{token} rend en lecture seule un brouillon d'article ou de page non publié dans le thème actif, autorisé uniquement par le jeton expirant et révocable.
Le jeton brut est résolu vers l'entité (validation d'une longueur 64-hex, contrôle révoqué/expiré) sans le consommer.
L'article est rendu avec les filtres content.render (shortcodes / table des matières), auteur, catégories, tags et image à la une; la page en équivalent.
Une bannière rouge fixe 'brouillon non publié' (CSP-safe, sans script) est ajoutée, avec noindex,nofollow forcé (meta robots + X-Robots-Tag) et en-têtes no-store/no-cache.
Seule la cible est exposée (pas de commentaires, pas d'incrément de vues, pas de données structurées, pas de liste de brouillons); un jeton invalide/expiré mène à une page 404 durcie.
Prévisualisation live du brouillon non sauvegardé (éditeur) Assainir titre/contenu/slugRendre via le thèmeRefléter catégories/tags/imageForcer noindex POST /admin/articles/preview et /admin/pages/preview rendent le contenu de formulaire en cours (non sauvegardé) dans le thème pour un aperçu rapide.
Le titre/contenu/slug soumis sont assainis puis rendus via le template de thème blog/page.
Les catégories, tags et image à la une sélectionnés dans le formulaire sont reflétés.
Override SEO noindex,nofollow, entité factice éphémère (non persistée), protégé par CSRF + articles.create / pages.create.
Manifest PWA dynamique Émettre name/short_name/start_url/scopeDéclarer icônes 192/512Fail-open vers défauts statiquesCacher 1 h GET /manifest.json est servi via PHP pour porter le nom/langue/base path en direct depuis les réglages et rester immunisé contre les 403 de permissions de fichiers statiques.
Émet name, short_name (≤12 caractères), description, start_url et scope depuis les réglages live + base path.
Icônes 192 et 512 en purposes 'any' et 'maskable', display standalone, theme_color #2563eb, background_color #0d0d18, categories, lang.
Fail-open vers des défauts statiques si la DB/les réglages sont indisponibles, mis en cache 1 h; un public/manifest.json statique subsiste comme repli.
Service worker offline (cache-first / network-first) Cache-first pour les actifsNetwork-first pour le HTML publicNe jamais cacher admin/api/SSEPurger via CACHE_VERSION public/service-worker.js fournit le cache offline PWA, enregistré sur les layouts admin et public, compatible sous-répertoire.
Cache-first pour les actifs statiques sous /assets/, le manifest et le favicon; network-first pour les navigations HTML publiques, avec repli sur le cache puis offline.html / repli inline.
Ne cache/sert jamais l'admin, /api/ ou les flux SSE (toujours en direct); contourne le non-GET, le cross-origin et les requêtes range.
Purge de cache basée sur CACHE_VERSION à l'activation, avec skipWaiting + clients.claim; le base path est dérivé de l'emplacement du worker (fonctionne à / ou /blog/).
Meta d'installation PWA + enregistrement du service worker Lier le manifest (link rel=manifest)Déclarer les meta Apple web-appEnregistrer le SW au chargement Les deux layouts (admin et public) lient le manifest via &lt;link rel=manifest&gt; et déclarent les meta Apple web-app (apple-mobile-web-app-capable, status-bar-style, title).
Le service worker est enregistré via navigator.serviceWorker.register('&lt;base&gt;/service-worker.js') au chargement de la fenêtre (window load).
L'appel est nonce'd (compatible CSP) et échoue en silence (fail-silent).
E-commerce

🛒 Plugin Boutique (E-commerce)

23 fonctionnalités
Fonctionnalité Actions Fonctionnement exact
Catalogue storefront et fiche produit ([shop_products]) ParcourirConsulter ficheAjouter au panierS'abonner Le shortcode [shop_products] s'injecte dans n'importe quelle page CMS via le hook content.render et affiche une grille des produits actifs, mis en avant d'abord puis les plus récents, limitée à 60 items.
• Une fiche produit détaillée est sélectionnée par ?produit=slug (findActiveBySlug), avec prix formaté selon la devise de la boutique (EUR symbole après, autres devises symbole avant).
• Un badge d'intervalle d'abonnement (« / mois », « / 3 mois ») s'affiche sur les produits récurrents; les produits physiques épuisés montrent « Rupture de stock » et masquent l'ajout au panier.
• Message « boutique indisponible » quand la boutique est désactivée; la fiche propose « Ajouter au panier » (achat unique) ou « S'abonner » (abonnement) avec champ quantité, et retombe sur l'image legacy image_path si le produit n'a aucun média enfant.
Galerie média produit (images + vidéos, pur CSS) Afficher vidéoBasculer miniaturesServir srcset Galerie multi-images/vidéos où la vidéo principale est présentée en premier (poster + balise <video> native avec contrôles) quand le produit en possède une.
• Le passage d'un média à l'autre se fait via une bande de miniatures 100% CSS (radio :checked, sans JavaScript, compatible CSP) qui échange la miniature et la scène.
• Les images sont servies en srcset responsive (largeurs 300/768/1200) avec indice sizes; le poster_path d'une vidéo sert de vignette de grille pour les produits vidéo seule.
• Repli sur l'image unique legacy quand le produit n'a pas de lignes média enfant (table shop_product_media).
Fichiers téléchargeables gratuits (publics) ListerTéléchargerLibeller La fiche produit liste les fichiers téléchargeables non protégés du produit sous forme de liens directs publics (freeFiles).
• Le libellé provient du label du fichier ou, à défaut, de l'original_name du média ou du nom de base du fichier.
• Les fichiers protégés (payants, isProtected) ne sont jamais listés ici et restent réservés à la livraison par token après achat.
Panier de session ([shop_cart]) AjouterModifier quantitéRetirerVider Panier stocké en session sous forme [productId => qty]; les prix et le stock sont toujours relus depuis la base à chaque affichage, jamais mémorisés.
• Actions POST /shop/cart/add (ajout/incrément), /shop/cart/update (quantité, 0 supprime la ligne) et /shop/cart/remove, toutes protégées CSRF et rate-limitées; quantité plafonnée à 99 par ligne.
• Règles métier: refus des produits d'une autre devise (currency_mismatch), règle « un abonnement s'achète seul » (ajouter un abonnement réinitialise le panier et bloque le mélange), rejet des quantités hors stock pour les produits physiques.
• Les produits archivés ou supprimés sont retirés des lignes résolues; le récapitulatif affiche sous-total, livraison, TVA incluse et total; le panier est vidé automatiquement au retour de Stripe.
Stripe Checkout (redirection paiement hébergée) Saisir email et nomSaisir adresseConsentirRediriger Transforme le panier en une commande « pending » persistée (avec snapshots prix/TVA/quantité par ligne) avant de contacter Stripe, puis crée une Checkout Session et redirige l'acheteur en 303 vers la page hébergée Stripe (mode paiement ou abonnement); aucune carte ne transite par le serveur (PCI SAQ-A).
• Capture l'email client (validé) et le nom complet, exige l'adresse de livraison (ligne/ville/code postal/pays) quand le panier contient des biens physiques.
• Impose côté serveur le consentement de vente à distance UE (terms_accepted + privacy_accepted) et affiche le droit de rétractation 14 jours avec exception contenu numérique.
• Refuse le checkout si la boutique est désactivée, le panier vide, Stripe non configuré ou l'identité vendeur incomplète; construit les line_items avec intervalle récurrent pour les abonnements, ajoute la livraison forfaitaire en shipping_option (paiements uniques seulement).
• Passe l'uuid de commande comme clé d'idempotence, client_reference_id et metadata; marque la commande « failed » si la session ne peut être créée; les URLs succès/annulation portent l'uuid.
Page de confirmation de commande ([shop_order]) Afficher statutLister itemsOuvrir factureVider panier Confirmation post-checkout lisant ?order=uuid (ou la session), avec bannière verte « paiement reçu » (paid/fulfilled) ou ambre « en cours de confirmation » (pending).
• Liste les articles de la commande et le total, et affiche un libellé de statut localisé (statusLabel).
• Propose un lien vers la facture légale une fois l'invoice_number attribué, et un lien vers l'avoir quand un credit_note_number existe.
• Vide le panier quand l'acheteur revient pour sa dernière commande.
Livraison sécurisée de téléchargements numériques (/shop/download/{token}) Valider tokenVérifier droitStreamer fichier Diffuse un fichier numérique acheté depuis le dossier privé storage/downloads/, protégé uniquement par un token de capacité impossible à deviner (64 caractères hexadécimaux, stocké haché en sha256).
• Valide le format du token (64 hex) puis le recherche par hash sha256; applique l'expiration du lien (30 jours par défaut) et le plafond d'utilisations par lien (5 par défaut).
• Vérifie le droit de la commande (payée et ni remboursée ni annulée), résout le token par fichier (product_file_id) ou retombe sur le download_path legacy.
• Durcit le chemin contre les traversées et octets nuls en confirmant qu'il reste dans le répertoire de base, incrémente downloads_used en best-effort (fail-open sur fichier payé) et streame le fichier en pièce jointe (jamais servable directement par le serveur web).
Facture légale et avoir client (public, /shop/invoice/{uuid}) Consulter factureConsulter avoirImprimer/PDF L'uuid de commande agit comme capacité pour consulter une facture HTML imprimable (impression/enregistrement PDF par le navigateur).
• L'avoir (credit note) se consulte via ?doc=credit sur la même URL.
• Renvoie une 404 quand la commande n'est pas payée ou que le numéro de document demandé est absent; seules les commandes payées portant le numéro demandé sont servies.
Portail de facturation client self-service ([shop_account] + /shop/account/portal) Lister abonnementsOuvrir portail StripeGérer carteAnnuler Les clients connectés voient la liste de leurs abonnements (montant, statut, date de renouvellement) via le shortcode [shop_account].
• L'action POST /shop/account/portal (authentifiée, CSRF, rate-limitée) ouvre une nouvelle session courte du Portail de Facturation Stripe pour l'auto-gestion (changer la carte, annuler, télécharger les factures).
• L'identifiant client Stripe est résolu d'abord depuis les abonnements de l'utilisateur, puis depuis ses commandes.
• Invite à se connecter si non authentifié, et affiche « aucun compte de facturation » quand aucun identifiant Stripe n'est trouvé.
Récepteur webhook Stripe (/shop/webhook/stripe) Vérifier signatureDédupliquerRouter événements Webhook exempté de CSRF et vérifié par signature HMAC-SHA256 (schéma v1, fenêtre anti-rejeu de 300 s); c'est l'UNIQUE chemin qui fait avancer l'état de paiement.
• Idempotent grâce à une table de réclamation d'event id (INSERT IGNORE) qui neutralise les livraisons dupliquées, avec dédup fail-open et libération de la réclamation en cas d'erreur de handler (500).
• Route les événements: checkout.session.completed → commande payée (avec attente de paiement asynchrone), checkout.session.expired → annulation de la commande pending abandonnée, customer.subscription.created/updated/deleted → synchronisation d'abonnement.
• Traite aussi invoice.paid (reçu de renouvellement + avancement de période), invoice.payment_failed (relance + past_due), charge.refunded (réconciliation de remboursement) et charge.dispute.created/closed; renvoie 200 sur les types non gérés pour que Stripe cesse de réessayer.
Pipeline de traitement des commandes payées (piloté par webhook) Promouvoir payéeNuméroter factureDécrémenter stockÉmettre liens Sur une Checkout Session payée et vérifiée, promeut atomiquement la commande en « paid » et estampille paid_at / payment_intent / identifiant client (idempotent sur paid_at).
• Attend le règlement asynchrone pour SEPA/iDEAL/Bacs (quand payment_status != paid); alloue le numéro de facture gapless dans la même transaction.
• Décrément de stock atomique et conditionnel (journalise une survente plutôt que de plafonner), enregistre l'abonnement Stripe pour les commandes en mode abonnement.
• Émet des liens de téléchargement sécurisés par fichier protégé, puis met en file l'email de confirmation après commit.
Emails transactionnels de boutique Envoyer confirmationNotifier expéditionRelancerNotifier annulation Six emails de marque (en-tête/pied identité vendeur), mis en file via le Mailer du cœur, chacun fail-open pour qu'une erreur d'envoi ne casse jamais le flux.
• Confirmation de commande (totaux détaillés, lien facture, liens de téléchargement sécurisés + note d'expiration) et confirmation de remboursement (lien avoir, remboursements complets).
• Notification d'expédition (transporteur + référence de suivi) et reçu de renouvellement d'abonnement (uniquement pour subscription_cycle, avec date du prochain renouvellement).
• Email de relance (dunning) sur échec de renouvellement (lien vers le portail self-service) et avis d'annulation d'abonnement (avec date de fin d'accès).
Dashboard admin de la boutique (/admin/shop) Afficher KPIsLister commandes récentesAlerter configuration Tableau de bord KPI (permission shop.view) affichant chiffre d'affaires payé, nombre de produits, nombre de commandes, commandes payées, commandes en attente et abonnements actifs.
• Liste les 10 commandes les plus récentes avec liens vers leur détail.
• Affiche des bannières d'alerte quand Stripe n'est pas configuré (checkout désactivé) ou que le secret webhook manque, avec un lien vers les réglages Stripe.
Gestion des produits admin / CRUD (/admin/shop/products) CréerÉditerSupprimerAttacher médias et fichiers CRUD produit complet: liste (nom, type, facturation, prix, stock, statut), création avec uuid auto, slug auto-unique et created_by, édition et suppression via modale de confirmation.
• Champs configurables: nom, slug, descriptions courte/longue, type (numérique/physique), facturation (achat unique/abonnement), prix TTC, taux de TVA, SKU, stock (vide = illimité, physique seulement).
• Intervalle d'abonnement (jour/semaine/mois/année) + nombre d'intervalles, bascule « mis en avant sur le storefront », statut (actif/brouillon/archivé).
• Attache des médias produit via le sélecteur de médiathèque (images + vidéos, choix de la vidéo principale) et un répéteur de fichiers téléchargeables protégés (payants) ou gratuits (publics); l'image principale et le premier fichier protégé sont mirrorés dans les colonnes legacy (liste = shop.view, écritures = shop.manage).
Upload de fichier privé durci (AJAX /admin/shop/upload-file) TéléverserValider MIMEBloquer exécutablesStocker privé Point AJAX pour les fichiers produit payants (protégés), stockés sous le répertoire privé storage/downloads/{Y/m}/ avec six couches de défense.
• Rejette les fichiers non whitelistés, les exécutables bloqués et les doubles extensions; vérifie le vrai type MIME via finfo contre l'extension déclarée.
• Impose une taille maximale configurable (512 Mo par défaut via general.download_max_size) et enregistre sous un nom de fichier aléatoire.
• Renvoie une réponse JSON contenant le chemin relatif privé du fichier stocké.
Gestion des commandes admin (/admin/shop/orders) ListerConsulter détailChanger statutExpédier Liste des commandes (max 200) filtrable par statut (pending/paid/failed/canceled/refunded/fulfilled), avec vue détaillée: articles, totaux, client, adresse de livraison, PI Stripe, montant remboursé et facture.
• Change le statut opérationnel vers « fulfilled » ou « canceled » (les états de paiement ne proviennent QUE de Stripe), en bloquant les transitions illégales via la carte TRANSITIONS (ex. jamais-payé → fulfilled impossible).
• Capture transporteur + numéro de suivi et estampille shipped_at au passage en « fulfilled », en déclenchant l'email de notification d'expédition.
• Re-stocke automatiquement (une seule fois) les articles d'une commande physique payée à l'annulation; consultation de la facture/avoir légal via /admin/shop/orders/{id}/invoice (liste/vue = shop.view, écritures = shop.orders).
Remboursements Stripe complets et partiels (/admin/shop/orders/{id}/refund) Rembourser totalRembourser partielRe-stockerNotifier Émet un vrai remboursement Stripe contre le payment_intent de la commande: remboursement complet (montant vide = solde remboursable restant) ou partiel (validé ≤ montant remboursable).
• Utilise une clé d'idempotence liée à la position cumulée remboursée (pas de double remboursement sur double soumission) et bascule atomiquement paid/fulfilled → refunded (sûr face à la course entre action admin et webhook, via applyRefund partagé avec charge.refunded).
• Alloue un numéro d'avoir (credit note) gapless sur remboursement complet, re-stocke les articles physiques une seule fois, et suit refunded_total cumulé / refunded_at / stripe_refund_id.
• Envoie l'email de confirmation de remboursement et écrit une entrée d'audit; refuse le remboursement sur commande jamais payée / annulée / échouée / déjà intégralement remboursée.
Gestion des litiges / chargebacks Notifier ouvertureNotifier clôtureAuditer Les webhooks charge.dispute.created/closed remontent les litiges aux admins sans changement d'état automatique (jugement humain requis).
• Notification « Chargeback opened » (criticité critique) avec lien vers la commande et rappel de la date limite du dashboard Stripe.
• Notification à la clôture d'un litige (gagné/perdu) avec sévérité selon l'issue.
• Écriture des entrées d'audit shop.order.dispute_opened / dispute_closed.
Gestion des abonnements admin (/admin/shop/subscriptions) ListerAnnuler en fin de périodeRéconcilier Liste les abonnements (client, statut, montant, intervalle, date de renouvellement, identifiant Stripe).
• Permet à un opérateur d'annuler un abonnement en fin de période courante via Stripe (cancel_at_period_end); rejette l'annulation en l'absence de référence Stripe ou si déjà annulé.
• Les états de renouvellement/échec sont pilotés par webhook; le webhook customer.subscription.deleted réconcilie vers « canceled » et notifie le client par email (liste = shop.view, annulation = shop.orders).
Réglages de la boutique (/admin/shop/settings) Activer/désactiverConfigurer devise et TVAConfigurer StripeRenseigner identité vendeur Écran de configuration (permission shop.manage) avec bascule boutique activée/désactivée.
• Devise (ISO-3), taux de TVA par défaut, taux de TVA livraison (0 = exonéré), frais de livraison forfaitaires et seuil de franco de port.
• Stripe: mode (test/live), clé publiable (en clair), clé secrète + secret webhook (chiffrés, laisser vide conserve l'existant), affichage de l'URL d'endpoint webhook et de la liste d'événements requis, avec avertissement si APP_ENCRYPTION_KEY manque.
• Slugs des pages storefront (shop/cart/order/account) mappés aux shortcodes, et identité légale vendeur: raison sociale, adresse postale, numéro de TVA, immatriculation (SIRET), email de contact, préfixe de numéro de facture et mentions légales en texte libre imprimées sur les factures.
Facturation légale numérotation séquentielle sans trou et ventilation TVA Allouer numéroVentiler TVARendre factureRendre avoir Factures et avoirs conformes UE/FR tirés d'un compteur atomique gapless par série et par année (idiome LAST_INSERT_ID), le numéro de facture étant alloué dans la transaction de paiement.
• Allocation d'un numéro d'avoir gapless sur remboursement complet; rendu de la facture avec identité vendeur + acheteur, lignes, récapitulatif de TVA par taux et totaux TTC/HT.
• La ventilation de TVA par taux est dérivée des prix TTC (taxFromInclusive) avec réconciliation de la TVA de livraison; l'avoir porte des montants négatifs, sa propre date d'émission et référence la facture d'origine.
• Impression/enregistrement PDF depuis le navigateur, avec un pied de page de mentions légales configurable.
Couverture RGPD (hook rgpd.data_sources) Exporter PIIAnonymiserRapprocher par email ou user_id Déclare shop_orders et shop_subscriptions comme sources de données personnelles pour l'export du cœur.
• Sur une demande d'accès (art. 15), exporte les PII de commande et d'abonnement; sur une demande d'effacement (art. 17), anonymise sur place customer_email, nom, facturation, livraison et stripe_customer_id tout en conservant les lignes pour l'intégrité comptable.
• Rapproche le sujet à la fois par customer_email et par user_id, ce qui couvre les commandes invité identifiées uniquement par email.
Navigation admin unifiée et permissions de boutique Afficher navigationFiltrer par permissionContrôler accès Contribue la section « Shop » à la barre latérale et au lanceur/recherche/aide Ctrl+K en une seule inscription, chaque item filtré par permission côté serveur (Auth::can).
• Items: Shop / Products / Orders / Subscriptions (shop.view) et Shop settings (shop.manage).
• Adossé à trois permissions granulaires accordées aux rôles admin/éditeur: shop.view (dashboard, listes produits/commandes), shop.manage (écritures produits/réglages) et shop.orders (actions commandes/abonnements).
Design & Thèmes

🏭 Plugin AI Studio Design

22 fonctionnalités
Fonctionnalité Actions Fonctionnement exact
Architecture usine & distinction avec le Theme Studio du cœur IsolerProduireDécoupler AISD est une USINE, jamais un runtime : chaque thème qu'il produit est un thème classique 100% autonome sous themes/{slug}/ qui continue de fonctionner à l'octet près même si le plugin est désactivé ou désinstallé.
C'est la distinction nette avec le Theme Studio du cœur : le plugin se branche au cœur EXCLUSIVEMENT via des hooks (routes.register.web, command.menu.items, admin.head) et ne touche jamais les fichiers du cœur, ni app/Services/ThemeStudio/*, ni theme-library/*.
Tous les écrans vivent sous /admin/aisd, protégés par auth + permission aisd.use ; l'activation exige aisd.publish + CSRF ; la file de revue exige aisd.library.review.
Les 3 permissions sont amorcées par migrations/ai-studio-design_2026_07_19_000001_permissions.sql. Aucune route n'est déclarée dans app/routes.php : tout passe par les hooks de Plugin.php.
Accueil du studio / liste des thèmes AISD ListerTrierActiverOuvrir L'écran d'accueil (StudioController::index) scanne le système de fichiers (aisdThemes) pour lister tous les thèmes AISD avec nom, slug, type de site, skin, build, score qualité /100 et nombre de problèmes critiques.
Le thème actif est repéré et trié en tête avec un badge « Active » (Theme::activeIncludingDefault), avec repli fail-open sur le filesystem si la BDD échoue.
Chaque ligne offre les liens Éditer (ouvre l'éditeur canvas) et Voir le score (bulletin qualité), plus une action Activer (formulaire POST) affichée uniquement pour les thèmes non actifs.
L'écran héberge aussi le formulaire de création de thème (brief, référence, capture, nom du site, type, langue).
Assistant de génération (brief → 4 directions) SoumettreChoisirGénérerPrévisualiser Assistant multi-étapes (WizardController::create) transformant un brief (≤2000 car.) + référence optionnelle (≤2000) + nom de site (≤80) en 4 directions de design divergentes via ArtDirector::direct.
Le type de site se choisit parmi vitrine, blog, landing, saas, portfolio (ManifestValidator::SITE_TYPES autorise aussi ecommerce) ; la langue de sortie parmi 10 locales : fr, en, es, de, pt, ru, ja, zh, ar, id.
Chaque direction est compilée en aperçu stocké sous storage/aisd/cache/wizard/{token} puis rendue en direct dans une carte via un aperçu shadow-DOM à styles cloisonnés, mis à l'échelle.
Les états d'assistant périmés sont purgés automatiquement après un TTL de 24h.
Direction pilotée par une référence (URL / description / capture) FournirTéléverserMapperDédupliquer Une référence peut être fournie comme URL de site ou courte description texte (withReferenceDirection), ou via une capture d'écran téléversée (PNG, JPEG, WEBP, GIF) qui prend le pas sur le texte (withReferenceImageDirection).
La référence est mappée sur un couple recette+skin curé de la bibliothèque puis ajoutée EN TÊTE comme première des 4 directions (mergeReferenceProposal), dédupliquée par (recette, skin) et plafonnée à 4.
Il s'agit d'une composition, jamais d'une copie.
Comportement fail-open : une référence inexploitable laisse silencieusement les 4 directions issues du brief. Traitée par ReferenceImporter et ReferenceAnalyzer.
Analyse de capture → brief éditable (vision) AnalyserAuto-remplirÉditer Endpoint AJAX (WizardController::analyze) déclenché par « Analyse the screenshot », qui POST l'image vers /admin/aisd/wizard/analyze et renvoie du JSON.
Un modèle de vision (ScreenshotBriefer) lit la capture et auto-remplit les champs brief + référence + type de site, que l'utilisateur peut ensuite éditer (approche glass-box).
Un overlay de scan animé affiche des indices de progression rotatifs.
Messages d'erreur fail-open granulaires : aucun fichier, limite d'upload, trop volumineux (>15MB), type invalide, dimensions invalides, PHP-GD absent, vision non supportée (ScreenshotIntake).
Curseurs / ajustements de direction (recompilation déterministe) RéglerRecompilerRepeindre Contrôles par direction (WizardController::adjust) recompilant de façon DÉTERMINISTE la direction choisie côté serveur, SANS aucune dépense IA.
Options en enums whitelistés uniquement (ArtDirector::ADJUSTMENTS) : Densité (compact, regular, airy), Rayon (sharp, soft, round, pill), Intensité de mouvement (calm, editorial, energetic), Mode par défaut (light, dark).
Le POST /admin/aisd/wizard/adjust recompile et repeint l'aperçu.
La direction ajustée est persistée dans state.json.
Aperçus d'assistant (page autonome + données JSON) OuvrirRécupérerValider Deux endpoints GET adossés à chaque carte de direction.
preview ouvre une direction en page autonome complète dans un nouvel onglet (mode + locale + nom appliqués).
previewData renvoie le CSS/HTML/mode d'aperçu en JSON pour peindre l'hôte shadow-DOM dans la carte (PreviewRenderer).
Accès sécurisé par token validé (16 caractères hex) et index borné (0..3).
Flux SSE de génération de thème DémarrerDiffuserRediriger Endpoint Server-Sent-Events (WizardController::generate) démarré via EventSource GET /admin/aisd/wizard/{token}/generate/{i}.
Il diffuse les étapes de progression compose, write, illustrate, build, score sur un overlay animé (ArtDirector::generate), le payload score.done portant le score + allowed.
Un événement « done » émet le nouveau slug/score et redirige vers le bulletin qualité ; un événement « failed » émet la liste d'erreurs.
Durcissement anti-buffering : backoff de retry, commentaire de padding, en-tête X-Accel-Buffering, plus un indice de récupération en cas de flux interrompu.
Bulletin qualité / écran de score AfficherDétaillerActiver Rapport qualité par thème (StudioController::score, QualityAnalyzer) affichant le score total /100 avec code couleur et le verdict du gate Autorisé/Refusé (seuil ≥90 ET 0 critique, QualityGate).
Barres par catégorie : Design, UX, Mobile, Speed, SEO, Code (score sur max).
Il liste les vérifications échouées par catégorie via leur id technique avec pénalité en points, les problèmes critiques bloquants (id + message), et les pénalités de design générique (id, -1 chacune).
Affiche aussi les métadonnées build id, skin, type de site, un bouton Activer en ligne quand le gate autorise hors fail-open, et un lien vers l'écran de suggestions IA.
Activation de thème sous gate qualité ActiverVérifierJournaliser POST /admin/aisd/theme/{slug}/activate protégé par la permission aisd.publish + CSRF (StudioController::activate).
L'activation est refusée quand le gate n'autorise pas (score<90 ou critiques>0), avec redirection vers le score et une erreur.
Fail-open : si le gate est indisponible, l'activation est autorisée avec un flash d'avertissement.
En cas de succès, la ligne Theme du cœur est upsertée (upsertThemeRow : type=aisd, name/description/version/author/license depuis theme.json) puis activée via le modèle du cœur. Chaque activation, refus ou échec est journalisé via AisdLog.
Suggestions d'amélioration IA à la demande (boucle visuelle) AnalyserListerRenvoyer GET /admin/aisd/theme/{slug}/suggest (StudioController::suggest) fait passer le critique (VisualCritic, VisualLoop) sur le plan de travail + le rapport qualité.
Il liste les suggestions survivantes sous forme prompt + chemin cible + raison.
État vide honnête quand l'exécution est impossible (pas de clé API, pas de budget, pas de plan) ; endpoint en GET pour que la dépense IA soit explicite ; budget-gated et fail-open.
Rien n'est appliqué automatiquement : un lien « Ouvrir dans l'éditeur » permet d'appliquer manuellement via l'éditeur normal.
Coquille de l'éditeur canvas (cliquer + décrire) SélectionnerBasculerNaviguer Éditeur visuel (EditorController::edit) avec aperçu live shadow-DOM où l'on clique n'importe quel nœud adressable (data-aisd-path) pour le sélectionner et l'éditer.
Bascules : largeur de viewport 375, 768, 1440 ; thème light/dark ; direction de texte LTR/RTL ; plus l'ouverture du panneau Direction artistique.
Un fil d'Ariane montre le chemin du nœud sélectionné.
Une chronologie d'historique liste les snapshots avec le snapshot courant surligné. Assets injectés via le hook admin.head (CSP-nonced, pages AISD uniquement).
Panneau de réglages du nœud (piloté par manifeste, zéro IA) RésoudreÉditerRéorganiser Panneau contextuel (EditorController::node, buildPanel) rendant les contrôles éditables du nœud sélectionné à partir de son manifeste de bibliothèque (LibraryScanner), sans aucune IA.
Il résout le type de nœud : chrome, section, pattern, primitive, scene, skin.
Contrôles : choix de variante (si >1 variante), options enum (menu déroulant), options booléennes (case à cocher), slots texte/url (champs texte avec longueur max).
Pour une scene : choix de tonalité (base, alt, inverse, inverse-alt), déplacer une section haut/bas, la retirer, insérer une section de contenu du catalogue. Pour skin/direction artistique : choix du skin et de l'intensité de mouvement (calm, editorial, energetic).
Entonnoir de mutation par patch typé (apply) AppliquerNégocierValiderSnapshoter Entonnoir unique (EditorController::apply/applyOps, PatchApplier) pour toutes les éditions directes via des ops typées : set_variant, set_option, set_slot, set_tone, set_stage, override_token, insert_section, remove_section, move_section, set_skin, set_motion_profile.
Négociation de contrainte : les éditions violant le contraste WCAG (<4.5) sont refusées avec la contre-proposition conforme la plus proche, applicable en un clic (ConstraintNegotiator).
Garde structurelle : refus de supprimer le hero ou le H1 de page, et refus d'ajouter un second hero.
Le plan entier est revalidé (refus par défaut), puis un snapshot d'historique est poussé et l'aperçu en mode édition recompilé + la chronologie sont renvoyés (recompilation sub-seconde).
Barre de prompt IA contextuelle (langage naturel → patch) DécrireCompilerPrévisualiserEnregistrer Barre de prompt (EditorController::prompt) où l'on saisit un changement (≤500 car.) porté sur le chemin du nœud sélectionné (défaut « skin »).
Le prompt est compilé en ops typées par le compléteur IA (PatchCompiler) puis passe par le MÊME entonnoir apply (mêmes validations et gardes).
Un prompt ambigu produit jusqu'à 3 alternatives prévisualisées, chacune applicable en un clic.
Un prompt insoluble est enregistré dans le backlog de bibliothèque (boucle d'apprentissage, sans PII) avec une raison remontée à l'utilisateur (LibraryBacklog).
Navigation d'historique annuler/rétablir AnnulerRétablirPersister POST .../edit/undo revient au snapshot de plan précédent ; POST .../edit/redo avance (EditorController::undo/redo, step).
Le plan restauré est persisté dans le dépôt et l'aperçu recompilé + la chronologie mise à jour sont renvoyés (HistoryService).
À la première ouverture d'un thème, l'historique est amorcé par un snapshot « initial ».
Le mécanisme est purement basé sur des snapshots, couvrant toutes les mutations de l'éditeur.
Publication / republication depuis l'éditeur ReconstruireRedirigerSignaler POST .../edit/publish reconstruit themes/{slug}/ à partir du plan de travail courant (EditorController::publish, ThemeAssembler).
En cas de succès, le build id est renvoyé et l'utilisateur redirigé vers l'écran de score.
En cas d'échec, les erreurs de l'assembleur sont remontées avec un code 422.
La sortie reste un thème classique autonome, indépendant du plugin.
Endpoint d'aperçu du plan de travail (éditeur) RendreRésoudreValider GET .../edit/preview renvoie le CSS/HTML/mode en mode édition + la chronologie en JSON (EditorController::preview, previewResponse).
Le plan de travail est résolu par ordre de priorité : curseur d'historique → miroir du dépôt → .aisd/site-plan.json du thème (ré-éditabilité), validé avant usage (loadWorkingPlan).
Sert à (re)peindre le canvas de l'éditeur.
La présence de .aisd/site-plan.json dans le thème garantit qu'un thème déjà bâti reste réouvrable et modifiable.
File de revue de composants + backlog de bibliothèque ListerAfficherFiltrer Écran (ReviewController::index) listant les composants brouillons avec id, requête d'origine et badge de statut (pending, approved, rejected), derrière la permission dédiée aisd.library.review.
Il affiche aussi le backlog de la boucle d'apprentissage : le top 30 des choses les plus demandées que la bibliothèque ne couvre pas (compte + échantillon, LibraryBacklog).
Chaque brouillon est lié à sa page de détail/revue (ComponentDraftStore).
C'est le portail humain de gouvernance des composants générés par l'IA avant leur entrée en bibliothèque.
Détail de brouillon de composant + approuver/rejeter ConsulterApprouverRejeter Écran de détail (ReviewController::show) montrant le partial.php et le primitive.css générés (échappés, en lecture seule) et le verdict du bac à sable : réussi (validé, isolé, XSS-échappé, CSP-clean) ou la liste des erreurs de sandbox (ComponentSandbox).
« Approuver & ajouter à la bibliothèque » (POST + CSRF) n'est proposé que pour les brouillons pending ; le store relance le sandbox À L'APPROBATION comme ultime défense (ReviewController::approve).
Le rejet du brouillon (POST + CSRF) est également disponible (ReviewController::reject).
Garde 404 propre pour sous-chemins AISD inconnus IntercepterRenvoyer 404 GET /admin/aisd/{any} renvoie Response::notFound() (StudioController::notFound), derrière auth + aisd.use.
Cette route de repli garantit un vrai 404 pour tout chemin /admin/aisd/* non implémenté, l'empêchant de retomber sur le catch-all public de contenu /{any} (où il serait résolu comme un slug de contenu).
Les routes réelles des phases sont enregistrées AVANT cette garde, l'ordre d'enregistrement l'emportant.
Entrées de navigation unifiées (sidebar + lanceur Ctrl+K) EnregistrerGrouperFiltrer Un SEUL enregistrement (Plugin::commandMenuItems via le hook command.menu.items) alimente à la fois la sidebar admin, la recherche et le lanceur Ctrl+K.
Il ajoute l'item « Ai Studio Design » → /admin/aisd (permission aisd.use, correspondance exacte) et l'item « Component review » → /admin/aisd/library/review (permission aisd.library.review), groupés sous une section « Ai Studio Design » avec icône et description.
Les items sont filtrés côté serveur par Auth::can par item.
Le filtre legacy admin.menu n'est délibérément PAS enregistré en plus, sinon la section serait rendue en double.
Aucune fonctionnalité ne correspond à votre recherche.