Le registre et l’éditeur
Comment chaque type de bloc trouve son éditeur et son rendu : la définition d’un bloc, un registre que le compilateur oblige à être complet, le cast de variance qui le parcourt, un éditeur contrôlé, les blocs permis par hôte, le sélecteur de fichiers et son dossier de départ, et les aperçus.
Précédemment. Un contenu est une liste de blocs décrite par un contrat partagé. Le texte riche y est assaini par une liste blanche, à l’entrée comme à la lecture, et rendu en HTML par une seule fonction.
Les parcours du chapitre#
Un parcours traverse ce chapitre : l’administrateur qui compose une page dans le tableau de bord, choisit des fichiers, regarde les aperçus et enregistre.
Acteur : Administrateur
- 1
Il choisit une image dans le sélecteur ; le chemin choisi est traduit en identifiant stable
media.resolveByPathsmembreLit : MediaAsset, MemberGroup, MemberGroupMembership
Le chemin dans le code
Chaque ligne est un appel, suivi de l'endroit où le lire (à ouvrir dans l'éditeur, par exemple avec la recherche de fichier).
media.resolveByPathspackages/backend/src/modules/media/router.ts:135ctx.prisma.mediaAsset.findManypackages/backend/src/modules/media/router.ts:148MediaAsset lueassertCanReadPathspackages/backend/src/modules/memberGroups/assertCanReadPaths.service.ts:14isAdminByGrouppackages/backend/src/modules/memberGroups/isAdminByGroup.service.ts:8prisma.memberGroupMembership.findFirstpackages/backend/src/modules/memberGroups/isAdminByGroup.service.ts:12MemberGroup lueMemberGroupMembership lue
resolveGroupAccessForUserpackages/backend/src/modules/memberGroups/resolveGroupAccessForUser.service.ts:15prisma.memberGroup.findUniquepackages/backend/src/modules/memberGroups/resolveGroupAccessForUser.service.ts:35MemberGroup lueprisma.memberGroupMembership.findUniquepackages/backend/src/modules/memberGroups/resolveGroupAccessForUser.service.ts:27MemberGroupMembership lue
- 2
L’aperçu du bloc résout ses médias, sur la route privée
media.resolveByIdsmembreLit : MediaAsset, MemberGroup, MemberGroupMembership
Le chemin dans le code
Chaque ligne est un appel, suivi de l'endroit où le lire (à ouvrir dans l'éditeur, par exemple avec la recherche de fichier).
media.resolveByIdspackages/backend/src/modules/media/router.ts:186ctx.prisma.mediaAsset.findManypackages/backend/src/modules/media/router.ts:197MediaAsset luecanReadStorageKeypackages/backend/src/modules/media/services/canReadStorageKey.service.ts:18isAdminByGrouppackages/backend/src/modules/memberGroups/isAdminByGroup.service.ts:8prisma.memberGroupMembership.findFirstpackages/backend/src/modules/memberGroups/isAdminByGroup.service.ts:12MemberGroup lueMemberGroupMembership lue
resolveGroupAccessForUserpackages/backend/src/modules/memberGroups/resolveGroupAccessForUser.service.ts:15prisma.memberGroup.findUniquepackages/backend/src/modules/memberGroups/resolveGroupAccessForUser.service.ts:35MemberGroup lueprisma.memberGroupMembership.findUniquepackages/backend/src/modules/memberGroups/resolveGroupAccessForUser.service.ts:27MemberGroupMembership lue
- 3
Il enregistre le contenu tenu par le formulaire
sitePage.saveadmin- PageMediaReference créée
- PageMediaReference supprimée
- SitePage créée : content, slug, title
- SitePage modifiée : content, slug, title
Lit : MediaAsset, PageMediaReference
Le chemin dans le code
Chaque ligne est un appel, suivi de l'endroit où le lire (à ouvrir dans l'éditeur, par exemple avec la recherche de fichier).
sitePage.savepackages/backend/src/modules/sitePages/router.ts:45syncPageMediaReferencespackages/backend/src/modules/media/services/syncPageMediaReferences.service.ts:47tx.pageMediaReference.createManypackages/backend/src/modules/media/services/syncPageMediaReferences.service.ts:130PageMediaReference crééetx.pageMediaReference.deleteManypackages/backend/src/modules/media/services/syncPageMediaReferences.service.ts:81PageMediaReference suppriméetx.pageMediaReference.deleteManypackages/backend/src/modules/media/services/syncPageMediaReferences.service.ts:122PageMediaReference supprimée
tx.sitePage.upsertpackages/backend/src/modules/sitePages/router.ts:57SitePage crééeSitePage modifiée
Ce qu’il faut pour un bloc#
Pour qu’un type de bloc existe vraiment, il faut quatre choses : un schéma dans le contrat (chapitre 1), un éditeur dans le tableau de bord, un rendu sur le site, et une entrée dans le menu « + » qui l’ajoute. Les trois dernières sont réunies dans une définition :
L’interface est générique : TBlock est une variante précise de l’union des blocs. Une définition du bloc média-texte est un BlockDefinition<MediaTextBlockV1>, et son éditeur ne peut recevoir qu’un MediaTextBlockV1. kind est typé TBlock["type"] : il est impossible d’écrire kind: "tiptap" dans la définition du média-texte.
Voici une définition complète :
Editor est un composant client, utilisé dans le tableau de bord. View est un composant serveur : son fichier s’appelle view.server.tsx, et il ne s’exécute jamais dans le navigateur (chapitre 4). defaultData fabrique le bloc vide que le menu « + » insère ; l’identifiant lui est fourni par le constructeur.
Un registre que le compilateur vérifie#
Les définitions sont rassemblées dans un registre. Son type est la partie intéressante :
C’est un type indexé (mapped type). Pour chaque valeur K de PageBlockKindV1 — "tiptap", "image-gallery"… —, il exige une propriété K dont la valeur est une définition de la variante dont type vaut K (Extract la retrouve dans l’union).
Deux erreurs deviennent impossibles :
Erreur | Ce que dit le compilateur |
|---|---|
Ajouter un bloc au contrat sans définition | La propriété manque dans BLOCK_REGISTRY : le type n’est pas satisfait. |
Ranger une définition sous la mauvaise clé | Sous "tiptap", une définition du média-texte n’est pas une BlockDefinition<TipTapBlockV1>. |
Showing entries1to2entries
Un bloc ne peut donc pas exister dans les données sans éditeur ni rendu. La lecture passe par une fonction qui conserve ce typage :
Appelée avec une valeur connue (getBlockDefinition("tiptap")), elle rend exactement la définition du bloc de texte.
Parcourir des blocs de types différents#
Le constructeur, lui, parcourt une liste de blocs de types mélangés. block.type n’y est pas une valeur connue, mais l’union PageBlockKindV1. getBlockDefinition(block.type) rend alors l’union des six définitions, et son Editor l’union des six composants. TypeScript refuse de rendre cette union avec un block quelconque :
Pourquoi ce refus ? Un éditeur reçoit onChange: (next: TBlock) => void. Une fonction qui attend un bloc de texte ne peut pas être utilisée là où l’on pourrait lui passer une galerie : en position de paramètre, un type plus précis n’est pas substituable à un type plus large. C’est la contravariance des paramètres de fonction. Le compilateur ne peut pas suivre le lien entre block.type et la définition choisie ; il voit seulement qu’une des six pourrait recevoir le mauvais bloc.
Le développeur, lui, sait que ce n’est jamais le cas : la définition est choisie par block.type, elle accepte donc exactement ce bloc. Le cast exprime ce savoir. Il est écrit à deux endroits seulement (BlockShell pour l’édition, PageRenderer pour le rendu), commenté, et ne peut rien masquer d’autre.
Un cast acceptable est local, justifié par un invariant que le compilateur ne voit pas, et placé là où cet invariant est garanti. Un as répandu dans chaque composant de bloc serait l’inverse : chaque copie pourrait se tromper.
Un éditeur contrôlé#
PageBuilder affiche les blocs les uns sous les autres, chacun dans un cadre commun (BlockShell : poignée de déplacement, icône, bouton de suppression), puis le menu « + ». Il ne garde pas le contenu dans son propre état : il le reçoit (value) et signale chaque modification (onChange). C’est un composant contrôlé, comme un <input value onChange>.
Chaque action fabrique un nouveau contenu à partir du précédent, sans le modifier :
Les quatre actions du constructeur
Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.
// ⚠ Fichier introuvable : apps/web/src/features/page-builder/PageBuilder.tsx// Il a été déplacé ou supprimé depuis la rédaction de ce chapitre.Le formulaire qui héberge le constructeur reste le seul détenteur du contenu. Il peut le comparer à ce qui est enregistré (« modifications non enregistrées »), l’envoyer avec ses autres champs, ou l’abandonner. Un constructeur qui garderait sa propre copie obligerait à synchroniser deux états.
Chaque bloc est rendu avec key={block.id}. Quand on déplace un bloc, React reconnaît chacun par son identifiant et déplace son éditeur avec lui — curseur et sélection compris. Avec l’indice comme clé, l’éditeur resterait à sa place et afficherait le contenu du voisin.
Les blocs permis par hôte#
Le même constructeur sert à plusieurs endroits, qui n’acceptent pas tous les mêmes blocs. L’hôte peut le restreindre :
Hôte | Blocs permis | Pourquoi |
|---|---|---|
Pages, actualités, fiches | Tous | Un contenu éditorial complet. |
Présentation courte (discipline, stage, événement) | tiptap | Elle s’affiche en carte sur l’accueil ou l’agenda : du texte seulement. |
Présentation d’instructeur | float-text | Un portrait que le texte enrobe ; sans image, le bloc rend du texte simple. |
Showing entries1to3entries
La liste est une liste d’autorisation : un bloc ajouté au registre plus tard n’apparaît pas dans un hôte restreint tant qu’on ne l’y a pas inscrit. Elle ne porte que sur l’ajout : un bloc déjà présent reste affiché et modifiable, pour ne pas faire disparaître du contenu parce qu’un réglage a changé.
Choisir un fichier#
Les éditeurs de blocs ouvrent le sélecteur de fichiers de la bibliothèque — le Finder de la partie précédente, en mode sélection. Ils ont besoin de deux valeurs que l’hôte connaît, partagées par un contexte React plutôt que passées de bloc en bloc :
appRoot est la racine de l’application (AKFC), envoyée avec chaque requête de fichiers : le serveur la compare à la sienne (sameAppRoot). mediaRoot est le dossier où s’ouvre le sélecteur. Pour un administrateur, c’est la racine : toute la bibliothèque. Pour un instructeur qui rédige sa présentation, c’est son espace personnel, le seul dont il puisse publier les fichiers (chapitre 6). Les deux valeurs étaient confondues : le sélecteur d’un instructeur s’ouvrait sur la racine, que le serveur refusait de lui lister.
Le sélecteur rend des chemins ; un bloc enregistre des identifiants. La traduction est faite par le serveur, au moment du choix :
Du chemin choisi à l’identifiant enregistré
Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.
// ⚠ Fichier introuvable : apps/web/src/features/page-builder/blocks/tiptap/builder-tiptap-editor.tsx// Il a été déplacé ou supprimé depuis la rédaction de ce chapitre.Le sélecteur ne montre que les fichiers ; il ne garantit pas qu’ils sont validés. Un fichier en attente peut être choisi, mais l’enregistrement le refusera (chapitre 5) : on ne publie pas un fichier qu’un administrateur n’a pas validé.
L’aperçu, sous chaque éditeur#
Sous chaque éditeur, un aperçu montre le bloc tel qu’il sera publié. Il ne peut pas réutiliser View : View est un composant serveur, qui attend des médias déjà résolus par le serveur. L’aperçu les résout donc lui-même, par une procédure :
media.resolveByIds rend des URLs de la route privée : l’aperçu est vu par un utilisateur connecté, et un fichier n’a pas à être placé pour qu’il le voie. Elle ne résout que les fichiers validés, hors corbeille ; pour un membre qui n’est pas administrateur, seulement ceux qu’il peut lire. Un fichier repassé en attente après avoir été choisi apparaît donc comme « introuvable » dans l’aperçu, avec un message qui dit pourquoi, au lieu d’un vide.
Pour le reste, l’aperçu reprend le rendu public : le même richTextHtml pour le texte (chapitre 2), les mêmes classes CSS (akfc-prose, akfc-block-columns), donc les mêmes réglages de style. Et la même alternance des blocs média-texte, calculée de la même façon dans PageBuilder et dans PageRenderer.
Un hôte complet ressemble à ceci — la présentation d’un instructeur, sur son profil :
Récapitulatif#
Question | Réponse du projet |
|---|---|
Que faut-il pour un type de bloc ? | Un schéma dans le contrat, puis une définition : libellé, icône, bloc vide, Editor (client) et View (serveur). |
Qui garantit qu’aucun bloc n’est oublié ? | Le type du registre, indexé sur PageBlockKindV1 : une définition manquante ou mal rangée ne compile pas. |
Pourquoi un cast dans BlockShell et PageRenderer ? | La contravariance de onChange empêche TypeScript de rendre l’union des éditeurs ; la définition étant choisie par block.type, le cast est sûr. |
Qui détient le contenu en cours d’édition ? | Le formulaire hôte : PageBuilder est contrôlé (value, onChange) et chaque action fabrique un nouveau contenu. |
Où s’ouvre le sélecteur de fichiers ? | Sur mediaRoot : la racine pour un administrateur, son espace personnel pour un instructeur. |
Comment l’aperçu trouve-t-il ses fichiers ? | Par media.resolveByIds, sur la route privée, avec le même rendu de texte et les mêmes classes que la page publique. |
Showing entries1to6entries
À suivre#
Le contenu est composé et enregistré. Côté site, un visiteur ouvre la page : il faut résoudre tous les fichiers de la page, choisir pour chacun une URL qu’un visiteur anonyme a le droit de charger, et rendre chaque bloc sans un aller-retour par image.
Chapitre suivant : le rendu serveur. PageRenderer passe par passe, la résolution groupée des fichiers et son audience, les avatars et le logo, l’alternance sans mutation, et ce qu’il coûte par page.