Référencement : métadonnées complètes, données structurées, sitemap et robots

- Layout : metadataBase, gabarit de titre « %s · Plein R », description,
  mots-clés, Open Graph / Twitter, robots, icônes, manifeste, viewport et
  couleur de thème ; JSON-LD Organization + WebSite (recherche annuaire).
- Chaque page publique pose titre, description (≤ 160 caractères) et
  canonique via pageMetadata() ; l'annuaire ignore ainsi ?q=. Fiche adhérent :
  titre « Nom · Catégorie à Ville », description dérivée, LocalBusiness avec
  adresse, contacts et horaires (OpeningHoursSpecification), BreadcrumbList.
  Annuaire : ItemList des adhérents. Association : BusinessEvent par
  rencontre à venir. Rencontres passées : fil d'Ariane.
- Nouvelles routes : robots.txt, sitemap.xml (pages statiques + adhérents
  actifs), manifest.webmanifest, image de partage 1200×630 générée avec le
  logo, page 404 dédiée (noindex).
- /backend, /login, /inscription/* et les fiches inactives en noindex.
- Landmarks <main> sur toutes les pages publiques, fil d'Ariane en <nav>.
- serializeJsonLd() échappe <, > et & : les saisies des adhérents ne peuvent
  pas fermer le bloc <script>. Tests unitaires dans tests/seo.test.ts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDbKbJrPWweXXYSxA8MXW7
This commit is contained in:
Claude committed 2026-09-05 10:41:53 +00:00
1 parent dd350038f0
commit a8adb9ae6a
21 files changed
+1113 -36

No files matched your search

+18
View File
@@ -114,6 +114,24 @@ site sans traitement supplémentaire.
n'est publié que sur `127.0.0.1`.
- `npm test` verrouille ces protections (`tests/security.test.ts`).
## Référencement (SEO)
- `src/lib/seo.ts` est **pur** (constantes, `pageMetadata()`, générateurs JSON-LD,
`serializeJsonLd()` qui échappe `<>&`) et verrouillé par `tests/seo.test.ts`.
`src/lib/seo-server.ts` fournit `publicBaseUrl()` : réglage `site_public_url`,
puis variables d'environnement, puis en-têtes de la requête.
- `app/layout.tsx` pose `metadataBase`, le gabarit de titre `%s · Plein R`,
Open Graph / Twitter, `robots`, le manifeste, et les JSON-LD `Organization` +
`WebSite`. Chaque page publique appelle `pageMetadata({ title, description,
path })` : le `path` sert de canonique (l'annuaire ignore ainsi `?q=`).
- Données structurées par page via `<JsonLd data={…} />` : `BreadcrumbList`
partout, `ItemList` sur l'annuaire, `LocalBusiness` (+ horaires) sur la fiche
adhérent, `BusinessEvent` pour chaque rencontre à venir.
- `app/robots.ts`, `app/sitemap.ts` (pages statiques + adhérents actifs),
`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>`.
## Roles
`admin` > `moderator` > `editor` are staff; `member` is an adhérent linked to a
+46 -9
View File
@@ -6,7 +6,17 @@ import { SiteHeader } from "@/components/SiteHeader";
import { SiteFooter } from "@/components/SiteFooter";
import { VitrineImage } from "@/components/VitrineImage";
import { PromoImage } from "@/components/PromoImage";
import { JsonLd } from "@/components/JsonLd";
import { getMemberLivePromotions, getPublicMember } from "@/lib/queries";
import {
NOINDEX,
breadcrumbJsonLd,
localBusinessJsonLd,
memberPath,
metaDescription,
pageMetadata,
} from "@/lib/seo";
import { publicBaseUrl } from "@/lib/seo-server";
import {
formatSlots,
getOpenStatus,
@@ -86,15 +96,32 @@ function ContactAction({
);
}
/** Titre de la fiche : « Nom · Catégorie à Ville », le plus parlant en résultat de recherche. */
function memberTitle(member: { name: string; categoryLabel: string | null; city: string | null }) {
const where = member.city ? ` à ${member.city}` : " · Bassin de Pompey";
return member.categoryLabel ? `${member.name} · ${member.categoryLabel}${where}` : `${member.name}${where}`;
}
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>;
}): Promise<Metadata> {
const { id } = await params;
const member = await getPublicMember(Number(id));
if (!member) return { title: "Adhérent — Plein R" };
return { title: `${member.name} — Plein R`, description: member.description ?? undefined };
const memberId = Number(id);
const member = memberId ? await getPublicMember(memberId) : null;
if (!member || member.status !== "active") {
return { title: "Adhérent introuvable", robots: NOINDEX };
}
const fallback =
`${member.name}, ${member.categoryLabel ? `${member.categoryLabel.toLowerCase()} ` : ""}` +
`adhérent Plein R${member.city ? ` à ${member.city}` : " sur le Bassin de Pompey"} : coordonnées, horaires et bons plans.`;
return pageMetadata({
title: memberTitle(member),
description: metaDescription(member.description, fallback),
path: memberPath(member.id),
ogType: "profile",
});
}
export default async function FicheAdherentPage({
@@ -109,7 +136,7 @@ export default async function FicheAdherentPage({
const member = await getPublicMember(memberId);
if (!member || member.status !== "active") notFound();
const promos = await getMemberLivePromotions(memberId);
const [promos, baseUrl] = await Promise.all([getMemberLivePromotions(memberId), publicBaseUrl()]);
const accent = member.accent ?? "#E0A63C";
const fullAddress = [member.address, [member.postalCode, member.city].filter(Boolean).join(" ")]
@@ -142,15 +169,25 @@ export default async function FicheAdherentPage({
return (
<div style={{ background: "#F6F2E8", minHeight: "100vh", fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
<SiteHeader active="annuaire" />
<JsonLd
data={[
breadcrumbJsonLd(baseUrl, [
{ name: "Accueil", path: "/" },
{ name: "Annuaire", path: "/annuaire" },
{ name: member.name, path: memberPath(member.id) },
]),
localBusinessJsonLd(baseUrl, member, hours, normalizedWebsite),
]}
/>
<div className="container" style={{ paddingBottom: 56 }}>
<main className="container" style={{ paddingBottom: 56 }}>
{/* breadcrumb */}
<div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 13.5, color: "#9a8d72", padding: "6px 0 18px", flexWrap: "wrap" }}>
<nav aria-label="Fil d'Ariane" style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 13.5, color: "#9a8d72", padding: "6px 0 18px", flexWrap: "wrap" }}>
<Link href="/annuaire" style={{ textDecoration: "none", color: "#9a6638", fontWeight: 600 }}>Annuaire</Link>
{member.categoryLabel && (<><span>›</span><span style={{ color: "#9a8d72" }}>{member.categoryLabel}</span></>)}
<span>›</span>
<span style={{ color: "#3c3322", fontWeight: 600 }}>{member.name}</span>
</div>
<span style={{ color: "#3c3322", fontWeight: 600 }} aria-current="page">{member.name}</span>
</nav>
{/* cover + identity */}
<section style={{ position: "relative", borderRadius: 22, overflow: "hidden", border: "1px solid #e6dcc6" }}>
@@ -409,7 +446,7 @@ export default async function FicheAdherentPage({
)}
</aside>
</div>
</div>
</main>
<SiteFooter />
</div>
+23 -7
View File
@@ -8,15 +8,21 @@ import {
getAllCategories,
getLiveBadgesByMember,
} from "@/lib/queries";
import { JsonLd } from "@/components/JsonLd";
import { breadcrumbJsonLd, memberListJsonLd, pageMetadata } from "@/lib/seo";
import { publicBaseUrl } from "@/lib/seo-server";
import { AnnuaireClient, type DirectoryMember } from "./AnnuaireClient";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Annuaire — Plein R",
// Canonique sans paramètre de recherche : /annuaire?q=… ne crée pas de doublon.
export const metadata: Metadata = pageMetadata({
title: "Annuaire des commerçants du Bassin de Pompey",
description:
"L'annuaire des commerçants et entreprises du Bassin de Pompey. Trouvez un professionnel et ses bons plans.",
};
"Trouvez un commerçant, artisan ou entreprise du Bassin de Pompey (Pompey, Frouard, Liverdun, " +
"Champigneulles…) : coordonnées, horaires et bons plans.",
path: "/annuaire",
});
export default async function AnnuairePage({
searchParams,
@@ -24,10 +30,11 @@ export default async function AnnuairePage({
searchParams: Promise<{ q?: string }>;
}) {
const { q } = await searchParams;
const [rawMembers, categories, badges] = await Promise.all([
const [rawMembers, categories, badges, baseUrl] = await Promise.all([
getActiveMembersWithCategory(),
getAllCategories(),
getLiveBadgesByMember(),
publicBaseUrl(),
]);
const badgeByMember = new Map<number, string | null>();
@@ -46,8 +53,17 @@ export default async function AnnuairePage({
return (
<div style={{ background: "#F6F2E8", minHeight: "100vh", fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
<SiteHeader active="annuaire" logo />
<JsonLd
data={[
breadcrumbJsonLd(baseUrl, [
{ name: "Accueil", path: "/" },
{ name: "Annuaire", path: "/annuaire" },
]),
memberListJsonLd(baseUrl, "Annuaire des adhérents Plein R", rawMembers),
]}
/>
<div className="container" style={{ paddingBottom: 56 }}>
<main className="container" style={{ paddingBottom: 56 }}>
{/* title block */}
<section style={{ position: "relative", padding: "14px 0 22px", overflow: "hidden" }}>
<Sparkle color="#E0A63C" size={18} style={{ top: 22, right: 30 }} duration={3.2} />
@@ -113,7 +129,7 @@ export default async function AnnuairePage({
style={{ border: "none", background: "#E0A63C", color: "#33291D", fontWeight: 700, fontSize: 15.5, padding: "14px 26px", borderRadius: 12, whiteSpace: "nowrap" }}
/>
</section>
</div>
</main>
<SiteFooter />
</div>
+29 -1
View File
@@ -1,8 +1,10 @@
import type { Metadata } from "next";
import Link from "next/link";
import { and, asc, desc, eq, gte, inArray, sql } from "drizzle-orm";
import { auth } from "@/auth";
import { db } from "@/db";
import { meetingRegistrations, meetings, pastMeetingPhotos, pastMeetings } from "@/db/schema";
import { JsonLd } from "@/components/JsonLd";
import { PastMeetingGallery } from "@/components/PastMeetingGallery";
import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
@@ -13,9 +15,26 @@ import {
parsePillars,
splitLines,
} from "@/lib/site-settings";
import { breadcrumbJsonLd, eventJsonLd, pageMetadata } from "@/lib/seo";
import { publicBaseUrl } from "@/lib/seo-server";
export const dynamic = "force-dynamic";
const ASSOCIATION_TITLE = "Plein R, l'association des entreprises du Bassin de Pompey";
export const metadata: Metadata = {
...pageMetadata({
title: ASSOCIATION_TITLE,
ogTitle: ASSOCIATION_TITLE,
description:
"Plein R fédère commerçants, artisans et entreprises du Bassin de Pompey : mission, équipe, " +
"prochaines rencontres. Rejoignez le réseau économique local.",
path: "/association",
}),
// Le nom de l'association est déjà dans le titre : pas de suffixe « · Plein R ».
title: { absolute: ASSOCIATION_TITLE },
};
function formatDate(date: Date) {
return new Intl.DateTimeFormat("fr-FR", {
weekday: "long",
@@ -30,7 +49,7 @@ function formatTime(date: Date) {
}
export default async function AssociationPage() {
const [settings, session] = await Promise.all([getSiteSettings(), auth()]);
const [settings, session, baseUrl] = await Promise.all([getSiteSettings(), auth(), publicBaseUrl()]);
const now = new Date();
const memberId = session?.user.memberId ?? null;
@@ -108,6 +127,15 @@ export default async function AssociationPage() {
return (
<div style={{ background: "#F6F2E8", minHeight: "100vh", fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
<SiteHeader active="association" logo />
<JsonLd
data={[
breadcrumbJsonLd(baseUrl, [
{ name: "Accueil", path: "/" },
{ name: "L'association", path: "/association" },
]),
...upcoming.map((meeting) => eventJsonLd(baseUrl, meeting)),
]}
/>
<main>
<section className="container" style={{ paddingTop: 34, paddingBottom: 52 }}>
+8
View File
@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { desc, eq } from "drizzle-orm";
import { getSession } from "@/lib/session";
@@ -7,9 +8,16 @@ import { ImageConsentForm } from "@/components/ImageConsentForm";
import { isStaff } from "@/lib/rbac";
import { saveImageConsent } from "./actions";
import { BackendShell } from "./BackendShell";
import { NOINDEX } from "@/lib/seo";
export const dynamic = "force-dynamic";
// Espace privé : jamais indexé, même si une page fuyait hors du middleware.
export const metadata: Metadata = {
title: "Espace adhérent",
robots: NOINDEX,
};
export default async function BackendLayout({
children,
}: {
+6 -4
View File
@@ -1,11 +1,13 @@
import type { Metadata } from "next";
import { LegalPage } from "@/components/LegalPage";
import { getSiteSettings } from "@/lib/site-settings";
import { pageMetadata } from "@/lib/seo";
export const metadata: Metadata = {
title: "Confidentialité — Plein R",
description: "Politique de confidentialité et traitement des données personnelles par Plein R.",
};
export const metadata: Metadata = pageMetadata({
title: "Politique de confidentialité",
description: "Politique de confidentialité et traitement des données personnelles par l'association Plein R.",
path: "/confidentialite",
});
export const dynamic = "force-dynamic";
+8
View File
@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import Link from "next/link";
import { and, asc, eq, gte, sql } from "drizzle-orm";
import { getSession } from "@/lib/session";
@@ -6,9 +7,16 @@ import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
import { db } from "@/db";
import { meetingRegistrations, meetings, members } from "@/db/schema";
import { NOINDEX } from "@/lib/seo";
export const dynamic = "force-dynamic";
// Page transactionnelle : rien à indexer, la rencontre est décrite sur /association.
export const metadata: Metadata = {
title: "Inscription à une rencontre",
robots: NOINDEX,
};
function formatDate(date: Date) {
return new Intl.DateTimeFormat("fr-FR", {
weekday: "long",
+80 -8
View File
@@ -1,4 +1,18 @@
import type { Metadata } from "next";
import type { Metadata, Viewport } from "next";
import { JsonLd } from "@/components/JsonLd";
import {
INDEX_FOLLOW,
SITE_DESCRIPTION,
SITE_KEYWORDS,
SITE_LOCALE,
SITE_NAME,
SITE_TITLE,
THEME_COLOR,
organizationJsonLd,
webSiteJsonLd,
} from "@/lib/seo";
import { metadataBase, publicBaseUrl } from "@/lib/seo-server";
import { getSiteSettings, socialLinks } from "@/lib/site-settings";
import "./globals.css";
// Rendu dynamique pour toutes les pages : la CSP à nonce du middleware ne peut
@@ -6,17 +20,73 @@ import "./globals.css";
// interrogent de toute façon la base.
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Plein R — Commerçants & entreprises du Bassin de Pompey",
description:
"Association des commerçants et entreprises du Bassin de Pompey. Réseau · Rencontre · Réussite.",
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
themeColor: THEME_COLOR,
colorScheme: "light",
};
export default function RootLayout({
/**
* Métadonnées par défaut de tout le site. Chaque page complète ou remplace
* titre, description et canonique ; `metadataBase` rend absolues les URLs
* relatives (canonique, Open Graph, image de partage).
*/
export async function generateMetadata(): Promise<Metadata> {
return {
metadataBase: await metadataBase(),
title: {
default: SITE_TITLE,
template: `%s · ${SITE_NAME}`,
},
description: SITE_DESCRIPTION,
applicationName: SITE_NAME,
keywords: SITE_KEYWORDS,
authors: [{ name: `Association ${SITE_NAME}` }],
creator: `Association ${SITE_NAME}`,
publisher: `Association ${SITE_NAME}`,
category: "business",
referrer: "strict-origin-when-cross-origin",
// Pas de canonique ici : chaque page publique pose la sienne via
// `pageMetadata()` ; les pages privées (noindex) n'en ont pas.
openGraph: {
type: "website",
locale: SITE_LOCALE,
siteName: SITE_NAME,
title: SITE_TITLE,
description: SITE_DESCRIPTION,
url: "/",
},
twitter: {
card: "summary_large_image",
title: SITE_TITLE,
description: SITE_DESCRIPTION,
},
robots: INDEX_FOLLOW,
icons: {
icon: [{ url: "/assets/logo.png", type: "image/png" }],
apple: [{ url: "/assets/logo.png", type: "image/png" }],
shortcut: ["/assets/logo.png"],
},
manifest: "/manifest.webmanifest",
};
}
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
// Données structurées communes : l'association (Organization) et le site
// (WebSite + recherche dans l'annuaire). Ne bloque jamais le rendu.
const [baseUrl, settings] = await Promise.all([
publicBaseUrl(),
getSiteSettings().catch(() => null),
]);
const structuredData = settings
? [organizationJsonLd(baseUrl, settings, socialLinks(settings)), webSiteJsonLd(baseUrl)]
: [webSiteJsonLd(baseUrl)];
return (
<html lang="fr">
<head>
@@ -30,13 +100,15 @@ export default function RootLayout({
href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,400;12..96,600;12..96,700;12..96,800&family=Public+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap"
rel="stylesheet"
/>
<link rel="icon" href="/assets/logo.png" type="image/png" />
{/* Fallback: if JS is disabled, never keep revealed content hidden. */}
<noscript>
<style>{`.reveal,.reveal-stagger>*{opacity:1!important}`}</style>
</noscript>
</head>
<body>{children}</body>
<body>
{children}
<JsonLd data={structuredData} />
</body>
</html>
);
}
+12
View File
@@ -0,0 +1,12 @@
import type { Metadata } from "next";
import { NOINDEX } from "@/lib/seo";
// La page de connexion est un composant client : ses métadonnées vivent ici.
export const metadata: Metadata = {
title: "Connexion à l'espace adhérent",
robots: NOINDEX,
};
export default function LoginLayout({ children }: { children: React.ReactNode }) {
return children;
}
+17
View File
@@ -0,0 +1,17 @@
import type { MetadataRoute } from "next";
import { SITE_DESCRIPTION, SITE_NAME, SITE_TITLE, THEME_COLOR } from "@/lib/seo";
/** Manifeste web : nom, icône et couleurs, pour l'ajout à l'écran d'accueil. */
export default function manifest(): MetadataRoute.Manifest {
return {
name: SITE_TITLE,
short_name: SITE_NAME,
description: SITE_DESCRIPTION,
lang: "fr",
start_url: "/",
display: "standalone",
background_color: "#F6F2E8",
theme_color: THEME_COLOR,
icons: [{ src: "/assets/logo.png", sizes: "any", type: "image/png" }],
};
}
+6 -4
View File
@@ -1,11 +1,13 @@
import type { Metadata } from "next";
import { LegalPage } from "@/components/LegalPage";
import { getSiteSettings } from "@/lib/site-settings";
import { pageMetadata } from "@/lib/seo";
export const metadata: Metadata = {
title: "Mentions légales — Plein R",
description: "Mentions légales du site de l'association Plein R.",
};
export const metadata: Metadata = pageMetadata({
title: "Mentions légales",
description: "Mentions légales du site de l'association Plein R : éditeur, hébergeur, propriété intellectuelle.",
path: "/mentions-legales",
});
export const dynamic = "force-dynamic";
+48
View File
@@ -0,0 +1,48 @@
import type { Metadata } from "next";
import Link from "next/link";
import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
import { NOINDEX } from "@/lib/seo";
export const metadata: Metadata = {
title: "Page introuvable",
robots: NOINDEX,
};
/** 404 publique : renvoie vers l'annuaire plutôt que vers une page blanche. */
export default function NotFound() {
return (
<div style={{ background: "#F6F2E8", minHeight: "100vh", fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
<SiteHeader logo />
<main className="container" style={{ paddingTop: 48, paddingBottom: 72, maxWidth: 720 }}>
<div style={{ color: "#9a6638", fontSize: 12, letterSpacing: "0.14em", textTransform: "uppercase", fontWeight: 800, marginBottom: 12 }}>
Erreur 404
</div>
<h1 className="font-display" style={{ margin: "0 0 14px", color: "#26201a", fontSize: "clamp(30px, 6vw, 48px)", lineHeight: 1.05, letterSpacing: "-0.02em" }}>
Cette page n&apos;existe pas ou plus
</h1>
<p style={{ fontSize: 17, lineHeight: 1.6, color: "#6c6150", margin: "0 0 26px" }}>
La fiche ou la page demandée a peut-être été retirée. Retrouvez tous les commerçants et
entreprises du Bassin de Pompey dans l&apos;annuaire.
</p>
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
<Link
href="/annuaire"
className="font-display"
style={{ textDecoration: "none", background: "#E0A63C", color: "#33291D", fontWeight: 700, fontSize: 15.5, padding: "13px 24px", borderRadius: 12 }}
>
Voir l&apos;annuaire
</Link>
<Link
href="/"
className="font-display"
style={{ textDecoration: "none", background: "#fff", border: "1px solid #e6dcc6", color: "#33291D", fontWeight: 700, fontSize: 15.5, padding: "13px 24px", borderRadius: 12 }}
>
Retour à l&apos;accueil
</Link>
</div>
</main>
<SiteFooter />
</div>
);
}
+88
View File
@@ -0,0 +1,88 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { ImageResponse } from "next/og";
import { SITE_NAME } from "@/lib/seo";
/**
* Image de partage (Open Graph / Twitter) générée à la volée, 1200×630.
* Sert de vignette par défaut à toutes les pages ; les fiches adhérents la
* reprennent, leurs logos étant stockés en data-URI (illisibles par les
* robots de prévisualisation).
*/
export const alt = "Plein R — Commerçants & entreprises du Bassin de Pompey";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
/** Logo servi depuis `public/` (copié dans l'image standalone), en data-URI pour Satori. */
async function logoDataUri(): Promise<string | null> {
try {
const file = await readFile(path.join(process.cwd(), "public", "assets", "logo.png"));
return `data:image/png;base64,${file.toString("base64")}`;
} catch {
return null;
}
}
export default async function OpenGraphImage() {
const logo = await logoDataUri();
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
padding: 72,
background: "linear-gradient(135deg, #F6F2E8 0%, #efe9da 100%)",
color: "#26201a",
fontFamily: "sans-serif",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 24 }}>
{logo ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={logo} alt="" width={120} height={120} style={{ objectFit: "contain" }} />
) : (
<div
style={{
width: 84,
height: 84,
borderRadius: 24,
background: "#E0A63C",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#26201a",
fontSize: 48,
fontWeight: 800,
}}
>
R
</div>
)}
<div style={{ fontSize: 40, fontWeight: 800, letterSpacing: -1 }}>{SITE_NAME}</div>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
<div style={{ fontSize: 26, letterSpacing: 6, textTransform: "uppercase", color: "#9a6638", fontWeight: 700 }}>
Réseau · Rencontre · Réussite
</div>
<div style={{ fontSize: 72, fontWeight: 800, lineHeight: 1.02, letterSpacing: -2, display: "flex", flexWrap: "wrap" }}>
Le cœur commerçant du&nbsp;
<span style={{ color: "#2C6FB3" }}>Bassin de Pompey</span>
</div>
<div style={{ fontSize: 30, color: "#6c6150", marginTop: 8 }}>
Annuaire des commerçants, artisans et entreprises · Bons plans · Rencontres
</div>
</div>
<div style={{ display: "flex", gap: 14 }}>
{["#E0A63C", "#2C6FB3", "#d8472b", "#1f8a5b"].map((c) => (
<div key={c} style={{ width: 64, height: 10, borderRadius: 999, background: c }} />
))}
</div>
</div>
),
{ ...size }
);
}
+15 -2
View File
@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Sparkle } from "@/components/Sparkle";
import { Reveal } from "@/components/Reveal";
@@ -10,9 +11,21 @@ import { PromoImage } from "@/components/PromoImage";
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
import { getLivePromotions, getRotatingActiveMembers } from "@/lib/queries";
import { getSiteSettings, socialLinks } from "@/lib/site-settings";
import { SITE_DESCRIPTION, SITE_TITLE, pageMetadata } from "@/lib/seo";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
...pageMetadata({
title: SITE_TITLE,
ogTitle: SITE_TITLE,
description: SITE_DESCRIPTION,
path: "/",
}),
// Titre absolu : la page d'accueil ne prend pas le suffixe « · Plein R ».
title: { absolute: SITE_TITLE },
};
const STRIPE_WARM =
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
@@ -40,7 +53,7 @@ export default async function AccueilPage() {
>
<SiteHeader active="accueil" />
<div className="container">
<main className="container">
{/* hero */}
<section
className="grid hero-grid"
@@ -456,7 +469,7 @@ export default async function AccueilPage() {
/>
</Reveal>
</section>
</div>
</main>
<SiteFooter />
</div>
+21 -1
View File
@@ -1,21 +1,34 @@
import type { Metadata } from "next";
import { asc, desc, inArray } from "drizzle-orm";
import { JsonLd } from "@/components/JsonLd";
import { PastMeetingGallery } from "@/components/PastMeetingGallery";
import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
import { db } from "@/db";
import { meetingRegistrations, pastMeetingPhotos, pastMeetings } from "@/db/schema";
import { splitLines } from "@/lib/site-settings";
import { breadcrumbJsonLd, pageMetadata } from "@/lib/seo";
import { publicBaseUrl } from "@/lib/seo-server";
export const dynamic = "force-dynamic";
export const metadata: Metadata = pageMetadata({
title: "Rencontres passées de l'association",
description:
"Retour en images sur les rencontres Plein R : soirées réseau, visites d'entreprises et moments " +
"partagés entre entrepreneurs du Bassin de Pompey.",
path: "/rencontres-passees",
});
function formatDate(date: Date) {
return new Intl.DateTimeFormat("fr-FR", { day: "numeric", month: "long", year: "numeric" }).format(date);
}
export default async function PastMeetingsPage() {
const [archives, photos] = await Promise.all([
const [archives, photos, baseUrl] = await Promise.all([
db.select().from(pastMeetings).orderBy(desc(pastMeetings.eventDate)),
db.select().from(pastMeetingPhotos).orderBy(asc(pastMeetingPhotos.position), asc(pastMeetingPhotos.id)),
publicBaseUrl(),
]);
const linkedIds = archives.map((archive) => archive.meetingId).filter((id): id is number => id !== null);
const linkedRegistrations = linkedIds.length > 0
@@ -37,6 +50,13 @@ export default async function PastMeetingsPage() {
return (
<div style={{ background: "#F6F2E8", minHeight: "100vh", fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
<SiteHeader active="association" logo />
<JsonLd
data={breadcrumbJsonLd(baseUrl, [
{ name: "Accueil", path: "/" },
{ name: "L'association", path: "/association" },
{ name: "Rencontres passées", path: "/rencontres-passees" },
])}
/>
<main>
<section style={{ background: "#13324F", color: "#fff" }}>
<div className="container" style={{ paddingTop: 58, paddingBottom: 54 }}>
+20
View File
@@ -0,0 +1,20 @@
import type { MetadataRoute } from "next";
import { PRIVATE_PATHS, absoluteUrl } from "@/lib/seo";
import { publicBaseUrl } from "@/lib/seo-server";
export const dynamic = "force-dynamic";
/** /robots.txt : tout le site public est explorable, l'espace privé exclu. */
export default async function robots(): Promise<MetadataRoute.Robots> {
const base = await publicBaseUrl();
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: PRIVATE_PATHS,
},
],
sitemap: base ? absoluteUrl(base, "/sitemap.xml") : undefined,
};
}
+40
View File
@@ -0,0 +1,40 @@
import type { MetadataRoute } from "next";
import { asc, eq } from "drizzle-orm";
import { db } from "@/db";
import { members } from "@/db/schema";
import { STATIC_ROUTES, absoluteUrl, memberPath } from "@/lib/seo";
import { publicBaseUrl } from "@/lib/seo-server";
export const dynamic = "force-dynamic";
/** /sitemap.xml : pages publiques + une entrée par adhérent actif. */
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const base = await publicBaseUrl();
// Pas de `lastModified` : sans date de mise à jour fiable en base, mieux vaut
// l'omettre qu'annoncer une valeur que les moteurs finiraient par ignorer.
const statics: MetadataRoute.Sitemap = STATIC_ROUTES.map((route) => ({
url: absoluteUrl(base, route.path),
changeFrequency: route.changeFrequency,
priority: route.priority,
}));
let rows: { id: number }[] = [];
try {
rows = await db
.select({ id: members.id })
.from(members)
.where(eq(members.status, "active"))
.orderBy(asc(members.id));
} catch {
// Base indisponible : on livre au moins les pages statiques.
}
const fiches: MetadataRoute.Sitemap = rows.map((m) => ({
url: absoluteUrl(base, memberPath(m.id)),
changeFrequency: "weekly",
priority: 0.7,
}));
return [...statics, ...fiches];
}
+27
View File
@@ -0,0 +1,27 @@
import { headers } from "next/headers";
import { serializeJsonLd } from "@/lib/seo";
/**
* Bloc de données structurées `application/ld+json`.
*
* Le contenu est sérialisé par `serializeJsonLd`, qui échappe `<`, `>` et `&` :
* les descriptions saisies par les adhérents ne peuvent pas fermer la balise.
* Le nonce de la CSP est recopié par cohérence, même si un bloc JSON-LD n'est
* jamais exécuté par le navigateur.
*/
export async function JsonLd({ data }: { data: unknown | unknown[] }) {
const nonce = (await headers()).get("x-nonce") ?? undefined;
const items = Array.isArray(data) ? data : [data];
return (
<>
{items.map((item, index) => (
<script
key={index}
type="application/ld+json"
nonce={nonce}
dangerouslySetInnerHTML={{ __html: serializeJsonLd(item) }}
/>
))}
</>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { headers } from "next/headers";
import { siteUrl } from "@/lib/social-accounts";
/**
* URL publique du site pour le référencement (canoniques, sitemap, JSON-LD).
*
* Priorité au réglage `site_public_url` (Backend › Réseaux sociaux) puis aux
* variables d'environnement, via `siteUrl()`. En dernier recours, l'URL est
* déduite des en-têtes de la requête (reverse proxy compris) : le site reste
* ainsi correctement référencé tant que le réglage n'est pas renseigné.
*/
export async function publicBaseUrl(): Promise<string> {
const configured = await siteUrl().catch(() => "");
if (configured) return configured;
try {
const h = await headers();
const host = h.get("x-forwarded-host") ?? h.get("host") ?? "";
if (!host) return "";
const protocol = h.get("x-forwarded-proto") ?? (host.startsWith("localhost") ? "http" : "https");
return `${protocol}://${host}`.replace(/\/+$/, "");
} catch {
// Hors requête (génération de robots/sitemap au build par exemple).
return "";
}
}
/** `metadataBase` pour Next : indéfini si aucune URL n'est connue. */
export async function metadataBase(): Promise<URL | undefined> {
const base = await publicBaseUrl();
if (!base) return undefined;
try {
return new URL(base);
} catch {
return undefined;
}
}
+391
View File
@@ -0,0 +1,391 @@
import type { Metadata } from "next";
import type { DaySchedule, WeekDayKey } from "@/lib/member-profile";
import type { SiteSettings, SocialLink } from "@/lib/site-settings";
/**
* Référencement : constantes, métadonnées et données structurées (JSON-LD).
*
* Tout ce module est **pur** : aucune lecture en base, aucun accès aux en-têtes
* de requête. Les pages lui passent l'URL publique obtenue via
* `publicBaseUrl()` (src/lib/seo-server.ts). Cela permet de verrouiller le
* comportement par des tests unitaires (tests/seo.test.ts).
*/
export const SITE_NAME = "Plein R";
export const SITE_TITLE = "Plein R — Commerçants & entreprises du Bassin de Pompey";
export const SITE_DESCRIPTION =
"Plein R, association des commerçants, artisans et entreprises du Bassin de Pompey : " +
"annuaire des professionnels, bons plans et rencontres près de chez vous.";
export const SITE_LOCALE = "fr_FR";
export const THEME_COLOR = "#E0A63C";
/** Mots-clés de la thématique : commerce de proximité sur le Bassin de Pompey. */
export const SITE_KEYWORDS = [
"Plein R",
"Bassin de Pompey",
"commerçants Bassin de Pompey",
"association de commerçants",
"entreprises Pompey",
"artisans Pompey",
"commerces Frouard",
"commerces Liverdun",
"commerces Champigneulles",
"commerces Custines",
"annuaire des professionnels",
"bons plans",
"promotions locales",
"commerce de proximité",
"Meurthe-et-Moselle",
"Lorraine",
];
/** Longueur cible d'une meta description : Google tronque vers 155–160 caractères. */
export const DESCRIPTION_MAX_LENGTH = 155;
/** Pages transactionnelles ou privées : jamais indexées. */
export const NOINDEX: NonNullable<Metadata["robots"]> = {
index: false,
follow: false,
nocache: true,
googleBot: { index: false, follow: false, noimageindex: true },
};
/** Directives d'indexation généreuses pour les pages publiques. */
export const INDEX_FOLLOW: NonNullable<Metadata["robots"]> = {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-image-preview": "large",
"max-snippet": -1,
"max-video-preview": -1,
},
};
/** Chemins exclus des moteurs (robots.txt + sitemap). */
export const PRIVATE_PATHS = ["/backend", "/api", "/login", "/inscription"];
/** Pages publiques statiques, avec leur priorité relative dans le sitemap. */
export const STATIC_ROUTES: { path: string; priority: number; changeFrequency: "daily" | "weekly" | "monthly" | "yearly" }[] = [
{ path: "/", priority: 1, changeFrequency: "daily" },
{ path: "/annuaire", priority: 0.9, changeFrequency: "daily" },
{ path: "/association", priority: 0.8, changeFrequency: "weekly" },
{ path: "/rencontres-passees", priority: 0.6, changeFrequency: "monthly" },
{ path: "/mentions-legales", priority: 0.2, changeFrequency: "yearly" },
{ path: "/confidentialite", priority: 0.2, changeFrequency: "yearly" },
];
/** Construit une URL absolue à partir de l'URL publique et d'un chemin. */
export function absoluteUrl(baseUrl: string, path = "/"): string {
const base = baseUrl.replace(/\/+$/, "");
if (!path || path === "/") return `${base}/`;
return `${base}${path.startsWith("/") ? path : `/${path}`}`;
}
/** Chemin canonique d'une fiche adhérent. */
export function memberPath(id: number): string {
return `/adherents/${id}`;
}
/**
* Ramène un texte libre à une meta description propre : espaces normalisés,
* coupée sur un mot entier avec une ellipse si elle dépasse la longueur cible.
*/
export function metaDescription(text: string | null | undefined, fallback = SITE_DESCRIPTION): string {
const clean = (text ?? "").replace(/\s+/g, " ").trim();
if (!clean) return metaDescription(fallback, "");
if (clean.length <= DESCRIPTION_MAX_LENGTH) return clean;
const cut = clean.slice(0, DESCRIPTION_MAX_LENGTH - 1);
const lastSpace = cut.lastIndexOf(" ");
return `${(lastSpace > 60 ? cut.slice(0, lastSpace) : cut).replace(/[\s,;:.!?–—-]+$/, "")}…`;
}
/**
* Sérialise un objet JSON-LD pour l'insérer dans un `<script>`.
*
* Les données viennent en partie des adhérents (descriptions, noms) : on échappe
* `<`, `>` et `&` en séquences Unicode pour qu'aucune saisie ne puisse fermer la
* balise `</script>` et injecter du HTML. JSON reste strictement valide.
*/
export function serializeJsonLd(data: unknown): string {
return JSON.stringify(data)
.replace(/</g, "\\u003c")
.replace(/>/g, "\\u003e")
.replace(/&/g, "\\u0026")
.replace(/\u2028/g, "\\u2028")
.replace(/\u2029/g, "\\u2029");
}
/** Retire les clés dont la valeur est vide : JSON-LD n'aime pas les `null`. */
export function compact<T extends Record<string, unknown>>(obj: T): Partial<T> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
if (value === null || value === undefined || value === "") continue;
if (Array.isArray(value) && value.length === 0) continue;
out[key] = value;
}
return out as Partial<T>;
}
// ---------------------------------------------------------------------------
// Données structurées
// ---------------------------------------------------------------------------
export function organizationId(baseUrl: string): string {
return `${absoluteUrl(baseUrl)}#organization`;
}
export function webSiteId(baseUrl: string): string {
return `${absoluteUrl(baseUrl)}#website`;
}
/** L'association elle-même (utilisée sur toutes les pages via le layout). */
export function organizationJsonLd(baseUrl: string, settings: SiteSettings, socials: SocialLink[]) {
const address = settings.association_address.trim();
return compact({
"@context": "https://schema.org",
"@type": "Organization",
"@id": organizationId(baseUrl),
name: settings.association_name || SITE_NAME,
alternateName: "Association Plein R — Bassin de Pompey",
legalName: settings.association_name ? `Association ${settings.association_name}` : undefined,
url: absoluteUrl(baseUrl),
logo: {
"@type": "ImageObject",
url: absoluteUrl(baseUrl, "/assets/logo.png"),
},
image: absoluteUrl(baseUrl, "/opengraph-image"),
description: metaDescription(settings.association_intro || settings.association_tagline),
slogan: "Réseau · Rencontre · Réussite",
email: settings.association_email.trim() || undefined,
telephone: settings.association_phone.trim() || undefined,
address: address
? { "@type": "PostalAddress", streetAddress: address, addressCountry: "FR" }
: undefined,
areaServed: {
"@type": "AdministrativeArea",
name: "Bassin de Pompey, Meurthe-et-Moselle, France",
},
sameAs: socials.map((s) => s.href),
});
}
/** Le site et sa fonction de recherche (annuaire), pour la « sitelinks searchbox ». */
export function webSiteJsonLd(baseUrl: string) {
return {
"@context": "https://schema.org",
"@type": "WebSite",
"@id": webSiteId(baseUrl),
name: SITE_NAME,
alternateName: SITE_TITLE,
url: absoluteUrl(baseUrl),
inLanguage: "fr-FR",
publisher: { "@id": organizationId(baseUrl) },
potentialAction: {
"@type": "SearchAction",
target: {
"@type": "EntryPoint",
urlTemplate: `${absoluteUrl(baseUrl, "/annuaire")}?q={search_term_string}`,
},
"query-input": "required name=search_term_string",
},
};
}
export type BreadcrumbItem = { name: string; path: string };
/** Fil d'Ariane : améliore l'affichage du chemin dans les résultats. */
export function breadcrumbJsonLd(baseUrl: string, items: BreadcrumbItem[]) {
return {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: items.map((item, index) => ({
"@type": "ListItem",
position: index + 1,
name: item.name,
item: absoluteUrl(baseUrl, item.path),
})),
};
}
const SCHEMA_DAYS: Record<WeekDayKey, string> = {
monday: "https://schema.org/Monday",
tuesday: "https://schema.org/Tuesday",
wednesday: "https://schema.org/Wednesday",
thursday: "https://schema.org/Thursday",
friday: "https://schema.org/Friday",
saturday: "https://schema.org/Saturday",
sunday: "https://schema.org/Sunday",
};
/** Horaires d'un adhérent au format `OpeningHoursSpecification`. */
export function openingHoursJsonLd(hours: DaySchedule[]) {
return hours
.filter((day) => !day.closed && day.slots.length > 0)
.flatMap((day) =>
day.slots.map((slot) => ({
"@type": "OpeningHoursSpecification",
dayOfWeek: SCHEMA_DAYS[day.key],
opens: slot.start,
closes: slot.end,
}))
);
}
export type LocalBusinessInput = {
id: number;
name: string;
description: string | null;
address: string | null;
postalCode: string | null;
city: string | null;
phone: string | null;
email: string | null;
website: string | null;
categoryLabel: string | null;
tags: string | null;
};
/** Une fiche adhérent : commerce ou entreprise locale. */
export function localBusinessJsonLd(
baseUrl: string,
member: LocalBusinessInput,
hours: DaySchedule[],
website: string | null
) {
const hasAddress = Boolean(member.address || member.postalCode || member.city);
const keywords = (member.tags ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
const url = absoluteUrl(baseUrl, memberPath(member.id));
return compact({
"@context": "https://schema.org",
"@type": "LocalBusiness",
"@id": `${url}#business`,
name: member.name,
description: member.description ? metaDescription(member.description, "") : undefined,
url: website ?? url,
mainEntityOfPage: url,
telephone: member.phone ?? undefined,
email: member.email ?? undefined,
address: hasAddress
? compact({
"@type": "PostalAddress",
streetAddress: member.address ?? undefined,
postalCode: member.postalCode ?? undefined,
addressLocality: member.city ?? undefined,
addressRegion: "Grand Est",
addressCountry: "FR",
})
: undefined,
openingHoursSpecification: openingHoursJsonLd(hours),
keywords: keywords.length > 0 ? keywords.join(", ") : undefined,
additionalType: member.categoryLabel ?? undefined,
memberOf: { "@id": organizationId(baseUrl) },
});
}
export type EventInput = {
id: number;
title: string;
startsAt: Date;
location: string | null;
description: string | null;
capacity: number;
registered: number;
};
/** Une rencontre à venir de l'association. */
export function eventJsonLd(baseUrl: string, meeting: EventInput) {
const remaining = Math.max(0, meeting.capacity - Number(meeting.registered));
return compact({
"@context": "https://schema.org",
"@type": "BusinessEvent",
name: meeting.title,
description: meeting.description ? metaDescription(meeting.description, "") : undefined,
startDate: meeting.startsAt.toISOString(),
eventStatus: "https://schema.org/EventScheduled",
eventAttendanceMode: "https://schema.org/OfflineEventAttendanceMode",
location: meeting.location
? { "@type": "Place", name: meeting.location, address: meeting.location }
: undefined,
organizer: { "@id": organizationId(baseUrl) },
url: absoluteUrl(baseUrl, "/association"),
maximumAttendeeCapacity: meeting.capacity,
remainingAttendeeCapacity: remaining,
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "EUR",
availability: remaining > 0 ? "https://schema.org/InStock" : "https://schema.org/SoldOut",
url: absoluteUrl(baseUrl, `/inscription/${meeting.id}`),
},
});
}
export type ItemListEntry = { id: number; name: string; description?: string | null };
/** Liste d'adhérents (annuaire) : chaque entrée pointe vers sa fiche. */
export function memberListJsonLd(baseUrl: string, name: string, members: ItemListEntry[]) {
return {
"@context": "https://schema.org",
"@type": "ItemList",
name,
numberOfItems: members.length,
itemListElement: members.map((m, index) => ({
"@type": "ListItem",
position: index + 1,
url: absoluteUrl(baseUrl, memberPath(m.id)),
name: m.name,
})),
};
}
/**
* Image de partage par défaut (générée par app/opengraph-image.tsx). Une page
* qui définit son propre `openGraph` remplace celui du layout en bloc : il faut
* donc redonner l'image explicitement, sinon elle disparaît des partages.
*/
export const SHARE_IMAGE = {
url: "/opengraph-image",
width: 1200,
height: 630,
alt: SITE_TITLE,
type: "image/png",
};
/** Métadonnées Open Graph / Twitter communes, complétées par chaque page. */
export function pageMetadata(input: {
title: string;
/** Titre complet pour Open Graph quand le titre de page est déjà « absolu ». */
ogTitle?: string;
description: string;
path: string;
robots?: Metadata["robots"];
ogType?: "website" | "profile" | "article";
}): Metadata {
const ogTitle = input.ogTitle ?? `${input.title} · ${SITE_NAME}`;
return {
title: input.title,
description: input.description,
alternates: { canonical: input.path },
openGraph: {
title: ogTitle,
description: input.description,
url: input.path,
type: input.ogType ?? "website",
locale: SITE_LOCALE,
siteName: SITE_NAME,
images: [SHARE_IMAGE],
},
twitter: {
card: "summary_large_image",
title: ogTitle,
description: input.description,
images: [SHARE_IMAGE],
},
robots: input.robots ?? INDEX_FOLLOW,
};
}
+174
View File
@@ -0,0 +1,174 @@
import { strict as assert } from "node:assert";
import { describe, it } from "node:test";
import {
DESCRIPTION_MAX_LENGTH,
NOINDEX,
PRIVATE_PATHS,
STATIC_ROUTES,
absoluteUrl,
breadcrumbJsonLd,
eventJsonLd,
localBusinessJsonLd,
memberListJsonLd,
metaDescription,
openingHoursJsonLd,
organizationJsonLd,
pageMetadata,
serializeJsonLd,
webSiteJsonLd,
} from "../src/lib/seo";
import { parseMemberHours } from "../src/lib/member-profile";
import { SITE_SETTING_DEFAULTS, socialLinks } from "../src/lib/site-settings";
const BASE = "https://pleinr.example.org";
describe("SEO — URLs et descriptions", () => {
it("construit des URLs absolues propres", () => {
assert.equal(absoluteUrl(BASE), `${BASE}/`);
assert.equal(absoluteUrl(`${BASE}//`, "/annuaire"), `${BASE}/annuaire`);
assert.equal(absoluteUrl(BASE, "adherents/3"), `${BASE}/adherents/3`);
});
it("borne la meta description sur un mot entier", () => {
const long = "Boulangerie artisanale ".repeat(20);
const out = metaDescription(long);
assert.ok(out.length <= DESCRIPTION_MAX_LENGTH, `trop long : ${out.length}`);
assert.ok(out.endsWith("…"));
assert.ok(!/ …$/.test(out), "pas d'espace avant l'ellipse");
assert.equal(metaDescription(" Deux lignes\n\nici "), "Deux lignes ici");
assert.equal(metaDescription(null, "repli"), "repli");
});
});
describe("SEO — JSON-LD sûr", () => {
// Les descriptions viennent des adhérents : un `</script>` ne doit jamais
// pouvoir sortir du bloc de données structurées.
it("échappe toute tentative de fermeture de balise", () => {
const out = serializeJsonLd({ description: `</script><script>alert(1)</script> & <img>` });
assert.ok(!out.includes("<"), out);
assert.ok(!out.includes(">"), out);
assert.ok(!out.includes("&"), out);
// Le JSON reste valide et fidèle une fois relu.
assert.equal(JSON.parse(out).description, `</script><script>alert(1)</script> & <img>`);
});
});
describe("SEO — données structurées", () => {
const settings = {
...SITE_SETTING_DEFAULTS,
association_email: "contact@pleinr.fr",
association_address: "1 place de la Mairie, 54340 Pompey",
};
it("décrit l'association avec ses réseaux et son logo", () => {
const org = organizationJsonLd(BASE, settings, socialLinks(settings)) as Record<string, unknown>;
assert.equal(org["@type"], "Organization");
assert.equal(org.url, `${BASE}/`);
assert.equal(org.email, "contact@pleinr.fr");
assert.deepEqual(org.sameAs, [settings.association_facebook, settings.association_linkedin]);
assert.equal((org.logo as { url: string }).url, `${BASE}/assets/logo.png`);
assert.ok(!("telephone" in org), "les champs vides sont retirés");
});
it("expose la recherche de l'annuaire au moteur", () => {
const site = webSiteJsonLd(BASE);
assert.equal(
site.potentialAction.target.urlTemplate,
`${BASE}/annuaire?q={search_term_string}`
);
assert.equal(site.publisher["@id"], `${BASE}/#organization`);
});
it("numérote le fil d'Ariane", () => {
const crumbs = breadcrumbJsonLd(BASE, [
{ name: "Accueil", path: "/" },
{ name: "Annuaire", path: "/annuaire" },
]);
assert.deepEqual(
crumbs.itemListElement.map((i) => [i.position, i.item]),
[[1, `${BASE}/`], [2, `${BASE}/annuaire`]]
);
});
it("traduit les horaires en OpeningHoursSpecification", () => {
const hours = parseMemberHours("Lundi – Vendredi|9h-12h / 14h-18h30\nSamedi|9h-12h\nDimanche|Fermé");
const spec = openingHoursJsonLd(hours);
const monday = spec.filter((s) => s.dayOfWeek === "https://schema.org/Monday");
assert.equal(monday.length, 2);
assert.deepEqual(monday.map((s) => [s.opens, s.closes]), [["09:00", "12:00"], ["14:00", "18:30"]]);
assert.ok(spec.every((s) => s.dayOfWeek !== "https://schema.org/Sunday"));
});
it("décrit une fiche adhérent en LocalBusiness", () => {
const member = {
id: 7,
name: "Au Bon Pain",
description: "Boulangerie artisanale.",
address: "12 rue du Four",
postalCode: "54340",
city: "Pompey",
phone: "03 83 00 00 00",
email: null,
website: null,
categoryLabel: "Boulangerie",
tags: "pain, viennoiseries",
};
const biz = localBusinessJsonLd(BASE, member, [], null) as Record<string, unknown>;
assert.equal(biz["@type"], "LocalBusiness");
assert.equal(biz.url, `${BASE}/adherents/7`);
assert.equal((biz.address as { addressLocality: string }).addressLocality, "Pompey");
assert.equal(biz.keywords, "pain, viennoiseries");
assert.ok(!("email" in biz));
assert.ok(!("openingHoursSpecification" in biz), "pas d'horaires vides");
const withSite = localBusinessJsonLd(BASE, member, [], "https://aubonpain.fr") as Record<string, unknown>;
assert.equal(withSite.url, "https://aubonpain.fr");
assert.equal(withSite.mainEntityOfPage, `${BASE}/adherents/7`);
});
it("décrit une rencontre à venir avec ses places restantes", () => {
const event = eventJsonLd(BASE, {
id: 3,
title: "Afterwork de rentrée",
startsAt: new Date("2026-09-24T17:30:00Z"),
location: "Salle des fêtes, Frouard",
description: null,
capacity: 30,
registered: 30,
}) as Record<string, unknown>;
assert.equal(event.startDate, "2026-09-24T17:30:00.000Z");
assert.equal(event.remainingAttendeeCapacity, 0);
assert.equal((event.offers as { availability: string }).availability, "https://schema.org/SoldOut");
assert.equal((event.offers as { url: string }).url, `${BASE}/inscription/3`);
});
it("liste les adhérents de l'annuaire", () => {
const list = memberListJsonLd(BASE, "Annuaire", [{ id: 1, name: "A" }, { id: 2, name: "B" }]);
assert.equal(list.numberOfItems, 2);
assert.equal(list.itemListElement[1].url, `${BASE}/adherents/2`);
});
});
describe("SEO — indexation", () => {
it("exclut l'espace privé et les pages transactionnelles", () => {
for (const path of ["/backend", "/api", "/login", "/inscription"]) {
assert.ok(PRIVATE_PATHS.includes(path), `${path} doit être exclu des robots`);
}
assert.ok(STATIC_ROUTES.every((r) => !PRIVATE_PATHS.some((p) => r.path.startsWith(p))));
assert.equal((NOINDEX as { index: boolean }).index, false);
});
it("pose canonique, Open Graph et Twitter sur chaque page", () => {
const meta = pageMetadata({ title: "Annuaire", description: "Desc", path: "/annuaire" });
assert.equal(meta.alternates?.canonical, "/annuaire");
assert.equal((meta.openGraph as { url: string }).url, "/annuaire");
assert.equal((meta.openGraph as { locale: string }).locale, "fr_FR");
assert.equal((meta.openGraph as { title: string }).title, "Annuaire · Plein R");
assert.equal((meta.twitter as { card: string }).card, "summary_large_image");
// L'image de partage doit survivre au remplacement du bloc openGraph du layout.
assert.equal((meta.openGraph as { images: { url: string }[] }).images[0].url, "/opengraph-image");
assert.equal((meta.twitter as { images: { url: string }[] }).images[0].url, "/opengraph-image");
assert.equal((meta.robots as { index: boolean }).index, true);
});
});