Landing : pages préchargées au survol d'un lien (speculationrules)

- Règles de préchargement dans l'accueil et les 15 guides : une page du site est préparée quand on survole
  son lien (appui sur téléphone), et s'ouvre presque tout de suite. Pages seulement, pas les fichiers ;
  l'application n'est pas concernée. Ignoré par les navigateurs qui ne connaissent pas ces règles.
- Une page préchargée ne compte pas de visite avant d'être vraiment ouverte (PostHog attend
  prerenderingchange), et l'animation du logo se joue à l'ouverture, pas pendant le préchargement.
- CSP de la landing (deploy/nginx.conf) : empreinte des règles, et nouvelle empreinte du script du logo.
  Le mot-clé 'inline-speculation-rules' ne suffisait pas à Chrome.
- Test : chaque script en ligne des pages a son empreinte dans la CSP, chaque page a ses règles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 committed 2026-10-27 18:05:12 +01:00
1 parent c45bcb0463
commit fcfb7d1494
20 files changed
+119 -3

No files matched your search

+5 -1
View File
@@ -33,12 +33,16 @@ map $host $parenthese_app_csp {
# script-src 'sha256-...' script en ligne du <head> (animation du logo, dans index.html et en/index.html).
# S'il change, recalculer l'empreinte : base64(sha256(contenu exact entre
# <script> et </script>)), sinon l'animation ne se joue plus (sans autre effet).
# 2e 'sha256-...' règles de préchargement des pages (<script type="speculationrules">, le même
# texte dans index.html et chaque guide). Même calcul s'il change, sinon les pages
# ne sont plus préchargées (sans autre effet). Le mot-clé 'inline-speculation-rules'
# ne suffisait pas à Chrome (essayé le 27/10).
# posthog comme pour l'app
# style-src 'unsafe-inline' attributs style du rendu serveur React
# frame-src démonstration de l'app en iframe
# frame-ancestors 'none' équivalent de X-Frame-Options DENY
map $host $parenthese_landing_csp {
default "default-src 'self'; script-src 'self' 'sha256-Av5a1/yoW/tQ2ZQWqH5FnaL896Bubcs9ivdFvsKZBrU='; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self' https://us.i.posthog.com https://us-assets.i.posthog.com; worker-src 'self' blob:; frame-src https://app.parenthese.io; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'";
default "default-src 'self'; script-src 'self' 'sha256-v68KpM0KHUkaezvOmKtoCY2RsfbwoEC9EZYD682DCiU=' 'sha256-eflWcNn8MfPvX2YD4hXJnR3x9nRGu0f7tIEq2j/yfPo='; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self' https://us.i.posthog.com https://us-assets.i.posthog.com; worker-src 'self' blob:; frame-src https://app.parenthese.io; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'";
}
# Réponses de l'API relayées (JSON, médias des familles) : intégrables seulement par l'app (PDF en iframe).
+5 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#FBFAF7" />
<!-- Arrivée du logo : une fois par visite, décidée avant le premier affichage (animation dans src/index.css) -->
<script>try{if(!sessionStorage.getItem('pz-logo')&&!matchMedia('(prefers-reduced-motion: reduce)').matches){document.documentElement.classList.add('logo-intro');sessionStorage.setItem('pz-logo','1')}}catch(e){}</script>
<script>(function(){function intro(){try{if(!sessionStorage.getItem('pz-logo')&&!matchMedia('(prefers-reduced-motion: reduce)').matches){document.documentElement.classList.add('logo-intro');sessionStorage.setItem('pz-logo','1')}}catch(e){}}if(document.prerendering)document.addEventListener('prerenderingchange',intro,{once:true});else intro()})()</script>
<title>Parenthèse : l'arbre généalogique vivant de votre famille</title>
<meta name="description" content="Créez l'arbre généalogique de votre famille en ligne, avec photos, vidéos et voix. Partagez-le en un lien : la famille consulte sans compte et contribue avec un mot de passe." />
@@ -72,6 +72,10 @@
]
}
</script>
<!-- Pages du site préparées en avance quand on survole un lien (appui sur téléphone) : elles s'ouvrent presque
tout de suite. Seulement les pages, pas les fichiers ; l'application (app.parenthese.io) n'est pas concernée.
Un navigateur qui ne connaît pas ces règles les ignore. -->
<script type="speculationrules">{"prerender":[{"where":{"and":[{"href_matches":"/*"},{"not":{"href_matches":"/*.*"}}]},"eagerness":"moderate"}]}</script>
</head>
<body>
<!-- Sans JavaScript, le contenu prérendu reste visible (les révélations au défilement demandent JS) -->
+4
View File
@@ -51,6 +51,10 @@
]
}
</script>
<!-- Pages du site préparées en avance quand on survole un lien (appui sur téléphone) : elles s'ouvrent presque
tout de suite. Seulement les pages, pas les fichiers ; l'application (app.parenthese.io) n'est pas concernée.
Un navigateur qui ne connaît pas ces règles les ignore. -->
<script type="speculationrules">{"prerender":[{"where":{"and":[{"href_matches":"/*"},{"not":{"href_matches":"/*.*"}}]},"eagerness":"moderate"}]}</script>
</head>
<body>
<header class="top">
@@ -49,6 +49,10 @@
]
}
</script>
<!-- Pages du site préparées en avance quand on survole un lien (appui sur téléphone) : elles s'ouvrent presque
tout de suite. Seulement les pages, pas les fichiers ; l'application (app.parenthese.io) n'est pas concernée.
Un navigateur qui ne connaît pas ces règles les ignore. -->
<script type="speculationrules">{"prerender":[{"where":{"and":[{"href_matches":"/*"},{"not":{"href_matches":"/*.*"}}]},"eagerness":"moderate"}]}</script>
</head>
<body>
<header class="top">
@@ -63,6 +63,10 @@
]
}
</script>
<!-- Pages du site préparées en avance quand on survole un lien (appui sur téléphone) : elles s'ouvrent presque
tout de suite. Seulement les pages, pas les fichiers ; l'application (app.parenthese.io) n'est pas concernée.
Un navigateur qui ne connaît pas ces règles les ignore. -->
<script type="speculationrules">{"prerender":[{"where":{"and":[{"href_matches":"/*"},{"not":{"href_matches":"/*.*"}}]},"eagerness":"moderate"}]}</script>
</head>
<body>
<header class="top">
@@ -48,6 +48,10 @@
]
}
</script>
<!-- Pages du site préparées en avance quand on survole un lien (appui sur téléphone) : elles s'ouvrent presque
tout de suite. Seulement les pages, pas les fichiers ; l'application (app.parenthese.io) n'est pas concernée.
Un navigateur qui ne connaît pas ces règles les ignore. -->
<script type="speculationrules">{"prerender":[{"where":{"and":[{"href_matches":"/*"},{"not":{"href_matches":"/*.*"}}]},"eagerness":"moderate"}]}</script>
</head>
<body>
<header class="top">
@@ -65,6 +65,10 @@
]
}
</script>
<!-- Pages du site préparées en avance quand on survole un lien (appui sur téléphone) : elles s'ouvrent presque
tout de suite. Seulement les pages, pas les fichiers ; l'application (app.parenthese.io) n'est pas concernée.
Un navigateur qui ne connaît pas ces règles les ignore. -->
<script type="speculationrules">{"prerender":[{"where":{"and":[{"href_matches":"/*"},{"not":{"href_matches":"/*.*"}}]},"eagerness":"moderate"}]}</script>
</head>
<body>
<header class="top">
+4
View File
@@ -54,6 +54,10 @@
]
}
</script>
<!-- Pages du site préparées en avance quand on survole un lien (appui sur téléphone) : elles s'ouvrent presque
tout de suite. Seulement les pages, pas les fichiers ; l'application (app.parenthese.io) n'est pas concernée.
Un navigateur qui ne connaît pas ces règles les ignore. -->
<script type="speculationrules">{"prerender":[{"where":{"and":[{"href_matches":"/*"},{"not":{"href_matches":"/*.*"}}]},"eagerness":"moderate"}]}</script>
</head>
<body>
<header class="top">
@@ -35,6 +35,10 @@
"publisher": { "@type": "Person", "name": "Ilies Allali", "email": "pro.allali.ilies@gmail.com" }
}
</script>
<!-- Pages du site préparées en avance quand on survole un lien (appui sur téléphone) : elles s'ouvrent presque
tout de suite. Seulement les pages, pas les fichiers ; l'application (app.parenthese.io) n'est pas concernée.
Un navigateur qui ne connaît pas ces règles les ignore. -->
<script type="speculationrules">{"prerender":[{"where":{"and":[{"href_matches":"/*"},{"not":{"href_matches":"/*.*"}}]},"eagerness":"moderate"}]}</script>
</head>
<body>
<header class="top">
@@ -54,6 +54,10 @@
]
}
</script>
<!-- Pages du site préparées en avance quand on survole un lien (appui sur téléphone) : elles s'ouvrent presque
tout de suite. Seulement les pages, pas les fichiers ; l'application (app.parenthese.io) n'est pas concernée.
Un navigateur qui ne connaît pas ces règles les ignore. -->
<script type="speculationrules">{"prerender":[{"where":{"and":[{"href_matches":"/*"},{"not":{"href_matches":"/*.*"}}]},"eagerness":"moderate"}]}</script>
</head>
<body>
<header class="top">
@@ -49,6 +49,10 @@
]
}
</script>
<!-- Pages du site préparées en avance quand on survole un lien (appui sur téléphone) : elles s'ouvrent presque
tout de suite. Seulement les pages, pas les fichiers ; l'application (app.parenthese.io) n'est pas concernée.
Un navigateur qui ne connaît pas ces règles les ignore. -->
<script type="speculationrules">{"prerender":[{"where":{"and":[{"href_matches":"/*"},{"not":{"href_matches":"/*.*"}}]},"eagerness":"moderate"}]}</script>
</head>
<body>
<header class="top">
@@ -48,6 +48,10 @@
]
}
</script>
<!-- Pages du site préparées en avance quand on survole un lien (appui sur téléphone) : elles s'ouvrent presque
tout de suite. Seulement les pages, pas les fichiers ; l'application (app.parenthese.io) n'est pas concernée.
Un navigateur qui ne connaît pas ces règles les ignore. -->
<script type="speculationrules">{"prerender":[{"where":{"and":[{"href_matches":"/*"},{"not":{"href_matches":"/*.*"}}]},"eagerness":"moderate"}]}</script>
</head>
<body>
<header class="top">
+4
View File
@@ -51,6 +51,10 @@
]
}
</script>
<!-- Pages du site préparées en avance quand on survole un lien (appui sur téléphone) : elles s'ouvrent presque
tout de suite. Seulement les pages, pas les fichiers ; l'application (app.parenthese.io) n'est pas concernée.
Un navigateur qui ne connaît pas ces règles les ignore. -->
<script type="speculationrules">{"prerender":[{"where":{"and":[{"href_matches":"/*"},{"not":{"href_matches":"/*.*"}}]},"eagerness":"moderate"}]}</script>
</head>
<body>
<header class="top">
@@ -65,6 +65,10 @@
]
}
</script>
<!-- Pages du site préparées en avance quand on survole un lien (appui sur téléphone) : elles s'ouvrent presque
tout de suite. Seulement les pages, pas les fichiers ; l'application (app.parenthese.io) n'est pas concernée.
Un navigateur qui ne connaît pas ces règles les ignore. -->
<script type="speculationrules">{"prerender":[{"where":{"and":[{"href_matches":"/*"},{"not":{"href_matches":"/*.*"}}]},"eagerness":"moderate"}]}</script>
</head>
<body>
<header class="top">
@@ -63,6 +63,10 @@
]
}
</script>
<!-- Pages du site préparées en avance quand on survole un lien (appui sur téléphone) : elles s'ouvrent presque
tout de suite. Seulement les pages, pas les fichiers ; l'application (app.parenthese.io) n'est pas concernée.
Un navigateur qui ne connaît pas ces règles les ignore. -->
<script type="speculationrules">{"prerender":[{"where":{"and":[{"href_matches":"/*"},{"not":{"href_matches":"/*.*"}}]},"eagerness":"moderate"}]}</script>
</head>
<body>
<header class="top">
+4
View File
@@ -35,6 +35,10 @@
"publisher": { "@type": "Person", "name": "Ilies Allali", "email": "pro.allali.ilies@gmail.com" }
}
</script>
<!-- Pages du site préparées en avance quand on survole un lien (appui sur téléphone) : elles s'ouvrent presque
tout de suite. Seulement les pages, pas les fichiers ; l'application (app.parenthese.io) n'est pas concernée.
Un navigateur qui ne connaît pas ces règles les ignore. -->
<script type="speculationrules">{"prerender":[{"where":{"and":[{"href_matches":"/*"},{"not":{"href_matches":"/*.*"}}]},"eagerness":"moderate"}]}</script>
</head>
<body>
<header class="top">
@@ -47,6 +47,10 @@
]
}
</script>
<!-- Pages du site préparées en avance quand on survole un lien (appui sur téléphone) : elles s'ouvrent presque
tout de suite. Seulement les pages, pas les fichiers ; l'application (app.parenthese.io) n'est pas concernée.
Un navigateur qui ne connaît pas ces règles les ignore. -->
<script type="speculationrules">{"prerender":[{"where":{"and":[{"href_matches":"/*"},{"not":{"href_matches":"/*.*"}}]},"eagerness":"moderate"}]}</script>
</head>
<body>
<header class="top">
@@ -47,6 +47,10 @@
]
}
</script>
<!-- Pages du site préparées en avance quand on survole un lien (appui sur téléphone) : elles s'ouvrent presque
tout de suite. Seulement les pages, pas les fichiers ; l'application (app.parenthese.io) n'est pas concernée.
Un navigateur qui ne connaît pas ces règles les ignore. -->
<script type="speculationrules">{"prerender":[{"where":{"and":[{"href_matches":"/*"},{"not":{"href_matches":"/*.*"}}]},"eagerness":"moderate"}]}</script>
</head>
<body>
<header class="top">
+6 -1
View File
@@ -5,10 +5,15 @@ import App from './App.jsx'
import { analytics } from './analytics.js'
import { localeFromPath } from './i18n.js'
// PostHog après le premier rendu : les événements émis avant sont mis en file et envoyés à l'init
// PostHog après le premier rendu : les événements émis avant sont mis en file et envoyés à l'init.
// Page préchargée (survol d'un lien, speculationrules) : rien ne part avant qu'on l'ouvre vraiment.
const deferInit = () => analytics.init()
const scheduleInit = () => {
if ('requestIdleCallback' in window) window.requestIdleCallback(deferInit, { timeout: 3000 })
else window.setTimeout(deferInit, 1500)
}
if (document.prerendering) document.addEventListener('prerenderingchange', scheduleInit, { once: true })
else scheduleInit()
const container = document.getElementById('root')
const app = (
+39
View File
@@ -0,0 +1,39 @@
// Les scripts en ligne de la landing (animation du logo, règles de préchargement des pages) ne passent la CSP de
// nginx que par leur empreinte exacte (deploy/nginx.conf). Un script modifié sans recalculer son empreinte ne
// casse rien de visible : l'animation ou le préchargement s'arrêtent sans bruit. Ce test le dit tout de suite.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { readFileSync, readdirSync, statSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const landing = fileURLToPath(new URL('..', import.meta.url))
const csp = readFileSync(path.join(landing, '../deploy/nginx.conf'), 'utf8').match(/map \$host \$parenthese_landing_csp \{\s*default "([^"]+)"/)[1]
const autorisees = new Set([...csp.matchAll(/'sha256-([^']+)'/g)].map((m) => m[1]))
const empreinte = (texte) => createHash('sha256').update(texte, 'utf8').digest('base64')
function pages(dossier) {
return readdirSync(dossier).flatMap((nom) => {
const chemin = path.join(dossier, nom)
if (statSync(chemin).isDirectory()) return pages(chemin)
return nom === 'index.html' ? [chemin] : []
})
}
const toutes = [path.join(landing, 'index.html'), ...pages(path.join(landing, 'public'))]
test('chaque script en ligne des pages a son empreinte dans la CSP de la landing', () => {
for (const fichier of toutes) {
const html = readFileSync(fichier, 'utf8')
for (const [, attributs, contenu] of html.matchAll(/<script([^>]*)>([\s\S]*?)<\/script>/g)) {
if (/\bsrc=|application\/ld\+json/.test(attributs)) continue
assert.ok(autorisees.has(empreinte(contenu)), `${path.relative(landing, fichier)} : script en ligne sans empreinte dans deploy/nginx.conf (${empreinte(contenu)})`)
}
}
})
test('chaque page a ses règles de préchargement', () => {
for (const fichier of toutes) {
assert.match(readFileSync(fichier, 'utf8'), /<script type="speculationrules">/, path.relative(landing, fichier))
}
})