Référence

Schéma des collections

Le format de leiho.schema.json.

Leiho Collections permet à un site de définir son modèle de contenu dans Leiho même, sans ACF : « des collections à la Strapi / Webflow dans WordPress ». Un site n’a besoin que de WordPress et de Leiho, rien d’autre. ACF continue de fonctionner via son adaptateur ; les deux peuvent cohabiter.

La phase 1 couvre le moteur de stockage, le format du schéma, les migrations, les révisions, l’API REST d’administration et l’adaptateur qui branche Collections sur l’éditeur visuel et l’API de contenu. La phase 2 est l’interface de construction du schéma, la phase 3 l’import ACF → Collections (§9) ; toutes deux s’appuient sur l’API décrite ici.

1. Concepts

Collection Un type de contenu avec de nombreuses entrées (Services, Articles, Membres de l’équipe).
Page unique Un type de contenu avec exactement une entrée (Accueil, Contact, Réglages du site). Leiho crée l’entrée ; elle ne peut être ni dupliquée ni supprimée.
Entrée Une ligne de wp_posts (type de publication = l’id de la collection) plus une ligne dans la table des champs de la collection.
Champ Une valeur typée d’une entrée, identifiée par une key stable et exposée sous son name.

Stockage hybride

Chaque entrée est une vraie publication WordPress : utilisateurs et rôles, brouillons, planification, corbeille, révisions, médiathèque, extensions SEO et multilingues continuent de fonctionner. Titre, slug, statut, dates, auteur, post_content (quand editor est pris en charge), extrait et image mise en avant restent dans wp_posts / wp_postmeta comme d’habitude.

Les valeurs des champs ne vont pas dans les postmeta. Chaque collection possède sa propre table, {$wpdb->prefix}leiho_<collection id>, avec une ligne par entrée indexée par post_id. Les champs scalaires ont une colonne typée et indexée ; les valeurs imbriquées sont stockées sous forme de texte JSON. Les requêtes restent ainsi peu coûteuses (une lecture par clé primaire par entrée au lieu de dizaines de lignes de méta), les valeurs peuvent être triées et filtrées en SQL, et wp_postmeta reste léger.

2. Format du schéma

Le schéma est stocké dans l’option leiho_collections et peut être exporté vers / importé depuis un fichier, leiho.schema.json, destiné à être versionné dans le dépôt du front (le schéma comme du code).

json
{
  "leiho": "collections",
  "version": 1,
  "collections": [
    {
      "id": "home",
      "kind": "singleton",
      "label": "Home",
      "path": "/",
      "fields": [
        { "key": "hero_title", "name": "hero_title", "label": "Hero title", "type": "text", "required": true, "max_length": 80 },
        { "key": "sections", "name": "sections", "label": "Sections", "type": "blocks", "blocks": [
          { "key": "hero", "name": "hero", "label": "Hero", "fields": [
            { "key": "image", "name": "image", "label": "Image", "type": "image" }
          ] },
          { "key": "cards", "name": "cards", "label": "Cards", "max": 3, "fields": [
            { "key": "items", "name": "items", "label": "Cards", "type": "list", "max": 6, "fields": [
              { "key": "title", "name": "title", "label": "Title", "type": "text" },
              { "key": "service", "name": "service", "label": "Service", "type": "reference", "to": ["service"] }
            ] }
          ] }
        ] }
      ]
    },
    {
      "id": "service",
      "label": "Services",
      "singular_label": "Service",
      "path": "/services/{slug}",
      "supports": ["thumbnail", "excerpt"],
      "fields": [
        { "key": "price", "name": "price", "label": "Price", "type": "number", "min": 0 },
        { "key": "featured", "name": "featured", "label": "Featured", "type": "boolean" },
        { "key": "tone", "name": "tone", "label": "Tone", "type": "select", "choices": [
          { "value": "light", "label": "Light" }, { "value": "dark", "label": "Dark" }
        ] },
        { "key": "related", "name": "related", "label": "Related", "type": "reference", "to": ["service"], "multiple": true, "max": 3 }
      ]
    }
  ]
}

Collection

