feat: added images to atproto uploads eb358c54
Steve · 2026-01-15 19:18 4 file(s) · +102 −5
bun.lock +5 −0
30 30
        "@biomejs/biome": "2.1.1",
31 31
        "@tailwindcss/aspect-ratio": "^0.4.2",
32 32
        "@tailwindcss/typography": "^0.5.8",
33 +
        "@types/bun": "^1.3.6",
33 34
        "@types/markdown-it": "^14.1.2",
34 35
        "@types/sanitize-html": "^2.16.0",
35 36
        "autoprefixer": "^10.4.13",
388 389
389 390
    "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
390 391
392 +
    "@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="],
393 +
391 394
    "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
392 395
393 396
    "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
485 488
    "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
486 489
487 490
    "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
491 +
492 +
    "bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
488 493
489 494
    "camelcase": ["camelcase@8.0.0", "", {}, "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA=="],
490 495
packages/client/package.json +1 −0
20 20
		"@biomejs/biome": "2.1.1",
21 21
		"@tailwindcss/aspect-ratio": "^0.4.2",
22 22
		"@tailwindcss/typography": "^0.5.8",
23 +
		"@types/bun": "^1.3.6",
23 24
		"@types/markdown-it": "^14.1.2",
24 25
		"@types/sanitize-html": "^2.16.0",
25 26
		"autoprefixer": "^10.4.13",
packages/client/scripts/publish-to-atproto.ts +88 −4
20 20
	atUri?: string;
