Un contenu fait de blocs

Pourquoi tous les textes du site sont enregistrés dans un même format, une liste de blocs en JSON : le contrat Zod bloc par bloc, l’union discriminée, l’enveloppe versionnée, les références aux médias, et la lecture tolérante d’un contenu enregistré.

Précédemment. Les fichiers sont envoyés, rangés et validés dans la bibliothèque. Un fichier validé n’est pourtant servi à personne tant qu’il n’est posé nulle part : la route publique exige qu’il soit placé dans une page ou une galerie publique. Cette partie raconte ce qui le place.

Cette partie suit un contenu du début à la fin. Ce chapitre décrit son format. Les suivants montrent comment le texte riche est rendu sans danger, comment l’éditeur et le rendu se branchent sur ce format, comment un contenu affiché sait quels fichiers il montre, ce qui change quand l’auteur n’est pas un administrateur, et enfin les réglages qui n’appartiennent à aucune page.

Des textes partout 📝#

Le site affiche beaucoup de textes mis en forme : la page « L’association », la description de chaque discipline et sa présentation courte, le contenu d’un cours, la description, le programme et le résumé d’un stage, le contenu et le résumé d’un événement, les actualités, la présentation de chaque instructeur. Chacun mêle des titres, des listes, des liens, des images de la bibliothèque, parfois une galerie ou une liste de documents.

Il existe trois façons courantes d’enregistrer un tel texte.

Format enregistré

Ce qu’il permet

Ce qu’il coûte

Une chaîne HTML
S’affiche telle quelle.
Rien n’est vérifiable : on enregistre ce que le navigateur a envoyé, script compris. Une image y est une URL figée, qui casse au premier déplacement du fichier.
Du Markdown
Lisible et léger.
Pas de mise en page (deux colonnes, image enrobée), et les mêmes URLs figées.
Un document structuré (JSON)
Chaque élément a un type et des propriétés connues ; une image est un identifiant.
Il faut un éditeur qui le produit, un schéma qui le valide et un rendu qui le transforme.

Le projet a choisi la troisième. Le coût est payé une fois — un éditeur, un schéma, un rendu — puis partagé par tous les contenus.

Un seul format pour tous les contenus#

Tous ces textes sont écrits avec le même outil, le constructeur de pages, et enregistrés dans le même format : une liste ordonnée de blocs.

Bloc

Ce qu’il affiche

tiptap
Du texte riche : titres, listes, liens, citations, code, cases à cocher, images de la bibliothèque dans le texte.
image-gallery
Une galerie d’images, en grille, carrousel ou mosaïque, avec légendes.
audio-collection
Une liste de pistes audio.
document-list
Une liste de documents à télécharger.
media-text
Un média (image, vidéo, avatar d’un membre ou logo du site) à côté d’un texte, sur deux colonnes.
float-text
Une petite image que le texte enrobe, comme un portrait dans une présentation.

Chaque endroit qui accueille un tel contenu est une colonne JSON d’une entité : , , et , , , et summary, et summary, . La base en garde la liste, parce que chacun doit déclarer les fichiers qu’il affiche (chapitre 5) :

