Skip to content

Commit

Permalink
image optimization (#536)
Browse files Browse the repository at this point in the history
* image optimization

* prioritize images

* png to webp, and properly size images

* image paths

* further optimization

* cleanup + prefetch links

* redeploy

* dynamically serve images with different sizes

* moved assets to public folder

* automatically generate image sizes
  • Loading branch information
ShiboSoftwareDev authored Jan 12, 2025
1 parent ddfde4d commit ba613ee
Show file tree
Hide file tree
Showing 16 changed files with 212 additions and 27 deletions.
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,6 @@ package-lock.json
test-results

.yalc
yalc.lock
yalc.lock

public/assets
Binary file modified bun.lockb
Binary file not shown.
10 changes: 7 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@
"start:playwright-server": "bun run build:fake-api && vite --port 5177",
"dev": "bun run build:fake-api && AUTOLOAD_SNIPPETS=true vite",
"dev:registry": "SNIPPETS_API_URL=http://localhost:3100 vite",
"build": "bun run build:fake-api && tsc -b && vite build",
"build": "bun run generate-images && bun run build:fake-api && tsc -b && vite build",
"preview": "vite preview",
"format": "biome format --write .",
"lint": "biome format .",
"build:fake-api": "winterspec bundle -o dist/bundle.js"
"build:fake-api": "winterspec bundle -o dist/bundle.js",
"generate-images": "bun run scripts/generate-image-sizes.ts"
},
"dependencies": {
"@babel/preset-react": "^7.25.9",
Expand Down Expand Up @@ -122,7 +123,6 @@
},
"devDependencies": {
"@anthropic-ai/sdk": "^0.27.3",
"terser": "^5.27.0",
"@babel/standalone": "^7.26.2",
"@biomejs/biome": "^1.9.2",
"@playwright/test": "^1.48.0",
Expand All @@ -135,6 +135,7 @@
"@types/react": "^18.3.9",
"@types/react-dom": "^18.3.0",
"@types/react-helmet": "^6.1.11",
"@types/sharp": "^0.32.0",
"@typescript/vfs": "^1.6.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
Expand All @@ -145,9 +146,12 @@
"prompts": "^2.4.2",
"react": "^18.3.1",
"redaxios": "^0.5.1",
"sharp": "^0.33.5",
"tailwindcss": "^3.4.13",
"terser": "^5.27.0",
"typescript": "^5.6.3",
"vite": "^5.4.8",
"vite-plugin-image-optimizer": "^1.1.8",
"winterspec": "^0.0.94",
"zod": "^3.23.8",
"zustand": "^4.5.5",
Expand Down
58 changes: 58 additions & 0 deletions scripts/generate-image-sizes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import sharp from "sharp"
import path from "path"
import fs from "fs"

const WIDTHS = [400, 600, 800, 1000, 1200, 1600, 2000]
const INPUT_DIR = "src/assets/originals"
const OUTPUT_DIR = "public/assets"

async function generateImageSizes() {
console.log("one")
if (!fs.existsSync(INPUT_DIR)) {
fs.mkdirSync(INPUT_DIR, { recursive: true })
}
if (!fs.existsSync(OUTPUT_DIR)) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true })
}

const files = fs.readdirSync(INPUT_DIR)

for (const file of files) {
if (file.startsWith(".")) continue

const filePath = path.join(INPUT_DIR, file)
const fileNameWithoutExt = path.parse(file).name
const outputDirForFile = path.join(OUTPUT_DIR)

if (!fs.existsSync(outputDirForFile)) {
fs.mkdirSync(outputDirForFile, { recursive: true })
}

for (const width of WIDTHS) {
const extension = path.extname(file)
const outputPath = path.join(
outputDirForFile,
`${fileNameWithoutExt}-${width}w${extension}`,
)

try {
await sharp(filePath)
.resize(width, null, {
withoutEnlargement: true,
fit: "inside",
})
.webp({
quality: 80,
effort: 6,
})
.toFile(outputPath)

console.log(`Generated ${outputPath}`)
} catch (error) {
console.error(`Error processing ${filePath} at width ${width}:`, error)
}
}
}
}

