feat(utils): add embed builder (#3145)

* feat: add embed builder

* feat: account for if a parsed color is NaN

* feat: add code doc

* feat: add example to JSDoc

* feat: add ability to override the current embed index

* style: remove extra whitespace

* feat: add validate method logic

* feat: finish remaining todo

* fix: account for length value in the setCurrentEmbed call

* feat: removing custom error and throw generic

* feat: add initial tests

* feat: add builder object with embeds function

* feat: changing to a method instead of object

* feat: add more tests

* style: lint
This commit is contained in:
Matthew Hatcher
2023-10-06 03:54:11 +00:00
committed by GitHub
parent 832df1697a
commit 9f5e3ab9ff
4 changed files with 447 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
import { EmbedsBuilder } from './builders/embeds.js'
export * from './builders/embeds.js'
export const createEmbeds = (): EmbedsBuilder => new EmbedsBuilder()
+379
View File
@@ -0,0 +1,379 @@
import type {
DiscordEmbed,
DiscordEmbedAuthor,
DiscordEmbedField,
DiscordEmbedFooter,
DiscordEmbedImage,
DiscordEmbedThumbnail,
DiscordEmbedVideo,
} from '@discordeno/types'
/**
* A builder to help create Discord embeds.
*
* @export
* @class EmbedsBuilder
* @typedef {EmbedsBuilder}
* @extends {Array<DiscordEmbed>}
* @example
* const embeds = new EmbedBuilder()
* .setTitle('My Embed')
* .setDescription('This is my new embed')
* .newEmbed()
* .setTitle('My Second Embed')
*/
export class EmbedsBuilder extends Array<DiscordEmbed> {
#currentEmbedIndex: number = 0
/**
* Adds a new field to the embed fields array.
*
* @param {string} name - Field name
* @param {string} value - Field value
* @param {?boolean} [inline=false] - Field should be inline or not.
* @returns {EmbedsBuilder}
*/
addField(name: string, value: string, inline?: boolean): EmbedsBuilder {
if (this.#currentEmbed.fields === undefined) {
this.#currentEmbed.fields = []
}
this.#currentEmbed.fields.push({
name,
value,
inline,
})
return this
}
/**
* Creates a blank embed.
*
* @returns {EmbedsBuilder}
*/
newEmbed(): EmbedsBuilder {
if (this.length >= 10) {
throw new Error('Maximum embed count exceeded. You can not have more than 10 embeds.')
}
this.push({})
this.setCurrentEmbed()
return this
}
/**
* Set the current embed author.
*
* @param {string} name - Name of the author
* @param {?Omit<DiscordEmbedAuthor, 'name'>} [options] - Extra author options
* @returns {EmbedsBuilder}
*/
setAuthor(name: string, options?: Omit<DiscordEmbedAuthor, 'name'>): EmbedsBuilder {
this.#currentEmbed.author = {
...this.#currentEmbed.author,
...options,
name,
}
return this
}
/**
* Set the color on the side of the current embed.
*
* @param {(number | string)} color - The color, in base16 or hex color code
* @returns {EmbedsBuilder}
*/
setColor(color: number | string): EmbedsBuilder {
if (typeof color === 'string') {
const convertedValue = parseInt(color.replace('#', ''), 16)
color = Number.isNaN(convertedValue) ? 0 : convertedValue
}
this.#currentEmbed.color = color
return this
}
/**
* Set the current embed to a different index.
*
* WARNING: Only use this method if you know what you're doing. Make sure to set it back to the latest when you're done.
*
* @param {?number} [index] - The index of the embed in the EmbedsBuilder array
* @returns {EmbedsBuilder}
*/
setCurrentEmbed(index?: number): EmbedsBuilder {
if (index === undefined) {
this.#currentEmbedIndex = this.length - 1
return this
}
if (index >= this.length || index < 0) {
throw new Error('Can not set the current embed to a index out of bounds.')
}
this.#currentEmbedIndex = index
return this
}
/**
* Set the description of the current embed.
*
* @param {string} description - Description
* @returns {EmbedsBuilder}
*/
setDescription(description: string): EmbedsBuilder {
this.#currentEmbed.description = description
return this
}
/**
* Overwrite all fields on the current embed.
*
* @param {DiscordEmbedField[]} fields
* @returns {EmbedsBuilder}
*/
setFields(fields: DiscordEmbedField[]): EmbedsBuilder {
this.#currentEmbed.fields = fields
return this
}
/**
* Set the footer in the current embed.
*
* @param {string} text - The text to display in the footer
* @param {?Omit<DiscordEmbedFooter, 'text'>} [options]
* @returns {EmbedsBuilder}
*/
setFooter(text: string, options?: Omit<DiscordEmbedFooter, 'text'>): EmbedsBuilder {
this.#currentEmbed.footer = {
...this.#currentEmbed.footer,
...options,
text,
}
return this
}
/**
* Set the image in the current embed.
*
* @param {string} url - URL of the image
* @param {?Omit<DiscordEmbedImage, 'url'>} [options]
* @returns {EmbedsBuilder}
*/
setImage(url: string, options?: Omit<DiscordEmbedImage, 'url'>): EmbedsBuilder {
this.#currentEmbed.image = {
...this.#currentEmbed.image,
...options,
url,
}
return this
}
/**
* Set the provider of the current embed.
*
* @param {string} name
* @param {?string} [url]
* @returns {EmbedsBuilder}
*/
setProvider(name: string, url?: string): EmbedsBuilder {
this.#currentEmbed.provider = {
name,
url,
}
return this
}
/**
* Set the color of the current embed to a random value.
*
* @returns {EmbedsBuilder}
*/
setRandomColor(): EmbedsBuilder {
return this.setColor(Math.floor(Math.random() * (0xffffff + 1)))
}
/**
* Set the title of the current embed.
*
* @param {string} title
* @param {?string} [url]
* @returns {EmbedsBuilder}
*/
setTitle(title: string, url?: string): EmbedsBuilder {
this.#currentEmbed.title = title
if (url) {
this.setUrl(url)
}
return this
}
/**
* Set the timestamp of the current embed.
*
* @param {?(string | number | Date)} [timestamp]
* @returns {EmbedsBuilder}
*/
setTimestamp(timestamp?: string | number | Date): EmbedsBuilder {
this.#currentEmbed.timestamp = new Date(timestamp!).toISOString()
return this
}
/**
* Set the thumbnail of the current embed.
*
* @param {string} url - URL of the image
* @param {?Omit<DiscordEmbedThumbnail, 'url'>} [options]
* @returns {EmbedsBuilder}
*/
setThumbnail(url: string, options?: Omit<DiscordEmbedThumbnail, 'url'>): EmbedsBuilder {
this.#currentEmbed.thumbnail = {
...this.#currentEmbed.thumbnail,
...options,
url,
}
return this
}
/**
* Set the URL of the current embed title.
*
* @param {string} url
* @returns {EmbedsBuilder}
*/
setUrl(url: string): EmbedsBuilder {
this.#currentEmbed.url = url
return this
}
/**
* Set the video of the current embed.
*
* @param {string} url
* @param {?Omit<DiscordEmbedVideo, 'url'>} [options]
* @returns {EmbedsBuilder}
*/
setVideo(url: string, options?: Omit<DiscordEmbedVideo, 'url'>): EmbedsBuilder {
this.#currentEmbed.video = {
...this.#currentEmbed.video,
...options,
url,
}
return this
}
/**
* Validate all embeds available against current known Discord limits to help prevent bad requests.
*
* @returns {EmbedsBuilder}
*/
validate(): EmbedsBuilder {
let totalCharacters = 0
if (this.length > 10) {
throw new Error('You can not have more than 10 embeds on a single message.')
}
this.forEach(({ author, description, fields, footer, title }, index) => {
if (title) {
const trimmedTitle = title.trim()
if (trimmedTitle.length > 256) {
throw new Error(`Title of embed ${index} can not be longer than 256 characters.`)
}
totalCharacters += trimmedTitle.length
}
if (description) {
const trimmedDescription = description.trim()
if (trimmedDescription.length > 4096) {
throw new Error(`Description of embed ${index} can not be longer than 4096 characters.`)
}
totalCharacters += trimmedDescription.length
}
if (fields) {
if (fields.length > 25) {
throw new Error(`embed ${index} can not have more than 25 fields.`)
}
fields.forEach(({ name, value }, fieldIndex) => {
const trimmedName = name.trim()
const trimmedValue = value.trim()
if (trimmedName.length > 256) {
throw new Error(`Name of field ${fieldIndex} on embed ${index} can not be longer than 256 characters.`)
}
if (trimmedValue.length > 4096) {
throw new Error(`Value of field ${fieldIndex} on embed ${index} can not be longer than 1024 characters.`)
}
totalCharacters += trimmedName.length
totalCharacters += trimmedValue.length
})
}
if (footer) {
const trimmedFooterText = footer.text.trim()
if (trimmedFooterText.length > 2048) {
throw new Error(`Footer text of embed ${index} can not be longer than 2048 characters.`)
}
totalCharacters += trimmedFooterText.length
}
if (author) {
const trimmedAuthorName = author.name.trim()
if (trimmedAuthorName.length > 256) {
throw new Error(`Author name of embed ${index} can not be longer than 256 characters.`)
}
totalCharacters += trimmedAuthorName.length
}
})
if (totalCharacters > 6000) {
throw new Error('Total character length of all embeds can not exceed 6000 characters.')
}
return this
}
/**
* Returns the current embed.
*
* @readonly
* @type {DiscordEmbed}
*/
get #currentEmbed(): DiscordEmbed {
if (this.length === 0) {
this.newEmbed()
this.setCurrentEmbed()
}
return this[this.#currentEmbedIndex]
}
}
+1
View File
@@ -1,6 +1,7 @@
export * from './Collection.js'
export * from './base64.js'
export * from './bucket.js'
export * from './builders.js'
export * from './casing.js'
export * from './colors.js'
export * from './hash.js'
+62
View File
@@ -0,0 +1,62 @@
import { expect } from 'chai'
import { describe, it } from 'mocha'
import { EmbedsBuilder } from '../src/builders.js'
describe('builders/embeds.ts', () => {
it('should create a new blank embed JSON', () => {
expect(new EmbedsBuilder().newEmbed()).to.eql([{}])
})
it('should set the author name in the embed JSON', () => {
expect(new EmbedsBuilder().setAuthor('Author')).to.eql([{ author: { name: 'Author' } }])
})
it('should set the color in the embed JSON', () => {
expect(new EmbedsBuilder().setColor('#000000')).to.eql([{ color: 0 }])
expect(new EmbedsBuilder().setColor('#21fa99')).to.eql([{ color: 2226841 }])
expect(new EmbedsBuilder().setColor('#thisisnotacolor')).to.eql([{ color: 0 }])
expect(new EmbedsBuilder().setColor(13530)).to.eql([{ color: 13530 }])
})
it('should set the description in the embed JSON', () => {
expect(new EmbedsBuilder().setDescription('My Description')).to.eql([{ description: 'My Description' }])
})
it('should set the fields in the embed JSON', () => {
expect(
new EmbedsBuilder().setFields([
{ name: 'firstname', value: 'firstvalue' },
{ name: 'secondname', value: 'secondvalue', inline: true },
]),
).to.eql([
{
fields: [
{ name: 'firstname', value: 'firstvalue' },
{ name: 'secondname', value: 'secondvalue', inline: true },
],
},
])
})
it('should set the footer text in the embed JSON', () => {
expect(new EmbedsBuilder().setFooter('footer text')).to.eql([{ footer: { text: 'footer text' } }])
})
it('should set the set a random color in the embed JSON', () => {
expect(new EmbedsBuilder().setRandomColor()[0]).to.haveOwnProperty('color')
})
it('should set the timestamp in the embed JSON', () => {
const now = new Date()
expect(new EmbedsBuilder().setTimestamp(now)).to.eql([{ timestamp: now.toISOString() }])
})
it('should set the title in the embed JSON', () => {
expect(new EmbedsBuilder().setTitle('My Title')).to.eql([{ title: 'My Title' }])
})
it('should set the url in the embed JSON', () => {
expect(new EmbedsBuilder().setUrl('https://google.com')).to.eql([{ url: 'https://google.com' }])
})
})