diff --git a/index.html b/index.html index 30c90af..5e776a5 100644 --- a/index.html +++ b/index.html @@ -26,7 +26,16 @@ - + + + + + + + + diff --git a/package.json b/package.json index 178db69..83877c0 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "preview": "vite preview", "optimize-images": "node scripts/optimize-images.js", "create-thumbnails": "node scripts/create-thumbnails.js", - "analyze": "node scripts/analyze-bundle.js" + "analyze": "node scripts/analyze-bundle.js", + "extract-critical-css": "npm run build && node scripts/extract-critical-css.js" }, "dependencies": { "@hookform/resolvers": "^3.10.0", diff --git a/scripts/extract-critical-css.js b/scripts/extract-critical-css.js new file mode 100644 index 0000000..c193a9c --- /dev/null +++ b/scripts/extract-critical-css.js @@ -0,0 +1,83 @@ +/** + * Script pour extraire le CSS critique + * Utilise Puppeteer pour analyser la page et extraire uniquement le CSS nécessaire au rendu initial + * + * Installation requise: + * npm install --save-dev puppeteer critical + * + * Utilisation: + * node scripts/extract-critical-css.js + */ + +import { generate } from 'critical'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +async function extractCriticalCSS() { + try { + console.log('🔍 Extraction du CSS critique...'); + + const { css, html } = await generate({ + // Chemin vers le fichier HTML de build + base: path.join(__dirname, '../dist/'), + src: 'index.html', + + // Dimensions pour le CSS critique (above the fold) + dimensions: [ + { + width: 375, + height: 667 + }, // Mobile + { + width: 1920, + height: 1080 + }, // Desktop + ], + + // Options d'extraction + inline: false, // Ne pas inliner automatiquement + extract: true, + + // Ignorer les erreurs de ressources externes + ignore: { + atrule: ['@font-face', '@import'], + rule: [/^\.tsparticles/], + }, + + // Pourcentage de couverture CSS + penthouse: { + timeout: 60000, + maxEmbeddedBase64Length: 1000, + renderWaitTime: 500, + } + }); + + console.log('✅ CSS critique extrait avec succès!'); + console.log(`📊 Taille: ${(css.length / 1024).toFixed(2)} KB`); + + // Sauvegarder le CSS critique + const fs = await import('fs'); + const criticalPath = path.join(__dirname, '../src/critical.css'); + + // Ajouter un commentaire en haut du fichier + const header = `/* Critical CSS - Généré automatiquement le ${new Date().toISOString()} */\n/* Ne pas modifier manuellement - Utiliser npm run extract-critical-css */\n\n`; + + fs.writeFileSync(criticalPath, header + css); + console.log(`💾 Sauvegardé dans: ${criticalPath}`); + + return css; + } catch (error) { + console.error('❌ Erreur lors de l\'extraction du CSS critique:', error); + process.exit(1); + } +} + +// Exécuter si appelé directement +if (import.meta.url === `file://${process.argv[1]}`) { + extractCriticalCSS(); +} + +export { extractCriticalCSS }; diff --git a/src/critical.css b/src/critical.css new file mode 100644 index 0000000..fb8b128 --- /dev/null +++ b/src/critical.css @@ -0,0 +1,63 @@ +/* Critical CSS - Above the fold content */ +/* This CSS should be inlined in the HTML head to prevent render-blocking */ + +:root { + --background: 0 0% 100%; + --foreground: 222 47% 11%; + --primary: 217 91% 60%; + --border: 214 32% 91%; +} + +.dark { + --background: 0 0% 4%; + --foreground: 0 0% 98%; + --primary: 217 91% 60%; + --border: 0 0% 15%; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + background-color: hsl(var(--background)); + color: hsl(var(--foreground)); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; + line-height: 1.6; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +#root { + min-height: 100vh; +} + +/* Hide content until fonts load to prevent FOUT */ +.wf-loading body { + visibility: hidden; +} + +/* Skeleton loader for initial render */ +.loading-skeleton { + background: linear-gradient(90deg, + hsl(var(--background)) 25%, + hsl(var(--border)) 50%, + hsl(var(--background)) 75%); + background-size: 200% 100%; + animation: loading 1.5s ease-in-out infinite; +} + +@keyframes loading { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} diff --git a/vite-plugin-critical-css.ts b/vite-plugin-critical-css.ts new file mode 100644 index 0000000..7777493 --- /dev/null +++ b/vite-plugin-critical-css.ts @@ -0,0 +1,121 @@ +import { Plugin } from 'vite'; +import fs from 'fs'; +import path from 'path'; + +/** + * Plugin Vite pour inliner le CSS critique dans le HTML + * Cela réduit le temps de First Contentful Paint (FCP) et Largest Contentful Paint (LCP) + */ +export function inlineCriticalCSS(): Plugin { + return { + name: 'inline-critical-css', + apply: 'build', // Appliqué uniquement en mode build + + transformIndexHtml: { + order: 'post', + handler(html) { + // Lire le CSS critique + const criticalCSSPath = path.resolve(__dirname, 'src/critical.css'); + + if (!fs.existsSync(criticalCSSPath)) { + console.warn('⚠️ Fichier critical.css introuvable. Ignoré.'); + return html; + } + + const criticalCSS = fs.readFileSync(criticalCSSPath, 'utf-8'); + + // Minifier le CSS critique (simple) + const minifiedCSS = criticalCSS + .replace(/\/\*[\s\S]*?\*\//g, '') // Supprimer les commentaires + .replace(/\s+/g, ' ') // Réduire les espaces multiples + .replace(/\s*([{}:;,>+~])\s*/g, '$1') // Supprimer les espaces autour des symboles + .trim(); + + // Injecter le CSS critique dans le + const criticalStyle = ``; + + // Insérer juste après la balise ou avant la première + const headEndIndex = html.indexOf(''); + if (headEndIndex !== -1) { + html = html.slice(0, headEndIndex) + criticalStyle + html.slice(headEndIndex); + } + + return html; + } + } + }; +} + +/** + * Plugin pour précharger les ressources critiques + */ +export function preloadCriticalAssets(): Plugin { + return { + name: 'preload-critical-assets', + apply: 'build', + + transformIndexHtml: { + order: 'post', + handler(html, ctx) { + const preloads: string[] = []; + + // Trouver les chunks CSS et JS critiques + if (ctx.bundle) { + for (const [fileName, chunk] of Object.entries(ctx.bundle)) { + if (chunk.type === 'chunk' && chunk.isEntry) { + // Précharger le JS principal + preloads.push(``); + } else if (chunk.type === 'asset' && fileName.endsWith('.css')) { + // Précharger le CSS principal + preloads.push(``); + } + } + } + + // Insérer les preloads dans le + if (preloads.length > 0) { + const headEndIndex = html.indexOf(''); + if (headEndIndex !== -1) { + html = html.slice(0, headEndIndex) + preloads.join('') + html.slice(headEndIndex); + } + } + + return html; + } + } + }; +} + +/** + * Plugin pour charger les CSS non critiques de manière asynchrone + */ +export function asyncNonCriticalCSS(): Plugin { + return { + name: 'async-non-critical-css', + apply: 'build', + + transformIndexHtml: { + order: 'post', + handler(html) { + // Convertir les en chargement asynchrone + html = html.replace( + /]*rel=["']stylesheet["'][^>]*)>/gi, + (match, attrs) => { + // Ne pas modifier les liens externes (Google Fonts, etc.) + if (attrs.includes('fonts.googleapis.com')) { + return match; + } + + // Ajouter media="print" onload="this.media='all'" pour chargement async + if (!attrs.includes('media=')) { + return ``; + } + return match; + } + ); + + return html; + } + } + }; +} diff --git a/vite.config.ts b/vite.config.ts index 6fddb7a..a3901bb 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react-swc"; import path from "path"; import { componentTagger } from "lovable-tagger"; +import { preloadCriticalAssets } from "./vite-plugin-critical-css"; // https://vitejs.dev/config/ export default defineConfig(({ mode }) => ({ @@ -16,7 +17,11 @@ export default defineConfig(({ mode }) => ({ "0.0.0.0" ] }, - plugins: [react(), mode === "development" && componentTagger()].filter(Boolean), + plugins: [ + react(), + mode === "development" && componentTagger(), + mode === "production" && preloadCriticalAssets() + ].filter(Boolean), resolve: { alias: { "@": path.resolve(__dirname, "./src"),