├── .all-contributorsrc ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .npmignore ├── CHANGELOG.md ├── README.md ├── demo ├── custom-event.html └── observer.html ├── index.html ├── lib ├── __tests__ │ └── lexer │ │ ├── lexer.arrays.test.js │ │ ├── lexer.assignments.test.js │ │ ├── lexer.block-statements.test.js │ │ ├── lexer.declarations.test.js │ │ ├── lexer.document-selector.test.js │ │ ├── lexer.error.test.js │ │ ├── lexer.expressions.test.js │ │ ├── lexer.functions.test.js │ │ ├── lexer.template-literals.test.js │ │ └── lexer.variable-shadowing.test.js ├── entity.js ├── entity │ ├── attributes.js │ ├── data.js │ └── events.js ├── extensions │ └── events-extensions.js ├── generators │ ├── interpreter.js │ ├── lexer.js │ └── observer.js ├── helpers.js ├── helpers │ ├── array.js │ ├── strings.js │ ├── time.js │ └── variables.js ├── main.js └── state.js ├── package.json └── vite.config.js /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "minijs", 3 | "projectOwner": "Group-One-Technology", 4 | "repoType": "github", 5 | "repoHost": "https://github.com", 6 | "files": [ 7 | "README.md" 8 | ], 9 | "imageSize": 100, 10 | "commit": true, 11 | "commitConvention": "angular", 12 | "contributors": [ 13 | { 14 | "login": "jensnowww", 15 | "name": "Jen", 16 | "avatar_url": "https://avatars.githubusercontent.com/u/26903002?v=4", 17 | "profile": "https://github.com/jensnowww", 18 | "contributions": [ 19 | "code", 20 | "infra", 21 | "doc" 22 | ] 23 | }, 24 | { 25 | "login": "tonyennis145", 26 | "name": "tonyennis145", 27 | "avatar_url": "https://avatars.githubusercontent.com/u/3110339?v=4", 28 | "profile": "https://github.com/tonyennis145", 29 | "contributions": [ 30 | "doc", 31 | "code" 32 | ] 33 | }, 34 | { 35 | "login": "jorenrui", 36 | "name": "Joeylene", 37 | "avatar_url": "https://avatars.githubusercontent.com/u/23741509?v=4", 38 | "profile": "https://joeylene.com/", 39 | "contributions": [ 40 | "infra", 41 | "code", 42 | "doc", 43 | "test" 44 | ] 45 | }, 46 | { 47 | "login": "notthatjen", 48 | "name": "Jen", 49 | "avatar_url": "https://avatars.githubusercontent.com/u/26903002?v=4", 50 | "profile": "https://github.com/notthatjen", 51 | "contributions": [ 52 | "doc", 53 | "code" 54 | ] 55 | } 56 | ], 57 | "contributorsPerLine": 7, 58 | "linkToUsage": false 59 | } 60 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: NPM Publish 2 | 3 | on: [push] 4 | 5 | jobs: 6 | automated_tests: 7 | runs-on: ubuntu-latest 8 | if: "!contains(github.event.head_commit.message, 'ci skip') && !contains(github.event.head_commit.message, 'skip ci') && github.ref != 'main'" 9 | steps: 10 | - uses: actions/checkout@v2 11 | - name: Use Node.js 20.x 12 | uses: actions/setup-node@v1 13 | with: 14 | node-version: 20.x 15 | - name: Cache node modules 16 | uses: actions/cache@v1 17 | with: 18 | path: node_modules 19 | key: yarn-deps-${{ hashFiles('yarn.lock') }} 20 | restore-keys: | 21 | yarn-deps-${{ hashFiles('yarn.lock') }} 22 | - name: Run tests 23 | run: | 24 | yarn install --frozen-lockfile 25 | yarn test 26 | release: 27 | runs-on: ubuntu-latest 28 | if: "!contains(github.event.head_commit.message, 'ci skip') && !contains(github.event.head_commit.message, 'skip ci')" 29 | steps: 30 | - uses: actions/checkout@v2 31 | 32 | - name: Use Node.js 20.x 33 | uses: actions/setup-node@v1 34 | with: 35 | node-version: 20.x 36 | 37 | - name: Prepare repository 38 | run: | 39 | git fetch --unshallow --tags 40 | 41 | - name: Cache node modules 42 | uses: actions/cache@v1 43 | with: 44 | path: node_modules 45 | key: yarn-deps-${{ hashFiles('yarn.lock') }} 46 | restore-keys: | 47 | yarn-deps-${{ hashFiles('yarn.lock') }} 48 | 49 | - name: Create Release 50 | env: 51 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 52 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 53 | run: | 54 | git config user.name 'Tonic Labs' 55 | git config user.email '<>' 56 | yarn install --frozen-lockfile 57 | yarn build 58 | npx auto shipit 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env* 2 | node_modules 3 | yarn.lock 4 | yarn-error.log 5 | dist 6 | .DS_STORE -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | babel.config.js 3 | package.json 4 | vite.config.js 5 | .all-contributorsrc 6 | .github -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # v1.0.20 (Thu Jun 20 2024) 2 | 3 | #### ⚠️ Pushed to `main` 4 | 5 | - Merge branch 'main' of github.com:Group-One-Technology/minijs ([@notthatjen](https://github.com/notthatjen)) 6 | - refactor: use math random better for uuid ([@notthatjen](https://github.com/notthatjen)) 7 | 8 | #### Authors: 1 9 | 10 | - Jen ([@notthatjen](https://github.com/notthatjen)) 11 | 12 | --- 13 | 14 | # v1.0.19 (Thu Jun 20 2024) 15 | 16 | #### ⚠️ Pushed to `main` 17 | 18 | - Merge branch 'main' of github.com:Group-One-Technology/minijs ([@notthatjen](https://github.com/notthatjen)) 19 | - refactor: improve uuid generation ([@notthatjen](https://github.com/notthatjen)) 20 | 21 | #### Authors: 1 22 | 23 | - Jen ([@notthatjen](https://github.com/notthatjen)) 24 | 25 | --- 26 | 27 | # v1.0.18 (Wed Jun 19 2024) 28 | 29 | #### ⚠️ Pushed to `main` 30 | 31 | - Merge branch 'main' of github.com:Group-One-Technology/minijs ([@notthatjen](https://github.com/notthatjen)) 32 | - refactor: failsafe domReady, wrap MiniJS init with domReady ([@notthatjen](https://github.com/notthatjen)) 33 | 34 | #### Authors: 1 35 | 36 | - Jen ([@notthatjen](https://github.com/notthatjen)) 37 | 38 | --- 39 | 40 | # v1.0.17 (Tue Jun 18 2024) 41 | 42 | #### ⚠️ Pushed to `main` 43 | 44 | - Merge branch 'main' of github.com:Group-One-Technology/minijs ([@notthatjen](https://github.com/notthatjen)) 45 | - bugfix: fix domReady, wait for the entire page to load ([@notthatjen](https://github.com/notthatjen)) 46 | 47 | #### Authors: 1 48 | 49 | - Jen ([@notthatjen](https://github.com/notthatjen)) 50 | 51 | --- 52 | 53 | # v1.0.16 (Tue Jun 18 2024) 54 | 55 | #### 🐛 Bug Fix 56 | 57 | - feat: allow custom attributes to be dynamic [#26](https://github.com/Group-One-Technology/minijs/pull/26) ([@jorenrui](https://github.com/jorenrui) [@tonyennis145](https://github.com/tonyennis145)) 58 | 59 | #### Authors: 2 60 | 61 | - [@tonyennis145](https://github.com/tonyennis145) 62 | - Joeylene ([@jorenrui](https://github.com/jorenrui)) 63 | 64 | --- 65 | 66 | # v1.0.15 (Sat Apr 13 2024) 67 | 68 | #### ⚠️ Pushed to `main` 69 | 70 | - feat: add wait helper to mini ([@jorenrui](https://github.com/jorenrui)) 71 | 72 | #### Authors: 1 73 | 74 | - Joeylene ([@jorenrui](https://github.com/jorenrui)) 75 | 76 | --- 77 | 78 | # v1.0.14 (Mon Mar 25 2024) 79 | 80 | #### ⚠️ Pushed to `main` 81 | 82 | - fix: delay in applying custom helpers to current state ([@jorenrui](https://github.com/jorenrui)) 83 | 84 | #### Authors: 1 85 | 86 | - Joeylene ([@jorenrui](https://github.com/jorenrui)) 87 | 88 | --- 89 | 90 | # v1.0.13 (Mon Mar 25 2024) 91 | 92 | #### ⚠️ Pushed to `main` 93 | 94 | - fix: do not flatten arrays for array toggle, remove, and subtract ([@jorenrui](https://github.com/jorenrui)) 95 | - fix: do not flatten array for array.add ([@jorenrui](https://github.com/jorenrui)) 96 | 97 | #### Authors: 1 98 | 99 | - Joeylene ([@jorenrui](https://github.com/jorenrui)) 100 | 101 | --- 102 | 103 | # v1.0.12 (Mon Mar 25 2024) 104 | 105 | #### ⚠️ Pushed to `main` 106 | 107 | - feat: deep clone mini array ([@jorenrui](https://github.com/jorenrui)) 108 | 109 | #### Authors: 1 110 | 111 | - Joeylene ([@jorenrui](https://github.com/jorenrui)) 112 | 113 | --- 114 | 115 | # v1.0.11 (Sun Mar 24 2024) 116 | 117 | #### ⚠️ Pushed to `main` 118 | 119 | - refactor: prevent array.nextItem and array.previousItem to return null ([@jorenrui](https://github.com/jorenrui)) 120 | - refactor: update how array.previousItem and array.nextItem works for multi dimensional arrays ([@jorenrui](https://github.com/jorenrui)) 121 | 122 | #### Authors: 1 123 | 124 | - Joeylene ([@jorenrui](https://github.com/jorenrui)) 125 | 126 | --- 127 | 128 | # v1.0.10 (Sun Mar 24 2024) 129 | 130 | #### ⚠️ Pushed to `main` 131 | 132 | - refactor: update array.search to return the same structure of the searched array ([@jorenrui](https://github.com/jorenrui)) 133 | 134 | #### Authors: 1 135 | 136 | - Joeylene ([@jorenrui](https://github.com/jorenrui)) 137 | 138 | --- 139 | 140 | # v1.0.9 (Sat Mar 23 2024) 141 | 142 | #### ⚠️ Pushed to `main` 143 | 144 | - feat: add array.deepFirst and array.deepLast properties ([@jorenrui](https://github.com/jorenrui)) 145 | - feat: add support for multi-dimensional arrays for array.add ([@jorenrui](https://github.com/jorenrui)) 146 | - feat: add support for multi dimensional arrays for array.toggle ([@jorenrui](https://github.com/jorenrui)) 147 | - feat: add deep equality checker for arrays with array.sameAs(array) ([@jorenrui](https://github.com/jorenrui)) 148 | - feat: add support for multi dimensional array for array.remove and array.subtract ([@jorenrui](https://github.com/jorenrui)) 149 | - feat: support multi-dimensional arrays in array.nextItem and array.previousItem ([@jorenrui](https://github.com/jorenrui)) 150 | - feat: add array.deepFlat and support multi dimensional array in array.search ([@jorenrui](https://github.com/jorenrui)) 151 | - feat: expose MiniArray as MiniJS.Array ([@jorenrui](https://github.com/jorenrui)) 152 | 153 | #### Authors: 1 154 | 155 | - Joeylene ([@jorenrui](https://github.com/jorenrui)) 156 | 157 | --- 158 | 159 | # v1.0.8 (Wed Mar 20 2024) 160 | 161 | #### ⚠️ Pushed to `main` 162 | 163 | - feat: make array mutations re-renders work for el and scope variables ([@jorenrui](https://github.com/jorenrui)) 164 | 165 | #### Authors: 1 166 | 167 | - Joeylene ([@jorenrui](https://github.com/jorenrui)) 168 | 169 | --- 170 | 171 | # v1.0.7 (Sun Mar 17 2024) 172 | 173 | #### ⚠️ Pushed to `main` 174 | 175 | - Update README.md ([@tonyennis145](https://github.com/tonyennis145)) 176 | 177 | #### Authors: 1 178 | 179 | - [@tonyennis145](https://github.com/tonyennis145) 180 | 181 | --- 182 | 183 | # v1.0.6 (Sun Mar 17 2024) 184 | 185 | #### ⚠️ Pushed to `main` 186 | 187 | - refactor: update array add, remove, toggle and replaceAt to be mutating array methods ([@jorenrui](https://github.com/jorenrui)) 188 | - feat: trigger re-render for array mutation variables ([@jorenrui](https://github.com/jorenrui)) 189 | - feat: identify array method usage with the lexer ([@jorenrui](https://github.com/jorenrui)) 190 | 191 | #### Authors: 1 192 | 193 | - Joeylene ([@jorenrui](https://github.com/jorenrui)) 194 | 195 | --- 196 | 197 | # v1.0.5 (Fri Mar 08 2024) 198 | 199 | #### ⚠️ Pushed to `main` 200 | 201 | - fix: :scope not working for dynamically inserted nodes ([@jorenrui](https://github.com/jorenrui)) 202 | 203 | #### Authors: 1 204 | 205 | - Joeylene ([@jorenrui](https://github.com/jorenrui)) 206 | 207 | --- 208 | 209 | # v1.0.4 (Fri Mar 08 2024) 210 | 211 | #### ⚠️ Pushed to `main` 212 | 213 | - fix: non conditional :class not working ([@jorenrui](https://github.com/jorenrui)) 214 | 215 | #### Authors: 1 216 | 217 | - Joeylene ([@jorenrui](https://github.com/jorenrui)) 218 | 219 | --- 220 | 221 | # v1.0.3 (Thu Feb 29 2024) 222 | 223 | #### 🐛 Bug Fix 224 | 225 | - Refactor: Rename :group to :scope [#22](https://github.com/Group-One-Technology/minijs/pull/22) ([@jorenrui](https://github.com/jorenrui)) 226 | 227 | #### Authors: 1 228 | 229 | - Joeylene ([@jorenrui](https://github.com/jorenrui)) 230 | 231 | --- 232 | 233 | # v1.0.2 (Tue Feb 27 2024) 234 | 235 | #### 🐛 Bug Fix 236 | 237 | - Release: v1.0.2 [#19](https://github.com/Group-One-Technology/minijs/pull/19) ([@jorenrui](https://github.com/jorenrui)) 238 | - Refactor: Entity Variable Restructure [#21](https://github.com/Group-One-Technology/minijs/pull/21) ([@jorenrui](https://github.com/jorenrui)) 239 | - Refactor: Group Variables (rename of Parent Variables) [#20](https://github.com/Group-One-Technology/minijs/pull/20) ([@jorenrui](https://github.com/jorenrui)) 240 | 241 | #### Authors: 1 242 | 243 | - Joeylene ([@jorenrui](https://github.com/jorenrui)) 244 | 245 | --- 246 | 247 | # v1.0.1 (Mon Feb 26 2024) 248 | 249 | :tada: This release contains work from new contributors! :tada: 250 | 251 | Thanks for all your work! 252 | 253 | :heart: Joeylene ([@jorenrui](https://github.com/jorenrui)) 254 | 255 | :heart: null[@tonyennis145](https://github.com/tonyennis145) 256 | 257 | :heart: Jen ([@jensnowww](https://github.com/jensnowww)) 258 | 259 | #### 🐛 Bug Fix 260 | 261 | - Fix: Lexer Bugs Found [#18](https://github.com/Group-One-Technology/minijs/pull/18) ([@jorenrui](https://github.com/jorenrui)) 262 | - Tests: Setup Vitest and Add Test Cases for the Lexer [#17](https://github.com/Group-One-Technology/minijs/pull/17) ([@jorenrui](https://github.com/jorenrui)) 263 | - Refactor: Lexer with Variable Shadowing [#16](https://github.com/Group-One-Technology/minijs/pull/16) ([@jorenrui](https://github.com/jorenrui)) 264 | - refactor: update condition for regions [#16](https://github.com/Group-One-Technology/minijs/pull/16) ([@jorenrui](https://github.com/jorenrui)) 265 | - Feature: Support for Dynamically Inserted Scripts [#15](https://github.com/Group-One-Technology/minijs/pull/15) ([@jorenrui](https://github.com/jorenrui)) 266 | - feat: add support for dynamically updated DOM element events [#15](https://github.com/Group-One-Technology/minijs/pull/15) ([@jorenrui](https://github.com/jorenrui)) 267 | - Refactor: :each with variables [#14](https://github.com/Group-One-Technology/minijs/pull/14) ([@jorenrui](https://github.com/jorenrui)) 268 | - Feat: Key Events - Key Modifiers [#13](https://github.com/Group-One-Technology/minijs/pull/13) ([@jorenrui](https://github.com/jorenrui)) 269 | - Fix: :checked attribute when set to false [#12](https://github.com/Group-One-Technology/minijs/pull/12) ([@jorenrui](https://github.com/jorenrui)) 270 | - Fix: Reactive El Variables; Refactor: Rehaul of State Dependencies; Feat: Parent El Variables [#11](https://github.com/Group-One-Technology/minijs/pull/11) ([@jorenrui](https://github.com/jorenrui)) 271 | - Fix: SVG :class not working [#10](https://github.com/Group-One-Technology/minijs/pull/10) ([@jorenrui](https://github.com/jorenrui)) 272 | - Refactor: Breakdown Entity [#9](https://github.com/Group-One-Technology/minijs/pull/9) ([@jorenrui](https://github.com/jorenrui)) 273 | - Commit fully compiled files [#9](https://github.com/Group-One-Technology/minijs/pull/9) ([@tonyennis145](https://github.com/tonyennis145)) 274 | - feat: remove event bindings on entity dispose [#8](https://github.com/Group-One-Technology/minijs/pull/8) ([@jorenrui](https://github.com/jorenrui)) 275 | - refactor: add try-catch for create init children [#8](https://github.com/Group-One-Technology/minijs/pull/8) ([@jorenrui](https://github.com/jorenrui)) 276 | - Feature: DOM Observer [#8](https://github.com/Group-One-Technology/minijs/pull/8) ([@jorenrui](https://github.com/jorenrui)) 277 | - style: use prettier [#8](https://github.com/Group-One-Technology/minijs/pull/8) ([@jorenrui](https://github.com/jorenrui)) 278 | - Refactor: Update Behavior of :class to Invert Classes [#7](https://github.com/Group-One-Technology/minijs/pull/7) ([@jorenrui](https://github.com/jorenrui)) 279 | - Feat: Slider [#6](https://github.com/Group-One-Technology/minijs/pull/6) ([@jorenrui](https://github.com/jorenrui)) 280 | - Refactor: Lexer Variables [#5](https://github.com/Group-One-Technology/minijs/pull/5) ([@jorenrui](https://github.com/jorenrui)) 281 | - Feature: Tonic Modal [#4](https://github.com/Group-One-Technology/minijs/pull/4) ([@jorenrui](https://github.com/jorenrui)) 282 | - Refactor: Use JS-Tokens for Lexer [#3](https://github.com/Group-One-Technology/minijs/pull/3) ([@jorenrui](https://github.com/jorenrui)) 283 | - Feature: Dialog and Alert Dialog [#2](https://github.com/Group-One-Technology/minijs/pull/2) ([@jorenrui](https://github.com/jorenrui)) 284 | - Feature: Multiple Select [#1](https://github.com/Group-One-Technology/minijs/pull/1) ([@jorenrui](https://github.com/jorenrui)) 285 | 286 | #### ⚠️ Pushed to `main` 287 | 288 | - chore: update file in contributorsrc ([@jorenrui](https://github.com/jorenrui)) 289 | - refactor: rename readme to README ([@jorenrui](https://github.com/jorenrui)) 290 | - chore: update contributors ([@jorenrui](https://github.com/jorenrui)) 291 | - build: setup git config ([@jorenrui](https://github.com/jorenrui)) 292 | - build: update dependencies ([@jorenrui](https://github.com/jorenrui)) 293 | - docs: add test script in readme ([@jorenrui](https://github.com/jorenrui)) 294 | - feat: add lexer support for object expression, rest element, and arrow functions ([@jorenrui](https://github.com/jorenrui)) 295 | - refactor: update fetch of variables in :each directive ([@jorenrui](https://github.com/jorenrui)) 296 | - fix: attach helper variables to variables after evaluate event attribute ([@jorenrui](https://github.com/jorenrui)) 297 | - docs: add link to notion docu ([@jorenrui](https://github.com/jorenrui)) 298 | - docs: add info regarding re-rendering ([@jorenrui](https://github.com/jorenrui)) 299 | - chore: add todo for lexer on object destructuring ([@jorenrui](https://github.com/jorenrui)) 300 | - fix: disable re-rendering on el and parent variables re-assignement in dynamic attributes (prevents infinite loop) ([@jorenrui](https://github.com/jorenrui)) 301 | - fix: prevent triggering re-render on evaluate dynamic attribute (prevent infinite loop) ([@jorenrui](https://github.com/jorenrui)) 302 | - docs: update description of :value, :class, and :text ([@jorenrui](https://github.com/jorenrui)) 303 | - refactor: remove unused helper ([@jorenrui](https://github.com/jorenrui)) 304 | - chore: add comment regarding null parent ([@jorenrui](https://github.com/jorenrui)) 305 | - docs: update previousItem usage ([@jorenrui](https://github.com/jorenrui)) 306 | - feat: add replaceAt array method ([@jorenrui](https://github.com/jorenrui)) 307 | - docs: update custom events ([@jorenrui](https://github.com/jorenrui)) 308 | - fix: parent is null ([@jorenrui](https://github.com/jorenrui)) 309 | - fix: prevent re-renders until ready ([@jorenrui](https://github.com/jorenrui)) 310 | - refactor: update cloned child ([@jorenrui](https://github.com/jorenrui)) 311 | - fix: :load event not running ([@jorenrui](https://github.com/jorenrui)) 312 | - chore: update todo ([@jorenrui](https://github.com/jorenrui)) 313 | - fix: ignore call expressions ([@jorenrui](https://github.com/jorenrui)) 314 | - fix: add support for array pattern ([@jorenrui](https://github.com/jorenrui)) 315 | - fix: ignore native variables and new expression for member identifiers ([@jorenrui](https://github.com/jorenrui)) 316 | - refactor: dispose event before setting event ([@jorenrui](https://github.com/jorenrui)) 317 | - refactor: relocate custom event setters to set event ([@jorenrui](https://github.com/jorenrui)) 318 | - refactor: update demo to use :clickme ([@jorenrui](https://github.com/jorenrui)) 319 | - refactor: update key of event listeners ([@jorenrui](https://github.com/jorenrui)) 320 | - feat: add :clickme event ([@jorenrui](https://github.com/jorenrui)) 321 | - refactor: rename special keys to system keys ([@jorenrui](https://github.com/jorenrui)) 322 | - fix: set parent entity as document if there are no parents found ([@jorenrui](https://github.com/jorenrui)) 323 | - feat: add support for aria-*, data-*, and dash-cased attributes ([@jorenrui](https://github.com/jorenrui)) 324 | - refactor: update query selector ([@jorenrui](https://github.com/jorenrui)) 325 | - fix: prevent returning current element as parent ([@jorenrui](https://github.com/jorenrui)) 326 | - fix: remove array type event listeners ([@jorenrui](https://github.com/jorenrui)) 327 | - fix: check if dynamic attribute before evaluating it for observeDOM ([@jorenrui](https://github.com/jorenrui)) 328 | - chore: add comment on getParent ([@jorenrui](https://github.com/jorenrui)) 329 | - refactor: remove unused logic ([@jorenrui](https://github.com/jorenrui)) 330 | - docs: add dynamic attributes to README ([@jorenrui](https://github.com/jorenrui)) 331 | - docs: update README ([@jorenrui](https://github.com/jorenrui)) 332 | - feat: detect and apply changes to dynamic attributes ([@jorenrui](https://github.com/jorenrui)) 333 | - fix: make :each children work with observe dom ([@jorenrui](https://github.com/jorenrui)) 334 | - refactor: remove dom content loaded listener ([@jorenrui](https://github.com/jorenrui)) 335 | - fix: prevent duplicate entity init for :each attribute ([@jorenrui](https://github.com/jorenrui)) 336 | - fix: use mini events ([@jorenrui](https://github.com/jorenrui)) 337 | - fix: array listeners not working ([@jorenrui](https://github.com/jorenrui)) 338 | - refactor: add try-catch for parser ([@jorenrui](https://github.com/jorenrui)) 339 | - fix: used variables by dynamically added dom are being removed during disposal of nodes ([@jorenrui](https://github.com/jorenrui)) 340 | - feat: track event listeners of entity ([@jorenrui](https://github.com/jorenrui)) 341 | - docs: run prettier ([@jorenrui](https://github.com/jorenrui)) 342 | - chore: add comments ([@jorenrui](https://github.com/jorenrui)) 343 | - refactor: expose window ([@jorenrui](https://github.com/jorenrui)) 344 | - fix: escape html for :each directive ([@jorenrui](https://github.com/jorenrui)) 345 | - refactor: relocate scope arg in async function ([@jorenrui](https://github.com/jorenrui)) 346 | - refactor: update default scope and this for interpreter ([@jorenrui](https://github.com/jorenrui)) 347 | - refactor: replace eval with async function ([@jorenrui](https://github.com/jorenrui)) 348 | - style: remove semicolons ([@jorenrui](https://github.com/jorenrui)) 349 | - feat: add try-catch for eval ([@jorenrui](https://github.com/jorenrui)) 350 | - refactor: relocate files under generators and helpers ([@jorenrui](https://github.com/jorenrui)) 351 | - feat: add interpreter and add eval in context ([@jorenrui](https://github.com/jorenrui)) 352 | - refactor: remove commented proxy object ([@jorenrui](https://github.com/jorenrui)) 353 | - fix: clickout not working when target is the html tag ([@jorenrui](https://github.com/jorenrui)) 354 | - fix: non conditional classnames not working ([@jorenrui](https://github.com/jorenrui)) 355 | - feat: add entity id on debug mode ([@jorenrui](https://github.com/jorenrui)) 356 | - docs: fix typo ([@jorenrui](https://github.com/jorenrui)) 357 | - docs: add installation steps ([@jorenrui](https://github.com/jorenrui)) 358 | - refactor: add default state to avoid content shift ([@jorenrui](https://github.com/jorenrui)) 359 | - refactor: hide unused button ([@jorenrui](https://github.com/jorenrui)) 360 | - fix: access to local storage ([@jorenrui](https://github.com/jorenrui)) 361 | - refactor: comment out proxy object ([@jorenrui](https://github.com/jorenrui)) 362 | - refactor: remove object property shorthand syntax identifier ([@jorenrui](https://github.com/jorenrui)) 363 | - refactor: replace document.querySelector with $ ([@jorenrui](https://github.com/jorenrui)) 364 | - refactor: update time on months click ([@jorenrui](https://github.com/jorenrui)) 365 | - feat: flexible option ui ([@jorenrui](https://github.com/jorenrui)) 366 | - feat: set $ to document.querySelector ([@jorenrui](https://github.com/jorenrui)) 367 | - fix: identifying object shorthand with operator, and calculated methods ([@jorenrui](https://github.com/jorenrui)) 368 | - fix: update check if start of obejct ([@jorenrui](https://github.com/jorenrui)) 369 | - feat: add dynamic attributes ([@jorenrui](https://github.com/jorenrui)) 370 | - refactor: update MiniJS.ignore ([@jorenrui](https://github.com/jorenrui)) 371 | - fix: ignore object with no assigned variable ([@jorenrui](https://github.com/jorenrui)) 372 | - fix: check if object is not assigned to any variable ([@jorenrui](https://github.com/jorenrui)) 373 | - refactor: remove initial class ([@jorenrui](https://github.com/jorenrui)) 374 | - refactor: remove duplicate filter ([@jorenrui](https://github.com/jorenrui)) 375 | - Merge branch 'jr.feat-proxy-variables' ([@jorenrui](https://github.com/jorenrui)) 376 | - refactor: update keyup events ([@jorenrui](https://github.com/jorenrui)) 377 | - refactor: set initial state for code blocks ([@jorenrui](https://github.com/jorenrui)) 378 | - fix: element being set if null ([@jorenrui](https://github.com/jorenrui)) 379 | - fix: update on enter trigger ([@jorenrui](https://github.com/jorenrui)) 380 | - feat: add check-in / check out tabs ([@jorenrui](https://github.com/jorenrui)) 381 | - fix: identifying object properties / method's parent ([@jorenrui](https://github.com/jorenrui)) 382 | - refactor: update transition and add color ([@jorenrui](https://github.com/jorenrui)) 383 | - fix: keypress working when not target element ([@jorenrui](https://github.com/jorenrui)) 384 | - feat: set selected destination on enter ([@jorenrui](https://github.com/jorenrui)) 385 | - feat: initial airbnb search bar clone ([@jorenrui](https://github.com/jorenrui)) 386 | - feat: add touch events on :press ([@jorenrui](https://github.com/jorenrui)) 387 | - fix: :value not changing when newValue is empty string ([@jorenrui](https://github.com/jorenrui)) 388 | - feat: add :press custom event ([@jorenrui](https://github.com/jorenrui)) 389 | - feat: add :keyup.enter and :keyup.space events ([@jorenrui](https://github.com/jorenrui)) 390 | - Update readme.md ([@tonyennis145](https://github.com/tonyennis145)) 391 | - feat: add proxy to nested objects ([@jorenrui](https://github.com/jorenrui)) 392 | - feat: listen for object property changes ([@jorenrui](https://github.com/jorenrui)) 393 | - refactor: update sample code for tonic modal ([@jorenrui](https://github.com/jorenrui)) 394 | - refactor: use :class on tonic modal ([@jorenrui](https://github.com/jorenrui)) 395 | - refactor: update html tags in multiple select ([@jorenrui](https://github.com/jorenrui)) 396 | - refactor: update code example ([@jorenrui](https://github.com/jorenrui)) 397 | - docs: update previousOf example ([@jorenrui](https://github.com/jorenrui)) 398 | - Bump version manually ([@tonyennis145](https://github.com/tonyennis145)) 399 | - Delete cached dist folder ([@tonyennis145](https://github.com/tonyennis145)) 400 | - ignore dist file ([@tonyennis145](https://github.com/tonyennis145)) 401 | - try version ([@tonyennis145](https://github.com/tonyennis145)) 402 | - Publish .1 ([@tonyennis145](https://github.com/tonyennis145)) 403 | - Fix variable assignment, add readme ([@tonyennis145](https://github.com/tonyennis145)) 404 | - Add scoping ([@tonyennis145](https://github.com/tonyennis145)) 405 | - Add each syntax and sample ([@tonyennis145](https://github.com/tonyennis145)) 406 | - Search improvement ([@jensnowww](https://github.com/jensnowww)) 407 | - Add each function ([@jensnowww](https://github.com/jensnowww)) 408 | - rename package to tonic-minijs ([@jensnowww](https://github.com/jensnowww)) 409 | - Add auth to npm ([@jensnowww](https://github.com/jensnowww)) 410 | - Include dist to deployment ([@jensnowww](https://github.com/jensnowww)) 411 | - Update demo ([@jensnowww](https://github.com/jensnowww)) 412 | - add npm ignore ([@jensnowww](https://github.com/jensnowww)) 413 | - Install specific package versions ([@jensnowww](https://github.com/jensnowww)) 414 | - Clear cacche ([@jensnowww](https://github.com/jensnowww)) 415 | - Add gitignore skip ci ([@jensnowww](https://github.com/jensnowww)) 416 | - add auto to package ([@jensnowww](https://github.com/jensnowww)) 417 | - add contributor package ([@jensnowww](https://github.com/jensnowww)) 418 | - Add author in package.json ([@jensnowww](https://github.com/jensnowww)) 419 | - remove watch on build ([@jensnowww](https://github.com/jensnowww)) 420 | - Create main.yml ([@jensnowww](https://github.com/jensnowww)) 421 | - Update node version ([@jensnowww](https://github.com/jensnowww)) 422 | - Add auto release ([@jensnowww](https://github.com/jensnowww)) 423 | - Initial commit ([@jensnowww](https://github.com/jensnowww)) 424 | 425 | #### Authors: 3 426 | 427 | - [@tonyennis145](https://github.com/tonyennis145) 428 | - Jen ([@jensnowww](https://github.com/jensnowww)) 429 | - Joeylene ([@jorenrui](https://github.com/jorenrui)) 430 | 431 | --- 432 | 433 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MiniJS 2 | 3 | Mini is a ~~library~~ extension for HTML which lets you add interactivity to your app without needing a full blown frontend framework. 4 | 5 | ## The Idea 6 | 7 | - HTML is great because it's easy to learn and extremely accessible. But HTML has shortcomings when it comes to building interfaces with interactivity. 8 | - Lots of libraries have emerged to address these shortcomings - react, vue etc. These libraries are great but they: 9 | - Have a high learning curve when it comes to code patterns and tooling. 10 | - Are primarily suited for interfaces with _lots_ of interactivity. 11 | - Mini JS lets you build interfaces with moderate amounts of interactivity without needing a heavyweight, javascript-centered library. Because it follows the same patterns as html, it doesn't require learning lots of new concepts. It's designed to be extremely minimal and learnable within an afternoon. 12 | - The key idea is that if we have 13 | 1. A way to set state when an interaction happens (e.g a user clicks a button or types in an input), and 14 | 2. A way to update other parts of the UI when those variables change, we can now easily do a range of things we previously couldn't do. 15 | - Technically vanilla HTML can already do (1), but it can't do (2). 16 | 17 | ## Setting State 18 | 19 | `State` are variables that changes the UI or the DOM that uses it when they get updated. 20 | 21 | Note: Only non-objects are supported for reactive state. 22 | 23 | ### Setting Initial State 24 | 25 | You can set the initial state of the variables using vanilla JS: 26 | 27 | ```html 28 | 32 | ``` 33 | 34 | ### Syncing the DOM with your state 35 | 36 | These are the following **dynamic attributes** that you can use to sync the DOM with your state: 37 | 38 | - `:value` 39 | - Set the value of a form input to the result of the evaluated JS code. 40 | - `:class` 41 | - Set the class of a DOM element to the result of the evaluated JS code. 42 | - `:text` 43 | - Set the text content of a DOM element to the result of the evaluated JS code. 44 | 45 | ```html 46 | 49 | 50 | 51 | 52 | 53 |

