Skip to content

Commit 294cf4d

Browse files
authored
Add asset management (#133)
* Add asset management Introduce a new 'images' content collection backed by a JSON manifest defining image metadata to support content-addressable image handling. Add a Bun script to scan local public/assets for images, compute SHA-256 hashes, upload unique images to Cloudflare R2 storage keyed by content hash, and generate a manifest mapping original image paths to their R2 URLs. Basically my simple CDN. In dev it’ll download encountered images that aren’t present locally. * Update manifest loading
1 parent 028808f commit 294cf4d

7 files changed

Lines changed: 567 additions & 2 deletions

File tree

.gitignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,11 @@ pnpm-debug.log*
2222

2323
# jetbrains setting folder
2424
.idea/
25+
26+
# local asset files (uploaded to R2, referenced via manifest)
27+
public/assets/*
28+
!public/assets/index.html
29+
30+
# wrangler temporary files
31+
.wrangler/
32+
*.png

mise.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ run = "vitest --ui"
3636
[tasks.prefix-codes]
3737
run = "bun scripts/prefix-codes.ts"
3838

39+
[tasks.upload-images]
40+
run = "bun scripts/upload-images.ts"
41+
description = "Upload images to R2 and update manifest"
42+
3943
[tasks.deploy]
4044
run = "wrangler deploy"
4145
depends = ["build"]

public/assets/index.html

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6+
<meta http-equiv="refresh" content="0; url=https://just-be.dev">
7+
<title>Redirecting...</title>
8+
</head>
9+
<body>
10+
<p>Redirecting to <a href="https://just-be.dev">just-be.dev</a>...</p>
11+
<script>
12+
window.location.href = "https://just-be.dev";
13+
</script>
14+
</body>
15+
</html>

scripts/upload-images.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
#!/usr/bin/env bun
2+
3+
/**
4+
* Upload images from public/assets to Cloudflare R2
5+
*
6+
* This script:
7+
* - Scans public/assets for images (png, jpg, jpeg, webp, gif, svg)
8+
* - Generates SHA-256 hash for each image
9+
* - Uploads to R2 with content-addressable key (hash.ext)
10+
* - Creates manifest mapping original paths to R2 URLs
11+
* - Skips uploads for images already in R2 (idempotent)
12+
*
13+
* Path structure:
14+
* - Local: public/assets/talks/codegen-in-rust/slide-1.png
15+
* - Manifest key: talks/codegen-in-rust/slide-1.png (relative to public/assets)
16+
* - R2 key: a3f2c1d4e5...f6.png (content hash)
17+
* - R2 URL: https://assets.just-be.dev/a3f2c1d4e5...f6.png
18+
*
19+
* Build-time resolution (src/utils/images.ts):
20+
* 1. Check if file exists locally → use /assets/{path}
21+
* 2. Otherwise, use R2 URL from manifest
22+
* This allows dev with local files, but falls back to R2 if files aren't present.
23+
*/
24+
25+
import { $ } from "bun";
26+
import { createHash } from "crypto";
27+
import { readdir, readFile, writeFile } from "fs/promises";
28+
import { join, relative } from "path";
29+
30+
const BUCKET_NAME = "just-be-dev-assets";
31+
const CUSTOM_DOMAIN = "https://assets.just-be.dev";
32+
const IMAGE_DIR = join(import.meta.dir, "../public/assets");
33+
const MANIFEST_PATH = join(import.meta.dir, "../src/content/image-manifest.json");
34+
35+
interface ImageManifest {
36+
version: string;
37+
images: Record<string, {
38+
hash: string;
39+
size: number;
40+
ext: string;
41+
}>;
42+
}
43+
44+
async function hashFile(filePath: string): Promise<string> {
45+
const content = await readFile(filePath);
46+
return createHash("sha256").update(content).digest("hex");
47+
}
48+
49+
async function findImages(dir: string): Promise<string[]> {
50+
const imageExtensions = ['.png', '.jpg', '.jpeg', '.webp', '.gif', '.svg'];
51+
const results: string[] = [];
52+
53+
async function walk(currentDir: string): Promise<void> {
54+
const entries = await readdir(currentDir, { withFileTypes: true });
55+
for (const entry of entries) {
56+
const fullPath = join(currentDir, entry.name);
57+
if (entry.isDirectory()) {
58+
await walk(fullPath);
59+
} else if (entry.isFile()) {
60+
const ext = entry.name.toLowerCase().slice(entry.name.lastIndexOf('.'));
61+
if (imageExtensions.includes(ext)) {
62+
results.push(fullPath);
63+
}
64+
}
65+
}
66+
}
67+
68+
await walk(dir);
69+
return results;
70+
}
71+
72+
async function checkR2Exists(key: string): Promise<boolean> {
73+
// Check if object exists via HTTP HEAD request to the CDN URL
74+
try {
75+
const response = await fetch(`${CUSTOM_DOMAIN}/${key}`, { method: "HEAD" });
76+
return response.ok;
77+
} catch {
78+
return false;
79+
}
80+
}
81+
82+
async function uploadToR2(localPath: string, key: string): Promise<void> {
83+
await $`wrangler r2 object put ${BUCKET_NAME}/${key} --file ${localPath} --remote`;
84+
}
85+
86+
async function uploadImages() {
87+
console.log(`Scanning images in: ${IMAGE_DIR}\n`);
88+
89+
const imagePaths = await findImages(IMAGE_DIR);
90+
console.log(`Found ${imagePaths.length} images\n`);
91+
92+
const manifest: ImageManifest = {
93+
version: "1.0",
94+
images: {},
95+
};
96+
97+
let uploadCount = 0;
98+
let skipCount = 0;
99+
100+
for (const imagePath of imagePaths) {
101+
const hash = await hashFile(imagePath);
102+
const ext = imagePath.split(".").pop()!;
103+
const key = `${hash}.${ext}`;
104+
105+
// Store path relative to public/assets (e.g., "talks/codegen-in-rust/slide-1.png")
106+
const originalPath = relative(join(import.meta.dir, "../public/assets"), imagePath);
107+
108+
const exists = await checkR2Exists(key);
109+
110+
if (exists) {
111+
console.log(`⏭ ${originalPath}${key} (already exists)`);
112+
skipCount++;
113+
} else {
114+
console.log(`⬆ ${originalPath}${key}`);
115+
await uploadToR2(imagePath, key);
116+
uploadCount++;
117+
}
118+
119+
const stats = await Bun.file(imagePath).stat();
120+
manifest.images[originalPath] = {
121+
hash,
122+
size: stats.size,
123+
ext,
124+
};
125+
}
126+
127+
await writeFile(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + "\n");
128+
console.log(`\n✅ Manifest written to ${MANIFEST_PATH}`);
129+
console.log(`\nUploaded: ${uploadCount}, Skipped: ${skipCount}, Total: ${imagePaths.length}`);
130+
}
131+
132+
uploadImages().catch((error) => {
133+
console.error("Fatal error:", error);
134+
process.exit(1);
135+
});

src/content.config.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { defineCollection, z } from "astro:content";
2-
import { glob } from "astro/loaders";
2+
import { glob, file } from "astro/loaders";
33
import { feedLoader } from "@ascorbic/feed-loader";
4+
import { readFileSync } from "node:fs";
5+
import { join } from "node:path";
46

57
const blog = defineCollection({
68
loader: glob({ base: "./src/content/blog", pattern: "**/*.{md,mdx}" }),
@@ -61,4 +63,32 @@ const devtools = defineCollection({
6163
}),
6264
});
6365

64-
export const collections = { blog, projects, research, pages, devtools, talks };
66+
const images = defineCollection({
67+
loader: {
68+
name: "image-manifest-loader",
69+
load: ({ store, logger }) => {
70+
logger.info("Loading image manifest");
71+
72+
const manifestPath = join(process.cwd(), "src/content/image-manifest.json");
73+
const manifestContent = readFileSync(manifestPath, "utf-8");
74+
const manifest = JSON.parse(manifestContent);
75+
76+
// Transform {images: {path: {metadata}}} to array of {id, ...metadata}
77+
for (const [path, metadata] of Object.entries(manifest.images)) {
78+
store.set({
79+
id: path,
80+
data: metadata,
81+
});
82+
}
83+
84+
logger.info(`Loaded ${Object.keys(manifest.images).length} images`);
85+
},
86+
},
87+
schema: z.object({
88+
hash: z.string(),
89+
size: z.number(),
90+
ext: z.string(),
91+
}),
92+
});
93+
94+
export const collections = { blog, projects, research, pages, devtools, talks, images };

0 commit comments

Comments
 (0)