generateImageSizes().catch(console.error)
Binary file removed src/assets/editor_example_1.png
Binary file not shown.
Binary file removed src/assets/editor_example_1_more_square.png
Binary file not shown.
Binary file removed src/assets/editor_example_2.png
Binary file not shown.
Binary file removed src/assets/example_schematic.png
Binary file not shown.
Binary file added src/assets/originals/editor_example_1.webp
Binary file not shown.
Binary file not shown.
Binary file added src/assets/originals/editor_example_2.webp
Binary file not shown.
Binary file added src/assets/originals/example_schematic.webp
Binary file not shown.
96 changes: 96 additions & 0 deletions src/components/OptimizedImage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { useState, useEffect } from "react"

/**
* OptimizedImage component for responsive images with automatic srcset generation
*
* This component automatically generates srcset attributes for responsive images.
* Place your original high-resolution images in src/assets/originals/.
* The build process will automatically generate all required sizes.
*
* Example usage:
* <OptimizedImage
* src="/assets/example.jpg"
* alt="Example"
* />
*
* The build process will generate:
* /assets/
* example-400w.jpg
* example-600w.jpg
* example-800w.jpg
* example-1000w.jpg
* example-1200w.jpg
* example-1600w.jpg
* example-2000w.jpg
*/
interface OptimizedImageProps
extends React.ImgHTMLAttributes<HTMLImageElement> {
src: string
alt: string
priority?: boolean
}

const getImageSizes = (src: string) => {
if (src.endsWith(".svg")) return { srcSet: src, sizes: "100vw" }

const widths = [400, 600, 800, 1000, 1200, 1600, 2000]
const basePath = src.substring(0, src.lastIndexOf("."))
const extension = src.substring(src.lastIndexOf("."))
const srcSet = widths
.map((w) => `${basePath}-${w}w${extension} ${w}w`)
.join(", ")

const sizes =
"(max-width: 400px) 400px, (max-width: 600px) 600px, (max-width: 800px) 800px, (max-width: 1000px) 1000px, (max-width: 1200px) 1200px, (max-width: 1600px) 1600px, 2000px"

return { srcSet, sizes }
}

export function OptimizedImage({
src,
alt,
className,
priority = false,
...props
}: OptimizedImageProps) {
const [imageLoading, setImageLoading] = useState(true)
const { srcSet, sizes } = getImageSizes(src)

useEffect(() => {
if (priority) {
const link = document.createElement("link")
link.rel = "preload"
link.as = "image"
link.href = src
link.type = src.endsWith(".svg") ? "image/svg+xml" : "image/webp"
link.imageSrcset = srcSet
link.imageSizes = sizes
document.head.appendChild(link)

return () => {
document.head.removeChild(link)
}
}
}, [src, priority, srcSet, sizes])

return (
<img
src={src}
srcSet={srcSet}
sizes={sizes}
alt={alt}
loading={priority ? "eager" : "lazy"}
decoding={priority ? "sync" : "async"}
fetchPriority={priority ? "high" : "auto"}
className={`${className} ${
imageLoading ? "animate-pulse bg-gray-200" : ""
} w-full h-auto object-contain`}
onLoad={() => setImageLoading(false)}
onError={(e) => {
console.error("Image failed to load:", e)
setImageLoading(false)
}}
{...props}
/>
)
}
3 changes: 2 additions & 1 deletion src/components/TrendingSnippetCarousel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Link } from "wouter"
import { Snippet } from "fake-snippets-api/lib/db/schema"
import { useEffect, useRef, useState } from "react"
import { useSnippetsBaseApiUrl } from "@/hooks/use-snippets-base-api-url"
import { OptimizedImage } from "./OptimizedImage"