54 | ``` 55 | 56 | ### Triggering DOM Updates / Re-renders 57 | 58 | A DOM update or a re-render happens when the state variable is re-assigned in **dynamic events**. 59 | 60 | ```html 61 | 62 | 63 | ``` 64 | 65 | When re-assignment happens in dynamic attributes, it will not trigger a re-render to avoid infinite loops. 66 | 67 | ```html 68 |

69 | 70 | ``` 71 | 72 | ### Special Variables 73 | 74 | There are special variables that you can use inside dynamic attributes and events: 75 | 76 | - `this` - the current element 77 | - `$` - equal to the `document.querySelector`. 78 | 79 | ## Dynamic Attributes 80 | 81 | Besides `:value`, `:class`, and `:text`, you can also make **any** attribute dynamic by renaming it from `attribute` to `:attribute`. Values set to dynamic attributes are evaluated as JavaScript: 82 | 83 | ```html 84 | 87 | 88 |

My style is changing

89 | 97 | ``` 98 | 99 | ## Classes 100 | 101 | You can make your class names reactive by using the `:class` attribute: 102 | 103 | ```html 104 | 107 | 108 | 111 | ``` 112 | 113 | ### Setting the Default Classes 114 | 115 | To set default classes, you can use the `class` attribute: 116 | 117 | ```html 118 | 119 | ``` 120 | 121 | ### Setting Multiple Reactive Classes 122 | 123 | To set multiple reactive classes, you can use the `:class` attribute: 124 | 125 | 1. Use multiple ternary operators enclosed in parentheses: 126 | 127 | ```html 128 |
132 | ``` 133 | 134 | 2. Use if-else statements: 135 | 136 | ```html 137 |
150 | ``` 151 | 152 | ## Events 153 | 154 | You can create, use, and update state variables inside DOM events. 155 | 156 | In events, you can get the current event using the `event` variable: 157 | 158 | ```html 159 | 160 | ``` 161 | 162 | ### Native Events 163 | 164 | All native events are supported. You can use them like this: 165 | 166 | ```html 167 | 168 | ``` 169 | 170 | You can access the current element in the event via `this`: 171 | 172 | ```html 173 | 174 | 175 | 176 | ``` 177 | 178 | ### Custom Events 179 | 180 | These are the events added in by MiniJS: 181 | 182 | - `:clickout` - This will trigger when the user clicks outside of the current element. 183 | - `:clickme` - This will trigger when the user clicks the current element. 184 | - `:change` - This will trigger when the user changes the value of a form input. 185 | - `:press` - This will trigger when the user: 186 | - triggers the `click` event. 187 | - triggers the `keyup.enter` and `keyup.space` events. 188 | - triggers the `touchstart` event. 189 | 190 | ### Keyboard Events 191 | 192 | For keyboard events, you can listen to them using `:keyup`, `:keydown`, and `:keypress`: 193 | 194 | ```html 195 | 196 | ``` 197 | 198 | #### Key Modifiers 199 | 200 | You can also use key modifiers to listen to specific keys. Modifiers are appended to the event name using a dot: 201 | 202 | ```html 203 | 208 | ``` 209 | 210 | You can chain multiple key modifiers together: 211 | 212 | ```html 213 | 214 | ``` 215 | 216 | For key values that have multiple words like `BracketLeft`, except for arrow keys, kebab case is used: 217 | 218 | ```html 219 | 223 | ``` 224 | 225 | The following are the available key modifiers: 226 | 227 | | Type | Key Value | Modifier | Usage | 228 | | ---------------------------------- | ---------------------------------- | ------------------------------ | --------------------------------------------------- | 229 | | Digits (0-9) | Digit1, Digit2 | 1, 2 | :keyup.1, :keyup.2 | 230 | | Letters (A-Z, a-z) | KeyA, KeyB | a, b | :keyup.a, :keyup.b | 231 | | Numpad (0-9) | Numpad1, Numpad2 | 1, 2 | :keyup.1, :keyup.2 | 232 | | Arrow Keys (up, down, left, right) | ArrowLeft, ArrowRight | left, right | :keyup.left, :keyup.right | 233 | | Meta (left, right) | Meta, MetaLeft, MetaRight | meta, meta-left, meta-right | :keyup.meta, :keyup.meta-left, :keyup.meta-right | 234 | | Alt (left, right) | Alt, AltLeft, AltRight | alt, alt-left, alt-right | :keyup.alt, :keyup.alt-left, :keyup.alt-right | 235 | | Control (left, right) | Control, ControlLeft, ControlRight | ctrl, ctrl-left, ctrl-right | :keyup.ctrl, :keyup.ctrl-left, :keyup.ctrl-right | 236 | | Shift (left, right) | Shift, ShiftLeft, ShiftRight | shift, shift-left, shift-right | :keyup.shift, :keyup.shift-left, :keyup.shift-right | 237 | | Symbols (., /, =, etc.) | Period, BracketLeft, Slash | period, bracket-left, slash | :keyup.period, :keyup.bracket-left, :keyup.slash | 238 | 239 | > Note: If you don't know the "name" of a symbol key, you can use the `console.log(event.code)` to see the key value. Example for the "Enter" key: `:keyup="console.log(event.code)"` will log "Enter". So you can use `:keyup.enter` to listen to the "Enter" key. 240 | 241 | --- 242 | 243 | ## Statements 244 | 245 | ### Each Statement 246 | 247 | The `:each` statement is used to loop through an array and render a template for each item. 248 | 249 | ```html 250 | 253 | 254 | 257 | 258 | 261 | ``` 262 | 263 | You can also use complex variables for the `:each` statement: 264 | 265 | ```html 266 | 274 | 275 | 278 | ``` 279 | 280 | Note: Each item variables are **read-only**, which means you can't re-assign them like: 281 | 282 | ```html 283 | 286 | ``` 287 | 288 | ## Variables 289 | 290 | ### Variables saved in Local Storage 291 | 292 | Appending `$` to the variable name will save the variable in the local storage: 293 | 294 | ```html 295 | 298 | 299 | 300 | ``` 301 | 302 | Note: Currently, this is only available for globally declared variables. 303 | 304 | ### Variable Scoping 305 | 306 | #### Global Variables 307 | 308 | Whenever you create a variable, it will automatically be added to the global scope. This means that you can access it anywhere in your code. 309 | 310 | ```html 311 | 314 | 315 | 316 | ``` 317 | 318 | #### Local Variables 319 | 320 | To use variables only in a current event, you can create a local variable using `const`, and `let`: 321 | 322 | ```html 323 | 329 | ``` 330 | 331 | ### Element Variables 332 | 333 | If you want to use the variable across an element's attributes and events, you can use `el.`: 334 | 335 | ```html 336 | 339 | 340 | 347 | ``` 348 | 349 | Like the example above, `:load` can be used to set the initial value of the variable. 350 | 351 | ### Scope Variables 352 | 353 | Adding a `:scope` attribute to an element will allow you to access its variables from its children using `scope.` variables. 354 | 355 | ```html 356 | 357 |
358 | 359 |
362 | 368 | 375 |
376 | 377 |
380 | 386 |
390 | Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy 391 | eirmod. 392 |
393 |
394 | 395 |
399 | 405 |
409 | Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy 410 | eirmod. 411 |
412 |
413 |
414 | ``` 415 | 416 | You can set the default value of the scope variables in the `:scope` directive: 417 | 418 | ```html 419 |
420 | 421 |
422 | ``` 423 | 424 | ### Variable Methods 425 | 426 | MiniJS added some commonly-used custom methods to variables. 427 | 428 | ### Array 429 | 430 | Here are the custom array methods which are available for you to use: 431 | 432 | - `first` - returns the first item in the array. 433 | Usage: `array.first` 434 | 435 | ```js 436 | array = ['Cherries', 'Chocolate', 'Blueberry', 'Vanilla'] 437 | array.first // returns 'Cherries' 438 | ``` 439 | 440 | - `last` - returns the last item in the array. 441 | Usage: `array.last` 442 | 443 | ```js 444 | array = ['Cherries', 'Chocolate', 'Blueberry', 'Vanilla'] 445 | array.last // returns 'Vanilla' 446 | ``` 447 | 448 | - `search` - returns a new array of items that match the query. 449 | Usage: `array.search('query')` 450 | 451 | ```js 452 | array = ['Cherries', 'Chocolate', 'Blueberry', 'Vanilla'] 453 | array.search('c') // returns ['Cherries', 'Chocolate'] 454 | ``` 455 | 456 | - `subtract` - removes a list of items from the array if they exist. 457 | Usage: `array.subtract(['item1', 'item2'])` 458 | 459 | ```js 460 | array = ['Tag 1', 'Tag 2', 'Tag 3', 'Tag 4'] 461 | array.subtract(['Tag 2', 'Tag 3']) // returns ['Tag 1', 'Tag 4'] 462 | ``` 463 | 464 | - `nextItem` - gets the next item based on the given item in the array. 465 | Usage: `array.nextItem('item')` 466 | ```js 467 | array = ['Tag 1', 'Tag 2', 'Tag 3', 'Tag 4'] 468 | array.nextItem('Tag 2') // returns 'Tag 3' 469 | ``` 470 | - `previousItem` - gets the next item based on the given item in the array. 471 | Usage: `array.previousItem('item')` 472 | 473 | ```js 474 | array = ['Tag 1', 'Tag 2', 'Tag 3', 'Tag 4'] 475 | array.previousItem('Tag 2') // returns 'Tag 1' 476 | ``` 477 | 478 | - `add` - adds an item to the original array if it doesn't exist. 479 | - This mutates the original array. 480 | Usage: `array.add('item')` 481 | 482 | ```js 483 | array = ['Tag 1', 'Tag 2', 'Tag 3', 'Tag 4'] 484 | array.add('Tag 5') // returns ['Tag 1', 'Tag 2', 'Tag 3', 'Tag 4', 'Tag 5'] 485 | ``` 486 | 487 | - `remove` - removes an item from the original array if it exists. 488 | - This mutates the original array. 489 | Usage: `array.remove('item')` 490 | 491 | ```js 492 | array = ['Tag 1', 'Tag 2', 'Tag 3', 'Tag 4'] 493 | array.remove('Tag 2') // returns ['Tag 1', 'Tag 3', 'Tag 4'] 494 | ``` 495 | 496 | - `toggle` - removes / adds the item in the original array 497 | - This mutates the original array. 498 | Usage: `array.toggle('item')` 499 | 500 | ```js 501 | array = ['Cherries', 'Chocolate', 'Blueberry', 'Vanilla'] 502 | array.toggle('Cherries') // removes 'Cherries' 503 | // returns ['Chocolate', 'Blueberry', 'Vanilla'] 504 | 505 | array.toggle('Cherries') // re-adds 'Cherries' 506 | // returns ['Cherries', 'Chocolate', 'Blueberry', 'Vanilla'] 507 | ``` 508 | 509 | - `replaceAt` - replaces the item at the given index with the new item. 510 | - This mutates the original array. 511 | Usage: `array.replaceAt(index, 'newItem')` 512 | 513 | ```js 514 | array = ['Tag 1', 'Tag 2', 'Tag 3', 'Tag 4'] 515 | array.replaceAt(1, 'Tag 5') // returns ['Tag 1', 'Tag 5', 'Tag 3', 'Tag 4'] 516 | ``` 517 | 518 | ## Contributors 519 | 520 | 521 | 522 | 523 | 524 | 525 | 526 | 527 | 528 | 529 | 530 |

