Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/chronicle/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
27 changes: 17 additions & 10 deletions packages/chronicle/src/cli/commands/static-generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
})),
});
}
}
Expand Down Expand Up @@ -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 });
Expand Down
15 changes: 8 additions & 7 deletions packages/chronicle/src/components/mdx/image.tsx
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -22,6 +18,11 @@ export function MDXImage({ src, alt, ...props }: MDXImageProps) {
);
}

const imgSrc = optimize ? buildOptimizedUrl(src, DEFAULT_WIDTH) : src;
return <img src={imgSrc} alt={alt ?? ''} loading='lazy' decoding='async' {...props} />;
if (optimize) {
const { base, version } = splitVersion(src);
const imgSrc = buildOptimizedUrl(base, DEFAULT_WIDTH, undefined, version);
return <img src={imgSrc} alt={alt ?? ''} loading='lazy' decoding='async' {...props} />;
}

return <img src={src} alt={alt ?? ''} loading='lazy' decoding='async' {...props} />;
}
67 changes: 67 additions & 0 deletions packages/chronicle/src/lib/asset-version.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
51 changes: 51 additions & 0 deletions packages/chronicle/src/lib/asset-version.ts
Original file line number Diff line number Diff line change
@@ -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<string, CacheEntry>();

/**
* 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<string | null> {
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;
}
}
51 changes: 51 additions & 0 deletions packages/chronicle/src/lib/image-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
isLocalImage,
isSvg,
buildOptimizedUrl,
webpUrl,
splitVersion,
ALLOWED_WIDTHS,
DEFAULT_WIDTH,
DEFAULT_QUALITY,
Expand Down Expand Up @@ -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);
Expand Down
19 changes: 17 additions & 2 deletions packages/chronicle/src/lib/image-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
11 changes: 9 additions & 2 deletions packages/chronicle/src/lib/page-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>;

Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading