mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-07 03:35:40 -05:00
Changelog images via CLI (#3373)
This commit is contained in:
21
AGENTS.md
21
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-<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
|
||||
|
||||
35
app/features/changelog/changelog-constants.ts
Normal file
35
app/features/changelog/changelog-constants.ts
Normal 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";
|
||||
22
app/features/changelog/changelog-search-params.test.ts
Normal file
22
app/features/changelog/changelog-search-params.test.ts
Normal 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"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
11
app/features/changelog/changelog-search-params.ts
Normal file
11
app/features/changelog/changelog-search-params.ts
Normal 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,
|
||||
}),
|
||||
});
|
||||
109
app/features/changelog/components/ChangelogGraphic.module.css
Normal file
109
app/features/changelog/components/ChangelogGraphic.module.css
Normal 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);
|
||||
}
|
||||
134
app/features/changelog/components/ChangelogGraphic.tsx
Normal file
134
app/features/changelog/components/ChangelogGraphic.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
app/features/changelog/core/entries.server.test.ts
Normal file
14
app/features/changelog/core/entries.server.test.ts
Normal 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("");
|
||||
}
|
||||
});
|
||||
});
|
||||
103
app/features/changelog/core/entries.server.ts
Normal file
103
app/features/changelog/core/entries.server.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
11
app/features/changelog/loaders/changelog-image.server.ts
Normal file
11
app/features/changelog/loaders/changelog-image.server.ts
Normal 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(),
|
||||
};
|
||||
};
|
||||
28
app/features/changelog/routes/changelog-image.tsx
Normal file
28
app/features/changelog/routes/changelog-image.tsx
Normal 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=<sha></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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
5
changelog/2026-08-28-bracket-team-text-color.md
Normal file
5
changelog/2026-08-28-bracket-team-text-color.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
navItem: calendar
|
||||
type: bug
|
||||
---
|
||||
Fixed team name (not own) color in the bracket
|
||||
5
changelog/2026-08-28-scrim-map-tracking-lock.md
Normal file
5
changelog/2026-08-28-scrim-map-tracking-lock.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
navItem: scrims
|
||||
type: bug
|
||||
---
|
||||
Fixed scrim map tracking locking before the scrim had even started
|
||||
5
changelog/2026-08-29-bracket-destination-links.md
Normal file
5
changelog/2026-08-29-bracket-destination-links.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
navItem: calendar
|
||||
type: bug
|
||||
---
|
||||
Fixed the links to follow-up brackets doing nothing on the bracket page
|
||||
5
changelog/2026-08-29-calendar-scroll-snapping.md
Normal file
5
changelog/2026-08-29-calendar-scroll-snapping.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
navItem: calendar
|
||||
type: feature
|
||||
---
|
||||
Calendar day columns now snap into place when scrolling sideways
|
||||
5
changelog/2026-08-29-in-game-name-characters.md
Normal file
5
changelog/2026-08-29-in-game-name-characters.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
navItem: settings
|
||||
type: bug
|
||||
---
|
||||
In-game names input now accepts more characters (kanji and hangul) that were missing
|
||||
9
changelog/2026-08-29-map-planner.md
Normal file
9
changelog/2026-08-29-map-planner.md
Normal 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
|
||||
5
changelog/2026-08-29-round-robin-group-sizes.md
Normal file
5
changelog/2026-08-29-round-robin-group-sizes.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
navItem: calendar
|
||||
type: feature
|
||||
---
|
||||
Round robin brackets can now be set up with groups of up to 8 teams
|
||||
5
changelog/2026-08-29-shared-twitch-streams.md
Normal file
5
changelog/2026-08-29-shared-twitch-streams.md
Normal 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
|
||||
@@ -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`
|
||||
|
||||
@@ -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"
|
||||
|
||||
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