Le texte riche
Le texte riche d’un bloc est un document ProseMirror transformé en HTML et injecté tel quel dans la page : ce que generateHTML recopie sans vérifier, la liste blanche qui réécrit chaque document à l’entrée et à la lecture, le rendu unique des blocs, et la stratégie différente des commentaires.
Précédemment. Tous les contenus du site sont une liste de blocs dans une enveloppe versionnée, décrite par un schéma Zod partagé. Le schéma refuse à l’entrée et répare à la lecture ; un bloc y désigne un fichier par son identifiant.
Les parcours du chapitre#
Deux parcours traversent ce chapitre : l’administrateur qui enregistre une page, le visiteur qui la lit. Chaque onglet en déroule les étapes relevées dans le code ; chaque étape mène à son explication plus bas.
Acteur : Administrateur
- 1
Le contenu reçu est validé ; le texte riche de chaque bloc est réécrit d’après la liste blanche avant d’être enregistré
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
Un document dans le document#
Le bloc tiptap, et la partie texte des blocs média-texte et texte enrobant, contiennent un document ProseMirror. ProseMirror est le moteur d’édition sur lequel est construit Tiptap, l’éditeur du projet. Il ne manipule pas du HTML, mais un arbre de nœuds typés :
Un document ProseMirror, tel qu’il est enregistré
{ "type": "doc", "content": [ { "type": "heading", "attrs": { "level": 2 }, "content": [{ "type": "text", "text": "Horaires" }] }, { "type": "paragraph", "attrs": { "textAlign": "left" }, "content": [ { "type": "text", "text": "Cours le " }, { "type": "text", "text": "mardi", "marks": [{ "type": "bold" }] }, { "type": "text", "text": ", voir le " }, { "type": "text", "text": "planning", "marks": [{ "type": "link", "attrs": { "href": "/agenda" } }] } ] }, { "type": "library-image", "attrs": { "mediaId": "cm9x…", "caption": "Le dojo" } } ]}Trois notions suffisent à le lire.
Notion | Ce que c’est | Dans l’exemple |
|---|---|---|
Nœud (node) | Un élément de structure, qui peut en contenir d’autres (content). | doc, heading, paragraph, text, library-image |
Marque (mark) | Une mise en forme posée sur un morceau de texte, sans le découper en sous-nœuds. | bold, link |
Attributs (attrs) | Les propriétés d’un nœud ou d’une marque. | level: 2, textAlign, href, mediaId |
Showing entries1to3entries
C’est ce document qui est enregistré dans la colonne JSON, à l’intérieur du bloc. L’image de la bibliothèque y est un nœud library-image qui ne porte qu’un identifiant : l’URL viendra au rendu, comme pour les autres blocs.
Du JSON au HTML#
Pour afficher le document, le rendu le transforme en HTML avec generateHTML, une fonction de Tiptap. Chaque extension de l’éditeur (titre, paragraphe, lien, surlignage…) décrit comment son nœud ou sa marque s’écrit en HTML, dans une méthode renderHTML. generateHTML parcourt l’arbre et appelle, pour chaque élément, la méthode de son extension :
Le même document, en HTML
<h2>Horaires</h2><p style="text-align: left">Cours le <strong>mardi</strong>, voir le <a href="/agenda" rel="noopener noreferrer nofollow">planning</a></p><figure class="tiptap-library-image"><img src="/api/media/public/r2/AKFC/…" alt="Le dojo" …><figcaption>Le dojo</figcaption></figure>Ce HTML est ensuite inséré dans la page par dangerouslySetInnerHTML :
D’ordinaire, React échappe tout ce qu’il affiche : une chaîne qui contient <script> s’affiche comme du texte. dangerouslySetInnerHTML désactive cette protection. Son nom est un avertissement : ce qu’on lui passe doit être sûr, parce que personne d’autre ne le vérifiera.
Le texte, lui, est sûr : les renderHTML de Tiptap ne fabriquent pas de chaîne HTML à la main, ils décrivent des éléments et des attributs que la bibliothèque écrit en échappant chaque valeur. Un texte <script> devient <script>. La question est ailleurs : dans les attributs.
Ce que generateHTML recopie sans vérifier ⚠️#
Quand on colle du HTML dans l’éditeur, chaque extension le relit avec des règles (parseHTML) qui écartent ce qu’elle ne reconnaît pas. Mais generateHTML part du JSON : les règles de lecture ne sont jamais appliquées. Ce que le JSON contient, l’extension le recopie.
Or le JSON n’est pas forcément produit par l’éditeur. Une procédure tRPC reçoit une requête HTTP, et une requête se fabrique sans navigateur. Voici un paragraphe qu’aucun éditeur ne produit, mais qu’une requête peut envoyer :
Un paragraphe fabriqué à la main
{ "type": "paragraph", "attrs": { "textAlign": "left; position: fixed; inset: 0; z-index: 99; background: url(https://ailleurs.example/faux.png)" }, "content": [ { "type": "text", "text": "Mettre à jour vos coordonnées", "marks": [{ "type": "link", "attrs": { "href": "javascript:fetch('/api/…')" } }] } ]}Trois effets, tous réels avant ce chapitre :
Ce qui est envoyé | Ce que le rendu en faisait | Conséquence |
|---|---|---|
textAlign avec du CSS à la suite | L’extension d’alignement écrit style="text-align: …" en recopiant la valeur. | Le paragraphe recouvre toute la page (position: fixed), avec une image prise ailleurs : un faux écran du club. |
La couleur d’un surlignage | Recopiée de la même façon dans un style. | Le même recouvrement, par un autre attribut. |
Un lien javascript: | Le lien est écrit tel quel. | Un clic exécute du script sur le domaine du club, avec la session du visiteur. |
Un type de nœud inconnu | generateHTML lève une exception. | La page entière tombe en erreur, pas seulement le bloc. |
Showing entries1to4entries
L’éditeur ne produit aucun de ces documents, et une page n’est écrite que par un administrateur. Mais la présentation d’un instructeur est écrite par un membre (chapitre 6), et « l’éditeur ne le produit pas » n’est pas une protection : le serveur reçoit ce qu’on lui envoie, pas ce que l’éditeur aurait envoyé.
Un contenu rendu en HTML brut est une entrée utilisateur jusqu’à preuve du contraire. On ne la valide pas en vérifiant qu’elle « a la bonne forme » (un objet), mais en ne gardant que ce qu’on sait rendre sans danger.
Une liste blanche#
Deux stratégies existent pour assainir. Une liste noire retire ce qu’on sait dangereux (javascript:, position: fixed…) ; elle échoue au premier cas qu’on n’a pas prévu (JaVaScRiPt:, une entité HTML, une autre propriété CSS). Une liste blanche ne garde que ce qu’on sait sûr ; un cas imprévu est retiré par défaut. Le projet utilise la seconde, dans packages/contracts/src/page/richText.ts.
D’abord, ce qu’est un lien sûr :
isSafeHref — un lien qu’on peut suivre
Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.
import { z } from "zod";/** * richText.ts — le texte riche des blocs du constructeur de pages, assaini. * * Le contenu ProseMirror arrive en JSON, et `generateHTML` le transforme en HTML injecté tel quel * dans la page. Les extensions TipTap échappent le texte, mais certaines recopient leurs ATTRIBUTS * sans les valider quand le document vient du JSON (et non d'un collage HTML) : * * - `textAlign` et la couleur de `highlight` partent dans un attribut `style` : * `"left; position:fixed; inset:0; z-index:99"` recouvrirait toute la page ; * - un attribut `class` sur un lien accepte n'importe quelle classe utilitaire ; * - un type de nœud inconnu fait LEVER `generateHTML` : toute la page tombe en erreur. * * Une page est écrite par un administrateur, mais une présentation d'instructeur l'est par un * membre, et une requête se fabrique sans passer par l'éditeur. D'où une liste BLANCHE : seuls les * nœuds, marques et attributs que l'éditeur sait produire passent, avec des valeurs vérifiées. * Le reste est retiré (un nœud inconnu disparaît, son texte avec lui). * * Appliqué à l'entrée (validation zod des contenus enregistrés) ET à la lecture * (`parsePageContentV1`) : un contenu enregistré avant cette règle est assaini au rendu. */type Json = Record<string, unknown>;const ALIGN = new Set(["left", "center", "right", "justify"]);const HIGHLIGHT_COLOR = /^(#[0-9a-fA-F]{3,8}|var\(--tt-color-highlight-[a-z]+\))$/;const CODE_LANGUAGE = /^[a-zA-Z0-9+#-]{1,20}$/;const LIST_TYPE = new Set(["1", "a", "A", "i", "I"]);/** Profondeur maximale d'imbrication : au-delà, rien de ce que l'éditeur produit. */const MAX_DEPTH = 40;/** Un lien : web, courriel, téléphone, ou chemin du site. Jamais `javascript:` ni `data:`. */export function isSafeHref(href: unknown): href is string { if (typeof href !== "string" || href.length > 2000) return false; const h = href.trim(); if (h.startsWith("/") && !h.startsWith("//")) return true; if (h.startsWith("#")) return true; return /^(https?:|mailto:|tel:)/i.test(h);}const str = (v: unknown, max: number): string | null => typeof v === "string" ? v.slice(0, max) : null;/** Attributs permis, par type de nœud ou de marque, avec leur validation. */const NODE_ATTRS: Record<string, (attrs: Json) => Json> = { doc: () => ({}), text: () => ({}), paragraph: (a) => (ALIGN.has(a.textAlign as string) ? { textAlign: a.textAlign } : {}), heading: (a) => ({ level: Number.isInteger(a.level) && (a.level as number) >= 1 && (a.level as number) <= 6 ? a.level : 2, ...(ALIGN.has(a.textAlign as string) ? { textAlign: a.textAlign } : {}), }), blockquote: () => ({}), bulletList: () => ({}), orderedList: (a) => ({ ...(Number.isInteger(a.start) ? { start: a.start } : {}), ...(LIST_TYPE.has(a.type as string) ? { type: a.type } : {}), }), listItem: () => ({}), codeBlock: (a) => (typeof a.language === "string" && CODE_LANGUAGE.test(a.language) ? { language: a.language } : {}), hardBreak: () => ({}), horizontalRule: () => ({}), taskList: () => ({}), taskItem: (a) => ({ checked: a.checked === true }), "library-image": (a) => ({ mediaId: str(a.mediaId, 100), caption: str(a.caption, 500), }),};const MARK_ATTRS: Record<string, (attrs: Json) => Json | null> = { bold: () => ({}), italic: () => ({}), strike: () => ({}), underline: () => ({}), code: () => ({}), subscript: () => ({}), superscript: () => ({}), // Un lien dont l'adresse n'est pas sûre perd sa marque : le texte reste, sans lien. link: (a) => (isSafeHref(a.href) ? { href: a.href.trim(), ...(a.target === "_blank" ? { target: "_blank" } : {}) } : null), highlight: (a) => (typeof a.color === "string" && HIGHLIGHT_COLOR.test(a.color) ? { color: a.color } : {}),};function sanitizeNode(node: unknown, depth: number): Json | null { if (!node || typeof node !== "object" || Array.isArray(node) || depth > MAX_DEPTH) return null; const n = node as Json; const type = n.type; if (typeof type !== "string") return null; const attrsOf = NODE_ATTRS[type]; if (!attrsOf) return null; const out: Json = { type }; const attrs = attrsOf((n.attrs && typeof n.attrs === "object" ? n.attrs : {}) as Json); if (Object.keys(attrs).length > 0) out.attrs = attrs; if (type === "text") { if (typeof n.text !== "string" || n.text.length === 0) return null; out.text = n.text; if (Array.isArray(n.marks)) { const marks = n.marks .map((m): Json | null => { if (!m || typeof m !== "object") return null; const mark = m as Json; const markAttrs = typeof mark.type === "string" ? MARK_ATTRS[mark.type] : undefined; if (!markAttrs) return null; const a = markAttrs((mark.attrs && typeof mark.attrs === "object" ? mark.attrs : {}) as Json); if (a === null) return null; return Object.keys(a).length > 0 ? { type: mark.type, attrs: a } : { type: mark.type }; }) .filter((m): m is Json => m !== null); if (marks.length > 0) out.marks = marks; } return out; } if (Array.isArray(n.content)) { out.content = n.content .map((child) => sanitizeNode(child, depth + 1)) .filter((child): child is Json => child !== null); } return out;}/** Le document assaini ; un document inutilisable devient un document vide. */export function sanitizeRichText(content: Json): Json { return sanitizeNode(content, 0) ?? { type: "doc", content: [] };}/** Texte riche d'un bloc : un objet, assaini à la validation. */export const richTextContentSchema = z .record(z.string(), z.unknown()) .transform((content) => sanitizeRichText(content));Ensuite, pour chaque type de nœud connu, une fonction qui reconstruit ses attributs à partir des seules valeurs valides :
const NODE_ATTRS: Record<string, (attrs: Json) => Json> = { doc: () => ({}), text: () => ({}), paragraph: (a) => (ALIGN.has(a.textAlign as string) ? { textAlign: a.textAlign } : {}), heading: (a) => ({ level: Number.isInteger(a.level) && (a.level as number) >= 1 && (a.level as number) <= 6 ? a.level : 2, ...(ALIGN.has(a.textAlign as string) ? { textAlign: a.textAlign } : {}), }), blockquote: () => ({}), bulletList: () => ({}), orderedList: (a) => ({ ...(Number.isInteger(a.start) ? { start: a.start } : {}), ...(LIST_TYPE.has(a.type as string) ? { type: a.type } : {}), }), listItem: () => ({}), codeBlock: (a) => (typeof a.language === "string" && CODE_LANGUAGE.test(a.language) ? { language: a.language } : {}), hardBreak: () => ({}), horizontalRule: () => ({}), taskList: () => ({}), taskItem: (a) => ({ checked: a.checked === true }), "library-image": (a) => ({ mediaId: str(a.mediaId, 100), caption: str(a.caption, 500), }),};La fonction ne filtre pas l’objet reçu, elle en fabrique un nouveau. textAlign n’est gardé que s’il vaut exactement left, center, right ou justify ; un niveau de titre hors de 1 à 6 devient 2 ; une case à cocher est cochée seulement si checked vaut exactement true. Un attribut qui n’est pas nommé ici n’existe pas dans le résultat — y compris ceux qu’une future version de Tiptap ajouterait.
Les marques suivent la même règle, avec une différence pour le lien :
const MARK_ATTRS: Record<string, (attrs: Json) => Json | null> = { bold: () => ({}), italic: () => ({}), strike: () => ({}), underline: () => ({}), code: () => ({}), subscript: () => ({}), superscript: () => ({}), // Un lien dont l'adresse n'est pas sûre perd sa marque : le texte reste, sans lien. link: (a) => (isSafeHref(a.href) ? { href: a.href.trim(), ...(a.target === "_blank" ? { target: "_blank" } : {}) } : null), highlight: (a) => (typeof a.color === "string" && HIGHLIGHT_COLOR.test(a.color) ? { color: a.color } : {}),};Un lien dont l’adresse n’est pas sûre rend null : la marque disparaît, le texte reste. Le lecteur voit « Mettre à jour vos coordonnées » en texte simple, au lieu d’un lien piégé. Une couleur de surlignage doit être un code hexadécimal ou l’une des variables du thème.
Reste le parcours de l’arbre :
sanitizeNode — reconstruire l’arbre
Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.
import { z } from "zod";/** * richText.ts — le texte riche des blocs du constructeur de pages, assaini. * * Le contenu ProseMirror arrive en JSON, et `generateHTML` le transforme en HTML injecté tel quel * dans la page. Les extensions TipTap échappent le texte, mais certaines recopient leurs ATTRIBUTS * sans les valider quand le document vient du JSON (et non d'un collage HTML) : * * - `textAlign` et la couleur de `highlight` partent dans un attribut `style` : * `"left; position:fixed; inset:0; z-index:99"` recouvrirait toute la page ; * - un attribut `class` sur un lien accepte n'importe quelle classe utilitaire ; * - un type de nœud inconnu fait LEVER `generateHTML` : toute la page tombe en erreur. * * Une page est écrite par un administrateur, mais une présentation d'instructeur l'est par un * membre, et une requête se fabrique sans passer par l'éditeur. D'où une liste BLANCHE : seuls les * nœuds, marques et attributs que l'éditeur sait produire passent, avec des valeurs vérifiées. * Le reste est retiré (un nœud inconnu disparaît, son texte avec lui). * * Appliqué à l'entrée (validation zod des contenus enregistrés) ET à la lecture * (`parsePageContentV1`) : un contenu enregistré avant cette règle est assaini au rendu. */type Json = Record<string, unknown>;const ALIGN = new Set(["left", "center", "right", "justify"]);const HIGHLIGHT_COLOR = /^(#[0-9a-fA-F]{3,8}|var\(--tt-color-highlight-[a-z]+\))$/;const CODE_LANGUAGE = /^[a-zA-Z0-9+#-]{1,20}$/;const LIST_TYPE = new Set(["1", "a", "A", "i", "I"]);/** Profondeur maximale d'imbrication : au-delà, rien de ce que l'éditeur produit. */const MAX_DEPTH = 40;/** Un lien : web, courriel, téléphone, ou chemin du site. Jamais `javascript:` ni `data:`. */export function isSafeHref(href: unknown): href is string { if (typeof href !== "string" || href.length > 2000) return false; const h = href.trim(); if (h.startsWith("/") && !h.startsWith("//")) return true; if (h.startsWith("#")) return true; return /^(https?:|mailto:|tel:)/i.test(h);}const str = (v: unknown, max: number): string | null => typeof v === "string" ? v.slice(0, max) : null;/** Attributs permis, par type de nœud ou de marque, avec leur validation. */const NODE_ATTRS: Record<string, (attrs: Json) => Json> = { doc: () => ({}), text: () => ({}), paragraph: (a) => (ALIGN.has(a.textAlign as string) ? { textAlign: a.textAlign } : {}), heading: (a) => ({ level: Number.isInteger(a.level) && (a.level as number) >= 1 && (a.level as number) <= 6 ? a.level : 2, ...(ALIGN.has(a.textAlign as string) ? { textAlign: a.textAlign } : {}), }), blockquote: () => ({}), bulletList: () => ({}), orderedList: (a) => ({ ...(Number.isInteger(a.start) ? { start: a.start } : {}), ...(LIST_TYPE.has(a.type as string) ? { type: a.type } : {}), }), listItem: () => ({}), codeBlock: (a) => (typeof a.language === "string" && CODE_LANGUAGE.test(a.language) ? { language: a.language } : {}), hardBreak: () => ({}), horizontalRule: () => ({}), taskList: () => ({}), taskItem: (a) => ({ checked: a.checked === true }), "library-image": (a) => ({ mediaId: str(a.mediaId, 100), caption: str(a.caption, 500), }),};const MARK_ATTRS: Record<string, (attrs: Json) => Json | null> = { bold: () => ({}), italic: () => ({}), strike: () => ({}), underline: () => ({}), code: () => ({}), subscript: () => ({}), superscript: () => ({}), // Un lien dont l'adresse n'est pas sûre perd sa marque : le texte reste, sans lien. link: (a) => (isSafeHref(a.href) ? { href: a.href.trim(), ...(a.target === "_blank" ? { target: "_blank" } : {}) } : null), highlight: (a) => (typeof a.color === "string" && HIGHLIGHT_COLOR.test(a.color) ? { color: a.color } : {}),};function sanitizeNode(node: unknown, depth: number): Json | null { if (!node || typeof node !== "object" || Array.isArray(node) || depth > MAX_DEPTH) return null; const n = node as Json; const type = n.type; if (typeof type !== "string") return null; const attrsOf = NODE_ATTRS[type]; if (!attrsOf) return null; const out: Json = { type }; const attrs = attrsOf((n.attrs && typeof n.attrs === "object" ? n.attrs : {}) as Json); if (Object.keys(attrs).length > 0) out.attrs = attrs; if (type === "text") { if (typeof n.text !== "string" || n.text.length === 0) return null; out.text = n.text; if (Array.isArray(n.marks)) { const marks = n.marks .map((m): Json | null => { if (!m || typeof m !== "object") return null; const mark = m as Json; const markAttrs = typeof mark.type === "string" ? MARK_ATTRS[mark.type] : undefined; if (!markAttrs) return null; const a = markAttrs((mark.attrs && typeof mark.attrs === "object" ? mark.attrs : {}) as Json); if (a === null) return null; return Object.keys(a).length > 0 ? { type: mark.type, attrs: a } : { type: mark.type }; }) .filter((m): m is Json => m !== null); if (marks.length > 0) out.marks = marks; } return out; } if (Array.isArray(n.content)) { out.content = n.content .map((child) => sanitizeNode(child, depth + 1)) .filter((child): child is Json => child !== null); } return out;}/** Le document assaini ; un document inutilisable devient un document vide. */export function sanitizeRichText(content: Json): Json { return sanitizeNode(content, 0) ?? { type: "doc", content: [] };}/** Texte riche d'un bloc : un objet, assaini à la validation. */export const richTextContentSchema = z .record(z.string(), z.unknown()) .transform((content) => sanitizeRichText(content));Le paragraphe fabriqué plus haut ressort ainsi : textAlign absent (la valeur n’est pas l’un des quatre mots), le lien retiré, le texte intact. Et un nœud inconnu ne fait plus tomber la page, puisqu’il n’arrive jamais jusqu’à generateHTML.
Assainir à l’entrée, et à la lecture#
Le schéma assainissant est branché là où le texte riche apparaît dans le contrat — le bloc tiptap, et la partie texte des blocs média-texte et texte enrobant :
const tiptapBlockSchema = blockBaseSchema.extend({ type: z.literal("tiptap"), content: richTextContentSchema,});Il agit donc partout où le contrat est appliqué, sans qu’aucune procédure ait à y penser :
z.object({ slug: sitePageSlug, title: z.string().trim().min(1).max(120), content: pageContentSchemaV1, }), )À l’entrée. Chaque procédure qui enregistre un contenu valide son entrée avec pageContentSchemaV1. Ce qui est écrit en base est le document réécrit, jamais le document reçu.
À la lecture. parsePageContentV1 (chapitre 1) lit avec le même schéma, donc avec la même transformation. Un contenu enregistré avant la règle est assaini au moment où il est affiché ; il n’y a pas de migration de données à écrire, et rien à oublier.
Un transform Zod fait d’un schéma un traducteur : la valeur qui en sort n’est pas « la valeur reçue, vérifiée », mais « la valeur qu’on accepte de manipuler ». Le reste du code ne voit jamais l’autre.
Un seul rendu pour tous les blocs#
Trois blocs rendent du texte riche (tiptap, media-text, float-text), et deux aperçus de l’éditeur le font aussi. Chacun avait sa propre copie de la liste des extensions et de l’appel à generateHTML. Cinq copies, qui avaient déjà divergé : les deux aperçus ne déclaraient pas le nœud library-image, et generateHTML levait dès qu’un texte contenait une image — l’aperçu entier tombait.
Le rendu est désormais écrit une fois :
richTextHtml — du document assaini au HTML
Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.
// ⚠ Fichier introuvable : apps/web/src/features/page-builder/richTextHtml.ts// Il a été déplacé ou supprimé depuis la rédaction de ce chapitre.Le nœud des images a son propre renderHTML, sans composant React, qui lit l’URL calculée par le serveur :
Un fichier non validé, supprimé ou jeté donne null à la résolution, et le nœud n’émet qu’un élément caché : la page s’affiche sans l’image plutôt que de montrer une image cassée. Le texte de la légende devient l’alt de l’image, et Tiptap l’échappe comme tout attribut.
Les commentaires : l’autre stratégie#
Les commentaires des actualités sont aussi des documents ProseMirror, écrits par n’importe quel membre connecté. Ils n’utilisent pas generateHTML : un petit rendu React parcourt le document et ne fabrique que des éléments qu’il connaît.
Pas de HTML brut du tout : React échappe tout, et un lien n’est posé que si son adresse est sûre. La contrepartie est la pauvreté : paragraphes, gras, italique, liens. C’est suffisant pour un commentaire, pas pour une page.
col_0 | Pages et présentations | Commentaires |
|---|---|---|
Mise en forme | Riche : titres, listes, alignement, surlignage, images. | Minimale : gras, italique, liens. |
Rendu | generateHTML, puis dangerouslySetInnerHTML. | Des éléments React, sans HTML brut. |
Protection | Liste blanche appliquée au document avant le rendu. | Liste blanche appliquée par le rendu lui-même. |
Showing entries1to3entries
Les deux sont des listes blanches ; elles diffèrent par l’endroit où elles s’appliquent. Quand le rendu doit être riche, on assainit la donnée ; quand il peut être pauvre, on écrit un rendu qui ne sait rien faire de dangereux.
Récapitulatif#
Question | Réponse du projet |
|---|---|
Qu’est-ce que le texte riche d’un bloc ? | Un document ProseMirror : des nœuds, des marques, des attributs. |
Comment devient-il du HTML ? | Par generateHTML et les renderHTML des extensions, puis dangerouslySetInnerHTML. |
Qu’est-ce qui était dangereux ? | Les attributs recopiés sans vérification (style, lien), et un nœud inconnu qui faisait lever le rendu. |
Comment est-il assaini ? | Par une liste blanche (richText.ts) qui reconstruit chaque nœud, marque et attribut à partir des seules valeurs valides. |
Quand ? | À l’entrée (le transform du schéma) et à la lecture (parsePageContentV1, puis richTextHtml) : un ancien contenu est assaini à l’affichage. |
Combien de rendus du texte riche ? | Un seul, richTextHtml, pour les trois blocs et les deux aperçus ; un bloc qui échoue est rendu vide. |
Showing entries1to6entries
À suivre#
Le texte riche est sûr. Il reste à voir comment chaque type de bloc trouve son éditeur dans le tableau de bord et son rendu sur le site, et pourquoi le compilateur refuse un bloc qui n’aurait pas les deux.
Chapitre suivant : le registre et l’éditeur. La définition d’un bloc, un registre que le compilateur vérifie, un éditeur contrôlé, les aperçus, et le dossier où s’ouvre le sélecteur de fichiers.