Skip to content

Commit a38e999

Browse files
rsbhclaude
andauthored
feat: on-demand image optimization with sharp (#95)
* feat: on-demand image optimization with sharp Add /api/image endpoint that resizes and converts content images to WebP on first request, caching results to disk. Images go from ~1.8MB originals to ~6KB optimized WebP at 640w. - New /api/image endpoint: accepts url, w (width), q (quality) params - Remark plugin rewrites image URLs to point through optimization endpoint - Image component and SSR preload use optimized URLs - SVGs pass through unchanged, sharp externalized for Nitro bundling - Allowlisted widths prevent cache flooding Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove unnecessary window.Image, use Image directly Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: rename Image component to MDXImage Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: use top-level import for sharp Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: content negotiation via Accept header for image format Serve AVIF, WebP, or original format based on browser Accept header. AVIF preferred > WebP > original (resized only). Adds Vary: Accept for proper CDN caching. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: use Nitro storage for image cache instead of manual fs Portable across deployment targets (filesystem, KV, Redis, etc.). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: extract image-cache storage key to constant Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: configure fs storage driver for image cache Persists optimized images to .cache/images/ on disk. Survives server restarts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use StatusCodes from http-status-codes, fix lint error Move useStorage before early returns to satisfy Biome hook ordering rule. Replace magic status numbers with StatusCodes constants. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: restore semicolons in new files Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: extract DEFAULT_WIDTH and DEFAULT_QUALITY constants Replace magic numbers 1024 and 75 with named constants from image-utils.ts, used across all image optimization call sites. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add tests for image optimization utilities and API 24 tests covering negotiateFormat, cacheKey, MIME mapping, isLocalImage, isSvg, buildOptimizedUrl, and constants. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address image optimization review comments - Restrict quality to allowed steps [60, 75, 90, 100] with snap-to-nearest - Add cache stampede protection via in-flight promise map - Add LRU cache eviction with 500 entry cap - Skip image optimization rewrite for static build presets - Accept optimize option in remark-resolve-images plugin Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 550bb64 commit a38e999

12 files changed

Lines changed: 414 additions & 13 deletions

File tree

bun.lock

Lines changed: 53 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/chronicle/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@
7777
"remark-mdx-frontmatter": "^5.2.0",
7878
"remark-parse": "^11.0.0",
7979
"satori": "^0.25.0",
80+
"sharp": "^0.34.5",
8081
"slugify": "^1.6.6",
8182
"std-env": "^4.1.0",
8283
"unified": "^11.0.5",
Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import type { ComponentProps } from 'react';
2+
import { isLocalImage, isSvg, buildOptimizedUrl, DEFAULT_WIDTH } from '@/lib/image-utils';
23

3-
type ImageProps = ComponentProps<'img'>;
4+
type MDXImageProps = ComponentProps<'img'>;
45

5-
export function Image({ src, alt, ...props }: ImageProps) {
6+
export function MDXImage({ src, alt, ...props }: MDXImageProps) {
67
if (!src) return null;
78

8-
return <img src={src} alt={alt ?? ''} loading='lazy' {...props} />;
9+
const optimize = isLocalImage(src) && !isSvg(src);
10+
const imgSrc = optimize ? buildOptimizedUrl(src, DEFAULT_WIDTH) : src;
11+
12+
return <img src={imgSrc} alt={alt ?? ''} loading='lazy' decoding='async' {...props} />;
913
}

packages/chronicle/src/components/mdx/index.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { MDXComponents } from 'mdx/types'
2-
import { Image } from './image'
2+
import { MDXImage } from './image'
33
import { Link } from './link'
44
import { MdxTable, MdxThead, MdxTbody, MdxTr, MdxTh, MdxTd } from './table'
55
import { MdxPre, MdxCode } from './code'
@@ -25,7 +25,7 @@ MdxTabs.Content = Tabs.Content
2525

2626
export const mdxComponents: MDXComponents = {
2727
p: MdxParagraph,
28-
img: Image,
28+
img: MDXImage,
2929
a: Link,
3030
table: MdxTable,
3131
thead: MdxThead,
@@ -45,5 +45,5 @@ export const mdxComponents: MDXComponents = {
4545
Mermaid,
4646
}
4747

48-
export { Image } from './image'
48+
export { MDXImage } from './image'
4949
export { Link } from './link'
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { describe, expect, test } from 'bun:test';
2+
import {
3+
isLocalImage,
4+
isSvg,
5+
buildOptimizedUrl,
6+
ALLOWED_WIDTHS,
7+
DEFAULT_WIDTH,
8+
DEFAULT_QUALITY,
9+
} from './image-utils';
10+
11+
describe('isLocalImage', () => {
12+
test('returns true for /_content/ URLs', () => {
13+
expect(isLocalImage('/_content/docs/photo.png')).toBe(true);
14+
});
15+
16+
test('returns false for external URLs', () => {
17+
expect(isLocalImage('https://example.com/img.png')).toBe(false);
18+
});
19+
20+
test('returns false for relative URLs', () => {
21+
expect(isLocalImage('/images/logo.png')).toBe(false);
22+
});
23+
});
24+
25+
describe('isSvg', () => {
26+
test('returns true for .svg files', () => {
27+
expect(isSvg('/_content/logo.svg')).toBe(true);
28+
});
29+
30+
test('returns true for .svg with query string', () => {
31+
expect(isSvg('/_content/logo.svg?v=1')).toBe(true);
32+
});
33+
34+
test('returns false for .png files', () => {
35+
expect(isSvg('/_content/photo.png')).toBe(false);
36+
});
37+
});
38+
39+
describe('buildOptimizedUrl', () => {
40+
test('builds URL with width and default quality', () => {
41+
const url = buildOptimizedUrl('/_content/img.png', 640);
42+
expect(url).toBe(`/api/image?url=%2F_content%2Fimg.png&w=640&q=${DEFAULT_QUALITY}`);
43+
});
44+
45+
test('builds URL with custom quality', () => {
46+
const url = buildOptimizedUrl('/_content/img.png', 320, 50);
47+
expect(url).toBe('/api/image?url=%2F_content%2Fimg.png&w=320&q=50');
48+
});
49+
50+
test('encodes special characters in URL', () => {
51+
const url = buildOptimizedUrl('/_content/my image (1).png', 640);
52+
expect(url).toContain('my%20image%20(1).png');
53+
});
54+
});
55+
56+
describe('constants', () => {
57+
test('ALLOWED_WIDTHS is sorted ascending', () => {
58+
for (let i = 1; i < ALLOWED_WIDTHS.length; i++) {
59+
expect(ALLOWED_WIDTHS[i]).toBeGreaterThan(ALLOWED_WIDTHS[i - 1]);
60+
}
61+
});
62+
63+
test('DEFAULT_WIDTH is in ALLOWED_WIDTHS', () => {
64+
expect(ALLOWED_WIDTHS).toContain(DEFAULT_WIDTH);
65+
});
66+
67+
test('DEFAULT_QUALITY is between 1 and 100', () => {
68+
expect(DEFAULT_QUALITY).toBeGreaterThanOrEqual(1);
69+
expect(DEFAULT_QUALITY).toBeLessThanOrEqual(100);
70+
});
71+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
const ALLOWED_WIDTHS = [320, 640, 768, 1024, 1280, 1536, 1920];
2+
const ALLOWED_QUALITIES = [60, 75, 90, 100];
3+
const DEFAULT_WIDTH = 1024;
4+
const DEFAULT_QUALITY = 75;
5+
6+
export function isLocalImage(url: string): boolean {
7+
return url.startsWith('/_content/');
8+
}
9+
10+
export function isSvg(url: string): boolean {
11+
return url.split('?')[0].endsWith('.svg');
12+
}
13+
14+
export function buildOptimizedUrl(url: string, width: number, quality = DEFAULT_QUALITY): string {
15+
return `/api/image?url=${encodeURIComponent(url)}&w=${width}&q=${quality}`;
16+
}
17+
18+
export { ALLOWED_WIDTHS, ALLOWED_QUALITIES, DEFAULT_WIDTH, DEFAULT_QUALITY };

packages/chronicle/src/lib/page-context.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type { VersionContext } from '@/lib/version-source';
1414
import { LATEST_CONTEXT } from '@/lib/version-source';
1515
import type { ChronicleConfig, Frontmatter, Page, PageNavLink, Root, TableOfContents } from '@/types';
1616
import { queryClient } from '@/lib/preload';
17+
import { isLocalImage, isSvg, buildOptimizedUrl, DEFAULT_WIDTH } from '@/lib/image-utils';
1718

1819
export type MdxLoader = (relativePath: string) => Promise<{ content: ReactNode; toc: TableOfContents }>;
1920

@@ -136,7 +137,7 @@ export function PageProvider({
136137
if (data.images?.length) {
137138
for (const src of data.images) {
138139
const img = new Image();
139-
img.src = src;
140+
img.src = isLocalImage(src) && !isSvg(src) ? buildOptimizedUrl(src, DEFAULT_WIDTH) : src;
140141
}
141142
}
142143
const { content, toc } = await loadMdx(data.originalPath || data.relativePath);

packages/chronicle/src/lib/remark-resolve-images.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { Image, Html } from 'mdast'
55
import type { Element } from 'hast'
66
import type { MdxJsxFlowElement, MdxJsxTextElement, MdxJsxAttribute } from 'mdast-util-mdx-jsx'
77
import { MdxNodeType } from './mdx-utils'
8+
import { isLocalImage, isSvg, buildOptimizedUrl, DEFAULT_WIDTH } from './image-utils'
89

910
function resolveUrl(src: string, dir: string): string {
1011
if (/^[a-z][a-z0-9+\-.]*:/i.test(src)) return src
@@ -16,7 +17,17 @@ function resolveUrl(src: string, dir: string): string {
1617
return `/_content/${path.posix.normalize(path.posix.join(dir, src))}`
1718
}
1819

19-
const remarkResolveImages: Plugin = () => {
20+
interface RemarkResolveImagesOptions {
21+
optimize?: boolean
22+
}
23+
24+
function optimizeUrl(url: string, optimize: boolean): string {
25+
if (optimize && isLocalImage(url) && !isSvg(url)) return buildOptimizedUrl(url, DEFAULT_WIDTH)
26+
return url
27+
}
28+
29+
const remarkResolveImages: Plugin<[RemarkResolveImagesOptions?]> = (options) => {
30+
const optimize = options?.optimize ?? true
2031
return (tree, file) => {
2132
const filePath = file.path
2233
if (!filePath) return
@@ -40,6 +51,7 @@ const remarkResolveImages: Plugin = () => {
4051
if (!node.url) return
4152
node.url = resolveUrl(node.url, dir)
4253
collect(node.url)
54+
node.url = optimizeUrl(node.url, optimize)
4355
})
4456

4557
visit(tree, 'html', (node: Html) => {
@@ -48,7 +60,7 @@ const remarkResolveImages: Plugin = () => {
4860
(_, before, src, after) => {
4961
const resolved = resolveUrl(src, dir)
5062
collect(resolved)
51-
return `${before}${resolved}${after}`
63+
return `${before}${optimizeUrl(resolved, optimize)}${after}`
5264
}
5365
)
5466
})
@@ -61,6 +73,7 @@ const remarkResolveImages: Plugin = () => {
6173
if (!srcAttr?.value || typeof srcAttr.value !== 'string') return
6274
srcAttr.value = resolveUrl(srcAttr.value, dir)
6375
collect(srcAttr.value)
76+
srcAttr.value = optimizeUrl(srcAttr.value, optimize)
6477
})
6578

6679
visit(tree, 'element', (node: Element) => {
@@ -69,6 +82,7 @@ const remarkResolveImages: Plugin = () => {
6982
if (typeof src !== 'string') return
7083
node.properties.src = resolveUrl(src, dir)
7184
collect(node.properties.src as string)
85+
node.properties.src = optimizeUrl(node.properties.src as string, optimize)
7286
})
7387

7488
file.data.images = images
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { describe, expect, test } from 'bun:test';
2+
import { negotiateFormat, cacheKey, MIME } from './image';
3+
4+
describe('negotiateFormat', () => {
5+
test('returns avif when Accept includes image/avif', () => {
6+
expect(negotiateFormat('image/avif,image/webp,*/*')).toBe('avif');
7+
});
8+
9+
test('returns webp when Accept includes image/webp but not avif', () => {
10+
expect(negotiateFormat('image/webp,image/png,*/*')).toBe('webp');
11+
});
12+
13+
test('returns original when Accept has neither avif nor webp', () => {
14+
expect(negotiateFormat('image/png,*/*')).toBe('original');
15+
});
16+
17+
test('returns original for null Accept header', () => {
18+
expect(negotiateFormat(null)).toBe('original');
19+
});
20+
21+
test('prefers avif over webp when both present', () => {
22+
expect(negotiateFormat('image/webp,image/avif')).toBe('avif');
23+
});
24+
});
25+
26+
describe('cacheKey', () => {
27+
test('returns deterministic key for same inputs', () => {
28+
const a = cacheKey('/_content/img.png', 640, 75, 'webp');
29+
const b = cacheKey('/_content/img.png', 640, 75, 'webp');
30+
expect(a).toBe(b);
31+
});
32+
33+
test('returns different keys for different widths', () => {
34+
const a = cacheKey('/_content/img.png', 640, 75, 'webp');
35+
const b = cacheKey('/_content/img.png', 1024, 75, 'webp');
36+
expect(a).not.toBe(b);
37+
});
38+
39+
test('returns different keys for different formats', () => {
40+
const a = cacheKey('/_content/img.png', 640, 75, 'webp');
41+
const b = cacheKey('/_content/img.png', 640, 75, 'avif');
42+
expect(a).not.toBe(b);
43+
});
44+
45+
test('returns different keys for different quality', () => {
46+
const a = cacheKey('/_content/img.png', 640, 75, 'webp');
47+
const b = cacheKey('/_content/img.png', 640, 50, 'webp');
48+
expect(a).not.toBe(b);
49+
});
50+
51+
test('key ends with format extension', () => {
52+
expect(cacheKey('/_content/img.png', 640, 75, 'webp')).toMatch(/\.webp$/);
53+
expect(cacheKey('/_content/img.png', 640, 75, 'avif')).toMatch(/\.avif$/);
54+
expect(cacheKey('/_content/img.png', 640, 75, 'original')).toMatch(/\.original$/);
55+
});
56+
});
57+
58+
describe('MIME', () => {
59+
test('maps common image extensions', () => {
60+
expect(MIME['.png']).toBe('image/png');
61+
expect(MIME['.jpg']).toBe('image/jpeg');
62+
expect(MIME['.jpeg']).toBe('image/jpeg');
63+
expect(MIME['.gif']).toBe('image/gif');
64+
expect(MIME['.webp']).toBe('image/webp');
65+
});
66+
67+
test('does not include svg (handled separately)', () => {
68+
expect(MIME['.svg']).toBeUndefined();
69+
});
70+
});

0 commit comments

Comments
 (0)