export const TrendingSnippetCarousel = () => {
const axios = useAxios()
Expand Down Expand Up @@ -53,7 +54,7 @@ export const TrendingSnippetCarousel = () => {
{snippet.owner_name}/{snippet.unscoped_name}
</div>
<div className="mb-2 h-24 w-full bg-black rounded overflow-hidden">
<img
<OptimizedImage
src={`${apiBaseUrl}/snippets/images/${snippet.owner_name}/${snippet.unscoped_name}/pcb.svg`}
alt="PCB preview"
className="w-full h-full object-contain p-2 scale-[3] rotate-45 hover:scale-[3.5] transition-transform"
Expand Down
44 changes: 22 additions & 22 deletions src/pages/landing.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,7 @@
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion"
import { OptimizedImage } from "@/components/OptimizedImage"
import {
CircuitBoard,
Code2,
Expand All @@ -16,13 +11,10 @@ import {
Maximize2,
Zap,
} from "lucide-react"
import { Helmet } from "react-helmet"
import { Link } from "wouter"
import { Header2 } from "@/components/Header2"
import Footer from "@/components/Footer"
import editorExampleImage1 from "@/assets/editor_example_1.png"
import editorExampleImage1MoreSquare from "@/assets/editor_example_1_more_square.png"
import editorExampleImage2 from "@/assets/editor_example_2.png"
import schematicExampleImage from "@/assets/example_schematic.png"
import { useSignIn } from "@/hooks/use-sign-in"
import { useGlobalStore } from "@/hooks/use-global-store"
import { navigate } from "wouter/use-browser-location"
Expand All @@ -34,6 +26,19 @@ export function LandingPage() {
const isLoggedIn = useGlobalStore((s) => Boolean(s.session))
return (
<div className="flex min-h-screen flex-col">
<Helmet>
<link rel="preconnect" href="https://img.shields.io" />
<link rel="dns-prefetch" href="https://img.shields.io" />

<link rel="preconnect" href="https://shields.io" />
<link rel="dns-prefetch" href="https://shields.io" />

<link rel="preconnect" href="https://tscircuit.com" />
<link rel="dns-prefetch" href="https://tscircuit.com" />

<link rel="preconnect" href="https://registry-api.tscircuit.com" />
<link rel="dns-prefetch" href="https://registry-api.tscircuit.com" />
</Helmet>
<Header2 />
<main className="flex-1">
<section className="w-full py-12 md:py-24 lg:py-32 xl:py-48">
Expand Down Expand Up @@ -103,12 +108,11 @@ export function LandingPage() {
</div>
</div>
</div>
<img
<OptimizedImage
alt="Product preview"
className="mx-auto aspect-video overflow-hidden rounded-xl object-cover object-center"
height="310"
src={editorExampleImage1MoreSquare}
width="550"
src="/assets/editor_example_1_more_square.webp"
priority={true}
/>
</div>
</div>
Expand Down Expand Up @@ -168,22 +172,18 @@ export function LandingPage() {
</div>
</section>
<div className="md:mt-8">
<img
<OptimizedImage
alt="Product preview"
className="mx-auto aspect-video overflow-hidden rounded-xl object-cover object-center"
height="310"
src={editorExampleImage2}
width="800"
src="/assets/editor_example_2.webp"
/>
</div>
<FAQ />
<div className="md:mt-8">
<img
<OptimizedImage
alt="Product preview"
className="mx-auto aspect-video overflow-hidden rounded-xl object-cover object-center"
height="310"
src={schematicExampleImage}
width="800"
src="/assets/example_schematic.webp"
/>
</div>
<section className="w-full py-12 md:py-24 lg:py-32 bg-primary" id="cta">
Expand Down
24 changes: 24 additions & 0 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { defineConfig, Plugin, UserConfig } from "vite"
import type { PluginOption } from "vite"
import path from "path"
import react from "@vitejs/plugin-react"
import { ViteImageOptimizer } from "vite-plugin-image-optimizer"
import { getNodeHandler } from "winterspec/adapters/node"
import vercel from "vite-plugin-vercel"

Expand Down Expand Up @@ -47,6 +48,29 @@ export default defineConfig(async (): Promise<UserConfig> => {
prerender: false,
buildCommand: "bun run build",
}),
ViteImageOptimizer({
png: {
quality: 75,
compressionLevel: 9,
},
jpeg: {
quality: 75,
progressive: true,
},
jpg: {
quality: 75,
progressive: true,
},
webp: {
quality: 75,
lossless: false,
effort: 6,
},
avif: {
quality: 75,
effort: 6,
},
}),
]

if (process.env.VITE_BUNDLE_ANALYZE === "true" || 1) {
Expand Down

0 comments on commit ba613ee

Please sign in to comment.