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

+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é`);
}
});
});