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

+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,
};
}