diff --git a/bun.lock b/bun.lock
index 6ad64e09..edaccb9e 100644
--- a/bun.lock
+++ b/bun.lock
@@ -20,6 +20,7 @@
},
"dependencies": {
"@analytics/google-analytics": "^1.1.0",
+ "@base-ui/react": "1.4.1",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/state": "^6.5.4",
"@codemirror/theme-one-dark": "^6.1.3",
diff --git a/packages/chronicle/package.json b/packages/chronicle/package.json
index 69d5f522..5b4b0226 100644
--- a/packages/chronicle/package.json
+++ b/packages/chronicle/package.json
@@ -38,6 +38,7 @@
},
"dependencies": {
"@analytics/google-analytics": "^1.1.0",
+ "@base-ui/react": "1.4.1",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/state": "^6.5.4",
"@codemirror/theme-one-dark": "^6.1.3",
diff --git a/packages/chronicle/src/cli/commands/static-generate.ts b/packages/chronicle/src/cli/commands/static-generate.ts
index 47f7898f..eb29bd59 100644
--- a/packages/chronicle/src/cli/commands/static-generate.ts
+++ b/packages/chronicle/src/cli/commands/static-generate.ts
@@ -19,7 +19,8 @@ import {
import { loadApiSpec, resolveDocument, type ApiSpec } from '@/lib/openapi';
import { buildApiRoutes, getSpecSlug } from '@/lib/api-routes';
import { buildLlmsTxt, type LlmsPage } from '@/lib/llms';
-import { DEFAULT_WIDTH, DEFAULT_QUALITY, isLocalImage, isSvg } from '@/lib/image-utils';
+import { DEFAULT_WIDTH, DEFAULT_QUALITY, isLocalImage, isSvg, splitVersion } from '@/lib/image-utils';
+import { getAssetVersion } from '@/lib/asset-version';
import type { VersionContext } from '@/lib/version-source';
import type { Frontmatter, PageNavLink } from '@/types';
@@ -167,12 +168,17 @@ async function scanContentDir(
draft: fm.draft as boolean | undefined,
},
rawContent: content,
- images: extractImages(content).map(img => {
+ images: await Promise.all(extractImages(content).map(async img => {
if (img.startsWith('http')) return img;
- if (img.startsWith('/')) return `/_content${img}`;
- const dirRelative = path.dirname(normalizedRelative);
- return `/_content/${path.join(dirRelative, img).replace(/\\/g, '/')}`;
- }),
+ const relative = img.startsWith('/')
+ ? img.slice(1)
+ : path.join(path.dirname(normalizedRelative), img).replace(/\\/g, '/');
+ const url = `/_content/${relative}`;
+ const diskPath = path.join(contentMirrorRoot, relative);
+ if (!diskPath.startsWith(contentMirrorRoot + path.sep)) return url;
+ const version = await getAssetVersion(diskPath);
+ return version ? `${url}?v=${version}` : url;
+ })),
});
}
}
@@ -685,14 +691,15 @@ async function optimizeImages(
for (const page of pages) {
for (const imgUrl of page.images) {
- if (!isLocalImage(imgUrl) || seen.has(imgUrl)) continue;
- seen.add(imgUrl);
+ const { base } = splitVersion(imgUrl);
+ if (!isLocalImage(base) || seen.has(base)) continue;
+ seen.add(base);
- const relativePath = imgUrl.replace(/^\/_content\//, '');
+ const relativePath = base.replace(/^\/_content\//, '');
const srcPath = path.resolve(contentDir, relativePath);
if (!srcPath.startsWith(contentDir + path.sep) && srcPath !== contentDir) continue;
- if (isSvg(imgUrl)) {
+ if (isSvg(base)) {
const destPath = path.join(outputDir, '_content', relativePath);
try {
await fs.mkdir(path.dirname(destPath), { recursive: true });
diff --git a/packages/chronicle/src/components/mdx/image.tsx b/packages/chronicle/src/components/mdx/image.tsx
index a68d0aa8..fbf0ee30 100644
--- a/packages/chronicle/src/components/mdx/image.tsx
+++ b/packages/chronicle/src/components/mdx/image.tsx
@@ -1,13 +1,9 @@
import type { ComponentProps } from 'react';
-import { isLocalImage, isSvg, buildOptimizedUrl, DEFAULT_WIDTH } from '@/lib/image-utils';
+import { isLocalImage, isSvg, buildOptimizedUrl, webpUrl, splitVersion, DEFAULT_WIDTH } from '@/lib/image-utils';
import { isStaticMode } from '@/lib/static-mode';
type MDXImageProps = ComponentProps<'img'>;
-function webpUrl(src: string): string {
- return src.replace(/\.[^.]+$/, '.webp');
-}
-
export function MDXImage({ src, alt, ...props }: MDXImageProps) {
if (!src) return null;
@@ -22,6 +18,11 @@ export function MDXImage({ src, alt, ...props }: MDXImageProps) {
);
}
- const imgSrc = optimize ? buildOptimizedUrl(src, DEFAULT_WIDTH) : src;
- return
;
+ if (optimize) {
+ const { base, version } = splitVersion(src);
+ const imgSrc = buildOptimizedUrl(base, DEFAULT_WIDTH, undefined, version);
+ return
;
+ }
+
+ return
;
}
diff --git a/packages/chronicle/src/lib/asset-version.test.ts b/packages/chronicle/src/lib/asset-version.test.ts
new file mode 100644
index 00000000..e403a501
--- /dev/null
+++ b/packages/chronicle/src/lib/asset-version.test.ts
@@ -0,0 +1,67 @@
+import { describe, expect, test } from 'bun:test';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { hashContent, getAssetVersion } from './asset-version';
+
+function tempFile(content: string): string {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'chronicle-asset-'));
+ const file = path.join(dir, 'image.png');
+ fs.writeFileSync(file, content);
+ return file;
+}
+
+describe('hashContent', () => {
+ test('returns a short hex hash', () => {
+ expect(hashContent('hello')).toMatch(/^[0-9a-f]{10}$/);
+ });
+
+ test('is deterministic for same content', () => {
+ expect(hashContent('hello')).toBe(hashContent('hello'));
+ });
+
+ test('differs for different content', () => {
+ expect(hashContent('hello')).not.toBe(hashContent('world'));
+ });
+});
+
+describe('getAssetVersion', () => {
+ test('returns the content hash of a file', async () => {
+ const file = tempFile('image-bytes-v1');
+ expect(await getAssetVersion(file)).toBe(hashContent('image-bytes-v1'));
+ });
+
+ test('returns the same hash on repeated calls', async () => {
+ const file = tempFile('image-bytes-v1');
+ expect(await getAssetVersion(file)).toBe(await getAssetVersion(file));
+ });
+
+ test('returns a new hash when the file content changes', async () => {
+ const file = tempFile('image-bytes-v1');
+ const before = await getAssetVersion(file);
+ fs.writeFileSync(file, 'image-bytes-v2');
+ const future = new Date(Date.now() + 5000);
+ fs.utimesSync(file, future, future);
+ const after = await getAssetVersion(file);
+ expect(after).toBe(hashContent('image-bytes-v2'));
+ expect(after).not.toBe(before);
+ });
+
+ test('detects replace-by-rename with same size and mtime', async () => {
+ const file = tempFile('same-size-A');
+ const before = await getAssetVersion(file);
+ const mtime = fs.statSync(file).mtime;
+ const replacement = `${file}.tmp`;
+ fs.writeFileSync(replacement, 'same-size-B');
+ fs.utimesSync(replacement, mtime, mtime);
+ fs.renameSync(replacement, file);
+ fs.utimesSync(file, mtime, mtime);
+ const after = await getAssetVersion(file);
+ expect(after).toBe(hashContent('same-size-B'));
+ expect(after).not.toBe(before);
+ });
+
+ test('returns null for a missing file', async () => {
+ expect(await getAssetVersion('/nonexistent/path/image.png')).toBeNull();
+ });
+});
diff --git a/packages/chronicle/src/lib/asset-version.ts b/packages/chronicle/src/lib/asset-version.ts
new file mode 100644
index 00000000..7ddf7924
--- /dev/null
+++ b/packages/chronicle/src/lib/asset-version.ts
@@ -0,0 +1,51 @@
+import crypto from 'node:crypto';
+import fs from 'node:fs/promises';
+
+const VERSION_LENGTH = 10;
+
+export function hashContent(data: Buffer | string): string {
+ return crypto.createHash('sha256').update(data).digest('hex').slice(0, VERSION_LENGTH);
+}
+
+interface CacheEntry {
+ mtimeMs: number;
+ ctimeMs: number;
+ size: number;
+ ino: number;
+ hash: string;
+}
+
+const cache = new Map();
+
+/**
+ * Content hash for a file on disk, memoized on mtime + ctime + size + inode
+ * so repeated lookups don't re-read unchanged files. ctime and inode catch
+ * replace-by-rename and in-place rewrites that preserve mtime and size.
+ * Returns null if unreadable.
+ */
+export async function getAssetVersion(filePath: string): Promise {
+ try {
+ const stat = await fs.stat(filePath);
+ const cached = cache.get(filePath);
+ if (
+ cached &&
+ cached.mtimeMs === stat.mtimeMs &&
+ cached.ctimeMs === stat.ctimeMs &&
+ cached.size === stat.size &&
+ cached.ino === stat.ino
+ ) {
+ return cached.hash;
+ }
+ const hash = hashContent(await fs.readFile(filePath));
+ cache.set(filePath, {
+ mtimeMs: stat.mtimeMs,
+ ctimeMs: stat.ctimeMs,
+ size: stat.size,
+ ino: stat.ino,
+ hash,
+ });
+ return hash;
+ } catch {
+ return null;
+ }
+}
diff --git a/packages/chronicle/src/lib/image-utils.test.ts b/packages/chronicle/src/lib/image-utils.test.ts
index aa3a1801..ac9d903d 100644
--- a/packages/chronicle/src/lib/image-utils.test.ts
+++ b/packages/chronicle/src/lib/image-utils.test.ts
@@ -3,6 +3,8 @@ import {
isLocalImage,
isSvg,
buildOptimizedUrl,
+ webpUrl,
+ splitVersion,
ALLOWED_WIDTHS,
DEFAULT_WIDTH,
DEFAULT_QUALITY,
@@ -53,6 +55,55 @@ describe('buildOptimizedUrl', () => {
});
});
+describe('buildOptimizedUrl with version', () => {
+ test('appends v param when version is provided', () => {
+ const url = buildOptimizedUrl('/_content/img.png', 640, 75, 'abc123def0');
+ expect(url).toBe('/api/image?url=%2F_content%2Fimg.png&w=640&q=75&v=abc123def0');
+ });
+
+ test('omits v param when version is undefined', () => {
+ const url = buildOptimizedUrl('/_content/img.png', 640, 75);
+ expect(url).toBe('/api/image?url=%2F_content%2Fimg.png&w=640&q=75');
+ });
+});
+
+describe('webpUrl', () => {
+ test('replaces the extension with .webp', () => {
+ expect(webpUrl('/_content/photo.png')).toBe('/_content/photo.webp');
+ });
+
+ test('preserves the query string', () => {
+ expect(webpUrl('/_content/photo.png?v=abc123def0')).toBe('/_content/photo.webp?v=abc123def0');
+ });
+
+ test('handles dots in directory names', () => {
+ expect(webpUrl('/_content/v1.2/photo.jpeg?v=abc')).toBe('/_content/v1.2/photo.webp?v=abc');
+ });
+});
+
+describe('splitVersion', () => {
+ test('separates the base URL from the v param', () => {
+ expect(splitVersion('/_content/img.png?v=abc123')).toEqual({
+ base: '/_content/img.png',
+ version: 'abc123',
+ });
+ });
+
+ test('returns undefined version when there is no query', () => {
+ expect(splitVersion('/_content/img.png')).toEqual({
+ base: '/_content/img.png',
+ version: undefined,
+ });
+ });
+
+ test('returns undefined version when query has no v param', () => {
+ expect(splitVersion('/_content/img.png?w=640')).toEqual({
+ base: '/_content/img.png',
+ version: undefined,
+ });
+ });
+});
+
describe('buildOptimizedUrl with backslashes', () => {
test('backslashes in input are not double-encoded', () => {
const url = buildOptimizedUrl('/_content/docs/imgs\\screenshot.png', 640);
diff --git a/packages/chronicle/src/lib/image-utils.ts b/packages/chronicle/src/lib/image-utils.ts
index 9c22b18b..87efc3ff 100644
--- a/packages/chronicle/src/lib/image-utils.ts
+++ b/packages/chronicle/src/lib/image-utils.ts
@@ -11,8 +11,23 @@ export function isSvg(url: string): boolean {
return url.split('?')[0].endsWith('.svg');
}
-export function buildOptimizedUrl(url: string, width: number, quality = DEFAULT_QUALITY): string {
- return `/api/image?url=${encodeURIComponent(url)}&w=${width}&q=${quality}`;
+export function buildOptimizedUrl(url: string, width: number, quality = DEFAULT_QUALITY, version?: string): string {
+ const base = `/api/image?url=${encodeURIComponent(url)}&w=${width}&q=${quality}`;
+ return version ? `${base}&v=${version}` : base;
+}
+
+export function splitVersion(url: string): { base: string; version?: string } {
+ const qIdx = url.indexOf('?');
+ if (qIdx === -1) return { base: url, version: undefined };
+ const version = new URLSearchParams(url.slice(qIdx + 1)).get('v');
+ return { base: url.slice(0, qIdx), version: version ?? undefined };
+}
+
+export function webpUrl(url: string): string {
+ const qIdx = url.indexOf('?');
+ const path = qIdx === -1 ? url : url.slice(0, qIdx);
+ const query = qIdx === -1 ? '' : url.slice(qIdx);
+ return path.replace(/\.[^./]+$/, '.webp') + query;
}
export { ALLOWED_WIDTHS, ALLOWED_QUALITIES, DEFAULT_WIDTH, DEFAULT_QUALITY };
diff --git a/packages/chronicle/src/lib/page-context.tsx b/packages/chronicle/src/lib/page-context.tsx
index 92aaff33..37917e50 100644
--- a/packages/chronicle/src/lib/page-context.tsx
+++ b/packages/chronicle/src/lib/page-context.tsx
@@ -16,7 +16,7 @@ import type { VersionContext } from '@/lib/version-source';
import { LATEST_CONTEXT } from '@/lib/version-source';
import type { ChronicleConfig, Frontmatter, Page, PageNavLink, Root, TableOfContents } from '@/types';
import { queryClient } from '@/lib/preload';
-import { isLocalImage, isSvg, buildOptimizedUrl, DEFAULT_WIDTH } from '@/lib/image-utils';
+import { isLocalImage, isSvg, buildOptimizedUrl, splitVersion, webpUrl, DEFAULT_WIDTH } from '@/lib/image-utils';
export type MdxLoader = (relativePath: string) => Promise<{ content: ReactNode; toc: TableOfContents }>;
@@ -134,7 +134,14 @@ export function PageProvider({
if (data.images?.length) {
for (const src of data.images) {
const img = new Image();
- img.src = isLocalImage(src) && !isSvg(src) ? buildOptimizedUrl(src, DEFAULT_WIDTH) : src;
+ const { base, version } = splitVersion(src);
+ if (isLocalImage(base) && !isSvg(base)) {
+ img.src = isStaticMode()
+ ? webpUrl(src)
+ : buildOptimizedUrl(base, DEFAULT_WIDTH, undefined, version);
+ } else {
+ img.src = src;
+ }
}
}
const { content, toc } = await loadMdx(data.originalPath || data.relativePath);
diff --git a/packages/chronicle/src/lib/remark-resolve-images.test.ts b/packages/chronicle/src/lib/remark-resolve-images.test.ts
new file mode 100644
index 00000000..8f6ed628
--- /dev/null
+++ b/packages/chronicle/src/lib/remark-resolve-images.test.ts
@@ -0,0 +1,120 @@
+import { describe, expect, test } from 'bun:test';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { unified } from 'unified';
+import remarkParse from 'remark-parse';
+import type { Image, Html, Root } from 'mdast';
+import { visit } from 'unist-util-visit';
+import remarkResolveImages from './remark-resolve-images';
+import { hashContent } from './asset-version';
+
+const PNG_BYTES = 'fake-png-bytes';
+const SVG_BYTES = '';
+const PNG_HASH = hashContent(PNG_BYTES);
+const SVG_HASH = hashContent(SVG_BYTES);
+
+function setupContentDir(): string {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chronicle-remark-'));
+ const contentDir = path.join(root, 'content');
+ fs.mkdirSync(path.join(contentDir, 'docs'), { recursive: true });
+ fs.writeFileSync(path.join(contentDir, 'docs', 'img.png'), PNG_BYTES);
+ fs.writeFileSync(path.join(contentDir, 'logo.svg'), SVG_BYTES);
+ return contentDir;
+}
+
+async function transform(markdown: string, options?: { optimize?: boolean }, contentDir = setupContentDir()) {
+ const processor = unified().use(remarkParse).use(remarkResolveImages, options);
+ const file: { path: string; data: { images?: string[] } } = {
+ path: path.join(contentDir, 'docs', 'page.mdx'),
+ data: {},
+ };
+ const tree = processor.parse(markdown);
+ const transformed = (await processor.run(tree, file as never)) as unknown as Root;
+ return { tree: transformed, file };
+}
+
+function firstImageUrl(tree: Root): string {
+ let url = '';
+ visit(tree, 'image', (node: Image) => {
+ if (!url) url = node.url;
+ });
+ return url;
+}
+
+function firstHtmlValue(tree: Root): string {
+ let value = '';
+ visit(tree, 'html', (node: Html) => {
+ if (!value) value = node.value;
+ });
+ return value;
+}
+
+describe('remark-resolve-images version stamping', () => {
+ test('appends content hash to resolved local images when not optimizing', async () => {
+ const { tree } = await transform('', { optimize: false });
+ expect(firstImageUrl(tree)).toBe(`/_content/docs/img.png?v=${PNG_HASH}`);
+ });
+
+ test('threads content hash into optimized image URLs', async () => {
+ const { tree } = await transform('', { optimize: true });
+ expect(firstImageUrl(tree)).toBe(
+ `/api/image?url=%2F_content%2Fdocs%2Fimg.png&w=1024&q=75&v=${PNG_HASH}`
+ );
+ });
+
+ test('appends content hash to svg images even when optimizing', async () => {
+ const { tree } = await transform('', { optimize: true });
+ expect(firstImageUrl(tree)).toBe(`/_content/logo.svg?v=${SVG_HASH}`);
+ });
+
+ test('leaves external URLs untouched', async () => {
+ const { tree } = await transform('', { optimize: false });
+ expect(firstImageUrl(tree)).toBe('https://example.com/img.png');
+ });
+
+ test('omits version for missing files but still resolves the URL', async () => {
+ const { tree } = await transform('', { optimize: false });
+ expect(firstImageUrl(tree)).toBe('/_content/docs/missing.png');
+ });
+
+ test('stamps versions on img tags inside raw html', async () => {
+ const { tree } = await transform('
', { optimize: false });
+ expect(firstHtmlValue(tree)).toContain(`/_content/docs/img.png?v=${PNG_HASH}`);
+ });
+
+ test('collects versioned plain URLs in file.data.images', async () => {
+ const { file } = await transform('\n\n', { optimize: true });
+ expect(file.data.images).toEqual([
+ `/_content/docs/img.png?v=${PNG_HASH}`,
+ `/_content/logo.svg?v=${SVG_HASH}`,
+ ]);
+ });
+
+ test('collects missing files without a version', async () => {
+ const { file } = await transform('', { optimize: true });
+ expect(file.data.images).toEqual(['/_content/docs/missing.png']);
+ });
+
+ test('keeps sizing params alongside the version when optimizing', async () => {
+ const { tree } = await transform('', { optimize: true });
+ expect(firstImageUrl(tree)).toBe(
+ `/api/image?url=%2F_content%2Fdocs%2Fimg.png&w=640&q=90&v=${PNG_HASH}`
+ );
+ });
+
+ test('never hashes files outside the content root', async () => {
+ const contentDir = setupContentDir();
+ fs.writeFileSync(path.join(contentDir, '..', 'secret.png'), 'outside-bytes');
+ const { tree } = await transform('', { optimize: false }, contentDir);
+ expect(firstImageUrl(tree)).toBe('/_content/../secret.png');
+ expect(firstImageUrl(tree)).not.toContain('?v=');
+ });
+
+ test('resolves url-encoded filenames to the file on disk', async () => {
+ const contentDir = setupContentDir();
+ fs.writeFileSync(path.join(contentDir, 'docs', 'my image.png'), PNG_BYTES);
+ const { tree } = await transform('', { optimize: false }, contentDir);
+ expect(firstImageUrl(tree)).toBe(`/_content/docs/my%20image.png?v=${PNG_HASH}`);
+ });
+});
diff --git a/packages/chronicle/src/lib/remark-resolve-images.ts b/packages/chronicle/src/lib/remark-resolve-images.ts
index bc2acee2..62da79ac 100644
--- a/packages/chronicle/src/lib/remark-resolve-images.ts
+++ b/packages/chronicle/src/lib/remark-resolve-images.ts
@@ -6,6 +6,7 @@ import type { Element } from 'hast'
import type { MdxJsxFlowElement, MdxJsxTextElement, MdxJsxAttribute } from 'mdast-util-mdx-jsx'
import { MdxNodeType } from './mdx-utils'
import { isLocalImage, isSvg, buildOptimizedUrl, DEFAULT_WIDTH } from './image-utils'
+import { getAssetVersion } from './asset-version'
interface ImageParams {
w?: number
@@ -46,17 +47,19 @@ interface RemarkResolveImagesOptions {
optimize?: boolean
}
-function optimizeUrl(url: string, optimize: boolean): string {
+function finalizeUrl(url: string, optimize: boolean, version?: string): string {
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
+ if (optimize && isLocalImage(base) && !isSvg(base)) return buildOptimizedUrl(base, width, quality, version)
+ return version ? `${base}?v=${version}` : base
}
+const IMG_SRC_PATTERN = /(
]*\bsrc=["'])([^"']+)(["'])/gi
+
const remarkResolveImages: Plugin<[RemarkResolveImagesOptions?]> = (options) => {
const optimize = options?.optimize ?? true
- return (tree, file) => {
+ return async (tree, file) => {
const filePath = file.path
if (!filePath) return
@@ -65,6 +68,7 @@ const remarkResolveImages: Plugin<[RemarkResolveImagesOptions?]> = (options) =>
const relative = filePath.slice(contentIdx + '/content/'.length)
const dir = path.posix.dirname(relative)
+ const contentRoot = filePath.slice(0, contentIdx + '/content/'.length)
const seen = new Set()
const images: string[] = []
@@ -75,24 +79,38 @@ const remarkResolveImages: Plugin<[RemarkResolveImagesOptions?]> = (options) =>
images.push(src)
}
- visit(tree, 'image', (node: Image) => {
- if (!node.url) return
- const { base, params } = parseImageParams(node.url)
+ async function versionFor(resolved: string): Promise {
+ if (!isLocalImage(resolved)) return undefined
+ let rel: string
+ try {
+ rel = decodeURIComponent(resolved.slice('/_content/'.length))
+ } catch {
+ return undefined
+ }
+ const diskPath = path.join(contentRoot, rel)
+ if (!diskPath.startsWith(contentRoot)) return undefined
+ return (await getAssetVersion(diskPath)) ?? undefined
+ }
+
+ async function processUrl(src: string): Promise {
+ const { base, params } = parseImageParams(src)
const resolved = resolveUrl(base, dir)
- collect(resolved)
- node.url = optimizeUrl(appendParams(resolved, params), optimize)
+ const version = await versionFor(resolved)
+ collect(version ? `${resolved}?v=${version}` : resolved)
+ return finalizeUrl(appendParams(resolved, params), optimize, version)
+ }
+
+ const imageNodes: Image[] = []
+ const htmlNodes: Html[] = []
+ const srcAttrs: MdxJsxAttribute[] = []
+ const imgElements: Element[] = []
+
+ visit(tree, 'image', (node: Image) => {
+ if (node.url) imageNodes.push(node)
})
visit(tree, 'html', (node: Html) => {
- node.value = node.value.replace(
- /(
]*\bsrc=["'])([^"']+)(["'])/gi,
- (_, before, src, after) => {
- const { base, params } = parseImageParams(src)
- const resolved = resolveUrl(base, dir)
- collect(resolved)
- return `${before}${optimizeUrl(appendParams(resolved, params), optimize)}${after}`
- }
- )
+ htmlNodes.push(node)
})
visit(tree, (node) => {
@@ -100,23 +118,37 @@ const remarkResolveImages: Plugin<[RemarkResolveImagesOptions?]> = (options) =>
const jsx = node as MdxJsxFlowElement | MdxJsxTextElement
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
- const { base: jsxBase, params: jsxParams } = parseImageParams(srcAttr.value)
- const jsxResolved = resolveUrl(jsxBase, dir)
- collect(jsxResolved)
- srcAttr.value = optimizeUrl(appendParams(jsxResolved, jsxParams), optimize)
+ if (srcAttr?.value && typeof srcAttr.value === 'string') srcAttrs.push(srcAttr)
})
visit(tree, 'element', (node: Element) => {
if (node.tagName !== 'img') return
- const src = node.properties?.src
- if (typeof src !== 'string') return
- const { base: elBase, params: elParams } = parseImageParams(src)
- const elResolved = resolveUrl(elBase, dir)
- collect(elResolved)
- node.properties.src = optimizeUrl(appendParams(elResolved, elParams), optimize)
+ if (typeof node.properties?.src === 'string') imgElements.push(node)
})
+ for (const node of imageNodes) {
+ node.url = await processUrl(node.url)
+ }
+
+ for (const node of htmlNodes) {
+ let result = ''
+ let last = 0
+ for (const match of node.value.matchAll(IMG_SRC_PATTERN)) {
+ const [full, before, src, after] = match
+ result += node.value.slice(last, match.index) + before + (await processUrl(src)) + after
+ last = match.index + full.length
+ }
+ node.value = result + node.value.slice(last)
+ }
+
+ for (const attr of srcAttrs) {
+ attr.value = await processUrl(attr.value as string)
+ }
+
+ for (const node of imgElements) {
+ node.properties.src = await processUrl(node.properties.src as string)
+ }
+
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 76e10da2..f4ea601c 100644
--- a/packages/chronicle/src/server/api/image.test.ts
+++ b/packages/chronicle/src/server/api/image.test.ts
@@ -54,6 +54,18 @@ describe('cacheKey', () => {
expect(a).not.toBe(b);
});
+ test('returns different keys for different content hashes', () => {
+ const a = cacheKey('/_content/img.png', 640, 75, 'webp', 'aaaa111111');
+ const b = cacheKey('/_content/img.png', 640, 75, 'webp', 'bbbb222222');
+ expect(a).not.toBe(b);
+ });
+
+ test('returns same key for same content hash', () => {
+ const a = cacheKey('/_content/img.png', 640, 75, 'webp', 'aaaa111111');
+ const b = cacheKey('/_content/img.png', 640, 75, 'webp', 'aaaa111111');
+ expect(a).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 46b9bff5..311ebb2e 100644
--- a/packages/chronicle/src/server/api/image.ts
+++ b/packages/chronicle/src/server/api/image.ts
@@ -6,7 +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_WIDTH, DEFAULT_QUALITY, isLocalImage, isSvg } from '@/lib/image-utils'
+import { assetCacheControl, etagFor, isNotModified, REVALIDATE_CACHE } from '@/server/utils/asset-cache'
+import { getAssetVersion } from '@/lib/asset-version'
+import { ALLOWED_WIDTHS, ALLOWED_QUALITIES, DEFAULT_WIDTH, DEFAULT_QUALITY, isLocalImage, isSvg, splitVersion } from '@/lib/image-utils'
export const STORAGE_KEY = 'image-cache'
@@ -28,8 +30,8 @@ export const MIME: Record = {
'.webp': 'image/webp',
}
-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)
+export function cacheKey(url: string, w: number, q: number, format: OutputFormat, version?: string | number): string {
+ const hash = crypto.createHash('sha256').update(`${url}:${w}:${q}:${format}:${version ?? 0}`).digest('hex').slice(0, 16)
return `${hash}.${format}`
}
@@ -99,29 +101,34 @@ export default defineHandler(async event => {
if (!stat) {
throw new HTTPError({ status: StatusCodes.NOT_FOUND, message: 'Not Found' })
}
- const key = cacheKey(url, w, q, format, stat.mtimeMs)
+ const currentVersion = await getAssetVersion(filePath)
+ const key = cacheKey(url, w, q, format, currentVersion ?? stat.mtimeMs)
+
+ const requestedVersion = event.url.searchParams.get('v')
+ const cacheControl = import.meta.dev
+ ? REVALIDATE_CACHE
+ : assetCacheControl(requestedVersion, currentVersion)
+ const etag = etagFor(currentVersion ?? String(stat.mtimeMs), String(w), String(q), format)
+ const headers = {
+ 'Content-Type': contentType,
+ 'Cache-Control': cacheControl,
+ 'ETag': etag,
+ 'Vary': 'Accept',
+ }
+
+ if (isNotModified(event.headers.get('if-none-match'), etag)) {
+ return new Response(null, { status: StatusCodes.NOT_MODIFIED, headers })
+ }
const cached = await storage.getItemRaw(key)
if (cached) {
- return new Response(cached, {
- headers: {
- 'Content-Type': contentType,
- 'Cache-Control': 'public, max-age=31536000, immutable',
- 'Vary': 'Accept',
- },
- })
+ return new Response(cached, { headers })
}
const existing = inflight.get(key)
if (existing) {
const optimized = await existing
- return new Response(optimized, {
- headers: {
- 'Content-Type': contentType,
- 'Cache-Control': 'public, max-age=31536000, immutable',
- 'Vary': 'Accept',
- },
- })
+ return new Response(optimized, { headers })
}
const work = (async () => {
@@ -133,13 +140,7 @@ export default defineHandler(async event => {
inflight.set(key, work)
try {
const optimized = await work
- return new Response(optimized, {
- headers: {
- 'Content-Type': contentType,
- 'Cache-Control': 'public, max-age=31536000, immutable',
- 'Vary': 'Accept',
- },
- })
+ return new Response(optimized, { headers })
} catch {
const source = await fs.readFile(filePath).catch(() => null)
if (!source) {
@@ -148,7 +149,7 @@ export default defineHandler(async event => {
return new Response(source, {
headers: {
'Content-Type': 'application/octet-stream',
- 'Cache-Control': 'public, max-age=86400',
+ 'Cache-Control': REVALIDATE_CACHE,
},
})
} finally {
@@ -171,17 +172,18 @@ export async function warmupImageCache() {
for (const page of pages) {
for (const url of getPageImages(page)) {
- if (!isLocalImage(url) || isSvg(url) || seen.has(url)) continue;
- seen.add(url);
+ const { base } = splitVersion(url);
+ if (!isLocalImage(base) || isSvg(base) || seen.has(base)) continue;
+ seen.add(base);
- const relativePath = url.replace(/^\/_content\//, '');
+ const relativePath = base.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 key = cacheKey(base, w, q, format, (await getAssetVersion(filePath)) ?? stat.mtimeMs);
const cached = await storage.getItemRaw(key);
if (cached) continue;
diff --git a/packages/chronicle/src/server/entry-server.tsx b/packages/chronicle/src/server/entry-server.tsx
index e5c4c3bb..81038170 100644
--- a/packages/chronicle/src/server/entry-server.tsx
+++ b/packages/chronicle/src/server/entry-server.tsx
@@ -16,7 +16,7 @@ import { resolvePageAndSlug, resolveDocsRedirect, compactTree } from '@/lib/tree
import { filterPageTreeByVersion, filterPageTreeByContentDir } from '@/lib/version-source';
import { getActiveContentDir } from '@/lib/navigation';
import { getLatestContentRoots, getVersionContentRoots } from '@/lib/config';
-import { isLocalImage, isSvg, buildOptimizedUrl, DEFAULT_WIDTH } from '@/lib/image-utils';
+import { isLocalImage, isSvg, buildOptimizedUrl, splitVersion, DEFAULT_WIDTH } from '@/lib/image-utils';
import { QueryClientProvider, QueryClient } from '@tanstack/react-query';
import { useNitroApp } from 'nitro/app';
import { App } from './App';
@@ -155,7 +155,10 @@ export default {
))}
{[...new Set(pageImages)].map((src: string) => {
- const href = isLocalImage(src) && !isSvg(src) ? buildOptimizedUrl(src, DEFAULT_WIDTH) : src;
+ const { base, version } = splitVersion(src);
+ const href = isLocalImage(base) && !isSvg(base)
+ ? buildOptimizedUrl(base, DEFAULT_WIDTH, undefined, version)
+ : src;
return ;
})}
{isApiRoute && (
diff --git a/packages/chronicle/src/server/routes/_content/[...path].ts b/packages/chronicle/src/server/routes/_content/[...path].ts
index 9846f79a..1ab392f8 100644
--- a/packages/chronicle/src/server/routes/_content/[...path].ts
+++ b/packages/chronicle/src/server/routes/_content/[...path].ts
@@ -2,6 +2,9 @@ import fs from 'node:fs/promises';
import path from 'node:path';
import { defineHandler, HTTPError } from 'nitro';
import { safePath } from '@/server/utils/safe-path';
+import { assetCacheControl, etagFor, isNotModified, REVALIDATE_CACHE } from '@/server/utils/asset-cache';
+import { getAssetVersion } from '@/lib/asset-version';
+import { StatusCodes } from 'http-status-codes';
const MIME: Record = {
'.png': 'image/png',
@@ -15,7 +18,7 @@ const MIME: Record = {
};
export default defineHandler(async event => {
- const pathname = event.path?.replace(/^\/_content/, '') || '';
+ const pathname = event.url.pathname.replace(/^\/_content/, '') || '';
if (!pathname || pathname.endsWith('.md') || pathname.endsWith('.mdx')) {
throw new HTTPError({ status: 404, message: 'Not Found' });
}
@@ -25,16 +28,29 @@ export default defineHandler(async event => {
try { filePath = safePath(contentDir, pathname); } catch { /* malformed URL encoding */ }
if (!filePath) throw new HTTPError({ status: 404, message: 'Not Found' });
- const data = await fs.readFile(filePath).catch(() => null);
- if (!data) throw new HTTPError({ status: 404, message: 'Not Found' });
+ const currentVersion = await getAssetVersion(filePath);
+ if (!currentVersion) throw new HTTPError({ status: 404, message: 'Not Found' });
+
+ const requestedVersion = event.url.searchParams.get('v');
+ const cacheControl = import.meta.dev
+ ? REVALIDATE_CACHE
+ : assetCacheControl(requestedVersion, currentVersion);
+ const etag = etagFor(currentVersion);
const ext = path.extname(filePath).toLowerCase();
const contentType = MIME[ext] ?? 'application/octet-stream';
+ const headers = {
+ 'Content-Type': contentType,
+ 'Cache-Control': cacheControl,
+ 'ETag': etag,
+ };
+
+ if (isNotModified(event.headers.get('if-none-match'), etag)) {
+ return new Response(null, { status: StatusCodes.NOT_MODIFIED, headers });
+ }
+
+ const data = await fs.readFile(filePath).catch(() => null);
+ if (!data) throw new HTTPError({ status: 404, message: 'Not Found' });
- return new Response(data, {
- headers: {
- 'Content-Type': contentType,
- 'Cache-Control': 'public, max-age=86400',
- },
- });
+ return new Response(data, { headers });
});
diff --git a/packages/chronicle/src/server/utils/asset-cache.test.ts b/packages/chronicle/src/server/utils/asset-cache.test.ts
new file mode 100644
index 00000000..f2cbf23c
--- /dev/null
+++ b/packages/chronicle/src/server/utils/asset-cache.test.ts
@@ -0,0 +1,56 @@
+import { describe, expect, test } from 'bun:test';
+import { assetCacheControl, etagFor, isNotModified } from './asset-cache';
+
+describe('assetCacheControl', () => {
+ test('returns immutable caching when requested version matches current', () => {
+ expect(assetCacheControl('abc123', 'abc123')).toBe('public, max-age=31536000, immutable');
+ });
+
+ test('forces revalidation when no version is requested', () => {
+ expect(assetCacheControl(null, 'abc123')).toBe('public, no-cache');
+ });
+
+ test('forces revalidation when requested version is stale', () => {
+ expect(assetCacheControl('old111', 'abc123')).toBe('public, no-cache');
+ });
+
+ test('forces revalidation when current version is unknown', () => {
+ expect(assetCacheControl('abc123', null)).toBe('public, no-cache');
+ });
+});
+
+describe('etagFor', () => {
+ test('joins parts into a quoted etag', () => {
+ expect(etagFor('abc123', '640', 'webp')).toBe('"abc123-640-webp"');
+ });
+
+ test('single part etag', () => {
+ expect(etagFor('abc123')).toBe('"abc123"');
+ });
+});
+
+describe('isNotModified', () => {
+ test('matches an exact etag', () => {
+ expect(isNotModified('"abc123"', '"abc123"')).toBe(true);
+ });
+
+ test('does not match a different etag', () => {
+ expect(isNotModified('"old111"', '"abc123"')).toBe(false);
+ });
+
+ test('returns false when header is missing', () => {
+ expect(isNotModified(null, '"abc123"')).toBe(false);
+ });
+
+ test('matches within a list of etags', () => {
+ expect(isNotModified('"one", "abc123"', '"abc123"')).toBe(true);
+ });
+
+ test('matches weak validators', () => {
+ expect(isNotModified('W/"abc123"', '"abc123"')).toBe(true);
+ });
+
+ test('matches wildcard', () => {
+ expect(isNotModified('*', '"abc123"')).toBe(true);
+ });
+});
diff --git a/packages/chronicle/src/server/utils/asset-cache.ts b/packages/chronicle/src/server/utils/asset-cache.ts
new file mode 100644
index 00000000..9917dfbc
--- /dev/null
+++ b/packages/chronicle/src/server/utils/asset-cache.ts
@@ -0,0 +1,31 @@
+export const IMMUTABLE_CACHE = 'public, max-age=31536000, immutable';
+export const REVALIDATE_CACHE = 'public, no-cache';
+
+/**
+ * Assets referenced with a `?v=` that matches the file on disk
+ * are safe to cache forever — a changed file gets a new URL. Anything else
+ * (no version, stale version, unreadable file) must revalidate so replaced
+ * files with the same name are picked up without a hard refresh.
+ */
+export function assetCacheControl(
+ requestedVersion: string | null,
+ currentVersion: string | null,
+): string {
+ if (requestedVersion && currentVersion && requestedVersion === currentVersion) {
+ return IMMUTABLE_CACHE;
+ }
+ return REVALIDATE_CACHE;
+}
+
+export function etagFor(...parts: string[]): string {
+ return `"${parts.join('-')}"`;
+}
+
+export function isNotModified(ifNoneMatch: string | null, etag: string): boolean {
+ if (!ifNoneMatch) return false;
+ if (ifNoneMatch.trim() === '*') return true;
+ return ifNoneMatch
+ .split(',')
+ .map(value => value.trim().replace(/^W\//, ''))
+ .includes(etag);
+}
diff --git a/packages/chronicle/src/types/globals.d.ts b/packages/chronicle/src/types/globals.d.ts
index 42aade29..25fc6a99 100644
--- a/packages/chronicle/src/types/globals.d.ts
+++ b/packages/chronicle/src/types/globals.d.ts
@@ -3,3 +3,8 @@ declare const __CHRONICLE_CONTENT_DIR__: string
declare const __CHRONICLE_PROJECT_ROOT__: string
declare const __CHRONICLE_PACKAGE_ROOT__: string
declare const __CHRONICLE_CONFIG_RAW__: string | null
+
+// Nitro sets import.meta.dev to true in the dev server, false in builds
+interface ImportMeta {
+ readonly dev?: boolean
+}