Les contenus qui affichent des fichiers
PRISMA
prisma/schema.prisma
enum PageReferencerKind {  /// Cours : Course.content.  COURSE  /// Séminaire : Seminar.description.  SEMINAR_DESCRIPTION  /// Séminaire : Seminar.program.  SEMINAR_PROGRAM  /// Post : Post.content.  POST  /// Discipline : Discipline.description ET Discipline.summary, fusionnés.  DISCIPLINE  /// Évènement : Event.content.  EVENT  /// Page de contenu éditoriale : SitePage.content (ex. « L'association »).  SITE_PAGE  /// Séminaire : Seminar.summary (présentation synthétique, carte d'agenda).  SEMINAR_SUMMARY  /// Évènement : Event.summary (présentation synthétique, carte d'agenda).  EVENT_SUMMARY  /// Bio d'instructeur : User.instructorBio (pageId = id du membre). Liens  /// effacés avec le membre (users.delete).  INSTRUCTOR_BIO}

Un format unique permet d’écrire une seule fois l’éditeur, le rendu, la validation et le recensement des fichiers. Ajouter un type de bloc le rend disponible partout à la fois ; corriger le rendu d’un bloc le corrige sur toutes les pages.

Le contrat, bloc par bloc#

Le format est un contrat : un schéma Zod dans packages/contracts, importé par le front (l’éditeur, le rendu) comme par le backend (la validation à l’enregistrement). Les deux côtés ne peuvent pas diverger, puisqu’ils lisent le même fichier.

Tous les blocs partagent une base : un identifiant.

La base commune
TS
packages/contracts/src/page/blocks.v1.ts
const blockBaseSchema = z.object({  /**   * Identifiant stable du bloc à l'intérieur de la page.   *   * Généré côté builder (cuid recommandé pour cohérence avec le reste du   * projet), il sert de clé React, de cible de focus/scroll au mount,   * et de point d'ancrage pour le drag-and-drop.   *   * N'est PAS l'identifiant d'un asset — c'est l'identifiant du bloc   * lui-même au sein de la page.   */  id: z.string().min(1),});

Cet id est celui du bloc, pas d’un fichier : il distingue deux blocs d’une même page. React s’en sert comme clé quand on réordonne les blocs ; sans lui, déplacer un bloc ferait reprendre à son voisin l’état de son éditeur (le curseur, la sélection).

Un bloc de liste de médias décrit chaque élément par un identifiant de fichier et un texte facultatif :

La galerie d’images
TS
packages/contracts/src/page/blocks.v1.ts
const imageGalleryBlockSchema = blockBaseSchema.extend({  type: z.literal("image-gallery"),  /**   * Liste des images de la galerie.   *   * Volontairement non `.min(1)` : un bloc fraîchement ajouté par le   * builder peut être vide le temps que l'utilisateur sélectionne ses   * premières images via le MediaPicker. La cohérence "non vide à la   * publication" est une décision UX, pas une décision de schema —   * elle pourra être imposée en amont du save par le builder ou par   * une validation côté admin si nécessaire.   */  items: z.array(    z.object({      mediaId: z.string().min(1),      caption: z.string().optional(),    }),  ),  layout: imageGalleryLayoutSchema.default("grid"),});

Deux décisions s’y lisent. La liste n’exige pas au moins un élément : un bloc qu’on vient d’ajouter est vide le temps de choisir ses images, et un schéma qui le refuserait empêcherait d’enregistrer une page en cours d’écriture. Et layout a une valeur par défaut : un bloc enregistré avant l’arrivée de ce champ se lit comme une grille, sans migration.

Les blocs média-texte et texte enrobant n’ont qu’un média, mais ce média peut être de trois sortes :

Trois sortes de médias
TS
packages/contracts/src/page/blocks.v1.ts
/** Média issu de la bibliothèque (MediaAsset). */const libraryMediaItemSchema = z.object({  kind: z.literal("library").default("library"),  mediaId: z.string().min(1),  caption: z.string().optional(),});/** * Référence LOGIQUE à l'avatar d'un utilisateur (pas au binaire). Résolue * dynamiquement au rendu : la page affiche toujours l'avatar COURANT du user * — pas de copie, pas de synchro, pas de dérive. Si le user change d'avatar, * la page suit automatiquement. */const avatarMediaItemSchema = z.object({  kind: z.literal("avatar"),  userId: z.string().min(1),  caption: z.string().optional(),});/** * Média d'un bloc media-text : soit un média de bibliothèque, soit une * référence avatar. Discriminé par `kind`. L'ancien format (objet * `{ mediaId }` sans `kind`) est traité comme `library` par le preprocess. *//** * Référence LOGIQUE au logo du site (pas au binaire). Unique — aucun id. Résolue * dynamiquement au rendu via SiteSettings : si l'admin change le logo, toutes les * pages qui l'utilisent suivent. Le logo vit hors finder (clé R2 système). */const siteLogoMediaItemSchema = z.object({  kind: z.literal("site-logo"),  caption: z.string().optional(),});const mediaTextItemSchema = z.discriminatedUnion("kind", [  libraryMediaItemSchema,  avatarMediaItemSchema,  siteLogoMediaItemSchema,]);

Un fichier de la bibliothèque (library), l’avatar d’un membre (avatar), ou le logo du site (site-logo). Les deux derniers sont des références logiques : on n’enregistre pas l’image, mais « l’avatar de tel membre », « le logo du site ». Si le membre change d’avatar ou si l’administrateur change le logo, chaque page qui les montre suit, sans copie ni synchronisation.

Le bloc média-texte montre comment un contrat absorbe son propre passé :

Le bloc média-texte et son passé

Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.

Deux parties facultatives
TS
packages/contracts/src/page/blocks.v1.ts
guided extract: lines 184-209focus line 4
import { z } from "zod";import { richTextContentSchema } from "@contracts/page/richText";/** * Contrat versionné de contenu de page — v1. * * Une page est composée d'une liste ordonnée de blocs discriminés par * un champ `type`. Six types de blocs sont définis en v1 : * *   - `tiptap`           : texte riche (ProseMirror JSON) *   - `image-gallery`    : galerie d'images *   - `audio-collection` : collection de pistes audio *   - `document-list`    : liste de documents téléchargeables *   - `media-text`       : un média et un texte côte à côte *   - `float-text`       : un texte qui enrobe une image * * Voir README.md pour la philosophie (référence stable par mediaId, * versioning, frontière contrat / builder / renderer). *//* -------------------------------------------------------------------------- *//*  Contenu ProseMirror                                                       *//* -------------------------------------------------------------------------- */// Le texte riche des blocs est validé par `richTextContentSchema` (richText.ts) : une liste// blanche de nœuds, de marques et d'attributs, parce qu'il est rendu en HTML. Le schéma OPAQUE// de shared/prosemirror.ts reste celui des commentaires, que `CommentContent` rend lui-même// sans HTML brut./* -------------------------------------------------------------------------- *//*  Base commune                                                              *//* -------------------------------------------------------------------------- */const blockBaseSchema = z.object({  /**   * Identifiant stable du bloc à l'intérieur de la page.   *   * Généré côté builder (cuid recommandé pour cohérence avec le reste du   * projet), il sert de clé React, de cible de focus/scroll au mount,   * et de point d'ancrage pour le drag-and-drop.   *   * N'est PAS l'identifiant d'un asset — c'est l'identifiant du bloc   * lui-même au sein de la page.   */  id: z.string().min(1),});/* -------------------------------------------------------------------------- *//*  Bloc tiptap                                                               *//* -------------------------------------------------------------------------- */const tiptapBlockSchema = blockBaseSchema.extend({  type: z.literal("tiptap"),  content: richTextContentSchema,});export type TipTapBlockV1 = z.infer<typeof tiptapBlockSchema>;/* -------------------------------------------------------------------------- *//*  Bloc image-gallery                                                        *//* -------------------------------------------------------------------------- */const imageGalleryLayoutSchema = z.enum(["grid", "carousel", "masonry"]);export type ImageGalleryLayout = z.infer<typeof imageGalleryLayoutSchema>;const imageGalleryBlockSchema = blockBaseSchema.extend({  type: z.literal("image-gallery"),  /**   * Liste des images de la galerie.   *   * Volontairement non `.min(1)` : un bloc fraîchement ajouté par le   * builder peut être vide le temps que l'utilisateur sélectionne ses   * premières images via le MediaPicker. La cohérence "non vide à la   * publication" est une décision UX, pas une décision de schema —   * elle pourra être imposée en amont du save par le builder ou par   * une validation côté admin si nécessaire.   */  items: z.array(    z.object({      mediaId: z.string().min(1),      caption: z.string().optional(),    }),  ),  layout: imageGalleryLayoutSchema.default("grid"),});export type ImageGalleryBlockV1 = z.infer<typeof imageGalleryBlockSchema>;/* -------------------------------------------------------------------------- *//*  Bloc audio-collection                                                     *//* -------------------------------------------------------------------------- */const audioCollectionBlockSchema = blockBaseSchema.extend({  type: z.literal("audio-collection"),  items: z.array(    z.object({      mediaId: z.string().min(1),      /** Titre affiché à la place du nom de fichier brut. */      title: z.string().optional(),    }),  ),});export type AudioCollectionBlockV1 = z.infer<typeof audioCollectionBlockSchema>;/* -------------------------------------------------------------------------- *//*  Bloc document-list                                                        *//* -------------------------------------------------------------------------- */const documentListBlockSchema = blockBaseSchema.extend({  type: z.literal("document-list"),  items: z.array(    z.object({      mediaId: z.string().min(1),      /** Libellé du lien (à défaut, on retombe sur le nom de fichier). */      label: z.string().optional(),    }),  ),});export type DocumentListBlockV1 = z.infer<typeof documentListBlockSchema>;/* -------------------------------------------------------------------------- *//*  Bloc media-text                                                           *//* -------------------------------------------------------------------------- *//** * Bloc composite « médias + texte » côte à côte, pensé pour une mise en page * éditoriale. Les DEUX parties sont optionnelles : * *   - `content` + `media` non vides → rendu en deux colonnes ; le RENDERER *     alterne automatiquement le côté des médias d'un bloc media-text au *     suivant (1er : médias à gauche ; 2e : à droite ; etc.). L'ordre n'est *     donc PAS stocké ici — c'est une décision de rendu fondée sur la *     position, pas une donnée du bloc. *   - une seule des deux parties → rendu centré, pleine largeur. * * `media` accepte plusieurs items (images et/ou une vidéo) ; le renderer * décide de leur agencement (grille pour plusieurs images, lecteur pour une * vidéo). `content` est le même ProseMirror JSON que le bloc tiptap. *//** Média issu de la bibliothèque (MediaAsset). */const libraryMediaItemSchema = z.object({  kind: z.literal("library").default("library"),  mediaId: z.string().min(1),  caption: z.string().optional(),});/** * Référence LOGIQUE à l'avatar d'un utilisateur (pas au binaire). Résolue * dynamiquement au rendu : la page affiche toujours l'avatar COURANT du user * — pas de copie, pas de synchro, pas de dérive. Si le user change d'avatar, * la page suit automatiquement. */const avatarMediaItemSchema = z.object({  kind: z.literal("avatar"),  userId: z.string().min(1),  caption: z.string().optional(),});/** * Média d'un bloc media-text : soit un média de bibliothèque, soit une * référence avatar. Discriminé par `kind`. L'ancien format (objet * `{ mediaId }` sans `kind`) est traité comme `library` par le preprocess. *//** * Référence LOGIQUE au logo du site (pas au binaire). Unique — aucun id. Résolue * dynamiquement au rendu via SiteSettings : si l'admin change le logo, toutes les * pages qui l'utilisent suivent. Le logo vit hors finder (clé R2 système). */const siteLogoMediaItemSchema = z.object({  kind: z.literal("site-logo"),  caption: z.string().optional(),});const mediaTextItemSchema = z.discriminatedUnion("kind", [  libraryMediaItemSchema,  avatarMediaItemSchema,  siteLogoMediaItemSchema,]);const mediaTextBlockSchema = blockBaseSchema.extend({  type: z.literal("media-text"),  /** Texte riche optionnel (ProseMirror). Absent/vide → côté texte masqué. */  content: richTextContentSchema.optional(),  /**   * UN SEUL média optionnel : média de bibliothèque OU référence avatar.   * Absent → côté médias masqué.   *   * Compat : preprocess tolérant — (1) un ancien TABLEAU est réduit à son   * premier élément ; (2) un objet SANS `kind` (ancien format média   * bibliothèque) reçoit `kind: "library"`.   */  media: z.preprocess((val) => {    let v = val;    if (Array.isArray(v)) v = v.length > 0 ? v[0] : undefined;    if (      v &&      typeof v === "object" &&      !("kind" in (v as Record<string, unknown>))    ) {      // Ancien format { mediaId, caption } → média bibliothèque.      return { kind: "library", ...(v as Record<string, unknown>) };    }    return v;  }, mediaTextItemSchema.optional()),});export type MediaTextBlockV1 = z.infer<typeof mediaTextBlockSchema>;/* -------------------------------------------------------------------------- *//*  Bloc float-text (texte enrobant une image)                                *//* -------------------------------------------------------------------------- *//** * Une image `float`-ée dans une coulée de texte qui l'enrobe. À distinguer du * média-texte : pas de deux colonnes, pas d'alternance de largeurs. Le `side` * est une DONNÉE du bloc (on choisit où l'illustration se pose), pas une * décision de position — un enrobage n'alterne pas. * * Même modèle de média que le média-texte (bibliothèque OU avatar), réutilisé * via `mediaTextItemSchema` et son preprocess tolérant. */const floatTextBlockSchema = blockBaseSchema.extend({  type: z.literal("float-text"),  content: richTextContentSchema.optional(),  side: z.enum(["left", "right"]).default("left"),  media: z.preprocess((val) => {    let v = val;    if (Array.isArray(v)) v = v.length > 0 ? v[0] : undefined;    if (      v &&      typeof v === "object" &&      !("kind" in (v as Record<string, unknown>))    ) {      return { kind: "library", ...(v as Record<string, unknown>) };    }    return v;  }, mediaTextItemSchema.optional()),});export type FloatTextBlockV1 = z.infer<typeof floatTextBlockSchema>;/* -------------------------------------------------------------------------- *//*  Union discriminée                                                         *//* -------------------------------------------------------------------------- */export const pageBlockSchemaV1 = z.discriminatedUnion("type", [  tiptapBlockSchema,  imageGalleryBlockSchema,  audioCollectionBlockSchema,  documentListBlockSchema,  mediaTextBlockSchema,  floatTextBlockSchema,]);export type PageBlockV1 = z.infer<typeof pageBlockSchemaV1>;/** * Union des `type` littéraux acceptés en v1. * * Pratique pour typer les clés d'un registry — voir * `features/page-builder/blockRegistry.ts` (sous-chantier 5). */export type PageBlockKindV1 = PageBlockV1["type"];/* -------------------------------------------------------------------------- *//*  Enveloppe versionnée                                                      *//* -------------------------------------------------------------------------- */export const pageContentSchemaV1 = z.object({  version: z.literal(1),  blocks: z.array(pageBlockSchemaV1),});export type PageContentV1 = z.infer<typeof pageContentSchemaV1>;/* -------------------------------------------------------------------------- *//*  Helper de fabrique                                                        *//* -------------------------------------------------------------------------- *//** * Fabrique un contenu de page vide, prêt à recevoir des blocs via le * builder. À utiliser comme valeur initiale d'un nouveau Course / Stage / * Post tant que l'admin n'a rien composé. */export function emptyPageContentV1(): PageContentV1 {  return { version: 1, blocks: [] };}

Le côté du média n’est pas enregistré dans un bloc média-texte : le rendu alterne, gauche puis droite, d’un bloc média-texte au suivant (chapitre 4). Le bloc texte enrobant, lui, enregistre son côté :

Un côté choisi, et non calculé
TS
packages/contracts/src/page/blocks.v1.ts
const floatTextBlockSchema = blockBaseSchema.extend({  type: z.literal("float-text"),  content: richTextContentSchema.optional(),  side: z.enum(["left", "right"]).default("left"),

La différence est voulue. Une alternance est une décision de mise en page, qui dépend de la position du bloc dans la page ; un portrait posé à droite d’un texte est un choix de l’auteur. Ce qui dépend de la position se calcule ; ce que l’auteur choisit s’enregistre.

L’union discriminée#

Les six schémas sont réunis en une seule union, discriminée par le champ type :

Six blocs, un champ qui les distingue
TS
packages/contracts/src/page/blocks.v1.ts
export const pageBlockSchemaV1 = z.discriminatedUnion("type", [  tiptapBlockSchema,  imageGalleryBlockSchema,  audioCollectionBlockSchema,  documentListBlockSchema,  mediaTextBlockSchema,  floatTextBlockSchema,]);export type PageBlockV1 = z.infer<typeof pageBlockSchemaV1>;/** * Union des `type` littéraux acceptés en v1. * * Pratique pour typer les clés d'un registry — voir * `features/page-builder/blockRegistry.ts` (sous-chantier 5). */export type PageBlockKindV1 = PageBlockV1["type"];

z.discriminatedUnion lit d’abord type, puis valide le bloc avec le seul schéma correspondant. Une simple z.union essaierait les six schémas l’un après l’autre et, en cas d’échec, rapporterait six listes d’erreurs ; ici l’erreur nomme le bon bloc, ou dit que type est inconnu.

Côté TypeScript, l’union donne le rétrécissement : dans if (block.type === "image-gallery"), le compilateur sait que block.items existe. Un switch sur block.type peut même être vérifié exhaustif — le chapitre 5 s’en sert pour qu’aucun bloc n’échappe au recensement des fichiers.

PageBlockKindV1 est l’union des valeurs de type : "tiptap" | "image-gallery" | …. Le registre des blocs (chapitre 3) l’utilise pour exiger une définition par type.

L’enveloppe versionnée#

Un contenu n’est pas une liste nue, mais une liste dans une enveloppe qui porte un numéro de version :

L’enveloppe et le contenu vide
TS
packages/contracts/src/page/blocks.v1.ts
export const pageContentSchemaV1 = z.object({  version: z.literal(1),  blocks: z.array(pageBlockSchemaV1),});export type PageContentV1 = z.infer<typeof pageContentSchemaV1>;/* -------------------------------------------------------------------------- *//*  Helper de fabrique                                                        *//* -------------------------------------------------------------------------- *//** * Fabrique un contenu de page vide, prêt à recevoir des blocs via le * builder. À utiliser comme valeur initiale d'un nouveau Course / Stage / * Post tant que l'admin n'a rien composé. */export function emptyPageContentV1(): PageContentV1 {  return { version: 1, blocks: [] };}

Une évolution compatible — un champ facultatif en plus, un champ avec une valeur par défaut, un ancien format absorbé par un preprocess — reste en version 1. Une évolution qui change le sens d’un champ ferait une version 2 : un fichier blocks.v2.ts, une fonction qui convertit un contenu v1 en v2, et la lecture qui choisit le schéma d’après version. Sans numéro dans les données, on ne saurait pas, devant un contenu enregistré il y a deux ans, avec quel schéma le lire.

emptyPageContentV1() fabrique un contenu vide. C’est la valeur de départ d’un nouveau contenu, et le repli d’une lecture qui échoue.

Des références, jamais des URLs 🔗#

Aucun bloc ne contient l’URL ni le chemin d’un fichier, seulement son mediaId. La partie « Stockage et médias » a montré pourquoi : un fichier se renomme, se déplace, change de statut. Une URL enregistrée dans une page casserait au premier déplacement ; un identifiant ne bouge pas.

L’URL est donc calculée au rendu, à partir de l’identifiant, avec la route qui convient au lecteur (la route privée dans l’éditeur, la route publique pour un visiteur). Le chapitre 4 montre ce calcul ; le chapitre 5, comment la base apprend qu’un fichier est placé.

Le même principe vaut pour l’avatar et le logo : on enregistre qui (« l’avatar de ce membre », « le logo du site »), jamais quoi (un fichier). Ce qui est enregistré ne vieillit pas.

Lire un contenu enregistré#

À l’enregistrement, le schéma refuse un contenu invalide : la procédure répond BAD_REQUEST, rien n’est écrit. À la lecture, la situation est inverse. Le contenu est déjà en base ; il peut dater d’un ancien format, avoir été écrit avant une règle, ou avoir été abîmé. Refuser ne servirait à rien : la page doit s’afficher.

Toutes les lectures passent donc par une seule fonction :

parsePageContentV1 — ne jamais casser une page

Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.

Le cas ordinaire
TS
packages/contracts/src/page/parseContent.ts
guided extract: lines 57-75focus line 3
import {  emptyPageContentV1,  pageBlockSchemaV1,  pageContentSchemaV1,  type PageBlockV1,  type PageContentV1,} from '@contracts/page/blocks.v1';/* ─────────────────────────────────────────────────────────────────────── *//*  parsePageContentV1                                                     *//* ─────────────────────────────────────────────────────────────────────── *//** * Lit un `content` brut de la base (typiquement le `Course.content` JSON, * ou `Stage.description` / `Stage.program` / `Post.content`) et rend un * `PageContentV1` toujours valide. * * ─── Pourquoi un helper plutôt qu'un appel direct au schema ? ────────── * * Sur le papier, on pourrait écrire à chaque site d'appel : * *     const parsed = pageContentSchemaV1.safeParse(raw); *     const content = parsed.success ? parsed.data : emptyPageContentV1(); * * Mais ce pattern revient en plusieurs endroits (chaque page publique * qui rend du contenu : `/cours/[id]`, `/stages/[id]`, `/actualites/[id]`, * etc.) et il y a une vraie tentation de l'oublier — ou pire, de jeter * une exception sur les contenus invalides au lieu de dégrader * proprement. * * Le helper porte la **politique de robustesse à la lecture** : un * contenu invalide ou inexistant rend une page vide (avec un éventuel * placeholder côté UI), pas une 500. Cohérent avec le filtrage * silencieux des assets manquants côté Views. * * ─── Quand ça déclenche le fallback ──────────────────────────────────── * *   - `raw` est `null` ou `undefined` (champ jamais peuplé) *   - `raw` est d'une version antérieure incompatible (avant le *     sous-chantier 5, format différent) *   - le `version` est diffèrent de `1` *   - une migration de bloc a partiellement échoué * * Dans tous ces cas, `emptyPageContentV1()` rend `{ version: 1, blocks: [] }`, * qui est sain et rendu en « page vide » par le `PageRenderer`. * * ─── Un bloc fautif ne vide pas la page ─────────────────────────────── * * Une enveloppe v1 dont UN bloc est invalide garde ses autres blocs : seul le * fautif est écarté. Rendre la page entière vide aurait un effet pire qu'à * l'affichage — l'éditeur s'ouvrirait sur une page vide, et l'enregistrer * effacerait tout le reste. * * Chaque écart est signalé par `console.warn` (serveur ou navigateur) : un * contenu qui disparaît sans trace ne se diagnostique pas. */export function parsePageContentV1(raw: unknown): PageContentV1 {  const result = pageContentSchemaV1.safeParse(raw);  if (result.success) return result.data;  if (raw === null || raw === undefined) return emptyPageContentV1();  const envelope = raw as { version?: unknown; blocks?: unknown };  if (envelope.version !== 1 || !Array.isArray(envelope.blocks)) {    console.warn('[parsePageContentV1] contenu illisible, page vide rendue', result.error.issues.slice(0, 3));    return emptyPageContentV1();  }  const blocks: PageBlockV1[] = [];  envelope.blocks.forEach((rawBlock, index) => {    const block = pageBlockSchemaV1.safeParse(rawBlock);    if (block.success) blocks.push(block.data);    else console.warn(`[parsePageContentV1] bloc #${index} écarté`, block.error.issues.slice(0, 3));  });  return { version: 1, blocks };}

La dernière étape est la plus importante. Une version antérieure rendait la page entière vide dès qu’un seul bloc était invalide. À l’affichage, c’était déjà une perte ; dans l’éditeur, c’était pire : l’administrateur ouvrait une page vide, ajoutait un paragraphe, enregistrait — et tous les autres blocs étaient effacés pour de bon.

Une lecture tolérante doit perdre le moins possible, et le dire. Un repli silencieux et total transforme une petite anomalie de données en perte définitive au premier enregistrement.

Le schéma sert donc deux fois : il refuse à l’entrée, il répare à la sortie. Le chapitre suivant montre qu’il fait aussi une troisième chose, pour le texte riche : il assainit.

Récapitulatif#

Question

Réponse du projet

Pourquoi du JSON plutôt que du HTML ?
Chaque élément a un type et des propriétés connues : on peut le valider, l’assainir, et une image y est un identifiant, pas une URL.
Où est défini le format ?
Dans packages/contracts/src/page/blocks.v1.ts, lu par le front et le backend.
Comment distingue-t-on les blocs ?
Par le champ type, d’une union discriminée : une erreur précise, et le rétrécissement en TypeScript.
Comment le format évolue-t-il ?
Champs facultatifs, valeurs par défaut et preprocess en version 1 ; un changement de sens ferait une version 2 avec conversion.
Comment un bloc désigne-t-il un média ?
Par son mediaId, ou par une référence logique (avatar d’un membre, logo du site) ; l’URL est calculée au rendu.
Que devient un contenu illisible ?
parsePageContentV1 écarte le seul bloc fautif, garde les autres, et le signale ; une enveloppe illisible donne une page vide.

À suivre#

Le format est posé. Un bloc reste à part : le texte riche, un document entier dans le document, que le rendu transforme en HTML injecté tel quel dans la page. C’est là que se joue la sécurité de tout le constructeur.

Chapitre suivant : le texte riche. Le modèle de ProseMirror, ce que generateHTML recopie sans vérifier, la liste blanche qui assainit chaque document, et le rendu unique partagé par tous les blocs.

Sur cette page