feat: added atproto publishing script for main blog f715839b
Steve · 2026-01-12 06:03 8 file(s) · +270 −3
packages/client/package.json +2 −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 && wrangler pages deploy dist"
13 +
		"deploy": "bun run build && wrangler pages deploy dist",
14 +
		"publish:atproto": "bun run scripts/publish-to-atproto.ts"
14 15
	},
15 16
	"devDependencies": {
16 17
		"@astrojs/mdx": "4.3.13",
packages/client/scripts/publish-to-atproto.ts (added) +261 −0
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://polybius.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 BlogPost {
24 +
	filePath: string;
25 +
	slug: string;
26 +
	frontmatter: PostFrontmatter;
27 +
	content: string;
28 +
	rawContent: string;
29 +
}
30 +
31 +
function parseFrontmatter(content: string): {
32 +
	frontmatter: PostFrontmatter;
33 +
	body: string;
34 +
} {
35 +
	const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/;
36 +
	const match = content.match(frontmatterRegex);
37 +
38 +
	if (!match) {
39 +
		throw new Error("Could not parse frontmatter");
40 +
	}
41 +
42 +
	const frontmatterStr = match[1];
43 +
	const body = match[2];
44 +
45 +
	// Parse YAML-like frontmatter manually
46 +
	const frontmatter: Record<string, unknown> = {};
47 +
	const lines = frontmatterStr.split("\n");
48 +
49 +
	for (const line of lines) {
50 +
		const colonIndex = line.indexOf(":");
51 +
		if (colonIndex === -1) continue;
52 +
53 +
		const key = line.slice(0, colonIndex).trim();
54 +
		let value = line.slice(colonIndex + 1).trim();
55 +
56 +
		// Handle quoted strings
57 +
		if (
58 +
			(value.startsWith('"') && value.endsWith('"')) ||
59 +
			(value.startsWith("'") && value.endsWith("'"))
60 +
		) {
61 +
			value = value.slice(1, -1);
62 +
		}
63 +
64 +
		// Handle arrays (simple case for tags)
65 +
		if (value.startsWith("[") && value.endsWith("]")) {
66 +
			const arrayContent = value.slice(1, -1);
67 +
			frontmatter[key] = arrayContent
68 +
				.split(",")
69 +
				.map((item) => item.trim().replace(/^["']|["']$/g, ""));
70 +
		} else if (value === "true") {
71 +
			frontmatter[key] = true;
72 +
		} else if (value === "false") {
73 +
			frontmatter[key] = false;
74 +
		} else {
75 +
			frontmatter[key] = value;
76 +
		}
77 +
	}
78 +
79 +
	return { frontmatter: frontmatter as unknown as PostFrontmatter, body };
80 +
}
81 +
82 +
function getSlugFromFilename(filename: string): string {
83 +
	return filename
84 +
		.replace(/\.mdx?$/, "")
85 +
		.toLowerCase()
86 +
		.replace(/\s+/g, "-");
87 +
}
88 +
89 +
async function getRecentPosts(limit: number = 5): Promise<BlogPost[]> {
90 +
	const files = fs.readdirSync(CONTENT_DIR);
91 +
	const posts: BlogPost[] = [];
92 +
93 +
	for (const file of files) {
94 +
		if (!file.endsWith(".mdx") && !file.endsWith(".md")) continue;
95 +
96 +
		const filePath = path.join(CONTENT_DIR, file);
97 +
		const rawContent = fs.readFileSync(filePath, "utf-8");
98 +
99 +
		try {
100 +
			const { frontmatter, body } = parseFrontmatter(rawContent);
101 +
			const slug = getSlugFromFilename(file);
102 +
103 +
			posts.push({
104 +
				filePath,
105 +
				slug,
106 +
				frontmatter,
107 +
				content: body,
108 +
				rawContent,
109 +
			});
110 +
		} catch (error) {
111 +
			console.error(`Error parsing ${file}:`, error);
112 +
		}
113 +
	}
114 +
115 +
	// Sort by publish date (newest first)
116 +
	posts.sort((a, b) => {
117 +
		const dateA = new Date(a.frontmatter.publishDate);
118 +
		const dateB = new Date(b.frontmatter.publishDate);
119 +
		return dateB.getTime() - dateA.getTime();
120 +
	});
121 +
122 +
	return posts.slice(0, limit);
123 +
}
124 +
125 +
function stripMarkdownForText(markdown: string): string {
126 +
	return markdown
127 +
		.replace(/#{1,6}\s/g, "") // Remove headers
128 +
		.replace(/\*\*([^*]+)\*\*/g, "$1") // Remove bold
129 +
		.replace(/\*([^*]+)\*/g, "$1") // Remove italic
130 +
		.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // Remove links, keep text
131 +
		.replace(/`{3}[\s\S]*?`{3}/g, "") // Remove code blocks
132 +
		.replace(/`([^`]+)`/g, "$1") // Remove inline code formatting
133 +
		.replace(/!\[.*?\]\(.*?\)/g, "") // Remove images
134 +
		.replace(/\n{3,}/g, "\n\n") // Normalize multiple newlines
135 +
		.trim();
136 +
}
137 +
138 +
function updateFrontmatterWithAtUri(rawContent: string, atUri: string): string {
139 +
	// Insert atUri before the closing ---
140 +
	const frontmatterEndIndex = rawContent.indexOf("---", 4);
141 +
	if (frontmatterEndIndex === -1) {
142 +
		throw new Error("Could not find frontmatter end");
143 +
	}
144 +
145 +
	const beforeEnd = rawContent.slice(0, frontmatterEndIndex);
146 +
	const afterEnd = rawContent.slice(frontmatterEndIndex);
147 +
148 +
	return `${beforeEnd}atUri: "${atUri}"\n${afterEnd}`;
149 +
}
150 +
151 +
async function createAtProtoDocument(
152 +
	agent: AtpAgent,
153 +
	post: BlogPost,
154 +
): Promise<string> {
155 +
	const postPath = `/posts/${post.slug}`;
156 +
	const markdownContent = {
157 +
		$type: "site.standard.content.markdown",
158 +
		markdown: post.content.trim(),
159 +
	};
160 +
161 +
	const textContent = stripMarkdownForText(post.content);
162 +
163 +
	// Parse the publish date
164 +
	const publishDate = new Date(post.frontmatter.publishDate);
165 +
166 +
	const record = {
167 +
		$type: "site.standard.document",
168 +
		title: post.frontmatter.title,
169 +
		site: PUBLICATION_URI,
170 +
		path: postPath,
171 +
		content: markdownContent,
172 +
		textContent: textContent.slice(0, 10000), // Limit text content length
173 +
		publishedAt: publishDate.toISOString(),
174 +
		canonicalUrl: `${SITE_URL}${postPath}`,
175 +
	};
176 +
177 +
	const response = await agent.com.atproto.repo.createRecord({
178 +
		repo: agent.session!.did,
179 +
		collection: "site.standard.document",
180 +
		record,
181 +
	});
182 +
183 +
	return response.data.uri;
184 +
}
185 +
186 +
async function main() {
187 +
	// Check for required environment variables
188 +
	const identifier = process.env.ATP_IDENTIFIER;
189 +
	const password = process.env.ATP_APP_PASSWORD;
190 +
191 +
	if (!identifier || !password) {
192 +
		console.error("Error: ATP_IDENTIFIER and ATP_APP_PASSWORD must be set");
193 +
		console.error("Example:");
194 +
		console.error(
195 +
			'  ATP_IDENTIFIER="your-handle.bsky.social" ATP_APP_PASSWORD="your-app-password" bun run scripts/publish-to-atproto.ts',
196 +
		);
197 +
		process.exit(1);
198 +
	}
199 +
200 +
	console.log(`Connecting to PDS at ${PDS_URL}...`);
201 +
202 +
	const agent = new AtpAgent({ service: PDS_URL });
203 +
204 +
	try {
205 +
		await agent.login({
206 +
			identifier,
207 +
			password,
208 +
		});
209 +
		console.log(`Logged in as ${agent.session?.handle}`);
210 +
	} catch (error) {
211 +
		console.error("Failed to login:", error);
212 +
		process.exit(1);
213 +
	}
214 +
215 +
	console.log("\nFetching recent posts...");
216 +
	const posts = await getRecentPosts(5);
217 +
218 +
	console.log(`Found ${posts.length} recent posts\n`);
219 +
220 +
	let publishedCount = 0;
221 +
	let skippedCount = 0;
222 +
223 +
	for (const post of posts) {
224 +
		console.log(`Processing: ${post.frontmatter.title}`);
225 +
226 +
		if (post.frontmatter.atUri) {
227 +
			console.log(`  - Already has atUri, skipping\n`);
228 +
			skippedCount++;
229 +
			continue;
230 +
		}
231 +
232 +
		if (post.frontmatter.hidden) {
233 +
			console.log(`  - Post is hidden, skipping\n`);
234 +
			skippedCount++;
235 +
			continue;
236 +
		}
237 +
238 +
		try {
239 +
			console.log(`  - Creating ATProto document...`);
240 +
			const atUri = await createAtProtoDocument(agent, post);
241 +
			console.log(`  - Created: ${atUri}`);
242 +
243 +
			// Update the file with the new atUri
244 +
			const updatedContent = updateFrontmatterWithAtUri(post.rawContent, atUri);
245 +
			fs.writeFileSync(post.filePath, updatedContent);
246 +
			console.log(
247 +
				`  - Updated frontmatter in ${path.basename(post.filePath)}\n`,
248 +
			);
249 +
250 +
			publishedCount++;
251 +
		} catch (error) {
252 +
			console.error(`  - Error publishing: ${error}\n`);
253 +
		}
254 +
	}
255 +
256 +
	console.log("---");
257 +
	console.log(`Published: ${publishedCount}`);
258 +
	console.log(`Skipped: ${skippedCount}`);
259 +
}
260 +
261 +
main();
packages/client/src/content/post/2026-site-plans.mdx +1 −0
4 4
description: "A small reflection and set of plans for making a ripple in a big lake"
5 5
tags: ["web", "personal sites", "philosophy"]
6 6
ogImage: "/blog-images/other/surf-the-web.png"
7 +
atUri: "at://did:plc:ia2zdnhjaokf5lazhxrmj6eu/site.standard.document/3mc7v3qot3c2x"
7 8
---
8 9
9 10
![surf](/blog-images/other/surf-the-web.png)
packages/client/src/content/post/how-gemini-gives-me-hope.mdx +1 −0
4 4
description: "A journey down a deep rabbit hole thanks to cassette tapes"
5 5
tags: ["gemini", "internet", "open web"]
6 6
ogImage: "/blog-images/other/gemini.png"
7 +
atUri: "at://did:plc:ia2zdnhjaokf5lazhxrmj6eu/site.standard.document/3mc7v3qrlx22x"
7 8
---
8 9
9 10
![cover](https://files.stevedylan.dev/gemini-post.png)
packages/client/src/content/post/introducing-alcove.mdx +1 −0
4 4
description: "Pushing forward the consumption of content without the invasion of privacy"
5 5
tags: ["rss", "programming", "privacy"]
6 6
ogImage: "/blog-images/other/alcove.jpg"
7 +
atUri: "at://did:plc:ia2zdnhjaokf5lazhxrmj6eu/site.standard.document/3mc7v3qtu7k2x"
7 8
---
8 9
9 10
![cover](https://files.stevedylan.dev/alcove.jpg)
packages/client/src/content/post/standard-site-the-publishing-gateway.mdx +1 −0
4 4
description: "Another deep exploration into ATProto and implementing lexicons"
5 5
tags: ["atproto", "open web", "bluesky"]
6 6
ogImage: "/blog-images/other/standard-site.png"
7 +
atUri: "at://did:plc:ia2zdnhjaokf5lazhxrmj6eu/site.standard.document/3mc7v3qixlc2x"
7 8
---
8 9
9 10
![cover](https://files.stevedylan.dev/standard-site.png)
packages/client/src/content/post/using-atproto-for-posse.mdx +1 −0
4 4
description: "My little weekend experiment to bring micro updates to my personal site"
5 5
tags: ["atproto", "blog", "open web"]
6 6
ogImage: "/blog-images/other/atproto.png"
7 +
atUri: "at://did:plc:ia2zdnhjaokf5lazhxrmj6eu/site.standard.document/3mc7v3qmb2c2x"
7 8
---
8 9
9 10
![atproto](https://files.stevedylan.dev/atproto.png)
packages/client/src/layouts/BlogPost.astro +2 −2
10 10
11 11
const { post } = Astro.props;
12 12
const {
13 -
	data: { title, description, ogImage, publishDate },
13 +
	data: { title, description, ogImage, publishDate, atUri },
14 14
} = post;
15 15
const socialImage = ogImage ?? "/social-card.png";
16 16
const articleDate = publishDate.toISOString();
36 36
	observer.observe(targetHeader);
37 37
</script>
38 38
39 -
<BaseLayout meta={{ title, description, articleDate, ogImage: socialImage }}>
39 +
<BaseLayout meta={{ title, description, articleDate, ogImage: socialImage, atUri }}>
40 40
	<div class="gap-x-10 lg:flex lg:items-start">
41 41
		{
42 42
			!!headings.length && (