mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-08 04:05:54 -05:00
Changelog images via CLI (#3373)
This commit is contained in:
228
scripts/generate-changelog-image.ts
Normal file
228
scripts/generate-changelog-image.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
/** biome-ignore-all lint/suspicious/noConsole: CLI script output */
|
||||
|
||||
// screenshots the canvas of the /admin/changelog-image page into a shareable PNG
|
||||
// and writes the same update as text, both as Bluesky alt text and as a Discord
|
||||
// post, see docs/dev/how-to.md
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { chromium, type Page } from "@playwright/test";
|
||||
import { format } from "date-fns";
|
||||
import sharp from "sharp";
|
||||
import {
|
||||
DISCORD_EMOJI_NAMES,
|
||||
DISCORD_FALLBACK_EMOJI_NAME,
|
||||
} from "../app/features/changelog/changelog-constants.ts";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
try {
|
||||
process.loadEnvFile();
|
||||
} catch {
|
||||
// .env is optional, the dev server port can also come from the environment
|
||||
}
|
||||
|
||||
const CHANGELOG_IMAGE_PAGE_URL = `http://localhost:${process.env.PORT ?? 5173}/admin/changelog-image`;
|
||||
|
||||
const OUT_DIR = fileURLToPath(new URL("./output", import.meta.url));
|
||||
|
||||
/** Doubles the canvas' CSS pixels so the posted image stays sharp. */
|
||||
const DEVICE_SCALE_FACTOR = 2;
|
||||
|
||||
interface ChangelogEntry {
|
||||
navItem?: keyof typeof DISCORD_EMOJI_NAMES;
|
||||
type: "feature" | "bug";
|
||||
headline: string;
|
||||
bullets?: string[];
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const since = process.argv[2];
|
||||
if (!since) {
|
||||
throw new Error(
|
||||
"Usage: pnpm run changelog:image <sha-of-previous-update-commit>",
|
||||
);
|
||||
}
|
||||
|
||||
await fs.mkdir(OUT_DIR, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({
|
||||
deviceScaleFactor: DEVICE_SCALE_FACTOR,
|
||||
colorScheme: "dark",
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
|
||||
await page.goto(`${CHANGELOG_IMAGE_PAGE_URL}?since=${since}`, {
|
||||
waitUntil: "networkidle",
|
||||
});
|
||||
await page.waitForFunction(() => document.fonts.status === "loaded");
|
||||
|
||||
const canvas = page.locator("[data-changelog-canvas]");
|
||||
if ((await canvas.count()) === 0) {
|
||||
throw new Error(`No changelog canvas found at ${CHANGELOG_IMAGE_PAGE_URL}`);
|
||||
}
|
||||
|
||||
const entries = await parseEntries(page);
|
||||
if (entries.length === 0) {
|
||||
throw new Error(
|
||||
`No changelog entries were added between ${since} and HEAD. Note that only committed entries count.`,
|
||||
);
|
||||
}
|
||||
|
||||
await canvas.evaluate(async (element) => {
|
||||
await Promise.all(
|
||||
Array.from(element.querySelectorAll("img")).map((image) =>
|
||||
image.decode().catch(() => null),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const screenshot = await canvas.screenshot({ type: "png" });
|
||||
|
||||
await browser.close();
|
||||
|
||||
const date = new Date();
|
||||
const fileNameBase = `update-${format(date, "yyyy-MM-dd")}`;
|
||||
|
||||
const imagePath = path.join(OUT_DIR, `${fileNameBase}.png`);
|
||||
await sharp(screenshot)
|
||||
.png({ compressionLevel: 9, effort: 10 })
|
||||
.toFile(imagePath);
|
||||
|
||||
const { size } = await fs.stat(imagePath);
|
||||
console.log(`${imagePath} (${Math.round(size / 1024)} kB)`);
|
||||
console.log(
|
||||
(await copyToClipboard(imagePath))
|
||||
? "Image copied to the clipboard"
|
||||
: "Could not copy the image to the clipboard",
|
||||
);
|
||||
|
||||
const versions = [
|
||||
{ label: "Alt text", suffix: "alt", text: altText(entries, date) },
|
||||
{ label: "Discord", suffix: "discord", text: discordText(entries, date) },
|
||||
];
|
||||
|
||||
for (const version of versions) {
|
||||
const textPath = path.join(
|
||||
OUT_DIR,
|
||||
`${fileNameBase}-${version.suffix}.txt`,
|
||||
);
|
||||
await fs.writeFile(textPath, version.text, "utf8");
|
||||
|
||||
console.log(`\n--- ${version.label} (${textPath}) ---\n`);
|
||||
console.log(version.text);
|
||||
}
|
||||
}
|
||||
|
||||
async function parseEntries(page: Page): Promise<ChangelogEntry[]> {
|
||||
const json = await page
|
||||
.locator("[data-changelog-entries]")
|
||||
.getAttribute("data-changelog-entries");
|
||||
|
||||
return json ? JSON.parse(json) : [];
|
||||
}
|
||||
|
||||
/** Plain prose for the image's alt text, where markup and emoji only get in the way. */
|
||||
function altText(entries: ChangelogEntry[], date: Date) {
|
||||
const { featured, oneLiners, fixes } = groupEntries(entries);
|
||||
|
||||
const sections = [heading(date)];
|
||||
|
||||
for (const entry of featured) {
|
||||
sections.push([entry.headline, ...bulletLines(entry)].join("\n"));
|
||||
}
|
||||
|
||||
for (const entry of oneLiners) {
|
||||
sections.push(entry.headline);
|
||||
}
|
||||
|
||||
if (fixes.length > 0) {
|
||||
sections.push(
|
||||
["Fixes", ...fixes.map((entry) => `- ${entry.headline}`)].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
return joinSections(sections);
|
||||
}
|
||||
|
||||
/** The update as a Discord post, leading every entry with the server emoji of its nav item. */
|
||||
function discordText(entries: ChangelogEntry[], date: Date) {
|
||||
const { featured, oneLiners, fixes } = groupEntries(entries);
|
||||
|
||||
const sections = [`**${heading(date)}**`];
|
||||
|
||||
for (const entry of featured) {
|
||||
sections.push(
|
||||
[`${emoji(entry)} **${entry.headline}**`, ...bulletLines(entry)].join(
|
||||
"\n",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (oneLiners.length > 0) {
|
||||
sections.push(
|
||||
oneLiners.map((entry) => `${emoji(entry)} ${entry.headline}`).join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
if (fixes.length > 0) {
|
||||
sections.push(
|
||||
[
|
||||
"**Fixes**",
|
||||
...fixes.map((entry) => `${emoji(entry)} ${entry.headline}`),
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
return joinSections(sections);
|
||||
}
|
||||
|
||||
function groupEntries(entries: ChangelogEntry[]) {
|
||||
const features = entries.filter((entry) => entry.type === "feature");
|
||||
|
||||
return {
|
||||
featured: features.filter((entry) => entry.bullets?.length),
|
||||
oneLiners: features.filter((entry) => !entry.bullets?.length),
|
||||
fixes: entries.filter((entry) => entry.type === "bug"),
|
||||
};
|
||||
}
|
||||
|
||||
function heading(date: Date) {
|
||||
return `sendou.ink update - ${format(date, "MMMM do yyyy")}`;
|
||||
}
|
||||
|
||||
function bulletLines(entry: ChangelogEntry) {
|
||||
return (entry.bullets ?? []).map((bullet) => `- ${bullet}`);
|
||||
}
|
||||
|
||||
function emoji(entry: ChangelogEntry) {
|
||||
return `:${entry.navItem ? DISCORD_EMOJI_NAMES[entry.navItem] : DISCORD_FALLBACK_EMOJI_NAME}:`;
|
||||
}
|
||||
|
||||
function joinSections(sections: string[]) {
|
||||
return `${sections.join("\n\n")}\n`;
|
||||
}
|
||||
|
||||
/** Puts the PNG itself (not its path) on the clipboard, ready to paste into a post. */
|
||||
async function copyToClipboard(filePath: string) {
|
||||
if (process.platform !== "darwin") return false;
|
||||
|
||||
try {
|
||||
await execFileAsync("osascript", [
|
||||
"-e",
|
||||
`set the clipboard to (read (POSIX file ${JSON.stringify(filePath)}) as «class PNGf»)`,
|
||||
]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user