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

+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("");
}