From 7863020b29c188415c4c669ac7e7e2adf104ed7c Mon Sep 17 00:00:00 2001 From: Rishabh Date: Wed, 27 May 2026 06:57:14 +0530 Subject: [PATCH 1/5] feat: remove cache limit and add image warmup on server start - Remove 500 entry cache limit and eviction logic - Extract optimizeImage function for reuse - Add image-warmup Nitro plugin that pre-caches all content images as webp at default width after first response - Export STORAGE_KEY and cacheKey for plugin access Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/chronicle/src/server/api/image.ts | 29 ++++----- .../src/server/plugins/image-warmup.ts | 65 +++++++++++++++++++ 2 files changed, 78 insertions(+), 16 deletions(-) create mode 100644 packages/chronicle/src/server/plugins/image-warmup.ts diff --git a/packages/chronicle/src/server/api/image.ts b/packages/chronicle/src/server/api/image.ts index 0ba78120..c5fdc68b 100644 --- a/packages/chronicle/src/server/api/image.ts +++ b/packages/chronicle/src/server/api/image.ts @@ -8,8 +8,7 @@ import { StatusCodes } from 'http-status-codes' import { safePath } from '@/server/utils/safe-path' import { ALLOWED_WIDTHS, ALLOWED_QUALITIES, DEFAULT_QUALITY } from '@/lib/image-utils' -const STORAGE_KEY = 'image-cache' -const MAX_CACHE_ENTRIES = 500 +export const STORAGE_KEY = 'image-cache' const inflight = new Map>() @@ -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 => { @@ -116,16 +121,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 })() diff --git a/packages/chronicle/src/server/plugins/image-warmup.ts b/packages/chronicle/src/server/plugins/image-warmup.ts new file mode 100644 index 00000000..ed6088ac --- /dev/null +++ b/packages/chronicle/src/server/plugins/image-warmup.ts @@ -0,0 +1,65 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { definePlugin } from 'nitro'; +import { useStorage } from 'nitro/storage'; +import { STORAGE_KEY, cacheKey, optimizeImage } from '@/server/api/image'; +import { DEFAULT_WIDTH, DEFAULT_QUALITY, isLocalImage, isSvg } from '@/lib/image-utils'; +import { safePath } from '@/server/utils/safe-path'; + +const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp']); + +async function findImages(dir: string): Promise { + const images: string[] = []; + const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory() || entry.isSymbolicLink()) { + const stat = await fs.stat(fullPath).catch(() => null); + if (stat?.isDirectory()) { + images.push(...await findImages(fullPath)); + } + } else if (IMAGE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) { + images.push(fullPath); + } + } + return images; +} + +export default definePlugin(() => { + return { + name: 'image-warmup', + async setup(nitroApp) { + nitroApp.hooks.hookOnce('afterResponse', async () => { + const contentDir = __CHRONICLE_CONTENT_DIR__; + const storage = useStorage(STORAGE_KEY); + const format = 'webp' as const; + const w = DEFAULT_WIDTH; + const q = DEFAULT_QUALITY; + + const images = await findImages(contentDir); + let warmed = 0; + + for (const filePath of images) { + const relativePath = path.relative(contentDir, filePath); + const url = `/_content/${relativePath.split(path.sep).join('/')}`; + const key = cacheKey(url, w, q, format); + + 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 images + } + } + + if (warmed > 0) { + console.log(`[image-warmup] cached ${warmed} images as webp@${w}w`); + } + }); + }, + }; +}); From 6a40218e95c575679629b7a439478b392638c1d8 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Wed, 27 May 2026 07:05:53 +0530 Subject: [PATCH 2/5] refactor: move warmup into image.ts, use getPages instead of fs walk Delete separate image-warmup plugin. Warmup now uses getPages() + getPageImages() from source.ts (images already collected by remark plugin). Triggered from ready probe alongside search index. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/chronicle/src/server/api/image.ts | 40 +++++++++++- packages/chronicle/src/server/api/ready.ts | 2 + .../src/server/plugins/image-warmup.ts | 65 ------------------- 3 files changed, 41 insertions(+), 66 deletions(-) delete mode 100644 packages/chronicle/src/server/plugins/image-warmup.ts diff --git a/packages/chronicle/src/server/api/image.ts b/packages/chronicle/src/server/api/image.ts index c5fdc68b..6a225a9f 100644 --- a/packages/chronicle/src/server/api/image.ts +++ b/packages/chronicle/src/server/api/image.ts @@ -6,7 +6,7 @@ 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' export const STORAGE_KEY = 'image-cache' @@ -151,3 +151,41 @@ export default defineHandler(async event => { inflight.delete(key) } }) + +export async function warmupImageCache() { + const { getPages, getPageImages } = await import('@/lib/source'); + 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 key = cacheKey(url, w, q, format); + const cached = await storage.getItemRaw(key); + if (cached) continue; + + const relativePath = url.replace(/^\/_content\//, ''); + const filePath = safePath(contentDir, `/${relativePath}`); + if (!filePath) 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 }), { diff --git a/packages/chronicle/src/server/plugins/image-warmup.ts b/packages/chronicle/src/server/plugins/image-warmup.ts deleted file mode 100644 index ed6088ac..00000000 --- a/packages/chronicle/src/server/plugins/image-warmup.ts +++ /dev/null @@ -1,65 +0,0 @@ -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { definePlugin } from 'nitro'; -import { useStorage } from 'nitro/storage'; -import { STORAGE_KEY, cacheKey, optimizeImage } from '@/server/api/image'; -import { DEFAULT_WIDTH, DEFAULT_QUALITY, isLocalImage, isSvg } from '@/lib/image-utils'; -import { safePath } from '@/server/utils/safe-path'; - -const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp']); - -async function findImages(dir: string): Promise { - const images: string[] = []; - const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []); - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory() || entry.isSymbolicLink()) { - const stat = await fs.stat(fullPath).catch(() => null); - if (stat?.isDirectory()) { - images.push(...await findImages(fullPath)); - } - } else if (IMAGE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) { - images.push(fullPath); - } - } - return images; -} - -export default definePlugin(() => { - return { - name: 'image-warmup', - async setup(nitroApp) { - nitroApp.hooks.hookOnce('afterResponse', async () => { - const contentDir = __CHRONICLE_CONTENT_DIR__; - const storage = useStorage(STORAGE_KEY); - const format = 'webp' as const; - const w = DEFAULT_WIDTH; - const q = DEFAULT_QUALITY; - - const images = await findImages(contentDir); - let warmed = 0; - - for (const filePath of images) { - const relativePath = path.relative(contentDir, filePath); - const url = `/_content/${relativePath.split(path.sep).join('/')}`; - const key = cacheKey(url, w, q, format); - - 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 images - } - } - - if (warmed > 0) { - console.log(`[image-warmup] cached ${warmed} images as webp@${w}w`); - } - }); - }, - }; -}); From 518d5dc08dd812c845b54914d0a4c33f8a3e939f Mon Sep 17 00:00:00 2001 From: Rishabh Date: Wed, 27 May 2026 07:10:13 +0530 Subject: [PATCH 3/5] feat: support query params for image width and quality in markdown Authors can now specify w and q on image URLs: ![alt](photo.png?w=640&q=90) Params are parsed from the URL, stripped before file resolution, and passed through to the optimization endpoint. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/lib/remark-resolve-images.ts | 57 ++++++++++++++----- 1 file changed, 44 insertions(+), 13 deletions(-) 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 From 97ee33d7c8e7d429f05b460c44606274afe975e9 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Wed, 27 May 2026 07:11:25 +0530 Subject: [PATCH 4/5] docs: add image optimization documentation Covers format negotiation, query params (w/q), allowed values, API endpoint, caching, and warmup behavior. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/content/docs/image-optimization.mdx | 114 +++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 docs/content/docs/image-optimization.mdx 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. From 6485f0569e526211a0652a13c70951db663b1a0c Mon Sep 17 00:00:00 2001 From: Rishabh Date: Wed, 27 May 2026 07:19:36 +0530 Subject: [PATCH 5/5] fix: include file mtime in cache key and suppress biome lint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cache key now includes source file mtime — replacing an image invalidates stale cache entries. Added biome-ignore for useStorage in warmupImageCache. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../chronicle/src/server/api/image.test.ts | 6 +++++ packages/chronicle/src/server/api/image.ts | 22 +++++++++++++------ 2 files changed, 21 insertions(+), 7 deletions(-) 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 6a225a9f..46b9bff5 100644 --- a/packages/chronicle/src/server/api/image.ts +++ b/packages/chronicle/src/server/api/image.ts @@ -28,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}` } @@ -95,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) { @@ -154,6 +158,7 @@ export default defineHandler(async event => { 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; @@ -169,14 +174,17 @@ export async function warmupImageCache() { if (!isLocalImage(url) || isSvg(url) || seen.has(url)) continue; seen.add(url); - const key = cacheKey(url, w, q, format); - const cached = await storage.getItemRaw(key); - if (cached) continue; - 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);