decentralization refactor wip

This commit is contained in:
MapleLeaf
2021-12-19 16:38:32 -06:00
parent 1e5d448f9a
commit 9828b5c536
15 changed files with 151 additions and 110 deletions

View File

@@ -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 {

View File

@@ -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)} />
)
} }

View File

@@ -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()} />
} }

View File

@@ -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 {}
}
}

View File

@@ -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"

View 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
}

View 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
}
}

View File

@@ -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)
} }

View 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> {}
}
}

View File

@@ -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() || "_ _",
} }
} }
} }

View File

@@ -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 {
raise(
`Cannot append child of type ${child.constructor.name} to ${parent.constructor.name}`,
)
} }
if (
parent instanceof EmbedInstance &&
(child instanceof TextInstance || child instanceof TextElementInstance)
) {
parent.add(child)
return
}
raise(
`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,

View File

@@ -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

View File

@@ -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()
} }
} }

View File

@@ -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()
} }
} }

View File

@@ -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/**"]
} }