Skip to content

Commit 8c7ed6d

Browse files
committed
💄 Restyle release announcements with Components V2
- Matches Amy's current message style: icon header, a section per category, install links as buttons - Breaking changes get their own section and are never trimmed - Trims categories proportionally so long releases show both changes and fixes - Drops the Discord template and the render options only it used
1 parent 51d5193 commit 8c7ed6d

6 files changed

Lines changed: 303 additions & 168 deletions

File tree

‎.scripts/plugins/releaseNoteTemplates/discord_release_notes_template‎

Lines changed: 0 additions & 11 deletions
This file was deleted.

‎.scripts/release/announce.ts‎

Lines changed: 2 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -10,25 +10,18 @@
1010
* `DISCORD_ROLE_MINOR`, `DISCORD_ROLE_PATCH`, `DISCORD_ROLE_BREAKING`.
1111
*/
1212
import { readFileSync } from 'node:fs'
13+
import { buildAnnouncement } from './announcement'
1314
import {
1415
type Changelog,
1516
type ReleasePing,
1617
getChangelogEntry,
1718
getReleasePings,
18-
getReleaseUrl,
19-
renderReleaseNotes,
2019
} from './releaseNotes'
2120

2221
const DISCORD_API = 'https://discord.com/api/v10'
2322
const USER_AGENT = 'DiscordBot (https://github.com/Animated-Java/animated-java, 1.0)'
24-
const TEMPLATE_PATH = './.scripts/plugins/releaseNoteTemplates/discord_release_notes_template'
2523
const CHANGELOG_PATH = './src/pluginPackage/changelog.json'
2624
const IS_COMPONENTS_V2 = 1 << 15
27-
// Shared by every text display in a Components V2 message.
28-
const MAX_TEXT_LENGTH = 4000
29-
const ACCENT_COLOR = 0x00aced
30-
const BREAKING_LABEL =
31-
'<:BreakingEmoji0:1432852678903463976><:BreakingEmoji1:1432852680404893818><:BreakingEmoji2:1432852682091270144><:BreakingEmoji3:1432852683534110790>'
3225
const ROLE_ENV_VARS: Record<ReleasePing, string> = {
3326
prerelease: 'DISCORD_ROLE_PRERELEASE',
3427
minor: 'DISCORD_ROLE_MINOR',
@@ -69,35 +62,11 @@ async function main() {
6962
const changelog = JSON.parse(readFileSync(CHANGELOG_PATH, 'utf-8')) as Changelog
7063
const entry = getChangelogEntry(changelog, version)
7164
const roleIds = getReleasePings(version, entry).map(ping => requireEnv(ROLE_ENV_VARS[ping]))
72-
const notes = renderReleaseNotes(readFileSync(TEMPLATE_PATH, 'utf-8'), version, entry, {
73-
vars: { pings: roleIds.map(id => `<@&${id}>`).join(' ') },
74-
maxLength: MAX_TEXT_LENGTH,
75-
breakingLabel: BREAKING_LABEL,
76-
})
7765

7866
const message = await discordPost(`/channels/${channelId}/messages`, {
7967
flags: IS_COMPONENTS_V2,
8068
allowed_mentions: { parse: [], roles: live ? roleIds : [] },
81-
components: [
82-
{
83-
type: 17, // Container
84-
accent_color: ACCENT_COLOR,
85-
components: [
86-
{ type: 10, content: notes }, // Text Display
87-
{
88-
type: 1, // Action Row
89-
components: [
90-
{
91-
type: 2,
92-
style: 5,
93-
label: 'View on GitHub',
94-
url: getReleaseUrl(version),
95-
},
96-
],
97-
},
98-
],
99-
},
100-
],
69+
components: buildAnnouncement(version, entry, roleIds),
10170
})
10271
console.log(`Posted v${version} announcement (message ${message.id}) to channel ${channelId}`)
10372

‎.scripts/release/announcement.ts‎

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import {
2+
type ChangelogCategory,
3+
type ChangelogEntry,
4+
BREAKING_TAG,
5+
getReleaseAssetUrl,
6+
getReleaseType,
7+
getReleaseUrl,
8+
} from './releaseNotes'
9+
10+
// Shared by every Text Display in a Components V2 message.
11+
const MAX_TEXT_LENGTH = 4000
12+
13+
const ANIMATED_JAVA_EMOJI = '<:AnimatedJava:1349340042379661392>'
14+
const ICON_URL =
15+
'https://raw.githubusercontent.com/Animated-Java/animated-java/refs/heads/main/src/assets/icons/animated_java_fancy_icon_centered.png'
16+
const INSTALL_GUIDE_URL = 'https://animated-java.dev/docs/getting-started/installing'
17+
const BREAKING_TITLE = 'Breaking Changes'
18+
const RELEASE_TYPE_LABELS = {
19+
prerelease: 'Pre-release',
20+
minor: 'Minor release',
21+
patch: 'Patch release',
22+
}
23+
const CATEGORY_EMOJI: Record<string, string> = {
24+
[BREAKING_TITLE]: '⚠️',
25+
Changes: '✨',
26+
Fixes: '🐛',
27+
}
28+
29+
function textDisplay(content: string) {
30+
return { type: 10, content }
31+
}
32+
33+
function largeSeparator() {
34+
return { type: 14, spacing: 2 }
35+
}
36+
37+
function linkButton(label: string, url: string) {
38+
return { type: 2, style: 5, label, url }
39+
}
40+
41+
function formatDate(date: string) {
42+
return new Date(date).toLocaleDateString('en-US', { dateStyle: 'long', timeZone: 'UTC' })
43+
}
44+
45+
function formatCategory({ title, list }: ChangelogCategory) {
46+
const emoji = CATEGORY_EMOJI[title]
47+
return `## ${emoji ? emoji + ' ' : ''}${title}\n` + list.map(item => '- ' + item).join('\n')
48+
}
49+
50+
/** Moves every `[BREAKING]` item into its own category, placed first. */
51+
function splitBreaking(categories: ChangelogCategory[]): ChangelogCategory[] {
52+
const breaking: string[] = []
53+
const rest = categories.map(category => ({
54+
title: category.title,
55+
list: category.list.filter(item => {
56+
if (!item.includes(BREAKING_TAG)) return true
57+
breaking.push(item.replaceAll(BREAKING_TAG, '').trim())
58+
return false
59+
}),
60+
}))
61+
return [{ title: BREAKING_TITLE, list: breaking }, ...rest]
62+
}
63+
64+
/**
65+
* Picks the category to drop an item from: the one keeping the largest share of its original
66+
* items, so every category shrinks proportionally. Ties go to the later category. Index 0 holds the
67+
* breaking changes, which are never dropped. Returns -1 when nothing is left to drop.
68+
*/
69+
function pickCategoryToTrim(categories: ChangelogCategory[], originalLengths: number[]) {
70+
let picked = -1
71+
let pickedShare = 0
72+
for (let i = 1; i < categories.length; i++) {
73+
const share = categories[i].list.length / originalLengths[i]
74+
if (share > 0 && share >= pickedShare) {
75+
picked = i
76+
pickedShare = share
77+
}
78+
}
79+
return picked
80+
}
81+
82+
/**
83+
* Formats each non-empty category as its own section, dropping items from the ends of categories
84+
* until they fit in `budget` characters.
85+
*/
86+
function formatCategories(version: string, categories: ChangelogCategory[], budget: number) {
87+
const originalLengths = categories.map(category => category.list.length)
88+
categories = categories.map(category => ({ ...category, list: [...category.list] }))
89+
let dropped = 0
90+
for (;;) {
91+
const sections = categories.filter(category => category.list.length > 0).map(formatCategory)
92+
if (dropped > 0) {
93+
sections[sections.length - 1] +=
94+
`\n-# …plus ${dropped} more in the [full release notes](${getReleaseUrl(version)})`
95+
}
96+
if (sections.join('').length <= budget) return sections
97+
98+
const index = pickCategoryToTrim(categories, originalLengths)
99+
if (index === -1) {
100+
throw new Error(
101+
`Release notes for ${version} don't fit in ${MAX_TEXT_LENGTH} characters, even with every non-breaking item removed`
102+
)
103+
}
104+
categories[index].list.pop()
105+
dropped++
106+
}
107+
}
108+
109+
/**
110+
* Builds a release announcement as Components V2, in the style of the Animated Java Discord bot's
111+
* messages: a header with the icon, a section per changelog category, then install links.
112+
*/
113+
export function buildAnnouncement(version: string, entry: ChangelogEntry, roleIds: string[]) {
114+
const header = textDisplay(
115+
`# ${ANIMATED_JAVA_EMOJI} Animated Java v${version}\n` +
116+
`-# ${RELEASE_TYPE_LABELS[getReleaseType(version)]} · ${formatDate(entry.date)}`
117+
)
118+
const install = textDisplay(
119+
'## 📦 How to Install\n' +
120+
"New releases take a while to reach Blockbench's plugin list, so to get this one right away, " +
121+
`download \`animated_java.js\` directly or [install it via URL](${INSTALL_GUIDE_URL}#advanced-installation-url).`
122+
)
123+
const footer = roleIds.length
124+
? [textDisplay('-# ' + roleIds.map(id => `<@&${id}>`).join(' '))]
125+
: []
126+
127+
const fixedLength = [header, install, ...footer].map(c => c.content).join('').length
128+
const sections = formatCategories(
129+
version,
130+
splitBreaking(entry.categories),
131+
MAX_TEXT_LENGTH - fixedLength
132+
)
133+
134+
return [
135+
{
136+
type: 9, // Section
137+
components: [header],
138+
accessory: { type: 11, media: { url: ICON_URL } }, // Thumbnail
139+
},
140+
largeSeparator(),
141+
...sections.map(textDisplay),
142+
largeSeparator(),
143+
{
144+
type: 9, // Section
145+
components: [install],
146+
accessory: linkButton('Download', getReleaseAssetUrl(version, 'animated_java.js')),
147+
},
148+
{
149+
type: 1, // Action Row
150+
components: [
151+
linkButton('View on GitHub', getReleaseUrl(version)),
152+
linkButton('Installation Guide', INSTALL_GUIDE_URL),
153+
],
154+
},
155+
...footer,
156+
]
157+
}

‎.scripts/release/releaseNotes.ts‎

Lines changed: 26 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -12,50 +12,43 @@ export interface ChangelogEntry {
1212

1313
export type Changelog = Record<string, ChangelogEntry>
1414

15-
export type ReleasePing = 'prerelease' | 'minor' | 'patch' | 'breaking'
15+
export type ReleaseType = 'prerelease' | 'minor' | 'patch'
16+
export type ReleasePing = ReleaseType | 'breaking'
1617

17-
export interface RenderOptions {
18-
/** Extra `{name}` template variables, such as `pings`. */
19-
vars?: Record<string, string>
20-
/** Drop changelog items from the end until the notes fit, noting that some were cut. */
21-
maxLength?: number
22-
/** Replaces `[BREAKING]` in changelog items. */
23-
breakingLabel?: string
24-
}
18+
export const BREAKING_TAG = '[BREAKING]'
2519

2620
const REPO_URL = 'https://github.com/Animated-Java/animated-java'
27-
const BREAKING_TAG = '[BREAKING]'
28-
const DEFAULT_BREAKING_LABEL = '⚠️ **BREAKING** —'
29-
const ESCAPE_URLS_MARKER = '[[ESCAPE_URLS]]'
21+
const BREAKING_LABEL = '⚠️ **BREAKING** —'
3022
const VERSION_REGEX = /^(\d+)\.(\d+)\.(\d+)(-.+)?$/
31-
// Skips URLs already wrapped in <>, so they aren't escaped twice.
32-
const URL_REGEX =
33-
/(?<!<)https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9]{1,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/gm
3423

3524
export function getReleaseUrl(version: string) {
3625
return `${REPO_URL}/releases/tag/v${version}`
3726
}
3827

28+
export function getReleaseAssetUrl(version: string, fileName: string) {
29+
return `${REPO_URL}/releases/download/v${version}/${fileName}`
30+
}
31+
3932
export function getChangelogEntry(changelog: Changelog, version: string) {
4033
const entry = changelog[version]
4134
if (!entry) throw new Error(`No changelog entry found for version ${version}`)
4235
return entry
4336
}
4437

38+
export function getReleaseType(version: string): ReleaseType {
39+
const match = VERSION_REGEX.exec(version)
40+
if (!match) throw new Error(`Invalid version "${version}"`)
41+
const [, , , patch, prerelease] = match
42+
if (prerelease) return 'prerelease'
43+
return patch === '0' ? 'minor' : 'patch'
44+
}
45+
4546
/**
4647
* Picks which Discord roles to ping for a release: one for the release type, plus `breaking` if any
4748
* changelog item is tagged `[BREAKING]`.
4849
*/
4950
export function getReleasePings(version: string, entry: ChangelogEntry) {
50-
const match = VERSION_REGEX.exec(version)
51-
if (!match) throw new Error(`Invalid version "${version}"`)
52-
const [, , , patch, prerelease] = match
53-
54-
const pings: ReleasePing[] = []
55-
if (prerelease) pings.push('prerelease')
56-
else if (patch === '0') pings.push('minor')
57-
else pings.push('patch')
58-
51+
const pings: ReleasePing[] = [getReleaseType(version)]
5952
if (entry.categories.some(category => category.list.some(item => item.includes(BREAKING_TAG))))
6053
pings.push('breaking')
6154
return pings
@@ -65,69 +58,24 @@ function replaceTemplateVars(str: string, items: Record<string, string>) {
6558
return str.replace(/\{(.+?)\}/g, str => items[str.replace(/[\{\}]/g, '')] ?? str)
6659
}
6760

68-
function formatCategories(categories: ChangelogCategory[], breakingLabel: string) {
61+
function formatCategories(categories: ChangelogCategory[]) {
6962
return categories
7063
.filter(category => category.list.length > 0)
7164
.map(
7265
category =>
7366
`### ${category.title}\n\n` +
7467
category.list
75-
.map(item => '- ' + item.replaceAll(BREAKING_TAG, breakingLabel))
68+
.map(item => '- ' + item.replaceAll(BREAKING_TAG, BREAKING_LABEL))
7669
.join('\n')
7770
)
7871
.join('\n\n')
7972
}
8073

81-
/** Returns a copy of `categories` without its last item, or `undefined` if there are none left. */
82-
function dropLastItem(categories: ChangelogCategory[]) {
83-
let index = categories.length - 1
84-
while (index >= 0 && categories[index].list.length === 0) index--
85-
if (index === -1) return
86-
return categories.map((category, i) =>
87-
i === index ? { ...category, list: category.list.slice(0, -1) } : category
88-
)
89-
}
90-
91-
export function renderReleaseNotes(
92-
template: string,
93-
version: string,
94-
entry: ChangelogEntry,
95-
options: RenderOptions = {}
96-
) {
97-
const render = (categories: ChangelogCategory[], trimmed: boolean) => {
98-
let formatted = formatCategories(
99-
categories,
100-
options.breakingLabel ?? DEFAULT_BREAKING_LABEL
101-
)
102-
if (trimmed) {
103-
formatted += `\n\n-# …plus more in the [full release notes](${getReleaseUrl(version)})`
104-
}
105-
let content = replaceTemplateVars(template, {
106-
...options.vars,
107-
version,
108-
release_url: getReleaseUrl(version),
109-
categories: formatted,
110-
})
111-
if (content.includes(ESCAPE_URLS_MARKER)) {
112-
content = content
113-
.replace(ESCAPE_URLS_MARKER, '')
114-
.replaceAll(URL_REGEX, match => `<${match}>`)
115-
}
116-
return content.trim() + '\n'
117-
}
118-
119-
let categories: ChangelogCategory[] | undefined = entry.categories
120-
let notes = render(categories, false)
121-
if (options.maxLength === undefined) return notes
122-
123-
while (notes.length > options.maxLength) {
124-
categories = dropLastItem(categories)
125-
if (!categories) {
126-
throw new Error(
127-
`Release notes for ${version} don't fit in ${options.maxLength} characters, even with every changelog item removed`
128-
)
129-
}
130-
notes = render(categories, true)
131-
}
132-
return notes
74+
export function renderReleaseNotes(template: string, version: string, entry: ChangelogEntry) {
75+
const content = replaceTemplateVars(template, {
76+
version,
77+
release_url: getReleaseUrl(version),
78+
categories: formatCategories(entry.categories),
79+
})
80+
return content.trim() + '\n'
13381
}

0 commit comments

Comments
 (0)