diff --git a/CLAUDE.md b/CLAUDE.md index 0326480..15efe12 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `` : `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 `
` et un seul `

`. + ## Roles `admin` > `moderator` > `editor` are staff; `member` is an adhérent linked to a diff --git a/src/app/adherents/[id]/page.tsx b/src/app/adherents/[id]/page.tsx index b479a7c..d3fa2c9 100644 --- a/src/app/adherents/[id]/page.tsx +++ b/src/app/adherents/[id]/page.tsx @@ -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 { 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 (
+ -
+
{/* breadcrumb */} -
+
+ {member.name} + {/* cover + identity */}
@@ -409,7 +446,7 @@ export default async function FicheAdherentPage({ )}
-
+

diff --git a/src/app/annuaire/page.tsx b/src/app/annuaire/page.tsx index 3d46ac1..950df72 100644 --- a/src/app/annuaire/page.tsx +++ b/src/app/annuaire/page.tsx @@ -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(); @@ -46,8 +53,17 @@ export default async function AnnuairePage({ return (
+ -
+
{/* title block */}
@@ -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" }} />
-
+
diff --git a/src/app/association/page.tsx b/src/app/association/page.tsx index 03de506..65a925d 100644 --- a/src/app/association/page.tsx +++ b/src/app/association/page.tsx @@ -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 (
+ eventJsonLd(baseUrl, meeting)), + ]} + />
diff --git a/src/app/backend/layout.tsx b/src/app/backend/layout.tsx index ba8f826..024cb24 100644 --- a/src/app/backend/layout.tsx +++ b/src/app/backend/layout.tsx @@ -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, }: { diff --git a/src/app/confidentialite/page.tsx b/src/app/confidentialite/page.tsx index 0ccaed3..94c081a 100644 --- a/src/app/confidentialite/page.tsx +++ b/src/app/confidentialite/page.tsx @@ -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"; diff --git a/src/app/inscription/[id]/page.tsx b/src/app/inscription/[id]/page.tsx index b084c75..1a56732 100644 --- a/src/app/inscription/[id]/page.tsx +++ b/src/app/inscription/[id]/page.tsx @@ -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", diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e2ff850..e41f0b1 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 { + 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 ( @@ -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" /> - {/* Fallback: if JS is disabled, never keep revealed content hidden. */} - {children} + + {children} + + ); } diff --git a/src/app/login/layout.tsx b/src/app/login/layout.tsx new file mode 100644 index 0000000..bbd3ddd --- /dev/null +++ b/src/app/login/layout.tsx @@ -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; +} diff --git a/src/app/manifest.ts b/src/app/manifest.ts new file mode 100644 index 0000000..fcd3e36 --- /dev/null +++ b/src/app/manifest.ts @@ -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" }], + }; +} diff --git a/src/app/mentions-legales/page.tsx b/src/app/mentions-legales/page.tsx index 941954f..a4dc0db 100644 --- a/src/app/mentions-legales/page.tsx +++ b/src/app/mentions-legales/page.tsx @@ -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"; diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx new file mode 100644 index 0000000..59ff207 --- /dev/null +++ b/src/app/not-found.tsx @@ -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 ( +
+ +
+
+ Erreur 404 +
+

+ Cette page n'existe pas ou plus +

+

+ 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'annuaire. +

+
+ + Voir l'annuaire + + + Retour à l'accueil + +
+
+ +
+ ); +} diff --git a/src/app/opengraph-image.tsx b/src/app/opengraph-image.tsx new file mode 100644 index 0000000..69389bf --- /dev/null +++ b/src/app/opengraph-image.tsx @@ -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 { + 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( + ( +
+
+ {logo ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( +
+ R +
+ )} +
{SITE_NAME}
+
+
+
+ Réseau · Rencontre · Réussite +
+
+ Le cœur commerçant du  + Bassin de Pompey +
+
+ Annuaire des commerçants, artisans et entreprises · Bons plans · Rencontres +
+
+
+ {["#E0A63C", "#2C6FB3", "#d8472b", "#1f8a5b"].map((c) => ( +
+ ))} +
+
+ ), + { ...size } + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 7539fd3..6bfbf4b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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() { > -
+
{/* hero */}
-
+
diff --git a/src/app/rencontres-passees/page.tsx b/src/app/rencontres-passees/page.tsx index a497cab..caec09c 100644 --- a/src/app/rencontres-passees/page.tsx +++ b/src/app/rencontres-passees/page.tsx @@ -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 (
+
diff --git a/src/app/robots.ts b/src/app/robots.ts new file mode 100644 index 0000000..3a0a172 --- /dev/null +++ b/src/app/robots.ts @@ -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 { + const base = await publicBaseUrl(); + return { + rules: [ + { + userAgent: "*", + allow: "/", + disallow: PRIVATE_PATHS, + }, + ], + sitemap: base ? absoluteUrl(base, "/sitemap.xml") : undefined, + }; +} diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts new file mode 100644 index 0000000..5bbef5d --- /dev/null +++ b/src/app/sitemap.ts @@ -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 { + 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]; +} diff --git a/src/components/JsonLd.tsx b/src/components/JsonLd.tsx new file mode 100644 index 0000000..ea348d9 --- /dev/null +++ b/src/components/JsonLd.tsx @@ -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) => ( + ` et injecter du HTML. JSON reste strictement valide. + */ +export function serializeJsonLd(data: unknown): string { + return JSON.stringify(data) + .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>(obj: T): Partial { + const out: Record = {}; + 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; +} + +// --------------------------------------------------------------------------- +// 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 = { + 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, + }; +} diff --git a/tests/seo.test.ts b/tests/seo.test.ts new file mode 100644 index 0000000..d83a4f1 --- /dev/null +++ b/tests/seo.test.ts @@ -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 `` ne doit jamais + // pouvoir sortir du bloc de données structurées. + it("échappe toute tentative de fermeture de balise", () => { + const out = serializeJsonLd({ description: ` & ` }); + 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, ` & `); + }); +}); + +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; + 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; + 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; + 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; + 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); + }); +});