From fb447b6d814a192952b8837b33781648f1837ed4 Mon Sep 17 00:00:00 2001 From: maboudharam Date: Wed, 14 Jan 2026 19:07:37 +0100 Subject: [PATCH] feat: add PageSpeed optimizations for exported projects JavaScript & Build: - Add Terser minification with console/debugger removal - Add Gzip and Brotli compression via vite-plugin-compression - Add vendor chunk splitting for React CSS & Fonts: - Add critical CSS inline in index.html - Add font-display: swap and latin subset for Google Fonts - Add preconnect and dns-prefetch for external resources Images: - Add WebP conversion for PNG/JPG images (Canvas API) - Add elements with WebP fallback - Add lazy loading and width/height attributes - Pre-compute WebP URLs and video detection (avoid regex per render) Caching: - Add cache headers for Vercel (vercel.json) - Add cache headers for Netlify (netlify.toml) - 1 year max-age for static assets Code quality: - Centralize regex patterns in helpers.ts - Add isWebpConvertible() utility function - Parallelize WebP conversions with Promise.all() - Filter non-convertible formats (SVG, GIF, WebP) Closes #23 --- services/export/config/packageJson.ts | 2 + services/export/config/viteConfig.ts | 37 ++++++++++- services/export/deploy/netlify.ts | 16 +++++ services/export/deploy/vercel.ts | 22 ++++++- services/export/helpers.ts | 66 +++++++++++++++++++ services/export/imageExtractor.ts | 39 +++++++++-- services/export/index.ts | 4 +- .../export/templates/app/blockComponent.ts | 9 ++- services/export/templates/app/index.ts | 21 ++++++ services/export/templates/app/layouts.ts | 10 ++- services/export/templates/indexHtml.ts | 20 +++++- 11 files changed, 230 insertions(+), 16 deletions(-) diff --git a/services/export/config/packageJson.ts b/services/export/config/packageJson.ts index 20027d9..57fab31 100644 --- a/services/export/config/packageJson.ts +++ b/services/export/config/packageJson.ts @@ -28,8 +28,10 @@ export const generatePackageJson = (name: string): string => { autoprefixer: '^10.4.20', postcss: '^8.4.49', tailwindcss: '^3.4.15', + terser: '^5.36.0', typescript: '^5.6.3', vite: '^5.4.11', + 'vite-plugin-compression': '^0.5.1', }, }, null, diff --git a/services/export/config/viteConfig.ts b/services/export/config/viteConfig.ts index f6c6f7b..cedd638 100644 --- a/services/export/config/viteConfig.ts +++ b/services/export/config/viteConfig.ts @@ -4,9 +4,44 @@ export const generateViteConfig = (): string => `import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' +import compression from 'vite-plugin-compression' export default defineConfig({ - plugins: [react()], + plugins: [ + react(), + // Gzip compression + compression({ + algorithm: 'gzip', + ext: '.gz', + }), + // Brotli compression (better ratio) + compression({ + algorithm: 'brotliCompress', + ext: '.br', + }), + ], base: './', + build: { + // Enable minification with terser for better compression + minify: 'terser', + terserOptions: { + compress: { + drop_console: true, + drop_debugger: true, + }, + }, + // Optimize chunk splitting + rollupOptions: { + output: { + manualChunks: { + vendor: ['react', 'react-dom'], + }, + }, + }, + // Disable sourcemaps in production for smaller bundle + sourcemap: false, + // Target modern browsers for smaller bundle + target: 'es2020', + }, }) `; diff --git a/services/export/deploy/netlify.ts b/services/export/deploy/netlify.ts index 63d2d25..a5e5531 100644 --- a/services/export/deploy/netlify.ts +++ b/services/export/deploy/netlify.ts @@ -10,4 +10,20 @@ export const NETLIFY_TOML = `[build] from = "/*" to = "/index.html" status = 200 + +# Cache static assets for 1 year +[[headers]] + for = "/assets/*" + [headers.values] + Cache-Control = "public, max-age=31536000, immutable" + +[[headers]] + for = "*.js" + [headers.values] + Cache-Control = "public, max-age=31536000, immutable" + +[[headers]] + for = "*.css" + [headers.values] + Cache-Control = "public, max-age=31536000, immutable" `; diff --git a/services/export/deploy/vercel.ts b/services/export/deploy/vercel.ts index 727e22a..5cf6dbc 100644 --- a/services/export/deploy/vercel.ts +++ b/services/export/deploy/vercel.ts @@ -5,6 +5,26 @@ export const VERCEL_JSON = `{ "buildCommand": "npm run build", "outputDirectory": "dist", - "installCommand": "npm install" + "installCommand": "npm install", + "headers": [ + { + "source": "/assets/(.*)", + "headers": [ + { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" } + ] + }, + { + "source": "/(.*)\\\\.js", + "headers": [ + { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" } + ] + }, + { + "source": "/(.*)\\\\.css", + "headers": [ + { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" } + ] + } + ] } `; diff --git a/services/export/helpers.ts b/services/export/helpers.ts index 6620503..e60ae01 100644 --- a/services/export/helpers.ts +++ b/services/export/helpers.ts @@ -2,6 +2,35 @@ * Helper functions for the export service */ +// Formats that benefit from WebP conversion (raster, non-animated) +// Note: 'jpeg' is normalized to 'jpg' by getExtensionFromBase64 +export const WEBP_CONVERTIBLE_EXTENSIONS = ['png', 'jpg'] as const; + +// Regex pattern for WebP-convertible files (matches .png, .jpg, .jpeg) +export const WEBP_CONVERTIBLE_REGEX = /\.(png|jpe?g)$/i; + +// Regex pattern for video files +export const VIDEO_REGEX = /\.(mp4|webm|ogg|mov)$/i; + +/** + * Check if an extension is convertible to WebP + */ +export function isWebpConvertible(ext: string): boolean { + return (WEBP_CONVERTIBLE_EXTENSIONS as readonly string[]).includes(ext); +} + +/** + * Get file extension from a base64 data URL based on MIME type + */ +export function getExtensionFromBase64(base64: string): string { + const mimeMatch = base64.match(/data:image\/([^;]+);/); + if (!mimeMatch) return 'png'; + const mime = mimeMatch[1].toLowerCase(); + if (mime === 'jpeg') return 'jpg'; + if (mime === 'svg+xml') return 'svg'; + return mime; +} + /** * Convert a base64 data URL to a Blob */ @@ -23,6 +52,43 @@ export function base64ToBlob(base64: string): Blob | null { } } +/** + * Convert a base64 image to WebP format using Canvas API + * Returns a Blob in WebP format with quality optimization + */ +export async function base64ToWebP(base64: string, quality = 0.8): Promise { + return new Promise((resolve) => { + try { + const img = new Image(); + img.onload = () => { + const canvas = document.createElement('canvas'); + canvas.width = img.width; + canvas.height = img.height; + + const ctx = canvas.getContext('2d'); + if (!ctx) { + resolve(null); + return; + } + + ctx.drawImage(img, 0, 0); + + canvas.toBlob( + (blob) => { + resolve(blob); + }, + 'image/webp', + quality + ); + }; + img.onerror = () => resolve(null); + img.src = base64; + } catch { + resolve(null); + } + }); +} + /** * Escape HTML special characters to prevent XSS */ diff --git a/services/export/imageExtractor.ts b/services/export/imageExtractor.ts index 6512156..8fd2fd3 100644 --- a/services/export/imageExtractor.ts +++ b/services/export/imageExtractor.ts @@ -4,7 +4,7 @@ import JSZip from 'jszip'; import { SiteData } from '../../types'; -import { base64ToBlob } from './helpers'; +import { base64ToBlob, base64ToWebP, getExtensionFromBase64, isWebpConvertible } from './helpers'; export interface ImageMap { [key: string]: string; @@ -13,16 +13,30 @@ export interface ImageMap { /** * Extract all base64 images from SiteData and add them to a zip folder * Returns a mapping from image keys to their new paths + * Also generates WebP versions for better compression */ -export function extractImages(data: SiteData, assetsFolder: JSZip | null): ImageMap { +export async function extractImages(data: SiteData, assetsFolder: JSZip | null): Promise { const imageMap: ImageMap = {}; + const webpConversions: Promise[] = []; // Extract avatar if it's a base64 image if (data.profile.avatarUrl?.startsWith('data:image')) { const blob = base64ToBlob(data.profile.avatarUrl); if (blob && assetsFolder) { - assetsFolder.file('avatar.png', blob); - imageMap['profile_avatar'] = '/assets/avatar.png'; + const ext = getExtensionFromBase64(data.profile.avatarUrl); + assetsFolder.file(`avatar.${ext}`, blob); + imageMap['profile_avatar'] = `/assets/avatar.${ext}`; + + // Only convert raster formats to WebP (skip svg, gif, webp) + if (isWebpConvertible(ext)) { + webpConversions.push( + base64ToWebP(data.profile.avatarUrl).then((webpBlob) => { + if (webpBlob) { + assetsFolder.file('avatar.webp', webpBlob); + } + }) + ); + } } } @@ -31,12 +45,27 @@ export function extractImages(data: SiteData, assetsFolder: JSZip | null): Image if (block.imageUrl?.startsWith('data:image')) { const blob = base64ToBlob(block.imageUrl); if (blob && assetsFolder) { - const filename = `block-${block.id}.png`; + const ext = getExtensionFromBase64(block.imageUrl); + const filename = `block-${block.id}.${ext}`; assetsFolder.file(filename, blob); imageMap[`block_${block.id}`] = `/assets/${filename}`; + + // Only convert raster formats to WebP (skip svg, gif, webp) + if (isWebpConvertible(ext)) { + webpConversions.push( + base64ToWebP(block.imageUrl).then((webpBlob) => { + if (webpBlob) { + assetsFolder.file(`block-${block.id}.webp`, webpBlob); + } + }) + ); + } } } } + // Wait for all WebP conversions to complete in parallel + await Promise.all(webpConversions); + return imageMap; } diff --git a/services/export/index.ts b/services/export/index.ts index ddc0837..93739e2 100644 --- a/services/export/index.ts +++ b/services/export/index.ts @@ -51,8 +51,8 @@ export const exportSite = async ( const assetsFolder = zip.folder('public/assets'); const srcFolder = zip.folder('src'); - // Extract base64 images and get mapping - const imageMap = extractImages(data, assetsFolder); + // Extract base64 images and get mapping (also generates WebP versions) + const imageMap = await extractImages(data, assetsFolder); const deploymentTarget: ExportDeploymentTarget = opts?.deploymentTarget ?? 'vercel'; diff --git a/services/export/templates/app/blockComponent.ts b/services/export/templates/app/blockComponent.ts index b2bd7b8..49db921 100644 --- a/services/export/templates/app/blockComponent.ts +++ b/services/export/templates/app/blockComponent.ts @@ -97,7 +97,7 @@ const Block = ({ block }: { block: BlockData }) => {
{videos.slice(0, 4).map((v, i) => ( e.stopPropagation()} className="relative overflow-hidden rounded bg-gray-100 group/vid"> - {v.title} + {v.title}
@@ -129,10 +129,13 @@ const Block = ({ block }: { block: BlockData }) => {
{block.type === BlockType.MEDIA && block.imageUrl ? (
- {/\\.(mp4|webm|ogg|mov)$/i.test(block.imageUrl) ? ( + {blockIsVideo[block.id] ? (
diff --git a/services/export/templates/app/index.ts b/services/export/templates/app/index.ts index 7bf1d3c..c5ca9d1 100644 --- a/services/export/templates/app/index.ts +++ b/services/export/templates/app/index.ts @@ -4,6 +4,7 @@ import { SiteData } from '../../../../types'; import { ImageMap } from '../../imageExtractor'; +import { WEBP_CONVERTIBLE_REGEX, VIDEO_REGEX } from '../../helpers'; import { generateImports } from './imports'; import { generateTypes } from './types'; import { generateSocialPlatformsConfig } from './socialPlatforms'; @@ -81,6 +82,26 @@ ${generateBlockComponent()} // Profile data const profile = ${profileJson} const blocks: BlockData[] = ${blocksJson} + +// Patterns for file type detection +const WEBP_CONVERTIBLE_REGEX = /${WEBP_CONVERTIBLE_REGEX.source}/i +const VIDEO_REGEX = /${VIDEO_REGEX.source}/i + +// Helper to get WebP URL for convertible formats (png, jpg, jpeg only) +const getWebpUrl = (url: string | undefined): string | null => { + if (!url || !url.startsWith('/assets/')) return null + if (!WEBP_CONVERTIBLE_REGEX.test(url)) return null + return url.replace(WEBP_CONVERTIBLE_REGEX, '.webp') +} + +// Pre-compute URLs and flags (avoids regex on every render) +const avatarWebpUrl = getWebpUrl(profile.avatarUrl) +const blockWebpUrls: Record = {} +const blockIsVideo: Record = {} +blocks.forEach(b => { + blockWebpUrls[b.id] = getWebpUrl(b.imageUrl) + blockIsVideo[b.id] = b.imageUrl ? VIDEO_REGEX.test(b.imageUrl) : false +}) ${generateAnalyticsHook(analyticsId)} ${generateMobileLayoutHelper()} // Sort blocks for mobile diff --git a/services/export/templates/app/layouts.ts b/services/export/templates/app/layouts.ts index b4646c2..ace3069 100644 --- a/services/export/templates/app/layouts.ts +++ b/services/export/templates/app/layouts.ts @@ -19,7 +19,10 @@ export const generateDesktopLayout = (params: LayoutParams): string => `
- {profile.name} + + {avatarWebpUrl && } + {profile.name} +

{profile.name}

{profile.bio}

@@ -76,7 +79,10 @@ export const generateMobileLayout = (params: LayoutParams): string => `
- {profile.name} + + {avatarWebpUrl && } + {profile.name} +

{profile.name}

{profile.bio}

diff --git a/services/export/templates/indexHtml.ts b/services/export/templates/indexHtml.ts index c9e2ee5..c56016d 100644 --- a/services/export/templates/indexHtml.ts +++ b/services/export/templates/indexHtml.ts @@ -8,9 +8,25 @@ export const generateIndexHtml = (title: string): string => ` - - + + + + + + + + + + + ${escapeHtml(title)} + + +