Blocs de plugin
Les plugins peuvent contribuer des blocs d'éditeur — paragraphes, embeds, primitives de mise en page, types de contenu complexes. L'API de bloc est la même que celle des blocs de thème — seul le
Les plugins peuvent contribuer des blocs d'éditeur — paragraphes, embeds, primitives de mise en page, types de contenu complexes. L'API de bloc est la même que celle des blocs de thème — seul le canal d'enregistrement diffère.
Blocs de plugin vs blocs de thème
| Bloc de plugin | Bloc de thème | |
|---|---|---|
| Enregistré via | pluginApi.registerBlock(...) |
ThemeManifest.blocks: [...] |
| Disponible quand | Le plugin est activé | Le thème est actif |
| Reset sur | Désactivation / ré-enregistrement de plugin | Changement de thème |
| Mieux pour | Blocs réutilisables entre thèmes (embeds, primitives) | Primitives de mise en page spécifiques au thème |
Le must-use flexweg-embeds contribue les blocs YouTube / Vimeo / Twitter / Spotify via l'enregistrement plugin — c'est pour ça que ces embeds fonctionnent dans chaque thème. Le thème magazine contribue son propre widget « Most read » comme bloc de thème — il dépend de la CSS spécifique à magazine.
Manifest de bloc
import { Image as IconImage } from "lucide-react";
import type { BlockManifest } from "@flexweg/cms-runtime";
const blockManifest: BlockManifest = {
id: "my-plugin/my-block", // <namespace>/<name>
titleKey: "blocks.myBlock.title", // clé i18n
namespace: "my-plugin", // namespace i18next
icon: IconImage, // composant Lucide-style
category: "media", // text | media | layout | embed | advanced
insert: (chain, ctx) => {
chain.focus().insertContent({
type: "myPluginMyBlock",
attrs: { src: "", alt: "" },
}).run();
},
extensions: [MyTiptapExtension], // l'extension Tiptap pour le bloc
isActive: (editor) => editor.isActive("myPluginMyBlock"),
inspector: ({ attrs, updateAttrs, editor }) => {
return <MyInspector attrs={attrs} onChange={updateAttrs} />;
},
};
// Enregistrement dans le manifest plugin :
register(api) {
api.registerBlock(blockManifest);
}
Champs
id — obligatoire
<namespace>/<name>. Le namespace doit matcher votre id de plugin. Le nom est en kebab-case.
titleKey — obligatoire
Clé i18n pour le label dans l'inserter. Résolue via useTranslation(namespace).t(titleKey).
namespace — optionnel
Le namespace i18next pour titleKey. Si omis, le namespace par défaut est utilisé. Mettez-le explicite pour des plugins qui ont leur propre bundle i18n.
icon — obligatoire
Composant Lucide-style (ou autre, du moment qu'il accepte les mêmes props standard). Affiché dans l'inserter à côté du titre.
category — obligatoire
Une des cinq : text, media, layout, embed, advanced. Drive le groupement dans l'inserter.
insert — obligatoire
Fonction appelée quand l'utilisateur clique sur le bloc dans l'inserter. Reçoit la chain Tiptap + un ctx avec helpers (notamment pickMedia() qui ouvre le media picker et retourne une promesse).
Typique : chain.focus().insertContent({ type, attrs }).run().
extensions — optionnel mais nécessaire pour les blocs custom
Array d'extensions Tiptap (Node / Mark / Extension). C'est ici que vous définissez le schéma du bloc.
import { Node, mergeAttributes } from "@tiptap/core";
export const MyBlock = Node.create({
name: "myPluginMyBlock",
group: "block",
atom: true, // bloc atomique (pas de contenu enfant)
addAttributes() {
return {
src: { default: "" },
alt: { default: "" },
};
},
parseHTML() {
return [{
tag: 'div[data-cms-block="my-plugin/my-block"]',
getAttrs: (el) => {
const enc = el.getAttribute("data-attrs") || "";
const attrs = JSON.parse(atob(enc));
return attrs;
},
}];
},
renderHTML({ HTMLAttributes }) {
const attrs = JSON.stringify({ src: HTMLAttributes.src, alt: HTMLAttributes.alt });
const encoded = btoa(attrs);
return ['div', mergeAttributes({
'data-cms-block': 'my-plugin/my-block',
'data-attrs': encoded,
})];
},
});
Le round-trip markdown utilise ce parseHTML / renderHTML — donc le markdown contient des marqueurs <div data-cms-block="..." data-attrs="<base64>"></div> qui round-trip proprement.
isActive — optionnel
Prédicat qui retourne true quand le curseur est dans ce bloc. Utilisé par l'inspecteur pour swap automatiquement vers son onglet Bloc.
isActive: (editor) => editor.isActive("myPluginMyBlock"),
inspector — optionnel
Composant React rendu dans l'onglet Bloc de l'inspecteur quand isActive retourne true. Reçoit { attrs, updateAttrs, editor }.
function MyInspector({ attrs, updateAttrs }) {
return (
<div>
<label>Source URL</label>
<input value={attrs.src} onChange={(e) => updateAttrs({ src: e.target.value })} />
<label>Alt</label>
<input value={attrs.alt} onChange={(e) => updateAttrs({ alt: e.target.value })} />
</div>
);
}
updateAttrs(partial) patche les attrs dans la mise à jour Tiptap.
Rendu côté serveur (publication)
L'inserter / l'inspecteur de l'éditeur c'est une chose. Le rendu HTML public au moment de la publication en est une autre. Pour qu'un bloc rende du HTML utile, ajoutez un handler post.html.body qui remplace le marqueur par le HTML rendu :
register(api) {
api.registerBlock(blockManifest);
api.addFilter<string>("post.html.body", (html, post, ctx) => {
return html.replace(
/<div\s+([^>]*data-cms-block="my-plugin\/my-block"[^>]*)>\s*<\/div>/g,
(full, raw) => {
const m = raw.match(/data-attrs=(?:"([^"]*)"|'([^']*)'|([^\s>]+))/);
const enc = m ? (m[1] ?? m[2] ?? m[3] ?? "") : "";
const attrs = decodeAttrs(enc, DEFAULT_ATTRS);
return renderMyBlockHtml(attrs);
}
);
});
}
decodeAttrs(encoded, defaults) = JSON.parse(atob(encoded)) avec fallback aux defaults.
renderMyBlockHtml(attrs) retourne le HTML que vous voulez voir sur le site public. C'est ici que vous appliquez votre logique de rendu.
Markdown round-trip
Le marqueur <div data-cms-block="..." data-attrs="<base64>"></div> est portable entre :
- L'éditeur Tiptap (qui le parse via
parseHTMLpour afficher l'aperçu) - Le markdown stocké en base
- Le filtre
post.html.body(qui le remplace par le HTML final)
Donc votre bloc survit aux changements de markdown brut, aux changements de thème (si le filtre reste actif), aux export / import.