248 Commits

Author SHA1 Message Date
itsMapleLeaf 3fb61f2812 Merge branch 'main' into rewrite-internals 2022-10-12 15:03:15 -05:00
itsMapleLeaf acbf21842f deploy to vercel 2022-10-02 18:04:15 -05:00
itsMapleLeaf 65be2ef201 Merge branch 'main' of https://github.com/itsMapleLeaf/reacord 2022-10-02 17:59:37 -05:00
itsMapleLeaf b6f244aaa0 wip more stuff 2022-10-02 17:57:49 -05:00
Darius c244124f6f Merge pull request #20 from kentcdodds/patch-1
Update sending-messages.md
2022-09-10 11:04:41 -05:00
Kent C. Dodds 4db8db0f2d Update sending-messages.md 2022-09-10 09:42:40 -06:00
itsMapleLeaf dc6239e598 remove .js in imports for now
pending https://github.com/esbuild-kit/tsx/issues/74
2022-08-07 20:53:27 -05:00
itsMapleLeaf d01c2a3bac lockfile 2022-08-07 14:00:11 -05:00
itsMapleLeaf 459cafdff2 fancy spinner for test setup 2022-08-07 13:01:20 -05:00
itsMapleLeaf fd8f85ea89 simpler ReacordTester with more parallelizing 2022-08-07 12:57:14 -05:00
itsMapleLeaf 74bada9351 share more logic between renderers 2022-08-07 12:56:57 -05:00
itsMapleLeaf bbf3c4ab17 improve async queue
resolve the promise for the added task immediately, instead of waiting on a promise for all tasks
2022-08-07 12:55:47 -05:00
itsMapleLeaf ac3df750bc library -> src 2022-08-07 12:02:40 -05:00
itsMapleLeaf 4caaed09e9 remove manual test script
we have real tests now :)
2022-08-07 12:02:35 -05:00
itsMapleLeaf 6084ab23e0 more test fixes 2022-08-06 10:22:12 -05:00
itsMapleLeaf e1f5eda3c7 helpers: add node types and tsconfig 2022-08-06 10:10:27 -05:00
itsMapleLeaf 14ebfee673 use verbose reporter 2022-08-06 09:52:56 -05:00
itsMapleLeaf aafba45696 fix doc comments 2022-08-06 09:52:50 -05:00
itsMapleLeaf 1d2620304f work around actions cache skipping cypress install 2022-08-06 01:37:29 -05:00
itsMapleLeaf 8bd8177472 why 2022-08-06 01:24:41 -05:00
itsMapleLeaf cdc3815ce2 remove random weird dev dep 2022-08-06 01:20:58 -05:00
itsMapleLeaf 49621c5d9d remove tailwindcss types 2022-08-06 00:54:47 -05:00
itsMapleLeaf 8443dfb019 upgrades 2022-08-06 00:54:02 -05:00
itsMapleLeaf c572f16638 fix pnpm setup 2022-08-06 00:19:21 -05:00
itsMapleLeaf cd22d75b3a clean up garbage 2022-08-06 00:15:15 -05:00
itsMapleLeaf 91c8e98e8c remove .only 2022-08-06 00:11:21 -05:00
itsMapleLeaf 57e0fd458c enable dep caching 2022-08-06 00:10:23 -05:00
itsMapleLeaf a39d6295c4 reenable tests in ci 2022-08-06 00:10:09 -05:00
itsMapleLeaf 1cbd5e9bfd throw together some scuffed integration testing infra 2022-08-06 00:05:30 -05:00
itsMapleLeaf e974f0073d ensure embed values aren't empty 2022-08-06 00:05:10 -05:00
itsMapleLeaf d5617fd1b5 remove discord-js test 2022-08-05 23:49:07 -05:00
itsMapleLeaf 55b5072e1b run local build script 2022-08-05 23:48:52 -05:00
itsMapleLeaf 69d29d2aa3 lazily create action rows 2022-08-05 23:48:36 -05:00
itsMapleLeaf 6b261d647b support text for embed description 2022-08-05 23:48:13 -05:00
itsMapleLeaf 9c60c24dca message payload tweaks 2022-08-05 11:44:46 -05:00
itsMapleLeaf 3bd0b33750 pass around a client promise so renderers can await login 2022-08-05 11:44:23 -05:00
itsMapleLeaf f58ec8d776 generate exports 2022-08-05 11:43:07 -05:00
itsMapleLeaf b2281d51cb added integration test for action row 2022-08-05 11:42:57 -05:00
itsMapleLeaf 66054b31fc update vitest 2022-08-05 11:42:27 -05:00
itsMapleLeaf f97b2f4816 generate exports before compile 2022-08-05 11:41:08 -05:00
itsMapleLeaf 339bf5a24f slight logical corrections in renderer 2022-08-05 09:06:47 -05:00
itsMapleLeaf e38a4439c1 Merge branch 'main' of https://github.com/itsMapleLeaf/reacord into rewrite-internals 2022-08-04 14:41:35 -05:00
itsMapleLeaf c0f2719171 added script to generate exports 2022-08-04 14:39:47 -05:00
itsMapleLeaf 3c59b5ac1e update doc for InteractionInfo 2022-08-04 14:39:10 -05:00
itsMapleLeaf 843b4ef9db remove some random comments and unneeded stuff 2022-08-04 14:37:54 -05:00
itsMapleLeaf 2c8742bc5f cleanup 2022-08-04 13:30:02 -05:00
itsMapleLeaf ffa9357f73 rename manual test script 2022-08-04 13:02:37 -05:00
github-actions[bot] 1fa4bc800b Version Packages 2022-08-04 10:39:56 -05:00
itsMapleLeaf e3351654ea changeset 2022-08-04 10:38:08 -05:00
itsMapleLeaf d1ca002939 fix links, closes #17 2022-08-04 10:38:08 -05:00
itsMapleLeaf 38a86bb783 fix links, closes #17 2022-08-04 10:34:19 -05:00
itsMapleLeaf 14d6f87dda untested rewrite 2022-08-04 10:29:06 -05:00
itsMapleLeaf 5852b4a616 flatten file structure 2022-08-01 22:49:31 -05:00
itsMapleLeaf 4171b7326a new structure with renderer skeleton 2022-08-01 22:30:29 -05:00
itsMapleLeaf cbd9120c34 .new.new 2022-07-31 23:43:32 -05:00
itsMapleLeaf 98d6f59fe4 make folder for djs stuff 2022-07-28 22:18:45 -05:00
itsMapleLeaf aee31c4be2 build library/main 2022-07-28 22:18:03 -05:00
itsMapleLeaf f2a322e4cd restore old tests + more parallel things 2022-07-28 22:15:49 -05:00
itsMapleLeaf 831bf9ea44 make a new package for helpers 2022-07-27 22:42:35 -05:00
itsMapleLeaf 0df45acba3 keep this helper for later maybe 2022-07-27 18:31:20 -05:00
itsMapleLeaf 76d50b00fa imports 2022-07-27 18:31:12 -05:00
itsMapleLeaf 528e600f1a more sensible test 2022-07-27 18:30:34 -05:00
itsMapleLeaf 42d1541697 more convenient test code 2022-07-27 18:29:59 -05:00
itsMapleLeaf de53faa828 move generate prop combinations to helpers 2022-07-27 12:47:17 -05:00
itsMapleLeaf 83d146279a scuffed button test 2022-07-26 12:48:00 -05:00
itsMapleLeaf 91c250f63f accept children for button label 2022-07-26 12:15:48 -05:00
itsMapleLeaf 4e3f1cc7cb refactor with node classes again
node classes are great as generic containers, and extended classes are great for node identity with instanceof

also realized that the NodeFactory is a detail of ReacordElement, so I moved it and renamed it to ReacordElementConfig
2022-07-26 09:19:59 -05:00
itsMapleLeaf 67b1f45a8f move things to folders 2022-07-25 11:03:55 -05:00
itsMapleLeaf 9a96da1d34 simplify node structure + convert to message payload in core 2022-07-25 10:47:12 -05:00
itsMapleLeaf 06a8976d8e buttons 2022-07-24 20:41:25 -05:00
itsMapleLeaf 4b6de3ab5f pretty ms for funsies 2022-07-24 15:24:21 -05:00
itsMapleLeaf 35fbf93be7 trying to reduce "layers of conversion"
one problem with the current iteration of reacord is the number of conversation layers there are between internals and the adapter.

the flow is: elements -> node tree -> reacord objects -> adapter objects -> adapter renderer

