Skip to content

Commit 848af53

Browse files
whistlegraphclaude
andcommitted
fix: text wrapping in blank.mjs, accurate TextButton width measurement, vscode ext v1.271.0
- Use write max-width param for centered multi-line description in blank.mjs - Fix TextButton width calculation to use per-character advance for variable-width typefaces - Bump vscode extension to 1.271.0 - Add publish-changelog and publish-commits scripts Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4be0369 commit 848af53

6 files changed

Lines changed: 662 additions & 8 deletions

File tree

at/publish-changelog.mjs

Lines changed: 398 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,398 @@
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

Comments
 (0)