mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-22 06:38:05 +02:00
Compare commits
4
Commits
7e7d307cdd
...
2da125eca2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2da125eca2 | ||
|
|
cc33cf5fbc | ||
|
|
90b121a3c1 | ||
|
|
b860eeaf29 |
No files matched your search
@@ -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)
|
||||
|
||||
@@ -225,6 +227,24 @@ même raison.
|
||||
`app/manifest.ts` et `app/opengraph-image.tsx` (vignette 1200×630 générée).
|
||||
- `/backend`, `/login`, `/inscription/*` et les fiches non actives sont en
|
||||
`NOINDEX` ; les pages publiques utilisent `<main>` et un seul `<h1>`.
|
||||
- **Mesure d'audience** : `src/lib/analytics.ts` est **pur**
|
||||
(`tests/analytics.test.ts`). `normalizeMeasurementId()` n'accepte qu'un
|
||||
identifiant GA4 (`G-…`, ni `UA-…` ni texte libre) : c'est la garde qui interdit
|
||||
d'injecter une chaîne arbitraire dans le script en ligne. Le réglage
|
||||
`google_analytics_id` (Paramètres) vide = **aucune balise posée**.
|
||||
`<Analytics>` (`src/components/Analytics.tsx`) rend les deux `<script>` avec le
|
||||
nonce de la CSP et n'agit que sur les pages publiques : `isTrackedPath()`
|
||||
écarte `PRIVATE_PATHS`, le chemin venant de l'en-tête `x-pathname` posé par le
|
||||
middleware (un layout ne connaît pas l'URL demandée). La CSP liste les hôtes
|
||||
`*.google-analytics.com` / `*.analytics.google.com` / `*.googletagmanager.com`
|
||||
en `connect-src` sans condition : elle est posée sur le runtime Edge, qui ne
|
||||
peut pas interroger la base. `script-src` n'a pas besoin d'eux —
|
||||
`'strict-dynamic'` couvre les scripts que gtag.js charge ensuite.
|
||||
- **Search Console** : le réglage `google_site_verification` alimente
|
||||
`verification.google` dans `app/layout.tsx` (balise `<meta>`).
|
||||
`normalizeSiteVerification()` refuse la balise `<meta>` complète collée telle
|
||||
quelle. Le plan du site est déjà servi sur `/sitemap.xml` et annoncé dans
|
||||
`/robots.txt` : il n'y a aucun fichier à déposer, seulement l'URL à déclarer.
|
||||
- **Tags adhérents** : `src/lib/tags.ts` (pur) porte un vocabulaire par métier
|
||||
(`CATEGORY_TAGS`, un par slug du référentiel, vérifié par `tests/tags.test.ts`)
|
||||
et un vocabulaire transversal détecté dans la description. `autoTags()` ne
|
||||
@@ -248,6 +268,17 @@ même raison.
|
||||
droit à l'image) et `/backend/espace/promotions` (dépôt et suivi des promos,
|
||||
les offres **en ligne** en tête). `EspaceHeader` porte le bandeau et les
|
||||
onglets ; toute action qui touche l'espace revalide les deux chemins.
|
||||
- **Pas de barre latérale hors staff** : `BackendShell` branche sur
|
||||
`isStaff(user.role)`. Chez un adhérent la barre ne porterait que la section
|
||||
« Côté adhérent », c'est-à-dire les trois entrées déjà rendues en onglets par
|
||||
`EspaceHeader` — deux menus pour la même chose. Il ne reste qu'un bandeau de
|
||||
marque, aligné sur la colonne de contenu (`max-width` 1040). Conséquence à ne
|
||||
pas perdre de vue : sous 1024px les actions du compte vivaient **dans le
|
||||
tiroir** ; sans tiroir, `.backend-header--plain` rétablit leur affichage dans
|
||||
l'entête, sinon la déconnexion deviendrait inatteignable sur téléphone.
|
||||
`tests/backend-nav.test.ts` verrouille les deux points. Le staff garde la
|
||||
barre : elle y est la navigation principale, et « Côté adhérent » son seul
|
||||
chemin vers son propre espace.
|
||||
- La catégorie d'une promotion est un **type de produit ou de service**
|
||||
(`src/lib/promo-categories.ts`, groupes pour `<optgroup>`), pas le métier de
|
||||
l'adhérent ; `defaultPromoCategory(slug)` pré-sélectionne depuis le métier.
|
||||
@@ -291,16 +322,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
|
||||
|
||||
@@ -4,7 +4,7 @@ import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { BackendIcon, type BackendIconName } from "@/components/BackendIcons";
|
||||
import { can, ROLE_LABELS } from "@/lib/rbac";
|
||||
import { can, isStaff, ROLE_LABELS } from "@/lib/rbac";
|
||||
import type { AppRole } from "@/types/next-auth";
|
||||
import { doSignOut } from "./actions";
|
||||
|
||||
@@ -192,6 +192,43 @@ export function BackendShell({
|
||||
</form>
|
||||
);
|
||||
|
||||
/**
|
||||
* Un adhérent n'a pas de barre latérale : elle ne porterait que la section
|
||||
* « Côté adhérent », c'est-à-dire les trois entrées déjà présentes en onglets
|
||||
* sous le bandeau de l'espace. Deux menus pour la même chose, dont un qui
|
||||
* mange un quart de l'écran. Il ne reste donc qu'un bandeau de marque, et le
|
||||
* contenu prend toute la largeur — recentré pour ne pas s'étirer.
|
||||
*
|
||||
* Le staff la garde : chez lui elle est la navigation principale, et
|
||||
* « Côté adhérent » y est le seul chemin vers son propre espace.
|
||||
*/
|
||||
if (!isStaff(user.role)) {
|
||||
return (
|
||||
<div className="backend backend--plain" style={{ fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
|
||||
<header className="backend-header backend-header--plain">
|
||||
<Link href="/backend/espace/informations" className="backend-brand">
|
||||
<span className="backend-brand__mark">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src="/assets/logo.png" alt="Plein R" style={{ height: 34, width: "auto", display: "block" }} />
|
||||
</span>
|
||||
<span className="backend-brand__text">
|
||||
<span className="font-display backend-brand__name">Plein R</span>
|
||||
<span className="backend-brand__sub">Espace adhérent</span>
|
||||
</span>
|
||||
</Link>
|
||||
<div className="backend-header__actions">
|
||||
{siteLink}
|
||||
{signOut}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Le bandeau de l'espace porte déjà le nom et les onglets : répéter
|
||||
ici le titre de la page ferait un troisième étage d'en-tête. */}
|
||||
<div className="backend-content backend-content--plain">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="backend" style={{ fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
|
||||
{menuOpen && (
|
||||
|
||||
@@ -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'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}
|
||||
|
||||
@@ -98,6 +98,32 @@ export default async function ParametresPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={panel}>
|
||||
<h2 className="font-display" style={title}>Référencement & mesure d'audience</h2>
|
||||
<div className="grid grid-2" style={{ gap: 16 }}>
|
||||
<Field
|
||||
label="Identifiant Google Analytics (GA4)"
|
||||
name="google_analytics_id"
|
||||
value={settings.google_analytics_id}
|
||||
placeholder="G-XXXXXXXXXX"
|
||||
/>
|
||||
<Field
|
||||
label="Jeton Search Console (balise HTML)"
|
||||
name="google_site_verification"
|
||||
value={settings.google_site_verification}
|
||||
placeholder="Valeur de l'attribut content"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginTop: 14, color: "#9a6638", fontSize: 13, lineHeight: 1.6 }}>
|
||||
Laissez vide pour ne rien charger : sans identifiant, aucune balise Google n'est
|
||||
posée sur le site. La mesure d'audience ne couvre que les pages publiques — ni le
|
||||
backoffice, ni l'espace adhérent. Le plan du site est déjà publié sur{" "}
|
||||
<code>/sitemap.xml</code> et annoncé dans <code>/robots.txt</code> : il suffit de le
|
||||
déclarer dans Search Console. Pensez à mentionner Google Analytics dans la page
|
||||
Confidentialité avant de l'activer.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button type="submit" style={submitButton}>Enregistrer les paramètres</button>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -850,6 +850,81 @@ img {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ---- Backend sans barre latérale (espace adhérent) ----
|
||||
Un adhérent n'a que les trois onglets de son espace : une barre latérale
|
||||
les répéterait. `.backend` étant une rangée flex, la variante repasse en
|
||||
bloc pour que l'entête et le contenu s'empilent simplement. */
|
||||
.backend--plain {
|
||||
display: block;
|
||||
}
|
||||
/* L'entête s'aligne sur la colonne de contenu : sans cela le logo collerait
|
||||
au bord de l'écran pendant que le contenu reste centré. */
|
||||
.backend-header--plain {
|
||||
padding: 14px max(24px, calc((100% - 1040px) / 2));
|
||||
}
|
||||
.backend-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
margin-right: auto;
|
||||
min-width: 0;
|
||||
text-decoration: none;
|
||||
}
|
||||
.backend-brand__mark {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
padding: 5px;
|
||||
border-radius: 11px;
|
||||
background: #fff;
|
||||
border: 1px solid #e6dcc6;
|
||||
}
|
||||
.backend-brand__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.15;
|
||||
min-width: 0;
|
||||
}
|
||||
.backend-brand__name {
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
.backend-brand__sub {
|
||||
font-size: 11.5px;
|
||||
color: var(--muted-3);
|
||||
}
|
||||
/* Sans barre latérale, il n'y a plus de tiroir où loger les actions sur
|
||||
téléphone : elles restent dans l'entête à toutes les largeurs. */
|
||||
.backend-header--plain .backend-header__actions {
|
||||
display: flex;
|
||||
}
|
||||
.backend-content--plain {
|
||||
width: 100%;
|
||||
max-width: 1040px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.backend-header--plain {
|
||||
padding: 11px 16px;
|
||||
}
|
||||
.backend-content--plain {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Le nom de marque s'efface avant les actions : le logo suffit à situer la
|
||||
page, deux boutons tronqués ne servent à rien. */
|
||||
@media (max-width: 560px) {
|
||||
.backend-brand__text {
|
||||
display: none;
|
||||
}
|
||||
.backend-header--plain .backend-action {
|
||||
padding: 8px 11px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
@@ -1039,6 +1114,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) {
|
||||
|
||||
+11
-1
@@ -1,4 +1,5 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Analytics } from "@/components/Analytics";
|
||||
import { JsonLd } from "@/components/JsonLd";
|
||||
import {
|
||||
INDEX_FOLLOW,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
organizationJsonLd,
|
||||
webSiteJsonLd,
|
||||
} from "@/lib/seo";
|
||||
import { normalizeSiteVerification } from "@/lib/analytics";
|
||||
import { metadataBase, publicBaseUrl } from "@/lib/seo-server";
|
||||
import { getSiteSettings, socialLinks } from "@/lib/site-settings";
|
||||
import "./globals.css";
|
||||
@@ -33,8 +35,14 @@ export const viewport: Viewport = {
|
||||
* relatives (canonique, Open Graph, image de partage).
|
||||
*/
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const [base, settings] = await Promise.all([
|
||||
metadataBase(),
|
||||
getSiteSettings().catch(() => null),
|
||||
]);
|
||||
// Jeton « balise HTML » de Search Console, collé dans Backend › Paramètres.
|
||||
const google = normalizeSiteVerification(settings?.google_site_verification);
|
||||
return {
|
||||
metadataBase: await metadataBase(),
|
||||
metadataBase: base,
|
||||
title: {
|
||||
default: SITE_TITLE,
|
||||
template: `%s · ${SITE_NAME}`,
|
||||
@@ -63,6 +71,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
description: SITE_DESCRIPTION,
|
||||
},
|
||||
robots: INDEX_FOLLOW,
|
||||
verification: google ? { google } : undefined,
|
||||
icons: {
|
||||
icon: [{ url: "/assets/logo.png", type: "image/png" }],
|
||||
apple: [{ url: "/assets/logo.png", type: "image/png" }],
|
||||
@@ -108,6 +117,7 @@ export default async function RootLayout({
|
||||
<body>
|
||||
{children}
|
||||
<JsonLd data={structuredData} />
|
||||
<Analytics measurementId={settings?.google_analytics_id ?? ""} />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { headers } from "next/headers";
|
||||
import { gtagConfigScript, gtagSrc, isTrackedPath, normalizeMeasurementId } from "@/lib/analytics";
|
||||
|
||||
/**
|
||||
* Balises Google Analytics 4, posées uniquement si l'identifiant de mesure est
|
||||
* renseigné dans Backend › Paramètres et si la page est publique.
|
||||
*
|
||||
* Les deux scripts portent le nonce de la CSP (`src/middleware.ts`) ; le
|
||||
* chemin est lu dans l'en-tête `x-pathname` que le middleware recopie, un
|
||||
* layout n'ayant pas accès à l'URL demandée.
|
||||
*/
|
||||
export async function Analytics({ measurementId }: { measurementId: string }) {
|
||||
const id = normalizeMeasurementId(measurementId);
|
||||
if (!id) return null;
|
||||
|
||||
const requestHeaders = await headers();
|
||||
if (!isTrackedPath(requestHeaders.get("x-pathname") ?? "/")) return null;
|
||||
const nonce = requestHeaders.get("x-nonce") ?? undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<script async src={gtagSrc(id)} nonce={nonce} />
|
||||
<script nonce={nonce} dangerouslySetInnerHTML={{ __html: gtagConfigScript(id) }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { PRIVATE_PATHS } from "@/lib/seo";
|
||||
|
||||
/**
|
||||
* Mesure d'audience Google Analytics 4.
|
||||
*
|
||||
* Module **pur** (verrouillé par `tests/analytics.test.ts`) : il ne sait ni lire
|
||||
* la base ni rendre du HTML, il décide seulement ce qui part dans la page.
|
||||
* L'identifiant vient du réglage `google_analytics_id` (Backend › Paramètres) :
|
||||
* tant qu'il est vide, aucune balise n'est posée et aucun appel n'est fait.
|
||||
*/
|
||||
|
||||
/** Identifiant de mesure GA4 : « G- » suivi de l'identifiant du flux. */
|
||||
const MEASUREMENT_ID = /^G-[A-Z0-9]{4,24}$/;
|
||||
|
||||
/**
|
||||
* Valide la saisie de l'administrateur avant qu'elle n'atteigne la page.
|
||||
*
|
||||
* Renvoie `""` pour tout ce qui n'est pas un identifiant GA4 : c'est la garde
|
||||
* qui interdit d'injecter une chaîne arbitraire dans le script en ligne — un
|
||||
* identifiant validé ne contient ni guillemet, ni `<`, ni `</script>`.
|
||||
* Les identifiants Universal Analytics (`UA-…`), arrêtés depuis 2023, sont
|
||||
* refusés : ils ne collectent plus rien.
|
||||
*/
|
||||
export function normalizeMeasurementId(raw: string | null | undefined): string {
|
||||
const value = (raw ?? "").trim().toUpperCase();
|
||||
return MEASUREMENT_ID.test(value) ? value : "";
|
||||
}
|
||||
|
||||
/** URL du chargeur gtag.js pour un identifiant **déjà normalisé**. */
|
||||
export function gtagSrc(measurementId: string): string {
|
||||
const id = normalizeMeasurementId(measurementId);
|
||||
return id ? `https://www.googletagmanager.com/gtag/js?id=${id}` : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Script d'amorçage de gtag.js. Il porte le nonce de la CSP comme le reste des
|
||||
* scripts du site ; les scripts que gtag.js charge ensuite passent grâce à
|
||||
* `'strict-dynamic'`.
|
||||
*/
|
||||
export function gtagConfigScript(measurementId: string): string {
|
||||
const id = normalizeMeasurementId(measurementId);
|
||||
if (!id) return "";
|
||||
return [
|
||||
"window.dataLayer=window.dataLayer||[];",
|
||||
"function gtag(){dataLayer.push(arguments);}",
|
||||
"gtag('js',new Date());",
|
||||
`gtag('config','${id}');`,
|
||||
].join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Seules les pages publiques sont mesurées.
|
||||
*
|
||||
* L'espace adhérent et le backoffice portent des identifiants de membres et de
|
||||
* promotions dans leurs URLs : les envoyer à un tiers n'apporterait rien et
|
||||
* ferait sortir des données personnelles du site.
|
||||
*/
|
||||
export function isTrackedPath(pathname: string): boolean {
|
||||
const path = pathname.split("?")[0] ?? "/";
|
||||
return !PRIVATE_PATHS.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
|
||||
}
|
||||
|
||||
/** Jeton de validation Search Console (balise `google-site-verification`). */
|
||||
export function normalizeSiteVerification(raw: string | null | undefined): string {
|
||||
const value = (raw ?? "").trim();
|
||||
// Google livre un jeton alphanumérique d'une quarantaine de caractères ; on
|
||||
// refuse le reste — dont la balise <meta> complète, collée telle quelle —
|
||||
// plutôt que de recopier dans le <head> une chaîne venue de n'importe où.
|
||||
return /^[A-Za-z0-9_-]{20,128}$/.test(value) ? value : "";
|
||||
}
|
||||
@@ -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
@@ -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("");
|
||||
}
|
||||
@@ -45,6 +45,10 @@ export const SITE_SETTING_DEFAULTS = {
|
||||
association_publication_director: "",
|
||||
association_privacy_contact: "",
|
||||
legal_updated: "",
|
||||
// Référencement : identifiant de mesure GA4 (« G-… ») et jeton de
|
||||
// validation Search Console. Vides = aucune balise posée sur le site.
|
||||
google_analytics_id: "",
|
||||
google_site_verification: "",
|
||||
};
|
||||
|
||||
export type SiteSettings = typeof SITE_SETTING_DEFAULTS;
|
||||
|
||||
+8
-1
@@ -32,7 +32,11 @@ function contentSecurityPolicy(nonce: string, isDev: boolean, isHttps: boolean):
|
||||
"font-src 'self' https://fonts.gstatic.com",
|
||||
// data: pour les images stockées en data-URI, https: pour les vignettes distantes.
|
||||
"img-src 'self' data: https:",
|
||||
"connect-src 'self'",
|
||||
// Mesure d'audience GA4 : gtag.js renvoie ses relevés par `fetch`. Les
|
||||
// hôtes sont listés même sans identifiant configuré — la CSP est posée
|
||||
// sur le runtime Edge, qui ne peut pas interroger la base pour savoir si
|
||||
// la mesure est active.
|
||||
"connect-src 'self' https://*.google-analytics.com https://*.analytics.google.com https://*.googletagmanager.com",
|
||||
// Carte « Nous situer » de la fiche adhérent.
|
||||
"frame-src https://www.google.com https://maps.google.com",
|
||||
// Uniquement quand la page est servie en HTTPS : sur une page HTTP (test
|
||||
@@ -55,6 +59,9 @@ export default auth((request: NextRequest) => {
|
||||
// ses balises <script>. Il est ensuite renvoyé sur la réponse au navigateur.
|
||||
const requestHeaders = new Headers(request.headers);
|
||||
requestHeaders.set("x-nonce", nonce);
|
||||
// Un layout ne connaît pas l'URL demandée : `Analytics` s'en sert pour ne
|
||||
// mesurer que les pages publiques.
|
||||
requestHeaders.set("x-pathname", request.nextUrl.pathname);
|
||||
requestHeaders.set("content-security-policy", csp);
|
||||
|
||||
const response = NextResponse.next({ request: { headers: requestHeaders } });
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import {
|
||||
gtagConfigScript,
|
||||
gtagSrc,
|
||||
isTrackedPath,
|
||||
normalizeMeasurementId,
|
||||
normalizeSiteVerification,
|
||||
} from "../src/lib/analytics";
|
||||
|
||||
describe("Identifiant de mesure GA4", () => {
|
||||
it("accepte un identifiant de flux", () => {
|
||||
assert.equal(normalizeMeasurementId("G-ABC1234567"), "G-ABC1234567");
|
||||
assert.equal(normalizeMeasurementId(" g-abc1234567 "), "G-ABC1234567");
|
||||
});
|
||||
|
||||
it("refuse une saisie vide ou absente", () => {
|
||||
assert.equal(normalizeMeasurementId(""), "");
|
||||
assert.equal(normalizeMeasurementId(null), "");
|
||||
assert.equal(normalizeMeasurementId(undefined), "");
|
||||
assert.equal(normalizeMeasurementId(" "), "");
|
||||
});
|
||||
|
||||
it("refuse Universal Analytics, arrêté depuis 2023", () => {
|
||||
assert.equal(normalizeMeasurementId("UA-12345-1"), "");
|
||||
});
|
||||
|
||||
it("refuse tout ce qui pourrait s'échapper du script en ligne", () => {
|
||||
for (const payload of [
|
||||
"G-ABC12345');alert(1);//",
|
||||
"G-ABC12345</script><script>alert(1)</script>",
|
||||
'G-ABC"12345',
|
||||
"G-ABC 12345",
|
||||
"<script>alert(1)</script>",
|
||||
]) {
|
||||
assert.equal(normalizeMeasurementId(payload), "", `accepté à tort : ${payload}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Balises gtag.js", () => {
|
||||
it("ne rend rien sans identifiant valide", () => {
|
||||
assert.equal(gtagSrc(""), "");
|
||||
assert.equal(gtagSrc("UA-12345-1"), "");
|
||||
assert.equal(gtagConfigScript(""), "");
|
||||
assert.equal(gtagConfigScript("pas-un-identifiant"), "");
|
||||
});
|
||||
|
||||
it("charge gtag.js depuis Google avec l'identifiant configuré", () => {
|
||||
assert.equal(
|
||||
gtagSrc("G-ABC1234567"),
|
||||
"https://www.googletagmanager.com/gtag/js?id=G-ABC1234567",
|
||||
);
|
||||
});
|
||||
|
||||
it("produit un script d'amorçage qui ne peut pas être refermé", () => {
|
||||
const script = gtagConfigScript("G-ABC1234567");
|
||||
assert.ok(script.includes("gtag('config','G-ABC1234567');"));
|
||||
assert.ok(!script.includes("</script"));
|
||||
assert.ok(!script.includes("<"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Pages mesurées", () => {
|
||||
it("mesure les pages publiques", () => {
|
||||
for (const path of ["/", "/annuaire", "/promotions", "/adherents/12-au-bon-pain", "/association"]) {
|
||||
assert.equal(isTrackedPath(path), true, `devrait être mesurée : ${path}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("ne mesure ni le backoffice, ni l'espace adhérent, ni les URLs privées", () => {
|
||||
for (const path of [
|
||||
"/backend",
|
||||
"/backend/espace/promotions",
|
||||
"/login",
|
||||
"/inscription/adherent",
|
||||
"/api/social/facebook/callback",
|
||||
]) {
|
||||
assert.equal(isTrackedPath(path), false, `ne devrait pas être mesurée : ${path}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("ne confond pas un préfixe avec un début de mot", () => {
|
||||
assert.equal(isTrackedPath("/backendeur"), true);
|
||||
assert.equal(isTrackedPath("/apiculteurs"), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Jeton Search Console", () => {
|
||||
it("accepte le jeton livré par Google", () => {
|
||||
assert.equal(
|
||||
normalizeSiteVerification(" aBc123_def-456ghijkl "),
|
||||
"aBc123_def-456ghijkl",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuse une balise complète ou tout autre collage", () => {
|
||||
for (const payload of [
|
||||
'<meta name="google-site-verification" content="abc">',
|
||||
"",
|
||||
"trop-court",
|
||||
"abc123 def456ghij",
|
||||
"abc\"><script>alert(1)</script>",
|
||||
]) {
|
||||
assert.equal(normalizeSiteVerification(payload), "", `accepté à tort : ${payload}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -42,3 +42,41 @@ test("chaque icône déclarée dans le type est dessinée", () => {
|
||||
assert.ok(drawn.has(name), `l'icône « ${name} » est déclarée mais pas dessinée`);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Un adhérent n'a pas de barre latérale : elle ne porterait que la section
|
||||
* « Côté adhérent », déjà rendue en onglets par `EspaceHeader`. Le test tient
|
||||
* les deux bouts — la coquille doit brancher sur le rôle, et sa variante sans
|
||||
* barre ne doit rendre ni `aside`, ni bouton de tiroir.
|
||||
*/
|
||||
const memberStart = shell.indexOf("if (!isStaff(user.role))");
|
||||
// La tranche s'arrête à la coquille du staff, sinon elle l'embarquerait et
|
||||
// le test verrait son <aside> — il passerait pour un échec de la variante.
|
||||
const staffStart = shell.indexOf('<div className="backend" style', memberStart);
|
||||
const memberBranch = shell.slice(memberStart, staffStart);
|
||||
|
||||
test("la coquille distingue le staff de l'adhérent", () => {
|
||||
assert.ok(shell.includes("isStaff"), "BackendShell ne consulte plus le rôle");
|
||||
assert.ok(memberStart >= 0, "la branche sans barre latérale a disparu");
|
||||
assert.ok(staffStart > memberStart, "la coquille du staff ne suit plus la branche adhérent");
|
||||
});
|
||||
|
||||
test("la variante adhérent ne rend ni barre latérale ni tiroir", () => {
|
||||
assert.ok(!/<aside/.test(memberBranch), "un <aside> subsiste dans la variante adhérent");
|
||||
assert.ok(!memberBranch.includes("backend-burger"), "le bouton de tiroir subsiste sans tiroir à ouvrir");
|
||||
assert.ok(!memberBranch.includes("sidebar"), "une classe de barre latérale subsiste");
|
||||
});
|
||||
|
||||
test("sans tiroir, les actions du compte restent dans l'entête", () => {
|
||||
// Sur téléphone elles vivaient dans la barre : sans elle, se déconnecter
|
||||
// deviendrait impossible si la règle d'affichage n'était pas rétablie.
|
||||
assert.ok(memberBranch.includes("{signOut}"), "la déconnexion a disparu de l'entête adhérent");
|
||||
assert.ok(memberBranch.includes("{siteLink}"), "le retour au site a disparu de l'entête adhérent");
|
||||
|
||||
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
assert.match(
|
||||
css,
|
||||
/\.backend-header--plain\s+\.backend-header__actions\s*\{\s*display:\s*flex/,
|
||||
"les actions restent masquées sous 1024px, où le tiroir n'existe plus",
|
||||
);
|
||||
});
|
||||
@@ -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_**",
|
||||
);
|
||||
});
|
||||
@@ -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("<script>"));
|
||||
assert.ok(html.includes("&"));
|
||||
assert.ok(!html.includes("<script"));
|
||||
assert.ok(!richTextToEditorHtml("[x](javascript:alert(1))").includes("<a "));
|
||||
});
|
||||
@@ -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é`);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user