Propriété
id obligatoire Nom du type de publication et suffixe de la table. ^[a-z][a-z0-9_]{0,19}$, pas un type de publication réservé de WordPress (post, page, attachment…), ni export/import/activity (la table propre à Leiho). Ne change jamais.
kind collection (par défaut) | singleton
label, singular_label Libellés d’administration. singular_label vaut label par défaut.
path Modèle d’URL côté front (/services/{slug}, {id} fonctionne aussi). Utilisé sauf si les réglages de Leiho en définissent un.
supports Fonctionnalités de publication supplémentaires : editor, excerpt, thumbnail, page-attributes. title et revisions sont toujours actifs.
icon Un nom dashicons-* pour le menu d’administration.
external bool Le type de publication est enregistré hors de Leiho (thème, extension, ACF) : Leiho ne l’enregistre pas, ne le refuse pas parce qu’il existe déjà, et ne l’ajoute pas aux types servis par l’API de contenu (ce sont les réglages de Leiho qui décident, comme avant). Leiho ne gère que ses champs. Jamais une page unique. Défini par l’import ACF.
source "" | acf acf : importé depuis ACF. Les noms des champs et des blocs gardent alors leur nom d’API ACF (^[A-Za-z_][A-Za-z0-9_-]{0,63}$, camelCase et tirets compris) pour que le front reçoive le même JSON ; les clés restent ^[a-z][a-z0-9_]{0,47}$.
fields Champs, 200 au maximum au premier niveau.
seo objet Facultatif. type (schema.org), title, description, image, faq (noms de champs racine) et llms. Voir SEO et recherche IA.

Champ

