Changelog images via CLI (#3373)

This commit is contained in:
Kalle
2026-08-29 13:18:43 +03:00
committed by GitHub
parent d0f5d037b5
commit e2d0437d3e
24 changed files with 899 additions and 9 deletions

View File

@@ -96,6 +96,27 @@
- when using namespace e.g. `const { t } = useTranslation("settings"]);` it needs to be defined in the `handle` for that route e.g. `export const handle: SendouRouteHandle = { i18n: ["settings"], ... }`. Certain namespaces are always included and you don't have to worry about those: "common", "forms", "game-misc", "weapons", "front", "friends"
- if changing translation key names make sure to port over any already translated values for non-english languages if the english language is unchanged
## Changelog
- every user facing change needs a changelog entry, added in the same commit as the change itself. One file per change, a commit can add several. Purely internal work (refactors, dependency bumps, dev tooling, tests) gets none
- entries live at `changelog/YYYY-MM-DD-<slug>.md` and are never deleted, they are the update history
- frontmatter is `type` (`feature` or `bug`) and optionally `navItem`, which must be one of `OG_IMAGE_PAGES` (`app/utils/urls.ts`) and picks the icon shown next to the entry. Omitted = the sendou.ink logo. A change to a page with no nav item of its own is filed under the closest existing one
- the body is either short (a one line headline) or long (headline followed by a markdown bullet list, for a big feature release)
- write them for users and not developers: what changed for them, not how it was implemented
```md
---
navItem: plans
type: feature
---
Map planner improvements
- Plans are saved and restored when you come back to the page
- Undo & redo are back, now in the toolbar
```
- on update day these become the image posted on social media, see [how-to.md](./docs/dev/how-to.md)
## Commits
- do not mention claude or claude code

View File

@@ -0,0 +1,35 @@
import type { OgImagePage } from "~/utils/urls";
export const CHANGELOG_FOLDER_PATH = "changelog";
/**
* Discord emoji shortcode per nav item, either one uploaded to the server or a
* built-in. Typing `:name:` in the Discord client resolves to the emoji, so the
* generated post is copy-pasteable. Most match the nav item name but not all.
*/
export const DISCORD_EMOJI_NAMES: Record<OgImagePage, string> = {
settings: "settings",
sendouq: "sendouq",
analyzer: "analyzer",
"comp-analyzer": "comp_analyzer",
builds: "builds",
"object-damage-calculator": "object_damage_calculator",
leaderboards: "leaderboards",
scrims: "scrims",
lfg: "lfg",
plans: "plans",
trophies: "trophies",
// built-in 📆
calendar: "calendar~1",
plus: "plus",
xsearch: "xsearch",
articles: "articles",
vods: "vods",
art: "art",
"tier-list-maker": "tier_list_maker",
links: "links",
maps: "maps",
};
/** Stands in for the sendou.ink logo, used by entries without a nav item. */
export const DISCORD_FALLBACK_EMOJI_NAME = "sendou";

View File

@@ -0,0 +1,22 @@
import { describe, test } from "vitest";
import {
assertDecodesToDefault,
assertRoundTrips,
} from "~/modules/search-params/search-params-test-utils";
import { changelogSearchParams } from "./changelog-search-params";
describe("changelogSearchParams", () => {
test("round-trips", () => {
assertRoundTrips(changelogSearchParams, {
since: ["317a3a3", "317a3a3f0b6f9e0d1c2b3a4958677889aabbccdd"],
});
});
test("garbage decodes to default", () => {
assertDecodesToDefault(changelogSearchParams, "since", [
["not-a-sha"],
["317a3a"],
["HEAD~1"],
]);
});
});

View File

@@ -0,0 +1,11 @@
import * as v from "valibot";
import * as SearchParams from "~/modules/search-params/search-params";
import { SP } from "~/modules/search-params/search-params";
const COMMIT_SHA_PATTERN = /^[0-9a-f]{7,40}$/;
export const changelogSearchParams = SearchParams.define({
since: SP.param(v.nullable(v.pipe(v.string(), v.regex(COMMIT_SHA_PATTERN))), {
loader: true,
}),
});

View File

@@ -0,0 +1,109 @@
.wrapper {
display: flex;
width: fit-content;
}
.titleAccent {
color: var(--graphic-accent);
}
/* the site nav's S.ink logo; em-sized so the same mark scales from the header
avatar slot down to the entry icons */
.logo {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 2.5em;
height: 2.25em;
background-color: var(--color-text-accent);
border-radius: var(--radius-field);
font-weight: var(--weight-bold);
color: var(--color-text-inverse);
line-height: 1;
}
.logoHeader {
font-size: 19px;
}
.logoS {
position: relative;
top: -0.25em;
font-size: 0.875em;
}
.logoInk {
position: relative;
bottom: -0.25em;
font-size: 0.75em;
}
.entries {
display: flex;
flex-direction: column;
gap: var(--s-2);
/* the shared graphic keeps text on one line for the in-app export, this one is
screenshotted by a real browser and its entries are whole sentences */
white-space: normal;
text-wrap: pretty;
}
.entryIcon {
flex: none;
display: flex;
}
.longFeature {
padding: var(--s-3) var(--s-4);
background-color: var(--graphic-row-bg);
border: 1.5px solid var(--graphic-row-border);
border-radius: var(--radius-box);
}
.longFeatureHeader {
display: flex;
align-items: center;
gap: var(--s-3);
}
.longFeatureHeadline {
font-size: var(--font-md);
font-weight: var(--weight-extra);
line-height: 1.15;
}
.bullets {
display: flex;
flex-direction: column;
gap: var(--s-1);
margin: var(--s-2) 0 0;
padding-left: var(--s-6);
font-size: var(--font-sm);
list-style: disc;
& li::marker {
color: var(--graphic-accent);
}
}
.shortFeature {
display: flex;
align-items: center;
gap: var(--s-3);
padding: var(--s-2) var(--s-4);
background-color: var(--graphic-row-bg);
border: 1.5px solid var(--graphic-row-border);
border-radius: var(--radius-box);
font-size: var(--font-sm);
font-weight: var(--weight-semi);
}
.fix {
display: flex;
align-items: center;
gap: var(--s-2);
padding-inline: var(--s-2);
font-size: var(--font-xs);
font-weight: var(--weight-semi);
}

View File

@@ -0,0 +1,134 @@
import clsx from "clsx";
import type * as React from "react";
import { Image } from "~/components/Image";
import {
GraphicContainer,
GraphicDateSubtitle,
GraphicHeader,
GraphicSectionDivider,
GraphicTitle,
} from "~/features/img-export/components/Graphic";
import { navIconUrl, type OgImagePage } from "~/utils/urls";
import styles from "./ChangelogGraphic.module.css";
const GRAPHIC_WIDTH = 560;
export interface ChangelogGraphicEntry {
/** Nav icon shown next to the entry; omitted = sendou.ink logo */
navItem?: OgImagePage;
type: "feature" | "bug";
headline: string;
bullets?: string[];
}
export function ChangelogGraphic({
date,
entries,
}: {
date: Date;
entries: ChangelogGraphicEntry[];
}) {
const longFeatures = entries.filter(
(entry) => entry.type === "feature" && (entry.bullets?.length ?? 0) > 0,
);
const shortFeatures = entries.filter(
(entry) => entry.type === "feature" && !entry.bullets?.length,
);
const fixes = entries.filter((entry) => entry.type === "bug");
return (
<div
className={styles.wrapper}
data-theme="dark"
data-default-theme
data-changelog-canvas
>
<GraphicContainer width={GRAPHIC_WIDTH}>
<GraphicHeader
leading={<SiteLogoMark className={styles.logoHeader} />}
titleRow={
<GraphicTitle>
sendou<span className={styles.titleAccent}>.ink</span> update
</GraphicTitle>
}
subtitle={<GraphicDateSubtitle date={date} />}
/>
<div className={styles.entries}>
{longFeatures.map((entry) => (
<LongFeature key={entry.headline} entry={entry} />
))}
{shortFeatures.map((entry) => (
<div key={entry.headline} className={styles.shortFeature}>
<EntryIcon entry={entry} size={28} />
{entry.headline}
</div>
))}
{fixes.length > 0 ? (
<>
<GraphicSectionDivider>Fixes</GraphicSectionDivider>
{fixes.map((entry) => (
<div key={entry.headline} className={styles.fix}>
<EntryIcon entry={entry} size={22} />
{entry.headline}
</div>
))}
</>
) : null}
</div>
</GraphicContainer>
</div>
);
}
function LongFeature({ entry }: { entry: ChangelogGraphicEntry }) {
return (
<section className={styles.longFeature}>
<div className={styles.longFeatureHeader}>
<EntryIcon entry={entry} size={36} />
<h2 className={styles.longFeatureHeadline}>{entry.headline}</h2>
</div>
<ul className={styles.bullets}>
{entry.bullets?.map((bullet) => (
<li key={bullet}>{bullet}</li>
))}
</ul>
</section>
);
}
function EntryIcon({
entry,
size,
}: {
entry: ChangelogGraphicEntry;
size: number;
}) {
if (!entry.navItem) {
return <SiteLogoMark style={{ fontSize: size / 2.25 }} />;
}
return (
<Image
path={navIconUrl(entry.navItem)}
alt=""
size={size}
containerClassName={styles.entryIcon}
loading="eager"
/>
);
}
function SiteLogoMark({
className,
style,
}: {
className?: string;
style?: React.CSSProperties;
}) {
return (
<div className={clsx(styles.logo, className)} style={style}>
<span className={styles.logoS}>S</span>
<span className={styles.logoInk}>ink</span>
</div>
);
}

View File

@@ -0,0 +1,14 @@
import { describe, expect, test } from "vitest";
import * as Entries from "./entries.server";
describe("Entries.allEntries", () => {
test("parses every committed changelog entry", () => {
expect(() => Entries.allEntries()).not.toThrow();
});
test("gives every entry a headline", () => {
for (const entry of Entries.allEntries()) {
expect(entry.headline).not.toBe("");
}
});
});

View File

@@ -0,0 +1,103 @@
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import matter from "gray-matter";
import * as v from "valibot";
import { OG_IMAGE_PAGES } from "~/utils/urls";
import { CHANGELOG_FOLDER_PATH } from "../changelog-constants";
import type { ChangelogGraphicEntry } from "../components/ChangelogGraphic";
const RESOLVED_CHANGELOG_DIR = path.resolve(CHANGELOG_FOLDER_PATH);
const BULLET_LINE_PATTERN = /^[-*]\s+/;
const frontmatterSchema = v.object({
navItem: v.optional(v.picklist(OG_IMAGE_PAGES)),
type: v.picklist(["feature", "bug"] as const),
});
/**
* Every changelog entry added between the given commit and HEAD, oldest first.
*
* @param since Sha of the commit the previous update was shipped from.
*/
export function entriesSince(since: string): ChangelogGraphicEntry[] {
const output = execFileSync(
"git",
[
"diff",
"--name-only",
"--diff-filter=A",
`${since}..HEAD`,
"--",
CHANGELOG_FOLDER_PATH,
],
{ encoding: "utf8" },
);
const fileNames = output
.split("\n")
.map((line) => line.trim())
.filter((line) => line.endsWith(".md"))
.map((filePath) => path.basename(filePath))
.sort();
return fileNames.map(parseEntryFile);
}
/** Every changelog entry ever committed, oldest first. */
export function allEntries(): ChangelogGraphicEntry[] {
return fs
.globSync("*.md", { cwd: RESOLVED_CHANGELOG_DIR })
.sort()
.map(parseEntryFile);
}
function parseEntryFile(fileName: string): ChangelogGraphicEntry {
const rawMarkdown = fs.readFileSync(
path.join(RESOLVED_CHANGELOG_DIR, fileName),
"utf8",
);
const { content, data } = matter(rawMarkdown);
let frontmatter: v.InferOutput<typeof frontmatterSchema>;
try {
frontmatter = v.parse(frontmatterSchema, data);
} catch (error) {
throw new Error(
`Invalid frontmatter in changelog entry "${fileName}": ${
error instanceof v.ValiError ? error.message : String(error)
}`,
);
}
const { headline, bullets } = parseBody(content, fileName);
return { ...frontmatter, headline, bullets };
}
function parseBody(content: string, fileName: string) {
const lines = content
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
const headlineLines = lines.filter((line) => !BULLET_LINE_PATTERN.test(line));
const bullets = lines
.filter((line) => BULLET_LINE_PATTERN.test(line))
.map((line) => line.replace(BULLET_LINE_PATTERN, ""));
if (headlineLines.length === 0) {
throw new Error(`Changelog entry "${fileName}" has no headline`);
}
if (headlineLines.length > 1) {
throw new Error(
`Changelog entry "${fileName}" has more than one headline paragraph`,
);
}
return {
headline: headlineLines[0],
bullets: bullets.length > 0 ? bullets : undefined,
};
}

View File

@@ -0,0 +1,11 @@
import type { LoaderFunctionArgs } from "react-router";
import { changelogSearchParams } from "../changelog-search-params";
import * as Entries from "../core/entries.server";
export const loader = ({ request }: LoaderFunctionArgs) => {
const { since } = changelogSearchParams.parse(request);
return {
entries: since ? Entries.entriesSince(since) : Entries.allEntries(),
};
};

View File

@@ -0,0 +1,28 @@
import { useLoaderData } from "react-router";
import { Main } from "~/components/Main";
import { ChangelogGraphic } from "../components/ChangelogGraphic";
import { loader } from "../loaders/changelog-image.server";
export { loader };
// this page is not accessible in production, its canvas is screenshotted by
// scripts/generate-changelog-image.ts
export default function ChangelogImagePage() {
const data = useLoaderData<typeof loader>();
return (
<Main className="stack lg">
<div className="stack sm">
<h1>Changelog Image</h1>
<div className="text-sm text-lighter">
{data.entries.length} entries. Add <code>?since=&lt;sha&gt;</code> to
only show the entries added after that commit.
</div>
</div>
<div data-changelog-entries={JSON.stringify(data.entries)}>
<ChangelogGraphic date={new Date()} entries={data.entries} />
</div>
</Main>
);
}

View File

@@ -52,6 +52,10 @@ import { SubNav, SubNavLink } from "~/components/SubNav";
import { Table } from "~/components/Table";
import { TierPill } from "~/components/TierPill";
import { WeaponSelect } from "~/components/WeaponSelect";
import {
ChangelogGraphic,
type ChangelogGraphicEntry,
} from "~/features/changelog/components/ChangelogGraphic";
import {
SeasonSummaryGraphic,
type SeasonSummaryGraphicActivity,
@@ -124,6 +128,11 @@ export const SECTIONS = [
id: "season-summary-graphic",
component: SeasonSummaryGraphicSection,
},
{
title: "Changelog Graphic",
id: "changelog-graphic",
component: ChangelogGraphicSection,
},
{
title: "Form Messages",
id: "form-messages",
@@ -2165,6 +2174,110 @@ function SeasonSummaryGraphicSection({ id }: { id: string }) {
);
}
const CHANGELOG_GRAPHIC_ENTRIES_SMALL: ChangelogGraphicEntry[] = [
{
navItem: "plans",
type: "feature",
headline: "Map planner plans persist across sessions",
},
{
navItem: "calendar",
type: "feature",
headline: "Calendar scroll snapping on mobile",
},
{
navItem: "builds",
type: "bug",
headline: "Fixed build filters resetting when navigating back",
},
];
const CHANGELOG_GRAPHIC_ENTRIES_LARGE: ChangelogGraphicEntry[] = [
{
navItem: "plans",
type: "feature",
headline: "Map planner rework",
bullets: [
"Undo & redo support",
"Plans persist across sessions",
"New drawing tools including shapes and text",
"Weapon images can be flipped",
],
},
{
navItem: "sendouq",
type: "feature",
headline: "SendouQ match improvements",
bullets: [
"Stacked chat sidebar shows all your rooms",
"Post-match screen shows SP changes",
],
},
{
navItem: "calendar",
type: "feature",
headline: "Calendar scroll snapping on mobile",
},
{
navItem: "leaderboards",
type: "feature",
headline: "Team leaderboard now shows team logos",
},
{
type: "feature",
headline: "Faster page loads across the site",
},
{
navItem: "builds",
type: "bug",
headline: "Fixed build filters resetting when navigating back",
},
{
navItem: "sendouq",
type: "bug",
headline: "Fixed chat messages sometimes arriving out of order",
},
{
navItem: "calendar",
type: "bug",
headline: "Fixed event times showing in the wrong timezone",
},
{
navItem: "vods",
type: "feature",
headline: "VoDs can be filtered by tournament",
},
{
navItem: "lfg",
type: "bug",
headline: "Fixed expired LFG posts appearing in search results",
},
];
function ChangelogGraphicSection({ id }: { id: string }) {
return (
<Section>
<SectionTitle id={id}>Changelog Graphic</SectionTitle>
<div className="stack md">
<ComponentRow label="Small update (3 entries)">
<ChangelogGraphic
date={new Date(2026, 7, 29)}
entries={CHANGELOG_GRAPHIC_ENTRIES_SMALL}
/>
</ComponentRow>
<ComponentRow label="Big update (10 entries)">
<ChangelogGraphic
date={new Date(2026, 7, 29)}
entries={CHANGELOG_GRAPHIC_ENTRIES_LARGE}
/>
</ComponentRow>
</div>
</Section>
);
}
function FormMessageSection({ id }: { id: string }) {
return (
<Section>

View File

@@ -71,31 +71,35 @@ export function GraphicContainer({
export function GraphicHeader({
avatarUrl,
identiconInput,
leading,
titleRow,
subtitle,
trailing,
alignTrailingWithTitle = false,
}: {
avatarUrl?: string;
identiconInput: string;
titleRow: React.ReactNode;
subtitle: React.ReactNode;
trailing?: React.ReactNode;
/** Line the trailing content up with the title instead of centering it against the title and subtitle together */
alignTrailingWithTitle?: boolean;
}) {
} & (
| { avatarUrl?: string; identiconInput: string; leading?: never }
| { leading: React.ReactNode; avatarUrl?: never; identiconInput?: never }
)) {
const trailingContent = trailing ? (
<div className={styles.headerTrailing}>{trailing}</div>
) : null;
return (
<header className={styles.header}>
<Avatar
url={avatarUrl}
identiconInput={identiconInput}
size="sm"
alt=""
/>
{leading ?? (
<Avatar
url={avatarUrl}
identiconInput={identiconInput ?? ""}
size="sm"
alt=""
/>
)}
<div className={styles.headerText}>
<div className={styles.headerTitleRow}>
{titleRow}

View File

@@ -14,6 +14,10 @@ const devOnlyRoutes =
"features/admin/routes/generate-images.tsx",
),
route("/admin/og-images", "features/admin/routes/og-images.tsx"),
route(
"/admin/changelog-image",
"features/changelog/routes/changelog-image.tsx",
),
route(
"/components",
"features/components-showcase/routes/components.tsx",

View File

@@ -0,0 +1,5 @@
---
navItem: calendar
type: bug
---
Fixed team name (not own) color in the bracket

View File

@@ -0,0 +1,5 @@
---
navItem: scrims
type: bug
---
Fixed scrim map tracking locking before the scrim had even started

View File

@@ -0,0 +1,5 @@
---
navItem: calendar
type: bug
---
Fixed the links to follow-up brackets doing nothing on the bracket page

View File

@@ -0,0 +1,5 @@
---
navItem: calendar
type: feature
---
Calendar day columns now snap into place when scrolling sideways

View File

@@ -0,0 +1,5 @@
---
navItem: settings
type: bug
---
In-game names input now accepts more characters (kanji and hangul) that were missing

View File

@@ -0,0 +1,9 @@
---
navItem: plans
type: feature
---
Map planner improvements
- Plans are saved and restored when you come back to the page
- Undo & redo are back, now in the toolbar
- Stage, mode, background and water level are kept in the URL so a view can be shared

View File

@@ -0,0 +1,5 @@
---
navItem: calendar
type: feature
---
Round robin brackets can now be set up with groups of up to 8 teams

View File

@@ -0,0 +1,5 @@
---
navItem: sendouq
type: bug
---
Fixed streams missing from SendouQ and tournaments when the same Twitch account is linked to several users

View File

@@ -74,6 +74,14 @@ OG images are the preview images shown when a page is shared on Discord, Bluesky
New pages need to be added to `OG_IMAGE_PAGES` and to `PAGE_COLORS` on the preview page. Routes opt in via `image: ogPageImage("<page>")` given to `metaTags`.
## Generate the update changelog image
The image posted on social media on update day is built from the entry files in `changelog/`.
1) Every commit with a user facing change adds one `changelog/YYYY-MM-DD-<slug>.md` per change. Frontmatter is `navItem` (optional, must be one of `OG_IMAGE_PAGES`, omitted = sendou.ink logo) and `type` (`feature` or `bug`). The body is a one line headline, optionally followed by a markdown bullet list for a bigger release. Entries are never deleted, they are the update history.
2) Preview and tweak the graphic on the `/admin/changelog-image` page (dev only). Without a `?since=<sha>` it renders every entry ever committed.
3) With the dev server running, `pnpm run changelog:image <sha-of-previous-update-commit>` writes `scripts/output/update-<date>.png` from the entries added since that commit, copies the image to the clipboard.
## Add a new translation string
1) Decide on where the translation should go. Either `common.json` which is available in every route by default or a feature specific one such as `builds.json`

View File

@@ -47,6 +47,7 @@
"seed": "cross-env DB_PATH=db.sqlite3 vite-node ./scripts/seed.ts",
"setup": "cross-env DB_PATH=db.sqlite3 vite-node ./scripts/setup.ts",
"og:generate": "node scripts/generate-og-images.ts",
"changelog:image": "node scripts/generate-changelog-image.ts",
"notification:test": "cross-env DB_PATH=db.sqlite3 vite-node ./scripts/send-test-notification.ts",
"i18n:sync": "node scripts/collapse-single-plural-keys.ts && i18next-locales-sync -e true -p en -s da de es-ES es-US fr-CA fr-EU he it ja ko nl pl pt-BR ru zh -l locales && pnpm run biome:fix",
"knip": "knip"

View 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);
});