so far it looks like I can reduce this to: elements -> node tree -> adapter renderer
2022-07-24 15:02:07 -05:00
itsMapleLeaf cfd88fe110 fix test 2022-07-24 13:46:08 -05:00
itsMapleLeaf a9b5e4c380 rename files appropriately 2022-07-24 13:42:21 -05:00
itsMapleLeaf f9564897aa classes are fine, actually! + simplified things more 2022-07-24 13:39:55 -05:00
itsMapleLeaf 533d8a0f60 add back reconciler generic comments
dunno what happened to them lol
2022-07-24 13:39:13 -05:00
itsMapleLeaf 05c940ff52 destroying messages, placeholder for deactivate 2022-07-23 19:19:13 -05:00
itsMapleLeaf 4db32ddbbb async queue abstraction 2022-07-23 18:39:17 -05:00
itsMapleLeaf 02808b7550 split stuff up + handle immediate renders 2022-07-23 18:29:16 -05:00
itsMapleLeaf 1197d12a19 initial hacked-together draft 2022-07-23 17:46:54 -05:00
itsMapleLeaf 72f4a4afff changeset 2022-07-23 14:42:12 -05:00
itsMapleLeaf eed5715f1f update website with new remix typings 2022-07-23 14:24:12 -05:00
itsMapleLeaf e486da0881 migrate to cypress 10 2022-07-23 14:24:12 -05:00
itsMapleLeaf b275d9b330 update reconciler 2022-07-23 14:24:12 -05:00
itsMapleLeaf bab134d697 remove vite
was only used for viest config types, don't need it now
2022-07-23 14:24:12 -05:00
itsMapleLeaf df9bdfaf77 remove nanoid, use crypto.randomUUID()
removes a dependency, and resolves an ESM require error
2022-07-23 14:24:12 -05:00
itsMapleLeaf 35d7f0b33f fix linter warnings 2022-07-23 14:24:12 -05:00
itsMapleLeaf 4f9fb4310f upgrade dependencies 2022-07-23 14:24:12 -05:00
itsMapleLeaf 7b74628732 add link to template + other tweaks 2022-07-23 00:16:27 -05:00
itsMapleLeaf 7536bdee43 changeset 2022-07-22 23:17:03 -05:00
itsMapleLeaf ef8d915e3b add types field in exports to work with TS NodeNext 2022-07-22 23:15:57 -05:00
github-actions[bot] 3f078c91d2 Version Packages 2022-07-22 22:28:53 -05:00
itsMapleLeaf 8df7bc9baa back to the old script 2022-07-22 22:19:48 -05:00
itsMapleLeaf 52e587e70f add back changelog config 2022-07-22 22:12:53 -05:00
itsMapleLeaf 3152b1b79e add version to website 2022-07-22 22:11:43 -05:00
itsMapleLeaf d20afb094c fix pnpm-workspace.yaml to work with changesets 2022-07-22 22:10:25 -05:00
itsMapleLeaf 118f567e8d add publish config 2022-07-22 21:46:11 -05:00
itsMapleLeaf a447fefc7b fix changeset 2022-07-22 21:45:20 -05:00
itsMapleLeaf 9efc61d8eb update release workflow for pnpm 2022-07-22 21:44:48 -05:00
itsMapleLeaf aa65da59df changeset 2022-07-22 21:35:54 -05:00
itsMapleLeaf bc91080eca allow JSX for text in more places 2022-07-22 21:35:54 -05:00
itsMapleLeaf 9afe6fe0fa use changeset publish 2022-07-22 17:31:34 -05:00
itsMapleLeaf abc60528d5 set access public 2022-07-22 17:29:04 -05:00
itsMapleLeaf 413f88c7b8 remove ignore config 2022-07-22 17:28:45 -05:00
itsMapleLeaf b482f07788 ignore package by folder 2022-07-22 17:23:04 -05:00
itsMapleLeaf 3b191d274e always cancel in progress 2022-07-22 17:20:10 -05:00
itsMapleLeaf be5ec7c545 install pnpm in release workflow 2022-07-22 17:17:51 -05:00
itsMapleLeaf c93815b9f9 setup releasing in CI 2022-07-22 17:16:01 -05:00
itsMapleLeaf 1e527993e5 only publish reacord 2022-07-22 17:16:01 -05:00
itsMapleLeaf f4eae8da75 consume changeset 2022-07-22 17:16:01 -05:00
itsMapleLeaf 62505ca98c test changeset 2022-07-22 17:16:01 -05:00
itsMapleLeaf cc1bc0932f init changesets 2022-07-22 17:16:01 -05:00
itsMapleLeaf c08f5621ef space 2022-07-22 13:51:02 -05:00
itsMapleLeaf b6d2aac7a3 update release script 2022-07-22 13:51:02 -05:00
itsMapleLeaf 51ac0c89da add a manual tester in favor of playground 2022-07-22 13:51:02 -05:00
itsMapleLeaf f4985b1d87 run one workflow at a time 2022-07-22 13:51:02 -05:00
itsMapleLeaf 96affac979 release v0.4.0 2022-07-21 16:23:15 -05:00
Crawron 93b321dc36 clean imports 2022-07-21 16:20:14 -05:00
Crawron e313399a5a fix type guards 2022-07-21 16:20:14 -05:00
Crawron 90744ebe47 tweak and infer return type 2022-07-21 16:20:14 -05:00
Crawron 33bb2ee196 use enums instead of strings for component type 2022-07-21 16:20:14 -05:00
Crawron eb97b2d23d Add helper to convert button style to enum 2022-07-21 16:20:14 -05:00
Crawron 5aaaffbda9 Update playground for djs v14 2022-07-21 16:20:14 -05:00
Crawron 43029019f4 gitignore pnpm debug log 2022-07-21 16:20:14 -05:00
Crawron 8c481f18c6 Update Discord.js version 2022-07-21 16:20:14 -05:00
itsMapleLeaf 87ecb20f7a fix pnpm scripts & lock pnpm version 2022-07-09 15:13:44 -05:00
itsMapleLeaf 2324f3c89f release v0.3.7 2022-07-09 14:55:00 -05:00
itsMapleLeaf c35c32bddd fix cjs require 2022-07-09 14:54:27 -05:00
itsMapleLeaf 6eb36b44f3 move scripts to root for deployment 2022-07-07 12:20:00 -05:00
itsMapleLeaf a1fc0287fc remove engines config 2022-07-07 12:02:13 -05:00
itsMapleLeaf 02dd763e63 install node 16 for website 2022-07-07 12:01:06 -05:00
itsMapleLeaf a4024394e3 remove dockerfile 2022-07-07 11:56:13 -05:00
itsMapleLeaf c72096058a fix umami script 2022-07-07 11:49:14 -05:00
itsMapleLeaf 672fcd5bc4 release v0.3.6 2022-04-27 22:39:35 -05:00
itsMapleLeaf 25f34b3715 alias release script 2022-04-27 22:36:32 -05:00
itsMapleLeaf 8a7557f0eb lint/typecheck fixes 2022-04-25 19:58:47 -05:00
itsMapleLeaf fc3025baaf update configs 2022-04-25 14:52:04 -05:00
itsMapleLeaf 81f32794b4 fix import aliases 2022-04-24 19:52:21 -05:00
itsMapleLeaf dbf9640b16 lockfile 2022-04-24 19:41:53 -05:00
itsMapleLeaf a485ebaf74 improve pruneNullishValues + test 2022-04-24 16:05:00 -05:00
itsMapleLeaf 7ef5a7ac9d remove disableComponents function 2022-04-23 03:47:50 -05:00
itsMapleLeaf 6715756c2b fix deactivate overwriting edits 2022-04-23 03:44:50 -05:00
itsMapleLeaf 6851c5419a test improvements 2022-04-23 01:54:52 -05:00
itsMapleLeaf 1ba75492e5 reconciler fix 2022-04-23 00:44:31 -05:00
itsMapleLeaf aced338d72 use require.resolve for eslint config 2022-04-23 00:37:11 -05:00
itsMapleLeaf 512c0649d8 ignore workspace files 2022-04-23 00:37:00 -05:00
itsMapleLeaf 91b82ca41f fix vitest fn usage 2022-04-22 23:58:05 -05:00
itsMapleLeaf 752ccc080d update remix imports + format 2022-04-22 23:50:01 -05:00
itsMapleLeaf 3c2d3b4683 upgrades 2022-04-22 23:45:30 -05:00
itsMapleLeaf ad57674d6e 0.3.5 2022-04-22 23:27:43 -05:00
itsMapleLeaf 065bec9a37 Merge branches 'main' and 'main' of github.com:itsMapleLeaf/reacord 2022-04-22 23:23:35 -05:00
itsMapleLeaf d3ccafc6d5 update website links 2022-04-22 23:22:28 -05:00
Darius c71d70bbb4 remove domain redirect 2022-03-27 13:34:09 -05:00
itsMapleLeaf 5ba12af699 this should be call uncontrolled modal 2022-01-16 18:18:44 -06:00
itsMapleLeaf ff39ef753f add accessible text to header logo 2022-01-16 14:09:53 -06:00
itsMapleLeaf 2288c27e1e fix style preload 2022-01-16 13:46:08 -06:00
MapleLeaf c86648f44e deploy domain redirect to fly 2022-01-15 13:56:15 -06:00
MapleLeaf 0edf702b5f change umami url 2022-01-15 11:36:10 -06:00
MapleLeaf 05bda71ad6 fix action row example 2022-01-14 15:55:49 -06:00
MapleLeaf 0217fb8533 adjust prose style 2022-01-14 15:55:37 -06:00
MapleLeaf b59dcc0ae7 active nav link style 2022-01-13 17:05:46 -06:00
Darius 3efaef162b update npm badge to link to npm 2022-01-13 12:54:42 -06:00
Darius 4c2aafe185 add npm badge 2022-01-13 12:52:55 -06:00
MapleLeaf d3d1f473ae release v0.3.4 2022-01-13 11:42:36 -06:00
MapleLeaf 116e606db1 more package.json metadata fields 2022-01-13 11:41:16 -06:00
MapleLeaf 490bf2cefa reduce initial animation delay 2022-01-13 11:26:29 -06:00
MapleLeaf e06bfa490f new main nav menu, remove alpine 2022-01-13 11:16:09 -06:00
MapleLeaf 2765b4fda4 extract and clean up buttonClass 2022-01-12 22:07:43 -06:00
MapleLeaf 5660297588 ignore /public/api 2022-01-12 22:07:19 -06:00
MapleLeaf 2cdd324495 un-proxy the api docs 2022-01-12 21:14:05 -06:00
MapleLeaf 7a09e8fdca add back get started button 2022-01-12 21:13:09 -06:00
MapleLeaf 8111931183 add meta theme-color 2022-01-12 20:32:50 -06:00
MapleLeaf fd64132e58 seo stuff 2022-01-12 20:24:11 -06:00
MapleLeaf 1ba0da6c86 update header logo 2022-01-12 20:14:12 -06:00
MapleLeaf fdd6a3a3cc add modal to show code 2022-01-12 20:10:09 -06:00
MapleLeaf 8ce3834cf4 fun landing animation 2022-01-12 19:29:20 -06:00
MapleLeaf 1bfbe9608c add neat tilted background 2022-01-12 17:55:36 -06:00
MapleLeaf 0695bbc6bd add favicon 2022-01-12 17:42:31 -06:00
MapleLeaf 569e5c7473 add new banner to readme 💖 thanks @Crawron 2022-01-12 17:12:40 -06:00
MapleLeaf 62300d23ca remove log in test script 2022-01-12 13:31:58 -06:00
MapleLeaf cda69a0c85 load api files through a remix route 2022-01-12 13:31:50 -06:00
MapleLeaf 785384286b add analytics + notice 2022-01-12 13:15:09 -06:00
MapleLeaf 50961e888e prebuild tailwindcss for caching
need to figure out how to do this with remix-tailwind lol
2022-01-12 11:01:38 -06:00
MapleLeaf 8c3304144e add more package.json fields 2022-01-12 10:25:06 -06:00
MapleLeaf b6b05232f7 release v0.3.3 2022-01-12 10:22:41 -06:00
MapleLeaf 33a170a4fb use bash release script 2022-01-12 10:21:54 -06:00
MapleLeaf 9a6f85a726 copy readme before publish 2022-01-12 09:57:02 -06:00
MapleLeaf a79649048f fix version 2022-01-12 09:55:46 -06:00
MapleLeaf e7196e686c build before release 2022-01-12 09:55:04 -06:00
Darius 26eb8bedb0 Add useInstance (#6) 2022-01-12 09:55:03 -06:00
MapleLeaf 2851d4b787 update todo 2022-01-12 09:55:03 -06:00
MapleLeaf 46d3c64133 release v0.2.0 2022-01-12 09:55:03 -06:00
MapleLeaf 6b77971ed5 docs -> website 2022-01-11 00:28:41 -06:00
MapleLeaf effd16ed97 run reacord and docs test in separate processes 2022-01-11 00:25:13 -06:00
MapleLeaf 989ab330b3 just test reacord 2022-01-11 00:25:13 -06:00
MapleLeaf 0aafc66ce1 debug log 2022-01-11 00:25:13 -06:00
MapleLeaf ff8347b52a try just running parallel test 2022-01-11 00:25:13 -06:00
MapleLeaf c436500b3a try running test in packages/docs 2022-01-11 00:25:13 -06:00
MapleLeaf e93506409f no esmo 2022-01-11 00:25:13 -06:00
MapleLeaf 8482f6e91a simplify workflow again 2022-01-11 00:25:13 -06:00
MapleLeaf c5e11b4417 run scripts in parallel 2022-01-11 00:25:13 -06:00
MapleLeaf 2e4ff0f524 remove detached flag 2022-01-11 00:25:13 -06:00
MapleLeaf d0c940c693 exit 2022-01-11 00:25:13 -06:00
MapleLeaf 01f245c2e2 kill detached process with sigkill 2022-01-11 00:25:13 -06:00
MapleLeaf b65004dd75 try to kill detached process 2022-01-11 00:25:12 -06:00
MapleLeaf d9dd4d5307 test: kill app with sigkill 2022-01-11 00:25:12 -06:00
MapleLeaf 2b3b8953d0 don't test in parallel??? 2022-01-11 00:25:12 -06:00
MapleLeaf a6b706a3de add wait-on types??? 2022-01-11 00:25:12 -06:00
MapleLeaf d3f6c8af4d write test script for cypress
to properly kill the process
2022-01-11 00:25:12 -06:00
MapleLeaf b0e937f896 exit after test 2022-01-11 00:25:12 -06:00
MapleLeaf 8b371cd1cf separate test and test dev scripts 2022-01-11 00:25:12 -06:00
MapleLeaf ba155226f1 run test scripts in parallel 2022-01-11 00:25:12 -06:00
MapleLeaf a1424f4607 linter lol 2022-01-11 00:25:12 -06:00
MapleLeaf 42461c7ec8 upgrades 2022-01-11 00:25:12 -06:00
MapleLeaf 27f793b1db print coverage from test script 2022-01-11 00:25:12 -06:00
MapleLeaf 97a5526d9d run test in main workflow 2022-01-11 00:25:12 -06:00
MapleLeaf 99eebef9a9 turn off watch in coverage 2022-01-11 00:25:12 -06:00
MapleLeaf 1b97f9256f deeply prune defined undefined values in test adapter 2022-01-11 00:25:12 -06:00
MapleLeaf 1a04e6093d clearer select option resolution 2022-01-11 00:25:12 -06:00
MapleLeaf 4803cb8478 remove reacord-tester from core 2022-01-11 00:25:12 -06:00
MapleLeaf 4201f45cc9 use export default in cypress plugin file 2022-01-11 00:25:12 -06:00
MapleLeaf 52b83522de ignore cypress videos and screenshots 2022-01-11 00:25:12 -06:00
MapleLeaf 017a417773 add vitest in project 2022-01-11 00:25:12 -06:00
MapleLeaf 661a253d8c update todo 2022-01-10 17:15:50 -06:00
MapleLeaf 5b68a1f0ea yaml is bad 2022-01-10 17:07:24 -06:00
MapleLeaf 8c4a450bfb update paths on deploy workflow 2022-01-10 17:06:45 -06:00
MapleLeaf 14b74a0a3f remove readme in docs 2022-01-10 17:05:33 -06:00
MapleLeaf f2d309b2a7 add eslint override for cypress 2022-01-10 16:52:31 -06:00
MapleLeaf adeb0fb0d9 fix type checking for cypress 2022-01-10 16:04:09 -06:00
Sami Ibrahim 4f8308dc73 Update README.md (#5) 2022-01-10 15:33:29 -06:00
MapleLeaf f5d8fa5271 docker: set node env after install 2022-01-10 15:11:38 -06:00
MapleLeaf ac759412b3 lint fix 2022-01-10 15:10:50 -06:00
MapleLeaf 608b008659 fix typress type error 2022-01-10 15:10:23 -06:00
MapleLeaf 87cbf1b74d docs: add cypress test 2022-01-10 15:06:55 -06:00
MapleLeaf 4f463bfa23 link directly to alpine dist js
just to reduce network round trips
2022-01-10 14:44:19 -06:00
MapleLeaf 2e65218083 release v0.1.1 2022-01-09 20:35:54 -06:00
MapleLeaf 1e4a2bd006 configure release-it 2022-01-09 20:34:44 -06:00
MapleLeaf 76bd0b0b98 add doc comments 2022-01-09 20:31:31 -06:00
MapleLeaf 021afc154f more helpful reference links 2022-01-09 19:41:48 -06:00
MapleLeaf fe39859b68 add links doc 2022-01-09 19:34:24 -06:00
MapleLeaf c55fb22a8f lower framerate gif 2022-01-09 17:40:15 -06:00
MapleLeaf 7d8336c7a7 migrate docs back to remix oops 2022-01-09 17:36:42 -06:00
Darius c6ea101330 Add demo gif (#3) 2022-01-09 16:39:08 -06:00
MapleLeaf 4e9b358731 run serve script directly from docker container 2022-01-09 13:41:43 -06:00
MapleLeaf a66520829a remove cross-env 2022-01-09 13:09:17 -06:00
MapleLeaf e2b150bbe2 docs: disable source maps in prod 2022-01-09 13:01:00 -06:00
MapleLeaf be42042552 upgrades 2022-01-09 12:55:37 -06:00
MapleLeaf 3bfc5f4977 try to fix deploy issues 2022-01-09 12:46:51 -06:00
211 changed files with 11051 additions and 8585 deletions
+8
View File
@@ -0,0 +1,8 @@
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "https://unpkg.com/@changesets/config@2.1.0/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}
+25
View File
@@ -0,0 +1,25 @@
require("@rushstack/eslint-patch/modern-module-resolution")
/** @type {import('eslint').Linter.Config} */
module.exports = {
extends: [require.resolve("@itsmapleleaf/configs/eslint")],
ignorePatterns: [
"**/node_modules/**",
"**/.cache/**",
"**/build/**",
"**/dist/**",
"**/coverage/**",
"**/public/**",
],
parserOptions: {
project: require.resolve("./tsconfig.base.json"),
},
overrides: [
{
files: ["packages/website/cypress/**"],
parserOptions: {
project: require.resolve("./packages/website/cypress/tsconfig.json"),
},
},
],
}
-17
View File
@@ -1,17 +0,0 @@
{
"extends": ["./node_modules/@itsmapleleaf/configs/eslint"],
"ignorePatterns": [
"**/node_modules/**",
"**/.cache/**",
"**/build/**",
"**/dist/**",
"**/coverage/**"
],
"parserOptions": {
"project": "./tsconfig.base.json"
},
"rules": {
"import/no-unused-modules": "off",
"unicorn/prevent-abbreviations": "off"
}
}
-16
View File
@@ -1,16 +0,0 @@
name: deploy docs
on:
push:
branches: [main]
paths: [packages/docs/**, reacord/library/**/*.ts]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: superfly/flyctl-actions@master
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
with:
args: "deploy"
+27 -14
View File
@@ -9,29 +9,42 @@ env:
TEST_BOT_TOKEN: ${{ secrets.TEST_BOT_TOKEN }} TEST_BOT_TOKEN: ${{ secrets.TEST_BOT_TOKEN }}
TEST_CHANNEL_ID: ${{ secrets.TEST_CHANNEL_ID }} TEST_CHANNEL_ID: ${{ secrets.TEST_CHANNEL_ID }}
TEST_GUILD_ID: ${{ secrets.TEST_GUILD_ID }} TEST_GUILD_ID: ${{ secrets.TEST_GUILD_ID }}
TEST_CATEGORY_ID: ${{ secrets.TEST_CATEGORY_ID }}
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
run-scripts: run-commands:
strategy: strategy:
matrix:
scripts:
- name: test
script: coverage
- name: lint
script: lint
- name: typecheck
script: typecheck
- name: build
script: build
fail-fast: false fail-fast: false
name: ${{ matrix.scripts.name }} matrix:
command:
# if tests run in the same process, it dies,
# so we test them separate
- name: test
run: pnpm test
- name: test website
# the cache doesn't include cypress install, need to do it manually here
run: pnpm -C packages/website exec cypress install && pnpm -C packages/website test
- name: build
run: pnpm --recursive run build
- name: lint
run: pnpm run lint
- name: typecheck
run: pnpm --recursive run typecheck
name: ${{ matrix.command.name }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: pnpm/action-setup@v2
with:
version: 7.8.0
- uses: actions/setup-node@v2 - uses: actions/setup-node@v2
with: with:
# https://github.com/actions/setup-node#supported-version-syntax # https://github.com/actions/setup-node#supported-version-syntax
node-version: "16" node-version: "16"
- run: npm i -g pnpm cache: "pnpm"
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- run: pnpm run --recursive ${{ matrix.scripts.script }} - run: ${{ matrix.command.run }}
+37
View File
@@ -0,0 +1,37 @@
# https://pnpm.io/using-changesets
name: release
on:
push:
branches:
- main
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
release:
name: release
runs-on: ubuntu-latest
steps:
- name: checkout
uses: actions/checkout@v2
- name: setup node
uses: actions/setup-node@v2
with:
node-version: 16
- name: install pnpm
run: npm install -g pnpm
- name: install deps
run: pnpm install --frozen-lockfile
- name: changesets release
id: changesets
uses: changesets/action@v1
with:
publish: pnpm run release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
+4
View File
@@ -3,4 +3,8 @@ node_modules
.vscode .vscode
coverage coverage
.env .env
*.code-workspace
.pnpm-debug.log
build
.cache
+1
View File
@@ -0,0 +1 @@
ignore-workspace-root-check = true
+1
View File
@@ -4,3 +4,4 @@ coverage
pnpm-lock.yaml pnpm-lock.yaml
build build
.cache .cache
packages/website/public/api
-13
View File
@@ -1,13 +0,0 @@
FROM node:lts-slim
WORKDIR /app
COPY / ./
RUN ls -R
RUN npm install -g pnpm
RUN pnpm install --unsafe-perm --frozen-lockfile
RUN pnpm build -C packages/docs
RUN pnpm install --prod --unsafe-perm --frozen-lockfile
CMD [ "pnpm", "start", "-C", "packages/docs" ]
+24 -2
View File
@@ -1,6 +1,26 @@
# reacord <center>
<img src="./packages/website/app/assets/banner.png" alt="Reacord: Create interactive Discord messages using React">
</center>
Create interactive Discord messages using React! [Visit the docs to get started.](https://reacord.fly.dev/guides/getting-started) ## Installation ∙ [![npm](https://img.shields.io/npm/v/reacord?color=blue&style=flat-square)](https://www.npmjs.com/package/reacord)
```console
# npm
npm install reacord react discord.js
# yarn
yarn add reacord react discord.js
# pnpm
pnpm add reacord react discord.js
```
## Get Started
[Visit the docs to get started.](https://reacord.mapleleaf.dev/guides/getting-started)
## Example
<!-- prettier-ignore --> <!-- prettier-ignore -->
```tsx ```tsx
@@ -21,3 +41,5 @@ function Counter() {
) )
} }
``` ```
![Counter demo](./reacord-counter-demo.gif)
-40
View File
@@ -1,40 +0,0 @@
# fly.toml file generated for reacord on 2021-12-29T14:06:41-06:00
app = "reacord"
kill_signal = "SIGINT"
kill_timeout = 5
processes = []
[env]
PORT = 8080
[experimental]
allowed_public_ports = []
auto_rollback = true
[[services]]
http_checks = []
internal_port = 8080
processes = ["app"]
protocol = "tcp"
script_checks = []
[services.concurrency]
hard_limit = 25
soft_limit = 20
type = "connections"
[[services.ports]]
handlers = ["http"]
port = 80
[[services.ports]]
handlers = ["tls", "http"]
port = 443
[[services.tcp_checks]]
grace_period = "1s"
interval = "15s"
restart_limit = 0
timeout = "2s"
+18 -16
View File
@@ -1,26 +1,28 @@
{ {
"name": "reacord-monorepo",
"private": true, "private": true,
"scripts": { "scripts": {
"lint": "eslint --ext js,ts,tsx .", "lint": "eslint --ext js,ts,tsx .",
"lint-fix": "pnpm lint -- --fix", "lint-fix": "pnpm lint -- --fix",
"format": "prettier --write ." "test": "vitest --coverage --no-watch",
}, "test-dev": "vitest --ui",
"dependencies": { "format": "prettier --write .",
"@itsmapleleaf/configs": "^1.1.2" "build": "pnpm -r run build",
"start": "pnpm -C packages/website run start",
"release": "pnpm -r run build && changeset publish"
}, },
"devDependencies": { "devDependencies": {
"@typescript-eslint/eslint-plugin": "^5.8.1", "@changesets/cli": "^2.24.2",
"@typescript-eslint/parser": "^5.8.1", "@itsmapleleaf/configs": "^1.1.5",
"eslint": "^8.5.0", "@rushstack/eslint-patch": "^1.1.4",
"eslint-config-prettier": "^8.3.0", "@types/eslint": "^8.4.5",
"eslint-import-resolver-typescript": "^2.5.0", "@vitest/ui": "^0.21.0",
"eslint-plugin-import": "^2.25.3", "c8": "^7.12.0",
"eslint-plugin-jsx-a11y": "^6.5.1", "eslint": "^8.21.0",
"eslint-plugin-react": "^7.28.0", "node": "^16.16.0",
"eslint-plugin-react-hooks": "^4.3.0", "prettier": "^2.7.1",
"eslint-plugin-unicorn": "^39.0.0", "typescript": "^4.7.4",
"prettier": "^2.5.1", "vitest": "^0.21.0"
"typescript": "^4.5.4"
}, },
"resolutions": { "resolutions": {
"esbuild": "latest" "esbuild": "latest"
-3
View File
@@ -1,3 +0,0 @@
.asset-cache
node_modules
/api
-65
View File
@@ -1,65 +0,0 @@
{
"name": "reacord-docs-new",
"type": "module",
"private": true,
"scripts": {
"prepare": "node ./scripts/fix-heroicons.js",
"serve": "esmo --experimental-import-meta-resolve --experimental-json-modules --no-warnings --enable-source-maps src/main.tsx | pino-colada",
"dev": "npm-run-all --parallel --print-label --race dev-*",
"dev-server": "nodemon --inspect --exec \"pnpm serve\" --watch src --ext ts,tsx,md,css",
"dev-docs": "typedoc --watch",
"build": "typedoc",
"start": "cross-env NODE_ENV=production pnpm serve",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@heroicons/react": "^1.0.5",
"@tailwindcss/typography": "^0.5.0",
"alpinejs": "^3.7.1",
"autoprefixer": "^10.4.1",
"clsx": "^1.1.1",
"compression": "^1.7.4",
"cross-env": "^7.0.3",
"cssnano": "^5.0.15",
"esbuild": "^0.14.10",
"esno": "^0.13.0",
"express": "^4.17.2",
"express-promise-router": "^4.1.1",
"fast-glob": "^3.2.7",
"gray-matter": "^4.0.3",
"http-terminator": "^3.0.4",
"markdown-it": "^12.3.0",
"markdown-it-prism": "^2.2.1",
"pino": "^7.6.2",
"pino-colada": "^2.2.2",
"pino-http": "^6.5.0",
"postcss": "^8.4.5",
"reacord": "workspace:*",
"react": "^18.0.0-rc.0",
"react-dom": "^18.0.0-rc.0",
"react-ssr-prepass": "^1.5.0",
"tailwindcss": "^3.0.8"
},
"devDependencies": {
"@types/browser-sync": "^2.26.3",
"@types/compression": "^1.7.2",
"@types/cssnano": "^5.0.0",
"@types/express": "^4.17.13",
"@types/markdown-it": "^12.2.3",
"@types/node": "*",
"@types/react": "^17.0.38",
"@types/react-dom": "^17.0.9",
"@types/tailwindcss": "^3.0.0",
"@types/wait-on": "^5.3.1",
"browser-sync": "^2.27.7",
"execa": "^6.0.0",
"nodemon": "^2.0.15",
"npm-run-all": "^4.1.5",
"rxjs": "^7.5.1",
"tsup": "^5.11.10",
"type-fest": "^2.8.0",
"typedoc": "^0.22.10",
"typescript": "^4.5.4",
"wait-on": "^6.0.0"
}
}
-6
View File
@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
-15
View File
@@ -1,15 +0,0 @@
// heroicons doesn't have "sideEffects": false in it's package json,
// which causes esbuild to bundle in 460+ imports of react for some reason,
// which causes memory issues
import glob from "fast-glob"
import { readFile, writeFile } from "node:fs/promises"
const files = await glob("node_modules/@heroicons/react/**/*.json", {
absolute: true,
})
for (const file of files) {
const data = JSON.parse(await readFile(file, "utf8"))
data.sideEffects = false
await writeFile(file, JSON.stringify(data, undefined, 2))
}
@@ -1,10 +0,0 @@
import { createContext, useContext } from "react"
import type { AssetBuilder } from "../asset-builder/asset-builder.js"
import { raise } from "../helpers/raise.js"
const Context = createContext<AssetBuilder>()
export const AssetBuilderProvider = Context.Provider
export const useAssetBuilder = () =>
useContext(Context) ?? raise("AssetBuilderProvider not found")
@@ -1,96 +0,0 @@
import type { RequestHandler } from "express"
import express from "express"
import { createHash } from "node:crypto"
import { mkdir, rm } from "node:fs/promises"
import { join, parse } from "node:path"
import React from "react"
import { renderToStaticMarkup } from "react-dom/server"
import ssrPrepass from "react-ssr-prepass"
import type { Promisable } from "type-fest"
import { ensureWrite, normalizeAsFilePath } from "../helpers/filesystem.js"
import { AssetBuilderProvider } from "./asset-builder-context.js"
export type AssetTransformer<Asset> = {
transform: (context: AssetTransformContext) => Promise<Asset>
}
export class AssetBuilder {
private constructor(private cacheFolder: string) {}
static async create(cacheFolder: string) {
if (process.env.NODE_ENV !== "production") {
await rm(cacheFolder, { recursive: true }).catch(() => {})
}
await mkdir(cacheFolder, { recursive: true })
return new AssetBuilder(cacheFolder)
}
async build<Asset>(
input: Promisable<string | URL>,
transformer: AssetTransformer<Asset>,
alias?: string,
): Promise<Asset> {
const inputFile = normalizeAsFilePath(await input)
// TODO: cache assets by inputFile in production
return transformer.transform(
new AssetTransformContext({
inputFile,
cacheFolder: this.cacheFolder,
alias: alias || parse(inputFile).name,
}),
)
}
async render(element: React.ReactElement) {
element = (
<AssetBuilderProvider value={this}>
<React.Suspense fallback={<></>}>{element}</React.Suspense>
</AssetBuilderProvider>
)
await ssrPrepass(element)
return `<!DOCTYPE html>\n${renderToStaticMarkup(element)}`
}
middleware(): RequestHandler {
return express.static(this.cacheFolder, {
immutable: true,
maxAge: "1y",
})
}
}
export type AssetTransformOptions = {
inputFile: string
cacheFolder: string
alias: string
}
export class AssetTransformContext {
constructor(private options: AssetTransformOptions) {}
get inputFile() {
return this.options.inputFile
}
get cacheFolder() {
return this.options.cacheFolder
}
get alias() {
return this.options.alias
}
getOutputFileName(content: string) {
const { ext } = parse(this.inputFile)
const hash = createHash("sha256").update(content).digest("hex").slice(0, 8)
return `${this.alias}.${hash}${ext}`
}
async writeOutputFile(content: string) {
const outputFileName = this.getOutputFileName(content)
const outputFile = join(this.cacheFolder, outputFileName)
await ensureWrite(outputFile, content)
return { outputFileName, outputFile }
}
}
-75
View File
@@ -1,75 +0,0 @@
import type { ReactNode } from "react";
import React from "react"
import { normalizeAsFilePath } from "../helpers/filesystem.js"
import { useAssetBuilder } from "./asset-builder-context.js"
import type { AssetBuilder, AssetTransformer } from "./asset-builder.js"
type AssetState =
| { status: "building"; promise: Promise<unknown> }
| { status: "built"; asset: unknown }
const cache = new Map<string, AssetState>()
function useAssetBuild<Asset>(
cacheKey: string,
build: (builder: AssetBuilder) => Promise<Asset>,
) {
const builder = useAssetBuilder()
const state = cache.get(cacheKey)
if (!state) {
const promise = build(builder).then((asset) => {
cache.set(cacheKey, { status: "built", asset })
})
cache.set(cacheKey, { status: "building", promise })
throw promise
}
if (state.status === "building") {
throw state.promise
}
return state.asset as Asset
}
export function LocalFileAsset<Asset>({
from,
using: transformer,
as: alias,
children,
}: {
from: string | URL
using: AssetTransformer<Asset>
as?: string
children: (url: Asset) => ReactNode
}) {
const inputFile = normalizeAsFilePath(from)
const asset = useAssetBuild(inputFile, (builder) => {
return builder.build(inputFile, transformer, alias)
})
return <>{children(asset)}</>
}
export function ModuleAsset<Asset>({
from,
using: transformer,
as: name,
children,
}: {
from: string
using: AssetTransformer<Asset>
as?: string
children: (url: Asset) => ReactNode
}) {
const cacheKey = `node:${from}`
const asset = useAssetBuild(cacheKey, async (builder) => {
const inputFile = await import.meta.resolve!(from)
return await builder.build(inputFile, transformer, name)
})
return <>{children(asset)}</>
}
@@ -1,29 +0,0 @@
import grayMatter from "gray-matter"
import MarkdownIt from "markdown-it"
import prism from "markdown-it-prism"
import { readFile } from "node:fs/promises"
import type { AssetTransformer } from "./asset-builder.jsx"
const renderer = new MarkdownIt({
html: true,
linkify: true,
}).use(prism)
export type MarkdownAsset = {
content: { __html: string }
data: Record<string, any>
}
export const markdownTransformer: AssetTransformer<MarkdownAsset> = {
async transform(context) {
const { data, content } = grayMatter(
await readFile(context.inputFile, "utf8"),
)
const html = renderer.render(content)
return {
content: { __html: html },
data,
}
},
}
@@ -1,26 +0,0 @@
import { build } from "esbuild"
import type { AssetTransformer } from "./asset-builder.jsx"
type ScriptAsset = {
url: string
}
export const scriptTransformer: AssetTransformer<ScriptAsset> = {
async transform(context) {
const scriptBuild = await build({
entryPoints: [context.inputFile],
bundle: true,
target: ["chrome89", "firefox89"],
format: "esm",
write: false,
minify: process.env.NODE_ENV === "production",
})
const content = scriptBuild.outputFiles[0]!.text
const { outputFileName } = await context.writeOutputFile(content)
return {
url: "/" + outputFileName,
}
},
}
@@ -1,26 +0,0 @@
import autoprefixer from "autoprefixer"
import cssnano from "cssnano"
import { readFile } from "node:fs/promises"
import type { AcceptedPlugin } from "postcss"
import postcss from "postcss"
import tailwindcss from "tailwindcss"
import type { AssetTransformer } from "./asset-builder.jsx"
export type StylesheetAsset = { url: string }
export const stylesheetTransformer: AssetTransformer<StylesheetAsset> = {
async transform(context) {
const plugins: AcceptedPlugin[] = [tailwindcss, autoprefixer]
if (process.env.NODE_ENV === "production") {
plugins.push(cssnano)
}
const result = await postcss(plugins).process(
await readFile(context.inputFile),
{ from: context.inputFile },
)
const { outputFileName } = await context.writeOutputFile(result.css)
return { url: "/" + outputFileName }
},
}
-7
View File
@@ -1,7 +0,0 @@
import { join } from "node:path"
const projectRoot = new URL("../", import.meta.url).pathname
export function fromProjectRoot(...subPaths: string[]) {
return join(projectRoot, ...subPaths)
}
@@ -1,43 +0,0 @@
---
order: 0
title: Getting Started
description: Learn how to get started with Reacord.
---
# Getting Started
This guide assumes some familiarity with JavaScript, [React](https://reactjs.org), [Discord.js](https://discord.js.org) and the [Discord API](https://discord.dev). Keep these pages as reference if you need it.
**Note:** Ensure your project has support for running code with JSX. I recommend using [esno](https://npm.im/esno).
## Install
```bash
# npm
npm install reacord react discord.js
# yarn
yarn add reacord react discord.js
# pnpm
pnpm add reacord react discord.js
```
## Setup
Create a Discord.js client and a Reacord instance:
```js
// main.js
import { Client } from "discord.js"
import { ReacordDiscordJs } from "reacord"
const client = new Client()
const reacord = new ReacordDiscordJs(client)
client.on("ready", () => {
console.log("Ready!")
})
await client.login(process.env.BOT_TOKEN)
```
-56
View File
@@ -1,56 +0,0 @@
import clsx from "clsx"
import React from "react"
import { LocalFileAsset } from "../asset-builder/asset.js"
import { markdownTransformer } from "../asset-builder/markdown-transformer.js"
import { Html } from "../html.js"
import { AppLink } from "../navigation/app-link"
import { guideLinks } from "../navigation/guide-links"
import { MainNavigation } from "../navigation/main-navigation"
import { docsProseClass, linkClass, maxWidthContainer } from "../ui/components"
export function GuidePage({ url }: { url: string }) {
return (
<LocalFileAsset
from={new URL(`${url}.md`, import.meta.url)}
using={markdownTransformer}
>
{(asset) => (
<Html title={asset.data.title} description={asset.data.description}>
<Header />
<Body content={asset.content} />
</Html>
)}
</LocalFileAsset>
)
}
function Header() {
return (
<header className="bg-slate-700/30 shadow sticky top-0 backdrop-blur-sm transition z-10 flex">
<div className={maxWidthContainer}>
<MainNavigation />
</div>
</header>
)
}
function Body({ content }: { content: { __html: string } }) {
return (
<main className={clsx(maxWidthContainer, "mt-8 flex items-start gap-4")}>
<nav className="w-48 sticky top-24 hidden md:block">
<h2 className="text-2xl">Guides</h2>
<ul className="mt-3 flex flex-col gap-2 items-start">
{guideLinks.map((link) => (
<li key={link.to}>
<AppLink {...link} className={linkClass} />
</li>
))}
</ul>
</nav>
<section
className={clsx(docsProseClass, "pb-8 flex-1 min-w-0")}
dangerouslySetInnerHTML={content}
/>
</main>
)
}
-11
View File
@@ -1,11 +0,0 @@
import { mkdir, writeFile } from "node:fs/promises"
import { dirname } from "node:path"
export async function ensureWrite(file: string, content: string) {
await mkdir(dirname(file), { recursive: true })
await writeFile(file, content)
}
export function normalizeAsFilePath(file: string | URL) {
return new URL(file, "file:").pathname
}
-15
View File
@@ -1,15 +0,0 @@
import grayMatter from "gray-matter"
import MarkdownIt from "markdown-it"
import prism from "markdown-it-prism"
import { readFile } from "node:fs/promises"
const renderer = new MarkdownIt({
html: true,
linkify: true,
}).use(prism)
export async function renderMarkdownFile(filePath: string) {
const { data, content } = grayMatter(await readFile(filePath, "utf8"))
const html = renderer.render(content)
return { html, data }
}
-70
View File
@@ -1,70 +0,0 @@
import packageJson from "reacord/package.json"
import type { ReactNode } from "react"
import React from "react"
import { LocalFileAsset, ModuleAsset } from "./asset-builder/asset.js"
import { scriptTransformer } from "./asset-builder/script-transformer.js"
import { stylesheetTransformer } from "./asset-builder/stylesheet-transformer.js"
export function Html({
title: titleProp,
description = packageJson.description,
children,
}: {
title?: string
description?: string
children: ReactNode
}) {
const title = [titleProp, "Reacord"].filter(Boolean).join(" | ")
return (
<html lang="en" className="bg-slate-900 text-slate-100">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="description" content={description} />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
rel="preconnect"
href="https://fonts.gstatic.com"
crossOrigin=""
/>
<link
rel="preload"
as="style"
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@500&family=Rubik:ital,wght@0,300;0,400;0,500;1,300;1,400;1,500&display=swap"
/>
<ModuleAsset
from="tailwindcss/tailwind.css"
using={stylesheetTransformer}
>
{(asset) => <link rel="stylesheet" href={asset.url} />}
</ModuleAsset>
<LocalFileAsset
from={new URL("ui/prism-theme.css", import.meta.url)}
using={stylesheetTransformer}
>
{(asset) => <link rel="stylesheet" href={asset.url} />}
</LocalFileAsset>
<LocalFileAsset
from={new URL("ui/markdown.css", import.meta.url)}
using={stylesheetTransformer}
>
{(asset) => <link rel="stylesheet" href={asset.url} />}
</LocalFileAsset>
<title>{title}</title>
<ModuleAsset
from="alpinejs/dist/cdn.js"
as="alpine"
using={scriptTransformer}
>
{(asset) => <script defer src={asset.url} />}
</ModuleAsset>
</head>
<body>{children}</body>
</html>
)
}
@@ -1,19 +0,0 @@
<!-- prettier-ignore -->
```tsx
import * as React from "react"
import { Embed, Button } from "reacord"
function Counter() {
const [count, setCount] = React.useState(0)
return (
<>
<Embed title="Counter">
This button has been clicked {count} times.
</Embed>
<Button onClick={() => setCount(count + 1)}>
+1
</Button>
</>
)
}
```
-42
View File
@@ -1,42 +0,0 @@
import packageJson from "reacord/package.json"
import React from "react"
import { LocalFileAsset } from "../asset-builder/asset.js"
import { markdownTransformer } from "../asset-builder/markdown-transformer.js"
import { Html } from "../html.js"
import { MainNavigation } from "../navigation/main-navigation"
import { maxWidthContainer } from "../ui/components"
export function Landing() {
return (
<Html>
<div className="flex flex-col min-w-0 min-h-screen text-center">
<header className={maxWidthContainer}>
<MainNavigation />
</header>
<div className="px-4 pb-8 flex flex-1">
<main className="px-4 py-6 rounded-lg shadow bg-slate-800 space-y-5 m-auto w-full max-w-xl">
<h1 className="text-6xl font-light">reacord</h1>
<LocalFileAsset
from={new URL("landing-example.md", import.meta.url)}
using={markdownTransformer}
>
{(asset) => (
<section
className="mx-auto text-sm sm:text-base"
dangerouslySetInnerHTML={asset.content}
/>
)}
</LocalFileAsset>
<p className="text-2xl font-light">{packageJson.description}</p>
<a
href="/guides/getting-started"
className="inline-block px-4 py-3 text-xl transition rounded-lg bg-emerald-700 hover:translate-y-[-2px] hover:bg-emerald-800 hover:shadow"
>
Get Started
</a>
</main>
</div>
</div>
</Html>
)
}
-61
View File
@@ -1,61 +0,0 @@
import compression from "compression"
import type { ErrorRequestHandler, Request } from "express"
import express from "express"
import PromiseRouter from "express-promise-router"
import httpTerminator from "http-terminator"
import pino from "pino"
import pinoHttp from "pino-http"
import * as React from "react"
import { AssetBuilder } from "./asset-builder/asset-builder.js"
import { fromProjectRoot } from "./constants"
import { GuidePage } from "./guides/guide-page"
import { Landing } from "./landing/landing"
const port = process.env.PORT || 3000
const builder = await AssetBuilder.create(fromProjectRoot(".asset-cache"))
const logger = pino()
const errorHandler: ErrorRequestHandler = (error, request, response, next) => {
response.status(500).send(error.message)
logger.error(error)
}
const router = PromiseRouter()
.use(pinoHttp({ logger }))
.use(compression())
.use(builder.middleware())
.use("/api", express.static("dist/api"))
.get("/guides/*", async (req: Request<{ 0: string }>, res) => {
res
.type("html")
.send(await builder.render(<GuidePage url={req.params[0]} />))
})
.get("/", async (req, res) => {
res.type("html").send(await builder.render(<Landing />))
})
.use(errorHandler)
const server = express()
.use(router)
.listen(port, () => {
logger.info(`Server is running on http://localhost:${port}`)
})
const terminator = httpTerminator.createHttpTerminator({ server })
process.on("SIGINT", () => {
terminator
.terminate()
.then(() => {
logger.info("Server terminated")
})
.catch((error) => {
logger.error(error)
})
.finally(() => {
process.exit()
})
})
-27
View File
@@ -1,27 +0,0 @@
import React from "react"
import { ExternalLink } from "../dom/external-link"
export type AppLinkProps = {
type: "internal" | "external"
label: React.ReactNode
to: string
className?: string
}
export function AppLink(props: AppLinkProps) {
switch (props.type) {
case "internal":
return (
<a className={props.className} href={props.to}>
{props.label}
</a>
)
case "external":
return (
<ExternalLink className={props.className} href={props.to}>
{props.label}
</ExternalLink>
)
}
}
@@ -1,34 +0,0 @@
import glob from "fast-glob"
import grayMatter from "gray-matter"
import { readFile } from "node:fs/promises"
import { join } from "node:path"
import type { AppLinkProps } from "./app-link"
const docsFolder = new URL("../guides", import.meta.url).pathname
const guideFiles = await glob("**/*.md", { cwd: docsFolder })
const entries = await Promise.all(
guideFiles.map(async (file) => {
const content = await readFile(join(docsFolder, file), "utf-8")
const { data } = grayMatter(content)
let order = Number(data.order)
if (!Number.isFinite(order)) {
order = Number.POSITIVE_INFINITY
}
return {
route: `/guides/${file.replace(/\.mdx?$/, "")}`,
title: String(data.title || ""),
order,
}
}),
)
export const guideLinks: AppLinkProps[] = entries
.sort((a, b) => a.order - b.order)
.map((item) => ({
type: "internal",
label: item.title,
to: item.route,
}))
@@ -1,40 +0,0 @@
import React from "react"
import { linkClass } from "../ui/components"
import { PopoverMenu } from "../ui/popover-menu"
import { AppLink } from "./app-link"
import { guideLinks } from "./guide-links"
import { mainLinks } from "./main-links"
export function MainNavigation() {
return (
<nav className="flex justify-between items-center h-16">
<a href="/">
<h1 className="text-3xl font-light">reacord</h1>
</a>
<div className="hidden md:flex gap-4">
{mainLinks.map((link) => (
<AppLink {...link} key={link.to} className={linkClass} />
))}
</div>
<div className="md:hidden" id="main-navigation-popover">
<PopoverMenu>
{mainLinks.map((link) => (
<AppLink
{...link}
key={link.to}
className={PopoverMenu.itemClass}
/>
))}
<hr className="border-0 h-[2px] bg-black/50" />
{guideLinks.map((link) => (
<AppLink
{...link}
key={link.to}
className={PopoverMenu.itemClass}
/>
))}
</PopoverMenu>
</div>
</nav>
)
}
-10
View File
@@ -1,10 +0,0 @@
import "react"
declare module "react" {
export function createContext<Value>(): Context<Value | undefined>
}
declare module "react-dom" {
export function createRoot(element: Element): {
render(element: React.ReactNode): void
}
}
-24
View File
@@ -1,24 +0,0 @@
import clsx from "clsx"
export const maxWidthContainer = clsx`mx-auto w-full max-w-screen-lg px-4`
export const inlineIconClass = clsx`inline w-5 align-sub`
export const linkClass = clsx`
font-medium inline-block relative
opacity-60 hover:opacity-100 transition-opacity
after:absolute after:block after:w-full after:h-px after:bg-white/50 after:translate-y-[3px] after:opacity-0 after:transition
hover:after:translate-y-[-1px] hover:after:opacity-100
`
export const docsProseClass = clsx`
prose prose-invert
prose-h1:font-light prose-h1:mb-4 prose-h1:text-3xl lg:prose-h1:text-4xl
prose-h2:font-light
prose-h3:font-light
prose-p:my-4
prose-a:font-medium prose-a:text-emerald-400 hover:prose-a:no-underline
prose-strong:font-medium prose-strong:text-emerald-400
prose-pre:font-monospace prose-pre:overflow-x-auto
max-w-none
`
-3
View File
@@ -1,3 +0,0 @@
.prose aside {
@apply opacity-75 italic border-l-4 pl-3 border-white/50;
}
@@ -1,38 +0,0 @@
import clsx from "clsx"
const menus = document.querySelectorAll("[data-popover]")
for (const menu of menus) {
const button = menu.querySelector<HTMLButtonElement>("[data-popover-button]")!
const panel = menu.querySelector<HTMLDivElement>("[data-popover-panel]")!
const panelClasses = clsx`${panel.className} transition-all`
const visibleClass = clsx`${panelClasses} visible opacity-100 translate-y-0`
const hiddenClass = clsx`${panelClasses} invisible opacity-0 translate-y-2`
let visible = false
const setVisible = (newVisible: boolean) => {
visible = newVisible
panel.className = visible ? visibleClass : hiddenClass
if (!visible) return
requestAnimationFrame(() => {
const handleClose = (event: MouseEvent) => {
if (panel.contains(event.target as Node)) return
setVisible(false)
window.removeEventListener("click", handleClose)
}
window.addEventListener("click", handleClose)
})
}
const toggleVisible = () => setVisible(!visible)
button.addEventListener("click", toggleVisible)
setVisible(false)
panel.hidden = false
}
-31
View File
@@ -1,31 +0,0 @@
import { MenuAlt4Icon } from "@heroicons/react/outline/esm"
import clsx from "clsx"
import React from "react"
import { linkClass } from "./components"
export function PopoverMenu({ children }: { children: React.ReactNode }) {
return (
<div className="relative" x-data="{ open: false }">
<button title="Menu" className={linkClass} x-on:click="open = !open">
<MenuAlt4Icon className="w-6" />
</button>
<div
className={`
w-48 max-h-[calc(100vh-4rem)]
absolute right-0 top-[calc(100%+8px)]
bg-slate-800 shadow rounded-lg
overflow-hidden overflow-y-auto
transition-all
`}
x-bind:class="open ? 'visible opacity-100' : 'invisible opacity-0 translate-y-3'"
>
{children}
</div>
</div>
)
}
PopoverMenu.itemClass = clsx`
px-3 py-2 transition text-left font-medium block
opacity-50 hover:opacity-100 hover:bg-black/30
`
+35
View File
@@ -0,0 +1,35 @@
export type AsyncCallback<T> = () => T
type QueueItem = {
callback: AsyncCallback<unknown>
resolve: (value: unknown) => void
reject: (error: unknown) => void
}
export class AsyncQueue {
private items: QueueItem[] = []
private running = false
append<T>(callback: AsyncCallback<T>): Promise<Awaited<T>> {
return new Promise((resolve, reject) => {
this.items.push({ callback, resolve: resolve as any, reject })
void this.run()
})
}
private async run() {
if (this.running) return
this.running = true
let item
while ((item = this.items.shift())) {
try {
item.resolve(await item.callback())
} catch (error) {
item.reject(error)
}
}
this.running = false
}
}
@@ -2,6 +2,7 @@ import type {
CamelCasedPropertiesDeep, CamelCasedPropertiesDeep,
SnakeCasedPropertiesDeep, SnakeCasedPropertiesDeep,
} from "type-fest" } from "type-fest"
import { expect, test } from "vitest"
import { camelCaseDeep, snakeCaseDeep } from "./convert-object-property-case" import { camelCaseDeep, snakeCaseDeep } from "./convert-object-property-case"
test("camelCaseDeep", () => { test("camelCaseDeep", () => {
@@ -12,12 +13,14 @@ test("camelCaseDeep", () => {
someOtherProp: "someOtherValue", someOtherProp: "someOtherValue",
} }
expect(camelCaseDeep(input)).toEqual<CamelCasedPropertiesDeep<typeof input>>({ const expected: CamelCasedPropertiesDeep<typeof input> = {
someProp: { someProp: {
someDeepProp: "some_deep_value", someDeepProp: "some_deep_value",
}, },
someOtherProp: "someOtherValue", someOtherProp: "someOtherValue",
}) }
expect(camelCaseDeep(input)).toEqual(expected)
}) })
test("snakeCaseDeep", () => { test("snakeCaseDeep", () => {
@@ -28,10 +31,12 @@ test("snakeCaseDeep", () => {
some_other_prop: "someOtherValue", some_other_prop: "someOtherValue",
} }
expect(snakeCaseDeep(input)).toEqual<SnakeCasedPropertiesDeep<typeof input>>({ const expected: SnakeCasedPropertiesDeep<typeof input> = {
some_prop: { some_prop: {
some_deep_prop: "someDeepValue", some_deep_prop: "someDeepValue",
}, },
some_other_prop: "someOtherValue", some_other_prop: "someOtherValue",
}) }
expect(snakeCaseDeep(input)).toEqual(expected)
}) })
@@ -0,0 +1,21 @@
export function generatePropCombinations<P>(values: {
[K in keyof P]: ReadonlyArray<P[K]>
}) {
return generatePropCombinationsRecursive(values) as P[]
}
function generatePropCombinationsRecursive(
value: Record<string, readonly unknown[]>,
): Array<Record<string, unknown>> {
const [key] = Object.keys(value)
if (!key) return [{}]
const { [key]: values = [], ...otherValues } = value
const result: Array<Record<string, unknown>> = []
for (const value of values) {
for (const otherValue of generatePropCombinationsRecursive(otherValues)) {
result.push({ [key]: value, ...otherValue })
}
}
return result
}
+7
View File
@@ -0,0 +1,7 @@
export function isObject<T>(
value: T,
): value is Exclude<T, Primitive | AnyFunction> {
return typeof value === "object" && value !== null
}
type Primitive = string | number | boolean | undefined | null
type AnyFunction = (...args: any[]) => any
@@ -1,6 +1,5 @@
import { inspect } from "node:util" import { inspect } from "node:util"
// eslint-disable-next-line import/no-unused-modules
export function logPretty(value: unknown) { export function logPretty(value: unknown) {
console.info( console.info(
inspect(value, { inspect(value, {
@@ -1,4 +1,3 @@
// eslint-disable-next-line import/no-unused-modules
export function omit<Subject extends object, Key extends PropertyKey>( export function omit<Subject extends object, Key extends PropertyKey>(
subject: Subject, subject: Subject,
keys: Key[], keys: Key[],
+11
View File
@@ -0,0 +1,11 @@
{
"name": "@reacord/helpers",
"type": "module",
"private": true,
"dependencies": {
"@types/lodash-es": "^4.17.6",
"@types/node": "*",
"lodash-es": "^4.17.21",
"type-fest": "^2.18.0"
}
}
@@ -1,6 +1,5 @@
import type { LoosePick, UnknownRecord } from "./types" import type { LoosePick, UnknownRecord } from "./types"
// eslint-disable-next-line import/no-unused-modules
export function pick<T, K extends keyof T | PropertyKey>( export function pick<T, K extends keyof T | PropertyKey>(
object: T, object: T,
keys: K[], keys: K[],
@@ -0,0 +1,35 @@
import { expect, test } from "vitest"
import type { PruneNullishValues } from "./prune-nullish-values"
import { pruneNullishValues } from "./prune-nullish-values"
test("pruneNullishValues", () => {
type InputType = {
a: string
b: string | null | undefined
c?: string
d: {
a: string
b: string | undefined
}
}
const input: InputType = {
a: "a",
// eslint-disable-next-line unicorn/no-null
b: null,
c: undefined,
d: {
a: "a",
b: undefined,
},
}
const output: PruneNullishValues<InputType> = {
a: "a",
d: {
a: "a",
},
}
expect(pruneNullishValues(input)).toEqual(output)
})
+42
View File
@@ -0,0 +1,42 @@
import { isObject } from "./is-object"
export function pruneNullishValues<T>(input: T): PruneNullishValues<T> {
if (Array.isArray(input)) {
return input.filter(Boolean).map((item) => pruneNullishValues(item)) as any
}
if (!isObject(input)) {
return input as any
}
const result: any = {}
for (const [key, value] of Object.entries(input)) {
if (value != undefined) {
result[key] = pruneNullishValues(value)
}
}
return result
}
export type PruneNullishValues<Input> = Input extends object
? OptionalKeys<
{ [Key in keyof Input]: NonNullable<PruneNullishValues<Input[Key]>> },
KeysWithNullishValues<Input>
>
: Input
type OptionalKeys<Input, Keys extends keyof Input> = Omit<Input, Keys> & {
[Key in Keys]?: Input[Key]
}
type KeysWithNullishValues<Input> = NonNullable<
Values<{
[Key in keyof Input]: null extends Input[Key]
? Key
: undefined extends Input[Key]
? Key
: never
}>
>
type Values<Input> = Input[keyof Input]
@@ -6,5 +6,5 @@ export async function rejectAfter(
error: unknown = `rejected after ${timeMs}ms`, error: unknown = `rejected after ${timeMs}ms`,
): Promise<never> { ): Promise<never> {
await setTimeout(timeMs) await setTimeout(timeMs)
return Promise.reject(toError(error)) throw toError(error)
} }
@@ -1,4 +1,3 @@
/* eslint-disable import/no-unused-modules */
export type MaybePromise<T> = T | Promise<T> export type MaybePromise<T> = T | Promise<T>
export type ValueOf<Type> = Type extends ReadonlyArray<infer Value> export type ValueOf<Type> = Type extends ReadonlyArray<infer Value>
+21
View File
@@ -0,0 +1,21 @@
import { setTimeout } from "node:timers/promises"
const maxTime = 1000
export async function waitFor<Result>(
predicate: () => Result,
): Promise<Awaited<Result>> {
const startTime = Date.now()
let lastError: unknown
while (Date.now() - startTime < maxTime) {
try {
return await predicate()
} catch (error) {
lastError = error
await setTimeout(50)
}
}
throw lastError ?? new Error("Timeout")
}
@@ -1,6 +1,5 @@
import { inspect } from "node:util" import { inspect } from "node:util"
// eslint-disable-next-line import/no-unused-modules
export function withLoggedMethodCalls<T extends object>(value: T) { export function withLoggedMethodCalls<T extends object>(value: T) {
return new Proxy(value as Record<string | symbol, unknown>, { return new Proxy(value as Record<string | symbol, unknown>, {
get(target, property) { get(target, property) {
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@reacord/playground",
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/main.tsx"
},
"dependencies": {
"@reacord/helpers": "workspace:*",
"discord.js": "^14.1.2",
"dotenv": "^16.0.1",
"ora": "^6.1.2",
"react": "^18.2.0"
},
"devDependencies": {
"@types/node": "*",
"@types/react": "^18.0.16",
"tsx": "^3.8.0",
"typescript": "^4.7.4"
}
}
+54
View File
@@ -0,0 +1,54 @@
import { raise } from "@reacord/helpers/raise"
import { Client, GatewayIntentBits } from "discord.js"
import * as dotenv from "dotenv"
import { join } from "node:path"
import { fileURLToPath } from "node:url"
import { oraPromise } from "ora"
import React from "react"
import { Button, ReacordClient } from "../../reacord/src/main"
dotenv.config({
path: join(fileURLToPath(import.meta.url), "../../../../.env"),
override: true,
})
const token = process.env.TEST_BOT_TOKEN ?? raise("TEST_BOT_TOKEN not defined")
const client = new Client({ intents: [GatewayIntentBits.Guilds] })
const reacord = new ReacordClient({ token })
client.once("ready", async (client) => {
try {
await oraPromise(
client.application.commands.create({
name: "counter",
description: "counts things",
}),
"Registering commands",
)
} catch (error) {
console.error("Failed to register commands:", error)
}
})
client.on("interactionCreate", async (interaction) => {
if (
interaction.isChatInputCommand() &&
interaction.commandName === "counter"
) {
reacord.reply(interaction, <Counter />)
// reacord.reply(interaction, "test3").render("test4")
}
})
await oraPromise(client.login(token), "Logging in")
function Counter() {
const [count, setCount] = React.useState(0)
return (
<>
count: {count}
<Button label="+" onClick={() => setCount(count + 1)} />
</>
)
}
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.base.json"
}
+2
View File
@@ -0,0 +1,2 @@
/README.md
/LICENSE
+15
View File
@@ -0,0 +1,15 @@
# reacord
## 0.5.1
### Patch Changes
- 72f4a4a: upgrade dependencies and remove some unneeded
- 7536bde: add types in exports to work with TS nodenext
- e335165: fix links
## 0.5.0
### Minor Changes
- aa65da5: allow JSX in more places
@@ -1,15 +0,0 @@
export function pruneNullishValues<T extends object>(
object: T,
): PruneNullishValues<T> {
const result: any = {}
for (const [key, value] of Object.entries(object)) {
if (value != undefined) {
result[key] = value
}
}
return result
}
type PruneNullishValues<T> = {
[Key in keyof T]: NonNullable<T[Key]>
}
-15
View File
@@ -1,15 +0,0 @@
/** @type {import('@jest/types').Config.InitialOptions} */
const config = {
transform: {
"^.+\\.[jt]sx?$": ["esbuild-jest", { format: "esm", sourcemap: true }],
},
extensionsToTreatAsEsm: [".ts", ".tsx"],
moduleNameMapper: {
"^(\\.\\.?/.+)\\.jsx?$": "$1",
},
verbose: true,
cache: false,
coverageReporters: ["text", "text-summary", "html"],
coveragePathIgnorePatterns: ["discord-js-adapter", "test/setup-testing"],
}
export default config
@@ -1,83 +0,0 @@
import type { ReactNode } from "react"
import type { ReacordInstance } from "./instance"
/**
* @category Component Event
*/
export type ComponentEvent = {
message: MessageInfo
channel: ChannelInfo
user: UserInfo
guild?: GuildInfo
reply(content?: ReactNode): ReacordInstance
ephemeralReply(content?: ReactNode): ReacordInstance
}
/**
* @category Component Event
*/
export type ChannelInfo = {
id: string
name?: string
topic?: string
nsfw?: boolean
lastMessageId?: string
ownerId?: string
parentId?: string
rateLimitPerUser?: number
}
/**
* @category Component Event
*/
export type MessageInfo = {
id: string
channelId: string
authorId: UserInfo
member?: GuildMemberInfo
content: string
timestamp: string
editedTimestamp?: string
tts: boolean
mentionEveryone: boolean
/** The IDs of mentioned users */
mentions: string[]
}
/**
* @category Component Event
*/
export type GuildInfo = {
id: string
name: string
member: GuildMemberInfo
}
/**
* @category Component Event
*/
export type GuildMemberInfo = {
id: string
nick?: string
displayName: string
avatarUrl?: string
displayAvatarUrl: string
roles: string[]
color: number
joinedAt?: string
premiumSince?: string
pending?: boolean
communicationDisabledUntil?: string
}
/**
* @category Component Event
*/
export type UserInfo = {
id: string
username: string
discriminator: string
tag: string
avatarUrl: string
accentColor?: number
}
@@ -1,58 +0,0 @@
import { nanoid } from "nanoid"
import React from "react"
import { ReacordElement } from "../../internal/element.js"
import type { ComponentInteraction } from "../../internal/interaction"
import type { MessageOptions } from "../../internal/message"
import { getNextActionRow } from "../../internal/message"
import { Node } from "../../internal/node.js"
import type { ComponentEvent } from "../component-event"
/**
* @category Button
*/
export type ButtonProps = {
label?: string
style?: "primary" | "secondary" | "success" | "danger"
disabled?: boolean
emoji?: string
onClick: (event: ButtonClickEvent) => void
}
/**
* @category Button
*/
export type ButtonClickEvent = ComponentEvent
/**
* @category Button
*/
export function Button(props: ButtonProps) {
return (
<ReacordElement props={props} createNode={() => new ButtonNode(props)} />
)
}
class ButtonNode extends Node<ButtonProps> {
private customId = nanoid()
override modifyMessageOptions(options: MessageOptions): void {
getNextActionRow(options).push({
type: "button",
customId: this.customId,
style: this.props.style ?? "secondary",
disabled: this.props.disabled,
emoji: this.props.emoji,
label: this.props.label,
})
}
override handleComponentInteraction(interaction: ComponentInteraction) {
if (
interaction.type === "button" &&
interaction.customId === this.customId
) {
this.props.onClick(interaction.event)
return true
}
return false
}
}
@@ -1,36 +0,0 @@
import React from "react"
import { ReacordElement } from "../../internal/element.js"
import { EmbedChildNode } from "./embed-child.js"
import type { EmbedOptions } from "./embed-options"
/**
* @category Embed
*/
export type EmbedAuthorProps = {
name?: string
children?: string
url?: string
iconUrl?: string
}
/**
* @category Embed
*/
export function EmbedAuthor(props: EmbedAuthorProps) {
return (
<ReacordElement
props={props}
createNode={() => new EmbedAuthorNode(props)}
/>
)
}
class EmbedAuthorNode extends EmbedChildNode<EmbedAuthorProps> {
override modifyEmbedOptions(options: EmbedOptions): void {
options.author = {
name: this.props.name ?? this.props.children ?? "",
url: this.props.url,
icon_url: this.props.iconUrl,
}
}
}
@@ -1,6 +0,0 @@
import { Node } from "../../internal/node.js"
import type { EmbedOptions } from "./embed-options"
export abstract class EmbedChildNode<Props> extends Node<Props> {
abstract modifyEmbedOptions(options: EmbedOptions): void
}
@@ -1,37 +0,0 @@
import React from "react"
import { ReacordElement } from "../../internal/element.js"
import { EmbedChildNode } from "./embed-child.js"
import type { EmbedOptions } from "./embed-options"
/**
* @category Embed
*/
export type EmbedFieldProps = {
name: string
value?: string
inline?: boolean
children?: string
}
/**
* @category Embed
*/
export function EmbedField(props: EmbedFieldProps) {
return (
<ReacordElement
props={props}
createNode={() => new EmbedFieldNode(props)}
/>
)
}
class EmbedFieldNode extends EmbedChildNode<EmbedFieldProps> {
override modifyEmbedOptions(options: EmbedOptions): void {
options.fields ??= []
options.fields.push({
name: this.props.name,
value: this.props.value ?? this.props.children ?? "",
inline: this.props.inline,
})
}
}
@@ -1,38 +0,0 @@
import React from "react"
import { ReacordElement } from "../../internal/element.js"
import { EmbedChildNode } from "./embed-child.js"
import type { EmbedOptions } from "./embed-options"
/**
* @category Embed
*/
export type EmbedFooterProps = {
text?: string
children?: string
iconUrl?: string
timestamp?: string | number | Date
}
/**
* @category Embed
*/
export function EmbedFooter(props: EmbedFooterProps) {
return (
<ReacordElement
props={props}
createNode={() => new EmbedFooterNode(props)}
/>
)
}
class EmbedFooterNode extends EmbedChildNode<EmbedFooterProps> {
override modifyEmbedOptions(options: EmbedOptions): void {
options.footer = {
text: this.props.text ?? this.props.children ?? "",
icon_url: this.props.iconUrl,
}
options.timestamp = this.props.timestamp
? new Date(this.props.timestamp).toISOString()
: undefined
}
}
@@ -1,29 +0,0 @@
import React from "react"
import { ReacordElement } from "../../internal/element.js"
import { EmbedChildNode } from "./embed-child.js"
import type { EmbedOptions } from "./embed-options"
/**
* @category Embed
*/
export type EmbedImageProps = {
url: string
}
/**
* @category Embed
*/
export function EmbedImage(props: EmbedImageProps) {
return (
<ReacordElement
props={props}
createNode={() => new EmbedImageNode(props)}
/>
)
}
class EmbedImageNode extends EmbedChildNode<EmbedImageProps> {
override modifyEmbedOptions(options: EmbedOptions): void {
options.image = { url: this.props.url }
}
}
@@ -1,8 +0,0 @@
import type { Except, SnakeCasedPropertiesDeep } from "type-fest"
import type { EmbedProps } from "./embed"
export type EmbedOptions = SnakeCasedPropertiesDeep<
Except<EmbedProps, "timestamp" | "children"> & {
timestamp?: string
}
>
@@ -1,29 +0,0 @@
import React from "react"
import { ReacordElement } from "../../internal/element.js"
import { EmbedChildNode } from "./embed-child.js"
import type { EmbedOptions } from "./embed-options"
/**
* @category Embed
*/
export type EmbedThumbnailProps = {
url: string
}
/**
* @category Embed
*/
export function EmbedThumbnail(props: EmbedThumbnailProps) {
return (
<ReacordElement
props={props}
createNode={() => new EmbedThumbnailNode(props)}
/>
)
}
class EmbedThumbnailNode extends EmbedChildNode<EmbedThumbnailProps> {
override modifyEmbedOptions(options: EmbedOptions): void {
options.thumbnail = { url: this.props.url }
}
}
@@ -1,31 +0,0 @@
import React from "react"
import { ReacordElement } from "../../internal/element.js"
import { EmbedChildNode } from "./embed-child.js"
import type { EmbedOptions } from "./embed-options"
/**
* @category Embed
*/
export type EmbedTitleProps = {
children: string
url?: string
}
/**
* @category Embed
*/
export function EmbedTitle(props: EmbedTitleProps) {
return (
<ReacordElement
props={props}
createNode={() => new EmbedTitleNode(props)}
/>
)
}
class EmbedTitleNode extends EmbedChildNode<EmbedTitleProps> {
override modifyEmbedOptions(options: EmbedOptions): void {
options.title = this.props.children
options.url = this.props.url
}
}
@@ -1,60 +0,0 @@
import React from "react"
import { snakeCaseDeep } from "../../../helpers/convert-object-property-case"
import { omit } from "../../../helpers/omit"
import { ReacordElement } from "../../internal/element.js"
import type { MessageOptions } from "../../internal/message"
import { Node } from "../../internal/node.js"
import { TextNode } from "../../internal/text-node"
import { EmbedChildNode } from "./embed-child.js"
import type { EmbedOptions } from "./embed-options"
/**
* @category Embed
*/
export type EmbedProps = {
title?: string
description?: string
url?: string
color?: number
fields?: Array<{ name: string; value: string; inline?: boolean }>
author?: { name: string; url?: string; iconUrl?: string }
thumbnail?: { url: string }
image?: { url: string }
video?: { url: string }
footer?: { text: string; iconUrl?: string }
timestamp?: string | number | Date
children?: React.ReactNode
}
/**
* @category Embed
*/
export function Embed(props: EmbedProps) {
return (
<ReacordElement props={props} createNode={() => new EmbedNode(props)}>
{props.children}
</ReacordElement>
)
}
class EmbedNode extends Node<EmbedProps> {
override modifyMessageOptions(options: MessageOptions): void {
const embed: EmbedOptions = {
...snakeCaseDeep(omit(this.props, ["children", "timestamp"])),
timestamp: this.props.timestamp
? new Date(this.props.timestamp).toISOString()
: undefined,
}
for (const child of this.children) {
if (child instanceof EmbedChildNode) {
child.modifyEmbedOptions(embed)
}
if (child instanceof TextNode) {
embed.description = (embed.description || "") + child.props
}
}
options.embeds.push(embed)
}
}
@@ -1,35 +0,0 @@
import React from "react"
import { ReacordElement } from "../../internal/element.js"
import type { MessageOptions } from "../../internal/message"
import { getNextActionRow } from "../../internal/message"
import { Node } from "../../internal/node.js"
/**
* @category Link
*/
export type LinkProps = {
label?: string
children?: string
emoji?: string
disabled?: boolean
url: string
}
/**
* @category Link
*/
export function Link(props: LinkProps) {
return <ReacordElement props={props} createNode={() => new LinkNode(props)} />
}
class LinkNode extends Node<LinkProps> {
override modifyMessageOptions(options: MessageOptions): void {
getNextActionRow(options).push({
type: "link",
disabled: this.props.disabled,
emoji: this.props.emoji,
label: this.props.label || this.props.children,
url: this.props.url,
})
}
}
@@ -1,14 +0,0 @@
import type { MessageSelectOptionOptions } from "../../internal/message"
import { Node } from "../../internal/node"
import type { OptionProps } from "./option"
export class OptionNode extends Node<OptionProps> {
get options(): MessageSelectOptionOptions {
return {
label: this.props.children || this.props.label || this.props.value,
value: this.props.value,
description: this.props.description,
emoji: this.props.emoji,
}
}
}
@@ -1,23 +0,0 @@
import React from "react"
import { ReacordElement } from "../../internal/element"
import { OptionNode } from "./option-node"
/**
* @category Select
*/
export type OptionProps = {
label?: string
children?: string
value: string
description?: string
emoji?: string
}
/**
* @category Select
*/
export function Option(props: OptionProps) {
return (
<ReacordElement props={props} createNode={() => new OptionNode(props)} />
)
}
@@ -1,99 +0,0 @@
import { nanoid } from "nanoid"
import type { ReactNode } from "react"
import React from "react"
import { isInstanceOf } from "../../../helpers/is-instance-of"
import { ReacordElement } from "../../internal/element.js"
import type { ComponentInteraction } from "../../internal/interaction"
import type { ActionRow, MessageOptions } from "../../internal/message"
import { Node } from "../../internal/node.js"
import type { ComponentEvent } from "../component-event"
import { OptionNode } from "./option-node"
/**
* @category Select
*/
export type SelectProps = {
children?: ReactNode
value?: string
values?: string[]
placeholder?: string
multiple?: boolean
minValues?: number
maxValues?: number
disabled?: boolean
onChange?: (event: SelectChangeEvent) => void
onChangeValue?: (value: string, event: SelectChangeEvent) => void
onChangeMultiple?: (values: string[], event: SelectChangeEvent) => void
}
/**
* @category Select
*/
export type SelectChangeEvent = ComponentEvent & {
values: string[]
}
/**
* @category Select
*/
export function Select(props: SelectProps) {
return (
<ReacordElement props={props} createNode={() => new SelectNode(props)}>
{props.children}
</ReacordElement>
)
}
class SelectNode extends Node<SelectProps> {
readonly customId = nanoid()
override modifyMessageOptions(message: MessageOptions): void {
const actionRow: ActionRow = []
message.actionRows.push(actionRow)
const options = [...this.children]
.filter(isInstanceOf(OptionNode))
.map((node) => node.options)
const {
multiple,
value,
values,
minValues = 0,
maxValues = 25,
children,
onChange,
onChangeValue,
onChangeMultiple,
...props
} = this.props
actionRow.push({
...props,
type: "select",
customId: this.customId,
options,
values: [...(values || []), ...(value ? [value] : [])],
minValues: multiple ? minValues : undefined,
maxValues: multiple ? Math.max(minValues, maxValues) : undefined,
})
}
override handleComponentInteraction(
interaction: ComponentInteraction,
): boolean {
const isSelectInteraction =
interaction.type === "select" &&
interaction.customId === this.customId &&
!this.props.disabled
if (!isSelectInteraction) return false
this.props.onChange?.(interaction.event)
this.props.onChangeMultiple?.(interaction.event.values, interaction.event)
if (interaction.event.values[0]) {
this.props.onChangeValue?.(interaction.event.values[0], interaction.event)
}
return true
}
}
-10
View File
@@ -1,10 +0,0 @@
import type { ReactNode } from "react"
/**
* @category Core
*/
export type ReacordInstance = {
render: (content: ReactNode) => void
deactivate: () => void
destroy: () => void
}
@@ -1,374 +0,0 @@
/* eslint-disable class-methods-use-this */
import * as Discord from "discord.js"
import type { ReactNode } from "react"
import type { Except } from "type-fest"
import { pick } from "../../helpers/pick"
import { pruneNullishValues } from "../../helpers/prune-null-values"
import { raise } from "../../helpers/raise"
import { toUpper } from "../../helpers/to-upper"
import type { ComponentInteraction } from "../internal/interaction"
import type { Message, MessageOptions } from "../internal/message"
import { ChannelMessageRenderer } from "../internal/renderers/channel-message-renderer"
import { InteractionReplyRenderer } from "../internal/renderers/interaction-reply-renderer"
import type {
ChannelInfo,
GuildInfo,
GuildMemberInfo,
MessageInfo,
UserInfo,
} from "./component-event"
import type { ReacordInstance } from "./instance"
import type { ReacordConfig } from "./reacord"
import { Reacord } from "./reacord"
/**
* The Reacord adapter for Discord.js.
* @category Core
*/
export class ReacordDiscordJs extends Reacord {
constructor(private client: Discord.Client, config: ReacordConfig = {}) {
super(config)
client.on("interactionCreate", (interaction) => {
if (interaction.isMessageComponent()) {
this.handleComponentInteraction(
this.createReacordComponentInteraction(interaction),
)
}
})
}
/**
* Sends a message to a channel.
* @param channelId The ID of the channel to create a message in.
* @param initialContent The initial content of the message.
* @returns A Record instance.
*/
override send(
channelId: string,
initialContent?: React.ReactNode,
): ReacordInstance {
return this.createInstance(
this.createChannelRenderer(channelId),
initialContent,
)
}
override reply(
interaction: Discord.CommandInteraction,
initialContent?: React.ReactNode,
): ReacordInstance {
return this.createInstance(
this.createInteractionReplyRenderer(interaction),
initialContent,
)
}
override ephemeralReply(
interaction: Discord.CommandInteraction,
initialContent?: React.ReactNode,
): ReacordInstance {
return this.createInstance(
this.createEphemeralInteractionReplyRenderer(interaction),
initialContent,
)
}
private createChannelRenderer(channelId: string) {
return new ChannelMessageRenderer({
send: async (options) => {
const channel =
this.client.channels.cache.get(channelId) ??
(await this.client.channels.fetch(channelId)) ??
raise(`Channel ${channelId} not found`)
if (!channel.isText()) {
raise(`Channel ${channelId} is not a text channel`)
}
const message = await channel.send(getDiscordMessageOptions(options))
return createReacordMessage(message)
},
})
}
private createInteractionReplyRenderer(
interaction:
| Discord.CommandInteraction
| Discord.MessageComponentInteraction,
) {
return new InteractionReplyRenderer({
type: "command",
id: interaction.id,
reply: async (options) => {
const message = await interaction.reply({
...getDiscordMessageOptions(options),
fetchReply: true,
})
return createReacordMessage(message as Discord.Message)
},
followUp: async (options) => {
const message = await interaction.followUp({
...getDiscordMessageOptions(options),
fetchReply: true,
})
return createReacordMessage(message as Discord.Message)
},
})
}
private createEphemeralInteractionReplyRenderer(
interaction:
| Discord.CommandInteraction
| Discord.MessageComponentInteraction,
) {
return new InteractionReplyRenderer({
type: "command",
id: interaction.id,
reply: async (options) => {
await interaction.reply({
...getDiscordMessageOptions(options),
ephemeral: true,
})
return createEphemeralReacordMessage()
},
followUp: async (options) => {
await interaction.followUp({
...getDiscordMessageOptions(options),
ephemeral: true,
})
return createEphemeralReacordMessage()
},
})
}
private createReacordComponentInteraction(
interaction: Discord.MessageComponentInteraction,
): ComponentInteraction {
// todo please dear god clean this up
const channel: ChannelInfo = interaction.channel
? {
...pick(pruneNullishValues(interaction.channel), [
"topic",
"nsfw",
"lastMessageId",
"ownerId",
"parentId",
"rateLimitPerUser",
]),
id: interaction.channelId,
}
: raise("Non-channel interactions are not supported")
const message: MessageInfo =
interaction.message instanceof Discord.Message
? {
...pick(interaction.message, [
"id",
"channelId",
"authorId",
"content",
"tts",
"mentionEveryone",
]),
timestamp: new Date(
interaction.message.createdTimestamp,
).toISOString(),
editedTimestamp: interaction.message.editedTimestamp
? new Date(interaction.message.editedTimestamp).toISOString()
: undefined,
mentions: interaction.message.mentions.users.map((u) => u.id),
}
: raise("Message not found")
const member: GuildMemberInfo | undefined =
interaction.member instanceof Discord.GuildMember
? {
...pick(pruneNullishValues(interaction.member), [
"id",
"nick",
"displayName",
"avatarUrl",
"displayAvatarUrl",
"color",
"pending",
]),
displayName: interaction.member.displayName,
roles: [...interaction.member.roles.cache.map((role) => role.id)],
joinedAt: interaction.member.joinedAt?.toISOString(),
premiumSince: interaction.member.premiumSince?.toISOString(),
communicationDisabledUntil:
interaction.member.communicationDisabledUntil?.toISOString(),
}
: undefined
const guild: GuildInfo | undefined = interaction.guild
? {
...pick(pruneNullishValues(interaction.guild), ["id", "name"]),
member: member ?? raise("unexpected: member is undefined"),
}
: undefined
const user: UserInfo = {
...pick(pruneNullishValues(interaction.user), [
"id",
"username",
"discriminator",
"tag",
]),
avatarUrl: interaction.user.avatarURL()!,
accentColor: interaction.user.accentColor ?? undefined,
}
const baseProps: Except<ComponentInteraction, "type"> = {
id: interaction.id,
customId: interaction.customId,
update: async (options: MessageOptions) => {
await interaction.update(getDiscordMessageOptions(options))
},
deferUpdate: async () => {
if (interaction.replied || interaction.deferred) return
await interaction.deferUpdate()
},
reply: async (options) => {
const message = await interaction.reply({
...getDiscordMessageOptions(options),
fetchReply: true,
})
return createReacordMessage(message as Discord.Message)
},
followUp: async (options) => {
const message = await interaction.followUp({
...getDiscordMessageOptions(options),
fetchReply: true,
})
return createReacordMessage(message as Discord.Message)
},
event: {
channel,
message,
user,
guild,
reply: (content?: ReactNode) =>
this.createInstance(
this.createInteractionReplyRenderer(interaction),
content,
),
ephemeralReply: (content: ReactNode) =>
this.createInstance(
this.createEphemeralInteractionReplyRenderer(interaction),
content,
),
},
}
if (interaction.isButton()) {
return {
...baseProps,
type: "button",
}
}
if (interaction.isSelectMenu()) {
return {
...baseProps,
type: "select",
event: {
...baseProps.event,
values: interaction.values,
},
}
}
raise(`Unsupported component interaction type: ${interaction.type}`)
}
}
function createReacordMessage(message: Discord.Message): Message {
return {
edit: async (options) => {
await message.edit(getDiscordMessageOptions(options))
},
disableComponents: async () => {
for (const actionRow of message.components) {
for (const component of actionRow.components) {
component.setDisabled(true)
}
}
await message.edit({
components: message.components,
})
},
delete: async () => {
await message.delete()
},
}
}
function createEphemeralReacordMessage(): Message {
return {
edit: () => {
console.warn("Ephemeral messages can't be edited")
return Promise.resolve()
},
disableComponents: () => {
console.warn("Ephemeral messages can't be edited")
return Promise.resolve()
},
delete: () => {
console.warn("Ephemeral messages can't be deleted")
return Promise.resolve()
},
}
}
// TODO: this could be a part of the core library,
// and also handle some edge cases, e.g. empty messages
function getDiscordMessageOptions(
reacordOptions: MessageOptions,
): Discord.MessageOptions {
const options: Discord.MessageOptions = {
// eslint-disable-next-line unicorn/no-null
content: reacordOptions.content || null,
embeds: reacordOptions.embeds,
components: reacordOptions.actionRows.map((row) => ({
type: "ACTION_ROW",
components: row.map(
(component): Discord.MessageActionRowComponentOptions => {
if (component.type === "button") {
return {
type: "BUTTON",
customId: component.customId,
label: component.label ?? "",
style: toUpper(component.style ?? "secondary"),
disabled: component.disabled,
emoji: component.emoji,
}
}
if (component.type === "select") {
return {
...component,
type: "SELECT_MENU",
options: component.options.map((option) => ({
...option,
default: component.values?.includes(option.value),
})),
}
}
raise(`Unsupported component type: ${component.type}`)
},
),
})),
}
if (!options.content && !options.embeds?.length) {
options.content = "_ _"
}
return options
}
@@ -1,295 +0,0 @@
/* eslint-disable class-methods-use-this */
/* eslint-disable require-await */
import { nanoid } from "nanoid"
import { nextTick } from "node:process"
import { promisify } from "node:util"
import type { ReactNode } from "react"
import { logPretty } from "../../helpers/log-pretty"
import { omit } from "../../helpers/omit"
import { raise } from "../../helpers/raise"
import type { Channel } from "../internal/channel"
import { Container } from "../internal/container"
import type {
ButtonInteraction,
CommandInteraction,
SelectInteraction,
} from "../internal/interaction"
import type {
Message,
MessageButtonOptions,
MessageOptions,
MessageSelectOptions,
} from "../internal/message"
import { ChannelMessageRenderer } from "../internal/renderers/channel-message-renderer"
import { InteractionReplyRenderer } from "../internal/renderers/interaction-reply-renderer"
import type {
ChannelInfo,
GuildInfo,
MessageInfo,
UserInfo,
} from "./component-event"
import type { ButtonClickEvent } from "./components/button"
import type { SelectChangeEvent } from "./components/select"
import type { ReacordInstance } from "./instance"
import { Reacord } from "./reacord"
const nextTickPromise = promisify(nextTick)
/**
* A Record adapter for automated tests. WIP
*/
export class ReacordTester extends Reacord {
private messageContainer = new Container<TestMessage>()
constructor() {
super({ maxInstances: 2 })
}
get messages(): readonly TestMessage[] {
return [...this.messageContainer]
}
override send(): ReacordInstance {
return this.createInstance(
new ChannelMessageRenderer(new TestChannel(this.messageContainer)),
)
}
override reply(): ReacordInstance {
return this.createInstance(
new InteractionReplyRenderer(
new TestCommandInteraction(this.messageContainer),
),
)
}
override ephemeralReply(): ReacordInstance {
return this.reply()
}
async assertMessages(expected: ReturnType<this["sampleMessages"]>) {
await nextTickPromise()
expect(this.sampleMessages()).toEqual(expected)
}
async assertRender(
content: ReactNode,
expected: ReturnType<this["sampleMessages"]>,
) {
const instance = this.reply()
instance.render(content)
await this.assertMessages(expected)
instance.destroy()
}
logMessages() {
logPretty(this.sampleMessages())
}
sampleMessages() {
return this.messages.map((message) => ({
...message.options,
actionRows: message.options.actionRows.map((row) =>
row.map((component) =>
omit(component, ["customId", "onClick", "onSelect", "onSelectValue"]),
),
),
}))
}
findButtonByLabel(label: string) {
for (const message of this.messageContainer) {
for (const component of message.options.actionRows.flat()) {
if (component.type === "button" && component.label === label) {
return this.createButtonActions(component, message)
}
}
}
raise(`Couldn't find button with label "${label}"`)
}
findSelectByPlaceholder(placeholder: string) {
for (const message of this.messageContainer) {
for (const component of message.options.actionRows.flat()) {
if (
component.type === "select" &&
component.placeholder === placeholder
) {
return this.createSelectActions(component, message)
}
}
}
raise(`Couldn't find select with placeholder "${placeholder}"`)
}
createMessage(options: MessageOptions) {
return new TestMessage(options, this.messageContainer)
}
private createButtonActions(
button: MessageButtonOptions,
message: TestMessage,
) {
return {
click: () => {
this.handleComponentInteraction(
new TestButtonInteraction(button.customId, message, this),
)
},
}
}
private createSelectActions(
component: MessageSelectOptions,
message: TestMessage,
) {
return {
select: (...values: string[]) => {
this.handleComponentInteraction(
new TestSelectInteraction(component.customId, message, values, this),
)
},
}
}
}
class TestMessage implements Message {
constructor(
public options: MessageOptions,
private container: Container<TestMessage>,
) {
container.add(this)
}
async edit(options: MessageOptions): Promise<void> {
this.options = options
}
async disableComponents(): Promise<void> {
for (const row of this.options.actionRows) {
for (const action of row) {
if (action.type === "button") {
action.disabled = true
}
}
}
}
async delete(): Promise<void> {
this.container.remove(this)
}
}
class TestCommandInteraction implements CommandInteraction {
readonly type = "command"
readonly id = "test-command-interaction"
readonly channelId = "test-channel-id"
constructor(private messageContainer: Container<TestMessage>) {}
reply(messageOptions: MessageOptions): Promise<Message> {
return Promise.resolve(
new TestMessage(messageOptions, this.messageContainer),
)
}
followUp(messageOptions: MessageOptions): Promise<Message> {
return Promise.resolve(
new TestMessage(messageOptions, this.messageContainer),
)
}
}
class TestInteraction {
readonly id = nanoid()
readonly channelId = "test-channel-id"
constructor(
readonly customId: string,
readonly message: TestMessage,
private tester: ReacordTester,
) {}
async update(options: MessageOptions): Promise<void> {
this.message.options = options
}
async deferUpdate(): Promise<void> {}
async reply(messageOptions: MessageOptions): Promise<Message> {
return this.tester.createMessage(messageOptions)
}
async followUp(messageOptions: MessageOptions): Promise<Message> {
return this.tester.createMessage(messageOptions)
}
}
class TestButtonInteraction
extends TestInteraction
implements ButtonInteraction
{
readonly type = "button"
readonly event: ButtonClickEvent
constructor(customId: string, message: TestMessage, tester: ReacordTester) {
super(customId, message, tester)
this.event = new TestButtonClickEvent(tester)
}
}
class TestSelectInteraction
extends TestInteraction
implements SelectInteraction
{
readonly type = "select"
readonly event: SelectChangeEvent
constructor(
customId: string,
message: TestMessage,
readonly values: string[],
tester: ReacordTester,
) {
super(customId, message, tester)
this.event = new TestSelectChangeEvent(values, tester)
}
}
class TestComponentEvent {
constructor(private tester: ReacordTester) {}
message: MessageInfo = {} as any // todo
channel: ChannelInfo = {} as any // todo
user: UserInfo = {} as any // todo
guild: GuildInfo = {} as any // todo
reply(content?: ReactNode): ReacordInstance {
return this.tester.reply()
}
ephemeralReply(content?: ReactNode): ReacordInstance {
return this.tester.ephemeralReply()
}
}
class TestButtonClickEvent
extends TestComponentEvent
implements ButtonClickEvent {}
class TestSelectChangeEvent
extends TestComponentEvent
implements SelectChangeEvent
{
constructor(readonly values: string[], tester: ReacordTester) {
super(tester)
}
}
class TestChannel implements Channel {
constructor(private messageContainer: Container<TestMessage>) {}
async send(messageOptions: MessageOptions): Promise<Message> {
return new TestMessage(messageOptions, this.messageContainer)
}
}
-72
View File
@@ -1,72 +0,0 @@
import type { ReactNode } from "react"
import type { ComponentInteraction } from "../internal/interaction"
import { reconciler } from "../internal/reconciler.js"
import type { Renderer } from "../internal/renderers/renderer"
import type { ReacordInstance } from "./instance"
/**
* @category Core
*/
export type ReacordConfig = {
/**
* The max number of active instances.
* When this limit is exceeded, the oldest instances will be disabled.
*/
maxInstances?: number
}
/**
* The main Reacord class that other Reacord adapters should extend.
* Only use this directly if you're making [a custom adapter](/guides/custom-adapters).
*/
export abstract class Reacord {
private renderers: Renderer[] = []
constructor(private readonly config: ReacordConfig = {}) {}
abstract send(...args: unknown[]): ReacordInstance
abstract reply(...args: unknown[]): ReacordInstance
abstract ephemeralReply(...args: unknown[]): ReacordInstance
protected handleComponentInteraction(interaction: ComponentInteraction) {
for (const renderer of this.renderers) {
if (renderer.handleComponentInteraction(interaction)) return
}
}
private get maxInstances() {
return this.config.maxInstances ?? 50
}
protected createInstance(renderer: Renderer, initialContent?: ReactNode) {
if (this.renderers.length > this.maxInstances) {
this.deactivate(this.renderers[0]!)
}
this.renderers.push(renderer)
const container = reconciler.createContainer(renderer, 0, false, {})
if (initialContent !== undefined) {
reconciler.updateContainer(initialContent, container)
}
return {
render: (content: ReactNode) => {
reconciler.updateContainer(content, container)
},
deactivate: () => {
this.deactivate(renderer)
},
destroy: () => {
this.renderers = this.renderers.filter((it) => it !== renderer)
renderer.destroy()
},
}
}
private deactivate(renderer: Renderer) {
this.renderers = this.renderers.filter((it) => it !== renderer)
renderer.deactivate()
}
}
@@ -1,5 +0,0 @@
import type { Message, MessageOptions } from "./message"
export type Channel = {
send(message: MessageOptions): Promise<Message>
}
@@ -1,27 +0,0 @@
export class Container<T> {
private items: T[] = []
add(...items: T[]) {
this.items.push(...items)
}
addBefore(item: T, before: T) {
let index = this.items.indexOf(before)
if (index === -1) {
index = this.items.length
}
this.items.splice(index, 0, item)
}
remove(toRemove: T) {
this.items = this.items.filter((item) => item !== toRemove)
}
clear() {
this.items = []
}
[Symbol.iterator]() {
return this.items[Symbol.iterator]()
}
}
@@ -1,35 +0,0 @@
import type { ComponentEvent } from "../core/component-event"
import type { ButtonClickEvent, SelectChangeEvent } from "../main"
import type { Message, MessageOptions } from "./message"
export type Interaction = CommandInteraction | ComponentInteraction
export type ComponentInteraction = ButtonInteraction | SelectInteraction
export type CommandInteraction = BaseInteraction<"command">
export type ButtonInteraction = BaseComponentInteraction<
"button",
ButtonClickEvent
>
export type SelectInteraction = BaseComponentInteraction<
"select",
SelectChangeEvent
>
export type BaseInteraction<Type extends string> = {
type: Type
id: string
reply(messageOptions: MessageOptions): Promise<Message>
followUp(messageOptions: MessageOptions): Promise<Message>
}
export type BaseComponentInteraction<
Type extends string,
Event extends ComponentEvent,
> = BaseInteraction<Type> & {
event: Event
customId: string
update(options: MessageOptions): Promise<void>
deferUpdate(): Promise<void>
}
@@ -1,24 +0,0 @@
export class LimitedCollection<T> {
private items: T[] = []
constructor(private readonly size: number) {}
add(item: T) {
if (this.items.length >= this.size) {
this.items.shift()
}
this.items.push(item)
}
has(item: T) {
return this.items.includes(item)
}
values(): readonly T[] {
return this.items
}
[Symbol.iterator]() {
return this.items[Symbol.iterator]()
}
}
@@ -1,63 +0,0 @@
import type { Except } from "type-fest"
import { last } from "../../helpers/last"
import type { EmbedOptions } from "../core/components/embed-options"
import type { SelectProps } from "../core/components/select"
export type MessageOptions = {
content: string
embeds: EmbedOptions[]
actionRows: ActionRow[]
}
export type ActionRow = Array<
MessageButtonOptions | MessageLinkOptions | MessageSelectOptions
>
export type MessageButtonOptions = {
type: "button"
customId: string
label?: string
style?: "primary" | "secondary" | "success" | "danger"
disabled?: boolean
emoji?: string
}
export type MessageLinkOptions = {
type: "link"
url: string
label?: string
emoji?: string
disabled?: boolean
}
export type MessageSelectOptions = Except<SelectProps, "children" | "value"> & {
type: "select"
customId: string
options: MessageSelectOptionOptions[]
}
export type MessageSelectOptionOptions = {
label: string
value: string
description?: string
emoji?: string
}
export type Message = {
edit(options: MessageOptions): Promise<void>
delete(): Promise<void>
disableComponents(): Promise<void>
}
export function getNextActionRow(options: MessageOptions): ActionRow {
let actionRow = last(options.actionRows)
if (
actionRow == undefined ||
actionRow.length >= 5 ||
actionRow[0]?.type === "select"
) {
actionRow = []
options.actionRows.push(actionRow)
}
return actionRow
}
-16
View File
@@ -1,16 +0,0 @@
/* eslint-disable class-methods-use-this */
import { Container } from "./container.js"
import type { ComponentInteraction } from "./interaction"
import type { MessageOptions } from "./message"
export abstract class Node<Props> {
readonly children = new Container<Node<unknown>>()
constructor(public props: Props) {}
modifyMessageOptions(options: MessageOptions) {}
handleComponentInteraction(interaction: ComponentInteraction): boolean {
return false
}
}
@@ -1,13 +0,0 @@
import type { Channel } from "../channel"
import type { Message, MessageOptions } from "../message"
import { Renderer } from "./renderer"
export class ChannelMessageRenderer extends Renderer {
constructor(private channel: Channel) {
super()
}
protected createMessage(options: MessageOptions): Promise<Message> {
return this.channel.send(options)
}
}
@@ -1,22 +0,0 @@
import type { Interaction } from "../interaction"
import type { Message, MessageOptions } from "../message"
import { Renderer } from "./renderer"
// keep track of interaction ids which have replies,
// so we know whether to call reply() or followUp()
const repliedInteractionIds = new Set<string>()
export class InteractionReplyRenderer extends Renderer {
constructor(private interaction: Interaction) {
super()
}
protected createMessage(options: MessageOptions): Promise<Message> {
if (repliedInteractionIds.has(this.interaction.id)) {
return this.interaction.followUp(options)
}
repliedInteractionIds.add(this.interaction.id)
return this.interaction.reply(options)
}
}
@@ -1,109 +0,0 @@
import { Subject } from "rxjs"
import { concatMap } from "rxjs/operators"
import { Container } from "../container.js"
import type { ComponentInteraction } from "../interaction"
import type { Message, MessageOptions } from "../message"
import type { Node } from "../node.js"
type UpdatePayload =
| { action: "update" | "deactivate"; options: MessageOptions }
| { action: "deferUpdate"; interaction: ComponentInteraction }
| { action: "destroy" }
export abstract class Renderer {
readonly nodes = new Container<Node<unknown>>()
private componentInteraction?: ComponentInteraction
private message?: Message
private active = true
private updates = new Subject<UpdatePayload>()
private updateSubscription = this.updates
.pipe(concatMap((payload) => this.updateMessage(payload)))
.subscribe({ error: console.error })
render() {
if (!this.active) {
console.warn("Attempted to update a deactivated message")
return
}
this.updates.next({
options: this.getMessageOptions(),
action: "update",
})
}
deactivate() {
this.active = false
this.updates.next({
options: this.getMessageOptions(),
action: "deactivate",
})
}
destroy() {
this.active = false
this.updates.next({ action: "destroy" })
}
handleComponentInteraction(interaction: ComponentInteraction) {
this.componentInteraction = interaction
setTimeout(() => {
this.updates.next({ action: "deferUpdate", interaction })
}, 500)
for (const node of this.nodes) {
if (node.handleComponentInteraction(interaction)) {
return true
}
}
}
protected abstract createMessage(options: MessageOptions): Promise<Message>
private getMessageOptions(): MessageOptions {
const options: MessageOptions = {
content: "",
embeds: [],
actionRows: [],
}
for (const node of this.nodes) {
node.modifyMessageOptions(options)
}
return options
}
private async updateMessage(payload: UpdatePayload) {
if (payload.action === "destroy") {
this.updateSubscription.unsubscribe()
await this.message?.delete()
return
}
if (payload.action === "deactivate") {
this.updateSubscription.unsubscribe()
await this.message?.disableComponents()
return
}
if (payload.action === "deferUpdate") {
await payload.interaction.deferUpdate()
return
}
if (this.componentInteraction) {
const promise = this.componentInteraction.update(payload.options)
this.componentInteraction = undefined
await promise
return
}
if (this.message) {
await this.message.edit(payload.options)
return
}
this.message = await this.createMessage(payload.options)
}
}
@@ -1,8 +0,0 @@
import type { MessageOptions } from "./message"
import { Node } from "./node.js"
export class TextNode extends Node<string> {
override modifyMessageOptions(options: MessageOptions) {
options.content = options.content + this.props
}
}

Some files were not shown because too many files have changed in this diff Show More