Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions services/export/config/packageJson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
37 changes: 36 additions & 1 deletion services/export/config/viteConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
})
`;
16 changes: 16 additions & 0 deletions services/export/deploy/netlify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
`;
22 changes: 21 additions & 1 deletion services/export/deploy/vercel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
]
}
]
}
`;
66 changes: 66 additions & 0 deletions services/export/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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<Blob | null> {
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
*/
Expand Down
39 changes: 34 additions & 5 deletions services/export/imageExtractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<ImageMap> {
const imageMap: ImageMap = {};
const webpConversions: Promise<void>[] = [];

// 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);
}
})
);
}
}
}

Expand All @@ -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;
}
4 changes: 2 additions & 2 deletions services/export/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
9 changes: 6 additions & 3 deletions services/export/templates/app/blockComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ const Block = ({ block }: { block: BlockData }) => {
<div className="flex-1 grid grid-cols-2 gap-1 overflow-hidden">
{videos.slice(0, 4).map((v, i) => (
<a key={i} href={\`https://youtube.com/watch?v=\${v.id}\`} target="_blank" rel="noopener noreferrer" onClick={e => e.stopPropagation()} className="relative overflow-hidden rounded bg-gray-100 group/vid">
<img src={v.thumbnail} alt={v.title} className="w-full h-full object-cover" />
<img src={v.thumbnail} alt={v.title} className="w-full h-full object-cover" loading="lazy" width="320" height="180" />
<div className="absolute inset-0 bg-black/20 group-hover/vid:bg-black/40 transition-colors flex items-center justify-center">
<div className="w-6 h-6 rounded-full bg-red-500 flex items-center justify-center opacity-0 group-hover/vid:opacity-100 transition-opacity">
<Play size={10} className="text-white ml-0.5" fill="white" />
Expand Down Expand Up @@ -129,10 +129,13 @@ const Block = ({ block }: { block: BlockData }) => {
<div className="w-full h-full relative z-10">
{block.type === BlockType.MEDIA && block.imageUrl ? (
<div className="w-full h-full relative overflow-hidden">
{/\\.(mp4|webm|ogg|mov)$/i.test(block.imageUrl) ? (
{blockIsVideo[block.id] ? (
<video src={block.imageUrl} className="full-img" style={{ objectPosition: \`\${mediaPos.x}% \${mediaPos.y}%\` }} autoPlay loop muted playsInline />
) : (
<img src={block.imageUrl} alt={block.title || ''} className="full-img" style={{ objectPosition: \`\${mediaPos.x}% \${mediaPos.y}%\` }} />
<picture>
{blockWebpUrls[block.id] && <source srcSet={blockWebpUrls[block.id]!} type="image/webp" />}
<img src={block.imageUrl} alt={block.title || ''} className="full-img" style={{ objectPosition: \`\${mediaPos.x}% \${mediaPos.y}%\` }} loading="lazy" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw" />
</picture>
)}
{block.title && <div className="media-overlay"><p className="media-title text-sm">{block.title}</p>{block.subtext && <p className="media-subtext">{block.subtext}</p>}</div>}
</div>
Expand Down
21 changes: 21 additions & 0 deletions services/export/templates/app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, string | null> = {}
const blockIsVideo: Record<string, boolean> = {}
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
Expand Down
10 changes: 8 additions & 2 deletions services/export/templates/app/layouts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ export const generateDesktopLayout = (params: LayoutParams): string => `
<div className="hidden lg:flex">
<div className="fixed left-0 top-0 w-[420px] h-screen flex flex-col justify-center items-start px-12">
<div className="w-40 h-40 overflow-hidden bg-gray-100 mb-8" style={avatarStyle}>
<img src={profile.avatarUrl} alt={profile.name} className="w-full h-full object-cover" />
<picture>
{avatarWebpUrl && <source srcSet={avatarWebpUrl} type="image/webp" />}
<img src={profile.avatarUrl} alt={profile.name} className="w-full h-full object-cover" width="160" height="160" loading="eager" fetchpriority="high" />
</picture>
</div>
<h1 className="text-4xl font-bold tracking-tight text-gray-900 mb-3">{profile.name}</h1>
<p className="text-base text-gray-500 font-medium whitespace-pre-wrap max-w-xs">{profile.bio}</p>
Expand Down Expand Up @@ -76,7 +79,10 @@ export const generateMobileLayout = (params: LayoutParams): string => `
<div className="lg:hidden">
<div className="p-4 pt-8 flex flex-col items-center text-center">
<div className="w-24 h-24 mb-4 overflow-hidden bg-gray-100" style={avatarStyle}>
<img src={profile.avatarUrl} alt={profile.name} className="w-full h-full object-cover" />
<picture>
{avatarWebpUrl && <source srcSet={avatarWebpUrl} type="image/webp" />}
<img src={profile.avatarUrl} alt={profile.name} className="w-full h-full object-cover" width="160" height="160" loading="eager" fetchpriority="high" />
</picture>
</div>
<h1 className="text-2xl font-extrabold tracking-tight text-gray-900 mb-2">{profile.name}</h1>
<p className="text-sm text-gray-500 font-medium whitespace-pre-wrap max-w-xs">{profile.bio}</p>
Expand Down
20 changes: 18 additions & 2 deletions services/export/templates/indexHtml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,25 @@ export const generateIndexHtml = (title: string): string => `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />

<!-- Preconnect to external resources for faster loading -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="dns-prefetch" href="https://fonts.googleapis.com" />
<link rel="dns-prefetch" href="https://fonts.gstatic.com" />

<!-- Google Fonts with font-display: swap and latin subset for better performance -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&subset=latin&display=swap" rel="stylesheet" />

<title>${escapeHtml(title)}</title>

<!-- Critical CSS for above-the-fold content -->
<style>
*,*::before,*::after{box-sizing:border-box}
body{margin:0;font-family:Inter,system-ui,-apple-system,sans-serif;-webkit-font-smoothing:antialiased}
#root{min-height:100vh}
</style>
</head>
<body>
<div id="root"></div>
Expand Down
Loading