commit 18723c1377b300ea37c8fc990883000fa4bf9f2e Author: Richard Date: Sun Feb 25 03:41:09 2024 -0800 feat: initial commit diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..8f628fe --- /dev/null +++ b/.eslintrc @@ -0,0 +1,26 @@ +{ + "parser": "@typescript-eslint/parser", + "parserOptions": { + "jsx": true, + "useJSXTextNode": true, + "ecmaVersion": 2018, + "sourceType": "module", + "project": "./tsconfig.json" + }, + "plugins": [ + "@typescript-eslint", + "roblox-ts", + "prettier" + ], + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:roblox-ts/recommended", + "plugin:prettier/recommended" + ], + "rules": { + "prettier/prettier": "warn", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unsafe-declaration-merging": "off" + } +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..58d6f77 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text=auto eol=lf +*.luau linguist-language=Lua diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a8561d3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: CI + +on: + push: + branches: + - main + + pull_request: + branches: + - main + +jobs: + analyze: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v3 + with: + version: 8.15.4 + + - name: Install Aftman + uses: ok-nick/setup-aftman@v0.4.2 + + - name: Install dependencies + run: | + pnpm install + pnpm wally + + - name: Check code quality + run: pnpm lint + + - name: Run ESLint + run: pnpm eslint + + - name: Build model + run: rojo build -o charm.rbxm diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..cb6a3c2 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,29 @@ +name: Release + +on: + release: + types: + - published + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Aftman + uses: ok-nick/setup-aftman@v0.4.2 + + - name: Install dependencies + run: wally install + + - name: Build model + run: rojo build -o charm.rbxm + + - name: Upload release + uses: softprops/action-gh-release@v2 + if: ${{ github.event.release }} + with: + files: charm.rbxm + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f57862f --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/node_modules +/Packages +/DevPackages +/sourcemap.json +*.tsbuildinfo +*.tgz +.DS_Store diff --git a/.luaurc b/.luaurc new file mode 100644 index 0000000..e2b625c --- /dev/null +++ b/.luaurc @@ -0,0 +1,3 @@ +{ + "languageMode": "strict" +} diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..701a273 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "printWidth": 120, + "tabWidth": 2, + "trailingComma": "all", + "useTabs": true +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..e6aa02a --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "luau-lsp.sourcemap.rojoProjectFile": "dev.project.json", + "luau-lsp.types.definitionFiles": ["bin/testez.d.luau"], + "luau-lsp.completion.imports.requireStyle": "alwaysRelative" +} diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..ae1bb3d --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,7 @@ +Copyright 2024 Littensy + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..652d82e --- /dev/null +++ b/README.md @@ -0,0 +1,142 @@ +

+

+ Logo +

+

Charm

+

+ Atomic state management for Roblox. +
+ npm package → +

+

