Informations : éditeur visuel à la place de la syntaxe Markdown

La barre d'outils écrivait des étoiles dans un textarea : l'adhérent qui
rédigeait devait connaître une syntaxe pour obtenir du gras. On édite
désormais comme dans un traitement de texte — on sélectionne, on clique sur
G, le texte devient gras à l'écran.

Le format stocké ne change pas d'un octet. C'est tout l'enjeu : le WYSIWYG
porte sur la saisie, pas sur le stockage. À chaque frappe, le navigateur
retraverse ce qu'il a édité (serializeToRichText) et n'en garde que le gras,
l'italique, les listes, les sous-titres et les liens ; le balisage part dans
un champ caché et le serveur le ré-analyse avec l'analyseur existant. Le HTML
de l'éditeur ne quitte jamais la page, donc aucun assainisseur HTML n'entre
dans le produit — le risque n'a jamais été la surface d'édition, mais la base.

- rich-text-dom.ts : sérialiseur pur, testable sans navigateur (MinimalNode).
  Il lit aussi le gras codé en style CSS, pour qu'un brouillon collé depuis
  Google Docs ne perde pas sa mise en forme ; le style explicite l'emporte sur
  la balise, sinon le <b style="font-weight:normal"> dont Docs enveloppe tout
  document mettrait le texte entier en gras.
- Un collage passe par DOMParser, dont le document est inerte : un <img
  onerror> collé ne s'exécute pas. Seul du HTML produit par nous est réinjecté.
- L'italique s'écrit _ainsi_ : **_x_** est lisible par l'analyseur là où ***x***
  serait ambigu. Un tiret bas au milieu d'un mot n'ouvre rien.
- saveInformation normalise les CRLF que l'encodage des formulaires introduit.

Vérifié au navigateur contre une base réelle : aller-retour identique après
enregistrement, et un collage hostile (script, img onerror, iframe, style,
lien javascript:) ressort en texte structuré sans qu'une seule alerte parte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP
This commit is contained in:
Claude committed 2026-09-15 06:37:52 +00:00
1 parent 90b121a3c1
commit cc33cf5fbc
10 files changed
+956 -91

No files matched your search

