diff --git a/.github/workflows/generate-pdf.yml b/.github/workflows/generate-pdf.yml index b204f96..9ac1142 100644 --- a/.github/workflows/generate-pdf.yml +++ b/.github/workflows/generate-pdf.yml @@ -41,6 +41,10 @@ jobs: steps: - name: Checkout du dépôt uses: actions/checkout@v5 + with: + # Historique complet : le pré-rendu date la création de la page profil + # (JSON-LD dateCreated) d'après le premier commit de index.html. + fetch-depth: 0 - name: Installer Node.js uses: actions/setup-node@v5 @@ -75,18 +79,20 @@ jobs: run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git add -A -- index.html results.html project-detail.html en projets assets/cv-antoine-berthaud-fr.pdf assets/cv-antoine-berthaud-en.pdf assets/cv-antoine-berthaud-fr-court.pdf assets/cv-antoine-berthaud-en-short.pdf + # sitemap.xml : le pré-rendu y tient à jour les des pages + # (zone static:pages) ; ceux des PDF sont posés ci-dessous. + git add -A -- index.html results.html project-detail.html en projets sitemap.xml assets/cv-antoine-berthaud-fr.pdf assets/cv-antoine-berthaud-en.pdf assets/cv-antoine-berthaud-fr-court.pdf assets/cv-antoine-berthaud-en-short.pdf if git diff --cached --quiet; then echo "Aucun changement dans les pages ni les PDF, rien à committer." else - # Pages et PDF sont déclarés dans sitemap.xml : on reporte la date de - # régénération (UTC) sur le de chaque fichier qui a changé. - python3 - "$(date -u +%Y-%m-%d)" $(git diff --cached --name-only) <<'EOF' + # Les PDF sont déclarés dans sitemap.xml : on reporte la date de + # régénération (UTC) sur le de chaque PDF qui a changé. + python3 - "$(date -u +%Y-%m-%d)" $(git diff --cached --name-only -- '*.pdf') <<'EOF' import re, sys, pathlib today, changed = sys.argv[1], set(sys.argv[2:]) - # Fichier du dépôt → URL du sitemap qu'il porte (index.html → dossier). + # Fichier du dépôt → URL du sitemap qu'il porte. def url(f): - return "https://cv.antoine.berthaud.me/" + (f[: -len("index.html")] if f.endswith("index.html") else f) + return "https://cv.antoine.berthaud.me/" + f p = pathlib.Path("sitemap.xml") s = p.read_text() total = 0 diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index dab3866..ecda56b 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -29,6 +29,8 @@ jobs: steps: - name: Checkout du dépôt uses: actions/checkout@v5 + with: + fetch-depth: 0 # le pré-rendu date la création de la page profil (premier commit de index.html) - name: Installer Node.js uses: actions/setup-node@v5 @@ -57,12 +59,12 @@ jobs: # recommittera après le merge (durées mises à jour, par exemple). - name: Écart entre les pages committées et le pré-rendu de la CI run: | - if git diff --quiet -- index.html results.html project-detail.html en/ projets/ && [ -z "$(git ls-files --others --exclude-standard -- en/ projets/)" ]; then + if git diff --quiet -- index.html results.html project-detail.html en/ projets/ sitemap.xml && [ -z "$(git ls-files --others --exclude-standard -- en/ projets/)" ]; then echo "Pages pré-rendues à jour : aucun écart." else - git --no-pager diff --stat -- index.html results.html project-detail.html en/ projets/ + git --no-pager diff --stat -- index.html results.html project-detail.html en/ projets/ sitemap.xml git ls-files --others --exclude-standard -- en/ projets/ - git --no-pager diff -- index.html results.html project-detail.html en/ projets/ | head -120 + git --no-pager diff -- index.html results.html project-detail.html en/ projets/ sitemap.xml | head -120 fi - name: Générer les PDF FR et EN (sans les committer) diff --git a/CLAUDE.md b/CLAUDE.md index 8fb7c25..fbe494d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -118,7 +118,7 @@ donnée : tout le contenu vit dans `js/data.js`, le rendu dans `js/app.js`. pages ; les courts sont hors sitemap, voulu), avec `scripts/lib/site-server.js` en commun. Le workflow `.github/workflows/generate-pdf.yml` enchaîne les deux et - recommit pages + PDF (+ `lastmod` du sitemap) ; il tourne aussi une fois + recommit pages, sitemap et PDF ; il tourne aussi une fois par mois (1er, 4h UTC) indépendamment de tout push, pour rafraîchir les durées d'expérience affichées (calculées jusqu'à "aujourd'hui") — et ouvre une issue GitHub (label `pdf-generation-failure`) s'il échoue. @@ -356,8 +356,17 @@ plein ont tous les deux été essayés et jugés trop nets avant ça. tenue par les job boards). Ce qui pèse le plus est hors code : liens depuis `antoine.berthaud.me` et `tourdegrowth.com`, LinkedIn, demandes d'indexation dans Search Console (propriété existante, accessible via le - MCP SEO Gets). Le `lastmod` des pages et des deux PDF dans `sitemap.xml` - est posé par `generate-pdf.yml` à chaque régénération, jamais à la main. + MCP SEO Gets). Dans `sitemap.xml`, les entrées des pages sont une zone + générée par `generate-static.js` : le `lastmod` d'une page n'avance que si + son HTML généré change (comparaison au fichier sur le disque), et le + `dateModified` de l'accueil reprend exactement cette date ; le + `dateCreated` vient du premier commit de `index.html`, d'où `fetch-depth: 0` + dans les deux workflows (le générateur refuse un clone superficiel). Le + `lastmod` des deux PDF est posé par `generate-pdf.yml`. Jamais à la main. + Avant sept. 2026, le workflow datait aussi les pages, mais seulement quand + la CI elle-même les modifiait : une PR arrivée avec ses pages déjà + régénérées laissait le `lastmod` figé (06/09 alors que la page avait changé + le 07/09). Les PDF portent des métadonnées (titre, auteur, langue) posées par `scripts/generate-pdf.js` via `pdf-lib`. - **Site considéré fonctionnellement complet** (sept. 2026) : bilingue, diff --git a/README.md b/README.md index f0e9f6a..e8197d6 100644 --- a/README.md +++ b/README.md @@ -230,7 +230,7 @@ Le bouton **"Télécharger PDF"** ne fait plus un simple `Ctrl/Cmd+P` navigateur 1. `scripts/generate-pdf.js` lance un mini-serveur local, ouvre le site dans Chromium, et exporte quatre fichiers : `assets/cv-antoine-berthaud-fr.pdf` et `-en.pdf` (complets), `-fr-court.pdf` et `-en-short.pdf` (courts). 2. Le CSS `@media print` (dans `style.css`) définit un rendu pensé spécifiquement pour le papier : les couleurs de marque sont conservées (bordures des piliers, dégradés d'avatar), les ombres portées sont retirées (elles ne rendent pas bien sur un support figé), et les sections interactives (navigation, Fit-Checker, bandeau teaser) sont masquées. -3. `.github/workflows/generate-pdf.yml` relance le pré-rendu puis cette génération automatiquement à chaque `git push` sur `main` qui touche le contenu ou le style, **et une fois par mois** (le 1er à 4h UTC) pour rafraîchir les durées d'expérience calculées jusqu'à « aujourd'hui », puis recommit les pages et les PDF à jour (avec leur `lastmod` dans `sitemap.xml`) — **tu n'as normalement jamais besoin de lancer ces scripts toi-même**. S'il échoue, il ouvre une issue GitHub (label `pdf-generation-failure`). Les mêmes scripts tournent sur chaque PR via `pr-checks.yml`, sans commit, avec leurs garde-fous (erreur JS, page vide, polices absentes, lien local, PDF trop court). Le problème "CV à jour" est réglé une fois pour toutes : tu édites `data.js`, tu push, les pages et les PDF suivent. +3. `.github/workflows/generate-pdf.yml` relance le pré-rendu puis cette génération automatiquement à chaque `git push` sur `main` qui touche le contenu ou le style, **et une fois par mois** (le 1er à 4h UTC) pour rafraîchir les durées d'expérience calculées jusqu'à « aujourd'hui », puis recommit les pages, le sitemap et les PDF à jour (le pré-rendu date les pages, le workflow les PDF) — **tu n'as normalement jamais besoin de lancer ces scripts toi-même**. S'il échoue, il ouvre une issue GitHub (label `pdf-generation-failure`). Les mêmes scripts tournent sur chaque PR via `pr-checks.yml`, sans commit, avec leurs garde-fous (erreur JS, page vide, polices absentes, lien local, PDF trop court). Le problème "CV à jour" est réglé une fois pour toutes : tu édites `data.js`, tu push, les pages et les PDF suivent. ### Régénérer en local (pour prévisualiser un changement avant de push) @@ -329,8 +329,11 @@ Soyons lucides sur l'objectif : sur une requête générique comme « product ma Ce qui est en place dans le code : - **Français à la racine, anglais sous `/en/`** (`/en/`, `/en/results.html`), deux vraies pages statiques, sans détection de la langue du navigateur (voir section 0) : Googlebot navigue en anglais et indexait la version anglaise à l'URL française. Chaque page déclare son propre canonical et les `hreflang` fr/en/x-default (x-default = français), dans le `` et dans le sitemap. Les anciennes URL `?lang=en` redirigent vers `/en/…` (script en tête du ``, GitHub Pages n'offrant pas de redirection serveur). Seule exception à « pas de détection navigateur » : `404.html`, qui n'est pas indexée, garde le bloc FR ou EN d'après l'URL cassée si elle est sous `/en/`, sinon d'après la langue du navigateur (les deux restent dans le HTML sans JavaScript). - **``, `<meta description>`, Open Graph et eyebrow du hero** qui citent Nantes, le métier et le secteur — textes dans `PROFILE.seo` (`js/data.js`), posés dans le `<head>` de `index.html` par le pré-rendu (section 5), comme tout le contenu de la page. `app.js` prévient dans la console du navigateur si le pitch pré-rendu diverge de `PROFILE.pitch.fr` (page pas encore régénérée). -- **Données structurées `ProfilePage` → `Person`** (JSON-LD de l'accueil, en français et en anglais, écrit par le pré-rendu depuis `scripts/lib/structured-data.js`) : métier, lieu (Nantes), formation, sujets maîtrisés, et `sameAs` vers LinkedIn, le portfolio photo et Tour de Growth, pour que Google relie le tout à la même personne (même `@id` dans les deux langues). -- **`sitemap.xml`** avec toutes les pages indexables (accueil et Résultats en FR et EN avec leurs `hreflang`, étude de cas) et une date `lastmod` **à mettre à jour quand une page change** ; les deux PDF y figurent aussi (décision du 06/09/2026 : ils sont déjà liés depuis les pages, autant que Google les compte), avec un `lastmod` mis à jour automatiquement par `generate-pdf.yml` à chaque régénération ; `robots.txt` ; `CNAME`. +- **Données structurées** (JSON-LD, écrites par le pré-rendu depuis `scripts/lib/structured-data.js`, dans la langue de chaque page ; la personne a partout le même `@id`, `https://cv.antoine.berthaud.me/#person`) : + - accueil : `ProfilePage` → `Person` (métier, lieu, formation, sujets maîtrisés, `sameAs` vers LinkedIn et le portfolio photo), avec `dateCreated` (premier commit de `index.html`) et `dateModified` (le `lastmod` de la page) ; + - études de cas : `CollectionPage` et fil d'Ariane (CV → Études de cas) ; + - pages projet : `WebPage`, fil d'Ariane et `WebApplication` dont Antoine est le `creator` — c'est ce lien, et non un `sameAs`, qui relie Tour de Growth à la personne. +- **`sitemap.xml`** avec toutes les pages indexables, en FR et EN avec leurs `hreflang` : ces entrées sont générées par le pré-rendu, dont le `lastmod` n'avance que quand le HTML de la page change (le même jour que le `dateModified` de l'accueil) ; les deux PDF y figurent aussi (décision du 06/09/2026 : ils sont déjà liés depuis les pages, autant que Google les compte), avec un `lastmod` mis à jour automatiquement par `generate-pdf.yml` à chaque régénération ; `robots.txt` ; `CNAME`. - **Pages secondaires** : `results.html` / `en/results.html` et les pages projet (`/projets/…`, `/en/projects/…`) ont leur propre `<title>`/description par langue, canonical et hreflang. L'ancienne adresse `project-detail.html?slug=…` redirige vers la page statique ; pour un slug inconnu elle affiche « Projet introuvable » en `noindex` (posé par JavaScript, jamais dans le HTML brut, pour que Google suive la redirection). Historique : l'étude de cas déclarait son propre canonical (avec le `?slug=`) et la page gabarit sans slug (« Projet introuvable ») est en `noindex`. - **PDF** avec titre, auteur, sujet, mots-clés et langue dans leurs métadonnées (écrites par `scripts/generate-pdf.js` via `pdf-lib`) — les PDF sont indexables eux aussi, autant qu'ils se présentent bien. - Repli `<noscript>` qui dit ce qui manque sans JavaScript (le Fit-Checker) et lie les PDF. diff --git a/en/index.html b/en/index.html index 2bf7f92..3f68dbc 100644 --- a/en/index.html +++ b/en/index.html @@ -40,6 +40,8 @@ "url": "https://cv.antoine.berthaud.me/en/", "name": "Antoine Berthaud's CV — Senior Product Manager in Nantes", "inLanguage": "en", + "dateCreated": "2026-08-22", + "dateModified": "2026-09-24", "mainEntity": { "@type": "Person", "@id": "https://cv.antoine.berthaud.me/#person", @@ -110,8 +112,7 @@ ], "sameAs": [ "https://www.linkedin.com/in/antoine-berthaud-pm/", - "https://antoine.berthaud.me/", - "https://www.tourdegrowth.com/" + "https://antoine.berthaud.me/" ] } } diff --git a/en/results.html b/en/results.html index 2acd13e..e05950b 100644 --- a/en/results.html +++ b/en/results.html @@ -25,6 +25,52 @@ <meta property="og:locale:alternate" content="fr_FR"> <!-- /static:langLinks --> +<!-- Données structurées (schema.org) : la page (CollectionPage) et son fil + d'Ariane, dans la langue de la page. Zone générée par + scripts/generate-static.js à partir de scripts/lib/structured-data.js — + ne pas éditer à la main. --> +<!-- static:jsonLd --> +<script type="application/ld+json"> +{ + "@context": "https://schema.org", + "@graph": [ + { + "@type": "CollectionPage", + "@id": "https://cv.antoine.berthaud.me/en/results.html#page", + "url": "https://cv.antoine.berthaud.me/en/results.html", + "name": "Product Manager case studies — Antoine Berthaud, Nantes", + "description": "Antoine Berthaud's product case studies, Product Manager in Nantes, France: context, hypothesis, options ruled out, action and result (AB Tasty, Everysens, SNCF Connect & Tech).", + "inLanguage": "en", + "author": { + "@id": "https://cv.antoine.berthaud.me/#person" + }, + "breadcrumb": { + "@id": "https://cv.antoine.berthaud.me/en/results.html#breadcrumb" + } + }, + { + "@type": "BreadcrumbList", + "@id": "https://cv.antoine.berthaud.me/en/results.html#breadcrumb", + "itemListElement": [ + { + "@type": "ListItem", + "position": 1, + "name": "CV", + "item": "https://cv.antoine.berthaud.me/en/" + }, + { + "@type": "ListItem", + "position": 2, + "name": "Case studies", + "item": "https://cv.antoine.berthaud.me/en/results.html" + } + ] + } + ] +} +</script> +<!-- /static:jsonLd --> + <meta property="og:type" content="article"> <meta property="og:title" content="Product Manager case studies — Antoine Berthaud, Nantes"> <meta property="og:description" content="Antoine Berthaud's product case studies, Product Manager in Nantes, France: context, hypothesis, options ruled out, action and result (AB Tasty, Everysens, SNCF Connect & Tech)."> diff --git a/index.html b/index.html index 26ba5d0..ee4aab4 100644 --- a/index.html +++ b/index.html @@ -53,6 +53,8 @@ "url": "https://cv.antoine.berthaud.me/", "name": "CV d'Antoine Berthaud — Senior Product Manager à Nantes", "inLanguage": "fr", + "dateCreated": "2026-08-22", + "dateModified": "2026-09-24", "mainEntity": { "@type": "Person", "@id": "https://cv.antoine.berthaud.me/#person", @@ -123,8 +125,7 @@ ], "sameAs": [ "https://www.linkedin.com/in/antoine-berthaud-pm/", - "https://antoine.berthaud.me/", - "https://www.tourdegrowth.com/" + "https://antoine.berthaud.me/" ] } } diff --git a/results.html b/results.html index b8dc795..0f4fdc7 100644 --- a/results.html +++ b/results.html @@ -38,6 +38,52 @@ <meta property="og:locale:alternate" content="en_US"> <!-- /static:langLinks --> +<!-- Données structurées (schema.org) : la page (CollectionPage) et son fil + d'Ariane, dans la langue de la page. Zone générée par + scripts/generate-static.js à partir de scripts/lib/structured-data.js — + ne pas éditer à la main. --> +<!-- static:jsonLd --> +<script type="application/ld+json"> +{ + "@context": "https://schema.org", + "@graph": [ + { + "@type": "CollectionPage", + "@id": "https://cv.antoine.berthaud.me/results.html#page", + "url": "https://cv.antoine.berthaud.me/results.html", + "name": "Études de cas Product Manager — Antoine Berthaud, Nantes", + "description": "Les études de cas produit d'Antoine Berthaud, Product Manager à Nantes : contexte, hypothèse, options écartées, action et résultat (AB Tasty, Everysens, SNCF Connect & Tech).", + "inLanguage": "fr", + "author": { + "@id": "https://cv.antoine.berthaud.me/#person" + }, + "breadcrumb": { + "@id": "https://cv.antoine.berthaud.me/results.html#breadcrumb" + } + }, + { + "@type": "BreadcrumbList", + "@id": "https://cv.antoine.berthaud.me/results.html#breadcrumb", + "itemListElement": [ + { + "@type": "ListItem", + "position": 1, + "name": "CV", + "item": "https://cv.antoine.berthaud.me/" + }, + { + "@type": "ListItem", + "position": 2, + "name": "Études de cas", + "item": "https://cv.antoine.berthaud.me/results.html" + } + ] + } + ] +} +</script> +<!-- /static:jsonLd --> + <meta property="og:type" content="article"> <meta property="og:title" content="Études de cas Product Manager — Antoine Berthaud, Nantes"> <meta property="og:description" content="Les études de cas produit d'Antoine Berthaud, Product Manager à Nantes : contexte, hypothèse, options écartées, action et résultat (AB Tasty, Everysens, SNCF Connect & Tech)."> diff --git a/scripts/check-seo.mjs b/scripts/check-seo.mjs index 776738a..4d85b73 100644 --- a/scripts/check-seo.mjs +++ b/scripts/check-seo.mjs @@ -29,6 +29,8 @@ import fs from "node:fs"; import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; import { chromium, request as playwrightRequest } from "playwright"; import pdfLib from "pdf-lib"; @@ -713,6 +715,103 @@ async function lot2(browser) { await jsCtx.close(); } +// --------------------------------------------------------------------------- +// Lot 3 — données structurées +// --------------------------------------------------------------------------- +// Toute référence à Antoine passe par le même @id (#person) : un nœud Person +// porte cet @id, et author / creator / mainEntity n'en citent pas d'autre. +function personRefs(node, bad = [], where = "") { + if (Array.isArray(node)) node.forEach((n, i) => personRefs(n, bad, `${where}[${i}]`)); + else if (node && typeof node === "object") { + if (node["@type"] === "Person" && node["@id"] !== `${SITE}/#person`) bad.push(`${where} Person @id=${node["@id"]}`); + for (const k of ["author", "creator", "mainEntity"]) { + if (node[k] && node[k]["@id"] && node[k]["@id"] !== `${SITE}/#person`) bad.push(`${where}.${k} → ${node[k]["@id"]}`); + if (node[k] && !node[k]["@id"]) bad.push(`${where}.${k} sans @id`); + } + Object.entries(node).forEach(([k, v]) => personRefs(v, bad, `${where}.${k}`)); + } + return bad; +} + +async function lot3(browser) { + const rawCtx = await newContext(browser, { js: false }); + const pages = [HOME_FR, HOME_EN, RES_FR, RES_EN, PROJ_FR, PROJ_EN]; + + // Sitemap : lastmod de chaque page + const sm = await rawFetch("/sitemap.xml"); + const lastmod = new Map([...sm.text.matchAll(/<loc>([^<]+)<\/loc>\s*<lastmod>([^<]+)<\/lastmod>/g)].map((m) => [m[1], m[2]])); + + // Premier commit de index.html, si le dépôt est là (vérification locale) + let created = null; + try { + const repo = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); + if (execFileSync("git", ["rev-parse", "--is-shallow-repository"], { cwd: repo, encoding: "utf8" }).trim() === "false") { + created = execFileSync("git", ["log", "--reverse", "--format=%as", "--", "index.html"], { cwd: repo, encoding: "utf8" }).split("\n")[0]; + } + } catch (_e) { + created = null; + } + + for (const pg of pages) { + const r = await rawFetch(pg.path); + const { page } = await openTracked(rawCtx, pg.path); + const info = await page.evaluate(readPage); + await page.close(); + let blocks = [], + err = ""; + try { + blocks = info.jsonld.map((x) => JSON.parse(x)); + } catch (e) { + err = e.message; + } + check(3, "3.1-parse", `${pg.path} : chaque bloc JSON-LD passe JSON.parse`, !err && blocks.length > 0, err || `${blocks.length} bloc(s)`); + const bad = personRefs(blocks); + check(3, "3.1-person", `${pg.path} : toute référence à Antoine = ${SITE}/#person`, !err && !bad.length, bad.slice(0, 3).join(" ; ")); + // Critère 3 : JSON-LD écrit par le générateur (dans sa zone), pas à la main + const zoned = (r.text.match(/<script type="application\/ld\+json">/g) || []).length === (r.text.match(/<!-- static:jsonLd -->\s*<script type="application\/ld\+json">/g) || []).length; + check(3, "3.3-zone", `${pg.path} : JSON-LD dans la zone générée (static:jsonLd)`, zoned && blocks.length > 0); + + if (pg.kind === "home") { + const pp = blocks.find((b) => b["@type"] === "ProfilePage") || {}; + const sameAs = (pp.mainEntity && pp.mainEntity.sameAs) || []; + check(3, "3.2-sameas", `${pg.path} : sameAs sans tourdegrowth.com (LinkedIn et antoine.berthaud.me gardés)`, !sameAs.some((u) => /tourdegrowth/.test(u)) && sameAs.includes("https://www.linkedin.com/in/antoine-berthaud-pm/") && sameAs.includes("https://antoine.berthaud.me/"), JSON.stringify(sameAs)); + const iso = /^\d{4}-\d{2}-\d{2}$/; + check(3, "3.2-dates", `${pg.path} : dateModified = <lastmod> du sitemap (${lastmod.get(pg.url)}), dateCreated ISO`, iso.test(pp.dateModified || "") && iso.test(pp.dateCreated || "") && pp.dateModified === lastmod.get(pg.url) && pp.dateCreated <= pp.dateModified, `dateCreated=${pp.dateCreated} dateModified=${pp.dateModified}`); + if (created) check(3, "3.2-created", `${pg.path} : dateCreated = premier commit de index.html (${created})`, pp.dateCreated === created, pp.dateCreated); + else skip(3, "3.2-created", `${pg.path} : dateCreated = premier commit de index.html`, "historique Git indisponible ici"); + } + if (pg.kind === "results") { + const graph = (blocks.find((b) => Array.isArray(b["@graph"])) || { "@graph": [] })["@graph"]; + const cp = graph.find((n) => n["@type"] === "CollectionPage"); + const bc = graph.find((n) => n["@type"] === "BreadcrumbList"); + const home = pg.lang === "en" ? `${SITE}/en/` : `${SITE}/`; + const crumb = pg.lang === "en" ? "Case studies" : "Études de cas"; + const ok = + cp && bc && + cp["@id"] === `${pg.url}#page` && cp.url === pg.url && cp.name === info.title && cp.description === info.description && cp.inLanguage === pg.lang && + cp.author["@id"] === `${SITE}/#person` && cp.breadcrumb["@id"] === `${pg.url}#breadcrumb` && bc["@id"] === `${pg.url}#breadcrumb` && + bc.itemListElement.length === 2 && bc.itemListElement[0].name === "CV" && bc.itemListElement[0].item === home && bc.itemListElement[1].name === crumb && bc.itemListElement[1].item === pg.url; + check(3, "3.results", `${pg.path} : CollectionPage + BreadcrumbList (« CV » → « ${crumb} », depuis ${new URL(home).pathname})`, ok, ok ? "" : JSON.stringify(graph).slice(0, 200)); + } + } + + // Critère 3 : sortie stable d'une génération à l'autre (vérifiable en local) + const generator = path.join(path.dirname(fileURLToPath(import.meta.url)), "generate-static.js"); + if (/^http:\/\/(localhost|127\.0\.0\.1)/.test(BASE) && fs.existsSync(generator)) { + let out = "", + code = 0; + try { + out = execFileSync("node", [generator, "--check"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); + } catch (e) { + code = e.status; + out = `${e.stdout || ""}${e.stderr || ""}`; + } + check(3, "3.3-stable", "générateur relancé : aucun fichier à réécrire (sortie stable, JSON-LD compris)", code === 0, code ? out.split("\n").filter((l) => /✗|Échec/.test(l)).join(" | ") : ""); + } else skip(3, "3.3-stable", "générateur relancé : sortie stable", "vérifiable seulement en local, dans le dépôt"); + + await rawCtx.close(); +} + // --------------------------------------------------------------------------- // Un vrai appel au Fit-Checker, depuis la page anglaise (--fit-call) // --------------------------------------------------------------------------- @@ -790,6 +889,7 @@ async function shots(browser) { await lot0(browser); if (MAX_LOT >= 1) await lot1(browser); if (MAX_LOT >= 2) await lot2(browser); + if (MAX_LOT >= 3) await lot3(browser); if (FIT_CALL) await fitCall(browser); if (SHOTS_DIR) await shots(browser); } finally { diff --git a/scripts/generate-static.js b/scripts/generate-static.js index 4de85b0..98b8697 100644 --- a/scripts/generate-static.js +++ b/scripts/generate-static.js @@ -46,7 +46,8 @@ const fs = require("fs"); const path = require("path"); const { chromium } = require("playwright"); const { ROOT, startServer } = require("./lib/site-server"); -const { PAGE_URLS, profilePage, projectPage, projectUrls, jsonLdScript } = require("./lib/structured-data"); +const { execFileSync } = require("child_process"); +const { PAGE_URLS, profilePage, resultsPage, projectPage, projectUrls, jsonLdScript } = require("./lib/structured-data"); const PORT = 4174; // ≠ 4173 (generate-pdf.js) : les deux scripts peuvent tourner côte à côte const CHECK_ONLY = process.argv.includes("--check"); @@ -67,7 +68,9 @@ const PAGES = [ path: { fr: "/index.html", en: "/en/index.html" }, urls: PAGE_URLS.home, head: META_HEAD, - jsonLd: (lang, data) => profilePage(lang, data.profile), + // dateModified = <lastmod> de la page, dateCreated = premier commit de + // index.html (voir pageDates plus bas). + jsonLd: (lang, rendered, page, dates) => profilePage(lang, rendered.profile, dates), zones: [ ["heroName", "#heroName"], ["heroRole", "#heroRole"], @@ -104,6 +107,8 @@ const PAGES = [ path: { fr: "/results.html", en: "/en/results.html" }, urls: PAGE_URLS.results, head: META_HEAD, + extraKeys: ["nav.cases"], // libellé du fil d'Ariane (JSON-LD) + jsonLd: (lang, rendered, page) => resultsPage(lang, { url: page.urls[lang], name: rendered.title, description: rendered.description, crumb: rendered.dict["nav.cases"] }), zones: [["resultsRoot", "#resultsRoot"]], sentinels: { fr: ["Contexte", "Leçon", "Everysens"], en: ["Context", "Lesson", "Everysens"] }, minWords: 700, @@ -181,7 +186,8 @@ function replaceZone(html, file, id, content) { if (matches.length !== 1) { throw new Error(`${file} : ${matches.length} paire(s) de marqueurs pour « ${id} » (attendu 1 : <!-- static:${id} --> … <!-- /static:${id} -->)`); } - return html.replace(re, () => `<!-- static:${id} -->\n${content.trim()}\n<!-- /static:${id} -->`); + // Sauts de ligne retirés aux deux bouts, pas l'indentation (sitemap). + return html.replace(re, () => `<!-- static:${id} -->\n${content.replace(/^\s*\n|\s+$/g, "")}\n<!-- /static:${id} -->`); } // Pose (ou remplace) un attribut dans une balise ouvrante. @@ -339,7 +345,7 @@ async function renderPage(browser, page, lang, { shell, keys }) { } // Écrit le rendu d'une langue dans le HTML (gabarit ou coquille anglaise). -function applyRender(html, page, lang, rendered, slugs) { +function applyRender(html, page, lang, rendered, slugs, dates) { const file = page.out[lang]; page.head.forEach((sel, i) => { @@ -359,7 +365,7 @@ function applyRender(html, page, lang, rendered, slugs) { html = replaceZone(html, file, "langLinks", langLinksHtml(page, lang)); html = replaceZone(html, file, "langToggle", langToggleHtml(page, lang, rendered.dict)); } - if (page.jsonLd) html = replaceZone(html, file, "jsonLd", jsonLdScript(page.jsonLd(lang, rendered, page))); + if (page.jsonLd) html = replaceZone(html, file, "jsonLd", jsonLdScript(page.jsonLd(lang, rendered, page, dates))); html = applyI18n(html, file, rendered.dict); html = applyProjectHrefs(html, file, lang, slugs); @@ -394,6 +400,75 @@ function legacyProjectPage(html, projects, dict, slugs) { return applyProjectHrefs(html, file, "fr", slugs); } +// --------------------------------------------------------------------------- +// Dates : <lastmod> du sitemap et dateModified / dateCreated du JSON-LD +// --------------------------------------------------------------------------- +// Le <lastmod> d'une page (et le dateModified de l'accueil, qui le reprend) +// n'avance que quand le HTML généré de cette page change : le script compare +// au fichier sur le disque. Une page régénérée dans une PR y porte déjà sa +// nouvelle date, la CI de main la retrouve à l'identique et ne touche à rien ; +// une page que seule la CI régénère (data.js modifié sans relancer le script, +// durées mensuelles) prend la date du jour. Jour en UTC, comme la CI. +const SITEMAP = "sitemap.xml"; +const TODAY = new Date().toISOString().slice(0, 10); +const sitemapSource = fs.readFileSync(path.join(ROOT, SITEMAP), "utf8"); +const previousLastmod = new Map([...sitemapSource.matchAll(/<loc>([^<]+)<\/loc>\s*<lastmod>(\d{4}-\d{2}-\d{2})<\/lastmod>/g)].map((m) => [m[1], m[2]])); +const newLastmod = new Map(); // URL → date retenue pendant ce passage + +// Premier commit de index.html : date de création de la page profil. Un +// clone superficiel (CI sans fetch-depth: 0) n'a pas l'historique : on +// s'arrête plutôt que d'écrire une date fausse. +function firstCommitDate(file) { + const git = (args) => execFileSync("git", args, { cwd: ROOT, encoding: "utf8" }).trim(); + if (git(["rev-parse", "--is-shallow-repository"]) === "true") { + throw new Error("Clone Git superficiel : impossible de dater la création de la page (dateCreated). En CI, actions/checkout doit avoir fetch-depth: 0."); + } + const first = git(["log", "--reverse", "--format=%as", "--", file]).split("\n")[0]; + if (!/^\d{4}-\d{2}-\d{2}$/.test(first)) throw new Error(`Aucun commit trouvé pour ${file} (dateCreated)`); + return first; +} +const DATE_CREATED = firstCommitDate("index.html"); + +// Construit la page avec la date de sa dernière modification connue ; si le +// résultat diffère du fichier existant, la page a changé : on la reconstruit +// datée d'aujourd'hui. Deux passages de suite donnent donc le même résultat. +function withLastmod(page, lang, build) { + const url = page.urls[lang]; + const filePath = path.join(ROOT, page.out[lang]); + const before = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : null; + const known = previousLastmod.get(url); + let date = known || TODAY; + let out = build({ created: DATE_CREATED, modified: date }); + if (out.html !== before && date !== TODAY) { + date = TODAY; + out = build({ created: DATE_CREATED, modified: date }); + } + // La date retenue est celle de la dernière construction : inchangée, la + // page garde la sienne ; reconstruite, elle porte celle du jour. + newLastmod.set(url, date); + return out; +} + +// Entrées « pages » du sitemap (zone <!-- static:pages -->) : une par page +// générée, avec ses hreflang et son lastmod. Les PDF, hors zone, restent +// datés par le workflow. +function sitemapPages(pages) { + const entries = pages.flatMap((page) => + page.langs.map((lang) => + [ + " <url>", + ` <loc>${page.urls[lang]}</loc>`, + ` <lastmod>${newLastmod.get(page.urls[lang])}</lastmod>`, + ` <xhtml:link rel="alternate" hreflang="fr" href="${page.urls.fr}"/>`, + ` <xhtml:link rel="alternate" hreflang="en" href="${page.urls.en}"/>`, + ` <xhtml:link rel="alternate" hreflang="x-default" href="${page.urls.fr}"/>`, + " </url>", + ].join("\n") + ) + ); + return replaceZone(sitemapSource, SITEMAP, "pages", entries.join("\n")); +} + // Coquille de la version anglaise : la page française générée, en anglais, sans // le script qui redirige les anciennes URL ?lang=en (il bouclerait sous /en/). function englishShell(frHtml, file, { redirect: hasRedirect = true } = {}) { @@ -435,22 +510,23 @@ function englishShell(frHtml, file, { redirect: hasRedirect = true } = {}) { if (!/^[a-z0-9-]+$/.test(slug)) throw new Error(`Slug « ${slug} » : minuscules, chiffres et tirets seulement (il devient un nom de fichier).`); }); - for (const page of [...PAGES, ...slugs.map(projectPageConfig)]) { + const allPages = [...PAGES, ...slugs.map(projectPageConfig)]; + for (const page of allPages) { const template = fs.readFileSync(path.join(ROOT, page.template), "utf8"); // + le libellé du lien FR/EN, que le gabarit ne cite pas (zone calculée). - const keys = [...i18nKeys(template), "nav.langToggleLabel"]; + const keys = [...i18nKeys(template), "nav.langToggleLabel", ...(page.extraKeys || [])]; // Page projet : la page française n'est pas le gabarit lui-même, c'est // le gabarit portant son slug, servi depuis la mémoire. const frSource = page.slug ? template.replace(/<body\b[^>]*>/, (tag) => setAttr(tag, "data-slug", page.slug)) : template; const fr = await renderPage(browser, page, "fr", { keys, shell: page.slug ? frSource : null }); - const frOut = applyRender(frSource, page, "fr", fr, slugs); + const frOut = withLastmod(page, "fr", (dates) => applyRender(frSource, page, "fr", fr, slugs, dates)); report(page.out.fr, frOut.html, frOut.words); if (page.langs.includes("en")) { const shell = englishShell(frOut.html, page.template, { redirect: !page.slug }); const en = await renderPage(browser, page, "en", { shell, keys }); - const enOut = applyRender(shell, page, "en", en, slugs); + const enOut = withLastmod(page, "en", (dates) => applyRender(shell, page, "en", en, slugs, dates)); report(page.out.en, enOut.html, enOut.words); } } @@ -460,6 +536,12 @@ function englishShell(frHtml, file, { redirect: hasRedirect = true } = {}) { const legacy = legacyProjectPage(legacyTemplate, projects, legacyDict, slugs); report(LEGACY_PROJECT_PAGE, legacy, wordCount(legacy)); + const sitemap = sitemapPages(allPages); + const sitemapChanged = sitemap !== sitemapSource; + if (sitemapChanged && !CHECK_ONLY) fs.writeFileSync(path.join(ROOT, SITEMAP), sitemap); + if (sitemapChanged) stale++; + console.log(`${sitemapChanged ? (CHECK_ONLY ? "✗ pas à jour" : "✓ régénéré ") : "= inchangé "} ${SITEMAP} (${allPages.length * 2} pages, créées le ${DATE_CREATED})`); + // Pages d'un projet retiré de PROJECT_DETAILS : fichiers générés devenus // orphelins, supprimés (signalés en mode --check). for (const dir of ["projets", "en/projects"]) { diff --git a/scripts/lib/structured-data.js b/scripts/lib/structured-data.js index 18d9f79..d904c14 100644 --- a/scripts/lib/structured-data.js +++ b/scripts/lib/structured-data.js @@ -20,9 +20,13 @@ const PAGE_URLS = { // Accueil : une ProfilePage (format que Google reconnaît pour la page profil // d'une personne) dont l'entité principale est la Person : qui, quel métier, -// où (Nantes), formation, sujets maîtrisés, et les autres pages qui parlent de -// la même personne. `profile` = { yearsExperience } lu dans js/data.js. -function profilePage(lang, profile) { +// où (Nantes), formation, sujets maîtrisés, et les autres pages qui décrivent +// la même personne (sameAs : LinkedIn, portfolio — pas Tour de Growth, un +// produit, relié à Antoine par WebApplication.creator sur sa page projet). +// `profile` = { yearsExperience } lu dans js/data.js ; `dates` = +// { created, modified } au format AAAA-MM-JJ : premier commit de index.html, +// et <lastmod> de la page dans sitemap.xml (posés par le générateur). +function profilePage(lang, profile, dates) { const years = profile.yearsExperience; const text = { fr: { @@ -41,6 +45,8 @@ function profilePage(lang, profile) { url: PAGE_URLS.home[lang], name: text.name, inLanguage: lang, + dateCreated: dates.created, + dateModified: dates.modified, mainEntity: { "@type": "Person", "@id": PERSON_ID, @@ -99,11 +105,41 @@ function profilePage(lang, profile) { { "@type": "Language", name: "Français", alternateName: "fr" }, { "@type": "Language", name: "English", alternateName: "en" }, ], - sameAs: ["https://www.linkedin.com/in/antoine-berthaud-pm/", "https://antoine.berthaud.me/", "https://www.tourdegrowth.com/"], + sameAs: ["https://www.linkedin.com/in/antoine-berthaud-pm/", "https://antoine.berthaud.me/"], }, }; } +// Études de cas (results.html, en/results.html) : une CollectionPage (la page +// rassemble les études) et son fil d'Ariane depuis l'accueil de la même +// langue. `page` = { url, name, description, crumb } : adresse, titre et +// description de la page telle que générée, libellé du fil d'Ariane. +function resultsPage(lang, page) { + return { + "@context": "https://schema.org", + "@graph": [ + { + "@type": "CollectionPage", + "@id": `${page.url}#page`, + url: page.url, + name: page.name, + description: page.description, + inLanguage: lang, + author: { "@id": PERSON_ID }, + breadcrumb: { "@id": `${page.url}#breadcrumb` }, + }, + { + "@type": "BreadcrumbList", + "@id": `${page.url}#breadcrumb`, + itemListElement: [ + { "@type": "ListItem", position: 1, name: "CV", item: PAGE_URLS.home[lang] }, + { "@type": "ListItem", position: 2, name: page.crumb, item: page.url }, + ], + }, + ], + }; +} + // Page d'un side project (PROJECT_DETAILS) : la page elle-même (WebPage), // son fil d'Ariane depuis l'accueil de la même langue, et l'application // qu'elle décrit (WebApplication), créée par la même personne que le CV. C'est @@ -164,4 +200,4 @@ function jsonLdScript(data) { return `<script type="application/ld+json">\n${json}\n</script>`; } -module.exports = { SITE, PERSON_ID, PAGE_URLS, profilePage, projectPage, projectUrls, jsonLdScript }; +module.exports = { SITE, PERSON_ID, PAGE_URLS, profilePage, resultsPage, projectPage, projectUrls, jsonLdScript }; diff --git a/sitemap.xml b/sitemap.xml index 7d3e353..bd9b3d1 100644 --- a/sitemap.xml +++ b/sitemap.xml @@ -2,11 +2,15 @@ <!-- Une entrée par page indexable. Chaque page existe en deux langues (français à la racine, anglais sous /en/), déclarées l'une à l'autre via les <xhtml:link hreflang>, le français servant de x-default. - Les <lastmod> sont mis à jour par .github/workflows/generate-pdf.yml à - chaque fois qu'il régénère une page pré-rendue ou un PDF (c'est ce qui - incite Google à revenir crawler) — ne pas les modifier à la main. --> + Les entrées des pages (zone static:pages) sont GÉNÉRÉES par + scripts/generate-static.js : il y ajoute toute page qu'il produit et date + son <lastmod> du jour où son HTML a changé pour la dernière fois (la même + date que le dateModified du JSON-LD de l'accueil). Les <lastmod> des PDF + sont posés par .github/workflows/generate-pdf.yml quand il les régénère. + Ne rien modifier à la main. --> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml"> +<!-- static:pages --> <url> <loc>https://cv.antoine.berthaud.me/</loc> <lastmod>2026-09-24</lastmod> @@ -49,6 +53,7 @@ <xhtml:link rel="alternate" hreflang="en" href="https://cv.antoine.berthaud.me/en/projects/tour-de-growth.html"/> <xhtml:link rel="alternate" hreflang="x-default" href="https://cv.antoine.berthaud.me/projets/tour-de-growth.html"/> </url> +<!-- /static:pages --> <!-- Les deux PDF complets du CV (décision d'Antoine, 06/09/2026) : déjà liés depuis les trois pages, déclarés ici pour que Google les compte. Les versions courtes (-fr-court, -en-short) ne sont volontairement pas