diff --git a/AGENTS.md b/AGENTS.md index c348ee6f8..de3454c85 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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-.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 diff --git a/app/features/changelog/changelog-constants.ts b/app/features/changelog/changelog-constants.ts new file mode 100644 index 000000000..9c9dbf161 --- /dev/null +++ b/app/features/changelog/changelog-constants.ts @@ -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 = { + 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"; diff --git a/app/features/changelog/changelog-search-params.test.ts b/app/features/changelog/changelog-search-params.test.ts new file mode 100644 index 000000000..2701f3e0c --- /dev/null +++ b/app/features/changelog/changelog-search-params.test.ts @@ -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"], + ]); + }); +}); diff --git a/app/features/changelog/changelog-search-params.ts b/app/features/changelog/changelog-search-params.ts new file mode 100644 index 000000000..db9281fb9 --- /dev/null +++ b/app/features/changelog/changelog-search-params.ts @@ -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, + }), +}); diff --git a/app/features/changelog/components/ChangelogGraphic.module.css b/app/features/changelog/components/ChangelogGraphic.module.css new file mode 100644 index 000000000..f279b0690 --- /dev/null +++ b/app/features/changelog/components/ChangelogGraphic.module.css @@ -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); +} diff --git a/app/features/changelog/components/ChangelogGraphic.tsx b/app/features/changelog/components/ChangelogGraphic.tsx new file mode 100644 index 000000000..9ff54b23a --- /dev/null +++ b/app/features/changelog/components/ChangelogGraphic.tsx @@ -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 ( +
+ + } + titleRow={ + + sendou.ink update + + } + subtitle={} + /> +
+ {longFeatures.map((entry) => ( + + ))} + {shortFeatures.map((entry) => ( +
+ + {entry.headline} +
+ ))} + {fixes.length > 0 ? ( + <> + Fixes + {fixes.map((entry) => ( +
+ + {entry.headline} +
+ ))} + + ) : null} +
+
+
+ ); +} + +function LongFeature({ entry }: { entry: ChangelogGraphicEntry }) { + return ( +
+
+ +

{entry.headline}

+
+
    + {entry.bullets?.map((bullet) => ( +
  • {bullet}
  • + ))} +
+
+ ); +} + +function EntryIcon({ + entry, + size, +}: { + entry: ChangelogGraphicEntry; + size: number; +}) { + if (!entry.navItem) { + return ; + } + + return ( + + ); +} + +function SiteLogoMark({ + className, + style, +}: { + className?: string; + style?: React.CSSProperties; +}) { + return ( +
+ S + ink +
+ ); +} diff --git a/app/features/changelog/core/entries.server.test.ts b/app/features/changelog/core/entries.server.test.ts new file mode 100644 index 000000000..2985c641a --- /dev/null +++ b/app/features/changelog/core/entries.server.test.ts @@ -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(""); + } + }); +}); diff --git a/app/features/changelog/core/entries.server.ts b/app/features/changelog/core/entries.server.ts new file mode 100644 index 000000000..383b75360 --- /dev/null +++ b/app/features/changelog/core/entries.server.ts @@ -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; + 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, + }; +} diff --git a/app/features/changelog/loaders/changelog-image.server.ts b/app/features/changelog/loaders/changelog-image.server.ts new file mode 100644 index 000000000..5e7627087 --- /dev/null +++ b/app/features/changelog/loaders/changelog-image.server.ts @@ -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(), + }; +}; diff --git a/app/features/changelog/routes/changelog-image.tsx b/app/features/changelog/routes/changelog-image.tsx new file mode 100644 index 000000000..7ef510fe3 --- /dev/null +++ b/app/features/changelog/routes/changelog-image.tsx @@ -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(); + + return ( +
+
+

Changelog Image

+
+ {data.entries.length} entries. Add ?since=<sha> to + only show the entries added after that commit. +
+
+
+ +
+
+ ); +} diff --git a/app/features/components-showcase/routes/components.tsx b/app/features/components-showcase/routes/components.tsx index 4fdf549d0..852997037 100644 --- a/app/features/components-showcase/routes/components.tsx +++ b/app/features/components-showcase/routes/components.tsx @@ -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 ( +
+ Changelog Graphic + +
+ + + + + + + +
+
+ ); +} + function FormMessageSection({ id }: { id: string }) { return (
diff --git a/app/features/img-export/components/Graphic.tsx b/app/features/img-export/components/Graphic.tsx index 3ab2de534..aa0972696 100644 --- a/app/features/img-export/components/Graphic.tsx +++ b/app/features/img-export/components/Graphic.tsx @@ -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 ? (
{trailing}
) : null; return (
- + {leading ?? ( + + )}
{titleRow} diff --git a/app/routes.ts b/app/routes.ts index 09376f223..93039d1de 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -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", diff --git a/changelog/2026-08-28-bracket-team-text-color.md b/changelog/2026-08-28-bracket-team-text-color.md new file mode 100644 index 000000000..6716f3857 --- /dev/null +++ b/changelog/2026-08-28-bracket-team-text-color.md @@ -0,0 +1,5 @@ +--- +navItem: calendar +type: bug +--- +Fixed team name (not own) color in the bracket diff --git a/changelog/2026-08-28-scrim-map-tracking-lock.md b/changelog/2026-08-28-scrim-map-tracking-lock.md new file mode 100644 index 000000000..d52c37176 --- /dev/null +++ b/changelog/2026-08-28-scrim-map-tracking-lock.md @@ -0,0 +1,5 @@ +--- +navItem: scrims +type: bug +--- +Fixed scrim map tracking locking before the scrim had even started diff --git a/changelog/2026-08-29-bracket-destination-links.md b/changelog/2026-08-29-bracket-destination-links.md new file mode 100644 index 000000000..9e28a04a5 --- /dev/null +++ b/changelog/2026-08-29-bracket-destination-links.md @@ -0,0 +1,5 @@ +--- +navItem: calendar +type: bug +--- +Fixed the links to follow-up brackets doing nothing on the bracket page diff --git a/changelog/2026-08-29-calendar-scroll-snapping.md b/changelog/2026-08-29-calendar-scroll-snapping.md new file mode 100644 index 000000000..9b7fed11e --- /dev/null +++ b/changelog/2026-08-29-calendar-scroll-snapping.md @@ -0,0 +1,5 @@ +--- +navItem: calendar +type: feature +--- +Calendar day columns now snap into place when scrolling sideways diff --git a/changelog/2026-08-29-in-game-name-characters.md b/changelog/2026-08-29-in-game-name-characters.md new file mode 100644 index 000000000..b73a65d29 --- /dev/null +++ b/changelog/2026-08-29-in-game-name-characters.md @@ -0,0 +1,5 @@ +--- +navItem: settings +type: bug +--- +In-game names input now accepts more characters (kanji and hangul) that were missing diff --git a/changelog/2026-08-29-map-planner.md b/changelog/2026-08-29-map-planner.md new file mode 100644 index 000000000..d4c16fb01 --- /dev/null +++ b/changelog/2026-08-29-map-planner.md @@ -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 diff --git a/changelog/2026-08-29-round-robin-group-sizes.md b/changelog/2026-08-29-round-robin-group-sizes.md new file mode 100644 index 000000000..27f940c7a --- /dev/null +++ b/changelog/2026-08-29-round-robin-group-sizes.md @@ -0,0 +1,5 @@ +--- +navItem: calendar +type: feature +--- +Round robin brackets can now be set up with groups of up to 8 teams diff --git a/changelog/2026-08-29-shared-twitch-streams.md b/changelog/2026-08-29-shared-twitch-streams.md new file mode 100644 index 000000000..e766ff5e7 --- /dev/null +++ b/changelog/2026-08-29-shared-twitch-streams.md @@ -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 diff --git a/docs/dev/how-to.md b/docs/dev/how-to.md index 539cc2e04..e496a1e90 100644 --- a/docs/dev/how-to.md +++ b/docs/dev/how-to.md @@ -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("")` 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-.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=` it renders every entry ever committed. +3) With the dev server running, `pnpm run changelog:image ` writes `scripts/output/update-.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` diff --git a/package.json b/package.json index 2559b3c6b..3aa21818a 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/scripts/generate-changelog-image.ts b/scripts/generate-changelog-image.ts new file mode 100644 index 000000000..4f442cfa4 --- /dev/null +++ b/scripts/generate-changelog-image.ts @@ -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 ", + ); + } + + 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 { + 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); +});