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

TS
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.

TS
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.

TS
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 }.

TSX
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 :

TS
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 parseHTML pour 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.