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
+
+```
+
+### 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+## 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 }), {