-
Notifications
You must be signed in to change notification settings - Fork 1
feat: image cache warmup, query params, and docs #110
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7863020
6a40218
518d5dc
97ee33d
6485f05
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| --- | ||
| title: Image Optimization | ||
| description: Automatic image optimization with on-demand resizing and format conversion. | ||
| order: 5 | ||
| --- | ||
|
|
||
| # Image Optimization | ||
|
|
||
| Chronicle automatically optimizes content images via an on-demand `/api/image` endpoint. Images are resized, converted to modern formats (WebP/AVIF), and cached on disk. | ||
|
|
||
| ## How It Works | ||
|
|
||
| 1. The remark plugin rewrites all image URLs to route through `/api/image` | ||
| 2. On first request, the image is resized and converted based on browser support | ||
| 3. The result is cached — subsequent requests are served instantly | ||
| 4. On server start, all content images are pre-cached (warmup) | ||
|
|
||
| ## Format Negotiation | ||
|
|
||
| The endpoint reads the browser's `Accept` header and serves the best format: | ||
|
|
||
| | Browser sends | Format served | | ||
| |---|---| | ||
| | `Accept: image/avif` | AVIF | | ||
| | `Accept: image/webp` | WebP | | ||
| | Neither | Original (resized only) | | ||
|
|
||
| SVG images are passed through unchanged. | ||
|
|
||
| ## Query Parameters | ||
|
|
||
| Control image output directly in markdown using query parameters: | ||
|
|
||
| ```md | ||
|  | ||
| ``` | ||
|
|
||
| ### Width (`w`) | ||
|
|
||
| Sets the output width in pixels. The image is resized proportionally (height auto-calculated). Images are never enlarged beyond their original size. | ||
|
|
||
| **Allowed values:** | ||
|
|
||
| | Width | Use case | | ||
| |---|---| | ||
| | `320` | Small thumbnails, mobile icons | | ||
| | `640` | Mobile content images | | ||
| | `768` | Tablet content images | | ||
| | `1024` | Default — desktop content | | ||
| | `1280` | Wide content areas | | ||
| | `1536` | Large displays | | ||
| | `1920` | Full-width hero images | | ||
|
|
||
| Default: `1024` | ||
|
|
||
| ### Quality (`q`) | ||
|
|
||
| Sets the compression quality. Lower values produce smaller files with more compression artifacts. | ||
|
|
||
| **Allowed values:** | ||
|
|
||
| | Quality | File size | Use case | | ||
| |---|---|---| | ||
| | `60` | Smallest | Thumbnails, previews | | ||
| | `75` | Balanced | Default — content images | | ||
| | `90` | High quality | Screenshots with text | | ||
| | `100` | Maximum | Lossless-like output | | ||
|
|
||
| Default: `75` | ||
|
|
||
| ## Examples | ||
|
|
||
| ```md | ||
| <!-- Default: 1024w, quality 75 --> | ||
|  | ||
|
|
||
| <!-- Small thumbnail --> | ||
|  | ||
|
|
||
| <!-- High quality screenshot --> | ||
|  | ||
|
|
||
| <!-- Width only, default quality --> | ||
|  | ||
|
|
||
| <!-- Quality only, default width --> | ||
|  | ||
| ``` | ||
|
|
||
| ## API Endpoint | ||
|
|
||
| ``` | ||
| GET /api/image?url=/_content/docs/photo.png&w=640&q=75 | ||
| ``` | ||
|
|
||
| | Parameter | Required | Description | | ||
| |---|---|---| | ||
| | `url` | Yes | Content image path (must start with `/_content/`) | | ||
| | `w` | Yes | Output width (must be an allowed value) | | ||
| | `q` | No | Quality 1-100 (snapped to nearest allowed value) | | ||
|
|
||
| ### Response Headers | ||
|
|
||
| ``` | ||
| Content-Type: image/webp (or image/avif, or original mime) | ||
| Cache-Control: public, max-age=31536000, immutable | ||
| Vary: Accept | ||
| ``` | ||
|
|
||
| ## Cache | ||
|
|
||
| Optimized images are cached using Nitro storage with the `fs` driver at `.cache/images/`. Cache persists across server restarts. | ||
|
|
||
| The warmup process pre-caches all content images as WebP at default width (1024) and quality (75) when the server starts. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,10 +6,9 @@ import { useStorage } from 'nitro/storage' | |
| import sharp from 'sharp' | ||
| import { StatusCodes } from 'http-status-codes' | ||
| import { safePath } from '@/server/utils/safe-path' | ||
| import { ALLOWED_WIDTHS, ALLOWED_QUALITIES, DEFAULT_QUALITY } from '@/lib/image-utils' | ||
| import { ALLOWED_WIDTHS, ALLOWED_QUALITIES, DEFAULT_WIDTH, DEFAULT_QUALITY, isLocalImage, isSvg } from '@/lib/image-utils' | ||
|
|
||
| const STORAGE_KEY = 'image-cache' | ||
| const MAX_CACHE_ENTRIES = 500 | ||
| export const STORAGE_KEY = 'image-cache' | ||
|
|
||
| const inflight = new Map<string, Promise<Buffer>>() | ||
|
|
||
|
|
@@ -29,8 +28,8 @@ export const MIME: Record<string, string> = { | |
| '.webp': 'image/webp', | ||
| } | ||
|
|
||
| export function cacheKey(url: string, w: number, q: number, format: OutputFormat): string { | ||
| const hash = crypto.createHash('sha256').update(`${url}:${w}:${q}:${format}`).digest('hex').slice(0, 16) | ||
| export function cacheKey(url: string, w: number, q: number, format: OutputFormat, mtime?: number): string { | ||
| const hash = crypto.createHash('sha256').update(`${url}:${w}:${q}:${format}:${mtime ?? 0}`).digest('hex').slice(0, 16) | ||
| return `${hash}.${format}` | ||
| } | ||
|
|
||
|
|
@@ -42,11 +41,17 @@ function snapQuality(q: number): number { | |
| return closest; | ||
| } | ||
|
|
||
| async function evictIfNeeded(storage: ReturnType<typeof useStorage>) { | ||
| const keys = await storage.getKeys() | ||
| if (keys.length <= MAX_CACHE_ENTRIES) return | ||
| const toRemove = keys.slice(0, keys.length - MAX_CACHE_ENTRIES) | ||
| await Promise.all(toRemove.map(k => storage.removeItem(k))) | ||
| export async function optimizeImage( | ||
| filePath: string, | ||
| w: number, | ||
| q: number, | ||
| format: OutputFormat, | ||
| ): Promise<Buffer> { | ||
| const source = await fs.readFile(filePath); | ||
| const pipeline = sharp(source).resize({ width: w, withoutEnlargement: true }); | ||
| if (format === 'avif') return pipeline.avif({ quality: q }).toBuffer(); | ||
| if (format === 'webp') return pipeline.webp({ quality: q }).toBuffer(); | ||
| return pipeline.toBuffer(); | ||
| } | ||
|
|
||
| export default defineHandler(async event => { | ||
|
|
@@ -90,7 +95,11 @@ export default defineHandler(async event => { | |
| const originalMime = MIME[ext] ?? 'application/octet-stream' | ||
| const contentType = format === 'original' ? originalMime : `image/${format}` | ||
|
|
||
| const key = cacheKey(url, w, q, format) | ||
| const stat = await fs.stat(filePath).catch(() => null) | ||
| if (!stat) { | ||
| throw new HTTPError({ status: StatusCodes.NOT_FOUND, message: 'Not Found' }) | ||
| } | ||
| const key = cacheKey(url, w, q, format, stat.mtimeMs) | ||
|
|
||
| const cached = await storage.getItemRaw<Buffer>(key) | ||
| if (cached) { | ||
|
|
@@ -116,16 +125,8 @@ export default defineHandler(async event => { | |
| } | ||
|
|
||
| const work = (async () => { | ||
| const source = await fs.readFile(filePath) | ||
| const pipeline = sharp(source).resize({ width: w, withoutEnlargement: true }) | ||
| const optimized = format === 'avif' | ||
| ? await pipeline.avif({ quality: q }).toBuffer() | ||
| : format === 'webp' | ||
| ? await pipeline.webp({ quality: q }).toBuffer() | ||
| : await pipeline.toBuffer() | ||
|
|
||
| const optimized = await optimizeImage(filePath, w, q, format) | ||
| await storage.setItemRaw(key, optimized) | ||
| await evictIfNeeded(storage) | ||
| return optimized | ||
| })() | ||
|
|
||
|
|
@@ -154,3 +155,45 @@ export default defineHandler(async event => { | |
| inflight.delete(key) | ||
| } | ||
| }) | ||
|
|
||
| export async function warmupImageCache() { | ||
| const { getPages, getPageImages } = await import('@/lib/source'); | ||
| // biome-ignore lint/correctness/useHookAtTopLevel: useStorage is a Nitro DI accessor, not a React hook | ||
| const storage = useStorage(STORAGE_KEY); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would this work with other toolkits apart from nitro?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No, it needs nitro. but we get abstration over different run time and different stoage options |
||
| const contentDir = __CHRONICLE_CONTENT_DIR__; | ||
| const format = 'webp' as const; | ||
| const w = DEFAULT_WIDTH; | ||
| const q = DEFAULT_QUALITY; | ||
|
|
||
| const pages = await getPages(); | ||
| const seen = new Set<string>(); | ||
| let warmed = 0; | ||
|
|
||
| for (const page of pages) { | ||
| for (const url of getPageImages(page)) { | ||
| if (!isLocalImage(url) || isSvg(url) || seen.has(url)) continue; | ||
| seen.add(url); | ||
|
|
||
| const relativePath = url.replace(/^\/_content\//, ''); | ||
| const filePath = safePath(contentDir, `/${relativePath}`); | ||
| if (!filePath) continue; | ||
|
|
||
| const stat = await fs.stat(filePath).catch(() => null); | ||
| if (!stat) continue; | ||
|
|
||
| const key = cacheKey(url, w, q, format, stat.mtimeMs); | ||
| const cached = await storage.getItemRaw(key); | ||
| if (cached) continue; | ||
|
|
||
| try { | ||
| const optimized = await optimizeImage(filePath, w, q, format); | ||
| await storage.setItemRaw(key, optimized); | ||
| warmed++; | ||
| } catch { /* skip unprocessable */ } | ||
| } | ||
| } | ||
|
|
||
| if (warmed > 0) { | ||
| console.log(`[image-warmup] cached ${warmed} images as webp@${w}w`); | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.