Skip to content
Merged
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
114 changes: 114 additions & 0 deletions docs/content/docs/image-optimization.mdx
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
![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
<!-- Default: 1024w, quality 75 -->
![Overview](overview.png)

<!-- Small thumbnail -->
![Thumb](thumb.png?w=320&q=60)

<!-- High quality screenshot -->
![UI](dashboard.png?w=1280&q=90)

<!-- Width only, default quality -->
![Photo](photo.png?w=640)

<!-- Quality only, default width -->
![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.
57 changes: 44 additions & 13 deletions packages/chronicle/src/lib/remark-resolve-images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) => {
Expand All @@ -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(
/(<img\b[^>]*\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}`
}
)
})
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions packages/chronicle/src/server/api/image.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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$/);
Expand Down
83 changes: 63 additions & 20 deletions packages/chronicle/src/server/api/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>>()

Expand All @@ -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}`
}

Expand All @@ -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 => {
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
await evictIfNeeded(storage)
return optimized
})()

Expand Down Expand Up @@ -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);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would this work with other toolkits apart from nitro?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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
https://nitro.build/docs/storage

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`);
}
}
2 changes: 2 additions & 0 deletions packages/chronicle/src/server/api/ready.ts
Original file line number Diff line number Diff line change
@@ -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 }), {
Expand Down
Loading