decentralization refactor wip
This commit is contained in:
@@ -40,7 +40,7 @@ test.beforeEach(async () => {
|
|||||||
await Promise.all(messages.map((message) => message.delete()))
|
await Promise.all(messages.map((message) => message.delete()))
|
||||||
})
|
})
|
||||||
|
|
||||||
test.serial("kitchen sink + destroy", async (t) => {
|
test.serial.only("kitchen sink + destroy", async (t) => {
|
||||||
const root = createRoot(channel)
|
const root = createRoot(channel)
|
||||||
|
|
||||||
await root.render(
|
await root.render(
|
||||||
@@ -110,18 +110,6 @@ test.serial("empty embed fallback", async (t) => {
|
|||||||
await assertMessages(t, [{ embeds: [{ color: null, description: "_ _" }] }])
|
await assertMessages(t, [{ embeds: [{ color: null, description: "_ _" }] }])
|
||||||
})
|
})
|
||||||
|
|
||||||
test.serial("invalid children error", (t) => {
|
|
||||||
const root = createRoot(channel)
|
|
||||||
|
|
||||||
t.throws(() =>
|
|
||||||
root.render(
|
|
||||||
<Text>
|
|
||||||
<Embed />
|
|
||||||
</Text>,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
type MessageData = ReturnType<typeof extractMessageData>
|
type MessageData = ReturnType<typeof extractMessageData>
|
||||||
function extractMessageData(message: Message) {
|
function extractMessageData(message: Message) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ColorResolvable } from "discord.js"
|
import type { ColorResolvable } from "discord.js"
|
||||||
import type { ReactNode } from "react"
|
import type { ReactNode } from "react"
|
||||||
import React from "react"
|
import React from "react"
|
||||||
|
import { EmbedInstance } from "../renderer/embed-instance.js"
|
||||||
|
|
||||||
export type EmbedProps = {
|
export type EmbedProps = {
|
||||||
color?: ColorResolvable
|
color?: ColorResolvable
|
||||||
@@ -8,5 +9,7 @@ export type EmbedProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Embed(props: EmbedProps) {
|
export function Embed(props: EmbedProps) {
|
||||||
return <reacord-embed {...props} />
|
return (
|
||||||
|
<reacord-element createInstance={() => new EmbedInstance(props.color)} />
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import type { ReactNode } from "react"
|
import type { ReactNode } from "react"
|
||||||
import React from "react"
|
import React from "react"
|
||||||
|
import { TextElementInstance } from "../renderer/text-element-instance.js"
|
||||||
|
|
||||||
export type TextProps = {
|
export type TextProps = {
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Text(props: TextProps) {
|
export function Text(props: TextProps) {
|
||||||
return <reacord-text {...props} />
|
return <reacord-element createInstance={() => new TextElementInstance()} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import type { EmbedProps } from "./components/embed.js"
|
|
||||||
import type { TextProps } from "./components/text.jsx"
|
|
||||||
|
|
||||||
export type ReacordElementMap = {
|
|
||||||
"reacord-text": TextProps
|
|
||||||
"reacord-embed": EmbedProps
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
namespace JSX {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
|
||||||
interface IntrinsicElements extends ReacordElementMap {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
export * from "./components/embed.js"
|
export * from "./components/embed.js"
|
||||||
export * from "./components/text.js"
|
export * from "./components/text.js"
|
||||||
export * from "./root.js"
|
export * from "./renderer/root.js"
|
||||||
|
|||||||
13
packages/reacord/src/renderer/base-instance.ts
Normal file
13
packages/reacord/src/renderer/base-instance.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { 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
|
||||||
|
}
|
||||||
40
packages/reacord/src/renderer/container-instance.ts
Normal file
40
packages/reacord/src/renderer/container-instance.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.children.splice(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,10 @@
|
|||||||
import type { Message, MessageOptions, TextBasedChannels } from "discord.js"
|
import type { Message, MessageOptions, TextBasedChannels } from "discord.js"
|
||||||
import type { EmbedInstance } from "./embed-instance.js"
|
import type { BaseInstance } from "./base-instance.js"
|
||||||
import type { TextElementInstance } from "./text-element-instance.js"
|
|
||||||
import type { TextInstance } from "./text-instance.js"
|
|
||||||
|
|
||||||
type Action =
|
type Action =
|
||||||
| { type: "updateMessage"; options: MessageOptions }
|
| { type: "updateMessage"; options: MessageOptions }
|
||||||
| { type: "deleteMessage" }
|
| { type: "deleteMessage" }
|
||||||
|
|
||||||
type ContainerChild = TextInstance | TextElementInstance | EmbedInstance
|
|
||||||
|
|
||||||
export class ReacordContainer {
|
export class ReacordContainer {
|
||||||
private channel: TextBasedChannels
|
private channel: TextBasedChannels
|
||||||
private message?: Message
|
private message?: Message
|
||||||
@@ -19,9 +15,13 @@ export class ReacordContainer {
|
|||||||
this.channel = channel
|
this.channel = channel
|
||||||
}
|
}
|
||||||
|
|
||||||
render(children: ContainerChild[]) {
|
render(children: BaseInstance[]) {
|
||||||
const options: MessageOptions = {}
|
const options: MessageOptions = {}
|
||||||
for (const child of children) {
|
for (const child of children) {
|
||||||
|
if (!child.renderToMessage) {
|
||||||
|
console.warn(`${child.name} is not a valid message child`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
child.renderToMessage(options)
|
child.renderToMessage(options)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
13
packages/reacord/src/renderer/elements.d.ts
vendored
Normal file
13
packages/reacord/src/renderer/elements.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export type ReacordElementTag = "reacord-element"
|
||||||
|
|
||||||
|
export type ReacordElementProps = {
|
||||||
|
createInstance: () => unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace JSX {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||||
|
interface IntrinsicElements
|
||||||
|
extends Record<ReacordElementTag, ReacordElementProps> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,21 +3,17 @@ import type {
|
|||||||
MessageEmbedOptions,
|
MessageEmbedOptions,
|
||||||
MessageOptions,
|
MessageOptions,
|
||||||
} from "discord.js"
|
} from "discord.js"
|
||||||
import type { TextElementInstance } from "./text-element-instance.js"
|
import { ContainerInstance } from "./container-instance.js"
|
||||||
import type { TextInstance } from "./text-instance.js"
|
|
||||||
|
|
||||||
type EmbedChild = TextInstance | TextElementInstance
|
/** Represents an <Embed /> element */
|
||||||
|
export class EmbedInstance extends ContainerInstance {
|
||||||
|
readonly name = "Embed"
|
||||||
|
|
||||||
export class EmbedInstance {
|
constructor(readonly color?: ColorResolvable) {
|
||||||
children: EmbedChild[] = []
|
super({ warnOnNonTextChildren: false })
|
||||||
|
|
||||||
constructor(readonly color: ColorResolvable) {}
|
|
||||||
|
|
||||||
add(child: EmbedChild) {
|
|
||||||
this.children.push(child)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
renderToMessage(message: MessageOptions) {
|
override renderToMessage(message: MessageOptions) {
|
||||||
message.embeds ??= []
|
message.embeds ??= []
|
||||||
message.embeds.push(this.embedOptions)
|
message.embeds.push(this.embedOptions)
|
||||||
}
|
}
|
||||||
@@ -25,7 +21,7 @@ export class EmbedInstance {
|
|||||||
get embedOptions(): MessageEmbedOptions {
|
get embedOptions(): MessageEmbedOptions {
|
||||||
return {
|
return {
|
||||||
color: this.color,
|
color: this.color,
|
||||||
description: this.children.map((child) => child.text).join("") || "_ _",
|
description: this.getChildrenText() || "_ _",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +1,46 @@
|
|||||||
/* eslint-disable unicorn/no-null */
|
/* eslint-disable unicorn/no-null */
|
||||||
|
import { inspect } from "node:util"
|
||||||
import { raise } from "reacord-helpers/raise.js"
|
import { raise } from "reacord-helpers/raise.js"
|
||||||
import ReactReconciler from "react-reconciler"
|
import ReactReconciler from "react-reconciler"
|
||||||
import type { ReacordElementMap } from "./elements.js"
|
import { BaseInstance } from "./base-instance.js"
|
||||||
import type { ReacordContainer } from "./renderer/container.js"
|
import { ContainerInstance } from "./container-instance.js"
|
||||||
import { EmbedInstance } from "./renderer/embed-instance.js"
|
import type { ReacordContainer } from "./container.js"
|
||||||
import { TextElementInstance } from "./renderer/text-element-instance.js"
|
import { TextInstance } from "./text-instance.js"
|
||||||
import { TextInstance } from "./renderer/text-instance.js"
|
|
||||||
|
|
||||||
// instances that represent an element
|
type ElementTag = string
|
||||||
type ElementInstance = TextElementInstance | EmbedInstance
|
|
||||||
|
|
||||||
// any instance
|
|
||||||
type Instance = ElementInstance | TextInstance
|
|
||||||
|
|
||||||
type ElementTag =
|
|
||||||
| keyof ReacordElementMap
|
|
||||||
| (string & { __autocompleteHack__?: never })
|
|
||||||
|
|
||||||
type Props = Record<string, unknown>
|
type Props = Record<string, unknown>
|
||||||
|
|
||||||
const createInstance = (type: ElementTag, props: Props): ElementInstance => {
|
const createInstance = (type: string, props: Props): BaseInstance => {
|
||||||
if (type === "reacord-text") {
|
if (type !== "reacord-element") {
|
||||||
return new TextElementInstance()
|
raise(`createInstance: unknown type: ${type}`)
|
||||||
}
|
}
|
||||||
if (type === "reacord-embed") {
|
|
||||||
return new EmbedInstance((props as any).color)
|
if (typeof props.createInstance !== "function") {
|
||||||
|
const actual = inspect(props.createInstance)
|
||||||
|
raise(`invalid createInstance function, received: ${actual}`)
|
||||||
}
|
}
|
||||||
raise(`Unknown element type "${type}"`)
|
|
||||||
|
const instance = props.createInstance()
|
||||||
|
if (!(instance instanceof BaseInstance)) {
|
||||||
|
raise(`invalid instance: ${inspect(instance)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return instance
|
||||||
}
|
}
|
||||||
|
|
||||||
export const reconciler = ReactReconciler<
|
export const reconciler = ReactReconciler<
|
||||||
ElementTag, // Type,
|
string, // Type (jsx tag),
|
||||||
Props, // Props,
|
Props, // Props,
|
||||||
ReacordContainer, // Container,
|
ReacordContainer, // Container,
|
||||||
ElementInstance, // Instance,
|
BaseInstance, // Instance,
|
||||||
TextInstance, // TextInstance,
|
TextInstance, // TextInstance,
|
||||||
never, // SuspenseInstance,
|
never, // SuspenseInstance,
|
||||||
never, // HydratableInstance,
|
never, // HydratableInstance,
|
||||||
never, // PublicInstance,
|
never, // PublicInstance,
|
||||||
null, // HostContext,
|
null, // HostContext,
|
||||||
never, // UpdatePayload,
|
never, // UpdatePayload,
|
||||||
Instance[], // ChildSet,
|
BaseInstance[], // ChildSet,
|
||||||
unknown, // TimeoutHandle,
|
unknown, // TimeoutHandle,
|
||||||
unknown // NoTimeout
|
unknown // NoTimeout
|
||||||
>({
|
>({
|
||||||
@@ -63,46 +63,37 @@ export const reconciler = ReactReconciler<
|
|||||||
|
|
||||||
createContainerChildSet: () => [],
|
createContainerChildSet: () => [],
|
||||||
|
|
||||||
appendChildToContainerChildSet: (childSet: Instance[], child: Instance) => {
|
appendChildToContainerChildSet: (
|
||||||
|
childSet: BaseInstance[],
|
||||||
|
child: BaseInstance,
|
||||||
|
) => {
|
||||||
childSet.push(child)
|
childSet.push(child)
|
||||||
},
|
},
|
||||||
|
|
||||||
finalizeContainerChildren: (
|
finalizeContainerChildren: (
|
||||||
container: ReacordContainer,
|
container: ReacordContainer,
|
||||||
children: Instance[],
|
children: BaseInstance[],
|
||||||
) => false,
|
) => false,
|
||||||
|
|
||||||
replaceContainerChildren: (
|
replaceContainerChildren: (
|
||||||
container: ReacordContainer,
|
container: ReacordContainer,
|
||||||
children: Instance[],
|
children: BaseInstance[],
|
||||||
) => {
|
) => {
|
||||||
container.render(children)
|
container.render(children)
|
||||||
},
|
},
|
||||||
|
|
||||||
appendInitialChild: (parent, child) => {
|
appendInitialChild: (parent, child) => {
|
||||||
if (
|
if (parent instanceof ContainerInstance) {
|
||||||
parent instanceof TextElementInstance &&
|
|
||||||
(child instanceof TextInstance || child instanceof TextElementInstance)
|
|
||||||
) {
|
|
||||||
parent.add(child)
|
parent.add(child)
|
||||||
return
|
} else {
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
parent instanceof EmbedInstance &&
|
|
||||||
(child instanceof TextInstance || child instanceof TextElementInstance)
|
|
||||||
) {
|
|
||||||
parent.add(child)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
raise(
|
raise(
|
||||||
`Cannot append child of type ${child.constructor.name} to ${parent.constructor.name}`,
|
`Cannot append child of type ${child.constructor.name} to ${parent.constructor.name}`,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
cloneInstance: (
|
cloneInstance: (
|
||||||
instance: Instance,
|
instance: BaseInstance,
|
||||||
_: unknown,
|
_: unknown,
|
||||||
type: ElementTag,
|
type: ElementTag,
|
||||||
oldProps: Props,
|
oldProps: Props,
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
/* eslint-disable unicorn/no-null */
|
/* eslint-disable unicorn/no-null */
|
||||||
import type { TextBasedChannels } from "discord.js"
|
import type { TextBasedChannels } from "discord.js"
|
||||||
import type { ReactNode } from "react"
|
import type { ReactNode } from "react"
|
||||||
|
import { ReacordContainer } from "./container"
|
||||||
import { reconciler } from "./reconciler"
|
import { reconciler } from "./reconciler"
|
||||||
import { ReacordContainer } from "./renderer/container"
|
|
||||||
|
|
||||||
export type ReacordRenderTarget = TextBasedChannels
|
export type ReacordRenderTarget = TextBasedChannels
|
||||||
|
|
||||||
@@ -1,22 +1,19 @@
|
|||||||
import type { MessageOptions } from "discord.js"
|
import type { MessageOptions } from "discord.js"
|
||||||
import type { TextInstance } from "./text-instance.js"
|
import { ContainerInstance } from "./container-instance.js"
|
||||||
|
|
||||||
type TextElementChild = TextElementInstance | TextInstance
|
/** Represents a <Text /> element */
|
||||||
|
export class TextElementInstance extends ContainerInstance {
|
||||||
|
readonly name = "Text"
|
||||||
|
|
||||||
export class TextElementInstance {
|
constructor() {
|
||||||
children = new Set<TextElementChild>()
|
super({ warnOnNonTextChildren: true })
|
||||||
|
|
||||||
add(child: TextElementChild) {
|
|
||||||
this.children.add(child)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
renderToMessage(options: MessageOptions) {
|
override getText() {
|
||||||
for (const child of this.children) {
|
return this.getChildrenText()
|
||||||
options.content = `${options.content ?? ""}${child.text}`
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get text(): string {
|
override renderToMessage(options: MessageOptions) {
|
||||||
return [...this.children].map((child) => child.text).join("")
|
options.content = (options.content ?? "") + this.getText()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
import type { MessageOptions } from "discord.js"
|
import type { MessageOptions } from "discord.js"
|
||||||
|
import { BaseInstance } from "./base-instance.js"
|
||||||
|
|
||||||
export class TextInstance {
|
/** Represents raw strings in JSX */
|
||||||
constructor(readonly text: string) {}
|
export class TextInstance extends BaseInstance {
|
||||||
|
readonly name = "Text"
|
||||||
|
|
||||||
renderToMessage(options: MessageOptions) {
|
constructor(private readonly text: string) {
|
||||||
options.content = `${options.content ?? ""}${this.text}`
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
override getText() {
|
||||||
|
return this.text
|
||||||
|
}
|
||||||
|
|
||||||
|
override renderToMessage(options: MessageOptions) {
|
||||||
|
options.content = (options.content ?? "") + this.getText()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
{
|
{
|
||||||
"extends": "@itsmapleleaf/configs/tsconfig.base",
|
"extends": "@itsmapleleaf/configs/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noImplicitOverride": true
|
||||||
|
},
|
||||||
"exclude": ["**/node_modules/**", "**/coverage/**", "**/dist/**"]
|
"exclude": ["**/node_modules/**", "**/coverage/**", "**/dist/**"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user