Blocs personnalisés
Ajouter un type de bloc à l'éditeur et le rendre côté public, par convention de dossier ou par API.
Un bloc personnalisé apparaît dans le menu de commandes de l'éditeur, s'édite avec ses propres champs, et se rend sur le site public avec sa propre JSX.
Deux voies existent : la convention de dossier, pour le projet hôte, et l'API programmatique, pour un module ou un besoin dynamique.
Voie 1 : convention de dossier
C'est la voie recommandée pour un projet qui utilise le template @lrvo/start.
Créer un dossier par bloc dans components/lrvo/blocks/<id>/, avec trois fichiers.
components/lrvo/blocks/hero/
├── fields.js métadonnées et champs
├── template.js rendu dans l'éditeur admin
└── render.js rendu publicfields.js
export const meta = {
id: 'hero',
label: 'Bannière',
icon: 'Image01Icon',
keywords: ['hero', 'banniere', 'entete'],
fields: [
{ key: 'title', type: 'text', label: 'Titre' },
{ key: 'subtitle', type: 'text', label: 'Sous-titre' },
{ key: 'image', type: 'image', label: 'Image de fond' },
{ key: 'dark', type: 'boolean', label: 'Fond sombre' },
],
};Le type de bloc devient custom:hero. Le namespace custom: est réservé aux blocs du projet hôte ; les blocs intégrés n'utilisent pas de :.
template.js
Rendu dans l'éditeur. Les champs viennent de @lrvo/core/blocks-registry/fields-ui.
'use client';
import { TextField, ImageField, BooleanField, LrvoBlockShell }
from '@lrvo/core/blocks-registry/fields-ui';
export default function HeroTemplate({ block, onChange, disabled }) {
const patch = (key) => (value) => onChange({ [key]: value });
return (
<LrvoBlockShell>
<TextField label="Titre" value={block.title} onChange={patch('title')} disabled={disabled} />
<TextField label="Sous-titre" value={block.subtitle} onChange={patch('subtitle')} disabled={disabled} />
<ImageField label="Image" value={block.image} onChange={patch('image')} disabled={disabled} />
<BooleanField label="Fond sombre" value={block.dark} onChange={patch('dark')} disabled={disabled} />
</LrvoBlockShell>
);
}Champs disponibles : TextField, TextareaField, NumberField, SelectField, BooleanField, ColorField, IconField, ImageField.
ImageField passe par le sélecteur de @lrvo/module-medias et se masque quand le module est absent. Voir Intégrer les médias.
render.js
Rendu public. Composant serveur par défaut, sauf s'il a besoin d'interactivité.
export default function HeroRender({ block }) {
return (
<section
id={block.anchor || undefined}
className={block.dark ? 'bg-fg text-surface' : 'bg-surface text-fg'}
>
<h1>{block.title}</h1>
<p>{block.subtitle}</p>
</section>
);
}Poser l'ancre soi-même. Le helper anchorProps du core est serveur strict, et un render.js peut porter 'use client'. Écrire id={block.anchor || undefined} en inline couvre les deux cas.
Synchroniser
npx lrvo-modules syncLa commande détecte le dossier et génère deux manifestes :
| Manifeste | Effet |
|---|---|
app/.lrvo/blocks.client.js | Appelle registerBlock avec le préfixe custom:, pour l'éditeur. |
app/.lrvo/blocks.server.js | Appelle registerCustomBlock, pour le rendu public. |
La synchronisation tourne déjà dans les scripts postinstall, dev et build.
Voie 2 : API programmatique
Pour un module, ou pour un bloc dont la définition dépend de l'exécution.
import { registerBlock, newBlockId } from '@lrvo/core/blocks-registry/client';
registerBlock({
type: 'kpi',
label: 'KPI',
icon: <ChartIcon width={18} height={18} />,
keywords: ['kpi', 'metric', 'stat'],
isText: false,
create: () => ({ id: newBlockId(), type: 'kpi', value: 0 }),
Component: ({ block, onChange, disabled }) => (
<input
type="number"
value={block.value}
disabled={disabled}
onChange={(e) => onChange({ value: Number(e.target.value) })}
/>
),
});Le type apparaît dans le menu de commandes de tout BlockEditor rendu après l'enregistrement.
Côté serveur, enregistrer le rendu public :
import { registerCustomBlock } from '@lrvo/core/blocks-registry';
import KpiRender from './KpiRender.js';
registerCustomBlock({ type: 'kpi', RenderComponent: KpiRender, meta });Bloc texte
Pour un bloc qui édite du texte inline plutôt que des champs :
registerBlock({
type: 'callout',
label: 'Encadré',
isText: true,
textTag: 'p',
textClassName: 'rounded-xl border border-blue-200 bg-blue-50 p-4',
create: () => ({ id: newBlockId(), type: 'callout', content: [] }),
renderPrefix: ({ block }) => <InformationCircleIcon width={18} height={18} />,
});Le content initial doit être [], un InlineNode[] vide, jamais [{ type: 'text', text: '' }].
renderPrefix({ block, onPatch, index, numberedIndex, disabled }) sert aux puces, numéros et cases à cocher.
Classes dynamiques et sélecteur de taille
Une définition texte peut remplacer textClassName par getTextClassName(block) pour des classes qui dépendent du bloc. Couplé à sizes et defaultSize, cela alimente automatiquement le segmented control « Taille » du menu d'actions, sans logique propre au type.
{
sizes: [
{ value: 'sm', label: 'S', title: 'Petit' },
{ value: 'md', label: 'M', title: 'Moyen' },
{ value: 'lg', label: 'L', title: 'Grand' },
],
defaultSize: 'md',
getTextClassName: (block) => ({ sm: 'text-sm', md: 'text-base', lg: 'text-lg' }[block.size || 'md']),
}C'est exactement ainsi que fonctionne la taille du paragraphe intégré.
Rendre dans un <Content> maison
Un <Content> personnalisé doit dispatcher les blocs personnalisés lui-même.
import { CustomBlockRender, isCustomBlockType, renderInline, anchorProps }
from '@lrvo/core/blocks-registry/render';
function Content({ blocks }) {
return blocks.map((block) => {
if (isCustomBlockType(block.type)) {
return <CustomBlockRender key={block.id} block={block} />;
}
// rendu des blocs intégrés
});
}Blocs dans les colonnes
Un bloc personnalisé peut vivre dans une colonne. Tout code serveur qui parcourt l'arbre (collecte d'images, normalisation média) doit utiliser flattenBlocks et mapBlocksDeep de @lrvo/core/blocks-registry/walk, sinon les blocs imbriqués sont ignorés.
Type non enregistré
Si la définition d'un bloc disparaît, parce qu'un module a été désactivé ou qu'un dossier a été supprimé, le bloc reste un bloc à part entière dans l'éditeur : poignée de glissement, lignes d'insertion, duplication et suppression restent disponibles. Seule la zone de contenu est remplacée par un encart d'avertissement avec un bouton Supprimer.
Le contenu n'est donc jamais perdu par accident, mais il n'est plus éditable tant que la définition n'est pas rétablie.
Pièges
Ne pas importer de code serveur dans template.js. Il finit dans le bundle navigateur.
registerBlock doit tourner avant le rendu de l'éditeur. Passer par le manifeste généré, ou par le client.js du module, tous deux exécutés au boot.
Le render.js peut être client. Le registre serveur accepte donc un composant sous forme de fonction ou d'objet : un module client importé depuis un manifeste serveur se résout en objet, pas en fonction.
Le format des données est du JSON stocké en base. Renommer un champ après publication casse les contenus existants. Prévoir une valeur de repli, ou une migration.