mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-21 22:28:04 +02:00
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
399 lines
13 KiB
TypeScript
399 lines
13 KiB
TypeScript
import type { ReactNode } from "react";
|
|
import { createElement, Fragment } from "react";
|
|
|
|
/**
|
|
* Sous-ensemble de Markdown pour les informations de l'association.
|
|
*
|
|
* Module **pur**, verrouillé par `tests/rich-text.test.ts`, bâti sur le même
|
|
* principe que `src/lib/activity.ts` : on ne stocke jamais de HTML et on ne
|
|
* rend jamais de HTML brut. Le texte saisi est analysé ici, puis rendu en
|
|
* éléments React côté site — ce que React échappe. Un éditeur WYSIWYG
|
|
* produirait du HTML, qu'il faudrait stocker puis assainir : deuxième
|
|
* dépendance, deuxième surface d'attaque.
|
|
*
|
|
* 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.
|
|
*/
|
|
|
|
export type Inline =
|
|
| { kind: "text"; value: string }
|
|
| { kind: "break" }
|
|
| { kind: "strong"; children: Inline[] }
|
|
| { kind: "em"; children: Inline[] }
|
|
| { kind: "link"; href: string; children: Inline[] };
|
|
|
|
export type Block =
|
|
| { kind: "paragraph"; children: Inline[] }
|
|
| { kind: "heading"; children: Inline[] }
|
|
| { kind: "bullets"; items: Inline[][] }
|
|
| { kind: "ordered"; items: Inline[][] };
|
|
|
|
/**
|
|
* Seuls `http:` et `https:` passent. Un `javascript:` ou un `data:` rendrait
|
|
* exécutable un lien écrit par un tiers ; il est rendu comme du texte.
|
|
*/
|
|
export function safeHttpUrl(value: string): string {
|
|
try {
|
|
const url = new URL(String(value ?? "").trim());
|
|
return url.protocol === "http:" || url.protocol === "https:" ? url.href : "";
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
/** 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 = "";
|
|
let index = 0;
|
|
|
|
const flush = () => {
|
|
if (buffer) nodes.push({ kind: "text", value: buffer });
|
|
buffer = "";
|
|
};
|
|
|
|
while (index < text.length) {
|
|
// `**` avant `*` : sans cet ordre, « **gras** » serait lu comme un
|
|
// italique vide suivi du mot.
|
|
if (text.startsWith("**", index)) {
|
|
const end = text.indexOf("**", index + 2);
|
|
if (end > index + 2) {
|
|
flush();
|
|
nodes.push({ kind: "strong", children: parseInline(text.slice(index + 2, end)) });
|
|
index = end + 2;
|
|
continue;
|
|
}
|
|
}
|
|
if (text[index] === "*") {
|
|
const end = text.indexOf("*", index + 1);
|
|
if (end > index + 1) {
|
|
flush();
|
|
nodes.push({ kind: "em", children: parseInline(text.slice(index + 1, end)) });
|
|
index = end + 1;
|
|
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] === "(") {
|
|
const paren = text.indexOf(")", close + 2);
|
|
if (paren > close + 1) {
|
|
const label = text.slice(index + 1, close);
|
|
const href = safeHttpUrl(text.slice(close + 2, paren));
|
|
flush();
|
|
// Lien refusé : le libellé reste, la cible disparaît.
|
|
if (href) nodes.push({ kind: "link", href, children: parseInline(label) });
|
|
else nodes.push(...parseInline(label));
|
|
index = paren + 1;
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
buffer += text[index];
|
|
index += 1;
|
|
}
|
|
|
|
flush();
|
|
return nodes;
|
|
}
|
|
|
|
function parseInlineLines(lines: string[]): Inline[] {
|
|
const nodes: Inline[] = [];
|
|
lines.forEach((line, index) => {
|
|
if (index > 0) nodes.push({ kind: "break" });
|
|
nodes.push(...parseInline(line.trim()));
|
|
});
|
|
return nodes;
|
|
}
|
|
|
|
export function parseRichText(source: string): Block[] {
|
|
const lines = String(source ?? "").replace(/\r\n?/g, "\n").split("\n");
|
|
const blocks: Block[] = [];
|
|
let paragraph: string[] = [];
|
|
|
|
const flushParagraph = () => {
|
|
if (paragraph.length === 0) return;
|
|
blocks.push({ kind: "paragraph", children: parseInlineLines(paragraph) });
|
|
paragraph = [];
|
|
};
|
|
|
|
const pushItem = (kind: "bullets" | "ordered", item: Inline[]) => {
|
|
flushParagraph();
|
|
const last = blocks[blocks.length - 1];
|
|
if (last && last.kind === kind) last.items.push(item);
|
|
else blocks.push({ kind, items: [item] } as Block);
|
|
};
|
|
|
|
for (const raw of lines) {
|
|
const line = raw.trimEnd();
|
|
if (!line.trim()) {
|
|
flushParagraph();
|
|
continue;
|
|
}
|
|
|
|
const heading = /^##\s+(.*)$/.exec(line);
|
|
if (heading) {
|
|
flushParagraph();
|
|
blocks.push({ kind: "heading", children: parseInline(heading[1].trim()) });
|
|
continue;
|
|
}
|
|
|
|
// L'espace après le tiret est obligatoire : « *italique* » en début de
|
|
// ligne ne doit pas devenir une puce.
|
|
const bullet = /^[-*]\s+(.*)$/.exec(line);
|
|
if (bullet) {
|
|
pushItem("bullets", parseInline(bullet[1].trim()));
|
|
continue;
|
|
}
|
|
|
|
const ordered = /^\d+[.)]\s+(.*)$/.exec(line);
|
|
if (ordered) {
|
|
pushItem("ordered", parseInline(ordered[1].trim()));
|
|
continue;
|
|
}
|
|
|
|
paragraph.push(line);
|
|
}
|
|
|
|
flushParagraph();
|
|
return blocks;
|
|
}
|
|
|
|
// ---- Rendu écran ----
|
|
|
|
function inlineNodes(nodes: Inline[]): ReactNode[] {
|
|
return nodes.map((node, index) => {
|
|
switch (node.kind) {
|
|
case "break":
|
|
return createElement("br", { key: index });
|
|
case "strong":
|
|
return createElement("strong", { key: index }, inlineNodes(node.children));
|
|
case "em":
|
|
return createElement("em", { key: index }, inlineNodes(node.children));
|
|
case "link":
|
|
return createElement(
|
|
"a",
|
|
{ key: index, href: node.href, target: "_blank", rel: "noopener noreferrer" },
|
|
inlineNodes(node.children),
|
|
);
|
|
default:
|
|
return createElement(Fragment, { key: index }, node.value);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Rend le texte en nœuds React. Rien n'est jamais passé à
|
|
* `dangerouslySetInnerHTML` : une balise écrite dans la saisie s'affiche
|
|
* littéralement au lieu d'être interprétée.
|
|
*/
|
|
export function richTextNodes(source: string): ReactNode {
|
|
return parseRichText(source).map((block, index) => {
|
|
switch (block.kind) {
|
|
case "heading":
|
|
return createElement("h4", { key: index }, inlineNodes(block.children));
|
|
case "bullets":
|
|
return createElement(
|
|
"ul",
|
|
{ key: index },
|
|
block.items.map((item, position) => createElement("li", { key: position }, inlineNodes(item))),
|
|
);
|
|
case "ordered":
|
|
return createElement(
|
|
"ol",
|
|
{ key: index },
|
|
block.items.map((item, position) => createElement("li", { key: position }, inlineNodes(item))),
|
|
);
|
|
default:
|
|
return createElement("p", { key: index }, inlineNodes(block.children));
|
|
}
|
|
});
|
|
}
|
|
|
|
// ---- Rendu e-mail ----
|
|
|
|
const BODY_STYLE = "font-family:Arial,Helvetica,sans-serif;font-size:15px;line-height:25px;color:#5d5447;";
|
|
const HEADING_STYLE = "font-family:Georgia,'Times New Roman',serif;font-size:19px;line-height:26px;color:#26201a;";
|
|
|
|
function esc(value: string) {
|
|
return String(value ?? "")
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
}
|
|
|
|
function inlineHtml(nodes: Inline[]): string {
|
|
return nodes
|
|
.map((node) => {
|
|
switch (node.kind) {
|
|
case "break":
|
|
return "<br>";
|
|
case "strong":
|
|
return `<strong style="color:#26201a;">${inlineHtml(node.children)}</strong>`;
|
|
case "em":
|
|
return `<em>${inlineHtml(node.children)}</em>`;
|
|
case "link":
|
|
return `<a href="${esc(node.href)}" target="_blank" style="color:#2C6FB3;">${inlineHtml(node.children)}</a>`;
|
|
default:
|
|
return esc(node.value);
|
|
}
|
|
})
|
|
.join("");
|
|
}
|
|
|
|
/**
|
|
* HTML à styles en ligne pour le gabarit d'e-mail. Sûr parce que le seul
|
|
* producteur est l'analyseur ci-dessus : aucune balise de la saisie n'arrive
|
|
* jusqu'ici, tout passe par `esc()`.
|
|
*/
|
|
export function richTextToEmailHtml(source: string): string {
|
|
return parseRichText(source)
|
|
.map((block) => {
|
|
switch (block.kind) {
|
|
case "heading":
|
|
return `<div style="${HEADING_STYLE}margin:0 0 12px;">${inlineHtml(block.children)}</div>`;
|
|
case "bullets":
|
|
case "ordered": {
|
|
const tag = block.kind === "bullets" ? "ul" : "ol";
|
|
const items = block.items.map((item) => `<li style="margin-bottom:4px;">${inlineHtml(item)}</li>`).join("");
|
|
return `<${tag} style="${BODY_STYLE}margin:0 0 18px;padding-left:22px;">${items}</${tag}>`;
|
|
}
|
|
default:
|
|
return `<div style="${BODY_STYLE}margin:0 0 18px;">${inlineHtml(block.children)}</div>`;
|
|
}
|
|
})
|
|
.join("");
|
|
}
|
|
|
|
// ---- Rendus texte ----
|
|
|
|
function inlinePlain(nodes: Inline[]): string {
|
|
return nodes
|
|
.map((node) => {
|
|
switch (node.kind) {
|
|
case "break":
|
|
return "\n";
|
|
case "link": {
|
|
const label = inlinePlain(node.children);
|
|
return label === node.href ? node.href : `${label} (${node.href})`;
|
|
}
|
|
case "strong":
|
|
case "em":
|
|
return inlinePlain(node.children);
|
|
default:
|
|
return node.value;
|
|
}
|
|
})
|
|
.join("");
|
|
}
|
|
|
|
/** Repli `text/plain` du message : même contenu, sans balisage. */
|
|
export function richTextToPlain(source: string): string {
|
|
return parseRichText(source)
|
|
.map((block) => {
|
|
switch (block.kind) {
|
|
case "bullets":
|
|
return block.items.map((item) => `- ${inlinePlain(item)}`).join("\n");
|
|
case "ordered":
|
|
return block.items.map((item, index) => `${index + 1}. ${inlinePlain(item)}`).join("\n");
|
|
default:
|
|
return inlinePlain(block.children);
|
|
}
|
|
})
|
|
.join("\n\n");
|
|
}
|
|
|
|
/** Résumé d'une ligne, pour les listes du back-office. */
|
|
export function richTextExcerpt(source: string, max = 160): string {
|
|
const plain = richTextToPlain(source).replace(/\s+/g, " ").trim();
|
|
if (plain.length <= max) return plain;
|
|
const cut = plain.slice(0, max);
|
|
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("");
|
|
}
|