+34 -10
View File
@@ -206,7 +206,9 @@ même raison.
journal, limitation des connexions, chiffrement des secrets, coordonnées du
référent, **mots de passe temporaires jamais mis en file**, secrets de
messagerie jamais renvoyés au navigateur, aucune copie cachée dans la chaîne
d'envoi, et rien rendu en HTML brut côté informations.
d'envoi, rien rendu en HTML brut côté informations, et **le HTML de l'éditeur
visuel qui ne quitte jamais la page** (formulaire à champ caché, collage passé
par `DOMParser`, réinjection limitée à `richTextToEditorHtml`).
## Référencement (SEO)
@@ -309,16 +311,38 @@ porte ce que publie le bureau depuis `/backend/informations`. Deux tables :
`information_reads`.
- **Texte riche** : `src/lib/rich-text.ts` est **pur** (`tests/rich-text.test.ts`)
et analyse un sous-ensemble de Markdown — `**gras**`, `*italique*`, `- puce`,
`1. numéro`, `[texte](https://…)`, `## sous-titre`. Deux rendus, un seul
et analyse un sous-ensemble de Markdown — `**gras**`, `_italique_`, `- puce`,
`1. numéro`, `[texte](https://…)`, `## sous-titre`. Trois rendus, un seul
analyseur : `richTextNodes()` pour l'écran (des éléments React, jamais
`dangerouslySetInnerHTML`), `richTextToEmailHtml()` pour le message. L'aperçu
du formulaire passe par le premier, il est donc fidèle par construction. Un
lien hors `http`/`https` perd sa cible et ne garde que son libellé
(`safeHttpUrl`, partagé avec `email-templates.ts`).
- **Pas de WYSIWYG** : il produirait du HTML, qu'il faudrait stocker puis
assainir — seconde dépendance, seconde surface d'attaque. La saisie est un
`<textarea>` avec une barre qui encadre la sélection (`setRangeText`).
`dangerouslySetInnerHTML`), `richTextToEmailHtml()` pour le message,
`richTextToEditorHtml()` pour remplir l'éditeur. Un lien hors `http`/`https`
perd sa cible et ne garde que son libellé (`safeHttpUrl`, partagé avec
`email-templates.ts`). L'italique s'écrit avec des tirets bas : `***x***`
serait ambigu pour l'analyseur, `**_x_**` ne l'est pas ; `*étoiles*` reste
accepté en lecture, et un tiret bas au milieu d'un mot
(`fichier_de_sauvegarde`) n'ouvre rien.
- **La syntaxe ne se montre jamais** : `RichTextEditor` est un
`contentEditable` avec une barre d'outils (G, I, Sous-titre, listes, lien).
L'adhérent qui rédige voit du gras, pas des étoiles. Le balisage voyage dans
un `<input type="hidden">`.
- **Le WYSIWYG porte sur la saisie, pas sur le stockage.** C'est l'invariant à
ne pas casser : à chaque frappe, `src/lib/rich-text-dom.ts`
(`serializeToRichText`, **pur**, `tests/rich-text-dom.test.ts`) retraverse le
contenu édité et n'en garde que le gras, l'italique, les listes, les
sous-titres et les liens. Le HTML de l'éditeur ne quitte jamais la page : la
base ne reçoit que le format balisé, que le serveur ré-analyse avec le même
analyseur qu'avant. Aucun assainisseur HTML n'est donc nécessaire.
Le module lit aussi le gras/italique **codés en style CSS**
(`<span style="font-weight:700">` de Google Docs), le style explicite
l'emportant sur la balise — sans quoi le `<b style="font-weight:normal">` dont
Docs enveloppe tout document mettrait le texte entier en gras.
- **Un collage est analysé par `DOMParser`**, jamais par une affectation
d'`innerHTML` : le document produit est inerte, donc un `<img onerror>` collé
ne s'exécute pas. Ce qui est réinjecté dans l'éditeur est du HTML **produit
par nous** (`richTextToEditorHtml`), jamais celui du presse-papier.
- **Les sauts de ligne sont normalisés à l'enregistrement** : l'encodage des
formulaires HTML convertit tout `\n` en `\r\n`, et le balisage stocké
différerait sinon de celui que l'éditeur a produit (`saveInformation`).
- **Une seule image**, en couverture, jamais dans le corps : une data-URI
recopiée dans chaque ligne de la file pèserait 3 Mo par destinataire. Elle est
servie aux clients mail par `/api/informations/[id]/image`, qui ne répond que
+3 -1
View File
@@ -1449,7 +1449,9 @@ export async function saveInformation(
const id = Number(formData.get("id") ?? 0) || null;
const title = asString(formData, "title");
const body = asString(formData, "body");
// L'encodage des formulaires HTML convertit tout saut de ligne en CRLF :
// sans cela le balisage stocké différerait de celui que l'éditeur a produit.
const body = asString(formData, "body").replace(/\r\n?/g, "\n");
if (!title) return { error: "Donnez un titre à cette information." };
if (!body) return { error: "Le message est vide." };
@@ -1,19 +1,23 @@
"use client";
import { useRef, useState } from "react";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ImageField } from "@/components/ImageField";
import { richTextNodes } from "@/lib/rich-text";
import { RichTextEditor } from "@/components/RichTextEditor";
import { saveInformation } from "../actions";
/**
* Rédaction d'une information.
*
* Pas d'éditeur WYSIWYG : un `<textarea>` et des boutons qui encadrent la
* sélection, plus un aperçu rendu par **le même** analyseur que le site
* (`richTextNodes`). L'aperçu est donc fidèle par construction, et rien de ce
* qui est saisi ne peut devenir du HTML.
* L'édition est **visuelle** : on sélectionne, on clique sur « G », le texte
* devient gras à l'écran. Aucune syntaxe n'est montrée — un rédacteur
* d'association n'a pas à connaître Markdown.
*
* Ce qui est enregistré reste malgré tout le format balisé restreint : c'est
* `RichTextEditor` qui resérialise à chaque frappe. Aucun HTML tiers n'atteint
* la base, et l'éditeur est son propre aperçu — il n'y a plus deux rendus à
* comparer.
*/
type Draft = {
@@ -24,18 +28,8 @@ type Draft = {
pinned: boolean;
};
const TOOLS = [
{ label: "B", title: "Gras", before: "**", after: "**", sample: "texte en gras", strong: true },
{ label: "I", title: "Italique", before: "*", after: "*", sample: "texte en italique", italic: true },
{ label: "Titre", title: "Sous-titre", before: "## ", after: "", sample: "Sous-titre", line: true },
{ label: "• liste", title: "Liste à puces", before: "- ", after: "", sample: "premier point", line: true },
{ label: "1. liste", title: "Liste numérotée", before: "1. ", after: "", sample: "première étape", line: true },
{ label: "Lien", title: "Lien", before: "[", after: "](https://)", sample: "libellé du lien" },
] as const;
export function InformationForm({ draft }: { draft: Draft }) {
const router = useRouter();
const bodyRef = useRef<HTMLTextAreaElement>(null);
const [title, setTitle] = useState(draft.title);
const [body, setBody] = useState(draft.body);
const [pinned, setPinned] = useState(draft.pinned);
@@ -45,19 +39,6 @@ export function InformationForm({ draft }: { draft: Draft }) {
// seul un changement de clé le remet à vide.
const [round, setRound] = useState(0);
function applyTool(tool: (typeof TOOLS)[number]) {
const field = bodyRef.current;
if (!field) return;
const { selectionStart, selectionEnd } = field;
const selected = field.value.slice(selectionStart, selectionEnd) || tool.sample;
// `setRangeText` conserve la pile d'annulation du navigateur, ce qu'une
// réécriture complète de la valeur ferait perdre.
const insert = "line" in tool && tool.line && selectionStart > 0 && field.value[selectionStart - 1] !== "\n";
field.setRangeText(`${insert ? "\n" : ""}${tool.before}${selected}${tool.after}`, selectionStart, selectionEnd, "select");
field.focus();
setBody(field.value);
}
async function submit(formData: FormData) {
setSaving(true);
setError(null);
@@ -99,44 +80,18 @@ export function InformationForm({ draft }: { draft: Draft }) {
style={{ marginBottom: 15, fontWeight: 700 }}
/>
<label className="field-label" htmlFor="information-body">Message</label>
<div
style={{
display: "flex", alignItems: "center", gap: 3, flexWrap: "wrap",
border: "1px solid #ddd2bb", borderBottom: "none", borderRadius: "10px 10px 0 0",
background: "#fff", padding: "6px 7px",
}}
>
{TOOLS.map((tool) => (
<button
key={tool.label}
type="button"
title={tool.title}
onClick={() => applyTool(tool)}
style={{
minWidth: 30, height: 28, padding: "0 9px", borderRadius: 7, border: "none", background: "transparent",
color: "#6c6150", fontSize: 12.5, fontWeight: 700, cursor: "pointer",
fontStyle: "italic" in tool && tool.italic ? "italic" : undefined,
}}
>
{tool.label}
</button>
))}
</div>
<textarea
id="information-body"
ref={bodyRef}
className="field"
name="body"
rows={10}
value={body}
onChange={(event) => setBody(event.target.value)}
placeholder={"Rédigez ici. Laissez une ligne vide entre deux paragraphes.\n\n- une puce\n**du gras**, [un lien](https://pleinr.fr)"}
style={{ borderRadius: "0 0 10px 10px", resize: "vertical", lineHeight: 1.7 }}
<label className="field-label" id="information-body-label">Message</label>
<RichTextEditor
value={draft.body}
onChange={setBody}
ariaLabel="Message de l'information"
placeholder="Rédigez votre message. Sélectionnez du texte puis cliquez sur G pour le mettre en gras."
/>
{/* Le format balisé ne se montre pas : il voyage dans un champ caché. */}
<input type="hidden" name="body" value={body} />
<p className="field-hint" style={{ marginTop: 6 }}>
Mise en forme simple : <strong>**gras**</strong>, <em>*italique*</em>, <code>- puce</code>,{" "}
<code>[libellé](https://…)</code>. Les liens s&apos;ouvrent dans un nouvel onglet.
Gras, italique, sous-titres, listes et liens. Le collage depuis un traitement de texte
conserve la mise en forme reconnue et laisse tomber le reste.
</p>
<div style={{ marginTop: 16 }}>
@@ -153,20 +108,6 @@ export function InformationForm({ draft }: { draft: Draft }) {
</span>
</label>
{body.trim() && (
<div style={{ marginTop: 18, borderTop: "1px solid #f0e8d6", paddingTop: 16 }}>
<div style={{ fontSize: 11, letterSpacing: "0.1em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800, marginBottom: 10 }}>
Aperçu
</div>
<div style={{ background: "#faf7ef", border: "1px solid #e6dcc6", borderRadius: 12, padding: "14px 16px" }}>
<h3 className="font-display" style={{ margin: "0 0 8px", fontWeight: 700, fontSize: 17, color: "#26201a" }}>
{title || "Sans titre"}
</h3>
<div className="rich-text">{richTextNodes(body)}</div>
</div>
</div>
)}
{error && (
<div role="alert" style={{ marginTop: 14, color: "#d8472b", fontSize: 13, fontWeight: 700 }}>
{error}
+86
View File
@@ -1039,6 +1039,92 @@ img {
text-underline-offset: 2px;
}
/* Éditeur visuel des informations : ce qu'on voit est ce qui sera publié. La
zone reprend `.rich-text` pour le rythme des paragraphes et des listes, de
sorte que l'éditeur est son propre aperçu. */
.rich-editor-toolbar {
display: flex;
align-items: center;
gap: 3px;
flex-wrap: wrap;
border: 1px solid var(--field-border);
border-bottom: none;
border-radius: 10px 10px 0 0;
background: #fff;
padding: 6px 7px;
}
.rich-editor-tool {
min-width: 30px;
height: 28px;
padding: 0 9px;
border: none;
border-radius: 7px;
background: transparent;
color: var(--muted);
font-family: inherit;
font-size: 12.5px;
font-weight: 700;
cursor: pointer;
transition: background 0.12s ease, color 0.12s ease;
}
.rich-editor-tool:hover {
background: var(--cream-3);
color: var(--ink-strong);
}
.rich-editor-tool.is-active {
background: var(--gold);
color: #33291d;
}
.rich-editor-separator {
width: 1px;
height: 18px;
margin: 0 5px;
background: var(--border-2);
}
.rich-editor-link {
display: flex;
align-items: center;
gap: 7px;
flex-wrap: wrap;
border: 1px solid var(--field-border);
border-bottom: none;
background: var(--cream);
padding: 8px 9px;
}
.rich-editor-link .field {
flex: 1;
min-width: 180px;
background: #fff;
}
.rich-editor {
position: relative;
min-height: 190px;
border: 1px solid var(--field-border);
border-radius: 0 0 10px 10px;
background: var(--field-bg);
padding: 13px 15px;
overflow-y: auto;
cursor: text;
}
.rich-editor:focus {
outline: 2px solid rgba(44, 111, 179, 0.35);
outline-offset: 1px;
}
/* Le texte d'invite s'efface dès la première frappe : `:empty` ne suffit pas,
le navigateur laisse un paragraphe vide dans la zone. Il est posé en
superposition et non à la place du paragraphe : masquer celui-ci ferait
tomber le curseur à côté, et le navigateur écrirait du texte nu hors de tout
bloc. */
.rich-editor[data-empty="true"]::before {
content: attr(data-placeholder);
position: absolute;
left: 15px;
right: 15px;
top: 13px;
color: var(--muted-4);
pointer-events: none;
}
/* Rédaction d'une information : éditeur et listes côte à côte, empilés dès
que la colonne de droite n'a plus la place d'être lisible. */
@media (max-width: 1100px) {
+262
View File
@@ -0,0 +1,262 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { richTextToEditorHtml } from "@/lib/rich-text";
import { serializeToRichText } from "@/lib/rich-text-dom";
/**
* Éditeur visuel : on écrit, on sélectionne, on clique sur **G**, et le texte
* devient gras à l'écran. Aucune syntaxe à connaître.
*
* Ce qui part au serveur reste pourtant le format balisé restreint : à chaque
* frappe, `serializeToRichText` retraverse le contenu édité et n'en garde que
* le gras, l'italique, les listes, les sous-titres et les liens. Le HTML de
* l'éditeur ne quitte jamais la page — c'était là, et non dans l'édition
* visuelle, que se trouvait le risque.
*
* `document.execCommand` est officiellement déprécié mais reste implémenté par
* tous les navigateurs, et c'est la seule façon d'obtenir une édition riche
* sans embarquer un moteur d'édition complet. Le jour où il faudrait en
* changer, seul ce fichier bougerait : le contrat de stockage est le
* sérialiseur, pas l'éditeur.
*/
type Tool = {
key: string;
label: string;
title: string;
command: string;
argument?: string;
/** Nom d'état interrogé pour allumer le bouton quand le curseur est dedans. */
state?: string;
render?: "bold" | "italic";
};
const TOOLS: Tool[] = [
{ key: "bold", label: "G", title: "Gras (Ctrl+B)", command: "bold", state: "bold", render: "bold" },
{ key: "italic", label: "I", title: "Italique (Ctrl+I)", command: "italic", state: "italic", render: "italic" },
{ key: "h4", label: "Sous-titre", title: "Transformer la ligne en sous-titre", command: "formatBlock", argument: "<h4>" },
{ key: "ul", label: "Liste à puces", title: "Liste à puces", command: "insertUnorderedList", state: "insertUnorderedList" },
{ key: "ol", label: "Liste numérotée", title: "Liste numérotée", command: "insertOrderedList", state: "insertOrderedList" },
];
export function RichTextEditor({
value,
onChange,
placeholder = "Rédigez votre message…",
ariaLabel = "Message",
}: {
value: string;
onChange: (markup: string) => void;
placeholder?: string;
ariaLabel?: string;
}) {
const editorRef = useRef<HTMLDivElement>(null);
const [active, setActive] = useState<Record<string, boolean>>({});
const [linkUrl, setLinkUrl] = useState<string | null>(null);
const savedRange = useRef<Range | null>(null);
const sync = useCallback(() => {
const editor = editorRef.current;
if (!editor) return;
editor.dataset.empty = String(editor.textContent?.trim().length === 0);
onChange(serializeToRichText(editor));
}, [onChange]);
// Contenu initial posé une seule fois : réécrire le HTML à chaque frappe
// replacerait le curseur au début de la zone.
useEffect(() => {
const editor = editorRef.current;
if (!editor) return;
editor.innerHTML = richTextToEditorHtml(value);
editor.dataset.empty = String(editor.textContent?.trim().length === 0);
try {
// Sans cela, Chrome écrit `<span style="font-weight:bold">` au lieu de
// `<b>` — le sérialiseur, qui ne lit pas les styles, perdrait le gras.
document.execCommand("styleWithCSS", false, "false");
document.execCommand("defaultParagraphSeparator", false, "p");
} catch {
/* Navigateur sans execCommand : la saisie reste possible, sans mise en forme. */
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Les boutons s'allument selon l'endroit où se trouve le curseur.
useEffect(() => {
function refresh() {
const editor = editorRef.current;
if (!editor || !editor.contains(document.getSelection()?.anchorNode ?? null)) return;
const next: Record<string, boolean> = {};
for (const tool of TOOLS) {
if (!tool.state) continue;
try {
next[tool.key] = document.queryCommandState(tool.state);
} catch {
next[tool.key] = false;
}
}
setActive(next);
}
document.addEventListener("selectionchange", refresh);
return () => document.removeEventListener("selectionchange", refresh);
}, []);
function run(command: string, argument?: string) {
editorRef.current?.focus();
try {
document.execCommand(command, false, argument);
} catch {
/* Commande refusée : on laisse le texte tel quel. */
}
sync();
}
function applyTool(tool: Tool) {
// Re-cliquer sur « Sous-titre » revient à un paragraphe ordinaire.
if (tool.command === "formatBlock" && currentBlock() === "H4") {
run("formatBlock", "<p>");
return;
}
run(tool.command, tool.argument);
}
function currentBlock(): string {
try {
return String(document.queryCommandValue("formatBlock") ?? "").toUpperCase();
} catch {
return "";
}
}
function openLink() {
const selection = document.getSelection();
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
setLinkUrl("");
savedRange.current = null;
return;
}
// La sélection est perdue dès que le champ d'adresse prend le focus :
// on la met de côté pour la retrouver à la validation.
savedRange.current = selection.getRangeAt(0).cloneRange();
setLinkUrl("https://");
}
function confirmLink() {
const url = (linkUrl ?? "").trim();
setLinkUrl(null);
if (!url || !savedRange.current) return;
const selection = document.getSelection();
selection?.removeAllRanges();
selection?.addRange(savedRange.current);
savedRange.current = null;
// Une adresse non http(s) est écartée par le sérialiseur : le libellé
// reste, le lien disparaît.
run("createLink", url);
}
function onPaste(event: React.ClipboardEvent<HTMLDivElement>) {
event.preventDefault();
const html = event.clipboardData.getData("text/html");
const text = event.clipboardData.getData("text/plain");
if (html) {
// Le HTML collé — d'un Word, d'une page web — est analysé dans un
// document **inerte** : `DOMParser` n'exécute rien et ne charge aucune
// ressource, contrairement à une affectation d'`innerHTML`. On le
// repasse ensuite par notre format, donc seule la mise en forme connue
// survit ; on ne réinjecte que du HTML produit par nous.
const parsed = new DOMParser().parseFromString(html, "text/html");
run("insertHTML", richTextToEditorHtml(serializeToRichText(parsed.body)));
return;
}
run("insertText", text);
}
return (
<div>
<div className="rich-editor-toolbar" role="toolbar" aria-label="Mise en forme">
{TOOLS.map((tool) => (
<button
key={tool.key}
type="button"
title={tool.title}
aria-pressed={active[tool.key] ?? false}
onMouseDown={(event) => event.preventDefault()}
onClick={() => applyTool(tool)}
className={`rich-editor-tool${active[tool.key] ? " is-active" : ""}`}
style={{
fontWeight: tool.render === "bold" ? 800 : undefined,
fontStyle: tool.render === "italic" ? "italic" : undefined,
}}
>
{tool.label}
</button>
))}
<span className="rich-editor-separator" />
<button
type="button"
title="Insérer un lien sur le texte sélectionné"
onMouseDown={(event) => event.preventDefault()}
onClick={openLink}
className="rich-editor-tool"
>
Lien
</button>
<button
type="button"
title="Retirer la mise en forme de la sélection"
onMouseDown={(event) => event.preventDefault()}
onClick={() => run("removeFormat")}
className="rich-editor-tool"
>
Effacer la mise en forme
</button>
</div>
{linkUrl !== null && (
<div className="rich-editor-link">
{savedRange.current ? (
<>
<input
className="field"
value={linkUrl}
autoFocus
placeholder="https://…"
onChange={(event) => setLinkUrl(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
confirmLink();
}
if (event.key === "Escape") setLinkUrl(null);
}}
/>
<button type="button" className="rich-editor-tool" onClick={confirmLink}>Ajouter</button>
<button type="button" className="rich-editor-tool" onClick={() => setLinkUrl(null)}>Annuler</button>
</>
) : (
<>
<span className="field-hint">Sélectionnez d&apos;abord le texte à transformer en lien.</span>
<button type="button" className="rich-editor-tool" onClick={() => setLinkUrl(null)}>Fermer</button>
</>
)}
</div>
)}
<div
ref={editorRef}
className="rich-editor rich-text"
contentEditable
suppressContentEditableWarning
role="textbox"
aria-multiline="true"
aria-label={ariaLabel}
data-placeholder={placeholder}
onInput={sync}
onBlur={sync}
onPaste={onPaste}
/>
</div>
);
}
+190
View File
@@ -0,0 +1,190 @@
import { safeHttpUrl } from "@/lib/rich-text";
/**
* Conversion d'un contenu édité visuellement vers le format balisé stocké.
*
* C'est la pièce qui permet un éditeur « comme dans Word » sans renoncer à
* l'invariant du produit : ce qui est **stocké** reste le sous-ensemble
* restreint de `src/lib/rich-text.ts`, jamais du HTML tiers. Le danger n'a
* jamais été la surface d'édition, mais la base de données — alors le
* navigateur retraverse ce qu'il a édité et n'en garde que le gras,
* l'italique, les listes, les sous-titres et les liens. Tout le reste — styles
* de Word, `<span>`, `<script>`, attributs d'événement — disparaît avant même
* de quitter la page.
*
* Le module est **pur** (`tests/rich-text-dom.test.ts`) : il ne connaît du DOM
* que `MinimalNode`, ce qui le rend testable sans navigateur.
*/
/** Ce que le sérialiseur a besoin de savoir d'un nœud. */
export type MinimalNode = {
nodeType: number;
nodeName: string;
textContent?: string | null;
childNodes: ArrayLike<MinimalNode>;
getAttribute?: (name: string) => string | null;
};
const ELEMENT_NODE = 1;
const TEXT_NODE = 3;
/** Éléments qui ouvrent une nouvelle ligne dans le format balisé. */
const BLOCKS = new Set(["P", "DIV", "LI", "BLOCKQUOTE", "H1", "H2", "H3", "H4", "H5", "H6", "SECTION", "ARTICLE"]);
/** Éléments dont le contenu ne doit jamais être lu, même comme texte. */
const DROPPED = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "IFRAME", "OBJECT", "SVG"]);
type LineKind = "paragraph" | "heading" | "bullet" | "ordered";
type Line = { kind: LineKind; text: string };
function nodeName(node: MinimalNode): string {
return node.nodeType === ELEMENT_NODE ? String(node.nodeName ?? "").toUpperCase() : "";
}
function children(node: MinimalNode): MinimalNode[] {
return Array.from(node.childNodes ?? []);
}
/** Texte d'un nœud, espaces insécables de l'éditeur ramenés à des espaces. */
function plain(value: string | null | undefined): string {
return String(value ?? "").replace(/\u00a0/g, " ").replace(/\r/g, "");
}
/** Contenu en ligne d'un nœud : gras, italique, liens, sauts de ligne. */
function inline(node: MinimalNode): string {
if (node.nodeType === TEXT_NODE) return plain(node.textContent);
if (node.nodeType !== ELEMENT_NODE) return "";
const name = nodeName(node);
if (DROPPED.has(name)) return "";
if (name === "BR") return "\n";
const inner = children(node).map(inline).join("");
// Un marqueur autour d'une sélection vide produirait « **** », que
// l'analyseur relirait comme du texte littéral.
if (!inner.trim()) return inner;
if (name === "A") {
const href = safeHttpUrl(node.getAttribute?.("href") ?? "");
return href ? `[${inner}](${href})` : inner;
}
const style = node.getAttribute?.("style") ?? "";
let marked = inner;
// L'italique en premier : le gras l'enveloppe, jamais l'inverse.
if (isItalic(name, style) && !wrapped(marked, "_")) marked = `_${marked}_`;
if (isBold(name, style) && !wrapped(marked, "**")) marked = `**${marked}**`;
return marked;
}
/** Déjà encadré par ce marqueur : ne pas le doubler. */
function wrapped(value: string, marker: string): boolean {
return value.startsWith(marker) && value.endsWith(marker) && value.length > marker.length * 2;
}
/**
* Un traitement de texte code souvent le gras en style plutôt qu'en balise :
* Google Docs écrit `<span style="font-weight:700">`. Sans cela, coller un
* brouillon rédigé ailleurs perdrait silencieusement toute sa mise en forme.
*
* Le style explicite l'emporte sur la balise, parce que Google Docs enveloppe
* aussi tout le document dans un `<b style="font-weight:normal">` — le prendre
* au mot mettrait le compte rendu entier en gras.
*/
function isBold(name: string, style: string): boolean {
if (/font-weight\s*:\s*(normal|[1-5]00)\b/i.test(style)) return false;
return name === "STRONG" || name === "B" || /font-weight\s*:\s*(bold(er)?|[6-9]00)\b/i.test(style);
}
/**
* L'italique est marqué par des tirets bas et non des étoiles :
* « **_gras italique_** » reste lisible par l'analyseur, là où « ***…*** »
* serait ambigu.
*/
function isItalic(name: string, style: string): boolean {
if (/font-style\s*:\s*normal\b/i.test(style)) return false;
return name === "EM" || name === "I" || /font-style\s*:\s*italic\b/i.test(style);
}
function push(lines: Line[], kind: LineKind, text: string) {
// Les espaces de bord disparaissent, les sauts de ligne internes restent :
// ce sont eux qui portent les retours à la ligne dans un paragraphe.
const cleaned = text.replace(/[ \t]+\n/g, "\n").replace(/\n[ \t]+/g, "\n").trim();
if (cleaned) lines.push({ kind, text: cleaned });
}
function walk(node: MinimalNode, lines: Line[], ordered: boolean) {
let buffer = "";
const flush = () => {
push(lines, "paragraph", buffer);
buffer = "";
};
for (const child of children(node)) {
const name = nodeName(child);
if (DROPPED.has(name)) continue;
if (name === "UL" || name === "OL") {
flush();
walk(child, lines, name === "OL");
continue;
}
if (name === "LI") {
flush();
push(lines, ordered ? "ordered" : "bullet", inline(child));
continue;
}
if (BLOCKS.has(name)) {
flush();
// Un `<div>` d'éditeur peut contenir d'autres blocs plutôt que du texte.
if (children(child).some((grandChild) => {
const grandName = nodeName(grandChild);
return BLOCKS.has(grandName) || grandName === "UL" || grandName === "OL";
})) {
walk(child, lines, ordered);
} else {
push(lines, /^H[1-6]$/.test(name) ? "heading" : "paragraph", inline(child));
}
continue;
}
// Texte nu et éléments en ligne : ils appartiennent au paragraphe courant.
buffer += inline(child);
}
flush();
}
function isList(kind: LineKind): boolean {
return kind === "bullet" || kind === "ordered";
}
/**
* Sérialise un fragment édité vers le format balisé.
*
* `root` est typiquement la `<div contenteditable>` de l'éditeur, ou le
* `body` d'un document inerte produit par `DOMParser` lors d'un collage.
*/
export function serializeToRichText(root: MinimalNode): string {
const lines: Line[] = [];
walk(root, lines, false);
let counter = 0;
return lines
.map((line, index) => {
const previous = lines[index - 1];
const continues = previous && isList(previous.kind) && previous.kind === line.kind;
counter = line.kind === "ordered" ? (continues ? counter + 1 : 1) : 0;
const prefix =
line.kind === "heading" ? "## " : line.kind === "bullet" ? "- " : line.kind === "ordered" ? `${counter}. ` : "";
// Les éléments d'une même liste se suivent ; tout le reste est séparé par
// une ligne vide, qui marque un nouveau paragraphe.
const separator = index === 0 ? "" : continues ? "\n" : "\n\n";
return `${separator}${prefix}${line.text}`;
})
.join("");
}
+81 -1
View File
@@ -11,10 +11,15 @@ import { createElement, Fragment } from "react";
* produirait du HTML, qu'il faudrait stocker puis assainir : deuxième
* dépendance, deuxième surface d'attaque.
*
* Grammaire : `**gras**`, `*italique*`, `- puce`, `1. numéro`,
* Grammaire : `**gras**`, `*italique*` ou `_italique_`, `- puce`, `1. numéro`,
* `[texte](https://…)`, `## sous-titre`, ligne vide = paragraphe.
* Un saut de ligne simple reste un saut de ligne.
*
* Ce balisage n'est **pas** montré à l'utilisateur : il écrit dans un éditeur
* visuel (`src/components/RichTextEditor.tsx`) et c'est le navigateur qui
* resérialise vers ce format (`src/lib/rich-text-dom.ts`) avant l'envoi. Le
* format reste donc le contrat de stockage, jamais une syntaxe à apprendre.
*
* Deux rendus, un seul analyseur : `richTextNodes()` pour l'écran,
* `richTextToEmailHtml()` pour le message. L'aperçu du formulaire passe par
* `richTextNodes()`, il est donc fidèle par construction.
@@ -46,6 +51,19 @@ export function safeHttpUrl(value: string): string {
}
}
/** Vrai en bord de mot : début, fin, espace ou ponctuation. */
function isWordEdge(char: string | undefined): boolean {
return char === undefined || !/[\p{L}\p{N}]/u.test(char);
}
/** Position du tiret bas fermant, ou -1 s'il n'y en a pas en bord de mot. */
function closingUnderscore(text: string, from: number): number {
for (let index = from; index < text.length; index += 1) {
if (text[index] === "_" && isWordEdge(text[index + 1])) return index;
}
return -1;
}
function parseInline(text: string): Inline[] {
const nodes: Inline[] = [];
let buffer = "";
@@ -77,6 +95,19 @@ function parseInline(text: string): Inline[] {
continue;
}
}
// Tiret bas accepté comme italique — c'est ce qu'émet le sérialiseur, pour
// éviter l'ambiguïté de « ***gras italique*** ». Il ne compte qu'aux
// frontières de mot, sans quoi « fichier_de_sauvegarde » deviendrait
// italique en son milieu.
if (text[index] === "_" && isWordEdge(text[index - 1])) {
const end = closingUnderscore(text, index + 1);
if (end > index + 1) {
flush();
nodes.push({ kind: "em", children: parseInline(text.slice(index + 1, end)) });
index = end + 1;
continue;
}
}
if (text[index] === "[") {
const close = text.indexOf("]", index + 1);
if (close > index && text[close + 1] === "(") {
@@ -316,3 +347,52 @@ export function richTextExcerpt(source: string, max = 160): string {
const lastSpace = cut.lastIndexOf(" ");
return `${(lastSpace > max * 0.6 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`;
}
// ---- Rendu vers l'éditeur visuel ----
function inlineEditorHtml(nodes: Inline[]): string {
return nodes
.map((node) => {
switch (node.kind) {
case "break":
return "<br>";
case "strong":
return `<strong>${inlineEditorHtml(node.children)}</strong>`;
case "em":
return `<em>${inlineEditorHtml(node.children)}</em>`;
case "link":
return `<a href="${esc(node.href)}">${inlineEditorHtml(node.children)}</a>`;
default:
return esc(node.value);
}
})
.join("");
}
/**
* HTML minimal posé dans la zone d'édition visuelle.
*
* Produit exactement les balises que `serializeToRichText` sait relire, pour
* que l'aller-retour édition → stockage → édition soit stable. Un contenu vide
* rend un paragraphe avec un `<br>` : sans lui, le navigateur n'offre aucune
* ligne où poser le curseur.
*/
export function richTextToEditorHtml(source: string): string {
const blocks = parseRichText(source);
if (blocks.length === 0) return "<p><br></p>";
return blocks
.map((block) => {
switch (block.kind) {
case "heading":
return `<h4>${inlineEditorHtml(block.children)}</h4>`;
case "bullets":
return `<ul>${block.items.map((item) => `<li>${inlineEditorHtml(item)}</li>`).join("")}</ul>`;
case "ordered":
return `<ol>${block.items.map((item) => `<li>${inlineEditorHtml(item)}</li>`).join("")}</ol>`;
default:
return `<p>${inlineEditorHtml(block.children) || "<br>"}</p>`;
}
})
.join("");
}
+204
View File
@@ -0,0 +1,204 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { serializeToRichText, type MinimalNode } from "../src/lib/rich-text-dom";
import { parseRichText, richTextToEmailHtml, richTextToPlain } from "../src/lib/rich-text";
/**
* Faux DOM minimal : le sérialiseur ne connaît du navigateur que `MinimalNode`,
* ce qui permet de le tester sans jsdom.
*/
function el(name: string, children: MinimalNode[] = [], attributes: Record<string, string> = {}): MinimalNode {
return {
nodeType: 1,
nodeName: name,
childNodes: children,
getAttribute: (key: string) => attributes[key] ?? null,
};
}
function txt(value: string): MinimalNode {
return { nodeType: 3, nodeName: "#text", textContent: value, childNodes: [] };
}
const root = (...children: MinimalNode[]) => el("DIV", children);
test("un paragraphe simple ressort tel quel", () => {
assert.equal(serializeToRichText(root(el("P", [txt("Bonjour à tous.")]))), "Bonjour à tous.");
});
test("deux paragraphes sont séparés par une ligne vide", () => {
const markup = serializeToRichText(root(el("P", [txt("Premier.")]), el("P", [txt("Second.")])));
assert.equal(markup, "Premier.\n\nSecond.");
assert.equal(parseRichText(markup).length, 2);
});
test("le gras et l'italique deviennent des marqueurs", () => {
assert.equal(
serializeToRichText(root(el("P", [txt("Rendez-vous "), el("STRONG", [txt("mardi")]), txt(" à "), el("EM", [txt("Pompey")])]))),
"Rendez-vous **mardi** à _Pompey_",
);
});
test("les balises B et I d'execCommand valent STRONG et EM", () => {
assert.equal(serializeToRichText(root(el("P", [el("B", [txt("a")]), el("I", [txt("b")])]))), "**a**_b_");
});
test("gras et italique imbriqués se relisent correctement", () => {
// C'est la raison du tiret bas : « ***x*** » serait ambigu pour l'analyseur.
const markup = serializeToRichText(root(el("P", [el("STRONG", [el("EM", [txt("très important")])])])));
assert.equal(markup, "**_très important_**");
const blocks = parseRichText(markup);
assert.equal(blocks[0].kind, "paragraph");
const strong = blocks[0].kind === "paragraph" ? blocks[0].children[0] : null;
assert.equal(strong?.kind, "strong");
assert.equal(strong?.kind === "strong" ? strong.children[0].kind : null, "em");
});
test("un saut de ligne dans un paragraphe est conservé", () => {
const markup = serializeToRichText(root(el("P", [txt("Une ligne"), el("BR"), txt("une autre")])));
assert.equal(markup, "Une ligne\nune autre");
assert.equal(parseRichText(markup).length, 1);
});
test("les listes à puces et numérotées prennent leur préfixe", () => {
assert.equal(
serializeToRichText(root(el("UL", [el("LI", [txt("un")]), el("LI", [txt("deux")])]))),
"- un\n- deux",
);
assert.equal(
serializeToRichText(root(el("OL", [el("LI", [txt("a")]), el("LI", [txt("b")]), el("LI", [txt("c")])]))),
"1. a\n2. b\n3. c",
);
});
test("la numérotation repart à un sur une seconde liste", () => {
const markup = serializeToRichText(
root(
el("OL", [el("LI", [txt("a")])]),
el("P", [txt("Entre les deux.")]),
el("OL", [el("LI", [txt("b")])]),
),
);
assert.equal(markup, "1. a\n\nEntre les deux.\n\n1. b");
});
test("un titre devient un sous-titre", () => {
assert.equal(serializeToRichText(root(el("H4", [txt("Ordre du jour")]))), "## Ordre du jour");
// Les autres niveaux de titre, venus d'un collage, se rabattent sur le même.
assert.equal(serializeToRichText(root(el("H1", [txt("Collé depuis le web")]))), "## Collé depuis le web");
});
test("un lien http(s) garde sa cible, les autres perdent la leur", () => {
assert.equal(
serializeToRichText(root(el("P", [el("A", [txt("le site")], { href: "https://pleinr.fr/" })]))),
"[le site](https://pleinr.fr/)",
);
assert.equal(
serializeToRichText(root(el("P", [el("A", [txt("piège")], { href: "javascript:alert(1)" })]))),
"piège",
);
assert.equal(serializeToRichText(root(el("P", [el("A", [txt("sans cible")])]))), "sans cible");
});
test("tout ce qui n'est pas reconnu perd sa balise et garde son texte", () => {
// Couleur, taille, police : rien de tout cela n'existe dans le format stocké.
const markup = serializeToRichText(
root(el("P", [el("SPAN", [txt("du texte")], { style: "color:red;font-size:32px" }), el("FONT", [txt(" coloré")], { color: "blue" })])),
);
assert.equal(markup, "du texte coloré");
});
test("le contenu exécutable est retiré, texte compris", () => {
const markup = serializeToRichText(
root(
el("SCRIPT", [txt("alert(1)")]),
el("STYLE", [txt("body{display:none}")]),
el("P", [txt("Bonjour"), el("IFRAME", [txt("piège")])]),
),
);
assert.equal(markup, "Bonjour");
assert.ok(!markup.includes("alert"));
assert.ok(!markup.includes("display:none"));
});
test("un collage de Word — div imbriqués et styles — se ramène au texte structuré", () => {
const pasted = root(
el("DIV", [
el("P", [el("SPAN", [txt("Compte rendu")], { style: "font-size:24px" })]),
el("UL", [el("LI", [el("SPAN", [el("B", [txt("Point 1")])])]), el("LI", [txt("Point 2")])]),
]),
);
assert.equal(serializeToRichText(pasted), "Compte rendu\n\n- **Point 1**\n- Point 2");
});
test("les espaces insécables de l'éditeur redeviennent des espaces", () => {
assert.equal(serializeToRichText(root(el("P", [txt("19\u00a0h\u00a0précises")]))), "19 h précises");
});
test("une mise en forme appliquée à une sélection vide ne produit pas de marqueur", () => {
// Chrome laisse un `<b></b>` quand on active le gras sans rien taper.
assert.equal(serializeToRichText(root(el("P", [el("B", []), txt("texte")]))), "texte");
assert.ok(!serializeToRichText(root(el("P", [el("B", [txt(" ")]), txt("x")]))).includes("**"));
});
test("un éditeur vide produit une chaîne vide", () => {
assert.equal(serializeToRichText(root()), "");
assert.equal(serializeToRichText(root(el("P", [el("BR")]))), "");
assert.equal(serializeToRichText(root(el("P", [txt(" ")]))), "");
});
test("l'aller-retour édition → stockage → rendu conserve le sens", () => {
const markup = serializeToRichText(
root(
el("H4", [txt("Assemblée générale")]),
el("P", [txt("Elle se tiendra "), el("STRONG", [txt("salle Jean-Jaurès")]), txt(".")]),
el("UL", [el("LI", [txt("comptes 2026")]), el("LI", [txt("budget")])]),
el("P", [el("A", [txt("le compte rendu")], { href: "https://pleinr.fr/association" })]),
),
);
assert.equal(
markup,
"## Assemblée générale\n\nElle se tiendra **salle Jean-Jaurès**.\n\n- comptes 2026\n- budget\n\n[le compte rendu](https://pleinr.fr/association)",
);
const html = richTextToEmailHtml(markup);
assert.ok(html.includes("<strong"));
assert.ok(html.includes("<ul"));
assert.ok(html.includes('href="https://pleinr.fr/association"'));
assert.ok(richTextToPlain(markup).includes("Assemblée générale"));
});
test("le gras codé en style CSS est reconnu — cas Google Docs", () => {
assert.equal(
serializeToRichText(root(el("P", [el("SPAN", [txt("important")], { style: "font-weight:700" })]))),
"**important**",
);
assert.equal(
serializeToRichText(root(el("P", [el("SPAN", [txt("nuance")], { style: "font-style: italic" })]))),
"_nuance_",
);
});
test("le style explicite l'emporte sur la balise", () => {
// Google Docs enveloppe tout le document dans un `<b style="font-weight:normal">`.
assert.equal(
serializeToRichText(root(el("B", [el("P", [txt("tout le document")])], { style: "font-weight:normal" }))),
"tout le document",
);
});
test("un marqueur n'est jamais doublé", () => {
assert.equal(
serializeToRichText(root(el("P", [el("B", [el("SPAN", [txt("x")], { style: "font-weight:bold" })])]))),
"**x**",
);
});
test("gras et italique combinés s'imbriquent dans le bon ordre", () => {
assert.equal(
serializeToRichText(root(el("P", [el("SPAN", [txt("les deux")], { style: "font-weight:700;font-style:italic" })]))),
"**_les deux_**",
);
});
+32
View File
@@ -5,6 +5,7 @@ import {
parseRichText,
richTextExcerpt,
richTextNodes,
richTextToEditorHtml,
richTextToEmailHtml,
richTextToPlain,
safeHttpUrl,
@@ -164,3 +165,34 @@ test("une saisie vide ne produit aucun bloc", () => {
assert.equal(richTextToEmailHtml(""), "");
assert.equal(richTextExcerpt(""), "");
});
test("le tiret bas vaut italique, mais seulement en bord de mot", () => {
assert.ok(richTextToEmailHtml("_doux_").includes("<em>doux</em>"));
assert.ok(richTextToEmailHtml("**_gras italique_**").includes("<em>gras italique</em>"));
// Un nom de fichier ne doit pas devenir italique en son milieu.
assert.equal(richTextToPlain("fichier_de_sauvegarde.pdf"), "fichier_de_sauvegarde.pdf");
assert.ok(!richTextToEmailHtml("fichier_de_sauvegarde.pdf").includes("<em>"));
assert.equal(richTextToPlain("un _ seul"), "un _ seul");
});
test("le rendu vers l'éditeur reprend les balises que le sérialiseur sait relire", () => {
const html = richTextToEditorHtml("## Titre\n\nDu **gras** et un [lien](https://pleinr.fr)\n\n- un\n- deux\n\n1. a");
assert.ok(html.includes("<h4>Titre</h4>"));
assert.ok(html.includes("<strong>gras</strong>"));
assert.ok(html.includes('<a href="https://pleinr.fr/">lien</a>'));
assert.ok(html.includes("<ul><li>un</li><li>deux</li></ul>"));
assert.ok(html.includes("<ol><li>a</li></ol>"));
});
test("un contenu vide offre une ligne où écrire", () => {
assert.equal(richTextToEditorHtml(""), "<p><br></p>");
assert.equal(richTextToEditorHtml(" "), "<p><br></p>");
});
test("le rendu vers l'éditeur échappe le texte et refuse les cibles hostiles", () => {
const html = richTextToEditorHtml('<script>alert(1)</script> & "guillemets"');
assert.ok(html.includes("&lt;script&gt;"));
assert.ok(html.includes("&amp;"));
assert.ok(!html.includes("<script"));
assert.ok(!richTextToEditorHtml("[x](javascript:alert(1))").includes("<a "));
});
+44
View File
@@ -277,6 +277,7 @@ describe("Informations — rien n'est rendu en HTML brut", () => {
"../src/lib/rich-text.ts",
"../src/components/InformationCard.tsx",
"../src/app/backend/informations/InformationForm.tsx",
"../src/components/RichTextEditor.tsx",
"../src/app/backend/espace/informations/page.tsx",
];
@@ -320,3 +321,46 @@ describe("Boucles de fond — jamais dans le bundle edge", () => {
assert.ok(worker.includes('process.env.MAIL_WORKER !== "off"'));
});
});
describe("Éditeur visuel — le HTML ne quitte jamais la page", () => {
const editor = readFileSync(new URL("../src/components/RichTextEditor.tsx", import.meta.url), "utf8");
const form = readFileSync(new URL("../src/app/backend/informations/InformationForm.tsx", import.meta.url), "utf8");
// L'édition est visuelle, mais ce qui part au serveur reste le format
// balisé restreint : c'est tout l'arrangement qui permet un éditeur
// confortable sans stocker de HTML tiers.
it("le formulaire envoie le balisage sérialisé, jamais du HTML", () => {
assert.ok(form.includes('<input type="hidden" name="body"'), "le corps doit voyager en champ caché");
assert.ok(!form.includes("innerHTML"), "le formulaire ne manipule pas de HTML");
assert.ok(editor.includes("serializeToRichText("), "l'éditeur doit resérialiser ce qu'il édite");
});
it("seul du HTML produit par nous est injecté dans la zone d'édition", () => {
// Toute écriture de HTML dans l'éditeur passe par notre générateur : jamais
// une chaîne venue du presse-papier ou du serveur.
for (const call of editor.match(/innerHTML\s*=\s*[^;\n]+/g) ?? []) {
assert.ok(call.includes("richTextToEditorHtml("), `HTML injecté sans générateur : ${call}`);
}
for (const call of editor.match(/insertHTML"[^)]*\)/g) ?? []) {
assert.ok(call.includes("richTextToEditorHtml("), `HTML inséré sans générateur : ${call}`);
}
});
it("le collage est analysé dans un document inerte", () => {
// `DOMParser` n'exécute rien et ne charge aucune ressource, contrairement
// à une affectation d'`innerHTML` — même sur un nœud détaché, un
// `<img onerror>` peut s'y déclencher.
assert.ok(editor.includes('getData("text/html")'), "le collage HTML doit être traité");
assert.ok(editor.includes("new DOMParser().parseFromString("), "le HTML collé doit passer par DOMParser");
const pasteIndex = editor.indexOf('getData("text/html")');
const parserIndex = editor.indexOf("new DOMParser().parseFromString(");
assert.ok(parserIndex > pasteIndex, "le HTML collé doit être analysé avant toute réinjection");
});
it("le sérialiseur écarte les balises exécutables", () => {
const serializer = readFileSync(new URL("../src/lib/rich-text-dom.ts", import.meta.url), "utf8");
for (const tag of ["SCRIPT", "STYLE", "IFRAME", "OBJECT"]) {
assert.ok(serializer.includes(`"${tag}"`), `${tag} devrait être explicitement écarté`);
}
});
});