mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-23 15:18:04 +02:00
Deux réglages dans Backend › Paramètres : - `google_analytics_id` pose les balises gtag.js. Vide = rien n'est chargé. `src/lib/analytics.ts` (pur) n'accepte qu'un identifiant GA4 « G-… » : la saisie ne peut donc pas s'échapper du script en ligne. Les deux `<script>` portent le nonce de la CSP, et seules les pages publiques sont mesurées (`isTrackedPath` écarte `PRIVATE_PATHS`, le chemin venant du nouvel en-tête `x-pathname` du middleware — un layout ne connaît pas l'URL demandée). - `google_site_verification` rend la balise de validation Search Console via `verification.google`, après contrôle du jeton. La CSP gagne les hôtes Google Analytics en `connect-src` : elle est posée sur le runtime Edge, qui ne peut pas lire la base pour savoir si la mesure est active. `script-src` est inchangé, `'strict-dynamic'` couvrant les scripts que gtag.js charge ensuite. Le plan du site n'a besoin de rien : `/sitemap.xml` est déjà servi et annoncé dans `/robots.txt`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7v5KcYUo2taDVDgehs7ZH
125 lines
4.0 KiB
TypeScript
125 lines
4.0 KiB
TypeScript
import type { Metadata, Viewport } from "next";
|
||
import { Analytics } from "@/components/Analytics";
|
||
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 { normalizeSiteVerification } from "@/lib/analytics";
|
||
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> {
|
||
const [base, settings] = await Promise.all([
|
||
metadataBase(),
|
||
getSiteSettings().catch(() => null),
|
||
]);
|
||
// Jeton « balise HTML » de Search Console, collé dans Backend › Paramètres.
|
||
const google = normalizeSiteVerification(settings?.google_site_verification);
|
||
return {
|
||
metadataBase: base,
|
||
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,
|
||
verification: google ? { google } : undefined,
|
||
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} />
|
||
<Analytics measurementId={settings?.google_analytics_id ?? ""} />
|
||
</body>
|
||
</html>
|
||
);
|
||
}
|