Propriété
key id stable ^[a-z][a-z0-9_]{0,47}$, unique parmi les champs frères. Ne la changez jamais : elle nomme la colonne et les clés JSON. Vaut name si elle est omise (à la création uniquement).
name nom d’API ^[a-z][a-z0-9_]{0,63}$ (snake_case ; voir source pour les collections importées), unique parmi les champs frères. Renommer = changer name en gardant key.
label Vaut name par défaut.
type Voir ci-dessous.
required bool Un champ obligatoire ne peut pas être vidé (vérifié à chaque écriture, quel que soit le client).
max_length int Champs de type texte. Appliqué à l’écriture.
help string Instructions affichées aux rédacteurs.
type Options Valeur servie par l’API de contenu Colonne
text multiline, max_length string VARCHAR(191) indexée quand 0 < max_length <= 191, sinon TEXT
richtext string HTML rendu LONGTEXT
code max_length string, exactement tel que saisi (balises comprises) : affichez-le comme du texte, jamais comme du HTML TEXT
number integer, min, max, step number \| null (un nombre entier est servi sous la forme 5, jamais 5.0) BIGINT si integer, sinon DECIMAL(20,6) ; indexée
boolean boolean TINYINT(1) indexée
date "YYYY-MM-DD" DATE indexée
datetime "YYYY-MM-DD HH:MM:SS" (heure du site) DATETIME indexée
select choices ([{value,label}]), multiple string / string[] VARCHAR(191) indexée / JSON
slug max_length string (assaini comme un slug de publication) VARCHAR(191) indexée
email string VARCHAR(191) indexée
url string TEXT
color string (#rgb[a], #rrggbb[aa], rgb[a]()) VARCHAR(32)
image, file LeihoMedia \| null BIGINT UNSIGNED indexée
gallery max LeihoMedia[] JSON
link { url, title, target } \| null JSON
reference to (ids de collections ou types de publication), multiple, max LeihoRef \| null / LeihoRef[] BIGINT UNSIGNED indexée / JSON
list fields, min, max Array<{…}> JSON
blocks blocks ([{key,name,label,fields,min,max}]), min, max Array<{ _layout: name, … }> JSON
group fields {…} JSON

Les colonnes JSON sont des LONGTEXT contenant du texte JSON : aucune fonction SQL JSON n’est utilisée, si bien que MySQL 5.7, MariaDB et le drop-in SQLite se comportent de la même façon. Dans le JSON, les objets sont indexés par la clé du champ (renommer un champ imbriqué ne coûte rien) et les lignes de blocs portent "_block": "<block key>". Les valeurs vides sont stockées en NULL.

L’imbrication est limitée à 8 niveaux ; les colonnes VARCHAR de premier niveau à 60 par collection (taille de ligne InnoDB).

3. Tables

sql
CREATE TABLE wp_leiho_service (
  post_id BIGINT(20) UNSIGNED NOT NULL,
  f_price DECIMAL(20,6) NULL DEFAULT NULL,
  f_featured TINYINT(1) NULL DEFAULT NULL,
  f_tone VARCHAR(191) NULL DEFAULT NULL,
  f_related LONGTEXT NULL,
  PRIMARY KEY (post_id),
  KEY k_f_price (f_price), KEY k_f_featured (f_featured), KEY k_f_tone (f_tone)
) {charset_collate};

Les colonnes s’appellent f_<key> (le préfixe évite les mots réservés). Les identifiants ne sont construits qu’à partir d’ids et de clés validés, et revérifiés avec ^[A-Za-z0-9_]{1,64}$ avant tout DDL ; les valeurs passent toujours par $wpdb->prepare() / $wpdb->insert() / $wpdb->update().

Ce que la base de données contient réellement est consigné dans l’option leiho_collections_state : pour chaque collection, sa table, et pour chaque clé de champ la colonne, son type SQL et la définition de champ pour laquelle elle a été créée, plus la liste des colonnes archivées. Les migrations comparent le schéma demandé à cet état, jamais au schéma précédent.

4. Migrations

Enregistrer un schéma (REST, import ou PHP) le valide, calcule un plan, l’applique, puis stocke le schéma. dry_run renvoie le plan sans rien toucher.

Changement Opération
Nouvelle collection CREATE TABLE IF NOT EXISTS ; une page unique reçoit son entrée.
Nouveau champ ADD COLUMN (+ index). S’il existe une colonne archivée avec la même clé et le même type SQL, elle est restaurée à la place : supprimer un champ par erreur puis le rajouter ne fait rien perdre.
Champ renommé (même key, nouveau name) Rien en base de données.
Options modifiées avec le même type de colonne (libellé, choix, max…) Rien en base de données.
Type modifié, ou changement de type de colonne (max_length 100 → 500, référence simple → multiple…) Nouvelle colonne f_<key>__<n>, valeurs converties ligne par ligne en PHP, ancienne colonne archivée (conservée, signalée). Les valeurs non convertibles sont comptées dans le rapport et restent dans la colonne archivée.
Champ supprimé Colonne archivée, jamais supprimée.
Collection supprimée Désenregistrée ; table, entrées et état conservés. La rajouter restaure tout.
Changement imbriqué (dans une liste / des blocs / un groupe) Rien en base de données : le JSON est indexé par clé. Un champ imbriqué supprimé disparaît du JSON stocké au prochain enregistrement de ce conteneur (les révisions gardent les valeurs précédentes).

Conversions : tout scalaire vers du texte ; texte numérique vers nombre ; texte vers booléen ("", "0", "false", "no" → false) ; texte interprétable vers date/datetime ; une valeur simple vers une liste (id → [id]) et le premier élément d’une liste vers une valeur simple ; tout le reste n’est pas converti.

Les colonnes archivées sont listées dans GET /leiho/v1/collections/{id} et ne sont supprimées que par un POST /leiho/v1/collections/{id}/purge explicite avec confirm égal à l’id de la collection.

Le plan est idempotent : l’état est mis à jour après chaque opération réussie, si bien qu’une migration interrompue par une erreur de base de données reprend en enregistrant à nouveau. Le SQL utilise CREATE TABLE, ALTER TABLE … ADD COLUMN, CREATE INDEX, DROP INDEX + ALTER TABLE … DROP COLUMN (purge uniquement) : tous pris en charge par MySQL 5.7+, MariaDB 10.3+ et le drop-in SQLite. Pas de ALTER … MODIFY / RENAME COLUMN.

5. Révisions

Les valeurs des entrées suivent les révisions WordPress :

  • Quand WordPress stocke une révision d’une entrée (_wp_put_post_revision), un instantané de la ligne de l’entrée est enregistré dans la méta _leiho_fields de la révision : { "v": 1, "fields": { "<key>": { "type": "…", "value": … } } } (indexé par clé de champ, si bien qu’un instantané survit aux renommages et aux changements de type).
  • wp_save_post_revision_post_has_changed compare aussi la ligne au dernier instantané, si bien qu’un changement portant uniquement sur les champs crée une révision. Enregistrer des champs via Leiho demande une révision à WordPress après l’écriture.
  • wp_restore_post_revision réécrit l’instantané (en convertissant les valeurs dont le type de champ a changé depuis, en ignorant les champs qui ne sont plus dans le schéma), puis stocke une nouvelle révision de l’état restauré.
  • Les entrées supprimées définitivement perdent leur ligne ; les entrées mises à la corbeille la gardent.

6. Surface d’API

PHP

php
Leiho_Collections::schema();                         // normalized schema
Leiho_Collections::get( 'service' );                 // one collection or null
Leiho_Collections::save_schema( $schema, $dry_run ); // validate + migrate + store → report | WP_Error
Leiho_Collections::put_collection( $collection, $dry_run );
Leiho_Collections::delete_collection( 'service' );
Leiho_Collections::export();                         // array, the leiho.schema.json content
Leiho_Collections::import( $json_or_array, $dry_run );
Leiho_Collections::entry( 'home' );                  // singleton post id
Leiho_Collections::save_entry( $post_id, array( 'price' => 120, 'sections.0.image' => 12 ) ); // { saved, errors }

Un rapport de migration : { dry_run, operations: [{ op, collection, key?, column?, … }], converted: { "<collection>.<key>": { converted, failed } }, warnings: [] }.

REST (leiho/v1, manage_options, filtre leiho_collections_capability)

Route
GET /collections { version, collections, state }
POST /collections Crée une collection (corps : la collection). ?dry_run=1.
GET /collections/{id} Collection + table + colonnes archivées.
PUT /collections/{id} Remplace la définition d’une collection, migre. ?dry_run=1.
DELETE /collections/{id} Retire du schéma ; données conservées.
POST /collections/{id}/purge Supprime les colonnes archivées (confirm = id).
GET /collections/export leiho.schema.json (pièce jointe).
POST /collections/import Remplace tout le schéma par le contenu d’un fichier. ?dry_run=1.

| GET /collections/import-acf | Types de contenu ayant des champs ACF et leur état d’import ; elsewhere : groupes de champs rattachés à aucun type de contenu. §9. |
| GET /collections/import-acf/preview?post_type= | Aperçu d’un import. |
| POST /collections/import-acf/run | { post_type } : lance l’import, puis copie un lot par appel. |
| GET /collections/import-acf/status?post_type= | Enregistrement de l’import. |
| POST /collections/import-acf/rollback | { post_type, confirm } (confirm = type de publication) : retour à ACF. |

DELETE /collections/{id} refuse une collection importée (409) : revenez plutôt à ACF.

Les schémas invalides renvoient 400 avec data.errors : une liste de { path, message } (collections.1.fields.3.key ; relatif à la collection, fields.3.key, sur les routes portant sur une seule collection).

Contenu et éditeur

Les routes existantes incluent désormais Collections : /leiho/v1/export, /content, /types servent tous les types de publication des collections (ajoutés à leiho_content_post_types via les réglages), et /leiho/v1/schema/{id} + /leiho/v1/save/{id} modifient les entrées avec les mêmes chemins pointés qu’ACF (sections.2.items.1.title).

7. Adaptateur

Leiho_Native_Adapter (id collections) implémente Leiho_Adapter_Interface et Leiho_Content_Adapter_Interface. Il traduit chaque champ Collections en tableau de champ au format ACF, que le code existant comprend déjà, et réutilise le moteur de champs commun (Leiho_Field_Adapter, extrait de l’adaptateur ACF) pour décrire, lire, modifier par chemins pointés, assainir et mettre en forme les valeurs publiques. L’éditeur visuel, le journal des modifications, l’API de contenu et Leiho_Types fonctionnent donc sans changement.

Collections Type du moteur de champs
text / text + multiline text / textarea
richtext wysiwyg
boolean true_false
date / datetime date_picker / date_time_picker (stockés en Y-m-d / Y-m-d H:i:s)
color color_picker
slug text (assaini avec sanitize_title)
reference / + multiple post_object / relationship (types de publication limités à to)
list / blocks / group repeater / flexible_content / group
autres même nom

L’assainissement reprend celui de l’adaptateur ACF (obligatoire, max_length, les pièces jointes et les références doivent exister et être lisibles par l’utilisateur courant) et ajoute : les références doivent appartenir aux types de publication de to, les nombres doivent respecter integer/min/max et la plage de la colonne, les sélections multiples n’acceptent que les choix déclarés.

8. Limites et prochaines phases

  • L’écran d’édition classique de wp-admin n’affiche que les champs de la publication ; les champs de collection se modifient dans l’éditeur Leiho (ou sa vue formulaire, pour les collections sans path) ou via les API.
  • Phase 2 (terminée) : le constructeur de schéma dans Leiho → Collections, une interface au-dessus des routes REST ci-dessus. Les nouveaux champs reçoivent key = name (rendu unique parmi les champs frères) lors de leur première application ; le constructeur ne change jamais la clé d’un champ enregistré.
  • Phase 3 (terminée) : import ACF → Collections, §9.
  • Cache par requête uniquement ; l’export lit une ligne par entrée (lectures par clé primaire).
  • Les valeurs datetime sont stockées telles que saisies (heure du site), comme dans ACF.
  • Désinstaller Leiho supprime les tables et options des collections (définissez LEIHO_KEEP_DATA pour les conserver) ; les entrées restent dans wp_posts.

9. Import ACF

Leiho_ACF_Import déplace les champs ACF d’un type de contenu vers une collection Leiho. Rien n’est supprimé d’ACF (postmeta, groupes de champs, types de publication ACF) : l’import est réversible.

Aperçu

Les groupes de champs rattachés au type de publication sont les groupes actifs dont les règles d’emplacement le désignent (Leiho_ACF_Adapter::targets()), dans l’ordre d’ACF. Un groupe dont les règles ajoutent d’autres conditions (modèle de page, taxonomie…) est importé lui aussi, et signalé : ses champs deviennent disponibles sur chaque entrée. Chaque champ ACF devient :

ACF Leiho Remarques
text, textarea text (multiline pour une zone de texte) maxlength → max_length
wysiwyg richtext Rendu comme dans ACF (acf_the_content), même une fois ACF désactivé
number, range number min, max, step
email, url identique
oembed url L’URL, telle qu’ACF la servait
image, file, gallery identique Restrictions de médias (taille, dimensions, types) abandonnées, signalées
select / checkbox / radio, button_group select / select + multiple / select Choix conservés dans l’ordre ; les valeurs hors de ces choix sont refusées (signalées)
true_false boolean
link link
post_object / relationship reference (+ multiple) to = les types de publication d’ACF, ou tous les types modifiables quand ACF n’en définit aucun ; filtres de taxonomie et bidirectionnalité abandonnés
date_picker / date_time_picker date / datetime Ymd → Y-m-d
time_picker text HH:MM:SS
color_picker color
repeater / group / flexible_content list / group / blocks Récursivement ; les layouts deviennent des types de blocs du même nom
clone ses champs / group Un clone transparent est déjà remplacé par les champs clonés dans les définitions d’ACF ; un clone affiché comme un groupe devient un groupe (même JSON). Un clone sans rien à cloner est abandonné
tab, message, accordion — Aucune valeur : ignorés
page_link, taxonomy, user, password, google_map, autres — Non repris : leurs valeurs disparaissent de l’API de contenu (elles restent dans ACF)

Les clés sont dérivées des noms (heroTitle → herotitle, uniques parmi les champs frères) ; les noms sont conservés (source: "acf"). La logique conditionnelle et les valeurs par défaut sont abandonnées et signalées. La collection générée est external: true et validée comme n’importe quel schéma avant toute écriture ; un type de publication qui ne peut pas servir d’id de collection (post, page, tirets, plus de 20 caractères) n’est pas importable, avec la raison : les champs Leiho appartiennent au type de publication propre à une collection, il n’existe pas de « champs Leiho sur les pages ».

Exécution

start() crée la collection (put_collection()) et l’enregistrement d’import, dans l’option leiho_acf_import (type de publication → { status, origin, args, total, done, cursor, imported, failed, different, failures, diffs, … }). Chaque step() copie le lot d’entrées suivant (tous statuts sauf auto-draft et inherit, par id croissant à partir de cursor) : il réinitialise la ligne de l’entrée, lit chaque valeur ACF brute (get_field( key, id, false ), valeurs par défaut d’ACF comprises), la convertit au format d’entrée de l’éditeur (lignes indexées par nom, _layout pour les blocs, dates Ymd, médias et publications disparus retirés) et l’écrit avec Leiho_Collections::save_entry(), c’est-à-dire avec la validation de l’éditeur, sans révision par entrée. Un champ refusé est consigné (post_id, field, code, message) et le reste de l’entrée et de l’import se poursuit. Ensuite, la valeur servie par Leiho (Leiho_Native_Adapter::get_content()) est comparée, champ par champ, à celle qu’ACF servait avant la copie (JSON canonique : 5 et 5.0 sont égaux) ; toute différence est consignée. Les 200 premiers échecs et différences sont conservés ; les compteurs sont exacts.

Tant que l’enregistrement est running, ACF sert toujours le type (l’adaptateur natif ne le sert qu’à l’import lui-même). Une fois le dernier lot terminé, l’enregistrement passe à done et le type est servi par Leiho : Leiho_ACF_Adapter l’ignore (retired()), et les règles d’emplacement d’ACF ne le ciblent plus (acf/location/rule_match), si bien que l’écran d’édition classique n’affiche plus les groupes ACF. Si la collection disparaît ensuite du schéma (un fichier de schéma importé sans elle), ACF sert de nouveau le type.

Enregistrement du type de publication

origin vaut acf (déclaré dans l’écran Types de publication d’ACF), external (thème, extension, mu-plugin) ou builtin. Pour acf, les arguments avec lesquels ACF enregistre le type (ACF_Post_Type::get_post_type_args()) sont stockés dans l’enregistrement, et Leiho_ACF_Import::register_post_types() (init, priorité 6, après ACF) enregistre le type avec eux quand rien d’autre ne l’a fait, c’est-à-dire une fois ACF désactivé. Les taxonomies déclarées dans ACF ne sont pas reprises. Pour external, le thème ou l’extension continue de l’enregistrer.

Retour arrière

rollback() (ACF doit être actif) retire la collection du schéma, supprime sa table et son état (Leiho_Collections_Store::drop()) et efface l’enregistrement : ACF sert de nouveau les valeurs d’origine. Les valeurs modifiées dans Leiho depuis l’import ne sont pas réécrites dans ACF ; elles sont perdues avec la table.

Compatibilité du front

Pour les champs importés, /export, /content et /types renvoient le même JSON qu’avant : mêmes noms et même ordre, objets média, références (cibles non publiées omises), lignes de répéteur, blocs avec _layout en premier, texte riche rendu, valeurs de select / checkbox sous forme de chaînes, dates Y-m-d, nombres (entiers en tant qu’entiers), booléens, null / "" / [] pour les valeurs vides. Différences connues, toutes signalées par l’aperçu ou l’import : les champs non repris disparaissent ; les valeurs que la validation de l’éditeur refuse (choix inconnu, texte au-delà de max_length, sous-champ obligatoire manquant…) restent vides ; les valeurs qu’elle réécrit (balises ou retours à la ligne dans un texte sur une ligne, HTML dangereux dans du texte riche) sont listées comme différences ; dans /types, les champs des groupes ayant des règles d’emplacement supplémentaires ne sont plus optionnels ; les nombres de plus de 6 décimales sont arrondis par DECIMAL(20,6). L’éditeur affiche un seul groupe (la collection) au lieu d’un par groupe ACF ; les chemins pointés (sections.2.cartes.1.titre) ne changent pas.

Un point pas clair sur cette page ? Dites-le-nous →