Écrire un plugin de bloc
Un bloc est un paquet : son schéma, sa vue, sa projection Markdown dans les deux sens et son rendu HTML statique — une seule déclaration. Cette totalité est le sujet de la page : un bloc ne doit pas pouvoir s'afficher sans dire comment il survit à un export.
Cette page est le guide. Les tables de champs générées depuis le code sont dans API des plugins, et cinq blocs livrés s'écrivent déjà exactement comme ceci : @nbe/blocks-callout, @nbe/blocks-code, @nbe/blocks-table, @nbe/blocks-toc, @nbe/blocks-mdx.
Pourquoi tout déclarer d'un coup
L'échec qu'on évite est documenté, deux fois. Dans Lexical, un nœud s'enregistre à trois endroits non synchronisés — exportJSON etexportDOM sur la classe, les transformateurs Markdown au point d'appel et pas sur la classe du tout : un nœud peut donc s'afficher parfaitement et disparaître d'un export Markdown sans que rien ne puisse s'en apercevoir. Tiptap a le même trou par l'autre bout : le Markdown bidirectionnel est arrivé des années après le gel de l'API d'extension, donc toute extension écrite avant n'a aucun gestionnaire.
Ici le type refuse le bloc incomplet. Et quand un format ne peutvraiment pas représenter un bloc, on le déclare aveclossy() — qui émet quand même quelque chose et dit ce qui a été perdu — plutôt que d'omettre la fonction, parce que la perte silencieuse à l'export est la seule défaillance que ce projet existe pour empêcher.
Deux points d'entrée, et la raison
Un paquet de bloc a deux entrées : la principale ne dépend que de@nbe/core et porte le schéma et les projections ; l'entrée/dom ajoute le comportement d'édition. C'est ce qui permet à@nbe/markdown et @nbe/static-renderer de consommer le même bloc sans jamais importer la vue — un rendu serveur n'a pas à embarquer un éditeur. La séparation est vérifiée en intégration continue.
{
"name": "@nbe/blocks-spoiler",
"exports": {
".": { "types": "./src/index.ts", "default": "./src/index.ts" },
"./dom": { "types": "./src/dom.ts", "default": "./src/dom.ts" }
},
"dependencies": { "@nbe/core": "workspace:*" },
"peerDependencies": { "@nbe/dom": "workspace:*" },
"peerDependenciesMeta": { "@nbe/dom": { "optional": true } }
}1. Le schéma et les projections
schema est déclaratif et sans comportement, exprès : le rendu statique et le portage Swift le consomment sans exécuter de JavaScript, ce qu'ils ne pourraient pas faire si des fonctions de rendu vivaient dedans.
// @nbe/blocks-spoiler — l'entrée principale : aucune dépendance au DOM
import type { BlockPlugin } from '@nbe/core';
import { PLUGIN_API_VERSION, plainText } from '@nbe/core';
const SPOILER = /^\|\|(.*)\|\|$/;
export const spoilerPlugin: BlockPlugin = {
apiVersion: PLUGIN_API_VERSION,
// déclaratif, sérialisable en JSON, sans comportement
schema: {
type: 'spoiler',
version: 1,
inline: true, // le bloc porte du texte enrichi
defaultProps: { revealed: false },
placeholder: 'Texte masqué…',
},
// les deux sens sont obligatoires : n'écrire que la sérialisation,
// c'est livrer un bloc qui se dégrade au réimport
markdown: {
toMarkdown: (block, ctx) => [`${' '.repeat(ctx.depth)}||${plainText(block.text)}||`],
fromMarkdown: [
{
match: SPOILER,
parse(lines, start) {
const m = SPOILER.exec(lines[start] ?? '');
if (!m) return null;
return {
block: {
id: '', type: 'spoiler', version: 1,
props: {}, text: [{ text: m[1] ?? '' }], children: [], parentId: null,
},
consumed: 1,
};
},
},
],
},
// rendu statique : une chaîne, donc pas de navigateur requis
html: (block) => `<span class="nbe-t-spoiler">${plainText(block.text)}</span>`,
};fromMarkdown est une liste de règles consultées dans l'ordre de précédence, première correspondance gagnante ; match est testé sur la première ligne du bloc candidat et parse rend le bloc et le nombre de lignes consommées. ctx.child()rend un enfant, pour qu'un conteneur ne réimplémente pas la descente, etctx.page donne les blocs de premier niveau à un bloc dont le contenu est le document — un sommaire, une liste de rétroliens.ctx.page est absent quand un bloc est rendu seul (un morceau de presse-papiers, un aperçu), donc une projection qui le lit doit se dégrader plutôt que supposer.
2. La vue
L'entrée /dom reprend la déclaration et lui ajouteview. Chaque contribution remplace un aiguillage fermé qui existait avant elle : render une branche derender.ts, slash une entrée d'un tableau,keys une branche du keymap.
// @nbe/blocks-spoiler/dom — la seule entrée qui dépend de @nbe/dom
import type { DomBlockPlugin } from '@nbe/dom';
import { spoilerPlugin } from './index';
export const spoiler: DomBlockPlugin = {
...spoilerPlugin,
view: {
// ajuste l'élément produit par le rendu standard, au lieu de le remplacer
decorate(ctx, block) {
ctx.root.classList.toggle('is-revealed', block.props['revealed'] === true);
},
toolbar: (ctx) => [
{
icon: ctx.block.props['revealed'] ? 'eye-off' : 'eye',
title: 'Révéler',
active: ctx.block.props['revealed'] === true,
onClick: (c) => c.setProps({ revealed: !c.block.props['revealed'] }),
},
],
slash: { label: 'Spoiler', keywords: ['spoiler', 'masqué'], icon: 'eye-off' },
turnInto: { label: 'Spoiler', icon: 'eye-off' },
// injecté une fois par document, à l'enregistrement
styles: `.nbe-t-spoiler { background: currentColor; border-radius: 3px; }
.nbe-t-spoiler.is-revealed { background: none; }`,
},
};| Contribution | Ce qu'elle fait |
|---|---|
chrome | Préfixe la ligne standard : une puce, une case à cocher, l'icône d'un callout. C'est ce que veut la plupart des blocs. |
render | Remplace entièrement le rendu. Pour ce qui n'est pas une ligne de texte : un tableau, une image, une vue de base de données. |
decorate | Ajuste l'élément déjà construit — une classe, un style en ligne. Ni préfixe, ni remplacement. |
actions | Entrées du menu de la poignée ⋮⋮. |
toolbar | Boutons de la barre flottante du bloc. toolbarPlacement la met inside ou above. |
slash | Apparition dans le menu « / ». Un tableau quand un même type mérite plusieurs préréglages. |
turnInto | Apparition dans « Transformer en ». |
keys | Gestionnaires par touche, consultés pour le bloc du caret et ses ancêtres : un tableau possède Tab sans que la cellule sache qu'elle est dans un tableau. |
features | Comportement à l'échelle de l'éditeur : une chrome hors de la boîte du bloc, un geste de pointeur, une sélection à soi. Même contrat attach(view) => unbind que les fonctionnalités du cœur. |
styles | Le CSS du bloc, injecté une fois par document et par type. |
Garder chrome et render séparés est ce qui empêche tout bloc en forme de texte de réimplémenter la descente ligne-puis-feuille — c'est-à-dire la manière dont une API de plugins accumule du copier-coller.
La précédence est nommée, jamais numérique
C'est la décision la mieux étayée du design. Un module isolé ne peut pas savoir quels nombres les autres ont choisis : ce sont des points sur un axe indifférencié, autrement dit un z-index. Lexical a livré cinq seaux numériques, a découvert qu'on ne pouvait pas passer devant un auditeur de son propre niveau, et a dû ajouter des constantes négatives qui retombent dans le même seau par masque de bits. Tiptap a livré son ordre à l'envers pendant longtemps sans que personne ne le remarque, parce que l'ordre obtenu n'est pas observable.
Ici : cinq catégories nommées — highest,high, default, low,lowest — et, à catégorie égale, l'ordre d'enregistrement décide, ce qui est observable dans le tableau que l'hôte a écrit. Et la précédence se pose par contribution, pas par plugin : le priority unique de Tiptap gouverne à la fois les keymaps, les règles de saisie, les règles de collage et le rendu, donc un bloc qui a besoin de gagner le clavier tout en perdant la règle de saisie ne peut pas le dire.
import { at } from '@nbe/core';
view: {
keys: {
// gagner le clavier sans rien gagner d'autre
Tab: at('high', ({ view, block }) => moveToNextCell(view, block)),
Enter: ({ event }) => { event.preventDefault(); return true; },
},
}Réparer le document, pas le valider
normalize est le point d'extension qu'un tableau a forcé à exister, et ce dont aucun bloc à structure interne ne peut se passer : une ligne qui a perdu une cellule, un conteneur resté vide, une fusion qui pointe vers une ligne disparue. Il tourne sur le modèle, à chaque transaction qui a changé la structure, donc un import sans interface est normalisé exactement comme une frappe.
Écrivez-le comme une réparation : lisez le document, émettez par tx les opérations qui le rendent légal, renvoyez si vous en avez émis. Il doit être idempotent — il verra sa propre sortie à la transaction suivante.
normalize(doc, tx) {
let changed = false;
for (const row of rowsMissingCells(doc)) {
tx.op(padRow(row));
changed = true;
}
return changed; // « ai-je réparé quelque chose ? »
}3. Monter le plugin
Trois endroits, parce que trois couches consomment la déclaration, et le registre est par éditeur, jamais global au module. Deux éditeurs sur une même page est la deuxième démo qu'on écrit ; un registre global rendrait impossible d'y avoir des jeux de blocs différents — c'est la panne au long cours de ProseMirror (« Adding different instances of a keyed plugin ») dès que deux copies d'un paquet se chargent.
// 1. le modèle — pour l'autoformat, la normalisation et le schéma
import { Editor } from '@nbe/core';
import { spoilerPlugin } from '@nbe/blocks-spoiler';
const editor = new Editor({ doc, plugins: [spoilerPlugin] });
// 2. la vue — c'est ici que la moitié « dom » arrive
import { EditorView } from '@nbe/dom';
import { spoiler } from '@nbe/blocks-spoiler/dom';
new EditorView(el, editor, { blocks: [spoiler] });
// 3. les projections — le registre se passe par appel, jamais par module
import { blocksToMarkdown } from '@nbe/markdown';
import { renderToHTML } from '@nbe/static-renderer';
blocksToMarkdown(children, { plugins: editor.plugins });
renderToHTML(json, { plugins: editor.plugins });apiVersion est vérifié à l'enregistrement : un plugin écrit pour un contrat futur échoue bruyamment, par son nom, au lieu de se comporter mal. C'est aussi ce qui laisserait un plugin v1 et un v2 cohabiter pendant une migration, plutôt que de casser tout le monde le même jour.
Ce qui n'est délibérément pas fourni
- Pas d'équivalent d'
addProseMirrorPlugins.Tiptap en a besoin parce que ProseMirror possède la vue. Ici la vue est la nôtre, et une trappe donnant un accès DOM brut deviendrait aussitôt l'API que tout le monde utilise — le moteur de rendu ne pourrait plus jamais changer. Le besoin réel derrière cette trappe, c'estfeatures: le même contrat que les fonctionnalités du cœur, avec unGestureRecognizerpour qu'une pression garde exactement un propriétaire. - Pas de bus d'événements ni de priorités numériques tant que rien ne l'impose.
- Pas d'histoire de distribution — registre, versions, bac à sable : cela n'a de sens qu'avec un second auteur.
Cinq plugins à lire
blocks-callout— le plus petit qui soit complet : chrome, actions, plusieurs entrées « / » pour un seul type, et un aller-retour Markdown sur la convention d'Obsidian.blocks-toc— un bloc dont le contenu est le document : il a falluProjectionContext.pageet unefeatureplutôt qu'un crochet de rendu, parce que taper dans un titre salit le sommaire.blocks-code—styles, et une coloration peinte via la CSS Custom Highlight API plutôt qu'injectée en balises dans la feuille éditable.blocks-table— trois types de blocs, deuxfeatures, une géométrie, des cellules fusionnées et une sélection qui n'est ni une plage de texte ni un ensemble de blocs.blocks-mdx— le bloc qu'on n'interprète pas : un composant JSX traverse l'éditeur, l'export et le retour octet pour octet, et n'est jamais évalué.
@nbe/blocks-mermaid est l'autre forme du même problème : pas un bloc mais une feature, parce que ```mermaid est déjà un bloc de code dans tous les outils Markdown existants et qu'un nouveau type se serait battu avec lui pour la même clôture. La bibliothèque est une dépendance de pair optionnelle, importée au premier diagramme.
La recherche et le plan qui ont mené à ce contrat sont dans le dépôt :docs/research/plugin-architecture.md etdocs/design/plugin-refactor-plan.md.