Jen

💻 🚇 📖

tonyennis145

📖 💻

Joeylene

🚇 💻 📖 ⚠️

Jen

📖 💻
531 | 532 | 533 | 534 | 535 | 536 | 537 | 538 | 539 | ## Installation 540 | 541 | To setup MiniJS in your local machine, you can do the following: 542 | 543 | 1. Clone the [repository](https://github.com/Group-One-Technology/minijs). 544 | 2. Run `yarn` to install dependencies. 545 | 3. Run `yarn build` to create the `dist` folder -> output for MiniJS. 546 | 4. Run `yarn dev` to run the demo page locally. 547 | 5. Run `yarn build-watch` on another terminal to build the code whenever the Mini.js code changes. 548 | 6. Run `yarn test` to run the tests. 549 | -------------------------------------------------------------------------------- /demo/custom-event.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Mini Custom Events 7 | 8 | 9 | 10 | 11 | 14 | 15 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /demo/observer.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Mini Demo Observer 7 | 8 | 9 | 10 | 11 | 12 | 13 |
    14 |
  1. 15 | 24 | 33 |
  2. 34 |
  3. 35 | 42 | 51 |
  4. 52 |
  5. 53 | 60 | 71 |
  6. 72 |
  7. 73 | 81 | 92 |
  8. 93 |
  9. …More will be added after 3 seconds…
  10. 94 |
