diff --git a/docs/content/docs/image-optimization.mdx b/docs/content/docs/image-optimization.mdx new file mode 100644 index 00000000..a3d82f5a --- /dev/null +++ b/docs/content/docs/image-optimization.mdx @@ -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 +![Screenshot](screenshot.png?w=640&q=90) +``` + +### 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 + +![Overview](overview.png) + + +![Thumb](thumb.png?w=320&q=60) + + +![UI](dashboard.png?w=1280&q=90) + + +![Photo](photo.png?w=640) + + +![Chart](chart.png?q=90) +``` + +## 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. diff --git a/packages/chronicle/src/lib/remark-resolve-images.ts b/packages/chronicle/src/lib/remark-resolve-images.ts index 50401af6..bc2acee2 100644 --- a/packages/chronicle/src/lib/remark-resolve-images.ts +++ b/packages/chronicle/src/lib/remark-resolve-images.ts @@ -7,6 +7,30 @@ import type { MdxJsxFlowElement, MdxJsxTextElement, MdxJsxAttribute } from 'mdas import { MdxNodeType } from './mdx-utils' import { isLocalImage, isSvg, buildOptimizedUrl, DEFAULT_WIDTH } from './image-utils' +interface ImageParams { + w?: number + q?: number +} + +function parseImageParams(src: string): { base: string; params: ImageParams } { + const qIdx = src.indexOf('?') + if (qIdx === -1) return { base: src, params: {} } + const base = src.slice(0, qIdx) + const search = new URLSearchParams(src.slice(qIdx + 1)) + const params: ImageParams = {} + if (search.has('w')) params.w = Number.parseInt(search.get('w')!, 10) + if (search.has('q')) params.q = Number.parseInt(search.get('q')!, 10) + return { base, params } +} + +function appendParams(url: string, params: ImageParams): string { + if (!params.w && !params.q) return url + const qs = new URLSearchParams() + if (params.w) qs.set('w', String(params.w)) + if (params.q) qs.set('q', String(params.q)) + return `${url}?${qs}` +} + function resolveUrl(src: string, dir: string): string { const normalized = src.replace(/\\/g, '/') if (/^[a-z][a-z0-9+\-.]*:/i.test(normalized)) return normalized @@ -23,8 +47,11 @@ interface RemarkResolveImagesOptions { } function optimizeUrl(url: string, optimize: boolean): string { - if (optimize && isLocalImage(url) && !isSvg(url)) return buildOptimizedUrl(url, DEFAULT_WIDTH) - return url + const { base, params } = parseImageParams(url) + const width = params.w || DEFAULT_WIDTH + const quality = params.q + if (optimize && isLocalImage(base) && !isSvg(base)) return buildOptimizedUrl(base, width, quality) + return base } const remarkResolveImages: Plugin<[RemarkResolveImagesOptions?]> = (options) => { @@ -50,18 +77,20 @@ const remarkResolveImages: Plugin<[RemarkResolveImagesOptions?]> = (options) => visit(tree, 'image', (node: Image) => { if (!node.url) return - node.url = resolveUrl(node.url, dir) - collect(node.url) - node.url = optimizeUrl(node.url, optimize) + const { base, params } = parseImageParams(node.url) + const resolved = resolveUrl(base, dir) + collect(resolved) + node.url = optimizeUrl(appendParams(resolved, params), optimize) }) visit(tree, 'html', (node: Html) => { node.value = node.value.replace( /(]*\bsrc=["'])([^"']+)(["'])/gi, (_, before, src, after) => { - const resolved = resolveUrl(src, dir) + const { base, params } = parseImageParams(src) + const resolved = resolveUrl(base, dir) collect(resolved) - return `${before}${optimizeUrl(resolved, optimize)}${after}` + return `${before}${optimizeUrl(appendParams(resolved, params), optimize)}${after}` } ) }) @@ -72,18 +101,20 @@ const remarkResolveImages: Plugin<[RemarkResolveImagesOptions?]> = (options) => if (jsx.name !== 'img') return const srcAttr = jsx.attributes.find((a): a is MdxJsxAttribute => a.type === 'mdxJsxAttribute' && a.name === 'src') if (!srcAttr?.value || typeof srcAttr.value !== 'string') return - srcAttr.value = resolveUrl(srcAttr.value, dir) - collect(srcAttr.value) - srcAttr.value = optimizeUrl(srcAttr.value, optimize) + const { base: jsxBase, params: jsxParams } = parseImageParams(srcAttr.value) + const jsxResolved = resolveUrl(jsxBase, dir) + collect(jsxResolved) + srcAttr.value = optimizeUrl(appendParams(jsxResolved, jsxParams), optimize) }) visit(tree, 'element', (node: Element) => { if (node.tagName !== 'img') return const src = node.properties?.src if (typeof src !== 'string') return - node.properties.src = resolveUrl(src, dir) - collect(node.properties.src as string) - node.properties.src = optimizeUrl(node.properties.src as string, optimize) + const { base: elBase, params: elParams } = parseImageParams(src) + const elResolved = resolveUrl(elBase, dir) + collect(elResolved) + node.properties.src = optimizeUrl(appendParams(elResolved, elParams), optimize) }) file.data.images = images diff --git a/packages/chronicle/src/server/api/image.test.ts b/packages/chronicle/src/server/api/image.test.ts index 8b8307ce..76e10da2 100644 --- a/packages/chronicle/src/server/api/image.test.ts +++ b/packages/chronicle/src/server/api/image.test.ts @@ -48,6 +48,12 @@ describe('cacheKey', () => { expect(a).not.toBe(b); }); + test('returns different keys for different mtime', () => { + const a = cacheKey('/_content/img.png', 640, 75, 'webp', 1000); + const b = cacheKey('/_content/img.png', 640, 75, 'webp', 2000); + expect(a).not.toBe(b); + }); + test('key ends with format extension', () => { expect(cacheKey('/_content/img.png', 640, 75, 'webp')).toMatch(/\.webp$/); expect(cacheKey('/_content/img.png', 640, 75, 'avif')).toMatch(/\.avif$/); diff --git a/packages/chronicle/src/server/api/image.ts b/packages/chronicle/src/server/api/image.ts index 0ba78120..46b9bff5 100644 --- a/packages/chronicle/src/server/api/image.ts +++ b/packages/chronicle/src/server/api/image.ts @@ -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>() @@ -29,8 +28,8 @@ export const MIME: Record = { '.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) { - 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 { + 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(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); + 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(); + 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`); + } +} diff --git a/packages/chronicle/src/server/api/ready.ts b/packages/chronicle/src/server/api/ready.ts index 6271606a..d7c636de 100644 --- a/packages/chronicle/src/server/api/ready.ts +++ b/packages/chronicle/src/server/api/ready.ts @@ -1,9 +1,11 @@ import { defineHandler } from 'nitro'; import { ensureIndex, isSearchReady } from './search'; +import { warmupImageCache } from './image'; import { LATEST_CONTEXT } from '@/lib/version-source'; export default defineHandler(async () => { ensureIndex(LATEST_CONTEXT).catch(e => console.error('[search:index]', e)); + warmupImageCache().catch(e => console.error('[image-warmup]', e)); if (!isSearchReady()) { return new Response(JSON.stringify({ status: 'not_ready', search: false }), {