+ +
+ +![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/littensy/charm/ci.yml?style=for-the-badge&branch=master&logo=github) +[![NPM Version](https://img.shields.io/npm/v/@rbxts/charm.svg?style=for-the-badge&logo=npm)](https://www.npmjs.com/package/@rbxts/charm) +[![GitHub License](https://img.shields.io/github/license/littensy/charm?style=for-the-badge)](LICENSE.md) + +
+ +**Charm** is a Roblox state management library, inspired by [Jotai](https://jotai.org). Designed to be an alternative to [Reflex](https://littensy.github.io/reflex), Charm aims to bring an atomic and composable approach to your game's state. + +> [!NOTE] +> Charm is incomplete and not ready for use. + +## 📦 Setup + +### TypeScript + +Install Charm through your package manager of choice. \ +[React](https://github.com/littensy/rbxts-react) is optional, but highly recommended. + +```sh +npm install @rbxts/charm +yarn add @rbxts/charm +pnpm add @rbxts/charm +``` + +### Wally + +Add `littensy/charm` to your `wally.toml` file. + +```toml +[dependencies] +Charm = "littensy/charm@VERSION" +``` + +## 📚 Documentation + +### `atom(state)` + +### `derive(...atoms, combiner)` + +### `subscribe(atom, listener)` + +### `observe(atom, observer)` + +### `split(atom, keyExtractor?)` + +--- + +### `Atom(state?)` + +### `Atom.get(selector?)` + +### `Atom.set(state)` + +### `Atom.memo(equalityFn)` + +### `Atom.readonly()` + +### `Atom.writable(onSet?)` + +### `Atom.named(label)` + +### `Atom.unmount()` + +--- + +### `useAtom(atom, selector?)` + +### `useAtomState(atom)` + +### `useSetAtom(atom)` + +## 🚀 Examples + +### Counter atom + +```ts +import { atom, derive, subscribe } from "@rbxts/charm"; + +const counterAtom = atom(0); +const doubleCounterAtom = derive(counterAtom, (counter) => counter * 2); + +subscribe(doubleCounterAtom, (value) => { + print(value); +}); + +counterAtom(1); +counterAtom((count) => count + 1); +``` + +### Counter component + +```tsx +import React from "@rbxts/react"; +import { useAtom } from "@rbxts/charm"; +import { counterAtom } from "./counter-atom"; + +function Counter() { + const [count, setCount] = useAtom(counterAtom); + + return ( + setCount(count + 1), + }} + /> + ); +} +``` + +## 🙋 Concerns + +In Jotai, `atom()` returns an [atom config](https://jotai.org/docs/core/atom), and its state is managed entirely by the Provider. However, in Charm, `atom()` returns something closer to a store, and its state is managed by the atom itself. + +Charm does this for simplicity in use cases outside of React, but this might go against Jotai's focus on pure functional programming. More insight is needed to determine if this is a good idea. + +--- + +

+Charm is released under the MIT License. +

+ +
+ +[![MIT License](https://img.shields.io/badge/license-mit-f4dbd6?style=for-the-badge&labelColor=302d41)](LICENSE.md) + +
diff --git a/aftman.toml b/aftman.toml new file mode 100644 index 0000000..187b7df --- /dev/null +++ b/aftman.toml @@ -0,0 +1,11 @@ +# This file lists tools managed by Aftman, a cross-platform toolchain manager. +# For more information, see https://github.com/LPGhatguy/aftman + +# To add a new tool, add an entry to this table. +[tools] +wally = "UpliftGames/wally@0.3.2" +rojo = "rojo-rbx/rojo@7.4.1" +wally-package-types = "JohnnyMorganz/wally-package-types@1.3.1" +stylua = "johnnymorganz/stylua@0.20.0" +selene = "Kampfkarren/selene@0.26.1" +luau-lsp = "johnnymorganz/luau-lsp@1.27.1" diff --git a/bin/check.sh b/bin/check.sh new file mode 100644 index 0000000..e388fe8 --- /dev/null +++ b/bin/check.sh @@ -0,0 +1,18 @@ +curl -o bin/roblox.d.luau https://raw.githubusercontent.com/JohnnyMorganz/luau-lsp/main/scripts/globalTypes.d.lua + +rojo sourcemap dev.project.json -o sourcemap.json + +luau-lsp analyze \ + --defs=bin/roblox.d.luau \ + --defs=bin/testez.d.luau \ + --flag:LuauFixIndexerSubtypingOrdering=true \ + --flag:LuauInstantiateInSubtyping=true \ + --flag:LuauTinyControlFlowAnalysis=true \ + --sourcemap=sourcemap.json \ + --ignore="**/_Index/**" src + +selene src +stylua --check src +eslint src + +rm bin/roblox.d.luau diff --git a/bin/install.sh b/bin/install.sh new file mode 100644 index 0000000..4091d75 --- /dev/null +++ b/bin/install.sh @@ -0,0 +1,4 @@ +wally install +rojo sourcemap dev.project.json --output sourcemap.json +wally-package-types --sourcemap sourcemap.json Packages +wally-package-types --sourcemap sourcemap.json DevPackages diff --git a/bin/spec.server.luau b/bin/spec.server.luau new file mode 100644 index 0000000..81d6907 --- /dev/null +++ b/bin/spec.server.luau @@ -0,0 +1,10 @@ +--!nocheck + +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Packages = ReplicatedStorage.Packages +local DevPackages = ReplicatedStorage.DevPackages + +local TestEZ = require(DevPackages.TestEZ) + +TestEZ.TestBootstrap:run({ Packages.charm.__tests__ }, TestEZ.Reporters.TextReporter) diff --git a/bin/testez.d.luau b/bin/testez.d.luau new file mode 100644 index 0000000..28bef92 --- /dev/null +++ b/bin/testez.d.luau @@ -0,0 +1,24 @@ +declare function afterAll(callback: () -> ()): () +declare function afterEach(callback: () -> ()): () + +declare function beforeAll(callback: () -> ()): () +declare function beforeEach(callback: () -> ()): () + +declare function describe(phrase: string, callback: () -> ()): () +declare function describeFOCUS(phrase: string, callback: () -> ()): () +declare function fdescribe(phrase: string, callback: () -> ()): () +declare function describeSKIP(phrase: string, callback: () -> ()): () +declare function xdescribe(phrase: string, callback: () -> ()): () + +declare function expect(value: any): any + +declare function FIXME(optionalMessage: string?): () +declare function FOCUS(): () +declare function SKIP(): () + +declare function it(phrase: string, callback: () -> ()): () +declare function itFOCUS(phrase: string, callback: () -> ()): () +declare function fit(phrase: string, callback: () -> ()): () +declare function itSKIP(phrase: string, callback: () -> ()): () +declare function xit(phrase: string, callback: () -> ()): () +declare function itFIXME(phrase: string, callback: () -> ()): () diff --git a/default.project.json b/default.project.json new file mode 100644 index 0000000..a485e4d --- /dev/null +++ b/default.project.json @@ -0,0 +1,6 @@ +{ + "name": "charm", + "tree": { + "$path": "src" + } +} diff --git a/dev.project.json b/dev.project.json new file mode 100644 index 0000000..ed0d1b9 --- /dev/null +++ b/dev.project.json @@ -0,0 +1,20 @@ +{ + "name": "charm-dev", + "tree": { + "$className": "DataModel", + "ReplicatedStorage": { + "$className": "ReplicatedStorage", + "Packages": { + "$path": "Packages", + "ReactLua": { "$path": "Packages" }, + "charm": { "$path": "src" } + }, + "DevPackages": { "$path": "DevPackages" } + }, + "TestService": { + "$className": "TestService", + "$properties": { "ExecuteWithStudioRun": true }, + "spec": { "$path": "bin/spec.server.luau" } + } + } +} diff --git a/images/logo.png b/images/logo.png new file mode 100644 index 0000000..7f4915e Binary files /dev/null and b/images/logo.png differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..0936d98 --- /dev/null +++ b/package.json @@ -0,0 +1,58 @@ +{ + "name": "@rbxts/charm", + "version": "0.1.0", + "description": "Atomic state management for Roblox", + "scripts": { + "lint": "sh bin/check.sh", + "wally": "sh bin/install.sh" + }, + "main": "src/init.lua", + "types": "src/index.d.ts", + "keywords": [ + "roblox", + "typescript", + "roblox-ts", + "state" + ], + "author": "littensy", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/littensy/charm" + }, + "bugs": { + "url": "https://github.com/littensy/charm/issues" + }, + "homepage": "https://github.com/littensy/charm", + "files": [ + "src", + "default.project.json", + "images/logo.png", + "!src/**/*.spec.ts" + ], + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "@rbxts/compiler-types": "^2.3.0-types.0", + "@rbxts/react": "^0.3.1", + "@rbxts/types": "^1.0.754", + "@typescript-eslint/eslint-plugin": "^7.0.2", + "@typescript-eslint/parser": "^7.0.2", + "eslint": "^8.56.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.1.3", + "eslint-plugin-roblox-ts": "^0.0.36", + "prettier": "^3.2.5", + "roblox-ts": "2.3.0-dev-ac7800f", + "typescript": "^5.3.3" + }, + "peerDependencies": { + "@rbxts/react": "*" + }, + "peerDependenciesMeta": { + "@rbxts/react": { + "optional": true + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..bff192f --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1482 @@ +lockfileVersion: '6.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +devDependencies: + '@rbxts/compiler-types': + specifier: ^2.3.0-types.0 + version: 2.3.0-types.0 + '@rbxts/react': + specifier: ^0.3.1 + version: 0.3.1 + '@rbxts/types': + specifier: ^1.0.754 + version: 1.0.756 + '@typescript-eslint/eslint-plugin': + specifier: ^7.0.2 + version: 7.0.2(@typescript-eslint/parser@7.0.2)(eslint@8.57.0)(typescript@5.3.3) + '@typescript-eslint/parser': + specifier: ^7.0.2 + version: 7.0.2(eslint@8.57.0)(typescript@5.3.3) + eslint: + specifier: ^8.56.0 + version: 8.57.0 + eslint-config-prettier: + specifier: ^9.1.0 + version: 9.1.0(eslint@8.57.0) + eslint-plugin-prettier: + specifier: ^5.1.3 + version: 5.1.3(eslint-config-prettier@9.1.0)(eslint@8.57.0)(prettier@3.2.5) + eslint-plugin-roblox-ts: + specifier: ^0.0.36 + version: 0.0.36(eslint@8.57.0) + prettier: + specifier: ^3.2.5 + version: 3.2.5 + roblox-ts: + specifier: 2.3.0-dev-ac7800f + version: 2.3.0-dev-ac7800f + typescript: + specifier: ^5.3.3 + version: 5.3.3 + +packages: + + /@aashutoshrathi/word-wrap@1.2.6: + resolution: {integrity: sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==} + engines: {node: '>=0.10.0'} + dev: true + + /@eslint-community/eslint-utils@4.4.0(eslint@8.57.0): + resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + dependencies: + eslint: 8.57.0 + eslint-visitor-keys: 3.4.3 + dev: true + + /@eslint-community/regexpp@4.10.0: + resolution: {integrity: sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + dev: true + + /@eslint/eslintrc@2.1.4: + resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + ajv: 6.12.6 + debug: 4.3.4 + espree: 9.6.1 + globals: 13.24.0 + ignore: 5.3.1 + import-fresh: 3.3.0 + js-yaml: 4.1.0 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + dev: true + + /@eslint/js@8.57.0: + resolution: {integrity: sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dev: true + + /@humanwhocodes/config-array@0.11.14: + resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} + engines: {node: '>=10.10.0'} + dependencies: + '@humanwhocodes/object-schema': 2.0.2 + debug: 4.3.4 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + dev: true + + /@humanwhocodes/module-importer@1.0.1: + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + dev: true + + /@humanwhocodes/object-schema@2.0.2: + resolution: {integrity: sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==} + dev: true + + /@nodelib/fs.scandir@2.1.5: + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + dev: true + + /@nodelib/fs.stat@2.0.5: + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + dev: true + + /@nodelib/fs.walk@1.2.8: + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.17.1 + dev: true + + /@pkgr/core@0.1.1: + resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + dev: true + + /@rbxts/compiler-types@2.3.0-types.0: + resolution: {integrity: sha512-2kllUXLlK22OzSi3GmLT+/O70U9oB2iXp4rGFk5FLwc1pqf4W9EsInuJ0t7poLE2XnqnpW/ojff9CUNivI2+lw==} + dev: true + + /@rbxts/react-vendor@0.3.1: + resolution: {integrity: sha512-5oYc5SkvNCkOrD6uqVoqVmjlR2FgCPlhHasqgPEHbE0qurrCDF8PKvVT9r0gi8VFHLVSp9Hj9BG2WNifDt6Yew==} + dev: true + + /@rbxts/react@0.3.1: + resolution: {integrity: sha512-J+PhKiDABBMC+hrOvqDiOSS6GhWqulizl9tSRG8ezZxNYsuoMGnShJvhDounPyTGl0ikjy3l2BRIKqy+xregzA==} + dependencies: + '@rbxts/react-vendor': 0.3.1 + dev: true + + /@rbxts/types@1.0.756: + resolution: {integrity: sha512-3lbsF5BWL3eSXnGhb4fQMnH03HvgW1tLwXA7caOy42y2RosQ3IooRMq5iMDr9NCoVNXEP+5vC3w1VcjqOBOzMA==} + dev: true + + /@roblox-ts/luau-ast@1.0.11: + resolution: {integrity: sha512-+maoLYpqY0HK8ugLFHS3qz0phMyDaN3i21jjW75T2ZaqJg84heKDUo98iXClvnx3mUDhW10IxqH+cYJ2iftYhQ==} + dev: true + + /@roblox-ts/path-translator@1.0.0: + resolution: {integrity: sha512-Lp6qVUqjmXIrICy2KPKRiX8IkJ+lNqn6RqoUplLiTr+4JehIN+mJv0tTnE72XRyIfcx0VWl5nKrRwUuqcOj1yg==} + dependencies: + ajv: 8.12.0 + fs-extra: 11.2.0 + dev: true + + /@roblox-ts/rojo-resolver@1.0.6: + resolution: {integrity: sha512-+heTECMo6BdH3a3h4DCj+8kJvwKuxWqBevcW/m2BzQaVtmo1GtLa4V4bJCMvDuAMeEqYKQZUB7546nN2dcqqAA==} + dependencies: + ajv: 8.12.0 + fs-extra: 11.2.0 + dev: true + + /@types/fs-extra@11.0.4: + resolution: {integrity: sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==} + dependencies: + '@types/jsonfile': 6.1.4 + '@types/node': 20.11.20 + dev: true + + /@types/json-schema@7.0.15: + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + dev: true + + /@types/jsonfile@6.1.4: + resolution: {integrity: sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==} + dependencies: + '@types/node': 20.11.20 + dev: true + + /@types/mocha@10.0.6: + resolution: {integrity: sha512-dJvrYWxP/UcXm36Qn36fxhUKu8A/xMRXVT2cliFF1Z7UA9liG5Psj3ezNSZw+5puH2czDXRLcXQxf8JbJt0ejg==} + dev: true + + /@types/node@20.11.20: + resolution: {integrity: sha512-7/rR21OS+fq8IyHTgtLkDK949uzsa6n8BkziAKtPVpugIkO6D+/ooXMvzXxDnZrmtXVfjb1bKQafYpb8s89LOg==} + dependencies: + undici-types: 5.26.5 + dev: true + + /@types/prompts@2.4.9: + resolution: {integrity: sha512-qTxFi6Buiu8+50/+3DGIWLHM6QuWsEKugJnnP6iv2Mc4ncxE4A/OJkjuVOA+5X0X1S/nq5VJRa8Lu+nwcvbrKA==} + dependencies: + '@types/node': 20.11.20 + kleur: 3.0.3 + dev: true + + /@types/resolve@1.20.6: + resolution: {integrity: sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==} + dev: true + + /@types/semver@7.5.7: + resolution: {integrity: sha512-/wdoPq1QqkSj9/QOeKkFquEuPzQbHTWAMPH/PaUMB+JuR31lXhlWXRZ52IpfDYVlDOUBvX09uBrPwxGT1hjNBg==} + dev: true + + /@types/yargs-parser@21.0.3: + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + dev: true + + /@types/yargs@17.0.32: + resolution: {integrity: sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==} + dependencies: + '@types/yargs-parser': 21.0.3 + dev: true + + /@typescript-eslint/eslint-plugin@7.0.2(@typescript-eslint/parser@7.0.2)(eslint@8.57.0)(typescript@5.3.3): + resolution: {integrity: sha512-/XtVZJtbaphtdrWjr+CJclaCVGPtOdBpFEnvtNf/jRV0IiEemRrL0qABex/nEt8isYcnFacm3nPHYQwL+Wb7qg==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + '@typescript-eslint/parser': ^7.0.0 + eslint: ^8.56.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@eslint-community/regexpp': 4.10.0 + '@typescript-eslint/parser': 7.0.2(eslint@8.57.0)(typescript@5.3.3) + '@typescript-eslint/scope-manager': 7.0.2 + '@typescript-eslint/type-utils': 7.0.2(eslint@8.57.0)(typescript@5.3.3) + '@typescript-eslint/utils': 7.0.2(eslint@8.57.0)(typescript@5.3.3) + '@typescript-eslint/visitor-keys': 7.0.2 + debug: 4.3.4 + eslint: 8.57.0 + graphemer: 1.4.0 + ignore: 5.3.1 + natural-compare: 1.4.0 + semver: 7.6.0 + ts-api-utils: 1.2.1(typescript@5.3.3) + typescript: 5.3.3 + transitivePeerDependencies: + - supports-color + dev: true + + /@typescript-eslint/experimental-utils@5.62.0(eslint@8.57.0)(typescript@5.3.3): + resolution: {integrity: sha512-RTXpeB3eMkpoclG3ZHft6vG/Z30azNHuqY6wKPBHlVMZFuEvrtlEDe8gMqDb+SO+9hjC/pLekeSCryf9vMZlCw==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + dependencies: + '@typescript-eslint/utils': 5.62.0(eslint@8.57.0)(typescript@5.3.3) + eslint: 8.57.0 + transitivePeerDependencies: + - supports-color + - typescript + dev: true + + /@typescript-eslint/parser@7.0.2(eslint@8.57.0)(typescript@5.3.3): + resolution: {integrity: sha512-GdwfDglCxSmU+QTS9vhz2Sop46ebNCXpPPvsByK7hu0rFGRHL+AusKQJ7SoN+LbLh6APFpQwHKmDSwN35Z700Q==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + eslint: ^8.56.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@typescript-eslint/scope-manager': 7.0.2 + '@typescript-eslint/types': 7.0.2 + '@typescript-eslint/typescript-estree': 7.0.2(typescript@5.3.3) + '@typescript-eslint/visitor-keys': 7.0.2 + debug: 4.3.4 + eslint: 8.57.0 + typescript: 5.3.3 + transitivePeerDependencies: + - supports-color + dev: true + + /@typescript-eslint/scope-manager@5.62.0: + resolution: {integrity: sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + '@typescript-eslint/types': 5.62.0 + '@typescript-eslint/visitor-keys': 5.62.0 + dev: true + + /@typescript-eslint/scope-manager@7.0.2: + resolution: {integrity: sha512-l6sa2jF3h+qgN2qUMjVR3uCNGjWw4ahGfzIYsCtFrQJCjhbrDPdiihYT8FnnqFwsWX+20hK592yX9I2rxKTP4g==} + engines: {node: ^16.0.0 || >=18.0.0} + dependencies: + '@typescript-eslint/types': 7.0.2 + '@typescript-eslint/visitor-keys': 7.0.2 + dev: true + + /@typescript-eslint/type-utils@7.0.2(eslint@8.57.0)(typescript@5.3.3): + resolution: {integrity: sha512-IKKDcFsKAYlk8Rs4wiFfEwJTQlHcdn8CLwLaxwd6zb8HNiMcQIFX9sWax2k4Cjj7l7mGS5N1zl7RCHOVwHq2VQ==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + eslint: ^8.56.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@typescript-eslint/typescript-estree': 7.0.2(typescript@5.3.3) + '@typescript-eslint/utils': 7.0.2(eslint@8.57.0)(typescript@5.3.3) + debug: 4.3.4 + eslint: 8.57.0 + ts-api-utils: 1.2.1(typescript@5.3.3) + typescript: 5.3.3 + transitivePeerDependencies: + - supports-color + dev: true + + /@typescript-eslint/types@5.62.0: + resolution: {integrity: sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dev: true + + /@typescript-eslint/types@7.0.2: + resolution: {integrity: sha512-ZzcCQHj4JaXFjdOql6adYV4B/oFOFjPOC9XYwCaZFRvqN8Llfvv4gSxrkQkd2u4Ci62i2c6W6gkDwQJDaRc4nA==} + engines: {node: ^16.0.0 || >=18.0.0} + dev: true + + /@typescript-eslint/typescript-estree@5.62.0(typescript@5.3.3): + resolution: {integrity: sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@typescript-eslint/types': 5.62.0 + '@typescript-eslint/visitor-keys': 5.62.0 + debug: 4.3.4 + globby: 11.1.0 + is-glob: 4.0.3 + semver: 7.6.0 + tsutils: 3.21.0(typescript@5.3.3) + typescript: 5.3.3 + transitivePeerDependencies: + - supports-color + dev: true + + /@typescript-eslint/typescript-estree@7.0.2(typescript@5.3.3): + resolution: {integrity: sha512-3AMc8khTcELFWcKcPc0xiLviEvvfzATpdPj/DXuOGIdQIIFybf4DMT1vKRbuAEOFMwhWt7NFLXRkbjsvKZQyvw==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@typescript-eslint/types': 7.0.2 + '@typescript-eslint/visitor-keys': 7.0.2 + debug: 4.3.4 + globby: 11.1.0 + is-glob: 4.0.3 + minimatch: 9.0.3 + semver: 7.6.0 + ts-api-utils: 1.2.1(typescript@5.3.3) + typescript: 5.3.3 + transitivePeerDependencies: + - supports-color + dev: true + + /@typescript-eslint/utils@5.62.0(eslint@8.57.0)(typescript@5.3.3): + resolution: {integrity: sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0) + '@types/json-schema': 7.0.15 + '@types/semver': 7.5.7 + '@typescript-eslint/scope-manager': 5.62.0 + '@typescript-eslint/types': 5.62.0 + '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.3.3) + eslint: 8.57.0 + eslint-scope: 5.1.1 + semver: 7.6.0 + transitivePeerDependencies: + - supports-color + - typescript + dev: true + + /@typescript-eslint/utils@7.0.2(eslint@8.57.0)(typescript@5.3.3): + resolution: {integrity: sha512-PZPIONBIB/X684bhT1XlrkjNZJIEevwkKDsdwfiu1WeqBxYEEdIgVDgm8/bbKHVu+6YOpeRqcfImTdImx/4Bsw==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + eslint: ^8.56.0 + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0) + '@types/json-schema': 7.0.15 + '@types/semver': 7.5.7 + '@typescript-eslint/scope-manager': 7.0.2 + '@typescript-eslint/types': 7.0.2 + '@typescript-eslint/typescript-estree': 7.0.2(typescript@5.3.3) + eslint: 8.57.0 + semver: 7.6.0 + transitivePeerDependencies: + - supports-color + - typescript + dev: true + + /@typescript-eslint/visitor-keys@5.62.0: + resolution: {integrity: sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + '@typescript-eslint/types': 5.62.0 + eslint-visitor-keys: 3.4.3 + dev: true + + /@typescript-eslint/visitor-keys@7.0.2: + resolution: {integrity: sha512-8Y+YiBmqPighbm5xA2k4wKTxRzx9EkBu7Rlw+WHqMvRJ3RPz/BMBO9b2ru0LUNmXg120PHUXD5+SWFy2R8DqlQ==} + engines: {node: ^16.0.0 || >=18.0.0} + dependencies: + '@typescript-eslint/types': 7.0.2 + eslint-visitor-keys: 3.4.3 + dev: true + + /@ungap/structured-clone@1.2.0: + resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} + dev: true + + /acorn-jsx@5.3.2(acorn@8.11.3): + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + dependencies: + acorn: 8.11.3 + dev: true + + /acorn@8.11.3: + resolution: {integrity: sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==} + engines: {node: '>=0.4.0'} + hasBin: true + dev: true + + /ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + dev: true + + /ajv@8.12.0: + resolution: {integrity: sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==} + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js: 4.4.1 + dev: true + + /ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + dev: true + + /ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + dependencies: + color-convert: 2.0.1 + dev: true + + /anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + dev: true + + /argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + dev: true + + /array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + dev: true + + /balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + dev: true + + /binary-extensions@2.2.0: + resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} + engines: {node: '>=8'} + dev: true + + /brace-expansion@1.1.11: + resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + dev: true + + /brace-expansion@2.0.1: + resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + dependencies: + balanced-match: 1.0.2 + dev: true + + /braces@3.0.2: + resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==} + engines: {node: '>=8'} + dependencies: + fill-range: 7.0.1 + dev: true + + /callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + dev: true + + /chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + dev: true + + /chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + dependencies: + anymatch: 3.1.3 + braces: 3.0.2 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + dev: true + + /cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + dev: true + + /color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + dependencies: + color-name: 1.1.4 + dev: true + + /color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + dev: true + + /concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + dev: true + + /cross-spawn@7.0.3: + resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} + engines: {node: '>= 8'} + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + dev: true + + /debug@4.3.4: + resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.1.2 + dev: true + + /deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + dev: true + + /dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + dependencies: + path-type: 4.0.0 + dev: true + + /doctrine@3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} + dependencies: + esutils: 2.0.3 + dev: true + + /emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + dev: true + + /escalade@3.1.2: + resolution: {integrity: sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==} + engines: {node: '>=6'} + dev: true + + /escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + dev: true + + /eslint-config-prettier@9.1.0(eslint@8.57.0): + resolution: {integrity: sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + dependencies: + eslint: 8.57.0 + dev: true + + /eslint-plugin-prettier@5.1.3(eslint-config-prettier@9.1.0)(eslint@8.57.0)(prettier@3.2.5): + resolution: {integrity: sha512-C9GCVAs4Eq7ZC/XFQHITLiHJxQngdtraXaM+LoUFoFp/lHNl2Zn8f3WQbe9HvTBBQ9YnKFB0/2Ajdqwo5D1EAw==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + '@types/eslint': '>=8.0.0' + eslint: '>=8.0.0' + eslint-config-prettier: '*' + prettier: '>=3.0.0' + peerDependenciesMeta: + '@types/eslint': + optional: true + eslint-config-prettier: + optional: true + dependencies: + eslint: 8.57.0 + eslint-config-prettier: 9.1.0(eslint@8.57.0) + prettier: 3.2.5 + prettier-linter-helpers: 1.0.0 + synckit: 0.8.8 + dev: true + + /eslint-plugin-roblox-ts@0.0.36(eslint@8.57.0): + resolution: {integrity: sha512-vlbgfHY1heWSyDunVX3gIOamCy28KqrJlDkmuDOyYVBo8uLqdvbFkbWkdGxn59Vky1m6pFcvZGU6TObfmwm5Pg==} + engines: {node: '>=0.10.0'} + dependencies: + '@types/node': 20.11.20 + '@typescript-eslint/experimental-utils': 5.62.0(eslint@8.57.0)(typescript@5.3.3) + typescript: 5.3.3 + transitivePeerDependencies: + - eslint + - supports-color + dev: true + + /eslint-scope@5.1.1: + resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} + engines: {node: '>=8.0.0'} + dependencies: + esrecurse: 4.3.0 + estraverse: 4.3.0 + dev: true + + /eslint-scope@7.2.2: + resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + dev: true + + /eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dev: true + + /eslint@8.57.0: + resolution: {integrity: sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + hasBin: true + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0) + '@eslint-community/regexpp': 4.10.0 + '@eslint/eslintrc': 2.1.4 + '@eslint/js': 8.57.0 + '@humanwhocodes/config-array': 0.11.14 + '@humanwhocodes/module-importer': 1.0.1 + '@nodelib/fs.walk': 1.2.8 + '@ungap/structured-clone': 1.2.0 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.3 + debug: 4.3.4 + doctrine: 3.0.0 + escape-string-regexp: 4.0.0 + eslint-scope: 7.2.2 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + esquery: 1.5.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 6.0.1 + find-up: 5.0.0 + glob-parent: 6.0.2 + globals: 13.24.0 + graphemer: 1.4.0 + ignore: 5.3.1 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + is-path-inside: 3.0.3 + js-yaml: 4.1.0 + json-stable-stringify-without-jsonify: 1.0.1 + levn: 0.4.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.3 + strip-ansi: 6.0.1 + text-table: 0.2.0 + transitivePeerDependencies: + - supports-color + dev: true + + /espree@9.6.1: + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + acorn: 8.11.3 + acorn-jsx: 5.3.2(acorn@8.11.3) + eslint-visitor-keys: 3.4.3 + dev: true + + /esquery@1.5.0: + resolution: {integrity: sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==} + engines: {node: '>=0.10'} + dependencies: + estraverse: 5.3.0 + dev: true + + /esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + dependencies: + estraverse: 5.3.0 + dev: true + + /estraverse@4.3.0: + resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} + engines: {node: '>=4.0'} + dev: true + + /estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + dev: true + + /esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + dev: true + + /fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + dev: true + + /fast-diff@1.3.0: + resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + dev: true + + /fast-glob@3.3.2: + resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==} + engines: {node: '>=8.6.0'} + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.5 + dev: true + + /fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + dev: true + + /fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + dev: true + + /fastq@1.17.1: + resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} + dependencies: + reusify: 1.0.4 + dev: true + + /file-entry-cache@6.0.1: + resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} + engines: {node: ^10.12.0 || >=12.0.0} + dependencies: + flat-cache: 3.2.0 + dev: true + + /fill-range@7.0.1: + resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} + engines: {node: '>=8'} + dependencies: + to-regex-range: 5.0.1 + dev: true + + /find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + dev: true + + /flat-cache@3.2.0: + resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} + engines: {node: ^10.12.0 || >=12.0.0} + dependencies: + flatted: 3.3.1 + keyv: 4.5.4 + rimraf: 3.0.2 + dev: true + + /flatted@3.3.1: + resolution: {integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==} + dev: true + + /fs-extra@11.2.0: + resolution: {integrity: sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==} + engines: {node: '>=14.14'} + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.1.0 + universalify: 2.0.1 + dev: true + + /fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + dev: true + + /fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + dev: true + + /get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + dev: true + + /glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + dependencies: + is-glob: 4.0.3 + dev: true + + /glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + dependencies: + is-glob: 4.0.3 + dev: true + + /glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + dev: true + + /globals@13.24.0: + resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} + engines: {node: '>=8'} + dependencies: + type-fest: 0.20.2 + dev: true + + /globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.2 + ignore: 5.3.1 + merge2: 1.4.1 + slash: 3.0.0 + dev: true + + /graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + dev: true + + /graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + dev: true + + /has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + dev: true + + /hasown@2.0.1: + resolution: {integrity: sha512-1/th4MHjnwncwXsIW6QMzlvYL9kG5e/CpVvLRZe4XPa8TOUNbCELqmvhDmnkNsAjwaG4+I8gJJL0JBvTTLO9qA==} + engines: {node: '>= 0.4'} + dependencies: + function-bind: 1.1.2 + dev: true + + /ignore@5.3.1: + resolution: {integrity: sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==} + engines: {node: '>= 4'} + dev: true + + /import-fresh@3.3.0: + resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} + engines: {node: '>=6'} + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + dev: true + + /imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + dev: true + + /inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + dev: true + + /inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + dev: true + + /is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + dependencies: + binary-extensions: 2.2.0 + dev: true + + /is-core-module@2.13.1: + resolution: {integrity: sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==} + dependencies: + hasown: 2.0.1 + dev: true + + /is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + dev: true + + /is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + dev: true + + /is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + dependencies: + is-extglob: 2.1.1 + dev: true + + /is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + dev: true + + /is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + dev: true + + /isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + dev: true + + /js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + dependencies: + argparse: 2.0.1 + dev: true + + /json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + dev: true + + /json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + dev: true + + /json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + dev: true + + /json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + dev: true + + /jsonfile@6.1.0: + resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + dev: true + + /keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + dependencies: + json-buffer: 3.0.1 + dev: true + + /kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + dev: true + + /kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + dev: true + + /levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + dev: true + + /locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + dependencies: + p-locate: 5.0.0 + dev: true + + /lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + dev: true + + /lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + dependencies: + yallist: 4.0.0 + dev: true + + /merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + dev: true + + /micromatch@4.0.5: + resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} + engines: {node: '>=8.6'} + dependencies: + braces: 3.0.2 + picomatch: 2.3.1 + dev: true + + /minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + dependencies: + brace-expansion: 1.1.11 + dev: true + + /minimatch@9.0.3: + resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} + engines: {node: '>=16 || 14 >=14.17'} + dependencies: + brace-expansion: 2.0.1 + dev: true + + /ms@2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + dev: true + + /natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + dev: true + + /normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + dev: true + + /once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + dependencies: + wrappy: 1.0.2 + dev: true + + /optionator@0.9.3: + resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==} + engines: {node: '>= 0.8.0'} + dependencies: + '@aashutoshrathi/word-wrap': 1.2.6 + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + dev: true + + /p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + dependencies: + yocto-queue: 0.1.0 + dev: true + + /p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + dependencies: + p-limit: 3.1.0 + dev: true + + /parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + dependencies: + callsites: 3.1.0 + dev: true + + /path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + dev: true + + /path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + dev: true + + /path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + dev: true + + /path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + dev: true + + /path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + dev: true + + /picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + dev: true + + /prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + dev: true + + /prettier-linter-helpers@1.0.0: + resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==} + engines: {node: '>=6.0.0'} + dependencies: + fast-diff: 1.3.0 + dev: true + + /prettier@3.2.5: + resolution: {integrity: sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==} + engines: {node: '>=14'} + hasBin: true + dev: true + + /punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + dev: true + + /queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + dev: true + + /readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + dependencies: + picomatch: 2.3.1 + dev: true + + /require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + dev: true + + /require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + dev: true + + /resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + dev: true + + /resolve@1.22.8: + resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==} + hasBin: true + dependencies: + is-core-module: 2.13.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + dev: true + + /reusify@1.0.4: + resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + dev: true + + /rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + hasBin: true + dependencies: + glob: 7.2.3 + dev: true + + /roblox-ts@2.3.0-dev-ac7800f: + resolution: {integrity: sha512-XZuBSgLoSMu11in/o5TUsaaHSj0LzTCrDyv6iFFXaoR3xY1AMl0XrUef7XPy+YN4LteX7VoDWV4dMitFP9KrrA==} + hasBin: true + dependencies: + '@roblox-ts/luau-ast': 1.0.11 + '@roblox-ts/path-translator': 1.0.0 + '@roblox-ts/rojo-resolver': 1.0.6 + '@types/fs-extra': 11.0.4 + '@types/mocha': 10.0.6 + '@types/node': 20.11.20 + '@types/prompts': 2.4.9 + '@types/resolve': 1.20.6 + '@types/ts-expose-internals': /ts-expose-internals@5.3.3 + '@types/yargs': 17.0.32 + chokidar: 3.6.0 + fs-extra: 11.2.0 + kleur: 4.1.5 + resolve: 1.22.8 + typescript: 5.3.3 + yargs: 17.7.2 + dev: true + + /run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + dependencies: + queue-microtask: 1.2.3 + dev: true + + /semver@7.6.0: + resolution: {integrity: sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==} + engines: {node: '>=10'} + hasBin: true + dependencies: + lru-cache: 6.0.0 + dev: true + + /shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + dependencies: + shebang-regex: 3.0.0 + dev: true + + /shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + dev: true + + /slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + dev: true + + /string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + dev: true + + /strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + dependencies: + ansi-regex: 5.0.1 + dev: true + + /strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + dev: true + + /supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + dependencies: + has-flag: 4.0.0 + dev: true + + /supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + dev: true + + /synckit@0.8.8: + resolution: {integrity: sha512-HwOKAP7Wc5aRGYdKH+dw0PRRpbO841v2DENBtjnR5HFWoiNByAl7vrx3p0G/rCyYXQsrxqtX48TImFtPcIHSpQ==} + engines: {node: ^14.18.0 || >=16.0.0} + dependencies: + '@pkgr/core': 0.1.1 + tslib: 2.6.2 + dev: true + + /text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + dev: true + + /to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + dependencies: + is-number: 7.0.0 + dev: true + + /ts-api-utils@1.2.1(typescript@5.3.3): + resolution: {integrity: sha512-RIYA36cJn2WiH9Hy77hdF9r7oEwxAtB/TS9/S4Qd90Ap4z5FSiin5zEiTL44OII1Y3IIlEvxwxFUVgrHSZ/UpA==} + engines: {node: '>=16'} + peerDependencies: + typescript: '>=4.2.0' + dependencies: + typescript: 5.3.3 + dev: true + + /ts-expose-internals@5.3.3: + resolution: {integrity: sha512-0lW96u0pa08dq9QLYUBRbgGkvsD+LvPAVGe94kr2tHaRZAxgckwJ+qqc1BSRoR1rpkTZf7AWAa6DKrLGFmctwg==} + dev: true + + /tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + dev: true + + /tslib@2.6.2: + resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} + dev: true + + /tsutils@3.21.0(typescript@5.3.3): + resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} + engines: {node: '>= 6'} + peerDependencies: + typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta' + dependencies: + tslib: 1.14.1 + typescript: 5.3.3 + dev: true + + /type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + dependencies: + prelude-ls: 1.2.1 + dev: true + + /type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + dev: true + + /typescript@5.3.3: + resolution: {integrity: sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==} + engines: {node: '>=14.17'} + hasBin: true + dev: true + + /undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + dev: true + + /universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + dev: true + + /uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + dependencies: + punycode: 2.3.1 + dev: true + + /which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + dependencies: + isexe: 2.0.0 + dev: true + + /wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + dev: true + + /wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + dev: true + + /y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + dev: true + + /yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + dev: true + + /yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + dev: true + + /yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + dependencies: + cliui: 8.0.1 + escalade: 3.1.2 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + dev: true + + /yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + dev: true diff --git a/selene.toml b/selene.toml new file mode 100644 index 0000000..56479a7 --- /dev/null +++ b/selene.toml @@ -0,0 +1,5 @@ +std = "roblox+testez" + +[lints] +global_usage = "allow" +shadowing = "allow" diff --git a/src/__tests__/atom.spec.luau b/src/__tests__/atom.spec.luau new file mode 100644 index 0000000..c41fa63 --- /dev/null +++ b/src/__tests__/atom.spec.luau @@ -0,0 +1,85 @@ +local atom = require(script.Parent.Parent.atom) + +return function() + it("updates state", function() + local source = atom(1) + source(2) + expect(source()).to.equal(2) + source(function(x) + return x + 1 + end) + expect(source()).to.equal(3) + end) + + it("calls listeners", function() + local source = atom(1) + local value + local function listener(state) + value = state + end + source.listeners[listener] = true + source(2) + expect(value).to.equal(2) + end) + + it("skips pending listeners", function() + local source = atom(1) + local outer = 0 + local inner = 0 + local function listener() + outer += 1 + local function listener() + inner += 1 + end + source.listeners[listener] = true + end + source.listeners[listener] = true + source(2) + expect(outer).to.equal(1) + expect(inner).to.equal(0) + source(3) + expect(outer).to.equal(2) + expect(inner).to.equal(1) + end) + + it("can be readonly", function() + local readonly = atom(1):readonly() + + expect(function() + (readonly :: any)(2) + end).to.throw() + + local writable = readonly:writable() + + writable(2) + expect(writable()).to.equal(2) + end) + + it("calls the setter", function() + local source = atom(1):writable(function(_, value: number) + return value + 1 + end) + source(1) + expect(source()).to.equal(2) + end) + + it("cancels if setter returns nil", function() + local source = atom(1):writable(function(value: number) + return if value == 2 then nil else value + end) + source(2) + expect(source()).to.equal(1) + source(3) + expect(source()).to.equal(3) + end) + + it("can be memoized", function() + local source = atom(1):memo(function(prev, next) + return next < prev + end) + source(2) + expect(source()).to.equal(2) + source(1) + expect(source()).to.equal(2) + end) +end diff --git a/src/__tests__/derive.spec.luau b/src/__tests__/derive.spec.luau new file mode 100644 index 0000000..a06db04 --- /dev/null +++ b/src/__tests__/derive.spec.luau @@ -0,0 +1,92 @@ +local atom = require(script.Parent.Parent.atom) +local derive = require(script.Parent.Parent.derive) + +return function() + it("derives from dependencies", function() + local a = atom(1) + local b = atom(2) + local sum = derive(a, b, function(a: number, b: number) + return a + b + end) + expect(sum()).to.equal(3) + a(2) + expect(sum()).to.equal(4) + b(3) + expect(sum()).to.equal(5) + expect(function() + (sum :: any)(2) + end).to.throw() + end) + + it("memoizes the result", function() + local calls = 0 + local source = atom(1) + local double = derive(source, function(value: number) + calls += 1 + return value * 2 + end) + expect(calls).to.equal(1) + source(1) + expect(calls).to.equal(1) + source(2) + expect(calls).to.equal(2) + double() + expect(calls).to.equal(2) + end) + + it("can be a dependency", function() + local source = atom(1) + local double = derive(source, function(value: number) + return value * 2 + end) + local quadruple = derive(double, function(value: number) + return value * 2 + end) + expect(quadruple()).to.equal(4) + source(2) + expect(quadruple()).to.equal(8) + end) + + it("allows custom setter", function() + local source = atom(1) + local double = derive(source, function(value: number) + return value * 2 + end):writable(function(update: number) + source(update / 2) + end) + double(4) + expect(source()).to.equal(2) + expect(double()).to.equal(4) + end) + + it("garbage collects", function() + -- large values coerce the garbage collector to run + local source = atom(string.rep("x", 1e5)) + local max = 0 + + local function listenersDecreased() + local count = 0 + for _ in source.listeners do + count += 1 + end + max = math.max(max, count) + return count < max + end + + for index = 1, 500 do + derive(source, function(value) + return string.upper(value) + end) + if index % 50 == 0 then + -- derived atoms check if they are collected when they are updated + source(source() .. "x") + task.wait() + end + if listenersDecreased() then + return + end + end + + error("None of the derived atoms were collected") + end) +end diff --git a/src/__tests__/init.spec.luau b/src/__tests__/init.spec.luau new file mode 100644 index 0000000..53f1537 --- /dev/null +++ b/src/__tests__/init.spec.luau @@ -0,0 +1,7 @@ +local reference = require(script.Parent.Parent.utils.reference) + +return function() + afterEach(function() + reference.clear() + end) +end diff --git a/src/__tests__/observe.spec.luau b/src/__tests__/observe.spec.luau new file mode 100644 index 0000000..79e8953 --- /dev/null +++ b/src/__tests__/observe.spec.luau @@ -0,0 +1,33 @@ +local atom = require(script.Parent.Parent.atom) +local observe = require(script.Parent.Parent.observe) + +return function() + it("observes lifetime", function() + local itemsAtom = atom({ "a", "b", "c" }) + local state = {} + + local cleanup, flush = observe(itemsAtom, function(item, key) + state[key] = item + return function() + state[key] = nil + end + end) + + for key = 1, 3 do + expect(state[key]).to.equal(itemsAtom:get()[key]) + end + + itemsAtom:set({ [4] = "d", [5] = "e", [6] = "f" }) + flush() + + for key = 1, 6 do + expect(state[key]).to.equal(itemsAtom:get()[key]) + end + + cleanup() + + for key = 1, 6 do + expect(state[key]).to.equal(nil) + end + end) +end diff --git a/src/__tests__/react/helpers/renderHook.luau b/src/__tests__/react/helpers/renderHook.luau new file mode 100644 index 0000000..1714a66 --- /dev/null +++ b/src/__tests__/react/helpers/renderHook.luau @@ -0,0 +1,42 @@ +_G.__ROACT_17_MOCK_SCHEDULER__ = true + +local Package = script:FindFirstAncestor("__tests__").Parent + +local React = require(Package.modules.React) +local ReactRoblox = require(Package.modules.ReactRoblox) + +type RenderedHook = { + result: Result, + rerender: (Props?) -> (), + unmount: () -> (), +} + +local function renderHook(callback: (Props) -> Result, props: Props?): RenderedHook + local root = ReactRoblox.createLegacyRoot(Instance.new("Folder")) + local hook = {} + + local function Component(props) + hook.result = callback(props) + end + + function hook.rerender(newProps) + props = newProps or props + ReactRoblox.act(function() + root:render(React.createElement(Component, props)) + end) + end + + function hook.unmount() + ReactRoblox.act(function() + root:unmount() + end) + end + + ReactRoblox.act(function() + hook.rerender(props) + end) + + return hook +end + +return renderHook diff --git a/src/__tests__/react/useAtomState.spec.luau b/src/__tests__/react/useAtomState.spec.luau new file mode 100644 index 0000000..9c3824b --- /dev/null +++ b/src/__tests__/react/useAtomState.spec.luau @@ -0,0 +1,27 @@ +local atom = require(script.Parent.Parent.Parent.atom) +local useAtomState = require(script.Parent.Parent.Parent.react.useAtomState) +local renderHook = require(script.Parent.helpers.renderHook) + +return function() + it("tracks atom state", function() + local source = atom(0) + local hook = renderHook(function() + return useAtomState(source) + end) + expect(hook.result).to.equal(0) + source(1) + expect(hook.result).to.equal(1) + end) + + it("receives a selector", function() + local source = atom(0) + local hook = renderHook(function() + return useAtomState(source, function(value) + return 2 * value + end) + end) + expect(hook.result).to.equal(0) + source(1) + expect(hook.result).to.equal(2) + end) +end diff --git a/src/__tests__/react/useSetAtom.spec.luau b/src/__tests__/react/useSetAtom.spec.luau new file mode 100644 index 0000000..c838be9 --- /dev/null +++ b/src/__tests__/react/useSetAtom.spec.luau @@ -0,0 +1,34 @@ +local atom = require(script.Parent.Parent.Parent.atom) +local useSetAtom = require(script.Parent.Parent.Parent.react.useSetAtom) +local renderHook = require(script.Parent.helpers.renderHook) + +return function() + it("sets atom state", function() + local source = atom(0) + local hook = renderHook(function() + return useSetAtom(source) + end) + expect(hook.result).to.be.a("function") + hook.result(1) + expect(source()).to.equal(1) + end) + + it("returns a stable function", function() + local source = atom(0) + local hook = renderHook(function() + return useSetAtom(source) + end) + local fn = hook.result + hook.rerender() + expect(hook.result).to.equal(fn) + end) + + it("memoizes the function", function() + local hook = renderHook(function(source) + return useSetAtom(source) + end, atom(0)) + local fn = hook.result + hook.rerender(atom(1)) + expect(hook.result).to.never.equal(fn) + end) +end diff --git a/src/__tests__/split.spec.luau b/src/__tests__/split.spec.luau new file mode 100644 index 0000000..829ee5f --- /dev/null +++ b/src/__tests__/split.spec.luau @@ -0,0 +1,128 @@ +local atom = require(script.Parent.Parent.atom) +local isAtom = require(script.Parent.Parent.isAtom) +local observe = require(script.Parent.Parent.observe) +local split = require(script.Parent.Parent.split) + +return function() + it("returns split atoms", function() + local itemsAtom = atom({ "a", "b", "c" }) + local itemAtoms = split(itemsAtom) + local updates = 0 + + itemAtoms.listeners[function() + updates += 1 + end] = true + + for key = 1, 3 do + local itemAtom = itemAtoms()[key] + assert(isAtom(itemAtom), `itemAtom {key} is not an atom; got {typeof(itemAtom)}`) + expect(itemAtom()).to.equal(itemsAtom()[key]) + end + + local prevAtoms = itemAtoms() + itemsAtom({ "d", "e", "f" }) + expect(updates).to.equal(1) + + for key = 1, 3 do + local itemAtom = itemAtoms()[key] + assert(itemAtom == prevAtoms[key] :: any, `itemAtom {key} is not the same atom; got {typeof(itemAtom)}`) + expect(itemAtom()).to.equal(itemsAtom()[key]) + end + + itemsAtom({ [1] = "d", [3] = "f" }) + expect(itemAtoms()[2]).to.never.be.ok() + expect(updates).to.equal(2) + end) + + it("has writable items", function() + local itemsAtom = atom({ "a", "b", "c" }) + local itemAtoms = split(itemsAtom) + local updates = 0 + + itemAtoms.listeners[function() + updates += 1 + end] = true + + itemAtoms()[2]("x") + expect(updates).to.equal(1) + expect(itemsAtom()[2]).to.equal("x") + expect(itemAtoms()[2]()).to.equal("x") + end) + + it("uses key extractor", function() + local itemsAtom = atom({ { key = 1 }, { key = 2 }, { key = 3 } }) + local itemAtoms = split(itemsAtom, function(item) + return item.key + end) + + for key = 1, 3 do + local itemAtom = itemAtoms()[key] + expect(itemAtom()).to.equal(itemsAtom()[key]) + end + + local prevAtoms = itemAtoms() + itemsAtom({ itemsAtom()[1], itemsAtom()[3] }) + + expect(itemAtoms()[1]).to.equal(prevAtoms[1]) + expect(itemAtoms()[1]()).to.equal(itemsAtom()[1]) + expect(itemAtoms()[2]).to.never.be.ok() + expect(itemAtoms()[3]).to.equal(prevAtoms[3]) + expect(itemAtoms()[3]()).to.equal(itemsAtom()[2]) + end) + + it("allows observe()", function() + local itemsAtom = atom({ + { key = 1, value = "a" }, + { key = 2, value = "b" }, + { key = 3, value = "c" }, + }) + local itemAtoms = split(itemsAtom, function(item) + return item.key + end) + + local added = 0 + local updated = 0 + local deleted = 0 + + local _, flush = observe(itemAtoms, function(item, key) + expect(item()).to.equal(itemsAtom()[key]) + + item.listeners[function(value) + expect(value).to.equal(itemsAtom()[key]) + updated += 1 + end] = true + + added += 1 + + return function() + expect(itemAtoms()[key]).to.never.be.ok() + deleted += 1 + end + end) + + expect(added).to.equal(3) + expect(updated).to.equal(0) + expect(deleted).to.equal(0) + + itemsAtom({ itemsAtom()[1], itemsAtom()[3] }) + flush() + + expect(added).to.equal(3) + expect(updated).to.equal(0) + expect(deleted).to.equal(1) + + itemAtoms()[1]({ key = 1, value = "x" }) + flush() + + expect(added).to.equal(3) + expect(updated).to.equal(1) + expect(deleted).to.equal(1) + + itemsAtom({}) + flush() + + expect(added).to.equal(3) + expect(updated).to.equal(1) + expect(deleted).to.equal(3) + end) +end diff --git a/src/__tests__/subscribe.spec.luau b/src/__tests__/subscribe.spec.luau new file mode 100644 index 0000000..99c33f5 --- /dev/null +++ b/src/__tests__/subscribe.spec.luau @@ -0,0 +1,92 @@ +local atom = require(script.Parent.Parent.atom) +local derive = require(script.Parent.Parent.derive) +local subscribe = require(script.Parent.Parent.subscribe) + +return function() + it("runs on state change", function() + local current, previous + local source = atom(0) + local unsubscribe, flush = subscribe(source, function(...) + current, previous = ... + end) + + for index = 1, 3 do + source(index) + flush() + expect(current).to.equal(index) + expect(previous).to.equal(index - 1) + end + + unsubscribe() + source(4) + flush() + + expect(current).to.equal(3) + expect(previous).to.equal(2) + end) + + it("batches updates", function() + local current, previous + local source = atom(0) + local _, flush = subscribe(source, function(...) + current, previous = ... + end) + + source(1) + source(2) + flush() + + expect(current).to.equal(2) + expect(previous).to.equal(0) + end) + + it("checks equality", function() + local current, previous + local source = atom(0):memo(function(previous, current) + return current < previous + end) + local _, flush = subscribe(source, function(...) + current, previous = ... + end) + + source(1) + flush() + + expect(current).to.equal(1) + expect(previous).to.equal(0) + + source(0) + flush() + + expect(current).to.equal(1) + expect(previous).to.equal(0) + end) + + it("prevents garbage collect", function() + local source = atom(1) + local updates = 0 + + for _ = 1, 1000 do + -- The idea here is to test whether subscribe() prevents every derived + -- atom in a chain from being garbage collected. If any are GCed, then + -- the subscription will stop updating and the test will fail. + subscribe( + derive( + derive(source, function(x) + return x + end), + function(x) + return 2 * x + end + ), + function() + updates += 1 + end + ) + end + + source(source() + 1) + task.wait() + expect(updates).to.equal(1000) + end) +end diff --git a/src/atom.luau b/src/atom.luau new file mode 100644 index 0000000..4d87a7e --- /dev/null +++ b/src/atom.luau @@ -0,0 +1,137 @@ +local reference = require(script.Parent.utils.reference) + +type AtomInternal = { + --- @deprecated + setForced: (self: AtomInternal, state: T | ((T) -> T)) -> (), + --- @deprecated + listeners: { [(T) -> ()]: true }, + --- @deprecated + debugTraceback: string, + --- @deprecated + debugLabel: string, + --- @deprecated + owners: { [any]: true }, +} + +export type Atom = (() -> T) & ((state: T | ((T) -> T)) -> ()) & { + get: ((self: Atom) -> T) & ((self: Atom, selector: (T) -> Result) -> Result), + set: (self: Atom, state: T | ((T) -> T)) -> (), + memo: (self: Atom, equalityFn: (previous: T, current: T) -> boolean) -> Atom, + writable: (self: Atom, setter: ((update: T, previous: T) -> T?) & (update: T, previous: T) -> ()) -> Atom, + readonly: (self: Atom) -> ReadonlyAtom, + named: (self: Atom, label: string) -> Atom, + unmount: (self: Atom) -> (), +} & AtomInternal + +export type ReadonlyAtom = (() -> T) & { + get: ((self: ReadonlyAtom) -> T) & ((self: ReadonlyAtom, selector: (T) -> Result) -> Result), + memo: (self: ReadonlyAtom, equalityFn: (previous: T, current: T) -> boolean) -> ReadonlyAtom, + writable: ((self: Atom) -> Atom) & ( + (self: Atom, setter: ((update: T, previous: T) -> T?) & (update: T, previous: T) -> ()) -> Atom + ), + readonly: (self: ReadonlyAtom) -> ReadonlyAtom, + named: (self: ReadonlyAtom, label: string) -> ReadonlyAtom, + unmount: (self: ReadonlyAtom) -> (), +} & AtomInternal + +local atomCount = 0 + +local function atom(state: State): Atom + atomCount += 1 + + local listeners: { [(State) -> ()]: true } = {} + local label = `atom{atomCount}` + local traceback = debug.traceback(nil, 2) + local owners: { [any]: true } = {} + + local atom = { + listeners = listeners, + debugTraceback = traceback, + debugLabel = label, + owners = owners, + } :: Atom + + local equals: ((State, State) -> boolean)? + local setter: ((State, State) -> State?)? + + function atom:get(selector: ((State) -> Result)?): any + return if selector then selector(state) else state + end + + function atom:set(update: State | ((State) -> State)) + local nextState = if type(update) == "function" then update(state) else update + + if setter then + local result = setter(nextState, state) + + if result == nil then + return + else + nextState = result + end + end + + if not (if equals then equals(state, nextState) else state == nextState) then + state = nextState + + for listener in table.clone(listeners) do + listener(state) + end + end + end + + function atom:setForced(update: State | ((State) -> State)) + local nextState = if type(update) == "function" then update(state) else update + + if not (if equals then equals(state, nextState) else state == nextState) then + state = nextState + + for listener in table.clone(listeners) do + listener(state) + end + end + end + + function atom:memo(equalityFn: (State, State) -> boolean): Atom + equals = equalityFn + return self + end + + function atom:writable(setterFn: ((State, State) -> State?)?): Atom + setter = setterFn + return self + end + + function atom:readonly(): ReadonlyAtom + function setter(_, _) + error(`Attempt to set read-only atom '{label}' created at: {traceback}`, 3) + end + return self + end + + function atom:named(name: string): Atom + label = name + return self + end + + function atom:unmount() + table.clear(listeners) + table.clear(owners) + reference.release(self) + end + + setmetatable(atom :: AtomInternal, { + __type = "Atom", + __call = function(self, ...) + if select("#", ...) > 0 then + return self:set((...)) + else + return self:get() + end + end, + }) + + return atom +end + +return atom diff --git a/src/derive.luau b/src/derive.luau new file mode 100644 index 0000000..2b40c88 --- /dev/null +++ b/src/derive.luau @@ -0,0 +1,53 @@ +local atom = require(script.Parent.atom) +type Atom = atom.Atom +type ReadonlyAtom = atom.ReadonlyAtom + +local reference = require(script.Parent.utils.reference) + +type Derive = + ((Atom, (A) -> T) -> ReadonlyAtom) + & ((Atom, Atom, (A, B) -> T) -> ReadonlyAtom) + & ((Atom, Atom, Atom, (A, B, C) -> T) -> ReadonlyAtom) + & ((Atom, Atom, Atom, Atom, (A, B, C, D) -> T) -> ReadonlyAtom) + & ((Atom, Atom, Atom, Atom, Atom, (A, B, C, D, E) -> T) -> ReadonlyAtom) + & ((...(Atom | ((...any) -> T))) -> ReadonlyAtom) + +local function derive(...): ReadonlyAtom + local atoms: { Atom } = { ... } + local combiner: (...unknown) -> Result = table.remove(atoms) :: any + local size = #atoms + + local dependencies: { unknown } = {} + + for index, atom in atoms do + dependencies[index] = atom:get() + end + + local derivedAtom = atom(combiner(table.unpack(dependencies, 1, size))):readonly() + local getDerivedAtom = reference.weak(derivedAtom) + + for index, atom in atoms do + local function listener(state: unknown) + -- Remove the listener if the derived atom has been garbage collected, + -- typically because no subscriptions to it exist. + local derivedAtom = getDerivedAtom() + + if derivedAtom then + dependencies[index] = state + derivedAtom:setForced(combiner(table.unpack(dependencies, 1, size))) + else + atom.listeners[listener] = nil + end + end + + -- Tie the lifetime of the derived atom to the lifetime of its dependencies + -- without creating a strong reference cycle. As long as an atom is in use, + -- its derived atoms will be in use as well. + derivedAtom.owners[atom] = true + atom.listeners[listener] = true + end + + return derivedAtom +end + +return derive :: Derive diff --git a/src/index.d.ts b/src/index.d.ts new file mode 100644 index 0000000..8a11c2c --- /dev/null +++ b/src/index.d.ts @@ -0,0 +1,89 @@ +export = Charm; +export as namespace Charm; + +declare namespace Charm { + interface InternalAtom { + /** @hidden */ + readonly listeners: Set<(value: State) => void>; + /** @hidden */ + readonly debugTraceback: string; + /** @hidden */ + readonly debugLabel: string; + /** @hidden */ + setForced(state: State | ((current: State) => State)): void; + } + + interface BaseAtom extends InternalAtom { + get(): State; + get(selector: (state: State) => Result): Result; + set(state: State | ((current: State) => State)): void; + memo(equalityFn: (previous: State, current: State) => boolean): this; + writable(onSet?: (update: State, previous: State) => State | void): Atom; + readonly(): ReadonlyAtom; + named(label: string): this; + unmount(): void; + } + + interface Atom extends BaseAtom { + (): State; + (state: State | ((previous: State) => State)): void; + } + + interface ReadonlyAtom extends BaseAtom { + (): State; + set: never; + } + + type AtomStates[]> = { + [Key in keyof T]: T[Key] extends Atom ? State : never; + }; + + function atom(state: State): Atom; + + function isAtom(value: unknown): value is Atom; + + function derive[], Result>( + ...args: [...atoms: Atoms, combiner: (...atoms: AtomStates) => Result] + ): ReadonlyAtom; + + function subscribe(atom: Atom, callback: (state: State) => void): () => void; + + function observe( + atom: Atom | ReadonlyMap>, + factory: (value: V, key: K) => (() => void) | void, + ): () => void; + + function observe(atom: Atom, factory: (value: unknown, key: unknown) => (() => void) | void): () => void; + + function splitAtom( + listAtom: Atom> | Atom>, + keyExtractor?: (item: Item) => Id, + ): Atom>>; + + function splitAtom(listAtom: Atom): Atom[]>; + + function splitAtom( + listAtom: Atom, + keyExtractor?: (item: Item) => Id, + ): Atom>>; + + function splitAtom( + listAtom: Atom<{ readonly [K in Key]?: Item }>, + keyExtractor?: (item: Item) => Id, + ): Atom>>; + + function useAtomState(atom: Atom): State; + + function useAtomState(atom: Atom, selector: (state: State) => Result): Result; + + function useSetAtom(atom: Atom): (state: State | ((previous: State) => State)) => void; + + function useAtom( + atom: Atom, + ): LuaTuple<[state: State, setState: (state: State | ((previous: State) => State)) => void]>; + + function useAtom( + atom: Atom, + selector: (state: State) => Result, + ): LuaTuple<[state: Result, setState: (state: Result | ((previous: Result) => Result)) => void]>; +} diff --git a/src/init.luau b/src/init.luau new file mode 100644 index 0000000..8bb4341 --- /dev/null +++ b/src/init.luau @@ -0,0 +1,26 @@ +_G[script.modules.Promise] = _G[script] + +local atom = require(script.atom) +local derive = require(script.derive) +local isAtom = require(script.isAtom) +local observe = require(script.observe) +local split = require(script.split) +local subscribe = require(script.subscribe) +local useAtom = require(script.react.useAtom) +local useAtomState = require(script.react.useAtomState) +local useSetAtom = require(script.react.useSetAtom) + +export type Atom = atom.Atom +export type ReadonlyAtom = atom.ReadonlyAtom + +return { + atom = atom, + derive = derive, + isAtom = isAtom, + observe = observe, + split = split, + subscribe = subscribe, + useAtom = useAtom, + useAtomState = useAtomState, + useSetAtom = useSetAtom, +} diff --git a/src/isAtom.luau b/src/isAtom.luau new file mode 100644 index 0000000..bd96e97 --- /dev/null +++ b/src/isAtom.luau @@ -0,0 +1,5 @@ +local function isAtom(value: any) + return type(value) == "table" and getmetatable(value) and getmetatable(value).__type == "Atom" or false +end + +return isAtom diff --git a/src/modules/Promise.luau b/src/modules/Promise.luau new file mode 100644 index 0000000..c716f98 --- /dev/null +++ b/src/modules/Promise.luau @@ -0,0 +1,9 @@ +local Package = script.Parent.Parent + +if _G[script] then + return _G[script].Promise +end + +assert(Package.Parent:FindFirstChild("Promise"), `Could not find Promise in {Package.Parent:GetFullName()}`) + +return require(Package.Parent.Promise) diff --git a/src/modules/React.luau b/src/modules/React.luau new file mode 100644 index 0000000..0ae6f64 --- /dev/null +++ b/src/modules/React.luau @@ -0,0 +1,8 @@ +local Package = script.Parent.Parent +local Packages = if Package.Parent:FindFirstChild("React") + then Package.Parent + else Package.Parent:FindFirstChild("ReactLua") + +type React = typeof(require(Package.Parent.React)) + +return (if Packages then require(Packages.React) else nil) :: React diff --git a/src/modules/ReactRoblox.luau b/src/modules/ReactRoblox.luau new file mode 100644 index 0000000..eeed498 --- /dev/null +++ b/src/modules/ReactRoblox.luau @@ -0,0 +1,8 @@ +local Package = script.Parent.Parent +local Packages = if Package.Parent:FindFirstChild("ReactRoblox") + then Package.Parent + else Package.Parent:FindFirstChild("ReactLua") + +type ReactRoblox = typeof(require(Package.Parent.ReactRoblox)) + +return (if Packages then require(Packages.ReactRoblox) else nil) :: ReactRoblox diff --git a/src/observe.luau b/src/observe.luau new file mode 100644 index 0000000..e6f78ff --- /dev/null +++ b/src/observe.luau @@ -0,0 +1,38 @@ +local atom = require(script.Parent.atom) +type Atom = atom.Atom + +local subscribe = require(script.Parent.subscribe) + +local function observe(atom: Atom<{ [Key]: Item }>, factory: (value: Item, key: Key) -> () -> ()): (() -> (), () -> ()) + local connections: { [Key]: () -> () } = {} + + local function update(state: { [Key]: Item }) + for key, disconnect in connections do + if state[key] == nil then + connections[key] = nil + disconnect() + end + end + + for key, value in state do + if not connections[key] then + connections[key] = factory(value, key) + end + end + end + + local unsubscribe, flush = subscribe(atom, update) + + local function cleanup() + unsubscribe() + for _, disconnect in connections do + disconnect() + end + end + + update(atom:get()) + + return cleanup, flush +end + +return observe diff --git a/src/react/useAtom.luau b/src/react/useAtom.luau new file mode 100644 index 0000000..ed06093 --- /dev/null +++ b/src/react/useAtom.luau @@ -0,0 +1,19 @@ +local atom = require(script.Parent.Parent.atom) +type Atom = atom.Atom + +local useAtomState = require(script.Parent.useAtomState) +local useSetAtom = require(script.Parent.useSetAtom) + +type UseAtom = + ((atom: Atom) -> (State, useSetAtom.SetState)) + & (( + atom: Atom, + selector: ((State) -> Result)? + ) -> (State | Result, useSetAtom.SetState)) + & ((atom: Atom, selector: (State) -> Result) -> (Result, useSetAtom.SetState)) + +local function useAtom(atom: Atom, selector: ((State) -> State)?): (State, useSetAtom.SetState) + return useAtomState(atom, selector), useSetAtom(atom) +end + +return useAtom :: UseAtom diff --git a/src/react/useAtomState.luau b/src/react/useAtomState.luau new file mode 100644 index 0000000..9deebbc --- /dev/null +++ b/src/react/useAtomState.luau @@ -0,0 +1,35 @@ +local React = require(script.Parent.Parent.modules.React) + +local atom = require(script.Parent.Parent.atom) +type Atom = atom.Atom + +type UseAtomState = + ((atom: Atom) -> State) + & ((atom: Atom, ((State) -> Result)?) -> State | Result) + & (atom: Atom, selector: (State) -> Result) -> Result + +local function useAtomState(atom: Atom, selector: ((State) -> State)?): State + local state, setState = React.useState(function() + return atom:get(selector :: (State) -> State) + end) + + local latestSelector = React.useRef(selector) + latestSelector.current = selector + + React.useEffect(function() + local function listener(state: State) + local selector = latestSelector.current + setState(if selector then selector(state) else state) + end + + atom.listeners[listener] = true + + return function() + atom.listeners[listener] = nil + end + end, { atom }) + + return state +end + +return useAtomState :: UseAtomState diff --git a/src/react/useSetAtom.luau b/src/react/useSetAtom.luau new file mode 100644 index 0000000..debb1ca --- /dev/null +++ b/src/react/useSetAtom.luau @@ -0,0 +1,14 @@ +local React = require(script.Parent.Parent.modules.React) + +local atom = require(script.Parent.Parent.atom) +type Atom = atom.Atom + +export type SetState = (T | ((T) -> T)) -> () + +local function useSetAtom(atom: Atom): SetState + return React.useCallback(function(value) + atom:set(value) + end, { atom }) +end + +return useSetAtom diff --git a/src/split.luau b/src/split.luau new file mode 100644 index 0000000..285c18f --- /dev/null +++ b/src/split.luau @@ -0,0 +1,75 @@ +local atom = require(script.Parent.atom) +type Atom = atom.Atom +type ReadonlyAtom = atom.ReadonlyAtom + +local reference = require(script.Parent.utils.reference) + +type Split = + ((atom: Atom<{ [Key]: Item }>) -> ReadonlyAtom<{ [Key]: Atom }>) + & ((atom: Atom<{ [any]: Item }>, keyExtractor: (Item) -> Key) -> ReadonlyAtom<{ [Key]: Atom }>) + +local function split( + itemsAtom: Atom<{ [Key]: Item }>, + keyExtractor: ((Item) -> Key)? +): ReadonlyAtom<{ [Key]: Atom }> + local splitAtom: ReadonlyAtom<{ [Key]: Atom }> = atom({}):readonly() + local getSplitAtom = reference.weak(splitAtom) + + local previousAtoms: { [Key]: Atom } = {} + local previousItems = itemsAtom:get() + local listener: ({ [Key]: Item }) -> () + + local function cleanup() + itemsAtom.listeners[listener] = nil + for _, atom in previousAtoms do + atom:unmount() + end + end + + function listener(items) + local splitAtom = getSplitAtom() + + if not splitAtom then + return cleanup() + end + + local atoms = table.clone(splitAtom:get()) + local itemsByKeys: { [Key]: Item } = {} + + for key, item in items do + local id = if keyExtractor then keyExtractor(item) else key + + itemsByKeys[id] = item + + if atoms[id] then + -- Force set the atom to avoid redundantly updating itemsAtom + atoms[id]:setForced(item) + else + atoms[id] = atom(item):writable(function(update) + local nextItems = table.clone(itemsAtom:get()) + nextItems[key] = update + itemsAtom:set(nextItems) + end) + end + end + + for key, item in previousItems do + local id = if keyExtractor then keyExtractor(item) else key + + if itemsByKeys[id] == nil then + atoms[id]:unmount() + atoms[id] = nil + end + end + + previousAtoms, previousItems = atoms, items + splitAtom:setForced(atoms) + end + + itemsAtom.listeners[listener] = true + listener(itemsAtom:get()) + + return splitAtom +end + +return (split :: any) :: Split diff --git a/src/subscribe.luau b/src/subscribe.luau new file mode 100644 index 0000000..30af236 --- /dev/null +++ b/src/subscribe.luau @@ -0,0 +1,46 @@ +local atom = require(script.Parent.atom) +type Atom = atom.Atom + +local reference = require(script.Parent.utils.reference) + +local function subscribe(atom: Atom, callback: (state: State, lastState: State) -> ()): (() -> (), () -> ()) + local pending: thread? = nil + local state = atom:get() + + local function emit() + local lastState = state + state = atom:get() + callback(state, lastState) + end + + local function update() + pending = pending or task.defer(function() + pending = nil + emit() + end) + end + + local function flush() + if pending then + pending = task.cancel(pending) + emit() + end + end + + local function unsubscribe() + reference.release(atom) + atom.listeners[update] = nil + + if pending then + pending = task.cancel(pending) + end + end + + -- Ensure the atom is held in memory while the subscription is active + reference.hold(atom) + atom.listeners[update] = true + + return unsubscribe, flush +end + +return subscribe diff --git a/src/utils/reference.luau b/src/utils/reference.luau new file mode 100644 index 0000000..de4cdca --- /dev/null +++ b/src/utils/reference.luau @@ -0,0 +1,31 @@ +local refs = {} + +--- Prevents the garbage collector from collecting a value +local function hold(value: unknown) + refs[value] = true +end + +--- Allows the garbage collector to collect a value +local function release(value: unknown) + refs[value] = nil +end + +--- Clears all held references +local function clear() + table.clear(refs) +end + +--- Returns a function that retrieves the value through a weak reference +local function weak(value: T): () -> T? + local set = setmetatable({ value }, { __mode = "v" }) + return function() + return set[1] + end +end + +return { + hold = hold, + release = release, + clear = clear, + weak = weak, +} diff --git a/testez-companion.toml b/testez-companion.toml new file mode 100644 index 0000000..bb50d4d --- /dev/null +++ b/testez-companion.toml @@ -0,0 +1 @@ +roots = ["ReplicatedStorage/Packages/charm/__tests__"] diff --git a/testez.yml b/testez.yml new file mode 100644 index 0000000..08be74e --- /dev/null +++ b/testez.yml @@ -0,0 +1,53 @@ +--- +globals: + FIXME: + args: + - required: false + type: string + FOCUS: + args: [] + SKIP: + args: [] + afterAll: + args: + - type: function + afterEach: + args: + - type: function + beforeAll: + args: + - type: function + beforeEach: + args: + - type: function + describe: + args: + - type: string + - type: function + describeFOCUS: + args: + - type: string + - type: function + describeSKIP: + args: + - type: string + - type: function + expect: + args: + - type: any + it: + args: + - type: string + - type: function + itFIXME: + args: + - type: string + - type: function + itFOCUS: + args: + - type: string + - type: function + itSKIP: + args: + - type: string + - type: function diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b255ab8 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + // required + "allowSyntheticDefaultImports": true, + "downlevelIteration": true, + "jsx": "react", + "jsxFactory": "React.createElement", + "jsxFragmentFactory": "React.Fragment", + "module": "commonjs", + "moduleResolution": "Node", + "noLib": true, + "resolveJsonModule": true, + "experimentalDecorators": true, + "forceConsistentCasingInFileNames": true, + "moduleDetection": "force", + "strict": true, + "target": "ESNext", + "typeRoots": ["node_modules/@rbxts"], + + // configurable + "rootDir": "src", + "outDir": "out", + "incremental": true, + "tsBuildInfoFile": "out/tsconfig.tsbuildinfo", + "declaration": true + } +} diff --git a/wally.lock b/wally.lock new file mode 100644 index 0000000..09550f7 --- /dev/null +++ b/wally.lock @@ -0,0 +1,103 @@ +# This file is automatically @generated by Wally. +# It is not intended for manual editing. +registry = "test" + +[[package]] +name = "evaera/promise" +version = "4.0.0" +dependencies = [] + +[[package]] +name = "jsdotlua/boolean" +version = "1.2.6" +dependencies = [["number", "jsdotlua/number@1.2.6"]] + +[[package]] +name = "jsdotlua/collections" +version = "1.2.6" +dependencies = [["es7-types", "jsdotlua/es7-types@1.2.6"], ["instance-of", "jsdotlua/instance-of@1.2.6"]] + +[[package]] +name = "jsdotlua/console" +version = "1.2.6" +dependencies = [["collections", "jsdotlua/collections@1.2.6"]] + +[[package]] +name = "jsdotlua/es7-types" +version = "1.2.6" +dependencies = [] + +[[package]] +name = "jsdotlua/instance-of" +version = "1.2.6" +dependencies = [] + +[[package]] +name = "jsdotlua/luau-polyfill" +version = "1.2.6" +dependencies = [["boolean", "jsdotlua/boolean@1.2.6"], ["collections", "jsdotlua/collections@1.2.6"], ["console", "jsdotlua/console@1.2.6"], ["es7-types", "jsdotlua/es7-types@1.2.6"], ["instance-of", "jsdotlua/instance-of@1.2.6"], ["math", "jsdotlua/math@1.2.6"], ["number", "jsdotlua/number@1.2.6"], ["string", "jsdotlua/string@1.2.6"], ["symbol-luau", "jsdotlua/symbol-luau@1.0.1"], ["timers", "jsdotlua/timers@1.2.6"]] + +[[package]] +name = "jsdotlua/math" +version = "1.2.6" +dependencies = [] + +[[package]] +name = "jsdotlua/number" +version = "1.2.6" +dependencies = [] + +[[package]] +name = "jsdotlua/promise" +version = "3.5.2" +dependencies = [] + +[[package]] +name = "jsdotlua/react" +version = "17.1.0" +dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.6"], ["shared", "jsdotlua/shared@17.1.0"]] + +[[package]] +name = "jsdotlua/react-reconciler" +version = "17.1.0" +dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.6"], ["promise", "jsdotlua/promise@3.5.2"], ["react", "jsdotlua/react@17.1.0"], ["scheduler", "jsdotlua/scheduler@17.1.0"], ["shared", "jsdotlua/shared@17.1.0"]] + +[[package]] +name = "jsdotlua/react-roblox" +version = "17.1.0" +dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.6"], ["react", "jsdotlua/react@17.1.0"], ["react-reconciler", "jsdotlua/react-reconciler@17.1.0"], ["scheduler", "jsdotlua/scheduler@17.1.0"], ["shared", "jsdotlua/shared@17.1.0"]] + +[[package]] +name = "jsdotlua/scheduler" +version = "17.1.0" +dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.6"], ["shared", "jsdotlua/shared@17.1.0"]] + +[[package]] +name = "jsdotlua/shared" +version = "17.1.0" +dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.6"]] + +[[package]] +name = "jsdotlua/string" +version = "1.2.6" +dependencies = [["es7-types", "jsdotlua/es7-types@1.2.6"], ["number", "jsdotlua/number@1.2.6"]] + +[[package]] +name = "jsdotlua/symbol-luau" +version = "1.0.1" +dependencies = [] + +[[package]] +name = "jsdotlua/timers" +version = "1.2.6" +dependencies = [["collections", "jsdotlua/collections@1.2.6"]] + +[[package]] +name = "littensy/charm" +version = "0.1.0" +dependencies = [["Promise", "evaera/promise@4.0.0"], ["React", "jsdotlua/react@17.1.0"], ["ReactRoblox", "jsdotlua/react-roblox@17.1.0"], ["TestEZ", "roblox/testez@0.4.1"]] + +[[package]] +name = "roblox/testez" +version = "0.4.1" +dependencies = [] diff --git a/wally.toml b/wally.toml new file mode 100644 index 0000000..980208d --- /dev/null +++ b/wally.toml @@ -0,0 +1,20 @@ +[package] +name = "littensy/charm" +description = "Atomic state management for Roblox" +version = "0.1.0" +license = "MIT" +registry = "https://github.com/UpliftGames/wally-index" +realm = "shared" +exclude = ["**"] +include = ["src", "src/**", "default.project.json", "wally.toml", "LICENSE.md"] + +[place] +shared-packages = "game.ReplicatedStorage.Packages" + +[dependencies] +React = "jsdotlua/react@17.1.0" +ReactRoblox = "jsdotlua/react-roblox@17.1.0" +Promise = "evaera/promise@4.0.0" + +[dev-dependencies] +TestEZ = "roblox/testez@0.4.1"