Intégration

Référencer un média depuis un module ou un bloc, attacher et détacher les références, sans dépendre du package.

Un module ou un bloc qui veut manipuler des médias n'importe jamais @lrvo/module-medias. Il importe les façades du core, que ce module remplit au boot quand il est installé et activé.

Règle absolue : ne jamais coder en dur l'URL d'un média, ni la stocker. Stocker son identifiant, et dériver l'URL au rendu. Un média peut être renommé, l'identifiant ne change jamais.

Les façades

ImportContenu
@lrvo/core/media-registryTéléversement et CRUD d'un média, attache et détache de références, construction de l'URL.
@lrvo/core/media-registry/block-editorNormalisation, enrichissement et synchronisation des blocs image.
@lrvo/core/media-registry/clientAccès au sélecteur de média côté navigateur.
@lrvo/core/media-registry/file-routeLe relais de la route publique de fichier, à monter dans le projet.

Voir Registre média pour la référence complète.

Quand le module est absent

Aucun appelant n'a besoin de se conditionner. Sans le module, les façades restent appelables et se comportent de façon neutre :

AppelSans le module
isMediaEnabled()false
getMediaById, getMediaByFilenamenull
getMediaByIds, listReferences[]
listMedia{ media: [], pagination: { page: 1, limit: 24, total: 0, totalPages: 0 } }
countReferences0
buildMediaUrlnull
uploadMedia, replaceMedia, updateMedia, deleteMedia{ success: false, error: 'media module not enabled' }
attachMedia, detachMedia, detachAllForSourceIdem
normalizeImageBlocks, enrichBlocksWithMediaLes blocs, inchangés
syncBlockImageReferences{ success: true, count: 0 }
isMediaPickerAvailable()false

C'est ce contrat qui permet à @lrvo/module-pages et @lrvo/module-posts de gérer des images sans déclarer la moindre dépendance vers ce module.

Référencer un média dans un champ

Le stockage est un simple identifiant. Le module consommateur l'accepte à l'entrée sous forme de chaîne ou d'objet portant un id, et le normalise avant écriture.

import { getMediaById, buildMediaUrl } from '@lrvo/core/media-registry';

const media = await getMediaById(post.thumbnail);
const url = buildMediaUrl(media);   // /lrvo/media/mon-image.jpg

Pour une liste, préférer getMediaByIds, qui fait une seule requête. L'ordre du résultat n'est pas garanti : réindexer sur l'identifiant plutôt que se fier à la position.

Une référence orpheline, dont le média a disparu, ne doit pas faire tomber le rendu. @lrvo/module-posts la renvoie sous la forme { id, filename: null }, à traiter comme une image manquante.

Téléverser depuis du code

Un module n'a pas besoin d'un formulaire ni d'une session pour alimenter la médiathèque. uploadMedia accepte un tampon binaire, avec le nom d'origine, et rend la fiche créée.

import { uploadMedia, attachMedia } from '@lrvo/core/media-registry';

const result = await uploadMedia({ file: buffer, filename: 'facture-2026-03.pdf' });
if (!result.success) return { error: result.error };

await attachMedia({
  mediaId: result.media.id,
  sourceType: '@lrvo/module-facturation:invoice',
  sourceId: invoice.id,
  field: 'document',
});

uploadedBy est optionnel : un dépôt fait par une tâche planifiée reste anonyme, puisqu'aucun humain n'est derrière. Les validations de format et de taille sont identiques à celles de l'interface, elles vivent dans la fonction.

La façade n'a un provider que dans le serveur Next, après le boot des modules. Un script exécuté hors de ce contexte obtiendrait le repli neutre du tableau ci-dessus. Voir Registre média.

La table de références

Chaque lien entre un contenu et un média est une ligne de lrvo_media_references, identifiée par le quadruplet (média, type de source, identifiant de source, champ).

Elle sert à une chose : empêcher la suppression d'un média encore utilisé. La clé étrangère est en restriction, et le module refuse la suppression en amont avec un message donnant le nombre de contenus concernés.

Attacher

import { attachMedia, detachAllForSource } from '@lrvo/core/media-registry';

await detachAllForSource({ sourceType: 'post', sourceId: String(post.id) });

await attachMedia({
  mediaId: post.thumbnail,
  sourceType: 'post',
  sourceId: String(post.id),
  field: 'thumbnail',
});

Le motif est toujours le même : tout détacher, puis rattacher ce qui reste. C'est plus simple et plus sûr qu'un calcul de différence, et l'insertion ignore les doublons.

sourceType et sourceId sont des chaînes libres, sans registre ni validation. Choisir une convention et s'y tenir : @lrvo/module-posts utilise post, @lrvo/module-pages utilise pages. Cette incohérence est historique, elle n'a pas d'effet fonctionnel puisque chaque module ne lit que ses propres lignes.

Détacher à la suppression

await detachAllForSource({ sourceType: 'post', sourceId: String(post.id) });
// puis seulement DELETE le contenu