21 21
}
22 22
23 +
interface BlobRef {
24 +
	$link: string;
25 +
}
26 +
27 +
interface BlobObject {
28 +
	$type: "blob";
29 +
	ref: BlobRef;
30 +
	mimeType: string;
31 +
	size: number;
32 +
}
33 +
23 34
interface BlogPost {
24 35
	filePath: string;
25 36
	slug: string;
148 159
	return `${beforeEnd}atUri: "${atUri}"\n${afterEnd}`;
149 160
}
150 161
162 +
async function uploadImageToPDS(
163 +
	agent: AtpAgent,
164 +
	imagePath: string,
165 +
): Promise<BlobObject | undefined> {
166 +
	if (!imagePath || !fs.existsSync(imagePath)) {
167 +
		return undefined;
168 +
	}
169 +
170 +
	try {
171 +
		// Use Bun's built-in file type detection
172 +
		const file = Bun.file(imagePath);
173 +
		const imageBuffer = await file.arrayBuffer();
174 +
		const mimeType = file.type || "application/octet-stream";
175 +
176 +
		const response = await agent.com.atproto.repo.uploadBlob(
177 +
			new Uint8Array(imageBuffer),
178 +
			{
179 +
				encoding: mimeType,
180 +
			},
181 +
		);
182 +
183 +
		console.log(response);
184 +
185 +
		return {
186 +
			$type: "blob",
187 +
			ref: {
188 +
				$link: response.data.blob.ref.toString(),
189 +
			},
190 +
			mimeType,
191 +
			size: imageBuffer.byteLength,
192 +
		};
193 +
	} catch (error) {
194 +
		console.error(`Error uploading image ${imagePath}:`, error);
195 +
		return undefined;
196 +
	}
197 +
}
198 +
199 +
function resolveImagePath(ogImage: string): string {
200 +
	// Extract just the filename from the ogImage path
201 +
	const filename = path.basename(ogImage);
202 +
203 +
	// All blog images are stored in packages/client/public/blog-images/other
204 +
	const imagePath = path.join(import.meta.dir, "../public/blog-images/other", filename);
205 +
206 +
	if (!fs.existsSync(imagePath)) {
207 +
		throw new Error(`Image not found: ${imagePath}`);
208 +
	}
209 +
210 +
	return imagePath;
211 +
}
212 +
151 213
async function createAtProtoDocument(
152 214
	agent: AtpAgent,
153 215
	post: BlogPost,
163 225
	// Parse the publish date
164 226
	const publishDate = new Date(post.frontmatter.publishDate);
165 227
228 +
	// Handle cover image upload
229 +
	let coverImage: BlobObject | undefined;
230 +
	if (post.frontmatter.ogImage) {
231 +
		const imagePath = resolveImagePath(post.frontmatter.ogImage);
232 +
		console.log(`  - Uploading cover image: ${imagePath}`);
233 +
		coverImage = await uploadImageToPDS(agent, imagePath);
234 +
		if (coverImage) {
235 +
			console.log(`  - Uploaded image blob: ${coverImage.ref.$link}`);
236 +
		}
237 +
	}
238 +
166 239
	const record = {
167 240
		$type: "site.standard.document",
168 241
		title: post.frontmatter.title,
169 242
		site: PUBLICATION_URI,
170 243
		path: postPath,
171 244
		content: markdownContent,
172 -
		coverImage: post.frontmatter.ogImage,
245 +
		coverImage,
173 246
		textContent: textContent.slice(0, 10000), // Limit text content length
174 247
		publishedAt: publishDate.toISOString(),
175 248
		canonicalUrl: `${SITE_URL}${postPath}`,
190 263
	post: BlogPost,
191 264
	atUri: string,
192 265
): Promise<void> {
193 -
	// Parse the atUri to get the repo, collection, and rkey
266 +
	// Parse the atUri to get the collection and rkey
194 267
	// Format: at://did:plc:xxx/collection/rkey
195 268
	const uriMatch = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
196 269
	if (!uriMatch) {
197 270
		throw new Error(`Invalid atUri format: ${atUri}`);
198 271
	}
199 272
200 -
	const [, repo, collection, rkey] = uriMatch;
273 +
	const [, , collection, rkey] = uriMatch;
201 274
202 275
	const postPath = `/posts/${post.slug}`;
203 276
	const markdownContent = {
210 283
	// Parse the publish date
211 284
	const publishDate = new Date(post.frontmatter.publishDate);
212 285
286 +
	// Handle cover image upload
287 +
	let coverImage: BlobObject | undefined;
288 +
	if (post.frontmatter.ogImage) {
289 +
		const imagePath = resolveImagePath(post.frontmatter.ogImage);
290 +
		console.log(`  - Uploading cover image: ${imagePath}`);
291 +
		coverImage = await uploadImageToPDS(agent, imagePath);
292 +
		if (coverImage) {
293 +
			console.log(`  - Uploaded image blob: ${coverImage.ref.$link}`);
294 +
		}
295 +
	}
296 +
213 297
	const record = {
214 298
		$type: "site.standard.document",
215 299
		title: post.frontmatter.title,
216 300
		site: PUBLICATION_URI,
217 301
		path: postPath,
218 302
		content: markdownContent,
219 -
		coverImage: post.frontmatter.ogImage,
303 +
		coverImage,
220 304
		textContent: textContent.slice(0, 10000), // Limit text content length
221 305
		publishedAt: publishDate.toISOString(),
222 306
		canonicalUrl: `${SITE_URL}${postPath}`,
packages/server/src/routes/now.ts +8 −1
151 151
				site: "at://did:plc:ia2zdnhjaokf5lazhxrmj6eu/site.standard.publication/3mbykzswhqc2x",
152 152
				...(normalizedPath && { path: normalizedPath.trim() }),
153 153
				content: markdownContent,
154 -
				coverImage: "https://stevedylan.dev/icon.png",
154 +
				coverImage: {
155 +
					type: "blob",
156 +
					ref: {
157 +
						link: "bafkreibuxyp2gth3igqik7fxu4cm4nducetgp67hhlx36bwahgnuw4xmoa",
158 +
					},
159 +
					mimeType: "image/png",
160 +
					size: 2522,
161 +
				},
155 162
				textContent: textContent,
156 163
				publishedAt: new Date().toISOString(),
157 164
			},