mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-13 18:29:03 +02:00
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:
21 files changed
+1113
-36
No files matched your search
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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,
|
||||
}: {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" }],
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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'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'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'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'accueil
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
<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
@@ -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>
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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) }}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user