Files
PleinR/src/app/layout.tsx
T
Claude a8adb9ae6a 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
2026-09-05 10:41:53 +00:00

115 lines
3.5 KiB
TypeScript

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
// pas signer les scripts d'un HTML pré-généré au build. Toutes les pages
// interrogent de toute façon la base.
export const dynamic = "force-dynamic";
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
themeColor: THEME_COLOR,
colorScheme: "light",
};
/**
* 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>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
rel="preconnect"
href="https://fonts.gstatic.com"
crossOrigin="anonymous"
/>
<link
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. */}
<noscript>
<style>{`.reveal,.reveal-stagger>*{opacity:1!important}`}</style>
</noscript>
</head>
<body>
{children}
<JsonLd data={structuredData} />
</body>
</html>
);
}