|
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(); |