diff --git a/at/publish-changelog.mjs b/at/publish-changelog.mjs new file mode 100644 index 0000000000..ef41640ce4 --- /dev/null +++ b/at/publish-changelog.mjs @@ -0,0 +1,398 @@ +#!/usr/bin/env node + +/** + * Publish a changelog post to change.pckt.blog + * + * Creates a site.standard.document record in the aesthetic.computer PDS + * using the blog.pckt.content block format. + * + * Usage: + * node at/publish-changelog.mjs "Title" "Body text in markdown-ish format" + * node at/publish-changelog.mjs --from-commits 5 # last 5 commits + * node at/publish-changelog.mjs --file changelog.md # from a file + * echo "content" | node at/publish-changelog.mjs "Title" --stdin + * + * Manage posts: + * node at/publish-changelog.mjs --list + * node at/publish-changelog.mjs --delete + * + * Body supports a simple subset: + * ## Heading → blog.pckt.block.heading (level 2) + * ### Heading → blog.pckt.block.heading (level 3) + * **bold text** → blog.pckt.richtext.facet#bold + * *italic text* → blog.pckt.richtext.facet#italic + * [text](url) → blog.pckt.richtext.facet#link + * blank line → paragraph break + */ + +import { AtpAgent } from "@atproto/api"; +import { config } from "dotenv"; +import { readFileSync } from "fs"; +import { execSync } from "child_process"; + +config({ path: new URL(".env", import.meta.url) }); + +const BSKY_SERVICE = process.env.BSKY_SERVICE || "https://bsky.social"; +const BSKY_IDENTIFIER = process.env.BSKY_IDENTIFIER; +const BSKY_APP_PASSWORD = process.env.BSKY_APP_PASSWORD; + +// The publication record URI for change.pckt.blog +const PUBLICATION_URI = + "at://did:plc:k3k3wknzkcnekbnyde4dbatz/site.standard.publication/3mhrqpf4rqykh"; + +// --------------------------------------------------------------------------- +// Markdown-ish → pckt blocks +// --------------------------------------------------------------------------- + +function parseInline(text) { + // Extract bold, italic, and link facets from a line of text. + // Returns { plaintext, facets } + const facets = []; + let plain = ""; + + // Regex order matters — bold before italic (** before *) + // We process left-to-right, replacing markup and tracking byte offsets. + const tokens = + text.match(/\*\*[^*]+\*\*|\*[^*]+\*|\[[^\]]+\]\([^)]+\)|[^*[\]]+/g) || []; + + for (const tok of tokens) { + const byteStart = Buffer.byteLength(plain, "utf8"); + + if (tok.startsWith("**") && tok.endsWith("**")) { + const inner = tok.slice(2, -2); + plain += inner; + facets.push({ + index: { + byteStart, + byteEnd: byteStart + Buffer.byteLength(inner, "utf8"), + }, + features: [{ $type: "blog.pckt.richtext.facet#bold" }], + }); + } else if (tok.startsWith("*") && tok.endsWith("*")) { + const inner = tok.slice(1, -1); + plain += inner; + facets.push({ + index: { + byteStart, + byteEnd: byteStart + Buffer.byteLength(inner, "utf8"), + }, + features: [{ $type: "blog.pckt.richtext.facet#italic" }], + }); + } else if (tok.startsWith("[")) { + const m = tok.match(/^\[([^\]]+)\]\(([^)]+)\)$/); + if (m) { + const [, linkText, url] = m; + plain += linkText; + facets.push({ + index: { + byteStart, + byteEnd: byteStart + Buffer.byteLength(linkText, "utf8"), + }, + features: [{ $type: "blog.pckt.richtext.facet#link", uri: url }], + }); + } else { + plain += tok; + } + } else { + plain += tok; + } + } + + return { plaintext: plain, facets }; +} + +function markdownToBlocks(md) { + const lines = md.split("\n"); + const items = []; + let paragraph = []; + + function flushParagraph() { + if (paragraph.length === 0) return; + const text = paragraph.join(" ").trim(); + paragraph = []; + if (!text) return; + + const { plaintext, facets } = parseInline(text); + const block = { $type: "blog.pckt.block.text", plaintext }; + if (facets.length > 0) block.facets = facets; + items.push(block); + } + + for (const line of lines) { + const trimmed = line.trim(); + + // Heading + const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/); + if (headingMatch) { + flushParagraph(); + const level = headingMatch[1].length; + items.push({ + $type: "blog.pckt.block.heading", + level, + plaintext: headingMatch[2], + }); + continue; + } + + // Blank line = paragraph break + if (trimmed === "") { + flushParagraph(); + continue; + } + + // Bullet points — prefix with bullet, treat as text + if (trimmed.startsWith("- ") || trimmed.startsWith("* ")) { + flushParagraph(); + const bulletText = "\u2022 " + trimmed.slice(2); + const { plaintext, facets } = parseInline(bulletText); + const block = { $type: "blog.pckt.block.text", plaintext }; + if (facets.length > 0) block.facets = facets; + items.push(block); + continue; + } + + paragraph.push(trimmed); + } + + flushParagraph(); + return items; +} + +// --------------------------------------------------------------------------- +// Generate changelog from git commits +// --------------------------------------------------------------------------- + +function changelogFromCommits(count = 10) { + const log = execSync( + `git log --oneline --no-decorate -n ${count} --format="%h %s"`, + { encoding: "utf8", cwd: process.env.INIT_CWD || process.cwd() }, + ).trim(); + + const lines = log.split("\n"); + const date = new Date().toISOString().slice(0, 10); + let md = `## Changelog ${date}\n\n`; + for (const line of lines) { + md += `- ${line}\n`; + } + return md; +} + +// --------------------------------------------------------------------------- +// Slug generation +// --------------------------------------------------------------------------- + +function slugify(title) { + const slug = title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); + // Append short random suffix like pckt does + const rand = Math.random().toString(36).slice(2, 9); + return `/${slug}-${rand}`; +} + +// --------------------------------------------------------------------------- +// Auth helper +// --------------------------------------------------------------------------- + +async function login() { + if (!BSKY_IDENTIFIER || !BSKY_APP_PASSWORD) { + console.error("Missing BSKY_IDENTIFIER or BSKY_APP_PASSWORD in at/.env"); + process.exit(1); + } + const agent = new AtpAgent({ service: BSKY_SERVICE }); + await agent.login({ + identifier: BSKY_IDENTIFIER, + password: BSKY_APP_PASSWORD, + }); + return agent; +} + +// --------------------------------------------------------------------------- +// List posts +// --------------------------------------------------------------------------- + +async function listPosts() { + const agent = await login(); + let cursor; + const all = []; + + do { + const res = await agent.com.atproto.repo.listRecords({ + repo: agent.session.did, + collection: "site.standard.document", + limit: 100, + cursor, + }); + // Only include posts belonging to our change publication + for (const rec of res.data.records) { + if (rec.value.site === PUBLICATION_URI) all.push(rec); + } + cursor = res.data.cursor; + } while (cursor); + + if (all.length === 0) { + console.log("No posts on change.pckt.blog yet."); + return; + } + + console.log(`\nchange.pckt.blog — ${all.length} post(s)\n`); + for (const rec of all) { + const rkey = rec.uri.split("/").pop(); + const date = rec.value.publishedAt?.slice(0, 10) || "?"; + console.log(` ${date} ${rkey} ${rec.value.title}`); + } + console.log(`\nTo delete: node at/publish-changelog.mjs --delete `); +} + +// --------------------------------------------------------------------------- +// Delete a post +// --------------------------------------------------------------------------- + +async function deletePost(rkey) { + const agent = await login(); + + // Verify it exists and belongs to our publication + try { + const rec = await agent.com.atproto.repo.getRecord({ + repo: agent.session.did, + collection: "site.standard.document", + rkey, + }); + if (rec.data.value.site !== PUBLICATION_URI) { + console.error("That record doesn't belong to change.pckt.blog."); + process.exit(1); + } + console.log(`Deleting: "${rec.data.value.title}" (${rkey})`); + } catch { + console.error(`Record not found: ${rkey}`); + process.exit(1); + } + + await agent.com.atproto.repo.deleteRecord({ + repo: agent.session.did, + collection: "site.standard.document", + rkey, + }); + + console.log("Deleted."); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main() { + const args = process.argv.slice(2); + + // Parse flags + const flagIdx = (f) => args.indexOf(f); + + if (flagIdx("--list") !== -1) { + return listPosts(); + } + + if (flagIdx("--delete") !== -1) { + const rkey = args[flagIdx("--delete") + 1]; + if (!rkey) { + console.error("Usage: --delete (use --list to find rkeys)"); + process.exit(1); + } + return deletePost(rkey); + } + + if (!BSKY_IDENTIFIER || !BSKY_APP_PASSWORD) { + console.error("Missing BSKY_IDENTIFIER or BSKY_APP_PASSWORD in at/.env"); + process.exit(1); + } + + let title, body; + + if (flagIdx("--from-commits") !== -1) { + const n = parseInt(args[flagIdx("--from-commits") + 1]) || 10; + title = args[0] && !args[0].startsWith("--") ? args[0] : `Changelog`; + body = changelogFromCommits(n); + } else if (flagIdx("--file") !== -1) { + const filePath = args[flagIdx("--file") + 1]; + const content = readFileSync(filePath, "utf8"); + // First line starting with # is the title, rest is body + const lines = content.split("\n"); + const titleLine = lines.findIndex((l) => l.startsWith("# ")); + if (titleLine !== -1) { + title = lines[titleLine].replace(/^#+\s*/, ""); + body = lines + .slice(titleLine + 1) + .join("\n") + .trim(); + } else { + title = args[0] || "Update"; + body = content; + } + } else if (flagIdx("--stdin") !== -1) { + title = args[0] || "Update"; + body = readFileSync("/dev/stdin", "utf8"); + } else { + title = args[0]; + body = args[1]; + if (!title) { + console.error( + `Usage: + node at/publish-changelog.mjs "Title" "Body text" + node at/publish-changelog.mjs --from-commits 5 + node at/publish-changelog.mjs --file changelog.md + echo "text" | node at/publish-changelog.mjs "Title" --stdin + node at/publish-changelog.mjs --list + node at/publish-changelog.mjs --delete `, + ); + process.exit(1); + } + if (!body) { + body = ""; + } + } + + const items = markdownToBlocks(body); + const path = slugify(title); + const now = new Date().toISOString(); + + console.log(`Publishing to change.pckt.blog...`); + console.log(` Title: ${title}`); + console.log(` Path: ${path}`); + console.log(` Blocks: ${items.length}`); + + const agent = await login(); + console.log(` Authenticated as @${BSKY_IDENTIFIER}`); + + const record = { + $type: "site.standard.document", + site: PUBLICATION_URI, + title, + path, + publishedAt: now, + content: { + $type: "blog.pckt.content", + items, + }, + textContent: items + .map((b) => b.plaintext || "") + .filter(Boolean) + .join("\n\n"), + tags: ["changelog"], + }; + + const res = await agent.com.atproto.repo.createRecord({ + repo: agent.session.did, + collection: "site.standard.document", + record, + }); + + console.log(`\nPublished!`); + console.log(` URI: ${res.data.uri}`); + console.log(` CID: ${res.data.cid}`); + console.log(` URL: https://change.pckt.blog${path}`); +} + +main().catch((err) => { + console.error("Failed:", err.message); + process.exit(1); +}); diff --git a/at/publish-commits.mjs b/at/publish-commits.mjs new file mode 100644 index 0000000000..52082966ff --- /dev/null +++ b/at/publish-commits.mjs @@ -0,0 +1,246 @@ +#!/usr/bin/env node + +/** + * Publish a commit summary to news.aesthetic.computer + * + * Posts a "Commits From to " news item with a formatted + * summary of git commits. Connects directly to MongoDB and optionally + * syncs to ATProto PDS. + * + * Usage: + * node at/publish-commits.mjs # last 20 commits + * node at/publish-commits.mjs --count 50 # last 50 commits + * node at/publish-commits.mjs --from abc1234 # from a specific hash + * node at/publish-commits.mjs --from abc1234 --to def5678 + * node at/publish-commits.mjs --since "3 days ago" # git log --since + * node at/publish-commits.mjs --dry-run # preview without posting + */ + +import { MongoClient } from "mongodb"; +import { AtpAgent } from "@atproto/api"; +import { config } from "dotenv"; +import { execSync } from "child_process"; +import { randomBytes } from "crypto"; + +// Load env from multiple sources +config({ path: new URL("../.devcontainer/envs/devcontainer.env", import.meta.url) }); +config({ path: new URL(".env", import.meta.url) }); + +const MONGODB_URI = process.env.MONGODB_CONNECTION_STRING; +const MONGODB_NAME = process.env.MONGODB_NAME || "aesthetic"; +const ADMIN_SUB = process.env.ADMIN_SUB; +const PDS_URL = process.env.PDS_URL || "https://at.aesthetic.computer"; + +// --------------------------------------------------------------------------- +// Args +// --------------------------------------------------------------------------- + +function parseArgs(argv) { + const out = { _: [] }; + for (let i = 0; i < argv.length; i++) { + const t = argv[i]; + if (!t.startsWith("--")) { out._.push(t); continue; } + const key = t.slice(2); + const next = argv[i + 1]; + if (next && !next.startsWith("--")) { out[key] = next; i++; } + else out[key] = true; + } + return out; +} + +// --------------------------------------------------------------------------- +// Git log +// --------------------------------------------------------------------------- + +function getCommits(args) { + const gitArgs = ["git", "log", "--oneline", "--no-decorate"]; + + if (args.from && args.to) { + gitArgs.push(`${args.from}..${args.to}`); + } else if (args.from) { + gitArgs.push(`${args.from}..HEAD`); + } else if (args.since) { + gitArgs.push(`--since="${args.since}"`); + } else { + gitArgs.push(`-n`, `${args.count || 20}`); + } + + gitArgs.push('--format="%h %s"'); + + const log = execSync(gitArgs.join(" "), { encoding: "utf8" }).trim(); + if (!log) return { lines: [], first: null, last: null }; + + const lines = log.split("\n"); + const first = lines[lines.length - 1].split(" ")[0]; // oldest + const last = lines[0].split(" ")[0]; // newest + return { lines, first, last }; +} + +// --------------------------------------------------------------------------- +// Format +// --------------------------------------------------------------------------- + +function formatBody(lines) { + return lines.map((l) => { + const space = l.indexOf(" "); + const hash = l.slice(0, space); + const msg = l.slice(space + 1); + return `${hash} ${msg}`; + }).join("\n"); +} + +// --------------------------------------------------------------------------- +// Short code (same logic as generate-short-code.mjs) +// --------------------------------------------------------------------------- + +const ALPHABET = "bcdfghjklmnpqrstvwxyzaeiou23456789"; + +function randomCode(len = 3) { + const bytes = randomBytes(len); + return Array.from(bytes).map((b) => ALPHABET[b % ALPHABET.length]).join(""); +} + +async function uniqueCode(collection) { + for (let i = 0; i < 100; i++) { + const code = `n${randomCode()}`; + const exists = await collection.findOne({ code }); + if (!exists) return code; + } + throw new Error("Could not generate unique code after 100 attempts"); +} + +// --------------------------------------------------------------------------- +// ATProto sync +// --------------------------------------------------------------------------- + +async function syncToAtproto(db, sub, newsData, refId) { + const users = db.collection("users"); + const user = await users.findOne({ _id: sub }); + + if (!user?.atproto?.did || !user?.atproto?.password) { + console.log(" No ATProto account for user, skipping PDS sync."); + return null; + } + + const agent = new AtpAgent({ service: PDS_URL }); + await agent.login({ + identifier: user.atproto.did, + password: user.atproto.password, + }); + + const record = { + $type: "computer.aesthetic.news", + headline: newsData.headline, + when: newsData.when.toISOString(), + ref: refId, + }; + if (newsData.body) record.body = newsData.body; + + const res = await agent.com.atproto.repo.createRecord({ + repo: user.atproto.did, + collection: "computer.aesthetic.news", + record, + }); + + return { + rkey: res.data.uri.split("/").pop(), + uri: res.data.uri, + did: user.atproto.did, + }; +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const dryRun = !!args["dry-run"]; + + const { lines, first, last } = getCommits(args); + + if (lines.length === 0) { + console.error("No commits found."); + process.exit(1); + } + + const title = `Commits From ${first} to ${last}`; + const body = formatBody(lines); + + console.log(`\n${title}`); + console.log(`${lines.length} commit(s)\n`); + console.log(body); + + if (dryRun) { + console.log("\n--dry-run: not posting."); + return; + } + + if (!MONGODB_URI) { + console.error("MONGODB_CONNECTION_STRING not set."); + process.exit(1); + } + if (!ADMIN_SUB) { + console.error("ADMIN_SUB not set."); + process.exit(1); + } + + const client = new MongoClient(MONGODB_URI); + try { + await client.connect(); + const db = client.db(MONGODB_NAME); + const posts = db.collection("news-posts"); + const votes = db.collection("news-votes"); + + const code = await uniqueCode(posts); + const now = new Date(); + + const doc = { + code, + title, + url: "", + text: body, + user: ADMIN_SUB, + when: now, + updated: now, + score: 1, + commentCount: 0, + status: "live", + }; + + await posts.insertOne(doc); + await votes.insertOne({ + itemType: "post", + itemId: code, + user: ADMIN_SUB, + when: now, + }); + + console.log(`\nPosted to news.aesthetic.computer/${code}`); + + // ATProto sync + try { + const atproto = await syncToAtproto( + db, + ADMIN_SUB, + { headline: title, body, when: now }, + doc._id?.toString(), + ); + if (atproto) { + await posts.updateOne({ code }, { $set: { atproto } }); + console.log(` ATProto synced: ${atproto.uri}`); + } + } catch (e) { + console.log(` ATProto sync failed: ${e.message}`); + } + + console.log(` URL: https://news.aesthetic.computer/${code}`); + } finally { + await client.close(); + } +} + +main().catch((err) => { + console.error("Failed:", err.message); + process.exit(1); +}); diff --git a/system/public/aesthetic.computer/disks/blank.mjs b/system/public/aesthetic.computer/disks/blank.mjs index e95d74bc12..852c4af644 100644 --- a/system/public/aesthetic.computer/disks/blank.mjs +++ b/system/public/aesthetic.computer/disks/blank.mjs @@ -215,7 +215,7 @@ function paint($) { // Title + product description (centered, below HUD label) ink(fg).write("AC Blank", { center: "x", y: 24, size: 2, screen }); - ink(fgDim).write(DESCRIPTION, { center: "x", y: 48, width: floor(w * 0.8), screen }); + ink(fgDim).write(DESCRIPTION, { center: "x", y: 48, screen }, undefined, floor(w * 0.85)); // Thanks page if (thanks) { diff --git a/system/public/aesthetic.computer/lib/ui.mjs b/system/public/aesthetic.computer/lib/ui.mjs index 47392fefd2..e4b6b9bc07 100644 --- a/system/public/aesthetic.computer/lib/ui.mjs +++ b/system/public/aesthetic.computer/lib/ui.mjs @@ -854,6 +854,7 @@ class TextButton { #g2 = this.#gap * 2; #h = 12 + this.#g2; // 19; // #offset = { x: this.#gap, y: this.#gap }; + #typeface = null; constructor(text = "Button", pos = { x: 0, y: 0 }, typeface = TYPEFACE_UI, gap = null) { // Allow custom gap/padding if provided @@ -863,6 +864,7 @@ class TextButton { this.#offset = { x: this.#gap, y: this.#gap }; } + this.#typeface = typeface; this.#cw = typeface.blockWidth; this.#h = typeface.blockHeight + this.#gap * 2; @@ -906,9 +908,18 @@ class TextButton { this.btn.stickyScrubbing = value; } + #measureTextWidth(text) { + const visibleText = stripColorCodes(text); + if (this.#typeface?.getAdvance) { + let w = 0; + for (const ch of visibleText) w += this.#typeface.getAdvance(ch); + return w; + } + return visibleText.length * this.#cw; + } + get width() { - const visibleText = stripColorCodes(this.txt); - return visibleText.length * this.#cw + this.#gap * 2; + return this.#measureTextWidth(this.txt) + this.#gap * 2; } get height() { @@ -922,8 +933,7 @@ class TextButton { #computePosition(text, pos = { x: 0, y: 0 }) { pos = { ...pos }; // Make a shallow copy of pos because we will mutate it. let x, y; - const visibleText = stripColorCodes(text); - const w = visibleText.length * this.#cw + this.#g2; + const w = this.#measureTextWidth(text) + this.#g2; const h = this.#h; if (pos.screen) { diff --git a/vscode-extension/package-lock.json b/vscode-extension/package-lock.json index a892284c18..af85a9d932 100644 --- a/vscode-extension/package-lock.json +++ b/vscode-extension/package-lock.json @@ -1,12 +1,12 @@ { "name": "aesthetic-computer-code", - "version": "1.270.0", + "version": "1.271.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aesthetic-computer-code", - "version": "1.270.0", + "version": "1.271.0", "license": "None", "dependencies": { "acorn": "^8.15.0", diff --git a/vscode-extension/package.json b/vscode-extension/package.json index b57b14f384..faddde12ac 100644 --- a/vscode-extension/package.json +++ b/vscode-extension/package.json @@ -4,7 +4,7 @@ "displayName": "Aesthetic Computer", "icon": "resources/icon.png", "author": "Jeffrey Alan Scudder", - "version": "1.270.0", + "version": "1.271.0", "description": "Code, run, and publish your pieces. Includes Aesthetic Computer themes and KidLisp syntax highlighting.", "engines": { "vscode": "^1.105.0"