chore: updated package.json 7f7f88bf
Steve · 2026-02-04 07:50 2 file(s) · +1 −404
packages/client/package.json +1 −1
10 10
		"preview": "astro preview",
11 11
		"format": "biome format --write src package.json",
12 12
		"deploy:orbiter": "orbiter deploy",
13 -
		"deploy": "bun run build && bun run publish:atproto && wrangler pages deploy dist",
13 +
		"deploy": "bun run build && wrangler pages deploy dist",
14 14
		"publish:atproto": "bun run scripts/publish-to-atproto.ts"
15 15
	},
16 16
	"devDependencies": {
packages/client/scripts/publish-to-atproto.ts (deleted) +0 −403
1 -
#!/usr/bin/env bun
2 -
3 -
import { AtpAgent } from "@atproto/api";
4 -
import * as fs from "fs";
5 -
import * as path from "path";
6 -
7 -
const CONTENT_DIR = path.join(import.meta.dir, "../src/content/post");
8 -
const PDS_URL = process.env.PDS_URL || "https://andromeda.social";
9 -
const PUBLICATION_URI =
10 -
	"at://did:plc:ia2zdnhjaokf5lazhxrmj6eu/site.standard.publication/3mbykzswhqc2x";
11 -
const SITE_URL = "https://stevedylan.dev";
12 -
13 -
interface PostFrontmatter {
14 -
	title: string;
15 -
	description: string;
16 -
	publishDate: string;
17 -
	tags?: string[];
18 -
	ogImage?: string;
19 -
	hidden?: boolean;
20 -
	atUri?: string;
21 -
}
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 -
34 -
interface BlogPost {
35 -
	filePath: string;
36 -
	slug: string;
37 -
	frontmatter: PostFrontmatter;
38 -
	content: string;
39 -
	rawContent: string;
40 -
}
41 -
42 -
function parseFrontmatter(content: string): {
43 -
	frontmatter: PostFrontmatter;
44 -
	body: string;
45 -
} {
46 -
	const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/;
47 -
	const match = content.match(frontmatterRegex);
48 -
49 -
	if (!match) {
50 -
		throw new Error("Could not parse frontmatter");
51 -
	}
52 -
53 -
	const frontmatterStr = match[1];
54 -
	const body = match[2];
55 -
56 -
	// Parse YAML-like frontmatter manually
57 -
	const frontmatter: Record<string, unknown> = {};
58 -
	const lines = frontmatterStr.split("\n");
59 -
60 -
	for (const line of lines) {
61 -
		const colonIndex = line.indexOf(":");
62 -
		if (colonIndex === -1) continue;
63 -
64 -
		const key = line.slice(0, colonIndex).trim();
65 -
		let value = line.slice(colonIndex + 1).trim();
66 -
67 -
		// Handle quoted strings
68 -
		if (
69 -
			(value.startsWith('"') && value.endsWith('"')) ||
70 -
			(value.startsWith("'") && value.endsWith("'"))
71 -
		) {
72 -
			value = value.slice(1, -1);
73 -
		}
74 -
75 -
		// Handle arrays (simple case for tags)
76 -
		if (value.startsWith("[") && value.endsWith("]")) {
77 -
			const arrayContent = value.slice(1, -1);
78 -
			frontmatter[key] = arrayContent
79 -
				.split(",")
80 -
				.map((item) => item.trim().replace(/^["']|["']$/g, ""));
81 -
		} else if (value === "true") {
82 -
			frontmatter[key] = true;
83 -
		} else if (value === "false") {
84 -
			frontmatter[key] = false;
85 -
		} else {
86 -
			frontmatter[key] = value;
87 -
		}
88 -
	}
89 -
90 -
	return { frontmatter: frontmatter as unknown as PostFrontmatter, body };
91 -
}
92 -
93 -
function getSlugFromFilename(filename: string): string {
94 -
	return filename
95 -
		.replace(/\.mdx?$/, "")
96 -
		.toLowerCase()
97 -
		.replace(/\s+/g, "-");
98 -
}
99 -
100 -
async function getRecentPosts(limit: number = 1): Promise<BlogPost[]> {
101 -
	const files = fs.readdirSync(CONTENT_DIR);
102 -
	const posts: BlogPost[] = [];
103 -
104 -
	for (const file of files) {
105 -
		if (!file.endsWith(".mdx") && !file.endsWith(".md")) continue;
106 -
107 -
		const filePath = path.join(CONTENT_DIR, file);
108 -
		const rawContent = fs.readFileSync(filePath, "utf-8");
109 -
110 -
		try {
111 -
			const { frontmatter, body } = parseFrontmatter(rawContent);
112 -
			const slug = getSlugFromFilename(file);
113 -
114 -
			posts.push({
115 -
				filePath,
116 -
				slug,
117 -
				frontmatter,
118 -
				content: body,
119 -
				rawContent,
120 -
			});
121 -
		} catch (error) {
122 -
			console.error(`Error parsing ${file}:`, error);
123 -
		}
124 -
	}
125 -
126 -
	// Sort by publish date (newest first)
127 -
	posts.sort((a, b) => {
128 -
		const dateA = new Date(a.frontmatter.publishDate);
129 -
		const dateB = new Date(b.frontmatter.publishDate);
130 -
		return dateB.getTime() - dateA.getTime();
131 -
	});
132 -
133 -
	return posts.slice(0, limit);
134 -
}
135 -
136 -
function stripMarkdownForText(markdown: string): string {
137 -
	return markdown
138 -
		.replace(/#{1,6}\s/g, "") // Remove headers
139 -
		.replace(/\*\*([^*]+)\*\*/g, "$1") // Remove bold
140 -
		.replace(/\*([^*]+)\*/g, "$1") // Remove italic
141 -
		.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // Remove links, keep text
142 -
		.replace(/`{3}[\s\S]*?`{3}/g, "") // Remove code blocks
143 -
		.replace(/`([^`]+)`/g, "$1") // Remove inline code formatting
144 -
		.replace(/!\[.*?\]\(.*?\)/g, "") // Remove images
145 -
		.replace(/\n{3,}/g, "\n\n") // Normalize multiple newlines
146 -
		.trim();
147 -
}
148 -
149 -
function updateFrontmatterWithAtUri(rawContent: string, atUri: string): string {
150 -
	// Insert atUri before the closing ---
151 -
	const frontmatterEndIndex = rawContent.indexOf("---", 4);
152 -
	if (frontmatterEndIndex === -1) {
153 -
		throw new Error("Could not find frontmatter end");
154 -
	}
155 -
156 -
	const beforeEnd = rawContent.slice(0, frontmatterEndIndex);
157 -
	const afterEnd = rawContent.slice(frontmatterEndIndex);
158 -
159 -
	return `${beforeEnd}atUri: "${atUri}"\n${afterEnd}`;
160 -
}
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(
205 -
		import.meta.dir,
206 -
		"../public/blog-images/other",
207 -
		filename,
208 -
	);
209 -
210 -
	if (!fs.existsSync(imagePath)) {
211 -
		throw new Error(`Image not found: ${imagePath}`);
212 -
	}
213 -
214 -
	return imagePath;
215 -
}
216 -
217 -
async function createAtProtoDocument(
218 -
	agent: AtpAgent,
219 -
	post: BlogPost,
220 -
): Promise<string> {
221 -
	const postPath = `/posts/${post.slug}`;
222 -
	const markdownContent = {
223 -
		$type: "site.standard.content.markdown",
224 -
		markdown: post.content.trim(),
225 -
	};
226 -
227 -
	const textContent = stripMarkdownForText(post.content);
228 -
229 -
	// Parse the publish date
230 -
	const publishDate = new Date(post.frontmatter.publishDate);
231 -
232 -
	// Handle cover image upload
233 -
	let coverImage: BlobObject | undefined;
234 -
	if (post.frontmatter.ogImage) {
235 -
		const imagePath = resolveImagePath(post.frontmatter.ogImage);
236 -
		console.log(`  - Uploading cover image: ${imagePath}`);
237 -
		coverImage = await uploadImageToPDS(agent, imagePath);
238 -
		if (coverImage) {
239 -
			console.log(`  - Uploaded image blob: ${coverImage.ref.$link}`);
240 -
		}
241 -
	}
242 -
243 -
	const record = {
244 -
		$type: "site.standard.document",
245 -
		title: post.frontmatter.title,
246 -
		site: PUBLICATION_URI,
247 -
		path: postPath,
248 -
		content: markdownContent,
249 -
		coverImage,
250 -
		textContent: textContent.slice(0, 10000), // Limit text content length
251 -
		publishedAt: publishDate.toISOString(),
252 -
		canonicalUrl: `${SITE_URL}${postPath}`,
253 -
		location: "main-blog",
254 -
	};
255 -
256 -
	const response = await agent.com.atproto.repo.createRecord({
257 -
		repo: agent.session!.did,
258 -
		collection: "site.standard.document",
259 -
		record,
260 -
	});
261 -
262 -
	return response.data.uri;
263 -
}
264 -
265 -
async function updateAtProtoDocument(
266 -
	agent: AtpAgent,
267 -
	post: BlogPost,
268 -
	atUri: string,
269 -
): Promise<void> {
270 -
	// Parse the atUri to get the collection and rkey
271 -
	// Format: at://did:plc:xxx/collection/rkey
272 -
	const uriMatch = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
273 -
	if (!uriMatch) {
274 -
		throw new Error(`Invalid atUri format: ${atUri}`);
275 -
	}
276 -
277 -
	const [, , collection, rkey] = uriMatch;
278 -
279 -
	const postPath = `/posts/${post.slug}`;
280 -
	const markdownContent = {
281 -
		$type: "site.standard.content.markdown",
282 -
		markdown: post.content.trim(),
283 -
	};
284 -
285 -
	const textContent = stripMarkdownForText(post.content);
286 -
287 -
	// Parse the publish date
288 -
	const publishDate = new Date(post.frontmatter.publishDate);
289 -
290 -
	// Handle cover image upload
291 -
	let coverImage: BlobObject | undefined;
292 -
	if (post.frontmatter.ogImage) {
293 -
		const imagePath = resolveImagePath(post.frontmatter.ogImage);
294 -
		console.log(`  - Uploading cover image: ${imagePath}`);
295 -
		coverImage = await uploadImageToPDS(agent, imagePath);
296 -
		if (coverImage) {
297 -
			console.log(`  - Uploaded image blob: ${coverImage.ref.$link}`);
298 -
		}
299 -
	}
300 -
301 -
	const record = {
302 -
		$type: "site.standard.document",
303 -
		title: post.frontmatter.title,
304 -
		site: PUBLICATION_URI,
305 -
		path: postPath,
306 -
		content: markdownContent,
307 -
		coverImage,
308 -
		textContent: textContent.slice(0, 10000), // Limit text content length
309 -
		publishedAt: publishDate.toISOString(),
310 -
		canonicalUrl: `${SITE_URL}${postPath}`,
311 -
		location: "main-blog",
312 -
	};
313 -
314 -
	await agent.com.atproto.repo.putRecord({
315 -
		repo: agent.session!.did,
316 -
		collection,
317 -
		rkey,
318 -
		record,
319 -
	});
320 -
}
321 -
322 -
async function main() {
323 -
	// Check for required environment variables
324 -
	const identifier = process.env.ATP_IDENTIFIER;
325 -
	const password = process.env.ATP_APP_PASSWORD;
326 -
327 -
	if (!identifier || !password) {
328 -
		console.error("Error: ATP_IDENTIFIER and ATP_APP_PASSWORD must be set");
329 -
		console.error("Example:");
330 -
		console.error(
331 -
			'  ATP_IDENTIFIER="your-handle.bsky.social" ATP_APP_PASSWORD="your-app-password" bun run scripts/publish-to-atproto.ts',
332 -
		);
333 -
		process.exit(1);
334 -
	}
335 -
336 -
	console.log(`Connecting to PDS at ${PDS_URL}...`);
337 -
338 -
	const agent = new AtpAgent({ service: PDS_URL });
339 -
340 -
	try {
341 -
		await agent.login({
342 -
			identifier,
343 -
			password,
344 -
		});
345 -
		console.log(`Logged in as ${agent.session?.handle}`);
346 -
	} catch (error) {
347 -
		console.error("Failed to login:", error);
348 -
		process.exit(1);
349 -
	}
350 -
351 -
	console.log("\nFetching recent posts...");
352 -
	const posts = await getRecentPosts(1);
353 -
354 -
	console.log(`Found ${posts.length} recent posts\n`);
355 -
356 -
	let publishedCount = 0;
357 -
	let updatedCount = 0;
358 -
	let skippedCount = 0;
359 -
360 -
	for (const post of posts) {
361 -
		console.log(`Processing: ${post.frontmatter.title}`);
362 -
363 -
		if (post.frontmatter.hidden) {
364 -
			console.log(`  - Post is hidden, skipping\n`);
365 -
			skippedCount++;
366 -
			continue;
367 -
		}
368 -
369 -
		try {
370 -
			if (post.frontmatter.atUri) {
371 -
				console.log(`  - Found existing atUri, updating document...`);
372 -
				await updateAtProtoDocument(agent, post, post.frontmatter.atUri);
373 -
				console.log(`  - Updated: ${post.frontmatter.atUri}\n`);
374 -
				updatedCount++;
375 -
			} else {
376 -
				console.log(`  - Creating ATProto document...`);
377 -
				const atUri = await createAtProtoDocument(agent, post);
378 -
				console.log(`  - Created: ${atUri}`);
379 -
380 -
				// Update the file with the new atUri
381 -
				const updatedContent = updateFrontmatterWithAtUri(
382 -
					post.rawContent,
383 -
					atUri,
384 -
				);
385 -
				fs.writeFileSync(post.filePath, updatedContent);
386 -
				console.log(
387 -
					`  - Updated frontmatter in ${path.basename(post.filePath)}\n`,
388 -
				);
389 -
390 -
				publishedCount++;
391 -
			}
392 -
		} catch (error) {
393 -
			console.error(`  - Error publishing: ${error}\n`);
394 -
		}
395 -
	}
396 -
397 -
	console.log("---");
398 -
	console.log(`Published: ${publishedCount}`);
399 -
	console.log(`Updated: ${updatedCount}`);
400 -
	console.log(`Skipped: ${skippedCount}`);
401 -
}
402 -
403 -
main();