|
| 1 | +#!/usr/bin/env node |
| 2 | + |
| 3 | +/** |
| 4 | + * Publish a changelog post to change.pckt.blog |
| 5 | + * |
| 6 | + * Creates a site.standard.document record in the aesthetic.computer PDS |
| 7 | + * using the blog.pckt.content block format. |
| 8 | + * |
| 9 | + * Usage: |
| 10 | + * node at/publish-changelog.mjs "Title" "Body text in markdown-ish format" |
| 11 | + * node at/publish-changelog.mjs --from-commits 5 # last 5 commits |
| 12 | + * node at/publish-changelog.mjs --file changelog.md # from a file |
| 13 | + * echo "content" | node at/publish-changelog.mjs "Title" --stdin |
| 14 | + * |
| 15 | + * Manage posts: |
| 16 | + * node at/publish-changelog.mjs --list |
| 17 | + * node at/publish-changelog.mjs --delete <rkey> |
| 18 | + * |
| 19 | + * Body supports a simple subset: |
| 20 | + * ## Heading → blog.pckt.block.heading (level 2) |
| 21 | + * ### Heading → blog.pckt.block.heading (level 3) |
| 22 | + * **bold text** → blog.pckt.richtext.facet#bold |
| 23 | + * *italic text* → blog.pckt.richtext.facet#italic |
| 24 | + * [text](url) → blog.pckt.richtext.facet#link |
| 25 | + * blank line → paragraph break |
| 26 | + */ |
| 27 | + |
| 28 | +import { AtpAgent } from "@atproto/api"; |
| 29 | +import { config } from "dotenv"; |
| 30 | +import { readFileSync } from "fs"; |
| 31 | +import { execSync } from "child_process"; |
| 32 | + |
| 33 | +config({ path: new URL(".env", import.meta.url) }); |
| 34 | + |
| 35 | +const BSKY_SERVICE = process.env.BSKY_SERVICE || "https://bsky.social"; |
| 36 | +const BSKY_IDENTIFIER = process.env.BSKY_IDENTIFIER; |
| 37 | +const BSKY_APP_PASSWORD = process.env.BSKY_APP_PASSWORD; |
| 38 | + |
| 39 | +// The publication record URI for change.pckt.blog |
| 40 | +const PUBLICATION_URI = |
| 41 | + "at://did:plc:k3k3wknzkcnekbnyde4dbatz/site.standard.publication/3mhrqpf4rqykh"; |
| 42 | + |
| 43 | +// --------------------------------------------------------------------------- |
| 44 | +// Markdown-ish → pckt blocks |
| 45 | +// --------------------------------------------------------------------------- |
| 46 | + |
| 47 | +function parseInline(text) { |
| 48 | + // Extract bold, italic, and link facets from a line of text. |
| 49 | + // Returns { plaintext, facets } |
| 50 | + const facets = []; |
| 51 | + let plain = ""; |
| 52 | + |
| 53 | + // Regex order matters — bold before italic (** before *) |
| 54 | + // We process left-to-right, replacing markup and tracking byte offsets. |
| 55 | + const tokens = |
| 56 | + text.match(/\*\*[^*]+\*\*|\*[^*]+\*|\[[^\]]+\]\([^)]+\)|[^*[\]]+/g) || []; |
| 57 | + |
| 58 | + for (const tok of tokens) { |
| 59 | + const byteStart = Buffer.byteLength(plain, "utf8"); |
| 60 | + |
| 61 | + if (tok.startsWith("**") && tok.endsWith("**")) { |
| 62 | + const inner = tok.slice(2, -2); |
| 63 | + plain += inner; |
| 64 | + facets.push({ |
| 65 | + index: { |
| 66 | + byteStart, |
| 67 | + byteEnd: byteStart + Buffer.byteLength(inner, "utf8"), |
| 68 | + }, |
| 69 | + features: [{ $type: "blog.pckt.richtext.facet#bold" }], |
| 70 | + }); |
| 71 | + } else if (tok.startsWith("*") && tok.endsWith("*")) { |
| 72 | + const inner = tok.slice(1, -1); |
| 73 | + plain += inner; |
| 74 | + facets.push({ |
| 75 | + index: { |
| 76 | + byteStart, |
| 77 | + byteEnd: byteStart + Buffer.byteLength(inner, "utf8"), |
| 78 | + }, |
| 79 | + features: [{ $type: "blog.pckt.richtext.facet#italic" }], |
| 80 | + }); |
| 81 | + } else if (tok.startsWith("[")) { |
| 82 | + const m = tok.match(/^\[([^\]]+)\]\(([^)]+)\)$/); |
| 83 | + if (m) { |
| 84 | + const [, linkText, url] = m; |
| 85 | + plain += linkText; |
| 86 | + facets.push({ |
| 87 | + index: { |
| 88 | + byteStart, |
| 89 | + byteEnd: byteStart + Buffer.byteLength(linkText, "utf8"), |
| 90 | + }, |
| 91 | + features: [{ $type: "blog.pckt.richtext.facet#link", uri: url }], |
| 92 | + }); |
| 93 | + } else { |
| 94 | + plain += tok; |
| 95 | + } |
| 96 | + } else { |
| 97 | + plain += tok; |
| 98 | + } |
| 99 | + } |
| 100 | + |
| 101 | + return { plaintext: plain, facets }; |
| 102 | +} |
| 103 | + |
| 104 | +function markdownToBlocks(md) { |
| 105 | + const lines = md.split("\n"); |
| 106 | + const items = []; |
| 107 | + let paragraph = []; |
| 108 | + |
| 109 | + function flushParagraph() { |
| 110 | + if (paragraph.length === 0) return; |
| 111 | + const text = paragraph.join(" ").trim(); |
| 112 | + paragraph = []; |
| 113 | + if (!text) return; |
| 114 | + |
| 115 | + const { plaintext, facets } = parseInline(text); |
| 116 | + const block = { $type: "blog.pckt.block.text", plaintext }; |
| 117 | + if (facets.length > 0) block.facets = facets; |
| 118 | + items.push(block); |
| 119 | + } |
| 120 | + |
| 121 | + for (const line of lines) { |
| 122 | + const trimmed = line.trim(); |
| 123 | + |
| 124 | + // Heading |
| 125 | + const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/); |
| 126 | + if (headingMatch) { |
| 127 | + flushParagraph(); |
| 128 | + const level = headingMatch[1].length; |
| 129 | + items.push({ |
| 130 | + $type: "blog.pckt.block.heading", |
| 131 | + level, |
| 132 | + plaintext: headingMatch[2], |
| 133 | + }); |
| 134 | + continue; |
| 135 | + } |
| 136 | + |
| 137 | + // Blank line = paragraph break |
| 138 | + if (trimmed === "") { |
| 139 | + flushParagraph(); |
| 140 | + continue; |
| 141 | + } |
| 142 | + |
| 143 | + // Bullet points — prefix with bullet, treat as text |
| 144 | + if (trimmed.startsWith("- ") || trimmed.startsWith("* ")) { |
| 145 | + flushParagraph(); |
| 146 | + const bulletText = "\u2022 " + trimmed.slice(2); |
| 147 | + const { plaintext, facets } = parseInline(bulletText); |
| 148 | + const block = { $type: "blog.pckt.block.text", plaintext }; |
| 149 | + if (facets.length > 0) block.facets = facets; |
| 150 | + items.push(block); |
| 151 | + continue; |
| 152 | + } |
| 153 | + |
| 154 | + paragraph.push(trimmed); |
| 155 | + } |
| 156 | + |
| 157 | + flushParagraph(); |
| 158 | + return items; |
| 159 | +} |
| 160 | + |
| 161 | +// --------------------------------------------------------------------------- |
| 162 | +// Generate changelog from git commits |
| 163 | +// --------------------------------------------------------------------------- |
| 164 | + |
| 165 | +function changelogFromCommits(count = 10) { |
| 166 | + const log = execSync( |
| 167 | + `git log --oneline --no-decorate -n ${count} --format="%h %s"`, |
| 168 | + { encoding: "utf8", cwd: process.env.INIT_CWD || process.cwd() }, |
| 169 | + ).trim(); |
| 170 | + |
| 171 | + const lines = log.split("\n"); |
| 172 | + const date = new Date().toISOString().slice(0, 10); |
| 173 | + let md = `## Changelog ${date}\n\n`; |
| 174 | + for (const line of lines) { |
| 175 | + md += `- ${line}\n`; |
| 176 | + } |
| 177 | + return md; |
| 178 | +} |
| 179 | + |
| 180 | +// --------------------------------------------------------------------------- |
| 181 | +// Slug generation |
| 182 | +// --------------------------------------------------------------------------- |
| 183 | + |
| 184 | +function slugify(title) { |
| 185 | + const slug = title |
| 186 | + .toLowerCase() |
| 187 | + .replace(/[^a-z0-9]+/g, "-") |
| 188 | + .replace(/^-|-$/g, ""); |
| 189 | + // Append short random suffix like pckt does |
| 190 | + const rand = Math.random().toString(36).slice(2, 9); |
| 191 | + return `/${slug}-${rand}`; |
| 192 | +} |
| 193 | + |
| 194 | +// --------------------------------------------------------------------------- |
| 195 | +// Auth helper |
| 196 | +// --------------------------------------------------------------------------- |
| 197 | + |
| 198 | +async function login() { |
| 199 | + if (!BSKY_IDENTIFIER || !BSKY_APP_PASSWORD) { |
| 200 | + console.error("Missing BSKY_IDENTIFIER or BSKY_APP_PASSWORD in at/.env"); |
| 201 | + process.exit(1); |
| 202 | + } |
| 203 | + const agent = new AtpAgent({ service: BSKY_SERVICE }); |
| 204 | + await agent.login({ |
| 205 | + identifier: BSKY_IDENTIFIER, |
| 206 | + password: BSKY_APP_PASSWORD, |
| 207 | + }); |
| 208 | + return agent; |
| 209 | +} |
| 210 | + |
| 211 | +// --------------------------------------------------------------------------- |
| 212 | +// List posts |
| 213 | +// --------------------------------------------------------------------------- |
| 214 | + |
| 215 | +async function listPosts() { |
| 216 | + const agent = await login(); |
| 217 | + let cursor; |
| 218 | + const all = []; |
| 219 | + |
| 220 | + do { |
| 221 | + const res = await agent.com.atproto.repo.listRecords({ |
| 222 | + repo: agent.session.did, |
| 223 | + collection: "site.standard.document", |
| 224 | + limit: 100, |
| 225 | + cursor, |
| 226 | + }); |
| 227 | + // Only include posts belonging to our change publication |
| 228 | + for (const rec of res.data.records) { |
| 229 | + if (rec.value.site === PUBLICATION_URI) all.push(rec); |
| 230 | + } |
| 231 | + cursor = res.data.cursor; |
| 232 | + } while (cursor); |
| 233 | + |
| 234 | + if (all.length === 0) { |
| 235 | + console.log("No posts on change.pckt.blog yet."); |
| 236 | + return; |
| 237 | + } |
| 238 | + |
| 239 | + console.log(`\nchange.pckt.blog — ${all.length} post(s)\n`); |
| 240 | + for (const rec of all) { |
| 241 | + const rkey = rec.uri.split("/").pop(); |
| 242 | + const date = rec.value.publishedAt?.slice(0, 10) || "?"; |
| 243 | + console.log(` ${date} ${rkey} ${rec.value.title}`); |
| 244 | + } |
| 245 | + console.log(`\nTo delete: node at/publish-changelog.mjs --delete <rkey>`); |
| 246 | +} |
| 247 | + |
| 248 | +// --------------------------------------------------------------------------- |
| 249 | +// Delete a post |
| 250 | +// --------------------------------------------------------------------------- |
| 251 | + |
| 252 | +async function deletePost(rkey) { |
| 253 | + const agent = await login(); |
| 254 | + |
| 255 | + // Verify it exists and belongs to our publication |
| 256 | + try { |
| 257 | + const rec = await agent.com.atproto.repo.getRecord({ |
| 258 | + repo: agent.session.did, |
| 259 | + collection: "site.standard.document", |
| 260 | + rkey, |
| 261 | + }); |
| 262 | + if (rec.data.value.site !== PUBLICATION_URI) { |
| 263 | + console.error("That record doesn't belong to change.pckt.blog."); |
| 264 | + process.exit(1); |
| 265 | + } |
| 266 | + console.log(`Deleting: "${rec.data.value.title}" (${rkey})`); |
| 267 | + } catch { |
| 268 | + console.error(`Record not found: ${rkey}`); |
| 269 | + process.exit(1); |
| 270 | + } |
| 271 | + |
| 272 | + await agent.com.atproto.repo.deleteRecord({ |
| 273 | + repo: agent.session.did, |
| 274 | + collection: "site.standard.document", |
| 275 | + rkey, |
| 276 | + }); |
| 277 | + |
| 278 | + console.log("Deleted."); |
| 279 | +} |
| 280 | + |
| 281 | +// --------------------------------------------------------------------------- |
| 282 | +// Main |
| 283 | +// --------------------------------------------------------------------------- |
| 284 | + |
| 285 | +async function main() { |
| 286 | + const args = process.argv.slice(2); |
| 287 | + |
| 288 | + // Parse flags |
| 289 | + const flagIdx = (f) => args.indexOf(f); |
| 290 | + |
| 291 | + if (flagIdx("--list") !== -1) { |
| 292 | + return listPosts(); |
| 293 | + } |
| 294 | + |
| 295 | + if (flagIdx("--delete") !== -1) { |
| 296 | + const rkey = args[flagIdx("--delete") + 1]; |
| 297 | + if (!rkey) { |
| 298 | + console.error("Usage: --delete <rkey> (use --list to find rkeys)"); |
| 299 | + process.exit(1); |
| 300 | + } |
| 301 | + return deletePost(rkey); |
| 302 | + } |
| 303 | + |
| 304 | + if (!BSKY_IDENTIFIER || !BSKY_APP_PASSWORD) { |
| 305 | + console.error("Missing BSKY_IDENTIFIER or BSKY_APP_PASSWORD in at/.env"); |
| 306 | + process.exit(1); |
| 307 | + } |
| 308 | + |
| 309 | + let title, body; |
| 310 | + |
| 311 | + if (flagIdx("--from-commits") !== -1) { |
| 312 | + const n = parseInt(args[flagIdx("--from-commits") + 1]) || 10; |
| 313 | + title = args[0] && !args[0].startsWith("--") ? args[0] : `Changelog`; |
| 314 | + body = changelogFromCommits(n); |
| 315 | + } else if (flagIdx("--file") !== -1) { |
| 316 | + const filePath = args[flagIdx("--file") + 1]; |
| 317 | + const content = readFileSync(filePath, "utf8"); |
| 318 | + // First line starting with # is the title, rest is body |
| 319 | + const lines = content.split("\n"); |
| 320 | + const titleLine = lines.findIndex((l) => l.startsWith("# ")); |
| 321 | + if (titleLine !== -1) { |
| 322 | + title = lines[titleLine].replace(/^#+\s*/, ""); |
| 323 | + body = lines |
| 324 | + .slice(titleLine + 1) |
| 325 | + .join("\n") |
| 326 | + .trim(); |
| 327 | + } else { |
| 328 | + title = args[0] || "Update"; |
| 329 | + body = content; |
| 330 | + } |
| 331 | + } else if (flagIdx("--stdin") !== -1) { |
| 332 | + title = args[0] || "Update"; |
| 333 | + body = readFileSync("/dev/stdin", "utf8"); |
| 334 | + } else { |
| 335 | + title = args[0]; |
| 336 | + body = args[1]; |
| 337 | + if (!title) { |
| 338 | + console.error( |
| 339 | + `Usage: |
| 340 | + node at/publish-changelog.mjs "Title" "Body text" |
| 341 | + node at/publish-changelog.mjs --from-commits 5 |
| 342 | + node at/publish-changelog.mjs --file changelog.md |
| 343 | + echo "text" | node at/publish-changelog.mjs "Title" --stdin |
| 344 | + node at/publish-changelog.mjs --list |
| 345 | + node at/publish-changelog.mjs --delete <rkey>`, |
| 346 | + ); |
| 347 | + process.exit(1); |
| 348 | + } |
| 349 | + if (!body) { |
| 350 | + body = ""; |
| 351 | + } |
| 352 | + } |
| 353 | + |
| 354 | + const items = markdownToBlocks(body); |
| 355 | + const path = slugify(title); |
| 356 | + const now = new Date().toISOString(); |
| 357 | + |
| 358 | + console.log(`Publishing to change.pckt.blog...`); |
| 359 | + console.log(` Title: ${title}`); |
| 360 | + console.log(` Path: ${path}`); |
| 361 | + console.log(` Blocks: ${items.length}`); |
| 362 | + |
| 363 | + const agent = await login(); |
| 364 | + console.log(` Authenticated as @${BSKY_IDENTIFIER}`); |
| 365 | + |
| 366 | + const record = { |
| 367 | + $type: "site.standard.document", |
| 368 | + site: PUBLICATION_URI, |
| 369 | + title, |
| 370 | + path, |
| 371 | + publishedAt: now, |
| 372 | + content: { |
| 373 | + $type: "blog.pckt.content", |
| 374 | + items, |
| 375 | + }, |
| 376 | + textContent: items |
| 377 | + .map((b) => b.plaintext || "") |
| 378 | + .filter(Boolean) |
| 379 | + .join("\n\n"), |
| 380 | + tags: ["changelog"], |
| 381 | + }; |
| 382 | + |
| 383 | + const res = await agent.com.atproto.repo.createRecord({ |
| 384 | + repo: agent.session.did, |
| 385 | + collection: "site.standard.document", |
| 386 | + record, |
| 387 | + }); |
| 388 | + |
| 389 | + console.log(`\nPublished!`); |
| 390 | + console.log(` URI: ${res.data.uri}`); |
| 391 | + console.log(` CID: ${res.data.cid}`); |
| 392 | + console.log(` URL: https://change.pckt.blog${path}`); |
| 393 | +} |
| 394 | + |
| 395 | +main().catch((err) => { |
| 396 | + console.error("Failed:", err.message); |
| 397 | + process.exit(1); |
| 398 | +}); |
0 commit comments