95 | 96 | 127 | 128 | 129 | -------------------------------------------------------------------------------- /lib/__tests__/lexer/lexer.arrays.test.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import dedent from 'dedent-js' 3 | import { Lexer } from '../../generators/lexer' 4 | 5 | describe('Lexer: Arrays', () => { 6 | test('Lexer.identifiers: gets identifiers from arrays', () => { 7 | const lexer = new Lexer('a = [1, 2, b]') 8 | expect(lexer.identifiers).toEqual(['a', 'b']) 9 | 10 | const lexer2 = new Lexer('const b = a[0]') 11 | expect(lexer2.identifiers).toEqual(['a']) 12 | }) 13 | 14 | test('Lexer.output: replaces identifiers in arrays', () => { 15 | const lexer = new Lexer('const a = [1, 2, b]') 16 | lexer.replace({ a: 'proxyWindow-a', b: 'proxyWindow-b' }) 17 | expect(lexer.output()).toBe(dedent` 18 | const a = [ 19 | 1, 20 | 2, 21 | proxyWindow.b 22 | ]; 23 | `) 24 | }) 25 | }) 26 | 27 | describe('Lexer: Support for Array Deconstruction', () => { 28 | test('Lexer.identifiers: gets identifiers from array deconstruction', () => { 29 | const lexer = new Lexer('const [a, b] = [1, 2]') 30 | expect(lexer.identifiers).toEqual([]) 31 | 32 | const lexer2 = new Lexer('[a, b] = [c, d]') 33 | expect(lexer2.identifiers).toEqual(['a', 'b', 'c', 'd']) 34 | 35 | const lexer3 = new Lexer( 36 | 'const [startDate, endDate] = getStartAndEndMonth(totalMonths)' 37 | ) 38 | expect(lexer3.identifiers).toEqual(['getStartAndEndMonth', 'totalMonths']) 39 | }) 40 | 41 | test('Lexer.output: replaces identifiers in array deconstruction', () => { 42 | const lexer = new Lexer('const [a, b] = [1, 2]') 43 | lexer.replace({ a: 'proxyWindow-a', b: 'proxyWindow-b' }) 44 | expect(lexer.output()).toBe('const [a, b] = [1, 2]') 45 | 46 | const lexer2 = new Lexer('[a, b] = [c, d]') 47 | lexer2.replace({ 48 | a: 'proxyWindow-a', 49 | b: 'proxyWindow-b', 50 | c: 'proxyWindow-c', 51 | d: 'proxyWindow-d', 52 | }) 53 | expect(lexer2.output()).toBe(dedent` 54 | [proxyWindow.a, proxyWindow.b] = [ 55 | proxyWindow.c, 56 | proxyWindow.d 57 | ];`) 58 | 59 | const lexer3 = new Lexer( 60 | 'const [startDate, endDate] = getStartAndEndMonth(totalMonths)' 61 | ) 62 | lexer3.replace({ 63 | getStartAndEndMonth: 'proxyWindow-getStartAndEndMonth', 64 | totalMonths: 'proxyWindow-totalMonths', 65 | startDate: 'proxyWindow-startDate', 66 | endDate: 'proxyWindow-endDate', 67 | }) 68 | expect(lexer3.output()).toBe( 69 | 'const [startDate, endDate] = proxyWindow.getStartAndEndMonth(proxyWindow.totalMonths);' 70 | ) 71 | }) 72 | }) 73 | 74 | describe('Lexer: Array Methods', () => { 75 | test('Lexer.identifiers: gets identifiers from array with methods', () => { 76 | const lexer = new Lexer('a.map((a) => a + 1)') 77 | expect(lexer.identifiers).toEqual(['a']) 78 | 79 | const lexer2 = new Lexer('b = a.previousItem(b)') 80 | expect(lexer2.identifiers).toEqual(['b', 'a']) 81 | }) 82 | 83 | test('Lexer.output: array methods', () => { 84 | const lexer = new Lexer('a.map((a) => a + 1)') 85 | lexer.replace({ a: 'proxyWindow-a' }) 86 | expect(lexer.output()).toBe('proxyWindow.a.map(a => a + 1);') 87 | 88 | const lexer2 = new Lexer('b = a.previousItem(b)') 89 | lexer2.replace({ a: 'proxyWindow-a', b: 'proxyWindow-b' }) 90 | expect(lexer2.output()).toBe( 91 | 'proxyWindow.b = proxyWindow.a.previousItem(proxyWindow.b);' 92 | ) 93 | }) 94 | }) 95 | 96 | describe('Lexer: Support for Higher Order Functions', () => { 97 | test('Lexer.identifiers: ignore parameters of higher order functions', () => { 98 | const lexer = new Lexer(dedent` 99 | const region = regions.find((region) => region.name === destination) 100 | if (region) selectedDestination = region.name 101 | else selectedDestination = null`) 102 | expect(lexer.identifiers).toEqual([ 103 | 'regions', 104 | 'destination', 105 | 'selectedDestination', 106 | ]) 107 | }) 108 | 109 | test("Lexer.output: prevent replacement of higher order function's parameters", () => { 110 | const lexer = new Lexer( 111 | 'regions.find((region) => region.name === destination)' 112 | ) 113 | lexer.replace({ 114 | regions: 'proxyWindow.regions', 115 | destination: 'proxyWindow.destination', 116 | region: 'proxyWindow-region', 117 | }) 118 | expect(lexer.output()).toBe( 119 | 'proxyWindow.regions.find(region => region.name === proxyWindow.destination);' 120 | ) 121 | }) 122 | }) 123 | -------------------------------------------------------------------------------- /lib/__tests__/lexer/lexer.assignments.test.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import dedent from 'dedent-js' 3 | import { Lexer } from '../../generators/lexer' 4 | 5 | describe('Lexer: Assignments', () => { 6 | test('Lexer.identifiers: gets identifiers from assignments', () => { 7 | const lexer = new Lexer('showSearch = false') 8 | expect(lexer.identifiers).toEqual(['showSearch']) 9 | }) 10 | 11 | test('Lexer.output: replaces identifiers in assignments', () => { 12 | const lexer = new Lexer('showSearch = false') 13 | lexer.replace({ showSearch: 'proxyWindow-showSearch' }) 14 | expect(lexer.output()).toBe('proxyWindow.showSearch = false;') 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /lib/__tests__/lexer/lexer.block-statements.test.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import dedent from 'dedent-js' 3 | import { Lexer } from '../../generators/lexer' 4 | 5 | describe('Lexer: Block Statements', () => { 6 | test('Lexer.identifiers: ignore declared variables in block statements', () => { 7 | const lexer = new Lexer('{ let a = 2 }') 8 | expect(lexer.identifiers).toEqual([]) 9 | }) 10 | 11 | test('Lexer.output: ignore declared variables in block statements', () => { 12 | const lexer = new Lexer('{ let a = 2 }') 13 | lexer.replace({ a: 'proxyWindow-a' }) 14 | expect(lexer.output()).toBe('{ let a = 2 }') 15 | }) 16 | 17 | test('Lexer.identifiers: gets identifiers from block statements', () => { 18 | const lexer = new Lexer('{ a + b }') 19 | expect(lexer.identifiers).toEqual(['a', 'b']) 20 | }) 21 | 22 | test('Lexer.output: replaces identifiers in block statements', () => { 23 | const lexer = new Lexer('{ a + b }') 24 | lexer.replace({ a: 'proxyWindow-a', b: 'proxyWindow-b' }) 25 | expect(lexer.output()).toBe(dedent` 26 | { 27 | proxyWindow.a + proxyWindow.b; 28 | } 29 | `) 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /lib/__tests__/lexer/lexer.declarations.test.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import dedent from 'dedent-js' 3 | import { Lexer } from '../../generators/lexer' 4 | 5 | describe('Lexer: Variable Declaration', () => { 6 | test('Lexer.identifiers: ignore declared variables', () => { 7 | const lexer = new Lexer('let a = 2; console.log(a, b)') 8 | expect(lexer.identifiers).toEqual(['b']) 9 | }) 10 | 11 | test('Lexer.output: prevent variable replacement for declared variables', () => { 12 | const lexer = new Lexer('let a = 2; console.log(a, b)') 13 | lexer.replace({ a: 'proxyWindow-a', b: 'proxyWindow-b' }) 14 | expect(lexer.output()).toBe(dedent` 15 | let a = 2; 16 | console.log(a, proxyWindow.b); 17 | `) 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /lib/__tests__/lexer/lexer.document-selector.test.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import dedent from 'dedent-js' 3 | import { Lexer } from '../../generators/lexer' 4 | 5 | describe('Lexer: Support for $ as document.querySelector', () => { 6 | test('Lexer.output: replace $ with document.querySelector', () => { 7 | const lexer = new Lexer('$') 8 | lexer.replace({ $: 'document-querySelector' }) 9 | expect(lexer.output()).toBe('document.querySelector;') 10 | }) 11 | 12 | test('Lexer.output: $ function call', () => { 13 | const lexer = new Lexer('$("a")') 14 | lexer.replace({ $: 'document-querySelector' }) 15 | expect(lexer.output()).toBe("document.querySelector('a');") 16 | }) 17 | 18 | test('Lexer.output: $ function call with chain methods', () => { 19 | const lexer = new Lexer(` 20 | $('#months').scrollBy({ top: 0, left: -SCROLL_OFFSET, behavior: 'smooth' }); 21 | scrollPosition = $('#months').scrollLeft <= SCROLL_OFFSET 22 | ? 'left' 23 | : 'middle'; 24 | `) 25 | lexer.replace({ 26 | $: 'document-querySelector', 27 | SCROLL_OFFSET: 'proxyWindow-SCROLL_OFFSET', 28 | }) 29 | expect(lexer.output()).toBe(dedent` 30 | document.querySelector('#months').scrollBy({ 31 | top: 0, 32 | left: -proxyWindow.SCROLL_OFFSET, 33 | behavior: 'smooth' 34 | }); 35 | scrollPosition = document.querySelector('#months').scrollLeft <= proxyWindow.SCROLL_OFFSET ? 'left' : 'middle';`) 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /lib/__tests__/lexer/lexer.error.test.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import dedent from 'dedent-js' 3 | import { Lexer } from '../../generators/lexer' 4 | 5 | describe('Lexer: Errors', () => { 6 | test('Lexer: throws a syntax error', () => { 7 | expect(() => new Lexer('a +')).toThrow(dedent` 8 | Failed to parse code 9 | 10 | a + 11 | 12 | SyntaxError: Unexpected token (1:3)`) 13 | }) 14 | }) 15 | -------------------------------------------------------------------------------- /lib/__tests__/lexer/lexer.expressions.test.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import dedent from 'dedent-js' 3 | import { Lexer } from '../../generators/lexer' 4 | 5 | describe('Lexer: Expressions', () => { 6 | test('Lexer.identifiers: gets identifiers from a simple expression', () => { 7 | const lexer = new Lexer('a + b') 8 | expect(lexer.identifiers).toEqual(['a', 'b']) 9 | }) 10 | 11 | test('Lexer.output: replaces identifiers in a simple expression', () => { 12 | const lexer = new Lexer('a + b') 13 | lexer.replace({ a: 'proxyWindow-a', b: 'proxyWindow-b' }) 14 | expect(lexer.output()).toBe('proxyWindow.a + proxyWindow.b;') 15 | }) 16 | 17 | test('Lexer.identifiers: gets identifiers from a complex expression', () => { 18 | const lexer = new Lexer('a + b * c') 19 | expect(lexer.identifiers).toEqual(['a', 'b', 'c']) 20 | }) 21 | 22 | test('Lexer.output: replaces identifiers in a complex expression', () => { 23 | const lexer = new Lexer('a + b * c') 24 | lexer.replace({ 25 | a: 'proxyWindow-a', 26 | b: 'proxyWindow-b', 27 | c: 'proxyWindow-c', 28 | }) 29 | expect(lexer.output()).toBe( 30 | 'proxyWindow.a + proxyWindow.b * proxyWindow.c;' 31 | ) 32 | }) 33 | 34 | test('Lexer.identifiers: gets identifiers from function calls', () => { 35 | const lexer = new Lexer('getId(a)') 36 | expect(lexer.identifiers).toEqual(['getId', 'a']) 37 | }) 38 | 39 | test('Lexer.output: replaces identifiers in function calls', () => { 40 | const lexer = new Lexer('getId(a)') 41 | lexer.replace({ getId: 'proxyWindow-getId', a: 'proxyWindow-a' }) 42 | expect(lexer.output()).toBe('proxyWindow.getId(proxyWindow.a);') 43 | }) 44 | 45 | test('Lexer.identifiers: gets identifiers from ternaries', () => { 46 | const lexer = new Lexer(`showSearch ? 'hidden' : ''`) 47 | expect(lexer.identifiers).toEqual(['showSearch']) 48 | }) 49 | 50 | test('Lexer.output: replaces identifiers in ternaries', () => { 51 | const lexer = new Lexer(`showSearch ? 'hidden' : ''`) 52 | lexer.replace({ showSearch: 'proxyWindow-showSearch' }) 53 | expect(lexer.output()).toBe("proxyWindow.showSearch ? 'hidden' : '';") 54 | }) 55 | }) 56 | -------------------------------------------------------------------------------- /lib/__tests__/lexer/lexer.functions.test.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import dedent from 'dedent-js' 3 | import { Lexer } from '../../generators/lexer' 4 | 5 | describe('Lexer: Functions', () => { 6 | test('Lexer.identifiers: ignore declared variables in function parameters', () => { 7 | const lexer = new Lexer('function f(a, ...b) { console.log(b) }') 8 | expect(lexer.identifiers).toEqual([]) 9 | }) 10 | 11 | test('Lexer.output: ignore declared variables in function parameters', () => { 12 | const lexer = new Lexer('function f(a, ...b) { console.log(b) }') 13 | lexer.replace({ b: 'proxyWindow-b' }) 14 | expect(lexer.output()).toBe('function f(a, ...b) { console.log(b) }') 15 | }) 16 | 17 | test('Lexer.identifiers: ignore arguments variables in functions', () => { 18 | const lexer = new Lexer('function f(a, ...b) { console.log(arguments) }') 19 | expect(lexer.identifiers).toEqual([]) 20 | }) 21 | 22 | test('Lexer.output: ignore arguments variables in functions', () => { 23 | const lexer = new Lexer('function f(a, ...b) { console.log(arguments) }') 24 | lexer.replace({ arguments: 'proxyWindow-arguments' }) 25 | expect(lexer.output()).toBe( 26 | 'function f(a, ...b) { console.log(arguments) }' 27 | ) 28 | }) 29 | 30 | test('Lexer.identifiers: ignore declared function names', () => { 31 | const lexer = new Lexer('function f() { console.log(arguments) }') 32 | expect(lexer.identifiers).toEqual([]) 33 | }) 34 | 35 | test('Lexer.output: ignore declared function names', () => { 36 | const lexer = new Lexer('function f() { console.log(arguments) }') 37 | lexer.replace({ f: 'proxyWindow-f' }) 38 | expect(lexer.output()).toBe('function f() { console.log(arguments) }') 39 | }) 40 | }) 41 | 42 | describe('Lexer: Function Expressions', () => { 43 | test('Lexer.identifiers: function expressions', () => { 44 | const lexer = new Lexer('const f = function() { console.log(arguments) }') 45 | expect(lexer.identifiers).toEqual([]) 46 | 47 | const lexer2 = new Lexer('f = () => console.log(arguments)') 48 | expect(lexer2.identifiers).toEqual(['f']) 49 | }) 50 | 51 | test('Lexer.output: function expressions', () => { 52 | const lexer = new Lexer('const f = function() { console.log(arguments) }') 53 | lexer.replace({ f: 'proxyWindow-f' }) 54 | expect(lexer.output()).toBe( 55 | 'const f = function() { console.log(arguments) }' 56 | ) 57 | 58 | const lexer2 = new Lexer('const f = () => console.log(arguments)') 59 | lexer2.replace({ f: 'proxyWindow-f' }) 60 | expect(lexer2.output()).toBe('const f = () => console.log(arguments)') 61 | }) 62 | }) 63 | 64 | describe('Lexer: Arrow Functions', () => { 65 | test('Lexer.identifiers: declared arrow functions', () => { 66 | const lexer = new Lexer('const f = () => console.log(arguments)') 67 | expect(lexer.identifiers).toEqual([]) 68 | }) 69 | 70 | test('Lexer.output: declared arrow functions', () => { 71 | const lexer = new Lexer('const f = () => console.log(arguments)') 72 | lexer.replace({ f: 'proxyWindow-f' }) 73 | expect(lexer.output()).toBe('const f = () => console.log(arguments)') 74 | }) 75 | 76 | test('Lexer.identifiers: arrow functions', () => { 77 | const lexer = new Lexer('f = () => console.log(arguments)') 78 | expect(lexer.identifiers).toEqual(['f']) 79 | }) 80 | 81 | test('Lexer.output: arrow functions', () => { 82 | const lexer = new Lexer('f = () => console.log(arguments)') 83 | lexer.replace({ f: 'proxyWindow-f' }) 84 | expect(lexer.output()).toBe('proxyWindow.f = () => console.log(arguments);') 85 | }) 86 | 87 | test('Lexer.output: arrow functions with parameters', () => { 88 | const lexer = new Lexer('const f = (a, b) => console.log(arguments)') 89 | lexer.replace({ 90 | f: 'proxyWindow-f', 91 | arguments: 'proxyWindow-arguments', 92 | a: 'proxyWindow-a', 93 | b: 'proxyWindow-b', 94 | }) 95 | expect(lexer.output()).toBe('const f = (a, b) => console.log(arguments)') 96 | 97 | const lexer2 = new Lexer('f = (a, b) => console.log(arguments)') 98 | lexer2.replace({ 99 | f: 'proxyWindow-f', 100 | arguments: 'proxyWindow-arguments', 101 | a: 'proxyWindow-a', 102 | b: 'proxyWindow-b', 103 | }) 104 | expect(lexer2.output()).toBe( 105 | 'proxyWindow.f = (a, b) => console.log(arguments);' 106 | ) 107 | }) 108 | }) 109 | -------------------------------------------------------------------------------- /lib/__tests__/lexer/lexer.template-literals.test.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import dedent from 'dedent-js' 3 | import { Lexer } from '../../generators/lexer' 4 | 5 | describe('Lexer: Support for Template Literals', () => { 6 | test('Lexer.identifiers: gets identifiers from template literals', () => { 7 | const lexer = new Lexer(` 8 | if (whenSelectedTab === 'Flexible') { 9 | return selectedMonths.length 10 | ? \`\${whenHowLong} in \${selectedMonths.join(', ')}\` 11 | : \`Any \${whenHowLong.toLowerCase()}\` 12 | } 13 | `) 14 | expect(lexer.identifiers).toEqual([ 15 | 'whenSelectedTab', 16 | 'selectedMonths', 17 | 'whenHowLong', 18 | ]) 19 | }) 20 | 21 | test('Lexer.output: replaces identifiers in template literals', () => { 22 | const lexer = new Lexer(` 23 | if (whenSelectedTab === 'Flexible') { 24 | return selectedMonths.length 25 | ? \`\${whenHowLong} in \${selectedMonths.join(', ')}\` 26 | : \`Any \${whenHowLong.toLowerCase()}\` 27 | } 28 | `) 29 | lexer.replace({ 30 | whenSelectedTab: 'proxyWindow-whenSelectedTab', 31 | selectedMonths: 'proxyWindow-selectedMonths', 32 | whenHowLong: 'proxyWindow-whenHowLong', 33 | }) 34 | expect(lexer.output()).toBe(dedent` 35 | if (proxyWindow.whenSelectedTab === 'Flexible') { 36 | return proxyWindow.selectedMonths.length ? \`\${ proxyWindow.whenHowLong } in \${ proxyWindow.selectedMonths.join(', ') }\` : \`Any \${ proxyWindow.whenHowLong.toLowerCase() }\`; 37 | } 38 | `) 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /lib/__tests__/lexer/lexer.variable-shadowing.test.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import dedent from 'dedent-js' 3 | import { Lexer } from '../../generators/lexer' 4 | 5 | describe('Lexer: Support for variable shadowing', () => { 6 | test('Lexer.output: identifies referenced variables that are also declared', () => { 7 | const lexer = new Lexer('a = 1; { let a = 2; }') 8 | expect(lexer.identifiers).toEqual(['a']) 9 | }) 10 | 11 | test('Lexer.output: prevent replacement of variables in higher order functions', () => { 12 | const lexer = new Lexer(dedent` 13 | const region = regions.find((region) => region.name === destination) 14 | if (region) selectedDestination = region.name 15 | else selectedDestination = null`) 16 | lexer.replace({ 17 | regions: 'proxyWindow.regions', 18 | destination: 'proxyWindow.destination', 19 | region: 'proxyWindow-region', 20 | selectedDestination: 'proxyWindow-selectedDestination', 21 | }) 22 | expect(lexer.output()).toBe( 23 | dedent` 24 | const region = proxyWindow.regions.find(region => region.name === proxyWindow.destination); 25 | if (region) 26 | proxyWindow.selectedDestination = region.name; 27 | else 28 | proxyWindow.selectedDestination = null;` 29 | ) 30 | }) 31 | 32 | test('Lexer.output: prevents replacement of shadowed variables, block statements', () => { 33 | const lexer = new Lexer( 34 | 'a = 1; { let a = 2; console.log(a) } console.log(a)' 35 | ) 36 | lexer.replace({ a: 'proxyWindow-a' }) 37 | expect(lexer.output()).toBe( 38 | dedent` 39 | proxyWindow.a = 1; 40 | { 41 | let a = 2; 42 | console.log(a); 43 | } 44 | console.log(proxyWindow.a);` 45 | ) 46 | }) 47 | 48 | test('Lexer.output: prevents replacement of shadowed variables, function and function parameters', () => { 49 | const lexer = new Lexer( 50 | dedent` 51 | a = 1; 52 | function f(...a) { 53 | b = a + 1; 54 | console.log(b) 55 | } 56 | 57 | f(3) 58 | a = 2` 59 | ) 60 | lexer.replace({ 61 | a: 'proxyWindow-a', 62 | b: 'proxyWindow-b', 63 | f: 'proxyWindow-f', 64 | }) 65 | 66 | expect(lexer.output()).toBe( 67 | dedent` 68 | proxyWindow.a = 1; 69 | function f(...a) { 70 | proxyWindow.b = a + 1; 71 | console.log(proxyWindow.b); 72 | } 73 | f(3); 74 | proxyWindow.a = 2;` 75 | ) 76 | }) 77 | }) 78 | -------------------------------------------------------------------------------- /lib/entity.js: -------------------------------------------------------------------------------- 1 | import { State } from '@/state' 2 | import { Mini } from '@/main' 3 | 4 | import { Lexer } from '@/generators/lexer' 5 | 6 | import { Events } from '@/entity/events' 7 | import { Attributes } from '@/entity/attributes' 8 | import { Data } from '@/entity/data' 9 | 10 | export class Entity { 11 | constructor(el, dynamicScripts = []) { 12 | this.base = new Mini() 13 | this.element = el 14 | this.tagName = el.tagName 15 | this.dynamicScripts = dynamicScripts 16 | 17 | this.id = this.generateEntityUUID() 18 | 19 | this.state = {} 20 | this.data = new Data(this) 21 | this.events = new Events(this) 22 | this.attributes = new Attributes(this) 23 | this.base.state.addEntity(this) 24 | 25 | if (this.base.debug) this.element.dataset.entityId = this.id 26 | 27 | this.setAsScope() 28 | } 29 | 30 | setAsScope() { 31 | if (!this.element.hasAttribute(':scope')) return 32 | if (this.isScope()) return 33 | 34 | this.uuid = this.id 35 | this.element.dataset['mini.uuid'] = this.uuid 36 | } 37 | 38 | isScope() { 39 | return !!this.uuid 40 | } 41 | 42 | isExists() { 43 | return document.documentElement.contains(this.element) 44 | } 45 | 46 | isInsideEachEl() { 47 | if (this.element.hasAttribute(':each')) return false 48 | if (this.element.hasAttribute(':each.item')) return true 49 | 50 | const eachEl = this.getClosestEl(':each') 51 | return eachEl != null 52 | } 53 | 54 | getEachEl() { 55 | return this.getClosestEl(':each') 56 | } 57 | 58 | getEachItemEl() { 59 | const eachItemEl = this.element.hasAttribute(':each.item') 60 | ? this.element 61 | : this.getClosestEl(':each.item') 62 | return eachItemEl 63 | } 64 | 65 | isInsideEachItem() { 66 | if (this.element.hasAttribute(':each')) return false 67 | 68 | const eachItemEl = this.getEachItemEl() 69 | return !(eachItemEl == null || eachItemEl?.[':each.item']) 70 | } 71 | 72 | isEachItem() { 73 | return this.element.hasAttribute(':each.item') 74 | } 75 | 76 | isEachItemEvaluated() { 77 | return ( 78 | this.element.getAttribute(':each.item') === 'true' || 79 | this.getClosestEl(':each.item')?.[':each.item'] === 'true' 80 | ) 81 | } 82 | 83 | getClosestEl(attr) { 84 | attr = attr.replaceAll(':', '\\:').replaceAll('.', '\\.') 85 | 86 | return this.element.closest( 87 | `*[${attr}]:not([data\\-mini\\.uuid='${this.uuid}'])` 88 | ) 89 | } 90 | 91 | getVariables() { 92 | this.data.init() 93 | } 94 | 95 | getScope() { 96 | const scopeNode = this.getClosestEl('data-mini.uuid') 97 | 98 | if (scopeNode == null) return { id: 'EntityDocument' } 99 | 100 | const entities = Array.from(this.base.state.entities.values()) 101 | const entity = entities.find( 102 | (e) => e.uuid == scopeNode.dataset['mini.uuid'] 103 | ) 104 | 105 | return entity 106 | } 107 | 108 | generateEntityUUID() { 109 | return 'Entity' + Math.floor(performance.now() * 1000).toString(36) + Math.random().toString(36).substr(2, 9); 110 | } 111 | 112 | async init() { 113 | const isScript = this.element.tagName === 'SCRIPT' 114 | 115 | if (!isScript) await this.base.observer.waitForScripts(this.dynamicScripts) 116 | 117 | this.getVariables() 118 | this.events.apply() 119 | await this.attributes.evaluate() 120 | } 121 | 122 | initChildren() { 123 | const elements = this.element.querySelectorAll('*') 124 | 125 | for (let i = 0; i < elements.length; i++) { 126 | const element = elements[i] 127 | if (element.nodeType !== 1) continue 128 | 129 | try { 130 | const entity = new Entity(element, this.dynamicScripts) 131 | 132 | const eachEl = entity.getEachEl() 133 | const eachItemEl = entity.getEachItemEl() 134 | const isEachItemAndInitialized = 135 | eachEl != null && eachItemEl != null && eachEl.contains(eachItemEl) 136 | 137 | if ( 138 | eachEl == null || 139 | eachEl === entity.element || 140 | isEachItemAndInitialized 141 | ) 142 | entity.init() 143 | else entity.dispose() 144 | } catch (error) { 145 | console.error('Failed to initialize child entity:', error) 146 | } 147 | } 148 | 149 | const eachItemEls = [...this.element.querySelectorAll('*[\\:each\\.item]')] 150 | if (this.isEachItem()) eachItemEls.push(this.element) 151 | 152 | eachItemEls.forEach((el) => { 153 | el.setAttribute(':each.item', true) 154 | 155 | Object.entries(el.dataset).forEach(([key, value]) => { 156 | if (!key.startsWith('mini.each:')) return 157 | delete el.dataset[key] 158 | }) 159 | }) 160 | } 161 | 162 | dispose() { 163 | const elements = [this.element, ...this.element.querySelectorAll('*')] 164 | const entities = Array.from(this.base.state.entities.values()) 165 | const variables = [] 166 | 167 | // Remove event bindings 168 | for (const element of elements) { 169 | if (element.tagName === "BUTTON") console.log("disposing element", element, this) 170 | if (element.nodeType !== 1) continue 171 | 172 | const entity = this.base.state.getEntityByElement(element, entities) 173 | 174 | if (!entity) continue 175 | 176 | variables.push(...entity.data.variables) 177 | entity.events.dispose() 178 | this.base.state.removeEntity(entity) 179 | } 180 | 181 | // Clean up unused variables 182 | const usedVariables = entities 183 | .filter((entity) => !elements.includes(entity.element)) 184 | .reduce((acc, entity) => acc.concat(entity.data.variables), []) 185 | 186 | const unusedVariables = variables.filter( 187 | (variable) => !usedVariables.includes(variable) 188 | ) 189 | 190 | this.base.state.disposeVariables(this.id, unusedVariables) 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /lib/entity/attributes.js: -------------------------------------------------------------------------------- 1 | import { Events } from '@/entity/events' 2 | import { Interpreter, ClassInterpreter } from '@/generators/interpreter' 3 | import { kebabToCamelCase } from '@/helpers/strings' 4 | import { State } from '@/state' 5 | import { Mini } from '@/main' 6 | 7 | export class Attributes { 8 | static CUSTOM_ATTRIBUTES = [ 9 | ':class', 10 | ':text', 11 | ':value', 12 | ':checked', 13 | ':each', 14 | ':each.item', 15 | ':scope', 16 | ] 17 | static FORBIDDEN_ATTRIBUTES = [':innerHTML', ':innerText'] 18 | 19 | static isValidAttribute(attribute, element) { 20 | if (attribute === ':') return false 21 | if (!attribute.startsWith(':')) return false 22 | if (Attributes.FORBIDDEN_ATTRIBUTES.includes(attribute)) return false 23 | if (Events.isValidEvent(attribute)) return false 24 | 25 | return true 26 | } 27 | 28 | static isValidNativeAttribute(attribute, element) { 29 | if (!Attributes.isValidAttribute(attribute)) return false 30 | 31 | const [nativeAttr] = attribute.replace(':', '').split('.') 32 | 33 | if (nativeAttr.startsWith('data-')) return true 34 | if (element[kebabToCamelCase(nativeAttr)] === undefined) return false 35 | 36 | return true 37 | } 38 | 39 | constructor(entity) { 40 | this.base = new Mini() 41 | this.entity = entity 42 | this.dynamicAttributes = [] 43 | this.initialState = { classList: this.entity.element.classList } 44 | 45 | this.evaluateEachItem() 46 | this._getDynamicAttributes() 47 | } 48 | 49 | _getDynamicAttributes() { 50 | const el = this.entity.element 51 | 52 | for (let i = 0; i < el.attributes.length; i++) { 53 | const attr = el.attributes[i] 54 | 55 | if (!Attributes.isValidAttribute(attr.name, el)) continue 56 | if (!this.dynamicAttributes.includes(attr.name)) 57 | this.dynamicAttributes.push(attr.name) 58 | } 59 | } 60 | 61 | _handleError(attr, expr, error) { 62 | if (!this.entity.isExists()) return 63 | console.error( 64 | `Failed to evaluate ${attr} for ${this.entity.id}:\n\nCode:\n${expr}\n\n`, 65 | error 66 | ) 67 | } 68 | 69 | async _interpret(expr, options = {}) { 70 | const Engine = options.isClass ? ClassInterpreter : Interpreter 71 | const engine = new Engine(expr, options) 72 | const ids = { 73 | $: 'document-querySelector', 74 | el: `proxyWindow['${this.entity.id}${State.DISABLE_RE_RENDER_KEY}']`, 75 | scope: this.entity.scope 76 | ? `proxyWindow['${this.entity.scope.id}${ 77 | !options.isScope ? State.DISABLE_RE_RENDER_KEY : '' 78 | }']` 79 | : undefined, 80 | ...(options.ids || {}), 81 | // "this" is set under the interpreter as bind context 82 | } 83 | 84 | engine.replace(ids) 85 | 86 | // window variables are used instead of proxy window 87 | // to avoid triggering re-renders (infinite loop) 88 | return await engine.interpret(this.entity, this.entity.state) 89 | } 90 | 91 | async evaluateVariable(variable) { 92 | const attributes = this.entity.data.getVariableAttributes(variable) 93 | 94 | const promises = [] 95 | 96 | attributes.forEach((attr) => { 97 | promises.push(this.evaluateAttribute(attr)) 98 | }) 99 | 100 | await Promise.all(promises) 101 | } 102 | 103 | async evaluate() { 104 | await this.evaluateAttribute(':scope') 105 | await this.evaluateClass() 106 | await this.evaluateText() 107 | await this.evaluateValue() 108 | await this.evaluateChecked() 109 | 110 | for (const attr of this.dynamicAttributes) { 111 | if (Attributes.CUSTOM_ATTRIBUTES.includes(attr)) continue 112 | await this.evaluateAttribute(attr) 113 | } 114 | 115 | await this.evaluateEach() 116 | } 117 | 118 | async evaluateAttribute(attr) { 119 | if (!Attributes.isValidAttribute(attr, this.entity.element)) return 120 | if (attr === ':scope') await this.evaluateScope() 121 | else if (attr === ':class') await this.evaluateClass() 122 | else if (attr === ':text') await this.evaluateText() 123 | else if (attr === ':value') await this.evaluateValue() 124 | else if (attr === ':checked') await this.evaluateChecked() 125 | else if (attr === ':each') await this.evaluateEach() 126 | else if (attr === ':each.item') await this.evaluateEachItem() 127 | else { 128 | if (!this.dynamicAttributes.includes(attr)) 129 | this.dynamicAttributes.push(attr) 130 | await this.evaluateOtherAttributes() 131 | } 132 | } 133 | 134 | evaluateEachItem() { 135 | if (!this.entity.isInsideEachItem()) return 136 | if (this.entity.isEachItemEvaluated()) return 137 | 138 | const state = {} 139 | 140 | const eachItemEl = this.entity.getEachItemEl() 141 | 142 | Object.entries(eachItemEl.dataset).forEach(([key, value]) => { 143 | if (!key.startsWith('mini.each:')) return 144 | 145 | const name = key.replace('mini.each:', '') 146 | 147 | try { 148 | this.entity.state[name] = JSON.parse(value) 149 | } catch (error) { 150 | console.error( 151 | `Failed to parse dataset.${key} for Entity#${this.entity.id}:`, 152 | error 153 | ) 154 | } 155 | }) 156 | } 157 | 158 | /* 159 | :scope is a special attribute that acts as an :load event 160 | when it has a given expr. Unlike other attributes, state updates 161 | inside :scope will trigger re-renders. 162 | 163 | NOTE: This should NOT be used in this.evaluate() because it will 164 | trigger an infinite loop. 165 | */ 166 | async evaluateScope() { 167 | if (!this.entity.isScope()) return 168 | 169 | const expr = this.entity.element.getAttribute(':scope') 170 | if (!expr) return 171 | 172 | const ids = {} 173 | 174 | // null for dynamically inserted nodes 175 | if (window[this.entity.id] == null) 176 | window[this.entity.id] = this.base.state.create({}, this.entity.id) 177 | 178 | this.entity.data.scopeVariables.forEach((variable) => { 179 | ids[variable] = `proxyWindow['${this.entity.id}'].${variable}` 180 | }) 181 | 182 | try { 183 | await this._interpret(expr, { isScope: true, ids }) 184 | } catch (error) { 185 | this._handleError(':scope', expr, error) 186 | } 187 | } 188 | 189 | async evaluateClass() { 190 | const expr = this.entity.element.getAttribute(':class') 191 | if (!expr) return 192 | 193 | try { 194 | const updatedClassNames = await this._interpret(expr, { 195 | base: this.initialState.classList, 196 | isClass: true, 197 | }) 198 | 199 | this.entity.element.setAttribute('class', updatedClassNames) 200 | } catch (error) { 201 | this._handleError(':class', expr, error) 202 | } 203 | } 204 | 205 | async evaluateText() { 206 | const expr = this.entity.element.getAttribute(':text') 207 | if (!expr) return 208 | 209 | try { 210 | const newText = await this._interpret(expr) 211 | 212 | if (newText || newText == '') this.entity.element.textContent = newText 213 | } catch (error) { 214 | this._handleError(':text', expr, error) 215 | } 216 | } 217 | 218 | async evaluateValue() { 219 | const expr = this.entity.element.getAttribute(':value') 220 | if (!expr) return 221 | 222 | try { 223 | const newValue = await this._interpret(expr) 224 | 225 | if (this.entity.element.value !== newValue && newValue != null) 226 | this.entity.element.value = newValue 227 | } catch (error) { 228 | this._handleError(':value', expr, error) 229 | } 230 | } 231 | 232 | async evaluateChecked() { 233 | const expr = this.entity.element.getAttribute(':checked') 234 | if (!expr) return 235 | 236 | try { 237 | const isChecked = await this._interpret(expr) 238 | 239 | if (this.entity.element.checked !== isChecked && isChecked != null) 240 | this.entity.element.checked = isChecked 241 | } catch (error) { 242 | this._handleError(':checked', expr, error) 243 | } 244 | } 245 | 246 | async evaluateOtherAttributes() { 247 | for (const attr of this.dynamicAttributes) { 248 | if (Attributes.CUSTOM_ATTRIBUTES.includes(attr)) continue 249 | 250 | const element = this.entity.element 251 | const expr = element.getAttribute(attr) 252 | if (!expr) return 253 | 254 | try { 255 | const newValue = await this._interpret(expr) 256 | 257 | if (Attributes.isValidNativeAttribute(attr, element)) { 258 | const nativeAttr = kebabToCamelCase(attr.slice(1)) 259 | 260 | if (attr.startsWith(':data-')) { 261 | if ( 262 | element.dataset[nativeAttr.slice(4)] !== newValue && 263 | newValue != null 264 | ) { 265 | const datasetAttr = 266 | nativeAttr[4].toLowerCase() + nativeAttr.slice(5) 267 | element.dataset[datasetAttr] = newValue 268 | } 269 | } else if (element[nativeAttr] !== newValue && newValue != null) { 270 | element[nativeAttr] = newValue 271 | } 272 | } else { 273 | element.setAttribute(attr.slice(1), newValue) 274 | } 275 | } catch (error) { 276 | this._handleError(attr, expr, error) 277 | } 278 | } 279 | } 280 | 281 | async evaluateEach() { 282 | const eachExpr = this.entity.element.getAttribute(':each') 283 | 284 | if (eachExpr == null) return 285 | 286 | const [args, iterable] = eachExpr.split(' in ') 287 | const [variable, indexName] = args.split(',').map((v) => v.trim()) 288 | 289 | try { 290 | const items = await this._interpret(iterable) 291 | this.childClone ||= this.entity.element.cloneNode(true).children 292 | 293 | this.entity.element.innerHTML = '' 294 | 295 | items.forEach((item, index) => { 296 | Array.from(this.childClone).forEach((child) => { 297 | const clonedChild = child.cloneNode(true) 298 | clonedChild.setAttribute(':each.item', false) 299 | clonedChild.dataset[`mini.each:${variable}`] = JSON.stringify(item) 300 | if (indexName) clonedChild.dataset[`mini.each:${indexName}`] = index 301 | 302 | // ObserveDOM will be called for updated DOM to initialize the entities 303 | this.entity.element.appendChild(clonedChild) 304 | }) 305 | }) 306 | } catch (error) { 307 | this._handleError(':each', iterable, error) 308 | } 309 | } 310 | 311 | disposeAttribute(attr) { 312 | this.entity.data.deleteAttribute(attr) 313 | this.dynamicAttributes = this.dynamicAttributes.filter((a) => a !== attr) 314 | } 315 | } 316 | -------------------------------------------------------------------------------- /lib/entity/data.js: -------------------------------------------------------------------------------- 1 | import { Lexer } from '@/generators/lexer' 2 | import { State } from '@/state' 3 | import { isNativeVariable } from '@/helpers/variables' 4 | 5 | const IGNORED_IDS = ['this', '$'] 6 | 7 | export class Data { 8 | constructor(entity) { 9 | this.entity = entity 10 | this._variables = new Map() // key: variable, value: attributes 11 | this._attributes = new Map() // key: attribute, value: variables 12 | this.scopeVariables = [] 13 | } 14 | 15 | get variables() { 16 | return Array.from(this._variables.keys()) 17 | } 18 | 19 | getAttributeVariables(attr) { 20 | return this._attributes.get(attr) ?? [] 21 | } 22 | 23 | getVariableAttributes(variable) { 24 | return this._variables.get(variable) ?? [] 25 | } 26 | 27 | init() { 28 | this._getAttributesVariables() 29 | this._getEventVariables() 30 | this._removeDuplicateVariables() 31 | this._initVariables() 32 | } 33 | 34 | initAttributeVariables(attr) { 35 | const expr = this.entity.element.getAttribute(attr) 36 | if (!expr) return 37 | 38 | const variables = this.getAttributeVariables(attr) 39 | 40 | if (variables.length) { 41 | variables.forEach((variable) => { 42 | this.remove(variable, attr) 43 | }) 44 | this._attributes.set(attr, []) 45 | } 46 | 47 | if (attr === 'each') { 48 | const [_, variable] = expr.split(' in ') 49 | 50 | if (isNativeVariable(variable)) return 51 | if (IGNORED_IDS.includes(variable)) return 52 | if (variable in this.entity.state) return 53 | this.add(variable, attr) 54 | } 55 | 56 | const lexer = new Lexer(expr) 57 | const isScopeAttr = attr === ':scope' 58 | 59 | lexer.identifiers.forEach((variable) => { 60 | if (IGNORED_IDS.includes(variable)) return 61 | 62 | const object = variable.split('.')[0] 63 | if (object in this.entity.state) return 64 | 65 | if (isScopeAttr) this.scopeVariables.push(variable) 66 | else this.add(variable, attr) 67 | }) 68 | } 69 | 70 | _getAttributesVariables() { 71 | this.entity.attributes.dynamicAttributes.forEach((attr) => { 72 | this.initAttributeVariables(attr) 73 | }) 74 | } 75 | 76 | _getEventVariables() { 77 | this.entity.events.dynamicEvents.forEach((event) => { 78 | const expr = this.entity.element.getAttribute(event) 79 | if (!expr) return 80 | 81 | const lexer = new Lexer(expr) 82 | 83 | lexer.identifiers.forEach((variable) => { 84 | if (IGNORED_IDS.includes(variable)) return 85 | 86 | const object = variable.split('.')[0] 87 | if (object in this.entity.state) return 88 | 89 | this.add(variable, event) 90 | }) 91 | }) 92 | } 93 | 94 | _removeDuplicateVariables() { 95 | this._variables.forEach((attributes, variable) => { 96 | this._variables.set(variable, [...new Set(attributes)]) 97 | }) 98 | 99 | this._attributes.forEach((variables, attribute) => { 100 | this._attributes.set(attribute, [...new Set(variables)]) 101 | }) 102 | } 103 | 104 | _initVariables() { 105 | const entityID = this.entity.id 106 | const state = this.entity.base.state 107 | 108 | this.variables.forEach((variable) => { 109 | if (State.isElState(variable)) { 110 | this.entity.setAsScope() 111 | 112 | if (window[entityID] == null) 113 | window[entityID] = state.create({}, entityID) 114 | 115 | state.addVariable(entityID, entityID) 116 | 117 | if (variable !== State.EL_STATE) { 118 | const [_, varName] = variable.split('.') 119 | state.addEntityVariable(entityID, varName, entityID) 120 | } 121 | } else if (State.isScopeState(variable)) { 122 | if (this.entity.scope == null) 123 | this.entity.scope = this.entity.getScope() 124 | 125 | // Cases where scope is not found: 126 | // - an each item with a :scope directive being removed due to re-evaluation of :each attribute 127 | if (this.entity.scope == null) return 128 | 129 | const scopeID = this.entity.scope?.id 130 | 131 | if (window[scopeID] == null) { 132 | window[scopeID] = state.create({}, scopeID) 133 | } 134 | 135 | state.addVariable(scopeID, entityID) 136 | 137 | if (variable !== State.SCOPE_STATE) { 138 | const [_, varName] = variable.split('.') 139 | state.addEntityVariable(scopeID, varName, entityID) 140 | } 141 | } else if (typeof window[variable] === 'function') { 142 | this.deleteVariable(variable) 143 | } else { 144 | try { 145 | const [identifier] = variable.split('.') 146 | window[identifier] = state.getState(identifier) 147 | state.addVariable(identifier, entityID) 148 | } catch (error) { 149 | console.error('Failed to initialize variable:', variable, error) 150 | } 151 | } 152 | }) 153 | 154 | state.removeDuplicateVariables() 155 | } 156 | 157 | add(variable, attribute) { 158 | const currentAttributes = this._variables.get(variable) ?? [] 159 | this._variables.set(variable, [...currentAttributes, attribute]) 160 | 161 | const currentVariables = this._attributes.get(attribute) ?? [] 162 | this._attributes.set(attribute, [...currentVariables, variable]) 163 | } 164 | 165 | remove(variable, attributes) { 166 | const currentAttributes = this._variables.get(variable) ?? [] 167 | const newAttributes = currentAttributes.filter( 168 | (attr) => !attributes.includes(attr) 169 | ) 170 | 171 | if (newAttributes.length === 0) { 172 | this._variables.delete(variable) 173 | } else { 174 | this._variables.set(variable, newAttributes) 175 | } 176 | 177 | attributes.forEach((attr) => { 178 | const currentVariables = this._attributes.get(attr) ?? [] 179 | const newVariables = currentVariables.filter( 180 | (varName) => varName !== variable 181 | ) 182 | 183 | if (newVariables.length === 0) { 184 | this._attributes.delete(attr) 185 | } else { 186 | this._attributes.set(attr, newVariables) 187 | } 188 | }) 189 | } 190 | 191 | deleteVariable(variable) { 192 | this._variables.delete(variable) 193 | this._attributes.forEach((variables, attr) => { 194 | if (!variables.includes(variable)) return 195 | this._attributes.set( 196 | attr, 197 | variables.filter((varName) => varName !== variable) 198 | ) 199 | }) 200 | } 201 | 202 | deleteAttribute(attr) { 203 | this._attributes.delete(attr) 204 | this._variables.forEach((attributes, variable) => { 205 | if (!attributes.includes(attr)) return 206 | this._variables.set( 207 | variable, 208 | attributes.filter((attrName) => attrName !== attr) 209 | ) 210 | }) 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /lib/entity/events.js: -------------------------------------------------------------------------------- 1 | import { Mini } from '@/main' 2 | import { State } from '@/state' 3 | import { Interpreter } from '@/generators/interpreter' 4 | import { camelToKebabCase } from '@/helpers/strings' 5 | import { EventsExtensions } from '@/extensions/events-extensions' 6 | 7 | const ELEMENTS = [ 8 | 'div', 9 | 'a', 10 | 'input', 11 | 'textarea', 12 | 'select', 13 | 'button', 14 | 'video', 15 | 'audio', 16 | 'img', 17 | 'form', 18 | 'details', 19 | 'iframe', 20 | 'canvas', 21 | ] 22 | 23 | const SYSTEM_KEYS = ['ctrl', 'meta', 'alt', 'shift'] 24 | const DIRECTION_KEYS = ['up', 'down', 'left', 'right'] 25 | 26 | function mapKey(keycode, key) { 27 | if (keycode.startsWith('Key')) return [keycode.slice(3).toLowerCase()] 28 | if (keycode.startsWith('Digit')) return [keycode.slice(5).toLowerCase()] 29 | if (keycode.startsWith('Numpad')) return [keycode.slice(6).toLowerCase()] 30 | if (keycode.startsWith('Arrow')) return [keycode.slice(5).toLowerCase()] 31 | if (keycode.startsWith('Meta')) { 32 | const direction = keycode.slice(4).toLowerCase() 33 | if (direction.length) return ['meta', `meta-${direction}`] 34 | return ['meta'] 35 | } 36 | if (keycode.startsWith('Alt')) { 37 | const direction = keycode.slice(3).toLowerCase() 38 | if (direction.length) return ['alt', `alt-${direction}`] 39 | return ['alt'] 40 | } 41 | if (keycode.startsWith('Control')) { 42 | const direction = keycode.slice(7).toLowerCase() 43 | if (direction.length) return ['ctrl', `ctrl-${direction}`] 44 | return ['ctrl'] 45 | } 46 | if (keycode.startsWith('Shift')) { 47 | const direction = keycode.slice(5).toLowerCase() 48 | if (direction.length) return ['shift', `shift-${direction}`] 49 | return ['shift'] 50 | } 51 | return [camelToKebabCase(keycode).toLowerCase()] 52 | } 53 | 54 | export class Events { 55 | static CUSTOM_KEY_EVENTS = [':keyup', ':keydown', ':keypress'] 56 | static CUSTOM_EVENTS = [ 57 | ':change', 58 | ':clickout', 59 | ':clickme', 60 | ':press', 61 | ':load', 62 | ...Events.CUSTOM_KEY_EVENTS, 63 | ] 64 | 65 | static initValidEvents() { 66 | const events = new Set() 67 | 68 | ELEMENTS.forEach((tag) => { 69 | const el = document.createElement(tag) 70 | for (const name in el) { 71 | if (name.startsWith('on')) events.add(`:${name.substring(2)}`) 72 | } 73 | }) 74 | 75 | Events.validEvents = [...events, ...Events.CUSTOM_EVENTS] 76 | } 77 | 78 | static applyEvents() { 79 | const mini = new Mini() 80 | const entities = Array.from(mini.state.entities.values()) 81 | entities.forEach((entity) => { 82 | entity.events.apply() 83 | }) 84 | } 85 | 86 | static isValidEvent(event) { 87 | if (!event.startsWith(':')) return false 88 | if (event === ':keyevents') return false 89 | if (event in EventsExtensions.USER_CUSTOM_EVENTS) return true 90 | 91 | return ( 92 | Events.validEvents.includes(event) || 93 | Events.CUSTOM_KEY_EVENTS.some((key) => event.startsWith(key + '.')) 94 | ) 95 | } 96 | static isKeyEvent(attr) { 97 | return Events.CUSTOM_KEY_EVENTS.some( 98 | (key) => attr.startsWith(key + '.') || attr === key 99 | ) 100 | } 101 | 102 | constructor(entity) { 103 | this.entity = entity 104 | this.listener = {} 105 | this.keysPressed = {} 106 | this.dynamicEvents = [] 107 | 108 | this._getDynamicEvents() 109 | } 110 | 111 | _getDynamicEvents() { 112 | const attributeNames = Array.from(this.entity.element.attributes).map( 113 | (attr) => attr.name 114 | ) 115 | 116 | this.dynamicEvents = attributeNames.filter((value) => 117 | Events.isValidEvent(value) 118 | ) 119 | } 120 | 121 | apply() { 122 | this.dispose() 123 | 124 | const keyEvents = [] 125 | 126 | Array.from(this.entity.element.attributes).forEach((attr) => { 127 | if (!Events.isValidEvent(attr.name)) return 128 | 129 | const isKeyEvent = Events.CUSTOM_KEY_EVENTS.some( 130 | (key) => attr.name.startsWith(key + '.') || attr.name === key 131 | ) 132 | 133 | if (Events.isKeyEvent(attr.name)) { 134 | keyEvents.push(attr.name) 135 | } else this.setEvent(attr.name) 136 | }) 137 | 138 | this.setKeyEvents(keyEvents) 139 | 140 | // Add event listeners 141 | Object.keys(this.listener).forEach((attr) => { 142 | this.applyEvent(attr, false) 143 | }) 144 | } 145 | 146 | applyEvent(attr, attachListener = true) { 147 | if (attachListener) this.setEvent(attr) 148 | 149 | const listener = this.listener[attr] 150 | if (!listener) return 151 | 152 | if (Array.isArray(listener)) { 153 | listener.forEach(({ el, eventName, event }) => { 154 | el.addEventListener(eventName, event) 155 | }) 156 | } else { 157 | const { el, eventName, event } = listener 158 | el.addEventListener(eventName, event) 159 | } 160 | } 161 | 162 | setChangeEvent() { 163 | const el = this.entity.element 164 | const key = ':change' 165 | 166 | if (this.listener[key]) this.disposeEvent(key, false) 167 | 168 | const expr = el.getAttribute(key) 169 | if (!expr) return 170 | 171 | this.listener[key] = { 172 | el, 173 | eventName: 174 | el.type == 'checkbox' || el.tagName == 'select' ? 'change' : 'input', 175 | event: () => { 176 | this.evaluate(key) 177 | }, 178 | } 179 | } 180 | 181 | setClickoutEvent() { 182 | const el = this.entity.element 183 | const key = ':clickout' 184 | 185 | if (this.listener[key]) this.disposeEvent(key, false) 186 | 187 | const expr = el.getAttribute(key) 188 | if (!expr) return 189 | 190 | this.listener[key] = { 191 | el: document, 192 | eventName: 'click', 193 | event: (e) => { 194 | if (!document.documentElement.contains(e.target)) return 195 | if (el.contains(e.target)) return 196 | this.evaluate(key) 197 | }, 198 | } 199 | } 200 | 201 | setClickMeEvent() { 202 | const el = this.entity.element 203 | const key = ':clickme' 204 | 205 | if (this.listener[key]) this.disposeEvent(key, false) 206 | 207 | const expr = el.getAttribute(key) 208 | if (!expr) return 209 | 210 | this.listener[key] = { 211 | el, 212 | eventName: 'click', 213 | event: (e) => { 214 | if (e.target !== el) return 215 | this.evaluate(key) 216 | }, 217 | } 218 | } 219 | 220 | setPressEvent() { 221 | const el = this.entity.element 222 | const key = ':press' 223 | 224 | if (this.listener[key]) this.disposeEvent(key, false) 225 | 226 | const expr = el.getAttribute(key) 227 | if (!expr) return 228 | 229 | this.listener[key] = [] 230 | this.listener[key].push({ 231 | el, 232 | eventName: 'keyup', 233 | event: (e) => { 234 | if (e.target !== el) return 235 | if (!['Enter', 'Space'].includes(e.code)) return 236 | if (e.code == 'Space') e.preventDefault() 237 | this.evaluate(key) 238 | }, 239 | }) 240 | 241 | this.listener[key].push({ 242 | el, 243 | eventName: 'click', 244 | event: (e) => { 245 | this.evaluate(key) 246 | }, 247 | }) 248 | 249 | this.listener[key].push({ 250 | el, 251 | eventName: 'touchstart', 252 | event: (e) => { 253 | this.evaluate(key) 254 | }, 255 | }) 256 | } 257 | 258 | setKeyEvents(attrs) { 259 | if (!attrs.length) return 260 | 261 | const el = this.entity.element 262 | 263 | const keyEvents = attrs 264 | .map((attribute) => { 265 | const [event, ...keycodes] = attribute.split('.') 266 | const nativeEventName = event.substring(1) 267 | 268 | const [systemKeys, normalKeys] = keycodes.reduce( 269 | ([system, normal], key) => { 270 | const lowerKey = key.toLowerCase() 271 | 272 | const [systemKey, directionKey] = lowerKey.split('-') 273 | 274 | if ( 275 | SYSTEM_KEYS.includes(systemKey) && 276 | (directionKey == null || DIRECTION_KEYS.includes(directionKey)) 277 | ) { 278 | system.push(lowerKey) 279 | } else { 280 | normal.push(lowerKey) 281 | } 282 | return [system, normal] 283 | }, 284 | [[], []] 285 | ) 286 | 287 | return { 288 | attribute, 289 | event, 290 | nativeEventName, 291 | keycodes: { systemKeys, normalKeys }, 292 | } 293 | }) 294 | .filter(({ attribute, event }) => { 295 | if (Events.isKeyEvent(event)) return true 296 | 297 | const expr = el.getAttribute(attribute) 298 | return expr != null 299 | }) 300 | 301 | if (!keyEvents.length) return 302 | 303 | const listenerKey = ':keyevents' 304 | if (this.listener[listenerKey]) this.disposeEvent(listenerKey, false) 305 | this.listener[listenerKey] = [] 306 | 307 | const ctx = this 308 | 309 | const areKeysPressed = (e, keycodes) => { 310 | return ( 311 | keycodes.normalKeys.every((key) => { 312 | const [_, directionKey] = key.split('-') 313 | 314 | if (directionKey) return ctx.keysPressed[key] 315 | 316 | return ctx.keysPressed[key] 317 | }) && 318 | keycodes.systemKeys.every((key) => { 319 | const [_, directionKey] = key.split('-') 320 | 321 | if (directionKey) return ctx.keysPressed[key] 322 | 323 | return e[`${key}Key`] || ctx.keysPressed[key] 324 | }) 325 | ) 326 | } 327 | 328 | const handleKeyPress = (e) => { 329 | if (e.target !== el) return 330 | 331 | if (e.type === 'keyup') { 332 | const keyUpEvents = keyEvents.filter( 333 | (event) => event.nativeEventName === 'keyup' 334 | ) 335 | 336 | keyUpEvents.forEach((keyEvent) => { 337 | if (areKeysPressed(e, keyEvent.keycodes)) 338 | this.evaluate(keyEvent.attribute) 339 | }) 340 | } 341 | 342 | const pressedKeys = mapKey(e.code, e.key) 343 | const isPressed = e.type === 'keydown' 344 | 345 | pressedKeys.forEach((key) => { 346 | ctx.keysPressed[key] = isPressed 347 | }) 348 | 349 | if (e.type === 'keydown') { 350 | const keyDownEvents = keyEvents.filter( 351 | (event) => event.nativeEventName === 'keydown' 352 | ) 353 | 354 | keyDownEvents.forEach((keyEvent) => { 355 | if (areKeysPressed(e, keyEvent.keycodes)) 356 | this.evaluate(keyEvent.attribute) 357 | }) 358 | } 359 | } 360 | 361 | this.listener[listenerKey].push({ 362 | el, 363 | eventName: 'keydown', 364 | event: handleKeyPress, 365 | }) 366 | 367 | this.listener[listenerKey].push({ 368 | el, 369 | eventName: 'keyup', 370 | event: handleKeyPress, 371 | }) 372 | 373 | const keyPressEvents = keyEvents.filter( 374 | (event) => event.nativeEventName === 'keypress' 375 | ) 376 | 377 | if (keyPressEvents.length) { 378 | this.listener[listenerKey].push({ 379 | el, 380 | eventName: 'keypress', 381 | event: (e) => { 382 | if (e.target !== el) return 383 | 384 | keyPressEvents.forEach((keyEvent) => { 385 | if (areKeysPressed(e, keyEvent.keycodes)) 386 | this.evaluate(keyEvent.attribute) 387 | }) 388 | }, 389 | }) 390 | } 391 | } 392 | 393 | setEvent(attr) { 394 | if (attr === ':press') return this.setPressEvent() 395 | else if (attr === ':change') return this.setChangeEvent() 396 | else if (attr === ':clickout') return this.setClickoutEvent() 397 | else if (attr === ':clickme') return this.setClickMeEvent() 398 | else if (attr === ':load') return this.evaluate(':load') 399 | 400 | const el = this.entity.element 401 | 402 | if (this.listener[attr]) this.disposeEvent(attr, false) 403 | 404 | const expr = el.getAttribute(attr) 405 | if (!expr) return 406 | 407 | const nativeEventName = 408 | attr in EventsExtensions.USER_CUSTOM_EVENTS 409 | ? EventsExtensions.USER_CUSTOM_EVENTS[attr] 410 | : attr.substring(1) 411 | 412 | this.listener[attr] = { 413 | el, 414 | eventName: nativeEventName, 415 | event: () => { 416 | this.evaluate(attr) 417 | }, 418 | } 419 | } 420 | 421 | async evaluate(attr) { 422 | const expr = this.entity.element.getAttribute(attr) 423 | if (!expr) return 424 | 425 | try { 426 | this._attachVariableHelpers(attr) 427 | 428 | await this._interpret(expr) 429 | 430 | this._attachVariableHelpers(attr) 431 | } catch (error) { 432 | if (!this.entity.isExists()) return 433 | console.error( 434 | `Failed to evaluate ${attr} for ${this.entity.id}:\n\nCode:\n${expr}\n\n`, 435 | error 436 | ) 437 | } 438 | } 439 | 440 | _attachVariableHelpers(attr) { 441 | const variables = [] 442 | const elVariables = [] 443 | const scopeVariables = [] 444 | 445 | this.entity.data.getAttributeVariables(attr).forEach((variable) => { 446 | const [_, object] = variable.split('.') 447 | 448 | if (State.isElState(variable)) elVariables.push(object) 449 | else if (State.isScopeState(variable)) scopeVariables.push(object) 450 | else variables.push(variable) 451 | }) 452 | 453 | const state = this.entity.base.state 454 | 455 | state.attachVariableHelpers(variables) 456 | state.attachVariableHelpers(elVariables, this.entity.id) 457 | 458 | if (this.entity.scope) 459 | state.attachVariableHelpers(scopeVariables, this.entity.scope.id) 460 | } 461 | 462 | async _interpret(expr) { 463 | const engine = new Interpreter(expr) 464 | const ids = { 465 | $: 'document-querySelector', 466 | el: `proxyWindow['${this.entity.id}']`, 467 | // "this" is set under the interpreter as bind context 468 | } 469 | 470 | if (this.entity.scope) ids.scope = `proxyWindow['${this.entity.scope.id}']` 471 | 472 | this.entity.data.variables.forEach((variable) => { 473 | if (State.isElState(variable) || State.isScopeState(variable)) return 474 | 475 | ids[variable] = `proxyWindow-${variable}` 476 | }) 477 | 478 | engine.replace(ids) 479 | 480 | await engine.interpret(this.entity, this.entity.state) 481 | 482 | const state = this.entity.base.state 483 | engine._arrayVariables.forEach((variable) => { 484 | if (!this.entity.data.variables.includes(variable)) return 485 | 486 | if (State.isElState(variable) || State.isScopeState(variable)) { 487 | const [type, object] = variable.split('.') 488 | 489 | if (!object) return 490 | 491 | if (type === State.EL_STATE) { 492 | const varName = `${this.entity.id}.${object}` 493 | state.evaluateDependencies(varName) 494 | } else if (type === State.SCOPE_STATE) { 495 | if (!this.entity.scope) return 496 | const varName = `${this.entity.scope.id}.${object}` 497 | state.evaluateDependencies(varName) 498 | } 499 | } else 500 | state.evaluateDependencies(variable) 501 | }) 502 | } 503 | 504 | disposeEvent(attr, disableTracking = true) { 505 | const listener = this.listener[attr] 506 | 507 | if (Array.isArray(listener)) { 508 | listener.forEach(({ el, eventName, event }) => { 509 | el.removeEventListener(eventName, event) 510 | }) 511 | } else { 512 | const { el, eventName, event } = listener 513 | el.removeEventListener(eventName, event) 514 | } 515 | 516 | if (this.listener[attr]) delete this.listener[attr] 517 | if (attr === ':keyevents') this.keysPressed = {} 518 | 519 | if (disableTracking) 520 | this.dynamicEvents = this.dynamicEvents.filter((event) => event !== attr) 521 | } 522 | 523 | dispose() { 524 | Object.keys(this.listener).forEach((attr) => { 525 | this.disposeEvent(attr, true) 526 | }) 527 | } 528 | } 529 | -------------------------------------------------------------------------------- /lib/extensions/events-extensions.js: -------------------------------------------------------------------------------- 1 | export class EventsExtensions { 2 | static instance 3 | static USER_CUSTOM_EVENTS = {} 4 | 5 | constructor() { 6 | if (EventsExtensions.instance) return EventsExtensions.instance 7 | 8 | EventsExtensions.instance = this 9 | } 10 | 11 | extend(events) { 12 | EventsExtensions.USER_CUSTOM_EVENTS = { 13 | ...EventsExtensions.USER_CUSTOM_EVENTS, 14 | ...events, 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /lib/generators/interpreter.js: -------------------------------------------------------------------------------- 1 | import { Mini } from '@/main.js' 2 | import { Lexer } from '@/generators/lexer.js' 3 | 4 | const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor 5 | 6 | const DEFAULT_SCOPE = { 7 | eval: {}, 8 | XMLHttpRequest: {}, 9 | Function: {}, 10 | } 11 | 12 | function safeAsyncFunction(context, scope, code, isExpression) { 13 | try { 14 | const expression = !isExpression ? `(async()=>{${code}})()` : code 15 | 16 | let func = new AsyncFunction( 17 | ['__scope'], 18 | `let __result; with (__scope) { __result = ${expression} }; return __result;` 19 | ).bind(context) 20 | 21 | Object.defineProperty(func, 'name', { 22 | value: `[MiniJS] ${code}`, 23 | }) 24 | 25 | const result = func(scope) 26 | return result 27 | } catch (error) { 28 | console.log(`Failed to run code for Entity#${context.id}:\n\n${code}`) 29 | console.error(error) 30 | return Promise.resolve() 31 | } 32 | } 33 | 34 | export class Interpreter extends Lexer { 35 | constructor(code) { 36 | super(code) 37 | } 38 | 39 | async interpret(context, _scope = {}) { 40 | const code = super.output() 41 | const mini = new Mini() 42 | 43 | const scope = { 44 | ...DEFAULT_SCOPE, 45 | proxyWindow: mini.state.window, 46 | ..._scope, 47 | } 48 | 49 | return await safeAsyncFunction( 50 | context.element, 51 | scope, 52 | code, 53 | this.isExpression 54 | ) 55 | } 56 | } 57 | 58 | export class ClassInterpreter extends Lexer { 59 | constructor(code, options) { 60 | super(code) 61 | this._baseClasses = options.base ?? [] 62 | } 63 | 64 | async interpret(context, _scope = {}) { 65 | const classNames = super.conditional() 66 | const mini = new Mini() 67 | 68 | const scope = { 69 | ...DEFAULT_SCOPE, 70 | proxyWindow: mini.state.window, 71 | ..._scope, 72 | } 73 | 74 | let newClassNames = [...this._baseClasses] 75 | 76 | if (typeof classNames === 'string') { 77 | const result = await safeAsyncFunction( 78 | context.element, 79 | scope, 80 | classNames, 81 | this.isExpression 82 | ) 83 | newClassNames = newClassNames.concat((result ?? '').split(' ')) 84 | } else if (Array.isArray(classNames)) { 85 | for (const conditional of classNames) { 86 | const condition = await safeAsyncFunction( 87 | context.element, 88 | scope, 89 | conditional.test, 90 | true 91 | ) 92 | const consequent = await safeAsyncFunction( 93 | context.element, 94 | scope, 95 | conditional.consequent, 96 | conditional.isExpression 97 | ) 98 | const alternate = await safeAsyncFunction( 99 | context.element, 100 | scope, 101 | conditional.alternate, 102 | conditional.isExpression 103 | ) 104 | 105 | const consequentClasses = consequent.split(' ') 106 | const alternateClasses = alternate.split(' ') 107 | 108 | if (condition) { 109 | newClassNames = newClassNames.concat(consequentClasses) 110 | newClassNames = newClassNames.filter( 111 | (value) => !alternateClasses.includes(value) 112 | ) 113 | } else { 114 | newClassNames = newClassNames.concat(alternateClasses) 115 | newClassNames = newClassNames.filter( 116 | (value) => !consequentClasses.includes(value) 117 | ) 118 | } 119 | } 120 | } 121 | 122 | return [...new Set(newClassNames)].join(' ') 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /lib/generators/lexer.js: -------------------------------------------------------------------------------- 1 | import { Parser } from 'acorn' 2 | import * as walk from 'acorn-walk' 3 | import escodegen from 'escodegen' 4 | import { isNativeVariable } from '@/helpers/variables' 5 | import { MiniArray } from '@/helpers/array' 6 | 7 | const FUNCTION_NODE_TYPES = [ 8 | 'FunctionDeclaration', 9 | 'FunctionExpression', 10 | 'ArrowFunctionExpression', 11 | ] 12 | 13 | function getMemberIdentifier(node) { 14 | if (node.type === 'MemberExpression') { 15 | const object = getMemberIdentifier(node.object) 16 | const property = getMemberIdentifier(node.property) 17 | 18 | if (object !== null && property !== null) return object + '.' + property 19 | else if (object !== null) return object 20 | else if (property !== null) return property 21 | else return '' 22 | } 23 | if (node.type === 'ArrayExpression') return '[]' 24 | if (node.type === 'ThisExpression') return 'this' 25 | if (node.type === 'Literal') return node.raw 26 | if (node.type === 'CallExpression') return '' 27 | if (node.type === 'NewExpression') return '' 28 | return node.name 29 | } 30 | 31 | function getDeclaredVariables(node, isParent = true) { 32 | if (node.type === 'VariableDeclaration') { 33 | const ids = node.declarations.reduce((acc, decl) => { 34 | if (decl.type === 'VariableDeclarator') { 35 | if (decl.id.type === 'Identifier') acc.push(decl.id.name) 36 | else if (decl.id.type === 'ArrayPattern') { 37 | decl.id.elements.forEach((element) => { 38 | if (element.type === 'Identifier') acc.push(element.name) 39 | }) 40 | } else if (decl.id.type === 'ObjectPattern') { 41 | decl.id.properties.forEach((prop) => { 42 | if (prop.value.type === 'Identifier') acc.push(prop.value.name) 43 | }) 44 | } 45 | } else if (decl.id.type === 'Identifier') acc.push(decl.id.name) 46 | else if (decl.id.type === 'ArrayPattern') { 47 | decl.id.elements.forEach((element) => { 48 | if (element.type === 'Identifier') acc.push(element.name) 49 | }) 50 | } else if (decl.id.type === 'ObjectPattern') { 51 | decl.id.properties.forEach((prop) => { 52 | if (prop.value.type === 'Identifier') acc.push(prop.value.name) 53 | }) 54 | } 55 | 56 | return acc 57 | }, []) 58 | 59 | return ids 60 | } else if (FUNCTION_NODE_TYPES.includes(node.type)) { 61 | const ids = ['arguments'] 62 | 63 | if (isParent) { 64 | const params = node.params.reduce((acc, param) => { 65 | if (param.type === 'Identifier') acc.push(param.name) 66 | if ( 67 | param.type === 'RestElement' && 68 | param.argument.type === 'Identifier' 69 | ) 70 | acc.push(param.argument.name) 71 | return acc 72 | }, []) 73 | 74 | ids.push(...params) 75 | } 76 | 77 | if (node.id?.type === 'Identifier') ids.push(node.id.name) 78 | 79 | return ids 80 | } else if (node.type === 'ObjectPattern') { 81 | const ids = node.properties.reduce((acc, prop) => { 82 | if (prop.value.type === 'Identifier') acc.push(prop.value.name) 83 | return acc 84 | }, []) 85 | 86 | return ids 87 | } 88 | 89 | return [] 90 | } 91 | 92 | function getVariables(node) { 93 | const ids = [] 94 | 95 | if (node.type === 'Identifier') ids.push(node.name) 96 | else if (node.type === 'MemberExpression') { 97 | const [object] = getMemberIdentifier(node.object).split('.') 98 | if (object.length) ids.push(object) 99 | } else if (node.type === 'AssignmentExpression') { 100 | ids.push(...getVariables(node.left)) 101 | ids.push(...getVariables(node.right)) 102 | } else if (node.type === 'ArrayPattern') { 103 | node.elements.forEach((element) => { 104 | if (element.type === 'Identifier') ids.push(element.name) 105 | }) 106 | } else if (node.type === 'ObjectPattern') { 107 | node.properties.forEach((prop) => { 108 | if (prop.value.type === 'Identifier') ids.push(prop.value.name) 109 | }) 110 | } 111 | 112 | return ids 113 | } 114 | 115 | export class Lexer { 116 | static debug = false 117 | static IGNORED_KEYS = ['event', 'window', 'document', 'console', 'Math'] 118 | static ENTITY_KEYS = ['el', 'scope'] 119 | 120 | constructor(code) { 121 | this._code = code 122 | this._declaredIdentifiers = null 123 | this._identifiers = null 124 | this._replacedIdentifiers = {} 125 | this._arrayVariables = [] 126 | 127 | try { 128 | this._ast = Parser.parse(code, { 129 | ecmaVersion: 'latest', 130 | sourceType: 'module', 131 | // Will interpret code inside an async function so return is allowed 132 | allowReturnOutsideFunction: true, 133 | }) 134 | 135 | this.isExpression = 136 | this._ast.body[0]?.type === 'ExpressionStatement' && 137 | this._ast.body.length === 1 138 | } catch (error) { 139 | throw new Error('Failed to parse code\n\n' + code + '\n\n' + error.stack) 140 | } 141 | } 142 | 143 | deepClone(obj) { 144 | return JSON.parse(JSON.stringify(obj)) 145 | } 146 | 147 | /** 148 | * Checks if a variable is declared in the current scope 149 | * @param {string} id - The variable name 150 | * @param {Array} state - The current state of the program (node object) 151 | * @returns {boolean} - Whether the variable is declared or not 152 | */ 153 | isDeclared(id, state) { 154 | const ancestors = this.deepClone(state) 155 | let currentNode = null 156 | let previousNode = null 157 | let isParent = true 158 | 159 | while (ancestors.length) { 160 | previousNode = currentNode 161 | currentNode = ancestors.pop() 162 | isParent = true 163 | 164 | // Move to the previous line of the program 165 | if (['Program', 'BlockStatement'].includes(currentNode.type)) { 166 | const { start, end, type } = previousNode 167 | const previousNodeIndex = currentNode.body.findIndex( 168 | (node) => 169 | node.start === start && node.end === end && node.type === type 170 | ) 171 | 172 | currentNode.body = currentNode.body.slice(0, previousNodeIndex) 173 | ancestors.push(currentNode) 174 | 175 | const nextNodeIndex = 176 | previousNodeIndex === -1 177 | ? currentNode.body.length - 1 178 | : previousNodeIndex - 1 179 | currentNode = currentNode.body[nextNodeIndex] 180 | isParent = false 181 | } 182 | 183 | if (currentNode == null) break 184 | 185 | if (isParent) { 186 | const parentFunctionNode = ancestors.find((node) => 187 | FUNCTION_NODE_TYPES.includes(node.type) 188 | ) 189 | 190 | if (parentFunctionNode) { 191 | const declarations = getDeclaredVariables(parentFunctionNode, true) 192 | if (declarations.includes(id)) return true 193 | } 194 | } 195 | 196 | const declarations = getDeclaredVariables(currentNode, isParent) 197 | if (declarations.includes(id)) return true 198 | } 199 | 200 | return false 201 | } 202 | 203 | /** 204 | * Get all declared identifiers in the program 205 | * @returns {Array} - The declared identifiers 206 | * Note: declared identifiers may only indicate a variable declaration, 207 | * variable shadowing may be used. Use isDeclared to check. 208 | */ 209 | get declaredIdentifiers() { 210 | if (this._declaredIdentifiers) return this._declaredIdentifiers 211 | this.identifiers 212 | return this._declaredIdentifiers 213 | } 214 | 215 | /** 216 | * Get all identifiers in the program 217 | * @returns {Array} - The identifiers 218 | */ 219 | get identifiers() { 220 | if (this._identifiers) return this._identifiers 221 | this._identifiers = [] 222 | this._declaredIdentifiers = [] 223 | this._arrayVariables = [] 224 | 225 | walk.fullAncestor(this._ast, (node, state, parent) => { 226 | if (node?.type === 'MemberExpression') { 227 | const id = getMemberIdentifier(node) 228 | const [object, ...properties] = id.split('.') 229 | 230 | const hasMutateArrayMethods = MiniArray.mutateMethods.some((method) => 231 | properties.includes(method) 232 | ) 233 | 234 | const isEntityVariable = Lexer.ENTITY_KEYS.some((key) => object.startsWith(key)) 235 | 236 | if (hasMutateArrayMethods) 237 | { 238 | if (isEntityVariable) 239 | this._arrayVariables.push([object, properties[0]].join('.')) 240 | else 241 | this._arrayVariables.push(object) 242 | } 243 | 244 | if (object.length && isEntityVariable) { 245 | this._identifiers.push(object) 246 | this._identifiers.push([object, properties[0]].join('.')) 247 | } 248 | } else if (node?.type === 'Identifier') { 249 | if (Lexer.IGNORED_KEYS.includes(node.name)) return 250 | if (this._declaredIdentifiers.includes(node.name)) return 251 | if (this._identifiers.includes(node.name)) return 252 | 253 | const ancestors = state.slice(0, -1) 254 | if (this.isDeclared(node.name, ancestors)) 255 | this._declaredIdentifiers.push(node.name) 256 | else this._identifiers.push(node.name) 257 | } else if (node?.type === 'ThisExpression') { 258 | this._identifiers.push('this') 259 | } else { 260 | const declarations = getDeclaredVariables(node, true).filter( 261 | (id) => 262 | !Lexer.IGNORED_KEYS.includes(id) && 263 | id !== 'this' && 264 | !this._declaredIdentifiers.includes(id) 265 | ) 266 | 267 | const ancestors = state.slice(0, -1) 268 | const variables = getVariables(node).filter( 269 | (id) => 270 | !declarations.includes(id) && 271 | !Lexer.IGNORED_KEYS.includes(id) && 272 | id !== 'this' && 273 | !this.isDeclared(id, ancestors) 274 | ) 275 | 276 | if (declarations.length) 277 | this._declaredIdentifiers = 278 | this._declaredIdentifiers.concat(declarations) 279 | 280 | if (variables.length) 281 | this._identifiers = this._identifiers.concat(variables) 282 | } 283 | }) 284 | 285 | this._identifiers = [...new Set(this._identifiers)].filter( 286 | (id) => !isNativeVariable(id) 287 | ) 288 | this._declaredIdentifiers = [...new Set(this._declaredIdentifiers)] 289 | 290 | return this._identifiers 291 | } 292 | 293 | /** 294 | * Used to replace a given identifiers in the program 295 | * @param {Object} ids - The identifiers to replace. { identifier: replacement } 296 | */ 297 | replace(ids) { 298 | this._replacedIdentifiers = ids 299 | 300 | Object.entries(ids).forEach(([key, value]) => { 301 | if (key.split('.').length > 1) 302 | throw new Error( 303 | `Cannot replace member expression identifier: ${key} with ${value}` 304 | ) 305 | }) 306 | } 307 | 308 | /** 309 | * Replace the identifiers in the program with the given replacements. 310 | * Needs to run the "replace" method first. 311 | * @returns {Object} - The replaced AST 312 | */ 313 | replaceAST() { 314 | if (this._replacedIdentifiers == null) return this._ast 315 | 316 | const foundIds = this.identifiers 317 | const identifiers = this._replacedIdentifiers 318 | 319 | const hasIdsToReplace = foundIds.some((id) => identifiers[id] != null) 320 | if (!hasIdsToReplace) return this._ast 321 | 322 | const ast = this.deepClone(this._ast) 323 | 324 | walk.fullAncestor(ast, (node, state) => { 325 | if (identifiers['this'] != null && node?.type === 'ThisExpression') { 326 | node.type = 'Identifier' 327 | node.name = identifiers['this'] 328 | } 329 | 330 | if (node?.type !== 'Identifier') return 331 | if (identifiers[node.name] == null) return 332 | 333 | if (this._declaredIdentifiers.includes(node.name)) { 334 | const ancestors = state.slice(0, -1) 335 | const isDeclared = this.isDeclared(node.name, ancestors) 336 | if (isDeclared) return 337 | } 338 | 339 | node.name = identifiers[node.name].replace(/-/g, '.') 340 | }) 341 | 342 | return ast 343 | } 344 | 345 | /** 346 | * Gets the output with replaced identifiers 347 | * @returns {string} - The output of the program 348 | */ 349 | output() { 350 | if (this._replacedIdentifiers == null) return this._code 351 | 352 | const foundIds = this.identifiers 353 | const identifiers = this._replacedIdentifiers 354 | 355 | const hasIdsToReplace = foundIds.some((id) => identifiers[id] != null) 356 | if (!hasIdsToReplace) return this._code 357 | 358 | const ast = this.replaceAST() 359 | const output = escodegen.generate(ast) 360 | 361 | if (Lexer.debug) 362 | console.log({ 363 | type: 'Lexer.output', 364 | input: this._code, 365 | output, 366 | ids: identifiers, 367 | }) 368 | 369 | return output 370 | } 371 | 372 | /** 373 | * Gets the conditional expressions in the program. Used in :class directive. 374 | * @returns {Array} - The conditional expressions [{ test, consequent, alternate, isExpression }] 375 | * Note: isExpression is used to differentiate between if statements and conditional expressions 376 | */ 377 | conditional() { 378 | if (this._replacedIdentifiers == null) return this._code 379 | 380 | const ast = this.replaceAST() 381 | const output = [] 382 | 383 | let hasConditionals = false 384 | 385 | walk.simple(ast, { 386 | ConditionalExpression(node) { 387 | hasConditionals = true 388 | const test = escodegen.generate(node.test) 389 | const consequent = escodegen.generate(node.consequent) 390 | const alternate = escodegen.generate(node.alternate) 391 | 392 | output.push({ test, consequent, alternate, isExpression: true }) 393 | }, 394 | IfStatement(node) { 395 | hasConditionals = true 396 | const test = escodegen.generate(node.test) 397 | let consequent = escodegen.generate(node.consequent) 398 | let alternate = escodegen.generate(node.alternate) 399 | 400 | // remove block statement symbols 401 | consequent = consequent.substring(1, consequent.length - 1) 402 | alternate = alternate.substring(1, alternate.length - 1) 403 | 404 | output.push({ test, consequent, alternate, isExpression: false }) 405 | }, 406 | }) 407 | 408 | if (!hasConditionals) { 409 | const output = escodegen.generate(ast) 410 | 411 | if (Lexer.debug) 412 | console.log({ 413 | type: 'Lexer.conditional', 414 | input: this._code, 415 | output, 416 | ids: this._replacedIdentifiers, 417 | }) 418 | 419 | return output 420 | } 421 | 422 | if (Lexer.debug) 423 | console.log({ 424 | type: 'Lexer.conditional', 425 | input: this._code, 426 | output, 427 | ids: this._replacedIdentifiers, 428 | }) 429 | 430 | return output 431 | } 432 | } 433 | -------------------------------------------------------------------------------- /lib/generators/observer.js: -------------------------------------------------------------------------------- 1 | import { Mini } from '@/main' 2 | import { Entity } from '@/entity' 3 | import { Events } from '@/entity/events' 4 | 5 | const MutationObserver = 6 | window.MutationObserver || window.WebKitMutationObserver 7 | 8 | function observeDOM(obj, callback) { 9 | if (obj == null || obj.nodeType !== 1) return 10 | 11 | if (MutationObserver) { 12 | // define a new observer 13 | const mutationObserver = new MutationObserver(callback) 14 | 15 | // have the observer observe for changes in children 16 | mutationObserver.observe(obj, { 17 | childList: true, 18 | subtree: true, 19 | attributes: true, 20 | }) 21 | 22 | return mutationObserver 23 | // browser support fallback 24 | } else if (window.addEventListener) { 25 | obj.addEventListener('DOMNodeInserted', callback, false) 26 | obj.addEventListener('DOMNodeRemoved', callback, false) 27 | 28 | return () => { 29 | obj.removeEventListener('DOMNodeInserted', callback, false) 30 | obj.removeEventListener('DOMNodeRemoved', callback, false) 31 | } 32 | } 33 | } 34 | 35 | export class Observer { 36 | static SCRIPT_ID = 'mini.scriptId' 37 | 38 | constructor(state) { 39 | this.base = new Mini() 40 | this.observer = null 41 | this.dynamicScripts = new Map() 42 | } 43 | 44 | init() { 45 | this.observe(document.body, (mutation) => { 46 | mutation.forEach((record) => { 47 | if ( 48 | record.type === 'attributes' && 49 | record.attributeName.startsWith(':') 50 | ) { 51 | const entity = this.base.state.getEntityByElement(record.target) 52 | if (!entity) return 53 | 54 | const attr = record.attributeName 55 | const isDeleted = entity.element.getAttribute(attr) === null 56 | 57 | if (Events.isValidEvent(attr)) { 58 | if (isDeleted) entity.events.disposeEvent(attr) 59 | else { 60 | entity.data.initAttributeVariables() 61 | entity.events.applyEvent(attr) 62 | } 63 | } else { 64 | if (isDeleted) entity.attributes.disposeAttribute(attr) 65 | else { 66 | entity.data.initAttributeVariables() 67 | entity.attributes.evaluateAttribute(attr) 68 | } 69 | } 70 | } 71 | 72 | record.removedNodes.forEach((node) => { 73 | if (node.nodeType !== 1) return 74 | const entity = this.base.state.getEntityByElement(node) 75 | entity?.dispose() 76 | }) 77 | 78 | const dynamicScripts = this.createScriptPromises( 79 | Array.from(record.addedNodes) 80 | ) 81 | 82 | record.addedNodes.forEach((node) => { 83 | if (node.nodeType !== 1) return 84 | 85 | const entity = new Entity(node, dynamicScripts) 86 | entity.init().then(() => { 87 | entity.initChildren() 88 | }) 89 | }) 90 | }) 91 | }) 92 | } 93 | 94 | observe(obj, callback) { 95 | this.observer = observeDOM(obj, callback) 96 | } 97 | 98 | createScriptPromises(nodes) { 99 | const dynamicScripts = nodes 100 | .reduce((acc, node) => { 101 | if (node.nodeType !== 1) return acc 102 | return [...acc, ...node.querySelectorAll('script')] 103 | }, []) 104 | .filter((node) => node.tagName === 'SCRIPT') 105 | 106 | return dynamicScripts.map((script) => { 107 | let resolve, reject 108 | const promise = new Promise((res, rej) => { 109 | resolve = res 110 | reject = rej 111 | }) 112 | promise.resolve = resolve 113 | promise.reject = reject 114 | 115 | script.dataset[Observer.SCRIPT_ID] = 116 | script.dataset[Observer.SCRIPT_ID] ?? this.generateUUID() 117 | 118 | script.textContent += `\nMiniJS.resolveScript()` 119 | if (!this.dynamicScripts.get(script.dataset[Observer.SCRIPT_ID])) 120 | this.dynamicScripts.set(script.dataset[Observer.SCRIPT_ID], promise) 121 | 122 | return script 123 | }) 124 | } 125 | 126 | async waitForScripts(scripts) { 127 | const promises = scripts.map((script) => 128 | this.dynamicScripts.get(script.dataset[Observer.SCRIPT_ID]) 129 | ) 130 | await Promise.all(promises.map((promise) => promise.catch((e) => e))) 131 | } 132 | 133 | resolveScript() { 134 | const script = document.currentScript 135 | const scriptID = script.dataset['mini.scriptId'] 136 | const promise = this.dynamicScripts.get(scriptID) 137 | if (!promise) return 138 | 139 | promise.resolve() 140 | 141 | delete script.dataset['mini.scriptId'] 142 | script.textContent = script.textContent.replace( 143 | '\nMiniJS.resolveScript()', 144 | '' 145 | ) 146 | } 147 | 148 | generateUUID() { 149 | return 'ScriptID' + Math.floor(performance.now() * 1000).toString(36) + Math.random().toString(36).substr(2, 9); 150 | } 151 | 152 | cryptoRandomString() { 153 | return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c => 154 | (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16) 155 | ); 156 | } 157 | 158 | disconnect() { 159 | if (this.observer == null) return 160 | this.observer.disconnect() 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /lib/helpers.js: -------------------------------------------------------------------------------- 1 | import { wait } from './helpers/time'; 2 | 3 | /** 4 | * Helper functions that can be used in Mini added to the global scope. 5 | */ 6 | export const EXPOSE = { 7 | wait, 8 | }; 9 | -------------------------------------------------------------------------------- /lib/helpers/array.js: -------------------------------------------------------------------------------- 1 | function deepSearch(arr, queries, isSubItem = false) { 2 | const newArray = [] 3 | 4 | for (let i = 0; i < arr.length; i++) { 5 | if (Array.isArray(arr[i])) { 6 | const subArray = deepSearch(arr[i], queries, true) 7 | 8 | if (subArray.length > 0) newArray.push(subArray) 9 | } else { 10 | const lowercaseItem = arr[i].toString().trim().toLowerCase() 11 | const matches = queries.some((query) => lowercaseItem.includes(query)) 12 | 13 | if (matches) { 14 | if (isSubItem) return arr 15 | else newArray.push(arr[i]) 16 | } 17 | } 18 | } 19 | 20 | return newArray 21 | } 22 | 23 | function deepEquality(arr1, arr2) { 24 | if (!Array.isArray(arr1) || !Array.isArray(arr2)) return false 25 | if (arr1.length !== arr2.length) return false 26 | 27 | for (let i = 0; i < arr1.length; i++) { 28 | if (Array.isArray(arr1[i]) && Array.isArray(arr2[i])) { 29 | if (!deepEquality(arr1[i], arr2[i])) return false 30 | } else if (arr1[i] !== arr2[i]) return false 31 | } 32 | 33 | return true 34 | } 35 | 36 | function getArrayItemIndex(arr, targetItem) { 37 | return arr.findIndex( 38 | (item) => Array.isArray(item) ? deepEquality(item, targetItem) : item === targetItem 39 | ) 40 | } 41 | 42 | function getNextArrayItem(arr, targetArray, nextIndexCallback) { 43 | const index = getArrayItemIndex(arr, targetArray) 44 | if (index === -1) return null 45 | 46 | const nextIndex = nextIndexCallback(index, arr.length) 47 | const difference = Math.abs(nextIndex - index) 48 | 49 | if (difference === 0) return arr[index] 50 | else if (nextIndex >= arr.length) { 51 | return difference === 1 ? arr.first : arr[difference - 1] 52 | } else if (nextIndex < 0) { 53 | return difference === 1 ? arr.last : arr[arr.length - difference] 54 | } else { 55 | return arr[nextIndex] 56 | } 57 | } 58 | 59 | export class MiniArray extends Array { 60 | static mutateMethods = [ 61 | 'fill', 62 | 'pop', 63 | 'push', 64 | 'shift', 65 | 'unshift', 66 | 'splice', 67 | 'sort', 68 | 'reverse', 69 | 'copyWithin', 70 | 'toggle', 71 | 'add', 72 | 'remove', 73 | 'replaceAt', 74 | ] 75 | 76 | static deepConvert = (arr) => { 77 | const newArray = new MiniArray() 78 | 79 | for (let i = 0; i < arr.length; i++) 80 | if (Array.isArray(arr[i])) newArray.push(MiniArray.deepConvert(arr[i])) 81 | else newArray.push(arr[i]) 82 | 83 | return newArray 84 | } 85 | 86 | constructor(...arr) { 87 | const newArray = [] 88 | 89 | for (let i = 0; i < arr.length; i++) 90 | if (Array.isArray(arr[i])) 91 | newArray.push(MiniArray.deepConvert(arr[i])) 92 | else newArray.push(arr[i]) 93 | 94 | super(...newArray) 95 | } 96 | 97 | get first() { 98 | return this[0] 99 | } 100 | 101 | get last() { 102 | return this.at(-1) 103 | } 104 | 105 | get deepFirst() { 106 | return this.deepFlat().first 107 | } 108 | 109 | get deepLast() { 110 | return this.deepFlat().last 111 | } 112 | 113 | deepFlat() { 114 | return this.flat(Infinity) 115 | } 116 | 117 | nextItem(item) { 118 | if (Array.isArray(item)) { 119 | const nextItem = getNextArrayItem(this, item, (index) => index + 1) 120 | if (Array.isArray(nextItem)) return new MiniArray(...nextItem) 121 | return nextItem 122 | } else { 123 | const flatArray = this.deepFlat() 124 | const nextIndex = flatArray.indexOf(item) + 1 125 | let nextItem 126 | 127 | if (nextIndex === -1) nextItem = flatArray.first 128 | else 129 | nextItem = 130 | nextIndex >= flatArray.length 131 | ? flatArray.first 132 | : flatArray.at(nextIndex) 133 | 134 | if (Array.isArray(nextItem)) return new MiniArray(...nextItem) 135 | return nextItem 136 | } 137 | } 138 | 139 | previousItem(item) { 140 | if (Array.isArray(item)) { 141 | return getNextArrayItem(this, item, (index) => index - 1) 142 | } else { 143 | const flatArray = this.deepFlat() 144 | const previousIndex = flatArray.indexOf(item) - 1 145 | let previousItem 146 | 147 | if (previousIndex === -1) previousItem = flatArray.last 148 | else 149 | previousItem = 150 | previousIndex < 0 ? flatArray.last : flatArray.at(previousIndex) 151 | 152 | if (Array.isArray(previousItem)) return new MiniArray(...previousItem) 153 | return previousItem 154 | } 155 | } 156 | 157 | toggle(...args) { 158 | const toAddValues = [] 159 | 160 | for (let i = 0; i < args.length; i++) { 161 | const arg = args[i]; 162 | const index = getArrayItemIndex(this, arg) 163 | 164 | if (index === -1) 165 | toAddValues.push(arg) 166 | else 167 | this.splice(index, 1); 168 | } 169 | 170 | this.push(...new MiniArray(...toAddValues)) 171 | 172 | return this 173 | } 174 | 175 | add(...args) { 176 | this.push(...new MiniArray(...args)) 177 | return this 178 | } 179 | 180 | remove(...args) { 181 | for (let i = 0; i < args.length; i++) { 182 | const arg = args[i]; 183 | const index = getArrayItemIndex(this, arg) 184 | 185 | if (index === -1) continue 186 | 187 | this.splice(index, 1); 188 | } 189 | 190 | return this 191 | } 192 | 193 | replaceAt(index, value) { 194 | this.splice(index, 1, value) 195 | return this 196 | } 197 | 198 | subtract(...args) { 199 | const newArray = [] 200 | 201 | for (let i = 0; i < this.length; i++) { 202 | const item = this[i] 203 | const index = getArrayItemIndex(args, item) 204 | 205 | if (index === -1) newArray.push(item) 206 | } 207 | 208 | return new MiniArray(...newArray) 209 | } 210 | 211 | search(...args) { 212 | const queries = args 213 | .map((arg) => 214 | (Array.isArray(arg) ? arg.flat(Infinity) : [arg]).map((query) => { 215 | return query.toString().trim().toLowerCase().split() 216 | }) 217 | ) 218 | .flat(Infinity) 219 | 220 | return new MiniArray(...deepSearch(this, queries)) 221 | } 222 | 223 | sameAs(array) { 224 | return deepEquality(this, array) 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /lib/helpers/strings.js: -------------------------------------------------------------------------------- 1 | export function kebabToCamelCase(string) { 2 | return string.replace(/-([a-z])/g, function (g) { 3 | return g[1].toUpperCase() 4 | }) 5 | } 6 | 7 | export function camelToKebabCase(string) { 8 | return string.replace( 9 | /[A-Z]+(?![a-z])|[A-Z]/g, 10 | ($, ofs) => (ofs ? '-' : '') + $.toLowerCase() 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /lib/helpers/time.js: -------------------------------------------------------------------------------- 1 | export function wait(delayInMs) { 2 | return new Promise((resolve) => setTimeout(resolve, delayInMs)) 3 | } 4 | -------------------------------------------------------------------------------- /lib/helpers/variables.js: -------------------------------------------------------------------------------- 1 | export const isNativeVariable = (variable) => 2 | typeof window[variable] === 'function' && 3 | window[variable].toString().indexOf('[native code]') !== -1 4 | -------------------------------------------------------------------------------- /lib/main.js: -------------------------------------------------------------------------------- 1 | import { State } from '@/state' 2 | import { Entity } from '@/entity' 3 | 4 | import { Lexer } from '@/generators/lexer' 5 | import { Observer } from '@/generators/observer' 6 | 7 | import { Events } from '@/entity/events' 8 | import { EventsExtensions } from '@/extensions/events-extensions' 9 | import { MiniArray } from '@/helpers/array' 10 | import { EXPOSE } from './helpers' 11 | 12 | export class Mini { 13 | static instance 14 | static debug = false 15 | 16 | constructor() { 17 | if (Mini.instance) return Mini.instance 18 | Mini.instance = this 19 | 20 | this.isReady = false 21 | this.state = new State(this) 22 | this.observer = new Observer(this) 23 | this.extensions = { 24 | events: new EventsExtensions(), 25 | } 26 | } 27 | 28 | init() { 29 | this._domReady(() => { 30 | this.isReady = true 31 | const startTime = performance.now() 32 | this._setDebugMode() 33 | this.state.setProxyWindow() 34 | Events.initValidEvents() 35 | this._initEntities() 36 | this._initializeGlobalVariables() 37 | Events.applyEvents() 38 | this.state.evaluate() 39 | this.observer.init() 40 | const endTime = performance.now() 41 | const executionTime = endTime - startTime 42 | console.log(`MiniJS took ${executionTime}ms to run.`) 43 | }) 44 | } 45 | 46 | /** 47 | * Execute a function now if DOMContentLoaded has fired, otherwise listen for it. 48 | * 49 | * This function uses isReady because there is no reliable way to ask the browser whether 50 | * the DOMContentLoaded event has already been fired; there's a gap between DOMContentLoaded 51 | * firing and readystate=complete. 52 | */ 53 | _domReady(fn) { 54 | // Checking readyState here is a failsafe in case minijs script tag entered the DOM by 55 | // some means other than the initial page load. 56 | if (this.isReady || document.readyState === 'complete') { 57 | setTimeout(fn) // wait for current stack to clear before running 58 | } else { 59 | // if document is not ready (loading, interactive), add an event listener 60 | document.addEventListener('DOMContentLoaded', fn) 61 | } 62 | } 63 | 64 | async _setDebugMode() { 65 | if (!this.debug) return 66 | console.log('MiniJS Debug Mode Enabled') 67 | Lexer.debug = true 68 | } 69 | 70 | _initEntities() { 71 | const elements = document.body.getElementsByTagName('*') 72 | 73 | for (let i = 0; i < elements.length; i++) { 74 | const element = elements[i] 75 | if (element.nodeType !== 1) continue 76 | 77 | const entity = new Entity(element) 78 | if (entity.isInsideEachEl()) entity.dispose() 79 | } 80 | } 81 | 82 | _initializeGlobalVariables() { 83 | const entities = Array.from(this.state.entities.values()) 84 | entities.forEach((entity, index) => { 85 | entity.getVariables() 86 | }) 87 | } 88 | } 89 | 90 | const MiniJS = (() => { 91 | const mini = new Mini() 92 | 93 | try { 94 | mini.init() 95 | } catch (error) { 96 | console.error('Error initializing MiniJS:', error) 97 | } 98 | 99 | return { 100 | get debug() { 101 | return Mini.debug 102 | }, 103 | set debug(value) { 104 | Mini.debug = !!value 105 | 106 | if (Mini.debug) MiniJS.main = mini 107 | else delete MiniJS.main 108 | }, 109 | get window() { 110 | return mini.state.window 111 | }, 112 | get Array() { 113 | return MiniArray 114 | }, 115 | resolveScript: () => { 116 | return mini.observer.resolveScript() 117 | }, 118 | extendEvents: (events) => { 119 | mini.extensions.events.extend(events) 120 | }, 121 | } 122 | })() 123 | 124 | window.MiniJS = MiniJS 125 | 126 | Object.keys(EXPOSE).forEach((key) => { 127 | window[key] = EXPOSE[key] 128 | }); 129 | -------------------------------------------------------------------------------- /lib/state.js: -------------------------------------------------------------------------------- 1 | import { Mini } from '@/main' 2 | import { MiniArray } from '@/helpers/array' 3 | 4 | export class State { 5 | static DISABLE_RE_RENDER_KEY = '_.' 6 | static EL_STATE = 'el' 7 | static SCOPE_STATE = 'scope' 8 | 9 | static isLocalState(variable) { 10 | return variable[0] === '$' 11 | } 12 | 13 | static isElState(variable) { 14 | return ( 15 | variable.startsWith(State.EL_STATE + '.') || variable === State.EL_STATE 16 | ) 17 | } 18 | 19 | static isScopeState(variable) { 20 | return ( 21 | variable.startsWith(State.SCOPE_STATE + '.') || 22 | variable === State.SCOPE_STATE 23 | ) 24 | } 25 | 26 | constructor() { 27 | this.base = new Mini() 28 | this.window = null 29 | 30 | this.entities = new Map() // key: entityID, value: entity 31 | this.variables = new Map() // key: variable, value: entityID 32 | this.entityVariables = new Map() // key: entityID.variable, value: entityID 33 | 34 | this.shouldUpdate = false 35 | } 36 | 37 | setProxyWindow() { 38 | this.window = this.create(window) 39 | } 40 | 41 | getEntityByElement(el, entities = Array.from(this.entities.values())) { 42 | return entities.find((entity) => entity.element === el) 43 | } 44 | 45 | addEntity(entity) { 46 | this.entities.set(entity.id, entity) 47 | } 48 | 49 | removeEntity(entity) { 50 | this.entities.delete(entity.id) 51 | 52 | const variables = [...this.variables.entries()] 53 | variables.forEach(([key, value]) => { 54 | if (key === entity.id) this.variables.delete(key) 55 | else if (value.includes(entity.id)) 56 | this.variables.set( 57 | key, 58 | value.filter((id) => id !== entity.id) 59 | ) 60 | }) 61 | 62 | const entityVariables = [...this.entityVariables.entries()] 63 | entityVariables.forEach(([key, value]) => { 64 | const [entityID] = key.split('.') 65 | if (entityID === entity.id) this.entityVariables.delete(key) 66 | else if (value.includes(entity.id)) 67 | this.entityVariables.set( 68 | key, 69 | value.filter((id) => id !== entity.id) 70 | ) 71 | }) 72 | 73 | delete window[entity.id] 74 | } 75 | 76 | hasDependency(variable) { 77 | return this.variables.has(variable) || this.entityVariables.has(variable) 78 | } 79 | 80 | addVariable(variable, entityID) { 81 | const variables = this.variables.get(variable) || [] 82 | if (variables.includes(entityID)) return 83 | this.variables.set(variable, [...variables, entityID]) 84 | } 85 | 86 | addEntityVariable(scopeID, variable, entityID) { 87 | const key = `${scopeID}.${variable}` 88 | const variables = this.entityVariables.get(key) || [] 89 | if (variables.includes(entityID)) return 90 | this.entityVariables.set(key, [...variables, entityID]) 91 | } 92 | 93 | removeDuplicateVariables() { 94 | this.variables.forEach((entityIDs, variable) => { 95 | this.variables.set(variable, [...new Set(entityIDs)]) 96 | }) 97 | 98 | this.entityVariables.forEach((entityIDs, variable) => { 99 | this.entityVariables.set(variable, [...new Set(entityIDs)]) 100 | }) 101 | } 102 | 103 | create(object, entityID = null) { 104 | const ctx = this 105 | 106 | return new Proxy(object, { 107 | set: function (target, property, value) { 108 | if (entityID) ctx.setEntityState(target, property, value, entityID) 109 | else if (State.isLocalState(property)) 110 | ctx.setLocalState(target, property, value) 111 | else ctx.setState(target, property, value) 112 | 113 | return true 114 | }, 115 | get: function (target, property) { 116 | if (entityID != null) return target[property] 117 | 118 | const isEntityState = property.startsWith('Entity') 119 | const isDisabledReRender = property.endsWith( 120 | State.DISABLE_RE_RENDER_KEY 121 | ) 122 | 123 | if (isEntityState && isDisabledReRender) { 124 | const entityID = property.replace(State.DISABLE_RE_RENDER_KEY, '') 125 | 126 | return new Proxy(target[entityID], { 127 | set: function (target, property, value) { 128 | ctx.setEntityState( 129 | target, 130 | property + State.DISABLE_RE_RENDER_KEY, 131 | value, 132 | entityID 133 | ) 134 | 135 | return true 136 | }, 137 | get: function (target, property) { 138 | return target[property] 139 | }, 140 | }) 141 | } 142 | 143 | return target[property] 144 | }, 145 | }) 146 | } 147 | 148 | shouldRerender(variable) { 149 | if (!this.shouldUpdate) return false 150 | if (variable.endsWith(State.DISABLE_RE_RENDER_KEY)) return false 151 | return true 152 | } 153 | 154 | getVariableName(variable) { 155 | return variable.endsWith(State.DISABLE_RE_RENDER_KEY) 156 | ? variable.slice(0, -1) 157 | : variable 158 | } 159 | 160 | getState(variable) { 161 | if (variable.startsWith('$')) return this.getLocalState(variable) 162 | return window[variable] 163 | } 164 | 165 | getLocalState(variable) { 166 | if (!variable.startsWith('$')) return undefined 167 | 168 | try { 169 | const localValue = localStorage.getItem(variable) 170 | 171 | if (localValue == null) return localValue 172 | return JSON.parse(localValue) 173 | } catch { 174 | return undefined 175 | } 176 | } 177 | 178 | setLocalState(target, property, value) { 179 | localStorage.setItem(property, JSON.stringify(value)) 180 | this.setState(target, property, value) 181 | } 182 | 183 | setState(target, property, value) { 184 | const varName = this.getVariableName(property) 185 | 186 | target[varName] = Array.isArray(value) ? new MiniArray(...value) : value 187 | 188 | if (!this.shouldRerender(property)) return 189 | if (!this.hasDependency(varName)) return 190 | 191 | this.evaluateDependencies(varName) 192 | this.attachVariableHelpers([varName]) 193 | } 194 | 195 | setEntityState(target, property, value, entityID) { 196 | const varName = this.getVariableName(property) 197 | 198 | target[varName] = Array.isArray(value) ? new MiniArray(...value) : value 199 | 200 | if (!this.shouldRerender(property)) return 201 | if (!this.hasDependency(entityID)) return 202 | 203 | const variable = `${entityID}.${varName}` 204 | this.evaluateDependencies(variable) 205 | this.attachVariableHelpers([entityID]) 206 | this.attachVariableHelpers([varName], entityID) 207 | } 208 | 209 | evaluate() { 210 | Array.from(this.entities.values()).forEach((entity) => { 211 | entity.attributes.evaluate() 212 | }) 213 | 214 | this.attachVariableHelpers(Array.from(this.variables.keys())) 215 | this.shouldUpdate = true 216 | } 217 | 218 | evaluateDependencies(variable) { 219 | const variableEntities = this.variables.get(variable) || [] 220 | const properties = variable.split('.') 221 | 222 | const scopeID = properties[1] != null ? properties[0] : null 223 | const varName = scopeID != null ? properties[1] : properties[0] 224 | 225 | variableEntities.forEach((entityID) => { 226 | const entity = this.entities.get(entityID) 227 | if (!entity) return 228 | 229 | let variable = varName 230 | 231 | if (scopeID != null) { 232 | if (entity.id === scopeID) variable = `el.${varName}` 233 | else variable = `scope.${varName}` 234 | } 235 | 236 | entity.attributes.evaluateVariable(variable) 237 | }) 238 | 239 | const entityVariablesEntities = this.entityVariables.get(variable) || [] 240 | 241 | entityVariablesEntities.forEach((entityID) => { 242 | const entity = this.entities.get(entityID) 243 | if (!entity) return 244 | 245 | let variable = varName 246 | 247 | if (scopeID != null) { 248 | if (entity.id === scopeID) variable = `el.${varName}` 249 | else variable = `scope.${varName}` 250 | } 251 | 252 | entity.attributes.evaluateVariable(variable) 253 | }) 254 | 255 | this.attachVariableHelpers([variable]) 256 | } 257 | 258 | attachVariableHelpers(variables, entityID = null) { 259 | variables.forEach((variable) => { 260 | const value = 261 | entityID != null 262 | ? this.window[entityID][variable] 263 | : this.window[variable] 264 | 265 | if (Array.isArray(value) && !(value instanceof MiniArray)) { 266 | if (entityID != null) 267 | this.window[entityID][variable] = new MiniArray(...value) 268 | else this.window[variable] = new MiniArray(...value) 269 | } 270 | }) 271 | } 272 | 273 | disposeVariables(entityID, variables) { 274 | variables.forEach((variable) => { 275 | if (State.isElState(variable)) { 276 | delete window[entityID] 277 | this.disposeVariable(entityID) 278 | 279 | if (variable !== State.EL_STATE) { 280 | const varName = variable.replace(State.EL_STATE + '.', '') 281 | this.disposeEntityVariable(entityID, varName) 282 | } 283 | } else { 284 | delete window[variable] 285 | this.disposeVariable(variable) 286 | 287 | if (State.isLocalState(variable)) localStorage.removeItem(variable) 288 | } 289 | }) 290 | } 291 | 292 | disposeVariable(variable) { 293 | this.variables.delete(variable) 294 | } 295 | 296 | disposeEntityVariable(entityID, variable) { 297 | const key = `${entityID}.${variable}` 298 | this.entityVariables.delete(key) 299 | } 300 | } 301 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tonic-minijs", 3 | "version": "1.0.20", 4 | "files": [ 5 | "dist" 6 | ], 7 | "main": "./dist/mini.umd.js", 8 | "module": "./dist/mini.es.js", 9 | "author": "Jen Villaganas", 10 | "license": "MIT", 11 | "scripts": { 12 | "dev": "vite", 13 | "build": "vite build", 14 | "build-watch": "vite build --watch", 15 | "preview": "vite preview", 16 | "test": "vitest" 17 | }, 18 | "dependencies": { 19 | "acorn": "^8.11.3", 20 | "acorn-walk": "^8.3.2", 21 | "escodegen": "^2.1.0", 22 | "vite": "^4.4.8" 23 | }, 24 | "exports": { 25 | ".": { 26 | "import": "./dist/YOUR_LIBRARY_NAME.es.js", 27 | "require": "./dist/YOUR_LIBRARY_NAME.umd.js" 28 | } 29 | }, 30 | "auto": { 31 | "plugins": [ 32 | "npm", 33 | "all-contributors", 34 | "first-time-contributor", 35 | "released" 36 | ] 37 | }, 38 | "devDependencies": { 39 | "@auto-it/all-contributors": "^11.1.1", 40 | "@auto-it/first-time-contributor": "^11.1.1", 41 | "all-contributors-cli": "^6.26.1", 42 | "auto": "^11.1.1", 43 | "dedent-js": "^1.0.1", 44 | "jsdom": "^24.0.0", 45 | "vitest": "^1.3.1" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const { defineConfig } = require('vite') 3 | 4 | module.exports = defineConfig({ 5 | build: { 6 | lib: { 7 | entry: path.resolve(__dirname, 'lib/main.js'), 8 | name: 'minijs', 9 | fileName: (format) => `minijs.${format}.js`, 10 | }, 11 | }, 12 | test: { 13 | include: ['lib/__tests__/**/*.test.js'], 14 | environment: 'jsdom', 15 | }, 16 | resolve: { 17 | alias: { 18 | '@': path.resolve(__dirname, './lib'), 19 | }, 20 | }, 21 | }) 22 | --------------------------------------------------------------------------------