Dans cet ordre. Supprimer le contenu d'abord laisserait des références pointant un contenu disparu, et ces références bloqueraient ensuite la suppression des médias concernés.

Blocs image du BlockEditor

Les images posées dans le BlockEditor suivent un chemin distinct des champs, parce qu'elles vivent à une profondeur arbitraire dans un arbre de blocs.

Trois fonctions couvrent le cycle, et s'appellent à trois moments différents.

FonctionMomentEffet
normalizeImageBlocks(blocks)Avant écritureRésout les anciennes URL en identifiants, vide les champs dupliqués, retire les clés temporaires.
syncBlockImageReferences({ sourceType, sourceId, blocks })Après écritureRecense les médias de l'arbre et réécrit les lignes de référence.
enrichBlocksWithMedia(blocks)À la lectureRattache le nom de fichier, le texte alternatif et la légende sur chaque bloc image.

La normalisation est stricte : le média est la seule source de vérité. Un bloc lié à un média ne conserve ni URL, ni texte alternatif, ni légende propres. Ces valeurs sont relues depuis la fiche du média à chaque rendu, ce qui garantit qu'un texte alternatif corrigé dans la médiathèque se propage partout.

Ordre impératif avec detachAllForSource

await detachAllForSource({ sourceType: 'post', sourceId: id });
await attachMedia({ /* un appel par média de chaque champ */ });
await syncBlockImageReferences({ sourceType: 'post', sourceId: id, blocks });

detachAllForSource supprime toutes les lignes de la source, y compris celles des blocs. Appeler syncBlockImageReferences avant lui effacerait donc le travail qu'il vient de faire. La synchronisation des blocs passe toujours en dernier.

Détection en profondeur

syncBlockImageReferences parcourt l'arbre de blocs et collecte toute clé mediaId, à n'importe quelle profondeur, y compris à l'intérieur des colonnes imbriquées. Les clés commençant par un souligné sont ignorées, ce sont des valeurs de travail.

Conséquence pour un bloc personnalisé : dès qu'un champ produit un objet portant mediaId, ses références sont suivies automatiquement. Rien n'est à déclarer.

C'est ce que fait ImageField des blocs personnalisés, qui émet { mediaId, src, alt, filename }. Voir Blocs personnalisés.

Sélecteur côté client

'use client';
import { getMediaPicker, isMediaPickerAvailable } from '@lrvo/core/media-registry/client';

const MediaPicker = getMediaPicker();

{isMediaPickerAvailable() && (
  <MediaPicker isOpen={open} onClose={close} onSelect={handleSelect} accept="image/*" />
)}
PropriétéRôle
isOpen, onCloseContrôle de l'ouverture.
onSelectReçoit le média choisi, ou un tableau de médias en mode multiple.
acceptFiltre initial, dérivé en catégorie de contenu. Pose aussi le filtre du téléversement.
multipleSélection multiple. Faux par défaut.
initialSelectedTableau de médias déjà retenus, affichés cochés à l'ouverture. Mode multiple.
titleTitre de la fenêtre.

Mode multiple

La sélection retient les objets média, pas seulement leurs identifiants : elle survit donc à un changement de page ou de filtre, dont le jeu de résultats ne contient plus les médias cochés auparavant.

onSelect rend le tableau dans l'ordre où la sélection a été constituée : l'ordre de clic pour un choix à la main, celui de la grille pour un lot téléversé. Un téléversement multiple insère dans l'ordre des fichiers, alors que la grille réaffiche les médias selon son propre tri ; le lot est donc réaligné sur l'affichage, pour que le résultat corresponde à ce qui vient d'apparaître coché.

initialSelected n'est lu qu'à l'ouverture de la fenêtre. Rouvrir le sélecteur repart donc de la sélection courante de l'appelant, ce qui rend le retour de onSelect exhaustif : il porte aussi bien les ajouts que ce qui a été décoché. L'appelant remplace sa valeur par ce tableau, il ne la complète pas.

En mode multiple, le champ de téléversement accepte plusieurs fichiers. Ils partent en une requête chacun, un échec unitaire n'interrompt pas le lot, et les médias créés rejoignent d'office la sélection.

Toujours conditionner l'affichage à isMediaPickerAvailable(), et prévoir un repli. Le BlockEditor du core masque son bouton « Choisir un média » et laisse l'insertion par URL disponible.

Notes

  • Deux fonctions portent le nom isMediaEnabled. Celle du core lit l'état du registre, celle du module lit la variable d'environnement. Depuis un module consommateur, c'est toujours celle du core qu'il faut appeler.
  • Les registres vivent sur globalThis via Symbol.for, pour survivre au double bundling et au rechargement à chaud. Voir Registres et singletons.
  • Supprimer un contenu sans appeler detachAllForSource laisse des références orphelines qui bloqueront la suppression des médias concernés, sans message explicite sur la cause.

Sur cette page