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 {alt; + if (optimize) { + const { base, version } = splitVersion(src); + const imgSrc = buildOptimizedUrl(base, DEFAULT_WIDTH, undefined, version); + return {alt; + } + + return {alt; } 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('![alt](./img.png)', { 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('![alt](./img.png)', { 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('![alt](/logo.svg)', { optimize: true }); + expect(firstImageUrl(tree)).toBe(`/_content/logo.svg?v=${SVG_HASH}`); + }); + + test('leaves external URLs untouched', async () => { + const { tree } = await transform('![alt](https://example.com/img.png)', { 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('![alt](./missing.png)', { optimize: false }); + expect(firstImageUrl(tree)).toBe('/_content/docs/missing.png'); + }); + + test('stamps versions on img tags inside raw html', async () => { + const { tree } = await transform('x', { 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('![alt](./img.png)\n\n![alt](/logo.svg)', { 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('![alt](./missing.png)', { 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('![alt](./img.png?w=640&q=90)', { 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('![alt](../../secret.png)', { 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('![alt](./my%20image.png)', { 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 +}