mise a jour structure SEO

This commit is contained in:
sebvtl728 2025-11-05 01:08:39 +01:00
parent 5909871dec
commit 1890d830e1
12 changed files with 3157 additions and 205 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -49,6 +49,53 @@
</head>
<body>
<div id="root"></div>
<noscript>
<style>
.no-js-wrapper {
padding: 1.5rem;
font-family: "Helvetica Neue", Arial, sans-serif;
line-height: 1.5;
background: #f5f7fb;
color: #0b1f44;
}
.no-js-wrapper a {
color: #0b5ed7;
text-decoration: none;
}
.no-js-wrapper a:hover,
.no-js-wrapper a:focus {
text-decoration: underline;
}
.no-js-nav {
margin: 1rem 0;
padding-left: 1.2rem;
}
</style>
<section class="no-js-wrapper">
<h2>Navigation sans JavaScript</h2>
<p>
Le site s&apos;appuie sur React et nécessite JavaScript pour l&apos;expérience complète.
Activez-le pour profiter de toutes les fonctionnalités. En attendant,
utilisez ces liens directs&nbsp;:
</p>
<ul class="no-js-nav">
<li><a href="/">Accueil</a></li>
<li><a href="/services/prestation-maitrise-oeuvre/">Création de site web</a></li>
<li><a href="/services/formations-web/">Formations web</a></li>
<li><a href="/services/electricite/">Graphisme</a></li>
<li><a href="/services/service-securite-incendie/">Podcast</a></li>
<li><a href="/services/expertises-tce/">Formations professionnelles</a></li>
<li><a href="/nosFormations/">Nos formations</a></li>
<li><a href="/posts/">Actualités</a></li>
<li><a href="/contact/">Contact</a></li>
<li><a href="/search/">Recherche</a></li>
<li><a href="/sitemap.xml">Plan du site complet</a></li>
</ul>
<p>
Retrouvez aussi nos informations légales et politiques via le plan du site.
</p>
</section>
</noscript>
<script type="module" src="/src/main.jsx" defer></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@ -5,9 +5,12 @@
"type": "module",
"scripts": {
"dev": "vite",
"prebuild": "node scripts/update-noscript.mjs",
"build": "vite build",
"postbuild": "node scripts/prerender.mjs",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"prerender": "node scripts/prerender.mjs"
},
"dependencies": {
"@emotion/react": "^11.14.0",
@ -41,6 +44,7 @@
"eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.14.0",
"postcss": "^8.4.49",
"puppeteer": "^24.28.0",
"rollup-plugin-visualizer": "^5.14.0",
"vite": "^6.0.5",
"vite-plugin-compression": "^0.5.1"

View File

@ -1,5 +1,2 @@
User-agent: *
Disallow: /static/
Allow: /
Sitemap: https://octopusdesign.fr/sitemap.xml
Disallow: /

View File

@ -0,0 +1,149 @@
import { createServer } from "node:http";
import { readFile, stat, writeFile, mkdir } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import puppeteer from "puppeteer";
import { PUBLIC_ROUTES, NOT_FOUND_ROUTE } from "./routes.mjs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const distDir = path.resolve(__dirname, "../dist");
const routes = PUBLIC_ROUTES.filter((route) => route.prerender).map(
(route) => route.path
);
const mimeTypes = {
".html": "text/html",
".js": "application/javascript",
".css": "text/css",
".json": "application/json",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".avif": "image/avif",
".webp": "image/webp",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".txt": "text/plain",
".xml": "application/xml",
".map": "application/json",
".gz": "application/gzip",
".br": "application/brotli",
};
function serveStaticFile(res, filePath) {
const ext = path.extname(filePath).toLowerCase();
const contentType = mimeTypes[ext] ?? "application/octet-stream";
res.statusCode = 200;
res.setHeader("Content-Type", contentType);
return readFile(filePath).then((data) => res.end(data));
}
function createStaticServer() {
const server = createServer(async (req, res) => {
try {
const requestUrl = new URL(req.url ?? "/", "http://localhost");
let pathname = decodeURIComponent(requestUrl.pathname);
const hasExtension = path.extname(pathname) !== "";
if (pathname.endsWith("/")) {
pathname = pathname.slice(0, -1);
}
let filePath = path.join(distDir, pathname);
try {
const fileStat = await stat(filePath);
if (fileStat.isDirectory()) {
filePath = path.join(filePath, "index.html");
}
await serveStaticFile(res, filePath);
return;
} catch (error) {
if (hasExtension) {
res.statusCode = 404;
res.end("Not found");
return;
}
filePath = path.join(distDir, "index.html");
await serveStaticFile(res, filePath);
}
} catch (error) {
res.statusCode = 500;
res.end(`Server error: ${error instanceof Error ? error.message : error}`);
}
});
return new Promise((resolve) => {
server.listen(0, () => {
const address = server.address();
if (address && typeof address === "object") {
resolve({ server, port: address.port });
} else {
resolve({ server, port: 4173 });
}
});
});
}
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function renderRoute(browser, baseUrl, route, outputFile) {
const page = await browser.newPage();
const targetUrl = `${baseUrl}${route}`;
try {
await page.goto(targetUrl, {
waitUntil: ["networkidle0", "domcontentloaded"],
timeout: 60000,
});
} catch (error) {
console.warn(
`[prerender] Navigation to ${targetUrl} failed: ${
error instanceof Error ? error.message : error
}`
);
}
try {
// Donne un léger délai pour laisser React terminer le rendu asynchrone
await delay(1500);
const html = await page.content();
const outputPath =
outputFile ??
(route === "/"
? path.join(distDir, "index.html")
: path.join(distDir, route.replace(/^\//, ""), "index.html"));
await mkdir(path.dirname(outputPath), { recursive: true });
await writeFile(outputPath, html, "utf8");
console.log(
`✔ prerendered ${route} -> ${path.relative(distDir, outputPath) || "index.html"}`
);
} finally {
await page.close();
}
}
async function main() {
const { server, port } = await createStaticServer();
const baseUrl = `http://127.0.0.1:${port}`;
const browser = await puppeteer.launch({ headless: true });
try {
for (const route of routes) {
await renderRoute(browser, baseUrl, route);
}
await renderRoute(browser, baseUrl, NOT_FOUND_ROUTE, path.join(distDir, "404.html"));
} finally {
await browser.close();
server.close();
}
}
main().catch((error) => {
console.error("[prerender] Failed to prerender routes:", error);
process.exit(1);
});

View File

@ -0,0 +1,70 @@
const withTrailingSlash = (path) => {
if (!path || path === "/") return "/";
return path.endsWith("/") ? path : `${path}/`;
};
export const PUBLIC_ROUTES = [
{ path: "/", href: "/", label: "Accueil", prerender: true },
{
path: "/services/prestation-maitrise-oeuvre",
href: withTrailingSlash("/services/prestation-maitrise-oeuvre"),
label: "Création de site web",
prerender: true,
},
{
path: "/services/formations-web",
href: withTrailingSlash("/services/formations-web"),
label: "Formations web",
prerender: true,
},
{
path: "/services/electricite",
href: withTrailingSlash("/services/electricite"),
label: "Graphisme",
prerender: true,
},
{
path: "/services/service-securite-incendie",
href: withTrailingSlash("/services/service-securite-incendie"),
label: "Podcast",
prerender: true,
},
{
path: "/services/expertises-tce",
href: withTrailingSlash("/services/expertises-tce"),
label: "Formations professionnelles",
prerender: true,
},
{
path: "/nosFormations",
href: withTrailingSlash("/nosFormations"),
label: "Nos formations",
prerender: true,
},
{
path: "/posts",
href: withTrailingSlash("/posts"),
label: "Actualités",
prerender: true,
},
{
path: "/contact",
href: withTrailingSlash("/contact"),
label: "Contact",
prerender: true,
},
{
path: "/search",
href: withTrailingSlash("/search"),
label: "Recherche",
prerender: true,
},
{
path: "/sitemap.xml",
href: "/sitemap.xml",
label: "Plan du site complet",
prerender: false,
},
];
export const NOT_FOUND_ROUTE = "/__404";

View File

@ -0,0 +1,50 @@
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { PUBLIC_ROUTES } from "./routes.mjs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const indexPath = path.resolve(__dirname, "../index.html");
const LIST_INDENT = " ";
const WRAPPER_INDENT = " ";
const generateListItems = () =>
PUBLIC_ROUTES.map(
({ path, href, label }) => {
const targetHref = href ?? path;
return `${LIST_INDENT}<li><a href="${targetHref}">${label}</a></li>`;
}
).join("\n");
async function main() {
const originalContent = await readFile(indexPath, "utf8");
const navPattern = new RegExp(
`${WRAPPER_INDENT}<ul class="no-js-nav">[\\s\\S]*?${WRAPPER_INDENT}<\\/ul>`,
"m"
);
const match = originalContent.match(navPattern);
if (!match) {
throw new Error(
"Bloc <ul class=\"no-js-nav\"> introuvable dans index.html. Impossible de mettre à jour la navigation noscript."
);
}
const replacement = `${WRAPPER_INDENT}<ul class="no-js-nav">\n${generateListItems()}\n${WRAPPER_INDENT}</ul>`;
const updatedContent = originalContent.replace(navPattern, replacement);
if (updatedContent !== originalContent) {
await writeFile(indexPath, updatedContent, "utf8");
console.log("✔ Mise à jour de la navigation noscript dans index.html");
} else {
console.log(" Navigation noscript déjà à jour");
}
}
main().catch((error) => {
console.error("[update-noscript] Échec de la mise à jour :", error);
process.exit(1);
});

View File

@ -6,6 +6,7 @@ import { Box, Typography, CircularProgress } from "@mui/material";
const API_URL =
"https://www.formations.octopusdesign.fr/webservice/rest/server.php";
const TOKEN = "685e1b5d794b558b60e971581154c3b2";
const PUBLIC_LINKS_STORAGE_KEY = "listeCoursPublicLinks";
const CoursLecture = () => {
// Empêche le clic droit
@ -23,6 +24,7 @@ const CoursLecture = () => {
const { token } = useParams();
const [content, setContent] = useState(null);
const [title, setTitle] = useState("");
const [loading, setLoading] = useState(true);
const [tokenValid, setTokenValid] = useState(true);
@ -46,15 +48,131 @@ const CoursLecture = () => {
// Récupération contenu
useEffect(() => {
const [courseId, pageId, expiry] = atob(token).split("_");
const decodeToken = () => {
if (!token) {
return null;
}
try {
const raw = atob(token);
try {
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object") {
throw new Error("Invalid JSON payload");
}
const {
courseId,
itemType = "page",
itemId,
expiry,
linkId = null,
} = parsed;
if (!courseId || !itemId || !expiry) {
return null;
}
return {
courseId: String(courseId),
itemType: itemType || "page",
itemId: String(itemId),
expiry,
linkId,
format: "json",
};
} catch (jsonError) {
const parts = raw.split("_");
if (parts.length === 3) {
const [courseId, itemId, expiry] = parts;
return {
courseId,
itemType: "page",
itemId,
expiry,
linkId: null,
format: "legacy",
};
}
if (parts.length === 4) {
const [courseId, itemType, itemId, expiry] = parts;
return {
courseId,
itemType,
itemId,
expiry,
linkId: null,
format: "legacy",
};
}
return null;
}
} catch (error) {
return null;
}
};
if (!courseId || !pageId || !expiry || Date.now() > parseInt(expiry)) {
const decoded = decodeToken();
if (!decoded) {
setTokenValid(false);
setContent("⛔ Lien invalide.");
setLoading(false);
return;
}
const { courseId, itemType, itemId, expiry, format } = decoded;
const expiryValue = Number(expiry);
if (
!courseId ||
!itemId ||
!Number.isFinite(expiryValue) ||
Date.now() > expiryValue
) {
setTokenValid(false);
setContent("⛔ Le cours est terminé !");
setLoading(false);
return;
}
const normalizedItemType = itemType || "page";
const linkStillActive = () => {
if (format === "legacy") {
return true;
}
if (typeof window === "undefined") {
return true;
}
try {
const stored = localStorage.getItem(PUBLIC_LINKS_STORAGE_KEY);
if (!stored) {
return false;
}
const registry = JSON.parse(stored);
const key = `${courseId}-${normalizedItemType}-${itemId}`;
const entry = registry?.[key];
if (!entry || !entry.active) {
return false;
}
if (entry.token && entry.token !== token) {
return false;
}
if (
entry.expiry !== undefined &&
Number.isFinite(Number(entry.expiry)) &&
Date.now() > Number(entry.expiry)
) {
return false;
}
return true;
} catch (error) {
console.error("Impossible de vérifier l'état du lien public :", error);
return false;
}
};
if (!linkStillActive()) {
setTokenValid(false);
setContent("⛔ Ce lien n'est plus actif.");
setLoading(false);
return;
}
const fetchPage = async () => {
try {
const res = await axios.get(API_URL, {
@ -62,7 +180,7 @@ const CoursLecture = () => {
wstoken: TOKEN,
wsfunction: "mod_page_get_pages_by_courses",
moodlewsrestformat: "json",
courseids: [parseInt(courseId)],
courseids: [parseInt(courseId, 10)],
},
paramsSerializer: (params) => {
const sp = new URLSearchParams();
@ -77,9 +195,12 @@ const CoursLecture = () => {
},
});
const found = res.data.pages.find((p) => p.id === parseInt(pageId));
const found = (res.data.pages || []).find(
(p) => p.id === parseInt(itemId, 10)
);
if (found) {
setContent(found.content);
setTitle(found.name || "");
setContent(found.content || "");
} else {
setTokenValid(false);
setContent("⛔ Page non trouvée.");
@ -92,7 +213,43 @@ const CoursLecture = () => {
}
};
fetchPage();
const fetchAssignment = async () => {
try {
const res = await axios.get(API_URL, {
params: {
wstoken: TOKEN,
wsfunction: "mod_assign_get_assignments",
moodlewsrestformat: "json",
},
});
const parsedCourseId = parseInt(courseId, 10);
const parsedAssignmentId = parseInt(itemId, 10);
const course = (res.data.courses || []).find(
(c) => c.id === parsedCourseId
);
const assignment = (course?.assignments || []).find(
(a) => a.id === parsedAssignmentId
);
if (assignment) {
setTitle(assignment.name || "");
setContent(assignment.intro || "<p>Aucun contenu disponible.</p>");
} else {
setTokenValid(false);
setContent("⛔ Devoir introuvable.");
}
} catch (error) {
setTokenValid(false);
setContent("❌ Erreur lors du chargement du devoir.");
} finally {
setLoading(false);
}
};
if (normalizedItemType === "assignment") {
fetchAssignment();
} else {
fetchPage();
}
}, [token]);
if (loading) {
@ -140,6 +297,11 @@ const CoursLecture = () => {
return (
<Box sx={{ padding: 4, mt: 5 }}>
{title ? (
<Typography variant="h4" sx={{ mb: 3, color: "#315397" }}>
{title}
</Typography>
) : null}
<div dangerouslySetInnerHTML={{ __html: content }} />
</Box>
);

File diff suppressed because it is too large Load Diff

View File

@ -2,12 +2,92 @@ import { useEffect, useMemo, useState } from "react";
import { Helmet } from "react-helmet-async";
import api from "../api";
const PRODUCTION_ORIGIN = "https://octopusdesign.fr";
const PREPROD_HOST = "preprod.octopusdesign.fr";
const PATH_PREFIX_TO_STRIP = "/api-octopus/server";
const FORCE_NO_INDEX = true;
const DEFAULT_OG_IMAGE =
"https://preprod.octopusdesign.fr/api-octopus/server/wp-content/uploads/2025/01/Construction-logements-au-Mans-01.avif";
const DEFAULT_ROBOTS = FORCE_NO_INDEX ? "noindex, nofollow" : "index, follow";
const getWindowHref = () =>
typeof window !== "undefined" ? window.location.href : "";
const shouldNormalize = (value) =>
typeof value === "string" &&
(value.includes(PREPROD_HOST) || value.includes(PATH_PREFIX_TO_STRIP) || value.startsWith("http://127.") || value.includes("localhost"));
const normalizeToProduction = (value) => {
if (typeof value !== "string" || value.length === 0) return value;
const trimmed = value.trim();
const keepTrailingSlash = trimmed.endsWith("/") && !trimmed.endsWith("//");
try {
const reference = new URL(PRODUCTION_ORIGIN);
const url = new URL(trimmed, PRODUCTION_ORIGIN);
const mustForceProductionHost =
url.hostname === PREPROD_HOST ||
url.hostname === "127.0.0.1" ||
url.hostname === "localhost";
if (mustForceProductionHost) {
url.protocol = reference.protocol;
url.hostname = reference.hostname;
url.port = "";
}
if (url.pathname.startsWith(PATH_PREFIX_TO_STRIP)) {
const stripped = url.pathname.replace(PATH_PREFIX_TO_STRIP, "") || "/";
url.pathname = stripped;
}
const shouldAppendSlash =
!url.pathname.includes(".") &&
url.pathname !== "/" &&
!url.pathname.endsWith("/") &&
(keepTrailingSlash || mustForceProductionHost || url.hostname === reference.hostname);
if (shouldAppendSlash) {
url.pathname = `${url.pathname}/`;
}
if (keepTrailingSlash && url.pathname === "/") {
url.pathname = "/";
}
const normalized = url.toString().replace(/([^:]\/)\/+/g, "$1");
return normalized;
} catch (error) {
if (shouldNormalize(trimmed)) {
return trimmed
.replace("https://preprod.octopusdesign.fr/api-octopus/server", PRODUCTION_ORIGIN)
.replace("https://preprod.octopusdesign.fr", PRODUCTION_ORIGIN)
.replace("http://127.0.0.1", PRODUCTION_ORIGIN)
.replace("http://localhost", PRODUCTION_ORIGIN);
}
return value;
}
};
const normalizeAttributeUrls = (attributes) => {
if (!attributes) return attributes;
const next = { ...attributes };
if (shouldNormalize(next.href)) {
next.href = normalizeToProduction(next.href);
}
if (shouldNormalize(next.content)) {
next.content = normalizeToProduction(next.content);
}
return next;
};
const SEO = ({
pageUrl,
defaultTitle = "Titre par défaut",
@ -18,18 +98,29 @@ const SEO = ({
const [rankMathHead, setRankMathHead] = useState(null);
const [shouldFallback, setShouldFallback] = useState(true);
const resolvedCanonical = defaultCanonicalUrl || getWindowHref();
const targetUrl = pageUrl || resolvedCanonical;
const runtimeUrl = getWindowHref();
const rankMathTargetUrl = pageUrl || defaultCanonicalUrl || runtimeUrl;
const normalizedPageUrl = normalizeToProduction(pageUrl);
const resolvedCanonical =
normalizeToProduction(defaultCanonicalUrl) ||
normalizedPageUrl ||
normalizeToProduction(runtimeUrl) ||
PRODUCTION_ORIGIN;
const targetUrl =
normalizedPageUrl ||
resolvedCanonical ||
normalizeToProduction(rankMathTargetUrl) ||
PRODUCTION_ORIGIN;
useEffect(() => {
if (!targetUrl) return;
if (!rankMathTargetUrl) return;
const controller = new AbortController();
const fetchRankMathHead = async () => {
try {
const { data } = await api.get("rankmath/v1/getHead", {
params: { url: targetUrl },
params: { url: rankMathTargetUrl },
signal: controller.signal,
});
@ -53,10 +144,10 @@ const SEO = ({
}, {});
const metaTags = Array.from(headElement.querySelectorAll("meta")).map(
(meta) => attributesToObject(meta)
(meta) => normalizeAttributeUrls(attributesToObject(meta))
);
const linkTags = Array.from(headElement.querySelectorAll("link")).map(
(link) => attributesToObject(link)
(link) => normalizeAttributeUrls(attributesToObject(link))
);
const scriptTags = Array.from(
headElement.querySelectorAll("script")
@ -96,7 +187,37 @@ const SEO = ({
const rankMathNodes = useMemo(() => {
if (!rankMathHead) return null;
const renderMetaTags = rankMathHead.meta.map((attributes, index) => (
const sanitizeMeta = (meta = []) => {
const filtered = meta.filter((attributes) => {
const name = attributes?.name?.toLowerCase();
if (name === "robots") {
if (FORCE_NO_INDEX) {
return false;
}
const content = attributes.content?.toLowerCase() || "";
if (content.includes("noindex") || content.includes("nofollow")) {
return false;
}
}
return true;
});
const hasRobotsTag = filtered.some(
(attributes) => attributes?.name?.toLowerCase() === "robots"
);
const withRobots = FORCE_NO_INDEX
? [...filtered, { name: "robots", content: DEFAULT_ROBOTS }]
: hasRobotsTag
? filtered
: [...filtered, { name: "robots", content: DEFAULT_ROBOTS }];
return withRobots.map((attributes) => normalizeAttributeUrls(attributes));
};
const sanitizedMeta = sanitizeMeta(rankMathHead.meta);
const renderMetaTags = sanitizedMeta.map((attributes, index) => (
<meta key={`rank-math-meta-${index}`} {...attributes} />
));
@ -130,6 +251,7 @@ const SEO = ({
<>
<title>{defaultTitle}</title>
<meta name="description" content={defaultDescription} />
<meta name="robots" content={DEFAULT_ROBOTS} />
{resolvedCanonical && (
<link rel="canonical" href={resolvedCanonical} />
)}

File diff suppressed because one or more lines are too long