refactor and simplify things

This commit is contained in:
MapleLeaf
2021-12-22 10:35:55 -06:00
parent 7b3ce42138
commit 765c6fadbb
26 changed files with 351 additions and 441 deletions

View File

@@ -74,14 +74,7 @@ test("empty embed fallback", async () => {
test("embed with only author", async () => {
await root.render(<Embed author={{ name: "only author" }} />)
await assertMessages([
{ embeds: [{ description: "_ _", author: { name: "only author" } }] },
])
})
test("empty embed author", async () => {
await root.render(<Embed author={{}} />)
await assertMessages([{ embeds: [{ description: "_ _" }] }])
await assertMessages([{ embeds: [{ author: { name: "only author" } }] }])
})
test("kitchen sink", async () => {
@@ -252,7 +245,7 @@ async function assertMessages(expected: MessageOptions[]) {
}
function extractMessageData(message: Message): MessageOptions {
return {
return pruneUndefinedValues({
content: nonEmptyOrUndefined(message.content),
embeds: nonEmptyOrUndefined(
pruneUndefinedValues(
@@ -305,7 +298,7 @@ function extractMessageData(message: Message): MessageOptions {
}),
})),
),
}
})
}
function pruneUndefinedValues<T>(input: T) {

View File

@@ -16,4 +16,5 @@ const config = {
verbose: true,
}
// eslint-disable-next-line import/no-unused-modules
export default config

View File

@@ -17,7 +17,7 @@
"lint": "eslint --ext js,ts,tsx .",
"lint-fix": "pnpm lint -- --fix",
"format": "prettier --write .",
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
"test": "node --experimental-vm-modules --no-warnings node_modules/jest/bin/jest.js",
"test-watch": "pnpm test -- --watch",
"coverage": "pnpm test -- --coverage",
"typecheck": "tsc --noEmit"

View File

@@ -1,45 +0,0 @@
import type {
MessageActionRowComponentOptions,
MessageOptions,
} from "discord.js"
import React from "react"
import { ContainerInstance } from "./container-instance.js"
export type ActionRowProps = {
children: React.ReactNode
}
export function ActionRow(props: ActionRowProps) {
return (
<reacord-element createInstance={() => new ActionRowInstance()}>
{props.children}
</reacord-element>
)
}
class ActionRowInstance extends ContainerInstance {
readonly name = "ActionRow"
constructor() {
super({ warnOnNonTextChildren: false })
}
// eslint-disable-next-line class-methods-use-this
override renderToMessage(options: MessageOptions) {
const row = {
type: "ACTION_ROW" as const,
components: [] as MessageActionRowComponentOptions[],
}
for (const child of this.children) {
if (!child.renderToActionRow) {
console.warn(`${child.name} is not an action row component`)
continue
}
child.renderToActionRow(row)
}
options.components ??= []
options.components.push(row)
}
}

View File

@@ -1,25 +0,0 @@
import type {
MessageActionRowOptions,
MessageEmbedOptions,
MessageOptions,
} from "discord.js"
export abstract class BaseInstance {
/** The name of the JSX element represented by this instance */
abstract readonly name: string
/** If the element represents text, the text for this element */
getText?(): string
/** If this element can be a child of a message,
* the function to modify the message options */
renderToMessage?(options: MessageOptions): void
/** If this element can be a child of an embed,
* the function to modify the embed options */
renderToEmbed?(options: MessageEmbedOptions): void
/** If this element can be a child of an action row,
* the function to modify the action row options */
renderToActionRow?(options: MessageActionRowOptions): void
}

View File

@@ -1,77 +0,0 @@
import type {
BaseMessageComponentOptions,
EmojiResolvable,
MessageActionRowOptions,
MessageButtonOptions,
MessageButtonStyle,
MessageOptions,
} from "discord.js"
import { nanoid } from "nanoid"
import React from "react"
import { ContainerInstance } from "./container-instance.js"
import { last } from "./helpers/last.js"
import { pick } from "./helpers/pick.js"
import { toUpper } from "./helpers/to-upper.js"
export type ButtonStyle = Exclude<Lowercase<MessageButtonStyle>, "link">
export type ButtonProps = {
style?: ButtonStyle
emoji?: EmojiResolvable
disabled?: boolean
children?: React.ReactNode
}
export function Button(props: ButtonProps) {
return (
<reacord-element createInstance={() => new ButtonInstance(props)}>
{props.children}
</reacord-element>
)
}
class ButtonInstance extends ContainerInstance {
readonly name = "Button"
constructor(private readonly props: ButtonProps) {
super({ warnOnNonTextChildren: true })
}
private getButtonOptions(): Required<BaseMessageComponentOptions> &
MessageButtonOptions {
return {
...pick(this.props, "emoji", "disabled"),
type: "BUTTON",
style: this.props.style ? toUpper(this.props.style) : "SECONDARY",
label: this.getChildrenText(),
customId: nanoid(),
}
}
override renderToMessage(options: MessageOptions) {
options.components ??= []
// i hate this
let actionRow:
| (Required<BaseMessageComponentOptions> & MessageActionRowOptions)
| undefined = last(options.components)
if (
!actionRow ||
actionRow.components[0]?.type === "SELECT_MENU" ||
actionRow.components.length >= 5
) {
actionRow = {
type: "ACTION_ROW",
components: [],
}
options.components.push(actionRow)
}
actionRow.components.push(this.getButtonOptions())
}
override renderToActionRow(row: MessageActionRowOptions) {
row.components.push(this.getButtonOptions())
}
}

View File

@@ -0,0 +1,13 @@
import React from "react"
export type ActionRowProps = {
children: React.ReactNode
}
export function ActionRow(props: ActionRowProps) {
return (
<reacord-element createNode={() => ({ type: "actionRow", children: [] })}>
{props.children}
</reacord-element>
)
}

21
src/components/button.tsx Normal file
View File

@@ -0,0 +1,21 @@
import type { EmojiResolvable, MessageButtonStyle } from "discord.js"
import React from "react"
export type ButtonStyle = Exclude<Lowercase<MessageButtonStyle>, "link">
export type ButtonProps = {
style?: ButtonStyle
emoji?: EmojiResolvable
disabled?: boolean
children?: React.ReactNode
}
export function Button(props: ButtonProps) {
return (
<reacord-element
createNode={() => ({ ...props, type: "button", children: [] })}
>
{props.children}
</reacord-element>
)
}

View File

@@ -0,0 +1,17 @@
import React from "react"
export type EmbedFieldProps = {
name: string
children: React.ReactNode
inline?: boolean
}
export function EmbedField(props: EmbedFieldProps) {
return (
<reacord-element
createNode={() => ({ ...props, type: "embedField", children: [] })}
>
{props.children}
</reacord-element>
)
}

32
src/components/embed.tsx Normal file
View File

@@ -0,0 +1,32 @@
import type { ColorResolvable } from "discord.js"
import type { ReactNode } from "react"
import React from "react"
export type EmbedProps = {
title?: string
color?: ColorResolvable
url?: string
timestamp?: Date | number | string
imageUrl?: string
thumbnailUrl?: string
author?: {
name: string
url?: string
iconUrl?: string
}
footer?: {
text: string
iconUrl?: string
}
children?: ReactNode
}
export function Embed(props: EmbedProps) {
return (
<reacord-element
createNode={() => ({ ...props, type: "embed", children: [] })}
>
{props.children}
</reacord-element>
)
}

14
src/components/text.tsx Normal file
View File

@@ -0,0 +1,14 @@
import type { ReactNode } from "react"
import React from "react"
export type TextProps = {
children?: ReactNode
}
export function Text(props: TextProps) {
return (
<reacord-element createNode={() => ({ type: "textElement", children: [] })}>
{props.children}
</reacord-element>
)
}

View File

@@ -1,36 +0,0 @@
import { BaseInstance } from "./base-instance.js"
// eslint-disable-next-line import/no-unused-modules
export type ContainerInstanceOptions = {
/**
* Whether or not to log a warning when calling getChildrenText() with non-text children
*
* Regardless of what this is set to, non-text children will always be skipped */
warnOnNonTextChildren: boolean
}
export abstract class ContainerInstance extends BaseInstance {
readonly children: BaseInstance[] = []
constructor(private readonly options: ContainerInstanceOptions) {
super()
}
add(child: BaseInstance) {
this.children.push(child)
}
protected getChildrenText(): string {
let text = ""
for (const child of this.children) {
if (!child.getText) {
if (this.options.warnOnNonTextChildren) {
console.warn(`${child.name} is not a valid child of ${this.name}`)
}
continue
}
text += child.getText()
}
return text
}
}

View File

@@ -1,34 +0,0 @@
import type { MessageEmbedOptions } from "discord.js"
import React from "react"
import { ContainerInstance } from "./container-instance.js"
import { pick } from "./helpers/pick.js"
export type EmbedFieldProps = {
name: string
children: React.ReactNode
inline?: boolean
}
export function EmbedField(props: EmbedFieldProps) {
return (
<reacord-element createInstance={() => new EmbedFieldInstance(props)}>
{props.children}
</reacord-element>
)
}
class EmbedFieldInstance extends ContainerInstance {
readonly name = "EmbedField"
constructor(private readonly props: EmbedFieldProps) {
super({ warnOnNonTextChildren: true })
}
override renderToEmbed(options: MessageEmbedOptions) {
options.fields ??= []
options.fields.push({
...pick(this.props, "name", "inline"),
value: this.getChildrenText(),
})
}
}

View File

@@ -1,85 +0,0 @@
import type {
ColorResolvable,
MessageEmbedOptions,
MessageOptions,
} from "discord.js"
import type { ReactNode } from "react"
import React from "react"
import { ContainerInstance } from "./container-instance.js"
export type EmbedProps = {
title?: string
color?: ColorResolvable
url?: string
timestamp?: Date | number | string
imageUrl?: string
thumbnailUrl?: string
author?: {
name?: string
url?: string
iconUrl?: string
}
footer?: {
text?: string
iconUrl?: string
}
children?: ReactNode
}
export function Embed(props: EmbedProps) {
return (
<reacord-element createInstance={() => new EmbedInstance(props)}>
{props.children}
</reacord-element>
)
}
class EmbedInstance extends ContainerInstance {
readonly name = "Embed"
constructor(readonly props: EmbedProps) {
super({ warnOnNonTextChildren: false })
}
override renderToMessage(message: MessageOptions) {
message.embeds ??= []
message.embeds.push(this.getEmbedOptions())
}
getEmbedOptions(): MessageEmbedOptions {
const options: MessageEmbedOptions = {
...this.props,
image: this.props.imageUrl ? { url: this.props.imageUrl } : undefined,
thumbnail: this.props.thumbnailUrl
? { url: this.props.thumbnailUrl }
: undefined,
author: {
...this.props.author,
iconURL: this.props.author?.iconUrl,
},
footer: {
text: "",
...this.props.footer,
iconURL: this.props.footer?.iconUrl,
},
timestamp: this.props.timestamp
? new Date(this.props.timestamp) // this _may_ need date-fns to parse this
: undefined,
}
for (const child of this.children) {
if (!child.renderToEmbed) {
console.warn(`${child.name} is not a valid child of ${this.name}`)
continue
}
child.renderToEmbed(options)
}
// can't render an empty embed
if (!options.description) {
options.description = "_ _"
}
return options
}
}

View File

@@ -1,3 +1,4 @@
// eslint-disable-next-line import/no-unused-modules
export function pick<T, K extends keyof T>(
object: T,
...keys: K[]

View File

@@ -1,3 +1,4 @@
/* eslint-disable import/no-unused-modules */
export type MaybePromise<T> = T | Promise<T>
export type ValueOf<Type> = Type extends ReadonlyArray<infer Value>

View File

@@ -2,6 +2,7 @@ import { rejectAfter } from "./reject-after.js"
import type { MaybePromise } from "./types.js"
import { waitFor } from "./wait-for.js"
// eslint-disable-next-line import/no-unused-modules
export function waitForWithTimeout(
condition: () => MaybePromise<boolean>,
timeout = 1000,

View File

@@ -1,5 +1,6 @@
import { inspect } from "node:util"
// eslint-disable-next-line import/no-unused-modules
export function withLoggedMethodCalls<T extends object>(value: T) {
return new Proxy(value as Record<string | symbol, unknown>, {
get(target, property) {

17
src/jsx.d.ts vendored
View File

@@ -1,11 +1,14 @@
declare namespace JSX {
import type { ReactNode } from "react"
import type { ReactNode } from "react"
import type { Node } from "./node-tree"
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
interface IntrinsicElements {
"reacord-element": {
createInstance: () => unknown
children?: ReactNode
declare global {
namespace JSX {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
interface IntrinsicElements {
"reacord-element": {
createNode: () => Node
children?: ReactNode
}
}
}
}

View File

@@ -1,7 +1,7 @@
/* eslint-disable import/no-unused-modules */
export * from "./action-row.js"
export * from "./button.js"
export * from "./embed-field.js"
export * from "./embed.js"
export * from "./components/action-row.js"
export * from "./components/button.js"
export * from "./components/embed-field.js"
export * from "./components/embed.js"
export * from "./components/text.js"
export * from "./root.js"
export * from "./text.js"

192
src/node-tree.ts Normal file
View File

@@ -0,0 +1,192 @@
import type {
BaseMessageComponentOptions,
ColorResolvable,
EmojiResolvable,
MessageActionRowOptions,
MessageEmbedOptions,
MessageOptions,
} from "discord.js"
import { nanoid } from "nanoid"
import type { ButtonStyle } from "./components/button.js"
import { last } from "./helpers/last.js"
import { toUpper } from "./helpers/to-upper.js"
export type MessageNode = {
type: "message"
children: Node[]
}
export type TextNode = {
type: "text"
text: string
}
type TextElementNode = {
type: "textElement"
children: Node[]
}
type EmbedNode = {
type: "embed"
title?: string
color?: ColorResolvable
url?: string
timestamp?: Date | number | string
imageUrl?: string
thumbnailUrl?: string
author?: {
name: string
url?: string
iconUrl?: string
}
footer?: {
text: string
iconUrl?: string
}
children: Node[]
}
type EmbedFieldNode = {
type: "embedField"
name: string
inline?: boolean
children: Node[]
}
type ActionRowNode = {
type: "actionRow"
children: Node[]
}
type ButtonNode = {
type: "button"
style?: ButtonStyle
emoji?: EmojiResolvable
disabled?: boolean
children: Node[]
}
export type Node =
| MessageNode
| TextNode
| TextElementNode
| EmbedNode
| EmbedFieldNode
| ActionRowNode
| ButtonNode
export function getMessageOptions(node: MessageNode): MessageOptions {
if (node.children.length === 0) {
// can't send an empty message
return { content: "_ _" }
}
const options: MessageOptions = {}
for (const child of node.children) {
if (child.type === "text" || child.type === "textElement") {
options.content = `${options.content ?? ""}${getNodeText(child)}`
}
if (child.type === "embed") {
options.embeds ??= []
options.embeds.push(getEmbedOptions(child))
}
if (child.type === "actionRow") {
options.components ??= []
options.components.push({
type: "ACTION_ROW",
components: [],
})
addActionRowItems(options.components, child.children)
}
if (child.type === "button") {
options.components ??= []
addActionRowItems(options.components, [child])
}
}
if (!options.content && !options.embeds?.length) {
options.content = "_ _"
}
return options
}
function getNodeText(node: Node): string | undefined {
if (node.type === "text") {
return node.text
}
if (node.type === "textElement") {
return node.children.map(getNodeText).join("")
}
}
function getEmbedOptions(node: EmbedNode) {
const options: MessageEmbedOptions = {
title: node.title,
color: node.color,
url: node.url,
timestamp: node.timestamp ? new Date(node.timestamp) : undefined,
image: { url: node.imageUrl },
thumbnail: { url: node.thumbnailUrl },
description: node.children.map(getNodeText).join(""),
author: node.author
? { ...node.author, iconURL: node.author.iconUrl }
: undefined,
footer: node.footer
? { text: node.footer.text, iconURL: node.footer.iconUrl }
: undefined,
}
for (const child of node.children) {
if (child.type === "embedField") {
options.fields ??= []
options.fields.push({
name: child.name,
value: child.children.map(getNodeText).join("") || "_ _",
inline: child.inline,
})
}
}
if (!options.description && !options.author) {
options.description = "_ _"
}
return options
}
type ActionRowOptions = Required<BaseMessageComponentOptions> &
MessageActionRowOptions
function addActionRowItems(components: ActionRowOptions[], nodes: Node[]) {
let actionRow = last(components)
if (
actionRow == undefined ||
actionRow.components[0]?.type === "SELECT_MENU" ||
actionRow.components.length >= 5
) {
actionRow = {
type: "ACTION_ROW",
components: [],
}
components.push(actionRow)
}
for (const node of nodes) {
if (node.type === "button") {
actionRow.components.push({
type: "BUTTON",
label: node.children.map(getNodeText).join(""),
style: node.style ? toUpper(node.style) : "SECONDARY",
emoji: node.emoji,
disabled: node.disabled,
customId: nanoid(),
})
}
}
}

View File

@@ -1,46 +1,41 @@
/* eslint-disable unicorn/no-null */
import { inspect } from "node:util"
import ReactReconciler from "react-reconciler"
import { BaseInstance } from "./base-instance.js"
import { ContainerInstance } from "./container-instance.js"
import type { ReacordContainer } from "./container.js"
import { raise } from "./helpers/raise.js"
import { TextInstance } from "./text-instance.js"
import type { MessageNode, Node, TextNode } from "./node-tree.js"
import type { MessageRenderer } from "./renderer.js"
type ElementTag = string
type Props = Record<string, unknown>
const createInstance = (type: string, props: Props): BaseInstance => {
const createInstance = (type: string, props: Props): Node => {
if (type !== "reacord-element") {
raise(`createInstance: unknown type: ${type}`)
}
if (typeof props.createInstance !== "function") {
const actual = inspect(props.createInstance)
raise(`invalid createInstance function, received: ${actual}`)
if (typeof props.createNode !== "function") {
const actual = inspect(props.createNode)
raise(`invalid createNode function, received: ${actual}`)
}
const instance = props.createInstance()
if (!(instance instanceof BaseInstance)) {
raise(`invalid instance: ${inspect(instance)}`)
}
return instance
return props.createNode()
}
type ChildSet = MessageNode
export const reconciler = ReactReconciler<
string, // Type (jsx tag),
Props, // Props,
ReacordContainer, // Container,
BaseInstance, // Instance,
TextInstance, // TextInstance,
MessageRenderer, // Container,
Node, // Instance,
TextNode, // TextInstance,
never, // SuspenseInstance,
never, // HydratableInstance,
never, // PublicInstance,
null, // HostContext,
[], // UpdatePayload,
BaseInstance[], // ChildSet,
ChildSet, // ChildSet,
unknown, // TimeoutHandle,
unknown // NoTimeout
>({
@@ -59,41 +54,37 @@ export const reconciler = ReactReconciler<
createInstance,
createTextInstance: (text) => new TextInstance(text),
createTextInstance: (text) => ({ type: "text", text }),
createContainerChildSet: () => [],
createContainerChildSet: (): ChildSet => ({
type: "message",
children: [],
}),
appendChildToContainerChildSet: (
childSet: BaseInstance[],
child: BaseInstance,
) => {
childSet.push(child)
appendChildToContainerChildSet: (childSet: ChildSet, child: Node) => {
childSet.children.push(child)
},
finalizeContainerChildren: (
container: ReacordContainer,
children: BaseInstance[],
) => false,
finalizeContainerChildren: (container: MessageRenderer, children: ChildSet) =>
false,
replaceContainerChildren: (
container: ReacordContainer,
children: BaseInstance[],
container: MessageRenderer,
children: ChildSet,
) => {
container.render(children)
},
appendInitialChild: (parent, child) => {
if (parent instanceof ContainerInstance) {
parent.add(child)
if ("children" in parent) {
parent.children.push(child)
} else {
raise(
`Cannot append child of type ${child.constructor.name} to ${parent.constructor.name}`,
)
raise(`${parent.type} cannot have children`)
}
},
cloneInstance: (
instance: BaseInstance,
instance: Node,
_: unknown,
type: ElementTag,
oldProps: Props,

View File

@@ -1,11 +1,12 @@
import type { Message, MessageOptions, TextBasedChannels } from "discord.js"
import type { BaseInstance } from "./base-instance.js"
import type { MessageNode } from "./node-tree.js"
import { getMessageOptions } from "./node-tree.js"
type Action =
| { type: "updateMessage"; options: MessageOptions }
| { type: "deleteMessage" }
export class ReacordContainer {
export class MessageRenderer {
private channel: TextBasedChannels
private message?: Message
private actions: Action[] = []
@@ -15,22 +16,11 @@ export class ReacordContainer {
this.channel = channel
}
render(children: BaseInstance[]) {
const options: MessageOptions = {}
for (const child of children) {
if (!child.renderToMessage) {
console.warn(`${child.name} is not a valid message child`)
continue
}
child.renderToMessage(options)
}
// can't render an empty message
if (!options?.content && !options.embeds?.length) {
options.content = "_ _"
}
this.addAction({ type: "updateMessage", options })
render(node: MessageNode) {
this.addAction({
type: "updateMessage",
options: getMessageOptions(node),
})
}
destroy() {

View File

@@ -1,15 +1,15 @@
/* eslint-disable unicorn/no-null */
import type { TextBasedChannels } from "discord.js"
import type { ReactNode } from "react"
import { ReacordContainer } from "./container"
import { reconciler } from "./reconciler"
import { MessageRenderer } from "./renderer"
export type ReacordRenderTarget = TextBasedChannels
export type ReacordRoot = ReturnType<typeof createRoot>
export function createRoot(target: ReacordRenderTarget) {
const container = new ReacordContainer(target)
const container = new MessageRenderer(target)
const containerId = reconciler.createContainer(container, 0, false, null)
return {
render: (content: ReactNode) => {

View File

@@ -1,23 +0,0 @@
import type { MessageEmbedOptions, MessageOptions } from "discord.js"
import { BaseInstance } from "./base-instance.js"
/** Represents raw strings in JSX */
export class TextInstance extends BaseInstance {
readonly name = "Text"
constructor(private readonly text: string) {
super()
}
override getText() {
return this.text
}
override renderToMessage(options: MessageOptions) {
options.content = (options.content ?? "") + this.getText()
}
override renderToEmbed(options: MessageEmbedOptions) {
options.description = (options.description ?? "") + this.getText()
}
}

View File

@@ -1,36 +0,0 @@
import type { MessageEmbedOptions, MessageOptions } from "discord.js"
import type { ReactNode } from "react"
import React from "react"
import { ContainerInstance } from "./container-instance.js"
export type TextProps = {
children?: ReactNode
}
export function Text(props: TextProps) {
return (
<reacord-element createInstance={() => new TextElementInstance()}>
{props.children}
</reacord-element>
)
}
class TextElementInstance extends ContainerInstance {
readonly name = "Text"
constructor() {
super({ warnOnNonTextChildren: true })
}
override getText() {
return this.getChildrenText()
}
override renderToMessage(options: MessageOptions) {
options.content = (options.content ?? "") + this.getText()
}
override renderToEmbed(options: MessageEmbedOptions) {
options.description = (options.description ?? "") + this.getText()
}
}